Skip to content
Merged

Dev #260

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
8 changes: 6 additions & 2 deletions backend/tests/auth/test_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,12 @@ def test_get_request_skips_csrf_check(client) -> None:


def _auth_failed_records(records: list[logging.LogRecord]) -> list[logging.LogRecord]:
"""auth_failed イベント (devforge ロガー WARNING) のみ抽出する。"""
return [r for r in records if r.name == "devforge" and r.message == "auth_failed"]
"""auth_failed イベント (devforge ロガー WARNING) のみ抽出する。

`LogRecord.message` はフォーマット後にのみ設定される派生属性のため、
常に値を返す `getMessage()` を使う方が堅牢(caplog 等のフォーマッタ設定差異に強い)。
"""
return [r for r in records if r.name == "devforge" and r.getMessage() == "auth_failed"]


def test_auth_failed_logged_when_cookie_missing(client, caplog) -> None:
Expand Down
13 changes: 10 additions & 3 deletions backend/tests/auth/test_oauth_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,15 @@ def test_github_login_url_uses_frontend_origin_when_callback_base_url_unset(clie


def test_github_login_url_uses_callback_base_url_when_set(client) -> None:
"""CALLBACK_BASE_URL が設定されている場合、x-forwarded-host より優先されることを確認する。"""
with patch.dict(os.environ, {"CALLBACK_BASE_URL": "devforge-dev-XXXXX-an.a.run.app"}):
"""CALLBACK_BASE_URL が設定されている場合、x-forwarded-host より優先されることを確認する。

settings.py のドキュメントに従い、scheme 付きの URL(``https://<host>``)を期待値とする。
GitHub OAuth は scheme 付きの redirect_uri しか受け付けないため、現実的な値で検証する。
"""
with patch.dict(
os.environ,
{"CALLBACK_BASE_URL": "https://devforge-dev-XXXXX-an.a.run.app"},
):
response = client.get(
"/auth/github/login-url",
headers={
Expand All @@ -188,7 +195,7 @@ def test_github_login_url_uses_callback_base_url_when_set(client) -> None:
assert response.status_code == 200
parsed = urlparse(response.json()["authorization_url"])
redirect_uri = parse_qs(parsed.query)["redirect_uri"][0]
assert redirect_uri == "devforge-dev-XXXXX-an.a.run.app/github/callback"
assert redirect_uri == "https://devforge-dev-XXXXX-an.a.run.app/github/callback"


def test_github_login_redirect_to_github(client) -> None:
Expand Down
34 changes: 23 additions & 11 deletions backend/tests/test_worker/test_execute_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
_safe_rollback,
execute_task,
)
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from ._helpers import run_sync as _run
Expand Down Expand Up @@ -107,26 +108,37 @@ def test_execute_task_creates_notification_on_success(self, db_session: Session)

class TestSafeRollback:
def test_rollback_after_failed_commit_restores_session(self, db_session: Session):
"""DB commit 失敗後に _safe_rollback を呼ぶと、セッションが再利用可能になること。"""
"""DB commit 失敗で実際にエラー状態に陥ったあと、_safe_rollback で
セッションが再利用可能になること。

`BlogSummaryCache.user_id` の unique 制約に違反させて IntegrityError を起こし、
その後 `_safe_rollback` を呼ぶことで、ロールバックが効いて以降の commit が成功する
という回復経路を実際に踏ませる。元実装は手動 `rollback()` のみで失敗状態を作らず、
テストとして空回りしていた。
"""
user = UserRepository(db_session).create(
"rollback-test-user", hashed_password=None, email="rollback@test.com"
)
cache = BlogSummaryCache(user_id=user.id, status="processing")
db_session.add(cache)
first_cache = BlogSummaryCache(user_id=user.id, status="processing")
db_session.add(first_cache)
db_session.commit()

# コミット失敗をシミュレートしてセッションを PendingRollback 状態にする
db_session.execute.__self__ if hasattr(db_session.execute, "__self__") else None
db_session.rollback() # まず手動でロールバックして dirty 状態を作る
# 同じ user_id で 2 件目を追加 → unique 制約違反で commit が失敗し、
# セッションは「次の操作で PendingRollbackError を投げる」状態になる
duplicate = BlogSummaryCache(user_id=user.id, status="processing")
db_session.add(duplicate)
with pytest.raises(IntegrityError):
db_session.commit()

# _safe_rollback は例外を上げないこと
# _safe_rollback は例外を外に漏らさないこと(dirty な状態でも安全に呼べる)
_safe_rollback(db_session)

# ロールバック後にセッションが再利用可能であること
cache.status = "dead_letter"
# ロールバック後にセッションが再利用可能であること。
# 元の cache に対する更新 commit が通れば回復経路 OK と判断する。
first_cache.status = "dead_letter"
db_session.commit()
db_session.refresh(cache)
assert cache.status == "dead_letter"
db_session.refresh(first_cache)
assert first_cache.status == "dead_letter"

def test_safe_rollback_suppresses_exception(self):
"""rollback() が例外を送出しても _safe_rollback は例外を外に漏らさないこと。"""
Expand Down
42 changes: 19 additions & 23 deletions frontend/src/components/analysis/GitHubAnalysisPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,25 @@ function renderPage() {
return renderWithProviders(<GitHubAnalysisPage />);
}

/**
* `GET /api/intelligence/cache` をキャッシュ未保存(入力画面表示)レスポンスに差し替える。
* 2 箇所でコピペされていた server.use ブロックを集約する。
*/
function mockEmptyCache() {
server.use(
http.get("*/api/intelligence/cache", () =>
HttpResponse.json({
analysis_result: null,
position_advice: null,
status: null,
}),
),
);
}

describe("GitHubAnalysisPage", () => {
it("キャッシュなしの場合、入力画面が表示される", async () => {
server.use(
http.get("*/api/intelligence/cache", () =>
HttpResponse.json({
analysis_result: null,
position_advice: null,
status: null,
}),
),
);
mockEmptyCache();

renderPage();

Expand Down Expand Up @@ -106,14 +114,8 @@ describe("GitHubAnalysisPage", () => {
it("分析開始ボタン押下後、ポーリング画面に遷移する", async () => {
const user = userEvent.setup();

mockEmptyCache();
server.use(
http.get("*/api/intelligence/cache", () =>
HttpResponse.json({
analysis_result: null,
position_advice: null,
status: null,
}),
),
http.get("*/api/intelligence/cache/status", () =>
HttpResponse.json({ status: "pending" }),
),
Expand All @@ -137,14 +139,8 @@ describe("GitHubAnalysisPage", () => {
it("API 500 エラー時にエラーメッセージが表示される", async () => {
const user = userEvent.setup();

mockEmptyCache();
server.use(
http.get("*/api/intelligence/cache", () =>
HttpResponse.json({
analysis_result: null,
position_advice: null,
status: null,
}),
),
http.post("*/api/intelligence/analyze", () =>
HttpResponse.json(
{
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/blog/BlogPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState } from "react";

import { useBlogAccountManager } from "../../hooks/useBlogAccountManager";
import { useBlogAccountManager } from "../../hooks/blog/useBlogAccountManager";
import { BlogScoreCard } from "./BlogScoreCard";
import { BlogAnalysisSection } from "./BlogAnalysisSection";
import { BlogPlatformList } from "./BlogPlatformList";
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/blog/BlogPlatformList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { BlogAccount } from "../../types";
import type { PlatformKey } from "../../hooks/useBlogAccountManager";
import type { PlatformKey } from "../../hooks/blog/useBlogAccountManager";
import { ZennIcon } from "../icons/ZennIcon";
import { NoteIcon } from "../icons/NoteIcon";
import { QiitaIcon } from "../icons/QiitaIcon";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState } from "react";
import type { CareerAnalysisResponse } from "../../api";
import { useCareerAnalysisPage } from "../../hooks/useCareerAnalysisPage";
import { useCareerAnalysisPage } from "../../hooks/career/useCareerAnalysisPage";
import { CareerAnalysisResultView } from "./result/CareerAnalysisResultView";
import { ErrorToast } from "../ui/ErrorToast";
import { InlineSpinner } from "../ui/InlineSpinner";
Expand Down
140 changes: 20 additions & 120 deletions frontend/src/components/forms/CareerResumeForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,14 @@ import { buildCareerPayload } from "../../payloadBuilders";
import type { CareerTextFieldKey } from "../../formTypes";
import { useQualifications, useTechnologyStacks } from "../../hooks/useMasterData";
import { usePdfActions } from "../../hooks/usePdfActions";
import type { ResumeQualification } from "../../types";
import { blankResumeQualification } from "../../constants";
import shared from "../../styles/shared.module.css";
import { ConfirmDialog } from "../ConfirmDialog";
import { Skeleton } from "../ui/Skeleton";
import { Combobox } from "./Combobox";
import { MarkdownTextarea } from "./MarkdownTextarea";
import { PdfPreviewModal } from "./PdfPreviewModal";
import { CareerBasicInfoSection } from "./sections/CareerBasicInfoSection";
import { CareerExperienceSection } from "./sections/CareerExperienceSection";
import { CareerQualificationsSection } from "./sections/CareerQualificationsSection";
import { CareerSelfPrSection } from "./sections/CareerSelfPrSection";

export function CareerResumeForm() {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
Expand Down Expand Up @@ -81,39 +80,6 @@ export function CareerResumeForm() {
setForm((prev) => ({ ...prev, [key]: value }));
};

/** 資格フィールド変更ハンドラ */
const updateQualificationField = (
index: number,
key: keyof ResumeQualification,
value: string,
) => {
setForm((prev) => ({
...prev,
qualifications: prev.qualifications.map((qualification, i) =>
i === index ? { ...qualification, [key]: value } : qualification,
),
}));
};

/** 資格追加ハンドラ */
const addQualification = () => {
setForm((prev) => ({
...prev,
qualifications: [...prev.qualifications, { ...blankResumeQualification }],
}));
};

/** 資格削除ハンドラ */
const removeQualification = (index: number) => {
setForm((prev) => ({
...prev,
qualifications:
prev.qualifications.length === 1
? [{ ...blankResumeQualification }]
: prev.qualifications.filter((_, i) => i !== index),
}));
};

const onSubmit = async (event: FormEvent) => {
event.preventDefault();
await save();
Expand Down Expand Up @@ -184,35 +150,12 @@ export function CareerResumeForm() {
{success && <p className={shared.success}>{success}</p>}

{/* 基本情報: 氏名・職務要約 */}
<section className={shared.section}>
<label>
<span className={shared.labelText}>
氏名<span className={shared.requiredBadge}>必須</span>
</span>
{loading ? (
<Skeleton height="38px" />
) : (
<input
type="text"
value={form.full_name}
onChange={(e) => onChangeField("full_name", e.target.value)}
placeholder="例: 山田 太郎"
required
/>
)}
</label>
{loading ? (
<Skeleton height="110px" />
) : (
<MarkdownTextarea
label="職務要約"
value={form.career_summary}
onChange={(v) => onChangeField("career_summary", v)}
rows={4}
required
/>
)}
</section>
<CareerBasicInfoSection
fullName={form.full_name}
careerSummary={form.career_summary}
loading={loading}
onChange={onChangeField}
/>

{/* 職務経歴セクション */}
{loading ? (
Expand All @@ -234,62 +177,19 @@ export function CareerResumeForm() {
)}

{/* 資格セクション */}
<section className={shared.section}>
<h2>資格</h2>
{loading ? (
<div className={shared.entry}>
<Skeleton height="56px" />
</div>
) : (
<>
{form.qualifications.map((qualification, index) => (
<div key={`qualification-${index}`} className={shared.entry}>
<div className={shared.inline}>
<label>
資格名 ※プルダウンにないものはテキストで入力できます。
<Combobox
value={qualification.name}
onChange={(val) => updateQualificationField(index, "name", val)}
options={qualificationNames}
placeholder="例: 基本情報技術者試験"
allowCustom
/>
</label>
<label>
取得日
<input
type="date"
value={qualification.acquired_date}
onChange={(e) => updateQualificationField(index, "acquired_date", e.target.value)}
/>
</label>
</div>
<button type="button" className="danger" onClick={() => removeQualification(index)}>
資格を削除
</button>
</div>
))}
<button type="button" className="ghost" onClick={addQualification}>
資格を追加
</button>
</>
)}
</section>
<CareerQualificationsSection
qualifications={form.qualifications}
qualificationNames={qualificationNames}
loading={loading}
setForm={setForm}
/>

{/* 自己PR */}
<section className={shared.section}>
{loading ? (
<Skeleton height="110px" />
) : (
<MarkdownTextarea
label="自己PR"
value={form.self_pr}
onChange={(v) => onChangeField("self_pr", v)}
rows={4}
required
/>
)}
</section>
<CareerSelfPrSection
selfPr={form.self_pr}
loading={loading}
onChange={(v) => onChangeField("self_pr", v)}
/>
</div>
</div>
</form>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/forms/ProjectModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
phaseOptions,
teamRoleOptions,
} from "../../constants";
import { useProjectModalForm } from "../../hooks/useProjectModalForm";
import { useProjectModalForm } from "../../hooks/career/useProjectModalForm";
import { Combobox } from "./Combobox";
import { MarkdownTextarea } from "./MarkdownTextarea";
import styles from "./ProjectModal.module.css";
Expand Down
Loading
Loading