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
6 changes: 6 additions & 0 deletions .claude/rules/common/duplication.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ PR 前に 1 度走らせて、新規重複が増えていないか確認する
- `minTokens: 50` / `minLines: 5` — これ未満の小片は無視
- `threshold: 0` — Phase 1 は fail させない(baseline 確定後に引き上げ)

### symlink 採用に伴う計測の前提(infra)

`infra/environments/{dev,stg,prod}/{variables,moved,outputs}.tf` は `../shared/<file>.tf` への symlink で 1 ファイルに物理統合してある。jscpd は symlink 経由で同一ファイルを 3 回スキャンすると 100% 重複として誤検出するため、`.jscpd.json` の `ignore` で 9 件(3 環境 × 3 ファイル)を明示除外している。

そのため `make dupe-check` の HCL 重複率は「真の重複ゼロ」ではなく「symlink 除外後の重複ゼロ」を意味する。`infra/environments/shared/` 配下の正本だけがスキャン対象になっている前提で読むこと。新規に環境別 symlink を増やす場合は同様に `.jscpd.json` に追記する。

### AI レビュー (refacter skill)

- 領域内: `BE_refacter` / `FE_refacter` / `INFRA_refacter`
Expand Down
5 changes: 5 additions & 0 deletions .claude/rules/infra/opentofu.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,8 @@ DB は Turso (libSQL) を使用。Terraform 対象外で `turso CLI` 手動管

- 重複検知 / DRY ポリシーは `.claude/rules/common/duplication.md` を参照
- `environments/{dev,stg,prod}` で同じ resource block をコピペしている場合は `modules/` 化を検討する(環境差分は `variable` で吸収)
- `environments/{dev,stg,prod}/{variables,moved,outputs}.tf` は `../shared/<file>.tf` への symlink で物理統合済み。新規ファイルを 3 環境で揃える場合も同じパターンで shared 化し、`.jscpd.json` の ignore に追記する

## monitoring の責務分割

`infra/modules/monitoring/` は責務別にファイル分割している(`notification_channels.tf` / `uptime.tf` / `auth_failures.tf` / `rate_limits.tf` / `task_failures.tf`)。**新規 alert を追加するときは既存ファイルに混ぜず、責務に対応するファイルへ追加するか、新しい責務であれば `monitoring/<新規責務>.tf` を新設する**。1 ファイルに alert を集約すると「監視増→ファイル肥大→責務不明瞭」が再発する。
3 changes: 0 additions & 3 deletions backend/app/schemas/career_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@

from pydantic import BaseModel, ConfigDict, Field

# 互換のため再エクスポート。新規実装は schemas.shared から import すること。
from .shared import TaskStatusResponse # noqa: F401


class TechStackItem(BaseModel):
"""技術スタック1件。"""
Expand Down
46 changes: 20 additions & 26 deletions backend/app/services/pdf/generators/intelligence_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,24 @@ def _add_page_number(canvas_obj, doc):
canvas_obj.restoreState()


def _table_style(
header_range: tuple[tuple[int, int], tuple[int, int]],
valign: str,
) -> TableStyle:
"""グリッド・ヘッダ背景・パディングをまとめた共通 TableStyle を返す。"""
return TableStyle(
[
("GRID", (0, 0), (-1, -1), 0.5, TABLE_BORDER),
("BACKGROUND", header_range[0], header_range[1], HEADER_BG),
("VALIGN", (0, 0), (-1, -1), valign),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
("LEFTPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
]
)


def build_intelligence_pdf(payload: dict) -> bytes:
buffer = BytesIO()
doc = SimpleDocTemplate(
Expand Down Expand Up @@ -68,19 +86,7 @@ def build_intelligence_pdf(payload: dict) -> bytes:
[Paragraph("<b>分析日時</b>", s["body"]), Paragraph(analyzed_at, s["body"])],
]
overview_table = Table(overview_data, colWidths=[40 * mm, content_width - 40 * mm])
overview_table.setStyle(
TableStyle(
[
("GRID", (0, 0), (-1, -1), 0.5, TABLE_BORDER),
("BACKGROUND", (0, 0), (0, -1), HEADER_BG),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
("LEFTPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
]
)
)
overview_table.setStyle(_table_style(((0, 0), (0, -1)), "MIDDLE"))
elements.append(overview_table)
elements.append(Spacer(1, 4 * mm))

Expand Down Expand Up @@ -138,19 +144,7 @@ def build_intelligence_pdf(payload: dict) -> bytes:
]
)
role_table = Table(role_data, colWidths=[45 * mm, 20 * mm, content_width - 65 * mm])
role_table.setStyle(
TableStyle(
[
("GRID", (0, 0), (-1, -1), 0.5, TABLE_BORDER),
("BACKGROUND", (0, 0), (-1, 0), HEADER_BG),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
("LEFTPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
]
)
)
role_table.setStyle(_table_style(((0, 0), (-1, 0)), "TOP"))
elements.append(role_table)
elements.append(Spacer(1, 2 * mm))

Expand Down
98 changes: 42 additions & 56 deletions backend/tests/test_blog_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,40 @@ def _run(coro):
loop.close()


def _mock_async_client_returning_json(api_json: dict | list) -> AsyncMock:
"""raise_for_status() を通り json() で `api_json` を返す AsyncClient モックを生成する。"""
mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock()
mock_resp.json = MagicMock(return_value=api_json)

mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
return mock_client


def _mock_async_client_with_status(status_code: int) -> AsyncMock:
"""get() が status_code だけを持つレスポンスを返す AsyncClient モックを生成する。"""
mock_resp = MagicMock()
mock_resp.status_code = status_code

mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
return mock_client


def _mock_async_client_raising(exc: BaseException) -> AsyncMock:
"""get() が `exc` を送出する AsyncClient モックを生成する。"""
mock_client = AsyncMock()
mock_client.get = AsyncMock(side_effect=exc)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
return mock_client


# ── fetch_note_articles テスト ───────────────────────────────────────────


Expand Down Expand Up @@ -68,15 +102,7 @@ def test_fetch_note_articles_no_hashtags_returns_empty_tags() -> None:
}
]
)

mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock()
mock_resp.json = MagicMock(return_value=api_json)

mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client = _mock_async_client_returning_json(api_json)

with patch("app.services.blog.collector.httpx.AsyncClient", return_value=mock_client):
articles = _run(fetch_note_articles("user"))
Expand Down Expand Up @@ -105,15 +131,7 @@ def test_fetch_note_articles_with_hashtags() -> None:
}
]
)

mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock()
mock_resp.json = MagicMock(return_value=api_json)

mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client = _mock_async_client_returning_json(api_json)

with patch("app.services.blog.collector.httpx.AsyncClient", return_value=mock_client):
articles = _run(fetch_note_articles("user"))
Expand All @@ -137,15 +155,7 @@ def test_fetch_qiita_articles_success() -> None:
"tags": [{"name": "TypeScript"}, {"name": "React"}],
}
]

mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock()
mock_resp.json = MagicMock(return_value=api_json)

mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client = _mock_async_client_returning_json(api_json)

with patch("app.services.blog.collector.httpx.AsyncClient", return_value=mock_client):
articles = _run(fetch_qiita_articles("user"))
Expand All @@ -164,10 +174,7 @@ def test_fetch_qiita_articles_success() -> None:

def test_fetch_zenn_articles_timeout_raises() -> None:
"""httpx.TimeoutException 発生時は例外が伝播すること。"""
mock_client = AsyncMock()
mock_client.get = AsyncMock(side_effect=httpx.TimeoutException("timeout"))
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client = _mock_async_client_raising(httpx.TimeoutException("timeout"))

with patch("app.services.blog.collector.httpx.AsyncClient", return_value=mock_client):
with pytest.raises(httpx.TimeoutException):
Expand All @@ -185,13 +192,7 @@ def test_verify_user_exists_unsupported_platform_raises_error() -> None:

def test_verify_user_exists_zenn_user_found() -> None:
"""Zenn ユーザーが存在する場合は True を返すこと。"""
mock_resp = MagicMock()
mock_resp.status_code = 200

mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client = _mock_async_client_with_status(200)

with patch("app.services.blog.collector.httpx.AsyncClient", return_value=mock_client):
result = _run(verify_user_exists("zenn", "testuser"))
Expand All @@ -201,13 +202,7 @@ def test_verify_user_exists_zenn_user_found() -> None:

def test_verify_user_exists_normalizes_before_request() -> None:
"""URL 入力でも正規化された username で存在確認すること。"""
mock_resp = MagicMock()
mock_resp.status_code = 200

mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client = _mock_async_client_with_status(200)

with patch("app.services.blog.collector.httpx.AsyncClient", return_value=mock_client):
result = _run(verify_user_exists("zenn", "https://zenn.dev/testuser/articles/test"))
Expand All @@ -218,13 +213,7 @@ def test_verify_user_exists_normalizes_before_request() -> None:

def test_verify_user_exists_zenn_user_not_found() -> None:
"""Zenn ユーザーが存在しない場合は False を返すこと。"""
mock_resp = MagicMock()
mock_resp.status_code = 404

mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client = _mock_async_client_with_status(404)

with patch("app.services.blog.collector.httpx.AsyncClient", return_value=mock_client):
result = _run(verify_user_exists("zenn", "nonexistent"))
Expand All @@ -239,10 +228,7 @@ def test_verify_user_exists_invalid_url_returns_false() -> None:

def test_verify_user_exists_timeout_raises_blog_platform_request_error() -> None:
"""接続タイムアウト時は BlogPlatformRequestError が送出されること。"""
mock_client = AsyncMock()
mock_client.get = AsyncMock(side_effect=httpx.TimeoutException("timeout"))
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client = _mock_async_client_raising(httpx.TimeoutException("timeout"))

with patch("app.services.blog.collector.httpx.AsyncClient", return_value=mock_client):
with pytest.raises(BlogPlatformRequestError):
Expand Down
1 change: 1 addition & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ REST API エンドポイント一覧と、バックエンド/フロントエ
| `APP_VERSION` | アプリケーションバージョン(ログ・メトリクス用) |
| `LOG_LEVEL` | ログレベル(`DEBUG` / `INFO` / `WARNING` / `ERROR`) |
| `LOG_FORMAT` | ログフォーマット(`json` / `text`) |
| `APP_BOOTSTRAPPED` | `1` 指定で起動時 bootstrap(DB / 鍵検証)をスキップ。マイグレーションを別途流す環境で使用 |

### フロントエンド

Expand Down
6 changes: 3 additions & 3 deletions frontend/src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { PATHS } from "./paths";
type AuthResponse = { username: string; is_github_user: boolean };

export async function getCurrentUser(): Promise<AuthResponse | null> {
const response = await fetch(`${API_BASE_URL}/auth/me`, {
const response = await fetch(`${API_BASE_URL}${PATHS.auth.me}`, {
credentials: "include",
});
if (response.status === 401) {
Expand All @@ -31,7 +31,7 @@ export const GITHUB_OAUTH_STATE_STORAGE_KEY = "github_oauth_state";
* state は sessionStorage で管理し、コールバック時に CSRF 検証する。 */
export async function initiateGitHubLogin(returnTo: string): Promise<void> {
const params = new URLSearchParams({ return_to: returnTo });
const response = await fetch(`${API_BASE_URL}/auth/github/login-url?${params.toString()}`, {
const response = await fetch(`${API_BASE_URL}${PATHS.auth.githubLoginUrl}?${params.toString()}`, {
credentials: "include",
});
if (!response.ok) throw new Error("GitHub OAuth の開始に失敗しました");
Expand All @@ -44,7 +44,7 @@ export async function initiateGitHubLogin(returnTo: string): Promise<void> {
/** サーバー側で refresh_jti を無効化し Cookie を削除する。
* 401 ハンドラのループを避けるため request ラッパーではなく fetch を直接使う。 */
export async function logout(): Promise<void> {
await fetch(`${API_BASE_URL}/auth/logout`, {
await fetch(`${API_BASE_URL}${PATHS.auth.logout}`, {
method: "POST",
credentials: "include",
});
Expand Down
31 changes: 31 additions & 0 deletions frontend/src/api/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,37 @@ describe("api/client request", () => {
expect(refreshCallCount).toBe(1);
});

/** 並行 401 でリフレッシュが失敗した場合、両方の元リクエストがエラーで rejected になること */
it("複数の同時 401 はリフレッシュ失敗時に 1 回のリフレッシュを共有して両方失敗する", async () => {
let refreshCallCount = 0;
let resolveRefresh!: (response: Response) => void;
const refreshPromise = new Promise<Response>((resolve) => {
resolveRefresh = resolve;
});

const fetchMock = vi.fn().mockImplementation((url: string) => {
if ((url as string).includes("/auth/refresh")) {
refreshCallCount++;
return refreshPromise;
}
return Promise.resolve(makeResponse(401));
});

vi.stubGlobal("fetch", fetchMock);

const req1 = request("/api/test1");
const req2 = request("/api/test2");

await Promise.resolve();
expect(refreshCallCount).toBe(1);

resolveRefresh(makeResponse(401));

await expect(req1).rejects.toThrow("認証が必要です");
await expect(req2).rejects.toThrow("認証が必要です");
expect(refreshCallCount).toBe(1);
});

/** 500 系のレスポンスでエラーがスローされること */
it("500 レスポンスの場合エラーがスローされる", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(makeResponse(500)));
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/api/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
export const PATHS = {
auth: {
githubCallback: "/auth/github/callback",
me: "/auth/me",
githubLoginUrl: "/auth/github/login-url",
logout: "/auth/logout",
},
resumes: {
base: "/api/resumes",
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/blog/BlogPlatformList.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { Dispatch, SetStateAction } from "react";

import type { BlogAccount } from "../../types";
import type { PlatformKey } from "../../hooks/blog/useBlogAccountManager";
import { ZennIcon } from "../icons/ZennIcon";
Expand Down Expand Up @@ -30,7 +32,7 @@ const PLATFORMS = [
type BlogPlatformListProps = {
accountMap: Map<string, BlogAccount>;
draftUsernames: Record<string, string>;
setDraftUsernames: React.Dispatch<React.SetStateAction<Record<string, string>>>;
setDraftUsernames: Dispatch<SetStateAction<Record<string, string>>>;
savingPlatform: string | null;
syncingPlatform: string | null;
onSave: (platform: PlatformKey) => Promise<void>;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useMemo } from "react";
import type { Dispatch, SetStateAction } from "react";

import type { CareerExperienceForm, CareerFormState, CareerProjectForm } from "../../../payloadBuilders";
import type { TechStackMasterItem } from "../../../types";
Expand All @@ -13,7 +14,7 @@ type CareerExperienceSectionProps = {
/** 職務経歴データの配列 */
experiences: CareerExperienceForm[];
/** フォーム状態更新ディスパッチャ */
setForm: React.Dispatch<React.SetStateAction<CareerFormState>>;
setForm: Dispatch<SetStateAction<CareerFormState>>;
/** 技術スタックのマスタデータ */
techStackOptions: TechStackMasterItem[];
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Dispatch, SetStateAction } from "react";
import { blankResumeQualification } from "../../../constants";
import type { CareerFormState } from "../../../payloadBuilders";
import type { ResumeQualification } from "../../../types";
Expand All @@ -14,7 +15,7 @@ type Props = {
/** ローディング中(Skeleton 表示) */
loading: boolean;
/** フォーム状態更新ディスパッチャ */
setForm: React.Dispatch<React.SetStateAction<CareerFormState>>;
setForm: Dispatch<SetStateAction<CareerFormState>>;
};

/**
Expand Down
Loading
Loading