diff --git a/.claude/rules/web/test.md b/.claude/rules/web/test.md index 4bec7b62..7e8ab73d 100644 --- a/.claude/rules/web/test.md +++ b/.claude/rules/web/test.md @@ -7,11 +7,11 @@ paths: ## いつテストを書く・回すか(トリガー) -### ユニット / コンポーネントテスト(vitest + node:test) +### ユニット / コンポーネントテスト(vitest) - **新規フック追加**: 必ず `*.test.ts` を作成(loading / success / error の 3 パス最低限) - **既存フックの契約変更**: 戻り値・副作用が変わる場合、既存 `*.test.ts` の assert を見直す -- **payloadBuilders / formMappers の変更**: `payloadBuilders.test.ts` を更新(node:test 経由) +- **payloadBuilders / formMappers の変更**: `payloadBuilders.test.ts` を更新(vitest) - **api/client.ts の変更**: `api/client.test.ts` を更新(401 リダイレクト、Cookie 認証の挙動) - **コンポーネント追加**: ロジックを含むものはテストを追加。表示のみのものは省略可 @@ -27,7 +27,7 @@ paths: ## 実行コマンド ```bash -make test-web # unit + vitest +make test-web # vitest(unit / コンポーネント) nix develop --command bash -c "cd web && npm run test:e2e" # E2E(Playwright) ``` @@ -40,7 +40,7 @@ nix develop --command bash -c "cd web && npx vitest run src/hooks/useDocumentFor 以下をすべて満たして初めて「テスト OK」と判定する: -1. **全 unit / vitest pass**: `make test-web` が exit 0 +1. **全 vitest pass**: `make test-web` が exit 0 2. **lint が pass**: `make lint-web` も同時に通ること 3. **build が通る**: `make build-web`(tsc + vite build)が通ること。TypeScript の型エラーが残っていないこと 4. **E2E トリガーに該当する場合は E2E pass**: 上記トリガーリストに該当する変更では `npm run test:e2e` を必ず実行し、全シナリオが green diff --git a/.gitignore b/.gitignore index 50325bc1..4babefbd 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,6 @@ htmlcov/ # Node node_modules/ dist/ -web/.test-dist/ # Playwright E2E の生成物(実行ごとに作られるローカル成果物) web/test-results/ web/playwright-report/ diff --git a/.jscpd.json b/.jscpd.json index 065e6d8c..ad6e103e 100644 --- a/.jscpd.json +++ b/.jscpd.json @@ -37,7 +37,6 @@ "**/node_modules/**", "**/.venv/**", "**/dist/**", - "**/.test-dist/**", "**/__pycache__/**", "backend/alembic_migrations/versions/**", "web/src/**/*.test.{ts,tsx}", diff --git a/backend/tests/auth/test_token_manager.py b/backend/tests/auth/test_token_manager.py index 87e9303d..f7bb79de 100644 --- a/backend/tests/auth/test_token_manager.py +++ b/backend/tests/auth/test_token_manager.py @@ -21,34 +21,20 @@ # ── RS256 トークン生成・検証 ────────────────────────────────────── -def test_create_access_token_contains_username() -> None: - token = create_access_token("alice") - payload = jwt.decode(token, _test_public_key, algorithms=["RS256"]) - - assert payload["sub"] == "alice" - - -def test_create_access_token_has_expiry() -> None: - token = create_access_token("alice") - payload = jwt.decode(token, _test_public_key, algorithms=["RS256"]) - - assert "exp" in payload - - -def test_create_access_token_type_is_access() -> None: - token = create_access_token("alice") - payload = jwt.decode(token, _test_public_key, algorithms=["RS256"]) - - assert payload["type"] == "access" - - -def test_create_refresh_token_type_is_refresh() -> None: - token, jti = create_refresh_token("alice") - payload = jwt.decode(token, _test_public_key, algorithms=["RS256"]) - - assert payload["type"] == "refresh" - assert payload["sub"] == "alice" - assert payload["jti"] == jti +def test_access_and_refresh_token_claims() -> None: + """アクセス / リフレッシュトークンのクレーム契約(sub / exp / type / jti)を検証する。""" + access_payload = jwt.decode( + create_access_token("alice"), _test_public_key, algorithms=["RS256"] + ) + assert access_payload["sub"] == "alice" + assert "exp" in access_payload + assert access_payload["type"] == "access" + + refresh_token, jti = create_refresh_token("alice") + refresh_payload = jwt.decode(refresh_token, _test_public_key, algorithms=["RS256"]) + assert refresh_payload["sub"] == "alice" + assert refresh_payload["type"] == "refresh" + assert refresh_payload["jti"] == jti def test_hs256_token_rejected_by_rs256_verification() -> None: diff --git a/backend/tests/test_database_libsql_binary.py b/backend/tests/test_database_libsql_binary.py index 30e2de71..47b68bb3 100644 --- a/backend/tests/test_database_libsql_binary.py +++ b/backend/tests/test_database_libsql_binary.py @@ -8,8 +8,7 @@ import importlib import pytest -from sqlalchemy import LargeBinary, create_engine -from sqlalchemy.pool import NullPool +from sqlalchemy import LargeBinary @pytest.fixture() @@ -35,13 +34,3 @@ def test_large_binary_bind_processor_passes_bytes_through(libsql_engine): assert proc is not None payload = b"%PDF-1.3\n%fake-binary" assert proc(payload) == payload - - -def test_standard_sqlite_dialect_unaffected(): - """通常の pysqlite ドライバには影響しない(Binary は元から存在する)。""" - engine = create_engine("sqlite:///:memory:", poolclass=NullPool) - assert engine.dialect.driver == "pysqlite" - # pysqlite は標準で Binary を提供する - dbapi = engine.dialect.dbapi - assert dbapi is not None - assert callable(dbapi.Binary) diff --git a/backend/tests/test_messages.py b/backend/tests/test_messages.py index 1e9dcc3d..04589c1e 100644 --- a/backend/tests/test_messages.py +++ b/backend/tests/test_messages.py @@ -1,19 +1,18 @@ from app.core.messages import get_error, get_notification, get_success, load_messages -def test_get_notification_returns_message() -> None: - load_messages() - - assert ( - get_notification("github_link", "completed") == "GitHub連携が完了しました" - ) - assert get_notification("github_link", "failed") == "GitHub連携に失敗しました" - +def test_key_lookup_returns_message() -> None: + """キー引きで messages.json の文言が返ること(アクセサごとに代表 1 件)。 -def test_get_error_returns_message_by_key() -> None: + 文言の逐語 pin を全キーに置くと文言変更のたびに壊れる割に検出力が無いため、 + ここではキー→文言の解決経路だけを確認する。 + """ load_messages() assert get_error("auth.login_required") == "ログインが必要です。" + assert get_notification("github_link", "completed") == "GitHub連携が完了しました" + # schemas/resume.py の model_validator から直接参照される契約なので明示的に固定する + assert get_error("validation.end_date_required") == "在職中でない場合は終了年月を入力してください。" def test_get_error_formats_placeholders() -> None: @@ -34,12 +33,6 @@ def test_missing_message_key_falls_back_to_key() -> None: assert get_error("unknown.category.key") == "unknown.category.key" -def test_validation_end_date_required() -> None: - load_messages() - - assert get_error("validation.end_date_required") == "在職中でない場合は終了年月を入力してください。" - - def test_master_data_placeholder() -> None: """master_data は {item}、document は {document} で統一されている。""" load_messages() diff --git a/backend/tests/test_schemas.py b/backend/tests/test_schemas.py index 9f80ab21..48fdd230 100644 --- a/backend/tests/test_schemas.py +++ b/backend/tests/test_schemas.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + import pytest from app.schemas import ( Client, @@ -5,6 +7,7 @@ Project, ResumeCreate, ) +from app.schemas.resume import ProjectPeriod from pydantic import ValidationError @@ -43,17 +46,6 @@ def experience_payload() -> dict: } -def test_current_experience_forces_end_date_empty() -> None: - """在籍中(is_current=True)なら end_date は "" に正規化される。""" - payload = experience_payload() - payload["is_current"] = True - payload["end_date"] = "2024-03" - - experience = Experience(**payload) - - assert experience.end_date == "" - - def test_end_date_is_required_when_not_current() -> None: payload = experience_payload() payload["is_current"] = False @@ -240,137 +232,83 @@ def test_experience_allows_empty_employee_count_and_capital() -> None: assert exp.capital == "" -def test_experience_end_date_before_start_date_is_rejected() -> None: - """経歴: 終了日が開始日より前の場合は422エラーとなること。""" - payload = experience_payload() - payload["start_date"] = "2024-04" - payload["end_date"] = "2021-03" - payload["is_current"] = False - - with pytest.raises(ValidationError, match="開始日は終了日より前"): - Experience(**payload) - - -def test_experience_end_date_equals_start_date_is_accepted() -> None: - """経歴: 終了日 = 開始日は正常に保存されること。""" +def _build_experience_period( + start_date: str, end_date: str | None, is_current: bool +) -> Experience: + """経歴の期間を検証する(期間フィールドは Experience 自身が持つ)。""" payload = experience_payload() - payload["start_date"] = "2024-04" - payload["end_date"] = "2024-04" - payload["is_current"] = False + payload["start_date"] = start_date + payload["end_date"] = end_date + payload["is_current"] = is_current + return Experience(**payload) - exp = Experience(**payload) - assert exp.start_date == "2024-04" - assert exp.end_date == "2024-04" - - -def test_experience_end_date_after_start_date_is_accepted() -> None: - """経歴: 終了日 > 開始日は正常に保存されること。""" - payload = experience_payload() - payload["start_date"] = "2021-04" - payload["end_date"] = "2024-03" - payload["is_current"] = False - - exp = Experience(**payload) - assert exp.start_date == "2021-04" - assert exp.end_date == "2024-03" - - -def test_experience_in_progress_end_date_is_normalized_to_empty() -> None: - """経歴: 在職中(is_current=True)は end_date が "" に正規化されること。 - - schema 上は str 必須・None 不可。在籍中なら値が入っていても "" に丸める。 - """ - payload = experience_payload() - payload["is_current"] = True - payload["end_date"] = "2024-03" - - exp = Experience(**payload) - assert exp.end_date == "" +def _build_project_period( + start_date: str, end_date: str | None, is_current: bool +) -> ProjectPeriod: + """プロジェクトの期間を検証する(フラット日付 → periods[0] 移行経路を通す)。""" + proj = Project.model_validate( + { + "name": "テスト", + "start_date": start_date, + "end_date": end_date, + "is_current": is_current, + "technology_stacks": [], + } + ) + return proj.periods[0] -def test_experience_end_date_none_is_rejected() -> None: - """経歴: end_date に None を渡すと ValidationError になる(str 必須契約)。""" - payload = experience_payload() - payload["is_current"] = True - payload["end_date"] = None - with pytest.raises(ValidationError): - Experience(**payload) +# 経歴・プロジェクトは共通の期間バリデーション契約を持つため、両モデルを同一系列で検証する +_period_builders = pytest.mark.parametrize( + "build_period", + [_build_experience_period, _build_project_period], + ids=["experience", "project"], +) +PeriodBuilder = Callable[[str, str | None, bool], Experience | ProjectPeriod] -def test_project_end_date_before_start_date_is_rejected() -> None: - """プロジェクト: 終了日が開始日より前の場合はエラーとなること。""" +@_period_builders +def test_end_date_before_start_date_is_rejected(build_period: PeriodBuilder) -> None: + """終了日が開始日より前の場合は 422 エラーとなること。""" with pytest.raises(ValidationError, match="開始日は終了日より前"): - Project.model_validate( - { - "name": "テスト", - "start_date": "2024-04", - "end_date": "2021-03", - "is_current": False, - "technology_stacks": [], - } - ) + build_period("2024-04", "2021-03", False) -def test_project_end_date_equals_start_date_is_accepted() -> None: - """プロジェクト: 終了日 = 開始日は正常に保存されること。""" - proj = Project.model_validate( - { - "name": "テスト", - "periods": [{"start_date": "2024-04", "end_date": "2024-04", "is_current": False}], - "technology_stacks": [], - } - ) - assert proj.periods[0].end_date == "2024-04" +@_period_builders +def test_end_date_equals_start_date_is_accepted(build_period: PeriodBuilder) -> None: + """終了日 = 開始日は正常に保存されること。""" + period = build_period("2024-04", "2024-04", False) + assert period.start_date == "2024-04" + assert period.end_date == "2024-04" -def test_project_end_date_after_start_date_is_accepted() -> None: - """プロジェクト: 終了日 > 開始日は正常に保存されること。""" - proj = Project.model_validate( - { - "name": "テスト", - "periods": [{"start_date": "2021-04", "end_date": "2024-03", "is_current": False}], - "technology_stacks": [], - } - ) - assert proj.periods[0].end_date == "2024-03" +@_period_builders +def test_end_date_after_start_date_is_accepted(build_period: PeriodBuilder) -> None: + """終了日 > 開始日は正常に保存されること。""" + period = build_period("2021-04", "2024-03", False) + assert period.start_date == "2021-04" + assert period.end_date == "2024-03" -def test_project_in_progress_end_date_is_normalized_to_empty() -> None: - """プロジェクト: 参画中(is_current=True)の期間は end_date が "" に正規化されること。""" - # 値が入っていても is_current=True なら "" に正規化される - proj = Project.model_validate( - { - "name": "テスト", - "periods": [{"start_date": "2021-04", "end_date": "2024-03", "is_current": True}], - "technology_stacks": [], - } - ) - assert proj.periods[0].end_date == "" +@_period_builders +@pytest.mark.parametrize("end_date", ["2024-03", ""], ids=["value", "empty"]) +def test_in_progress_end_date_is_normalized_to_empty( + build_period: PeriodBuilder, end_date: str +) -> None: + """在職中/参画中(is_current=True)は end_date が "" に正規化されること。 - # 空文字列も当然 OK - proj_empty = Project.model_validate( - { - "name": "テスト", - "periods": [{"start_date": "2021-04", "end_date": "", "is_current": True}], - "technology_stacks": [], - } - ) - assert proj_empty.periods[0].end_date == "" + schema 上は str 必須・None 不可。値が入っていても "" に丸める。 + """ + period = build_period("2021-04", end_date, True) + assert period.end_date == "" -def test_project_end_date_none_is_rejected() -> None: - """プロジェクト: end_date に None を渡すと ValidationError になる。""" +@_period_builders +def test_end_date_none_is_rejected(build_period: PeriodBuilder) -> None: + """end_date に None を渡すと ValidationError になる(str 必須契約)。""" with pytest.raises(ValidationError): - Project.model_validate( - { - "name": "テスト", - "start_date": "2021-04", - "end_date": None, - "is_current": True, - "technology_stacks": [], - } - ) + build_period("2021-04", None, True) def test_non_it_experience_with_description_and_no_clients() -> None: diff --git a/web/package.json b/web/package.json index 1d52f9c5..4d1ab383 100644 --- a/web/package.json +++ b/web/package.json @@ -7,9 +7,7 @@ "dev": "vite", "build": "node scripts/gen-redirects.mjs && tsc -b && vite build", "preview": "vite build && wrangler pages dev dist --proxy 8080", - "test": "npm run test:unit && npm run test:vitest", - "test:unit": "tsc --target ES2020 --module CommonJS --moduleResolution Node --strict --skipLibCheck --outDir .test-dist --rootDir src src/payloadBuilders.ts && node -e \"require('node:fs').writeFileSync('.test-dist/package.json', '{\\\"type\\\":\\\"commonjs\\\"}')\" && node --test tests/payloadBuilders.test.cjs", - "test:vitest": "vitest run", + "test": "vitest run", "test:coverage": "vitest run --coverage", "test:mutation": "stryker run", "test:e2e": "playwright test", diff --git a/web/tests/payloadBuilders.test.cjs b/web/tests/payloadBuilders.test.cjs deleted file mode 100644 index e8eac463..00000000 --- a/web/tests/payloadBuilders.test.cjs +++ /dev/null @@ -1,163 +0,0 @@ -const assert = require("node:assert/strict"); -const test = require("node:test"); - -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: [ - { - company: " Example株式会社 ", - business_description: " SES事業 ", - start_date: "2020-04", - end_date: "", - is_current: true, - employee_count: " 300名 ", - capital: " 1億円 ", - is_it_company: true, - clients: [ - { - has_client: true, - name: " クライアントA ", - projects: [ - { - name: " プロジェクトA ", - periods: [ - { start_date: "2020-04", end_date: "2021-03", is_current: false } - ], - role: " メンバー ", - description: " 課題・行動・成果テスト ", - team: { - total: " 5 ", - members: [ - { role: "SE", count: "3" }, - { role: "PG", count: "2" }, - { role: " ", count: " " } - ] - }, - phases: ["要件定義", "開発", ""], - technology_stacks: [ - { - category: "framework", - name: " FastAPI " - }, - { - category: "language", - name: " " - } - ] - } - ] - } - ] - } - ], - qualifications: [ - { - acquired_date: "2020-04-01", - name: "応用情報技術者" - }, - { - acquired_date: " ", - name: " " - } - ] - }); - - 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); - assert.equal(payload.experiences[0].is_current, true); - assert.equal(payload.experiences[0].business_description, "SES事業"); - assert.equal(payload.experiences[0].clients.length, 1); - assert.equal(payload.experiences[0].clients[0].name, "クライアントA"); - assert.equal(payload.experiences[0].clients[0].projects.length, 1); - assert.equal(payload.experiences[0].clients[0].projects[0].name, "プロジェクトA"); - assert.equal(payload.experiences[0].clients[0].projects[0].role, "メンバー"); - assert.equal( - payload.experiences[0].clients[0].projects[0].description, - "課題・行動・成果テスト" - ); - assert.deepEqual(payload.experiences[0].clients[0].projects[0].technology_stacks, [ - { - category: "framework", - name: "FastAPI" - } - ]); - assert.equal(payload.qualifications.length, 1); - assert.deepEqual(payload.qualifications[0], { - acquired_date: "2020-04-01", - name: "応用情報技術者" - }); -}); - -test("buildCareerPayload throws when full_name is empty", () => { - assert.throws( - () => - buildCareerPayload({ - full_name: "", - career_summary: "職務要約", - self_pr: "自己PR", - experiences: [], - qualifications: [] - }), - /氏名を入力してください。/ - ); -}); - -test("buildCareerPayload throws when 離職で終了年月がない", () => { - assert.throws( - () => - buildCareerPayload({ - full_name: "山田 太郎", - email: "yamada@example.com", - github_url: "", - career_summary: "職務要約", - self_pr: "自己PR", - experiences: [ - { - company: "Example株式会社", - business_description: "SES事業", - start_date: "2022-04", - end_date: "", - is_current: false, - employee_count: "100名", - capital: "5000万円", - is_it_company: true, - clients: [] - } - ], - qualifications: [] - }), - /職務経歴の離職年月を入力するか、在職を選択してください。/ - ); -}); - -test("buildCareerPayload throws when a 資格 is partially filled", () => { - assert.throws( - () => - buildCareerPayload({ - full_name: "山田 太郎", - email: "yamada@example.com", - github_url: "", - career_summary: "要約", - self_pr: "自己PR", - experiences: [], - qualifications: [ - { - acquired_date: "2020-04-01", - name: "" - } - ] - }), - /資格は取得日と名称を両方入力してください。/ - ); -});