Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` で調整できます。
Expand Down
1 change: 1 addition & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
43 changes: 43 additions & 0 deletions backend/tests/test_pdf_generator.py
Original file line number Diff line number Diff line change
@@ -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
65 changes: 65 additions & 0 deletions backend/tests/test_schemas.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 3 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
197 changes: 12 additions & 185 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<keyof CareerExperienceForm, "technology_stacks">;

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"
Expand Down Expand Up @@ -110,150 +81,6 @@ const blankHistory: RirekishoHistory = {
name: ""
};

function hasAnyText(values: Array<string | null | undefined>): 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<BasicFormState>({
full_name: "",
Expand Down
Loading