From da2c583f0cafac69a8a377fb3b8eda002cb617bf Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Sat, 21 Feb 2026 23:32:36 +0900 Subject: [PATCH] force commit --- README.md | 13 ++ backend/requirements.txt | 1 + backend/tests/test_pdf_generator.py | 43 ++++++ backend/tests/test_schemas.py | 65 ++++++++ frontend/package.json | 4 +- frontend/src/App.tsx | 197 ++---------------------- frontend/src/payloadBuilders.ts | 189 +++++++++++++++++++++++ frontend/tests/payloadBuilders.test.cjs | 132 ++++++++++++++++ 8 files changed, 458 insertions(+), 186 deletions(-) create mode 100644 backend/tests/test_pdf_generator.py create mode 100644 backend/tests/test_schemas.py create mode 100644 frontend/src/payloadBuilders.ts create mode 100644 frontend/tests/payloadBuilders.test.cjs diff --git a/README.md b/README.md index 812d61c1..81ccc28b 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,19 @@ npm run dev ### その他 - `GET /health`: ヘルスチェック +## テスト +### フロントエンド +```bash +cd frontend +npm run test +``` + +### バックエンド +```bash +cd backend +.venv/bin/python -m pytest -q +``` + ## メモ - DBテーブルはFastAPI起動時に自動作成されます。 - CORS許可元は `backend/.env` の `CORS_ORIGINS` で調整できます。 diff --git a/backend/requirements.txt b/backend/requirements.txt index 8f45159e..d788d479 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,4 @@ psycopg2-binary==2.9.10 pydantic==2.10.3 python-dotenv==1.0.1 reportlab==4.2.5 +pytest==8.3.4 diff --git a/backend/tests/test_pdf_generator.py b/backend/tests/test_pdf_generator.py new file mode 100644 index 00000000..67a0e269 --- /dev/null +++ b/backend/tests/test_pdf_generator.py @@ -0,0 +1,43 @@ +from app.services.pdf_generator import _experience_period, build_resume_pdf, build_rirekisho_pdf + + +def test_experience_period_for_current() -> None: + period = _experience_period("2022-04", None, True) + assert period == "2022-04 - 在職" + + +def test_build_resume_pdf_returns_pdf_bytes() -> None: + payload = { + "full_name": "山田 太郎", + "record_date": "2026-02-21", + "qualifications": [{"acquired_date": "2020-04-01", "name": "応用情報技術者"}], + "career_summary": "職務要約テスト", + "self_pr": "自己PRテスト", + "experiences": [], + } + + pdf_bytes = build_resume_pdf(payload) + + assert pdf_bytes.startswith(b"%PDF") + assert len(pdf_bytes) > 100 + + +def test_build_rirekisho_pdf_returns_pdf_bytes() -> None: + payload = { + "full_name": "山田 太郎", + "record_date": "2026-02-21", + "qualifications": [{"acquired_date": "2020-04-01", "name": "応用情報技術者"}], + "postal_code": "150-0001", + "prefecture": "東京都", + "address": "渋谷区", + "email": "test@example.com", + "phone": "09012345678", + "motivation": "志望動機テスト", + "educations": [], + "work_histories": [], + } + + pdf_bytes = build_rirekisho_pdf(payload) + + assert pdf_bytes.startswith(b"%PDF") + assert len(pdf_bytes) > 100 diff --git a/backend/tests/test_schemas.py b/backend/tests/test_schemas.py new file mode 100644 index 00000000..75b52edb --- /dev/null +++ b/backend/tests/test_schemas.py @@ -0,0 +1,65 @@ +import pytest +from pydantic import ValidationError + +from app.schemas import Experience, ResumeCreate + + +def experience_payload() -> dict: + return { + "company": "Example株式会社", + "title": "バックエンドエンジニア", + "start_date": "2021-04", + "end_date": "2024-03", + "is_current": False, + "description": "API開発", + "achievements": "処理速度を改善", + "employee_count": "300名", + "capital": "1億円", + "technology_stacks": [{"category": "言語", "name": "Python"}], + } + + +def test_current_experience_forces_end_date_none() -> None: + payload = experience_payload() + payload["is_current"] = True + payload["end_date"] = "2024-03" + + experience = Experience(**payload) + + assert experience.end_date is None + + +def test_end_date_is_required_when_not_current() -> None: + payload = experience_payload() + payload["is_current"] = False + payload["end_date"] = "" + + with pytest.raises(ValidationError): + Experience(**payload) + + +def test_framework_category_is_accepted() -> None: + payload = experience_payload() + payload["technology_stacks"] = [{"category": "フレームワーク", "name": "FastAPI"}] + + experience = Experience(**payload) + + assert experience.technology_stacks[0].category == "フレームワーク" + + +def test_unknown_category_is_rejected() -> None: + payload = experience_payload() + payload["technology_stacks"] = [{"category": "ミドルウェア", "name": "Nginx"}] + + with pytest.raises(ValidationError): + Experience(**payload) + + +def test_resume_requires_career_summary() -> None: + payload = { + "self_pr": "自己PR", + "experiences": [experience_payload()], + } + + with pytest.raises(ValidationError): + ResumeCreate(**payload) diff --git a/frontend/package.json b/frontend/package.json index 0c3683b3..48050d83 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "npm run test:unit", + "test:unit": "tsc --target ES2020 --module CommonJS --moduleResolution Node --strict --skipLibCheck --outDir .test-dist --rootDir src src/payloadBuilders.ts src/types.ts && node -e \"require('node:fs').writeFileSync('.test-dist/package.json', '{\\\"type\\\":\\\"commonjs\\\"}')\" && node --test tests/payloadBuilders.test.cjs" }, "dependencies": { "react": "^18.3.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a5e9847c..094c9d71 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,60 +11,31 @@ import { updateCareerResume, updateRirekisho } from "./api"; +import { + buildBasicPayload, + buildCareerPayload, + buildRirekishoPayload +} from "./payloadBuilders"; +import type { + BasicFormState, + CareerExperienceForm, + CareerFormState, + RirekishoFormState +} from "./payloadBuilders"; import type { - BasicInfoPayload, BasicQualification, - CareerExperience, CareerTechnologyStack, CareerTechnologyStackCategory, - CareerResumePayload, - RirekishoHistory, - RirekishoPayload + RirekishoHistory } from "./types"; type PageKey = "basic" | "career" | "rirekisho"; -type BasicFormState = { - full_name: string; - record_date: string; - qualifications: BasicQualification[]; -}; - type BasicTextFieldKey = "full_name" | "record_date"; -type CareerExperienceForm = { - company: string; - title: string; - start_date: string; - end_date: string; - is_current: boolean; - description: string; - achievements: string; - employee_count: string; - capital: string; - technology_stacks: CareerTechnologyStack[]; -}; - -type CareerFormState = { - career_summary: string; - self_pr: string; - experiences: CareerExperienceForm[]; -}; - type CareerTextFieldKey = "career_summary" | "self_pr"; type CareerExperienceFieldKey = Exclude; -type RirekishoFormState = { - postal_code: string; - prefecture: string; - address: string; - email: string; - phone: string; - motivation: string; - educations: RirekishoHistory[]; - work_histories: RirekishoHistory[]; -}; - type RirekishoTextFieldKey = | "postal_code" | "prefecture" @@ -110,150 +81,6 @@ const blankHistory: RirekishoHistory = { name: "" }; -function hasAnyText(values: Array): boolean { - return values.some((value) => Boolean(value?.trim())); -} - -function buildBasicPayload(state: BasicFormState): BasicInfoPayload { - const payload: BasicInfoPayload = { - full_name: state.full_name.trim(), - record_date: state.record_date.trim(), - qualifications: state.qualifications - .map((qualification) => ({ - acquired_date: qualification.acquired_date.trim(), - name: qualification.name.trim() - })) - .filter((qualification) => hasAnyText([qualification.acquired_date, qualification.name])) - }; - - if (!payload.full_name || !payload.record_date) { - throw new Error("氏名と記載日は必須です。"); - } - - for (const qualification of payload.qualifications) { - if (!qualification.acquired_date || !qualification.name) { - throw new Error("資格は取得日と名称を両方入力してください。"); - } - } - - return payload; -} - -function buildCareerPayload(state: CareerFormState): CareerResumePayload { - const career_summary = state.career_summary.trim(); - if (!career_summary) { - throw new Error("職務要約を入力してください。"); - } - - const self_pr = state.self_pr.trim(); - if (!self_pr) { - throw new Error("自己PRを入力してください。"); - } - - const experiences: CareerExperience[] = state.experiences - .map((exp) => ({ - company: exp.company.trim(), - title: exp.title.trim(), - start_date: exp.start_date.trim(), - end_date: exp.is_current ? null : exp.end_date.trim(), - is_current: exp.is_current, - description: exp.description.trim(), - achievements: exp.achievements.trim(), - employee_count: exp.employee_count.trim(), - capital: exp.capital.trim(), - technology_stacks: exp.technology_stacks - .map((stack) => ({ - category: stack.category, - name: stack.name.trim() - })) - .filter((stack) => Boolean(stack.name)) - })) - .filter((exp) => - hasAnyText([ - exp.company, - exp.title, - exp.start_date, - exp.end_date ?? "", - exp.description, - exp.achievements, - exp.employee_count, - exp.capital, - ...exp.technology_stacks.map((stack) => stack.name) - ]) - ); - - for (const exp of experiences) { - if ( - !exp.company || - !exp.title || - !exp.start_date || - !exp.description || - !exp.achievements || - !exp.employee_count || - !exp.capital - ) { - throw new Error("職務経歴は入力する場合、必須項目をすべて埋めてください。"); - } - if (!exp.is_current && !exp.end_date) { - throw new Error("職務経歴の離職年月を入力するか、在職を選択してください。"); - } - } - - return { - career_summary, - self_pr, - experiences - }; -} - -function buildRirekishoPayload(state: RirekishoFormState): RirekishoPayload { - const payload: RirekishoPayload = { - postal_code: state.postal_code.trim(), - prefecture: state.prefecture.trim(), - address: state.address.trim(), - email: state.email.trim(), - phone: state.phone.trim(), - motivation: state.motivation.trim(), - educations: state.educations - .map((education) => ({ - date: education.date.trim(), - name: education.name.trim() - })) - .filter((education) => hasAnyText([education.date, education.name])), - work_histories: state.work_histories - .map((workHistory) => ({ - date: workHistory.date.trim(), - name: workHistory.name.trim() - })) - .filter((workHistory) => hasAnyText([workHistory.date, workHistory.name])) - }; - - if ( - !payload.postal_code || - !payload.prefecture || - !payload.address || - !payload.email || - !payload.phone || - !payload.motivation - ) { - throw new Error("郵便番号、都道府県、住所、メールアドレス、電話番号、志望動機は必須です。"); - } - - for (const education of payload.educations) { - if (!education.date || !education.name) { - throw new Error("学歴は日付と名称を両方入力してください。"); - } - } - - for (const workHistory of payload.work_histories) { - if (!workHistory.date || !workHistory.name) { - throw new Error("職歴は日付と名称を両方入力してください。"); - } - } - - return payload; -} - function BasicInfoForm() { const [form, setForm] = useState({ full_name: "", diff --git a/frontend/src/payloadBuilders.ts b/frontend/src/payloadBuilders.ts new file mode 100644 index 00000000..127bc7a0 --- /dev/null +++ b/frontend/src/payloadBuilders.ts @@ -0,0 +1,189 @@ +import type { + BasicInfoPayload, + BasicQualification, + CareerExperience, + CareerResumePayload, + CareerTechnologyStack, + RirekishoHistory, + RirekishoPayload +} from "./types"; + +export type BasicFormState = { + full_name: string; + record_date: string; + qualifications: BasicQualification[]; +}; + +export type CareerExperienceForm = { + company: string; + title: string; + start_date: string; + end_date: string; + is_current: boolean; + description: string; + achievements: string; + employee_count: string; + capital: string; + technology_stacks: CareerTechnologyStack[]; +}; + +export type CareerFormState = { + career_summary: string; + self_pr: string; + experiences: CareerExperienceForm[]; +}; + +export type RirekishoFormState = { + postal_code: string; + prefecture: string; + address: string; + email: string; + phone: string; + motivation: string; + educations: RirekishoHistory[]; + work_histories: RirekishoHistory[]; +}; + +export function hasAnyText(values: Array): boolean { + return values.some((value) => Boolean(value?.trim())); +} + +export function buildBasicPayload(state: BasicFormState): BasicInfoPayload { + const payload: BasicInfoPayload = { + full_name: state.full_name.trim(), + record_date: state.record_date.trim(), + qualifications: state.qualifications + .map((qualification) => ({ + acquired_date: qualification.acquired_date.trim(), + name: qualification.name.trim() + })) + .filter((qualification) => hasAnyText([qualification.acquired_date, qualification.name])) + }; + + if (!payload.full_name || !payload.record_date) { + throw new Error("氏名と記載日は必須です。"); + } + + for (const qualification of payload.qualifications) { + if (!qualification.acquired_date || !qualification.name) { + throw new Error("資格は取得日と名称を両方入力してください。"); + } + } + + return payload; +} + +export function buildCareerPayload(state: CareerFormState): CareerResumePayload { + const career_summary = state.career_summary.trim(); + if (!career_summary) { + throw new Error("職務要約を入力してください。"); + } + + const self_pr = state.self_pr.trim(); + if (!self_pr) { + throw new Error("自己PRを入力してください。"); + } + + const experiences: CareerExperience[] = state.experiences + .map((exp) => ({ + company: exp.company.trim(), + title: exp.title.trim(), + start_date: exp.start_date.trim(), + end_date: exp.is_current ? null : exp.end_date.trim(), + is_current: exp.is_current, + description: exp.description.trim(), + achievements: exp.achievements.trim(), + employee_count: exp.employee_count.trim(), + capital: exp.capital.trim(), + technology_stacks: exp.technology_stacks + .map((stack) => ({ + category: stack.category, + name: stack.name.trim() + })) + .filter((stack) => Boolean(stack.name)) + })) + .filter((exp) => + hasAnyText([ + exp.company, + exp.title, + exp.start_date, + exp.end_date ?? "", + exp.description, + exp.achievements, + exp.employee_count, + exp.capital, + ...exp.technology_stacks.map((stack) => stack.name) + ]) + ); + + for (const exp of experiences) { + if ( + !exp.company || + !exp.title || + !exp.start_date || + !exp.description || + !exp.achievements || + !exp.employee_count || + !exp.capital + ) { + throw new Error("職務経歴は入力する場合、必須項目をすべて埋めてください。"); + } + if (!exp.is_current && !exp.end_date) { + throw new Error("職務経歴の離職年月を入力するか、在職を選択してください。"); + } + } + + return { + career_summary, + self_pr, + experiences + }; +} + +export function buildRirekishoPayload(state: RirekishoFormState): RirekishoPayload { + const payload: RirekishoPayload = { + postal_code: state.postal_code.trim(), + prefecture: state.prefecture.trim(), + address: state.address.trim(), + email: state.email.trim(), + phone: state.phone.trim(), + motivation: state.motivation.trim(), + educations: state.educations + .map((education) => ({ + date: education.date.trim(), + name: education.name.trim() + })) + .filter((education) => hasAnyText([education.date, education.name])), + work_histories: state.work_histories + .map((workHistory) => ({ + date: workHistory.date.trim(), + name: workHistory.name.trim() + })) + .filter((workHistory) => hasAnyText([workHistory.date, workHistory.name])) + }; + + if ( + !payload.postal_code || + !payload.prefecture || + !payload.address || + !payload.email || + !payload.phone || + !payload.motivation + ) { + throw new Error("郵便番号、都道府県、住所、メールアドレス、電話番号、志望動機は必須です。"); + } + + for (const education of payload.educations) { + if (!education.date || !education.name) { + throw new Error("学歴は日付と名称を両方入力してください。"); + } + } + + for (const workHistory of payload.work_histories) { + if (!workHistory.date || !workHistory.name) { + throw new Error("職歴は日付と名称を両方入力してください。"); + } + } + + return payload; +} diff --git a/frontend/tests/payloadBuilders.test.cjs b/frontend/tests/payloadBuilders.test.cjs new file mode 100644 index 00000000..dfa347ed --- /dev/null +++ b/frontend/tests/payloadBuilders.test.cjs @@ -0,0 +1,132 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { + buildBasicPayload, + buildCareerPayload, + buildRirekishoPayload +} = require("../.test-dist/payloadBuilders.js"); + +test("buildBasicPayload trims values and excludes empty資格", () => { + const payload = buildBasicPayload({ + full_name: " 山田 太郎 ", + record_date: "2026-02-21", + qualifications: [ + { + acquired_date: "2020-04-01", + name: "応用情報技術者" + }, + { + acquired_date: " ", + name: " " + } + ] + }); + + assert.equal(payload.full_name, "山田 太郎"); + assert.equal(payload.qualifications.length, 1); + assert.deepEqual(payload.qualifications[0], { + acquired_date: "2020-04-01", + name: "応用情報技術者" + }); +}); + +test("buildBasicPayload throws when a 資格 is partially filled", () => { + assert.throws( + () => + buildBasicPayload({ + full_name: "山田 太郎", + record_date: "2026-02-21", + qualifications: [ + { + acquired_date: "2020-04-01", + name: "" + } + ] + }), + /資格は取得日と名称を両方入力してください。/ + ); +}); + +test("buildCareerPayload trims data and keeps only non-empty technology stacks", () => { + const payload = buildCareerPayload({ + career_summary: " 職務要約テスト ", + self_pr: " 自己PRテスト ", + experiences: [ + { + company: " Example株式会社 ", + title: " バックエンドエンジニア ", + start_date: "2020-04", + end_date: "", + is_current: true, + description: " API開発 ", + achievements: " パフォーマンス改善 ", + employee_count: " 300名 ", + capital: " 1億円 ", + technology_stacks: [ + { + category: "フレームワーク", + name: " FastAPI " + }, + { + category: "言語", + name: " " + } + ] + } + ] + }); + + 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.deepEqual(payload.experiences[0].technology_stacks, [ + { + category: "フレームワーク", + name: "FastAPI" + } + ]); +}); + +test("buildCareerPayload throws when 離職で終了年月がない", () => { + assert.throws( + () => + buildCareerPayload({ + career_summary: "職務要約", + self_pr: "自己PR", + experiences: [ + { + company: "Example株式会社", + title: "エンジニア", + start_date: "2022-04", + end_date: "", + is_current: false, + description: "開発", + achievements: "改善", + employee_count: "100名", + capital: "5000万円", + technology_stacks: [] + } + ] + }), + /職務経歴の離職年月を入力するか、在職を選択してください。/ + ); +}); + +test("buildRirekishoPayload throws when required fields are empty", () => { + assert.throws( + () => + buildRirekishoPayload({ + postal_code: "", + prefecture: "東京都", + address: "渋谷区", + email: "test@example.com", + phone: "09012345678", + motivation: "志望動機", + educations: [], + work_histories: [] + }), + /郵便番号、都道府県、住所、メールアドレス、電話番号、志望動機は必須です。/ + ); +});