From 8e122b295d54b985c1e0ba8f0de3a950f8d53bcc Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Wed, 10 Jun 2026 22:50:31 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(resume):=20=E8=81=B7=E5=8B=99=E7=B5=8C?= =?UTF-8?q?=E6=AD=B4=E6=9B=B8=E3=81=AB=E9=80=A3=E7=B5=A1=E5=85=88=EF=BC=88?= =?UTF-8?q?email=20/=20github=EF=BC=89=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=97?= =?UTF-8?q?=20PDF/Markdown=20=E3=81=A7=E3=83=AA=E3=83=B3=E3=82=AF=E8=A1=A8?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resume schema に email(必須)/ github_url(任意・https://github.com/ 始まり検証)を追加 - migration 0043 で resumes テーブルに contact カラムを追加 - フォーム(CareerBasicInfoSection)/ payloadBuilders / dirty 判定 / 差分プレビューに email・github を反映 - PDF / Markdown 生成の連絡先ラベルを email / github に統一し、github URL を ハイパーリンク表示にする(PDF: 青・下線、Markdown: [URL](URL) 記法) - 差分確認モーダルの変更サマリを全件スクロール可能にし、変更が多くても ロールバック/操作ボタンが clip されないよう修正 Co-Authored-By: Claude Opus 4.8 --- .../versions/0043_add_contact_to_resumes.py | 41 +++++++++++ backend/app/messages.json | 4 +- backend/app/models/resume.py | 3 + backend/app/repositories/resume.py | 2 + backend/app/routers/resumes.py | 2 + backend/app/schemas/resume.py | 35 ++++++++- .../markdown/generators/resume_generator.py | 9 +++ .../pdf/generators/resume_generator.py | 18 +++++ backend/app/services/pdf/templates/resume.css | 6 ++ backend/tests/auth/test_endpoints.py | 1 + backend/tests/conftest.py | 1 + backend/tests/security/_helpers.py | 1 + backend/tests/test_endpoints.py | 71 +++++++++++++++++++ backend/tests/test_markdown_generator.py | 36 ++++++++++ backend/tests/test_pdf_generator.py | 38 ++++++++++ backend/tests/test_schemas.py | 53 ++++++++++++++ frontend/e2e/auth.spec.ts | 4 ++ frontend/e2e/career-dirty-indicator.spec.ts | 4 ++ frontend/e2e/career-field-modal.spec.ts | 2 + frontend/src/api/generated.ts | 21 ++++++ .../forms/CareerDiffModal.module.css | 8 +++ .../src/components/forms/CareerResumeForm.tsx | 6 +- .../forms/sections/CareerBasicInfoSection.tsx | 55 +++++++++++++- frontend/src/constants/messages.ts | 5 ++ frontend/src/formMappers.ts | 4 ++ frontend/src/formTypes.ts | 7 +- .../src/hooks/career/useCareerDirty.test.ts | 2 + frontend/src/hooks/career/useCareerDirty.ts | 16 ++++- .../useCareerExperienceMutators.test.ts | 2 + .../hooks/career/useResumeDiffPreview.test.ts | 2 + frontend/src/payloadBuilders.test.ts | 32 ++++++++- frontend/src/payloadBuilders.ts | 23 ++++++ frontend/src/utils/careerDiff.test.ts | 2 + frontend/src/utils/careerDiff.ts | 2 + frontend/src/utils/careerDraft.ts | 2 + frontend/tests/payloadBuilders.test.cjs | 8 +++ 36 files changed, 521 insertions(+), 7 deletions(-) create mode 100644 backend/alembic_migrations/versions/0043_add_contact_to_resumes.py diff --git a/backend/alembic_migrations/versions/0043_add_contact_to_resumes.py b/backend/alembic_migrations/versions/0043_add_contact_to_resumes.py new file mode 100644 index 00000000..3652f124 --- /dev/null +++ b/backend/alembic_migrations/versions/0043_add_contact_to_resumes.py @@ -0,0 +1,41 @@ +"""職務経歴書(resumes)に連絡先カラム(email / github_url)を追加する + +- resumes に email(メールアドレス・必須運用)と github_url(GitHub URL・任意)を追加。 + 既存行は連絡先未入力のため server_default="" で後方互換を保つ(次回フォーム保存時に + 必須バリデーションが効く)。 + +libSQL (SQLite 互換) は ADD COLUMN を直接サポートするため upgrade は op.add_column を使う。 +downgrade の列削除は、resumes が子テーブル(resume_experiences / resume_qualifications)から +FK 参照される親テーブルのため batch_alter_table(テーブル再作成)を使わない。素のカラムは +SQLite/libSQL 3.35+ の ALTER TABLE DROP COLUMN で直接削除できる。 + +Revision ID: 0043_add_contact_to_resumes +Revises: 0042_drop_users_hashed_password +Create Date: 2026-06-10 00:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0043_add_contact_to_resumes" +down_revision: Union[str, None] = "0042_drop_users_hashed_password" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "resumes", + sa.Column("email", sa.String(length=255), nullable=False, server_default=""), + ) + op.add_column( + "resumes", + sa.Column("github_url", sa.String(length=255), nullable=False, server_default=""), + ) + + +def downgrade() -> None: + op.drop_column("resumes", "github_url") + op.drop_column("resumes", "email") diff --git a/backend/app/messages.json b/backend/app/messages.json index c4335c30..d2899488 100644 --- a/backend/app/messages.json +++ b/backend/app/messages.json @@ -48,7 +48,9 @@ "invalid_input": "入力内容を確認してください。", "date_range_invalid": "開始日は終了日より前に設定してください。", "start_date_required": "開始年月を入力してください。", - "end_date_required": "在職中でない場合は終了年月を入力してください。" + "end_date_required": "在職中でない場合は終了年月を入力してください。", + "email_invalid": "メールアドレスの形式が正しくありません。", + "github_url_invalid": "GitHub の URL は https://github.com/ で始まる形式で入力してください。" }, "server": { "internal_error": "サーバーエラーが発生しました。しばらくしてから再度お試しください。", diff --git a/backend/app/models/resume.py b/backend/app/models/resume.py index 5435ec3a..f9b53e6c 100644 --- a/backend/app/models/resume.py +++ b/backend/app/models/resume.py @@ -28,6 +28,9 @@ class Resume(Base): String(36), ForeignKey("users.id"), nullable=False, index=True ) full_name: Mapped[str] = mapped_column(String(120), nullable=False, default="") + # 連絡先(メールは必須・GitHub URL は任意)。バリデーションは schemas/resume.py の ResumeBase が正本。 + email: Mapped[str] = mapped_column(String(255), nullable=False, default="") + github_url: Mapped[str] = mapped_column(String(255), nullable=False, default="") career_summary: Mapped[str] = mapped_column(Text, nullable=False, default="") self_pr: Mapped[str] = mapped_column(Text, nullable=False) experience_rows: Mapped[list["ResumeExperience"]] = relationship( diff --git a/backend/app/repositories/resume.py b/backend/app/repositories/resume.py index eb697ef1..e13e74d9 100644 --- a/backend/app/repositories/resume.py +++ b/backend/app/repositories/resume.py @@ -41,6 +41,8 @@ class ResumeRepository(SingleUserDocumentRepository): def _apply_payload(self, entity: Resume, payload: dict[str, object]) -> None: entity.full_name = payload["full_name"] + entity.email = payload["email"] + entity.github_url = payload.get("github_url", "") entity.career_summary = payload["career_summary"] entity.self_pr = payload["self_pr"] sorted_experiences = sort_by_period_desc( diff --git a/backend/app/routers/resumes.py b/backend/app/routers/resumes.py index b305b56f..05624117 100644 --- a/backend/app/routers/resumes.py +++ b/backend/app/routers/resumes.py @@ -31,6 +31,8 @@ def _resume_to_payload(resume: Resume) -> dict: """Resume ORM から PDF/Markdown 生成用 payload を組み立てる。""" return { "full_name": resume.full_name, + "email": resume.email, + "github_url": resume.github_url, "career_summary": resume.career_summary, "self_pr": resume.self_pr, "experiences": resume.experiences, diff --git a/backend/app/schemas/resume.py b/backend/app/schemas/resume.py index b15b335d..495ffd76 100644 --- a/backend/app/schemas/resume.py +++ b/backend/app/schemas/resume.py @@ -12,11 +12,19 @@ ``Client.vacation_*``(期間・詳細)を使う。 """ +import re from datetime import datetime from typing import Literal from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field, field_serializer, model_validator +from pydantic import ( + BaseModel, + ConfigDict, + Field, + field_serializer, + field_validator, + model_validator, +) from ..core.date_utils import to_jst from ..core.messages import get_error @@ -219,13 +227,38 @@ def validate_dates(self) -> "Experience": return self +# 簡易メール形式(RFC 5322 完全準拠ではなく UX 優先の最小チェック)。FE 側 payloadBuilders と一致させる。 +_EMAIL_PATTERN = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$") +# GitHub アカウント URL の接頭辞。値があるときのみ前方一致で検証する。 +_GITHUB_URL_PREFIX = "https://github.com/" + + class ResumeBase(BaseModel): full_name: str = Field(min_length=1, max_length=120) + email: str = Field(min_length=1, max_length=255) + github_url: str = Field(default="", max_length=255) career_summary: str = Field(min_length=1, max_length=2000) self_pr: str = Field(min_length=1, max_length=2000) experiences: list[Experience] = Field(default_factory=list) qualifications: list[ResumeQualificationItem] = Field(default_factory=list) + @field_validator("email") + @classmethod + def validate_email(cls, value: str) -> str: + """メールアドレスの簡易形式チェック。不正なら 422(日本語メッセージ)で返す。""" + if not _EMAIL_PATTERN.match(value.strip()): + raise ValueError(get_error("validation.email_invalid")) + return value + + @field_validator("github_url") + @classmethod + def validate_github_url(cls, value: str) -> str: + """GitHub URL は任意。値があるときだけ ``https://github.com/`` 始まりを要求する。""" + stripped = value.strip() + if stripped and not stripped.startswith(_GITHUB_URL_PREFIX): + raise ValueError(get_error("validation.github_url_invalid")) + return value + class ResumeCreate(ResumeBase): pass diff --git a/backend/app/services/markdown/generators/resume_generator.py b/backend/app/services/markdown/generators/resume_generator.py index 131b5f4f..0029a3b6 100644 --- a/backend/app/services/markdown/generators/resume_generator.py +++ b/backend/app/services/markdown/generators/resume_generator.py @@ -19,6 +19,15 @@ def build_resume_markdown(payload: dict[str, Any]) -> str: full_name = payload.get("full_name", "") if full_name: lines.append(field_line("氏名", full_name)) + # 連絡先(メールは必須・GitHub URL は任意。値があるときだけ行を出す) + email = payload.get("email", "") + if email: + lines.append(field_line("email", email)) + github_url = payload.get("github_url", "") + if github_url: + # github URL は Markdown リンク記法でハイパーリンク化する(GitHub/各種ビューアで + # 青・下線のリンク表示になる)。href は schema で https://github.com/ 始まりに検証済み。 + lines.append(field_line("github", f"[{github_url}]({github_url})")) lines.append("") qualifications = payload.get("qualifications", []) diff --git a/backend/app/services/pdf/generators/resume_generator.py b/backend/app/services/pdf/generators/resume_generator.py index ff93badb..26e983e7 100644 --- a/backend/app/services/pdf/generators/resume_generator.py +++ b/backend/app/services/pdf/generators/resume_generator.py @@ -220,6 +220,24 @@ def _build_html(resume: dict) -> str: f'
氏名 {_esc(full_name)}
', ) + # 連絡先(メールは必須・GitHub URL は任意。値があるときだけ行を出す) + email = resume.get("email") or "" + if email: + parts.append( + f'
email {_esc(email)}
', + ) + github_url = resume.get("github_url") or "" + if github_url: + # github URL はハイパーリンク表示(青・下線)にする。href は schema で + # https://github.com/ 始まりに検証済みのため安全。data-fp は左右 diff の + # ハイライト対象として span ではなく
に直接付与する(annotateHtml は + # [data-fp] 全要素を対象にするため要素種別は問わない)。 + esc_url = _esc(github_url) + parts.append( + f'
github ' + f'{esc_url}
', + ) + # 記載日(日本時間) today = datetime.now(JST) parts.append( diff --git a/backend/app/services/pdf/templates/resume.css b/backend/app/services/pdf/templates/resume.css index e868d469..fa50a614 100644 --- a/backend/app/services/pdf/templates/resume.css +++ b/backend/app/services/pdf/templates/resume.css @@ -35,6 +35,12 @@ h1 { margin-bottom: 4mm; } +/* 連絡先の github URL をハイパーリンク表示(青・下線)にする。 */ +.meta-link { + color: #0066cc; + text-decoration: underline; +} + h2 { font-size: 12pt; border-bottom: 2px solid #333; diff --git a/backend/tests/auth/test_endpoints.py b/backend/tests/auth/test_endpoints.py index 41050703..ca1fdf75 100644 --- a/backend/tests/auth/test_endpoints.py +++ b/backend/tests/auth/test_endpoints.py @@ -129,6 +129,7 @@ def test_me_returns_current_user(client) -> None: def _csrf_resume_payload() -> dict: return { "full_name": "テスト", + "email": "test@example.com", "career_summary": "要約", "self_pr": "自己PR", "experiences": [], diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index f0229b66..aaf30c21 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -166,6 +166,7 @@ def make_resume_payload(**overrides) -> dict: """ payload: dict = { "full_name": "山田 太郎", + "email": "yamada@example.com", "career_summary": "キャリアサマリー", "self_pr": "自己PR", "experiences": [ diff --git a/backend/tests/security/_helpers.py b/backend/tests/security/_helpers.py index 19fa7f94..b9584419 100644 --- a/backend/tests/security/_helpers.py +++ b/backend/tests/security/_helpers.py @@ -22,6 +22,7 @@ RESUME_PAYLOAD: dict = { "full_name": "山田 太郎", + "email": "yamada@example.com", "career_summary": "キャリアサマリー", "self_pr": "自己PR", "experiences": [], diff --git a/backend/tests/test_endpoints.py b/backend/tests/test_endpoints.py index 2682891a..3304a0d5 100644 --- a/backend/tests/test_endpoints.py +++ b/backend/tests/test_endpoints.py @@ -30,6 +30,7 @@ def test_resume_crud(client: TestClient) -> None: "/api/resumes", json={ "full_name": "田中太郎", + "email": "tanaka@example.com", "career_summary": "キャリアサマリー", "self_pr": "自己PR", "experiences": [], @@ -50,6 +51,7 @@ def test_resume_crud(client: TestClient) -> None: f"/api/resumes/{resume_id}", json={ "full_name": "田中花子", + "email": "tanaka@example.com", "career_summary": "更新済みサマリー", "self_pr": "自己PR", "experiences": [], @@ -62,6 +64,68 @@ def test_resume_crud(client: TestClient) -> None: assert resp.json()["full_name"] == "田中花子" +def test_resume_contact_round_trips(client: TestClient) -> None: + """メール・GitHub URL が作成→取得で往復することを確認する。""" + headers = auth_header(client, "resume-contact-user") + resp = client.post( + "/api/resumes", + json={ + "full_name": "連絡先 太郎", + "email": "contact@example.com", + "github_url": "https://github.com/contact-taro", + "career_summary": "要約", + "self_pr": "自己PR", + "experiences": [], + "qualifications": [], + }, + headers=headers, + ) + assert resp.status_code == 201, resp.text + + resp = client.get("/api/resumes/latest", headers=headers) + assert resp.status_code == 200 + body = resp.json() + assert body["email"] == "contact@example.com" + assert body["github_url"] == "https://github.com/contact-taro" + + +def test_resume_rejects_invalid_email_via_api(client: TestClient) -> None: + """不正なメール形式は 422 で拒否される。""" + headers = auth_header(client, "resume-bademail-user") + resp = client.post( + "/api/resumes", + json={ + "full_name": "太郎", + "email": "not-an-email", + "career_summary": "要約", + "self_pr": "自己PR", + "experiences": [], + "qualifications": [], + }, + headers=headers, + ) + assert resp.status_code == 422 + + +def test_resume_rejects_invalid_github_url_via_api(client: TestClient) -> None: + """github.com 以外の GitHub URL は 422 で拒否される。""" + headers = auth_header(client, "resume-badgithub-user") + resp = client.post( + "/api/resumes", + json={ + "full_name": "太郎", + "email": "ok@example.com", + "github_url": "https://gitlab.com/taro", + "career_summary": "要約", + "self_pr": "自己PR", + "experiences": [], + "qualifications": [], + }, + headers=headers, + ) + assert resp.status_code == 422 + + def test_resume_qualifications_sorted_asc(client: TestClient) -> None: """資格一覧が取得日昇順で返ることを確認する。""" headers = auth_header(client, "resume-sort-user") @@ -70,6 +134,7 @@ def test_resume_qualifications_sorted_asc(client: TestClient) -> None: "/api/resumes", json={ "full_name": "ソート確認", + "email": "sort@example.com", "career_summary": "要約", "self_pr": "自己PR", "experiences": [], @@ -97,6 +162,7 @@ def test_resume_qualification_date_is_year_month(client: TestClient) -> None: "/api/resumes", json={ "full_name": "年月確認", + "email": "ym@example.com", "career_summary": "要約", "self_pr": "自己PR", "experiences": [], @@ -119,6 +185,7 @@ def test_resume_round_trips_nested_structure(client: TestClient) -> None: payload = { "full_name": "山田 太郎", + "email": "yamada@example.com", "career_summary": "キャリアサマリー", "self_pr": "自己PR", "experiences": [ @@ -179,6 +246,7 @@ def test_resume_round_trips_non_it_and_vacation(client: TestClient) -> None: payload = { "full_name": "佐藤 花子", + "email": "sato@example.com", "career_summary": "キャリアサマリー", "self_pr": "自己PR", "experiences": [ @@ -284,6 +352,7 @@ def test_resume_get_by_id(client: TestClient) -> None: "/api/resumes", json={ "full_name": "山田 太郎", + "email": "yamada@example.com", "career_summary": "サマリー", "self_pr": "自己PR", "experiences": [], @@ -318,6 +387,7 @@ def test_resume_preview_returns_annotated_html(client: TestClient) -> None: "/api/resumes/preview", json={ "full_name": "山田太郎", + "email": "yamada@example.com", "career_summary": "キャリアサマリー", "self_pr": "自己PR", "experiences": [ @@ -374,6 +444,7 @@ def test_resume_preview_validation_error(client: TestClient) -> None: "/api/resumes/preview", json={ "full_name": "", + "email": "yamada@example.com", "career_summary": "x", "self_pr": "y", "experiences": [], diff --git a/backend/tests/test_markdown_generator.py b/backend/tests/test_markdown_generator.py index 14d375a4..1a724af0 100644 --- a/backend/tests/test_markdown_generator.py +++ b/backend/tests/test_markdown_generator.py @@ -34,6 +34,42 @@ def test_capital_unit_defaults_to_sen_man_when_missing() -> None: assert "5千万円" in md +def test_contact_fields_rendered_when_present() -> None: + md = build_resume_markdown( + { + "full_name": "山田 太郎", + "email": "yamada@example.com", + "github_url": "https://github.com/yamada", + "career_summary": "職務要約", + "self_pr": "自己PR", + "qualifications": [], + "experiences": [], + } + ) + assert "yamada@example.com" in md + # ラベルは email / github(小文字)。 + assert "**email:**" in md + assert "**github:**" in md + # github URL は Markdown リンク記法でハイパーリンク化する。 + assert "[https://github.com/yamada](https://github.com/yamada)" in md + + +def test_contact_fields_omitted_when_empty() -> None: + # github URL は任意。空なら github 行を出さない(email は必須運用だが空入力にも耐える)。 + md = build_resume_markdown( + { + "full_name": "山田 太郎", + "email": "yamada@example.com", + "github_url": "", + "career_summary": "職務要約", + "self_pr": "自己PR", + "qualifications": [], + "experiences": [], + } + ) + assert "github" not in md + + def _it_experience() -> dict: return { "company": "Example株式会社", diff --git a/backend/tests/test_pdf_generator.py b/backend/tests/test_pdf_generator.py index abf072ca..423bdfde 100644 --- a/backend/tests/test_pdf_generator.py +++ b/backend/tests/test_pdf_generator.py @@ -135,6 +135,44 @@ def test_build_html_annotates_data_fp() -> None: assert 'data-unit="qualifications.0"' in html +def test_build_html_renders_contact_fields() -> None: + """メール・GitHub URL は値があるとき data-fp 付きで出力され、空なら省略される。""" + html = _build_html( + { + "full_name": "山田太郎", + "email": "yamada@example.com", + "github_url": "https://github.com/yamada", + "career_summary": "要約", + "self_pr": "PR", + "qualifications": [], + "experiences": [], + } + ) + assert 'data-fp="email"' in html + assert "yamada@example.com" in html + # ラベルは email / github(小文字)で出す。 + assert "email " in html + assert "github " in html + assert 'data-fp="github_url"' in html + assert "https://github.com/yamada" in html + # github URL はハイパーリンク(青・下線)として で出す。 + assert ' None: """在職中の期間は「現在」を is_current に、休暇期間は各日付フィールドに紐づける。""" payload = { diff --git a/backend/tests/test_schemas.py b/backend/tests/test_schemas.py index 9f48c52b..cdee6708 100644 --- a/backend/tests/test_schemas.py +++ b/backend/tests/test_schemas.py @@ -87,6 +87,7 @@ def test_unknown_category_is_rejected() -> None: def test_resume_requires_career_summary() -> None: payload = { "full_name": "山田 太郎", + "email": "yamada@example.com", "self_pr": "自己PR", "experiences": [experience_payload()], "qualifications": [], @@ -98,6 +99,7 @@ def test_resume_requires_career_summary() -> None: def test_resume_requires_full_name() -> None: payload = { + "email": "yamada@example.com", "career_summary": "要約", "self_pr": "自己PR", "experiences": [experience_payload()], @@ -108,6 +110,57 @@ def test_resume_requires_full_name() -> None: ResumeCreate(**payload) +def _resume_payload(**overrides) -> dict: + """メール・GitHub URL 検証用の最小 ResumeCreate payload。""" + payload = { + "full_name": "山田 太郎", + "email": "yamada@example.com", + "career_summary": "要約", + "self_pr": "自己PR", + "experiences": [], + "qualifications": [], + } + payload.update(overrides) + return payload + + +def test_resume_requires_email() -> None: + """email は必須。欠落で ValidationError。""" + payload = _resume_payload() + del payload["email"] + with pytest.raises(ValidationError): + ResumeCreate(**payload) + + +@pytest.mark.parametrize("bad_email", ["", "not-an-email", "foo@bar", "a@b.", "no domain.com"]) +def test_resume_rejects_invalid_email(bad_email: str) -> None: + """不正なメール形式は ValidationError。""" + with pytest.raises(ValidationError): + ResumeCreate(**_resume_payload(email=bad_email)) + + +def test_resume_accepts_valid_github_url() -> None: + """https://github.com/ 始まりの GitHub URL は受理される。""" + resume = ResumeCreate(**_resume_payload(github_url="https://github.com/yamada")) + assert resume.github_url == "https://github.com/yamada" + + +def test_resume_allows_empty_github_url() -> None: + """GitHub URL は任意。空文字(デフォルト)でも受理される。""" + resume = ResumeCreate(**_resume_payload()) + assert resume.github_url == "" + + +@pytest.mark.parametrize( + "bad_url", + ["https://github.com/yamada", "https://gitlab.com/yamada", "github.com/yamada", "ftp://x"], +) +def test_resume_rejects_invalid_github_url(bad_url: str) -> None: + """github.com 以外・スキーム不正の URL は ValidationError。""" + with pytest.raises(ValidationError): + ResumeCreate(**_resume_payload(github_url=bad_url)) + + def test_project_migrates_scale_to_team() -> None: """旧形式 scale → team に自動変換されることを検証する。""" proj = Project( diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts index b50b1435..745826d2 100644 --- a/frontend/e2e/auth.spec.ts +++ b/frontend/e2e/auth.spec.ts @@ -159,6 +159,8 @@ test.describe("未認証ユーザー(お試し入力)", () => { body: JSON.stringify({ id: "new-resume-id", full_name: createdName ?? "", + email: "yamada@example.com", + github_url: "", career_summary: "", self_pr: "", experiences: [], @@ -175,6 +177,8 @@ test.describe("未認証ユーザー(お試し入力)", () => { "career_draft", JSON.stringify({ full_name: "山田太郎", + email: "yamada@example.com", + github_url: "", career_summary: "テスト要約", self_pr: "テスト自己PR", experiences: [ diff --git a/frontend/e2e/career-dirty-indicator.spec.ts b/frontend/e2e/career-dirty-indicator.spec.ts index 021f1342..159a2540 100644 --- a/frontend/e2e/career-dirty-indicator.spec.ts +++ b/frontend/e2e/career-dirty-indicator.spec.ts @@ -16,6 +16,8 @@ async function setupResumeApi(page: Page) { const baseResume = { id: "resume-1", full_name: "山田 太郎", + email: "yamada@example.com", + github_url: "", career_summary: "サマリー", self_pr: "自己PR", experiences: [ @@ -94,6 +96,8 @@ test.describe("職務経歴書 未保存マーク", () => { const baseResumeWithProject = { id: "resume-1", full_name: "山田 太郎", + email: "yamada@example.com", + github_url: "", career_summary: "サマリー", self_pr: "自己PR", experiences: [ diff --git a/frontend/e2e/career-field-modal.spec.ts b/frontend/e2e/career-field-modal.spec.ts index c287d969..78f50d51 100644 --- a/frontend/e2e/career-field-modal.spec.ts +++ b/frontend/e2e/career-field-modal.spec.ts @@ -28,6 +28,8 @@ async function setupResumeApi(page: Page) { body: JSON.stringify({ id: "resume-1", full_name: "山田 太郎", + email: "yamada@example.com", + github_url: "", career_summary: "初期サマリー", self_pr: "初期自己PR", experiences: [], diff --git a/frontend/src/api/generated.ts b/frontend/src/api/generated.ts index 074970b6..a32c5a0e 100644 --- a/frontend/src/api/generated.ts +++ b/frontend/src/api/generated.ts @@ -1321,10 +1321,17 @@ export interface components { ResumeCreate: { /** Career Summary */ career_summary: string; + /** Email */ + email: string; /** Experiences */ experiences?: components["schemas"]["Experience-Input"][]; /** Full Name */ full_name: string; + /** + * Github Url + * @default + */ + github_url: string; /** Qualifications */ qualifications?: components["schemas"]["ResumeQualificationItem"][]; /** Self Pr */ @@ -1357,10 +1364,17 @@ export interface components { career_summary: string; /** Created At */ created_at: string; + /** Email */ + email: string; /** Experiences */ experiences?: components["schemas"]["Experience-Output"][]; /** Full Name */ full_name: string; + /** + * Github Url + * @default + */ + github_url: string; /** * Id * Format: uuid @@ -1377,10 +1391,17 @@ export interface components { ResumeUpdate: { /** Career Summary */ career_summary: string; + /** Email */ + email: string; /** Experiences */ experiences?: components["schemas"]["Experience-Input"][]; /** Full Name */ full_name: string; + /** + * Github Url + * @default + */ + github_url: string; /** Qualifications */ qualifications?: components["schemas"]["ResumeQualificationItem"][]; /** Self Pr */ diff --git a/frontend/src/components/forms/CareerDiffModal.module.css b/frontend/src/components/forms/CareerDiffModal.module.css index 1edd53f8..029f2562 100644 --- a/frontend/src/components/forms/CareerDiffModal.module.css +++ b/frontend/src/components/forms/CareerDiffModal.module.css @@ -41,6 +41,14 @@ min-height: 0; display: grid; grid-template-columns: 1fr 1fr 320px; + /* + * 行を明示的に minmax(0, 1fr) で固定する。未指定だと暗黙の auto 行になり、 + * 変更点が多いときにサイドバーの中身(.list)の高さに合わせて行が伸び、 + * モーダル(92vh / overflow:hidden)からはみ出した分が clip されてロール + * バックボタンや操作ボタンが見えなくなる。0 を下限にすることで中身では + * 伸びず、.list の overflow-y:auto が効いて全項目をスクロールで辿れる。 + */ + grid-template-rows: minmax(0, 1fr); gap: 0.75rem; padding: 0.5rem 1rem 0.75rem; } diff --git a/frontend/src/components/forms/CareerResumeForm.tsx b/frontend/src/components/forms/CareerResumeForm.tsx index d930b1ac..1cf029e1 100644 --- a/frontend/src/components/forms/CareerResumeForm.tsx +++ b/frontend/src/components/forms/CareerResumeForm.tsx @@ -413,14 +413,18 @@ export function CareerResumeForm({ isAuthenticated }: { isAuthenticated: boolean
{validationError &&

{validationError}

} - {/* 基本情報: 氏名・職務要約 */} + {/* 基本情報: 氏名・連絡先・職務要約 */} setEditingField("career_summary")} fullNameDirty={dirty.full_name} + emailDirty={dirty.email} + githubUrlDirty={dirty.github_url} careerSummaryDirty={dirty.career_summary} focusLocator={focusLocator} /> diff --git a/frontend/src/components/forms/sections/CareerBasicInfoSection.tsx b/frontend/src/components/forms/sections/CareerBasicInfoSection.tsx index b166db99..dab52243 100644 --- a/frontend/src/components/forms/sections/CareerBasicInfoSection.tsx +++ b/frontend/src/components/forms/sections/CareerBasicInfoSection.tsx @@ -10,16 +10,24 @@ import { MarkdownFieldTrigger } from "../MarkdownFieldTrigger"; type Props = { /** 氏名 */ fullName: string; + /** メールアドレス(必須) */ + email: string; + /** GitHub アカウント URL(任意) */ + githubUrl: string; /** 職務要約(Markdown) */ careerSummary: string; /** ローディング中(Skeleton 表示) */ loading: boolean; /** フィールド変更ハンドラ */ - onChange: (key: "full_name" | "career_summary", value: string) => void; + onChange: (key: "full_name" | "email" | "github_url" | "career_summary", value: string) => void; /** 職務要約の入力モーダルを開く */ onEditCareerSummary: () => void; /** 氏名フィールドが未保存か */ fullNameDirty?: boolean; + /** メールフィールドが未保存か */ + emailDirty?: boolean; + /** GitHub URL フィールドが未保存か */ + githubUrlDirty?: boolean; /** 職務要約フィールドが未保存か */ careerSummaryDirty?: boolean; /** バリデーション失敗フィールドの位置情報(フォーカス・赤枠用) */ @@ -33,17 +41,25 @@ type Props = { */ export function CareerBasicInfoSection({ fullName, + email, + githubUrl, careerSummary, loading, onChange, onEditCareerSummary, fullNameDirty = false, + emailDirty = false, + githubUrlDirty = false, careerSummaryDirty = false, focusLocator = null, }: Props) { const fullNameInvalid = focusLocator?.kind === "full_name"; + const emailInvalid = focusLocator?.kind === "email"; + const githubUrlInvalid = focusLocator?.kind === "github_url"; const careerSummaryInvalid = focusLocator?.kind === "career_summary"; const fullNameRef = useFocusOnMatch(fullNameInvalid); + const emailRef = useFocusOnMatch(emailInvalid); + const githubUrlRef = useFocusOnMatch(githubUrlInvalid); return (
@@ -66,6 +82,43 @@ export function CareerBasicInfoSection({ /> )} + + = {}): CareerFormState { return { full_name: "山田 太郎", + email: "yamada@example.com", + github_url: "", career_summary: "サマリー", self_pr: "自己PR", experiences: [ diff --git a/frontend/src/hooks/career/useCareerDirty.ts b/frontend/src/hooks/career/useCareerDirty.ts index 4d72e364..24bbd512 100644 --- a/frontend/src/hooks/career/useCareerDirty.ts +++ b/frontend/src/hooks/career/useCareerDirty.ts @@ -59,6 +59,8 @@ export type CareerDirtyMap = { /** 全体で何か未保存があるか(保存ボタン横用) */ any: boolean; full_name: boolean; + email: boolean; + github_url: boolean; career_summary: boolean; self_pr: boolean; experiences: ExperienceDirty[]; @@ -106,6 +108,8 @@ function buildClean(form: CareerFormState): CareerDirtyMap { return { any: false, full_name: false, + email: false, + github_url: false, career_summary: false, self_pr: false, experiences: form.experiences.map((exp) => buildCleanExperience(exp)), @@ -224,6 +228,8 @@ export function useCareerDirty( if (!baseline) return buildClean(form); const full_name = form.full_name !== baseline.full_name; + const email = form.email !== baseline.email; + const github_url = form.github_url !== baseline.github_url; const career_summary = form.career_summary !== baseline.career_summary; const self_pr = form.self_pr !== baseline.self_pr; @@ -243,11 +249,19 @@ export function useCareerDirty( removedQualifications || qualifications.some((q) => q.self); const any = - full_name || career_summary || self_pr || experiencesAny || qualificationsAny; + full_name || + email || + github_url || + career_summary || + self_pr || + experiencesAny || + qualificationsAny; return { any, full_name, + email, + github_url, career_summary, self_pr, experiences, diff --git a/frontend/src/hooks/career/useCareerExperienceMutators.test.ts b/frontend/src/hooks/career/useCareerExperienceMutators.test.ts index 9e334eae..63351ac0 100644 --- a/frontend/src/hooks/career/useCareerExperienceMutators.test.ts +++ b/frontend/src/hooks/career/useCareerExperienceMutators.test.ts @@ -17,6 +17,8 @@ import { useCareerExperienceMutators } from "./useCareerExperienceMutators"; function buildForm(overrides: Partial = {}): CareerFormState { return { full_name: "山田 太郎", + email: "yamada@example.com", + github_url: "", career_summary: "", self_pr: "", experiences: [ diff --git a/frontend/src/hooks/career/useResumeDiffPreview.test.ts b/frontend/src/hooks/career/useResumeDiffPreview.test.ts index 758c5dc5..5903fccb 100644 --- a/frontend/src/hooks/career/useResumeDiffPreview.test.ts +++ b/frontend/src/hooks/career/useResumeDiffPreview.test.ts @@ -15,6 +15,8 @@ const mockPreview = getCareerResumePreview as unknown as ReturnType = {}): CareerE const baseState = (overrides: Partial = {}): CareerFormState => ({ full_name: "山田 太郎", + email: "yamada@example.com", + github_url: "", career_summary: "要約", self_pr: "自己PR", experiences: [], @@ -127,18 +129,46 @@ describe("buildCareerPayload (basic validation)", () => { expect(() => buildCareerPayload(baseState({ self_pr: "" }))).toThrow(/自己PR/); }); + it("メールが空ならエラー", () => { + expect(() => buildCareerPayload(baseState({ email: " " }))).toThrow(/メールアドレス/); + }); + + it("メール形式が不正ならエラー", () => { + expect(() => buildCareerPayload(baseState({ email: "not-an-email" }))).toThrow(/メールアドレス/); + }); + + it("GitHub URL が github.com 以外ならエラー", () => { + expect(() => buildCareerPayload(baseState({ github_url: "https://gitlab.com/x" }))).toThrow( + /GitHub/, + ); + }); + + it("GitHub URL は任意(空なら通る)", () => { + const payload = buildCareerPayload(baseState({ github_url: "" })); + expect(payload.github_url).toBe(""); + }); + it("必須項目が揃えば experiences/qualifications 空でも payload を返す", () => { const payload = buildCareerPayload(baseState()); expect(payload.full_name).toBe("山田 太郎"); + expect(payload.email).toBe("yamada@example.com"); expect(payload.experiences).toEqual([]); expect(payload.qualifications).toEqual([]); }); it("前後の空白は trim される", () => { const payload = buildCareerPayload( - baseState({ full_name: " 山田 ", career_summary: " 要約 ", self_pr: " PR " }), + baseState({ + full_name: " 山田 ", + email: " yamada@example.com ", + github_url: " https://github.com/yamada ", + career_summary: " 要約 ", + self_pr: " PR ", + }), ); expect(payload.full_name).toBe("山田"); + expect(payload.email).toBe("yamada@example.com"); + expect(payload.github_url).toBe("https://github.com/yamada"); expect(payload.career_summary).toBe("要約"); expect(payload.self_pr).toBe("PR"); }); diff --git a/frontend/src/payloadBuilders.ts b/frontend/src/payloadBuilders.ts index a6f8bdc1..b61b96df 100644 --- a/frontend/src/payloadBuilders.ts +++ b/frontend/src/payloadBuilders.ts @@ -12,6 +12,11 @@ import type { TechnologyStackItem, } from "./api/types"; +/** 簡易メール形式(UX 補助)。正本は backend schemas/resume.py の検証。 */ +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +/** GitHub アカウント URL の接頭辞。値があるときだけ前方一致で検証する。 */ +const GITHUB_URL_PREFIX = "https://github.com/"; + export type TeamMemberForm = { role: string; count: string; @@ -63,6 +68,8 @@ export type CareerExperienceForm = { export type CareerFormState = { full_name: string; + email: string; + github_url: string; career_summary: string; self_pr: string; experiences: CareerExperienceForm[]; @@ -77,6 +84,8 @@ export type CareerFormState = { */ export type CareerFieldLocator = | { kind: "full_name" } + | { kind: "email" } + | { kind: "github_url" } | { kind: "career_summary" } | { kind: "self_pr" } | { @@ -248,6 +257,18 @@ export function validateCareerForm(state: CareerFormState): CareerValidationErro if (!state.full_name.trim()) { return { message: VALIDATION_MESSAGES.FULL_NAME_REQUIRED, locator: { kind: "full_name" } }; } + const email = state.email.trim(); + if (!email) { + return { message: VALIDATION_MESSAGES.EMAIL_REQUIRED, locator: { kind: "email" } }; + } + if (!EMAIL_PATTERN.test(email)) { + return { message: VALIDATION_MESSAGES.EMAIL_INVALID, locator: { kind: "email" } }; + } + // GitHub URL は任意。入力があるときだけ形式を検証する。 + const githubUrl = state.github_url.trim(); + if (githubUrl && !githubUrl.startsWith(GITHUB_URL_PREFIX)) { + return { message: VALIDATION_MESSAGES.GITHUB_URL_INVALID, locator: { kind: "github_url" } }; + } if (!state.career_summary.trim()) { return { message: VALIDATION_MESSAGES.CAREER_SUMMARY_REQUIRED, @@ -417,6 +438,8 @@ export function buildCareerPayload(state: CareerFormState): ResumeCreate { return { full_name: state.full_name.trim(), + email: state.email.trim(), + github_url: state.github_url.trim(), career_summary: state.career_summary.trim(), self_pr: state.self_pr.trim(), experiences, diff --git a/frontend/src/utils/careerDiff.test.ts b/frontend/src/utils/careerDiff.test.ts index 516f6283..a54603d4 100644 --- a/frontend/src/utils/careerDiff.test.ts +++ b/frontend/src/utils/careerDiff.test.ts @@ -14,6 +14,8 @@ import { buildCareerChanges } from "./careerDiff"; function buildForm(): CareerFormState { return structuredClone({ full_name: "山田 太郎", + email: "yamada@example.com", + github_url: "", career_summary: "サマリー", self_pr: "自己PR", experiences: [ diff --git a/frontend/src/utils/careerDiff.ts b/frontend/src/utils/careerDiff.ts index c58e541b..0df792a6 100644 --- a/frontend/src/utils/careerDiff.ts +++ b/frontend/src/utils/careerDiff.ts @@ -283,6 +283,8 @@ export function buildCareerChanges( // 並び順は PDF レイアウト(氏名 → 職務要約 → 職務経歴 → 資格 → 自己PR)に合わせる。 // 左右ペインとサイドバー(変更点 / 校正)の縦順が一致し、突合しやすくなる。 pushScalar(changes, [], L.FULL_NAME, ["full_name"], form.full_name, baseline.full_name); + pushScalar(changes, [], L.EMAIL, ["email"], form.email, baseline.email); + pushScalar(changes, [], L.GITHUB_URL, ["github_url"], form.github_url, baseline.github_url); pushScalar(changes, [], L.CAREER_SUMMARY, ["career_summary"], form.career_summary, baseline.career_summary); diffArray( diff --git a/frontend/src/utils/careerDraft.ts b/frontend/src/utils/careerDraft.ts index a82c5982..b584c4cf 100644 --- a/frontend/src/utils/careerDraft.ts +++ b/frontend/src/utils/careerDraft.ts @@ -34,6 +34,8 @@ function isCareerDraft(value: unknown): value is CareerFormState { const v = value as Record; return ( typeof v.full_name === "string" && + typeof v.email === "string" && + typeof v.github_url === "string" && typeof v.career_summary === "string" && typeof v.self_pr === "string" && Array.isArray(v.experiences) && diff --git a/frontend/tests/payloadBuilders.test.cjs b/frontend/tests/payloadBuilders.test.cjs index 5032f765..e8eac463 100644 --- a/frontend/tests/payloadBuilders.test.cjs +++ b/frontend/tests/payloadBuilders.test.cjs @@ -6,6 +6,8 @@ const { buildCareerPayload } = require("../.test-dist/payloadBuilders.js"); test("buildCareerPayload trims data and keeps only non-empty technology stacks", () => { const payload = buildCareerPayload({ full_name: " 山田 太郎 ", + email: " yamada@example.com ", + github_url: " https://github.com/yamada ", career_summary: " 職務要約テスト ", self_pr: " 自己PRテスト ", experiences: [ @@ -68,6 +70,8 @@ test("buildCareerPayload trims data and keeps only non-empty technology stacks", }); assert.equal(payload.full_name, "山田 太郎"); + assert.equal(payload.email, "yamada@example.com"); + assert.equal(payload.github_url, "https://github.com/yamada"); assert.equal(payload.career_summary, "職務要約テスト"); assert.equal(payload.self_pr, "自己PRテスト"); assert.equal(payload.experiences.length, 1); @@ -114,6 +118,8 @@ test("buildCareerPayload throws when 離職で終了年月がない", () => { () => buildCareerPayload({ full_name: "山田 太郎", + email: "yamada@example.com", + github_url: "", career_summary: "職務要約", self_pr: "自己PR", experiences: [ @@ -140,6 +146,8 @@ test("buildCareerPayload throws when a 資格 is partially filled", () => { () => buildCareerPayload({ full_name: "山田 太郎", + email: "yamada@example.com", + github_url: "", career_summary: "要約", self_pr: "自己PR", experiences: [], From 262a1a0ca157601fb1f6198353449de6fe8c6d64 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Wed, 10 Jun 2026 23:43:48 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test(resume):=20=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E6=8C=87=E6=91=98=E5=AF=BE=E5=BF=9C=EF=BC=88=E9=80=A3?= =?UTF-8?q?=E7=B5=A1=E5=85=88=E3=81=AE=E5=B7=AE=E5=88=86=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E8=BF=BD=E5=8A=A0=E3=83=BBpayload=20DRY=20=E5=8C=96?= =?UTF-8?q?=E3=83=BB=E5=80=A4=E6=AD=A3=E8=A6=8F=E5=8C=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - careerDiff.test: email / github_url の変更検出と項目別ロールバック復元を明示テスト - test_endpoints: 連絡先系 3 テストの重複 payload を conftest.make_resume_payload に集約 - test_markdown_generator: 空 github の省略確認を厳密なラベルトークン "**github:**" で判定 - schemas/resume: email / github_url validator が前後空白を除去した正規化値を返すよう修正 Co-Authored-By: Claude Opus 4.8 --- backend/app/schemas/resume.py | 11 +++--- backend/tests/test_endpoints.py | 48 +++++------------------- backend/tests/test_markdown_generator.py | 2 +- frontend/src/utils/careerDiff.test.ts | 28 ++++++++++++++ 4 files changed, 45 insertions(+), 44 deletions(-) diff --git a/backend/app/schemas/resume.py b/backend/app/schemas/resume.py index 495ffd76..57a3ec10 100644 --- a/backend/app/schemas/resume.py +++ b/backend/app/schemas/resume.py @@ -245,19 +245,20 @@ class ResumeBase(BaseModel): @field_validator("email") @classmethod def validate_email(cls, value: str) -> str: - """メールアドレスの簡易形式チェック。不正なら 422(日本語メッセージ)で返す。""" - if not _EMAIL_PATTERN.match(value.strip()): + """メールアドレスの簡易形式チェック。前後空白を除去して正規化し、不正なら 422(日本語)で返す。""" + trimmed = value.strip() + if not _EMAIL_PATTERN.match(trimmed): raise ValueError(get_error("validation.email_invalid")) - return value + return trimmed @field_validator("github_url") @classmethod def validate_github_url(cls, value: str) -> str: - """GitHub URL は任意。値があるときだけ ``https://github.com/`` 始まりを要求する。""" + """GitHub URL は任意。値があるときだけ ``https://github.com/`` 始まりを要求する。前後空白は除去する。""" stripped = value.strip() if stripped and not stripped.startswith(_GITHUB_URL_PREFIX): raise ValueError(get_error("validation.github_url_invalid")) - return value + return stripped class ResumeCreate(ResumeBase): diff --git a/backend/tests/test_endpoints.py b/backend/tests/test_endpoints.py index 3304a0d5..a97f4050 100644 --- a/backend/tests/test_endpoints.py +++ b/backend/tests/test_endpoints.py @@ -1,7 +1,7 @@ import pytest from fastapi.testclient import TestClient -from conftest import auth_header +from conftest import auth_header, make_resume_payload # ── CRUD: Auth required (401 without token) ──────────────────── @@ -67,19 +67,12 @@ def test_resume_crud(client: TestClient) -> None: def test_resume_contact_round_trips(client: TestClient) -> None: """メール・GitHub URL が作成→取得で往復することを確認する。""" headers = auth_header(client, "resume-contact-user") - resp = client.post( - "/api/resumes", - json={ - "full_name": "連絡先 太郎", - "email": "contact@example.com", - "github_url": "https://github.com/contact-taro", - "career_summary": "要約", - "self_pr": "自己PR", - "experiences": [], - "qualifications": [], - }, - headers=headers, + payload = make_resume_payload( + full_name="連絡先 太郎", + email="contact@example.com", + github_url="https://github.com/contact-taro", ) + resp = client.post("/api/resumes", json=payload, headers=headers) assert resp.status_code == 201, resp.text resp = client.get("/api/resumes/latest", headers=headers) @@ -92,37 +85,16 @@ def test_resume_contact_round_trips(client: TestClient) -> None: def test_resume_rejects_invalid_email_via_api(client: TestClient) -> None: """不正なメール形式は 422 で拒否される。""" headers = auth_header(client, "resume-bademail-user") - resp = client.post( - "/api/resumes", - json={ - "full_name": "太郎", - "email": "not-an-email", - "career_summary": "要約", - "self_pr": "自己PR", - "experiences": [], - "qualifications": [], - }, - headers=headers, - ) + payload = make_resume_payload(email="not-an-email") + resp = client.post("/api/resumes", json=payload, headers=headers) assert resp.status_code == 422 def test_resume_rejects_invalid_github_url_via_api(client: TestClient) -> None: """github.com 以外の GitHub URL は 422 で拒否される。""" headers = auth_header(client, "resume-badgithub-user") - resp = client.post( - "/api/resumes", - json={ - "full_name": "太郎", - "email": "ok@example.com", - "github_url": "https://gitlab.com/taro", - "career_summary": "要約", - "self_pr": "自己PR", - "experiences": [], - "qualifications": [], - }, - headers=headers, - ) + payload = make_resume_payload(github_url="https://gitlab.com/taro") + resp = client.post("/api/resumes", json=payload, headers=headers) assert resp.status_code == 422 diff --git a/backend/tests/test_markdown_generator.py b/backend/tests/test_markdown_generator.py index 1a724af0..a7c8a54c 100644 --- a/backend/tests/test_markdown_generator.py +++ b/backend/tests/test_markdown_generator.py @@ -67,7 +67,7 @@ def test_contact_fields_omitted_when_empty() -> None: "experiences": [], } ) - assert "github" not in md + assert "**github:**" not in md def _it_experience() -> dict: diff --git a/frontend/src/utils/careerDiff.test.ts b/frontend/src/utils/careerDiff.test.ts index a54603d4..dd6518d1 100644 --- a/frontend/src/utils/careerDiff.test.ts +++ b/frontend/src/utils/careerDiff.test.ts @@ -66,6 +66,34 @@ describe("buildCareerChanges", () => { expect(changes[0].label).toBe("氏名"); }); + it("連絡先(email / github_url)の変更を検出し、ロールバックで復元できる", () => { + const baseline = buildForm(); + const form = buildForm(); + form.email = "sato@example.com"; + form.github_url = "https://github.com/sato"; // baseline は ""(未設定) + + const changes = buildCareerChanges(form, baseline); + + // email: 既存値 → 新値の modified として検出される。 + const emailChange = changes.find((c) => c.path.join(".") === "email"); + expect(emailChange).toBeDefined(); + expect(emailChange).toMatchObject({ + kind: "modified", + oldValue: "yamada@example.com", + newValue: "sato@example.com", + }); + + // github_url: 空 → 新値の modified として検出される。 + const githubChange = changes.find((c) => c.path.join(".") === "github_url"); + expect(githubChange).toBeDefined(); + expect(githubChange?.kind).toBe("modified"); + expect(githubChange?.newValue).toBe("https://github.com/sato"); + + // 項目別ロールバックで両フィールドが baseline 値へ戻る。 + expect(emailChange!.rollback(form).email).toBe("yamada@example.com"); + expect(githubChange!.rollback(form).github_url).toBe(""); + }); + it("職歴の追加を added として検出する", () => { const baseline = buildForm(); const form = buildForm();