diff --git a/.coderabbit.yaml b/.coderabbit.yaml index fa971a10..0ff54bcb 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -2,13 +2,21 @@ # https://docs.coderabbit.ai/guides/configure-coderabbit # # 自動レビューの対象とする base ブランチを指定する。 -# default branch (main) は常に含まれるため、ここでは追加ブランチのみ指定する。 -# Configure the base branches for auto review. The default branch (main) -# is always included, so only list additional branches here. +# default branch は常に含まれるため、ここでは追加ブランチのみ指定する。 +# Configure the base branches for auto review. The default branch is always +# included, so only list additional branches here. +# +# 移行期間メモ: default branch を main → develop へ切り替える作業(#734)と +# この設定変更は非同期に行われるため、切替完了までは main / develop の両方を +# 明示しておく。切替完了後に `develop` を削除するクリーンアップ PR を出す。 +# Transition note: the default-branch switch (#734) is handled outside this +# repo. Keep both `main` and `develop` listed until the switch is complete, +# then drop `develop` (it will be covered implicitly as the default branch). reviews: auto_review: enabled: true base_branches: + - "main" - "develop" - "release/.*" - "feature/.*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf9c3f7a..cee625da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,6 +98,26 @@ jobs: - run: bun run test:coverage + # Workspace package tests are not picked up by the root vitest config, + # so run their dedicated configs explicitly. Keep this list in sync with + # the `test:run` script in root package.json. server/mcp has its own job + # (`mcp-test`); server/hocuspocus is invoked only via `test:run` and not + # duplicated here. + # ルートの vitest 設定はワークスペース配下のテストを含まないため、 + # 各パッケージの vitest.config を明示的に実行する。server/mcp は別 job + # (`mcp-test`)で動かすためここには含めない。 + - name: Run admin tests + run: bunx vitest run --config admin/vitest.config.ts + + - name: Run @zedi/shared tests + run: bunx vitest run --config packages/shared/vitest.config.ts + + - name: Run @zedi/ui tests + run: bunx vitest run --config packages/ui/vitest.config.ts + + - name: Run @zedi/claude-sidecar tests + run: bunx vitest run --config packages/claude-sidecar/vitest.config.ts + - name: Upload coverage report if: always() uses: actions/upload-artifact@v7 @@ -167,6 +187,36 @@ jobs: working-directory: server/api run: bunx tsc --noEmit + drizzle-migration-check: + name: Drizzle Migration Check + # Drizzle TS スキーマを変更したら必ずマイグレーション SQL を追加するルールの強制。 + # PR #728 のように TS スキーマだけ更新して `server/api/drizzle/*.sql` を忘れると、 + # 本番 DB がスキーマに追いつかず 500 エラーになるため、PR 段階で検出する。 + # PR 限定(push にはマージベースの計算対象が無い)。 + # + # Enforce: any change under `server/api/src/schema/**` must come with a new + # migration SQL file and an updated `_journal.json`. Detects PR #728-style + # regressions where production drifts from the application schema. + if: github.event_name == 'pull_request' && !github.event.pull_request.draft + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + with: + # PR ベースとの diff を取るため履歴を全部取得する。 + # Need full history so the script can diff against the PR base. + fetch-depth: 0 + + - uses: actions/setup-node@v6 + with: + node-version-file: ".nvmrc" + + - name: Run drizzle migration consistency check + env: + DRIZZLE_DIFF_BASE: origin/${{ github.base_ref }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + run: node scripts/check-drizzle-migrations.mjs + mcp-test: name: MCP Server Tests if: github.event_name != 'pull_request' || !github.event.pull_request.draft @@ -219,8 +269,15 @@ jobs: - run: bun install --frozen-lockfile + # Golden list of files with stable mutation scores (Phase 1/2 of Epic #468). + # 安定したスコアを持つファイルを段階的に追加し、PR で退行を早めに検知する。 + # Local scores measured 2026-04-26 with `stryker.config.mutation-changed.mjs`: + # dateUtils.ts 94.03% / useContainerColumns.ts 86.67% + # noteViewHelpers.ts 100.00% / aiChatConversationTitle.ts 100.00% + # aiCostUtils.ts 91.18% / encryption.ts 96.43% + # mcpServerImportHelpers.ts 100.00% / onboardingState.ts 83.82% - name: Run mutation tests (limited scope) - run: bun run test:mutation -- --mutate "src/lib/dateUtils.ts,src/hooks/useContainerColumns.ts,src/pages/NoteView/noteViewHelpers.ts" + run: bun run test:mutation -- --mutate "src/lib/dateUtils.ts,src/hooks/useContainerColumns.ts,src/pages/NoteView/noteViewHelpers.ts,src/lib/aiChatConversationTitle.ts,src/lib/aiCostUtils.ts,src/lib/encryption.ts,src/lib/mcpServerImportHelpers.ts,src/lib/onboardingState.ts" - name: Upload mutation report if: always() diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index f42cbaeb..aa31948b 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -1,9 +1,19 @@ # Claude Code Review - main/develop 向け PR で自動実行 +# Claude Code Review - runs automatically on PRs targeting main / develop. # # 初回セットアップ時の注意: -# OIDC 検証のため、ワークフローはデフォルトブランチ(main)に存在し同一内容である必要があります。 -# 初回の PR では Claude Code Review が失敗しますが、マージ後・develop→main リリース後に -# 以降の PR で正常動作します。continue-on-error により初回失敗時もマージをブロックしません。 +# OIDC 検証のため、ワークフローは「現在のデフォルトブランチ」に存在し、 +# 対象ブランチ側にも同一内容である必要があります。 +# 初回の PR では Claude Code Review が失敗する場合がありますが、 +# 必要な反映後の PR で正常動作します。 +# continue-on-error により初回失敗時もマージをブロックしません。 +# +# Initial setup note: +# For OIDC verification, this workflow must exist on the repository's current +# default branch and its contents must match the workflow on the target branch. +# The first PR after a default-branch change may fail, but subsequent PRs work +# normally once the workflow is propagated. `continue-on-error: true` ensures +# that initial failures do not block merges. # ref: https://github.com/anthropics/claude-code-action/issues/722 # name: Claude Code Review diff --git a/AGENTS.md b/AGENTS.md index c7fd7b42..53825d46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,22 @@ bun run test:run # Vitest 単体テスト - 既存のディレクトリ構成・命名規則に合わせる。 - Conventional Commits 形式でコミット(`feat:`, `fix:`, `docs:` 等)。 +## DB スキーマ変更(必読) / Database schema changes (must read) + +- **TS スキーマと SQL マイグレーションは常に対で更新する**。`server/api/src/schema/**/*.ts` を編集したら、必ず `server/api/drizzle/NNNN_*.sql` を新規追加し、`server/api/drizzle/meta/_journal.json` にエントリを追記する。 + Always pair TS schema edits with a SQL migration: add a new `server/api/drizzle/NNNN_*.sql` and append an entry to `server/api/drizzle/meta/_journal.json`. Skipping this caused production 500s in PR #728 on `/api/onboarding/status` and `/api/pages`. +- **正本のマイグレーション置き場は `server/api/drizzle/` のみ**。CI (`deploy-{dev,prod}.yml`) は `bunx drizzle-kit migrate` だけを実行するため、ここ以外に SQL を置いても本番には適用されない。 + _Source of truth is `server/api/drizzle/`. CI runs only `bunx drizzle-kit migrate`; SQL placed elsewhere is dead code._ +- **マイグレーションの書き方**: + - 既存の手書き例(`0017_add_link_type.sql` など)の体裁に合わせ、ステートメント間に `--> statement-breakpoint` を入れる。 + - 既存環境で重複適用されても安全になるよう、原則として `IF NOT EXISTS` / `ON CONFLICT DO NOTHING` を使う。 + - 必要であればバックフィル(既存行への初期値投入)も同じファイル内で行う。 + - `bunx drizzle-kit generate` で雛形を作るときは、過去スナップショットが欠落しているため巨大な diff が出ることがある。その場合は `--name` 指定の出力を手で削減し、既存マイグレーション間で重複しない形に整えてから commit する(snapshot ファイルは生成物のみ、当面コミットしない方針)。 +- **CI ガード**: `.github/workflows/ci.yml` の `drizzle-migration-check` ジョブが PR で `server/api/src/schema/**` の変更と新規 `server/api/drizzle/*.sql` がペアになっているかを検証する。例外的に SQL 不要な場合(コメント/JSDoc 修正のみなど)は PR 本文かコミットメッセージに `[skip drizzle-check]` を入れる。 + _CI guard `drizzle-migration-check` enforces the schema/migration pairing. Use the `[skip drizzle-check]` marker only for non-DDL edits (comments, JSDoc, type aliases that do not affect SQL)._ +- **環境別の自動適用**: `develop` への push → `deploy-dev.yml` が development DB へ migrate。`main` への push → `deploy-prod.yml` が production DB へ migrate。スキーマ追従はこの 2 本だけ。 + _Auto-apply: push to `develop` migrates dev DB; push to `main` migrates prod DB. No other path applies migrations._ + ## ブランチ・PR の命名規則 - **ブランチ**: `feature/説明`、`fix/説明`、`hotfix/説明`、`chore/説明` など(例: `feature/ai-models-ui`, `fix/search-crash`)。Issue 番号から作る場合は `feature/123`。 @@ -93,6 +109,17 @@ terraform/ # インフラ定義 - ルート `package.json` の `workspaces` は `packages/*` と `admin` のみを含む。`server/api`, `server/hocuspocus`, `server/mcp` は **意図的にルートの Bun workspace から外して**、個別の Bun プロジェクトとして管理する。 _Root `workspaces` covers only `packages/*` and `admin`. The three `server/*` services (`api`, `hocuspocus`, `mcp`) are intentionally kept **outside** the root Bun workspace and managed as standalone Bun projects._ + +### サーバ/クライアント間で共有する定数 / Sharing constants between server and client + +- `packages/shared`(`@zedi/shared`)は、フロント・admin・サーバすべてで共通利用したいピュアな TypeScript 定数を集約するためのワークスペースパッケージ。React や Node 専用 API には依存させない。 + _`packages/shared` (`@zedi/shared`) is a workspace package for pure TypeScript constants shared by client, admin, and (logically) server code. Keep it free of React or Node-only dependencies._ +- フロント (`src/`) と `admin/` はワークスペース内なので `import { ... } from "@zedi/shared/..."` で直接利用できる。 + _Workspace consumers (`src/`, `admin/`) import via `@zedi/shared/...`._ +- `server/api` 等のサーバプロジェクトはワークスペース外なので `@zedi/shared` を **直接 import できない**。代わりに同じ値を当該サーバ内に二重定義し、フロント側の vitest が `fs.readFileSync` でサーバファイルを読んで両者の文字列一致を検証するドリフト検知テスト(例: `src/lib/tagCharacterClassSync.test.ts`)を置くことで CI で同期を担保する。 + _Server projects (e.g. `server/api`) cannot import `@zedi/shared` because they are intentionally outside the workspace. Duplicate the constant inside the server source and add a client-side vitest (e.g. `src/lib/tagCharacterClassSync.test.ts`) that reads the server file via `fs.readFileSync` and asserts the two literals match. This keeps drift detectable in CI._ +- 値を更新する際は **`packages/shared` とサーバ側コピーを同時に編集すること**。ドリフト検知テストが落ちたら、片方しか変更していないサインなのでもう一方も追従させる。 + _When updating a shared value, edit `packages/shared` and the server-side copy together. If the drift test fails, the change touched only one side; sync the other._ - 理由 / Rationale: - Railway の Dockerfile ビルドは「各サービスの Root Directory」を build context に取る (例: `server/mcp`)。ここからルート `bun.lock` を参照するのは面倒で、context をサービス単位に閉じるほうが再現性が高い。 _Railway Dockerfile builds take each service's Root Directory as the build context. Scoping `bun.lock` per service keeps the build self-contained and reproducible._ diff --git a/CLAUDE.md b/CLAUDE.md index eb2f4088..5ffa64e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,11 @@ - エラーハンドリングとログが適切か。 - 日本語・英語のコメント・ドキュメントがプロジェクトのトーンに合っているか。 +## DB スキーマ変更 + +- TS スキーマ (`server/api/src/schema/**`) を変更した PR では必ず `server/api/drizzle/NNNN_*.sql` を新規追加し、`server/api/drizzle/meta/_journal.json` にもエントリを追記する。詳細は [AGENTS.md §「DB スキーマ変更」](./AGENTS.md#db-スキーマ変更必読--database-schema-changes-must-read) を参照。 +- CI の `drizzle-migration-check` ジョブが PR でスキーマ変更と SQL 追加のペアを強制する。 + ## その他 - 変更が大きい場合は小さな PR に分けることを推奨する。 diff --git a/README.md b/README.md index 898d6da7..53ceea84 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ VITE_REALTIME_URL=ws://localhost:1234 # 本番は wss://realtime.zedi-note.app | **Visualization** | Recharts / `@xyflow/react` (React Flow) / Mermaid / KaTeX / Tesseract.js (OCR) | | **Auth** | [Better Auth](https://better-auth.com/) (OAuth / セッション cookie) | | **API** | `server/api` — Hono on Bun + Drizzle ORM (PostgreSQL) | -| **Database** | PostgreSQL (Drizzle migrations: `db/migrations`, `server/api/drizzle`) / IndexedDB (local・ブラウザ) | +| **Database** | PostgreSQL (Drizzle migrations: `server/api/drizzle/`) / IndexedDB (local・ブラウザ) | | **Realtime** | `server/hocuspocus` — Hocuspocus (Y.js) によるリアルタイム共同編集 | | **MCP** | `server/mcp` — Claude Code 連携(stdio / HTTP、詳細は [server/mcp/README.md](server/mcp/README.md)) | | **Storage** | AWS S3(API 経由でアップロード、`@aws-sdk/client-s3`) | @@ -337,7 +337,7 @@ packages/ # Bun workspaces(共有ライブラリ) admin/ # 管理画面アプリ(別 Vite + React + Tailwind / `@zedi/ui` 利用) extension/ # ブラウザ拡張(Manifest v3、Web Clipper) -db/migrations/ # PostgreSQL マイグレーション SQL +server/api/drizzle/ # PostgreSQL マイグレーション(drizzle-kit が読む正本 / source of truth) terraform/cloudflare/ # Cloudflare 関連インフラ定義 e2e/ # Playwright E2E テスト scripts/ # セットアップ / sidecar ビルド / Stryker / 拡張ビルド等のスクリプト diff --git a/admin/src/api/activity.test.ts b/admin/src/api/activity.test.ts new file mode 100644 index 00000000..c9508702 --- /dev/null +++ b/admin/src/api/activity.test.ts @@ -0,0 +1,118 @@ +/** + * activity API クライアントのテスト(adminFetch をモック)。 + * Tests for the activity API client (adminFetch mocked). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { listActivity } from "./activity"; + +vi.mock("./client", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + adminFetch: vi.fn(), + }; +}); + +const { adminFetch } = await import("./client"); + +describe("listActivity", () => { + beforeEach(() => { + vi.mocked(adminFetch).mockReset(); + }); + + it("パラメータ無しなら /api/activity を呼ぶ / hits /api/activity without params", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ entries: [], total: 0, limit: 50 }), { status: 200 }), + ); + await listActivity(); + expect(adminFetch).toHaveBeenCalledWith("/api/activity"); + }); + + it("kind / actor / from / to / limit / offset を querystring に詰める / packs kind/actor/from/to/limit/offset into querystring", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ entries: [], total: 0, limit: 10 }), { status: 200 }), + ); + + await listActivity({ + kind: "lint_run", + actor: "ai", + from: "2026-01-01T00:00:00Z", + to: "2026-02-01T00:00:00Z", + limit: 10, + offset: 20, + }); + + const calledWith = vi.mocked(adminFetch).mock.calls[0]?.[0]; + expect(calledWith).toContain("/api/activity?"); + const qs = new URLSearchParams((calledWith as string).split("?")[1] ?? ""); + expect(qs.get("kind")).toBe("lint_run"); + expect(qs.get("actor")).toBe("ai"); + expect(qs.get("from")).toBe("2026-01-01T00:00:00Z"); + expect(qs.get("to")).toBe("2026-02-01T00:00:00Z"); + expect(qs.get("limit")).toBe("10"); + expect(qs.get("offset")).toBe("20"); + }); + + it("limit=0 は数値として扱う(offset 同様)/ accepts numeric 0 for limit and offset", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ entries: [], total: 0, limit: 0 }), { status: 200 }), + ); + + await listActivity({ limit: 0, offset: 0 }); + const calledWith = vi.mocked(adminFetch).mock.calls[0]?.[0] as string; + const qs = new URLSearchParams(calledWith.split("?")[1] ?? ""); + expect(qs.get("limit")).toBe("0"); + expect(qs.get("offset")).toBe("0"); + }); + + it("undefined のキーは付けない / omits keys when undefined", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ entries: [], total: 0, limit: 0 }), { status: 200 }), + ); + + await listActivity({ kind: "clip_ingest" }); + const calledWith = vi.mocked(adminFetch).mock.calls[0]?.[0] as string; + const qs = new URLSearchParams(calledWith.split("?")[1] ?? ""); + expect(qs.get("kind")).toBe("clip_ingest"); + expect(qs.get("actor")).toBeNull(); + expect(qs.get("from")).toBeNull(); + expect(qs.get("limit")).toBeNull(); + expect(qs.get("offset")).toBeNull(); + }); + + it("200 なら ActivityListResponse を返す / returns ActivityListResponse on success", async () => { + const body = { + entries: [ + { + id: "a1", + kind: "lint_run" as const, + actor: "system" as const, + target_page_ids: [], + detail: null, + created_at: "2026-01-01T00:00:00Z", + }, + ], + total: 1, + limit: 50, + }; + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify(body), { status: 200 }), + ); + + await expect(listActivity()).resolves.toEqual(body); + }); + + it("!res.ok なら getErrorMessage 由来の Error を投げる / throws when response is not ok", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ message: "Forbidden" }), { status: 403 }), + ); + await expect(listActivity()).rejects.toThrow("Forbidden"); + }); + + it("body が空でも fallback メッセージで Error を投げる / throws fallback when body is empty", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(null, { status: 500, statusText: "" }), + ); + await expect(listActivity()).rejects.toThrow("Failed to fetch activity entries"); + }); +}); diff --git a/admin/src/api/client.test.ts b/admin/src/api/client.test.ts new file mode 100644 index 00000000..2ede388f --- /dev/null +++ b/admin/src/api/client.test.ts @@ -0,0 +1,106 @@ +/** + * adminFetch / getErrorMessage の単体テスト。 + * Unit tests for adminFetch / getErrorMessage. + * + * `adminFetch` は global の `fetch` をモックして検証し、 + * `getErrorMessage` は `Response` を直接組み立てて分岐を網羅する。 + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { adminFetch, getErrorMessage } from "./client"; + +describe("getErrorMessage", () => { + it("レスポンス JSON の message を trim して返す / returns trimmed message from JSON body", async () => { + const res = new Response(JSON.stringify({ message: " Boom " }), { status: 500 }); + await expect(getErrorMessage(res, "fallback")).resolves.toBe("Boom"); + }); + + it("message が無い場合は statusText を使う / falls back to statusText when message is missing", async () => { + const res = new Response(JSON.stringify({ other: "x" }), { + status: 503, + statusText: "Service Unavailable", + }); + await expect(getErrorMessage(res, "fallback")).resolves.toBe("Service Unavailable"); + }); + + it("body が JSON でない場合も statusText に倒す / uses statusText when body is not JSON", async () => { + const res = new Response("not-json-body", { + status: 502, + statusText: "Bad Gateway", + }); + await expect(getErrorMessage(res, "fallback")).resolves.toBe("Bad Gateway"); + }); + + it("message が空白だけのとき fallback を返す / returns fallback when message is blank", async () => { + const res = new Response(JSON.stringify({ message: " " }), { + status: 500, + statusText: "", + }); + await expect(getErrorMessage(res, "fallback")).resolves.toBe("fallback"); + }); + + it("message が文字列以外なら無視して statusText / fallback を使う / ignores non-string message", async () => { + const res = new Response(JSON.stringify({ message: 42 }), { + status: 500, + statusText: "", + }); + await expect(getErrorMessage(res, "fallback")).resolves.toBe("fallback"); + }); + + it("statusText も空のとき fallback を返す / returns fallback when both message and statusText are empty", async () => { + const res = new Response(null, { status: 500, statusText: "" }); + await expect(getErrorMessage(res, "fallback")).resolves.toBe("fallback"); + }); +}); + +describe("adminFetch", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("ok", { status: 200 }))); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("credentials: include を必ず指定する / always sets credentials: 'include'", async () => { + await adminFetch("/api/foo"); + expect(globalThis.fetch).toHaveBeenCalledWith( + "/api/foo", + expect.objectContaining({ credentials: "include" }), + ); + }); + + it("body があるとき Content-Type: application/json を付与する / sets JSON content-type when body is present", async () => { + await adminFetch("/api/foo", { method: "POST", body: JSON.stringify({ a: 1 }) }); + const call = vi.mocked(globalThis.fetch).mock.calls[0]; + const init = call?.[1] as RequestInit; + const headers = new Headers(init.headers); + expect(headers.get("Content-Type")).toBe("application/json"); + }); + + it("FormData の場合は Content-Type を勝手に付けない / does not override Content-Type for FormData body", async () => { + const fd = new FormData(); + fd.append("k", "v"); + await adminFetch("/api/foo", { method: "POST", body: fd }); + const call = vi.mocked(globalThis.fetch).mock.calls[0]; + const init = call?.[1] as RequestInit; + const headers = new Headers(init.headers); + expect(headers.get("Content-Type")).toBeNull(); + }); + + it("呼び出し側で Content-Type を指定したらそのまま使う / preserves caller-supplied Content-Type", async () => { + await adminFetch("/api/foo", { + method: "POST", + headers: { "Content-Type": "text/plain" }, + body: "hi", + }); + const call = vi.mocked(globalThis.fetch).mock.calls[0]; + const init = call?.[1] as RequestInit; + const headers = new Headers(init.headers); + expect(headers.get("Content-Type")).toBe("text/plain"); + }); + + it("先頭に / が無いパスでも / を付ける / normalises path without leading slash", async () => { + await adminFetch("api/foo"); + expect(globalThis.fetch).toHaveBeenCalledWith("/api/foo", expect.any(Object)); + }); +}); diff --git a/admin/src/api/lint.test.ts b/admin/src/api/lint.test.ts new file mode 100644 index 00000000..8f1cc7e4 --- /dev/null +++ b/admin/src/api/lint.test.ts @@ -0,0 +1,107 @@ +/** + * lint API クライアントのテスト(adminFetch をモック)。 + * Tests for the lint API client (adminFetch mocked). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { runLint, getLintFindings, resolveLintFinding } from "./lint"; + +vi.mock("./client", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + adminFetch: vi.fn(), + }; +}); + +const { adminFetch } = await import("./client"); + +describe("runLint", () => { + beforeEach(() => { + vi.mocked(adminFetch).mockReset(); + }); + + it("POST /api/lint/run を呼んで結果を返す / posts to /api/lint/run", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ summary: [{ rule: "orphan", count: 2 }], total: 2 }), { + status: 200, + }), + ); + const result = await runLint(); + expect(result.total).toBe(2); + expect(result.summary[0]).toEqual({ rule: "orphan", count: 2 }); + expect(adminFetch).toHaveBeenCalledWith("/api/lint/run", { method: "POST" }); + }); + + it("!res.ok なら fallback で Error を投げる / throws on failure", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(null, { status: 500, statusText: "" }), + ); + await expect(runLint()).rejects.toThrow("Failed to run lint"); + }); +}); + +describe("getLintFindings", () => { + beforeEach(() => { + vi.mocked(adminFetch).mockReset(); + }); + + it("findings と total を返す / returns findings and total", async () => { + const findings = [ + { + id: "f1", + rule: "orphan" as const, + severity: "warn" as const, + page_ids: ["p1"], + detail: { reason: "no inbound link" }, + created_at: "2026-01-01T00:00:00Z", + resolved_at: null, + }, + ]; + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ findings, total: 1 }), { status: 200 }), + ); + const out = await getLintFindings(); + expect(out).toEqual({ findings, total: 1 }); + expect(adminFetch).toHaveBeenCalledWith("/api/lint/findings"); + }); + + it("!res.ok なら body の message でエラーになる / surfaces server error message", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ message: "Forbidden" }), { status: 403 }), + ); + await expect(getLintFindings()).rejects.toThrow("Forbidden"); + }); +}); + +describe("resolveLintFinding", () => { + beforeEach(() => { + vi.mocked(adminFetch).mockReset(); + }); + + it("id を URL エンコードして POST する / encodes id and POSTs", async () => { + const finding = { + id: "lint:1/2", + rule: "ghost_many" as const, + severity: "info" as const, + page_ids: [], + detail: {}, + created_at: "2026-01-01T00:00:00Z", + resolved_at: "2026-01-02T00:00:00Z", + }; + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ finding }), { status: 200 }), + ); + const out = await resolveLintFinding("lint:1/2"); + expect(out.finding).toEqual(finding); + expect(adminFetch).toHaveBeenCalledWith("/api/lint/findings/lint%3A1%2F2/resolve", { + method: "POST", + }); + }); + + it("!res.ok なら fallback で Error を投げる / throws on failure", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(null, { status: 500, statusText: "" }), + ); + await expect(resolveLintFinding("x")).rejects.toThrow("Failed to resolve finding"); + }); +}); diff --git a/admin/src/lib/dateUtils.test.ts b/admin/src/lib/dateUtils.test.ts new file mode 100644 index 00000000..38bbbe83 --- /dev/null +++ b/admin/src/lib/dateUtils.test.ts @@ -0,0 +1,24 @@ +/** + * formatDate のテスト。 + * Tests for formatDate. + */ +import { describe, it, expect } from "vitest"; +import { formatDate } from "./dateUtils"; + +describe("formatDate", () => { + it("ISO 8601 を YYYY/MM/DD(ja-JP)に整形する / formats ISO date in ja-JP locale", () => { + expect(formatDate("2026-04-25T01:23:45Z")).toBe("2026/04/25"); + }); + + it("月日が 1 桁でも 0 埋めされる / zero-pads single-digit month/day", () => { + expect(formatDate("2026-01-02T00:00:00Z")).toBe("2026/01/02"); + }); + + it("不正な日付文字列はそのまま返す / returns input as-is for invalid date", () => { + expect(formatDate("not-a-date")).toBe("not-a-date"); + }); + + it("空文字も入力をそのまま返す / returns empty input as-is", () => { + expect(formatDate("")).toBe(""); + }); +}); diff --git a/admin/src/pages/ai-models/useAiModelActions.test.ts b/admin/src/pages/ai-models/useAiModelActions.test.ts new file mode 100644 index 00000000..2e7ad770 --- /dev/null +++ b/admin/src/pages/ai-models/useAiModelActions.test.ts @@ -0,0 +1,283 @@ +/** + * useAiModelActions のテスト。 + * - 楽観的更新 → 失敗時 rollback / optimistic update with rollback + * - mounted ref が false の間は state 更新しない / no state mutation when unmounted + * + * Tests for useAiModelActions. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { useAiModelActions } from "./useAiModelActions"; +import type { AiModelAdmin } from "@/api/admin"; + +vi.mock("@/api/admin", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + patchAiModel: vi.fn(), + }; +}); + +const { patchAiModel } = await import("@/api/admin"); + +const baseModel: AiModelAdmin = { + id: "openai:gpt-4", + provider: "openai", + modelId: "gpt-4", + displayName: "GPT-4", + tierRequired: "pro", + inputCostUnits: 100, + outputCostUnits: 100, + isActive: true, + sortOrder: 0, + createdAt: "2026-01-01T00:00:00Z", +}; + +interface Refs { + models: AiModelAdmin[]; + setModels: ReturnType; + setError: ReturnType; + isMountedRef: { current: boolean }; + originalModelsRef: { current: AiModelAdmin[] }; +} + +function createRefs(initial: AiModelAdmin[] = [baseModel]): Refs { + // setState 互換の更新関数を受け付け、内部配列を更新する + // Accepts either next-state value or updater fn (matches React.SetStateAction) + let models = initial; + const setModels = vi.fn( + (updater: AiModelAdmin[] | ((prev: AiModelAdmin[]) => AiModelAdmin[])) => { + models = + typeof updater === "function" + ? (updater as (p: AiModelAdmin[]) => AiModelAdmin[])(models) + : updater; + }, + ); + return { + get models() { + return models; + }, + setModels, + setError: vi.fn(), + isMountedRef: { current: true }, + originalModelsRef: { current: [...initial] }, + }; +} + +describe("useAiModelActions.handleModelUpdate", () => { + beforeEach(() => { + vi.mocked(patchAiModel).mockReset(); + }); + + it("成功時: 楽観的更新 → originalModelsRef も更新される / optimistic update + originalModelsRef sync", async () => { + vi.mocked(patchAiModel).mockResolvedValueOnce({ ...baseModel, displayName: "GPT-4 Updated" }); + const refs = createRefs(); + const { result } = renderHook(() => + useAiModelActions({ + setModels: refs.setModels as never, + setError: refs.setError as never, + isMountedRef: refs.isMountedRef as never, + originalModelsRef: refs.originalModelsRef as never, + }), + ); + + await act(async () => { + await result.current.handleModelUpdate(baseModel, { displayName: "GPT-4 Updated" }); + }); + + // setError(null) で先に reset されている + expect(refs.setError).toHaveBeenCalledWith(null); + // setModels が楽観的更新で 1 回呼ばれる + expect(refs.setModels).toHaveBeenCalledTimes(1); + expect(refs.models[0]?.displayName).toBe("GPT-4 Updated"); + // originalModelsRef も更新される + expect(refs.originalModelsRef.current[0]?.displayName).toBe("GPT-4 Updated"); + }); + + it("失敗時: rollback して setError に message を渡す / rollbacks and surfaces error", async () => { + vi.mocked(patchAiModel).mockRejectedValueOnce(new Error("nope")); + const refs = createRefs(); + const { result } = renderHook(() => + useAiModelActions({ + setModels: refs.setModels as never, + setError: refs.setError as never, + isMountedRef: refs.isMountedRef as never, + originalModelsRef: refs.originalModelsRef as never, + }), + ); + + await act(async () => { + await result.current.handleModelUpdate(baseModel, { displayName: "broken" }); + }); + + // 楽観的更新(1回目) + rollback(2回目)= 2 回呼ばれる + expect(refs.setModels).toHaveBeenCalledTimes(2); + // rollback 後は元の値に戻っている + expect(refs.models[0]?.displayName).toBe("GPT-4"); + expect(refs.setError).toHaveBeenLastCalledWith("nope"); + // 例外オブジェクトが Error 以外でも対応する別ケースは下のテスト + }); + + it("Error 以外を投げたとき String() 化して setError する / stringifies non-Error throws", async () => { + vi.mocked(patchAiModel).mockRejectedValueOnce("oops"); + const refs = createRefs(); + const { result } = renderHook(() => + useAiModelActions({ + setModels: refs.setModels as never, + setError: refs.setError as never, + isMountedRef: refs.isMountedRef as never, + originalModelsRef: refs.originalModelsRef as never, + }), + ); + + await act(async () => { + await result.current.handleModelUpdate(baseModel, { displayName: "x" }); + }); + expect(refs.setError).toHaveBeenLastCalledWith("oops"); + }); + + it("isMountedRef が false なら早期 return(成功前にアンマウント)/ short-circuits before any state update if unmounted", async () => { + const refs = createRefs(); + refs.isMountedRef.current = false; + + const { result } = renderHook(() => + useAiModelActions({ + setModels: refs.setModels as never, + setError: refs.setError as never, + isMountedRef: refs.isMountedRef as never, + originalModelsRef: refs.originalModelsRef as never, + }), + ); + + await act(async () => { + await result.current.handleModelUpdate(baseModel, { displayName: "x" }); + }); + + expect(refs.setError).not.toHaveBeenCalled(); + expect(refs.setModels).not.toHaveBeenCalled(); + expect(patchAiModel).not.toHaveBeenCalled(); + }); + + it("失敗 + アンマウント済みなら rollback も skip する / skips rollback if unmounted between request and failure", async () => { + let resolveReject: (() => void) | null = null; + vi.mocked(patchAiModel).mockReturnValueOnce( + new Promise((_, reject) => { + resolveReject = () => reject(new Error("late")); + }) as never, + ); + const refs = createRefs(); + const { result } = renderHook(() => + useAiModelActions({ + setModels: refs.setModels as never, + setError: refs.setError as never, + isMountedRef: refs.isMountedRef as never, + originalModelsRef: refs.originalModelsRef as never, + }), + ); + + let pending: Promise; + await act(async () => { + pending = result.current.handleModelUpdate(baseModel, { displayName: "x" }); + }); + + // 楽観的更新(1回目)は走った後にアンマウント + expect(refs.setModels).toHaveBeenCalledTimes(1); + refs.isMountedRef.current = false; + + await act(async () => { + resolveReject?.(); + await pending; + }); + + // rollback は呼ばれず、setModels は依然 1 回のまま + expect(refs.setModels).toHaveBeenCalledTimes(1); + }); +}); + +describe("useAiModelActions.handleToggleActive", () => { + beforeEach(() => { + vi.mocked(patchAiModel).mockReset(); + }); + + it("originalModel が無いと何もしない / no-op when original model is missing", async () => { + vi.mocked(patchAiModel).mockResolvedValueOnce(baseModel); + const refs = createRefs(); + refs.originalModelsRef.current = []; + const { result } = renderHook(() => + useAiModelActions({ + setModels: refs.setModels as never, + setError: refs.setError as never, + isMountedRef: refs.isMountedRef as never, + originalModelsRef: refs.originalModelsRef as never, + }), + ); + + await act(async () => { + await result.current.handleToggleActive(baseModel); + }); + + expect(patchAiModel).not.toHaveBeenCalled(); + }); + + it("isActive を反転して PATCH する / toggles isActive based on current value", async () => { + vi.mocked(patchAiModel).mockResolvedValueOnce({ ...baseModel, isActive: false }); + const refs = createRefs(); + const { result } = renderHook(() => + useAiModelActions({ + setModels: refs.setModels as never, + setError: refs.setError as never, + isMountedRef: refs.isMountedRef as never, + originalModelsRef: refs.originalModelsRef as never, + }), + ); + + await act(async () => { + await result.current.handleToggleActive(baseModel); + }); + + expect(patchAiModel).toHaveBeenCalledWith(baseModel.id, { isActive: false }); + }); +}); + +describe("useAiModelActions.handleTierChange", () => { + beforeEach(() => { + vi.mocked(patchAiModel).mockReset(); + }); + + it("同じ tier なら no-op / skips when tier is unchanged", async () => { + const refs = createRefs(); + const { result } = renderHook(() => + useAiModelActions({ + setModels: refs.setModels as never, + setError: refs.setError as never, + isMountedRef: refs.isMountedRef as never, + originalModelsRef: refs.originalModelsRef as never, + }), + ); + + await act(async () => { + await result.current.handleTierChange(baseModel, "pro"); + }); + + expect(patchAiModel).not.toHaveBeenCalled(); + }); + + it("tier を変更して PATCH する / sends new tier", async () => { + vi.mocked(patchAiModel).mockResolvedValueOnce({ ...baseModel, tierRequired: "free" }); + const refs = createRefs(); + const { result } = renderHook(() => + useAiModelActions({ + setModels: refs.setModels as never, + setError: refs.setError as never, + isMountedRef: refs.isMountedRef as never, + originalModelsRef: refs.originalModelsRef as never, + }), + ); + + await act(async () => { + await result.current.handleTierChange(baseModel, "free"); + }); + + expect(patchAiModel).toHaveBeenCalledWith(baseModel.id, { tierRequired: "free" }); + }); +}); diff --git a/admin/src/pages/ai-models/useAiModelsDragReorder.test.ts b/admin/src/pages/ai-models/useAiModelsDragReorder.test.ts new file mode 100644 index 00000000..dbc73122 --- /dev/null +++ b/admin/src/pages/ai-models/useAiModelsDragReorder.test.ts @@ -0,0 +1,271 @@ +/** + * useAiModelsDragReorder のテスト。 + * - 並び替えと永続化の楽観的更新 / optimistic reorder + persist + * - エラー時の load() による recovery / load() recovery on failure + * + * Tests for useAiModelsDragReorder. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { useAiModelsDragReorder } from "./useAiModelsDragReorder"; +import type { AiModelAdmin } from "@/api/admin"; + +vi.mock("@/api/admin", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + patchAiModelsBulk: vi.fn(), + }; +}); + +const { patchAiModelsBulk } = await import("@/api/admin"); + +const makeModel = (id: string, sortOrder: number): AiModelAdmin => ({ + id, + provider: "openai", + modelId: id, + displayName: id.toUpperCase(), + tierRequired: "pro", + inputCostUnits: 100, + outputCostUnits: 100, + isActive: true, + sortOrder, + createdAt: "2026-01-01T00:00:00Z", +}); + +type LoadFn = (showLoading?: boolean) => Promise; + +interface Args { + models: AiModelAdmin[]; + setModels: ReturnType; + setError: ReturnType; + isMountedRef: { current: boolean }; + load: LoadFn & ReturnType; +} + +function createArgs(models: AiModelAdmin[]): Args { + // `load` の型は (showLoading?: boolean) => Promise なので、 + // Mock の汎用シグネチャを LoadFn にキャストして型エラーを回避する。 + // Cast vi.fn() to LoadFn so the hook's `load` prop type is satisfied. + const load = vi.fn().mockResolvedValue(undefined) as ReturnType & LoadFn; + return { + models, + setModels: vi.fn(), + setError: vi.fn(), + isMountedRef: { current: true }, + load, + }; +} + +function makeDragEvent(payload: Record = {}) { + const data = new Map(Object.entries(payload)); + return { + preventDefault: vi.fn(), + dataTransfer: { + effectAllowed: "", + dropEffect: "", + setData: (k: string, v: string) => data.set(k, v), + getData: (k: string) => data.get(k) ?? "", + }, + } as unknown as React.DragEvent; +} + +describe("useAiModelsDragReorder.handleReorder", () => { + beforeEach(() => { + vi.mocked(patchAiModelsBulk).mockReset(); + }); + + it("配列を並び替えて sortOrder を採番し直し、API を呼ぶ / reorders and persists with re-numbered sortOrder", async () => { + vi.mocked(patchAiModelsBulk).mockResolvedValueOnce({ updated: 3, models: [] }); + const models = [makeModel("a", 0), makeModel("b", 1), makeModel("c", 2)]; + const args = createArgs(models); + + const { result } = renderHook(() => + useAiModelsDragReorder({ + models: args.models, + setModels: args.setModels as never, + setError: args.setError as never, + isMountedRef: args.isMountedRef as never, + load: args.load, + }), + ); + + // handleReorder は内部関数なので handleDrop 経由で 0 → 2 の移動を発火する + // (a を末尾に動かす)。 + // Trigger handleReorder via handleDrop (drop "a" onto "c" => move to end). + await act(async () => { + result.current.handleDrop(makeDragEvent({ "text/plain": "a" }), "c"); + await Promise.resolve(); + }); + + // 楽観的に setModels が呼ばれている + expect(args.setModels).toHaveBeenCalledTimes(1); + const updater = args.setModels.mock.calls[0]?.[0] as AiModelAdmin[]; + expect(updater.map((m) => m.id)).toEqual(["b", "c", "a"]); + expect(updater.map((m) => m.sortOrder)).toEqual([0, 1, 2]); + expect(patchAiModelsBulk).toHaveBeenCalledWith([ + { id: "b", sortOrder: 0 }, + { id: "c", sortOrder: 1 }, + { id: "a", sortOrder: 2 }, + ]); + }); + + it("API が失敗したら load(false) で再取得し、setError にメッセージを渡す / falls back to load() and surfaces error", async () => { + vi.mocked(patchAiModelsBulk).mockRejectedValueOnce(new Error("server-down")); + const args = createArgs([makeModel("a", 0), makeModel("b", 1)]); + + const { result } = renderHook(() => + useAiModelsDragReorder({ + models: args.models, + setModels: args.setModels as never, + setError: args.setError as never, + isMountedRef: args.isMountedRef as never, + load: args.load, + }), + ); + + await act(async () => { + result.current.handleDrop(makeDragEvent({ "text/plain": "a" }), "b"); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(args.load).toHaveBeenCalledWith(false); + expect(args.setError).toHaveBeenLastCalledWith("server-down"); + }); + + it("失敗時に既にアンマウントなら load も setError も呼ばない / skip recovery when unmounted", async () => { + let rejectFn: (() => void) | null = null; + vi.mocked(patchAiModelsBulk).mockReturnValueOnce( + new Promise((_, reject) => { + rejectFn = () => reject(new Error("late")); + }) as never, + ); + const args = createArgs([makeModel("a", 0), makeModel("b", 1)]); + + const { result } = renderHook(() => + useAiModelsDragReorder({ + models: args.models, + setModels: args.setModels as never, + setError: args.setError as never, + isMountedRef: args.isMountedRef as never, + load: args.load, + }), + ); + + await act(async () => { + result.current.handleDrop(makeDragEvent({ "text/plain": "a" }), "b"); + await Promise.resolve(); + }); + + args.isMountedRef.current = false; + await act(async () => { + rejectFn?.(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(args.load).not.toHaveBeenCalled(); + expect(args.setError).toHaveBeenCalledTimes(1); // 楽観的更新時の null reset のみ + expect(args.setError).toHaveBeenCalledWith(null); + }); + + it("fromIndex === toIndex の場合は何もしない / no-op when index is the same", async () => { + const args = createArgs([makeModel("a", 0)]); + const { result } = renderHook(() => + useAiModelsDragReorder({ + models: args.models, + setModels: args.setModels as never, + setError: args.setError as never, + isMountedRef: args.isMountedRef as never, + load: args.load, + }), + ); + + await act(async () => { + result.current.handleDrop(makeDragEvent({ "text/plain": "a" }), "a"); + await Promise.resolve(); + }); + + expect(args.setModels).not.toHaveBeenCalled(); + expect(patchAiModelsBulk).not.toHaveBeenCalled(); + }); +}); + +describe("useAiModelsDragReorder drag state", () => { + it("handleDragStart で draggedId を設定し、dataTransfer に id を入れる / sets draggedId and writes id to dataTransfer", () => { + const args = createArgs([makeModel("a", 0)]); + const { result } = renderHook(() => + useAiModelsDragReorder({ + models: args.models, + setModels: args.setModels as never, + setError: args.setError as never, + isMountedRef: args.isMountedRef as never, + load: args.load, + }), + ); + + const ev = makeDragEvent(); + act(() => { + result.current.handleDragStart(ev, "a"); + }); + + expect(result.current.draggedId).toBe("a"); + expect(ev.dataTransfer.effectAllowed).toBe("move"); + expect(ev.dataTransfer.getData("text/plain")).toBe("a"); + expect(JSON.parse(ev.dataTransfer.getData("application/json"))).toEqual({ id: "a" }); + }); + + it("handleDragOver で dragOverId を設定し preventDefault を呼ぶ / sets dragOverId and calls preventDefault", () => { + const args = createArgs([makeModel("a", 0)]); + const { result } = renderHook(() => + useAiModelsDragReorder({ + models: args.models, + setModels: args.setModels as never, + setError: args.setError as never, + isMountedRef: args.isMountedRef as never, + load: args.load, + }), + ); + + const ev = makeDragEvent(); + act(() => { + result.current.handleDragOver(ev, "a"); + }); + + expect(ev.preventDefault).toHaveBeenCalled(); + expect(ev.dataTransfer.dropEffect).toBe("move"); + expect(result.current.dragOverId).toBe("a"); + }); + + it("handleDragEnd / handleDragLeave で id 状態がクリアされる / clears drag id state on drag end/leave", () => { + const args = createArgs([makeModel("a", 0)]); + const { result } = renderHook(() => + useAiModelsDragReorder({ + models: args.models, + setModels: args.setModels as never, + setError: args.setError as never, + isMountedRef: args.isMountedRef as never, + load: args.load, + }), + ); + + act(() => { + result.current.handleDragStart(makeDragEvent(), "a"); + result.current.handleDragOver(makeDragEvent(), "a"); + }); + expect(result.current.draggedId).toBe("a"); + + act(() => { + result.current.handleDragEnd(); + }); + expect(result.current.draggedId).toBeNull(); + expect(result.current.dragOverId).toBeNull(); + + act(() => { + result.current.handleDragOver(makeDragEvent(), "a"); + result.current.handleDragLeave(); + }); + expect(result.current.dragOverId).toBeNull(); + }); +}); diff --git a/admin/src/pages/users/useConfirmDialogs.test.ts b/admin/src/pages/users/useConfirmDialogs.test.ts new file mode 100644 index 00000000..65891ee0 --- /dev/null +++ b/admin/src/pages/users/useConfirmDialogs.test.ts @@ -0,0 +1,256 @@ +/** + * useConfirmDialogs のテスト。 + * - 各 dialog の request → confirm / cancel ライフサイクル + * - getUserImpact の race 対策(requestId)/ guard against stale impact responses + * + * Tests for useConfirmDialogs. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { useConfirmDialogs } from "./useConfirmDialogs"; +import type { UserAdmin, UserImpact } from "@/api/admin"; + +vi.mock("@/api/admin", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getUserImpact: vi.fn(), + }; +}); + +const { getUserImpact } = await import("@/api/admin"); + +const userA: UserAdmin = { + id: "user-a", + email: "a@example.com", + name: "User A", + role: "user", + status: "active", + suspendedAt: null, + suspendedReason: null, + suspendedBy: null, + createdAt: "2026-01-01T00:00:00Z", + pageCount: 0, +}; + +const userB: UserAdmin = { ...userA, id: "user-b", email: "b@example.com", name: "User B" }; + +const sampleImpact: UserImpact = { + notesCount: 3, + sessionsCount: 1, + activeSubscription: false, + lastAiUsageAt: null, +}; + +function makeHook() { + const onRoleChange = vi.fn(); + const onUnsuspend = vi.fn(); + const onDelete = vi.fn(); + const result = renderHook(() => useConfirmDialogs(onRoleChange, onUnsuspend, onDelete)); + return { ...result, onRoleChange, onUnsuspend, onDelete }; +} + +describe("useConfirmDialogs - role change", () => { + it("同じロールへの変更は target を立てない / no-op when role is unchanged", () => { + const { result } = makeHook(); + act(() => { + result.current.requestRoleChange(userA, "user"); + }); + expect(result.current.roleChangeTarget).toBeNull(); + }); + + it("requestRoleChange → confirm で onRoleChange を呼んで target を null に戻す", () => { + const { result, onRoleChange } = makeHook(); + act(() => { + result.current.requestRoleChange(userA, "admin"); + }); + expect(result.current.roleChangeTarget).toEqual({ user: userA, newRole: "admin" }); + + act(() => { + result.current.confirmRoleChange(); + }); + expect(onRoleChange).toHaveBeenCalledWith(userA, "admin"); + expect(result.current.roleChangeTarget).toBeNull(); + }); + + it("confirm が target なしのときは何もしない / confirm without target is a no-op", () => { + const { result, onRoleChange } = makeHook(); + act(() => { + result.current.confirmRoleChange(); + }); + expect(onRoleChange).not.toHaveBeenCalled(); + }); + + it("cancel で target を null に戻す", () => { + const { result } = makeHook(); + act(() => { + result.current.requestRoleChange(userA, "admin"); + result.current.cancelRoleChange(); + }); + expect(result.current.roleChangeTarget).toBeNull(); + }); +}); + +describe("useConfirmDialogs - unsuspend", () => { + it("request → confirm で onUnsuspend を呼ぶ", () => { + const { result, onUnsuspend } = makeHook(); + act(() => { + result.current.requestUnsuspend(userA); + }); + expect(result.current.unsuspendTarget).toEqual(userA); + + act(() => { + result.current.confirmUnsuspend(); + }); + expect(onUnsuspend).toHaveBeenCalledWith(userA); + expect(result.current.unsuspendTarget).toBeNull(); + }); + + it("cancel で target を null に戻す", () => { + const { result } = makeHook(); + act(() => { + result.current.requestUnsuspend(userA); + result.current.cancelUnsuspend(); + }); + expect(result.current.unsuspendTarget).toBeNull(); + }); +}); + +describe("useConfirmDialogs - delete with impact", () => { + beforeEach(() => { + vi.mocked(getUserImpact).mockReset(); + }); + + it("requestDelete でローディング状態 → impact 取得後に impact 反映", async () => { + vi.mocked(getUserImpact).mockResolvedValueOnce(sampleImpact); + const { result } = makeHook(); + + act(() => { + result.current.requestDelete(userA); + }); + + expect(result.current.deleteTarget).toEqual({ + user: userA, + impact: null, + loadingImpact: true, + }); + + await waitFor(() => { + expect(result.current.deleteTarget?.loadingImpact).toBe(false); + }); + expect(result.current.deleteTarget?.impact).toEqual(sampleImpact); + }); + + it("getUserImpact が失敗したら loadingImpact: false で impact は null のまま", async () => { + vi.mocked(getUserImpact).mockRejectedValueOnce(new Error("nope")); + const { result } = makeHook(); + + act(() => { + result.current.requestDelete(userA); + }); + await waitFor(() => { + expect(result.current.deleteTarget?.loadingImpact).toBe(false); + }); + expect(result.current.deleteTarget?.impact).toBeNull(); + }); + + it("古い request の resolve は新しい target を上書きしない / stale resolve is ignored", async () => { + let resolveA: ((v: UserImpact) => void) | null = null; + let resolveB: ((v: UserImpact) => void) | null = null; + vi.mocked(getUserImpact) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveA = resolve; + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveB = resolve; + }), + ); + + const { result } = makeHook(); + + act(() => { + result.current.requestDelete(userA); + }); + expect(result.current.deleteTarget?.user.id).toBe(userA.id); + + // ユーザー B に切り替えてから古い request A を resolve しても、状態は B のまま + // Even if old request A resolves after switching to user B, the state stays on B. + act(() => { + result.current.requestDelete(userB); + }); + expect(result.current.deleteTarget?.user.id).toBe(userB.id); + + await act(async () => { + resolveA?.({ ...sampleImpact, notesCount: 999 }); + await Promise.resolve(); + }); + // A の結果は反映されない / A's resolved result must not be applied. + expect(result.current.deleteTarget?.user.id).toBe(userB.id); + expect(result.current.deleteTarget?.impact).toBeNull(); + expect(result.current.deleteTarget?.loadingImpact).toBe(true); + + // B の resolve はちゃんと反映される / B's resolve still propagates correctly. + await act(async () => { + resolveB?.(sampleImpact); + await Promise.resolve(); + }); + expect(result.current.deleteTarget?.impact).toEqual(sampleImpact); + expect(result.current.deleteTarget?.loadingImpact).toBe(false); + }); + + it("cancelDelete は requestId をインクリメントし、後から来た resolve を無効化する", async () => { + let resolveLate: ((v: UserImpact) => void) | null = null; + vi.mocked(getUserImpact).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveLate = resolve; + }), + ); + + const { result } = makeHook(); + act(() => { + result.current.requestDelete(userA); + }); + act(() => { + result.current.cancelDelete(); + }); + expect(result.current.deleteTarget).toBeNull(); + + // resolve しても deleteTarget は null のまま / late resolve does not revive deleteTarget. + await act(async () => { + resolveLate?.(sampleImpact); + await Promise.resolve(); + }); + expect(result.current.deleteTarget).toBeNull(); + }); + + it("confirmDelete で onDelete を呼んで target を null にする", async () => { + vi.mocked(getUserImpact).mockResolvedValueOnce(sampleImpact); + const { result, onDelete } = makeHook(); + act(() => { + result.current.requestDelete(userA); + }); + await waitFor(() => { + expect(result.current.deleteTarget?.loadingImpact).toBe(false); + }); + + act(() => { + result.current.confirmDelete(); + }); + expect(onDelete).toHaveBeenCalledWith(userA); + expect(result.current.deleteTarget).toBeNull(); + }); + + it("confirmDelete が target なしのときは onDelete を呼ばない", () => { + const { result, onDelete } = makeHook(); + act(() => { + result.current.confirmDelete(); + }); + expect(onDelete).not.toHaveBeenCalled(); + }); +}); diff --git a/admin/vitest.config.ts b/admin/vitest.config.ts index b9f34756..3be273e1 100644 --- a/admin/vitest.config.ts +++ b/admin/vitest.config.ts @@ -2,6 +2,11 @@ import path from "path"; import { defineConfig } from "vitest/config"; export default defineConfig({ + // Anchor `include` / `setupFiles` to admin/ so the config also works when + // invoked from the workspace root (`vitest run --config admin/vitest.config.ts`). + // ルート(`vitest run --config admin/vitest.config.ts`)から呼ばれた場合も + // `include` / `setupFiles` が admin/ 配下を指すよう root を固定する。 + root: __dirname, resolve: { alias: { "@": path.resolve(__dirname, "./src"), diff --git a/bun.lock b/bun.lock index fc9ec009..333bb258 100644 --- a/bun.lock +++ b/bun.lock @@ -74,6 +74,7 @@ "@tiptap/starter-kit": "^3.20.0", "@tiptap/y-tiptap": "^3.0.2", "@xyflow/react": "^12.10.1", + "@zedi/shared": "workspace:*", "@zedi/ui": "workspace:*", "baseline-browser-mapping": "^2.9.19", "better-auth": "^1.2.0", @@ -211,6 +212,14 @@ "vitest": "^4.0.16", }, }, + "packages/shared": { + "name": "@zedi/shared", + "version": "0.1.0", + "devDependencies": { + "typescript": "^6.0.2", + "vitest": "^4.0.16", + }, + }, "packages/ui": { "name": "@zedi/ui", "version": "0.1.0", @@ -1393,6 +1402,8 @@ "@zedi/claude-sidecar": ["@zedi/claude-sidecar@workspace:packages/claude-sidecar"], + "@zedi/shared": ["@zedi/shared@workspace:packages/shared"], + "@zedi/ui": ["@zedi/ui@workspace:packages/ui"], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], diff --git a/db/migrations/001_add_notes_tables.sql b/db/migrations/001_add_notes_tables.sql deleted file mode 100644 index 3a036bf4..00000000 --- a/db/migrations/001_add_notes_tables.sql +++ /dev/null @@ -1,49 +0,0 @@ --- Migration: 001_add_notes_tables --- Description: Add notes, note_pages, and note_members tables for sharing feature --- Date: 2026-01-23 - --- 公開ノート -CREATE TABLE IF NOT EXISTS notes ( - id TEXT PRIMARY KEY, - owner_user_id TEXT NOT NULL, - title TEXT, - visibility TEXT NOT NULL DEFAULT 'private', - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - is_deleted INTEGER DEFAULT 0 -); - -CREATE INDEX IF NOT EXISTS idx_notes_owner ON notes(owner_user_id); -CREATE INDEX IF NOT EXISTS idx_notes_visibility ON notes(visibility); - --- ノート内ページ -CREATE TABLE IF NOT EXISTS note_pages ( - note_id TEXT NOT NULL, - page_id TEXT NOT NULL, - added_by_user_id TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - is_deleted INTEGER DEFAULT 0, - FOREIGN KEY(note_id) REFERENCES notes(id) ON DELETE CASCADE, - FOREIGN KEY(page_id) REFERENCES pages(id) ON DELETE CASCADE, - PRIMARY KEY (note_id, page_id) -); - -CREATE INDEX IF NOT EXISTS idx_note_pages_note ON note_pages(note_id); -CREATE INDEX IF NOT EXISTS idx_note_pages_page ON note_pages(page_id); - --- ノートメンバー -CREATE TABLE IF NOT EXISTS note_members ( - note_id TEXT NOT NULL, - member_email TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'viewer', - invited_by_user_id TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - is_deleted INTEGER DEFAULT 0, - FOREIGN KEY(note_id) REFERENCES notes(id) ON DELETE CASCADE, - PRIMARY KEY (note_id, member_email) -); - -CREATE INDEX IF NOT EXISTS idx_note_members_note ON note_members(note_id); -CREATE INDEX IF NOT EXISTS idx_note_members_email ON note_members(member_email); diff --git a/db/migrations/002_add_page_snapshots.sql b/db/migrations/002_add_page_snapshots.sql deleted file mode 100644 index 70557beb..00000000 --- a/db/migrations/002_add_page_snapshots.sql +++ /dev/null @@ -1,19 +0,0 @@ --- Migration: 002_add_page_snapshots --- Description: Add page_snapshots table for page version history --- Date: 2026-04-07 - --- ページスナップショット(バージョン履歴) --- Page snapshots (version history) -CREATE TABLE IF NOT EXISTS page_snapshots ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - page_id UUID NOT NULL REFERENCES pages(id) ON DELETE CASCADE, - version BIGINT NOT NULL, - ydoc_state BYTEA NOT NULL, - content_text TEXT, - created_by TEXT, - trigger TEXT NOT NULL DEFAULT 'auto', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_page_snapshots_page_id ON page_snapshots(page_id); -CREATE INDEX IF NOT EXISTS idx_page_snapshots_page_created ON page_snapshots(page_id, created_at DESC); diff --git a/db/migrations/003_add_invitation_tokens.sql b/db/migrations/003_add_invitation_tokens.sql deleted file mode 100644 index bc5461c7..00000000 --- a/db/migrations/003_add_invitation_tokens.sql +++ /dev/null @@ -1,33 +0,0 @@ --- 003: 招待トークン管理 / Invitation token management --- note_members に招待ステータスを追加し、note_invitations テーブルを新規作成する。 --- Add invitation status to note_members and create note_invitations table. - --- ── note_members: ステータス + 承認ユーザー ID を追加 ───────────────────────── -ALTER TABLE note_members - ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'pending' - CHECK (status IN ('pending', 'accepted', 'declined')); - --- Backfill: treat all existing non-deleted members as accepted --- 既存の有効メンバーを accepted に設定する -UPDATE note_members SET status = 'accepted' WHERE is_deleted = FALSE; - -ALTER TABLE note_members - ADD COLUMN IF NOT EXISTS accepted_user_id TEXT REFERENCES "user"(id) ON DELETE SET NULL; - --- ── note_invitations テーブル ───────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS note_invitations ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - note_id UUID NOT NULL REFERENCES notes(id) ON DELETE CASCADE, - member_email TEXT NOT NULL, - token TEXT NOT NULL UNIQUE, - expires_at TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - used_at TIMESTAMPTZ, - UNIQUE(note_id, member_email) -); - -CREATE INDEX IF NOT EXISTS idx_note_invitations_token - ON note_invitations(token); - -CREATE INDEX IF NOT EXISTS idx_note_invitations_note_id - ON note_invitations(note_id); diff --git a/db/migrations/004_add_invitation_locale_tracking.sql b/db/migrations/004_add_invitation_locale_tracking.sql deleted file mode 100644 index 91e137e7..00000000 --- a/db/migrations/004_add_invitation_locale_tracking.sql +++ /dev/null @@ -1,18 +0,0 @@ --- 004: 招待メールのロケール対応と送信トラッキング --- Invitation email locale support and send tracking --- --- note_invitations に以下のカラムを追加: --- - locale: 招待メールの言語('ja' デフォルト) --- - last_email_sent_at: 直近の送信日時 --- - email_send_count: 送信回数(再送のたびに +1) --- Add columns for email locale, last-sent timestamp, and send counter. - -ALTER TABLE note_invitations - ADD COLUMN IF NOT EXISTS locale TEXT NOT NULL DEFAULT 'ja' - CHECK (locale IN ('ja', 'en')); - -ALTER TABLE note_invitations - ADD COLUMN IF NOT EXISTS last_email_sent_at TIMESTAMPTZ; - -ALTER TABLE note_invitations - ADD COLUMN IF NOT EXISTS email_send_count INTEGER NOT NULL DEFAULT 0; diff --git a/db/migrations/005_add_onboarding_and_page_kind.sql b/db/migrations/005_add_onboarding_and_page_kind.sql deleted file mode 100644 index 7d32bd1a..00000000 --- a/db/migrations/005_add_onboarding_and_page_kind.sql +++ /dev/null @@ -1,69 +0,0 @@ --- 005: ユーザーオンボーディング状況テーブルとページ種別カラムの追加 --- Add user onboarding status table and page kind column --- --- 1. pages.kind を追加('user' / 'welcome' / 'update_notice')。既存ページは全て 'user'。 --- ウェルカムページはオーナーごとに最大 1 件となる部分ユニークインデックスを張る。 --- 2. 新テーブル user_onboarding_status を作成し、セットアップ完了時刻・ウェルカム --- ページ生成状況・ホームスライド表示状況・更新情報自動生成トグルを保持する。 --- --- 1. Add pages.kind column ('user' / 'welcome' / 'update_notice'). Existing rows --- default to 'user'. A partial unique index guarantees at most one live --- welcome page per owner. --- 2. Create user_onboarding_status table tracking setup completion, welcome --- page creation, home slide display, and the auto-update-notice toggle. - --- ---- pages.kind ---------------------------------------------------------- - -ALTER TABLE pages - ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'user' - CHECK (kind IN ('user', 'welcome', 'update_notice')); - -CREATE INDEX IF NOT EXISTS idx_pages_owner_kind ON pages (owner_id, kind); - --- オーナーごとに有効なウェルカムページは最大 1 件 --- At most one live welcome page per owner. -CREATE UNIQUE INDEX IF NOT EXISTS idx_pages_unique_welcome_per_owner - ON pages (owner_id) - WHERE kind = 'welcome' AND is_deleted = false; - --- ---- user_onboarding_status --------------------------------------------- - -CREATE TABLE IF NOT EXISTS user_onboarding_status ( - user_id TEXT PRIMARY KEY REFERENCES "user" (id) ON DELETE CASCADE, - setup_completed_at TIMESTAMPTZ, - welcome_page_created_at TIMESTAMPTZ, - welcome_page_id UUID REFERENCES pages (id) ON DELETE SET NULL, - -- セットアップウィザードで選択したロケール。ログイン時リトライがユーザーの - -- 意図した言語でウェルカムページを生成するために保持する。 - -- Locale chosen at the setup wizard. Retained so login-time retries create - -- the welcome page in the user's originally selected language. - requested_locale TEXT - CHECK (requested_locale IS NULL OR requested_locale IN ('ja', 'en')), - home_slides_shown_at TIMESTAMPTZ, - auto_create_update_notice BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- バックグラウンドリトライ対象(セットアップ済みだがウェルカムページ未生成)の --- 高速検索のための部分インデックス。 --- Partial index for retry lookups (setup completed but welcome page not yet created). -CREATE INDEX IF NOT EXISTS idx_user_onboarding_status_needs_welcome - ON user_onboarding_status (setup_completed_at) - WHERE setup_completed_at IS NOT NULL AND welcome_page_created_at IS NULL; - --- ---- バックフィル / Backfill ----------------------------------------------- --- このマイグレーションが走った時点で既に存在するユーザーは、旧フローで --- セットアップを完了しているとみなして `setup_completed_at = NOW()` を記録する。 --- そうしないと次回ログイン時に全員がオンボーディングウィザードへ戻されてしまう。 --- `welcome_page_created_at` は NULL のままにしておき、バックグラウンドリトライ --- でウェルカムページを生成する余地を残す(ユーザーが望めば)。 --- --- Backfill: mark all existing users as "setup completed" so they are not --- forced back through the wizard on their next visit after this migration --- lands. `welcome_page_created_at` stays NULL so the background retry is --- free to generate a welcome page later (users can dismiss it anytime). -INSERT INTO user_onboarding_status (user_id, setup_completed_at, created_at, updated_at) -SELECT id, NOW(), NOW(), NOW() -FROM "user" -ON CONFLICT (user_id) DO NOTHING; diff --git a/package.json b/package.json index 6fd4e2c0..eb5aabe4 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "format:check": "prettier --check .", "preview": "vite preview", "test": "vitest", - "test:run": "vitest run && vitest run --config packages/claude-sidecar/vitest.config.ts && vitest run --config server/hocuspocus/vitest.config.ts && vitest run --config server/mcp/vitest.config.ts", + "test:run": "vitest run && vitest run --config packages/shared/vitest.config.ts && vitest run --config packages/ui/vitest.config.ts && vitest run --config packages/claude-sidecar/vitest.config.ts && vitest run --config server/hocuspocus/vitest.config.ts && vitest run --config server/mcp/vitest.config.ts && vitest run --config admin/vitest.config.ts", "test:coverage": "vitest run --coverage", "test:ui": "vitest --ui", "test:e2e": "playwright test", @@ -155,6 +155,7 @@ "@tiptap/starter-kit": "^3.20.0", "@tiptap/y-tiptap": "^3.0.2", "@xyflow/react": "^12.10.1", + "@zedi/shared": "workspace:*", "@zedi/ui": "workspace:*", "baseline-browser-mapping": "^2.9.19", "better-auth": "^1.2.0", diff --git a/packages/claude-sidecar/src/handlers/installation.test.ts b/packages/claude-sidecar/src/handlers/installation.test.ts new file mode 100644 index 00000000..0fc02e47 --- /dev/null +++ b/packages/claude-sidecar/src/handlers/installation.test.ts @@ -0,0 +1,126 @@ +/** + * checkClaudeInstallation のユニットテスト + * + * - `claudeVersionArgv` のプラットフォーム別分岐 + * - `Bun.spawn` をモックして 0/非 0 終了・throw 系のシナリオを網羅 + * + * Unit tests for the installation handler. We mock `Bun.spawn` rather than touching the host + * file system so the test passes regardless of whether `claude` is on PATH. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { checkClaudeInstallation, claudeVersionArgv } from "./installation"; + +/** Bun.spawn のテスト用最小スタブ / Minimal stand-in for the bits of Bun.spawn we use. */ +type SpawnStub = { + stdout: ReadableStream; + stderr: ReadableStream; + exited: Promise; +}; + +/** Build a single-chunk ReadableStream from a string. / 文字列から 1 チャンクのストリームを作る。 */ +function streamOf(text: string): ReadableStream { + return new ReadableStream({ + start(controller) { + if (text.length > 0) controller.enqueue(new TextEncoder().encode(text)); + controller.close(); + }, + }); +} + +/** Replace Bun.spawn for one test; return a vi.fn() spy. / 1 テストの間だけ Bun.spawn を差し替える。 */ +function stubBunSpawn(impl: (argv: string[]) => SpawnStub | Promise | never): { + spy: ReturnType; +} { + const spy = vi.fn(impl as (...args: unknown[]) => unknown); + // biome / typescript: Bun.spawn の正確な型を入れずに最低限の差し替えを行う。 + // We intentionally don't import the full Bun typings; the handler only uses {stdout, stderr, exited}. + vi.stubGlobal("Bun", { + ...((globalThis as { Bun?: unknown }).Bun ?? {}), + spawn: spy, + }); + return { spy }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("claudeVersionArgv", () => { + it("uses cmd.exe wrapper on win32 (npm shim resolution)", () => { + expect(claudeVersionArgv("win32")).toEqual(["cmd.exe", "/c", "claude", "--version"]); + }); + + it("returns plain claude argv on darwin", () => { + expect(claudeVersionArgv("darwin")).toEqual(["claude", "--version"]); + }); + + it("returns plain claude argv on linux", () => { + expect(claudeVersionArgv("linux")).toEqual(["claude", "--version"]); + }); + + it("defaults to the host process.platform when not provided", () => { + expect(claudeVersionArgv()).toEqual(claudeVersionArgv(process.platform)); + }); +}); + +describe("checkClaudeInstallation", () => { + it("returns installed=true with stdout version string on exit code 0", async () => { + const { spy } = stubBunSpawn(() => ({ + stdout: streamOf("1.2.3 (Claude Code)\n"), + stderr: streamOf(""), + exited: Promise.resolve(0), + })); + + const result = await checkClaudeInstallation(); + + expect(result).toEqual({ installed: true, version: "1.2.3 (Claude Code)" }); + expect(spy).toHaveBeenCalledOnce(); + const argv = spy.mock.calls[0]?.[0] as string[]; + expect(argv).toEqual(claudeVersionArgv()); + }); + + it("falls back to stderr when stdout is empty", async () => { + // 一部の CLI はバージョンを stderr に書く。 Some CLIs print --version to stderr. + stubBunSpawn(() => ({ + stdout: streamOf(""), + stderr: streamOf("claude 9.9.9\n"), + exited: Promise.resolve(0), + })); + + const result = await checkClaudeInstallation(); + expect(result).toEqual({ installed: true, version: "claude 9.9.9" }); + }); + + it("returns installed=true with no version when both streams are empty", async () => { + stubBunSpawn(() => ({ + stdout: streamOf(""), + stderr: streamOf(""), + exited: Promise.resolve(0), + })); + + const result = await checkClaudeInstallation(); + expect(result).toEqual({ installed: true, version: undefined }); + }); + + it("returns installed=false when the CLI exits non-zero", async () => { + stubBunSpawn(() => ({ + stdout: streamOf(""), + stderr: streamOf("error: cannot find anthropic auth\n"), + exited: Promise.resolve(1), + })); + + const result = await checkClaudeInstallation(); + expect(result).toEqual({ installed: false }); + }); + + it("returns installed=false when spawn throws (CLI not on PATH)", async () => { + // PATH に claude が無いと Bun.spawn 自体が throw する。例外は飲み込み false を返す。 + // When `claude` is not on PATH Bun.spawn throws synchronously; we must catch and report `installed: false`. + stubBunSpawn(() => { + throw new Error("ENOENT: No such file or directory"); + }); + + const result = await checkClaudeInstallation(); + expect(result).toEqual({ installed: false }); + }); +}); diff --git a/packages/claude-sidecar/src/handlers/installation.ts b/packages/claude-sidecar/src/handlers/installation.ts index 57bb7ab8..1bc7f2c6 100644 --- a/packages/claude-sidecar/src/handlers/installation.ts +++ b/packages/claude-sidecar/src/handlers/installation.ts @@ -9,14 +9,18 @@ export interface InstallationCheckResult { version?: string; } -const CLAUDE = process.platform === "win32" ? "claude.exe" : "claude"; - -/** argv for `claude --version` (Windows uses `cmd /c` for npm-style shims). / Windows は npm 系シム解決のため cmd 経由 */ -function claudeVersionArgv(): string[] { - if (process.platform === "win32") { +/** + * argv for `claude --version` (Windows uses `cmd /c` for npm-style shims). + * Windows は npm 系シム解決のため cmd 経由。 + * + * @param platform - Override of `process.platform` for testing. / テスト用に `process.platform` を差し替えるためのオプション。 + * @internal exported for unit testing only. + */ +export function claudeVersionArgv(platform: NodeJS.Platform = process.platform): string[] { + if (platform === "win32") { return ["cmd.exe", "/c", "claude", "--version"]; } - return [CLAUDE, "--version"]; + return ["claude", "--version"]; } /** diff --git a/packages/claude-sidecar/src/handlers/models.test.ts b/packages/claude-sidecar/src/handlers/models.test.ts new file mode 100644 index 00000000..ded2c83d --- /dev/null +++ b/packages/claude-sidecar/src/handlers/models.test.ts @@ -0,0 +1,114 @@ +/** + * listClaudeModels のユニットテスト + * + * - SDK の `query()` を `vi.mock` で差し替え、`initializationResult` の戻り値を + * `ClaudeModelInfo[]` にマップしていること、`finally` で `q.close()` を必ず呼ぶことを検証する。 + * + * Unit tests for {@link listClaudeModels}. The SDK's `query()` is mocked so the test does + * not require a real Claude session. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const queryMock = vi.fn(); +vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ + query: (...args: unknown[]) => queryMock(...args), +})); + +import { listClaudeModels } from "./models"; + +interface FakeQuery { + initializationResult: ReturnType; + close: ReturnType; +} + +/** Build a minimal `Query` stub matching the methods listClaudeModels touches. / 必要メソッドだけのスタブ */ +function fakeQuery(initImpl: () => Promise = async () => ({ models: [] })): FakeQuery { + return { + initializationResult: vi.fn(initImpl), + close: vi.fn(), + }; +} + +beforeEach(() => { + queryMock.mockReset(); +}); + +afterEach(() => { + queryMock.mockReset(); +}); + +describe("listClaudeModels", () => { + it("requests a minimal plan-mode query (maxTurns:0, permissionMode:'plan')", async () => { + const fq = fakeQuery(); + queryMock.mockReturnValue(fq); + + await listClaudeModels(); + + expect(queryMock).toHaveBeenCalledOnce(); + const arg = queryMock.mock.calls[0]?.[0] as { + prompt: string; + options: { maxTurns: number; permissionMode: string }; + }; + expect(arg.prompt).toBe(""); + expect(arg.options.maxTurns).toBe(0); + expect(arg.options.permissionMode).toBe("plan"); + }); + + it("maps initializationResult.models to ClaudeModelInfo[]", async () => { + const fq = fakeQuery(async () => ({ + models: [ + { + value: "claude-opus-4-7", + displayName: "Claude Opus 4.7", + description: "Most capable", + // 余計なフィールドはマップから落ちる / extra fields must be dropped from the result. + extra: "ignored", + }, + { + value: "claude-haiku-4-5", + displayName: "Claude Haiku 4.5", + description: "Fast and cheap", + }, + ], + })); + queryMock.mockReturnValue(fq); + + const models = await listClaudeModels(); + expect(models).toEqual([ + { + value: "claude-opus-4-7", + displayName: "Claude Opus 4.7", + description: "Most capable", + }, + { + value: "claude-haiku-4-5", + displayName: "Claude Haiku 4.5", + description: "Fast and cheap", + }, + ]); + expect(fq.initializationResult).toHaveBeenCalledOnce(); + }); + + it("calls q.close() even when initializationResult rejects", async () => { + const fq = fakeQuery(() => Promise.reject(new Error("init failed"))); + queryMock.mockReturnValue(fq); + + await expect(listClaudeModels()).rejects.toThrow("init failed"); + expect(fq.close).toHaveBeenCalledOnce(); + }); + + it("calls q.close() exactly once on success", async () => { + const fq = fakeQuery(async () => ({ models: [] })); + queryMock.mockReturnValue(fq); + + await listClaudeModels(); + expect(fq.close).toHaveBeenCalledOnce(); + }); + + it("returns an empty array when the SDK exposes no models", async () => { + const fq = fakeQuery(async () => ({ models: [] })); + queryMock.mockReturnValue(fq); + + expect(await listClaudeModels()).toEqual([]); + }); +}); diff --git a/packages/claude-sidecar/src/handlers/query.test.ts b/packages/claude-sidecar/src/handlers/query.test.ts new file mode 100644 index 00000000..edb5b73f --- /dev/null +++ b/packages/claude-sidecar/src/handlers/query.test.ts @@ -0,0 +1,641 @@ +/** + * query.ts のユニットテスト + * + * - 7 つのストリーム抽出ヘルパ (extract*, is*, emitResultOrError) を表駆動で検証する + * - `runQuery` は SDK の `query` をモック化し、stream → SidecarResponse の写像を + * end-to-end で検証する。SDK 自体は触らない。 + * + * Unit tests for the stream-event helpers and the `runQuery` orchestrator. The Claude + * Agent SDK is mocked so this suite is hermetic and runs without network access. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + SDKAssistantMessage, + SDKMessage, + SDKPartialAssistantMessage, + SDKResultMessage, + SDKToolProgressMessage, +} from "@anthropic-ai/claude-agent-sdk"; + +// `runQuery` 内部で SDK の `query()` を呼ぶ箇所をモック化する。 +// Mock the SDK `query()` so runQuery's orchestration is exercised without real network calls. +const queryMock = vi.fn(); + +vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ + query: (...args: unknown[]) => queryMock(...args), +})); + +import { + extractAssistantText, + extractTextDelta, + extractToolUseStart, + emitResultOrError, + isContentBlockStop, + isResultMessage, + isSystemInitMessage, + isToolProgressMessage, + runQuery, +} from "./query"; +import { QueryActivityTracker } from "./status"; +import type { SidecarResponse } from "../protocol"; + +// ── helpers ───────────────────────────────────────────────────────────────── + +/** Build a `stream_event` partial-assistant message wrapping a raw event. / 任意イベントをラップした partial 用テストヘルパ */ +function partial(event: unknown): SDKPartialAssistantMessage { + return { + type: "stream_event", + event, + parent_tool_use_id: null, + uuid: "uuid-partial", + session_id: "sess-1", + } as unknown as SDKPartialAssistantMessage; +} + +/** Build an SDKAssistantMessage with the given content blocks. / 指定 content の assistant メッセージを作る */ +function assistant(content: unknown[]): SDKAssistantMessage { + return { + type: "assistant", + message: { content } as unknown, + parent_tool_use_id: null, + uuid: "uuid-assistant", + session_id: "sess-1", + } as unknown as SDKAssistantMessage; +} + +/** Build a `tool_progress` message. / `tool_progress` テストヘルパ */ +function toolProgress(toolName: string): SDKToolProgressMessage { + return { + type: "tool_progress", + tool_use_id: "tool-1", + tool_name: toolName, + parent_tool_use_id: null, + elapsed_time_seconds: 0.1, + uuid: "uuid-tool", + session_id: "sess-1", + } as unknown as SDKToolProgressMessage; +} + +/** Build a successful `result` message. / 成功 result */ +function resultSuccess(text: string): SDKResultMessage { + return { + type: "result", + subtype: "success", + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + num_turns: 1, + result: text, + stop_reason: "end_turn", + total_cost_usd: 0, + usage: {} as never, + modelUsage: {}, + permission_denials: [], + uuid: "uuid-result", + session_id: "sess-1", + } as unknown as SDKResultMessage; +} + +/** Build an error `result` message with an explicit `errors` array. / エラー result */ +function resultError( + subtype: "error_during_execution" | "error_max_turns", + errors: string[], +): SDKResultMessage { + return { + type: "result", + subtype, + duration_ms: 1, + duration_api_ms: 1, + is_error: true, + num_turns: 1, + stop_reason: null, + total_cost_usd: 0, + usage: {} as never, + modelUsage: {}, + permission_denials: [], + errors, + uuid: "uuid-result", + session_id: "sess-1", + } as unknown as SDKResultMessage; +} + +/** Wraps an array of SDKMessage into the async-iterable shape `runQuery` expects. / 配列を async-iterable 化する */ +function makeQueryIterable(messages: SDKMessage[]): { + [Symbol.asyncIterator](): AsyncIterator; +} { + return { + async *[Symbol.asyncIterator]() { + for (const m of messages) yield m; + }, + }; +} + +// ── helper tests ──────────────────────────────────────────────────────────── + +describe("extractTextDelta", () => { + it("returns the text on a content_block_delta with text_delta", () => { + const msg = partial({ + type: "content_block_delta", + delta: { type: "text_delta", text: "hello" }, + }); + expect(extractTextDelta(msg)).toBe("hello"); + }); + + it("returns null when event is not content_block_delta", () => { + expect(extractTextDelta(partial({ type: "content_block_start" }))).toBeNull(); + }); + + it("returns null when delta is not a text_delta", () => { + const msg = partial({ + type: "content_block_delta", + delta: { type: "input_json_delta", partial_json: "{}" }, + }); + expect(extractTextDelta(msg)).toBeNull(); + }); + + it("returns null when event is missing", () => { + expect(extractTextDelta(partial(undefined))).toBeNull(); + }); + + it("returns null when text is not a string", () => { + const msg = partial({ + type: "content_block_delta", + delta: { type: "text_delta", text: 123 }, + }); + expect(extractTextDelta(msg)).toBeNull(); + }); +}); + +describe("extractToolUseStart", () => { + it("extracts {name, input} when input is a string", () => { + const msg = partial({ + type: "content_block_start", + content_block: { type: "tool_use", name: "Read", input: '{"path":"x"}' }, + }); + expect(extractToolUseStart(msg)).toEqual({ name: "Read", input: '{"path":"x"}' }); + }); + + it("JSON-stringifies non-string input", () => { + const msg = partial({ + type: "content_block_start", + content_block: { type: "tool_use", name: "Bash", input: { command: "ls" } }, + }); + expect(extractToolUseStart(msg)).toEqual({ + name: "Bash", + input: JSON.stringify({ command: "ls" }), + }); + }); + + it("falls back to 'unknown' when name is missing", () => { + const msg = partial({ + type: "content_block_start", + content_block: { type: "tool_use", input: "x" }, + }); + expect(extractToolUseStart(msg)).toEqual({ name: "unknown", input: "x" }); + }); + + it("returns null when content_block is not a tool_use", () => { + const msg = partial({ + type: "content_block_start", + content_block: { type: "text", text: "" }, + }); + expect(extractToolUseStart(msg)).toBeNull(); + }); + + it("returns null when event type is wrong", () => { + expect(extractToolUseStart(partial({ type: "content_block_stop" }))).toBeNull(); + }); +}); + +describe("isContentBlockStop", () => { + it("returns true only for content_block_stop events", () => { + expect(isContentBlockStop(partial({ type: "content_block_stop" }))).toBe(true); + expect(isContentBlockStop(partial({ type: "content_block_delta" }))).toBe(false); + expect(isContentBlockStop(partial(undefined))).toBe(false); + }); +}); + +describe("isToolProgressMessage", () => { + it("identifies tool_progress messages", () => { + expect(isToolProgressMessage(toolProgress("Bash"))).toBe(true); + }); + + it("rejects other message types", () => { + expect(isToolProgressMessage(assistant([]) as unknown as SDKMessage)).toBe(false); + expect(isToolProgressMessage(resultSuccess("ok"))).toBe(false); + }); +}); + +describe("extractAssistantText", () => { + it("concatenates every text block in order", () => { + const text = extractAssistantText( + assistant([ + { type: "text", text: "hello " }, + { type: "tool_use", name: "Read", input: {} }, + { type: "text", text: "world" }, + ]), + ); + expect(text).toBe("hello world"); + }); + + it("returns an empty string when content is missing", () => { + expect(extractAssistantText(assistant(undefined as unknown as unknown[]))).toBe(""); + }); + + it("ignores non-text blocks", () => { + const text = extractAssistantText( + assistant([ + { type: "tool_use", name: "Read", input: {} }, + { type: "tool_result", content: "foo" }, + ]), + ); + expect(text).toBe(""); + }); +}); + +describe("isResultMessage", () => { + it("matches success and error result subtypes", () => { + expect(isResultMessage(resultSuccess("x"))).toBe(true); + expect(isResultMessage(resultError("error_during_execution", []))).toBe(true); + }); + + it("rejects non-result messages", () => { + expect(isResultMessage(toolProgress("x") as unknown as SDKMessage)).toBe(false); + }); +}); + +describe("isSystemInitMessage", () => { + it("matches a system message with subtype: init", () => { + expect(isSystemInitMessage({ type: "system", subtype: "init" } as unknown as SDKMessage)).toBe( + true, + ); + }); + + it("rejects non-init system messages", () => { + expect(isSystemInitMessage({ type: "system", subtype: "other" } as unknown as SDKMessage)).toBe( + false, + ); + }); + + it("rejects messages of other types", () => { + expect(isSystemInitMessage(resultSuccess("x"))).toBe(false); + }); +}); + +describe("emitResultOrError", () => { + it("emits stream-complete with msg.result on success", () => { + const calls: SidecarResponse[] = []; + emitResultOrError("q1", resultSuccess("final"), "agg", (r) => calls.push(r)); + expect(calls).toEqual([{ type: "stream-complete", id: "q1", result: { content: "final" } }]); + }); + + it("falls back to aggregated text when msg.result is missing", () => { + const calls: SidecarResponse[] = []; + const msg = resultSuccess(""); + // Simulate the SDK returning nullish `result` (e.g. older SDK versions). + // 古い SDK で result が null/undefined になるパスを模擬する。 + (msg as { result?: string | null }).result = null; + emitResultOrError("q1", msg, "from-stream", (r) => calls.push(r)); + expect(calls).toEqual([ + { type: "stream-complete", id: "q1", result: { content: "from-stream" } }, + ]); + }); + + it("joins error array with '; ' on error subtypes", () => { + const calls: SidecarResponse[] = []; + emitResultOrError("q1", resultError("error_during_execution", ["one", "two"]), "", (r) => + calls.push(r), + ); + expect(calls).toEqual([ + { + type: "error", + id: "q1", + error: "one; two", + code: "error_during_execution", + }, + ]); + }); + + it("falls back to a generic message when errors[] is empty", () => { + const calls: SidecarResponse[] = []; + emitResultOrError("q1", resultError("error_max_turns", []), "", (r) => calls.push(r)); + expect(calls).toEqual([ + { + type: "error", + id: "q1", + error: "Claude Code finished with subtype error_max_turns", + code: "error_max_turns", + }, + ]); + }); +}); + +// ── runQuery integration ──────────────────────────────────────────────────── + +describe("runQuery", () => { + let writes: string[]; + let tracker: QueryActivityTracker; + + beforeEach(() => { + writes = []; + tracker = new QueryActivityTracker(); + queryMock.mockReset(); + }); + + afterEach(() => { + queryMock.mockReset(); + }); + + /** Parse the JSONL writes into an array of SidecarResponse objects. / writeLine の出力を JSON にして返す */ + function parsed(): SidecarResponse[] { + return writes.map((line) => JSON.parse(line.trim()) as SidecarResponse); + } + + it("forwards default tools and options to the SDK and emits a stream-complete on success", async () => { + const messages: SDKMessage[] = [ + // text delta -> stream-chunk + partial({ + type: "content_block_delta", + delta: { type: "text_delta", text: "hi" }, + }) as unknown as SDKMessage, + resultSuccess("hi"), + ]; + queryMock.mockReturnValue(makeQueryIterable(messages)); + + const ac = new AbortController(); + await runQuery({ + id: "q1", + prompt: "say hi", + writeLine: (l) => writes.push(l), + abortController: ac, + tracker, + }); + + expect(queryMock).toHaveBeenCalledOnce(); + const opts = queryMock.mock.calls[0]?.[0] as { + prompt: string; + options: Record; + }; + expect(opts.prompt).toBe("say hi"); + expect(opts.options.allowedTools).toEqual(["Read", "Write", "Bash", "WebSearch"]); + expect(opts.options.maxTurns).toBe(25); + expect(opts.options.permissionMode).toBe("acceptEdits"); + expect(opts.options.includePartialMessages).toBe(true); + expect(opts.options.mcpServers).toBeUndefined(); + + expect(parsed()).toEqual([ + { type: "stream-chunk", id: "q1", content: "hi" }, + { type: "stream-complete", id: "q1", result: { content: "hi" } }, + ]); + // tracker は finally で end されるので最終的に idle に戻る / tracker ends in finally → idle + expect(tracker.snapshot().status).toBe("idle"); + }); + + it("appends the mcp__* permission when mcpServers are provided", async () => { + queryMock.mockReturnValue(makeQueryIterable([resultSuccess("done")])); + + await runQuery({ + id: "q1", + prompt: "mcp", + mcpServers: { z: { command: "/bin/z" } }, + writeLine: (l) => writes.push(l), + abortController: new AbortController(), + tracker, + }); + + const opts = queryMock.mock.calls[0]?.[0] as { options: { allowedTools: string[] } }; + expect(opts.options.allowedTools).toEqual(["Read", "Write", "Bash", "WebSearch", "mcp__*"]); + }); + + it("respects an explicit allowedTools override", async () => { + queryMock.mockReturnValue(makeQueryIterable([resultSuccess("done")])); + + await runQuery({ + id: "q1", + prompt: "p", + allowedTools: ["Bash"], + writeLine: (l) => writes.push(l), + abortController: new AbortController(), + tracker, + }); + + const opts = queryMock.mock.calls[0]?.[0] as { options: { allowedTools: string[] } }; + expect(opts.options.allowedTools).toEqual(["Bash"]); + }); + + it("emits tool-use-start and tool-use-complete around a tool block", async () => { + queryMock.mockReturnValue( + makeQueryIterable([ + partial({ + type: "content_block_start", + content_block: { type: "tool_use", name: "Read", input: '{"path":"a"}' }, + }) as unknown as SDKMessage, + partial({ type: "content_block_stop" }) as unknown as SDKMessage, + resultSuccess("ok"), + ]), + ); + + await runQuery({ + id: "q1", + prompt: "p", + writeLine: (l) => writes.push(l), + abortController: new AbortController(), + tracker, + }); + + expect(parsed()).toEqual([ + { type: "tool-use-start", id: "q1", toolName: "Read", toolInput: '{"path":"a"}' }, + { type: "tool-use-complete", id: "q1", toolName: "Read" }, + { type: "stream-complete", id: "q1", result: { content: "ok" } }, + ]); + }); + + it("aggregates text deltas before falling back to assistant slicing", async () => { + // partial deltas が "Hello " を集約した後、assistant は "Hello world" を持つ。 + // 残りの " world" のみが新しい delta として出力されるはず。 + // After "Hello " is aggregated via deltas, the assistant message contains + // "Hello world"; only the trailing " world" should be emitted as a new chunk. + queryMock.mockReturnValue( + makeQueryIterable([ + partial({ + type: "content_block_delta", + delta: { type: "text_delta", text: "Hello " }, + }) as unknown as SDKMessage, + assistant([{ type: "text", text: "Hello world" }]) as unknown as SDKMessage, + resultSuccess("Hello world"), + ]), + ); + + await runQuery({ + id: "q1", + prompt: "p", + writeLine: (l) => writes.push(l), + abortController: new AbortController(), + tracker, + }); + + expect(parsed()).toEqual([ + { type: "stream-chunk", id: "q1", content: "Hello " }, + { type: "stream-chunk", id: "q1", content: "world" }, + { type: "stream-complete", id: "q1", result: { content: "Hello world" } }, + ]); + }); + + it("starts a new tool when tool_progress reports a different tool, completing the previous one", async () => { + queryMock.mockReturnValue( + makeQueryIterable([ + toolProgress("ToolA") as unknown as SDKMessage, + toolProgress("ToolA") as unknown as SDKMessage, + toolProgress("ToolB") as unknown as SDKMessage, + resultSuccess(""), + ]), + ); + + await runQuery({ + id: "q1", + prompt: "p", + writeLine: (l) => writes.push(l), + abortController: new AbortController(), + tracker, + }); + + expect(parsed()).toEqual([ + { type: "tool-use-start", id: "q1", toolName: "ToolA", toolInput: "" }, + { type: "tool-use-complete", id: "q1", toolName: "ToolA" }, + { type: "tool-use-start", id: "q1", toolName: "ToolB", toolInput: "" }, + { type: "tool-use-complete", id: "q1", toolName: "ToolB" }, + { type: "stream-complete", id: "q1", result: { content: "" } }, + ]); + }); + + it("emits mcp-status when system init carries non-empty mcp_servers", async () => { + queryMock.mockReturnValue( + makeQueryIterable([ + { + type: "system", + subtype: "init", + mcp_servers: [ + { + name: "zedi", + status: "connected", + tools: [{ name: "search", description: "Find" }], + }, + { name: "broken", status: "error", error: "boom" }, + ], + } as unknown as SDKMessage, + resultSuccess(""), + ]), + ); + + await runQuery({ + id: "q1", + prompt: "p", + writeLine: (l) => writes.push(l), + abortController: new AbortController(), + tracker, + }); + + const responses = parsed(); + expect(responses[0]).toEqual({ + type: "mcp-status", + id: "q1", + servers: [ + { + name: "zedi", + status: "connected", + error: undefined, + tools: [{ name: "search", description: "Find" }], + }, + { name: "broken", status: "error", error: "boom", tools: undefined }, + ], + }); + }); + + it("converts a result error into an error response", async () => { + queryMock.mockReturnValue( + makeQueryIterable([resultError("error_max_turns", ["limit reached"])]), + ); + + await runQuery({ + id: "q1", + prompt: "p", + writeLine: (l) => writes.push(l), + abortController: new AbortController(), + tracker, + }); + + expect(parsed()).toEqual([ + { type: "error", id: "q1", error: "limit reached", code: "error_max_turns" }, + ]); + }); + + it("catches synchronous SDK exceptions and emits a query_exception error", async () => { + queryMock.mockImplementation(() => { + throw new Error("SDK exploded"); + }); + + await runQuery({ + id: "q1", + prompt: "p", + writeLine: (l) => writes.push(l), + abortController: new AbortController(), + tracker, + }); + + expect(parsed()).toEqual([ + { type: "error", id: "q1", error: "SDK exploded", code: "query_exception" }, + ]); + expect(tracker.snapshot().status).toBe("idle"); + }); + + it("emits an aborted error when the abort signal is fired before the result arrives", async () => { + const ac = new AbortController(); + // Trigger abort before iteration begins so the loop sees it on the first message. + // 反復開始前に abort して、最初のメッセージでループを抜けるようにする。 + ac.abort(); + queryMock.mockReturnValue( + makeQueryIterable([ + partial({ + type: "content_block_delta", + delta: { type: "text_delta", text: "x" }, + }) as unknown as SDKMessage, + ]), + ); + + await runQuery({ + id: "q1", + prompt: "p", + writeLine: (l) => writes.push(l), + abortController: ac, + tracker, + }); + + expect(parsed()).toEqual([ + { type: "error", id: "q1", error: "Query aborted", code: "aborted" }, + ]); + }); + + it("emits stream-complete with the aggregated text when the stream ends without a result", async () => { + queryMock.mockReturnValue( + makeQueryIterable([ + partial({ + type: "content_block_delta", + delta: { type: "text_delta", text: "partial" }, + }) as unknown as SDKMessage, + ]), + ); + + await runQuery({ + id: "q1", + prompt: "p", + writeLine: (l) => writes.push(l), + abortController: new AbortController(), + tracker, + }); + + expect(parsed()).toEqual([ + { type: "stream-chunk", id: "q1", content: "partial" }, + { type: "stream-complete", id: "q1", result: { content: "partial" } }, + ]); + }); +}); diff --git a/packages/claude-sidecar/src/handlers/query.ts b/packages/claude-sidecar/src/handlers/query.ts index a70e56e0..abaa93f1 100644 --- a/packages/claude-sidecar/src/handlers/query.ts +++ b/packages/claude-sidecar/src/handlers/query.ts @@ -21,7 +21,13 @@ export type WriteLine = (line: string) => void; const DEFAULT_TOOLS = ["Read", "Write", "Bash", "WebSearch"] as const; -function extractTextDelta(msg: SDKPartialAssistantMessage): string | null { +/** + * Extracts a text delta from a `content_block_delta` stream event. + * `content_block_delta` ストリームイベントからテキスト delta を取り出す。 + * + * @internal exported for unit testing only. + */ +export function extractTextDelta(msg: SDKPartialAssistantMessage): string | null { const ev = msg.event as unknown as Record | undefined; if (!ev || typeof ev !== "object") return null; if (ev.type !== "content_block_delta") return null; @@ -34,8 +40,10 @@ function extractTextDelta(msg: SDKPartialAssistantMessage): string | null { /** * Detects a tool_use content_block_start from a stream event. * ストリームイベントから tool_use の content_block_start を検出する。 + * + * @internal exported for unit testing only. */ -function extractToolUseStart( +export function extractToolUseStart( msg: SDKPartialAssistantMessage, ): { name: string; input: string } | null { const ev = msg.event as unknown as Record | undefined; @@ -51,17 +59,31 @@ function extractToolUseStart( /** * Detects a content_block_stop from a stream event (to mark tool use complete). * ストリームイベントから content_block_stop を検出する。 + * + * @internal exported for unit testing only. */ -function isContentBlockStop(msg: SDKPartialAssistantMessage): boolean { +export function isContentBlockStop(msg: SDKPartialAssistantMessage): boolean { const ev = msg.event as { type?: string } | undefined; return ev?.type === "content_block_stop"; } -function isToolProgressMessage(msg: SDKMessage): msg is SDKToolProgressMessage { +/** + * Type guard for `tool_progress` messages emitted by the SDK. + * SDK が送出する `tool_progress` メッセージかを判定する型ガード。 + * + * @internal exported for unit testing only. + */ +export function isToolProgressMessage(msg: SDKMessage): msg is SDKToolProgressMessage { return typeof msg === "object" && msg !== null && "type" in msg && msg.type === "tool_progress"; } -function extractAssistantText(msg: SDKAssistantMessage): string { +/** + * Concatenates text blocks contained in an assistant message. + * assistant メッセージ内のテキストブロックを連結する。 + * + * @internal exported for unit testing only. + */ +export function extractAssistantText(msg: SDKAssistantMessage): string { const message = msg.message as { content?: unknown }; const content = message.content; if (!Array.isArray(content)) return ""; @@ -80,15 +102,23 @@ function extractAssistantText(msg: SDKAssistantMessage): string { return out; } -function isResultMessage(msg: SDKMessage): msg is SDKResultMessage { +/** + * Type guard for SDK `result` messages. + * SDK の `result` メッセージかを判定する型ガード。 + * + * @internal exported for unit testing only. + */ +export function isResultMessage(msg: SDKMessage): msg is SDKResultMessage { return typeof msg === "object" && msg !== null && "type" in msg && msg.type === "result"; } /** * Checks if a message is a system init message containing MCP server status. * system init メッセージ(MCP サーバーステータスを含む)かどうかを判定する。 + * + * @internal exported for unit testing only. */ -function isSystemInitMessage(msg: SDKMessage): boolean { +export function isSystemInitMessage(msg: SDKMessage): boolean { return ( typeof msg === "object" && msg !== null && @@ -102,8 +132,10 @@ function isSystemInitMessage(msg: SDKMessage): boolean { /** * Emits stream-complete on success or error line on failure. * 成功時は stream-complete、失敗時は error 行を出す。 + * + * @internal exported for unit testing only. */ -function emitResultOrError( +export function emitResultOrError( id: string, msg: SDKResultMessage, aggregated: string, diff --git a/packages/claude-sidecar/src/handlers/status.test.ts b/packages/claude-sidecar/src/handlers/status.test.ts new file mode 100644 index 00000000..13af2a67 --- /dev/null +++ b/packages/claude-sidecar/src/handlers/status.test.ts @@ -0,0 +1,84 @@ +/** + * QueryActivityTracker のユニットテスト + * + * - start / end / clearAll の Set ベースの状態追跡 + * - snapshot() の status / activeQueryIds が常に正しい形になること + * + * Unit tests for the {@link QueryActivityTracker} state machine. + */ +import { describe, expect, it } from "vitest"; +import { QueryActivityTracker } from "./status"; + +describe("QueryActivityTracker", () => { + it("starts empty with status 'idle'", () => { + const tracker = new QueryActivityTracker(); + const snap = tracker.snapshot(); + expect(snap.status).toBe("idle"); + expect(snap.activeQueryIds).toEqual([]); + }); + + it("transitions to 'processing' after start()", () => { + const tracker = new QueryActivityTracker(); + tracker.start("q-1"); + const snap = tracker.snapshot(); + expect(snap.status).toBe("processing"); + expect(snap.activeQueryIds).toEqual(["q-1"]); + }); + + it("returns to 'idle' after every running query ends", () => { + const tracker = new QueryActivityTracker(); + tracker.start("q-1"); + tracker.start("q-2"); + tracker.end("q-1"); + expect(tracker.snapshot().status).toBe("processing"); + expect(tracker.snapshot().activeQueryIds).toEqual(["q-2"]); + tracker.end("q-2"); + expect(tracker.snapshot()).toEqual({ status: "idle", activeQueryIds: [] }); + }); + + it("dedupes duplicate start() calls (Set semantics)", () => { + // 重複 start でも 1 ID として扱う / Duplicate `start()` calls collapse to a single id. + const tracker = new QueryActivityTracker(); + tracker.start("q-1"); + tracker.start("q-1"); + expect(tracker.snapshot().activeQueryIds).toEqual(["q-1"]); + tracker.end("q-1"); + expect(tracker.snapshot().status).toBe("idle"); + }); + + it("end() on an unknown id is a no-op", () => { + // 未知の ID を end() しても例外にならず状態も変えない / Unknown end() must not throw or mutate state. + const tracker = new QueryActivityTracker(); + tracker.start("q-1"); + expect(() => tracker.end("does-not-exist")).not.toThrow(); + expect(tracker.snapshot().activeQueryIds).toEqual(["q-1"]); + }); + + it("clearAll() empties active ids without aborting controllers", () => { + // shutdown 用: 状態だけクリアし、AbortController には触れない / clearAll only clears tracked ids. + const tracker = new QueryActivityTracker(); + tracker.start("a"); + tracker.start("b"); + tracker.clearAll(); + expect(tracker.snapshot()).toEqual({ status: "idle", activeQueryIds: [] }); + }); + + it("snapshot() returns an isolated array (mutation safety)", () => { + // 返却された配列を改変しても内部状態には影響しないこと / Returned array must be a copy. + const tracker = new QueryActivityTracker(); + tracker.start("q-1"); + const snap = tracker.snapshot(); + snap.activeQueryIds.push("rogue"); + expect(tracker.snapshot().activeQueryIds).toEqual(["q-1"]); + }); + + it("preserves insertion order of active query ids", () => { + // Set の挿入順を保つことに依存するクライアントがあるため、順序保証を明示する。 + // Some clients depend on insertion order, so we explicitly assert it. + const tracker = new QueryActivityTracker(); + tracker.start("third"); + tracker.start("first"); + tracker.start("second"); + expect(tracker.snapshot().activeQueryIds).toEqual(["third", "first", "second"]); + }); +}); diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 00000000..f641b62e --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,18 @@ +{ + "name": "@zedi/shared", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Source-of-truth constants shared between client (src, admin) and server packages. / クライアント (src, admin) とサーバ (server/api 等) で共有する定数モジュール。", + "exports": { + ".": "./src/index.ts", + "./tagCharacterClass": "./src/tagCharacterClass.ts" + }, + "scripts": { + "test": "vitest run" + }, + "devDependencies": { + "typescript": "^6.0.2", + "vitest": "^4.0.16" + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 00000000..a6121744 --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,9 @@ +/** + * `@zedi/shared` のエントリ。サーバ/クライアント/管理画面すべてで共有可能な + * ピュアな定数だけをここに集約する。React や Node 専用 API には依存しない。 + * + * Entry point for `@zedi/shared`. Holds pure constants that can be imported + * from server, client, and admin code alike. Must not depend on React or + * Node-only APIs so the package stays universally importable. + */ +export { TAG_NAME_CHAR_CLASS } from "./tagCharacterClass.js"; diff --git a/packages/shared/src/tagCharacterClass.test.ts b/packages/shared/src/tagCharacterClass.test.ts new file mode 100644 index 00000000..84b6762a --- /dev/null +++ b/packages/shared/src/tagCharacterClass.test.ts @@ -0,0 +1,44 @@ +/** + * `TAG_NAME_CHAR_CLASS` の最低限のスペックテスト。文字列リテラル仕様(正規表現 + * の文字クラス内側のみ、グローバル/アンカー無し)と、組み立てた正規表現が + * 期待通りの字種を受理/拒否することを確認する。 + * + * Lock down the minimum contract for `TAG_NAME_CHAR_CLASS`: it is the inner + * contents of a regex character class (no flags, no anchors), and a regex + * built from it accepts/rejects the documented script families. + */ +import { describe, it, expect } from "vitest"; + +import { TAG_NAME_CHAR_CLASS } from "./tagCharacterClass.js"; + +describe("TAG_NAME_CHAR_CLASS", () => { + it("contains no character-class brackets so callers can wrap it in `[...]`", () => { + // 完成した `[...]` ではなく中身だけを公開するという契約を固定する。 + // Lock the "inner contents only" contract so wrappers stay correct. + expect(TAG_NAME_CHAR_CLASS.startsWith("[")).toBe(false); + expect(TAG_NAME_CHAR_CLASS.endsWith("]")).toBe(false); + }); + + it("accepts ASCII letters, digits, underscore, and hyphen", () => { + const re = new RegExp(`^[${TAG_NAME_CHAR_CLASS}]+$`); + expect(re.test("Foo_bar-1")).toBe(true); + expect(re.test("ABCxyz089")).toBe(true); + }); + + it("accepts hiragana, katakana, and CJK characters", () => { + const re = new RegExp(`^[${TAG_NAME_CHAR_CLASS}]+$`); + // ひらがな・カタカナ・漢字。 + expect(re.test("ひらがな")).toBe(true); + expect(re.test("カタカナ")).toBe(true); + expect(re.test("日本語")).toBe(true); + expect(re.test("混合Mix日本語")).toBe(true); + }); + + it("rejects whitespace and ASCII punctuation outside the allowed set", () => { + const re = new RegExp(`^[${TAG_NAME_CHAR_CLASS}]+$`); + expect(re.test("has space")).toBe(false); + expect(re.test("dot.notation")).toBe(false); + expect(re.test("slash/sep")).toBe(false); + expect(re.test("emoji😀")).toBe(false); + }); +}); diff --git a/packages/shared/src/tagCharacterClass.ts b/packages/shared/src/tagCharacterClass.ts new file mode 100644 index 00000000..a508aaf4 --- /dev/null +++ b/packages/shared/src/tagCharacterClass.ts @@ -0,0 +1,41 @@ +/** + * タグ名 (`#name`) として許容する文字の集合を 1 ヶ所に集約した定数。 + * 正規表現の文字クラス (`[...]`) の **中身だけ** を提供する。完成した正規表現 + * を共有しないのは、貼り付け検出 (client) と名前検証 (server) で + * フラグ・アンカー・先読みが異なるため。両者には同じ文字集合だけを揃えれば + * 十分で、用途依存の組み立ては各呼び出し側に任せる方がドリフトに強い。 + * + * Single source of truth for the character set allowed inside a tag name + * (`#name`). Exposes only the **inner contents** of a regex character class + * (`[...]`). The full regex is intentionally not shared because the + * paste-detection regex (client) and the name-validation regex (server) need + * different flags, anchors, and look-arounds. Sharing only the character set + * keeps drift-prone surface area minimal. + * + * 含まれる字種 / Included scripts: + * - 半角英数字: `A-Za-z0-9` + * - 区切り: アンダースコア `_`、ハイフン `-` + * - ひらがな (Hiragana, Unicode block U+3040..U+309F) + * - カタカナ (Katakana, Unicode block U+30A0..U+30FF) + * ひらがなとカタカナは別ブロックだが、`぀-ヿ` (U+3040..U+30FF) の単一範囲で + * 両ブロックを連続して覆える。 + * Hiragana and Katakana are distinct Unicode blocks but the single range + * `぀-ヿ` (U+3040..U+30FF) covers both contiguously. + * - CJK 統合漢字 + 拡張 A: U+3400..U+9FFF (`㐀-鿿`) + * CJK Unified Ideographs + Extension A. + * + * 同期義務 / Sync obligation: + * - 本ファイルを編集したら、`server/api/src/services/ydocRenameRewrite.ts` + * の `TAG_NAME_CHAR_CLASS_STRING` も一致させること。`server/api` はワーク + * スペース外(自前の `bun.lock` を持つ Railway ビルド)なのでこの定数を + * 直接 import できない。代わりに `src/lib/tagCharacterClassSync.test.ts` + * が両者の文字列一致を CI でチェックする。 + * + * When this file changes, also update + * `server/api/src/services/ydocRenameRewrite.ts`'s + * `TAG_NAME_CHAR_CLASS_STRING` to match. `server/api` lives outside the + * Bun workspace (its own `bun.lock` is consumed by Railway), so it cannot + * import this constant. `src/lib/tagCharacterClassSync.test.ts` enforces + * the equality in CI. + */ +export const TAG_NAME_CHAR_CLASS = "A-Za-z0-9_\\-぀-ヿ㐀-鿿"; diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 00000000..e4bd6558 --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "types": [] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts new file mode 100644 index 00000000..9ca71dba --- /dev/null +++ b/packages/shared/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + root: import.meta.dirname, + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +}); diff --git a/packages/ui/package.json b/packages/ui/package.json index 87dbcbdb..ab9fccee 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -10,6 +10,10 @@ "./hooks/use-toast": "./src/hooks/use-toast.ts", "./components/sonner": "./src/components/sonner.tsx" }, + "scripts": { + "test": "vitest --config vitest.config.ts", + "test:run": "vitest run --config vitest.config.ts" + }, "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/packages/ui/src/components/sidebar/SidebarProvider.test.tsx b/packages/ui/src/components/sidebar/SidebarProvider.test.tsx new file mode 100644 index 00000000..8422ff6a --- /dev/null +++ b/packages/ui/src/components/sidebar/SidebarProvider.test.tsx @@ -0,0 +1,211 @@ +/** + * SidebarProvider のテスト。 + * - 初期値(cookie / defaultOpen)の解決 + * - open/close の cookie 永続化 + * - controlled モード(open prop / onOpenChange) + * - キーボードショートカット(Ctrl/Meta + b) + * + * Tests for SidebarProvider. + */ +import * as React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { act, render, screen } from "@testing-library/react"; +import { SidebarProvider } from "./SidebarProvider"; +import { useSidebar } from "./useSidebar"; +import { SIDEBAR_COOKIE_NAME, SIDEBAR_KEYBOARD_SHORTCUT } from "./sidebarConstants"; + +function clearCookies(): void { + for (const part of document.cookie.split(";")) { + const name = part.split("=")[0]?.trim(); + if (name) document.cookie = `${name}=; path=/; max-age=0`; + } +} + +interface ProbeProps { + onCtx?: (ctx: ReturnType) => void; +} + +function Probe({ onCtx }: ProbeProps): React.JSX.Element { + const ctx = useSidebar(); + onCtx?.(ctx); + return ( +
+ {ctx.state} + {String(ctx.open)} + +
+ ); +} + +describe("SidebarProvider initial state", () => { + beforeEach(() => { + clearCookies(); + }); + + it("Cookie が無ければ defaultOpen に従う / falls back to defaultOpen", () => { + render( + + + , + ); + expect(screen.getByTestId("open").textContent).toBe("false"); + expect(screen.getByTestId("state").textContent).toBe("collapsed"); + }); + + it("Cookie の値が defaultOpen より優先される / cookie wins over defaultOpen", () => { + document.cookie = `${SIDEBAR_COOKIE_NAME}=true; path=/`; + render( + + + , + ); + expect(screen.getByTestId("open").textContent).toBe("true"); + expect(screen.getByTestId("state").textContent).toBe("expanded"); + }); +}); + +describe("SidebarProvider toggle / cookie persistence", () => { + beforeEach(() => { + clearCookies(); + }); + + it("toggleSidebar で開閉が切り替わり cookie に永続化される", () => { + render( + + + , + ); + expect(screen.getByTestId("open").textContent).toBe("false"); + + act(() => { + screen.getByTestId("toggle").click(); + }); + + expect(screen.getByTestId("open").textContent).toBe("true"); + expect(document.cookie).toContain(`${SIDEBAR_COOKIE_NAME}=true`); + }); +}); + +describe("SidebarProvider controlled mode", () => { + beforeEach(() => { + clearCookies(); + }); + + it("open prop で controlled になり、toggle は onOpenChange を呼ぶ", () => { + const onOpenChange = vi.fn(); + function Wrapper(): React.JSX.Element { + const [open, setOpen] = React.useState(true); + return ( + { + onOpenChange(v); + setOpen(v); + }} + > + + + ); + } + render(); + expect(screen.getByTestId("open").textContent).toBe("true"); + + act(() => { + screen.getByTestId("toggle").click(); + }); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(screen.getByTestId("open").textContent).toBe("false"); + }); +}); + +describe("SidebarProvider keyboard shortcut", () => { + beforeEach(() => { + clearCookies(); + }); + + function dispatchShortcut(modifier: "ctrl" | "meta", target?: HTMLElement): KeyboardEvent { + const evt = new KeyboardEvent("keydown", { + key: SIDEBAR_KEYBOARD_SHORTCUT, + ctrlKey: modifier === "ctrl", + metaKey: modifier === "meta", + bubbles: true, + cancelable: true, + }); + (target ?? window).dispatchEvent(evt); + return evt; + } + + it("Ctrl+B でトグルされ preventDefault される", () => { + render( + + + , + ); + let evt: KeyboardEvent | undefined; + act(() => { + evt = dispatchShortcut("ctrl"); + }); + expect(screen.getByTestId("open").textContent).toBe("true"); + expect(evt?.defaultPrevented).toBe(true); + }); + + it("Meta+B でも動作する / works with Meta key as well", () => { + render( + + + , + ); + act(() => { + dispatchShortcut("meta"); + }); + expect(screen.getByTestId("open").textContent).toBe("true"); + }); + + it("INPUT にフォーカス中はショートカットを無視する / ignores shortcut while typing in inputs", () => { + render( + + + + , + ); + + const input = screen.getByTestId("input") as HTMLInputElement; + input.focus(); + + act(() => { + const evt = new KeyboardEvent("keydown", { + key: SIDEBAR_KEYBOARD_SHORTCUT, + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + input.dispatchEvent(evt); + }); + + expect(screen.getByTestId("open").textContent).toBe("false"); + }); + + it("contenteditable 要素でも無視する / ignores shortcut on contenteditable", () => { + render( + +
+ + , + ); + + const editor = screen.getByTestId("editor"); + act(() => { + const evt = new KeyboardEvent("keydown", { + key: SIDEBAR_KEYBOARD_SHORTCUT, + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + editor.dispatchEvent(evt); + }); + + expect(screen.getByTestId("open").textContent).toBe("false"); + }); +}); diff --git a/packages/ui/src/components/sidebar/sidebarConstants.test.ts b/packages/ui/src/components/sidebar/sidebarConstants.test.ts new file mode 100644 index 00000000..a3400959 --- /dev/null +++ b/packages/ui/src/components/sidebar/sidebarConstants.test.ts @@ -0,0 +1,67 @@ +/** + * sidebarConstants の Cookie パースロジックをテストする。 + * Tests for the cookie parsing logic in sidebarConstants. + */ +import { describe, it, expect, beforeEach } from "vitest"; +import { + readSidebarOpenFromCookie, + SIDEBAR_COOKIE_NAME, + SIDEBAR_COOKIE_MAX_AGE, + SIDEBAR_KEYBOARD_SHORTCUT, + SIDEBAR_WIDTH, + SIDEBAR_WIDTH_ICON, + SIDEBAR_WIDTH_MOBILE, +} from "./sidebarConstants"; + +function clearCookies(): void { + for (const part of document.cookie.split(";")) { + const name = part.split("=")[0]?.trim(); + if (name) document.cookie = `${name}=; path=/; max-age=0`; + } +} + +describe("sidebarConstants - module exports", () => { + it("Cookie 名と max-age(7 日)/ exposes name and 7-day max-age", () => { + expect(SIDEBAR_COOKIE_NAME).toBe("sidebar:state"); + expect(SIDEBAR_COOKIE_MAX_AGE).toBe(60 * 60 * 24 * 7); + }); + + it("ショートカットキーとレイアウト幅の定数 / exposes shortcut and layout widths", () => { + expect(SIDEBAR_KEYBOARD_SHORTCUT).toBe("b"); + expect(SIDEBAR_WIDTH).toBe("14rem"); + expect(SIDEBAR_WIDTH_MOBILE).toBe("18rem"); + expect(SIDEBAR_WIDTH_ICON).toBe("3rem"); + }); +}); + +describe("readSidebarOpenFromCookie", () => { + beforeEach(() => { + clearCookies(); + }); + + it("Cookie 未設定なら null / returns null when cookie is not set", () => { + expect(readSidebarOpenFromCookie()).toBeNull(); + }); + + it("`sidebar:state=true` のとき true / returns true when set to 'true'", () => { + document.cookie = `${SIDEBAR_COOKIE_NAME}=true; path=/`; + expect(readSidebarOpenFromCookie()).toBe(true); + }); + + it("`sidebar:state=false` のとき false / returns false when set to 'false'", () => { + document.cookie = `${SIDEBAR_COOKIE_NAME}=false; path=/`; + expect(readSidebarOpenFromCookie()).toBe(false); + }); + + it("値が `true`/`false` 以外のときは null / returns null for other values", () => { + document.cookie = `${SIDEBAR_COOKIE_NAME}=open; path=/`; + expect(readSidebarOpenFromCookie()).toBeNull(); + }); + + it("空白付きの Cookie 並びでも正しく解釈する / handles whitespace-separated cookies", () => { + document.cookie = `other=1; path=/`; + document.cookie = `${SIDEBAR_COOKIE_NAME}=true; path=/`; + document.cookie = `another=2; path=/`; + expect(readSidebarOpenFromCookie()).toBe(true); + }); +}); diff --git a/packages/ui/src/hooks/use-mobile.test.tsx b/packages/ui/src/hooks/use-mobile.test.tsx new file mode 100644 index 00000000..36b18847 --- /dev/null +++ b/packages/ui/src/hooks/use-mobile.test.tsx @@ -0,0 +1,97 @@ +/** + * useIsMobile のテスト。 + * - matchMedia の subscribe / snapshot を介して値が更新されること + * - breakpoint 定数の妥当性 + * + * Tests for useIsMobile. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { MOBILE_BREAKPOINT, useIsMobile } from "./use-mobile"; + +interface MockMql { + matches: boolean; + listeners: Set<(e: MediaQueryListEvent) => void>; +} + +function installMatchMedia(initialMatches: boolean): { mql: MockMql; restore: () => void } { + const mql: MockMql = { matches: initialMatches, listeners: new Set() }; + const original = window.matchMedia; + window.matchMedia = vi.fn().mockImplementation(() => ({ + get matches() { + return mql.matches; + }, + media: "", + onchange: null, + addEventListener: (_evt: string, listener: (e: MediaQueryListEvent) => void) => { + mql.listeners.add(listener); + }, + removeEventListener: (_evt: string, listener: (e: MediaQueryListEvent) => void) => { + mql.listeners.delete(listener); + }, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; + return { + mql, + restore: () => { + window.matchMedia = original; + }, + }; +} + +describe("MOBILE_BREAKPOINT", () => { + it("Tailwind の md (768) と一致 / matches Tailwind md breakpoint", () => { + expect(MOBILE_BREAKPOINT).toBe(768); + }); +}); + +describe("useIsMobile", () => { + let cleanup: (() => void) | null = null; + + beforeEach(() => { + cleanup = null; + }); + + afterEach(() => { + cleanup?.(); + }); + + it("初回スナップショットが false なら false を返す / returns initial snapshot value", () => { + const { mql, restore } = installMatchMedia(false); + cleanup = restore; + const { result } = renderHook(() => useIsMobile()); + expect(result.current).toBe(false); + expect(mql.listeners.size).toBeGreaterThan(0); + }); + + it("初回スナップショットが true なら true を返す / returns true when viewport is mobile", () => { + const { restore } = installMatchMedia(true); + cleanup = restore; + const { result } = renderHook(() => useIsMobile()); + expect(result.current).toBe(true); + }); + + it("media query 変化で値が更新される / updates on media query change", () => { + const { mql, restore } = installMatchMedia(false); + cleanup = restore; + const { result } = renderHook(() => useIsMobile()); + expect(result.current).toBe(false); + + act(() => { + mql.matches = true; + mql.listeners.forEach((l) => l({ matches: true } as MediaQueryListEvent)); + }); + expect(result.current).toBe(true); + }); + + it("アンマウント時に listener が解除される / unsubscribes on unmount", () => { + const { mql, restore } = installMatchMedia(false); + cleanup = restore; + const { unmount } = renderHook(() => useIsMobile()); + expect(mql.listeners.size).toBe(1); + unmount(); + expect(mql.listeners.size).toBe(0); + }); +}); diff --git a/packages/ui/src/hooks/use-toast.test.ts b/packages/ui/src/hooks/use-toast.test.ts new file mode 100644 index 00000000..684d3524 --- /dev/null +++ b/packages/ui/src/hooks/use-toast.test.ts @@ -0,0 +1,154 @@ +/** + * use-toast のテスト。 + * - reducer の state 遷移(ADD / UPDATE / DISMISS / REMOVE) + * - useToast フックの subscription / dispatch 経路 + * + * Tests for the use-toast module. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { reducer, toast, useToast } from "./use-toast"; + +type State = ReturnType; +type Toast = State["toasts"][number]; + +const makeToast = (id: string, overrides: Partial = {}): Toast => + ({ + id, + open: true, + title: id, + ...overrides, + }) as Toast; + +describe("use-toast reducer", () => { + it("ADD_TOAST は配列の先頭に追加し、TOAST_LIMIT(=1) で切り詰める", () => { + const after = reducer({ toasts: [] }, { type: "ADD_TOAST", toast: makeToast("a") }); + expect(after.toasts).toHaveLength(1); + expect(after.toasts[0]?.id).toBe("a"); + + const second = reducer(after, { type: "ADD_TOAST", toast: makeToast("b") }); + // 上限 1 件で切り詰められる + expect(second.toasts).toHaveLength(1); + expect(second.toasts[0]?.id).toBe("b"); + }); + + it("UPDATE_TOAST は同じ id の toast をマージする / merges fields by id", () => { + const initial: State = { toasts: [makeToast("a", { title: "old" })] }; + const after = reducer(initial, { + type: "UPDATE_TOAST", + toast: { id: "a", title: "new" } as Partial, + }); + expect(after.toasts[0]?.title).toBe("new"); + }); + + it("DISMISS_TOAST(id) は対象を open:false にする / closes only the targeted toast", () => { + const initial: State = { + toasts: [makeToast("a"), makeToast("b")], + }; + const after = reducer(initial, { type: "DISMISS_TOAST", toastId: "a" }); + const a = after.toasts.find((t) => t.id === "a"); + const b = after.toasts.find((t) => t.id === "b"); + expect(a?.open).toBe(false); + expect(b?.open).toBe(true); + }); + + it("DISMISS_TOAST(undefined) は全 toast を open:false にする / closes all toasts", () => { + const initial: State = { + toasts: [makeToast("a"), makeToast("b")], + }; + const after = reducer(initial, { type: "DISMISS_TOAST" }); + expect(after.toasts.every((t) => t.open === false)).toBe(true); + }); + + it("REMOVE_TOAST(id) は配列から該当 toast を消す / removes toast by id", () => { + const initial: State = { toasts: [makeToast("a"), makeToast("b")] }; + const after = reducer(initial, { type: "REMOVE_TOAST", toastId: "a" }); + expect(after.toasts).toHaveLength(1); + expect(after.toasts[0]?.id).toBe("b"); + }); + + it("REMOVE_TOAST(undefined) は配列を空にする / clears all toasts", () => { + const initial: State = { toasts: [makeToast("a"), makeToast("b")] }; + const after = reducer(initial, { type: "REMOVE_TOAST" }); + expect(after.toasts).toHaveLength(0); + }); +}); + +describe("toast() / useToast()", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + // memoryState はモジュール singleton なのでテスト間で共有される。 + // dismiss → REMOVE_TOAST(=TOAST_REMOVE_DELAY 後) を待ってから real timer に戻す。 + // memoryState is a module-level singleton; dismiss + advance fake timers so + // the queued REMOVE_TOAST clears toasts before switching back to real timers. + const { result } = renderHook(() => useToast()); + act(() => { + result.current.dismiss(); + vi.advanceTimersByTime(1_000_000); + }); + vi.useRealTimers(); + }); + + it("toast() を呼ぶと subscriber に新しい toast が伝播される / emits new toast to subscribers", () => { + const { result } = renderHook(() => useToast()); + expect(result.current.toasts).toHaveLength(0); + + act(() => { + toast({ title: "Hello" }); + }); + + expect(result.current.toasts).toHaveLength(1); + expect(result.current.toasts[0]?.title).toBe("Hello"); + expect(result.current.toasts[0]?.open).toBe(true); + }); + + it("dismiss() で open: false に変わる / dismiss flips open to false", () => { + const { result } = renderHook(() => useToast()); + + let id = ""; + act(() => { + id = toast({ title: "Hello" }).id; + }); + + act(() => { + result.current.dismiss(id); + }); + + expect(result.current.toasts[0]?.open).toBe(false); + }); + + it("toast().update() が subscriber に伝播 / update propagates to subscribers", () => { + const { result } = renderHook(() => useToast()); + + let api: ReturnType | null = null; + act(() => { + api = toast({ title: "old" }); + }); + + act(() => { + api?.update({ + ...(result.current.toasts[0] as Toast), + title: "new", + }); + }); + + expect(result.current.toasts[0]?.title).toBe("new"); + }); + + it("onOpenChange(false) で dismiss されたかのように open:false になる / onOpenChange(false) triggers dismiss", () => { + const { result } = renderHook(() => useToast()); + act(() => { + toast({ title: "x" }); + }); + const created = result.current.toasts[0]; + expect(created?.open).toBe(true); + + act(() => { + created?.onOpenChange?.(false); + }); + expect(result.current.toasts[0]?.open).toBe(false); + }); +}); diff --git a/packages/ui/src/test/setup.ts b/packages/ui/src/test/setup.ts new file mode 100644 index 00000000..5a62920e --- /dev/null +++ b/packages/ui/src/test/setup.ts @@ -0,0 +1,26 @@ +import "@testing-library/jest-dom/vitest"; + +/** + * jsdom does not implement `window.matchMedia`. Provide a minimal stub so + * hooks like `useIsMobile` can call it during tests. Individual tests can + * override this with `vi.fn()` to assert subscription / change behaviour. + * + * jsdom には `window.matchMedia` が無いため、`useIsMobile` などが落ちないよう + * 最小限のスタブを差し込む。挙動を検証するテストは個別に上書きする。 + */ +if (typeof window !== "undefined" && typeof window.matchMedia !== "function") { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + }), + }); +} diff --git a/packages/ui/vitest.config.ts b/packages/ui/vitest.config.ts new file mode 100644 index 00000000..a115ef8f --- /dev/null +++ b/packages/ui/vitest.config.ts @@ -0,0 +1,28 @@ +import path from "path"; +import { defineConfig } from "vitest/config"; + +/** + * Vitest config for `@zedi/ui` package. + * - Uses jsdom for DOM-touching hooks/components (sidebar, toast, mobile detection). + * - Aligns React/react-dom with the workspace root so Radix and our package use the same instance. + * + * `@zedi/ui` パッケージ用の vitest 設定。 + * - DOM を触るフック・コンポーネント(sidebar, toast, モバイル判定)のため jsdom を使う。 + * - Radix と本パッケージが同一 React インスタンスを共有するよう、ワークスペースルートの react を参照する。 + */ +export default defineConfig({ + root: import.meta.dirname, + resolve: { + alias: { + react: path.resolve(import.meta.dirname, "../../node_modules/react"), + "react-dom": path.resolve(import.meta.dirname, "../../node_modules/react-dom"), + }, + dedupe: ["react", "react-dom"], + }, + test: { + globals: true, + environment: "jsdom", + setupFiles: [path.resolve(import.meta.dirname, "src/test/setup.ts")], + include: ["src/**/*.{test,spec}.{ts,tsx}"], + }, +}); diff --git a/scripts/check-drizzle-migrations.mjs b/scripts/check-drizzle-migrations.mjs new file mode 100644 index 00000000..a3090ea8 --- /dev/null +++ b/scripts/check-drizzle-migrations.mjs @@ -0,0 +1,209 @@ +#!/usr/bin/env node +/** + * Drizzle スキーマ変更とマイグレーションファイルの整合性チェッカー。 + * Drizzle schema/migration consistency checker. + * + * 目的 / Purpose: + * PR #728 のように `server/api/src/schema/**` の TS スキーマだけを変更し、 + * 対応する `server/api/drizzle/*.sql` のマイグレーションを忘れる事故を防ぐ。 + * そうすると本番 DB がスキーマに追いつかず、500 エラーで露見する。 + * + * Catch the failure mode where a contributor edits the Drizzle TS schema + * under `server/api/src/schema/**` without committing a matching migration + * in `server/api/drizzle/*.sql`. Without this guard, production runs against + * a DB that lags the application schema (#728 hit exactly this). + * + * 仕組み / How it works: + * - 比較ベースを決める(環境変数 `DRIZZLE_DIFF_BASE` または既定で `origin/develop`)。 + * - `git diff --name-only --diff-filter=ADMR ...HEAD` でスキーマ変更を抽出。 + * - 変更ファイル: `server/api/src/schema/**` + * - ただしテスト (`*.test.ts` / `__tests__/`) と純粋な型ファイル (`types/`) は除外。 + * - 同じ diff で新規追加された `server/api/drizzle/*.sql` または + * `server/api/drizzle/meta/_journal.json` の変更が両方あるか確認する。 + * - スキーマ変更があるのにマイグレーションが追加されていなければ exit 1。 + * + * Compute the diff between HEAD and the configured base (default + * `origin/develop`). If any `server/api/src/schema/**` source file changed + * but no new `server/api/drizzle/*.sql` was added (and the journal was not + * updated), exit non-zero with an actionable message. + * + * 使い方 / Usage: + * node scripts/check-drizzle-migrations.mjs + * DRIZZLE_DIFF_BASE=origin/main node scripts/check-drizzle-migrations.mjs + * + * False positive を出した場合の救済 / Escape hatch: + * コメントだけ・JSDoc だけのスキーマ TS 変更や、CHECK 制約に影響しない型の + * 別名導入など「DB に当てる必要がない」差分の場合は、PR メッセージに + * `[skip drizzle-check]` を含めるか、コミットメッセージに同じ文字列を + * 含めることで許容できる(環境変数 + * `DRIZZLE_SKIP_MARKER` でも可)。 + */ + +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); + +const SCHEMA_PREFIX = "server/api/src/schema/"; +const MIGRATION_PREFIX = "server/api/drizzle/"; +const MIGRATION_JOURNAL = "server/api/drizzle/meta/_journal.json"; + +/** + * 既定の比較ベース。develop ブランチへの PR を主用途とするので origin/develop を既定にする。 + * ローカル実行時は STR DRIZZLE_DIFF_BASE で上書きできる。 + */ +const DEFAULT_BASE = process.env.DRIZZLE_DIFF_BASE || "origin/develop"; + +/** SKIP マーカー(PR / コミットメッセージ)。 */ +const SKIP_MARKER = process.env.DRIZZLE_SKIP_MARKER || "[skip drizzle-check]"; + +/** + * @param {readonly string[]} args + * @returns {string} + */ +function git(args) { + const result = spawnSync("git", args, { encoding: "utf8", cwd: root }); + if (result.status !== 0) { + const stderr = result.stderr?.trim() || "(no stderr)"; + throw new Error(`git ${args.join(" ")} failed (exit ${result.status}): ${stderr}`); + } + return result.stdout || ""; +} + +/** + * リモート参照が存在するか確認する。 + * Pull request CI では base が解決できない場合に fail fast する。 + */ +function ensureBaseExists(base) { + const result = spawnSync("git", ["rev-parse", "--verify", "--quiet", base], { + encoding: "utf8", + cwd: root, + }); + return result.status === 0; +} + +/** + * @param {string} base + * @returns {string[]} + */ +function changedPaths(base) { + const out = git(["diff", "--name-only", "--diff-filter=ADMR", `${base}...HEAD`]); + return out + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); +} + +/** + * @param {string} base + * @returns {string[]} + */ +function addedPaths(base) { + const out = git(["diff", "--name-only", "--diff-filter=A", `${base}...HEAD`]); + return out + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); +} + +/** + * @param {string} path + */ +function isRelevantSchemaChange(path) { + if (!path.startsWith(SCHEMA_PREFIX)) return false; + if (path.endsWith(".test.ts")) return false; + if (path.includes("/__tests__/")) return false; + if (path.includes("/types/")) return false; + return path.endsWith(".ts"); +} + +/** + * 新規マイグレーション SQL が追加され、かつ journal も更新されたか。 + * Whether both a new migration SQL was added AND the journal was modified. + * + * @param {string[]} added + * @param {string[]} changed + */ +function hasMigrationUpdate(added, changed) { + const newSql = added.some( + (p) => p.startsWith(MIGRATION_PREFIX) && p.endsWith(".sql") && !p.includes("/meta/"), + ); + const journalUpdated = changed.includes(MIGRATION_JOURNAL); + return newSql && journalUpdated; +} + +/** + * @param {string} base + * @returns {boolean} + */ +function hasSkipMarker(base) { + const log = git(["log", `${base}..HEAD`, "--pretty=%B"]); + if (log.includes(SKIP_MARKER)) return true; + const prTitle = process.env.PR_TITLE || ""; + const prBody = process.env.PR_BODY || ""; + return prTitle.includes(SKIP_MARKER) || prBody.includes(SKIP_MARKER); +} + +function main() { + const base = DEFAULT_BASE; + + if (!ensureBaseExists(base)) { + if (process.env.CI === "true") { + console.error( + `[check-drizzle-migrations] base ref "${base}" not found. Ensure actions/checkout uses fetch-depth: 0 or fetch the PR base branch before running this check.`, + ); + process.exit(1); + } + + console.log( + `[check-drizzle-migrations] base ref "${base}" not found; skipping check (most likely running outside PR context).`, + ); + return; + } + + const changed = changedPaths(base); + const added = addedPaths(base); + + const schemaChanges = changed.filter(isRelevantSchemaChange); + if (schemaChanges.length === 0) { + console.log("[check-drizzle-migrations] no schema changes detected; OK."); + return; + } + + if (hasMigrationUpdate(added, changed)) { + console.log( + "[check-drizzle-migrations] schema changes detected and matching drizzle migration was added; OK.", + ); + return; + } + + if (hasSkipMarker(base)) { + console.log( + `[check-drizzle-migrations] schema changes detected but "${SKIP_MARKER}" present; skipping.`, + ); + return; + } + + console.error("[check-drizzle-migrations] FAIL"); + console.error(""); + console.error("Drizzle schema files were modified but no migration was added:"); + for (const f of schemaChanges) console.error(` - ${f}`); + console.error(""); + console.error(`Expected: at least one new "${MIGRATION_PREFIX}NNNN_*.sql" file`); + console.error(` AND an updated "${MIGRATION_JOURNAL}" entry.`); + console.error(""); + console.error("How to fix / 修正方法:"); + console.error(" 1. cd server/api && bunx drizzle-kit generate --name "); + console.error(" (DB 接続が必要な場合は DATABASE_URL を一時的にダミー値で渡す)"); + console.error(" 2. 生成された SQL を確認し、必要なら backfill を追記する。"); + console.error(" 3. drizzle-kit が自動生成するスナップショットが大きすぎる場合は、"); + console.error(" 既存の手書きマイグレーションスタイル(0017_add_link_type.sql 等)に合わせて"); + console.error(" diff を最小化した SQL を手書きし、_journal.json も手で追記する。"); + console.error(""); + console.error(`If this change truly does not require a DB migration (e.g. JSDoc-only),`); + console.error(`include "${SKIP_MARKER}" in a commit message or the PR body.`); + process.exit(1); +} + +main(); diff --git a/scripts/setup-develop-branch.sh b/scripts/setup-develop-branch.sh deleted file mode 100644 index 6d3a2610..00000000 --- a/scripts/setup-develop-branch.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/bin/bash - -# developブランチのセットアップスクリプト -# このスクリプトは、mainブランチからdevelopブランチを作成し、リモートにプッシュします - -set -e - -echo "🚀 developブランチのセットアップを開始します..." - -# 現在のブランチを確認 -CURRENT_BRANCH=$(git branch --show-current) -echo "現在のブランチ: $CURRENT_BRANCH" - -# mainブランチに切り替え -if [ "$CURRENT_BRANCH" != "main" ]; then - echo "📦 mainブランチに切り替えます..." - git checkout main -fi - -# 最新の状態を取得 -echo "📥 最新の状態を取得します..." -git pull origin main - -# developブランチが既に存在するか確認 -if git show-ref --verify --quiet refs/heads/develop; then - echo "⚠️ developブランチは既に存在します。" - read -p "上書きしますか? (y/N): " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "❌ セットアップをキャンセルしました。" - exit 1 - fi - git branch -D develop -fi - -# developブランチを作成 -echo "🌿 developブランチを作成します..." -git checkout -b develop - -# リモートにプッシュ -echo "📤 リモートにプッシュします..." -git push -u origin develop - -echo "✅ developブランチのセットアップが完了しました!" -echo "" -echo "次のステップ:" -echo "1. GitHubでdevelopブランチの保護ルールを設定してください" -echo " 詳細: docs/guides/setup-develop-branch.md" -echo "2. チームメンバーに通知してください" -echo "" -echo "現在のブランチ: develop" diff --git a/server/api/drizzle/0018_add_onboarding_and_page_kind.sql b/server/api/drizzle/0018_add_onboarding_and_page_kind.sql new file mode 100644 index 00000000..d2093a8b --- /dev/null +++ b/server/api/drizzle/0018_add_onboarding_and_page_kind.sql @@ -0,0 +1,114 @@ +-- 0018: Add `pages.kind` column and `user_onboarding_status` table. +-- 0018: pages.kind カラムと user_onboarding_status テーブルを追加。 +-- +-- Background / 背景: +-- PR #728 (feat: add welcome page generation and unified media upload UI) +-- introduced `pages.kind` (`user` / `welcome` / `update_notice`) and the +-- `user_onboarding_status` table in the Drizzle TS schema, but the matching +-- `server/api/drizzle/*.sql` migration was never generated. Production and +-- development databases therefore miss both objects, which surfaces as +-- `GET /api/onboarding/status` and `POST /api/pages` returning 500 +-- (`relation "user_onboarding_status" does not exist` / +-- `column "kind" of relation "pages" does not exist`). +-- +-- PR #728 で `pages.kind` と `user_onboarding_status` を Drizzle TS スキーマに +-- 追加したものの、対応する `server/api/drizzle/*.sql` のマイグレーション +-- ファイルが生成されていなかった。その結果、本番 / 開発 DB の両方で +-- `GET /api/onboarding/status` と `POST /api/pages` が 500 を返していた。 +-- +-- `db/migrations/005_add_onboarding_and_page_kind.sql` には等価な内容が +-- 置かれていたが、CI (`deploy-{dev,prod}.yml`) は `bunx drizzle-kit migrate` +-- しか実行しないため、`server/api/drizzle/` に置き直す必要があった。 +-- +-- IF NOT EXISTS を多用しているのは、すでに手動で `db/migrations/005_*.sql` を +-- 流したことのある環境(開発者ローカル等)でも安全に再実行できるようにする +-- ため。drizzle-kit 自身は `__drizzle_migrations` テーブルで適用済みかを +-- 管理するので、本来は IF NOT EXISTS は不要だが、過去経緯への配慮として +-- 残している。 +-- +-- Use `IF NOT EXISTS` everywhere so that environments which previously ran the +-- legacy `db/migrations/005_*.sql` manually do not break. drizzle-kit itself +-- tracks applied migrations in `__drizzle_migrations`, so the guards are only +-- defense in depth. + +-- ── pages.kind ───────────────────────────────────────────────────────────── +-- +-- ADD COLUMN IF NOT EXISTS と inline CHECK を 1 文にまとめる。 +-- column が新規追加されるときだけ CHECK 制約も同時に作られる。 +-- legacy 環境(手動で旧 005 SQL を流したケース)ですでに column が +-- 存在する場合は ADD COLUMN ごとスキップされる。 +-- +-- Combine ADD COLUMN IF NOT EXISTS with an inline CHECK so the constraint is +-- created only when the column itself is created. Legacy environments that +-- already ran the old `db/migrations/005_*.sql` keep their existing column +-- and constraint untouched. + +ALTER TABLE "pages" + ADD COLUMN IF NOT EXISTS "kind" text NOT NULL DEFAULT 'user' + CHECK ("kind" IN ('user', 'welcome', 'update_notice')); +--> statement-breakpoint + +CREATE INDEX IF NOT EXISTS "idx_pages_owner_kind" + ON "pages" USING btree ("owner_id", "kind"); +--> statement-breakpoint + +-- オーナーごとに有効なウェルカムページは最大 1 件。 +-- welcomePageService.ts の `onConflictDoNothing` が target としてこの述語に +-- 依拠しているため、ここで部分ユニーク index を必ず張る。 +-- At most one live welcome page per owner. The +-- `onConflictDoNothing` in welcomePageService.ts targets this exact partial +-- unique index, so it must exist for the upsert to be a no-op on conflict. +CREATE UNIQUE INDEX IF NOT EXISTS "idx_pages_unique_welcome_per_owner" + ON "pages" ("owner_id") + WHERE "kind" = 'welcome' AND "is_deleted" = false; +--> statement-breakpoint + +-- ── user_onboarding_status ──────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS "user_onboarding_status" ( + "user_id" text PRIMARY KEY NOT NULL + REFERENCES "user" ("id") ON DELETE CASCADE, + "setup_completed_at" timestamp with time zone, + "welcome_page_created_at" timestamp with time zone, + "welcome_page_id" uuid + REFERENCES "pages" ("id") ON DELETE SET NULL, + -- セットアップウィザードで選択したロケール。ログイン時リトライ + -- (`retryWelcomePageIfNeeded`) がユーザーの意図した言語でウェルカム + -- ページを生成するために保持する。NULL は「未選択」。 + -- Locale chosen at the setup wizard. Retained so login-time retries + -- regenerate the welcome page in the user's originally selected language. + "requested_locale" text + CHECK ("requested_locale" IS NULL OR "requested_locale" IN ('ja', 'en')), + "home_slides_shown_at" timestamp with time zone, + "auto_create_update_notice" boolean NOT NULL DEFAULT true, + "created_at" timestamp with time zone NOT NULL DEFAULT now(), + "updated_at" timestamp with time zone NOT NULL DEFAULT now() +); +--> statement-breakpoint + +-- バックグラウンドリトライ対象(セットアップ済みだがウェルカムページ未生成)の +-- 高速検索のための部分インデックス。`retryWelcomePageIfNeeded` が WHERE 句で +-- そのまま使う形に揃えている。 +-- Partial index for the login-time retry scan: rows where setup completed +-- but the welcome page has not been generated yet. +CREATE INDEX IF NOT EXISTS "idx_user_onboarding_status_needs_welcome" + ON "user_onboarding_status" ("setup_completed_at") + WHERE "setup_completed_at" IS NOT NULL AND "welcome_page_created_at" IS NULL; +--> statement-breakpoint + +-- ── バックフィル / Backfill ──────────────────────────────────────────────── +-- +-- このマイグレーションが走った時点で既に存在するユーザーは、旧フローで +-- セットアップを終えていると見なし `setup_completed_at = NOW()` で記録する。 +-- そうしないと次回ログインで全員が onboarding ウィザードに戻されてしまう。 +-- `welcome_page_created_at` は NULL のままにしておき、`retryWelcomePageIfNeeded` +-- でログイン時にウェルカムページを生成する余地を残す。 +-- +-- Mark every pre-existing user as "setup completed" so they are not pushed +-- back through the wizard after this migration lands. Leaving +-- `welcome_page_created_at` as NULL keeps the login-time retry free to +-- generate a welcome page lazily. +INSERT INTO "user_onboarding_status" ("user_id", "setup_completed_at", "created_at", "updated_at") +SELECT "id", NOW(), NOW(), NOW() +FROM "user" +ON CONFLICT ("user_id") DO NOTHING; diff --git a/server/api/drizzle/meta/_journal.json b/server/api/drizzle/meta/_journal.json index 920add2d..da9c2c44 100644 --- a/server/api/drizzle/meta/_journal.json +++ b/server/api/drizzle/meta/_journal.json @@ -120,6 +120,13 @@ "when": 1777334400000, "tag": "0017_add_link_type", "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1777420800000, + "tag": "0018_add_onboarding_and_page_kind", + "breakpoints": true } ] } diff --git a/server/api/src/__tests__/lib/userDelete.test.ts b/server/api/src/__tests__/lib/userDelete.test.ts new file mode 100644 index 00000000..1331e043 --- /dev/null +++ b/server/api/src/__tests__/lib/userDelete.test.ts @@ -0,0 +1,309 @@ +/** + * `lib/userDelete.ts` のユニットテスト。 + * + * `getUserImpact`: + * - notes / sessions / subscriptions / aiUsageLogs を並行に問い合わせ、 + * それぞれのカウントと最終 AI 使用日時を返すこと。 + * - 各テーブルにデータが無い場合のフォールバック値が正しいこと。 + * + * `anonymizeUser`: + * - 削除対象が存在しない場合は throw すること。 + * - 「session 削除 → account 削除 → users 更新」の **呼び出し順** を、 + * 対象テーブルまで含めて厳守すること(FK 違反防止)。 + * - users 行は PII (name / email / image) が匿名化され status が "deleted" になること。 + * - 監査ログ用 before スナップショットには PII を含めないこと。 + * + * Unit tests for the soft-delete helpers in `lib/userDelete.ts`. Verifies the + * impact aggregation and the cascade order/payload of the anonymization step. + */ +import { describe, it, expect } from "vitest"; +import { getUserImpact, anonymizeUser } from "../../lib/userDelete.js"; +import { users, session, account } from "../../schema/users.js"; +import { notes } from "../../schema/notes.js"; +import { subscriptions } from "../../schema/subscriptions.js"; +import { aiUsageLogs } from "../../schema/aiModels.js"; +import type { Database } from "../../types/index.js"; + +const USER_ID = "user-001"; +const NOW = new Date("2026-04-26T00:00:00Z"); + +/** + * Drizzle 風モック。各クエリについて + * - 最上位メソッド名 (`select` / `delete` / `update` / …) と引数(テーブル) + * - 後続のチェーン呼び出し (`from` / `where` / `set` / `returning` / …) + * をすべて順序付きで保存し、テストから検証できるようにする。 + * + * Drizzle-style mock that records, per query, both the top-level call (with + * its table argument) and every chained call (`from`, `where`, `set`, …). + * Tests can therefore assert ordering, target tables, and even the payload + * passed to `.set()` without spinning up a second proxy layer. + */ +interface ChainCall { + method: string; + args: unknown[]; +} +interface OpRecord { + method: string; + args: unknown[]; + chain: ChainCall[]; +} + +function createOrderedMockDb(results: unknown[]) { + const ops: OpRecord[] = []; + let resultIdx = 0; + + function makeChain(rIdx: number, chainSink: ChainCall[]): unknown { + return new Proxy( + {}, + { + get(_target, prop: string) { + if (prop === "then") { + const result = results[rIdx]; + return (resolve?: (v: unknown) => unknown, reject?: (e: unknown) => unknown) => + Promise.resolve(result).then(resolve, reject); + } + return (...args: unknown[]) => { + chainSink.push({ method: prop, args }); + return makeChain(rIdx, chainSink); + }; + }, + }, + ); + } + + const db = new Proxy( + {}, + { + get(_t, prop: string) { + if (prop === "transaction") { + return (fn: (tx: Database) => Promise) => fn(db as unknown as Database); + } + return (...args: unknown[]) => { + const idx = resultIdx++; + const chain: ChainCall[] = []; + ops.push({ method: prop, args, chain }); + return makeChain(idx, chain); + }; + }, + }, + ); + + return { db: db as unknown as Database, ops }; +} + +/** + * Find the table object passed to the first `from(...)` call on a chain. + * select クエリは最上位 `select(...)` の引数ではなく `.from(table)` でテーブルを + * 渡すため、対象テーブルの検証用にチェーンから抽出するヘルパ。 + */ +function fromTable(op: OpRecord): unknown { + return op.chain.find((c) => c.method === "from")?.args[0]; +} + +describe("getUserImpact", () => { + it("returns counts and last AI usage timestamp aggregated across tables", async () => { + const { db } = createOrderedMockDb([ + [{ count: 4 }], // notes + [{ count: 2 }], // sessions + [{ count: 1 }], // active subs (>0) + [{ createdAt: NOW }], // aiUsageLogs latest + ]); + + const impact = await getUserImpact(db, USER_ID); + + expect(impact).toEqual({ + notesCount: 4, + sessionsCount: 2, + activeSubscription: true, + lastAiUsageAt: NOW.toISOString(), + }); + }); + + it("falls back to zeros / null when all queries return empty rows", async () => { + // 新規ユーザーや関連データの無いユーザーで NaN を返さないこと。 + // No notes / no sessions / no sub / no AI usage → defaults must hold. + const { db } = createOrderedMockDb([[], [], [], []]); + + const impact = await getUserImpact(db, USER_ID); + + expect(impact).toEqual({ + notesCount: 0, + sessionsCount: 0, + activeSubscription: false, + lastAiUsageAt: null, + }); + }); + + it("treats subscription count of 0 as activeSubscription=false", async () => { + const { db } = createOrderedMockDb([ + [{ count: 0 }], + [{ count: 0 }], + [{ count: 0 }], // 0 active subs + [], + ]); + const impact = await getUserImpact(db, USER_ID); + expect(impact.activeSubscription).toBe(false); + }); + + it("queries the four expected tables: notes, session, subscriptions, aiUsageLogs", async () => { + // メソッド数だけでなく、`.from(...)` の対象テーブルが正しいことまで検証する。 + // Verify both the operation count (4 selects) and that each one targets + // the right Drizzle table object — protects against accidental swaps. + const { db, ops } = createOrderedMockDb([[], [], [], []]); + await getUserImpact(db, USER_ID); + expect(ops).toHaveLength(4); + expect(ops.every((o) => o.method === "select")).toBe(true); + expect(new Set(ops.map(fromTable))).toEqual( + new Set([notes, session, subscriptions, aiUsageLogs]), + ); + }); +}); + +describe("anonymizeUser", () => { + it("throws when the target user does not exist", async () => { + // 1 つ目の select が空配列 → ターゲットなし → エラー。 + // Empty initial select must abort before any destructive write. + const { db, ops } = createOrderedMockDb([[]]); + await expect(anonymizeUser(db, "missing-user")).rejects.toThrow(/not found/i); + // It must short-circuit before issuing delete/update statements. + // 後続の delete / update が走らないことを保証する。 + expect(ops).toHaveLength(1); + expect(ops[0]?.method).toBe("select"); + }); + + it("performs select(users) → delete(session) → delete(account) → update(users), in that exact order", async () => { + // 監査ログの一貫性 + FK 違反を避けるため、削除順とテーブルは仕様で固定されている。 + // Order AND target table are part of the contract: never update users + // before its dependents are gone, and never confuse session/account. + const target = { + id: USER_ID, + name: "Alice", + email: "alice@example.com", + image: "https://cdn.example/avatar.png", + status: "active", + }; + const updatedRow = { + id: USER_ID, + name: "Deleted User", + email: `deleted-${USER_ID}@example.invalid`, + role: "user", + status: "deleted", + suspendedAt: null, + suspendedReason: null, + suspendedBy: null, + createdAt: NOW, + }; + const { db, ops } = createOrderedMockDb([ + [target], // select existing user + undefined, // delete sessions + undefined, // delete accounts + [updatedRow], // update users RETURNING + ]); + + const result = await anonymizeUser(db, USER_ID); + + expect(ops.map((o) => o.method)).toEqual(["select", "delete", "delete", "update"]); + // select は from(users) でテーブルを指定する。 + // select uses .from(users) rather than passing the table to select(). + const selectOp = ops[0]; + expect(selectOp).toBeDefined(); + expect(fromTable(selectOp as OpRecord)).toBe(users); + // delete / update はトップレベルでテーブルを取る。 + // delete()/update() take the table as their direct argument. + expect(ops[1]?.args[0]).toBe(session); + expect(ops[2]?.args[0]).toBe(account); + expect(ops[3]?.args[0]).toBe(users); + expect(result.updated).toEqual(updatedRow); + }); + + it("anonymizes name/email/image and clears suspension fields on the update payload", async () => { + const target = { + id: USER_ID, + name: "Alice", + email: "alice@example.com", + image: "https://cdn.example/avatar.png", + status: "suspended", + }; + const updatedRow = { + id: USER_ID, + name: "Deleted User", + email: `deleted-${USER_ID}@example.invalid`, + role: "user", + status: "deleted", + suspendedAt: null, + suspendedReason: null, + suspendedBy: null, + createdAt: NOW, + }; + const { db, ops } = createOrderedMockDb([[target], undefined, undefined, [updatedRow]]); + + await anonymizeUser(db, USER_ID); + + // チェーンに残った `.set(...)` 呼び出しからペイロードを直接取り出す。 + // Pull the .set(...) payload directly from the recorded chain — no + // separate spy proxy is required. + const updateOp = ops.find((o) => o.method === "update"); + const setCall = updateOp?.chain.find((c) => c.method === "set"); + expect(setCall, "expected update chain to include a .set() call").toBeDefined(); + const payload = (setCall as ChainCall).args[0] as Record; + expect(payload.name).toBe("Deleted User"); + expect(payload.email).toBe(`deleted-${USER_ID}@example.invalid`); + expect(payload.image).toBeNull(); + expect(payload.status).toBe("deleted"); + expect(payload.suspendedAt).toBeNull(); + expect(payload.suspendedReason).toBeNull(); + expect(payload.suspendedBy).toBeNull(); + expect(payload.updatedAt).toBeInstanceOf(Date); + }); + + it("returns a redacted before-snapshot that contains status only (no PII)", async () => { + // 監査ログには元の name / email を残してはいけない。 + // The audit log must NOT receive recoverable PII. + const target = { + id: USER_ID, + name: "Bob", + email: "bob@example.com", + image: null, + status: "active", + }; + const updatedRow = { + id: USER_ID, + name: "Deleted User", + email: `deleted-${USER_ID}@example.invalid`, + role: "user", + status: "deleted", + suspendedAt: null, + suspendedReason: null, + suspendedBy: null, + createdAt: NOW, + }; + const { db } = createOrderedMockDb([[target], undefined, undefined, [updatedRow]]); + + const { before } = await anonymizeUser(db, USER_ID); + + expect(before).toEqual({ status: "active", piiRedacted: true }); + // Defensive: the before snapshot must not leak any of these fields. + // 念のため、PII を持ち込んでいないことを構造的にも確認する。 + expect(before).not.toHaveProperty("name"); + expect(before).not.toHaveProperty("email"); + expect(before).not.toHaveProperty("image"); + }); + + it("throws if the update returns no row (race / concurrent delete)", async () => { + const target = { + id: USER_ID, + name: "Alice", + email: "alice@example.com", + image: null, + status: "active", + }; + const { db } = createOrderedMockDb([ + [target], + undefined, + undefined, + [], // RETURNING empty → race + ]); + + await expect(anonymizeUser(db, USER_ID)).rejects.toThrow(/Failed to anonymize/i); + }); +}); diff --git a/server/api/src/__tests__/middleware/adminAuth.test.ts b/server/api/src/__tests__/middleware/adminAuth.test.ts new file mode 100644 index 00000000..f92d883b --- /dev/null +++ b/server/api/src/__tests__/middleware/adminAuth.test.ts @@ -0,0 +1,116 @@ +/** + * `middleware/adminAuth.ts` のユニットテスト。 + * + * - 未認証 (userId なし) は 401 を返す。 + * - DB 上の role が "admin" 以外なら 403 を返す。 + * - role が "admin" のときのみ next() に進み、後続ハンドラが呼ばれる。 + * + * Unit tests for the `adminRequired` Hono middleware. + * Verifies that unauthenticated requests get 401, non-admin roles get 403, + * and only admins are allowed past to the route handler. + */ +import { describe, it, expect, vi } from "vitest"; +import { Hono } from "hono"; +import type { AppEnv } from "../../types/index.js"; +import { adminRequired } from "../../middleware/adminAuth.js"; + +type MockRoleRow = { role: "user" | "admin" }; + +/** + * Build a minimal Drizzle-style DB double whose + * `select().from().where().limit()` resolves with the given rows. + * テスト用に最小限の Drizzle 風 DB を作る。 + */ +function createMockDb(roleRows: MockRoleRow[]) { + return { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => roleRows, + }), + }), + }), + } as unknown as AppEnv["Variables"]["db"]; +} + +/** + * Build a Hono app that wires `userId` and the mocked DB into context, + * then mounts a single `/admin` route guarded by `adminRequired`. + * `adminRequired` を装着した最小の Hono アプリを生成する。 + */ +type RouteHandlerFn = () => { ok: boolean }; + +function createApp(opts: { userId?: string; rows?: MockRoleRow[]; handler?: RouteHandlerFn }) { + const app = new Hono(); + const handler = vi.fn(opts.handler ?? (() => ({ ok: true }))); + app.use("*", async (c, next) => { + if (opts.userId !== undefined) c.set("userId", opts.userId); + c.set("db", createMockDb(opts.rows ?? [])); + await next(); + }); + app.get("/admin", adminRequired, (c) => c.json(handler())); + return { app, handler }; +} + +describe("adminRequired", () => { + it("returns 401 when userId is not set in context (unauthenticated)", async () => { + // authRequired が走っていない場合は 401 を返さなければならない。 + // adminRequired must reject when authRequired hasn't populated userId. + const { app, handler } = createApp({ rows: [{ role: "admin" }] }); + const res = await app.request("/admin"); + expect(res.status).toBe(401); + expect(handler).not.toHaveBeenCalled(); + // HTTPException without an onError serializes the message as plain text. + // onError 未登録時の HTTPException は本文がテキストになるため text() を使う。 + const body = await res.text(); + expect(body).toMatch(/authentication required/i); + }); + + it("returns 403 when the user has role='user'", async () => { + const { app, handler } = createApp({ userId: "u-1", rows: [{ role: "user" }] }); + const res = await app.request("/admin"); + expect(res.status).toBe(403); + expect(handler).not.toHaveBeenCalled(); + const body = await res.text(); + expect(body).toMatch(/admin access required/i); + }); + + it("returns 403 when the user does not exist (no row returned)", async () => { + // ユーザーが削除済み / 不在のときは role が null として扱われ、403 になる。 + // A missing user row should not be treated as admin. + const { app, handler } = createApp({ userId: "u-missing", rows: [] }); + const res = await app.request("/admin"); + expect(res.status).toBe(403); + expect(handler).not.toHaveBeenCalled(); + }); + + it("calls the route handler exactly once when role='admin'", async () => { + const { app, handler } = createApp({ userId: "u-admin", rows: [{ role: "admin" }] }); + const res = await app.request("/admin"); + expect(res.status).toBe(200); + expect(handler).toHaveBeenCalledTimes(1); + const body = (await res.json()) as { ok: boolean }; + expect(body.ok).toBe(true); + }); + + it("does NOT query the DB when userId is missing", async () => { + // 401 ショートサーキット時に DB を引かないことを確認する。 + // Verify the DB lookup is short-circuited before role resolution. + const selectSpy = vi.fn(() => ({ + from: () => ({ + where: () => ({ + limit: async () => [{ role: "admin" }], + }), + }), + })); + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("db", { select: selectSpy } as unknown as AppEnv["Variables"]["db"]); + await next(); + }); + app.get("/admin", adminRequired, (c) => c.json({ ok: true })); + const res = await app.request("/admin"); + expect(res.status).toBe(401); + expect(selectSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/server/api/src/__tests__/middleware/csrfOrigin.test.ts b/server/api/src/__tests__/middleware/csrfOrigin.test.ts new file mode 100644 index 00000000..41db4e3e --- /dev/null +++ b/server/api/src/__tests__/middleware/csrfOrigin.test.ts @@ -0,0 +1,190 @@ +/** + * `middleware/csrfOrigin.ts` のユニットテスト。 + * + * - 状態変更メソッド (POST/PUT/PATCH/DELETE) は Origin / Referer を検証する。 + * - 安全メソッド (GET/HEAD/OPTIONS) は素通りする。 + * - CORS_ORIGIN が未設定 / "*" のときは検証をスキップする (=後続のヘッダで拒否)。 + * - 除外パス (/api/webhooks/*, /api/ext/session, /api/ext/clip-and-create) は検査されない。 + * + * Unit tests for the `csrfOriginCheck` Hono middleware. Verifies that + * mutation requests are gated by Origin / Referer matching the CORS allow-list, + * while safe methods, excluded paths, and wildcard CORS configurations bypass. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { Hono } from "hono"; +import type { AppEnv } from "../../types/index.js"; +import { csrfOriginCheck } from "../../middleware/csrfOrigin.js"; + +/** + * Test app: applies the middleware to every method on a small set of routes. + * テスト用アプリ。複数メソッド・複数パスを 1 つの Hono に同居させる。 + */ +function createApp() { + const app = new Hono(); + app.use("*", csrfOriginCheck); + for (const path of [ + "/api/state", + "/api/webhooks/stripe", + "/api/ext/session", + "/api/ext/clip-and-create", + "/api/ext/authorize-code", + ]) { + app.get(path, (c) => c.json({ ok: true })); + app.post(path, (c) => c.json({ ok: true })); + app.put(path, (c) => c.json({ ok: true })); + app.patch(path, (c) => c.json({ ok: true })); + app.delete(path, (c) => c.json({ ok: true })); + app.options(path, (c) => c.json({ ok: true })); + } + return app; +} + +describe("csrfOriginCheck", () => { + // CORS_ORIGIN を直接読み出すヘルパに依存しているため、 + // 各テストで明示的に設定し、終了時に元に戻す。 + // The middleware reads CORS_ORIGIN at call time; reset around each test. + const originalCorsOrigin = process.env.CORS_ORIGIN; + + beforeEach(() => { + process.env.CORS_ORIGIN = "https://app.example.com,https://admin.example.com"; + }); + + afterEach(() => { + if (originalCorsOrigin === undefined) { + delete process.env.CORS_ORIGIN; + } else { + process.env.CORS_ORIGIN = originalCorsOrigin; + } + }); + + describe("safe methods bypass entirely", () => { + it.each(["GET", "OPTIONS"] as const)("%s does not require Origin/Referer", async (method) => { + const res = await createApp().request("/api/state", { method }); + expect(res.status).toBe(200); + }); + }); + + describe("mutation methods enforce Origin/Referer", () => { + it.each(["POST", "PUT", "PATCH", "DELETE"] as const)( + "%s allows requests whose Origin matches the allow-list", + async (method) => { + const res = await createApp().request("/api/state", { + method, + headers: { Origin: "https://app.example.com" }, + }); + expect(res.status).toBe(200); + }, + ); + + it.each(["POST", "PUT", "PATCH", "DELETE"] as const)( + "%s rejects requests whose Origin is NOT in the allow-list", + async (method) => { + const res = await createApp().request("/api/state", { + method, + headers: { Origin: "https://evil.example.org" }, + }); + expect(res.status).toBe(403); + // HTTPException without an onError serializes the message as plain text. + // onError 未登録時の HTTPException は本文がテキストで返る。 + const body = await res.text(); + expect(body).toMatch(/Origin or Referer/i); + }, + ); + + it("rejects mutation when neither Origin nor Referer is set", async () => { + const res = await createApp().request("/api/state", { method: "POST" }); + expect(res.status).toBe(403); + }); + + it("falls back to Referer when Origin is absent (allowed)", async () => { + const res = await createApp().request("/api/state", { + method: "POST", + headers: { Referer: "https://app.example.com/some/path?x=1" }, + }); + expect(res.status).toBe(200); + }); + + it("rejects when Referer is from an untrusted origin", async () => { + const res = await createApp().request("/api/state", { + method: "POST", + headers: { Referer: "https://evil.example.org/" }, + }); + expect(res.status).toBe(403); + }); + + it("rejects when Referer is malformed (URL parse fails → null)", async () => { + const res = await createApp().request("/api/state", { + method: "POST", + headers: { Referer: "not-a-url" }, + }); + expect(res.status).toBe(403); + }); + + it("prefers Origin over Referer when both are present", async () => { + // Origin 単独で許可されていれば、Referer の値は無視される。 + // When Origin is explicitly trusted, the Referer check is bypassed. + const res = await createApp().request("/api/state", { + method: "POST", + headers: { + Origin: "https://app.example.com", + Referer: "https://evil.example.org/", + }, + }); + expect(res.status).toBe(200); + }); + }); + + describe("CORS_ORIGIN configuration edge cases", () => { + it("skips validation entirely when CORS_ORIGIN is unset (dev default)", async () => { + delete process.env.CORS_ORIGIN; + const res = await createApp().request("/api/state", { method: "POST" }); + expect(res.status).toBe(200); + }); + + it('skips validation entirely when CORS_ORIGIN is "*"', async () => { + process.env.CORS_ORIGIN = "*"; + const res = await createApp().request("/api/state", { + method: "POST", + headers: { Origin: "https://anywhere.example.org" }, + }); + expect(res.status).toBe(200); + }); + + it("respects multi-origin CORS_ORIGIN (second entry also accepted)", async () => { + const res = await createApp().request("/api/state", { + method: "POST", + headers: { Origin: "https://admin.example.com" }, + }); + expect(res.status).toBe(200); + }); + }); + + describe("excluded paths", () => { + it("does not validate Origin for /api/webhooks/* (signed by sender)", async () => { + // Webhook は送信側の署名で検証されるため Origin 検査の対象外。 + // Webhook payloads are verified by signature, not by Origin. + const res = await createApp().request("/api/webhooks/stripe", { + method: "POST", + headers: { Origin: "https://evil.example.org" }, + }); + expect(res.status).toBe(200); + }); + + it("does not validate Origin for /api/ext/session (Bearer-only)", async () => { + const res = await createApp().request("/api/ext/session", { method: "POST" }); + expect(res.status).toBe(200); + }); + + it("does not validate Origin for /api/ext/clip-and-create (Bearer-only)", async () => { + const res = await createApp().request("/api/ext/clip-and-create", { method: "POST" }); + expect(res.status).toBe(200); + }); + + it("STILL validates /api/ext/authorize-code (cookie-based, not exempt)", async () => { + // /api/ext/authorize-code は Cookie 認証のため CSRF 対象に残す必要がある。 + // The authorize-code endpoint uses cookie auth, so it must remain protected. + const res = await createApp().request("/api/ext/authorize-code", { method: "POST" }); + expect(res.status).toBe(403); + }); + }); +}); diff --git a/server/api/src/__tests__/middleware/errorHandler.test.ts b/server/api/src/__tests__/middleware/errorHandler.test.ts new file mode 100644 index 00000000..4522e03f --- /dev/null +++ b/server/api/src/__tests__/middleware/errorHandler.test.ts @@ -0,0 +1,108 @@ +/** + * `middleware/errorHandler.ts` のユニットテスト。 + * + * - HTTPException はそのままステータスとメッセージを返す。 + * - サービス層が throw する `new Error("UNAUTHORIZED")` などの + * "magic message" は statusMap に従って HTTP ステータスへ写像される。 + * - 未知のエラーは 500 を返し、message は「Internal server error」または素のエラー文。 + * + * Unit tests for the global Hono error handler. Covers HTTPException pass-through, + * the magic-message → status mapping, and the unknown-error 500 default. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import type { AppEnv } from "../../types/index.js"; +import { errorHandler } from "../../middleware/errorHandler.js"; + +/** + * Build an app whose `/throw` route throws the supplied error. + * テスト対象のエラーを必ず throw するルートを持つアプリを作る。 + */ +function appThrowing(err: unknown) { + const app = new Hono(); + app.onError(errorHandler); + app.get("/throw", () => { + throw err; + }); + return app; +} + +describe("errorHandler", () => { + // 例外発生時に console.error が呼ばれるため、テスト中は黙らせる。 + // Silence the `[api] ...` log lines emitted on every error path. + let errorSpy: ReturnType; + + beforeEach(() => { + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + errorSpy.mockRestore(); + }); + + describe("HTTPException pass-through", () => { + it("preserves the HTTPException status code", async () => { + const res = await appThrowing(new HTTPException(418, { message: "I'm a teapot" })).request( + "/throw", + ); + expect(res.status).toBe(418); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("I'm a teapot"); + }); + + it("returns 401 for an unauthorized HTTPException", async () => { + const res = await appThrowing(new HTTPException(401, { message: "no" })).request("/throw"); + expect(res.status).toBe(401); + }); + + it("returns 403 for a forbidden HTTPException", async () => { + const res = await appThrowing(new HTTPException(403, { message: "denied" })).request( + "/throw", + ); + expect(res.status).toBe(403); + }); + }); + + describe("statusMap (magic message) mapping", () => { + // 各エントリは Error message → 期待 HTTP ステータス。 + // Each magic message must map to exactly the documented status. + it.each([ + ["UNAUTHORIZED", 401], + ["FORBIDDEN", 403], + ["RATE_LIMIT_EXCEEDED", 429], + ["STORAGE_QUOTA_EXCEEDED", 403], + ["NOT_FOUND", 404], + ["BAD_REQUEST", 400], + ["CONFLICT", 409], + ] as const)("maps Error('%s') to %d", async (message, expected) => { + const res = await appThrowing(new Error(message)).request("/throw"); + expect(res.status).toBe(expected); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe(message); + }); + }); + + describe("unknown errors", () => { + it("returns 500 for an Error with an unmapped message and echoes the message", async () => { + const res = await appThrowing(new Error("kapow")).request("/throw"); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("kapow"); + }); + + it("logs the error with method and path context", async () => { + await appThrowing(new Error("BAD_REQUEST")).request("/throw"); + // statusMap によるマッピング後にログが残ること。 + // Verify the `[api] GET /throw → 400` log line was emitted. + expect(errorSpy).toHaveBeenCalled(); + const firstCall = errorSpy.mock.calls[0]; + expect(firstCall).toBeDefined(); + const firstArg = firstCall?.[0]; + expect(typeof firstArg).toBe("string"); + expect(firstArg as string).toContain("GET"); + expect(firstArg as string).toContain("/throw"); + expect(firstArg as string).toContain("400"); + }); + }); +}); diff --git a/server/api/src/__tests__/routes/activity.test.ts b/server/api/src/__tests__/routes/activity.test.ts new file mode 100644 index 00000000..942dd714 --- /dev/null +++ b/server/api/src/__tests__/routes/activity.test.ts @@ -0,0 +1,286 @@ +/** + * /api/activity のテスト(list, index 読み取り, index 再構築)。 + * Tests for /api/activity routes (list, index read, index rebuild). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Context, Next } from "hono"; +import type { AppEnv } from "../../types/index.js"; + +vi.mock("../../middleware/auth.js", () => ({ + authRequired: async (c: Context, next: Next) => { + const userId = c.req.header("x-test-user-id"); + if (!userId) return c.json({ message: "Unauthorized" }, 401); + c.set("userId", userId); + await next(); + }, +})); + +const { mockListActivity, mockRecordActivity, mockBuildIndex, mockRebuildIndex } = vi.hoisted( + () => ({ + mockListActivity: vi.fn(), + mockRecordActivity: vi.fn(), + mockBuildIndex: vi.fn(), + mockRebuildIndex: vi.fn(), + }), +); + +vi.mock("../../services/activityLogService.js", () => ({ + listActivityForOwner: mockListActivity, + recordActivity: mockRecordActivity, + ACTIVITY_LIST_DEFAULT_LIMIT: 50, + ACTIVITY_LIST_MAX_LIMIT: 200, +})); + +vi.mock("../../services/indexBuilder.js", () => ({ + buildIndexForOwner: mockBuildIndex, + rebuildIndexForOwner: mockRebuildIndex, +})); + +import { Hono } from "hono"; +import activityRoutes from "../../routes/activity.js"; +import { errorHandler } from "../../middleware/errorHandler.js"; +import { createMockDb } from "../createMockDb.js"; + +const TEST_USER_ID = "user-act-1"; + +function createTestApp(dbResults: unknown[]) { + const { db, chains } = createMockDb(dbResults); + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("db", db as unknown as AppEnv["Variables"]["db"]); + await next(); + }); + app.onError(errorHandler); + app.route("/api/activity", activityRoutes); + return { app, chains }; +} + +function authHeaders(userId: string = TEST_USER_ID): Record { + return { + "x-test-user-id": userId, + "Content-Type": "application/json", + }; +} + +beforeEach(() => { + mockListActivity.mockReset(); + mockRecordActivity.mockReset(); + mockBuildIndex.mockReset(); + mockRebuildIndex.mockReset(); +}); + +// ── GET /api/activity ─────────────────────────────────────────────────────── + +describe("GET /api/activity", () => { + it("returns mapped entries with snake_case field names", async () => { + mockListActivity.mockResolvedValue({ + rows: [ + { + id: "act-1", + kind: "lint_run", + actor: "system", + targetPageIds: ["p-1"], + detail: { count: 3 }, + createdAt: new Date("2026-04-01T00:00:00Z"), + }, + ], + total: 1, + }); + const { app } = createTestApp([]); + + const res = await app.request("/api/activity", { headers: authHeaders() }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + entries: Array>; + total: number; + limit: number; + }; + expect(body.entries[0]).toEqual({ + id: "act-1", + kind: "lint_run", + actor: "system", + target_page_ids: ["p-1"], + detail: { count: 3 }, + created_at: "2026-04-01T00:00:00.000Z", + }); + expect(body.total).toBe(1); + expect(body.limit).toBe(50); + }); + + it("clamps limit to ACTIVITY_LIST_MAX_LIMIT (200) in the response", async () => { + mockListActivity.mockResolvedValue({ rows: [], total: 0 }); + const { app } = createTestApp([]); + + const res = await app.request("/api/activity?limit=9999", { headers: authHeaders() }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { limit: number }; + expect(body.limit).toBe(200); + // 何が下流に渡されるかはサービス側の責務なので、ここではアサートしない。 + // Don't pin the value passed downstream — leave the bounding contract to the service. + }); + + it("rejects invalid kind with 400", async () => { + const { app } = createTestApp([]); + + const res = await app.request("/api/activity?kind=bogus", { headers: authHeaders() }); + + expect(res.status).toBe(400); + expect(mockListActivity).not.toHaveBeenCalled(); + }); + + it("rejects invalid actor with 400", async () => { + const { app } = createTestApp([]); + + const res = await app.request("/api/activity?actor=robot", { headers: authHeaders() }); + + expect(res.status).toBe(400); + expect(mockListActivity).not.toHaveBeenCalled(); + }); + + it("rejects invalid 'from' date with 400", async () => { + const { app } = createTestApp([]); + + const res = await app.request("/api/activity?from=not-a-date", { headers: authHeaders() }); + + expect(res.status).toBe(400); + }); + + it("rejects inverted from > to range with 400", async () => { + const { app } = createTestApp([]); + + const res = await app.request( + "/api/activity?from=2026-04-10T00:00:00Z&to=2026-04-01T00:00:00Z", + { headers: authHeaders() }, + ); + + expect(res.status).toBe(400); + }); + + it("falls back to default limit when limit query is non-numeric", async () => { + mockListActivity.mockResolvedValue({ rows: [], total: 0 }); + const { app } = createTestApp([]); + + const res = await app.request("/api/activity?limit=abc", { headers: authHeaders() }); + + expect(res.status).toBe(200); + expect(mockListActivity.mock.calls[0]?.[2]?.limit).toBe(50); + }); + + it("returns 401 without auth", async () => { + const { app } = createTestApp([]); + + const res = await app.request("/api/activity", { + headers: { "Content-Type": "application/json" }, + }); + + expect(res.status).toBe(401); + }); +}); + +// ── GET /api/activity/index ───────────────────────────────────────────────── + +describe("GET /api/activity/index", () => { + it("returns pageId=null and category summary when no __index__ page exists", async () => { + mockBuildIndex.mockResolvedValue({ + totalPages: 10, + categories: [ + { label: "Foo", entries: [{ id: "p-1" }, { id: "p-2" }] }, + { label: "Bar", entries: [{ id: "p-3" }] }, + ], + }); + // db.select(...).from(pages).where(...) returns empty. + const { app } = createTestApp([[]]); + + const res = await app.request("/api/activity/index", { headers: authHeaders() }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + pageId: string | null; + lastBuiltAt: string | null; + totalPages: number; + categories: Array<{ label: string; count: number }>; + }; + expect(body.pageId).toBeNull(); + expect(body.lastBuiltAt).toBeNull(); + expect(body.totalPages).toBe(10); + expect(body.categories).toEqual([ + { label: "Foo", count: 2 }, + { label: "Bar", count: 1 }, + ]); + }); + + it("returns pageId and lastBuiltAt when __index__ page exists", async () => { + mockBuildIndex.mockResolvedValue({ totalPages: 0, categories: [] }); + const built = new Date("2026-04-15T12:00:00Z"); + const { app } = createTestApp([[{ id: "page-index", updatedAt: built }]]); + + const res = await app.request("/api/activity/index", { headers: authHeaders() }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + pageId: string | null; + lastBuiltAt: string | null; + totalPages: number; + categories: unknown[]; + }; + expect(body.pageId).toBe("page-index"); + expect(body.lastBuiltAt).toBe("2026-04-15T12:00:00.000Z"); + }); +}); + +// ── POST /api/activity/index/rebuild ──────────────────────────────────────── + +describe("POST /api/activity/index/rebuild", () => { + it("returns rebuilt summary and records an index_build activity", async () => { + mockRebuildIndex.mockResolvedValue({ + pageId: "page-index", + created: true, + document: { + totalPages: 5, + categories: [{ label: "A", entries: [{ id: "p" }] }], + generatedAt: "2026-04-26T00:00:00.000Z", + }, + }); + mockRecordActivity.mockResolvedValue(undefined); + const { app } = createTestApp([]); + + const res = await app.request("/api/activity/index/rebuild", { + method: "POST", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + pageId: string; + created: boolean; + totalPages: number; + categories: Array<{ label: string; count: number }>; + generatedAt: string; + }; + expect(body.pageId).toBe("page-index"); + expect(body.created).toBe(true); + expect(body.totalPages).toBe(5); + expect(body.categories).toEqual([{ label: "A", count: 1 }]); + + expect(mockRecordActivity).toHaveBeenCalledTimes(1); + expect(mockRecordActivity.mock.calls[0]?.[1]).toMatchObject({ + ownerId: TEST_USER_ID, + kind: "index_build", + actor: "user", + targetPageIds: ["page-index"], + }); + }); + + it("returns 401 without auth", async () => { + const { app } = createTestApp([]); + + const res = await app.request("/api/activity/index/rebuild", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + + expect(res.status).toBe(401); + }); +}); diff --git a/server/api/src/__tests__/routes/ai/chat.test.ts b/server/api/src/__tests__/routes/ai/chat.test.ts new file mode 100644 index 00000000..ca2ce46c --- /dev/null +++ b/server/api/src/__tests__/routes/ai/chat.test.ts @@ -0,0 +1,376 @@ +/** + * /api/chat のテスト(モデル検証、usage 制御、SSE ストリーミング、エラー)。 + * Tests for /api/chat: model validation, usage gating, SSE streaming, errors. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { Context, Next } from "hono"; +import type { AppEnv } from "../../../types/index.js"; + +vi.mock("../../../middleware/auth.js", () => ({ + authRequired: async (c: Context, next: Next) => { + const userId = c.req.header("x-test-user-id"); + if (!userId) return c.json({ message: "Unauthorized" }, 401); + c.set("userId", userId); + await next(); + }, +})); + +// rateLimit はテストでは何もしないノーオプ。 +// rateLimit becomes a no-op in tests so we can isolate the chat handler. +vi.mock("../../../middleware/rateLimit.js", () => ({ + rateLimit: () => async (_c: Context, next: Next) => { + await next(); + }, +})); + +const { + mockGetUserTier, + mockValidateModelAccess, + mockCheckUsage, + mockCalculateCost, + mockRecordUsage, + mockCallProvider, + mockStreamProvider, + mockGetProviderApiKeyName, +} = vi.hoisted(() => ({ + mockGetUserTier: vi.fn(), + mockValidateModelAccess: vi.fn(), + mockCheckUsage: vi.fn(), + mockCalculateCost: vi.fn(), + mockRecordUsage: vi.fn(), + mockCallProvider: vi.fn(), + mockStreamProvider: vi.fn(), + mockGetProviderApiKeyName: vi.fn(), +})); + +vi.mock("../../../services/subscriptionService.js", () => ({ + getUserTier: mockGetUserTier, +})); + +vi.mock("../../../services/usageService.js", () => ({ + checkUsage: mockCheckUsage, + validateModelAccess: mockValidateModelAccess, + calculateCost: mockCalculateCost, + recordUsage: mockRecordUsage, +})); + +vi.mock("../../../services/aiProviders.js", () => ({ + callProvider: mockCallProvider, + streamProvider: mockStreamProvider, + getProviderApiKeyName: mockGetProviderApiKeyName, +})); + +import { Hono } from "hono"; +import chatRoutes from "../../../routes/ai/chat.js"; +import { errorHandler } from "../../../middleware/errorHandler.js"; +import { createMockDb } from "../../createMockDb.js"; + +const TEST_USER_ID = "user-chat-1"; +const ORIGINAL_ENV = { ...process.env }; + +function createTestApp() { + const { db } = createMockDb([]); + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("db", db as unknown as AppEnv["Variables"]["db"]); + await next(); + }); + app.onError(errorHandler); + app.route("/api/chat", chatRoutes); + return app; +} + +function authHeaders(): Record { + return { + "x-test-user-id": TEST_USER_ID, + "Content-Type": "application/json", + }; +} + +beforeEach(() => { + mockGetUserTier.mockReset().mockResolvedValue("pro"); + mockValidateModelAccess.mockReset().mockResolvedValue({ + provider: "openai", + apiModelId: "gpt-4o", + inputCostUnits: 5, + outputCostUnits: 15, + }); + mockCheckUsage.mockReset().mockResolvedValue({ + allowed: true, + usagePercent: 10, + remaining: 13500, + tier: "pro", + budgetUnits: 15000, + consumedUnits: 1500, + }); + mockCalculateCost.mockReset().mockReturnValue(42); + mockRecordUsage.mockReset().mockResolvedValue(undefined); + mockCallProvider.mockReset(); + mockStreamProvider.mockReset(); + mockGetProviderApiKeyName.mockReset().mockReturnValue("OPENAI_API_KEY"); + process.env = { ...ORIGINAL_ENV }; +}); + +// process.env と vi.spyOn(...) をテスト終了時に必ずクリーンアップする。 +// テスト失敗時に inline mockRestore() がスキップされても spy が漏れないようにする。 +// Always reset process.env and restore spies on test end so a failing test +// can't leak a console.error spy or env var into the next test. +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + vi.restoreAllMocks(); +}); + +const validBody = { + provider: "openai" as const, + model: "gpt-4o", + messages: [{ role: "user" as const, content: "hi" }], +}; + +// ── 入力検証 / Input validation ───────────────────────────────────────────── + +describe("POST /api/chat — input validation", () => { + it("returns 400 when provider is missing", async () => { + const app = createTestApp(); + + const res = await app.request("/api/chat", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ model: "gpt-4o", messages: [{ role: "user", content: "x" }] }), + }); + + expect(res.status).toBe(400); + }); + + it("returns 400 when messages array is empty", async () => { + const app = createTestApp(); + + const res = await app.request("/api/chat", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ provider: "openai", model: "gpt-4o", messages: [] }), + }); + + expect(res.status).toBe(400); + }); + + it("returns 401 without auth", async () => { + const app = createTestApp(); + + const res = await app.request("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(validBody), + }); + + expect(res.status).toBe(401); + }); +}); + +// ── usage / API key 制御 / Gating ──────────────────────────────────────────── + +describe("POST /api/chat — usage and API key gating", () => { + it("returns 429 when usage budget is exceeded", async () => { + process.env.OPENAI_API_KEY = "sk-test"; + mockCheckUsage.mockResolvedValueOnce({ + allowed: false, + usagePercent: 105, + remaining: 0, + tier: "pro", + budgetUnits: 15000, + consumedUnits: 16000, + }); + const app = createTestApp(); + + const res = await app.request("/api/chat", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(validBody), + }); + + expect(res.status).toBe(429); + }); + + it("returns 503 when the provider's API key env var is not configured", async () => { + delete process.env.OPENAI_API_KEY; + const app = createTestApp(); + + const res = await app.request("/api/chat", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(validBody), + }); + + expect(res.status).toBe(503); + }); + + it("propagates 'Model not found or inactive' as a 500 by default", async () => { + process.env.OPENAI_API_KEY = "sk-test"; + mockValidateModelAccess.mockRejectedValueOnce(new Error("Model not found or inactive")); + vi.spyOn(console, "error").mockImplementation(() => {}); + const app = createTestApp(); + + const res = await app.request("/api/chat", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(validBody), + }); + + expect(res.status).toBe(500); + }); + + it("maps a FORBIDDEN error from validateModelAccess to 403", async () => { + process.env.OPENAI_API_KEY = "sk-test"; + mockValidateModelAccess.mockRejectedValueOnce(new Error("FORBIDDEN")); + vi.spyOn(console, "error").mockImplementation(() => {}); + const app = createTestApp(); + + const res = await app.request("/api/chat", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(validBody), + }); + + // errorHandler の statusMap で FORBIDDEN → 403。 + // statusMap in errorHandler maps the literal "FORBIDDEN" message to 403. + expect(res.status).toBe(403); + }); +}); + +// ── 非ストリーミング応答 / Non-streaming response ─────────────────────────── + +describe("POST /api/chat — non-streaming response", () => { + it("returns content + usage and records the usage", async () => { + process.env.OPENAI_API_KEY = "sk-test"; + mockCallProvider.mockResolvedValue({ + content: "hello", + usage: { inputTokens: 10, outputTokens: 5 }, + finishReason: "stop", + }); + const app = createTestApp(); + + const res = await app.request("/api/chat", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(validBody), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + content: string; + finishReason: string; + usage: { inputTokens: number; outputTokens: number; costUnits: number; usagePercent: number }; + }; + expect(body.content).toBe("hello"); + expect(body.finishReason).toBe("stop"); + expect(body.usage.inputTokens).toBe(10); + expect(body.usage.outputTokens).toBe(5); + expect(body.usage.costUnits).toBe(42); + + expect(mockRecordUsage).toHaveBeenCalledWith( + TEST_USER_ID, + "gpt-4o", + "chat", + { inputTokens: 10, outputTokens: 5 }, + 42, + "system", + expect.anything(), + ); + }); + + it("uses 'chat' as the default feature when options.feature is omitted", async () => { + process.env.OPENAI_API_KEY = "sk-test"; + mockCallProvider.mockResolvedValue({ + content: "ok", + usage: { inputTokens: 1, outputTokens: 1 }, + finishReason: "stop", + }); + const app = createTestApp(); + + await app.request("/api/chat", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(validBody), + }); + + expect(mockRecordUsage.mock.calls[0]?.[2]).toBe("chat"); + }); + + it("respects custom options.feature", async () => { + process.env.OPENAI_API_KEY = "sk-test"; + mockCallProvider.mockResolvedValue({ + content: "ok", + usage: { inputTokens: 1, outputTokens: 1 }, + finishReason: "stop", + }); + const app = createTestApp(); + + await app.request("/api/chat", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ ...validBody, options: { feature: "summarize" } }), + }); + + expect(mockRecordUsage.mock.calls[0]?.[2]).toBe("summarize"); + }); +}); + +// ── ストリーミング応答 / SSE streaming ────────────────────────────────────── + +describe("POST /api/chat — SSE streaming", () => { + it("emits chunk and done payloads then records usage", async () => { + process.env.OPENAI_API_KEY = "sk-test"; + + async function* fakeStream() { + yield { content: "Hel" }; + yield { content: "lo" }; + yield { done: true, finishReason: "stop" }; + } + mockStreamProvider.mockReturnValue(fakeStream()); + const app = createTestApp(); + + const res = await app.request("/api/chat", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ ...validBody, options: { stream: true } }), + }); + + expect(res.status).toBe(200); + const text = await res.text(); + + // SSE フォーマット: 各 data: 行に JSON ペイロードが入る。 + // SSE format: each `data: ...` line carries one JSON payload. + expect(text).toContain('data: {"content":"Hel"}'); + expect(text).toContain('data: {"content":"lo"}'); + expect(text).toMatch(/"done":true/); + expect(text).toMatch(/"finishReason":"stop"/); + + expect(mockRecordUsage).toHaveBeenCalledTimes(1); + const recordedFeature = mockRecordUsage.mock.calls[0]?.[2]; + expect(recordedFeature).toBe("chat"); + }); + + it("emits an error payload when the provider stream throws", async () => { + process.env.OPENAI_API_KEY = "sk-test"; + + async function* failingStream(): AsyncGenerator<{ content?: string }> { + yield { content: "partial" }; + throw new Error("upstream 500"); + } + mockStreamProvider.mockReturnValue(failingStream()); + const app = createTestApp(); + + const res = await app.request("/api/chat", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ ...validBody, options: { stream: true } }), + }); + + expect(res.status).toBe(200); + const text = await res.text(); + expect(text).toMatch(/"error":"upstream 500"/); + expect(text).toMatch(/"done":true/); + // エラー時は recordUsage を呼ばない(done チャンクが来ないため)。 + // recordUsage is skipped on stream error since no `done` chunk arrives. + expect(mockRecordUsage).not.toHaveBeenCalled(); + }); +}); diff --git a/server/api/src/__tests__/routes/checkout.test.ts b/server/api/src/__tests__/routes/checkout.test.ts new file mode 100644 index 00000000..1fb08568 --- /dev/null +++ b/server/api/src/__tests__/routes/checkout.test.ts @@ -0,0 +1,196 @@ +/** + * /api/checkout のテスト(Polar 連携、Origin 検証、successUrl 構築)。 + * Tests for /api/checkout (Polar integration, origin validation, successUrl). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Context, Next } from "hono"; +import type { AppEnv } from "../../types/index.js"; + +vi.mock("../../middleware/auth.js", () => ({ + authRequired: async (c: Context, next: Next) => { + const userId = c.req.header("x-test-user-id"); + if (!userId) return c.json({ message: "Unauthorized" }, 401); + c.set("userId", userId); + await next(); + }, +})); + +const { mockCheckoutsCreate, mockCustomerSessionsCreate, mockGetAllowedOrigins, mockGetEnv } = + vi.hoisted(() => ({ + mockCheckoutsCreate: vi.fn(), + mockCustomerSessionsCreate: vi.fn(), + mockGetAllowedOrigins: vi.fn(), + mockGetEnv: vi.fn(), + })); + +vi.mock("@polar-sh/sdk", () => ({ + Polar: class MockPolar { + checkouts = { create: mockCheckoutsCreate }; + customerSessions = { create: mockCustomerSessionsCreate }; + }, +})); + +vi.mock("../../lib/cors.js", () => ({ + getAllowedOrigins: () => mockGetAllowedOrigins(), +})); + +vi.mock("../../lib/env.js", () => ({ + getEnv: (key: string) => mockGetEnv(key), +})); + +import { Hono } from "hono"; +import checkoutRoutes from "../../routes/checkout.js"; +import { errorHandler } from "../../middleware/errorHandler.js"; + +const TEST_USER_ID = "user-checkout-1"; + +function createTestApp() { + const app = new Hono(); + app.onError(errorHandler); + app.route("/api", checkoutRoutes); + return app; +} + +function authHeaders(extra: Record = {}): Record { + return { + "x-test-user-id": TEST_USER_ID, + "Content-Type": "application/json", + ...extra, + }; +} + +beforeEach(() => { + mockCheckoutsCreate.mockReset(); + mockCustomerSessionsCreate.mockReset(); + mockGetAllowedOrigins.mockReset().mockReturnValue([]); + mockGetEnv.mockReset().mockReturnValue("polar-token"); +}); + +// ── POST /api/checkout ────────────────────────────────────────────────────── + +describe("POST /api/checkout", () => { + it("returns 400 when productId is missing", async () => { + const app = createTestApp(); + + const res = await app.request("/api/checkout", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({}), + }); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("productId is required"); + expect(mockCheckoutsCreate).not.toHaveBeenCalled(); + }); + + it("uses Origin header when allowed and builds successUrl", async () => { + mockGetAllowedOrigins.mockReturnValue([ + "https://app.example.com", + "https://staging.example.com", + ]); + mockCheckoutsCreate.mockResolvedValue({ url: "https://polar.example/checkout/abc" }); + const app = createTestApp(); + + const res = await app.request("/api/checkout", { + method: "POST", + headers: authHeaders({ Origin: "https://app.example.com" }), + body: JSON.stringify({ productId: "prod-1" }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { url: string }; + expect(body.url).toBe("https://polar.example/checkout/abc"); + expect(mockCheckoutsCreate).toHaveBeenCalledWith({ + products: ["prod-1"], + externalCustomerId: TEST_USER_ID, + successUrl: "https://app.example.com/pricing?checkout=success", + }); + }); + + it("rejects untrusted Origin by leaving successUrl undefined", async () => { + mockGetAllowedOrigins.mockReturnValue(["https://app.example.com"]); + mockCheckoutsCreate.mockResolvedValue({ url: "https://polar.example/checkout/abc" }); + const app = createTestApp(); + + const res = await app.request("/api/checkout", { + method: "POST", + headers: authHeaders({ Origin: "https://evil.example.com" }), + body: JSON.stringify({ productId: "prod-1" }), + }); + + expect(res.status).toBe(200); + expect(mockCheckoutsCreate).toHaveBeenCalledWith({ + products: ["prod-1"], + externalCustomerId: TEST_USER_ID, + }); + }); + + it("falls back to first allowed origin when no Origin header is present", async () => { + mockGetAllowedOrigins.mockReturnValue([ + "https://primary.example.com", + "https://other.example.com", + ]); + mockCheckoutsCreate.mockResolvedValue({ url: "https://polar.example/checkout/xyz" }); + const app = createTestApp(); + + const res = await app.request("/api/checkout", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ productId: "prod-2" }), + }); + + expect(res.status).toBe(200); + expect(mockCheckoutsCreate).toHaveBeenCalledWith({ + products: ["prod-2"], + externalCustomerId: TEST_USER_ID, + successUrl: "https://primary.example.com/pricing?checkout=success", + }); + }); + + it("returns 401 without auth", async () => { + const app = createTestApp(); + + const res = await app.request("/api/checkout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ productId: "p" }), + }); + + expect(res.status).toBe(401); + }); +}); + +// ── POST /api/customer-portal ─────────────────────────────────────────────── + +describe("POST /api/customer-portal", () => { + it("returns the customer portal URL from Polar", async () => { + mockCustomerSessionsCreate.mockResolvedValue({ + customerPortalUrl: "https://polar.example/portal/u1", + }); + const app = createTestApp(); + + const res = await app.request("/api/customer-portal", { + method: "POST", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { url: string }; + expect(body.url).toBe("https://polar.example/portal/u1"); + expect(mockCustomerSessionsCreate).toHaveBeenCalledWith({ + externalCustomerId: TEST_USER_ID, + }); + }); + + it("returns 401 without auth", async () => { + const app = createTestApp(); + + const res = await app.request("/api/customer-portal", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + + expect(res.status).toBe(401); + }); +}); diff --git a/server/api/src/__tests__/routes/health.test.ts b/server/api/src/__tests__/routes/health.test.ts new file mode 100644 index 00000000..1815b3c0 --- /dev/null +++ b/server/api/src/__tests__/routes/health.test.ts @@ -0,0 +1,45 @@ +/** + * /health のテスト。認証不要、レート制限なし、現在時刻を返すだけ。 + * Tests for /health: no auth, no rate limiting, returns current timestamp. + */ +import { describe, it, expect } from "vitest"; +import { Hono } from "hono"; +import type { AppEnv } from "../../types/index.js"; +import healthRoutes from "../../routes/health.js"; + +function createHealthApp(): Hono { + const app = new Hono(); + app.route("/", healthRoutes); + return app; +} + +describe("GET /health", () => { + it("returns 200 with status ok and an ISO timestamp", async () => { + const app = createHealthApp(); + + const before = Date.now(); + const res = await app.request("/health"); + const after = Date.now(); + + expect(res.status).toBe(200); + const body = (await res.json()) as { status: string; timestamp: string }; + expect(body.status).toBe("ok"); + expect(typeof body.timestamp).toBe("string"); + + // ISO 8601 の妥当性確認 + ハンドラ実行範囲内の時刻であること。 + // Validate ISO 8601 parseability and that the timestamp falls within the call window. + const parsed = new Date(body.timestamp).getTime(); + expect(Number.isFinite(parsed)).toBe(true); + expect(parsed).toBeGreaterThanOrEqual(before); + expect(parsed).toBeLessThanOrEqual(after); + }); + + it("does not require auth", async () => { + const app = createHealthApp(); + + // 認証ヘッダなしでも 200 を返すことを確認する。 + // Confirm 200 is returned even without auth headers. + const res = await app.request("/health"); + expect(res.status).toBe(200); + }); +}); diff --git a/server/api/src/__tests__/routes/lint.test.ts b/server/api/src/__tests__/routes/lint.test.ts new file mode 100644 index 00000000..16539ef9 --- /dev/null +++ b/server/api/src/__tests__/routes/lint.test.ts @@ -0,0 +1,243 @@ +/** + * /api/lint のテスト(run, findings, page-scoped findings, resolve)。 + * Tests for /api/lint routes. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Context, Next } from "hono"; +import type { AppEnv } from "../../types/index.js"; + +vi.mock("../../middleware/auth.js", () => ({ + authRequired: async (c: Context, next: Next) => { + const userId = c.req.header("x-test-user-id"); + if (!userId) return c.json({ message: "Unauthorized" }, 401); + c.set("userId", userId); + await next(); + }, +})); + +const { mockRunAll, mockGetUnresolved, mockGetForPage, mockResolve } = vi.hoisted(() => ({ + mockRunAll: vi.fn(), + mockGetUnresolved: vi.fn(), + mockGetForPage: vi.fn(), + mockResolve: vi.fn(), +})); + +vi.mock("../../services/lintEngine/index.js", () => ({ + runAllLintRules: mockRunAll, + getUnresolvedFindings: mockGetUnresolved, + getFindingsForPage: mockGetForPage, + resolveFinding: mockResolve, +})); + +import { Hono } from "hono"; +import lintRoutes from "../../routes/lint.js"; +import { errorHandler } from "../../middleware/errorHandler.js"; +import { createMockDb } from "../createMockDb.js"; + +const TEST_USER_ID = "user-lint-1"; + +function createTestApp() { + const { db } = createMockDb([]); + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("db", db as unknown as AppEnv["Variables"]["db"]); + await next(); + }); + app.onError(errorHandler); + app.route("/api/lint", lintRoutes); + return app; +} + +function authHeaders(userId: string = TEST_USER_ID): Record { + return { + "x-test-user-id": userId, + "Content-Type": "application/json", + }; +} + +beforeEach(() => { + mockRunAll.mockReset(); + mockGetUnresolved.mockReset(); + mockGetForPage.mockReset(); + mockResolve.mockReset(); +}); + +// ── POST /api/lint/run ────────────────────────────────────────────────────── + +describe("POST /api/lint/run", () => { + it("returns aggregated summary and total finding count", async () => { + mockRunAll.mockResolvedValue([ + { rule: "orphan", findings: [{}, {}] }, + { rule: "broken_link", findings: [{}] }, + { rule: "ghost_many", findings: [] }, + ]); + const app = createTestApp(); + + const res = await app.request("/api/lint/run", { + method: "POST", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + summary: Array<{ rule: string; count: number }>; + total: number; + }; + expect(body.summary).toEqual([ + { rule: "orphan", count: 2 }, + { rule: "broken_link", count: 1 }, + { rule: "ghost_many", count: 0 }, + ]); + expect(body.total).toBe(3); + }); + + it("returns 401 without auth", async () => { + const app = createTestApp(); + + const res = await app.request("/api/lint/run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + + expect(res.status).toBe(401); + expect(mockRunAll).not.toHaveBeenCalled(); + }); +}); + +// ── GET /api/lint/findings ────────────────────────────────────────────────── + +describe("GET /api/lint/findings", () => { + it("returns mapped findings with snake_case fields and total", async () => { + mockGetUnresolved.mockResolvedValue([ + { + id: "f-1", + rule: "orphan", + severity: "info", + pageIds: ["p-1"], + detail: { title: "T" }, + createdAt: new Date("2026-04-01T00:00:00Z"), + }, + ]); + const app = createTestApp(); + + const res = await app.request("/api/lint/findings", { headers: authHeaders() }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + findings: Array>; + total: number; + }; + expect(body.total).toBe(1); + expect(body.findings[0]).toEqual({ + id: "f-1", + rule: "orphan", + severity: "info", + page_ids: ["p-1"], + detail: { title: "T" }, + created_at: "2026-04-01T00:00:00.000Z", + }); + }); + + it("returns empty list when no unresolved findings", async () => { + mockGetUnresolved.mockResolvedValue([]); + const app = createTestApp(); + + const res = await app.request("/api/lint/findings", { headers: authHeaders() }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { findings: unknown[]; total: number }; + expect(body.findings).toEqual([]); + expect(body.total).toBe(0); + }); +}); + +// ── GET /api/lint/findings/page/:pageId ───────────────────────────────────── + +describe("GET /api/lint/findings/page/:pageId", () => { + it("forwards pageId to service and returns findings", async () => { + mockGetForPage.mockResolvedValue([ + { + id: "f-2", + rule: "broken_link", + severity: "error", + pageIds: ["p-9", "p-10"], + detail: {}, + createdAt: new Date("2026-04-02T00:00:00Z"), + }, + ]); + const app = createTestApp(); + + const res = await app.request("/api/lint/findings/page/p-9", { + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + expect(mockGetForPage).toHaveBeenCalledWith(TEST_USER_ID, "p-9", expect.anything()); + const body = (await res.json()) as { findings: Array>; total: number }; + expect(body.total).toBe(1); + expect(body.findings[0]?.id).toBe("f-2"); + }); +}); + +// ── POST /api/lint/findings/:id/resolve ───────────────────────────────────── + +describe("POST /api/lint/findings/:id/resolve", () => { + it("returns the resolved finding when present", async () => { + mockResolve.mockResolvedValue({ + id: "f-3", + rule: "orphan", + severity: "info", + pageIds: ["p-3"], + detail: {}, + resolvedAt: new Date("2026-04-03T00:00:00Z"), + createdAt: new Date("2026-04-01T00:00:00Z"), + }); + const app = createTestApp(); + + const res = await app.request("/api/lint/findings/f-3/resolve", { + method: "POST", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { finding: Record }; + expect(body.finding.id).toBe("f-3"); + expect(body.finding.resolved_at).toBe("2026-04-03T00:00:00.000Z"); + expect(body.finding.created_at).toBe("2026-04-01T00:00:00.000Z"); + expect(body.finding.page_ids).toEqual(["p-3"]); + }); + + it("returns 404 when finding does not exist or belongs to another user", async () => { + mockResolve.mockResolvedValue(null); + const app = createTestApp(); + + const res = await app.request("/api/lint/findings/missing/resolve", { + method: "POST", + headers: authHeaders(), + }); + + expect(res.status).toBe(404); + }); + + it("handles null resolvedAt by serializing to null", async () => { + mockResolve.mockResolvedValue({ + id: "f-4", + rule: "orphan", + severity: "info", + pageIds: [], + detail: {}, + resolvedAt: null, + createdAt: new Date("2026-04-01T00:00:00Z"), + }); + const app = createTestApp(); + + const res = await app.request("/api/lint/findings/f-4/resolve", { + method: "POST", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { finding: Record }; + expect(body.finding.resolved_at).toBeNull(); + }); +}); diff --git a/server/api/src/__tests__/routes/onboarding.test.ts b/server/api/src/__tests__/routes/onboarding.test.ts new file mode 100644 index 00000000..e8b6dde5 --- /dev/null +++ b/server/api/src/__tests__/routes/onboarding.test.ts @@ -0,0 +1,279 @@ +/** + * /api/onboarding のテスト(POST /complete, GET /status)。 + * Tests for /api/onboarding routes (POST /complete, GET /status). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Context, Next } from "hono"; +import type { AppEnv } from "../../types/index.js"; + +vi.mock("../../middleware/auth.js", () => ({ + authRequired: async (c: Context, next: Next) => { + const userId = c.req.header("x-test-user-id"); + if (!userId) return c.json({ message: "Unauthorized" }, 401); + c.set("userId", userId); + await next(); + }, +})); + +const { mockInsertWelcomePage, mockRetryWelcomePageIfNeeded } = vi.hoisted(() => ({ + mockInsertWelcomePage: vi.fn(), + mockRetryWelcomePageIfNeeded: vi.fn(), +})); + +vi.mock("../../lib/welcomePageService.js", () => ({ + insertWelcomePage: mockInsertWelcomePage, + retryWelcomePageIfNeeded: mockRetryWelcomePageIfNeeded, +})); + +import { Hono } from "hono"; +import onboardingRoutes from "../../routes/onboarding.js"; +import { errorHandler } from "../../middleware/errorHandler.js"; +import { createMockDb } from "../createMockDb.js"; + +const TEST_USER_ID = "user-onboard-1"; + +function createTestApp(dbResults: unknown[]) { + const { db, chains } = createMockDb(dbResults); + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("db", db as unknown as AppEnv["Variables"]["db"]); + await next(); + }); + app.onError(errorHandler); + app.route("/api/onboarding", onboardingRoutes); + return { app, chains }; +} + +function authHeaders(userId: string = TEST_USER_ID): Record { + return { + "x-test-user-id": userId, + "Content-Type": "application/json", + }; +} + +beforeEach(() => { + mockInsertWelcomePage.mockReset(); + mockRetryWelcomePageIfNeeded.mockReset(); +}); + +// ── POST /api/onboarding/complete ─────────────────────────────────────────── + +describe("POST /api/onboarding/complete", () => { + const finalRow = { + setupCompletedAt: new Date("2026-04-01T00:00:00Z"), + welcomePageId: "page-welcome-1", + welcomePageCreatedAt: new Date("2026-04-01T00:00:00Z"), + }; + + it("returns 200 with normalized state when display_name and locale are valid", async () => { + mockInsertWelcomePage.mockResolvedValue({ + pageId: "page-welcome-1", + locale: "ja", + }); + // tx.update(users), tx.insert(userOnboardingStatus), tx.select() の 3 回。 + // The handler calls tx.update, tx.insert, then tx.select inside the transaction. + const { app } = createTestApp([undefined, undefined, [finalRow]]); + + const res = await app.request("/api/onboarding/complete", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + display_name: " Alice ", + avatar_url: " https://example.com/a.png ", + locale: "ja", + }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + setup_completed_at: string | null; + welcome_page_id: string | null; + welcome_page_created_at: string | null; + welcome_page_locale: string | null; + }; + expect(body.setup_completed_at).toBe("2026-04-01T00:00:00.000Z"); + expect(body.welcome_page_id).toBe("page-welcome-1"); + expect(body.welcome_page_locale).toBe("ja"); + + // insertWelcomePage は trim 後の値ではなく正規化された locale を受け取る。 + // insertWelcomePage receives the normalized locale (ja|en|null). + expect(mockInsertWelcomePage).toHaveBeenCalledTimes(1); + expect(mockInsertWelcomePage.mock.calls[0]?.[2]).toBe("ja"); + }); + + it("passes unknown locale through to insertWelcomePage as null", async () => { + // 実サービスは locale を必ず "ja" | "en" のいずれかで返す(resolveWelcomePageLocale + // のフォールバックがあるため)。ここでは "ja" にフォールバックされた結果を再現する。 + // The real service always returns locale: "ja" | "en" because resolveWelcomePageLocale + // falls back to "ja" when requested is null. Mirror that real shape here. + mockInsertWelcomePage.mockResolvedValue({ pageId: "p", locale: "ja" }); + const { app } = createTestApp([undefined, undefined, [finalRow]]); + + const res = await app.request("/api/onboarding/complete", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ display_name: "Alice", locale: "fr" }), + }); + + expect(res.status).toBe(200); + // 不明な locale はサービス呼び出し時に null へ正規化される。 + // Unknown locales must be normalized to null before reaching the service. + expect(mockInsertWelcomePage.mock.calls[0]?.[2]).toBeNull(); + }); + + it("returns 400 for invalid JSON body", async () => { + const { app } = createTestApp([]); + + const res = await app.request("/api/onboarding/complete", { + method: "POST", + headers: authHeaders(), + body: "not-json", + }); + + expect(res.status).toBe(400); + expect(mockInsertWelcomePage).not.toHaveBeenCalled(); + }); + + it("returns 400 when body is not an object", async () => { + const { app } = createTestApp([]); + + const res = await app.request("/api/onboarding/complete", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(null), + }); + + expect(res.status).toBe(400); + expect(mockInsertWelcomePage).not.toHaveBeenCalled(); + }); + + it("returns 400 when display_name is missing or empty after trim", async () => { + const { app } = createTestApp([]); + + const res = await app.request("/api/onboarding/complete", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ display_name: " " }), + }); + + expect(res.status).toBe(400); + expect(mockInsertWelcomePage).not.toHaveBeenCalled(); + }); + + it("returns 400 when display_name exceeds 120 characters", async () => { + const { app } = createTestApp([]); + const longName = "a".repeat(121); + + const res = await app.request("/api/onboarding/complete", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ display_name: longName }), + }); + + expect(res.status).toBe(400); + expect(mockInsertWelcomePage).not.toHaveBeenCalled(); + }); + + it("returns 401 without auth header", async () => { + const { app } = createTestApp([]); + + const res = await app.request("/api/onboarding/complete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ display_name: "Alice" }), + }); + + expect(res.status).toBe(401); + }); + + it("preserves existing welcome page fields when insertWelcomePage returns null", async () => { + // service が null を返すのは「ウェルカムページが既に存在する」シグナル。 + // この場合、既存レコードの welcome_page_id を COALESCE で保持する。 + // A null result means the welcome page already exists (idempotent path); + // the existing row's welcome_page_id is preserved via COALESCE. + mockInsertWelcomePage.mockResolvedValue(null); + const rowWithExistingWelcome = { + setupCompletedAt: new Date("2026-04-01T00:00:00Z"), + welcomePageId: "page-existing-1", + welcomePageCreatedAt: new Date("2026-03-31T00:00:00Z"), + }; + const { app } = createTestApp([undefined, undefined, [rowWithExistingWelcome]]); + + const res = await app.request("/api/onboarding/complete", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ display_name: "Alice", locale: "en" }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.welcome_page_id).toBe("page-existing-1"); + expect(body.welcome_page_created_at).toBe("2026-03-31T00:00:00.000Z"); + // welcome_page_locale は service から読むため、null 返却時は null になる。 + // welcome_page_locale comes from the service result; null result → null locale. + expect(body.welcome_page_locale).toBeNull(); + }); +}); + +// ── GET /api/onboarding/status ────────────────────────────────────────────── + +describe("GET /api/onboarding/status", () => { + it("returns the persisted onboarding row", async () => { + mockRetryWelcomePageIfNeeded.mockResolvedValue(undefined); + const row = { + setupCompletedAt: new Date("2026-04-01T00:00:00Z"), + welcomePageId: "page-1", + welcomePageCreatedAt: new Date("2026-04-01T00:00:00Z"), + homeSlidesShownAt: new Date("2026-04-02T00:00:00Z"), + autoCreateUpdateNotice: false, + }; + const { app } = createTestApp([[row]]); + + const res = await app.request("/api/onboarding/status", { + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).toEqual({ + setup_completed_at: "2026-04-01T00:00:00.000Z", + welcome_page_id: "page-1", + welcome_page_created_at: "2026-04-01T00:00:00.000Z", + home_slides_shown_at: "2026-04-02T00:00:00.000Z", + auto_create_update_notice: false, + }); + expect(mockRetryWelcomePageIfNeeded).toHaveBeenCalledTimes(1); + // 認証済みユーザー ID が 2 番目の引数として渡されることを確認する。 + // The authenticated userId must be forwarded as the second argument. + expect(mockRetryWelcomePageIfNeeded.mock.calls[0]?.[1]).toBe(TEST_USER_ID); + }); + + it("returns nulls and auto_create_update_notice=true when no row exists", async () => { + mockRetryWelcomePageIfNeeded.mockResolvedValue(undefined); + const { app } = createTestApp([[]]); + + const res = await app.request("/api/onboarding/status", { + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).toEqual({ + setup_completed_at: null, + welcome_page_id: null, + welcome_page_created_at: null, + home_slides_shown_at: null, + auto_create_update_notice: true, + }); + }); + + it("returns 401 without auth header", async () => { + const { app } = createTestApp([]); + + const res = await app.request("/api/onboarding/status", { + headers: { "Content-Type": "application/json" }, + }); + + expect(res.status).toBe(401); + }); +}); diff --git a/server/api/src/__tests__/routes/pages.test.ts b/server/api/src/__tests__/routes/pages.test.ts index ec8a27b0..a36b3d67 100644 --- a/server/api/src/__tests__/routes/pages.test.ts +++ b/server/api/src/__tests__/routes/pages.test.ts @@ -334,4 +334,50 @@ describe("PUT /api/pages/:id/content", () => { expect(res.status).toBe(400); }); + + // Issue #726: タイトル変更検出のため、PUT に title が含まれるとき pages.title + // を SELECT してから UPDATE を行う。これにより伝播処理の起点になる。 + // Issue #726: when PUT carries `title`, the route SELECTs the current + // `pages.title` before UPDATE so the handler can detect a rename and + // trigger background propagation. + it("issues an extra SELECT for rename detection when body.title is provided", async () => { + const ydocB64 = Buffer.from("hello").toString("base64"); + const { app, chains } = createPagesAppWithChains([ + // 1. access check select + [{ id: PAGE_ID, ownerId: TEST_USER_ID }], + // 2. UPDATE page_contents (optimistic version path) + [{ version: 2, pageId: PAGE_ID }], + // 3. SELECT pages.title in applyPagesMetadataUpdate (rename detection) + // Same title as body → no propagation triggered. + [{ title: "Same Title" }], + // 4. UPDATE pages (title + updatedAt) + [], + // 5. auto-snapshot select (empty → no snapshot) + [], + ]); + + const res = await app.request(`/api/pages/${PAGE_ID}/content`, { + method: "PUT", + headers: authHeaders(), + body: JSON.stringify({ + ydoc_state: ydocB64, + expected_version: 1, + title: "Same Title", + }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { version: number }; + expect(body.version).toBe(2); + + // applyPagesMetadataUpdate must have issued the extra SELECT for the + // pages.title read. The shape includes access-check SELECT + title-read + // SELECT (+ auto-snapshot SELECT), and at least one UPDATE chain. + // リネーム検出のため pages.title を読む SELECT が増えること。 + const selectChains = chains.filter((c) => c.startMethod === "select"); + expect(selectChains.length).toBeGreaterThanOrEqual(2); + const updateChains = chains.filter((c) => c.startMethod === "update"); + // UPDATE page_contents + UPDATE pages + expect(updateChains.length).toBeGreaterThanOrEqual(2); + }); }); diff --git a/server/api/src/__tests__/routes/subscriptionManage.test.ts b/server/api/src/__tests__/routes/subscriptionManage.test.ts new file mode 100644 index 00000000..9cd49b95 --- /dev/null +++ b/server/api/src/__tests__/routes/subscriptionManage.test.ts @@ -0,0 +1,394 @@ +/** + * /api/subscription のテスト(details / cancel / reactivate / change-plan)。 + * Tests for subscription management routes. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { Context, Next } from "hono"; +import type { AppEnv } from "../../types/index.js"; + +vi.mock("../../middleware/auth.js", () => ({ + authRequired: async (c: Context, next: Next) => { + const userId = c.req.header("x-test-user-id"); + if (!userId) return c.json({ message: "Unauthorized" }, 401); + c.set("userId", userId); + await next(); + }, +})); + +const { + mockSubscriptionsUpdate, + mockGetUserTier, + mockGetSubscription, + mockCheckUsage, + mockGetEnv, +} = vi.hoisted(() => ({ + mockSubscriptionsUpdate: vi.fn(), + mockGetUserTier: vi.fn(), + mockGetSubscription: vi.fn(), + mockCheckUsage: vi.fn(), + mockGetEnv: vi.fn(), +})); + +vi.mock("@polar-sh/sdk", () => ({ + Polar: class MockPolar { + subscriptions = { update: mockSubscriptionsUpdate }; + }, +})); + +vi.mock("../../lib/env.js", () => ({ + getEnv: (key: string) => mockGetEnv(key), +})); + +vi.mock("../../services/subscriptionService.js", () => ({ + getUserTier: mockGetUserTier, + getSubscription: mockGetSubscription, +})); + +vi.mock("../../services/usageService.js", () => ({ + checkUsage: mockCheckUsage, +})); + +import { Hono } from "hono"; +import subRoutes from "../../routes/subscriptionManage.js"; +import { errorHandler } from "../../middleware/errorHandler.js"; +import { createMockDb } from "../createMockDb.js"; + +const TEST_USER_ID = "user-sub-1"; +const ORIGINAL_ENV = { ...process.env }; + +function createTestApp() { + const { db } = createMockDb([]); + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("db", db as unknown as AppEnv["Variables"]["db"]); + await next(); + }); + app.onError(errorHandler); + app.route("/api/subscription", subRoutes); + return app; +} + +function authHeaders(): Record { + return { + "x-test-user-id": TEST_USER_ID, + "Content-Type": "application/json", + }; +} + +beforeEach(() => { + mockSubscriptionsUpdate.mockReset(); + mockGetUserTier.mockReset(); + mockGetSubscription.mockReset(); + mockCheckUsage.mockReset(); + mockGetEnv.mockReset().mockReturnValue("polar-token"); + process.env = { ...ORIGINAL_ENV }; +}); + +// process.env と vi.spyOn(...) を必ずクリーンアップする。 +// テスト失敗時に inline mockRestore() がスキップされても spy が漏れないようにする。 +// Reset process.env and restore spies on test end so a failing assertion can't +// leak a console.error spy or env var into the next test. +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + vi.restoreAllMocks(); +}); + +// ── GET /details ──────────────────────────────────────────────────────────── + +describe("GET /api/subscription/details", () => { + it("returns free-plan response when user has no subscription", async () => { + mockGetUserTier.mockResolvedValue("free"); + mockGetSubscription.mockResolvedValue(null); + mockCheckUsage.mockResolvedValue({ + budgetUnits: 1500, + consumedUnits: 100, + remaining: 1400, + usagePercent: 6.67, + }); + const app = createTestApp(); + + const res = await app.request("/api/subscription/details", { headers: authHeaders() }); + + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).toMatchObject({ + plan: "free", + status: "active", + billingInterval: null, + currentPeriodStart: null, + currentPeriodEnd: null, + usage: { + budgetUnits: 1500, + consumedUnits: 100, + remainingUnits: 1400, + }, + }); + }); + + it("returns paid plan details when subscription exists", async () => { + mockGetUserTier.mockResolvedValue("pro"); + mockGetSubscription.mockResolvedValue({ + plan: "pro", + status: "active", + billingInterval: "monthly", + currentPeriodStart: "2026-04-01", + currentPeriodEnd: "2026-05-01", + externalId: "sub_123", + }); + mockCheckUsage.mockResolvedValue({ + budgetUnits: 15000, + consumedUnits: 7500, + remaining: 7500, + usagePercent: 50, + }); + const app = createTestApp(); + + const res = await app.request("/api/subscription/details", { headers: authHeaders() }); + + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).toMatchObject({ + plan: "pro", + status: "active", + billingInterval: "monthly", + currentPeriodStart: "2026-04-01", + currentPeriodEnd: "2026-05-01", + usage: { budgetUnits: 15000, consumedUnits: 7500, remainingUnits: 7500, usagePercent: 50 }, + }); + }); +}); + +// ── POST /cancel ──────────────────────────────────────────────────────────── + +describe("POST /api/subscription/cancel", () => { + it("returns 404 when no active subscription", async () => { + mockGetSubscription.mockResolvedValue(null); + const app = createTestApp(); + + const res = await app.request("/api/subscription/cancel", { + method: "POST", + headers: authHeaders(), + }); + + expect(res.status).toBe(404); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("No active subscription found"); + expect(mockSubscriptionsUpdate).not.toHaveBeenCalled(); + }); + + it("calls Polar update with cancelAtPeriodEnd: true and returns success", async () => { + mockGetSubscription.mockResolvedValue({ externalId: "sub_42" }); + mockSubscriptionsUpdate.mockResolvedValue({}); + const app = createTestApp(); + + const res = await app.request("/api/subscription/cancel", { + method: "POST", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { success: boolean; message: string }; + expect(body.success).toBe(true); + expect(mockSubscriptionsUpdate).toHaveBeenCalledWith({ + id: "sub_42", + subscriptionUpdate: { cancelAtPeriodEnd: true }, + }); + }); + + it("returns 500 when Polar throws", async () => { + mockGetSubscription.mockResolvedValue({ externalId: "sub_42" }); + mockSubscriptionsUpdate.mockRejectedValue(new Error("Polar down")); + vi.spyOn(console, "error").mockImplementation(() => {}); + const app = createTestApp(); + + const res = await app.request("/api/subscription/cancel", { + method: "POST", + headers: authHeaders(), + }); + + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("Failed to cancel subscription"); + }); +}); + +// ── POST /reactivate ──────────────────────────────────────────────────────── + +describe("POST /api/subscription/reactivate", () => { + it("returns 404 when no subscription", async () => { + mockGetSubscription.mockResolvedValue(null); + const app = createTestApp(); + + const res = await app.request("/api/subscription/reactivate", { + method: "POST", + headers: authHeaders(), + }); + + expect(res.status).toBe(404); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("No subscription found"); + }); + + it("calls Polar update with cancelAtPeriodEnd: false", async () => { + mockGetSubscription.mockResolvedValue({ externalId: "sub_99" }); + mockSubscriptionsUpdate.mockResolvedValue({}); + const app = createTestApp(); + + const res = await app.request("/api/subscription/reactivate", { + method: "POST", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + expect(mockSubscriptionsUpdate).toHaveBeenCalledWith({ + id: "sub_99", + subscriptionUpdate: { cancelAtPeriodEnd: false }, + }); + }); + + it("returns 500 when Polar throws", async () => { + mockGetSubscription.mockResolvedValue({ externalId: "sub_99" }); + mockSubscriptionsUpdate.mockRejectedValue(new Error("oops")); + vi.spyOn(console, "error").mockImplementation(() => {}); + const app = createTestApp(); + + const res = await app.request("/api/subscription/reactivate", { + method: "POST", + headers: authHeaders(), + }); + + expect(res.status).toBe(500); + }); +}); + +// ── POST /change-plan ─────────────────────────────────────────────────────── + +describe("POST /api/subscription/change-plan", () => { + it("returns 400 when JSON body is invalid", async () => { + const app = createTestApp(); + + const res = await app.request("/api/subscription/change-plan", { + method: "POST", + headers: authHeaders(), + body: "{not json", + }); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("Invalid JSON body"); + }); + + it("returns 400 when billingInterval is invalid", async () => { + const app = createTestApp(); + + const res = await app.request("/api/subscription/change-plan", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ billingInterval: "weekly" }), + }); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("billingInterval must be 'monthly' or 'yearly'"); + }); + + it("returns 400 when billingInterval is missing from the body", async () => { + const app = createTestApp(); + + const res = await app.request("/api/subscription/change-plan", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({}), + }); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("billingInterval must be 'monthly' or 'yearly'"); + }); + + it("returns 404 when no active subscription", async () => { + mockGetSubscription.mockResolvedValue(null); + const app = createTestApp(); + + const res = await app.request("/api/subscription/change-plan", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ billingInterval: "yearly" }), + }); + + expect(res.status).toBe(404); + }); + + it("returns 500 when product ID env var is not configured", async () => { + mockGetSubscription.mockResolvedValue({ externalId: "sub_1" }); + delete process.env.POLAR_PRO_YEARLY_PRODUCT_ID; + delete process.env.POLAR_PRO_MONTHLY_PRODUCT_ID; + const app = createTestApp(); + + const res = await app.request("/api/subscription/change-plan", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ billingInterval: "yearly" }), + }); + + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("Product ID not configured for this billing interval"); + }); + + it("calls Polar update with the yearly productId when billingInterval is 'yearly'", async () => { + mockGetSubscription.mockResolvedValue({ externalId: "sub_y" }); + mockSubscriptionsUpdate.mockResolvedValue({}); + process.env.POLAR_PRO_YEARLY_PRODUCT_ID = "prod_yearly"; + const app = createTestApp(); + + const res = await app.request("/api/subscription/change-plan", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ billingInterval: "yearly" }), + }); + + expect(res.status).toBe(200); + expect(mockSubscriptionsUpdate).toHaveBeenCalledWith({ + id: "sub_y", + subscriptionUpdate: { productId: "prod_yearly" }, + }); + }); + + it("calls Polar update with the monthly productId when billingInterval is 'monthly'", async () => { + mockGetSubscription.mockResolvedValue({ externalId: "sub_m" }); + mockSubscriptionsUpdate.mockResolvedValue({}); + process.env.POLAR_PRO_MONTHLY_PRODUCT_ID = "prod_monthly"; + const app = createTestApp(); + + const res = await app.request("/api/subscription/change-plan", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ billingInterval: "monthly" }), + }); + + expect(res.status).toBe(200); + expect(mockSubscriptionsUpdate).toHaveBeenCalledWith({ + id: "sub_m", + subscriptionUpdate: { productId: "prod_monthly" }, + }); + }); + + it("returns 500 when Polar throws", async () => { + mockGetSubscription.mockResolvedValue({ externalId: "sub_x" }); + mockSubscriptionsUpdate.mockRejectedValue(new Error("Polar 503")); + process.env.POLAR_PRO_MONTHLY_PRODUCT_ID = "prod_monthly"; + vi.spyOn(console, "error").mockImplementation(() => {}); + const app = createTestApp(); + + const res = await app.request("/api/subscription/change-plan", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ billingInterval: "monthly" }), + }); + + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("Failed to change plan"); + }); +}); diff --git a/server/api/src/__tests__/routes/syncPages.test.ts b/server/api/src/__tests__/routes/syncPages.test.ts index 9779663d..86660741 100644 --- a/server/api/src/__tests__/routes/syncPages.test.ts +++ b/server/api/src/__tests__/routes/syncPages.test.ts @@ -43,6 +43,66 @@ function createSyncApp(dbResults: unknown[]) { return { app, chains: mock.chains }; } +describe("GET /api/sync/pages — link_type in response (issue #725 Phase 1)", () => { + it("returns link_type on each links row and ghost_links row", async () => { + const now = new Date("2025-06-01T00:00:00Z"); + const { app } = createSyncApp([ + // 1: pages query + [ + { + id: OWNED_PAGE, + owner_id: TEST_USER_ID, + title: "P", + content_preview: null, + thumbnail_url: null, + source_url: null, + source_page_id: null, + is_deleted: false, + created_at: now, + updated_at: now, + }, + ], + // 2: links query + [ + { sourceId: OWNED_PAGE, targetId: "t-wiki", linkType: "wiki", createdAt: now }, + { sourceId: OWNED_PAGE, targetId: "t-tag", linkType: "tag", createdAt: now }, + ], + // 3: ghost_links query + [ + { + linkText: "ghost-wiki", + sourcePageId: OWNED_PAGE, + linkType: "wiki", + createdAt: now, + originalTargetPageId: null, + originalNoteId: null, + }, + { + linkText: "ghost-tag", + sourcePageId: OWNED_PAGE, + linkType: "tag", + createdAt: now, + originalTargetPageId: null, + originalNoteId: null, + }, + ], + ]); + + const res = await app.request("/api/sync/pages", { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + links: Array<{ source_id: string; target_id: string; link_type: string }>; + ghost_links: Array<{ link_text: string; source_page_id: string; link_type: string }>; + }; + expect(body.links.map((l) => l.link_type).sort()).toEqual(["tag", "wiki"]); + expect(body.ghost_links.map((g) => g.link_type).sort()).toEqual(["tag", "wiki"]); + }); +}); + describe("POST /api/sync/pages — IDOR protection", () => { it("skips link insertion when source_id is not owned by the user", async () => { const oldDate = new Date("2024-01-01T00:00:00Z"); @@ -182,6 +242,183 @@ describe("POST /api/sync/pages — IDOR protection", () => { expect(inserted?.title).toBe("newer"); }); + // ── Issue #725 Phase 1: link_type support ──────────────────────────── + // `link_type` は WikiLink (`'wiki'`) とタグ (`'tag'`) を区別する識別子。 + // 1) 既存クライアント互換: `link_type` 省略 → `'wiki'` として扱う。 + // 2) タグ同期時に WikiLink を巻き添え削除しないよう、DELETE は + // `(source_id, link_type)` ごとにスコープされる。 + // 3) 同一 source の wiki と tag は独立エッジとして両立する。 + // + // `link_type` distinguishes WikiLink (`'wiki'`) from Tag (`'tag'`). The + // server must (1) default to `'wiki'` for legacy bodies, (2) scope DELETE + // per `(source_id, link_type)` so tag sync cannot wipe wiki edges, and + // (3) accept wiki + tag edges on the same source pair simultaneously. + describe("link_type support (issue #725 Phase 1)", () => { + it("defaults link_type to 'wiki' when omitted in body.links (legacy client compat)", async () => { + const oldDate = new Date("2024-01-01T00:00:00Z"); + const { app, chains } = createSyncApp([ + [{ id: OWNED_PAGE, ownerId: TEST_USER_ID, noteId: null, updatedAt: oldDate }], + undefined, + [{ id: OWNED_PAGE }], + undefined, + undefined, + ]); + + const res = await app.request("/api/sync/pages", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + pages: [{ id: OWNED_PAGE, title: "My Page", updated_at: "2025-06-01T00:00:00Z" }], + links: [{ source_id: OWNED_PAGE, target_id: "target-a" }], + }), + }); + + expect(res.status).toBe(200); + + const insertChains = chains.filter((c) => c.startMethod === "insert"); + expect(insertChains).toHaveLength(1); + const valuesOp = (insertChains[0]?.ops ?? []).find((op) => op.method === "values"); + const inserted = valuesOp?.args?.[0] as { linkType?: string } | undefined; + expect(inserted?.linkType).toBe("wiki"); + }); + + it("scopes DELETE per (source_id, link_type) when body.links mixes wiki + tag (no wiki wipeout)", async () => { + // 同一 source に wiki と tag が混在する push。wiki 用 DELETE と tag 用 + // DELETE がそれぞれ独立に発行されること、INSERT も 2 件(各 link_type) + // になることを検証する。 + // + // Mixed wiki + tag push for the same source: verify one DELETE per + // `(source_id, link_type)` pair and one INSERT per row. + const oldDate = new Date("2024-01-01T00:00:00Z"); + const { app, chains } = createSyncApp([ + [{ id: OWNED_PAGE, ownerId: TEST_USER_ID, noteId: null, updatedAt: oldDate }], + undefined, + [{ id: OWNED_PAGE }], + undefined, // DELETE wiki + undefined, // DELETE tag + undefined, // INSERT wiki link + undefined, // INSERT tag link + ]); + + const res = await app.request("/api/sync/pages", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + pages: [{ id: OWNED_PAGE, title: "My Page", updated_at: "2025-06-01T00:00:00Z" }], + links: [ + { source_id: OWNED_PAGE, target_id: "target-wiki", link_type: "wiki" }, + { source_id: OWNED_PAGE, target_id: "target-tag", link_type: "tag" }, + ], + }), + }); + + expect(res.status).toBe(200); + + const deleteChains = chains.filter((c) => c.startMethod === "delete"); + // 1 DELETE per (source, link_type) pair = 2 + expect(deleteChains).toHaveLength(2); + + const insertChains = chains.filter((c) => c.startMethod === "insert"); + // page UPDATE + 2 link INSERTs。page UPDATE は insert chain には + // 含まれないので、期待する insert は 2 件(wiki + tag)。 + // Page UPDATE is not counted as an insert chain; expect 2 link INSERTs + // (one wiki + one tag). + expect(insertChains).toHaveLength(2); + const insertedLinkTypes = insertChains + .map((ch) => { + const valuesOp = ch.ops.find((op) => op.method === "values"); + return (valuesOp?.args?.[0] as { linkType?: string } | undefined)?.linkType; + }) + .sort(); + expect(insertedLinkTypes).toEqual(["tag", "wiki"]); + }); + + it("does not touch existing tag edges when body.links contains only wiki (scoped DELETE)", async () => { + // tag エッジを持つページに対して wiki のみ push したとき、tag 用 DELETE が + // 発行されないことで既存 tag エッジが残ることを検証する。 + // + // Push only wiki edges → server must not issue a tag DELETE, leaving + // existing tag edges untouched. + const oldDate = new Date("2024-01-01T00:00:00Z"); + const { app, chains } = createSyncApp([ + [{ id: OWNED_PAGE, ownerId: TEST_USER_ID, noteId: null, updatedAt: oldDate }], + undefined, + [{ id: OWNED_PAGE }], + undefined, // DELETE wiki only + undefined, // INSERT wiki + ]); + + const res = await app.request("/api/sync/pages", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + pages: [{ id: OWNED_PAGE, title: "My Page", updated_at: "2025-06-01T00:00:00Z" }], + links: [{ source_id: OWNED_PAGE, target_id: "target-a", link_type: "wiki" }], + }), + }); + + expect(res.status).toBe(200); + const deleteChains = chains.filter((c) => c.startMethod === "delete"); + // 単一 (source, wiki) ペアのみ → DELETE は 1 回だけ + expect(deleteChains).toHaveLength(1); + }); + + it("accepts link_type='tag' on ghost_links and defaults to 'wiki' when omitted", async () => { + const oldDate = new Date("2024-01-01T00:00:00Z"); + const { app, chains } = createSyncApp([ + [{ id: OWNED_PAGE, ownerId: TEST_USER_ID, noteId: null, updatedAt: oldDate }], + undefined, + [{ id: OWNED_PAGE }], + undefined, // DELETE ghost wiki + undefined, // DELETE ghost tag + undefined, // INSERT ghost wiki + undefined, // INSERT ghost tag + ]); + + const res = await app.request("/api/sync/pages", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + pages: [{ id: OWNED_PAGE, title: "My Page", updated_at: "2025-06-01T00:00:00Z" }], + ghost_links: [ + { link_text: "legacy", source_page_id: OWNED_PAGE }, + { link_text: "newtag", source_page_id: OWNED_PAGE, link_type: "tag" }, + ], + }), + }); + + expect(res.status).toBe(200); + const insertChains = chains.filter((c) => c.startMethod === "insert"); + expect(insertChains).toHaveLength(2); + const insertedTypes = insertChains + .map((ch) => { + const valuesOp = ch.ops.find((op) => op.method === "values"); + return (valuesOp?.args?.[0] as { linkType?: string } | undefined)?.linkType; + }) + .sort(); + expect(insertedTypes).toEqual(["tag", "wiki"]); + }); + + it("rejects unknown link_type values with 400", async () => { + const oldDate = new Date("2024-01-01T00:00:00Z"); + const { app } = createSyncApp([ + [{ id: OWNED_PAGE, ownerId: TEST_USER_ID, noteId: null, updatedAt: oldDate }], + undefined, + ]); + + const res = await app.request("/api/sync/pages", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + pages: [{ id: OWNED_PAGE, title: "My Page", updated_at: "2025-06-01T00:00:00Z" }], + links: [{ source_id: OWNED_PAGE, target_id: "target-a", link_type: "totally-bogus" }], + }), + }); + + expect(res.status).toBe(400); + }); + }); + it("skips both links and ghost_links for non-owned pages in combined request", async () => { const oldDate = new Date("2024-01-01T00:00:00Z"); const { app, chains } = createSyncApp([ diff --git a/server/api/src/__tests__/routes/thumbnail/commit.test.ts b/server/api/src/__tests__/routes/thumbnail/commit.test.ts new file mode 100644 index 00000000..ac32cc9a --- /dev/null +++ b/server/api/src/__tests__/routes/thumbnail/commit.test.ts @@ -0,0 +1,178 @@ +/** + * /api/thumbnail/commit のテスト(入力検証、外部 API、保存先未設定、容量超過)。 + * Tests for /api/thumbnail/commit (input validation, S3 wiring, errors). + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { Context, Next } from "hono"; +import type { AppEnv } from "../../../types/index.js"; + +vi.mock("../../../middleware/auth.js", () => ({ + authRequired: async (c: Context, next: Next) => { + const userId = c.req.header("x-test-user-id"); + if (!userId) return c.json({ message: "Unauthorized" }, 401); + c.set("userId", userId); + await next(); + }, +})); + +vi.mock("../../../middleware/rateLimit.js", () => ({ + rateLimit: () => async (_c: Context, next: Next) => { + await next(); + }, +})); + +const { mockCommitImage } = vi.hoisted(() => ({ + mockCommitImage: vi.fn(), +})); + +vi.mock("../../../services/commitService.js", () => ({ + commitImage: mockCommitImage, +})); + +import { Hono } from "hono"; +import commitRoutes from "../../../routes/thumbnail/commit.js"; +import { errorHandler } from "../../../middleware/errorHandler.js"; +import { createMockDb } from "../../createMockDb.js"; + +const TEST_USER_ID = "user-thumb-1"; +const ORIGINAL_ENV = { ...process.env }; + +function createTestApp() { + const { db } = createMockDb([]); + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("db", db as unknown as AppEnv["Variables"]["db"]); + await next(); + }); + app.onError(errorHandler); + app.route("/api/thumbnail/commit", commitRoutes); + return app; +} + +function authHeaders(): Record { + return { + "x-test-user-id": TEST_USER_ID, + "Content-Type": "application/json", + }; +} + +beforeEach(() => { + mockCommitImage.mockReset(); + process.env = { ...ORIGINAL_ENV }; + process.env.STORAGE_BUCKET_NAME = "test-bucket"; +}); + +// process.env と vi.spyOn(...) を必ずクリーンアップする。 +// テスト失敗時に inline mockRestore() がスキップされても spy が漏れないようにする。 +// Reset process.env and restore spies on test end so a failing assertion can't +// leak a console.error spy or env var into the next test. +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + vi.restoreAllMocks(); +}); + +describe("POST /api/thumbnail/commit", () => { + it("returns 400 when sourceUrl is missing", async () => { + const app = createTestApp(); + + const res = await app.request("/api/thumbnail/commit", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({}), + }); + + expect(res.status).toBe(400); + expect(mockCommitImage).not.toHaveBeenCalled(); + }); + + it("returns 400 when sourceUrl is whitespace only", async () => { + const app = createTestApp(); + + const res = await app.request("/api/thumbnail/commit", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ sourceUrl: " " }), + }); + + expect(res.status).toBe(400); + }); + + it("returns 503 when STORAGE_BUCKET_NAME is not configured", async () => { + delete process.env.STORAGE_BUCKET_NAME; + const app = createTestApp(); + + const res = await app.request("/api/thumbnail/commit", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ sourceUrl: "https://example.com/img.png" }), + }); + + expect(res.status).toBe(503); + expect(mockCommitImage).not.toHaveBeenCalled(); + }); + + it("returns 200 with imageUrl + provider when commitService succeeds", async () => { + mockCommitImage.mockResolvedValue({ imageUrl: "https://cdn.example/abc.png" }); + const app = createTestApp(); + + const res = await app.request("/api/thumbnail/commit", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + sourceUrl: " https://example.com/img.png ", + fallbackUrl: " https://example.com/fallback.png ", + }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { imageUrl: string; provider: string }; + expect(body).toEqual({ imageUrl: "https://cdn.example/abc.png", provider: "s3" }); + // trim 後の値をサービスに渡す。 + // The handler trims both sourceUrl and fallbackUrl before forwarding. + expect(mockCommitImage).toHaveBeenCalledWith( + TEST_USER_ID, + "https://example.com/img.png", + "https://example.com/fallback.png", + expect.anything(), + ); + }); + + it("returns 413 when commitService throws STORAGE_QUOTA_EXCEEDED", async () => { + mockCommitImage.mockRejectedValue(new Error("STORAGE_QUOTA_EXCEEDED")); + const app = createTestApp(); + + const res = await app.request("/api/thumbnail/commit", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ sourceUrl: "https://example.com/img.png" }), + }); + + expect(res.status).toBe(413); + }); + + it("returns 502 when commitService throws an unrelated error", async () => { + mockCommitImage.mockRejectedValue(new Error("S3 down")); + vi.spyOn(console, "error").mockImplementation(() => {}); + const app = createTestApp(); + + const res = await app.request("/api/thumbnail/commit", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ sourceUrl: "https://example.com/img.png" }), + }); + + expect(res.status).toBe(502); + }); + + it("returns 401 without auth", async () => { + const app = createTestApp(); + + const res = await app.request("/api/thumbnail/commit", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sourceUrl: "https://example.com/x.png" }), + }); + + expect(res.status).toBe(401); + }); +}); diff --git a/server/api/src/__tests__/routes/thumbnail/imageGenerate.test.ts b/server/api/src/__tests__/routes/thumbnail/imageGenerate.test.ts new file mode 100644 index 00000000..d772e5e7 --- /dev/null +++ b/server/api/src/__tests__/routes/thumbnail/imageGenerate.test.ts @@ -0,0 +1,148 @@ +/** + * /api/thumbnail/generate のテスト(Gemini 連携、入力検証、API キー未設定)。 + * Tests for /api/thumbnail/generate. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { Context, Next } from "hono"; +import type { AppEnv } from "../../../types/index.js"; + +vi.mock("../../../middleware/auth.js", () => ({ + authRequired: async (c: Context, next: Next) => { + const userId = c.req.header("x-test-user-id"); + if (!userId) return c.json({ message: "Unauthorized" }, 401); + c.set("userId", userId); + await next(); + }, +})); + +vi.mock("../../../middleware/rateLimit.js", () => ({ + rateLimit: () => async (_c: Context, next: Next) => { + await next(); + }, +})); + +const { mockGenerate } = vi.hoisted(() => ({ + mockGenerate: vi.fn(), +})); + +vi.mock("../../../services/gemini.js", () => ({ + generateImageWithGemini: mockGenerate, +})); + +import { Hono } from "hono"; +import imageGenerateRoutes from "../../../routes/thumbnail/imageGenerate.js"; +import { errorHandler } from "../../../middleware/errorHandler.js"; + +const ORIGINAL_ENV = { ...process.env }; + +function createTestApp() { + const app = new Hono(); + app.onError(errorHandler); + app.route("/api/thumbnail/generate", imageGenerateRoutes); + return app; +} + +function authHeaders(): Record { + return { + "x-test-user-id": "user-1", + "Content-Type": "application/json", + }; +} + +beforeEach(() => { + mockGenerate.mockReset(); + process.env = { ...ORIGINAL_ENV }; + process.env.GOOGLE_AI_API_KEY = "test-key"; +}); + +// process.env はワーカー間で共有されうるので、テスト終了後にも必ず元へ戻す。 +// process.env can leak between test files via shared workers — restore it after every test. +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; +}); + +describe("POST /api/thumbnail/generate", () => { + it("returns 400 when prompt is missing", async () => { + const app = createTestApp(); + + const res = await app.request("/api/thumbnail/generate", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({}), + }); + + expect(res.status).toBe(400); + expect(mockGenerate).not.toHaveBeenCalled(); + }); + + it("returns 400 when prompt is whitespace only", async () => { + const app = createTestApp(); + + const res = await app.request("/api/thumbnail/generate", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ prompt: " " }), + }); + + expect(res.status).toBe(400); + expect(mockGenerate).not.toHaveBeenCalled(); + }); + + it("returns 503 when GOOGLE_AI_API_KEY is not set", async () => { + delete process.env.GOOGLE_AI_API_KEY; + const app = createTestApp(); + + const res = await app.request("/api/thumbnail/generate", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ prompt: "a cat" }), + }); + + expect(res.status).toBe(503); + expect(mockGenerate).not.toHaveBeenCalled(); + }); + + it("returns 200 with imageUrl and mimeType from gemini service", async () => { + mockGenerate.mockResolvedValue({ + imageUrl: "data:image/png;base64,xxx", + mimeType: "image/png", + }); + const app = createTestApp(); + + const res = await app.request("/api/thumbnail/generate", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ prompt: " a sunset ", aspectRatio: "1:1" }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { imageUrl: string; mimeType: string }; + expect(body).toEqual({ imageUrl: "data:image/png;base64,xxx", mimeType: "image/png" }); + expect(mockGenerate).toHaveBeenCalledWith("a sunset", "test-key", { aspectRatio: "1:1" }); + }); + + it("defaults aspectRatio to 16:9 when omitted", async () => { + mockGenerate.mockResolvedValue({ imageUrl: "data:...", mimeType: "image/png" }); + const app = createTestApp(); + + await app.request("/api/thumbnail/generate", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ prompt: "x" }), + }); + + expect(mockGenerate).toHaveBeenCalledWith("x", "test-key", { aspectRatio: "16:9" }); + }); + + it("returns 401 without auth", async () => { + const app = createTestApp(); + + const res = await app.request("/api/thumbnail/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt: "x" }), + }); + + expect(res.status).toBe(401); + }); +}); diff --git a/server/api/src/__tests__/routes/thumbnail/imageSearch.test.ts b/server/api/src/__tests__/routes/thumbnail/imageSearch.test.ts new file mode 100644 index 00000000..9f6fd036 --- /dev/null +++ b/server/api/src/__tests__/routes/thumbnail/imageSearch.test.ts @@ -0,0 +1,202 @@ +/** + * /api/thumbnail/search のテスト(クエリ検証、ページング、重複排除、エラー)。 + * Tests for /api/thumbnail/search (query validation, pagination, dedup, errors). + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { Context, Next } from "hono"; +import type { AppEnv, ImageSearchItem } from "../../../types/index.js"; + +vi.mock("../../../middleware/auth.js", () => ({ + authRequired: async (c: Context, next: Next) => { + const userId = c.req.header("x-test-user-id"); + if (!userId) return c.json({ message: "Unauthorized" }, 401); + c.set("userId", userId); + await next(); + }, +})); + +vi.mock("../../../middleware/rateLimit.js", () => ({ + rateLimit: () => async (_c: Context, next: Next) => { + await next(); + }, +})); + +const { mockSearchImages } = vi.hoisted(() => ({ + mockSearchImages: vi.fn(), +})); + +vi.mock("../../../services/imageSearch.js", () => ({ + searchImages: mockSearchImages, +})); + +import { Hono } from "hono"; +import searchRoutes from "../../../routes/thumbnail/imageSearch.js"; +import { errorHandler } from "../../../middleware/errorHandler.js"; + +const ORIGINAL_ENV = { ...process.env }; + +function createTestApp() { + const app = new Hono(); + app.onError(errorHandler); + app.route("/api/thumbnail/search", searchRoutes); + return app; +} + +function authHeaders(): Record { + return { + "x-test-user-id": "user-search-1", + "Content-Type": "application/json", + }; +} + +function makeItem(suffix: string): ImageSearchItem { + return { + id: `id-${suffix}`, + previewUrl: `https://cdn/${suffix}-thumb.jpg`, + imageUrl: `https://cdn/${suffix}.jpg`, + alt: `alt ${suffix}`, + sourceName: "cdn", + sourceUrl: `https://cdn/${suffix}.html`, + }; +} + +beforeEach(() => { + mockSearchImages.mockReset(); + process.env = { ...ORIGINAL_ENV }; + process.env.GOOGLE_CUSTOM_SEARCH_API_KEY = "k"; + process.env.GOOGLE_CUSTOM_SEARCH_ENGINE_ID = "cx"; +}); + +// process.env と vi.spyOn(...) を必ずクリーンアップする。 +// テスト失敗時に inline mockRestore() がスキップされても spy が漏れないようにする。 +// Reset process.env and restore spies on test end so a failing assertion can't +// leak a console.error spy or env var into the next test. +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + vi.restoreAllMocks(); +}); + +describe("GET /api/thumbnail/search", () => { + it("returns empty items when query is missing", async () => { + const app = createTestApp(); + + const res = await app.request("/api/thumbnail/search", { headers: authHeaders() }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { items: unknown[]; nextCursor?: string }; + expect(body.items).toEqual([]); + expect(body.nextCursor).toBeUndefined(); + expect(mockSearchImages).not.toHaveBeenCalled(); + }); + + it("returns 503 when API key or engine id is missing", async () => { + delete process.env.GOOGLE_CUSTOM_SEARCH_API_KEY; + const app = createTestApp(); + + const res = await app.request("/api/thumbnail/search?query=cats", { + headers: authHeaders(), + }); + + expect(res.status).toBe(503); + }); + + it("returns 502 when service throws", async () => { + mockSearchImages.mockRejectedValue(new Error("upstream")); + vi.spyOn(console, "error").mockImplementation(() => {}); + const app = createTestApp(); + + const res = await app.request("/api/thumbnail/search?query=cats", { + headers: authHeaders(), + }); + + expect(res.status).toBe(502); + }); + + it("deduplicates items by imageUrl", async () => { + const a = makeItem("a"); + const b = makeItem("b"); + // 重複する imageUrl を持つ項目はドロップされる / Items with a duplicate imageUrl are dropped. + const c = { ...makeItem("c"), imageUrl: a.imageUrl }; + mockSearchImages.mockResolvedValue([a, b, c]); + const app = createTestApp(); + + const res = await app.request("/api/thumbnail/search?query=cats&limit=10", { + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { items: ImageSearchItem[] }; + expect(body.items.map((i) => i.id)).toEqual(["id-a", "id-b"]); + }); + + it("clamps limit to [1, 30] and forwards to service", async () => { + mockSearchImages.mockResolvedValue([]); + const app = createTestApp(); + + await app.request("/api/thumbnail/search?query=x&limit=999", { headers: authHeaders() }); + await app.request("/api/thumbnail/search?query=x&limit=0", { headers: authHeaders() }); + + expect(mockSearchImages.mock.calls[0]?.[4]).toBe(30); + expect(mockSearchImages.mock.calls[1]?.[4]).toBe(1); + }); + + it("clamps cursor to >= 1", async () => { + mockSearchImages.mockResolvedValue([]); + const app = createTestApp(); + + await app.request("/api/thumbnail/search?query=x&cursor=0", { headers: authHeaders() }); + await app.request("/api/thumbnail/search?query=x&cursor=-5", { headers: authHeaders() }); + + expect(mockSearchImages.mock.calls[0]?.[3]).toBe(1); + expect(mockSearchImages.mock.calls[1]?.[3]).toBe(1); + }); + + it("emits a nextCursor when results are present and pagination cap not reached", async () => { + mockSearchImages.mockResolvedValue([makeItem("a")]); + const app = createTestApp(); + + const res = await app.request("/api/thumbnail/search?query=x&limit=10&cursor=2", { + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { nextCursor?: string }; + expect(body.nextCursor).toBe("3"); + }); + + it("omits nextCursor once cursor*limit reaches 100", async () => { + mockSearchImages.mockResolvedValue([makeItem("a")]); + const app = createTestApp(); + + const res = await app.request("/api/thumbnail/search?query=x&limit=10&cursor=10", { + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { nextCursor?: string }; + expect(body.nextCursor).toBeUndefined(); + }); + + it("omits nextCursor when no items match", async () => { + mockSearchImages.mockResolvedValue([]); + const app = createTestApp(); + + const res = await app.request("/api/thumbnail/search?query=x&cursor=1", { + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { nextCursor?: string }; + expect(body.nextCursor).toBeUndefined(); + }); + + it("returns 401 without auth", async () => { + const app = createTestApp(); + + const res = await app.request("/api/thumbnail/search?query=x", { + headers: { "Content-Type": "application/json" }, + }); + + expect(res.status).toBe(401); + }); +}); diff --git a/server/api/src/__tests__/services/aiProviders.test.ts b/server/api/src/__tests__/services/aiProviders.test.ts new file mode 100644 index 00000000..cd976337 --- /dev/null +++ b/server/api/src/__tests__/services/aiProviders.test.ts @@ -0,0 +1,367 @@ +/** + * aiProviders.ts のテスト(OpenAI / Anthropic / Google の call と stream、SSE パーサ)。 + * Tests for the AI provider wrappers (call + stream) and SSE parsing. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + callOpenAI, + callAnthropic, + callGoogle, + streamOpenAI, + streamAnthropic, + streamGoogle, + callProvider, + streamProvider, + getProviderApiKeyName, +} from "../../services/aiProviders.js"; +import type { AIMessage } from "../../types/index.js"; + +const fetchSpy = vi.spyOn(globalThis, "fetch"); + +beforeEach(() => { + fetchSpy.mockReset(); +}); + +afterEach(() => { + fetchSpy.mockReset(); +}); + +function okJson(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function sseResponse(chunks: string[]): Response { + // SSE ストリームを ReadableStream に詰めて返す。 + // Wrap pre-encoded SSE chunks in a ReadableStream. + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + for (const c of chunks) { + controller.enqueue(encoder.encode(c)); + } + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); +} + +const messages: AIMessage[] = [ + { role: "system", content: "Be concise." }, + { role: "user", content: "Hello" }, +]; + +// ── getProviderApiKeyName ─────────────────────────────────────────────────── + +describe("getProviderApiKeyName", () => { + it("maps each provider to the expected env var name", () => { + expect(getProviderApiKeyName("openai")).toBe("OPENAI_API_KEY"); + expect(getProviderApiKeyName("anthropic")).toBe("ANTHROPIC_API_KEY"); + expect(getProviderApiKeyName("google")).toBe("GOOGLE_AI_API_KEY"); + }); + + it("throws for an unknown provider", () => { + expect(() => getProviderApiKeyName("bogus" as never)).toThrow(/unknown provider/i); + }); +}); + +// ── callOpenAI ────────────────────────────────────────────────────────────── + +describe("callOpenAI", () => { + it("returns mapped content and usage", async () => { + fetchSpy.mockResolvedValue( + okJson({ + choices: [{ message: { content: "Hi!" }, finish_reason: "stop" }], + usage: { prompt_tokens: 10, completion_tokens: 3 }, + }), + ); + + const result = await callOpenAI("k", "gpt-4o", messages); + + expect(result.content).toBe("Hi!"); + expect(result.usage).toEqual({ inputTokens: 10, outputTokens: 3 }); + expect(result.finishReason).toBe("stop"); + + const [url, init] = fetchSpy.mock.calls[0] ?? []; + expect(url).toBe("https://api.openai.com/v1/chat/completions"); + const headers = (init as RequestInit).headers as Record; + expect(headers.Authorization).toBe("Bearer k"); + }); + + it("includes web_search_options when both flags are set", async () => { + fetchSpy.mockResolvedValue( + okJson({ + choices: [{ message: { content: "x" }, finish_reason: "stop" }], + usage: { prompt_tokens: 0, completion_tokens: 0 }, + }), + ); + + await callOpenAI("k", "gpt-4o", messages, { + useWebSearch: true, + webSearchOptions: { search_context_size: "high" }, + }); + + const body = JSON.parse(String((fetchSpy.mock.calls[0]?.[1] as RequestInit).body)) as { + web_search_options?: Record; + }; + expect(body.web_search_options).toEqual({ search_context_size: "high" }); + }); + + it("throws when API responds with non-200", async () => { + fetchSpy.mockResolvedValue(new Response("rate limited", { status: 429 })); + await expect(callOpenAI("k", "gpt-4o", messages)).rejects.toThrow(/429/); + }); + + it("returns empty content / 0 tokens when fields are missing", async () => { + fetchSpy.mockResolvedValue(okJson({ choices: [], usage: undefined })); + const result = await callOpenAI("k", "gpt-4o", messages); + expect(result.content).toBe(""); + expect(result.usage.inputTokens).toBe(0); + expect(result.usage.outputTokens).toBe(0); + expect(result.finishReason).toBe("stop"); + }); +}); + +// ── callAnthropic ─────────────────────────────────────────────────────────── + +describe("callAnthropic", () => { + it("separates system messages and joins content blocks", async () => { + fetchSpy.mockResolvedValue( + okJson({ + content: [{ text: "Hi " }, { text: "there." }], + stop_reason: "end_turn", + usage: { input_tokens: 12, output_tokens: 4 }, + }), + ); + + const result = await callAnthropic("k", "claude-3", messages); + + expect(result.content).toBe("Hi there."); + expect(result.usage).toEqual({ inputTokens: 12, outputTokens: 4 }); + expect(result.finishReason).toBe("end_turn"); + + const body = JSON.parse(String((fetchSpy.mock.calls[0]?.[1] as RequestInit).body)) as { + messages: Array<{ role: string; content: string }>; + system?: string; + }; + expect(body.system).toBe("Be concise."); + expect(body.messages).toEqual([{ role: "user", content: "Hello" }]); + }); + + it("omits `system` field when no system messages are provided", async () => { + fetchSpy.mockResolvedValue( + okJson({ + content: [{ text: "ok" }], + stop_reason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 }, + }), + ); + + await callAnthropic("k", "claude-3", [{ role: "user", content: "yo" }]); + + const body = JSON.parse(String((fetchSpy.mock.calls[0]?.[1] as RequestInit).body)) as { + system?: string; + }; + expect(body.system).toBeUndefined(); + }); + + it("falls back to 'end_turn' when stop_reason is missing", async () => { + fetchSpy.mockResolvedValue( + okJson({ + content: [{ text: "ok" }], + usage: { input_tokens: 1, output_tokens: 1 }, + }), + ); + + const result = await callAnthropic("k", "claude-3", messages); + expect(result.finishReason).toBe("end_turn"); + }); + + it("throws when API responds with non-200", async () => { + fetchSpy.mockResolvedValue(new Response("err", { status: 500 })); + await expect(callAnthropic("k", "claude-3", messages)).rejects.toThrow(/500/); + }); +}); + +// ── callGoogle ────────────────────────────────────────────────────────────── + +describe("callGoogle", () => { + it("converts assistant messages to 'model' role and extracts text", async () => { + fetchSpy.mockResolvedValue( + okJson({ + candidates: [ + { content: { parts: [{ text: "Hello " }, { text: "there!" }] }, finishReason: "STOP" }, + ], + usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 2 }, + }), + ); + + const result = await callGoogle("k", "gemini-2.0", [ + { role: "system", content: "be helpful" }, + { role: "user", content: "hi" }, + { role: "assistant", content: "earlier reply" }, + ]); + + expect(result.content).toBe("Hello there!"); + expect(result.usage).toEqual({ inputTokens: 5, outputTokens: 2 }); + expect(result.finishReason).toBe("STOP"); + + const body = JSON.parse(String((fetchSpy.mock.calls[0]?.[1] as RequestInit).body)) as { + contents: Array<{ role: string }>; + systemInstruction?: { parts: Array<{ text: string }> }; + tools?: unknown; + }; + expect(body.contents.map((c) => c.role)).toEqual(["user", "model"]); + expect(body.systemInstruction?.parts[0]?.text).toBe("be helpful"); + expect(body.tools).toBeUndefined(); + }); + + it("includes googleSearch tool when useGoogleSearch is true", async () => { + fetchSpy.mockResolvedValue( + okJson({ + candidates: [{ content: { parts: [{ text: "ok" }] }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 0, candidatesTokenCount: 0 }, + }), + ); + + await callGoogle("k", "gemini-2.0", [{ role: "user", content: "x" }], { + useGoogleSearch: true, + }); + + const body = JSON.parse(String((fetchSpy.mock.calls[0]?.[1] as RequestInit).body)) as { + tools?: unknown[]; + }; + expect(body.tools).toEqual([{ googleSearch: {} }]); + }); + + it("throws on non-200", async () => { + fetchSpy.mockResolvedValue(new Response("oops", { status: 503 })); + await expect(callGoogle("k", "gemini-2.0", messages)).rejects.toThrow(/503/); + }); +}); + +// ── stream wrappers ───────────────────────────────────────────────────────── + +async function collect(gen: AsyncGenerator): Promise { + const out: T[] = []; + for await (const item of gen) out.push(item); + return out; +} + +describe("streamOpenAI", () => { + it("yields content chunks and a done chunk from SSE", async () => { + const chunks = [ + 'data: {"choices":[{"delta":{"content":"Hel"}}]}\n', + 'data: {"choices":[{"delta":{"content":"lo"}}]}\n', + 'data: {"choices":[{"finish_reason":"stop","delta":{}}]}\n', + "data: [DONE]\n", + ]; + fetchSpy.mockResolvedValue(sseResponse(chunks)); + + const events = await collect(streamOpenAI("k", "gpt-4o", messages)); + + expect(events).toEqual([ + { content: "Hel" }, + { content: "lo" }, + { done: true, finishReason: "stop" }, + ]); + }); + + it("throws on non-200", async () => { + fetchSpy.mockResolvedValue(new Response("err", { status: 500 })); + await expect(collect(streamOpenAI("k", "gpt-4o", messages))).rejects.toThrow(/500/); + }); + + it("throws when response has no body", async () => { + fetchSpy.mockResolvedValue(new Response(null, { status: 200 })); + await expect(collect(streamOpenAI("k", "gpt-4o", messages))).rejects.toThrow(/no body/i); + }); +}); + +describe("streamAnthropic", () => { + it("yields content_block_delta chunks and message_delta with stop_reason", async () => { + const chunks = [ + 'data: {"type":"content_block_delta","delta":{"text":"Hi"}}\n', + 'data: {"type":"content_block_delta","delta":{"text":" there"}}\n', + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n', + ]; + fetchSpy.mockResolvedValue(sseResponse(chunks)); + + const events = await collect(streamAnthropic("k", "claude-3", messages)); + + expect(events).toEqual([ + { content: "Hi" }, + { content: " there" }, + { done: true, finishReason: "end_turn" }, + ]); + }); +}); + +describe("streamGoogle", () => { + it("yields text chunks and emits a done event when finishReason is STOP", async () => { + const chunks = [ + 'data: {"candidates":[{"content":{"parts":[{"text":"He"}]}}]}\n', + 'data: {"candidates":[{"content":{"parts":[{"text":"llo"}]}}]}\n', + 'data: {"candidates":[{"finishReason":"STOP"}]}\n', + ]; + fetchSpy.mockResolvedValue(sseResponse(chunks)); + + const events = await collect(streamGoogle("k", "gemini", messages)); + + // 末尾 STOP 単独イベントは done として yield される。 + // The lone STOP frame is yielded as `done`. + expect(events).toContainEqual({ content: "He" }); + expect(events).toContainEqual({ content: "llo" }); + expect(events).toContainEqual({ done: true, finishReason: "STOP" }); + }); + + it("yields a done event with non-STOP finishReason when present", async () => { + const chunks = ['data: {"candidates":[{"finishReason":"SAFETY"}]}\n']; + fetchSpy.mockResolvedValue(sseResponse(chunks)); + + const events = await collect(streamGoogle("k", "gemini", messages)); + expect(events).toContainEqual({ done: true, finishReason: "SAFETY" }); + }); +}); + +// ── ディスパッチャー / Dispatchers ────────────────────────────────────────── + +describe("callProvider / streamProvider dispatchers", () => { + it("callProvider routes to the OpenAI wrapper", async () => { + fetchSpy.mockResolvedValue( + okJson({ + choices: [{ message: { content: "x" }, finish_reason: "stop" }], + usage: { prompt_tokens: 0, completion_tokens: 0 }, + }), + ); + + const result = await callProvider("openai", "k", "gpt-4o", messages); + expect(result.content).toBe("x"); + expect(String(fetchSpy.mock.calls[0]?.[0])).toContain("api.openai.com"); + }); + + it("callProvider throws for unknown providers", async () => { + await expect(callProvider("nope" as never, "k", "m", messages)).rejects.toThrow( + /unknown provider/i, + ); + }); + + it("streamProvider routes to the OpenAI streamer", async () => { + const chunks = ['data: {"choices":[{"finish_reason":"stop","delta":{}}]}\n']; + fetchSpy.mockResolvedValue(sseResponse(chunks)); + + const events = await collect(streamProvider("openai", "k", "gpt-4o", messages)); + expect(events).toContainEqual({ done: true, finishReason: "stop" }); + }); + + it("streamProvider throws synchronously for unknown providers", () => { + // streamProvider は同期的に switch で投げるので、generator を返す前に throw する。 + // streamProvider throws synchronously from the switch before returning a generator. + expect(() => streamProvider("zzz" as never, "k", "m", messages)).toThrow(/unknown provider/i); + }); +}); diff --git a/server/api/src/__tests__/services/gemini.test.ts b/server/api/src/__tests__/services/gemini.test.ts new file mode 100644 index 00000000..afd15054 --- /dev/null +++ b/server/api/src/__tests__/services/gemini.test.ts @@ -0,0 +1,134 @@ +/** + * gemini.ts のテスト(Google Generative Language API ラッパー)。 + * Tests for the Gemini image-generation wrapper. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { generateImageWithGemini } from "../../services/gemini.js"; + +const fetchSpy = vi.spyOn(globalThis, "fetch"); + +beforeEach(() => { + fetchSpy.mockReset(); +}); + +afterEach(() => { + fetchSpy.mockReset(); +}); + +function ok(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("generateImageWithGemini", () => { + it("throws when prompt is empty", async () => { + await expect(generateImageWithGemini("", "key")).rejects.toThrow( + /prompt and api key are required/i, + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("throws when apiKey is empty", async () => { + await expect(generateImageWithGemini("a prompt", "")).rejects.toThrow( + /prompt and api key are required/i, + ); + }); + + it("returns a base64 data URI on success", async () => { + fetchSpy.mockResolvedValue( + ok({ + candidates: [ + { + content: { + parts: [{ inlineData: { mimeType: "image/png", data: "AAA" } }], + }, + }, + ], + }), + ); + + const result = await generateImageWithGemini("a cat", "test-key"); + + expect(result).toEqual({ + imageUrl: "data:image/png;base64,AAA", + mimeType: "image/png", + }); + + // URL とヘッダの確認。 + // Verify the URL and required headers were sent. + const [url, init] = fetchSpy.mock.calls[0] ?? []; + expect(String(url)).toContain("/models/gemini-2.5-flash-image:generateContent"); + const headers = (init as RequestInit).headers as Record; + expect(headers["x-goog-api-key"]).toBe("test-key"); + expect(headers["Content-Type"]).toBe("application/json"); + + // body にプロンプトと aspectRatio が含まれる(デフォルト 16:9)。 + // Body must include the prompt and the default aspectRatio. + const body = JSON.parse(String((init as RequestInit).body)) as { + contents: Array<{ parts: Array<{ text: string }> }>; + generationConfig: { imageConfig: { aspectRatio: string } }; + }; + expect(body.contents[0]?.parts[0]?.text).toBe("a cat"); + expect(body.generationConfig.imageConfig.aspectRatio).toBe("16:9"); + }); + + it("respects custom aspectRatio and model from options", async () => { + fetchSpy.mockResolvedValue( + ok({ + candidates: [ + { + content: { + parts: [{ inlineData: { mimeType: "image/jpeg", data: "BBB" } }], + }, + }, + ], + }), + ); + + await generateImageWithGemini("p", "key", { model: "gemini-2.0-flash", aspectRatio: "1:1" }); + + const [url, init] = fetchSpy.mock.calls[0] ?? []; + expect(String(url)).toContain("/models/gemini-2.0-flash:generateContent"); + const body = JSON.parse(String((init as RequestInit).body)) as { + generationConfig: { imageConfig: { aspectRatio: string } }; + }; + expect(body.generationConfig.imageConfig.aspectRatio).toBe("1:1"); + }); + + it("throws when API responds with non-200", async () => { + fetchSpy.mockResolvedValue( + new Response("rate limited", { + status: 429, + headers: { "Content-Type": "text/plain" }, + }), + ); + + await expect(generateImageWithGemini("p", "k")).rejects.toThrow(/429/); + }); + + it("throws when the response carries an `error` payload", async () => { + fetchSpy.mockResolvedValue(ok({ error: { code: 400, message: "bad prompt" } })); + + await expect(generateImageWithGemini("p", "k")).rejects.toThrow(/bad prompt/); + }); + + it("throws when no candidate content/parts are returned", async () => { + fetchSpy.mockResolvedValue(ok({ candidates: [{ content: { parts: [] } }] })); + + await expect(generateImageWithGemini("p", "k")).rejects.toThrow(/no image data/i); + }); + + it("throws when no inlineData part is present", async () => { + fetchSpy.mockResolvedValue( + ok({ + candidates: [ + { content: { parts: [{ inlineData: { data: "x" } /* missing mimeType */ }] } }, + ], + }), + ); + + await expect(generateImageWithGemini("p", "k")).rejects.toThrow(/no image data/i); + }); +}); diff --git a/server/api/src/__tests__/services/imageSearch.test.ts b/server/api/src/__tests__/services/imageSearch.test.ts new file mode 100644 index 00000000..2c0923c3 --- /dev/null +++ b/server/api/src/__tests__/services/imageSearch.test.ts @@ -0,0 +1,143 @@ +/** + * imageSearch.ts のテスト(Google Custom Search API ラッパー)。 + * Tests for the Google Custom Search image wrapper. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { searchImages } from "../../services/imageSearch.js"; + +const fetchSpy = vi.spyOn(globalThis, "fetch"); + +beforeEach(() => { + fetchSpy.mockReset(); +}); + +afterEach(() => { + fetchSpy.mockReset(); +}); + +function okJson(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("searchImages", () => { + it("returns empty array when query is empty", async () => { + const result = await searchImages("", "k", "cx", 1, 10); + expect(result).toEqual([]); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("returns empty array when apiKey or engineId is missing", async () => { + expect(await searchImages("q", "", "cx", 1, 10)).toEqual([]); + expect(await searchImages("q", "k", "", 1, 10)).toEqual([]); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("returns empty array when start > 100 (Google API hard cap)", async () => { + // page=11, num=10 → start = 101. + const result = await searchImages("q", "k", "cx", 11, 10); + expect(result).toEqual([]); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("clamps `num` into [1, 10]", async () => { + // Response は body を一度しか read できないので、毎回新しいインスタンスを返す。 + // Response body is single-use; return a fresh instance per call. + fetchSpy.mockImplementation(async () => okJson({ items: [] })); + + await searchImages("q", "k", "cx", 1, 999); + const url1 = String(fetchSpy.mock.calls[0]?.[0]); + expect(new URL(url1).searchParams.get("num")).toBe("10"); + + await searchImages("q", "k", "cx", 1, 0); + const url2 = String(fetchSpy.mock.calls[1]?.[0]); + expect(new URL(url2).searchParams.get("num")).toBe("1"); + }); + + it("computes `start` from page and num (1-based)", async () => { + fetchSpy.mockImplementation(async () => okJson({ items: [] })); + + await searchImages("q", "k", "cx", 3, 5); // (3-1)*5 + 1 = 11 + const url = String(fetchSpy.mock.calls[0]?.[0]); + expect(new URL(url).searchParams.get("start")).toBe("11"); + }); + + it("maps API items to ImageSearchItem and skips entries without link/image", async () => { + fetchSpy.mockResolvedValue( + okJson({ + items: [ + { + title: "Cat", + link: "https://cdn/cat.jpg", + displayLink: "cdn", + image: { + thumbnailLink: "https://cdn/cat-thumb.jpg", + contextLink: "https://cdn/cat.html", + }, + }, + // Skipped: missing link. + { title: "no link", image: { thumbnailLink: "x" } }, + // Skipped: missing image. + { title: "no img", link: "https://cdn/x.jpg" }, + ], + }), + ); + + const result = await searchImages("q", "k", "cx", 1, 10); + expect(result).toEqual([ + { + id: "https://cdn/cat.jpg", + previewUrl: "https://cdn/cat-thumb.jpg", + imageUrl: "https://cdn/cat.jpg", + alt: "Cat", + sourceName: "cdn", + sourceUrl: "https://cdn/cat.html", + }, + ]); + }); + + it("falls back to query for `alt` when title is missing", async () => { + fetchSpy.mockResolvedValue( + okJson({ + items: [ + { + link: "https://cdn/x.jpg", + image: { thumbnailLink: "https://cdn/x-thumb.jpg", contextLink: "https://cdn/x.html" }, + }, + ], + }), + ); + + const result = await searchImages("kitten", "k", "cx", 1, 10); + expect(result[0]?.alt).toBe("kitten"); + }); + + it("falls back to URL hostname for sourceName when displayLink is missing", async () => { + fetchSpy.mockResolvedValue( + okJson({ + items: [ + { + title: "T", + link: "https://example.com/x.jpg", + image: { thumbnailLink: "https://example.com/x-t.jpg", contextLink: "" }, + }, + ], + }), + ); + + const result = await searchImages("q", "k", "cx", 1, 10); + expect(result[0]?.sourceName).toBe("example.com"); + }); + + it("throws when the API responds with non-200", async () => { + fetchSpy.mockResolvedValue(new Response("forbidden", { status: 403 })); + await expect(searchImages("q", "k", "cx", 1, 10)).rejects.toThrow(/403/); + }); + + it("returns empty array when API returns no items field", async () => { + fetchSpy.mockResolvedValue(okJson({})); + expect(await searchImages("q", "k", "cx", 1, 10)).toEqual([]); + }); +}); diff --git a/server/api/src/__tests__/services/magicLinkService.test.ts b/server/api/src/__tests__/services/magicLinkService.test.ts new file mode 100644 index 00000000..b8cbb563 --- /dev/null +++ b/server/api/src/__tests__/services/magicLinkService.test.ts @@ -0,0 +1,162 @@ +/** + * `services/magicLinkService.ts` のユニットテスト。 + * + * - Better Auth の `auth.handler` に正しい URL / メソッド / ペイロードで + * POST すること。 + * - `Accept-Language` ヘッダがロケールを伝搬していること (省略時 ja)。 + * - レスポンスが `ok` のときは `sent: true` を返し、`!ok` のときは body を + * error として返すこと。 + * - `auth.handler` が throw した場合も `sent: false` で握り潰すこと。 + * + * Unit tests for the magic-link service. The Better Auth `auth` module pulls in + * a long list of env vars at import time, so we mock both `../../auth.js` and + * `../../lib/env.js` before importing the SUT. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const handlerMock = vi.fn(); + +/** + * `getEnv` を vi.fn で差し替え、テストごとに `mockReturnValueOnce` などで + * 値を上書きできるようにする。`vi.resetModules()` + `vi.doMock()` で再ロード + * する旧パターンより軽量で速い。 + * + * Backed by a single vi.fn so individual tests can override the return value + * (e.g. with mockReturnValueOnce) without having to re-import the SUT module. + */ +const getEnvMock = vi.fn<(key: string) => string>(); + +vi.mock("../../auth.js", () => ({ + auth: { handler: (req: Request) => handlerMock(req) }, +})); + +vi.mock("../../lib/env.js", () => ({ + getEnv: (key: string) => getEnvMock(key), + getOptionalEnv: () => "", +})); + +const { sendInvitationMagicLink } = await import("../../services/magicLinkService.js"); + +describe("sendInvitationMagicLink", () => { + let errorSpy: ReturnType; + + beforeEach(() => { + handlerMock.mockReset(); + // 既定では BETTER_AUTH_URL を末尾スラッシュ無しで返す。 + // Default: BETTER_AUTH_URL resolves without a trailing slash. + getEnvMock.mockReset(); + getEnvMock.mockImplementation((key: string) => { + if (key === "BETTER_AUTH_URL") return "https://api.example.com"; + throw new Error(`unexpected env lookup: ${key}`); + }); + // Suppress the `[magicLinkService] Unexpected error` log emitted on throw. + // throw 経路で出るエラーログを黙らせる。 + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + errorSpy.mockRestore(); + }); + + it("POSTs to /api/auth/sign-in/magic-link with email + callbackURL JSON", async () => { + handlerMock.mockResolvedValue(new Response(null, { status: 200 })); + + const result = await sendInvitationMagicLink({ + email: "invitee@example.com", + callbackURL: "https://app.example.com/notes/abc", + }); + + expect(result).toEqual({ sent: true, status: 200 }); + expect(handlerMock).toHaveBeenCalledTimes(1); + const req = handlerMock.mock.calls[0]?.[0] as Request; + expect(req.method).toBe("POST"); + expect(req.url).toBe("https://api.example.com/api/auth/sign-in/magic-link"); + expect(req.headers.get("content-type")).toBe("application/json"); + const body = (await req.json()) as { email: string; callbackURL: string }; + expect(body).toEqual({ + email: "invitee@example.com", + callbackURL: "https://app.example.com/notes/abc", + }); + }); + + it("defaults Accept-Language to 'ja' when no locale is given", async () => { + handlerMock.mockResolvedValue(new Response(null, { status: 200 })); + + await sendInvitationMagicLink({ + email: "x@example.com", + callbackURL: "https://app.example.com/", + }); + + expect(handlerMock).toHaveBeenCalledTimes(1); + const req = handlerMock.mock.calls[0]?.[0] as Request; + expect(req.headers.get("accept-language")).toBe("ja"); + }); + + it("propagates the supplied locale via Accept-Language", async () => { + handlerMock.mockResolvedValue(new Response(null, { status: 200 })); + + await sendInvitationMagicLink({ + email: "x@example.com", + callbackURL: "https://app.example.com/", + locale: "en", + }); + + expect(handlerMock).toHaveBeenCalledTimes(1); + const req = handlerMock.mock.calls[0]?.[0] as Request; + expect(req.headers.get("accept-language")).toBe("en"); + }); + + it("strips a trailing slash from BETTER_AUTH_URL when constructing the URL", async () => { + // 1 回限り getEnv をスラッシュ付きで返し、`.replace(/\/$/, "")` を発火させる。 + // Override BETTER_AUTH_URL just for this call to exercise the + // trailing-slash trim — far cheaper than reloading the SUT module. + getEnvMock.mockReturnValueOnce("https://api.example.com/"); + handlerMock.mockResolvedValue(new Response(null, { status: 200 })); + + await sendInvitationMagicLink({ + email: "x@example.com", + callbackURL: "https://app.example.com/", + }); + + expect(handlerMock).toHaveBeenCalledTimes(1); + const req = handlerMock.mock.calls[0]?.[0] as Request; + expect(req.url).toBe("https://api.example.com/api/auth/sign-in/magic-link"); + }); + + it("returns sent=false with the response body when Better Auth replies non-OK", async () => { + handlerMock.mockResolvedValue( + new Response("rate limited", { status: 429, statusText: "Too Many Requests" }), + ); + const result = await sendInvitationMagicLink({ + email: "x@example.com", + callbackURL: "https://app.example.com/", + }); + expect(result.sent).toBe(false); + expect(result.status).toBe(429); + expect(result.error).toBe("rate limited"); + }); + + it("falls back to a synthetic error message when the response body is empty", async () => { + handlerMock.mockResolvedValue(new Response("", { status: 500 })); + const result = await sendInvitationMagicLink({ + email: "x@example.com", + callbackURL: "https://app.example.com/", + }); + expect(result.sent).toBe(false); + expect(result.status).toBe(500); + expect(result.error).toMatch(/failed with status 500/i); + }); + + it("catches handler-thrown errors and returns sent=false (no propagation)", async () => { + // Better Auth が例外を投げても呼び出し側にリークさせない。 + // The wrapper must absorb thrown errors so callers see a clean result object. + handlerMock.mockRejectedValue(new Error("network down")); + const result = await sendInvitationMagicLink({ + email: "x@example.com", + callbackURL: "https://app.example.com/", + }); + expect(result.sent).toBe(false); + expect(result.error).toBe("network down"); + expect(result.status).toBeUndefined(); + }); +}); diff --git a/server/api/src/__tests__/services/subscriptionService.test.ts b/server/api/src/__tests__/services/subscriptionService.test.ts new file mode 100644 index 00000000..e5be26da --- /dev/null +++ b/server/api/src/__tests__/services/subscriptionService.test.ts @@ -0,0 +1,119 @@ +/** + * `services/subscriptionService.ts` のユニットテスト。 + * + * - `getUserTier`: + * - active / trialing なサブスク行から `plan` を返す。 + * - 未契約 (DB ヒットなし) は `"free"` を返す。 + * - 30 秒の TTL キャッシュが効いており、同 TTL 内では DB を再問い合わせしない。 + * - TTL 経過後は再問い合わせする。 + * - `getSubscription`: + * - DB の最初の 1 行を返す / 無ければ null。 + * + * Unit tests for getUserTier (with its 30s in-memory TTL cache) and + * getSubscription. The cache is a module-level Map, so tests use distinct + * userIds per case to avoid cross-test bleed. + */ +import { describe, it, expect, vi, afterEach } from "vitest"; +import type { Database } from "../../types/index.js"; +import { getUserTier, getSubscription } from "../../services/subscriptionService.js"; + +/** + * Minimal Drizzle stub: select().from().where().limit() resolves with `rows`. + * Counts how many times the chain was started so cache hits are observable. + * + * select チェーンが何回起動されたかを数える最小の DB スタブ。 + * キャッシュヒット時に呼ばれないことを検証するために使う。 + */ +function createCountingDb(rows: unknown[]) { + const selectSpy = vi.fn(() => ({ + from: () => ({ + where: () => ({ + limit: async () => rows, + }), + }), + })); + return { + db: { select: selectSpy } as unknown as Database, + selectSpy, + }; +} + +describe("getUserTier", () => { + // setSystemTime を使った各テストの後始末。 + // Restore real timers after every test that mocks them. + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns 'pro' when DB returns a pro subscription row", async () => { + const { db } = createCountingDb([{ plan: "pro" }]); + const tier = await getUserTier("u-pro-1", db); + expect(tier).toBe("pro"); + }); + + it("returns 'free' when DB returns no active/trialing subscription", async () => { + // 未契約ユーザー / non-subscribed users are billed as free. + const { db } = createCountingDb([]); + const tier = await getUserTier("u-none-1", db); + expect(tier).toBe("free"); + }); + + it("caches the result for the TTL window (no DB call on second hit)", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-26T00:00:00Z")); + + const { db, selectSpy } = createCountingDb([{ plan: "pro" }]); + const userId = "u-cache-hit"; + + expect(await getUserTier(userId, db)).toBe("pro"); + expect(selectSpy).toHaveBeenCalledTimes(1); + + // Within the 30s TTL: cached value, no second DB call. + // TTL 内: DB は再問い合わせしない。 + vi.setSystemTime(new Date("2026-04-26T00:00:29Z")); + expect(await getUserTier(userId, db)).toBe("pro"); + expect(selectSpy).toHaveBeenCalledTimes(1); + }); + + it("re-queries the DB after the TTL window expires", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-26T00:00:00Z")); + + const { db, selectSpy } = createCountingDb([{ plan: "pro" }]); + const userId = "u-cache-expire"; + + expect(await getUserTier(userId, db)).toBe("pro"); + expect(selectSpy).toHaveBeenCalledTimes(1); + + // Advance past the 30s TTL: cache is invalidated. + // TTL 経過: キャッシュが切れて再問い合わせされる。 + vi.setSystemTime(new Date("2026-04-26T00:00:31Z")); + expect(await getUserTier(userId, db)).toBe("pro"); + expect(selectSpy).toHaveBeenCalledTimes(2); + }); + + it("does not share cache entries across distinct userIds", async () => { + const { db: dbA, selectSpy: spyA } = createCountingDb([{ plan: "pro" }]); + const { db: dbB, selectSpy: spyB } = createCountingDb([]); + + expect(await getUserTier("u-iso-A", dbA)).toBe("pro"); + expect(await getUserTier("u-iso-B", dbB)).toBe("free"); + expect(spyA).toHaveBeenCalledTimes(1); + expect(spyB).toHaveBeenCalledTimes(1); + }); +}); + +describe("getSubscription", () => { + it("returns the first row when one exists", async () => { + const sub = { id: "sub-1", userId: "u1", plan: "pro", status: "active" }; + const { db } = createCountingDb([sub]); + const got = await getSubscription("u1", db); + expect(got).toEqual(sub); + }); + + it("returns null when no subscription row exists", async () => { + const { db } = createCountingDb([]); + const got = await getSubscription("u-no-sub", db); + expect(got).toBeNull(); + }); +}); diff --git a/server/api/src/__tests__/services/titleRenamePropagationService.test.ts b/server/api/src/__tests__/services/titleRenamePropagationService.test.ts new file mode 100644 index 00000000..2ad01037 --- /dev/null +++ b/server/api/src/__tests__/services/titleRenamePropagationService.test.ts @@ -0,0 +1,456 @@ +/** + * `titleRenamePropagationService` の単体テスト。 + * Unit tests for `titleRenamePropagationService` — orchestrates WikiLink / + * tag rewrites across source pages and ghost promotion when a page is + * renamed (issue #726). + */ + +import { describe, it, expect, vi } from "vitest"; +import * as Y from "yjs"; + +import { createMockDb } from "../createMockDb.js"; +import { propagateTitleRename } from "../../services/titleRenamePropagationService.js"; + +/** + * page_contents 行に入っているようなバイナリ Y.Doc を生成するヘルパー。 + * Build an encoded Y.Doc blob shaped like a `page_contents.ydoc_state` row. + */ +function makeYdocWithWikiLink(title: string, targetId?: string): Buffer { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("default"); + const paragraph = new Y.XmlElement("paragraph"); + fragment.insert(0, [paragraph]); + const text = new Y.XmlText(); + paragraph.insert(0, [text]); + const wikiLink: Record = { title, exists: true, referenced: false }; + if (targetId !== undefined) { + wikiLink.targetId = targetId; + } + text.insert(0, title, { wikiLink }); + return Buffer.from(Y.encodeStateAsUpdate(doc)); +} + +/** + * 同名タイトルの 2 つのリンクを並べた Y.Doc を作るヘルパー。`targetId` で + * どちらが renamedPage を指すかを明示する。issue #737 の重複タイトルケース + * を検証する。 + * + * Build a Y.Doc with two same-titled links discriminated by `targetId`. + * Used to verify the issue #737 scenario where a rename must touch only one + * of the two visually identical links. + */ +function makeYdocWithTwoSameTitleLinks( + title: string, + firstTargetId: string, + secondTargetId: string, +): Buffer { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("default"); + const paragraph = new Y.XmlElement("paragraph"); + fragment.insert(0, [paragraph]); + const text = new Y.XmlText(); + paragraph.insert(0, [text]); + text.insert(0, title, { + wikiLink: { title, exists: true, referenced: false, targetId: firstTargetId }, + }); + // null を挟んで 2 つ目のマーク区間を独立させる(Yjs の format 継承を断つ)。 + // Insert with `null` to break Yjs' formatting inheritance between segments. + text.insert(text.length, " and ", { wikiLink: null }); + text.insert(text.length, title, { + wikiLink: { title, exists: true, referenced: false, targetId: secondTargetId }, + }); + return Buffer.from(Y.encodeStateAsUpdate(doc)); +} + +function decodeYdocWikiLinkTitle(buffer: Buffer): string | null { + const doc = new Y.Doc(); + Y.applyUpdate(doc, new Uint8Array(buffer)); + const fragment = doc.getXmlFragment("default"); + const paragraph = fragment.get(0); + if (!(paragraph instanceof Y.XmlElement)) return null; + const text = paragraph.get(0); + if (!(text instanceof Y.XmlText)) return null; + const delta = text.toDelta() as Array<{ insert: unknown; attributes?: Record }>; + for (const item of delta) { + const wl = item.attributes?.wikiLink as { title?: string } | undefined; + if (wl?.title) return wl.title; + } + return null; +} + +/** + * `decodeYdocWikiLinkTitle` の同名リンク 2 つ版。`targetId` ごとにタイトルを + * 取り出し、どちらが書き換わったかを検証可能にする。 + * + * Sibling helper to `decodeYdocWikiLinkTitle` that returns titles keyed by + * `targetId` so tests can assert which of the two same-titled links was + * rewritten and which was preserved. + */ +function decodeYdocWikiLinkTitlesByTargetId(buffer: Buffer): Record { + const doc = new Y.Doc(); + Y.applyUpdate(doc, new Uint8Array(buffer)); + const fragment = doc.getXmlFragment("default"); + const paragraph = fragment.get(0); + if (!(paragraph instanceof Y.XmlElement)) return {}; + const text = paragraph.get(0); + if (!(text instanceof Y.XmlText)) return {}; + const delta = text.toDelta() as Array<{ insert: unknown; attributes?: Record }>; + const out: Record = {}; + for (const item of delta) { + const wl = item.attributes?.wikiLink as { title?: string; targetId?: string } | undefined; + if (wl?.title && wl.targetId) { + out[wl.targetId] = wl.title; + } + } + return out; +} + +const PAGE_ID = "11111111-aaaa-bbbb-cccc-000000000001"; +const SOURCE_PAGE_ID = "11111111-aaaa-bbbb-cccc-000000000002"; +const OWNER_ID = "owner-user-1"; + +/** Default scope result: personal page owned by OWNER_ID. 個人ページ既定スコープ。 */ +const PERSONAL_SCOPE_ROW = [{ noteId: null, ownerId: OWNER_ID }]; + +describe("propagateTitleRename", () => { + it("returns a zero result and skips all DB work when oldTitle or newTitle is missing", async () => { + const { db, chains } = createMockDb([]); + const invalidate = vi.fn().mockResolvedValue(undefined); + + const a = await propagateTitleRename(db as never, PAGE_ID, "", "Bar", { + invalidateDocument: invalidate, + }); + const b = await propagateTitleRename(db as never, PAGE_ID, "Foo", undefined, { + invalidateDocument: invalidate, + }); + const c = await propagateTitleRename(db as never, PAGE_ID, null, "Foo", { + invalidateDocument: invalidate, + }); + + for (const r of [a, b, c]) { + expect(r.sourcePagesAttempted).toBe(0); + expect(r.wikiLinkMarksUpdated).toBe(0); + expect(r.tagMarksUpdated).toBe(0); + expect(r.ghostPromotionsCount).toBe(0); + } + expect(chains.length).toBe(0); + expect(invalidate).not.toHaveBeenCalled(); + }); + + it("returns a zero result when oldTitle and newTitle normalize to the same value", async () => { + const { db, chains } = createMockDb([]); + const invalidate = vi.fn().mockResolvedValue(undefined); + + const result = await propagateTitleRename(db as never, PAGE_ID, "Foo", " foo ", { + invalidateDocument: invalidate, + }); + + expect(result.sourcePagesAttempted).toBe(0); + expect(result.ghostPromotionsCount).toBe(0); + expect(chains.length).toBe(0); + expect(invalidate).not.toHaveBeenCalled(); + }); + + it("rewrites matching wikiLink marks, updates contentText/preview, and invalidates the doc", async () => { + const originalYdoc = makeYdocWithWikiLink("Foo"); + + // Query plan: + // 1. SELECT sourceId FROM links WHERE targetId = ... → sources + // 2. TX: SELECT 1 ... FOR UPDATE → (ignored) + // 3. TX: SELECT * FROM page_contents → row with old ydoc + // 4. TX: UPDATE page_contents → (ignored) + // 5. TX: UPDATE pages (content_preview) → (ignored) + // 6. TX (promote): SELECT pages scope → personal scope + // 7. TX (promote): SELECT candidates (join) → [] (no ghosts) + const { db, chains } = createMockDb([ + [{ sourceId: SOURCE_PAGE_ID }], // 1 + [], // 2 — FOR UPDATE + [{ pageId: SOURCE_PAGE_ID, ydocState: originalYdoc, version: 7 }], // 3 + [{ version: 8 }], // 4 + [], // 5 + PERSONAL_SCOPE_ROW, // 6 + [], // 7 — no candidates + ]); + const invalidate = vi.fn().mockResolvedValue(undefined); + + const result = await propagateTitleRename(db as never, PAGE_ID, "Foo", "Bar", { + invalidateDocument: invalidate, + }); + + expect(result.sourcePagesAttempted).toBe(1); + expect(result.sourcePagesSucceeded).toBe(1); + expect(result.sourcePagesFailed).toBe(0); + expect(result.wikiLinkMarksUpdated).toBe(1); + expect(result.wikiLinkTextUpdated).toBe(1); + expect(invalidate).toHaveBeenCalledTimes(1); + expect(invalidate).toHaveBeenCalledWith(SOURCE_PAGE_ID); + + // UPDATE page_contents carries ydoc_state (wikiLink title → "Bar") and + // the freshly-extracted contentText. UPDATE pages carries content_preview. + // UPDATE page_contents は ydoc_state と contentText を更新し、UPDATE pages は + // content_preview を更新する。 + const updateChains = chains.filter((c) => c.startMethod === "update"); + expect(updateChains.length).toBe(2); + const pageContentsUpdate = updateChains.find((c) => { + const setArg = c.ops.find((op) => op.method === "set")?.args[0] as + | Record + | undefined; + return setArg && "ydocState" in setArg; + }); + expect(pageContentsUpdate).toBeTruthy(); + const pcSetArg = pageContentsUpdate?.ops.find((op) => op.method === "set")?.args[0] as + | { ydocState: Buffer; contentText: string } + | undefined; + expect(pcSetArg?.ydocState).toBeInstanceOf(Buffer); + // `extractTextFromYXml` appends a newline after block-level XmlElements + // (e.g. paragraph), so the raw plain text is `"Bar\n"`. + // `extractTextFromYXml` はブロック要素 (paragraph 等) の後に改行を付けるため、 + // プレーンテキストは末尾に改行が付く。 + expect(pcSetArg?.contentText).toBe("Bar\n"); + if (pcSetArg?.ydocState) { + expect(decodeYdocWikiLinkTitle(pcSetArg.ydocState)).toBe("Bar"); + } + + const pagesUpdate = updateChains.find((c) => { + const setArg = c.ops.find((op) => op.method === "set")?.args[0] as + | Record + | undefined; + return setArg && "contentPreview" in setArg; + }); + expect(pagesUpdate).toBeTruthy(); + const pagesSetArg = pagesUpdate?.ops.find((op) => op.method === "set")?.args[0] as + | { contentPreview: string } + | undefined; + expect(pagesSetArg?.contentPreview).toBe("Bar"); + }); + + it("skips rewriting when the source page has no page_contents row", async () => { + const { db, chains } = createMockDb([ + [{ sourceId: SOURCE_PAGE_ID }], // sources + [], // FOR UPDATE + [], // page_contents empty + PERSONAL_SCOPE_ROW, // ghost scope + [], // ghost candidates (none) + ]); + const invalidate = vi.fn(); + + const result = await propagateTitleRename(db as never, PAGE_ID, "Foo", "Bar", { + invalidateDocument: invalidate, + }); + + expect(result.sourcePagesAttempted).toBe(1); + expect(result.sourcePagesSucceeded).toBe(1); + expect(result.wikiLinkMarksUpdated).toBe(0); + // No UPDATE when there's no content row. / コンテンツ行が無ければ UPDATE しない。 + const updateChain = chains.find((c) => c.startMethod === "update"); + expect(updateChain).toBeUndefined(); + expect(invalidate).not.toHaveBeenCalled(); + }); + + it("skips UPDATE and invalidation when rewriting yields zero changes", async () => { + // Source page has no matching wiki-link: the rewriter returns zero changes. + // ソース側にマッチするリンクが無ければ書き換えゼロで終わる。 + const unrelatedYdoc = makeYdocWithWikiLink("Unrelated"); + + const { db, chains } = createMockDb([ + [{ sourceId: SOURCE_PAGE_ID }], + [], // FOR UPDATE + [{ pageId: SOURCE_PAGE_ID, ydocState: unrelatedYdoc, version: 1 }], + PERSONAL_SCOPE_ROW, // ghost scope + [], // ghost candidates (none) + ]); + const invalidate = vi.fn(); + + const result = await propagateTitleRename(db as never, PAGE_ID, "Foo", "Bar", { + invalidateDocument: invalidate, + }); + + expect(result.sourcePagesAttempted).toBe(1); + expect(result.wikiLinkMarksUpdated).toBe(0); + expect(chains.find((c) => c.startMethod === "update")).toBeUndefined(); + expect(invalidate).not.toHaveBeenCalled(); + }); + + it("promotes in-scope ghost links whose text matches the new title", async () => { + const GHOST_SOURCE = "11111111-aaaa-bbbb-cccc-000000000003"; + + const { db, chains } = createMockDb([ + [], // no real link sources + PERSONAL_SCOPE_ROW, // renamed-page scope (personal) + // in-scope ghost candidates (SELECT … INNER JOIN pages) + [ + { sourcePageId: GHOST_SOURCE, linkType: "wiki" }, + { sourcePageId: GHOST_SOURCE, linkType: "tag" }, + ], + [], // DELETE ghost_links (result ignored) + [], // INSERT links (result ignored) + ]); + const invalidate = vi.fn(); + + const result = await propagateTitleRename(db as never, PAGE_ID, "Foo", "Bar", { + invalidateDocument: invalidate, + }); + + expect(result.ghostPromotionsCount).toBe(2); + + // Delete on ghost_links and Insert on links should both be present. + // 削除と挿入の両方が行われる。 + const deleteChain = chains.find((c) => c.startMethod === "delete"); + expect(deleteChain).toBeTruthy(); + const insertChain = chains.find((c) => c.startMethod === "insert"); + expect(insertChain).toBeTruthy(); + const valuesCall = insertChain?.ops.find((op) => op.method === "values"); + const valuesArg = valuesCall?.args[0] as Array<{ + sourceId: string; + targetId: string; + linkType: string; + }>; + expect(valuesArg).toHaveLength(2); + expect(valuesArg?.every((v) => v.targetId === PAGE_ID)).toBe(true); + }); + + it("does not issue DELETE or INSERT when no in-scope ghost candidates match", async () => { + const { db, chains } = createMockDb([ + [], // no sources + PERSONAL_SCOPE_ROW, // scope + [], // candidates (empty) + ]); + const invalidate = vi.fn(); + + const result = await propagateTitleRename(db as never, PAGE_ID, "Foo", "Bar", { + invalidateDocument: invalidate, + }); + + expect(result.ghostPromotionsCount).toBe(0); + expect(chains.find((c) => c.startMethod === "delete")).toBeUndefined(); + expect(chains.find((c) => c.startMethod === "insert")).toBeUndefined(); + }); + + it("skips ghost promotion when the renamed page's scope row is missing", async () => { + // The renamed page was deleted between the title change and the background + // propagation run. Without a scope row we can't decide which ghosts belong + // to the same tenant, so we skip promotion entirely. + // リネーム対象の pages 行が消えた場合はスコープ判定が出来ないため、 + // ゴースト昇格はスキップする(PR #736 P1 レビュー対応)。 + const { db, chains } = createMockDb([ + [], // no sources + [], // pages scope — empty + ]); + const invalidate = vi.fn(); + + const result = await propagateTitleRename(db as never, PAGE_ID, "Foo", "Bar", { + invalidateDocument: invalidate, + }); + + expect(result.ghostPromotionsCount).toBe(0); + // Only the initial "sources" select + the scope select should have run. + // 初期の sources SELECT とスコープ SELECT のみ。 + expect(chains.filter((c) => c.startMethod === "select")).toHaveLength(2); + expect(chains.find((c) => c.startMethod === "delete")).toBeUndefined(); + expect(chains.find((c) => c.startMethod === "insert")).toBeUndefined(); + }); + + it("records failures per source page and still attempts ghost promotion", async () => { + // 1st source: FOR UPDATE rejects with an error → counted as failure. + // ただしベストエフォート方針で後続処理(ghost 昇格)は続行する。 + // Best-effort: a per-source failure must not abort ghost promotion. + const baseResults = [ + [{ sourceId: SOURCE_PAGE_ID }], // sources + PERSONAL_SCOPE_ROW, // promote scope + [], // promote candidates (none) + ]; + const base = createMockDb(baseResults); + let forUpdateCallCount = 0; + const db = new Proxy(base.db as unknown as Record, { + get(target, prop: string) { + if (prop === "transaction") { + return async (fn: (tx: unknown) => Promise) => { + const txProxy = new Proxy(target, { + get(t, p: string) { + if (p === "execute") { + // First FOR UPDATE execute call throws. + // 1 回目の FOR UPDATE を失敗させる。 + return () => { + forUpdateCallCount += 1; + if (forUpdateCallCount === 1) { + return Promise.reject(new Error("lock failed")); + } + return Promise.resolve([]); + }; + } + return (t as never)[p]; + }, + }); + return fn(txProxy); + }; + } + return (target as never)[prop]; + }, + }); + const invalidate = vi.fn(); + + const result = await propagateTitleRename(db as never, PAGE_ID, "Foo", "Bar", { + invalidateDocument: invalidate, + }); + + expect(result.sourcePagesAttempted).toBe(1); + expect(result.sourcePagesFailed).toBe(1); + expect(result.sourcePagesSucceeded).toBe(0); + expect(invalidate).not.toHaveBeenCalled(); + // Ghost promotion path still ran (empty result here). / ゴースト昇格の経路は通る。 + expect(result.ghostPromotionsCount).toBe(0); + }); + + it("rewrites only the link whose targetId matches the renamed page (issue #737)", async () => { + // 重複タイトル下のリネーム: ソースページ X が `[[Foo]]` を 2 回参照する。 + // 1 つは renamedPage (PAGE_ID) を、もう 1 つは別ページ (OTHER_TARGET_ID) + // を `targetId` で指している。ID 一致の方だけを `[[Bar]]` に書き換え、 + // もう一方は `[[Foo]]` のまま残ることを検証する。issue #737 案 A の本質。 + // Same-title rename: source page X holds two `[[Foo]]` marks pointing to + // different pages via `targetId`. Only the mark whose `targetId` matches + // the renamed page should become `[[Bar]]`; the other must stay `[[Foo]]`. + // This is the core acceptance scenario for issue #737 (approach A). + const OTHER_TARGET_ID = "33333333-aaaa-bbbb-cccc-000000000003"; + const originalYdoc = makeYdocWithTwoSameTitleLinks("Foo", PAGE_ID, OTHER_TARGET_ID); + + const { db, chains } = createMockDb([ + [{ sourceId: SOURCE_PAGE_ID }], + [], // FOR UPDATE + [{ pageId: SOURCE_PAGE_ID, ydocState: originalYdoc, version: 1 }], + [{ version: 2 }], // UPDATE page_contents + [], // UPDATE pages + PERSONAL_SCOPE_ROW, + [], // ghost candidates (none) + ]); + const invalidate = vi.fn().mockResolvedValue(undefined); + + const result = await propagateTitleRename(db as never, PAGE_ID, "Foo", "Bar", { + invalidateDocument: invalidate, + }); + + expect(result.sourcePagesAttempted).toBe(1); + expect(result.sourcePagesSucceeded).toBe(1); + expect(result.wikiLinkMarksUpdated).toBe(1); + expect(result.wikiLinkTextUpdated).toBe(1); + expect(invalidate).toHaveBeenCalledTimes(1); + + // 書き戻された ydoc_state を読み取り、targetId ごとのタイトル分布を検証。 + // Decode the persisted ydoc_state and check titles by `targetId`. + const updateChains = chains.filter((c) => c.startMethod === "update"); + const pageContentsUpdate = updateChains.find((c) => { + const setArg = c.ops.find((op) => op.method === "set")?.args[0] as + | Record + | undefined; + return setArg && "ydocState" in setArg; + }); + const pcSetArg = pageContentsUpdate?.ops.find((op) => op.method === "set")?.args[0] as + | { ydocState: Buffer } + | undefined; + expect(pcSetArg?.ydocState).toBeInstanceOf(Buffer); + if (pcSetArg?.ydocState) { + const titles = decodeYdocWikiLinkTitlesByTargetId(pcSetArg.ydocState); + expect(titles[PAGE_ID]).toBe("Bar"); + expect(titles[OTHER_TARGET_ID]).toBe("Foo"); + } + }); +}); diff --git a/server/api/src/__tests__/services/usageService.test.ts b/server/api/src/__tests__/services/usageService.test.ts new file mode 100644 index 00000000..10071a1b --- /dev/null +++ b/server/api/src/__tests__/services/usageService.test.ts @@ -0,0 +1,211 @@ +/** + * usageService.ts のテスト(checkUsage / validateModelAccess / calculateCost / recordUsage)。 + * Tests for usageService. + */ +import { describe, it, expect } from "vitest"; +import { + checkUsage, + validateModelAccess, + calculateCost, + recordUsage, +} from "../../services/usageService.js"; +import { createMockDb } from "../createMockDb.js"; +import type { Database } from "../../types/index.js"; + +function asDb(results: unknown[]) { + const { db, chains } = createMockDb(results); + return { db: db as unknown as Database, chains }; +} + +// ── calculateCost ─────────────────────────────────────────────────────────── + +describe("calculateCost", () => { + it("rounds up combined input + output cost", () => { + // (1000 / 1000) * 5 + (500 / 1000) * 15 = 5 + 7.5 = 12.5 → ceil → 13 + expect(calculateCost({ inputTokens: 1000, outputTokens: 500 }, 5, 15)).toBe(13); + }); + + it("returns 0 when usage is zero", () => { + expect(calculateCost({ inputTokens: 0, outputTokens: 0 }, 10, 20)).toBe(0); + }); + + it("treats sub-1k token usage proportionally and rounds up", () => { + // (100 / 1000) * 10 + (0 / 1000) * 20 = 1 → ceil → 1 + expect(calculateCost({ inputTokens: 100, outputTokens: 0 }, 10, 20)).toBe(1); + }); +}); + +// ── checkUsage ────────────────────────────────────────────────────────────── + +describe("checkUsage", () => { + it("returns budget/consumed/usagePercent when both rows exist", async () => { + // 1) tier budget row, 2) monthly usage row + const { db } = asDb([[{ monthlyBudgetUnits: 10000 }], [{ totalCostUnits: 2500 }]]); + + const result = await checkUsage("u1", "pro", db); + + expect(result).toMatchObject({ + allowed: true, + budgetUnits: 10000, + consumedUnits: 2500, + remaining: 7500, + tier: "pro", + }); + expect(result.usagePercent).toBeCloseTo(25); + }); + + it("falls back to default budget (15000 for pro) when no budget row exists", async () => { + const { db } = asDb([[], []]); + + const result = await checkUsage("u1", "pro", db); + + expect(result.budgetUnits).toBe(15000); + expect(result.consumedUnits).toBe(0); + expect(result.allowed).toBe(true); + }); + + it("falls back to 1500 for the free tier", async () => { + const { db } = asDb([[], []]); + + const result = await checkUsage("u1", "free", db); + + expect(result.budgetUnits).toBe(1500); + expect(result.tier).toBe("free"); + }); + + it("returns allowed=false when consumed >= budget", async () => { + const { db } = asDb([[{ monthlyBudgetUnits: 1000 }], [{ totalCostUnits: 1000 }]]); + + const result = await checkUsage("u1", "pro", db); + + expect(result.allowed).toBe(false); + expect(result.remaining).toBe(0); + expect(result.usagePercent).toBeCloseTo(100); + }); + + it("clamps remaining to 0 when consumed > budget", async () => { + const { db } = asDb([[{ monthlyBudgetUnits: 1000 }], [{ totalCostUnits: 1500 }]]); + + const result = await checkUsage("u1", "pro", db); + + expect(result.remaining).toBe(0); + expect(result.allowed).toBe(false); + }); + + it("returns 0 usagePercent when budget is 0", async () => { + const { db } = asDb([[{ monthlyBudgetUnits: 0 }], [{ totalCostUnits: 0 }]]); + + const result = await checkUsage("u1", "pro", db); + + expect(result.usagePercent).toBe(0); + }); +}); + +// ── validateModelAccess ───────────────────────────────────────────────────── + +describe("validateModelAccess", () => { + it("returns model info when model is active and tier matches", async () => { + const { db } = asDb([ + [ + { + provider: "openai", + modelId: "gpt-4o", + inputCostUnits: 5, + outputCostUnits: 15, + tierRequired: "free", + isActive: true, + }, + ], + ]); + + const info = await validateModelAccess("model-1", "free", db); + + expect(info).toEqual({ + provider: "openai", + apiModelId: "gpt-4o", + inputCostUnits: 5, + outputCostUnits: 15, + }); + }); + + it("throws 'Model not found or inactive' when no row matches", async () => { + const { db } = asDb([[]]); + + await expect(validateModelAccess("missing", "pro", db)).rejects.toThrow( + /not found or inactive/i, + ); + }); + + it("throws 'FORBIDDEN' when model requires pro and tier is free", async () => { + const { db } = asDb([ + [ + { + provider: "openai", + modelId: "gpt-4-pro", + inputCostUnits: 10, + outputCostUnits: 30, + tierRequired: "pro", + isActive: true, + }, + ], + ]); + + await expect(validateModelAccess("model-pro", "free", db)).rejects.toThrow("FORBIDDEN"); + }); + + it("allows pro tier on a pro-required model", async () => { + const { db } = asDb([ + [ + { + provider: "anthropic", + modelId: "claude-opus", + inputCostUnits: 20, + outputCostUnits: 60, + tierRequired: "pro", + isActive: true, + }, + ], + ]); + + const info = await validateModelAccess("model-pro", "pro", db); + expect(info.apiModelId).toBe("claude-opus"); + }); +}); + +// ── recordUsage ───────────────────────────────────────────────────────────── + +describe("recordUsage", () => { + it("issues an insert into aiUsageLogs and an upsert into aiMonthlyUsage", async () => { + // 1) insert aiUsageLogs, 2) upsert aiMonthlyUsage + const { db, chains } = asDb([undefined, undefined]); + + await recordUsage( + "user-1", + "model-1", + "chat", + { inputTokens: 200, outputTokens: 100 }, + 7, + "system", + db, + ); + + // 2 つの DB チェーンが消費される。 + // recordUsage starts exactly two top-level chains (insert + upsert). + expect(chains.length).toBe(2); + expect(chains[0]?.startMethod).toBe("insert"); + expect(chains[1]?.startMethod).toBe("insert"); + + // 1 件目: values() に渡された生データ。 + // First chain: values() argument carries the log fields. + const valuesCall = chains[0]?.ops.find((op) => op.method === "values"); + expect(valuesCall?.args[0]).toMatchObject({ + userId: "user-1", + modelId: "model-1", + feature: "chat", + inputTokens: 200, + outputTokens: 100, + costUnits: 7, + apiMode: "system", + }); + }); +}); diff --git a/server/api/src/__tests__/services/ydocRenameRewrite.test.ts b/server/api/src/__tests__/services/ydocRenameRewrite.test.ts new file mode 100644 index 00000000..384ad02c --- /dev/null +++ b/server/api/src/__tests__/services/ydocRenameRewrite.test.ts @@ -0,0 +1,567 @@ +/** + * `ydocRenameRewrite` の単体テスト。 + * Unit tests for `ydocRenameRewrite` — the pure Y.Doc mutation helper that + * rewrites WikiLink and tag marks when a page is renamed. + * + * Part of issue #726 (Phase 2 rename propagation). + */ + +import { describe, it, expect } from "vitest"; +import * as Y from "yjs"; + +import { rewriteTitleRefsInDoc } from "../../services/ydocRenameRewrite.js"; + +/** + * 最小の Tiptap 風 Y.Doc ツリーを組み立てるヘルパー。`segments` は Y.XmlText + * の delta と同じ形式で、`attributes` にマーク情報(`wikiLink` / `tag` 等)を入れる。 + * + * Build a minimal Tiptap-like Y.Doc tree. `segments` follows the Y.XmlText + * delta format; put mark info (`wikiLink` / `tag`) inside `attributes`. + */ +function buildDocWithParagraph( + segments: Array<{ insert: string; attributes?: Record }>, +): { doc: Y.Doc; text: Y.XmlText } { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("default"); + const paragraph = new Y.XmlElement("paragraph"); + fragment.insert(0, [paragraph]); + const text = new Y.XmlText(); + paragraph.insert(0, [text]); + for (const segment of segments) { + text.insert(text.length, segment.insert, segment.attributes); + } + return { doc, text }; +} + +/** + * Y.XmlText のプレーンテキストを取り出すテスト用ヘルパー。`toJSON()` は + * マークを XML 要素として直列化するため、素のテキスト比較には使えない。 + * + * Extract plain text from a Y.XmlText. `toJSON()` serializes marks as XML + * elements, so we reconstruct the string from its delta instead. + */ +function plainText(text: Y.XmlText): string { + const delta = text.toDelta() as Array<{ insert: unknown }>; + return delta.map((item) => (typeof item.insert === "string" ? item.insert : "")).join(""); +} + +describe("rewriteTitleRefsInDoc", () => { + describe("WikiLink marks", () => { + it("updates mark title and text when the segment text matches the old title", () => { + const { doc, text } = buildDocWithParagraph([ + { + insert: "Foo", + attributes: { wikiLink: { title: "Foo", exists: true, referenced: false } }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar"); + + expect(result).toMatchObject({ + wikiLinkMarksUpdated: 1, + wikiLinkTextUpdated: 1, + tagMarksUpdated: 0, + tagTextUpdated: 0, + }); + expect(text.toDelta()).toEqual([ + { + insert: "Bar", + attributes: { wikiLink: { title: "Bar", exists: true, referenced: false } }, + }, + ]); + }); + + it("is case-insensitive and trim-insensitive on the old title match", () => { + const { doc, text } = buildDocWithParagraph([ + { + insert: " FOO ", + attributes: { wikiLink: { title: " foo ", exists: true, referenced: false } }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "foo", "Bar"); + + expect(result.wikiLinkMarksUpdated).toBe(1); + expect(result.wikiLinkTextUpdated).toBe(1); + const delta = text.toDelta(); + expect(delta[0]?.insert).toBe("Bar"); + expect(delta[0]?.attributes?.wikiLink).toEqual({ + title: "Bar", + exists: true, + referenced: false, + }); + }); + + it("updates only the mark attribute when the segment text does not match (manual edit)", () => { + const { doc, text } = buildDocWithParagraph([ + { + insert: "Custom label", + attributes: { wikiLink: { title: "Foo", exists: true, referenced: false } }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar"); + + expect(result.wikiLinkMarksUpdated).toBe(1); + expect(result.wikiLinkTextUpdated).toBe(0); + expect(text.toDelta()).toEqual([ + { + insert: "Custom label", + attributes: { wikiLink: { title: "Bar", exists: true, referenced: false } }, + }, + ]); + }); + + it("does not touch wikiLink marks whose title does not match the old title", () => { + const { doc, text } = buildDocWithParagraph([ + { + insert: "Baz", + attributes: { wikiLink: { title: "Baz", exists: true, referenced: false } }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar"); + + expect(result.wikiLinkMarksUpdated).toBe(0); + expect(result.wikiLinkTextUpdated).toBe(0); + expect(text.toDelta()).toEqual([ + { + insert: "Baz", + attributes: { wikiLink: { title: "Baz", exists: true, referenced: false } }, + }, + ]); + }); + + it("rewrites multiple wikiLink occurrences across segments and siblings", () => { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("default"); + const p1 = new Y.XmlElement("paragraph"); + const p2 = new Y.XmlElement("paragraph"); + fragment.insert(0, [p1, p2]); + + const t1 = new Y.XmlText(); + p1.insert(0, [t1]); + // Use `wikiLink: null` to break the formatting inheritance between the + // two link segments — Y.XmlText inserts otherwise inherit the preceding + // segment's marks. / Yjs は直前のフォーマットを引き継ぐため、明示的に + // null を渡して二つの wikiLink 区間を独立させる。 + t1.insert(0, "Foo", { wikiLink: { title: "Foo", exists: true, referenced: false } }); + t1.insert(t1.length, " and ", { wikiLink: null }); + t1.insert(t1.length, "Foo", { + wikiLink: { title: "Foo", exists: true, referenced: true }, + }); + + const t2 = new Y.XmlText(); + p2.insert(0, [t2]); + t2.insert(0, "related: "); + t2.insert(t2.length, "Foo", { + wikiLink: { title: "Foo", exists: true, referenced: false }, + }); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar"); + + expect(result.wikiLinkMarksUpdated).toBe(3); + expect(result.wikiLinkTextUpdated).toBe(3); + expect(plainText(t1)).toBe("Bar and Bar"); + expect(plainText(t2)).toBe("related: Bar"); + }); + }); + + describe("Tag marks", () => { + it("updates tag name and text when the segment text matches the old tag", () => { + const { doc, text } = buildDocWithParagraph([ + { + insert: "foo", + attributes: { tag: { name: "foo", exists: true, referenced: false } }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar"); + + expect(result.tagMarksUpdated).toBe(1); + expect(result.tagTextUpdated).toBe(1); + expect(text.toDelta()).toEqual([ + { insert: "Bar", attributes: { tag: { name: "Bar", exists: true, referenced: false } } }, + ]); + }); + + it("leaves tag marks untouched when the new title is not a valid tag name", () => { + const { doc, text } = buildDocWithParagraph([ + { + insert: "foo", + attributes: { tag: { name: "foo", exists: true, referenced: false } }, + }, + ]); + + // Spaces are not valid tag characters — tag cannot follow the rename. + // スペースはタグ名として無効なので、タグは追従させない。 + const result = rewriteTitleRefsInDoc(doc, "foo", "bar baz"); + + expect(result.tagMarksUpdated).toBe(0); + expect(result.tagTextUpdated).toBe(0); + expect(text.toDelta()).toEqual([ + { insert: "foo", attributes: { tag: { name: "foo", exists: true, referenced: false } } }, + ]); + }); + + it("handles wikiLink and tag marks in the same paragraph", () => { + // Explicitly null surrounding marks so neighbouring segments do not + // inherit each other's formatting (Yjs default behaviour). + // 隣接セグメント間のフォーマット継承を断ち切るため、null を渡して + // マーク境界を明示する。 + const { doc, text } = buildDocWithParagraph([ + { insert: "hello " }, + { + insert: "Foo", + attributes: { wikiLink: { title: "Foo", exists: true, referenced: false } }, + }, + { insert: " and ", attributes: { wikiLink: null, tag: null } }, + { insert: "foo", attributes: { tag: { name: "foo", exists: true, referenced: false } } }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar"); + + expect(result.wikiLinkMarksUpdated).toBe(1); + expect(result.wikiLinkTextUpdated).toBe(1); + expect(result.tagMarksUpdated).toBe(1); + expect(result.tagTextUpdated).toBe(1); + expect(plainText(text)).toBe("hello Bar and Bar"); + }); + }); + + describe("targetId-based matching (issue #737)", () => { + const RENAMED_PAGE_ID = "11111111-aaaa-bbbb-cccc-000000000001"; + const OTHER_PAGE_ID = "22222222-aaaa-bbbb-cccc-000000000002"; + + it("rewrites a wikiLink mark whose targetId matches renamedPageId", () => { + // 案 A: ID 一致で書き換え対象を特定する。タイトル一致だけに頼らないことで、 + // 同名ページが別 ID で共存していても正しい一方だけを書き換えられる。 + // Approach A: id-matching pinpoints which mark to rewrite, so that + // same-titled pages with different ids do not interfere. + const { doc, text } = buildDocWithParagraph([ + { + insert: "Foo", + attributes: { + wikiLink: { title: "Foo", exists: true, referenced: false, targetId: RENAMED_PAGE_ID }, + }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar", { renamedPageId: RENAMED_PAGE_ID }); + + expect(result.wikiLinkMarksUpdated).toBe(1); + expect(result.wikiLinkTextUpdated).toBe(1); + const delta = text.toDelta(); + expect(delta[0]?.insert).toBe("Bar"); + expect(delta[0]?.attributes?.wikiLink).toEqual({ + title: "Bar", + exists: true, + referenced: false, + targetId: RENAMED_PAGE_ID, + }); + }); + + it("does NOT rewrite a wikiLink mark whose title matches but targetId points elsewhere", () => { + // 同名ページの誤書き換え (issue #737) を防ぐ核心ケース。タイトルは一致 + // するが `targetId` が別ページを指しているため、書き換えてはいけない。 + // The exact issue #737 scenario: same title but a different `targetId`. + // The mark refers to a different page and must stay untouched. + const { doc, text } = buildDocWithParagraph([ + { + insert: "Foo", + attributes: { + wikiLink: { title: "Foo", exists: true, referenced: false, targetId: OTHER_PAGE_ID }, + }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar", { renamedPageId: RENAMED_PAGE_ID }); + + expect(result.wikiLinkMarksUpdated).toBe(0); + expect(result.wikiLinkTextUpdated).toBe(0); + expect(text.toDelta()).toEqual([ + { + insert: "Foo", + attributes: { + wikiLink: { title: "Foo", exists: true, referenced: false, targetId: OTHER_PAGE_ID }, + }, + }, + ]); + }); + + it("falls back to title matching for marks without targetId (lazy migration)", () => { + // 旧データ・未解決マークでは `targetId` が無いので、従来通りタイトル一致 + // で書き換える。これにより既存 Y.Doc を移行せずに済む。 + // Lazy migration: marks without `targetId` keep matching by title so + // existing Y.Docs do not require an upfront migration pass. + const { doc, text } = buildDocWithParagraph([ + { + insert: "Foo", + attributes: { + wikiLink: { title: "Foo", exists: true, referenced: false }, + }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar", { renamedPageId: RENAMED_PAGE_ID }); + + expect(result.wikiLinkMarksUpdated).toBe(1); + expect(result.wikiLinkTextUpdated).toBe(1); + const delta = text.toDelta(); + expect(delta[0]?.insert).toBe("Bar"); + expect(delta[0]?.attributes?.wikiLink).toMatchObject({ title: "Bar" }); + }); + + it("treats empty-string targetId as missing and falls back to title match", () => { + // `data-target-id=""` が parseHTML されたケースなど、空文字 `targetId` を + // 「ID 無し」と等価に扱う。これも lazy migration 経路に倒す。 + // Empty-string `targetId` (e.g. parsed from a stray empty data-attr) + // is treated as id-less so it lands on the legacy fallback path. + const { doc, text } = buildDocWithParagraph([ + { + insert: "Foo", + attributes: { + wikiLink: { title: "Foo", exists: true, referenced: false, targetId: "" }, + }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar", { renamedPageId: RENAMED_PAGE_ID }); + + expect(result.wikiLinkMarksUpdated).toBe(1); + expect(plainText(text)).toBe("Bar"); + }); + + it("rewrites tag marks by targetId match and skips same-name tags pointing elsewhere", () => { + // タグマークも同様。同名タグが別ページに紐付いている場合は触らない。 + // Tag marks honour the same id-strict rule: same name on a different + // page id must not be rewritten. + const { doc, text } = buildDocWithParagraph([ + { + insert: "foo", + attributes: { + tag: { name: "foo", exists: true, referenced: false, targetId: RENAMED_PAGE_ID }, + }, + }, + { insert: " ", attributes: { tag: null, wikiLink: null } }, + { + insert: "foo", + attributes: { + tag: { name: "foo", exists: true, referenced: false, targetId: OTHER_PAGE_ID }, + }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "foo", "bar", { renamedPageId: RENAMED_PAGE_ID }); + + expect(result.tagMarksUpdated).toBe(1); + expect(result.tagTextUpdated).toBe(1); + // 1 つ目の tag は `bar` に書き換わり、2 つ目は `foo` のまま残る。 + // First tag becomes `bar`; the second one stays `foo`. + expect(plainText(text)).toBe("bar foo"); + }); + + it("skips marks that carry a targetId when renamedPageId is omitted (cannot verify)", () => { + // `renamedPageId` を渡さない呼び出しで、マークに `targetId` がある場合は + // 同名ページとの判別ができないため安全側に倒して書き換えない。 + // `targetId` 無しのマークだけが従来通りタイトル一致でフォールバックする。 + // Without `renamedPageId` we cannot verify a mark's `targetId`, so the + // safe default is to skip rewriting it (avoids the same-title bug + // regressing for any legacy caller). Marks without `targetId` still + // fall back to title matching as before. + const { doc, text } = buildDocWithParagraph([ + { + insert: "Foo", + attributes: { + wikiLink: { title: "Foo", exists: true, referenced: false, targetId: OTHER_PAGE_ID }, + }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar"); + + expect(result.wikiLinkMarksUpdated).toBe(0); + expect(plainText(text)).toBe("Foo"); + }); + + it("keeps rewriting id-less marks by title even when renamedPageId is omitted", () => { + // `renamedPageId` 無しでも、`targetId` を持たないマークは従来通り + // タイトル一致で書き換える(後方互換)。 + // Marks without `targetId` continue to use title-fallback rewriting + // even when `renamedPageId` is omitted (backward compat). + const { doc, text } = buildDocWithParagraph([ + { + insert: "Foo", + attributes: { + wikiLink: { title: "Foo", exists: true, referenced: false }, + }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar"); + + expect(result.wikiLinkMarksUpdated).toBe(1); + expect(plainText(text)).toBe("Bar"); + }); + + // タグマーク版の fallback 挙動パリティ。同じ 2 ケースを `tag` マーク側でも + // 保証する(CodeRabbit レビュー指摘)。`tag` 単独でリグレッションが + // 入らないようテスト面でも `wikiLink` と同等の網を張る。 + // Tag-mark parity for the two `renamedPageId`-omitted fallback branches + // (CodeRabbit review). Mirrors the wikiLink coverage so a tag-only + // regression cannot slip past the suite. + it("skips tag marks that carry a targetId when renamedPageId is omitted", () => { + const { doc, text } = buildDocWithParagraph([ + { + insert: "foo", + attributes: { + tag: { name: "foo", exists: true, referenced: false, targetId: OTHER_PAGE_ID }, + }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "foo", "bar"); + + expect(result.tagMarksUpdated).toBe(0); + expect(result.tagTextUpdated).toBe(0); + expect(plainText(text)).toBe("foo"); + }); + + it("keeps rewriting id-less tag marks by name even when renamedPageId is omitted", () => { + const { doc, text } = buildDocWithParagraph([ + { + insert: "foo", + attributes: { + tag: { name: "foo", exists: true, referenced: false }, + }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "foo", "bar"); + + expect(result.tagMarksUpdated).toBe(1); + expect(result.tagTextUpdated).toBe(1); + expect(plainText(text)).toBe("bar"); + }); + }); + + describe("Backward-compat for legacy 4-arg fragmentName form", () => { + // 旧 API では第 4 引数が `fragmentName: string` だった。文字列をそのまま + // 受け取って `{ fragmentName }` として解釈できることを固定する + // (CodeRabbit レビュー指摘)。これにより、issue #737 以前のスナップショット + // から拾い上げられた呼び出し元が静かに既定フラグメントへ書き換わる事故 + // を防ぐ。 + // Pre-issue-#737 callers passed the fourth arg as a `fragmentName` + // string. Lock in that the function still accepts that shape so legacy + // callers do not silently retarget the default fragment (CodeRabbit). + it("treats a string fourth argument as fragmentName", () => { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("custom"); + const paragraph = new Y.XmlElement("paragraph"); + fragment.insert(0, [paragraph]); + const text = new Y.XmlText(); + paragraph.insert(0, [text]); + text.insert(0, "Foo", { wikiLink: { title: "Foo", exists: true, referenced: false } }); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar", "custom"); + + expect(result.wikiLinkMarksUpdated).toBe(1); + expect(plainText(text)).toBe("Bar"); + }); + + it("does not touch the default fragment when only `custom` is asked for", () => { + // 旧 API の呼び出しがオプション形へ自動変換され、誤って default フラグ + // メントを書き換えてしまわないことを担保する。 + // Guard against a regression where the legacy form is parsed as + // options and silently rewrites the default fragment instead. + const doc = new Y.Doc(); + const defaultFragment = doc.getXmlFragment("default"); + const defaultPara = new Y.XmlElement("paragraph"); + defaultFragment.insert(0, [defaultPara]); + const defaultText = new Y.XmlText(); + defaultPara.insert(0, [defaultText]); + defaultText.insert(0, "Foo", { + wikiLink: { title: "Foo", exists: true, referenced: false }, + }); + + const customFragment = doc.getXmlFragment("custom"); + const customPara = new Y.XmlElement("paragraph"); + customFragment.insert(0, [customPara]); + const customText = new Y.XmlText(); + customPara.insert(0, [customText]); + customText.insert(0, "Foo", { + wikiLink: { title: "Foo", exists: true, referenced: false }, + }); + + rewriteTitleRefsInDoc(doc, "Foo", "Bar", "custom"); + + // 既定フラグメントは触らず、`custom` のみが書き換わる。 + // The default fragment is left untouched; only `custom` rewrites. + expect(plainText(defaultText)).toBe("Foo"); + expect(plainText(customText)).toBe("Bar"); + }); + }); + + describe("Guards and edge cases", () => { + it("is a no-op when oldTitle and newTitle normalize to the same value", () => { + const { doc, text } = buildDocWithParagraph([ + { + insert: "Foo", + attributes: { wikiLink: { title: "Foo", exists: true, referenced: false } }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "Foo", " foo "); + + expect(result.wikiLinkMarksUpdated).toBe(0); + expect(result.wikiLinkTextUpdated).toBe(0); + // Content unchanged. 内容に変化がない。 + expect(plainText(text)).toBe("Foo"); + }); + + it("is a no-op when either title is empty", () => { + const { doc, text } = buildDocWithParagraph([ + { + insert: "Foo", + attributes: { wikiLink: { title: "Foo", exists: true, referenced: false } }, + }, + ]); + + expect(rewriteTitleRefsInDoc(doc, "", "Bar").wikiLinkMarksUpdated).toBe(0); + expect(rewriteTitleRefsInDoc(doc, "Foo", "").wikiLinkMarksUpdated).toBe(0); + expect(plainText(text)).toBe("Foo"); + }); + + it("recurses into nested XmlElement children (e.g. list items)", () => { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("default"); + const list = new Y.XmlElement("bulletList"); + fragment.insert(0, [list]); + const item = new Y.XmlElement("listItem"); + list.insert(0, [item]); + const para = new Y.XmlElement("paragraph"); + item.insert(0, [para]); + const text = new Y.XmlText(); + para.insert(0, [text]); + text.insert(0, "Foo", { wikiLink: { title: "Foo", exists: true, referenced: false } }); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar"); + + expect(result.wikiLinkMarksUpdated).toBe(1); + expect(plainText(text)).toBe("Bar"); + }); + + it("returns a zero-result object when the document has no matching refs", () => { + const { doc } = buildDocWithParagraph([{ insert: "plain text, no marks" }]); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar"); + + expect(result).toEqual({ + wikiLinkMarksUpdated: 0, + wikiLinkTextUpdated: 0, + tagMarksUpdated: 0, + tagTextUpdated: 0, + }); + }); + }); +}); diff --git a/server/api/src/lib/extractPlainTextFromYXml.ts b/server/api/src/lib/extractPlainTextFromYXml.ts new file mode 100644 index 00000000..15eaad9b --- /dev/null +++ b/server/api/src/lib/extractPlainTextFromYXml.ts @@ -0,0 +1,78 @@ +/** + * Y.Doc / Y.Xml からプレーンテキストおよびコンテンツプレビューを抽出する + * ユーティリティ。`server/hocuspocus/src/extractPlainTextFromYXml.ts` と + * 同等の実装を意図的に複製している。 + * + * Plain-text / preview extraction from Y.Doc trees. Intentionally mirrors + * `server/hocuspocus/src/extractPlainTextFromYXml.ts` so the API server can + * derive content text when it rewrites a Y.Doc server-side (issue #726). + * + * ⚠️ 変更時は Hocuspocus 側の同名ファイルも合わせて更新すること。両者は + * Bun workspace が分かれているため共有パッケージにはなっていない(CLAUDE.md + * 参照)。 + * ⚠️ When editing, keep the Hocuspocus copy in sync — the two servers live + * in separate Bun projects and do not share a package (see CLAUDE.md). + */ + +import * as Y from "yjs"; + +const INLINE_XML_ELEMENT_NAMES = new Set([ + "bold", + "italic", + "strike", + "code", + "link", + "underline", + "highlight", + "subscript", + "superscript", + "textStyle", +]); + +function isInlineXmlElement(node: Y.XmlElement): boolean { + return INLINE_XML_ELEMENT_NAMES.has(node.nodeName); +} + +/** + * Y.Doc の XmlFragment(または XmlElement 根)からプレーンテキストを再帰的に抽出する。 + * Recursively extract plain text from a Y.XmlFragment or Y.XmlElement subtree. + */ +export function extractTextFromYXml(node: Y.XmlFragment | Y.XmlElement): string { + let text = ""; + + // `node.get(i)` は O(i) なのでインデックスループは全体で O(N^2)。 + // `toArray()` は一度だけ O(N) で配列化できる(PR #736 レビュー参照)。 + // `node.get(i)` is O(i); iterating by index is O(N^2) total. `toArray()` + // does a single O(N) pass — see PR #736 review comments. + for (const child of node.toArray() as Array) { + if (child instanceof Y.XmlText) { + for (const op of child.toDelta() as Array<{ insert: unknown }>) { + if (typeof op.insert === "string") { + text += op.insert; + } + } + } else if (child instanceof Y.XmlElement) { + const inner = extractTextFromYXml(child); + const suffix = isInlineXmlElement(child) ? " " : "\n"; + text += inner + suffix; + } + } + return text; +} + +/** + * プレビュー文字列の最大長(`pages.content_preview` と一致)。 + * Max length for content preview (aligned with `pages.content_preview`). + */ +export const CONTENT_PREVIEW_MAX_LENGTH = 120; + +/** + * プレーンテキストからコンテンツプレビューを生成する。 + * Generate a content preview (first 120 chars) from plain text. + */ +export function buildContentPreview(text: string): string { + const trimmed = text.trim().replace(/\s+/g, " "); + if (trimmed.length <= CONTENT_PREVIEW_MAX_LENGTH) return trimmed; + const headLength = Math.max(0, CONTENT_PREVIEW_MAX_LENGTH - 3); + return `${trimmed.slice(0, headLength).trim()}...`; +} diff --git a/server/api/src/lib/hocuspocusInvalidation.ts b/server/api/src/lib/hocuspocusInvalidation.ts new file mode 100644 index 00000000..ffbb7ebd --- /dev/null +++ b/server/api/src/lib/hocuspocusInvalidation.ts @@ -0,0 +1,89 @@ +/** + * Hocuspocus のインメモリ Y.Doc を破棄するための内部 HTTP クライアント。 + * Best-effort HTTP client that asks the Hocuspocus server to drop its cached + * Y.Doc for a given page, so subsequent clients reload from the database. + * + * 共通呼び出し元 / Callers: + * - ページスナップショットの復元 (`routes/pageSnapshots.ts`) + * - タイトルリネーム伝播 (`services/titleRenamePropagationService.ts`, issue #726) + * + * ネットワーク失敗・タイムアウトは ログに残すだけで呼び出し側に伝播させない。 + * Failures (timeout, non-2xx, network) are logged only and never thrown — the + * caller should continue with its main flow. + */ + +const DEFAULT_HOCUSPOCUS_INTERNAL_URL = "http://127.0.0.1:1234"; +/** HTTP timeout for invalidation (ms). / 無効化 HTTP のタイムアウト (ミリ秒) */ +const HOCUSPOCUS_INVALIDATE_TIMEOUT_MS = 2500; + +function getHocuspocusInternalUrl(): string | null { + const explicitUrl = process.env.HOCUSPOCUS_INTERNAL_URL?.trim(); + if (explicitUrl) { + return explicitUrl.replace(/\/$/, ""); + } + return process.env.NODE_ENV === "development" ? DEFAULT_HOCUSPOCUS_INTERNAL_URL : null; +} + +/** + * Hocuspocus にライブドキュメントの破棄を依頼する(ベストエフォート)。 + * + * 環境変数 `HOCUSPOCUS_INTERNAL_URL` と `BETTER_AUTH_SECRET` が揃っている + * 場合のみ動作する。開発環境では `HOCUSPOCUS_INTERNAL_URL` が未設定でも + * デフォルトの `http://127.0.0.1:1234` にフォールバックする。 + * + * Ask Hocuspocus to drop its live Y.Doc for `pageId`. Requires + * `HOCUSPOCUS_INTERNAL_URL` and `BETTER_AUTH_SECRET`. In development the + * URL defaults to `http://127.0.0.1:1234`. + */ +export async function invalidateHocuspocusDocument( + pageId: string, + opts?: { logPrefix?: string }, +): Promise { + const baseUrl = getHocuspocusInternalUrl(); + const internalSecret = process.env.BETTER_AUTH_SECRET?.trim(); + const prefix = opts?.logPrefix ?? "[Hocuspocus]"; + + if (!baseUrl || !internalSecret) { + // Always log — silent skipping in production hides misconfiguration + // until stale Y.Docs start winning against committed writes. List which + // envs are missing so operators can diagnose. 本番で silent に無効化 + // すると古い Y.Doc が勝ち続ける原因調査が難しくなるため、常にログを残す。 + const missing = [ + baseUrl ? null : "HOCUSPOCUS_INTERNAL_URL", + internalSecret ? null : "BETTER_AUTH_SECRET", + ] + .filter((v): v is string => v !== null) + .join(", "); + console.warn( + `${prefix} Skipped invalidation for page ${pageId}: missing env var(s): ${missing}`, + ); + return; + } + + const url = `${baseUrl}/internal/documents/${encodeURIComponent(pageId)}/invalidate`; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), HOCUSPOCUS_INVALIDATE_TIMEOUT_MS); + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "x-internal-secret": internalSecret, + }, + signal: controller.signal, + }); + clearTimeout(timeoutId); + + if (!response.ok) { + console.warn(`${prefix} Invalidation HTTP ${response.status} for page ${pageId}`); + } + } catch (error) { + clearTimeout(timeoutId); + const name = error instanceof Error ? error.name : ""; + if (name === "AbortError") { + console.warn(`${prefix} Invalidation timed out for page ${pageId}`); + return; + } + console.warn(`${prefix} Invalidation failed for page ${pageId}:`, error); + } +} diff --git a/server/api/src/routes/pageSnapshots.ts b/server/api/src/routes/pageSnapshots.ts index 94975c93..ecf3f5c9 100644 --- a/server/api/src/routes/pageSnapshots.ts +++ b/server/api/src/routes/pageSnapshots.ts @@ -14,69 +14,9 @@ import { authRequired } from "../middleware/auth.js"; import type { AppEnv } from "../types/index.js"; import { assertPageViewAccess, assertPageEditAccess } from "../services/pageAccessService.js"; import { pruneSnapshotsExceedingLimitSql } from "../services/snapshotService.js"; +import { invalidateHocuspocusDocument } from "../lib/hocuspocusInvalidation.js"; const app = new Hono(); -const DEFAULT_HOCUSPOCUS_INTERNAL_URL = "http://127.0.0.1:1234"; -/** Best-effort invalidation HTTP timeout (ms). / ベストエフォート無効化の HTTP タイムアウト(ミリ秒) */ -const HOCUSPOCUS_INVALIDATE_TIMEOUT_MS = 2500; - -function getHocuspocusInternalUrl(): string | null { - const explicitUrl = process.env.HOCUSPOCUS_INTERNAL_URL?.trim(); - if (explicitUrl) { - return explicitUrl.replace(/\/$/, ""); - } - return process.env.NODE_ENV === "development" ? DEFAULT_HOCUSPOCUS_INTERNAL_URL : null; -} - -/** - * Hocuspocus に復元後のライブドキュメント無効化を依頼する(ベストエフォート)。 - * タイムアウト・HTTP エラーはログのみで呼び出し元には伝えない。 - * - * Best-effort: asks Hocuspocus to drop live Y.Doc after restore. Timeouts and HTTP - * errors are logged only and never thrown to the caller. - */ -async function invalidateHocuspocusDocument(pageId: string): Promise { - const baseUrl = getHocuspocusInternalUrl(); - const internalSecret = process.env.BETTER_AUTH_SECRET?.trim(); - - if (!baseUrl || !internalSecret) { - if (process.env.NODE_ENV === "development") { - console.warn( - `[Snapshots] Skipped Hocuspocus invalidation for page ${pageId}: internal URL or secret is missing.`, - ); - } - return; - } - - const url = `${baseUrl}/internal/documents/${encodeURIComponent(pageId)}/invalidate`; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), HOCUSPOCUS_INVALIDATE_TIMEOUT_MS); - - try { - const response = await fetch(url, { - method: "POST", - headers: { - "x-internal-secret": internalSecret, - }, - signal: controller.signal, - }); - clearTimeout(timeoutId); - - if (!response.ok) { - console.warn( - `[Snapshots] Hocuspocus invalidation HTTP ${response.status} for page ${pageId}`, - ); - } - } catch (error) { - clearTimeout(timeoutId); - const name = error instanceof Error ? error.name : ""; - if (name === "AbortError") { - console.warn(`[Snapshots] Hocuspocus invalidation timed out for page ${pageId}`); - return; - } - console.warn(`[Snapshots] Hocuspocus invalidation failed for page ${pageId}:`, error); - } -} // ── GET /:id/snapshots ────────────────────────────────────────────────────── app.get("/:id/snapshots", authRequired, async (c) => { @@ -294,7 +234,7 @@ app.post("/:id/snapshots/:snapshotId/restore", authRequired, async (c) => { }; }); - await invalidateHocuspocusDocument(pageId); + await invalidateHocuspocusDocument(pageId, { logPrefix: "[Snapshots]" }); return c.json({ version: result.version, diff --git a/server/api/src/routes/pages.ts b/server/api/src/routes/pages.ts index 6ab73316..62488114 100644 --- a/server/api/src/routes/pages.ts +++ b/server/api/src/routes/pages.ts @@ -17,6 +17,7 @@ import { authRequired } from "../middleware/auth.js"; import type { AppEnv, Database } from "../types/index.js"; import { maybeCreateSnapshot } from "../services/snapshotService.js"; import { assertPageViewAccess, assertPageEditAccess } from "../services/pageAccessService.js"; +import { propagateTitleRename } from "../services/titleRenamePropagationService.js"; /** * ベストエフォートで自動スナップショットを作成する。失敗してもメイン処理には影響しない。 @@ -39,21 +40,77 @@ async function tryAutoSnapshot( const app = new Hono(); +/** + * タイトル変更を検出した際に WikiLink / タグを他ページへ伝播させる + * (issue #726)。リネーム本体のレスポンスはブロックしないよう fire-and-forget + * で呼び出す。失敗時はログのみ。 + * + * Fire-and-forget propagation of a title rename into referencing documents + * and ghost-link promotion (issue #726). The caller is not blocked; failures + * are logged but do not affect the main response. + */ +function tryPropagateTitleRename( + db: Database, + pageId: string, + oldTitle: string, + newTitle: string, +): void { + void propagateTitleRename(db, pageId, oldTitle, newTitle).catch((error) => { + console.error( + `[RenamePropagation] Background propagation crashed for ${pageId} ` + + `(${oldTitle} → ${newTitle}):`, + error, + ); + }); +} + /** * PUT /content リクエストから pages テーブルの更新セットを構築し、変更があれば適用する。 - * Build and apply pages-table updates (title, content_preview, updated_at) from PUT body. + * タイトル更新を検出した場合は旧タイトルを返して呼び出し側から伝播処理を + * 起動できるようにする(issue #726)。 + * + * Build and apply pages-table updates (title, content_preview, updated_at) + * from the PUT body. When the title is being changed, return the old / new + * title pair so the caller can kick off rename propagation once the row + * update is durable (issue #726). */ async function applyPagesMetadataUpdate( - db: { update: Database["update"] }, + db: { select: Database["select"]; update: Database["update"] }, pageId: string, body: { title?: string; content_preview?: string }, -): Promise { +): Promise<{ renamed: { oldTitle: string; newTitle: string } | null }> { + let renamed: { oldTitle: string; newTitle: string } | null = null; + + if (body.title !== undefined) { + const current = await db + .select({ title: pages.title }) + .from(pages) + .where(eq(pages.id, pageId)) + .limit(1); + const previousRaw = current[0]?.title ?? null; + const previousTrimmed = typeof previousRaw === "string" ? previousRaw.trim() : ""; + const nextTrimmed = body.title.trim(); + // 正規化(小文字化)して比較することで "Foo" → "foo" のような表記揺れだけの + // 変更は伝播をスキップする。`wikiLinkUtils` / `tagUtils` の照合も同一正規化。 + // Normalize for comparison so "Foo" → "foo" — a change that wouldn't + // affect matching — does not trigger propagation. Mirrors the client-side + // `wikiLinkUtils` / `tagUtils` normalization. + if ( + previousTrimmed.length > 0 && + nextTrimmed.length > 0 && + previousTrimmed.toLowerCase() !== nextTrimmed.toLowerCase() + ) { + renamed = { oldTitle: previousTrimmed, newTitle: nextTrimmed }; + } + } + const set: Record = {}; if (body.title !== undefined) set.title = body.title; if (body.content_preview !== undefined) set.contentPreview = body.content_preview; - if (Object.keys(set).length === 0) return; + if (Object.keys(set).length === 0) return { renamed }; set.updatedAt = new Date(); await db.update(pages).set(set).where(eq(pages.id, pageId)); + return { renamed }; } // ── GET /pages ────────────────────────────────────────────────────────────── @@ -247,9 +304,9 @@ app.put("/:id/content", authRequired, async (c) => { const insertedRow = inserted[0]; if (!insertedRow) throw new HTTPException(500, { message: "Insert failed" }); - await applyPagesMetadataUpdate(tx, pageId, body); + const { renamed } = await applyPagesMetadataUpdate(tx, pageId, body); - return { done: true as const, version: insertedRow.version ?? 1 }; + return { done: true as const, version: insertedRow.version ?? 1, renamed }; }); if (firstSave.done) { @@ -261,6 +318,14 @@ app.put("/:id/content", authRequired, async (c) => { firstSave.version, userId, ); + if (firstSave.renamed) { + tryPropagateTitleRename( + db, + pageId, + firstSave.renamed.oldTitle, + firstSave.renamed.newTitle, + ); + } return c.json({ version: firstSave.version }); } } @@ -293,7 +358,7 @@ app.put("/:id/content", authRequired, async (c) => { const updatedRow = updated[0]; if (!updatedRow) throw new HTTPException(500, { message: "Update failed" }); - await applyPagesMetadataUpdate(db, pageId, body); + const { renamed } = await applyPagesMetadataUpdate(db, pageId, body); void tryAutoSnapshot( db, @@ -304,6 +369,10 @@ app.put("/:id/content", authRequired, async (c) => { userId, ); + if (renamed) { + tryPropagateTitleRename(db, pageId, renamed.oldTitle, renamed.newTitle); + } + return c.json({ version: updatedRow.version ?? 0 }); } @@ -327,7 +396,7 @@ app.put("/:id/content", authRequired, async (c) => { }) .returning(); - await applyPagesMetadataUpdate(db, pageId, body); + const { renamed } = await applyPagesMetadataUpdate(db, pageId, body); const resultRow = result[0]; if (!resultRow) throw new HTTPException(500, { message: "Upsert failed" }); @@ -341,6 +410,10 @@ app.put("/:id/content", authRequired, async (c) => { userId, ); + if (renamed) { + tryPropagateTitleRename(db, pageId, renamed.oldTitle, renamed.newTitle); + } + return c.json({ version: resultRow.version }); }); diff --git a/server/api/src/routes/syncPages.ts b/server/api/src/routes/syncPages.ts index b7027579..51a3e6c2 100644 --- a/server/api/src/routes/syncPages.ts +++ b/server/api/src/routes/syncPages.ts @@ -7,10 +7,28 @@ import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; import { eq, and, gt, inArray, isNull } from "drizzle-orm"; -import { pages, links, ghostLinks } from "../schema/index.js"; +import { pages, links, ghostLinks, LINK_TYPES, type LinkType } from "../schema/index.js"; import { authRequired } from "../middleware/auth.js"; import type { AppEnv } from "../types/index.js"; +/** + * `body.links` / `body.ghost_links` で受け取った `link_type` を正規化する。 + * 未指定は `'wiki'`(issue #725 マイグレーション前の既定値)にフォールバック。 + * 許可されない値は 400 で拒否する。 + * + * Normalize the `link_type` received on wire. Omitted fields fall back to + * `'wiki'` for legacy-client compatibility; unknown values raise HTTP 400. + */ +function normalizeLinkType(value: unknown): LinkType { + if (value === undefined || value === null) return "wiki"; + if (typeof value === "string" && (LINK_TYPES as readonly string[]).includes(value)) { + return value as LinkType; + } + throw new HTTPException(400, { + message: `Invalid link_type: ${JSON.stringify(value)}. Expected one of ${LINK_TYPES.join(", ")}.`, + }); +} + const app = new Hono(); // ── GET /sync/pages ───────────────────────────────────────────────────────── @@ -73,11 +91,15 @@ app.get("/", authRequired, async (c) => { links: linksRows.map((l) => ({ source_id: l.sourceId, target_id: l.targetId, + // `link_type` は issue #725 で導入。未マイグレ行は DB 側の既定で 'wiki'。 + // Added by issue #725; legacy rows are backfilled to 'wiki' by the DB default. + link_type: l.linkType, created_at: l.createdAt.toISOString(), })), ghost_links: ghostLinksRows.map((g) => ({ link_text: g.linkText, source_page_id: g.sourcePageId, + link_type: g.linkType, created_at: g.createdAt.toISOString(), original_target_page_id: g.originalTargetPageId, original_note_id: g.originalNoteId, @@ -105,10 +127,19 @@ app.post("/", authRequired, async (c) => { links?: Array<{ source_id: string; target_id: string; + /** + * `link_type` は WikiLink (`'wiki'`) とタグ (`'tag'`) を区別する。 + * 省略時は `'wiki'` として扱う(issue #725 導入前の既定値)。 + * `link_type` distinguishes WikiLink (`'wiki'`) from Tag (`'tag'`); + * omitted → `'wiki'` for legacy client compatibility (issue #725). + */ + link_type?: string; }>; ghost_links?: Array<{ link_text: string; source_page_id: string; + /** 同上 / Same contract as above. */ + link_type?: string; original_target_page_id?: string; original_note_id?: string; }>; @@ -118,6 +149,17 @@ app.post("/", authRequired, async (c) => { throw new HTTPException(400, { message: "pages array is required" }); } + // `link_type` を先行バリデーションして、DB 書き込み前に不正値を弾く。 + // Validate `link_type` up front so bad input never reaches DB writes. + const incomingLinks = (body.links ?? []).map((l) => ({ + ...l, + link_type: normalizeLinkType(l.link_type), + })); + const incomingGhostLinks = (body.ghost_links ?? []).map((g) => ({ + ...g, + link_type: normalizeLinkType(g.link_type), + })); + const results: Array<{ id: string; action: string }> = []; // ページごとに LWW (Last Write Wins) 同期。 @@ -237,18 +279,37 @@ app.post("/", authRequired, async (c) => { // リンク同期 — 個人ページ間のみ // Link sync — personal pages only - if (body.links?.length) { - const sourceIds = [...new Set(body.links.map((l) => l.source_id))]; + // + // issue #725: DELETE は `(source_id, link_type)` ペア単位でスコープする。 + // これにより、タグ同期時に WikiLink エッジを、あるいはその逆を巻き添え削除 + // しない。body に現れなかった `link_type` のエッジには触れない(従来の + // 「push に現れない source_id は触らない」セマンティクスを link_type 方向 + // にも拡張した形)。 + // + // issue #725: DELETE is scoped per `(source_id, link_type)` pair so that + // tag sync cannot wipe wiki edges (and vice versa). A `link_type` that does + // not appear in the push is left untouched, extending the existing + // "missing source_id → no delete" semantics along the link_type axis. + if (incomingLinks.length > 0) { + const sourceIds = [...new Set(incomingLinks.map((l) => l.source_id))]; const ownedPages = await db .select({ id: pages.id }) .from(pages) .where(and(eq(pages.ownerId, userId), isNull(pages.noteId), inArray(pages.id, sourceIds))); const ownedIds = new Set(ownedPages.map((r) => r.id)); - for (const sourceId of sourceIds) { - if (!ownedIds.has(sourceId)) continue; - await db.delete(links).where(eq(links.sourceId, sourceId)); + + const deletePairs = new Set(); + for (const link of incomingLinks) { + if (!ownedIds.has(link.source_id)) continue; + const key = `${link.source_id}${link.link_type}`; + if (deletePairs.has(key)) continue; + deletePairs.add(key); + await db + .delete(links) + .where(and(eq(links.sourceId, link.source_id), eq(links.linkType, link.link_type))); } - for (const link of body.links) { + + for (const link of incomingLinks) { if (link.source_id === link.target_id) continue; // self-ref skip if (!ownedIds.has(link.source_id)) continue; // IDOR protection await db @@ -256,31 +317,46 @@ app.post("/", authRequired, async (c) => { .values({ sourceId: link.source_id, targetId: link.target_id, + linkType: link.link_type, }) .onConflictDoNothing(); } } // ゴーストリンク同期 — 個人ページ間のみ - // Ghost link sync — personal pages only - if (body.ghost_links?.length) { - const sourceIds = [...new Set(body.ghost_links.map((g) => g.source_page_id))]; + // Ghost link sync — personal pages only (同じ link_type スコープ化方針) + if (incomingGhostLinks.length > 0) { + const sourceIds = [...new Set(incomingGhostLinks.map((g) => g.source_page_id))]; const ownedGhostPages = await db .select({ id: pages.id }) .from(pages) .where(and(eq(pages.ownerId, userId), isNull(pages.noteId), inArray(pages.id, sourceIds))); const ownedGhostIds = new Set(ownedGhostPages.map((r) => r.id)); - for (const sourceId of sourceIds) { - if (!ownedGhostIds.has(sourceId)) continue; - await db.delete(ghostLinks).where(eq(ghostLinks.sourcePageId, sourceId)); + + const deletePairs = new Set(); + for (const gl of incomingGhostLinks) { + if (!ownedGhostIds.has(gl.source_page_id)) continue; + const key = `${gl.source_page_id}${gl.link_type}`; + if (deletePairs.has(key)) continue; + deletePairs.add(key); + await db + .delete(ghostLinks) + .where( + and( + eq(ghostLinks.sourcePageId, gl.source_page_id), + eq(ghostLinks.linkType, gl.link_type), + ), + ); } - for (const gl of body.ghost_links) { + + for (const gl of incomingGhostLinks) { if (!ownedGhostIds.has(gl.source_page_id)) continue; // IDOR protection await db .insert(ghostLinks) .values({ linkText: gl.link_text, sourcePageId: gl.source_page_id, + linkType: gl.link_type, originalTargetPageId: gl.original_target_page_id ?? null, originalNoteId: gl.original_note_id ?? null, }) diff --git a/server/api/src/schema/index.ts b/server/api/src/schema/index.ts index 2d27d9e2..c6fa075e 100644 --- a/server/api/src/schema/index.ts +++ b/server/api/src/schema/index.ts @@ -40,10 +40,12 @@ export { export { links, ghostLinks, + LINK_TYPES, type Link, type NewLink, type GhostLink, type NewGhostLink, + type LinkType, } from "./links.js"; export { pageContents, type PageContent, type NewPageContent } from "./pageContents.js"; export { pageSnapshots, type PageSnapshot, type NewPageSnapshot } from "./pageSnapshots.js"; diff --git a/server/api/src/schema/pages.ts b/server/api/src/schema/pages.ts index 774713e1..77104e61 100644 --- a/server/api/src/schema/pages.ts +++ b/server/api/src/schema/pages.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, text, timestamp, boolean, index } from "drizzle-orm/pg-core"; +import { pgTable, uuid, text, timestamp, boolean, index, uniqueIndex } from "drizzle-orm/pg-core"; import { sql } from "drizzle-orm"; import { users } from "./users.js"; import { notes } from "./notes.js"; @@ -98,6 +98,20 @@ export const pages = pgTable( * 部分述語に効くインデックス。 */ index("idx_pages_note_id").on(table.noteId), + /** + * オーナーごとに有効なウェルカムページは最大 1 件であることを担保する部分 + * ユニーク index。`welcomePageService.insertWelcomePage` の `onConflictDoNothing` + * が target としてこの index に依拠している。実 DDL は + * `drizzle/0018_add_onboarding_and_page_kind.sql` を参照。 + * + * Partial unique index that enforces "at most one live welcome page per + * owner". The `onConflictDoNothing` call in + * `welcomePageService.insertWelcomePage` targets this exact index. The + * actual DDL lives in `drizzle/0018_add_onboarding_and_page_kind.sql`. + */ + uniqueIndex("idx_pages_unique_welcome_per_owner") + .on(table.ownerId) + .where(sql`${table.kind} = 'welcome' AND ${table.isDeleted} = false`), ], ); diff --git a/server/api/src/services/lintEngine/rules/brokenLink.test.ts b/server/api/src/services/lintEngine/rules/brokenLink.test.ts new file mode 100644 index 00000000..66d3db75 --- /dev/null +++ b/server/api/src/services/lintEngine/rules/brokenLink.test.ts @@ -0,0 +1,64 @@ +/** + * brokenLink ルールのテスト(モック DB から返した行を finding にマッピングできるか)。 + * Tests for the broken-link rule's row → finding mapping using a mocked db. + */ +import { describe, it, expect } from "vitest"; +import { runBrokenLinkRule } from "./brokenLink.js"; +import { createMockDb } from "../../../__tests__/createMockDb.js"; +import type { Database } from "../../../types/index.js"; + +function asDb(results: unknown[]) { + const { db, chains } = createMockDb(results); + return { db: db as unknown as Database, chains }; +} + +describe("runBrokenLinkRule", () => { + it("returns rule='broken_link' with no findings when no broken rows are returned", async () => { + const { db } = asDb([[]]); + const result = await runBrokenLinkRule("user-1", db); + + expect(result.rule).toBe("broken_link"); + expect(result.findings).toEqual([]); + }); + + it("maps each broken row into a finding with severity=error and both page IDs", async () => { + const { db } = asDb([ + [ + { sourceId: "src-1", targetId: "tgt-1", sourceTitle: "Important Page" }, + { sourceId: "src-2", targetId: "tgt-2", sourceTitle: null }, + ], + ]); + + const result = await runBrokenLinkRule("user-1", db); + + expect(result.findings).toHaveLength(2); + expect(result.findings[0]).toEqual({ + rule: "broken_link", + severity: "error", + pageIds: ["src-1", "tgt-1"], + detail: { + sourceTitle: "Important Page", + sourceId: "src-1", + targetId: "tgt-1", + suggestion: expect.stringMatching(/link target has been deleted/i), + }, + }); + + // 無題ページのフォールバックタイトル。 + // null titles fall back to the bilingual "(無題 / untitled)" placeholder. + expect(result.findings[1]?.detail.sourceTitle).toBe("(無題 / untitled)"); + }); + + it("scopes the query to ownerId via Drizzle (chain inspection)", async () => { + const { db, chains } = asDb([[]]); + await runBrokenLinkRule("user-x", db); + + // SELECT が 1 本だけ走り、必ず .where(...) を伴っていること(owner フィルタが + // うっかり外れていないことを最低限保証する)。 + // Exactly one SELECT chain runs, and it always carries a .where(...) clause — + // this is the floor that catches an accidentally-removed owner filter. + expect(chains).toHaveLength(1); + expect(chains[0]?.startMethod).toBe("select"); + expect(chains[0]?.ops.some((op) => op.method === "where")).toBe(true); + }); +}); diff --git a/server/api/src/services/lintEngine/rules/ghostMany.test.ts b/server/api/src/services/lintEngine/rules/ghostMany.test.ts new file mode 100644 index 00000000..3e1da342 --- /dev/null +++ b/server/api/src/services/lintEngine/rules/ghostMany.test.ts @@ -0,0 +1,60 @@ +/** + * ghostMany ルールのテスト(同 link_text が複数ページから参照される行を finding に変換)。 + * Tests for the ghost-many rule's row → finding mapping. + */ +import { describe, it, expect } from "vitest"; +import { runGhostManyRule } from "./ghostMany.js"; +import { createMockDb } from "../../../__tests__/createMockDb.js"; +import type { Database } from "../../../types/index.js"; + +function asDb(results: unknown[]) { + const { db, chains } = createMockDb(results); + return { db: db as unknown as Database, chains }; +} + +describe("runGhostManyRule", () => { + it("returns rule='ghost_many' with no findings when no rows pass the threshold", async () => { + const { db } = asDb([[]]); + const result = await runGhostManyRule("user-1", db); + + expect(result.rule).toBe("ghost_many"); + expect(result.findings).toEqual([]); + }); + + it("maps each row to a finding with severity=warn and the source page IDs", async () => { + const { db } = asDb([ + [ + { linkText: "TODO", count: 5, sourcePageIds: ["p-1", "p-2", "p-3"] }, + { linkText: "Roadmap", count: 3, sourcePageIds: ["p-4", "p-5", "p-6"] }, + ], + ]); + + const result = await runGhostManyRule("user-1", db); + + expect(result.findings).toHaveLength(2); + const todo = result.findings[0]; + expect(todo).toMatchObject({ + rule: "ghost_many", + severity: "warn", + pageIds: ["p-1", "p-2", "p-3"], + }); + expect(todo?.detail.linkText).toBe("TODO"); + expect(todo?.detail.count).toBe(5); + expect(String(todo?.detail.suggestion)).toContain("TODO"); + + const roadmap = result.findings[1]; + expect(roadmap?.pageIds).toEqual(["p-4", "p-5", "p-6"]); + expect(roadmap?.detail.count).toBe(3); + }); + + it("starts exactly one select chain with where + having (HAVING shares the chain)", async () => { + const { db, chains } = asDb([[]]); + await runGhostManyRule("user-x", db); + expect(chains).toHaveLength(1); + expect(chains[0]?.startMethod).toBe("select"); + // owner フィルタと閾値ハービング両方が外れていないことの最低限の保険。 + // Floor check that neither the owner filter nor the count threshold is silently dropped. + expect(chains[0]?.ops.some((op) => op.method === "where")).toBe(true); + expect(chains[0]?.ops.some((op) => op.method === "having")).toBe(true); + }); +}); diff --git a/server/api/src/services/lintEngine/rules/orphan.test.ts b/server/api/src/services/lintEngine/rules/orphan.test.ts new file mode 100644 index 00000000..91cd768a --- /dev/null +++ b/server/api/src/services/lintEngine/rules/orphan.test.ts @@ -0,0 +1,56 @@ +/** + * orphan ルールのテスト(被リンクなしページを finding にマッピング)。 + * Tests for the orphan rule's row → finding mapping. + */ +import { describe, it, expect } from "vitest"; +import { runOrphanRule } from "./orphan.js"; +import { createMockDb } from "../../../__tests__/createMockDb.js"; +import type { Database } from "../../../types/index.js"; + +function asDb(results: unknown[]) { + const { db, chains } = createMockDb(results); + return { db: db as unknown as Database, chains }; +} + +describe("runOrphanRule", () => { + it("returns rule='orphan' with no findings when no orphan rows are returned", async () => { + const { db } = asDb([[]]); + const result = await runOrphanRule("user-1", db); + + expect(result.rule).toBe("orphan"); + expect(result.findings).toEqual([]); + }); + + it("maps each orphan page into a single-page finding with severity=info", async () => { + const { db } = asDb([ + [ + { id: "p-1", title: "Solo Page" }, + { id: "p-2", title: null }, + ], + ]); + + const result = await runOrphanRule("user-1", db); + + expect(result.findings).toHaveLength(2); + expect(result.findings[0]).toEqual({ + rule: "orphan", + severity: "info", + pageIds: ["p-1"], + detail: { title: "Solo Page" }, + }); + // 無題ページのフォールバック。 + // null titles fall back to the bilingual placeholder. + expect(result.findings[1]?.detail.title).toBe("(無題 / untitled)"); + expect(result.findings[1]?.pageIds).toEqual(["p-2"]); + }); + + it("starts exactly one select chain with a where clause", async () => { + const { db, chains } = asDb([[]]); + await runOrphanRule("user-x", db); + expect(chains).toHaveLength(1); + expect(chains[0]?.startMethod).toBe("select"); + // owner フィルタが外れていないことの最低限の保険。 + // Floor check that the owner filter has not been silently dropped. + expect(chains[0]?.ops.some((op) => op.method === "where")).toBe(true); + }); +}); diff --git a/server/api/src/services/titleRenamePropagationService.ts b/server/api/src/services/titleRenamePropagationService.ts new file mode 100644 index 00000000..ef2c4314 --- /dev/null +++ b/server/api/src/services/titleRenamePropagationService.ts @@ -0,0 +1,384 @@ +/** + * ページタイトルのリネームを、参照元ドキュメントとゴーストリンクへ伝播する + * サービス (issue #726 Phase 2)。 + * + * Propagate a page-title rename into (1) WikiLink / tag marks inside every + * source document that links to the renamed page and (2) ghost_links whose + * text now matches the new title (promotion). Issue #726 Phase 2. + * + * スコープ / Scope: + * - 実体 → 実体 の書き換え: `links` で `target_id = renamedPageId` のソース + * ページを全走査し、Y.Doc 内の対象マークのテキストと属性を書き換える。 + * 手動編集されたテキスト(属性と一致しない区間)はテキストを残し、属性 + * だけ更新する。`ydocRenameRewrite.ts` を参照。 + * - ゴースト → 実体 の昇格: `ghost_links.link_text` が newTitle に一致する + * 行を `links` に挿入し、ゴースト行を削除する。自己参照(同一ページ + * 内の行)は DB CHECK で拒否されるためスキップする。 + * - 伝播後、`Hocuspocus` のライブドキュメントを破棄して次回クライアント + * 接続で DB から再読込させる(ベストエフォート、失敗はログのみ)。 + * + * - Real → real: find every source page via `links.target_id = renamedPageId`, + * open its Y.Doc, and rewrite the matching wiki-link / + * tag marks. Manually-edited link text (segments that no longer match + * the mark title) keeps its text; only the attribute is refreshed. See + * `ydocRenameRewrite.ts`. + * - Ghost → real (promotion): move `ghost_links` rows whose normalized + * `link_text` matches the new title into `links`. Self-references are + * rejected by a DB CHECK constraint so we filter them out up front. + * - After rewriting each source page, ask Hocuspocus to drop its cached + * Y.Doc so next clients reload from DB (best-effort; failures logged). + * + * 非スコープ / Out of scope: + * - 永続的な非同期ジョブキュー(本実装は呼び出し元が `void` で捨てる + * fire-and-forget を想定)。リトライ戦略は呼び出し側 TODO。 + * - 実体 → ゴーストへの降格(削除由来のため issue #726 では扱わない)。 + * - ノート・テナント境界フィルタ(`links` 自体に到達できる行は既存認可 + * 経路で担保されている前提)。 + * + * - Durable retry queue (callers should fire-and-forget with `.catch`; + * persistent retries live in a follow-up ticket). + * - Real → ghost demotion on deletion (separate issue). + * - Tenant-scoped filtering — `links` rows are authorized upstream. + */ + +import * as Y from "yjs"; +import { and, eq, sql, ne, inArray, isNull } from "drizzle-orm"; +import { links, ghostLinks, pageContents, pages } from "../schema/index.js"; +import type { Database } from "../types/index.js"; +import { rewriteTitleRefsInDoc, type RewriteResult } from "./ydocRenameRewrite.js"; +import { invalidateHocuspocusDocument } from "../lib/hocuspocusInvalidation.js"; +import { buildContentPreview, extractTextFromYXml } from "../lib/extractPlainTextFromYXml.js"; + +/** + * `propagateTitleRename` の結果カウンタ。ログ出力・監視用。 + * Counters returned by `propagateTitleRename` for logging and observability. + */ +export interface TitleRenamePropagationResult extends RewriteResult { + /** Number of source pages scanned (had an outgoing link to the renamed page). */ + sourcePagesAttempted: number; + /** Source pages whose transaction completed without throwing. */ + sourcePagesSucceeded: number; + /** Source pages whose rewrite transaction threw (best-effort: error is logged). */ + sourcePagesFailed: number; + /** Ghost-link rows promoted to real `links` rows because their text now matches. */ + ghostPromotionsCount: number; +} + +/** + * `propagateTitleRename` のオプション。テスト用に Hocuspocus 無効化の + * 呼び出しを差し替え可能にする。 + * + * Options for `propagateTitleRename`. Allows tests to inject a stub in + * place of the real Hocuspocus invalidation HTTP call. + */ +export interface PropagateTitleRenameOptions { + /** + * Override the Hocuspocus invalidation call. Defaults to the real HTTP + * helper; tests inject a stub. 既定値はテスト時に差し替え可能。 + */ + invalidateDocument?: (pageId: string) => Promise; +} + +function normalizeTitle(value: string): string { + return value.toLowerCase().trim(); +} + +function emptyResult(): TitleRenamePropagationResult { + return { + wikiLinkMarksUpdated: 0, + wikiLinkTextUpdated: 0, + tagMarksUpdated: 0, + tagTextUpdated: 0, + sourcePagesAttempted: 0, + sourcePagesSucceeded: 0, + sourcePagesFailed: 0, + ghostPromotionsCount: 0, + }; +} + +function toBuffer(ydocState: unknown): Buffer | null { + if (ydocState instanceof Buffer) return ydocState; + if (ydocState instanceof Uint8Array) return Buffer.from(ydocState); + if (typeof ydocState === "string") { + // 万一 DB 層が base64 文字列で返してきた場合のフォールバック。 + // Defensive path in case a driver hands back a base64 string. + return Buffer.from(ydocState, "base64"); + } + return null; +} + +/** + * 1 つのソースページについて Y.Doc を読み込んで書き換え、変更があれば + * 楽観バージョンを +1 して書き戻す。 + * + * Rewrite a single source page's Y.Doc in a serialized transaction. Returns + * `{ changed: false }` when either the page has no `page_contents` row or + * the rewriter produced zero changes — callers should skip the Hocuspocus + * invalidation in that case. + */ +async function rewriteSourcePage( + db: Database, + sourcePageId: string, + renamedPageId: string, + oldTitle: string, + newTitle: string, +): Promise<{ changed: boolean; rewrite: RewriteResult }> { + const zeroRewrite: RewriteResult = { + wikiLinkMarksUpdated: 0, + wikiLinkTextUpdated: 0, + tagMarksUpdated: 0, + tagTextUpdated: 0, + }; + + return db.transaction(async (tx) => { + // 行ロックを取って Hocuspocus の並行書き込みと直列化する + // (snapshot restore と同じパターン)。 + // Serialize with Hocuspocus' concurrent `onStoreDocument` writes by + // grabbing the same row lock the snapshot-restore path uses. + await tx.execute(sql`SELECT 1 FROM page_contents WHERE page_id = ${sourcePageId} FOR UPDATE`); + + const current = await tx + .select() + .from(pageContents) + .where(eq(pageContents.pageId, sourcePageId)) + .limit(1); + + const row = current[0]; + if (!row) { + return { changed: false, rewrite: zeroRewrite }; + } + + const buffer = toBuffer(row.ydocState); + if (!buffer) { + return { changed: false, rewrite: zeroRewrite }; + } + + const doc = new Y.Doc(); + Y.applyUpdate(doc, new Uint8Array(buffer)); + // `renamedPageId` を渡すことで `targetId` 属性付きマークは ID 一致のみで + // 書き換える(issue #737)。`targetId` が無い旧マークはタイトル一致で + // フォールバック書き換えされる。 + // Pass `renamedPageId` so marks carrying a `targetId` are rewritten only + // on id match (issue #737); legacy marks without `targetId` continue to + // use the title-only fallback (lazy migration). + const rewrite = rewriteTitleRefsInDoc(doc, oldTitle, newTitle, { renamedPageId }); + const hasChanges = rewrite.wikiLinkMarksUpdated > 0 || rewrite.tagMarksUpdated > 0; + if (!hasChanges) { + return { changed: false, rewrite }; + } + + const encodedState = Buffer.from(Y.encodeStateAsUpdate(doc)); + // リネーム後の Y.Doc からプレーンテキストとプレビューを取り直し、 + // `page_contents.content_text` / `pages.content_preview` が古い + // タイトルのまま取り残されないようにする(PR #736 レビュー参照)。 + // Derive the new plain text / preview from the rewritten Y.Doc and + // persist them atomically with `ydoc_state` so search, listing, and + // snapshot metadata stay consistent. See PR #736 review. + const newContentText = extractTextFromYXml(doc.getXmlFragment("default")); + const newContentPreview = buildContentPreview(newContentText); + await tx + .update(pageContents) + .set({ + ydocState: encodedState, + version: sql`${pageContents.version} + 1`, + contentText: newContentText, + updatedAt: new Date(), + }) + .where(eq(pageContents.pageId, sourcePageId)); + + await tx + .update(pages) + .set({ contentPreview: newContentPreview, updatedAt: new Date() }) + .where(eq(pages.id, sourcePageId)); + + return { changed: true, rewrite }; + }); +} + +/** + * 新タイトルと一致するゴーストリンクを、リネーム対象と同一スコープ内でのみ + * 実体リンクへ昇格させる。スコープはリネーム対象の `pages.note_id` と + * `pages.owner_id` から決定する: + * + * - リネーム対象がノートネイティブ (`note_id` 非 NULL): ソースの `note_id` + * が同一の場合のみ昇格。 + * - リネーム対象が個人ページ (`note_id` NULL): ソースも個人 (`note_id` NULL) + * かつオーナーが同一の場合のみ昇格。 + * + * これにより、別テナント/別ノートで同一テキストを持つ `ghost_links` が + * 誤って消費されることを防ぐ(PR #736 P1 レビュー参照)。 + * + * Promote ghost-link rows matching the new title — but only within the + * renamed page's ownership / note scope. Without this filter, a rename in + * one tenant would silently consume unrelated ghost rows elsewhere that + * happen to share the same text, creating cross-tenant link edges + * (reviewed as P1 on PR #736). + * + * - Note-native target (`note_id != null`): only promote ghosts whose + * source page is in the same `note_id`. + * - Personal target (`note_id = null`): only promote ghosts whose source + * is also personal (`note_id = null`) and has the same `owner_id`. + */ +async function promoteGhostLinks( + db: Database, + renamedPageId: string, + newTitle: string, +): Promise { + return db.transaction(async (tx) => { + // 1. Resolve the scope of the renamed page. Missing row → nothing to do. + // リネーム対象のスコープを解決。行が無ければ何もしない。 + const scopeRows = await tx + .select({ noteId: pages.noteId, ownerId: pages.ownerId }) + .from(pages) + .where(eq(pages.id, renamedPageId)) + .limit(1); + const scope = scopeRows[0]; + if (!scope) return 0; + + // 2. 同一スコープかつテキスト一致のゴースト行を列挙する。 + // Find in-scope ghost rows whose text matches the new title. + const scopePredicate = + scope.noteId !== null + ? eq(pages.noteId, scope.noteId) + : and(isNull(pages.noteId), eq(pages.ownerId, scope.ownerId)); + + const candidates = await tx + .select({ + sourcePageId: ghostLinks.sourcePageId, + linkType: ghostLinks.linkType, + }) + .from(ghostLinks) + .innerJoin(pages, eq(pages.id, ghostLinks.sourcePageId)) + .where( + and( + sql`LOWER(TRIM(${ghostLinks.linkText})) = LOWER(TRIM(${newTitle}))`, + ne(ghostLinks.sourcePageId, renamedPageId), + scopePredicate, + ), + ); + + if (candidates.length === 0) return 0; + + // 3. Delete the matching in-scope ghost rows and insert real links. + // 同一スコープのゴースト行だけ削除し、本物のリンクを挿入する。 + const scopedSourceIds = Array.from(new Set(candidates.map((c) => c.sourcePageId))); + await tx + .delete(ghostLinks) + .where( + and( + sql`LOWER(TRIM(${ghostLinks.linkText})) = LOWER(TRIM(${newTitle}))`, + inArray(ghostLinks.sourcePageId, scopedSourceIds), + ), + ); + + await tx + .insert(links) + .values( + candidates.map((row) => ({ + sourceId: row.sourcePageId, + targetId: renamedPageId, + linkType: row.linkType, + })), + ) + // 競合(既に同じエッジがある場合)は無視する。 + // Conflicts (an authoritative edge already exists) are harmless. + .onConflictDoNothing(); + + return candidates.length; + }); +} + +/** + * ページ `renamedPageId` のタイトル変更 `oldTitle` → `newTitle` を、参照元 + * ドキュメントおよびゴーストリンクへ伝播する。 + * + * Propagate a rename `oldTitle` → `newTitle` for `renamedPageId` through + * every source page's Y.Doc and through the ghost-link graph. + * + * この関数はベストエフォート動作である: + * - 個々のソースページ書き換えが失敗しても、残りのページとゴースト昇格 + * 処理は続行する。失敗はカウンタとログに残る。 + * - Hocuspocus 無効化の失敗は呼び出し側に伝播させない。 + * + * Best-effort: + * - Per-source rewrite failures are logged and counted in + * `sourcePagesFailed`; they do not abort the rest of the work. + * - Hocuspocus invalidation failures are logged and swallowed. + */ +export async function propagateTitleRename( + db: Database, + renamedPageId: string, + oldTitle: string | null | undefined, + newTitle: string | null | undefined, + options?: PropagateTitleRenameOptions, +): Promise { + const result = emptyResult(); + + const trimmedOld = typeof oldTitle === "string" ? oldTitle.trim() : ""; + const trimmedNew = typeof newTitle === "string" ? newTitle.trim() : ""; + + if (!trimmedOld || !trimmedNew) return result; + if (normalizeTitle(trimmedOld) === normalizeTitle(trimmedNew)) return result; + + const invalidate = + options?.invalidateDocument ?? + ((pageId: string) => + invalidateHocuspocusDocument(pageId, { logPrefix: "[RenamePropagation]" })); + + // 1. Rewrite source pages that have a real link to the renamed page. + // 実体リンク経由でリネーム対象を参照しているページ群を書き換える。 + const sourceRows = await db + .select({ sourceId: links.sourceId }) + .from(links) + .where(eq(links.targetId, renamedPageId)); + + const uniqueSourceIds = Array.from(new Set(sourceRows.map((r) => r.sourceId))); + + for (const sourceId of uniqueSourceIds) { + result.sourcePagesAttempted += 1; + try { + const { changed, rewrite } = await rewriteSourcePage( + db, + sourceId, + renamedPageId, + trimmedOld, + trimmedNew, + ); + result.sourcePagesSucceeded += 1; + result.wikiLinkMarksUpdated += rewrite.wikiLinkMarksUpdated; + result.wikiLinkTextUpdated += rewrite.wikiLinkTextUpdated; + result.tagMarksUpdated += rewrite.tagMarksUpdated; + result.tagTextUpdated += rewrite.tagTextUpdated; + + if (changed) { + try { + await invalidate(sourceId); + } catch (error) { + console.warn( + `[RenamePropagation] Invalidation failed for source page ${sourceId}:`, + error, + ); + } + } + } catch (error) { + result.sourcePagesFailed += 1; + console.error( + `[RenamePropagation] Failed to rewrite source page ${sourceId} ` + + `for rename ${renamedPageId} (${trimmedOld} → ${trimmedNew}):`, + error, + ); + } + } + + // 2. Promote matching ghost links. ベストエフォートで昇格させる。 + try { + result.ghostPromotionsCount = await promoteGhostLinks(db, renamedPageId, trimmedNew); + } catch (error) { + console.error( + `[RenamePropagation] Ghost-link promotion failed for ${renamedPageId} (new title ${trimmedNew}):`, + error, + ); + } + + return result; +} diff --git a/server/api/src/services/ydocRenameRewrite.ts b/server/api/src/services/ydocRenameRewrite.ts new file mode 100644 index 00000000..b63d40fa --- /dev/null +++ b/server/api/src/services/ydocRenameRewrite.ts @@ -0,0 +1,423 @@ +/** + * Y.Doc 上の WikiLink / タグマークのテキストおよび属性を書き換えるピュアな + * ヘルパー。`titleRenamePropagationService.ts` から呼び出される。 + * + * Pure helper that rewrites WikiLink and tag marks inside a Y.Doc when the + * referenced page title changes. Called from `titleRenamePropagationService`. + * + * 設計方針 / Design notes (issue #726, updated for issue #737): + * - 対象: `wikiLink` マーク(`attrs.title` / `attrs.targetId`)と `tag` マーク + * (`attrs.name` / `attrs.targetId`)。 + * - マッチング優先順位: + * 1. マークが `targetId` 属性(UUID 文字列)を持つ場合は **id 一致のみ** で + * 書き換える(`renamedPageId` と一致したときに書き換え)。これにより + * 同名ページが共存していても誤書き換えを防ぐ(issue #737)。 + * 2. `targetId` を持たない(旧データ・未解決状態)場合はタイトル/名前 + * 文字列の一致でフォールバック書き換えを行う(既存挙動 / lazy migration)。 + * - セグメントのテキストは旧タイトル(正規化済み)と一致する場合にのみ書き換える。 + * 一致しない場合は「手動で編集された」扱いでテキストはそのまま、マーク属性だけ + * 更新する。 + * - タグ書き換えは新タイトルがタグ名として有効な文字集合(`tagUtils.ts` の + * `TAG_PASTE_REGEX` に準拠)のときだけ行う。スペースや無効文字を含む + * タイトルへ追従するとタグが壊れるため。 + * + * - Targets `wikiLink` marks (`attrs.title` / `attrs.targetId`) and `tag` + * marks (`attrs.name` / `attrs.targetId`). + * - Match precedence: + * 1. When the mark carries a `targetId` (UUID string), only rewrite when + * `targetId === renamedPageId`. This avoids same-title pages being + * rewritten in lockstep (issue #737). + * 2. Otherwise (no `targetId` — pre-issue-#737 data or unresolved marks), + * fall back to title/name string matching (legacy behaviour, lazy + * migration so older docs still rewrite). + * - Matching is case/whitespace insensitive to line up with `wikiLinkUtils` + * / `tagUtils` client-side normalization. + * - Segment text is replaced only when it matches the old title after + * normalization. Non-matching segments are treated as manual edits — only + * the mark attribute is updated; the text is left alone. + * - Tag rewrites happen only when the new title consists of valid tag + * characters (mirroring `TAG_PASTE_REGEX` in `src/lib/tagUtils.ts`). + * Otherwise the tag would become syntactically invalid after rename. + */ + +import * as Y from "yjs"; + +/** + * 書き換え結果のカウンタ。運用ログ・テスト検証に使う。 + * Counters reporting what the rewrite touched. Used for logs and tests. + */ +export interface RewriteResult { + /** Number of `wikiLink` marks whose `title` attribute was rewritten. */ + wikiLinkMarksUpdated: number; + /** Of the updated `wikiLink` marks, how many also had their text replaced. */ + wikiLinkTextUpdated: number; + /** Number of `tag` marks whose `name` attribute was rewritten. */ + tagMarksUpdated: number; + /** Of the updated `tag` marks, how many also had their text replaced. */ + tagTextUpdated: number; +} + +/** + * タグ名として許容する文字集合(正規表現の文字クラス内側のみ)。クライアント + * 側の `@zedi/shared/tagCharacterClass` (`TAG_NAME_CHAR_CLASS`) と同一文字列で + * なければならない。`server/api` はワークスペース外で独自の `bun.lock` を持つ + * (Railway 単一 build context) ため `@zedi/shared` を直接 import できない。 + * 代わりに `src/lib/tagCharacterClassSync.test.ts` がクライアント側の vitest + * で本ファイルを `fs.readFileSync` し、文字列一致を CI で検証する。本定数を + * 編集する場合は `packages/shared/src/tagCharacterClass.ts` も同時に更新する + * こと。 + * + * Allowed characters for a tag name (inner contents of a regex character + * class). MUST stay byte-equal to `TAG_NAME_CHAR_CLASS` in + * `@zedi/shared/tagCharacterClass`. `server/api` is intentionally outside the + * Bun workspace (its own `bun.lock` is the Railway build context), so it + * cannot import `@zedi/shared` directly. The client-side vitest file + * `src/lib/tagCharacterClassSync.test.ts` reads this file via + * `fs.readFileSync` and asserts both literals match in CI. When editing this + * constant, update `packages/shared/src/tagCharacterClass.ts` in lockstep. + */ +export const TAG_NAME_CHAR_CLASS_STRING = "A-Za-z0-9_\\-぀-ヿ㐀-鿿"; + +// ReDoS 安全 / ReDoS-safe: +// `TAG_NAME_CHAR_CLASS_STRING` はハードコードされた定数(外部入力ではない)。 +// パターンは `^[...]+$` のみで、ネストした量指定子・代替も無いため +// 入力長に対して線形時間。静的解析ツールが警告を出した場合は誤検知。 +// `TAG_NAME_CHAR_CLASS_STRING` is a hardcoded constant (never user input). +// The pattern is a single anchored character class with one quantifier and +// no nested quantifiers / alternations, so matching is linear in input +// length. Static-analysis ReDoS warnings on this regex are false positives. +const VALID_TAG_NAME_REGEX = new RegExp(`^[${TAG_NAME_CHAR_CLASS_STRING}]+$`); + +function normalizeTitle(value: string): string { + return value.toLowerCase().trim(); +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +interface DeltaSegment { + insert: string; + attributes?: Record; +} + +interface PendingEdit { + /** Start position within the Y.XmlText. / Y.XmlText 内の開始位置。 */ + index: number; + /** Length of the segment being replaced. / 置換対象セグメントの長さ。 */ + length: number; + /** Text to insert. When unchanged we reinsert the original text. / 挿入するテキスト。未変更なら元のテキストを再挿入する。 */ + text: string; + /** Attribute set applied to the re-inserted text. / 再挿入テキストに適用する属性セット。 */ + attributes: Record; +} + +function extractStringAttr(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +interface SegmentPlan { + wikiMatches: boolean; + tagMatches: boolean; + wikiLinkMark: Record | null; + tagMark: Record | null; +} + +/** + * `targetId` 属性が UUID 文字列として有効か判定するヘルパー。空文字や非文字列、 + * 純粋な空白は「id 無し」として扱い、タイトル一致 fallback の対象にする。 + * + * Decide whether `targetId` is a usable UUID string. Empty / non-string / + * whitespace-only values fall back to the legacy title-matching path so + * pre-issue-#737 marks still propagate. + */ +function isUsableTargetId(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +/** + * 旧データ/未解決マークかを判定する。`targetId` を持たない場合のみ + * タイトル一致 fallback を許可する(issue #737 lazy migration)。 + * + * Detect a legacy / unresolved mark. Only marks without a usable `targetId` + * are allowed to match by title (issue #737 lazy migration). + */ +function shouldUseTitleFallback(mark: Record): boolean { + return !isUsableTargetId(mark.targetId); +} + +function planSegment( + attributes: Record, + normalizedOld: string, + allowTagRewrite: boolean, + renamedPageId: string | null, +): SegmentPlan { + const wikiLinkMark = isPlainObject(attributes.wikiLink) ? attributes.wikiLink : null; + const tagMark = isPlainObject(attributes.tag) ? attributes.tag : null; + const wikiTitle = wikiLinkMark ? extractStringAttr(wikiLinkMark.title) : null; + const tagName = tagMark ? extractStringAttr(tagMark.name) : null; + + // `targetId` を持つマークは ID 一致のみで判定する。同名ページの誤書き換え + // (issue #737) を防ぐため、`targetId` が renamedPageId と異なる場合は + // 例えタイトルが一致していても書き換えない。`targetId` が無いマークだけが + // タイトル一致 fallback の対象(lazy migration)。 + // Marks carrying a `targetId` are matched strictly by id. Even if the + // title also matches, a non-matching id means the mark targets a different + // (same-titled) page and must not be rewritten. Only id-less marks fall + // back to title matching (legacy / lazy migration). + const wikiHasFallback = wikiLinkMark !== null && shouldUseTitleFallback(wikiLinkMark); + const tagHasFallback = tagMark !== null && shouldUseTitleFallback(tagMark); + + const wikiIdMatches = + wikiLinkMark !== null && + renamedPageId !== null && + isUsableTargetId(wikiLinkMark.targetId) && + wikiLinkMark.targetId === renamedPageId; + const tagIdMatches = + tagMark !== null && + renamedPageId !== null && + isUsableTargetId(tagMark.targetId) && + tagMark.targetId === renamedPageId; + + const wikiTitleMatches = wikiTitle !== null && normalizeTitle(wikiTitle) === normalizedOld; + const tagTitleMatches = tagName !== null && normalizeTitle(tagName) === normalizedOld; + + return { + wikiLinkMark, + tagMark, + wikiMatches: wikiIdMatches || (wikiHasFallback && wikiTitleMatches), + tagMatches: allowTagRewrite && (tagIdMatches || (tagHasFallback && tagTitleMatches)), + }; +} + +function applyPlanToAttributes( + attributes: Record, + plan: SegmentPlan, + newTitle: string, + segmentMatchesOld: boolean, + result: RewriteResult, +): Record { + const next: Record = { ...attributes }; + if (plan.wikiMatches && plan.wikiLinkMark) { + next.wikiLink = { ...plan.wikiLinkMark, title: newTitle }; + result.wikiLinkMarksUpdated += 1; + if (segmentMatchesOld) result.wikiLinkTextUpdated += 1; + } + if (plan.tagMatches && plan.tagMark) { + next.tag = { ...plan.tagMark, name: newTitle }; + result.tagMarksUpdated += 1; + if (segmentMatchesOld) result.tagTextUpdated += 1; + } + return next; +} + +function rewriteText( + text: Y.XmlText, + oldTitle: string, + newTitle: string, + allowTagRewrite: boolean, + renamedPageId: string | null, + result: RewriteResult, +): void { + const delta = text.toDelta() as Array; + if (delta.length === 0) return; + + const normalizedOld = normalizeTitle(oldTitle); + const edits: PendingEdit[] = []; + + let offset = 0; + for (const raw of delta) { + if (!isPlainObject(raw) || typeof raw.insert !== "string") { + // Embeds (non-string inserts) still occupy one position in Y.XmlText. + // 埋め込み(非文字列 insert)も Y.XmlText 上で 1 文字分の位置を占める。 + offset += 1; + continue; + } + + const segmentText = raw.insert; + const length = segmentText.length; + const attributes: DeltaSegment["attributes"] = isPlainObject(raw.attributes) + ? raw.attributes + : undefined; + + if (length === 0) continue; + if (!attributes) { + offset += length; + continue; + } + + const plan = planSegment(attributes, normalizedOld, allowTagRewrite, renamedPageId); + if (!plan.wikiMatches && !plan.tagMatches) { + offset += length; + continue; + } + + const segmentMatchesOld = normalizeTitle(segmentText) === normalizedOld; + const nextAttributes = applyPlanToAttributes( + attributes, + plan, + newTitle, + segmentMatchesOld, + result, + ); + + edits.push({ + index: offset, + length, + text: segmentMatchesOld ? newTitle : segmentText, + attributes: nextAttributes, + }); + + offset += length; + } + + if (edits.length === 0) return; + + // 末尾から適用することで、先に処理したセグメントの長さ変化が後続の + // オフセットに影響するのを避ける。 + // Apply from the end so earlier edits' length changes do not shift later + // offsets. + for (let i = edits.length - 1; i >= 0; i--) { + const edit = edits[i]; + if (!edit) continue; + text.delete(edit.index, edit.length); + text.insert(edit.index, edit.text, edit.attributes); + } +} + +type XmlNode = Y.XmlFragment | Y.XmlElement | Y.XmlText | Y.XmlHook; + +function walk( + node: Y.XmlFragment | Y.XmlElement, + oldTitle: string, + newTitle: string, + allowTagRewrite: boolean, + renamedPageId: string | null, + result: RewriteResult, +): void { + // `node.get(i)` は Yjs の連結リストを頭から辿るため O(i)。インデックス + // ループにすると N 要素で O(N^2) になる。`toArray()` で一度だけ O(N) + // 走査して配列化し、その後は通常のイテレーションに切り替える。 + // `node.get(i)` walks Yjs' linked list from the head (O(i)), so an + // index-based loop is O(N^2) in the number of children. `toArray()` does + // a single O(N) pass; iterate the resulting array instead. + const children = node.toArray() as XmlNode[]; + for (const child of children) { + if (child instanceof Y.XmlText) { + rewriteText(child, oldTitle, newTitle, allowTagRewrite, renamedPageId, result); + } else if (child instanceof Y.XmlElement) { + walk(child, oldTitle, newTitle, allowTagRewrite, renamedPageId, result); + } + // Y.XmlHook は Tiptap のスキーマで通常使わないためスキップする。 + // Y.XmlHook is not used by Tiptap's default schema, so skip it. + } +} + +/** + * `rewriteTitleRefsInDoc` のオプション。`renamedPageId` を渡せるようにし、 + * `targetId` 属性ベースの厳密マッチを有効化する(issue #737)。 + * + * Options for `rewriteTitleRefsInDoc`. `renamedPageId` enables strict + * `targetId`-based matching introduced for issue #737. + */ +export interface RewriteTitleRefsOptions { + /** + * リネーム対象ページの UUID。マークが `targetId` 属性を持つ場合は ID 一致で + * のみ書き換える(同名ページの誤書き換えを防ぐ)。`null` / 省略時は、 + * `targetId` を **持たない** マークだけがタイトル一致 fallback で書き換わる。 + * 一方、`targetId` を持つマークは検証手段が無いため安全側に倒して **書き換え + * ない**(誤書き換え防止 / issue #737)。 + * + * UUID of the renamed page. When provided, marks carrying a `targetId` + * attribute are rewritten only on id match — preventing same-title pages + * from being rewritten in lockstep. When `null`/omitted, only marks + * **without** a usable `targetId` fall back to legacy title-only matching; + * marks that already carry a `targetId` are **skipped** because their + * target cannot be verified safely (issue #737). + */ + renamedPageId?: string | null; + /** + * 対象 XmlFragment 名。Tiptap の既定値 `"default"`。テスト用途以外では + * 通常変更しない。 + * + * Target XmlFragment name; Tiptap default `"default"`. Tests rarely need + * to override this. + */ + fragmentName?: string; +} + +/** + * `doc` 内の WikiLink / タグマークについて、旧タイトル `oldTitle` を参照 + * しているものを新タイトル `newTitle` へ書き換える。テキストノードは + * セグメントのテキストが旧タイトルと一致する場合のみ書き換える。 + * + * `options.renamedPageId` を指定すると、`targetId` 属性を持つマークは ID + * 一致でのみ書き換える(同名ページの誤書き換えを防ぐ・issue #737)。 + * `targetId` を持たない既存マークは従来通りタイトル一致でフォールバックする + * (lazy migration)。 + * + * Rewrite WikiLink and tag marks in `doc` whose target matches `oldTitle` + * so they point at `newTitle`. Segment text is only rewritten when it + * matches the old title (so user-edited link text is preserved). + * + * Passing `options.renamedPageId` switches marks that carry a `targetId` + * attribute to id-strict matching (preventing same-title-page collisions — + * issue #737). Marks without a `targetId` continue to match by title + * (legacy / lazy migration). + * + * 後方互換 / Backward compatibility: + * 第 4 引数は旧 API では `fragmentName: string` だった。文字列を渡された場合は + * `{ fragmentName }` として解釈し、issue #737 以前の呼び出し元が静かに既定 + * フラグメントへ書き換わってしまうのを防ぐ。 + * The fourth argument used to be a `fragmentName` string. When a string is + * passed it is interpreted as `{ fragmentName }`, so pre-issue-#737 callers + * are not silently retargeted at the default fragment. + * + * @param doc - 書き換え対象の Y.Doc / the Y.Doc to mutate. + * @param oldTitle - 旧ページタイトル / previous page title. + * @param newTitle - 新ページタイトル / new page title. + * @param optionsOrFragmentName - 任意オプション、または旧 API の fragmentName + * 文字列 / either the new options object or the legacy `fragmentName` + * string (kept for backward compatibility). + * @returns 書き換え件数 / counts of what was rewritten. + */ +export function rewriteTitleRefsInDoc( + doc: Y.Doc, + oldTitle: string, + newTitle: string, + optionsOrFragmentName: RewriteTitleRefsOptions | string = {}, +): RewriteResult { + // 旧 4-arg 形式 (`fragmentName` 文字列) を `{ fragmentName }` に正規化する。 + // 文字列をそのまま `options` として読むと `"foo".fragmentName` が undefined + // になり、既定フラグメントを書き換えてしまう静かな破壊が発生する。 + // Normalize the legacy 4-arg form (`fragmentName` string) into the options + // shape. Reading a raw string as `options` would silently retarget the + // default fragment because `"foo".fragmentName === undefined`. + const options: RewriteTitleRefsOptions = + typeof optionsOrFragmentName === "string" + ? { fragmentName: optionsOrFragmentName } + : optionsOrFragmentName; + + const result: RewriteResult = { + wikiLinkMarksUpdated: 0, + wikiLinkTextUpdated: 0, + tagMarksUpdated: 0, + tagTextUpdated: 0, + }; + + if (!oldTitle || !newTitle) return result; + if (normalizeTitle(oldTitle) === normalizeTitle(newTitle)) return result; + + const allowTagRewrite = VALID_TAG_NAME_REGEX.test(newTitle); + const fragmentName = options.fragmentName ?? "default"; + const renamedPageId = isUsableTargetId(options.renamedPageId) ? options.renamedPageId : null; + const fragment = doc.getXmlFragment(fragmentName); + + doc.transact(() => { + walk(fragment, oldTitle, newTitle, allowTagRewrite, renamedPageId, result); + }, "rename-propagation"); + + return result; +} diff --git a/server/hocuspocus/src/extractPlainTextFromYXml.test.ts b/server/hocuspocus/src/extractPlainTextFromYXml.test.ts index 80dbf313..e875f427 100644 --- a/server/hocuspocus/src/extractPlainTextFromYXml.test.ts +++ b/server/hocuspocus/src/extractPlainTextFromYXml.test.ts @@ -140,6 +140,6 @@ describe("buildContentPreview", () => { const long = "x".repeat(200); const prev = buildContentPreview(long); expect(prev.endsWith("...")).toBe(true); - expect(prev.length).toBeLessThanOrEqual(124); + expect(prev.length).toBeLessThanOrEqual(120); }); }); diff --git a/server/hocuspocus/src/extractPlainTextFromYXml.ts b/server/hocuspocus/src/extractPlainTextFromYXml.ts index 8ac6fd31..053416f5 100644 --- a/server/hocuspocus/src/extractPlainTextFromYXml.ts +++ b/server/hocuspocus/src/extractPlainTextFromYXml.ts @@ -72,5 +72,6 @@ export const CONTENT_PREVIEW_MAX_LENGTH = 120; export function buildContentPreview(text: string): string { const trimmed = text.trim().replace(/\s+/g, " "); if (trimmed.length <= CONTENT_PREVIEW_MAX_LENGTH) return trimmed; - return trimmed.slice(0, CONTENT_PREVIEW_MAX_LENGTH).trim() + "..."; + const headLength = Math.max(0, CONTENT_PREVIEW_MAX_LENGTH - 3); + return `${trimmed.slice(0, headLength).trim()}...`; } diff --git a/server/mcp/src/__tests__/tools/index.test.ts b/server/mcp/src/__tests__/tools/index.test.ts new file mode 100644 index 00000000..6e8c6080 --- /dev/null +++ b/server/mcp/src/__tests__/tools/index.test.ts @@ -0,0 +1,145 @@ +/** + * tools/index.ts のユニットテスト + * + * `server.test.ts` は MCP クライアント経由で end-to-end の挙動を見ているのに対し、 + * このテストは `registerAllTools` と `ALL_TOOL_NAMES` のメタ情報の整合性を直接検証する。 + * + * - `ALL_TOOL_NAMES` は重複なく zedi_ 接頭辞のみで構成されること + * - `registerAllTools(server, client)` は `ALL_TOOL_NAMES` の各要素を 1 度ずつ登録すること + * - 既存ツール定義の一覧と完全に一致すること(追加・削除のたぶん漏れを検知する) + * + * Unit tests for the registry contract: `registerAllTools` registers exactly the tools + * advertised in `ALL_TOOL_NAMES`. Catches silent additions or removals. + */ +import { describe, expect, it, vi } from "vitest"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ZediClient } from "../../client/ZediClient.js"; +import { ALL_TOOL_NAMES, registerAllTools } from "../../tools/index.js"; + +/** Build a fully-mocked ZediClient. Tools call only the methods we register, so types are safe. / + * 全メソッドをモック化した ZediClient。 */ +function createMockClient(): ZediClient { + return { + getCurrentUser: vi.fn(), + listPages: vi.fn(), + getPageContent: vi.fn(), + createPage: vi.fn(), + updatePageContent: vi.fn(), + deletePage: vi.fn(), + listNotes: vi.fn(), + getNote: vi.fn(), + createNote: vi.fn(), + updateNote: vi.fn(), + deleteNote: vi.fn(), + addPageToNote: vi.fn(), + removePageFromNote: vi.fn(), + reorderNotePages: vi.fn(), + listNotePages: vi.fn(), + listNoteMembers: vi.fn(), + addNoteMember: vi.fn(), + updateNoteMember: vi.fn(), + removeNoteMember: vi.fn(), + search: vi.fn(), + clipUrl: vi.fn(), + }; +} + +/** Captures registerTool calls so we can assert which tools were registered. / 登録呼び出しを記録するスタブ */ +function createServerStub(): { + server: Pick; + registered: string[]; +} { + const registered: string[] = []; + const server = { + registerTool: ((name: string) => { + registered.push(name); + // Real `registerTool` returns a `RegisteredTool`, but tools/index.ts does not + // consume the return value, so a no-op stub is sufficient. + // 実装の戻り値は `RegisteredTool` だが、tools/index.ts は無視するため空 object を返す。 + return {} as unknown; + }) as unknown as McpServer["registerTool"], + }; + return { server: server as Pick, registered }; +} + +describe("ALL_TOOL_NAMES", () => { + it("contains no duplicates", () => { + const set = new Set(ALL_TOOL_NAMES); + expect(set.size).toBe(ALL_TOOL_NAMES.length); + }); + + it("uses the zedi_ prefix consistently", () => { + for (const name of ALL_TOOL_NAMES) { + expect(name).toMatch(/^zedi_[a-z0-9_]+$/); + } + }); + + it("includes the canonical user / pages / notes / search / clip surface", () => { + // この list は意図的に `ALL_TOOL_NAMES` を二重化している。`registerAllTools` 側のテストは + // 「実装と `ALL_TOOL_NAMES` がズレないこと」しか保証しないので、両方を一括で消した + // (= 公開 API 縮退) 場合は検出できない。ここで仕様として固定したい一群のツール名を + // ハードコードしておくことで、その縮退を CI で確実に止める。新規ツール追加時にこの + // list の更新は不要 (`>=` 関係)、ただし既存ツールの削除/改名時は意図的な仕様変更として + // この list も併せて更新すること。 + // + // This list intentionally duplicates `ALL_TOOL_NAMES`. The `registerAllTools` test only + // guarantees the registry stays consistent with `ALL_TOOL_NAMES`; if both were dropped + // together (i.e. a silent public-API regression) it would still pass. Locking the + // canonical surface here forces any tool removal/rename to be an explicit edit to this + // list, surfacing it in code review. Adding a new tool does NOT require touching this + // list (it's a "must contain" check, not an equality check). + const required = [ + "zedi_get_current_user", + "zedi_list_pages", + "zedi_get_page", + "zedi_create_page", + "zedi_update_page_content", + "zedi_delete_page", + "zedi_list_notes", + "zedi_get_note", + "zedi_create_note", + "zedi_update_note", + "zedi_delete_note", + "zedi_list_note_pages", + "zedi_add_page_to_note", + "zedi_remove_page_from_note", + "zedi_reorder_note_pages", + "zedi_list_note_members", + "zedi_add_note_member", + "zedi_update_note_member", + "zedi_remove_note_member", + "zedi_search", + "zedi_clip_url", + ]; + for (const name of required) { + expect(ALL_TOOL_NAMES).toContain(name); + } + }); +}); + +describe("registerAllTools", () => { + it("registers exactly the tools listed in ALL_TOOL_NAMES (no extras, no missing)", () => { + const { server, registered } = createServerStub(); + registerAllTools(server as unknown as McpServer, createMockClient()); + + expect(registered.sort()).toEqual([...ALL_TOOL_NAMES].sort()); + }); + + it("registers each tool exactly once (no double registration)", () => { + const { server, registered } = createServerStub(); + registerAllTools(server as unknown as McpServer, createMockClient()); + + const counts = new Map(); + for (const name of registered) counts.set(name, (counts.get(name) ?? 0) + 1); + for (const [name, count] of counts) { + expect(count, `tool ${name} should be registered once`).toBe(1); + } + }); + + it("registers ALL_TOOL_NAMES.length tools total", () => { + const { server, registered } = createServerStub(); + registerAllTools(server as unknown as McpServer, createMockClient()); + + expect(registered).toHaveLength(ALL_TOOL_NAMES.length); + }); +}); diff --git a/src/components/editor/PageEditor/useEditorAutoSave.test.ts b/src/components/editor/PageEditor/useEditorAutoSave.test.ts index 6d11c94d..add45658 100644 --- a/src/components/editor/PageEditor/useEditorAutoSave.test.ts +++ b/src/components/editor/PageEditor/useEditorAutoSave.test.ts @@ -55,7 +55,7 @@ describe("useEditorAutoSave", () => { expect(syncWikiLinks).toHaveBeenCalledWith(pageId, expectedWikiLinks); }); - it("WikiLink が含まれない content では syncWikiLinks は呼ばれない(保存は行う)", async () => { + it("WikiLink が含まれない content でも syncWikiLinks は空配列で呼ばれる(stale cleanup のため、issue #725 Phase 1 レビュー指摘)", async () => { const plainContent = JSON.stringify({ type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "No links" }] }], @@ -84,7 +84,119 @@ describe("useEditorAutoSave", () => { expect(onSave).toHaveBeenCalledTimes(1); expect(extractWikiLinksFromContent(plainContent)).toHaveLength(0); - expect(syncWikiLinks).not.toHaveBeenCalled(); + // issue #725 Phase 1 レビュー指摘: Mark が無くても同期呼び出しは走らせて + // サーバ側の stale エッジを空配列 delta で削除させる。 + // Always call sync with an empty array so stale edges cleared on save. + expect(syncWikiLinks).toHaveBeenCalledTimes(1); + expect(syncWikiLinks).toHaveBeenCalledWith(pageId, []); + }); + + it("tag marks あり + syncTags 指定時は syncTags が呼ばれ、重複タグは getUniqueTagNames で畳まれる (issue #725 Phase 1)", async () => { + // 同じタグ名 `#tech` を 2 回出しても `getUniqueTagNames` で 1 件に畳まれて + // `syncTags` が呼ばれることを検証(CodeRabbit のレビュー指摘)。 + // Duplicate `#tech` marks must collapse via `getUniqueTagNames` so + // `syncTags` is called once with a single entry (CodeRabbit review). + const tagContent = JSON.stringify({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + marks: [{ type: "tag", attrs: { name: "tech", exists: false, referenced: false } }], + text: "#tech", + }, + { type: "text", text: " " }, + { + type: "text", + marks: [{ type: "tag", attrs: { name: "tech", exists: false, referenced: false } }], + text: "#tech", + }, + ], + }, + ], + }); + const syncWikiLinks = vi.fn().mockResolvedValue(undefined); + const syncTags = vi.fn().mockResolvedValue(undefined); + const onSave = vi.fn().mockResolvedValue(true); + const onSaveContentOnly = vi.fn().mockResolvedValue(true); + + const { result } = renderHook(() => + useEditorAutoSave({ + pageId, + debounceMs: 0, + onSave, + onSaveContentOnly, + syncWikiLinks, + syncTags, + }), + ); + + act(() => { + result.current.saveChanges("Title", tagContent); + }); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + // dedupe 後の 1 件だけが渡ることを確認。 + // Only the deduped single entry should reach `syncTags`. + expect(syncTags).toHaveBeenCalledTimes(1); + expect(syncTags).toHaveBeenCalledWith(pageId, [{ name: "tech" }]); + // Wiki マークは無いが、stale cleanup のため空配列で 1 回呼ぶ契約。 + // No wiki marks, but we still call syncWikiLinks with `[]` so stale + // wiki edges get delta-deleted (issue #725 Phase 1 review feedback). + expect(syncWikiLinks).toHaveBeenCalledTimes(1); + expect(syncWikiLinks).toHaveBeenCalledWith(pageId, []); + }); + + it("syncTags 未指定ならタグがあっても呼ばれない (backward compat)", async () => { + const tagContent = JSON.stringify({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + marks: [{ type: "tag", attrs: { name: "tech", exists: false, referenced: false } }], + text: "#tech", + }, + ], + }, + ], + }); + const syncWikiLinks = vi.fn().mockResolvedValue(undefined); + const onSave = vi.fn().mockResolvedValue(true); + const onSaveContentOnly = vi.fn().mockResolvedValue(true); + + const { result } = renderHook(() => + useEditorAutoSave({ + pageId, + debounceMs: 0, + onSave, + onSaveContentOnly, + syncWikiLinks, + }), + ); + + act(() => { + result.current.saveChanges("Title", tagContent); + }); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + // syncTags prop が無ければタグ同期はスキップ。一方 syncWikiLinks は + // 空配列(wiki マーク無し)でも呼んで stale cleanup を走らせる。 + // Without a `syncTags` prop, tag sync is skipped; meanwhile + // `syncWikiLinks` is still called (with an empty array when no wiki + // marks exist) so stale wiki edges get delta-cleaned. + expect(syncWikiLinks).toHaveBeenCalledTimes(1); + expect(syncWikiLinks).toHaveBeenCalledWith(pageId, []); }); it("保存がスキップ(didSave false)でも syncWikiLinks は呼ばれる", async () => { @@ -114,6 +226,60 @@ describe("useEditorAutoSave", () => { expect(syncWikiLinks).toHaveBeenCalledTimes(1); expect(syncWikiLinks).toHaveBeenCalledWith(pageId, expectedWikiLinks); }); + + it("保存がスキップ(didSave false)でも syncTags は呼ばれる (issue #725 Phase 1)", async () => { + // Mirror of the WikiLink didSave=false test, covering the tag-sync + // contract: save may fail but the link-graph sync still runs so the + // server's stale edges get delta-updated. + // WikiLink 版と対になる契約テスト。保存が skipped でも tag 同期は + // 走らせる。 + const tagContent = JSON.stringify({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + marks: [{ type: "tag", attrs: { name: "tech", exists: false, referenced: false } }], + text: "#tech", + }, + ], + }, + ], + }); + const syncWikiLinks = vi.fn().mockResolvedValue(undefined); + const syncTags = vi.fn().mockResolvedValue(undefined); + const onSave = vi.fn().mockResolvedValue(false); // skipped + const onSaveContentOnly = vi.fn().mockResolvedValue(false); + + const { result } = renderHook(() => + useEditorAutoSave({ + pageId, + debounceMs: 0, + onSave, + onSaveContentOnly, + syncWikiLinks, + syncTags, + }), + ); + + act(() => { + result.current.saveChanges("Title", tagContent); + }); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(syncTags).toHaveBeenCalledTimes(1); + expect(syncTags).toHaveBeenCalledWith(pageId, [{ name: "tech" }]); + // 並列で WikiLink 側も空配列で 1 回呼ばれる(stale cleanup 契約)。 + // `syncWikiLinks` is still called once with an empty array to clear + // any stale wiki edges. + expect(syncWikiLinks).toHaveBeenCalledTimes(1); + expect(syncWikiLinks).toHaveBeenCalledWith(pageId, []); + }); }); describe("onSaveSuccess", () => { diff --git a/src/components/editor/PageEditor/useEditorAutoSave.ts b/src/components/editor/PageEditor/useEditorAutoSave.ts index fadd6214..4935ab51 100644 --- a/src/components/editor/PageEditor/useEditorAutoSave.ts +++ b/src/components/editor/PageEditor/useEditorAutoSave.ts @@ -1,5 +1,6 @@ import { useCallback, useRef, useEffect, useState } from "react"; import { extractWikiLinksFromContent } from "@/lib/wikiLinkUtils"; +import { extractTagsFromContent, getUniqueTagNames } from "@/lib/tagUtils"; interface UseEditorAutoSaveOptions { pageId: string | null; @@ -8,6 +9,16 @@ interface UseEditorAutoSaveOptions { onSave: (updates: { title?: string; content: string }) => boolean | Promise; onSaveContentOnly: (content: string) => boolean | Promise; syncWikiLinks: (pageId: string, wikiLinks: Array<{ title: string }>) => Promise; + /** + * オプショナル: タグ (`#name`) マークを `link_type='tag'` バケットに同期する + * コールバック (issue #725 Phase 1)。未指定ならタグ同期はスキップする(旧コード + * パス互換)。呼び出し側は `useSyncWikiLinks().syncTags` を渡す想定。 + * + * Optional callback to sync tag marks into the `link_type='tag'` bucket + * (issue #725 Phase 1). Omit to skip tag sync (legacy behavior). Callers + * typically pass `useSyncWikiLinks().syncTags`. + */ + syncTags?: (pageId: string, tags: Array<{ name: string }>) => Promise; onSaveSuccess?: () => void; } @@ -28,6 +39,7 @@ export function useEditorAutoSave({ onSave, onSaveContentOnly, syncWikiLinks, + syncTags, onSaveSuccess, }: UseEditorAutoSaveOptions): UseEditorAutoSaveReturn { const saveTimeoutRef = useRef(null); @@ -48,10 +60,23 @@ export function useEditorAutoSave({ saveTimeoutRef.current = null; const pending = pendingRef.current; if (pending && pageId) { - const syncWikiLinksFromContent = async (contentToSync: string) => { + const syncGraphFromContent = async (contentToSync: string) => { + // unmount フラッシュでも `saveChanges` と同じ契約で同期する。 + // 空配列で呼ぶことで「最後の Mark を消して /home に戻った」ケースの + // stale cleanup がサーバ側まで届く。失敗は下の catch で握りつぶす。 + // Use the same contract as `saveChanges` here: call each bucket + // with an empty array when no marks exist so removing the last + // mark and navigating away still clears stale edges. Errors bubble + // to the best-effort try/catch below. const wikiLinks = extractWikiLinksFromContent(contentToSync); - if (wikiLinks.length > 0) { - await syncWikiLinks(pageId, wikiLinks); + await syncWikiLinks(pageId, wikiLinks); + if (syncTags) { + const tags = extractTagsFromContent(contentToSync); + const uniqueNames = getUniqueTagNames(tags); + await syncTags( + pageId, + uniqueNames.map((name) => ({ name })), + ); } }; const saveAction = pending.contentOnly @@ -64,7 +89,7 @@ export function useEditorAutoSave({ console.error("Auto-save flush on unmount failed:", e); } try { - await syncWikiLinksFromContent(pending.content); + await syncGraphFromContent(pending.content); } catch { // Ignore sync errors during unmount flush } @@ -72,17 +97,38 @@ export function useEditorAutoSave({ } } }; - }, [pageId, onSave, onSaveContentOnly, syncWikiLinks]); + }, [pageId, onSave, onSaveContentOnly, syncWikiLinks, syncTags]); const saveChanges = useCallback( (newTitle: string, newContent: string, forceBlockTitle = false) => { if (!pageId) return; - // WikiLinkを抽出して同期する関数 - const syncWikiLinksFromContent = async (contentToSync: string) => { + /** + * Extract WikiLinks + tags from the editor content and sync each to its + * dedicated `link_type` bucket. Tag sync is only wired when `syncTags` + * is provided (issue #725 Phase 1). **Both buckets are synced on every + * save**, including with empty arrays — `syncLinksWithRepo` relies on + * the empty-input delta to clear stale links/ghosts, so skipping the + * call when the editor has no marks of that type would leave orphaned + * edges in the DB after the user removes the last mark. + * + * WikiLink とタグを Tiptap コンテンツから抽出し、それぞれ独立の + * `link_type` バケットへ同期する(issue #725 Phase 1)。**Mark が + * 空でも毎回呼ぶ**: `syncLinksWithRepo` は空配列を delta として受け + * 取って既存エッジを削除する設計のため、ガードで弾くと「最後の Mark + * を消しても DB に残る」挙動になる。`syncTags` が渡らない旧呼び出し + * 元ではタグ同期のみスキップする。 + */ + const syncGraphFromContent = async (contentToSync: string) => { const wikiLinks = extractWikiLinksFromContent(contentToSync); - if (wikiLinks.length > 0) { - await syncWikiLinks(pageId, wikiLinks); + await syncWikiLinks(pageId, wikiLinks); + if (syncTags) { + const tags = extractTagsFromContent(contentToSync); + const uniqueNames = getUniqueTagNames(tags); + await syncTags( + pageId, + uniqueNames.map((name) => ({ name })), + ); } }; @@ -92,7 +138,7 @@ export function useEditorAutoSave({ try { const didSave = await saveAction(); try { - await syncWikiLinksFromContent(newContent); + await syncGraphFromContent(newContent); } finally { setIsSyncingLinks(false); } @@ -134,7 +180,16 @@ export function useEditorAutoSave({ void runSave(() => onSave({ title: newTitle, content: newContent })); }, debounceMs); }, - [pageId, debounceMs, shouldBlockSave, onSave, onSaveContentOnly, syncWikiLinks, onSaveSuccess], + [ + pageId, + debounceMs, + shouldBlockSave, + onSave, + onSaveContentOnly, + syncWikiLinks, + syncTags, + onSaveSuccess, + ], ); return { diff --git a/src/components/editor/PageEditor/usePageEditorAutoSaveWithMutation.ts b/src/components/editor/PageEditor/usePageEditorAutoSaveWithMutation.ts index 282d5ec7..a71cc4c5 100644 --- a/src/components/editor/PageEditor/usePageEditorAutoSaveWithMutation.ts +++ b/src/components/editor/PageEditor/usePageEditorAutoSaveWithMutation.ts @@ -9,6 +9,16 @@ interface UsePageEditorAutoSaveWithMutationOptions { updateLastSaved: (timestamp: number) => void; } +/** + * ページエディタの autosave を `useUpdatePage` mutation と `useSyncWikiLinks` + * の WikiLink / タグ同期に配線するフック。保存成功時にサムネイル抽出と + * `linkedPages` クエリの invalidate も行う(issue #725 Phase 1 でタグ同期を追加)。 + * + * Hook that wires the page editor's autosave pipeline to the `useUpdatePage` + * mutation and `useSyncWikiLinks` (WikiLink + tag sync). It also extracts the + * first image for thumbnail updates and invalidates the `linkedPages` cache on + * save. Tag sync was added by issue #725 Phase 1. + */ export function usePageEditorAutoSaveWithMutation({ currentPageId, shouldBlockSave, @@ -17,7 +27,7 @@ export function usePageEditorAutoSaveWithMutation({ const queryClient = useQueryClient(); const { userId } = useRepository(); const updatePageMutation = useUpdatePage(); - const { syncLinks } = useSyncWikiLinks(); + const { syncLinks, syncTags } = useSyncWikiLinks(); const { saveChanges, @@ -46,6 +56,7 @@ export function usePageEditorAutoSaveWithMutation({ return !result.skipped; }, syncWikiLinks: syncLinks, + syncTags, onSaveSuccess: () => { updateLastSaved(Date.now()); if (currentPageId && userId) { diff --git a/src/components/editor/TiptapEditor/EditorBubbleMenu.test.tsx b/src/components/editor/TiptapEditor/EditorBubbleMenu.test.tsx index 256423d6..fb1b4b54 100644 --- a/src/components/editor/TiptapEditor/EditorBubbleMenu.test.tsx +++ b/src/components/editor/TiptapEditor/EditorBubbleMenu.test.tsx @@ -47,6 +47,9 @@ vi.mock("@/hooks/usePageQueries", () => ({ checkExistence: vi.fn().mockResolvedValue({ pageTitles: new Set(), referencedTitles: new Set(), + // issue #737: 新フィールド `pageTitleToId` の契約に追従。 + // Match the issue #737 contract by returning an empty map. + pageTitleToId: new Map(), }), }) as unknown, })); diff --git a/src/components/editor/TiptapEditor/useBubbleMenuWikiLink.test.ts b/src/components/editor/TiptapEditor/useBubbleMenuWikiLink.test.ts index aa273667..f20a5148 100644 --- a/src/components/editor/TiptapEditor/useBubbleMenuWikiLink.test.ts +++ b/src/components/editor/TiptapEditor/useBubbleMenuWikiLink.test.ts @@ -49,7 +49,15 @@ function createMockEditor(options: { describe("useBubbleMenuWikiLink", () => { beforeEach(() => { vi.clearAllMocks(); - mockCheckExistence.mockResolvedValue({ pageTitles: new Set(), referencedTitles: new Set() }); + // issue #737: 既定モックは `pageTitleToId` を空 Map で返す。個別テストが + // resolved 経路を試したい場合は `mockResolvedValue` を上書きする。 + // Default mock returns an empty `pageTitleToId` (issue #737); tests that + // exercise the resolved branch override `mockResolvedValue` directly. + mockCheckExistence.mockResolvedValue({ + pageTitles: new Set(), + referencedTitles: new Set(), + pageTitleToId: new Map(), + }); }); it("returns isWikiLinkSelection false when editor is not in wikiLink", () => { @@ -99,7 +107,7 @@ describe("useBubbleMenuWikiLink", () => { marks: [ { type: "wikiLink", - attrs: { title: "New Page", exists: false, referenced: false }, + attrs: { title: "New Page", exists: false, referenced: false, targetId: null }, }, ], text: "[[New Page]]", @@ -108,10 +116,13 @@ describe("useBubbleMenuWikiLink", () => { expect(editor.chainReturn.run).toHaveBeenCalled(); }); - it("convertToWikiLink uses exists and referenced from checkExistence", async () => { + it("convertToWikiLink uses exists, referenced, and targetId from checkExistence", async () => { + // issue #737: 解決済みターゲットの id を `targetId` 属性に埋める。 + // Resolved target id is written into the `targetId` attribute (issue #737). mockCheckExistence.mockResolvedValue({ pageTitles: new Set(["existing page"]), referencedTitles: new Set(["existing page"]), + pageTitleToId: new Map([["existing page", "page-existing-id"]]), }); const editor = createMockEditor({ textBetween: "Existing Page" }); const { result } = renderHook(() => useBubbleMenuWikiLink({ editor, pageId: "p1" })); @@ -126,7 +137,12 @@ describe("useBubbleMenuWikiLink", () => { marks: [ { type: "wikiLink", - attrs: { title: "Existing Page", exists: true, referenced: true }, + attrs: { + title: "Existing Page", + exists: true, + referenced: true, + targetId: "page-existing-id", + }, }, ], text: "[[Existing Page]]", @@ -134,10 +150,11 @@ describe("useBubbleMenuWikiLink", () => { ]); }); - it("convertToWikiLink uses referenced true when only referencedTitles has the title", async () => { + it("convertToWikiLink uses referenced true and null targetId when only ghosted", async () => { mockCheckExistence.mockResolvedValue({ pageTitles: new Set(), referencedTitles: new Set(["ghost"]), + pageTitleToId: new Map(), }); const editor = createMockEditor({ textBetween: "Ghost" }); const { result } = renderHook(() => useBubbleMenuWikiLink({ editor, pageId: "p1" })); @@ -152,7 +169,9 @@ describe("useBubbleMenuWikiLink", () => { marks: [ { type: "wikiLink", - attrs: { title: "Ghost", exists: false, referenced: true }, + // `targetId` は未解決なので `null`(リネーム伝播はタイトル一致 fallback)。 + // Unresolved → `targetId: null` (rename uses title fallback). + attrs: { title: "Ghost", exists: false, referenced: true, targetId: null }, }, ], text: "[[Ghost]]", @@ -185,14 +204,15 @@ describe("useBubbleMenuWikiLink", () => { }); it("convertToWikiLink ignores second call until first resolves (re-entrancy guard)", async () => { - const deferred: { - resolve: (v: { pageTitles: Set; referencedTitles: Set }) => void; - } = { resolve: () => {} }; - const checkPromise = new Promise<{ pageTitles: Set; referencedTitles: Set }>( - (r) => { - deferred.resolve = r; - }, - ); + type CheckResult = { + pageTitles: Set; + referencedTitles: Set; + pageTitleToId: Map; + }; + const deferred: { resolve: (v: CheckResult) => void } = { resolve: () => {} }; + const checkPromise = new Promise((r) => { + deferred.resolve = r; + }); mockCheckExistence.mockReturnValue(checkPromise); const editor = createMockEditor({ textBetween: "Foo" }); @@ -210,7 +230,11 @@ describe("useBubbleMenuWikiLink", () => { if (firstCall === undefined) throw new Error("expected firstCall"); await act(async () => { - deferred.resolve({ pageTitles: new Set(), referencedTitles: new Set() }); + deferred.resolve({ + pageTitles: new Set(), + referencedTitles: new Set(), + pageTitleToId: new Map(), + }); await firstCall; }); diff --git a/src/components/editor/TiptapEditor/useBubbleMenuWikiLink.ts b/src/components/editor/TiptapEditor/useBubbleMenuWikiLink.ts index 28ef5ffe..09f8d33a 100644 --- a/src/components/editor/TiptapEditor/useBubbleMenuWikiLink.ts +++ b/src/components/editor/TiptapEditor/useBubbleMenuWikiLink.ts @@ -2,12 +2,53 @@ import { useCallback, useRef, useState } from "react"; import type { Editor } from "@tiptap/core"; import { useWikiLinkExistsChecker } from "@/hooks/usePageQueries"; +/** + * `useBubbleMenuWikiLink` のオプション。バブルメニューから WikiLink への + * 変換を行うエディタと、解決スコープ判定に使う現在のページ id を受け取る。 + * + * Options for {@link useBubbleMenuWikiLink}. Provides the editor that will + * receive the WikiLink conversion and the current page id used to scope + * existence checks (and to exclude self-references). + */ export interface UseBubbleMenuWikiLinkOptions { editor: Editor; pageId?: string; } -export function useBubbleMenuWikiLink({ editor, pageId }: UseBubbleMenuWikiLinkOptions) { +/** + * `useBubbleMenuWikiLink` の戻り値。バブルメニューが必要とする状態と + * コマンドを公開契約として固定する(CodeRabbit レビュー指摘 / 戦略的 + * `any` 禁止に従い、戻り値の型を明示)。 + * + * Return shape of {@link useBubbleMenuWikiLink}. Fixes the public contract + * so downstream consumers stay stable as the payload evolves (per CodeRabbit + * review and the project's "no inferred public types" rule). + */ +export interface UseBubbleMenuWikiLinkResult { + /** True while the current selection sits inside a `wikiLink` mark. */ + isWikiLinkSelection: boolean; + /** Convert the current selection text into a `[[Title]]` WikiLink mark. */ + convertToWikiLink: () => Promise; + /** Remove the `wikiLink` mark from the current selection. */ + unsetWikiLink: () => void; + /** True while {@link UseBubbleMenuWikiLinkResult.convertToWikiLink} is running. */ + isConverting: boolean; +} + +/** + * バブルメニューの「WikiLink に変換」操作を提供するフック。選択中テキストを + * `[[Title]]` マークに変換し、解決済みのターゲットページがあれば `targetId` + * 属性も埋める(issue #737)。 + * + * Hook providing the bubble-menu "convert to WikiLink" action. Wraps the + * selection in a `[[Title]]` mark and, when the title resolves to an + * existing page, populates the `targetId` attribute (issue #737) so future + * rename propagation can disambiguate same-title pages by id. + */ +export function useBubbleMenuWikiLink({ + editor, + pageId, +}: UseBubbleMenuWikiLinkOptions): UseBubbleMenuWikiLinkResult { const { checkExistence } = useWikiLinkExistsChecker(); const [isConverting, setIsConverting] = useState(false); const convertingRef = useRef(false); @@ -18,7 +59,7 @@ export function useBubbleMenuWikiLink({ editor, pageId }: UseBubbleMenuWikiLinkO if (convertingRef.current) return; const { from, to } = editor.state.selection; - const text = editor.state.doc.textBetween(from, to, null, "\ufffc").trim(); + const text = editor.state.doc.textBetween(from, to, null, "").trim(); if (!text) return; convertingRef.current = true; @@ -26,17 +67,26 @@ export function useBubbleMenuWikiLink({ editor, pageId }: UseBubbleMenuWikiLinkO try { let exists = false; let referenced = false; + let targetId: string | null = null; if (pageId !== undefined) { - const { pageTitles, referencedTitles } = await checkExistence([text], pageId); + const { pageTitles, referencedTitles, pageTitleToId } = await checkExistence( + [text], + pageId, + ); const normalized = text.toLowerCase().trim(); exists = pageTitles.has(normalized); referenced = referencedTitles.has(normalized); + // 解決済みターゲット ID を埋めることで、後続のリネーム伝播が同名ページとの + // 衝突を ID 一致で回避できる(issue #737)。未解決時は `null` のまま残し、 + // 旧データと同様にタイトル一致 fallback を許す。 + // Populate the resolved target id so future rename propagation can + // disambiguate same-title pages by id (issue #737). Leaving it null + // preserves the legacy title-only fallback path for unresolved marks. + targetId = pageTitleToId.get(normalized) ?? null; } const { from: currentFrom, to: currentTo } = editor.state.selection; - const currentText = editor.state.doc - .textBetween(currentFrom, currentTo, null, "\ufffc") - .trim(); + const currentText = editor.state.doc.textBetween(currentFrom, currentTo, null, "").trim(); if (currentText !== text) return; editor @@ -49,7 +99,7 @@ export function useBubbleMenuWikiLink({ editor, pageId }: UseBubbleMenuWikiLinkO marks: [ { type: "wikiLink", - attrs: { title: text, exists, referenced }, + attrs: { title: text, exists, referenced, targetId }, }, ], text: `[[${text}]]`, diff --git a/src/components/editor/TiptapEditor/useEditorBubbleMenu.test.ts b/src/components/editor/TiptapEditor/useEditorBubbleMenu.test.ts index 2079d648..8ea6e0f9 100644 --- a/src/components/editor/TiptapEditor/useEditorBubbleMenu.test.ts +++ b/src/components/editor/TiptapEditor/useEditorBubbleMenu.test.ts @@ -9,6 +9,9 @@ vi.mock("@/hooks/usePageQueries", () => ({ checkExistence: vi.fn().mockResolvedValue({ pageTitles: new Set(), referencedTitles: new Set(), + // issue #737: `pageTitleToId` を返す契約に追従。 + // Match the issue #737 contract by returning an empty map. + pageTitleToId: new Map(), }), }) as unknown, })); diff --git a/src/components/editor/TiptapEditor/useEditorLifecycle.ts b/src/components/editor/TiptapEditor/useEditorLifecycle.ts index 0d329a90..057d5bf4 100644 --- a/src/components/editor/TiptapEditor/useEditorLifecycle.ts +++ b/src/components/editor/TiptapEditor/useEditorLifecycle.ts @@ -3,6 +3,7 @@ import type { Editor } from "@tiptap/core"; import { sanitizeTiptapContent } from "@/lib/contentUtils"; import { useContentSanitizer } from "./useContentSanitizer"; import { useWikiLinkStatusSync } from "./useWikiLinkStatusSync"; +import { useTagStatusSync } from "./useTagStatusSync"; import { usePasteImageHandler } from "./usePasteImageHandler"; import { rememberSlashAgentSelection } from "@/lib/agentSlashCommands/slashAgentSelectionCache"; @@ -161,4 +162,15 @@ export function useEditorLifecycle({ skipSync: isWikiGenerating, pageNoteId: pageNoteId ?? null, }); + + // issue #725 Phase 1: tag Mark の `exists` / `referenced` を同じ契約で同期する。 + // Keep tag marks' status in sync alongside WikiLink marks (issue #725 Phase 1). + useTagStatusSync({ + editor, + content, + pageId: pageId || undefined, + onChange, + skipSync: isWikiGenerating, + pageNoteId: pageNoteId ?? null, + }); } diff --git a/src/components/editor/TiptapEditor/useTagStatusSync.test.tsx b/src/components/editor/TiptapEditor/useTagStatusSync.test.tsx new file mode 100644 index 00000000..2a0f0462 --- /dev/null +++ b/src/components/editor/TiptapEditor/useTagStatusSync.test.tsx @@ -0,0 +1,170 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, act } from "@testing-library/react"; +import { useTagStatusSync } from "./useTagStatusSync"; + +type MockNotePage = { id: string; title: string }; + +let mockNotePagesData: MockNotePage[] | undefined; + +vi.mock("@/hooks/useNoteQueries", () => ({ + useNotePages: vi.fn(() => ({ + data: mockNotePagesData, + })), +})); + +vi.mock("@/hooks/usePageQueries", () => ({ + useWikiLinkExistsChecker: vi.fn( + (options?: { notePages?: MockNotePage[]; pageNoteId?: string | null }) => ({ + checkExistence: vi.fn(async (titles: string[]) => { + const inScope = options?.pageNoteId ? (options.notePages ?? []) : []; + const pageTitles = new Set(inScope.map((page) => page.title.toLowerCase().trim())); + // issue #737: `pageTitleToId` を返すモック契約。 + // Mock contract for issue #737. + const pageTitleToId = new Map( + inScope.map((page) => [page.title.toLowerCase().trim(), page.id]), + ); + return { + pageTitles, + referencedTitles: new Set(), + pageTitleToId, + }; + }), + }), + ), +})); + +/** + * Mock a Tiptap editor with a single tag Mark so we can observe attribute + * updates. Mirrors the shape used in `useWikiLinkStatusSync.test.tsx`. + * + * タグ Mark を 1 つ持つ Tiptap エディタのモック。`useWikiLinkStatusSync` の + * テストと同じ雛形で属性更新を観測する。 + */ +function createMockEditor() { + const tagMark = { + type: { name: "tag" }, + attrs: { name: "Beta", exists: false, referenced: false }, + }; + + const chainApi = { + setTextSelection: vi.fn(() => chainApi), + extendMarkRange: vi.fn(() => chainApi), + updateAttributes: vi.fn((_name: string, attrs: { exists: boolean; referenced: boolean }) => { + tagMark.attrs = { ...tagMark.attrs, ...attrs }; + return chainApi; + }), + run: vi.fn(() => true), + }; + + return { + editor: { + state: { + doc: { + descendants: ( + visitor: ( + node: { isText: boolean; marks: (typeof tagMark)[]; nodeSize: number }, + pos: number, + ) => void, + ) => { + visitor( + { + isText: true, + marks: [tagMark], + nodeSize: 4, + }, + 1, + ); + }, + }, + }, + chain: vi.fn(() => chainApi), + getJSON: vi.fn(() => ({ type: "doc", content: [] })), + }, + tagMark, + chainApi, + }; +} + +function Harness({ + editor, + onChange, +}: { + editor: ReturnType["editor"]; + onChange: (content: string) => void; +}) { + useTagStatusSync({ + editor: editor as never, + content: JSON.stringify({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "#Beta", + marks: [ + { + type: "tag", + attrs: { name: "Beta", exists: false, referenced: false }, + }, + ], + }, + ], + }, + ], + }), + pageId: "page-1", + onChange, + pageNoteId: "note-1", + }); + + return null; +} + +describe("useTagStatusSync (issue #725 Phase 1)", () => { + beforeEach(() => { + mockNotePagesData = []; + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("updates tag mark exists/referenced when the note-scoped page set grows to include the tag name", async () => { + const { editor, tagMark, chainApi } = createMockEditor(); + const onChange = vi.fn(); + + const view = render(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(150); + }); + + // 初期状態ではページが未作成なので `exists` は false のまま、`onChange` 無呼び。 + // No pages yet → exists stays false, onChange untouched. + expect(onChange).not.toHaveBeenCalled(); + expect(tagMark.attrs.exists).toBe(false); + + // タグ名と同じ title のページがノート内に現れたら `exists: true` に更新される。 + // A page matching the tag name appears → exists flips to true. + mockNotePagesData = [{ id: "page-beta", title: "Beta" }]; + view.rerender(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(150); + }); + + // issue #737: 解決時には `targetId` も同時に payload に乗る。 + // Resolution also writes `targetId` (issue #737). + expect(chainApi.updateAttributes).toHaveBeenCalledWith("tag", { + exists: true, + referenced: false, + targetId: "page-beta", + }); + expect(tagMark.attrs.exists).toBe(true); + expect(onChange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/editor/TiptapEditor/useTagStatusSync.ts b/src/components/editor/TiptapEditor/useTagStatusSync.ts new file mode 100644 index 00000000..a1c06f73 Binary files /dev/null and b/src/components/editor/TiptapEditor/useTagStatusSync.ts differ diff --git a/src/components/editor/TiptapEditor/useWikiLinkStatusSync.test.tsx b/src/components/editor/TiptapEditor/useWikiLinkStatusSync.test.tsx index ee9a856d..ca3fddb6 100644 --- a/src/components/editor/TiptapEditor/useWikiLinkStatusSync.test.tsx +++ b/src/components/editor/TiptapEditor/useWikiLinkStatusSync.test.tsx @@ -17,14 +17,19 @@ vi.mock("@/hooks/usePageQueries", () => ({ useWikiLinkExistsChecker: vi.fn( (options?: { notePages?: MockNotePage[]; pageNoteId?: string | null }) => ({ checkExistence: vi.fn(async (titles: string[]) => { - const pageTitles = new Set( - (options?.pageNoteId ? (options.notePages ?? []) : []).map((page) => - page.title.toLowerCase().trim(), - ), + const inScope = options?.pageNoteId ? (options.notePages ?? []) : []; + const pageTitles = new Set(inScope.map((page) => page.title.toLowerCase().trim())); + // issue #737: `pageTitleToId` を返すモック契約。`targetId` 解決を伴う + // シナリオを検証できるよう、note スコープ内ページから title→id を構築する。 + // Mock contract for issue #737. Build a title→id map from in-scope + // pages so `targetId` resolution paths are testable. + const pageTitleToId = new Map( + inScope.map((page) => [page.title.toLowerCase().trim(), page.id]), ); return { pageTitles, referencedTitles: new Set(), + pageTitleToId, }; }), }), @@ -143,9 +148,12 @@ describe("useWikiLinkStatusSync", () => { await vi.advanceTimersByTimeAsync(150); }); + // issue #737: 解決時には `targetId` も同時に payload に乗る。 + // Resolution also writes `targetId` (issue #737). expect(chainApi.updateAttributes).toHaveBeenCalledWith("wikiLink", { exists: true, referenced: false, + targetId: "page-beta", }); expect(wikiLinkMark.attrs.exists).toBe(true); expect(onChange).toHaveBeenCalledTimes(1); diff --git a/src/components/editor/TiptapEditor/useWikiLinkStatusSync.ts b/src/components/editor/TiptapEditor/useWikiLinkStatusSync.ts index 04652308..87dcb375 100644 --- a/src/components/editor/TiptapEditor/useWikiLinkStatusSync.ts +++ b/src/components/editor/TiptapEditor/useWikiLinkStatusSync.ts @@ -106,7 +106,7 @@ export function useWikiLinkStatusSync({ const titles = getUniqueWikiLinkTitles(currentWikiLinks); // ページの存在確認と参照状態を一括チェック - const { pageTitles, referencedTitles } = await checkExistence(titles, pageId); + const { pageTitles, referencedTitles, pageTitleToId } = await checkExistence(titles, pageId); // チェック準備ができていない場合はスキップ(次回再試行) if (pageTitles.size === 0 && titles.length > 0) { @@ -114,7 +114,7 @@ export function useWikiLinkStatusSync({ } // エディター内のWikiLinkマークを検索して更新が必要なものを収集 - const updates = collectWikiLinkUpdates(editor, pageTitles, referencedTitles); + const updates = collectWikiLinkUpdates(editor, pageTitles, referencedTitles, pageTitleToId); // チェック完了を記録 lastCheckedRef.current = { pageId, wikiLinkCount: currentCount, pageScopeSignature }; @@ -142,6 +142,15 @@ interface WikiLinkUpdate { to: number; exists: boolean; referenced: boolean; + /** + * 解決済みターゲットページの id。`null` のままにしておくと、後段で + * `updateAttributes` の payload から外して既存値を保持する(属性を消さない)。 + * + * Resolved target page id. When `null`, the value is omitted from the + * `updateAttributes` payload so an existing `targetId` is preserved + * (we never blank out a previously-resolved id). + */ + targetId: string | null; } /** @@ -151,6 +160,7 @@ function collectWikiLinkUpdates( editor: Editor, pageTitles: Set, referencedTitles: Set, + pageTitleToId: Map, ): WikiLinkUpdate[] { const updates: WikiLinkUpdate[] = []; const { doc } = editor.state; @@ -164,14 +174,27 @@ function collectWikiLinkUpdates( const normalizedTitle = (mark.attrs.title as string).toLowerCase().trim(); const newExists = pageTitles.has(normalizedTitle); const newReferenced = referencedTitles.has(normalizedTitle); + // 解決済みなら id を埋める (issue #737)。未解決時は `null` を返すと + // 適用フェーズで属性ペイロードから外し、既存の `targetId` を温存する。 + // Populate `targetId` when the link resolves (issue #737). A `null` + // means "don't touch" — the apply step skips the field so a previously + // populated id is preserved through transient unresolved states. + const resolvedId = newExists ? (pageTitleToId.get(normalizedTitle) ?? null) : null; + const currentTargetId = typeof mark.attrs.targetId === "string" ? mark.attrs.targetId : null; + const targetIdChanged = resolvedId !== null && resolvedId !== currentTargetId; // ステータスが変わった場合のみ更新対象に追加 - if (mark.attrs.exists !== newExists || mark.attrs.referenced !== newReferenced) { + if ( + mark.attrs.exists !== newExists || + mark.attrs.referenced !== newReferenced || + targetIdChanged + ) { updates.push({ from: pos, to: pos + node.nodeSize, exists: newExists, referenced: newReferenced, + targetId: targetIdChanged ? resolvedId : null, }); } }); @@ -186,14 +209,23 @@ function collectWikiLinkUpdates( function applyWikiLinkUpdates(editor: Editor, updates: WikiLinkUpdate[]): void { // 位置がずれないよう逆順で適用 for (const update of updates.reverse()) { + const attrs: Record = { + exists: update.exists, + referenced: update.referenced, + }; + // `targetId` を渡すのは「新しく解決された」ときだけ。`null` のまま + // 上書きすると、解決状態が一時的に外れた瞬間に id を失ってしまう。 + // Only include `targetId` when we actually resolved a new id; passing + // `null` would clobber a previously-resolved id during transient + // unresolved windows (e.g. note-page list still loading). + if (update.targetId !== null) { + attrs.targetId = update.targetId; + } editor .chain() .setTextSelection({ from: update.from, to: update.to }) .extendMarkRange("wikiLink") - .updateAttributes("wikiLink", { - exists: update.exists, - referenced: update.referenced, - }) + .updateAttributes("wikiLink", attrs) .run(); } } diff --git a/src/components/editor/extensions/TagExtension.test.ts b/src/components/editor/extensions/TagExtension.test.ts index b3ba4757..c65a0392 100644 --- a/src/components/editor/extensions/TagExtension.test.ts +++ b/src/components/editor/extensions/TagExtension.test.ts @@ -202,4 +202,72 @@ describe("Tag extension configuration", () => { expect(extension.config.renderHTML).toBeDefined(); expect(extension.config.addAttributes).toBeDefined(); }); + + describe("targetId attribute (issue #737)", () => { + // 重複タイトル下でリネームを ID 一致で識別するため、tag マークに `targetId` + // 属性を追加した(issue #737 / 案 A)。本テスト群は属性宣言が正しい既定値 + // と HTML ラウンドトリップを持つことを固定する。 + // Pin the schema for the new `targetId` attribute used by rename + // propagation to discriminate same-title pages (issue #737, approach A). + function getTargetIdSpec(): { + default: unknown; + parseHTML: (el: HTMLElement) => unknown; + renderHTML: (attrs: Record) => Record; + } { + const extension = Tag.configure({}); + const addAttributes = extension.config.addAttributes; + if (typeof addAttributes !== "function") { + throw new Error("addAttributes must be a function"); + } + type AddAttributesContext = Record & { + parent?: (() => Record) | undefined; + }; + const context: AddAttributesContext = { + ...extension, + parent: undefined, + }; + const attrs = addAttributes.call(context) as Record; + const targetId = attrs.targetId as ReturnType; + if (!targetId) throw new Error("targetId attribute missing"); + return targetId; + } + + it("declares a targetId attribute with default null", () => { + const spec = getTargetIdSpec(); + expect(spec.default).toBeNull(); + }); + + it("parses targetId from data-target-id on the rendered span", () => { + const spec = getTargetIdSpec(); + const el = document.createElement("span"); + el.setAttribute("data-target-id", "11111111-aaaa-bbbb-cccc-000000000001"); + expect(spec.parseHTML(el)).toBe("11111111-aaaa-bbbb-cccc-000000000001"); + + const empty = document.createElement("span"); + expect(spec.parseHTML(empty)).toBeNull(); + + empty.setAttribute("data-target-id", ""); + expect(spec.parseHTML(empty)).toBeNull(); + }); + + it("omits data-target-id when targetId is null or empty", () => { + // 属性が無いマーク(旧データや未解決状態)で `data-target-id=""` を出さない + // ことで、サーバ側 `rewriteTitleRefsInDoc` が「id が無い → タイトル fallback」 + // と判定できるようにする。 + // Pre-issue-#737 marks (and unresolved fresh pastes) must not emit a + // `data-target-id` attribute so the server-side rewriter sees them as + // id-less and falls back to title matching. + const spec = getTargetIdSpec(); + expect(spec.renderHTML({ targetId: null })).toEqual({}); + expect(spec.renderHTML({ targetId: "" })).toEqual({}); + expect(spec.renderHTML({})).toEqual({}); + }); + + it("emits data-target-id when targetId is a non-empty string", () => { + const spec = getTargetIdSpec(); + expect(spec.renderHTML({ targetId: "11111111-aaaa-bbbb-cccc-000000000001" })).toEqual({ + "data-target-id": "11111111-aaaa-bbbb-cccc-000000000001", + }); + }); + }); }); diff --git a/src/components/editor/extensions/TagExtension.ts b/src/components/editor/extensions/TagExtension.ts index a9276b6f..a6a60118 100644 --- a/src/components/editor/extensions/TagExtension.ts +++ b/src/components/editor/extensions/TagExtension.ts @@ -1,5 +1,6 @@ import { Mark, markPasteRule, mergeAttributes } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { TAG_NAME_CHAR_CLASS } from "@zedi/shared/tagCharacterClass"; /** * Regex matching hashtag patterns `#name` in pasted text. @@ -9,8 +10,10 @@ import { Plugin, PluginKey } from "@tiptap/pm/state"; * Preconditions to match: * - `#` must not be preceded by a word character, `/`, or another `#` * (excludes `abc#tag`, URL fragments like `/page#anchor`, and `##`). - * - The name must consist of Latin letters/digits, underscore, hyphen, - * Hiragana, Katakana, or CJK Unified/Extension A characters. + * - The name must consist of characters in {@link TAG_NAME_CHAR_CLASS} + * (Latin letters/digits, underscore, hyphen, Hiragana, Katakana, CJK + * Unified/Extension A). The character class lives in `@zedi/shared` so + * the server's `VALID_TAG_NAME_REGEX` cannot drift from it. * - Trailing punctuation (`、。,.!?:;` 等) terminates the name. * * Tiptap's `markPasteRule` applies the mark to the *last* capture group and @@ -24,6 +27,8 @@ import { Plugin, PluginKey } from "@tiptap/pm/state"; * それ以外を削除する仕様(`WikiLinkExtension` と同様)。先頭の `#` を * マーク範囲に含めるため、敢えてキャプチャグループを使わず `match[0]` を * そのままマーク対象とし、タグ名は {@link extractTagName} で後付け抽出する。 + * 文字クラス本体は `@zedi/shared` の `TAG_NAME_CHAR_CLASS` を共有することで、 + * サーバ側 (`VALID_TAG_NAME_REGEX`) との二重定義によるドリフトを防ぐ。 * * Fine-grained exclusions (numeric-only, 6/8-char hex colors) are applied in * `getAttributes` via {@link isExcludedTagName} so reject reasons sit next to @@ -32,13 +37,13 @@ import { Plugin, PluginKey } from "@tiptap/pm/state"; * 数字のみ・6/8 桁純 hex のような細かな除外は {@link isExcludedTagName} で * 行い、理由とデータ形状をまとめて管理する。 */ -export const TAG_PASTE_REGEX = /(?({ "data-referenced": String(attributes.referenced), }), }, + /** + * 解決済みターゲットページの UUID。`useTagStatusSync` がリンク解決時に + * 埋め、リネーム伝播(issue #737 / `ydocRenameRewrite`)がタグ名文字列 + * ではなく ID 一致で対象を特定できるようにする。未解決や旧データでは + * `null` で、その場合の伝播は名前文字列でフォールバックする + * (後方互換のため)。 + * + * Resolved target page UUID. Populated by `useTagStatusSync` once the + * tag resolves to an existing page so rename propagation + * (issue #737 / `ydocRenameRewrite`) matches by id instead of by name + * string. `null` for unresolved or pre-issue-#737 marks; the rewriter + * falls back to name matching in that case (lazy migration). + */ + targetId: { + default: null, + parseHTML: (element) => { + const raw = element.getAttribute("data-target-id"); + if (typeof raw !== "string") return null; + const normalized = raw.trim(); + return normalized.length > 0 ? normalized : null; + }, + renderHTML: (attributes) => { + const value = attributes.targetId; + if (typeof value !== "string" || value.length === 0) { + return {}; + } + return { "data-target-id": value }; + }, + }, }; }, @@ -197,7 +231,7 @@ export const Tag = Mark.create({ // the leading `#`) and reject when it hits an exclusion rule. const name = extractTagName(match[0] ?? ""); if (!name || isExcludedTagName(name)) return false; - return { name, exists: false, referenced: false }; + return { name, exists: false, referenced: false, targetId: null }; }, }), ]; diff --git a/src/components/editor/extensions/WikiLinkExtension.test.ts b/src/components/editor/extensions/WikiLinkExtension.test.ts index 6388d5bf..96a9e15c 100644 --- a/src/components/editor/extensions/WikiLinkExtension.test.ts +++ b/src/components/editor/extensions/WikiLinkExtension.test.ts @@ -90,5 +90,73 @@ describe("WikiLinkExtension paste rule", () => { expect(extension.config.renderHTML).toBeDefined(); expect(extension.config.addAttributes).toBeDefined(); }); + + describe("targetId attribute (issue #737)", () => { + // 重複タイトル下でリネームを ID 一致で識別するため、wikiLink マークに + // `targetId` 属性を追加した(issue #737 / 案 A)。本テスト群は属性宣言が + // 正しい既定値・HTML ラウンドトリップを持つことを固定する。 + // Pin the schema for the new `targetId` attribute used by rename + // propagation to discriminate same-title pages (issue #737, approach A). + function getTargetIdSpec(): { + default: unknown; + parseHTML: (el: HTMLElement) => unknown; + renderHTML: (attrs: Record) => Record; + } { + const extension = WikiLink.configure({}); + const addAttributes = extension.config.addAttributes; + if (typeof addAttributes !== "function") { + throw new Error("addAttributes must be a function"); + } + type AddAttributesContext = Record & { + parent?: (() => Record) | undefined; + }; + const context: AddAttributesContext = { + ...extension, + parent: undefined, + }; + const attrs = addAttributes.call(context) as Record; + const targetId = attrs.targetId as ReturnType; + if (!targetId) throw new Error("targetId attribute missing"); + return targetId; + } + + it("declares a targetId attribute with default null", () => { + const spec = getTargetIdSpec(); + expect(spec.default).toBeNull(); + }); + + it("parses targetId from data-target-id on the rendered span", () => { + const spec = getTargetIdSpec(); + const el = document.createElement("span"); + el.setAttribute("data-target-id", "11111111-aaaa-bbbb-cccc-000000000001"); + expect(spec.parseHTML(el)).toBe("11111111-aaaa-bbbb-cccc-000000000001"); + + const empty = document.createElement("span"); + expect(spec.parseHTML(empty)).toBeNull(); + + empty.setAttribute("data-target-id", ""); + expect(spec.parseHTML(empty)).toBeNull(); + }); + + it("omits data-target-id when targetId is null or empty", () => { + // 属性が無いマーク(旧データや未解決状態)で `data-target-id=""` を出さない + // ことで、サーバ側 `rewriteTitleRefsInDoc` が「id が無い → タイトル fallback」 + // と判定できるようにする。 + // Pre-issue-#737 marks (and unresolved fresh pastes) must not emit a + // `data-target-id` attribute so the server-side rewriter sees them as + // id-less and falls back to title matching. + const spec = getTargetIdSpec(); + expect(spec.renderHTML({ targetId: null })).toEqual({}); + expect(spec.renderHTML({ targetId: "" })).toEqual({}); + expect(spec.renderHTML({})).toEqual({}); + }); + + it("emits data-target-id when targetId is a non-empty string", () => { + const spec = getTargetIdSpec(); + expect(spec.renderHTML({ targetId: "11111111-aaaa-bbbb-cccc-000000000001" })).toEqual({ + "data-target-id": "11111111-aaaa-bbbb-cccc-000000000001", + }); + }); + }); }); }); diff --git a/src/components/editor/extensions/WikiLinkExtension.ts b/src/components/editor/extensions/WikiLinkExtension.ts index f7d7d61e..784f476b 100644 --- a/src/components/editor/extensions/WikiLinkExtension.ts +++ b/src/components/editor/extensions/WikiLinkExtension.ts @@ -53,7 +53,11 @@ export interface WikiLinkOptions { declare module "@tiptap/core" { interface Commands { wikiLink: { - setWikiLink: (attributes: { title: string; exists: boolean }) => ReturnType; + setWikiLink: (attributes: { + title: string; + exists: boolean; + targetId?: string | null; + }) => ReturnType; unsetWikiLink: () => ReturnType; }; } @@ -98,6 +102,36 @@ export const WikiLink = Mark.create({ "data-referenced": String(attributes.referenced), }), }, + /** + * 解決済みターゲットページの UUID。リンクが既存ページに解決されたタイミング + * (`useWikiLinkStatusSync`) で埋められ、リネーム伝播(issue #737 / `ydocRenameRewrite`) + * がタイトル文字列ではなく ID 一致で対象を特定できるようにする。未解決の + * 段階や旧データでは `null` で、この場合の伝播はタイトル文字列にフォール + * バックする(後方互換のため)。 + * + * Resolved target page UUID. Filled in by `useWikiLinkStatusSync` once + * the link resolves to an existing page so rename propagation + * (issue #737 / `ydocRenameRewrite`) can match by id instead of by + * title string — preventing same-title pages from being rewritten in + * lockstep. `null` for unresolved or pre-issue-#737 marks; the + * rewriter falls back to title matching in that case. + */ + targetId: { + default: null, + parseHTML: (element) => { + const raw = element.getAttribute("data-target-id"); + if (typeof raw !== "string") return null; + const normalized = raw.trim(); + return normalized.length > 0 ? normalized : null; + }, + renderHTML: (attributes) => { + const value = attributes.targetId; + if (typeof value !== "string" || value.length === 0) { + return {}; + } + return { "data-target-id": value }; + }, + }, }; }, @@ -141,7 +175,7 @@ export const WikiLink = Mark.create({ // match[0] is the full `[[Title]]` literal; extract only the title. const title = extractWikiLinkTitle(match[0] ?? ""); if (!title) return false; - return { title, exists: false, referenced: false }; + return { title, exists: false, referenced: false, targetId: null }; }, }), ]; diff --git a/src/components/editor/extensions/slashSuggestionPlugin.test.ts b/src/components/editor/extensions/slashSuggestionPlugin.test.ts new file mode 100644 index 00000000..68c65b18 --- /dev/null +++ b/src/components/editor/extensions/slashSuggestionPlugin.test.ts @@ -0,0 +1,253 @@ +/** + * Tests for the `/` slash suggestion ProseMirror plugin. + * `/` スラッシュサジェスト用 ProseMirror プラグインのテスト。 + * + * The plugin tracks slash queries triggered at the start of a line or after a + * space, and notifies subscribers when its state changes. These tests build a + * minimal ProseMirror schema + state and feed transactions directly so we can + * pin the trigger conditions without a full Tiptap editor. + * 行頭またはスペース直後の `/` を検知する。最小スキーマ + 直接トランザクション + * 適用でトリガ条件を固定する。 + */ + +import type { Plugin } from "@tiptap/pm/state"; +import { EditorState, TextSelection } from "@tiptap/pm/state"; +import { Schema } from "@tiptap/pm/model"; +import { describe, expect, it, vi } from "vitest"; +import { + SlashSuggestionPlugin, + slashSuggestionPluginKey, + type SlashSuggestionState, +} from "./slashSuggestionPlugin"; + +const schema = new Schema({ + nodes: { + doc: { content: "block+" }, + paragraph: { + group: "block", + content: "text*", + toDOM: () => ["p", 0], + }, + text: { group: "inline" }, + }, +}); + +/** + * Pulls the bare ProseMirror plugin out of the Tiptap extension wrapper so we + * can install it on a hand-built `EditorState`. + * Tiptap 拡張ラッパーから素の ProseMirror プラグインを取り出す。 + */ +function getPlugin(onStateChange?: (s: SlashSuggestionState) => void): Plugin { + const extension = SlashSuggestionPlugin.configure({ onStateChange }); + const addPlugins = extension.config.addProseMirrorPlugins; + if (!addPlugins) throw new Error("addProseMirrorPlugins missing"); + // Tiptap stores extension options on `this.options` inside the method. + // 拡張内部の `this.options` 経由で onStateChange を渡す必要がある。 + const plugins = addPlugins.call({ options: { onStateChange } } as never); + return plugins[0]; +} + +/** + * Creates an editor state seeded with `text` inside a single paragraph and a + * collapsed cursor at the end of that text. + * 1 段落のみのドキュメントを生成し、キャレットを末尾に置く。 + */ +function makeState(text: string, plugin: Plugin): EditorState { + const doc = schema.node("doc", null, [ + schema.node("paragraph", null, text ? [schema.text(text)] : []), + ]); + const state = EditorState.create({ doc, schema, plugins: [plugin] }); + // 段落内末尾にキャレットを置く: pos 1 が段落 open、pos 1+text.length が末尾。 + // Place the caret at the end of the paragraph (pos 1 + text length). + const tr = state.tr.setSelection(TextSelection.create(state.doc, 1 + text.length)); + return state.apply(tr); +} + +describe("slashSuggestionPlugin — initial state", () => { + it("initialises with no active suggestion", () => { + const plugin = getPlugin(); + const state = makeState("", plugin); + const pluginState = slashSuggestionPluginKey.getState(state); + expect(pluginState).toEqual({ + active: false, + query: "", + range: null, + decorations: expect.any(Object), + }); + }); +}); + +describe("slashSuggestionPlugin — activation triggers", () => { + it("activates on `/` at the start of a line", () => { + const onStateChange = vi.fn(); + const plugin = getPlugin(onStateChange); + const state = makeState("/", plugin); + const pluginState = slashSuggestionPluginKey.getState(state); + + expect(pluginState?.active).toBe(true); + expect(pluginState?.query).toBe(""); + // `/` 自体は pos 1〜2 の範囲。range はメニュー削除に再利用される。 + // The slash itself sits at positions 1..2; range is later used to delete it. + expect(pluginState?.range).toEqual({ from: 1, to: 2 }); + expect(onStateChange).toHaveBeenCalledWith(pluginState); + }); + + it("captures the query text after `/`", () => { + const plugin = getPlugin(); + const state = makeState("/analyze", plugin); + const pluginState = slashSuggestionPluginKey.getState(state); + + expect(pluginState?.active).toBe(true); + expect(pluginState?.query).toBe("analyze"); + expect(pluginState?.range).toEqual({ from: 1, to: 9 }); + }); + + it("activates on a `/` preceded by a space mid-line", () => { + const plugin = getPlugin(); + const state = makeState("hi /run", plugin); + const pluginState = slashSuggestionPluginKey.getState(state); + + expect(pluginState?.active).toBe(true); + expect(pluginState?.query).toBe("run"); + // "hi " (3 chars) → `/` at pos 4, query ends at pos 8. + // 行頭ではなくスペース直後の `/` も同様に検知する。 + expect(pluginState?.range).toEqual({ from: 4, to: 8 }); + }); + + it("preserves spaces inside the query so multi-token args stay active", () => { + // `/analyze path/to/file` のような入力を維持する設計(コードコメント参照)。 + // Multi-word args after the command must keep the menu open. + const plugin = getPlugin(); + const state = makeState("/analyze src/foo.ts", plugin); + const pluginState = slashSuggestionPluginKey.getState(state); + + expect(pluginState?.active).toBe(true); + expect(pluginState?.query).toBe("analyze src/foo.ts"); + }); +}); + +describe("slashSuggestionPlugin — non-trigger inputs", () => { + it("does not activate when `/` is embedded in a word", () => { + const plugin = getPlugin(); + const state = makeState("foo/bar", plugin); + const pluginState = slashSuggestionPluginKey.getState(state); + expect(pluginState?.active).toBe(false); + }); + + it("does not activate without any `/`", () => { + const plugin = getPlugin(); + const state = makeState("plain text", plugin); + const pluginState = slashSuggestionPluginKey.getState(state); + expect(pluginState?.active).toBe(false); + }); +}); + +describe("slashSuggestionPlugin — deactivation", () => { + it("deactivates when the selection becomes a non-empty range", () => { + const onStateChange = vi.fn(); + const plugin = getPlugin(onStateChange); + + // 1) Activate with `/foo`. + // 1) `/foo` でアクティブ化する。 + let state = makeState("/foo", plugin); + expect(slashSuggestionPluginKey.getState(state)?.active).toBe(true); + onStateChange.mockClear(); + + // 2) Expand the selection to a range; the plugin must turn off. + // 2) 選択範囲をレンジに広げると、プラグインは非アクティブになる。 + const tr = state.tr.setSelection(TextSelection.create(state.doc, 1, 5)); + state = state.apply(tr); + const pluginState = slashSuggestionPluginKey.getState(state); + expect(pluginState?.active).toBe(false); + expect(pluginState?.range).toBeNull(); + expect(pluginState?.query).toBe(""); + expect(onStateChange).toHaveBeenCalledWith(pluginState); + }); + + it("stays inactive when a non-empty selection is applied from an inactive state", () => { + const onStateChange = vi.fn(); + const plugin = getPlugin(onStateChange); + + let state = makeState("hello", plugin); + expect(slashSuggestionPluginKey.getState(state)?.active).toBe(false); + onStateChange.mockClear(); + + const tr = state.tr.setSelection(TextSelection.create(state.doc, 1, 4)); + state = state.apply(tr); + expect(slashSuggestionPluginKey.getState(state)?.active).toBe(false); + // 既に inactive のため通知しない(不要な再描画を避ける)。 + // No notification when already inactive — avoids redundant re-renders. + expect(onStateChange).not.toHaveBeenCalled(); + }); + + it("turns off when the user types past the slash trigger and the regex no longer matches", () => { + const plugin = getPlugin(); + + // Active first. + // まずアクティブ状態にする。 + let state = makeState("/foo", plugin); + expect(slashSuggestionPluginKey.getState(state)?.active).toBe(true); + + // Replace the entire paragraph contents with text that no longer matches. + // 段落全体を非トリガなテキストに差し替える(pos 1〜5 が段落内 4 文字 + 末尾位置)。 + const tr = state.tr.replaceWith(1, 5, schema.text("plain")); + state = state.apply(tr); + expect(slashSuggestionPluginKey.getState(state)?.active).toBe(false); + }); + + it("explicit close meta clears the active state and notifies subscribers", () => { + const onStateChange = vi.fn(); + const plugin = getPlugin(onStateChange); + + let state = makeState("/foo", plugin); + expect(slashSuggestionPluginKey.getState(state)?.active).toBe(true); + onStateChange.mockClear(); + + const tr = state.tr.setMeta(slashSuggestionPluginKey, { close: true }); + state = state.apply(tr); + const pluginState = slashSuggestionPluginKey.getState(state); + expect(pluginState).toEqual({ + active: false, + query: "", + range: null, + decorations: expect.any(Object), + }); + expect(onStateChange).toHaveBeenCalledWith(pluginState); + }); +}); + +describe("slashSuggestionPlugin — decorations", () => { + it("creates a single decoration over the slash range when active", () => { + const plugin = getPlugin(); + const state = makeState("/run", plugin); + const pluginState = slashSuggestionPluginKey.getState(state); + expect(pluginState?.active).toBe(true); + + // DecorationSet.find returns all decorations overlapping the document. + // 装飾が `/run` 範囲(pos 1〜5)を覆っていることを確認する。 + const decos = pluginState?.decorations.find(0, state.doc.content.size) ?? []; + expect(decos).toHaveLength(1); + expect(decos[0].from).toBe(1); + expect(decos[0].to).toBe(5); + }); + + it("returns an empty decoration set when inactive", () => { + const plugin = getPlugin(); + const state = makeState("plain", plugin); + const pluginState = slashSuggestionPluginKey.getState(state); + expect(pluginState?.active).toBe(false); + const decos = pluginState?.decorations.find(0, state.doc.content.size) ?? []; + expect(decos).toHaveLength(0); + }); +}); + +describe("slashSuggestionPlugin — extension wiring", () => { + it("declares the expected extension name", () => { + expect(SlashSuggestionPlugin.name).toBe("slashSuggestion"); + }); + + it("exposes a default options object with no callback", () => { + const ext = SlashSuggestionPlugin.configure(); + expect(ext.options.onStateChange).toBeUndefined(); + }); +}); diff --git a/src/hooks/useAIChatPanelContentLogic.test.ts b/src/hooks/useAIChatPanelContentLogic.test.ts new file mode 100644 index 00000000..a322216f --- /dev/null +++ b/src/hooks/useAIChatPanelContentLogic.test.ts @@ -0,0 +1,506 @@ +/** + * Tests for {@link useAIChatPanelContentLogic}. + * {@link useAIChatPanelContentLogic} のテスト。 + * + * Issue #743: cover the composition contract — page-title derivation feeds + * `useAIChat`, page conversations come from the `useAIChatConversations` + * helper, lifecycle/handler params receive the right values, and the returned + * object exposes both transcript state and handlers. + * Issue #743: 構成上の契約を検証する — ページタイトル抽出が `useAIChat` に渡る、 + * `useAIChatConversations` ヘルパーから page conversations が取得される、ライフサイクルと + * ハンドラに正しい値が渡る、戻り値にトランスクリプト状態とハンドラが揃う。 + */ + +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +import type { + Conversation, + MessageMap, + PageContext, + ReferencedPage, + TreeChatMessage, +} from "@/types/aiChat"; + +// --- Mock setup --------------------------------------------------------- + +const mockUseAIChatContext = vi.fn(); +const mockUsePagesSummary = vi.fn(); + +const mockGetConversation = vi.fn(); +const mockGetConversationsForPage = vi.fn(); +const mockCreateConversation = vi.fn(); +const mockUpdateConversation = vi.fn(); +const mockDeleteConversation = vi.fn(); + +const mockHandleExecuteAction = vi.fn(); + +type AIChatHookSpy = { + options: unknown; +}; +const aiChatHookSpy: AIChatHookSpy = { options: undefined }; + +const mockSendMessage = vi.fn(); +const mockStopStreaming = vi.fn(); +const mockClearMessages = vi.fn(); +const mockLoadConversation = vi.fn(); +const mockEditAndResend = vi.fn(); +const mockSwitchBranch = vi.fn(); +const mockNavigateToNode = vi.fn(); +const mockSetBranchPoint = vi.fn(); +const mockDeleteBranch = vi.fn(); +const mockPrepareBranchFromUserMessage = vi.fn(); + +const lifecycleSpy = vi.fn(); + +vi.mock("@/contexts/AIChatContext", () => ({ + useAIChatContext: () => mockUseAIChatContext(), +})); + +vi.mock("@/hooks/usePageQueries", () => ({ + usePagesSummary: () => mockUsePagesSummary(), +})); + +vi.mock("@/hooks/useAIChatConversations", () => ({ + useAIChatConversations: () => ({ + getConversation: (id: string) => mockGetConversation(id), + getConversationsForPage: (pageId: string | undefined, type: string | undefined) => + mockGetConversationsForPage(pageId, type), + // 実装は `createConversation({ type, pageId, pageTitle })` の形でスナップショットを渡すため、 + // 引数を破棄せず転送して回帰(snapshot を渡し忘れる変更)を検知できるようにする。 + // The handler invokes `createConversation` with a `PageContextSnapshot`; forward + // the args so a regression that drops the snapshot is caught by the test. + createConversation: (...args: unknown[]) => mockCreateConversation(...args), + updateConversation: (id: string, patch: unknown) => mockUpdateConversation(id, patch), + deleteConversation: (id: string) => mockDeleteConversation(id), + }), +})); + +vi.mock("@/hooks/useAIChatActions", () => ({ + useAIChatActions: ({ pageContext }: { pageContext: PageContext | null }) => ({ + handleExecuteAction: (...args: unknown[]) => mockHandleExecuteAction(pageContext, ...args), + }), +})); + +vi.mock("@/hooks/useAIChat", () => ({ + useAIChat: (options: unknown) => { + aiChatHookSpy.options = options; + return { + messages: [{ id: "u1", role: "user", content: "Hi", timestamp: 0 }] as TreeChatMessage[], + messageMap: { + u1: { id: "u1", role: "user", parentId: null, content: "Hi", timestamp: 0 }, + } satisfies MessageMap, + rootMessageId: "u1", + activeLeafId: "u1", + sendMessage: (...args: unknown[]) => mockSendMessage(...args), + stopStreaming: (...args: unknown[]) => mockStopStreaming(...args), + clearMessages: () => mockClearMessages(), + loadConversation: (c: Conversation) => mockLoadConversation(c), + editAndResend: (...args: unknown[]) => mockEditAndResend(...args), + switchBranch: (...args: unknown[]) => mockSwitchBranch(...args), + navigateToNode: (id: string) => mockNavigateToNode(id), + setBranchPoint: (id: string) => mockSetBranchPoint(id), + deleteBranch: (id: string) => mockDeleteBranch(id), + prepareBranchFromUserMessage: (id: string) => mockPrepareBranchFromUserMessage(id), + isStreaming: true, + }; + }, +})); + +vi.mock("@/hooks/useAIChatPanelContentLifecycle", () => ({ + useAIChatPanelContentLifecycle: (params: unknown) => { + lifecycleSpy(params); + }, +})); + +import { useAIChatPanelContentLogic } from "./useAIChatPanelContentLogic"; + +// --- Test helpers ------------------------------------------------------- + +const baseSetActiveConversation = vi.fn(); + +const editorContext: PageContext = { + type: "editor", + pageId: "page-1", + pageTitle: "Note", + pageFullContent: "", +}; + +beforeEach(() => { + vi.clearAllMocks(); + aiChatHookSpy.options = undefined; + mockUseAIChatContext.mockReturnValue({ pageContext: editorContext }); + mockUsePagesSummary.mockReturnValue({ data: [] }); + mockGetConversation.mockReturnValue(undefined); + mockGetConversationsForPage.mockReturnValue([]); +}); + +// --- Tests -------------------------------------------------------------- + +describe("useAIChatPanelContentLogic - composition", () => { + it("forwards pageContext, contextEnabled, and existingPageTitles to useAIChat", () => { + mockUsePagesSummary.mockReturnValue({ + data: [ + { id: "p1", title: " Alpha ", isDeleted: false }, + { id: "p2", title: "Beta", isDeleted: false }, + { id: "p3", title: "Gamma", isDeleted: true }, + { id: "p4", title: " ", isDeleted: false }, + ], + }); + + renderHook(() => + useAIChatPanelContentLogic({ + activeConversationId: null, + setActiveConversation: baseSetActiveConversation, + contextEnabled: true, + }), + ); + + expect(aiChatHookSpy.options).toMatchObject({ + pageContext: editorContext, + contextEnabled: true, + }); + const opts = aiChatHookSpy.options as { + existingPageTitles: string[]; + availablePages: Array<{ id: string; title: string; isDeleted: boolean }>; + }; + expect(opts.existingPageTitles).toEqual(["Alpha", "Beta"]); + expect(opts.availablePages).toHaveLength(4); + }); + + it("queries pageConversations using pageContext.pageId and pageContext.type", () => { + mockGetConversationsForPage.mockReturnValue([ + { + id: "c1", + title: "", + messageMap: {}, + rootMessageId: null, + activeLeafId: null, + createdAt: 0, + updatedAt: 0, + } as Conversation, + ]); + + const { result } = renderHook(() => + useAIChatPanelContentLogic({ + activeConversationId: null, + setActiveConversation: baseSetActiveConversation, + contextEnabled: false, + }), + ); + + expect(mockGetConversationsForPage).toHaveBeenCalledWith("page-1", "editor"); + expect(result.current.pageConversations).toHaveLength(1); + }); + + it("passes pageContext through to useAIChatActions and exposes handleExecuteAction", () => { + const { result } = renderHook(() => + useAIChatPanelContentLogic({ + activeConversationId: null, + setActiveConversation: baseSetActiveConversation, + contextEnabled: false, + }), + ); + + result.current.handleExecuteAction({ type: "noop" } as unknown as Parameters< + typeof result.current.handleExecuteAction + >[0]); + + expect(mockHandleExecuteAction).toHaveBeenCalledTimes(1); + expect(mockHandleExecuteAction.mock.calls[0][0]).toEqual(editorContext); + expect(mockHandleExecuteAction.mock.calls[0][1]).toEqual({ type: "noop" }); + }); + + it("returns the streaming/messages snapshot from useAIChat", () => { + const { result } = renderHook(() => + useAIChatPanelContentLogic({ + activeConversationId: null, + setActiveConversation: baseSetActiveConversation, + contextEnabled: false, + }), + ); + + expect(result.current.messages).toHaveLength(1); + expect(result.current.rootMessageId).toBe("u1"); + expect(result.current.activeLeafId).toBe("u1"); + expect(result.current.isStreaming).toBe(true); + }); + + it("forwards the active conversation lookup to the lifecycle hook", () => { + const conv: Conversation = { + id: "c-active", + title: "T", + messageMap: {}, + rootMessageId: null, + activeLeafId: null, + createdAt: 0, + updatedAt: 0, + }; + mockGetConversation.mockReturnValue(conv); + + renderHook(() => + useAIChatPanelContentLogic({ + activeConversationId: "c-active", + setActiveConversation: baseSetActiveConversation, + contextEnabled: false, + }), + ); + + expect(mockGetConversation).toHaveBeenCalledWith("c-active"); + expect(lifecycleSpy).toHaveBeenCalledWith( + expect.objectContaining({ + pageContext: editorContext, + activeConversation: conv, + activeConversationId: "c-active", + }), + ); + }); + + it("does not look up a conversation when activeConversationId is null", () => { + renderHook(() => + useAIChatPanelContentLogic({ + activeConversationId: null, + setActiveConversation: baseSetActiveConversation, + contextEnabled: false, + }), + ); + + expect(mockGetConversation).not.toHaveBeenCalled(); + expect(lifecycleSpy).toHaveBeenCalledWith( + expect.objectContaining({ activeConversation: undefined }), + ); + }); + + it("memoizes existingPageTitles when pages reference is unchanged", () => { + const pages = [ + { id: "p1", title: "Alpha", isDeleted: false }, + { id: "p2", title: "Beta", isDeleted: false }, + ]; + mockUsePagesSummary.mockReturnValue({ data: pages }); + + const { rerender } = renderHook(() => + useAIChatPanelContentLogic({ + activeConversationId: null, + setActiveConversation: baseSetActiveConversation, + contextEnabled: false, + }), + ); + + const titlesA = (aiChatHookSpy.options as { existingPageTitles: string[] }).existingPageTitles; + rerender(); + const titlesB = (aiChatHookSpy.options as { existingPageTitles: string[] }).existingPageTitles; + + expect(titlesA).toBe(titlesB); + }); +}); + +describe("useAIChatPanelContentLogic - handlers wiring", () => { + it("handleSendMessage creates a conversation snapshot from pageContext on first send", async () => { + mockCreateConversation.mockReturnValue({ + id: "new-conv", + title: "", + messageMap: {}, + rootMessageId: null, + activeLeafId: null, + createdAt: 0, + updatedAt: 0, + }); + + const setActive = vi.fn(); + const { result } = renderHook(() => + useAIChatPanelContentLogic({ + activeConversationId: null, + setActiveConversation: setActive, + contextEnabled: false, + }), + ); + + await act(async () => { + await result.current.handleSendMessage("hi", [] as ReferencedPage[]); + }); + + expect(mockCreateConversation).toHaveBeenCalledTimes(1); + // 実装は editorContext の `{ type, pageId, pageTitle }` だけを抜き出してスナップショット化するので + // pageFullContent などの他のフィールドは含めずに比較する。 + // The handler should pass a snapshot containing only the discriminant fields, + // not the full PageContext (e.g. pageFullContent must be excluded). + expect(mockCreateConversation).toHaveBeenCalledWith({ + type: editorContext.type, + pageId: editorContext.pageId, + pageTitle: editorContext.pageTitle, + }); + expect(setActive).toHaveBeenCalledWith("new-conv"); + expect(mockSendMessage).toHaveBeenCalledWith("hi", []); + }); + + it("handleSelectConversation just delegates to setActiveConversation", () => { + const setActive = vi.fn(); + const { result } = renderHook(() => + useAIChatPanelContentLogic({ + activeConversationId: "c1", + setActiveConversation: setActive, + contextEnabled: false, + }), + ); + + act(() => { + result.current.handleSelectConversation("c2"); + }); + + expect(setActive).toHaveBeenCalledWith("c2"); + expect(mockCreateConversation).not.toHaveBeenCalled(); + }); + + it("handleDeleteConversation clears active when deleting the active id", () => { + const setActive = vi.fn(); + const { result } = renderHook(() => + useAIChatPanelContentLogic({ + activeConversationId: "c1", + setActiveConversation: setActive, + contextEnabled: false, + }), + ); + + act(() => { + result.current.handleDeleteConversation("c1"); + }); + + expect(mockDeleteConversation).toHaveBeenCalledWith("c1"); + expect(setActive).toHaveBeenCalledWith(null); + expect(mockClearMessages).toHaveBeenCalledTimes(1); + }); + + it("handleDeleteConversation leaves active alone when deleting another id", () => { + const setActive = vi.fn(); + const { result } = renderHook(() => + useAIChatPanelContentLogic({ + activeConversationId: "c1", + setActiveConversation: setActive, + contextEnabled: false, + }), + ); + + act(() => { + result.current.handleDeleteConversation("c-other"); + }); + + expect(mockDeleteConversation).toHaveBeenCalledWith("c-other"); + expect(setActive).not.toHaveBeenCalled(); + expect(mockClearMessages).not.toHaveBeenCalled(); + }); + + it("handleEditMessage forwards to editAndResend", () => { + const { result } = renderHook(() => + useAIChatPanelContentLogic({ + activeConversationId: "c1", + setActiveConversation: baseSetActiveConversation, + contextEnabled: false, + }), + ); + + act(() => { + result.current.handleEditMessage("m1", "new"); + }); + + expect(mockEditAndResend).toHaveBeenCalledWith("m1", "new"); + }); + + it("handleSelectBranch navigates and switches view tab to chat", () => { + const { result } = renderHook(() => + useAIChatPanelContentLogic({ + activeConversationId: "c1", + setActiveConversation: baseSetActiveConversation, + contextEnabled: false, + }), + ); + + act(() => { + // Move to a different tab first to assert the switch back to "chat". + result.current.setActiveViewTab("branchTree"); + }); + expect(result.current.activeViewTab).toBe("branchTree"); + + act(() => { + result.current.handleSelectBranch("u1"); + }); + + expect(mockNavigateToNode).toHaveBeenCalledWith("u1"); + expect(result.current.activeViewTab).toBe("chat"); + }); + + it("handleBranchFrom on a user message prefills input with branch text", () => { + mockPrepareBranchFromUserMessage.mockReturnValue("draft from user"); + + const { result } = renderHook(() => + useAIChatPanelContentLogic({ + activeConversationId: "c1", + setActiveConversation: baseSetActiveConversation, + contextEnabled: false, + }), + ); + + act(() => { + result.current.handleBranchFrom("u1"); + }); + + expect(mockSetBranchPoint).toHaveBeenCalledWith("u1"); + expect(mockPrepareBranchFromUserMessage).toHaveBeenCalledWith("u1"); + expect(result.current.inputPrefill?.text).toBe("draft from user"); + expect(result.current.activeViewTab).toBe("chat"); + }); + + it("handleBranchFrom is a no-op when nodeId is unknown", () => { + const { result } = renderHook(() => + useAIChatPanelContentLogic({ + activeConversationId: "c1", + setActiveConversation: baseSetActiveConversation, + contextEnabled: false, + }), + ); + + act(() => { + result.current.handleBranchFrom("unknown"); + }); + + expect(mockSetBranchPoint).not.toHaveBeenCalled(); + expect(mockPrepareBranchFromUserMessage).not.toHaveBeenCalled(); + }); + + it("handleDeleteBranchFromTree forwards to deleteBranch", () => { + const { result } = renderHook(() => + useAIChatPanelContentLogic({ + activeConversationId: "c1", + setActiveConversation: baseSetActiveConversation, + contextEnabled: false, + }), + ); + + act(() => { + result.current.handleDeleteBranchFromTree("u1"); + }); + + expect(mockDeleteBranch).toHaveBeenCalledWith("u1"); + }); + + it("stopStreaming and switchBranch on the returned object call the underlying hook", () => { + const { result } = renderHook(() => + useAIChatPanelContentLogic({ + activeConversationId: "c1", + setActiveConversation: baseSetActiveConversation, + contextEnabled: false, + }), + ); + + act(() => { + result.current.stopStreaming(); + result.current.switchBranch("u1", "next"); + }); + + expect(mockStopStreaming).toHaveBeenCalledTimes(1); + expect(mockSwitchBranch).toHaveBeenCalledWith("u1", "next"); + }); +}); + +// React unused-import guard: keeps module reference live for JSX runtime. +void React; diff --git a/src/hooks/useAISettings.test.ts b/src/hooks/useAISettings.test.ts new file mode 100644 index 00000000..7935eb73 --- /dev/null +++ b/src/hooks/useAISettings.test.ts @@ -0,0 +1,406 @@ +/** + * Tests for {@link useAISettings}. + * {@link useAISettings} のテスト。 + * + * Issue #743: cover async load fallback paths, provider/model switching rules, + * connection-test side effects (model list refresh + selected model fallback), + * save flow, and reset. + * Issue #743: 非同期ロードのフォールバック分岐、プロバイダー/モデル切り替え規則、 + * 接続テストの副作用(モデル一覧更新と選択モデルのフォールバック)、save、reset を検証する。 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import type { AISettings, AIProviderType } from "@/types/ai"; +import type { ConnectionTestResult } from "@/lib/aiClient"; + +const mockLoadAISettings = vi.fn<() => Promise>(); +const mockSaveAISettings = vi.fn<(s: AISettings) => Promise>(); +const mockClearAISettings = vi.fn<() => void>(); +const mockGetDefaultAISettings = vi.fn<() => AISettings>(); + +const mockTestConnection = + vi.fn<(provider: AIProviderType, apiKey: string) => Promise>(); +const mockGetAvailableModels = vi.fn<(provider: AIProviderType) => string[]>(); +const mockClearModelsCache = vi.fn<() => void>(); + +vi.mock("@/lib/aiSettings", () => ({ + loadAISettings: () => mockLoadAISettings(), + saveAISettings: (s: AISettings) => mockSaveAISettings(s), + clearAISettings: () => mockClearAISettings(), + getDefaultAISettings: () => mockGetDefaultAISettings(), +})); + +vi.mock("@/lib/aiClient", () => ({ + testConnection: (provider: AIProviderType, apiKey: string) => + mockTestConnection(provider, apiKey), + getAvailableModels: (provider: AIProviderType) => mockGetAvailableModels(provider), + clearModelsCache: () => mockClearModelsCache(), +})); + +import { useAISettings } from "./useAISettings"; + +// テスト失敗時も console.error 等の spy が確実に元に戻るよう、ファイル全体で後始末する。 +// File-wide cleanup so any console spy (e.g. console.error) is restored even +// when an assertion throws before the test reaches its inline mockRestore(). +afterEach(() => { + vi.restoreAllMocks(); +}); + +const baseDefaults: AISettings = { + provider: "google", + apiKey: "", + apiMode: "api_server", + model: "gemini-3-flash-preview", + modelId: "google:gemini-3-flash-preview", + isConfigured: false, +}; + +describe("useAISettings - initial load", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetDefaultAISettings.mockReturnValue({ ...baseDefaults }); + mockGetAvailableModels.mockReturnValue(["gemini-3-flash-preview", "gemini-3-pro-preview"]); + }); + + it("loads stored settings and uses cached models for the loaded provider", async () => { + const stored: AISettings = { + ...baseDefaults, + provider: "openai", + model: "gpt-5.2", + modelId: "openai:gpt-5.2", + apiKey: "sk-test", + apiMode: "user_api_key", + isConfigured: true, + }; + mockLoadAISettings.mockResolvedValue(stored); + mockGetAvailableModels.mockReturnValue(["gpt-5.2", "gpt-5-mini"]); + + const { result } = renderHook(() => useAISettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.settings).toEqual(stored); + expect(result.current.availableModels).toEqual(["gpt-5.2", "gpt-5-mini"]); + expect(mockGetAvailableModels).toHaveBeenCalledWith("openai"); + }); + + it("falls back to defaults when no stored settings exist", async () => { + mockLoadAISettings.mockResolvedValue(null); + + const { result } = renderHook(() => useAISettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.settings).toEqual(baseDefaults); + expect(result.current.availableModels.length).toBeGreaterThan(0); + }); + + it("falls back to defaults and logs error when loadAISettings rejects", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + mockLoadAISettings.mockRejectedValue(new Error("decryption failed")); + + const { result } = renderHook(() => useAISettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.settings).toEqual(baseDefaults); + expect(errorSpy).toHaveBeenCalled(); + // The file-wide afterEach restores all spies, so no inline mockRestore is needed. + }); +}); + +describe("useAISettings - updateSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetDefaultAISettings.mockReturnValue({ ...baseDefaults }); + mockGetAvailableModels.mockImplementation((provider: AIProviderType) => { + if (provider === "openai") return ["gpt-5.2", "gpt-5-mini"]; + if (provider === "anthropic") return ["claude-opus-4-6"]; + return ["gemini-3-flash-preview", "gemini-3-pro-preview"]; + }); + mockLoadAISettings.mockResolvedValue({ ...baseDefaults }); + }); + + it("changing provider swaps the model list and picks the first model", async () => { + const { result } = renderHook(() => useAISettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + act(() => { + result.current.updateSettings({ provider: "openai" }); + }); + + expect(result.current.settings.provider).toBe("openai"); + expect(result.current.availableModels).toEqual(["gpt-5.2", "gpt-5-mini"]); + expect(result.current.settings.model).toBe("gpt-5.2"); + // modelId は明示指定がないのでクリアされる + expect(result.current.settings.modelId).toBe(""); + }); + + it("respects an explicit model when provider changes", async () => { + const { result } = renderHook(() => useAISettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + act(() => { + result.current.updateSettings({ provider: "openai", model: "gpt-5-mini" }); + }); + + expect(result.current.settings.provider).toBe("openai"); + expect(result.current.settings.model).toBe("gpt-5-mini"); + }); + + it("changing only the model also clears modelId unless explicit", async () => { + const { result } = renderHook(() => useAISettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + act(() => { + result.current.updateSettings({ model: "gemini-3-pro-preview" }); + }); + + expect(result.current.settings.model).toBe("gemini-3-pro-preview"); + expect(result.current.settings.modelId).toBe(""); + }); + + it("updateSettings preserves modelId when caller passes it explicitly", async () => { + const { result } = renderHook(() => useAISettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + act(() => { + result.current.updateSettings({ + model: "gemini-3-pro-preview", + modelId: "google:gemini-3-pro-preview", + }); + }); + + expect(result.current.settings.modelId).toBe("google:gemini-3-pro-preview"); + }); + + it("updateSettings resets the previous testResult", async () => { + mockTestConnection.mockResolvedValue({ + success: true, + message: "ok", + models: ["gpt-5.2"], + } satisfies ConnectionTestResult); + + const { result } = renderHook(() => useAISettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + act(() => { + result.current.updateSettings({ provider: "openai", apiKey: "sk-test" }); + }); + + await act(async () => { + await result.current.test(); + }); + expect(result.current.testResult?.success).toBe(true); + + act(() => { + result.current.updateSettings({ apiKey: "sk-new" }); + }); + expect(result.current.testResult).toBeNull(); + }); +}); + +describe("useAISettings - save", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetDefaultAISettings.mockReturnValue({ ...baseDefaults }); + mockGetAvailableModels.mockReturnValue(["gemini-3-flash-preview"]); + mockLoadAISettings.mockResolvedValue({ ...baseDefaults }); + mockSaveAISettings.mockResolvedValue(undefined); + }); + + it("save returns true and sends a namespaced modelId", async () => { + const { result } = renderHook(() => useAISettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + act(() => { + result.current.updateSettings({ + provider: "openai", + model: "gpt-5.2", + apiKey: "sk-test", + apiMode: "user_api_key", + }); + }); + + let saved = false; + await act(async () => { + saved = await result.current.save(); + }); + + expect(saved).toBe(true); + expect(mockSaveAISettings).toHaveBeenCalledTimes(1); + const arg = mockSaveAISettings.mock.calls[0][0] as AISettings; + expect(arg.modelId).toBe("openai:gpt-5.2"); + expect(arg.isConfigured).toBe(true); + }); + + it("save uses the special claude-code:default modelId when provider is claude-code", async () => { + const { result } = renderHook(() => useAISettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + act(() => { + result.current.updateSettings({ provider: "claude-code" }); + }); + await act(async () => { + await result.current.save(); + }); + + const arg = mockSaveAISettings.mock.calls[0][0] as AISettings; + expect(arg.modelId).toBe("claude-code:default"); + expect(arg.isConfigured).toBe(true); + }); + + it("save marks isConfigured=false when API key is required but missing", async () => { + const { result } = renderHook(() => useAISettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + act(() => { + result.current.updateSettings({ + provider: "anthropic", + apiKey: "", + apiMode: "user_api_key", + }); + }); + await act(async () => { + await result.current.save(); + }); + + const arg = mockSaveAISettings.mock.calls[0][0] as AISettings; + expect(arg.isConfigured).toBe(false); + }); + + it("save returns false when saveAISettings rejects", async () => { + mockSaveAISettings.mockRejectedValueOnce(new Error("storage")); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { result } = renderHook(() => useAISettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + let saved: boolean | undefined; + await act(async () => { + saved = await result.current.save(); + }); + + expect(saved).toBe(false); + expect(result.current.isSaving).toBe(false); + expect(errorSpy).toHaveBeenCalled(); + // Spy restoration is handled by the file-wide afterEach. + }); +}); + +describe("useAISettings - test (connection)", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetDefaultAISettings.mockReturnValue({ ...baseDefaults }); + mockGetAvailableModels.mockReturnValue(["gemini-3-flash-preview"]); + mockLoadAISettings.mockResolvedValue({ + ...baseDefaults, + provider: "openai", + model: "gpt-5.2", + apiKey: "sk-test", + apiMode: "user_api_key", + }); + }); + + it("test returns the result and refreshes availableModels on success", async () => { + mockTestConnection.mockResolvedValue({ + success: true, + message: "ok", + models: ["gpt-5.2", "gpt-5-mini", "gpt-5-nano"], + } satisfies ConnectionTestResult); + + const { result } = renderHook(() => useAISettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + let returned: ConnectionTestResult | undefined; + await act(async () => { + returned = await result.current.test(); + }); + + expect(returned?.success).toBe(true); + expect(result.current.availableModels).toEqual(["gpt-5.2", "gpt-5-mini", "gpt-5-nano"]); + expect(result.current.testResult?.success).toBe(true); + expect(result.current.isTesting).toBe(false); + expect(mockTestConnection).toHaveBeenCalledWith("openai", "sk-test"); + }); + + it("falls back to first model when current model is missing from refreshed list", async () => { + mockTestConnection.mockResolvedValue({ + success: true, + message: "ok", + models: ["gpt-5-mini", "gpt-5-nano"], + } satisfies ConnectionTestResult); + + const { result } = renderHook(() => useAISettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.test(); + }); + + expect(result.current.settings.model).toBe("gpt-5-mini"); + expect(result.current.settings.modelId).toBe("openai:gpt-5-mini"); + }); + + it("preserves selected model when it is still in the refreshed list", async () => { + mockTestConnection.mockResolvedValue({ + success: true, + message: "ok", + models: ["gpt-5.2", "gpt-5-mini"], + } satisfies ConnectionTestResult); + + const { result } = renderHook(() => useAISettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.test(); + }); + + expect(result.current.settings.model).toBe("gpt-5.2"); + }); + + it("captures errors thrown synchronously by testConnection", async () => { + // 同期 throw を再現することでテスト名と挙動を一致させる。 + // Use a synchronous throw so the test exercises the path implied by its title. + mockTestConnection.mockImplementationOnce(() => { + throw new Error("network down"); + }); + + const { result } = renderHook(() => useAISettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + let returned: ConnectionTestResult | undefined; + await act(async () => { + returned = await result.current.test(); + }); + + expect(returned?.success).toBe(false); + expect(returned?.error).toBe("network down"); + expect(result.current.testResult?.success).toBe(false); + expect(result.current.isTesting).toBe(false); + }); +}); + +describe("useAISettings - reset", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetDefaultAISettings.mockReturnValue({ ...baseDefaults }); + mockGetAvailableModels.mockReturnValue(["gemini-3-flash-preview"]); + mockLoadAISettings.mockResolvedValue({ + ...baseDefaults, + provider: "openai", + apiKey: "sk-test", + }); + }); + + it("clears persisted settings, model cache, and resets state to defaults", async () => { + const { result } = renderHook(() => useAISettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + act(() => { + result.current.reset(); + }); + + expect(mockClearAISettings).toHaveBeenCalledTimes(1); + expect(mockClearModelsCache).toHaveBeenCalledTimes(1); + expect(result.current.settings).toEqual(baseDefaults); + expect(result.current.testResult).toBeNull(); + }); +}); diff --git a/src/hooks/useGeneralSettings.test.ts b/src/hooks/useGeneralSettings.test.ts new file mode 100644 index 00000000..27016e7b --- /dev/null +++ b/src/hooks/useGeneralSettings.test.ts @@ -0,0 +1,247 @@ +/** + * Tests for {@link useGeneralSettings}. + * {@link useGeneralSettings} のテスト。 + * + * Issue #743: cover load/save lifecycle, theme/locale sync, font-size clamping, + * and error handling on persistence failures. + * Issue #743: 読み込み/保存ライフサイクル、テーマ/言語同期、フォントサイズの clamp、 + * 永続化失敗時のエラーハンドリングを検証する。 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; + +const mockSetTheme = vi.fn(); +const mockChangeLanguage = vi.fn(); +const mockLoadGeneralSettings = vi.fn(); +const mockSaveGeneralSettings = vi.fn(); + +// `useTheme()` / `useTranslation()` の戻り値はレンダーごとに同一参照に固定する。 +// 内部 effect の依存配列が `[setTheme, i18n]` のため、毎回新しい参照を返すと +// effect が再実行され、テスト中の状態更新が `loadGeneralSettings` の戻り値で +// 上書きされてしまう。 +// Stabilize hook return values across renders. The component-side effect +// depends on `[setTheme, i18n]`, so unstable references cause it to re-run +// after every state update and clobber the test's local mutations. +const stableI18n = { changeLanguage: mockChangeLanguage }; +const stableTheme = { setTheme: mockSetTheme }; + +vi.mock("next-themes", () => ({ + useTheme: () => stableTheme, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + i18n: stableI18n, + t: (key: string) => key, + }), +})); + +vi.mock("@/lib/generalSettings", () => ({ + loadGeneralSettings: () => mockLoadGeneralSettings(), + saveGeneralSettings: (settings: unknown) => mockSaveGeneralSettings(settings), +})); + +import { DEFAULT_GENERAL_SETTINGS } from "@/types/generalSettings"; +import { useGeneralSettings } from "./useGeneralSettings"; + +describe("useGeneralSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockLoadGeneralSettings.mockReturnValue({ ...DEFAULT_GENERAL_SETTINGS }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("loads stored settings and syncs theme + locale on mount", async () => { + mockLoadGeneralSettings.mockReturnValue({ + ...DEFAULT_GENERAL_SETTINGS, + theme: "dark", + locale: "en", + }); + + const { result } = renderHook(() => useGeneralSettings()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.settings.theme).toBe("dark"); + expect(result.current.settings.locale).toBe("en"); + expect(mockSetTheme).toHaveBeenCalledWith("dark"); + expect(mockChangeLanguage).toHaveBeenCalledWith("en"); + }); + + it("updateTheme persists, updates state, and syncs next-themes", async () => { + const { result } = renderHook(() => useGeneralSettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + mockSaveGeneralSettings.mockClear(); + mockSetTheme.mockClear(); + + act(() => { + result.current.updateTheme("light"); + }); + + expect(result.current.settings.theme).toBe("light"); + expect(mockSetTheme).toHaveBeenCalledWith("light"); + expect(mockSaveGeneralSettings).toHaveBeenCalledTimes(1); + expect(mockSaveGeneralSettings).toHaveBeenCalledWith( + expect.objectContaining({ theme: "light" }), + ); + }); + + it("updateEditorFontSize persists the new preset", async () => { + const { result } = renderHook(() => useGeneralSettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + mockSaveGeneralSettings.mockClear(); + + act(() => { + result.current.updateEditorFontSize("large"); + }); + + expect(result.current.settings.editorFontSize).toBe("large"); + expect(mockSaveGeneralSettings).toHaveBeenCalledWith( + expect.objectContaining({ editorFontSize: "large" }), + ); + }); + + it("updateCustomFontSizePx clamps below 12", async () => { + const { result } = renderHook(() => useGeneralSettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + act(() => { + result.current.updateCustomFontSizePx(2); + }); + + expect(result.current.settings.customFontSizePx).toBe(12); + }); + + it("updateCustomFontSizePx clamps above 24", async () => { + const { result } = renderHook(() => useGeneralSettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + act(() => { + result.current.updateCustomFontSizePx(99); + }); + + expect(result.current.settings.customFontSizePx).toBe(24); + }); + + it("updateCustomFontSizePx keeps values within range as-is", async () => { + const { result } = renderHook(() => useGeneralSettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + act(() => { + result.current.updateCustomFontSizePx(20); + }); + + expect(result.current.settings.customFontSizePx).toBe(20); + }); + + it("updateLocale persists and triggers i18n.changeLanguage", async () => { + const { result } = renderHook(() => useGeneralSettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + mockChangeLanguage.mockClear(); + mockSaveGeneralSettings.mockClear(); + + act(() => { + result.current.updateLocale("en"); + }); + + expect(result.current.settings.locale).toBe("en"); + expect(mockChangeLanguage).toHaveBeenCalledWith("en"); + expect(mockSaveGeneralSettings).toHaveBeenCalledWith(expect.objectContaining({ locale: "en" })); + }); + + it("updateExecutableCodeConfirmBeforeRun toggles persisted flag", async () => { + const { result } = renderHook(() => useGeneralSettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + mockSaveGeneralSettings.mockClear(); + + act(() => { + result.current.updateExecutableCodeConfirmBeforeRun(false); + }); + + expect(result.current.settings.executableCodeConfirmBeforeRun).toBe(false); + expect(mockSaveGeneralSettings).toHaveBeenCalledWith( + expect.objectContaining({ executableCodeConfirmBeforeRun: false }), + ); + }); + + it("save returns true, persists via saveGeneralSettings, and toggles isSaving flag", async () => { + const { result } = renderHook(() => useGeneralSettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + // 初回ロードで saveGeneralSettings は呼ばれていないが、念のためクリアしてから assert する。 + // Clear any prior calls so the assertion below pinpoints save()'s side effect. + mockSaveGeneralSettings.mockClear(); + + let saved = false; + await act(async () => { + saved = await result.current.save(); + }); + + expect(saved).toBe(true); + expect(mockSaveGeneralSettings).toHaveBeenCalledTimes(1); + expect(mockSaveGeneralSettings).toHaveBeenCalledWith(result.current.settings); + expect(result.current.isSaving).toBe(false); + }); + + it("save returns false and logs when saveGeneralSettings throws", async () => { + const { result } = renderHook(() => useGeneralSettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + mockSaveGeneralSettings.mockImplementationOnce(() => { + throw new Error("disk full"); + }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + let saved: boolean | undefined; + await act(async () => { + saved = await result.current.save(); + }); + + expect(saved).toBe(false); + expect(errorSpy).toHaveBeenCalled(); + expect(result.current.isSaving).toBe(false); + }); + + it("editorFontSizePx resolves preset px from FONT_SIZE_OPTIONS", async () => { + mockLoadGeneralSettings.mockReturnValue({ + ...DEFAULT_GENERAL_SETTINGS, + editorFontSize: "large", + }); + + const { result } = renderHook(() => useGeneralSettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.editorFontSizePx).toBe(18); + }); + + it("editorFontSizePx resolves customFontSizePx when editorFontSize is custom", async () => { + mockLoadGeneralSettings.mockReturnValue({ + ...DEFAULT_GENERAL_SETTINGS, + editorFontSize: "custom", + customFontSizePx: 22, + }); + + const { result } = renderHook(() => useGeneralSettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.editorFontSizePx).toBe(22); + }); + + it("editorFontSizePx falls back to 16 when custom px is missing", async () => { + mockLoadGeneralSettings.mockReturnValue({ + ...DEFAULT_GENERAL_SETTINGS, + editorFontSize: "custom", + customFontSizePx: undefined, + }); + + const { result } = renderHook(() => useGeneralSettings()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.editorFontSizePx).toBe(16); + }); +}); diff --git a/src/hooks/useGlobalSearch.test.ts b/src/hooks/useGlobalSearch.test.ts index f56f493b..7cf3b98b 100644 --- a/src/hooks/useGlobalSearch.test.ts +++ b/src/hooks/useGlobalSearch.test.ts @@ -1,7 +1,13 @@ import { describe, it, expect } from "vitest"; -import { searchPages } from "./useGlobalSearch"; +import { + searchPages, + buildGlobalSearchResults, + dedupSharedRowsAgainstPersonal, +} from "./useGlobalSearch"; import type { Page } from "@/types/page"; +import type { SearchSharedResponse } from "@/lib/api/types"; import { createPlainTextContent } from "@/test/testDatabase"; +import { parseSearchQuery } from "@/lib/searchUtils"; // Helper to create a test page function createTestPage(id: string, title: string, content: string, options?: Partial): Page { @@ -263,3 +269,173 @@ describe("searchPages", () => { }); }); }); + +/** + * 共有検索の row 雛形を作るテストヘルパー。サーバーは個人ページも `note_id IS NULL` + * で返してくる仕様 (Issue #718 Phase 5-1) なので、両方のケースを並べて検証できる + * よう `note_id` を任意で受け取る。 + * + * Test helper that builds a `SearchSharedResponse` row. The server still + * returns personal pages with `note_id: null` under `scope=shared`, so the + * tests below need to construct both shapes side-by-side. + */ +function createSharedRow( + overrides: Partial & + Pick, +): SearchSharedResponse["results"][number] { + return { + id: overrides.id, + note_id: null, + owner_id: "u1", + title: "shared", + content_preview: "preview", + thumbnail_url: null, + source_url: null, + updated_at: new Date().toISOString(), + ...overrides, + }; +} + +function createPersonalPage(id: string, title: string): Page { + const now = Date.now(); + return { + id, + ownerUserId: "u1", + noteId: null, + title, + content: createPlainTextContent("body"), + createdAt: now, + updatedAt: now, + isDeleted: false, + }; +} + +describe("buildGlobalSearchResults", () => { + /** + * Issue #718 Phase 5-4 (Codex / CodeRabbit 指摘反映後): + * dedup は `pageId` の集合一致でのみ行う。`note_id IS NULL` の shared 行は + * IDB に無いリンク済み個人ページの場合があり、安易に落とすと検索結果から + * 漏れる。 + * + * Phase 5-4 dedup contract (post Codex/CodeRabbit fix): only drop shared + * rows whose `pageId` is already in the personal IDB result set. Don't use + * `note_id` as a proxy because linked personal pages reachable via note + * membership/ownership can have `note_id IS NULL` and aren't in IDB. + */ + describe("Issue #718 Phase 5-4: dedup", () => { + it("drops shared rows that overlap by id with personal IDB results", () => { + const query = "alpha"; + const keywords = parseSearchQuery(query); + const personal: Page[] = [createPersonalPage("p1", "alpha")]; + const shared = [ + createSharedRow({ id: "p1", note_id: null, title: "alpha" }), + createSharedRow({ id: "p2", note_id: "note-1", title: "alpha note" }), + ]; + + const results = buildGlobalSearchResults(personal, shared, query, keywords); + + const ids = results.map((r) => r.pageId).sort(); + // `p1` だけが personal 由来で 1 回、`p2` がノート由来で 1 回。 + // `p1` only appears once (personal), `p2` once (shared note-native). + expect(ids).toEqual(["p1", "p2"]); + }); + + it("keeps note-native shared rows with noteId for canonical /notes/:noteId/:pageId routing", () => { + const query = "alpha"; + const keywords = parseSearchQuery(query); + const shared = [createSharedRow({ id: "p3", note_id: "note-9", title: "alpha shared" })]; + + const results = buildGlobalSearchResults([], shared, query, keywords); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + pageId: "p3", + noteId: "note-9", + }); + }); + + it("preserves linked personal pages (note_id IS NULL) not present in IDB", () => { + // 他ユーザー所有のリンク済み個人ページや、IDB がまだ hydrate されて + // いない時点での自分の個人ページ。`note_id IS NULL` だが personal 結果に + // 居ないので shared 側に残す必要がある (Codex / CodeRabbit 指摘)。 + // + // Linked personal pages owned by other note members — or the caller's + // own personal pages before IDB has hydrated — have `note_id IS NULL` + // but are not yet in the personal results, so they must survive the + // dedup (Codex / CodeRabbit review). + const query = "alpha"; + const keywords = parseSearchQuery(query); + const shared = [ + createSharedRow({ + id: "linked-personal", + note_id: null, + title: "alpha linked personal", + owner_id: "other-user", + }), + ]; + + const results = buildGlobalSearchResults([], shared, query, keywords); + + expect(results).toHaveLength(1); + expect(results[0].pageId).toBe("linked-personal"); + // `noteId` は undefined のままなので、UI 側は /pages/:id にルーティングする。 + // `noteId` stays undefined so the UI routes to /pages/:id. + expect(results[0].noteId).toBeUndefined(); + }); + + it("does not over-drop when IDB has not loaded yet", () => { + // 初回ロード等で `useSearchPages` が空配列を返す場合、shared 結果は + // 何も落とされず全件残るはず。 + // When IDB has not hydrated and `useSearchPages` returns [], every + // shared row must survive — otherwise the user sees no hits at all. + const query = "alpha"; + const keywords = parseSearchQuery(query); + const shared = [ + createSharedRow({ id: "a", note_id: null, title: "alpha" }), + createSharedRow({ id: "b", note_id: "n1", title: "alpha b" }), + ]; + + const results = buildGlobalSearchResults([], shared, query, keywords); + + expect(results.map((r) => r.pageId).sort()).toEqual(["a", "b"]); + }); + + it("returns empty when query is shorter than 3 chars", () => { + const query = "ab"; + const shared = [createSharedRow({ id: "p1", note_id: "n1" })]; + const results = buildGlobalSearchResults([], shared, query, parseSearchQuery(query)); + expect(results).toEqual([]); + }); + }); +}); + +describe("dedupSharedRowsAgainstPersonal (Issue #718 Phase 5-4)", () => { + it("drops rows whose id is in the personal id set", () => { + const rows = [ + createSharedRow({ id: "p1", note_id: null }), + createSharedRow({ id: "p2", note_id: "n1" }), + createSharedRow({ id: "p3", note_id: null }), + ]; + const personalIds = new Set(["p1"]); + + const filtered = dedupSharedRowsAgainstPersonal(rows, personalIds); + + // `p1` だけが personal 由来で重複、`p2`/`p3` は残る (リンク済み個人 / ノート)。 + // Only `p1` overlaps with the personal result set; `p2` (note-native) and + // `p3` (linked personal not in IDB) survive. + expect(filtered.map((r) => r.id).sort()).toEqual(["p2", "p3"]); + }); + + it("keeps every row when the personal id set is empty (IDB not hydrated)", () => { + const rows = [ + createSharedRow({ id: "p1", note_id: null }), + createSharedRow({ id: "p2", note_id: "n1" }), + ]; + const filtered = dedupSharedRowsAgainstPersonal(rows, new Set()); + expect(filtered).toHaveLength(2); + }); + + it("handles empty input", () => { + expect(dedupSharedRowsAgainstPersonal([], new Set(["p1"]))).toEqual([]); + }); +}); diff --git a/src/hooks/useGlobalSearch.ts b/src/hooks/useGlobalSearch.ts index 1b9db933..72dd4360 100644 --- a/src/hooks/useGlobalSearch.ts +++ b/src/hooks/useGlobalSearch.ts @@ -11,6 +11,44 @@ import { calculateEnhancedScore, } from "@/lib/searchUtils"; import type { Page } from "@/types/page"; +import type { SearchSharedResponse } from "@/lib/api/types"; + +type SharedResultRow = SearchSharedResponse["results"][number]; + +/** + * Issue #718 Phase 5-4 の dedup 契約を一箇所に集約するヘルパー。 + * + * `scope=shared` レスポンスは以下の 3 種類のページを返す + * (`server/api/src/routes/search.ts`): + * + * 1. 呼び出し元自身の個人ページ (`owner_id = me AND note_id IS NULL`) + * 2. ノートメンバーシップ / オーナーシップ経由で見えるリンク済み個人ページ + * (`note_pages` 経由、他ユーザー所有の `note_id IS NULL` ページも含み得る) + * 3. ノートネイティブページ (`note_id IS NOT NULL`) + * + * IDB は (1) しか持たないので、ここでは「`useSearchPages` で既に出ている page + * id」を集合で受け取り、それと一致する shared 行だけを落とす。`note_id` の + * null/non-null では判定しない (それだと (2) のリンク済み個人ページが脱落する。 + * Codex 指摘)。 + * + * Centralizes the Phase 5-4 dedup contract. `scope=shared` returns three kinds + * of rows: (1) the caller's own personal pages, (2) linked personal pages + * visible through note membership or ownership (these may belong to other + * users and can have `note_id IS NULL`), and (3) note-native pages. IDB only + * holds (1), so we dedup against the personal page id set instead of using + * `note_id` as a proxy — otherwise (2) would silently disappear (Codex + * review). + * + * @param rows shared 検索 API のレスポンス行 / Rows from the shared search API. + * @param personalIds `useSearchPages` (IDB) で既に出ている page id の集合 / + * Set of page ids already present in personal search results. + */ +export function dedupSharedRowsAgainstPersonal( + rows: readonly T[], + personalIds: ReadonlySet, +): T[] { + return rows.filter((r) => !personalIds.has(r.id)); +} /** * @@ -78,12 +116,89 @@ export function searchPages(pages: Page[], query: string): SearchResult[] { .slice(0, 10); } +/** + * 個人ページ (IDB) と shared 検索結果 (API) を統合してグローバル検索結果に + * 整形する pure 関数。`useGlobalSearch` がメモ化して呼ぶが、振る舞いを + * 単独でテストできるようエクスポートする (Issue #718 Phase 5-4)。 + * + * Pure helper that fuses personal-IDB results and the shared API response + * into the unified `GlobalSearchResultItem` list rendered in the search UI. + * Exported so the dedup behavior can be tested without spinning up React + * Query (Issue #718 Phase 5-4). + * + * **Dedup contract**: dedup is by `pageId` against the personal id set — + * see {@link dedupSharedRowsAgainstPersonal}. We can't filter by `note_id` + * alone because the server's `scope=shared` SQL returns linked personal + * pages (other users' `note_id IS NULL` pages reachable via `note_pages`) + * that are NOT covered by IDB. + * + * **重複排除の契約**: dedup は `pageId` 一致でのみ行う + * ({@link dedupSharedRowsAgainstPersonal})。`note_id IS NULL` の中には IDB に + * 載っていないリンク済み個人ページが混ざるので、`note_id` での絞り込みは不可。 + */ +export function buildGlobalSearchResults( + personalPages: Page[], + sharedRows: SharedResultRow[], + query: string, + keywords: string[], + limit = 10, +): GlobalSearchResultItem[] { + if (query.trim().length < 3 || keywords.length === 0) return []; + + // 中間 sort は不要(最後にまとめて score 降順で並べ直す)。Gemini レビュー指摘。 + // No intermediate sort here — the final merge sorts by score (Gemini review). + const personal: Array = personalPages + .filter((page) => !page.isDeleted) + .map((page) => { + const content = extractPlainText(page.content); + const matchType = determineMatchType(page.title, content, keywords, query); + const score = calculateEnhancedScore(page, keywords, matchType); + const matchedText = extractSmartSnippet(content, keywords); + const highlightedText = highlightKeywords(matchedText, keywords); + return { + pageId: page.id, + title: page.title || "無題のページ", + highlightedText, + matchType, + sourceUrl: page.sourceUrl, + score, + }; + }); + + const personalIds = new Set(personal.map((p) => p.pageId)); + const shared: Array = dedupSharedRowsAgainstPersonal( + sharedRows, + personalIds, + ).map((r) => { + const preview = r.content_preview ?? ""; + const highlightedText = highlightKeywords(preview, keywords); + return { + pageId: r.id, + // ノートネイティブ / リンク済みノート所属ページのみ /notes ルーティングに乗せる。 + // 単なるリンク済み個人ページ (`note_id IS NULL`) は note 側に飛ばさず /pages へ。 + // Only note-native rows route under /notes; bare linked personal rows + // (`note_id IS NULL`) keep the personal /pages destination. + noteId: r.note_id ?? undefined, + title: r.title ?? "無題のページ", + highlightedText: highlightedText || "(共有ノート)", + matchType: "content" as MatchType, + sourceUrl: r.source_url ?? undefined, + score: 0, + }; + }); + + return [...personal, ...shared] + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map(({ score: _s, ...item }) => item); +} + /** * Hook for global search functionality (C3-8: personal + shared merged). * * - Personal: StorageAdapter.searchPages via useSearchPages(). * - Shared: apiClient.searchSharedNotes via useSearchSharedNotes(). - * - Results are merged, sorted by score, and capped. + * - Merge / dedup is delegated to {@link buildGlobalSearchResults}. */ export function useGlobalSearch() { const [query, setQuery] = useState(""); @@ -96,49 +211,11 @@ export function useGlobalSearch() { const keywords = useMemo(() => parseSearchQuery(debouncedQuery), [debouncedQuery]); - const searchResults = useMemo((): GlobalSearchResultItem[] => { - if (debouncedQuery.trim().length < 3 || keywords.length === 0) return []; - - const personal: Array = serverSearchResults - .filter((page) => !page.isDeleted) - .map((page) => { - const content = extractPlainText(page.content); - const matchType = determineMatchType(page.title, content, keywords, debouncedQuery); - const score = calculateEnhancedScore(page, keywords, matchType); - const matchedText = extractSmartSnippet(content, keywords); - const highlightedText = highlightKeywords(matchedText, keywords); - return { - pageId: page.id, - title: page.title || "無題のページ", - highlightedText, - matchType, - sourceUrl: page.sourceUrl, - score, - }; - }) - .sort((a, b) => b.score - a.score); - - const shared: Array = sharedResults.map((r) => { - const preview = r.content_preview ?? ""; - const highlightedText = highlightKeywords(preview, keywords); - return { - pageId: r.id, - // `note_id` は個人ページが混ざると null になり得るので undefined に正規化する。 - // `note_id` may be null when personal pages are mixed into shared results. - noteId: r.note_id ?? undefined, - title: r.title ?? "無題のページ", - highlightedText: highlightedText || "(共有ノート)", - matchType: "content" as MatchType, - sourceUrl: r.source_url ?? undefined, - score: 0, - }; - }); - - return [...personal, ...shared] - .sort((a, b) => b.score - a.score) - .slice(0, 10) - .map(({ score: _s, ...item }) => item); - }, [serverSearchResults, sharedResults, debouncedQuery, keywords]); + const searchResults = useMemo( + (): GlobalSearchResultItem[] => + buildGlobalSearchResults(serverSearchResults, sharedResults, debouncedQuery, keywords), + [serverSearchResults, sharedResults, debouncedQuery, keywords], + ); return { query, diff --git a/src/hooks/useImageUpload.test.ts b/src/hooks/useImageUpload.test.ts index 55666537..8a85dd93 100644 --- a/src/hooks/useImageUpload.test.ts +++ b/src/hooks/useImageUpload.test.ts @@ -1,21 +1,34 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderHook, act } from "@testing-library/react"; +import { renderHook, act, waitFor } from "@testing-library/react"; +/** + * Hoisted mock state for `useImageUpload`. Each test mutates these + * containers (instead of redefining the modules) to vary settings, configured + * status, WebP conversion, and provider behavior. + * + * フック `useImageUpload` 用のホイスト済みモック。各テストはモジュール再定義 + * ではなくこれらのコンテナを書き換えて、設定・configured 判定・WebP 変換・ + * プロバイダの挙動を変える。 + */ const mocks = vi.hoisted(() => ({ providerUploadImage: vi.fn(), getToken: vi.fn().mockResolvedValue("test-token"), -})); - -vi.mock("./useStorageSettings", () => ({ - useStorageSettings: () => ({ + isStorageConfiguredForUpload: vi.fn(() => true), + getSettingsForUpload: vi.fn((settings: unknown) => settings), + convertToWebP: vi.fn(async (file: File) => file), + storageSettings: { settings: { provider: "s3", preferDefaultStorage: true, config: {}, isConfigured: true, - }, + } as unknown, isLoading: false, - }), + }, +})); + +vi.mock("./useStorageSettings", () => ({ + useStorageSettings: () => mocks.storageSettings, })); vi.mock("./useAuth", () => ({ @@ -28,31 +41,459 @@ vi.mock("@/lib/storage", () => ({ getStorageProvider: vi.fn(() => ({ uploadImage: mocks.providerUploadImage, })), - getSettingsForUpload: vi.fn((settings) => settings), - isStorageConfiguredForUpload: vi.fn(() => true), - convertToWebP: vi.fn(async (file: File) => file), + getSettingsForUpload: (settings: unknown) => mocks.getSettingsForUpload(settings), + isStorageConfiguredForUpload: () => mocks.isStorageConfiguredForUpload(), + convertToWebP: (file: File) => mocks.convertToWebP(file), })); import { useImageUpload } from "./useImageUpload"; +/** + * Build a test image File succinctly. Defaults to PNG (subject to WebP conversion). + * テスト用の画像 File を簡潔に生成する。既定は PNG(WebP 変換対象)。 + */ +function makeFile(type: string = "image/png", name = "sample.png"): File { + return new File([new Uint8Array([1, 2, 3, 4])], name, { type }); +} + describe("useImageUpload", () => { beforeEach(() => { vi.clearAllMocks(); mocks.providerUploadImage.mockResolvedValue("https://cdn.example.com/image.webp"); + mocks.isStorageConfiguredForUpload.mockReturnValue(true); + mocks.convertToWebP.mockImplementation(async (file: File) => file); + mocks.storageSettings = { + settings: { + provider: "s3", + preferDefaultStorage: true, + config: {}, + isConfigured: true, + } as unknown, + isLoading: false, + }; }); - it("forwards AbortSignal to the storage provider uploadImage call", async () => { - const { result } = renderHook(() => useImageUpload()); - const controller = new AbortController(); - const file = new File([new Uint8Array([1, 2, 3])], "sample.png", { type: "image/png" }); + describe("uploadImage", () => { + it("forwards AbortSignal to the storage provider uploadImage call", async () => { + // signal を provider に渡し、外部からのキャンセル要求を伝播させる契約を固定する。 + // Pin the contract that the abort signal reaches the provider. + const { result } = renderHook(() => useImageUpload()); + const controller = new AbortController(); + const file = makeFile(); + + await act(async () => { + await result.current.uploadImage(file, { signal: controller.signal }); + }); + + // PNG は convertToWebP を経由するが、既定モックは入力 File をそのまま返すため、 + // provider に渡るのは元の `file` インスタンスそのもの(参照同一性で検証)。 + // PNG goes through convertToWebP, but the default mock returns the input + // unchanged, so the provider receives the very same `file` instance. + expect(mocks.providerUploadImage).toHaveBeenCalledWith( + file, + expect.objectContaining({ signal: controller.signal }), + ); + }); + + it("returns the URL produced by the provider", async () => { + // 戻り値が provider.uploadImage の解決値そのままであること。 + // Pin that the hook returns the provider URL verbatim (no rewriting). + mocks.providerUploadImage.mockResolvedValueOnce("https://cdn.example.com/foo.webp"); + const { result } = renderHook(() => useImageUpload()); + + let url = ""; + await act(async () => { + url = await result.current.uploadImage(makeFile()); + }); + + expect(url).toBe("https://cdn.example.com/foo.webp"); + }); + + it("throws AbortError synchronously when signal is already aborted", async () => { + // 開始時点で abort 済みなら provider 呼び出しの前に DOMException("AbortError") を投げる。 + // Pin the early-abort guard: provider must not be called when already aborted. + const { result } = renderHook(() => useImageUpload()); + const controller = new AbortController(); + controller.abort(); + + await expect( + act(async () => { + await result.current.uploadImage(makeFile(), { signal: controller.signal }); + }), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(mocks.providerUploadImage).not.toHaveBeenCalled(); + expect(mocks.convertToWebP).not.toHaveBeenCalled(); + }); + + it("throws the configured-storage error when storage is not configured", async () => { + // 未設定時は固定文言の Error を投げ、provider を呼ばない。 + // Pin both the error message and that provider is not invoked when not configured. + mocks.isStorageConfiguredForUpload.mockReturnValue(false); + const { result } = renderHook(() => useImageUpload()); + + await expect( + act(async () => { + await result.current.uploadImage(makeFile()); + }), + ).rejects.toThrow("ストレージが設定されていません。設定画面でストレージを設定してください。"); + expect(mocks.providerUploadImage).not.toHaveBeenCalled(); + }); + + it.each([ + { type: "text/plain", name: "note.txt" }, + { type: "application/pdf", name: "doc.pdf" }, + { type: "video/mp4", name: "clip.mp4" }, + ])("throws when file type is non-image ($type)", async ({ type, name }) => { + // `image/` 以外は明示文言で拒否し、provider は呼ばない。startsWith の比較変異を殺す。 + // Pin the non-image rejection message and kill `startsWith("image/")` mutations. + const { result } = renderHook(() => useImageUpload()); + await expect( + act(async () => { + await result.current.uploadImage(makeFile(type, name)); + }), + ).rejects.toThrow("画像ファイルのみアップロードできます"); + expect(mocks.providerUploadImage).not.toHaveBeenCalled(); + }); + + it.each([ + { type: "image/jpeg", name: "photo.jpg" }, + { type: "image/png", name: "photo.png" }, + ])("converts $type to WebP before upload", async ({ type, name }) => { + // 静止画は WebP 変換を経由する。`||` の両辺(jpeg, png)を個別に検証する。 + // Pin WebP conversion for both branches of the static-image OR. + const original = makeFile(type, name); + const converted = new File([new Uint8Array([9, 9, 9])], "converted.webp", { + type: "image/webp", + }); + mocks.convertToWebP.mockResolvedValueOnce(converted); + const { result } = renderHook(() => useImageUpload()); + + await act(async () => { + await result.current.uploadImage(original); + }); + + expect(mocks.convertToWebP).toHaveBeenCalledTimes(1); + expect(mocks.convertToWebP).toHaveBeenCalledWith(original); + // provider に渡るのは変換後ファイル。 + // Provider receives the converted file, not the original. + expect(mocks.providerUploadImage).toHaveBeenCalledWith(converted, expect.any(Object)); + }); + + it.each([ + { type: "image/gif", name: "anim.gif" }, + { type: "image/webp", name: "already.webp" }, + { type: "image/svg+xml", name: "icon.svg" }, + ])("does NOT convert $type to WebP", async ({ type, name }) => { + // GIF/WebP/SVG は変換対象外。`isStaticImage` 条件のロジック反転変異を殺す。 + // Kill the `isStaticImage` boolean mutation by ensuring conversion is skipped here. + const original = makeFile(type, name); + const { result } = renderHook(() => useImageUpload()); + + await act(async () => { + await result.current.uploadImage(original); + }); + + expect(mocks.convertToWebP).not.toHaveBeenCalled(); + expect(mocks.providerUploadImage).toHaveBeenCalledWith(original, expect.any(Object)); + }); + + it("provider's onProgress callback writes its arg through to state.progress", async () => { + // provider に渡される onProgress クロージャはアップロード完了後も有効で、 + // 呼び出すと `setState((prev) => ({ ...prev, progress }))` が実行され、 + // 引数オブジェクトがそのまま state.progress に反映されることを検証する。 + // Capture the closure, finish the upload, then fire onProgress manually + // and observe the state. This avoids nested-act issues with deferred + // promises while still proving the wiring (closure → setState). + // Kills mutations that (a) drop the `progress` arg from setState or + // (b) replace it with a different value. + type ProgressArg = { loaded: number; total: number; percentage: number }; + let capturedOnProgress: ((p: ProgressArg) => void) | undefined; + mocks.providerUploadImage.mockImplementationOnce(async (_file, options) => { + capturedOnProgress = options.onProgress; + return "https://cdn.example.com/x.webp"; + }); + const { result } = renderHook(() => useImageUpload()); + + await act(async () => { + await result.current.uploadImage(makeFile()); + }); + + // provider が onProgress を受け取った(関数として配線されている)。 + // Pin that the provider received a callable onProgress. + expect(capturedOnProgress).toBeTypeOf("function"); + + // クロージャを発火し、引数オブジェクトが state.progress に反映されることを検証する。 + // Fire the captured callback and observe the resulting state.progress. + const observed: ProgressArg = { loaded: 50, total: 100, percentage: 50 }; + if (!capturedOnProgress) throw new Error("capturedOnProgress was not set by provider mock"); + const fire = capturedOnProgress; + act(() => { + fire(observed); + }); + expect(result.current.progress).toEqual(observed); + }); + + it("reports the provider error message in error state and re-throws", async () => { + // 失敗時は state.error にメッセージを格納し、エラーを再 throw する。 + // Pin both the propagated throw and that error state captures the message. + mocks.providerUploadImage.mockRejectedValueOnce(new Error("network down")); + const { result } = renderHook(() => useImageUpload()); + + let thrown: unknown; + await act(async () => { + try { + await result.current.uploadImage(makeFile()); + } catch (e) { + thrown = e; + } + }); + + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).message).toBe("network down"); + expect(result.current.error).toBe("network down"); + expect(result.current.isUploading).toBe(false); + expect(result.current.progress).toBeNull(); + }); + + it("uses the fallback error string for non-Error throwables", async () => { + // Error 以外(文字列 throw など)の場合は固定の日本語フォールバック文言を入れる。 + // Pin the fallback "アップロードに失敗しました" branch when the thrown value is not an Error. + mocks.providerUploadImage.mockRejectedValueOnce("just a string"); + const { result } = renderHook(() => useImageUpload()); + + let thrown: unknown; + await act(async () => { + try { + await result.current.uploadImage(makeFile()); + } catch (e) { + thrown = e; + } + }); - await act(async () => { - await result.current.uploadImage(file, { signal: controller.signal }); + expect(thrown).toBe("just a string"); + expect(result.current.error).toBe("アップロードに失敗しました"); }); - expect(mocks.providerUploadImage).toHaveBeenCalledWith( - file, - expect.objectContaining({ signal: controller.signal }), - ); + it("does NOT set error state when failure is due to caller-side abort", async () => { + // signal.aborted の場合は state.error を null のまま、progress を null に戻す。 + // Pin the abort branch: error stays null, progress is cleared, error is re-thrown. + const controller = new AbortController(); + mocks.providerUploadImage.mockImplementationOnce(async () => { + controller.abort(); + throw new DOMException("aborted", "AbortError"); + }); + const { result } = renderHook(() => useImageUpload()); + + await expect( + act(async () => { + await result.current.uploadImage(makeFile(), { signal: controller.signal }); + }), + ).rejects.toMatchObject({ name: "AbortError" }); + + await waitFor(() => expect(result.current.isUploading).toBe(false)); + expect(result.current.error).toBeNull(); + expect(result.current.progress).toBeNull(); + }); + + it("returns successfully even if the signal aborts AFTER the provider resolves", async () => { + // provider が成功裏に解決した後の throwIfAborted は呼ばない(孤児化防止)。 + // Pin the post-resolve invariant: do not throw after a successful upload. + const controller = new AbortController(); + mocks.providerUploadImage.mockImplementationOnce(async () => { + // 解決直前に signal を abort する。実装が誤って throwIfAborted を再度呼ぶと失敗する。 + // Aborting just before resolve would trip a buggy post-resolve abort check. + controller.abort(); + return "https://cdn.example.com/late.webp"; + }); + const { result } = renderHook(() => useImageUpload()); + + let url: string | undefined; + await act(async () => { + url = await result.current.uploadImage(makeFile(), { signal: controller.signal }); + }); + + expect(url).toBe("https://cdn.example.com/late.webp"); + }); + + it("sets isUploading=false and progress=100% on success", async () => { + // 成功後は isUploading が false に戻り、進捗 100% で締めくくられる。 + // Pin the terminal state of a successful upload. + const { result } = renderHook(() => useImageUpload()); + + await act(async () => { + await result.current.uploadImage(makeFile()); + }); + + expect(result.current.isUploading).toBe(false); + expect(result.current.progress).toEqual({ + loaded: expect.any(Number), + total: expect.any(Number), + percentage: 100, + }); + expect(result.current.error).toBeNull(); + }); + }); + + describe("uploadImages", () => { + it("filters non-image files and uploads only image entries", async () => { + // image/* 以外は事前に除外し、画像だけを並列でアップロードする。 + // Pin the prefilter and that only image files reach the provider. + const files = [ + makeFile("text/plain", "a.txt"), + makeFile("image/png", "b.png"), + makeFile("application/pdf", "c.pdf"), + makeFile("image/jpeg", "d.jpg"), + ]; + mocks.providerUploadImage.mockResolvedValue("https://cdn.example.com/x.webp"); + const { result } = renderHook(() => useImageUpload()); + + let urls: string[] = []; + await act(async () => { + urls = await result.current.uploadImages(files); + }); + + expect(urls).toHaveLength(2); + expect(mocks.providerUploadImage).toHaveBeenCalledTimes(2); + // 通った 2 件が画像エントリ (b.png, d.jpg) であることを参照同一性で固定する。 + // Pin that the two calls are exactly the image entries (not text/pdf), + // so a swapped-filter mutation that lets non-image files through fails. + expect(mocks.providerUploadImage).toHaveBeenCalledWith(files[1], expect.any(Object)); + expect(mocks.providerUploadImage).toHaveBeenCalledWith(files[3], expect.any(Object)); + }); + + it("throws when no image files remain after filtering", async () => { + // フィルタ後に画像が 0 件なら固定文言で throw、provider は呼ばない。 + // Pin the empty-after-filter branch and its message. + const { result } = renderHook(() => useImageUpload()); + + await expect( + act(async () => { + await result.current.uploadImages([ + makeFile("text/plain", "a.txt"), + makeFile("application/pdf", "b.pdf"), + ]); + }), + ).rejects.toThrow("画像ファイルが選択されていません"); + expect(mocks.providerUploadImage).not.toHaveBeenCalled(); + }); + + it("returns URLs in input order (Promise.all preserves order)", async () => { + // 戻り値は入力順を保つ(Promise.all のセマンティクスに依存)。 + // Pin in-order URL return so a `.map` → `.reverse` mutation is caught. + mocks.providerUploadImage + .mockResolvedValueOnce("https://cdn.example.com/1.webp") + .mockResolvedValueOnce("https://cdn.example.com/2.webp"); + const { result } = renderHook(() => useImageUpload()); + + let urls: string[] = []; + await act(async () => { + urls = await result.current.uploadImages([ + makeFile("image/png", "first.png"), + makeFile("image/jpeg", "second.jpg"), + ]); + }); + + expect(urls).toEqual(["https://cdn.example.com/1.webp", "https://cdn.example.com/2.webp"]); + }); + + it("captures error message in state and re-throws on batch failure", async () => { + // バッチで失敗した場合も error 文言を state に格納し、再 throw する。 + // Pin the batch error path: state.error and re-throw both fire. + mocks.providerUploadImage.mockRejectedValue(new Error("batch boom")); + const { result } = renderHook(() => useImageUpload()); + + let thrown: unknown; + await act(async () => { + try { + await result.current.uploadImages([makeFile("image/png", "x.png")]); + } catch (e) { + thrown = e; + } + }); + + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).message).toBe("batch boom"); + expect(result.current.error).toBe("batch boom"); + expect(result.current.isUploading).toBe(false); + }); + + it("uses the fallback error string for non-Error rejections in batch", async () => { + // 非 Error 例外時のフォールバック文言を固定する。 + // Pin the fallback message branch in `uploadImages`. + mocks.providerUploadImage.mockRejectedValue({ weird: true }); + const { result } = renderHook(() => useImageUpload()); + + let thrown: unknown; + await act(async () => { + try { + await result.current.uploadImages([makeFile("image/png", "x.png")]); + } catch (e) { + thrown = e; + } + }); + + expect(thrown).toEqual({ weird: true }); + expect(result.current.error).toBe("アップロードに失敗しました"); + }); + }); + + describe("isConfigured", () => { + it("is true when not loading and storage is configured", () => { + mocks.storageSettings = { settings: { isConfigured: true } as unknown, isLoading: false }; + mocks.isStorageConfiguredForUpload.mockReturnValue(true); + const { result } = renderHook(() => useImageUpload()); + expect(result.current.isConfigured).toBe(true); + }); + + it("is false while storage settings are still loading", () => { + // ロード中は configured とみなさない(`!isLoading` のロジック反転を殺す)。 + // Pin that `isLoading=true` forces `isConfigured=false`. + mocks.storageSettings = { settings: { isConfigured: true } as unknown, isLoading: true }; + mocks.isStorageConfiguredForUpload.mockReturnValue(true); + const { result } = renderHook(() => useImageUpload()); + expect(result.current.isConfigured).toBe(false); + }); + + it("is false when storage is not configured", () => { + // `isStorageConfiguredForUpload` が false の場合は configured=false。 + // Pin the right side of the `&&` in `isConfigured`. + mocks.storageSettings = { settings: { isConfigured: false } as unknown, isLoading: false }; + mocks.isStorageConfiguredForUpload.mockReturnValue(false); + const { result } = renderHook(() => useImageUpload()); + expect(result.current.isConfigured).toBe(false); + }); + }); + + describe("clearError", () => { + it("resets error state to null and leaves the rest of state untouched", async () => { + // `setState((prev) => ({ ...prev, error: null }))` の意味論を検証する。 + // 失敗した uploadImage の後に clearError を呼び、`error` だけが null に戻り、 + // `isUploading` と `progress` は失敗時の値(false / null)のままであることを固定する。 + // Pin both: (1) error → null, (2) the spread keeps `isUploading` / `progress` + // at the values the failed upload left them at (false / null), so a buggy + // clearError that resets the whole state would also surface here. + mocks.providerUploadImage.mockRejectedValueOnce(new Error("oops")); + const { result } = renderHook(() => useImageUpload()); + + await act(async () => { + try { + await result.current.uploadImage(makeFile()); + } catch { + /* expected */ + } + }); + + expect(result.current.error).toBe("oops"); + const isUploadingBefore = result.current.isUploading; + const progressBefore = result.current.progress; + + act(() => { + result.current.clearError(); + }); + + expect(result.current.error).toBeNull(); + expect(result.current.isUploading).toBe(isUploadingBefore); + expect(result.current.progress).toBe(progressBefore); + }); }); }); diff --git a/src/hooks/useMermaidGenerator.test.ts b/src/hooks/useMermaidGenerator.test.ts new file mode 100644 index 00000000..9d2d34d8 --- /dev/null +++ b/src/hooks/useMermaidGenerator.test.ts @@ -0,0 +1,269 @@ +/** + * Tests for {@link useMermaidGenerator}. + * {@link useMermaidGenerator} のテスト。 + * + * Issue #743: cover the state machine (idle to generating to completed or error), + * input validation, callback wiring, AI configuration check, and reset. + * Issue #743: 状態遷移(idle から generating、完了またはエラーへ)、入力バリデーション、 + * コールバック呼び出し、AI 設定確認、reset を検証する。 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import type { + MermaidDiagramType, + MermaidGeneratorCallbacks, + MermaidGeneratorResult, +} from "@/lib/mermaidGenerator"; +import type { AISettings } from "@/types/ai"; + +const mockGenerateMermaidDiagram = + vi.fn< + ( + text: string, + diagramTypes: MermaidDiagramType[], + callbacks: MermaidGeneratorCallbacks, + ) => Promise + >(); +const mockGetAISettingsOrThrow = vi.fn<() => Promise>(); + +vi.mock("@/lib/mermaidGenerator", () => ({ + generateMermaidDiagram: ( + text: string, + diagramTypes: MermaidDiagramType[], + callbacks: MermaidGeneratorCallbacks, + ) => mockGenerateMermaidDiagram(text, diagramTypes, callbacks), + getAISettingsOrThrow: () => mockGetAISettingsOrThrow(), +})); + +import { useMermaidGenerator } from "./useMermaidGenerator"; + +// Safety net to keep spies (e.g. console.error if added in future tests) and +// any module-level setup from leaking between tests on assertion failures. +// assertion 失敗時でも spy やモジュールレベルの設定が次のテストへ漏れないようにする。 +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("useMermaidGenerator", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("starts in idle state with no result/error", () => { + const { result } = renderHook(() => useMermaidGenerator()); + + expect(result.current.status).toBe("idle"); + expect(result.current.result).toBeNull(); + expect(result.current.error).toBeNull(); + expect(result.current.isAIConfigured).toBeNull(); + }); + + it("generate with empty text sets error status and does not call generateMermaidDiagram", async () => { + const { result } = renderHook(() => useMermaidGenerator()); + + await act(async () => { + await result.current.generate(" ", ["flowchart"]); + }); + + expect(result.current.status).toBe("error"); + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toBe("テキストが空です"); + expect(mockGenerateMermaidDiagram).not.toHaveBeenCalled(); + }); + + it("generate with empty diagramTypes sets error status", async () => { + const { result } = renderHook(() => useMermaidGenerator()); + + await act(async () => { + await result.current.generate("hello", []); + }); + + expect(result.current.status).toBe("error"); + expect(result.current.error?.message).toBe("ダイアグラムタイプを選択してください"); + expect(mockGenerateMermaidDiagram).not.toHaveBeenCalled(); + }); + + it("generate transitions to completed on onComplete callback", async () => { + const completedResult: MermaidGeneratorResult = { + code: "flowchart TD\nA-->B", + diagramType: "flowchart", + }; + mockGenerateMermaidDiagram.mockImplementation( + ( + _text: string, + _types: MermaidDiagramType[], + callbacks: MermaidGeneratorCallbacks, + ): Promise => { + callbacks.onComplete(completedResult); + return Promise.resolve(); + }, + ); + + const { result } = renderHook(() => useMermaidGenerator()); + + await act(async () => { + await result.current.generate("topic", ["flowchart"]); + }); + + expect(result.current.status).toBe("completed"); + expect(result.current.result).toEqual(completedResult); + expect(result.current.error).toBeNull(); + expect(mockGenerateMermaidDiagram).toHaveBeenCalledWith( + "topic", + ["flowchart"], + expect.any(Object), + ); + }); + + it("generate transitions to error when callbacks.onError is invoked", async () => { + const failure = new Error("boom"); + mockGenerateMermaidDiagram.mockImplementation( + ( + _text: string, + _types: MermaidDiagramType[], + callbacks: MermaidGeneratorCallbacks, + ): Promise => { + callbacks.onError(failure); + return Promise.resolve(); + }, + ); + + const { result } = renderHook(() => useMermaidGenerator()); + + await act(async () => { + await result.current.generate("topic", ["sequence"]); + }); + + expect(result.current.status).toBe("error"); + expect(result.current.error).toBe(failure); + expect(result.current.result).toBeNull(); + }); + + it("generate catches synchronous throws from generateMermaidDiagram and sets error", async () => { + mockGenerateMermaidDiagram.mockRejectedValueOnce(new Error("network")); + + const { result } = renderHook(() => useMermaidGenerator()); + + await act(async () => { + await result.current.generate("topic", ["pie"]); + }); + + expect(result.current.status).toBe("error"); + expect(result.current.error?.message).toBe("network"); + }); + + it("generate wraps non-Error rejections into a generic error", async () => { + mockGenerateMermaidDiagram.mockRejectedValueOnce("not an error"); + + const { result } = renderHook(() => useMermaidGenerator()); + + await act(async () => { + await result.current.generate("topic", ["pie"]); + }); + + expect(result.current.status).toBe("error"); + expect(result.current.error?.message).toBe("生成中にエラーが発生しました"); + }); + + it("reset returns the state machine to idle and clears result/error", async () => { + mockGenerateMermaidDiagram.mockImplementation( + ( + _text: string, + _types: MermaidDiagramType[], + callbacks: MermaidGeneratorCallbacks, + ): Promise => { + callbacks.onComplete({ code: "x", diagramType: "flowchart" }); + return Promise.resolve(); + }, + ); + + const { result } = renderHook(() => useMermaidGenerator()); + + await act(async () => { + await result.current.generate("topic", ["flowchart"]); + }); + expect(result.current.status).toBe("completed"); + + act(() => { + result.current.reset(); + }); + + expect(result.current.status).toBe("idle"); + expect(result.current.result).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it("checkAIConfigured returns true and sets flag when settings load", async () => { + mockGetAISettingsOrThrow.mockResolvedValue({ provider: "google" }); + + const { result } = renderHook(() => useMermaidGenerator()); + + let configured: boolean | undefined; + await act(async () => { + configured = await result.current.checkAIConfigured(); + }); + + expect(configured).toBe(true); + await waitFor(() => expect(result.current.isAIConfigured).toBe(true)); + }); + + it("checkAIConfigured returns false and sets flag when getAISettingsOrThrow rejects", async () => { + mockGetAISettingsOrThrow.mockRejectedValue(new Error("AI_NOT_CONFIGURED")); + + const { result } = renderHook(() => useMermaidGenerator()); + + let configured: boolean | undefined; + await act(async () => { + configured = await result.current.checkAIConfigured(); + }); + + expect(configured).toBe(false); + await waitFor(() => expect(result.current.isAIConfigured).toBe(false)); + }); + + it("generate clears any previous error before transitioning to generating", async () => { + const { result } = renderHook(() => useMermaidGenerator()); + + // Trigger an initial error first. + // まず初期エラーを発生させる。 + await act(async () => { + await result.current.generate("", ["flowchart"]); + }); + expect(result.current.status).toBe("error"); + + // Stub a long-running generation that resolves later so we can observe the + // transitional state. + // 遷移中の状態を観測できるよう、あとで resolve する長時間生成を stub する。 + let resolveCb: (() => void) | null = null; + mockGenerateMermaidDiagram.mockImplementation( + ( + _text: string, + _types: MermaidDiagramType[], + callbacks: MermaidGeneratorCallbacks, + ): Promise => { + return new Promise((resolve) => { + resolveCb = () => { + callbacks.onComplete({ code: "x", diagramType: "flowchart" }); + resolve(); + }; + }); + }, + ); + + let pending: Promise | undefined; + act(() => { + pending = result.current.generate("topic", ["flowchart"]); + }); + + await waitFor(() => expect(result.current.status).toBe("generating")); + expect(result.current.error).toBeNull(); + expect(result.current.result).toBeNull(); + + await act(async () => { + resolveCb?.(); + await pending; + }); + expect(result.current.status).toBe("completed"); + }); +}); diff --git a/src/hooks/usePageQueries.ts b/src/hooks/usePageQueries.ts index 92948a7c..5b06a09e 100644 --- a/src/hooks/usePageQueries.ts +++ b/src/hooks/usePageQueries.ts @@ -271,14 +271,34 @@ export function usePage(pageId: string, options?: UsePageOptions) { } /** - * Hook to search pages (personal; StorageAdapter) + * 個人スコープ専用のページ検索フック(IndexedDB 経由)。 + * + * **スコープ契約 (Issue #718 Phase 5-4)**: + * - 返すのは `noteId === null` の個人ページのみ。 + * - 実装は `IndexedDBStorageAdapter.searchPages` に委ねており、IDB には個人 + * ページしか永続化されない(`getAllPages` も `noteId === null` で防御的に + * フィルタしている)。ノートネイティブページは API 経由で取得する。 + * - ノート配下の検索が必要な場合は `useSearchSharedNotes`(混在)か、将来 + * 実装される note-scoped 検索フック(Phase 5-2 の + * `GET /api/notes/:noteId/search` を呼ぶ)を使うこと。 + * + * Personal-scope-only page search (via IndexedDB). + * + * **Scope contract (Issue #718 Phase 5-4)**: returns only personal pages + * (`noteId === null`). The implementation delegates to + * `IndexedDBStorageAdapter.searchPages`, which only ever holds personal pages. + * For note-native results, callers must reach for `useSearchSharedNotes` (mixed + * scope) or a future note-scoped hook backed by Phase 5-2's + * `GET /api/notes/:noteId/search` endpoint. + * + * @returns React Query result whose `data` is `Page[]` of personal pages. */ export function useSearchPages(query: string) { const { getRepository, userId, isLoaded } = useRepository(); return useQuery({ queryKey: pageKeys.search(userId, query), - queryFn: async () => { + queryFn: async (): Promise => { if (!query.trim()) return []; try { const repo = await getRepository(); @@ -298,7 +318,24 @@ export function useSearchPages(query: string) { } /** - * Hook to search shared notes (API: GET /api/search?q=&scope=shared). C3-8. + * 混在スコープのページ検索フック(API: `GET /api/search?q=&scope=shared`)。 + * C3-8 / Issue #718 Phase 5-4。 + * + * **スコープ挙動**: + * - サーバーは個人ページ (`note_id IS NULL`) と、自分が参加するノートの + * ネイティブページの両方を返す(Phase 5-1 で `scope=own` の方は + * `note_id IS NULL` の防御フィルタを追加済みだが、`shared` は意図的に + * 混在のまま)。 + * - 各行は `note_id: string | null` を含むので、呼び出し側は必要に応じて + * 個人 / ノートネイティブを判別できる(`useGlobalSearch` / + * `SearchResults` は個人ページ重複を避けるためここでフィルタしている)。 + * + * Mixed-scope search hook (`GET /api/search?q=&scope=shared`). + * + * The server returns both personal pages and note-native pages from notes the + * caller participates in. Each row carries `note_id: string | null` so callers + * can branch on it (`useGlobalSearch` / `SearchResults` filter to note-native + * rows so personal pages from `useSearchPages` are not double-counted). */ export function useSearchSharedNotes(query: string) { const { getToken, isSignedIn } = useAuth(); @@ -723,12 +760,39 @@ export function useSyncWikiLinks(options: UseSyncWikiLinksOptions = {}) { await syncLinksWithRepo(repo, userId, sourcePageId, wikiLinks, { pageNoteId, notePages, + linkType: "wiki", + }); + }, + [getRepository, userId, pageNoteId, notePages], + ); + + /** + * タグ (`#name`) を `link_type='tag'` バケットで同期する (issue #725 Phase 1)。 + * 解決ロジックは WikiLink と同一(タイトル正規化で一致 → `links`、不一致 → + * `ghost_links`)。呼び出し側は `extractTagsFromContent` で `{ name }` 配列を + * 作って `{ title: name, exists: false }` 形に詰め替える。 + * + * Sync tags in the `link_type='tag'` bucket (issue #725 Phase 1). The + * resolution strategy matches WikiLinks (normalized-title match → `links`, + * miss → `ghost_links`). Callers feed `extractTagsFromContent` results in + * as `{ title: name }` entries so we can reuse one resolver. + */ + const syncTags = useCallback( + async (sourcePageId: string, tags: Array<{ name: string }>): Promise => { + const repo = await getRepository(); + // tag の `name` は WikiLink の `title` に等価なので同じ resolver に流す。 + // The tag name is title-equivalent for resolution purposes. + const asLinks = tags.map((t) => ({ title: t.name, exists: false })); + await syncLinksWithRepo(repo, userId, sourcePageId, asLinks, { + pageNoteId, + notePages, + linkType: "tag", }); }, [getRepository, userId, pageNoteId, notePages], ); - return { syncLinks }; + return { syncLinks, syncTags }; } /** @@ -780,9 +844,25 @@ export function useWikiLinkExistsChecker(options: UseWikiLinkExistsCheckerOption ): Promise<{ pageTitles: Set; referencedTitles: Set; + /** + * 正規化済みタイトル → ターゲットページ id のマップ。同一スコープ内に + * 同名ページが複数あった場合は **最後に出現したページの id** が残る + * (Map への上書き)。`useWikiLinkStatusSync` / `useTagStatusSync` が + * `targetId` 属性を埋めるためだけに使う(issue #737 / 案 A)。 + * + * Normalized title → target page id map. With duplicate titles inside + * the same scope the **last write wins** (Map overwrite). Used by the + * status-sync hooks to populate the `targetId` attribute on resolved + * marks (issue #737, approach A). + */ + pageTitleToId: Map; }> => { if (!isLoaded || titles.length === 0) { - return { pageTitles: new Set(), referencedTitles: new Set() }; + return { + pageTitles: new Set(), + referencedTitles: new Set(), + pageTitleToId: new Map(), + }; } const repo = await getRepository(); @@ -794,15 +874,27 @@ export function useWikiLinkExistsChecker(options: UseWikiLinkExistsCheckerOption // Select the candidate source based on scope (issue #713 Phase 4). // If note-scope candidates have not loaded yet, return empty sets so // we do not mis-classify valid same-note links as missing on this pass. - let pageTitles: Set; - if (pageNoteId !== null) { - if (notePages === undefined) { - return { pageTitles: new Set(), referencedTitles: new Set() }; - } - pageTitles = new Set(notePages.map((p) => p.title.toLowerCase().trim())); - } else { - const pages = await repo.getPagesSummary(userId); - pageTitles = new Set(pages.map((p) => p.title.toLowerCase().trim())); + const sourcePages = pageNoteId !== null ? notePages : await repo.getPagesSummary(userId); + if (pageNoteId !== null && sourcePages === undefined) { + return { + pageTitles: new Set(), + referencedTitles: new Set(), + pageTitleToId: new Map(), + }; + } + + // 単一ループで `pageTitles` と `pageTitleToId` を構築する。`.map()` で + // Set を作ってから別ループで Map を埋める旧実装は冗長で、データに対する + // 走査が 2 回発生していた(Gemini レビュー指摘)。 + // Single pass populates both `pageTitles` and `pageTitleToId`. The + // earlier shape used `.map()` to seed the Set and a separate loop for + // the Map, walking the same data twice (Gemini review feedback). + const pageTitles = new Set(); + const pageTitleToId = new Map(); + for (const p of sourcePages ?? []) { + const normalized = p.title.toLowerCase().trim(); + pageTitles.add(normalized); + pageTitleToId.set(normalized, p.id); } // Get ghost links to check referenced status. ノートスコープのゴースト @@ -835,7 +927,7 @@ export function useWikiLinkExistsChecker(options: UseWikiLinkExistsCheckerOption } } - return { pageTitles, referencedTitles }; + return { pageTitles, referencedTitles, pageTitleToId }; }, [getRepository, userId, isLoaded, pageNoteId, notePages], ); diff --git a/src/hooks/useSyncWikiLinks.test.ts b/src/hooks/useSyncWikiLinks.test.ts index 31064d82..35889622 100644 --- a/src/hooks/useSyncWikiLinks.test.ts +++ b/src/hooks/useSyncWikiLinks.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from "vitest"; import { syncLinksWithRepo } from "@/lib/syncWikiLinks"; import type { IPageRepository } from "@/lib/pageRepository"; -import type { PageSummary } from "@/types/page"; +import type { PageSummary, LinkType } from "@/types/page"; function createMockRepo(overrides: { getPagesSummary?: () => Promise; @@ -78,12 +78,12 @@ describe("syncLinksWithRepo", () => { ]); expect(addLink).toHaveBeenCalledTimes(1); - expect(addLink).toHaveBeenCalledWith(sourcePageId, "page-a"); + expect(addLink).toHaveBeenCalledWith(sourcePageId, "page-a", "wiki"); expect(addGhostLink).toHaveBeenCalledTimes(1); - expect(addGhostLink).toHaveBeenCalledWith("Non Existing", sourcePageId); + expect(addGhostLink).toHaveBeenCalledWith("Non Existing", sourcePageId, "wiki"); expect(removeLink).not.toHaveBeenCalled(); // 既存ページへの add 時に ghost を消すため removeGhostLink("Page A") が1回呼ばれる - expect(removeGhostLink).toHaveBeenCalledWith("Page A", sourcePageId); + expect(removeGhostLink).toHaveBeenCalledWith("Page A", sourcePageId, "wiki"); }); }); @@ -117,7 +117,7 @@ describe("syncLinksWithRepo", () => { await syncLinksWithRepo(repo, userId, sourcePageId, []); expect(removeLink).toHaveBeenCalledTimes(1); - expect(removeLink).toHaveBeenCalledWith(sourcePageId, "page-a"); + expect(removeLink).toHaveBeenCalledWith(sourcePageId, "page-a", "wiki"); expect(removeGhostLink).not.toHaveBeenCalled(); }); @@ -134,7 +134,7 @@ describe("syncLinksWithRepo", () => { await syncLinksWithRepo(repo, userId, sourcePageId, []); expect(removeGhostLink).toHaveBeenCalledTimes(1); - expect(removeGhostLink).toHaveBeenCalledWith("Old Ghost", sourcePageId); + expect(removeGhostLink).toHaveBeenCalledWith("Old Ghost", sourcePageId, "wiki"); }); it("古いリンク1件のとき、wikiLinks を別のリンクだけに変更すると remove 1回 + add 1回が呼ばれる", async () => { @@ -178,9 +178,9 @@ describe("syncLinksWithRepo", () => { await syncLinksWithRepo(repo, userId, sourcePageId, [{ title: "Page B", exists: true }]); expect(removeLink).toHaveBeenCalledTimes(1); - expect(removeLink).toHaveBeenCalledWith(sourcePageId, "page-a"); + expect(removeLink).toHaveBeenCalledWith(sourcePageId, "page-a", "wiki"); expect(addLink).toHaveBeenCalledTimes(1); - expect(addLink).toHaveBeenCalledWith(sourcePageId, "page-b"); + expect(addLink).toHaveBeenCalledWith(sourcePageId, "page-b", "wiki"); }); }); @@ -216,7 +216,7 @@ describe("syncLinksWithRepo", () => { ]); // 正規化で同一タイトルになるため addLink は1回(重複は追加されない実装に依存。StorageAdapterPageRepository は既存なら skip) - expect(addLink).toHaveBeenCalledWith(sourcePageId, "page-a"); + expect(addLink).toHaveBeenCalledWith(sourcePageId, "page-a", "wiki"); expect(addLink.mock.calls.length).toBeLessThanOrEqual(3); }); }); @@ -305,7 +305,7 @@ describe("syncLinksWithRepo", () => { expect(getPagesSummary).not.toHaveBeenCalled(); expect(addLink).not.toHaveBeenCalled(); expect(addGhostLink).toHaveBeenCalledTimes(1); - expect(addGhostLink).toHaveBeenCalledWith("Personal A", sourcePageId); + expect(addGhostLink).toHaveBeenCalledWith("Personal A", sourcePageId, "wiki"); }); it("pageNoteId + notePages 指定時、同じノート内のページへのリンクは addLink で解決される", async () => { @@ -336,7 +336,7 @@ describe("syncLinksWithRepo", () => { ); expect(addLink).toHaveBeenCalledTimes(1); - expect(addLink).toHaveBeenCalledWith(sourcePageId, "note-page-1"); + expect(addLink).toHaveBeenCalledWith(sourcePageId, "note-page-1", "wiki"); expect(addGhostLink).not.toHaveBeenCalled(); }); @@ -357,7 +357,7 @@ describe("syncLinksWithRepo", () => { expect(addLink).not.toHaveBeenCalled(); expect(addGhostLink).toHaveBeenCalledTimes(1); - expect(addGhostLink).toHaveBeenCalledWith("Unknown", sourcePageId); + expect(addGhostLink).toHaveBeenCalledWith("Unknown", sourcePageId, "wiki"); }); // CodeRabbit / Codex が指摘: ノートスコープで `notePages` が空のとき、 @@ -382,7 +382,83 @@ describe("syncLinksWithRepo", () => { }); expect(removeLink).toHaveBeenCalledTimes(1); - expect(removeLink).toHaveBeenCalledWith(sourcePageId, "stale-target-id"); + expect(removeLink).toHaveBeenCalledWith(sourcePageId, "stale-target-id", "wiki"); + }); + }); + + // Issue #725 Phase 1: linkType オプションでタグエッジを独立に同期する。 + // `linkType: 'tag'` の同期では WikiLink 用の outgoing / ghost は読まれず、 + // 書き込みもタグスコープに閉じる。逆も同様。 + // Issue #725 Phase 1: tag sync operates in its own `linkType` bucket and + // never reads or writes WikiLink edges, and vice versa. + describe("linkType オプション(issue #725 Phase 1)", () => { + it("linkType='tag' 指定時は repo.getOutgoingLinks / addLink / addGhostLink に 'tag' が渡る", async () => { + const summaries: PageSummary[] = [ + { + id: "tag-target", + ownerUserId: userId, + noteId: null, + title: "Foo", + contentPreview: undefined, + thumbnailUrl: undefined, + sourceUrl: undefined, + createdAt: 0, + updatedAt: 0, + isDeleted: false, + }, + ]; + const getOutgoingLinks = vi.fn().mockResolvedValue([]); + const getGhostLinksBySourcePage = vi.fn().mockResolvedValue([]); + const addLink = vi.fn().mockResolvedValue(undefined); + const addGhostLink = vi.fn().mockResolvedValue(undefined); + const removeGhostLink = vi.fn().mockResolvedValue(undefined); + + const repo = createMockRepo({ + getPagesSummary: vi.fn().mockResolvedValue(summaries), + getOutgoingLinks, + getGhostLinksBySourcePage, + addLink, + addGhostLink, + removeGhostLink, + }); + + await syncLinksWithRepo( + repo, + userId, + sourcePageId, + [ + { title: "Foo", exists: true }, + { title: "Unknown", exists: false }, + ], + { linkType: "tag" satisfies LinkType }, + ); + + expect(getOutgoingLinks).toHaveBeenCalledWith(sourcePageId, "tag"); + expect(getGhostLinksBySourcePage).toHaveBeenCalledWith(sourcePageId, "tag"); + expect(addLink).toHaveBeenCalledWith(sourcePageId, "tag-target", "tag"); + expect(addGhostLink).toHaveBeenCalledWith("Unknown", sourcePageId, "tag"); + }); + + it("linkType='wiki' (既定) では wiki スコープで同期し tag バケットには触れない", async () => { + const getOutgoingLinks = vi.fn().mockResolvedValue([]); + const getGhostLinksBySourcePage = vi.fn().mockResolvedValue([]); + const addGhostLink = vi.fn().mockResolvedValue(undefined); + + const repo = createMockRepo({ + getPagesSummary: vi.fn().mockResolvedValue([]), + getOutgoingLinks, + getGhostLinksBySourcePage, + addGhostLink, + }); + + await syncLinksWithRepo(repo, userId, sourcePageId, [{ title: "Unknown", exists: false }]); + + expect(getOutgoingLinks).toHaveBeenCalledWith(sourcePageId, "wiki"); + expect(getGhostLinksBySourcePage).toHaveBeenCalledWith(sourcePageId, "wiki"); + expect(addGhostLink).toHaveBeenCalledWith("Unknown", sourcePageId, "wiki"); + // 'tag' 呼び出しが無いことを確認(wiki スコープに閉じる) + const tagCalls = getOutgoingLinks.mock.calls.filter((c: unknown[]) => c[1] === "tag"); + expect(tagCalls).toHaveLength(0); }); }); }); diff --git a/src/hooks/useWorkflowDraft.test.ts b/src/hooks/useWorkflowDraft.test.ts new file mode 100644 index 00000000..c4d17b9a --- /dev/null +++ b/src/hooks/useWorkflowDraft.test.ts @@ -0,0 +1,402 @@ +/** + * Tests for {@link useWorkflowDraft}. + * {@link useWorkflowDraft} のテスト。 + * + * Issue #743: cover step CRUD, template loading, save validation, JSON + * import/export, and saved-definition selection lifecycle. + * Issue #743: ステップ CRUD、テンプレートのロード、保存時のバリデーション、 + * JSON インポート/エクスポート、保存済み定義の選択ライフサイクルを検証する。 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; + +const mockToast = vi.fn(); +const mockUpsertDefinition = vi.fn(); +const mockRemoveDefinition = vi.fn(); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("@zedi/ui", () => ({ + useToast: () => ({ toast: mockToast }), +})); + +import { useWorkflowDefinitionsStore } from "@/stores/workflowDefinitionsStore"; +import type { WorkflowDefinition } from "@/lib/workflow/types"; +import { useWorkflowDraft } from "./useWorkflowDraft"; + +function resetStore(initial: WorkflowDefinition[] = []): void { + useWorkflowDefinitionsStore.setState({ + definitions: initial, + // 実装の `useWorkflowDefinitionsStore` は upsert 時に updatedAt 降順で並び替えるため + // モックでも同じ並びを保つ。順序依存のリグレッションが隠れないようにするため。 + // Mirror the real store: sort by `updatedAt` descending after upsert so the + // test setup matches production ordering and order-dependent regressions + // can surface. + upsertDefinition: (def: WorkflowDefinition) => { + mockUpsertDefinition(def); + useWorkflowDefinitionsStore.setState((s) => { + const next = [...s.definitions.filter((d) => d.id !== def.id), def].sort( + (a, b) => b.updatedAt - a.updatedAt, + ); + return { ...s, definitions: next }; + }); + }, + removeDefinition: (id: string) => { + mockRemoveDefinition(id); + useWorkflowDefinitionsStore.setState((s) => ({ + ...s, + definitions: s.definitions.filter((d) => d.id !== id), + })); + }, + }); +} + +// テスト失敗時もスパイ(URL.createObjectURL / HTMLAnchorElement.prototype.click 等)が +// 確実に元に戻るよう、ファイル全体で共通の後始末を行う。fake timers を使ったテストは +// 自分で `vi.useRealTimers()` を呼ぶか、ここで restore される前提でクリーンアップする。 +// File-wide cleanup so spies on globals (URL.createObjectURL, +// HTMLAnchorElement.prototype.click, ...) are restored even if a test throws. +// Tests that opt into fake timers should restore real timers themselves; +// this hook handles spy restoration as a safety net. +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe("useWorkflowDraft - initial state and steps", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetStore(); + }); + + it("starts with one empty step and an auto-generated id/name", () => { + const { result } = renderHook(() => useWorkflowDraft()); + + expect(result.current.draft.name).toBe(""); + expect(result.current.draft.steps).toHaveLength(1); + expect(result.current.draft.steps[0]).toMatchObject({ title: "", instruction: "" }); + expect(result.current.draft.steps[0].id).toBeTruthy(); + expect(result.current.selectedSavedId).toBe(""); + }); + + it("addStep appends a new empty step", () => { + const { result } = renderHook(() => useWorkflowDraft()); + + act(() => { + result.current.addStep(); + }); + + expect(result.current.draft.steps).toHaveLength(2); + expect(result.current.draft.steps[1]).toMatchObject({ title: "", instruction: "" }); + }); + + it("addStep bumps updatedAt", () => { + const { result } = renderHook(() => useWorkflowDraft()); + const before = result.current.draft.updatedAt; + + act(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(before + 1000)); + result.current.addStep(); + vi.useRealTimers(); + }); + + expect(result.current.draft.updatedAt).toBeGreaterThan(before); + }); + + it("removeStep removes the step at index", () => { + const { result } = renderHook(() => useWorkflowDraft()); + act(() => { + result.current.addStep(); + result.current.addStep(); + }); + expect(result.current.draft.steps).toHaveLength(3); + + act(() => { + result.current.updateStep(0, { title: "first" }); + result.current.updateStep(1, { title: "second" }); + result.current.updateStep(2, { title: "third" }); + }); + act(() => { + result.current.removeStep(1); + }); + + expect(result.current.draft.steps).toHaveLength(2); + expect(result.current.draft.steps.map((s) => s.title)).toEqual(["first", "third"]); + }); + + it("updateStep applies a partial patch", () => { + const { result } = renderHook(() => useWorkflowDraft()); + + act(() => { + result.current.updateStep(0, { title: "T", instruction: "I" }); + }); + + expect(result.current.draft.steps[0]).toMatchObject({ title: "T", instruction: "I" }); + }); +}); + +describe("useWorkflowDraft - templates", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetStore(); + }); + + it("loadTemplate populates the draft from a template id and clears selectedSavedId", () => { + const { result } = renderHook(() => useWorkflowDraft()); + + act(() => { + result.current.loadTemplate("code-investigate-design"); + }); + + expect(result.current.draft.steps.length).toBeGreaterThanOrEqual(1); + expect(result.current.draft.name).toBe("aiChat.workflow.templates.codeInvestigateDesign"); + expect(result.current.selectedSavedId).toBe(""); + }); +}); + +describe("useWorkflowDraft - saveCustom", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetStore(); + }); + + it("rejects save when draft.name is empty and toasts a destructive notice", () => { + const { result } = renderHook(() => useWorkflowDraft()); + + act(() => { + result.current.saveCustom(); + }); + + expect(mockUpsertDefinition).not.toHaveBeenCalled(); + expect(mockToast).toHaveBeenCalledWith({ + title: "aiChat.workflow.nameRequired", + variant: "destructive", + }); + }); + + it("upserts draft into the store and toasts success when name is set", () => { + const { result } = renderHook(() => useWorkflowDraft()); + + act(() => { + result.current.setDraft((d) => ({ ...d, name: "My Flow" })); + }); + act(() => { + result.current.saveCustom(); + }); + + expect(mockUpsertDefinition).toHaveBeenCalledTimes(1); + expect(mockUpsertDefinition).toHaveBeenCalledWith(expect.objectContaining({ name: "My Flow" })); + expect(mockToast).toHaveBeenCalledWith({ title: "aiChat.workflow.saved" }); + }); +}); + +describe("useWorkflowDraft - import / export", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetStore(); + }); + + it("exportJson opens a download link with sanitized filename", () => { + // 実装は `window.setTimeout(..., 0)` で revokeObjectURL を遅延させるため、 + // fake timers を使ってフラッシュすることでフレーキーな実 setTimeout 待ちを避ける。 + // The hook defers `revokeObjectURL` via `window.setTimeout(..., 0)`; use fake + // timers to flush deterministically and avoid flaky real-time waits. + vi.useFakeTimers(); + const createObjectURLSpy = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:mock-url"); + const revokeObjectURLSpy = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + + const { result } = renderHook(() => useWorkflowDraft()); + + act(() => { + result.current.setDraft((d) => ({ ...d, name: "Plan A" })); + }); + + act(() => { + result.current.exportJson(); + }); + + expect(createObjectURLSpy).toHaveBeenCalledTimes(1); + expect(clickSpy).toHaveBeenCalledTimes(1); + + // キューされた setTimeout(..., 0) を実行して revokeObjectURL を発火させる。 + // Flush the queued setTimeout(..., 0) that triggers revokeObjectURL. + vi.runAllTimers(); + expect(revokeObjectURLSpy).toHaveBeenCalledWith("blob:mock-url"); + }); + + it("exportJson uses 'workflow' as the fallback when name is empty", () => { + vi.useFakeTimers(); + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:mock-url"); + vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + + let downloadName = ""; + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function ( + this: HTMLAnchorElement, + ) { + downloadName = this.download; + }); + + const { result } = renderHook(() => useWorkflowDraft()); + act(() => { + result.current.exportJson(); + }); + + expect(downloadName).toBe("workflow.json"); + vi.runAllTimers(); + }); + + it("onImportFile populates draft from a valid JSON file", async () => { + const json = JSON.stringify({ + name: "Imported", + steps: [{ title: "S1", instruction: "do" }], + }); + const file = new File([json], "flow.json", { type: "application/json" }); + + const { result } = renderHook(() => useWorkflowDraft()); + + const fakeInput = document.createElement("input"); + fakeInput.type = "file"; + Object.defineProperty(fakeInput, "files", { + value: [file], + configurable: true, + }); + const event = { + target: fakeInput, + } as unknown as React.ChangeEvent; + + act(() => { + result.current.onImportFile(event); + }); + + await waitFor(() => { + expect(result.current.draft.name).toBe("Imported"); + }); + expect(result.current.draft.steps).toHaveLength(1); + expect(result.current.draft.steps[0]).toMatchObject({ title: "S1", instruction: "do" }); + expect(mockToast).toHaveBeenCalledWith({ title: "aiChat.workflow.imported" }); + }); + + it("onImportFile is a no-op when no file is selected", () => { + const { result } = renderHook(() => useWorkflowDraft()); + const initialName = result.current.draft.name; + + const event = { + target: { files: null, value: "" } as unknown as HTMLInputElement, + } as unknown as React.ChangeEvent; + + act(() => { + result.current.onImportFile(event); + }); + + expect(result.current.draft.name).toBe(initialName); + expect(mockToast).not.toHaveBeenCalled(); + }); + + it("onImportFile toasts importFailed when JSON is invalid", async () => { + const file = new File(["not json"], "bad.json", { type: "application/json" }); + + const { result } = renderHook(() => useWorkflowDraft()); + + const fakeInput = document.createElement("input"); + fakeInput.type = "file"; + Object.defineProperty(fakeInput, "files", { + value: [file], + configurable: true, + }); + const event = { + target: fakeInput, + } as unknown as React.ChangeEvent; + + act(() => { + result.current.onImportFile(event); + }); + + await waitFor(() => { + expect(mockToast).toHaveBeenCalledWith({ + title: "aiChat.workflow.importFailed", + variant: "destructive", + }); + }); + }); +}); + +describe("useWorkflowDraft - saved definitions", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("loadSaved copies the matching definition into the draft", () => { + const saved: WorkflowDefinition = { + id: "saved-1", + name: "Saved", + steps: [{ id: "s1", title: "T", instruction: "I" }], + createdAt: 100, + updatedAt: 200, + }; + resetStore([saved]); + const { result } = renderHook(() => useWorkflowDraft()); + + act(() => { + result.current.loadSaved("saved-1"); + }); + + expect(result.current.draft.id).toBe("saved-1"); + expect(result.current.draft.name).toBe("Saved"); + expect(result.current.selectedSavedId).toBe("saved-1"); + }); + + it("loadSaved is a no-op when the id is unknown", () => { + resetStore([]); + const { result } = renderHook(() => useWorkflowDraft()); + const before = result.current.draft; + + act(() => { + result.current.loadSaved("missing"); + }); + + expect(result.current.draft).toBe(before); + expect(result.current.selectedSavedId).toBe(""); + }); + + it("deleteSaved is a no-op when nothing is selected", () => { + resetStore([]); + const { result } = renderHook(() => useWorkflowDraft()); + + act(() => { + result.current.deleteSaved(); + }); + + expect(mockRemoveDefinition).not.toHaveBeenCalled(); + expect(mockToast).not.toHaveBeenCalled(); + }); + + it("deleteSaved removes the selected definition and toasts deleted", () => { + const saved: WorkflowDefinition = { + id: "saved-2", + name: "Saved Two", + steps: [{ id: "s1", title: "T", instruction: "I" }], + createdAt: 100, + updatedAt: 200, + }; + resetStore([saved]); + const { result } = renderHook(() => useWorkflowDraft()); + + act(() => { + result.current.loadSaved("saved-2"); + }); + act(() => { + result.current.deleteSaved(); + }); + + expect(mockRemoveDefinition).toHaveBeenCalledWith("saved-2"); + expect(result.current.selectedSavedId).toBe(""); + expect(mockToast).toHaveBeenCalledWith({ title: "aiChat.workflow.deleted" }); + }); +}); diff --git a/src/hooks/useWorkflowRunSession.test.ts b/src/hooks/useWorkflowRunSession.test.ts new file mode 100644 index 00000000..1001ed83 --- /dev/null +++ b/src/hooks/useWorkflowRunSession.test.ts @@ -0,0 +1,493 @@ +/** + * Tests for {@link useWorkflowRunSession}. + * {@link useWorkflowRunSession} のテスト。 + * + * Issue #743: cover the orchestration entry points (validation guards, mode + * dispatch, completed/paused/stopped/error outcomes), abort cleanup on unmount, + * and pause / stop signal forwarding. + * Issue #743: 実行入口(バリデーション、モード分岐、completed/paused/stopped/error の + * 結果反映)、unmount 時の abort クリーンアップ、pause/stop の信号伝搬を検証する。 + */ + +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; + +import type { WorkflowDefinition } from "@/lib/workflow/types"; +import type { WorkflowExecutionOutcome } from "@/lib/workflow/runWorkflowExecution"; + +const mockToast = vi.fn(); +const mockIsTauriDesktop = vi.fn(); +const mockRunWorkflowExecution = vi.fn(); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +vi.mock("@zedi/ui", () => ({ + useToast: () => ({ toast: mockToast }), +})); + +vi.mock("@/lib/platform", () => ({ + isTauriDesktop: () => mockIsTauriDesktop(), +})); + +vi.mock("@/lib/workflow/runWorkflowExecution", () => ({ + runWorkflowExecution: (...args: unknown[]) => mockRunWorkflowExecution(...args), +})); + +import { AIChatProvider, useAIChatContext } from "@/contexts/AIChatContext"; +import type { PageContext } from "@/types/aiChat"; +import { useWorkflowRunSession } from "./useWorkflowRunSession"; + +function makeDraft(overrides?: Partial): WorkflowDefinition { + const now = Date.now(); + return { + id: "wf-1", + name: "Test Flow", + steps: [ + { id: "s1", title: "Step One", instruction: "do" }, + { id: "s2", title: "Step Two", instruction: "next" }, + ], + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +function ContextSetter({ pageContext }: { pageContext: PageContext | null }) { + const { setPageContext } = useAIChatContext(); + React.useEffect(() => { + setPageContext(pageContext); + }, [pageContext, setPageContext]); + return null; +} + +function createWrapper(pageContext: PageContext | null) { + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement( + AIChatProvider, + null, + React.createElement( + React.Fragment, + null, + React.createElement(ContextSetter, { pageContext }), + children, + ), + ); + }; +} + +const editorContext: PageContext = { + type: "editor", + pageId: "p1", + pageTitle: "Note", + pageFullContent: "", + claudeWorkspaceRoot: "/tmp/wsroot", +}; + +describe("useWorkflowRunSession - validation guards", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsTauriDesktop.mockReturnValue(true); + }); + + it("aborts with desktopOnly toast outside of Tauri", async () => { + mockIsTauriDesktop.mockReturnValue(false); + const { result } = renderHook(() => useWorkflowRunSession(makeDraft()), { + wrapper: createWrapper(editorContext), + }); + + await act(async () => { + await result.current.runExecution("fresh"); + }); + + expect(mockToast).toHaveBeenCalledWith({ + title: "aiChat.workflow.desktopOnly", + variant: "destructive", + }); + expect(mockRunWorkflowExecution).not.toHaveBeenCalled(); + }); + + it("aborts with editorRequired toast when pageContext is not editor", async () => { + // 実装では type !== "editor" のときに editorRequired を出す。 + // PageContext.type の有効値("editor" | "home" | "search" | "other")から + // editor 以外を選ぶことで unsafe な cast を避ける。 + // The hook gates execution on `type === "editor"`. Use the valid "home" + // discriminant so we exercise the "not editor" branch without an as-cast. + const { result } = renderHook(() => useWorkflowRunSession(makeDraft()), { + wrapper: createWrapper({ + type: "home", + pageId: "w1", + pageTitle: "Home", + pageContent: "", + }), + }); + + await act(async () => { + await result.current.runExecution("fresh"); + }); + + expect(mockToast).toHaveBeenCalledWith({ + title: "aiChat.workflow.editorRequired", + variant: "destructive", + }); + expect(mockRunWorkflowExecution).not.toHaveBeenCalled(); + }); + + it("aborts with nameRequired when draft.name is empty", async () => { + const { result } = renderHook(() => useWorkflowRunSession(makeDraft({ name: " " })), { + wrapper: createWrapper(editorContext), + }); + + await act(async () => { + await result.current.runExecution("fresh"); + }); + + expect(mockToast).toHaveBeenCalledWith({ + title: "aiChat.workflow.nameRequired", + variant: "destructive", + }); + expect(mockRunWorkflowExecution).not.toHaveBeenCalled(); + }); + + it("aborts with stepsRequired when no step has both title and instruction", async () => { + const { result } = renderHook( + () => useWorkflowRunSession(makeDraft({ steps: [{ id: "s1", title: "", instruction: "" }] })), + { wrapper: createWrapper(editorContext) }, + ); + + await act(async () => { + await result.current.runExecution("fresh"); + }); + + expect(mockToast).toHaveBeenCalledWith({ + title: "aiChat.workflow.stepsRequired", + variant: "destructive", + }); + expect(mockRunWorkflowExecution).not.toHaveBeenCalled(); + }); + + it("aborts resume mode with nothingToResume when no paused state exists", async () => { + const { result } = renderHook(() => useWorkflowRunSession(makeDraft()), { + wrapper: createWrapper(editorContext), + }); + + await act(async () => { + await result.current.runExecution("resume"); + }); + + expect(mockToast).toHaveBeenCalledWith({ + title: "aiChat.workflow.nothingToResume", + variant: "destructive", + }); + expect(mockRunWorkflowExecution).not.toHaveBeenCalled(); + }); +}); + +describe("useWorkflowRunSession - run outcomes", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsTauriDesktop.mockReturnValue(true); + }); + + it("fresh run dispatches to runWorkflowExecution with valid steps and reports completed", async () => { + mockRunWorkflowExecution.mockResolvedValue({ + outcome: "completed", + } satisfies WorkflowExecutionOutcome); + + const { result } = renderHook(() => useWorkflowRunSession(makeDraft()), { + wrapper: createWrapper(editorContext), + }); + + await act(async () => { + await result.current.runExecution("fresh"); + }); + + expect(mockRunWorkflowExecution).toHaveBeenCalledTimes(1); + const callArg = mockRunWorkflowExecution.mock.calls[0][0] as { + definition: WorkflowDefinition; + cwd: string | undefined; + startStepIndex: number; + }; + expect(callArg.definition.steps).toHaveLength(2); + expect(callArg.cwd).toBe("/tmp/wsroot"); + expect(callArg.startStepIndex).toBe(0); + + await waitFor(() => { + expect(result.current.progress?.phase).toBe("completed"); + }); + expect(mockToast).toHaveBeenCalledWith({ title: "aiChat.workflow.completed" }); + expect(result.current.pausedState).toBeNull(); + }); + + it("paused outcome stores resumable snapshot and toasts paused", async () => { + mockRunWorkflowExecution.mockResolvedValue({ + outcome: "paused", + pausedAtStepIndex: 1, + pausedStepId: "s2", + stepOutputsById: { s1: "first done" }, + stepOutputs: ["first done", ""], + partialForStep: "in-progress text", + } satisfies WorkflowExecutionOutcome); + + const { result } = renderHook(() => useWorkflowRunSession(makeDraft()), { + wrapper: createWrapper(editorContext), + }); + + await act(async () => { + await result.current.runExecution("fresh"); + }); + + await waitFor(() => { + expect(result.current.pausedState).not.toBeNull(); + }); + expect(result.current.pausedState).toMatchObject({ + pausedStepId: "s2", + stepOutputsById: { s1: "first done" }, + partialForStep: "in-progress text", + }); + expect(result.current.progress?.phase).toBe("paused"); + expect(mockToast).toHaveBeenCalledWith({ title: "aiChat.workflow.paused" }); + }); + + it("error outcome records lastError and toasts a destructive notice", async () => { + mockRunWorkflowExecution.mockResolvedValue({ + outcome: "error", + error: "boom", + } satisfies WorkflowExecutionOutcome); + + const { result } = renderHook(() => useWorkflowRunSession(makeDraft()), { + wrapper: createWrapper(editorContext), + }); + + await act(async () => { + await result.current.runExecution("fresh"); + }); + + await waitFor(() => { + expect(result.current.progress?.phase).toBe("aborted"); + }); + expect(result.current.progress?.lastError).toBe("boom"); + expect(mockToast).toHaveBeenCalledWith({ + title: "aiChat.workflow.error", + variant: "destructive", + }); + }); +}); + +describe("useWorkflowRunSession - resume", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsTauriDesktop.mockReturnValue(true); + }); + + it("resume passes startStepIndex/stepOutputs/resumePartial back to runWorkflowExecution", async () => { + // First, drive a paused outcome so the hook captures pausedState. + // まず paused outcome を発生させ、hook が pausedState を保持する状態にする。 + mockRunWorkflowExecution.mockResolvedValueOnce({ + outcome: "paused", + pausedAtStepIndex: 1, + pausedStepId: "s2", + stepOutputsById: { s1: "first" }, + stepOutputs: ["first", ""], + partialForStep: "partial", + } satisfies WorkflowExecutionOutcome); + + const { result } = renderHook(() => useWorkflowRunSession(makeDraft()), { + wrapper: createWrapper(editorContext), + }); + + await act(async () => { + await result.current.runExecution("fresh"); + }); + await waitFor(() => expect(result.current.pausedState).not.toBeNull()); + + mockRunWorkflowExecution.mockResolvedValueOnce({ + outcome: "completed", + } satisfies WorkflowExecutionOutcome); + + await act(async () => { + await result.current.runExecution("resume"); + }); + + const secondCall = mockRunWorkflowExecution.mock.calls[1][0] as { + startStepIndex: number; + stepOutputs: string[]; + resumePartialForCurrentStep: string | undefined; + }; + expect(secondCall.startStepIndex).toBe(1); + expect(secondCall.stepOutputs).toEqual(["first", ""]); + expect(secondCall.resumePartialForCurrentStep).toBe("partial"); + }); + + it("resume aborts when paused step id is no longer in valid steps", async () => { + mockRunWorkflowExecution.mockResolvedValueOnce({ + outcome: "paused", + pausedAtStepIndex: 1, + pausedStepId: "s2", + stepOutputsById: { s1: "first" }, + stepOutputs: ["first", ""], + partialForStep: "partial", + } satisfies WorkflowExecutionOutcome); + + const draft = makeDraft(); + const { rerender, result } = renderHook( + ({ d }: { d: WorkflowDefinition }) => useWorkflowRunSession(d), + { + wrapper: createWrapper(editorContext), + initialProps: { d: draft }, + }, + ); + + await act(async () => { + await result.current.runExecution("fresh"); + }); + await waitFor(() => expect(result.current.pausedState).not.toBeNull()); + + // User edited the draft and removed `s2`. + // ユーザーが draft を編集し、`s2` を削除したケース。 + const editedDraft = makeDraft({ + steps: [{ id: "s1", title: "Step One", instruction: "do" }], + }); + rerender({ d: editedDraft }); + + await act(async () => { + await result.current.runExecution("resume"); + }); + + expect(mockToast).toHaveBeenCalledWith({ + title: "aiChat.workflow.pausedStepNotFound", + variant: "destructive", + }); + // After aborting, pausedState is reset to null. + // abort 後、pausedState が null にリセットされる。 + expect(result.current.pausedState).toBeNull(); + }); +}); + +describe("useWorkflowRunSession - cleanup and signals", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsTauriDesktop.mockReturnValue(true); + }); + + it("handlePause aborts the current step controller", async () => { + let capturedStepAbort: AbortController | null = null; + let resolveExecution: ((value: WorkflowExecutionOutcome) => void) | null = null; + + mockRunWorkflowExecution.mockImplementation( + async (params: { + createStepAbort: () => AbortController; + }): Promise => { + capturedStepAbort = params.createStepAbort(); + return new Promise((resolve) => { + resolveExecution = resolve; + }); + }, + ); + + const { result } = renderHook(() => useWorkflowRunSession(makeDraft()), { + wrapper: createWrapper(editorContext), + }); + + let pending: Promise | undefined; + act(() => { + pending = result.current.runExecution("fresh"); + }); + await waitFor(() => expect(capturedStepAbort).not.toBeNull()); + + act(() => { + result.current.handlePause(); + }); + + expect(capturedStepAbort?.signal.aborted).toBe(true); + + // Drain the promise so the test does not leak. + // テストリークを防ぐために Promise を解放する。 + await act(async () => { + resolveExecution?.({ outcome: "completed" }); + await pending; + }); + }); + + it("handleStop aborts both workflow and current-step controllers", async () => { + let capturedStepAbort: AbortController | null = null; + let capturedWorkflowSignal: AbortSignal | null = null; + let resolveExecution: ((value: WorkflowExecutionOutcome) => void) | null = null; + + mockRunWorkflowExecution.mockImplementation( + async (params: { + workflowSignal: AbortSignal; + createStepAbort: () => AbortController; + }): Promise => { + capturedWorkflowSignal = params.workflowSignal; + capturedStepAbort = params.createStepAbort(); + return new Promise((resolve) => { + resolveExecution = resolve; + }); + }, + ); + + const { result } = renderHook(() => useWorkflowRunSession(makeDraft()), { + wrapper: createWrapper(editorContext), + }); + + let pending: Promise | undefined; + act(() => { + pending = result.current.runExecution("fresh"); + }); + await waitFor(() => expect(capturedWorkflowSignal).not.toBeNull()); + + act(() => { + result.current.handleStop(); + }); + + expect(capturedWorkflowSignal?.aborted).toBe(true); + expect(capturedStepAbort?.signal.aborted).toBe(true); + + await act(async () => { + resolveExecution?.({ outcome: "stopped" }); + await pending; + }); + }); + + it("aborts in-flight controllers on unmount", async () => { + let capturedStepAbort: AbortController | null = null; + let capturedWorkflowSignal: AbortSignal | null = null; + let resolveExecution: ((value: WorkflowExecutionOutcome) => void) | null = null; + + mockRunWorkflowExecution.mockImplementation( + async (params: { + workflowSignal: AbortSignal; + createStepAbort: () => AbortController; + }): Promise => { + capturedWorkflowSignal = params.workflowSignal; + capturedStepAbort = params.createStepAbort(); + return new Promise((resolve) => { + resolveExecution = resolve; + }); + }, + ); + + const { result, unmount } = renderHook(() => useWorkflowRunSession(makeDraft()), { + wrapper: createWrapper(editorContext), + }); + + let pending: Promise | undefined; + act(() => { + pending = result.current.runExecution("fresh"); + }); + await waitFor(() => expect(capturedWorkflowSignal).not.toBeNull()); + + unmount(); + + expect(capturedWorkflowSignal?.aborted).toBe(true); + expect(capturedStepAbort?.signal.aborted).toBe(true); + + // Resolve to avoid leaking the pending promise into the next test. + // 次のテストへ pending Promise が漏れないように resolve する。 + resolveExecution?.({ outcome: "stopped" }); + await pending; + }); +}); diff --git a/src/lib/agentSlashCommands/agentSlashEditorText.test.ts b/src/lib/agentSlashCommands/agentSlashEditorText.test.ts new file mode 100644 index 00000000..9213ec5d --- /dev/null +++ b/src/lib/agentSlashCommands/agentSlashEditorText.test.ts @@ -0,0 +1,55 @@ +/** + * Tests for plain-text / selection-text helpers used by agent slash commands. + * エージェントスラッシュ用のテキスト取得ヘルパーのテスト。 + */ + +import type { Editor } from "@tiptap/core"; +import { describe, expect, it, vi } from "vitest"; +import { getEditorPlainText, getEditorSelectionText } from "./agentSlashEditorText"; + +describe("getEditorPlainText", () => { + it("requests text from Tiptap with a newline block separator", () => { + const getText = vi.fn(() => "line1\nline2"); + const editor = { getText } as unknown as Editor; + + expect(getEditorPlainText(editor)).toBe("line1\nline2"); + expect(getText).toHaveBeenCalledWith({ blockSeparator: "\n" }); + }); + + it("returns whatever Tiptap returns, including empty strings", () => { + const editor = { getText: vi.fn(() => "") } as unknown as Editor; + expect(getEditorPlainText(editor)).toBe(""); + }); +}); + +describe("getEditorSelectionText", () => { + it("returns empty when the selection is collapsed", () => { + const textBetween = vi.fn(); + const editor = { + state: { + selection: { from: 5, to: 5 }, + doc: { textBetween }, + }, + } as unknown as Editor; + + expect(getEditorSelectionText(editor)).toBe(""); + // 折りたたみ選択では textBetween を呼ばない(不要な計算を避ける)。 + // No call to textBetween for a collapsed selection — avoids wasted work. + expect(textBetween).not.toHaveBeenCalled(); + }); + + it("delegates to doc.textBetween with a newline block separator and U+FFFC leaf marker", () => { + const textBetween = vi.fn(() => "abc"); + const editor = { + state: { + selection: { from: 2, to: 7 }, + doc: { textBetween }, + }, + } as unknown as Editor; + + expect(getEditorSelectionText(editor)).toBe("abc"); + // U+FFFC (object replacement char) はノード代替の標準プレースホルダ。 + // U+FFFC is the standard placeholder used for node replacements in ProseMirror. + expect(textBetween).toHaveBeenCalledWith(2, 7, "\n", ""); + }); +}); diff --git a/src/lib/agentSlashCommands/buildAgentSlashPrompt.test.ts b/src/lib/agentSlashCommands/buildAgentSlashPrompt.test.ts new file mode 100644 index 00000000..da4d4610 --- /dev/null +++ b/src/lib/agentSlashCommands/buildAgentSlashPrompt.test.ts @@ -0,0 +1,114 @@ +/** + * Dispatcher-level tests for `buildAgentSlashPrompt` and Claude option policy. + * `buildAgentSlashPrompt` 振り分けと Claude 実行ポリシーのテスト。 + */ + +import type { Editor } from "@tiptap/core"; +import { describe, expect, it, vi } from "vitest"; +import { buildAgentSlashPrompt, getAgentSlashClaudeOptions } from "./buildAgentSlashPrompt"; +import type { AgentSlashCommandId } from "./types"; + +/** + * Minimal editor mock used only by `agent-explain` / `agent-summarize` paths + * when captures are not supplied. Other branches do not touch the editor. + * `agent-explain` / `agent-summarize` 以外の分岐ではエディタを触らない。 + */ +function makeMockEditor(opts: { plainText?: string; selectionText?: string }): Editor { + return { + getText: vi.fn(() => opts.plainText ?? ""), + state: { + selection: { from: 0, to: opts.selectionText ? opts.selectionText.length : 0 }, + doc: { + textBetween: vi.fn(() => opts.selectionText ?? ""), + }, + }, + } as unknown as Editor; +} + +describe("buildAgentSlashPrompt — command dispatch", () => { + // Editor が必要になる分岐は限定的。args の前後空白は trim される契約。 + // Only explain/summarize touch the editor; args are trimmed by contract. + + it.each([ + ["agent-analyze", " src/lib.ts ", "Target path (relative to workspace): src/lib.ts"], + ["agent-run", " echo hi ", "Command: echo hi"], + ["agent-research", " graphs ", "Topic: graphs"], + ["agent-review", " src/x.ts ", "Target path: src/x.ts"], + ["agent-test", " src/x.test.ts ", "Focus path or pattern: src/x.test.ts"], + ] as const)("dispatches %s and forwards trimmed args", (id, args, needle) => { + const editor = makeMockEditor({}); + const out = buildAgentSlashPrompt(id as AgentSlashCommandId, args, editor); + expect(out).toContain(needle); + }); + + it("dispatches agent-git-summary regardless of args", () => { + const editor = makeMockEditor({}); + const out = buildAgentSlashPrompt("agent-git-summary", "ignored args", editor); + expect(out).toContain("git log -n 20 --oneline"); + }); + + it("dispatches agent-explain and forwards the captured selection", () => { + const editor = makeMockEditor({}); + const out = buildAgentSlashPrompt("agent-explain", "", editor, { + selectionText: "snippet", + }); + expect(out).toContain("Selection:"); + expect(out).toContain("snippet"); + expect(editor.state.doc.textBetween).not.toHaveBeenCalled(); + }); + + it("dispatches agent-summarize and forwards the captured plain text", () => { + const editor = makeMockEditor({}); + const out = buildAgentSlashPrompt("agent-summarize", "", editor, { + plainText: "body", + }); + expect(out).toContain("Note text:\nbody"); + expect(editor.getText).not.toHaveBeenCalled(); + }); + + it("falls back to the live editor when captures are absent (explain)", () => { + const editor = makeMockEditor({ selectionText: "live" }); + const out = buildAgentSlashPrompt("agent-explain", "", editor); + expect(out).toContain("live"); + expect(editor.state.doc.textBetween).toHaveBeenCalled(); + }); + + it("falls back to the live editor when captures are absent (summarize)", () => { + const editor = makeMockEditor({ plainText: "live body" }); + const out = buildAgentSlashPrompt("agent-summarize", "", editor); + expect(out).toContain("live body"); + expect(editor.getText).toHaveBeenCalled(); + }); + + it("throws on an unknown command id (defensive exhaustiveness check)", () => { + const editor = makeMockEditor({}); + // 型に存在しない id を渡したケースのフェイルセーフ。 + // Defensive guard for an id that bypassed the type system. + expect(() => + buildAgentSlashPrompt("agent-unknown" as unknown as AgentSlashCommandId, "", editor), + ).toThrow(/Unhandled agent slash command/); + }); +}); + +describe("getAgentSlashClaudeOptions", () => { + it.each([ + ["agent-analyze", 20, ["Read"]], + ["agent-explain", 8, []], + ["agent-git-summary", 10, ["Bash"]], + ["agent-research", 20, ["WebSearch", "Read"]], + ["agent-review", 20, ["Read"]], + ["agent-run", 12, ["Bash"]], + ["agent-summarize", 16, []], + ["agent-test", 18, ["Bash", "Read"]], + ] as const)("returns the policy for %s", (id, maxTurns, allowedTools) => { + const opts = getAgentSlashClaudeOptions(id as AgentSlashCommandId); + expect(opts.maxTurns).toBe(maxTurns); + expect(opts.allowedTools).toEqual(allowedTools); + }); + + it("throws on an unknown command id (defensive exhaustiveness check)", () => { + expect(() => + getAgentSlashClaudeOptions("agent-unknown" as unknown as AgentSlashCommandId), + ).toThrow(/Unhandled agent slash command/); + }); +}); diff --git a/src/lib/agentSlashCommands/buildAgentSlashPromptParts.test.ts b/src/lib/agentSlashCommands/buildAgentSlashPromptParts.test.ts new file mode 100644 index 00000000..0058e4b0 --- /dev/null +++ b/src/lib/agentSlashCommands/buildAgentSlashPromptParts.test.ts @@ -0,0 +1,184 @@ +/** + * Per-command prompt builder tests for agent slash commands. + * エージェントスラッシュ各コマンドのプロンプト生成テスト。 + */ + +import type { Editor } from "@tiptap/core"; +import { describe, expect, it, vi } from "vitest"; +import { + buildAnalyzePrompt, + buildExplainPrompt, + buildGitSummaryPrompt, + buildResearchPrompt, + buildReviewPrompt, + buildRunPrompt, + buildSummarizePrompt, + buildTestPrompt, +} from "./buildAgentSlashPromptParts"; + +/** + * Builds a minimal mock editor that satisfies the calls made by the + * prompt-builder helpers (`getText` and selection-aware `textBetween`). + * プロンプトビルダから呼ばれる API のみを満たす最小モックを返す。 + */ +function makeMockEditor(opts: { + /** Plain text returned from `editor.getText`. / `editor.getText` の戻り値 */ + plainText?: string; + /** Selection range for `state.selection`. / `state.selection` の範囲 */ + selection?: { from: number; to: number }; + /** Selection text returned by `doc.textBetween`. / `doc.textBetween` の戻り値 */ + selectionText?: string; +}): Editor { + const sel = opts.selection ?? { from: 0, to: 0 }; + return { + getText: vi.fn(() => opts.plainText ?? ""), + state: { + selection: sel, + doc: { + textBetween: vi.fn(() => opts.selectionText ?? ""), + }, + }, + } as unknown as Editor; +} + +describe("buildAnalyzePrompt", () => { + it("includes the workspace path when args are provided", () => { + const out = buildAnalyzePrompt("src/lib/foo.ts"); + expect(out).toContain("Target path (relative to workspace): src/lib/foo.ts"); + expect(out).toContain("Analyze the code at the given path"); + }); + + it("falls back to inference message when args are empty", () => { + const out = buildAnalyzePrompt(""); + expect(out).toContain("No path was given"); + expect(out).not.toContain("Target path (relative to workspace):"); + }); +}); + +describe("buildGitSummaryPrompt", () => { + it("instructs to run git log and summarize in Japanese", () => { + const out = buildGitSummaryPrompt(); + expect(out).toContain("git log -n 20 --oneline"); + expect(out).toContain("Bash"); + expect(out).toContain("Japanese"); + }); +}); + +describe("buildRunPrompt", () => { + it("includes the command when args are provided", () => { + const out = buildRunPrompt("ls -la"); + expect(out).toContain("Command: ls -la"); + expect(out).toContain("Bash"); + }); + + it("asks the user to provide a command when args are empty", () => { + const out = buildRunPrompt(""); + expect(out).toContain("No command was given"); + }); +}); + +describe("buildResearchPrompt", () => { + it("includes the topic when args are provided", () => { + const out = buildResearchPrompt("Tiptap performance"); + expect(out).toContain("Topic: Tiptap performance"); + expect(out).toContain("WebSearch"); + }); + + it("asks the user to specify a topic when args are empty", () => { + const out = buildResearchPrompt(""); + expect(out).toContain("No topic was given"); + }); +}); + +describe("buildReviewPrompt", () => { + it("includes the target path when args are provided", () => { + const out = buildReviewPrompt("src/components/Foo.tsx"); + expect(out).toContain("Target path: src/components/Foo.tsx"); + expect(out).toContain("code review"); + }); + + it("asks for a default when args are empty", () => { + const out = buildReviewPrompt(""); + expect(out).toContain("No path was given"); + }); +}); + +describe("buildTestPrompt", () => { + it("includes the focus path when args are provided", () => { + const out = buildTestPrompt("src/lib/foo.test.ts"); + expect(out).toContain("Focus path or pattern: src/lib/foo.test.ts"); + expect(out).toContain("bun run test:run"); + }); + + it("falls back to default test script when args are empty", () => { + const out = buildTestPrompt(""); + expect(out).toContain("No path"); + expect(out).toContain("default test script"); + }); +}); + +describe("buildExplainPrompt", () => { + it("uses the captured selection text when provided", () => { + const editor = makeMockEditor({}); + const out = buildExplainPrompt(editor, { selectionText: "captured snippet" }); + expect(out).toContain("Selection:"); + expect(out).toContain("captured snippet"); + // /explain のフォールバックは getEditorSelectionText → doc.textBetween。 + // captures.selectionText がある場合はこちらが呼ばれないことを固定する。 + // /explain falls back via getEditorSelectionText → doc.textBetween, so + // pin that the document accessor is skipped when captures provide the text. + expect(editor.state.doc.textBetween).not.toHaveBeenCalled(); + }); + + it("falls back to live editor selection when captures are absent", () => { + const editor = makeMockEditor({ + selection: { from: 1, to: 8 }, + selectionText: "live sel", + }); + const out = buildExplainPrompt(editor); + expect(out).toContain("Selection:"); + expect(out).toContain("live sel"); + expect(editor.state.doc.textBetween).toHaveBeenCalled(); + }); + + it("instructs the user to select text when no selection is available", () => { + const editor = makeMockEditor({ selection: { from: 4, to: 4 } }); + const out = buildExplainPrompt(editor); + expect(out).toContain("No selection"); + }); +}); + +describe("buildSummarizePrompt", () => { + it("uses the captured plain text when provided", () => { + const editor = makeMockEditor({}); + const out = buildSummarizePrompt(editor, { plainText: "note body" }); + expect(out).toContain("Note text:\nnote body"); + expect(editor.getText).not.toHaveBeenCalled(); + }); + + it("falls back to live editor text when captures are absent", () => { + const editor = makeMockEditor({ plainText: "live body" }); + const out = buildSummarizePrompt(editor); + expect(out).toContain("Note text:\nlive body"); + expect(editor.getText).toHaveBeenCalledWith({ blockSeparator: "\n" }); + }); + + it("reports an empty note when there is no text", () => { + const editor = makeMockEditor({ plainText: "" }); + const out = buildSummarizePrompt(editor); + expect(out).toContain("The note appears empty."); + expect(out).not.toContain("Note text:"); + }); + + it("truncates very large note text and marks it as truncated", () => { + const huge = "x".repeat(13000); + const editor = makeMockEditor({ plainText: huge }); + const out = buildSummarizePrompt(editor); + expect(out).toContain("…(truncated)"); + // 12000 文字 + "\n\n…(truncated)" のみが本文として残る。 + // Exactly 12000 chars from the original should be included before the truncation marker. + const slice = out.split("Note text:\n")[1] ?? ""; + expect(slice.startsWith("x".repeat(12000))).toBe(true); + expect(slice.length).toBeLessThan(huge.length); + }); +}); diff --git a/src/lib/agentSlashCommands/executeAgentSlashCommand.test.ts b/src/lib/agentSlashCommands/executeAgentSlashCommand.test.ts new file mode 100644 index 00000000..880bf892 --- /dev/null +++ b/src/lib/agentSlashCommands/executeAgentSlashCommand.test.ts @@ -0,0 +1,334 @@ +/** + * Tests for the high-level orchestration of one slash-agent execution. + * スラッシュエージェント 1 回分のオーケストレーションテスト。 + */ + +import type { Editor } from "@tiptap/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/claudeCode/runQueryToCompletion", () => ({ + runClaudeQueryToCompletion: vi.fn(), +})); + +vi.mock("./buildAgentSlashPrompt", () => ({ + buildAgentSlashPrompt: vi.fn(() => "PROMPT"), + getAgentSlashClaudeOptions: vi.fn(() => ({ maxTurns: 5, allowedTools: ["Read"] })), + getEditorPlainText: vi.fn(() => "PLAIN"), + getEditorSelectionText: vi.fn(() => ""), +})); + +vi.mock("./insertSlashAgentMarkdown", () => ({ + insertSlashAgentMarkdownAt: vi.fn(), +})); + +vi.mock("./insertPosition", () => ({ + readSlashAgentInsertPosition: vi.fn(() => "cursor"), +})); + +vi.mock("./slashAgentSelectionCache", () => ({ + clearLastSlashAgentSelection: vi.fn(), + getLastSlashAgentSelection: vi.fn(() => ""), +})); + +import { runClaudeQueryToCompletion } from "@/lib/claudeCode/runQueryToCompletion"; +import { + buildAgentSlashPrompt, + getAgentSlashClaudeOptions, + getEditorSelectionText, +} from "./buildAgentSlashPrompt"; +import { executeAgentSlashCommand } from "./executeAgentSlashCommand"; +import { + getSlashAgentCommandHook, + registerSlashAgentCommandHook, + type SlashAgentCommandHook, +} from "./hook"; +import { readSlashAgentInsertPosition } from "./insertPosition"; +import { insertSlashAgentMarkdownAt } from "./insertSlashAgentMarkdown"; +import { + clearLastSlashAgentSelection, + getLastSlashAgentSelection, +} from "./slashAgentSelectionCache"; + +/** + * Builds a fluent editor mock with traceable chain calls and a mutable cursor. + * チェーン呼び出しを追跡できるエディタモックを構築する。 + */ +function makeMockEditor(opts: { cursorAfterDelete?: number } = {}): { + editor: Editor; + deleteRange: ReturnType; + insertContentAt: ReturnType; +} { + const deleteRange = vi.fn(); + const insertContentAt = vi.fn(); + + const chainObj: { + focus: () => typeof chainObj; + deleteRange: (range: unknown) => typeof chainObj; + insertContentAt: (pos: number, content: unknown) => typeof chainObj; + run: () => boolean; + } = { + focus() { + return chainObj; + }, + deleteRange(range) { + deleteRange(range); + return chainObj; + }, + insertContentAt(pos, content) { + insertContentAt(pos, content); + return chainObj; + }, + run() { + return true; + }, + }; + + const editor = { + chain: vi.fn(() => chainObj), + state: { + selection: { from: opts.cursorAfterDelete ?? 12 }, + }, + } as unknown as Editor; + + return { editor, deleteRange, insertContentAt }; +} + +beforeEach(() => { + vi.mocked(runClaudeQueryToCompletion).mockReset(); + vi.mocked(buildAgentSlashPrompt).mockClear(); + vi.mocked(getAgentSlashClaudeOptions).mockClear(); + vi.mocked(getEditorSelectionText).mockReset().mockReturnValue(""); + vi.mocked(insertSlashAgentMarkdownAt).mockReset(); + vi.mocked(readSlashAgentInsertPosition).mockReset().mockReturnValue("cursor"); + vi.mocked(clearLastSlashAgentSelection).mockReset(); + vi.mocked(getLastSlashAgentSelection).mockReset().mockReturnValue(""); +}); + +afterEach(() => { + registerSlashAgentCommandHook(null); +}); + +describe("executeAgentSlashCommand — hook short-circuit", () => { + it("uses hook output without calling Claude", async () => { + const hook: SlashAgentCommandHook = vi.fn(() => ({ markdown: "from-hook" })); + registerSlashAgentCommandHook(hook); + + const { editor, deleteRange } = makeMockEditor({ cursorAfterDelete: 17 }); + + const result = await executeAgentSlashCommand({ + commandId: "agent-analyze", + query: "analyze src/foo.ts", + editor, + range: { from: 4, to: 10 }, + }); + + expect(result).toBeNull(); + expect(hook).toHaveBeenCalledTimes(1); + expect(deleteRange).toHaveBeenCalledWith({ from: 4, to: 10 }); + // Claude 経路に進まないこと。 + // The Claude pipeline must not run when the hook resolves. + expect(runClaudeQueryToCompletion).not.toHaveBeenCalled(); + expect(insertSlashAgentMarkdownAt).toHaveBeenCalledWith(editor, 17, "from-hook", "cursor"); + }); + + it("returns the hook's error message when the hook throws", async () => { + registerSlashAgentCommandHook(() => { + throw new Error("hook boom"); + }); + + const { editor, deleteRange } = makeMockEditor(); + + const result = await executeAgentSlashCommand({ + commandId: "agent-analyze", + query: "analyze x", + editor, + range: { from: 0, to: 5 }, + }); + + expect(result).toBe("hook boom"); + // 失敗時はエディタを変更しない(範囲削除も Claude 呼び出しもしない)。 + // On failure no editor mutation and no Claude call should happen. + expect(deleteRange).not.toHaveBeenCalled(); + expect(runClaudeQueryToCompletion).not.toHaveBeenCalled(); + }); + + it("falls through to Claude when the hook returns null", async () => { + registerSlashAgentCommandHook(() => null); + vi.mocked(runClaudeQueryToCompletion).mockResolvedValue({ ok: true, content: "claude-result" }); + + const { editor } = makeMockEditor(); + + const result = await executeAgentSlashCommand({ + commandId: "agent-analyze", + query: "analyze src/foo.ts", + editor, + range: { from: 0, to: 5 }, + }); + + expect(result).toBeNull(); + expect(runClaudeQueryToCompletion).toHaveBeenCalledTimes(1); + // makeMockEditor() の既定で state.selection.from = 12 になるため、 + // 挿入位置はその固定値を期待する(曖昧な expect.any(Number) を避ける)。 + // The default mock editor has state.selection.from = 12, so pin it + // exactly instead of accepting any number. + expect(insertSlashAgentMarkdownAt).toHaveBeenCalledWith(editor, 12, "claude-result", "cursor"); + }); +}); + +describe("executeAgentSlashCommand — Claude execution path", () => { + it("forwards the resolved args + selection captures to the prompt builder", async () => { + vi.mocked(runClaudeQueryToCompletion).mockResolvedValue({ ok: true, content: "ok" }); + vi.mocked(getEditorSelectionText).mockReturnValue("live-sel"); + + const { editor } = makeMockEditor(); + + await executeAgentSlashCommand({ + commandId: "agent-analyze", + query: "analyze src/foo.ts", + editor, + range: { from: 0, to: 5 }, + }); + + expect(buildAgentSlashPrompt).toHaveBeenCalledWith("agent-analyze", "src/foo.ts", editor, { + selectionText: "live-sel", + plainText: "PLAIN", + }); + }); + + it("merges claudeCwd into the Claude options when provided", async () => { + vi.mocked(runClaudeQueryToCompletion).mockResolvedValue({ ok: true, content: "ok" }); + + const { editor } = makeMockEditor(); + const signal = new AbortController().signal; + + await executeAgentSlashCommand({ + commandId: "agent-run", + query: "run echo", + editor, + range: { from: 0, to: 3 }, + signal, + claudeCwd: "/tmp/work", + }); + + const [prompt, opts, passedSignal] = vi.mocked(runClaudeQueryToCompletion).mock.calls[0]; + expect(prompt).toBe("PROMPT"); + expect(opts).toEqual({ maxTurns: 5, allowedTools: ["Read"], cwd: "/tmp/work" }); + expect(passedSignal).toBe(signal); + }); + + it("does not include cwd when claudeCwd is omitted", async () => { + vi.mocked(runClaudeQueryToCompletion).mockResolvedValue({ ok: true, content: "ok" }); + + const { editor } = makeMockEditor(); + + await executeAgentSlashCommand({ + commandId: "agent-run", + query: "run echo", + editor, + range: { from: 0, to: 3 }, + }); + + const opts = vi.mocked(runClaudeQueryToCompletion).mock.calls[0][1]; + expect(opts).toEqual({ maxTurns: 5, allowedTools: ["Read"] }); + expect((opts as Record).cwd).toBeUndefined(); + }); + + it("inserts the Claude content at the post-delete cursor with the chosen position", async () => { + vi.mocked(runClaudeQueryToCompletion).mockResolvedValue({ ok: true, content: "## Result" }); + vi.mocked(readSlashAgentInsertPosition).mockReturnValue("end"); + + const { editor } = makeMockEditor({ cursorAfterDelete: 99 }); + + await executeAgentSlashCommand({ + commandId: "agent-analyze", + query: "analyze x", + editor, + range: { from: 0, to: 5 }, + }); + + expect(insertSlashAgentMarkdownAt).toHaveBeenCalledWith(editor, 99, "## Result", "end"); + }); + + it("inserts a paragraph with the error and returns the message on failure", async () => { + vi.mocked(runClaudeQueryToCompletion).mockResolvedValue({ ok: false, error: "boom" }); + + const { editor, insertContentAt } = makeMockEditor({ cursorAfterDelete: 25 }); + + const result = await executeAgentSlashCommand({ + commandId: "agent-analyze", + query: "analyze x", + editor, + range: { from: 0, to: 5 }, + }); + + expect(result).toBe("boom"); + expect(insertContentAt).toHaveBeenCalledWith(25, { + type: "paragraph", + content: [{ type: "text", text: "Claude Code: boom" }], + }); + // 失敗時は Markdown 挿入経路を通らないこと。 + // The Markdown-insert path must not run on failure. + expect(insertSlashAgentMarkdownAt).not.toHaveBeenCalled(); + }); + + it("falls back to the cached selection for /explain when the live selection is empty", async () => { + vi.mocked(runClaudeQueryToCompletion).mockResolvedValue({ ok: true, content: "ok" }); + vi.mocked(getEditorSelectionText).mockReturnValue(""); + vi.mocked(getLastSlashAgentSelection).mockReturnValue("cached"); + + const { editor } = makeMockEditor(); + + await executeAgentSlashCommand({ + commandId: "agent-explain", + query: "explain", + editor, + range: { from: 0, to: 7 }, + }); + + expect(buildAgentSlashPrompt).toHaveBeenCalledWith("agent-explain", "", editor, { + selectionText: "cached", + plainText: "PLAIN", + }); + // 成功時は /explain のキャッシュをクリアする。 + // On success the /explain selection cache is cleared. + expect(clearLastSlashAgentSelection).toHaveBeenCalledWith(editor); + }); + + it("does not consult the /explain selection cache for other commands", async () => { + vi.mocked(runClaudeQueryToCompletion).mockResolvedValue({ ok: true, content: "ok" }); + vi.mocked(getEditorSelectionText).mockReturnValue(""); + + const { editor } = makeMockEditor(); + + await executeAgentSlashCommand({ + commandId: "agent-summarize", + query: "summarize", + editor, + range: { from: 0, to: 9 }, + }); + + expect(getLastSlashAgentSelection).not.toHaveBeenCalled(); + expect(clearLastSlashAgentSelection).not.toHaveBeenCalled(); + }); + + it("hook short-circuit clears the /explain cache too", async () => { + registerSlashAgentCommandHook(() => ({ markdown: "from-hook" })); + + const { editor } = makeMockEditor(); + + await executeAgentSlashCommand({ + commandId: "agent-explain", + query: "explain", + editor, + range: { from: 0, to: 5 }, + }); + + expect(clearLastSlashAgentSelection).toHaveBeenCalledWith(editor); + }); + + it("returns the resolved hook from the registry on each invocation", () => { + const hook: SlashAgentCommandHook = vi.fn(() => null); + registerSlashAgentCommandHook(hook); + expect(getSlashAgentCommandHook()).toBe(hook); + }); +}); diff --git a/src/lib/agentSlashCommands/hook.test.ts b/src/lib/agentSlashCommands/hook.test.ts new file mode 100644 index 00000000..6b48a763 --- /dev/null +++ b/src/lib/agentSlashCommands/hook.test.ts @@ -0,0 +1,64 @@ +/** + * Tests for the global slash-agent command hook registry. + * グローバルなスラッシュエージェントフック登録のテスト。 + */ + +import type { Editor } from "@tiptap/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + getSlashAgentCommandHook, + registerSlashAgentCommandHook, + type SlashAgentCommandHook, +} from "./hook"; + +afterEach(() => { + // 共有モジュール状態を必ずリセットする(テスト間の漏れ防止)。 + // Always reset shared module state to prevent cross-test bleed. + registerSlashAgentCommandHook(null); +}); + +describe("registerSlashAgentCommandHook / getSlashAgentCommandHook", () => { + it("returns null when no hook is registered", () => { + expect(getSlashAgentCommandHook()).toBeNull(); + }); + + it("registers the supplied hook and exposes it via the getter", () => { + const hook: SlashAgentCommandHook = vi.fn(() => null); + registerSlashAgentCommandHook(hook); + expect(getSlashAgentCommandHook()).toBe(hook); + }); + + it("clears the registered hook when null is passed", () => { + const hook: SlashAgentCommandHook = vi.fn(() => null); + registerSlashAgentCommandHook(hook); + registerSlashAgentCommandHook(null); + expect(getSlashAgentCommandHook()).toBeNull(); + }); + + it("replaces the previous hook on re-registration", () => { + const first: SlashAgentCommandHook = vi.fn(() => null); + const second: SlashAgentCommandHook = vi.fn(() => null); + registerSlashAgentCommandHook(first); + registerSlashAgentCommandHook(second); + expect(getSlashAgentCommandHook()).toBe(second); + }); + + it("invokes the registered hook with the supplied context", async () => { + const hook: SlashAgentCommandHook = vi.fn(() => ({ markdown: "from-hook" })); + registerSlashAgentCommandHook(hook); + const editor = {} as Editor; + const result = await getSlashAgentCommandHook()?.({ + commandId: "agent-analyze", + args: "src/x", + query: "analyze src/x", + editor, + }); + expect(hook).toHaveBeenCalledWith({ + commandId: "agent-analyze", + args: "src/x", + query: "analyze src/x", + editor, + }); + expect(result).toEqual({ markdown: "from-hook" }); + }); +}); diff --git a/src/lib/agentSlashCommands/insertPosition.test.ts b/src/lib/agentSlashCommands/insertPosition.test.ts new file mode 100644 index 00000000..de8ffabc --- /dev/null +++ b/src/lib/agentSlashCommands/insertPosition.test.ts @@ -0,0 +1,112 @@ +/** + * Tests for the insert-position localStorage helpers. + * 挿入位置を localStorage に保持するヘルパーのテスト。 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { readSlashAgentInsertPosition, writeSlashAgentInsertPosition } from "./insertPosition"; + +const STORAGE_KEY = "zedi.slashAgent.insertPosition"; + +describe("readSlashAgentInsertPosition", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("defaults to 'cursor' when no value is stored", () => { + expect(readSlashAgentInsertPosition()).toBe("cursor"); + }); + + it("returns 'end' when 'end' is stored", () => { + window.localStorage.setItem(STORAGE_KEY, "end"); + expect(readSlashAgentInsertPosition()).toBe("end"); + }); + + it("returns 'cursor' when an unknown value is stored", () => { + // 仕様外の値は cursor にフォールバック(前向きに壊れない)。 + // Unknown values fall back to 'cursor' (forward-compatible). + window.localStorage.setItem(STORAGE_KEY, "middle"); + expect(readSlashAgentInsertPosition()).toBe("cursor"); + }); + + it("returns 'cursor' when localStorage.getItem throws", () => { + const spy = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("denied"); + }); + try { + expect(readSlashAgentInsertPosition()).toBe("cursor"); + } finally { + spy.mockRestore(); + } + }); +}); + +describe("writeSlashAgentInsertPosition", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("persists 'end' under the canonical key", () => { + writeSlashAgentInsertPosition("end"); + expect(window.localStorage.getItem(STORAGE_KEY)).toBe("end"); + }); + + it("persists 'cursor' under the canonical key", () => { + writeSlashAgentInsertPosition("cursor"); + expect(window.localStorage.getItem(STORAGE_KEY)).toBe("cursor"); + }); + + it("swallows quota / private-mode errors silently", () => { + const spy = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("quota"); + }); + try { + // 例外が外に漏れないことを契約として固定する。 + // Pin the contract that errors do not propagate. + expect(() => writeSlashAgentInsertPosition("end")).not.toThrow(); + } finally { + spy.mockRestore(); + } + }); +}); + +describe("readSlashAgentInsertPosition — non-browser environment", () => { + // jsdom 上で window を一時的に消し、SSR 経路の早期 return を確認する。 + // また `delete window` が黙って失敗するケース(globalThis.window が + // non-configurable)でも誤検知しないように、localStorage への + // アクセス自体が発生していないことも合わせて検証する。 + // Temporarily delete window to exercise the SSR early return. Because + // `delete window` may silently fail in some jsdom builds (the property is + // non-configurable), also assert that localStorage was never touched — + // that pins the contract that the early-return path actually ran. + let originalWindow: typeof globalThis.window | undefined; + let getItemSpy: ReturnType; + let setItemSpy: ReturnType; + + beforeEach(() => { + originalWindow = globalThis.window; + delete (globalThis as { window?: unknown }).window; + getItemSpy = vi.spyOn(Storage.prototype, "getItem"); + setItemSpy = vi.spyOn(Storage.prototype, "setItem"); + }); + + afterEach(() => { + getItemSpy.mockRestore(); + setItemSpy.mockRestore(); + if (originalWindow !== undefined) { + (globalThis as { window?: typeof globalThis.window }).window = originalWindow; + } + }); + + it("defaults to 'cursor' without touching localStorage when window is undefined", () => { + expect(readSlashAgentInsertPosition()).toBe("cursor"); + // SSR 経路が確かに走ったことを localStorage 非アクセスで担保する。 + // Asserts the SSR branch actually executed (no localStorage access). + expect(getItemSpy).not.toHaveBeenCalled(); + }); + + it("writeSlashAgentInsertPosition is a no-op without touching localStorage", () => { + expect(() => writeSlashAgentInsertPosition("end")).not.toThrow(); + expect(setItemSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/agentSlashCommands/insertSlashAgentMarkdown.test.ts b/src/lib/agentSlashCommands/insertSlashAgentMarkdown.test.ts new file mode 100644 index 00000000..31ee95dc --- /dev/null +++ b/src/lib/agentSlashCommands/insertSlashAgentMarkdown.test.ts @@ -0,0 +1,139 @@ +/** + * Tests for inserting Markdown returned by Claude into the Tiptap editor. + * Claude が返した Markdown を Tiptap に挿入する処理のテスト。 + */ + +import type { Editor } from "@tiptap/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/markdownToTiptap", () => ({ + convertMarkdownToTiptapContent: vi.fn(), +})); + +import { convertMarkdownToTiptapContent } from "@/lib/markdownToTiptap"; +import { insertSlashAgentMarkdownAt } from "./insertSlashAgentMarkdown"; + +/** + * Builds a fluent chain spy that captures `insertContentAt` arguments. + * `insertContentAt` の呼び出しを記録するチェーンスパイを構築する。 + */ +function makeChainSpy(): { + chain: () => { + focus: () => { insertContentAt: ReturnType }; + }; + insertContentAt: ReturnType; +} { + const insertContentAt = vi.fn(() => ({ run: vi.fn() })); + const chain = vi.fn(() => ({ + focus: vi.fn(() => ({ insertContentAt })), + })); + return { chain, insertContentAt }; +} + +/** + * Builds an editor mock with a fluent chain and a doc of the given size. + * 指定サイズの doc とチェーンスパイを持つエディタモックを返す。 + */ +function makeMockEditor(docSize: number): { + editor: Editor; + insertContentAt: ReturnType; +} { + const { chain, insertContentAt } = makeChainSpy(); + const editor = { + chain, + state: { + doc: { content: { size: docSize } }, + }, + } as unknown as Editor; + return { editor, insertContentAt }; +} + +beforeEach(() => { + vi.mocked(convertMarkdownToTiptapContent).mockReset(); +}); + +describe("insertSlashAgentMarkdownAt", () => { + it("inserts converted content at the cursor position when position='cursor'", () => { + const fakeJson = JSON.stringify({ + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: "hi" }] }], + }); + vi.mocked(convertMarkdownToTiptapContent).mockReturnValue(fakeJson); + + const { editor, insertContentAt } = makeMockEditor(100); + insertSlashAgentMarkdownAt(editor, 7, "**hi**", "cursor"); + + expect(convertMarkdownToTiptapContent).toHaveBeenCalledWith("**hi**"); + expect(insertContentAt).toHaveBeenCalledTimes(1); + expect(insertContentAt).toHaveBeenCalledWith(7, [ + { type: "paragraph", content: [{ type: "text", text: "hi" }] }, + ]); + }); + + it("inserts at the document end when position='end'", () => { + const fakeJson = JSON.stringify({ + type: "doc", + content: [{ type: "paragraph" }], + }); + vi.mocked(convertMarkdownToTiptapContent).mockReturnValue(fakeJson); + + const { editor, insertContentAt } = makeMockEditor(42); + insertSlashAgentMarkdownAt(editor, 7, "any", "end"); + + expect(insertContentAt).toHaveBeenCalledWith(42, [{ type: "paragraph" }]); + }); + + it("trims whitespace and substitutes a placeholder for empty results", () => { + vi.mocked(convertMarkdownToTiptapContent).mockReturnValue( + JSON.stringify({ type: "doc", content: [] }), + ); + + const { editor } = makeMockEditor(10); + insertSlashAgentMarkdownAt(editor, 0, " \n ", "cursor"); + + // 空白のみの結果は "(empty result)" に置換され、ユーザに変換不能を示す。 + // Whitespace-only results are replaced with "(empty result)" so the user notices. + expect(convertMarkdownToTiptapContent).toHaveBeenCalledWith("(empty result)"); + }); + + it("handles missing content array by inserting an empty list (no crash)", () => { + // content が無いケースでも例外にならず、空配列で挿入する契約。 + // When `content` is missing, fall back to `[]` instead of throwing. + vi.mocked(convertMarkdownToTiptapContent).mockReturnValue(JSON.stringify({ type: "doc" })); + + const { editor, insertContentAt } = makeMockEditor(10); + insertSlashAgentMarkdownAt(editor, 3, "x", "cursor"); + + expect(insertContentAt).toHaveBeenCalledWith(3, []); + }); + + it("falls back to a plain paragraph when conversion JSON cannot be parsed", () => { + vi.mocked(convertMarkdownToTiptapContent).mockReturnValue("not-json{"); + + const { editor, insertContentAt } = makeMockEditor(10); + insertSlashAgentMarkdownAt(editor, 5, "raw text", "cursor"); + + expect(insertContentAt).toHaveBeenCalledWith(5, [ + { + type: "paragraph", + content: [{ type: "text", text: "raw text" }], + }, + ]); + }); + + it("falls back with the placeholder text when an empty input cannot be parsed either", () => { + // 空入力 + パース失敗の合成ケース:プレースホルダがフォールバック段落に入る。 + // Empty input + parse failure: the placeholder text seeds the fallback paragraph. + vi.mocked(convertMarkdownToTiptapContent).mockReturnValue("not-json{"); + + const { editor, insertContentAt } = makeMockEditor(10); + insertSlashAgentMarkdownAt(editor, 0, "", "cursor"); + + expect(insertContentAt).toHaveBeenCalledWith(0, [ + { + type: "paragraph", + content: [{ type: "text", text: "(empty result)" }], + }, + ]); + }); +}); diff --git a/src/lib/agentSlashCommands/slashAgentSelectionCache.test.ts b/src/lib/agentSlashCommands/slashAgentSelectionCache.test.ts new file mode 100644 index 00000000..7021ae0e --- /dev/null +++ b/src/lib/agentSlashCommands/slashAgentSelectionCache.test.ts @@ -0,0 +1,81 @@ +/** + * Tests for the per-editor selection cache used by `/explain`. + * `/explain` 用に選択テキストを保持するキャッシュのテスト。 + */ + +import type { Editor } from "@tiptap/core"; +import { describe, expect, it, vi } from "vitest"; +import { + clearLastSlashAgentSelection, + getLastSlashAgentSelection, + rememberSlashAgentSelection, +} from "./slashAgentSelectionCache"; + +/** + * Builds an editor mock whose `state.selection` and `doc.textBetween` reflect + * the supplied range/text. WeakMap lookups need a real, stable object identity. + * 範囲とテキストを反映するエディタモックを作る(WeakMap キーには安定オブジェクトが必要)。 + */ +function makeMockEditor(opts: { from: number; to: number; text?: string }): Editor { + return { + state: { + selection: { from: opts.from, to: opts.to }, + doc: { textBetween: vi.fn(() => opts.text ?? "") }, + }, + } as unknown as Editor; +} + +describe("slashAgentSelectionCache", () => { + it("returns empty when nothing is cached", () => { + const editor = makeMockEditor({ from: 0, to: 0 }); + expect(getLastSlashAgentSelection(editor)).toBe(""); + }); + + it("does not cache when the selection is collapsed", () => { + const editor = makeMockEditor({ from: 4, to: 4, text: "ignored" }); + rememberSlashAgentSelection(editor); + expect(getLastSlashAgentSelection(editor)).toBe(""); + // 折りたたみ選択では textBetween を呼ばないこと。 + // textBetween must not be called for a collapsed selection. + expect(editor.state.doc.textBetween).not.toHaveBeenCalled(); + }); + + it("caches the selection text for the next read", () => { + const editor = makeMockEditor({ from: 1, to: 6, text: "hello" }); + rememberSlashAgentSelection(editor); + expect(getLastSlashAgentSelection(editor)).toBe("hello"); + expect(editor.state.doc.textBetween).toHaveBeenCalledWith(1, 6, "\n", ""); + }); + + it("overwrites the cached value with the most recent non-empty selection", () => { + const editor = makeMockEditor({ from: 1, to: 4, text: "old" }); + rememberSlashAgentSelection(editor); + expect(getLastSlashAgentSelection(editor)).toBe("old"); + + (editor as unknown as { state: { selection: { from: number; to: number } } }).state.selection = + { from: 5, to: 9 }; + (editor.state.doc.textBetween as ReturnType).mockReturnValueOnce("new"); + rememberSlashAgentSelection(editor); + expect(getLastSlashAgentSelection(editor)).toBe("new"); + }); + + it("scopes cached selections per editor instance", () => { + const a = makeMockEditor({ from: 1, to: 3, text: "A" }); + const b = makeMockEditor({ from: 2, to: 7, text: "B" }); + rememberSlashAgentSelection(a); + rememberSlashAgentSelection(b); + expect(getLastSlashAgentSelection(a)).toBe("A"); + expect(getLastSlashAgentSelection(b)).toBe("B"); + }); + + it("clears the cache for a single editor without affecting others", () => { + const a = makeMockEditor({ from: 1, to: 3, text: "A" }); + const b = makeMockEditor({ from: 2, to: 7, text: "B" }); + rememberSlashAgentSelection(a); + rememberSlashAgentSelection(b); + + clearLastSlashAgentSelection(a); + expect(getLastSlashAgentSelection(a)).toBe(""); + expect(getLastSlashAgentSelection(b)).toBe("B"); + }); +}); diff --git a/src/lib/aiChatConversationTitle.test.ts b/src/lib/aiChatConversationTitle.test.ts index d79ec62f..645b9029 100644 --- a/src/lib/aiChatConversationTitle.test.ts +++ b/src/lib/aiChatConversationTitle.test.ts @@ -1,52 +1,136 @@ import { describe, it, expect } from "vitest"; import { generateConversationTitleFromTree } from "./aiChatConversationTitle"; +import type { TreeChatMessage } from "../types/aiChat"; -describe("generateConversationTitleFromTree", () => { - it("returns empty string when no user message on path (caller localizes)", () => { - const map = { - a: { - id: "a", - role: "assistant" as const, - content: "hi", - timestamp: 1, - parentId: null as string | null, - }, +/** + * Build a small message-map with the given ordered chain (root → ... → leaf). + * 親子チェーンを 1 行で組み立てるユーティリティ。 + */ +function chain( + ...nodes: Array< + Partial & { id: string; role: TreeChatMessage["role"]; content: string } + > +): { + map: Record; + leafId: string; +} { + const map: Record = {}; + let parentId: string | null = null; + let ts = 1; + for (const n of nodes) { + map[n.id] = { + id: n.id, + role: n.role, + content: n.content, + timestamp: n.timestamp ?? ts++, + parentId: n.parentId ?? parentId, }; - expect(generateConversationTitleFromTree(map, "a")).toBe(""); + parentId = n.id; + } + return { map, leafId: nodes[nodes.length - 1].id }; +} + +describe("generateConversationTitleFromTree", () => { + describe("empty / no-user-message branch", () => { + it("returns empty string when no user message exists on the active path", () => { + // ユーザーメッセージが無ければ空文字(呼び出し側が未設定ラベルを出す)。 + // Pin the empty-string sentinel for the no-user branch. + const { map, leafId } = chain({ id: "a", role: "assistant", content: "hi" }); + expect(generateConversationTitleFromTree(map, leafId)).toBe(""); + }); + + it("returns empty string when activeLeafId is null", () => { + // null のリーフは getActivePath で空配列を返すため、空文字に落ちる。 + // Pin the null-leaf path through getActivePath. + const { map } = chain({ id: "a", role: "user", content: "ignored" }); + expect(generateConversationTitleFromTree(map, null)).toBe(""); + }); + + it("returns empty string for an empty map", () => { + // 空マップでも例外を出さず、空文字を返す。 + // Pin the empty-map fallback. + expect(generateConversationTitleFromTree({}, "missing")).toBe(""); + }); }); - it("uses first user message and truncates with ellipsis", () => { - const map = { - u: { - id: "u", - role: "user" as const, - content: "x".repeat(60), - timestamp: 1, - parentId: null, - }, - a: { - id: "a", - role: "assistant" as const, - content: "ok", - timestamp: 2, - parentId: "u", - }, - }; - const title = generateConversationTitleFromTree(map, "a"); - expect(title.endsWith("...")).toBe(true); - expect(title.length).toBeLessThanOrEqual(53); + describe("first-user-message selection", () => { + it("uses the FIRST user message on the path (not subsequent ones)", () => { + // assistant を挟んで複数の user メッセージがあるとき、最初の user の内容を使う。 + // Pin `path.find(m => m.role === "user")`; a `findLast`/swap mutation surfaces here. + const { map, leafId } = chain( + { id: "u1", role: "user", content: "first message" }, + { id: "a1", role: "assistant", content: "ack" }, + { id: "u2", role: "user", content: "second message" }, + ); + expect(generateConversationTitleFromTree(map, leafId)).toBe("first message"); + }); + + it("ignores assistant messages that come before the first user message", () => { + // assistant が path 先頭にあっても、それは無視して次の user を採用する。 + // Pin the role filter so a `=== "assistant"` mutation surfaces. + const { map, leafId } = chain( + { id: "a0", role: "assistant", content: "system-ish prelude" }, + { id: "u1", role: "user", content: "real prompt" }, + ); + expect(generateConversationTitleFromTree(map, leafId)).toBe("real prompt"); + }); }); - it("does not add ellipsis when under 50 chars", () => { - const map = { - u: { - id: "u", - role: "user" as const, - content: "short", - timestamp: 1, - parentId: null, - }, - }; - expect(generateConversationTitleFromTree(map, "u")).toBe("short"); + describe("truncation boundary at 50 characters", () => { + it("returns the content verbatim when it is shorter than 50 chars", () => { + const { map, leafId } = chain({ id: "u", role: "user", content: "short prompt" }); + expect(generateConversationTitleFromTree(map, leafId)).toBe("short prompt"); + }); + + it("returns the content verbatim when it is EXACTLY 50 chars (no ellipsis)", () => { + // 境界 50 文字ちょうどでは省略記号を付けない。`text.length < content.length` 経路を検証する。 + // Kills the `<` → `<=` mutation at the 50-char boundary by asserting no "..." is appended. + const fifty = "a".repeat(50); + const { map, leafId } = chain({ id: "u", role: "user", content: fifty }); + const out = generateConversationTitleFromTree(map, leafId); + expect(out).toBe(fifty); + expect(out.endsWith("...")).toBe(false); + expect(out.length).toBe(50); + }); + + it("appends '...' and truncates at 50 chars when content is 51 chars", () => { + // 境界の 1 文字超え (51 文字) で初めて省略記号が付く。 + // Pin the just-over-boundary case so a `<` → `>` mutation flips behavior visibly. + const fiftyOne = "b".repeat(51); + const { map, leafId } = chain({ id: "u", role: "user", content: fiftyOne }); + const out = generateConversationTitleFromTree(map, leafId); + expect(out).toBe(`${"b".repeat(50)}...`); + expect(out.length).toBe(53); + }); + + it("truncates at 50 chars and appends exactly '...' for long content", () => { + // 50 文字に切り詰め、末尾はちょうど "..." (3 文字)。 + // Pin both the slice length and the literal ellipsis suffix. + const long = "0123456789".repeat(10); // 100 chars + const { map, leafId } = chain({ id: "u", role: "user", content: long }); + const out = generateConversationTitleFromTree(map, leafId); + expect(out).toBe(`${long.slice(0, 50)}...`); + expect(out.endsWith("...")).toBe(true); + expect(out.length).toBe(53); + }); + + it("uses character-based slicing (not byte-based) for multibyte content", () => { + // 日本語のような multibyte 文字でも文字数 50 で切る(バイト数ではない)。 + // Pin character semantics; `slice` is code-unit-based, not byte-based. + const fifty = "あ".repeat(50); + const { map, leafId } = chain({ id: "u", role: "user", content: fifty }); + expect(generateConversationTitleFromTree(map, leafId)).toBe(fifty); + + const fiftyOne = "あ".repeat(51); + const { map: m2, leafId: l2 } = chain({ id: "u", role: "user", content: fiftyOne }); + expect(generateConversationTitleFromTree(m2, l2)).toBe(`${"あ".repeat(50)}...`); + }); + + it("returns empty string verbatim when first user content is empty", () => { + // 空文字 user content の場合、slice(0,50) も "" で、length 比較も等しく省略しない。 + // Pin that an empty user content yields `""` (no spurious "..." gets appended). + const { map, leafId } = chain({ id: "u", role: "user", content: "" }); + expect(generateConversationTitleFromTree(map, leafId)).toBe(""); + }); }); }); diff --git a/src/lib/aiClient.test.ts b/src/lib/aiClient.test.ts index 33222a79..a234f0dc 100644 --- a/src/lib/aiClient.test.ts +++ b/src/lib/aiClient.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createAIClient, getCachedModels, @@ -140,7 +140,73 @@ describe("aiClient", () => { }); }); - describe("testConnection", () => { + describe("createAIClient - claude-code", () => { + it("throws because Claude Code does not use a traditional client", () => { + expect(() => + createAIClient({ + provider: "claude-code", + apiKey: "", + model: "", + modelId: "", + isConfigured: true, + }), + ).toThrow(/Claude Code/); + }); + }); + + describe("getCachedModels - corrupt cache", () => { + it("returns null when cache JSON is malformed", () => { + localStorage.setItem(CACHE_KEY, "not json"); + expect(getCachedModels("openai")).toBeNull(); + }); + + it("returns null when provider is missing from cache", () => { + localStorage.setItem( + CACHE_KEY, + JSON.stringify({ google: { provider: "google", models: [], cachedAt: Date.now() } }), + ); + expect(getCachedModels("openai")).toBeNull(); + }); + }); + + describe("saveCachedModels - merging", () => { + it("does not overwrite other providers in cache", () => { + const initial = { + anthropic: { provider: "anthropic", models: ["claude-x"], cachedAt: 1 }, + }; + localStorage.setItem(CACHE_KEY, JSON.stringify(initial)); + saveCachedModels("openai", ["gpt-5"]); + const stored = JSON.parse(localStorage.getItem(CACHE_KEY) ?? "{}"); + expect(stored.anthropic.models).toEqual(["claude-x"]); + expect(stored.openai.models).toEqual(["gpt-5"]); + }); + + it("壊れた既存キャッシュがあっても落ちず、生データは上書きしない / does not crash or overwrite when existing cache JSON is corrupt", () => { + localStorage.setItem(CACHE_KEY, "{not json"); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + saveCachedModels("openai", ["gpt-5"]); + // parse 失敗をログに残す。`getCachedModels` 経由では null が返るが、 + // 生の localStorage は壊れたまま放置される(saveCachedModels は catch して終わるため)。 + // The parse failure is logged. `getCachedModels` returns null afterward, + // but the raw localStorage value stays untouched (saveCachedModels exits in the catch). + expect(errSpy).toHaveBeenCalled(); + expect(localStorage.getItem(CACHE_KEY)).toBe("{not json"); + }); + }); + + describe("getAvailableModels - empty cache", () => { + it("falls back to defaults when cache exists but list is empty", () => { + const cache = { + openai: { provider: "openai", models: [], cachedAt: Date.now() }, + }; + localStorage.setItem(CACHE_KEY, JSON.stringify(cache)); + const models = getAvailableModels("openai"); + expect(models.length).toBeGreaterThan(0); + expect(models).toContain("gpt-5-mini"); + }); + }); + + describe("testConnection - input validation", () => { it("returns failure for empty API key", async () => { const result = await testConnection("openai", ""); expect(result.success).toBe(false); @@ -159,4 +225,263 @@ describe("aiClient", () => { expect(result.message).toContain("Claude Code"); }); }); + + describe("testConnection - openai", () => { + it("returns success with sorted/filtered GPT models on success", async () => { + const list = vi.fn().mockResolvedValue({ + data: [ + { id: "gpt-3.5-turbo" }, + { id: "gpt-4o" }, + { id: "gpt-4-vision-preview" }, // filtered out + { id: "gpt-4-instruct" }, // filtered out + { id: "gpt-4o-realtime" }, // filtered out + { id: "gpt-4-audio" }, // filtered out + { id: "text-embedding" }, // filtered out + { id: "gpt-4-turbo" }, + ], + }); + vi.mocked(OpenAI).mockImplementation(function (this: unknown) { + return { models: { list } } as unknown as OpenAI; + }); + + const result = await testConnection("openai", "sk-test"); + expect(result.success).toBe(true); + expect(result.models).toEqual(["gpt-4o", "gpt-4-turbo", "gpt-3.5-turbo"]); + expect(result.message).toContain("3個"); + + // キャッシュにも保存される + const stored = JSON.parse(localStorage.getItem(CACHE_KEY) ?? "{}"); + expect(stored.openai.models).toEqual(["gpt-4o", "gpt-4-turbo", "gpt-3.5-turbo"]); + }); + + it("falls back to default models when API returns no GPT models", async () => { + vi.mocked(OpenAI).mockImplementation(function (this: unknown) { + return { + models: { list: vi.fn().mockResolvedValue({ data: [{ id: "dall-e-3" }] }) }, + } as unknown as OpenAI; + }); + const result = await testConnection("openai", "sk-test"); + expect(result.success).toBe(true); + expect(result.models).toContain("gpt-5-mini"); + }); + + it("maps 401 error to APIキーが無効です", async () => { + vi.mocked(OpenAI).mockImplementation(function (this: unknown) { + return { + models: { list: vi.fn().mockRejectedValue(new Error("401 Unauthorized")) }, + } as unknown as OpenAI; + }); + const result = await testConnection("openai", "sk-test"); + expect(result.success).toBe(false); + expect(result.message).toContain("APIキーが無効"); + expect(result.error).toContain("401"); + }); + + it("maps invalid_api_key error to APIキーが無効です", async () => { + vi.mocked(OpenAI).mockImplementation(function (this: unknown) { + return { + models: { + list: vi.fn().mockRejectedValue(new Error("invalid_api_key")), + }, + } as unknown as OpenAI; + }); + const result = await testConnection("openai", "sk-test"); + expect(result.success).toBe(false); + expect(result.message).toContain("APIキーが無効"); + }); + + it("maps generic error to 接続に失敗しました", async () => { + vi.mocked(OpenAI).mockImplementation(function (this: unknown) { + return { + models: { list: vi.fn().mockRejectedValue(new Error("network down")) }, + } as unknown as OpenAI; + }); + const result = await testConnection("openai", "sk-test"); + expect(result.success).toBe(false); + expect(result.message).toBe("接続に失敗しました"); + expect(result.error).toBe("network down"); + }); + + it("uses 'Unknown error' when thrown value is not an Error", async () => { + vi.mocked(OpenAI).mockImplementation(function (this: unknown) { + return { + models: { list: vi.fn().mockRejectedValue("string failure") }, + } as unknown as OpenAI; + }); + const result = await testConnection("openai", "sk-test"); + expect(result.error).toBe("Unknown error"); + }); + }); + + describe("testConnection - anthropic", () => { + it("returns success and default models when ping succeeds", async () => { + const create = vi.fn().mockResolvedValue({ id: "msg_123" }); + vi.mocked(Anthropic).mockImplementation(function (this: unknown) { + return { messages: { create } } as unknown as Anthropic; + }); + const result = await testConnection("anthropic", "sk-ant-x"); + expect(result.success).toBe(true); + expect(result.models).toEqual( + expect.arrayContaining(["claude-opus-4-6", "claude-sonnet-4-20250514"]), + ); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + model: "claude-3-haiku-20240307", + max_tokens: 10, + messages: [{ role: "user", content: "Hi" }], + }), + ); + }); + + it("returns 予期しないレスポンス形式 when response has no id", async () => { + vi.mocked(Anthropic).mockImplementation(function (this: unknown) { + return { + messages: { create: vi.fn().mockResolvedValue({ content: [] }) }, + } as unknown as Anthropic; + }); + const result = await testConnection("anthropic", "sk-ant-x"); + expect(result.success).toBe(false); + expect(result.message).toContain("予期しないレスポンス"); + }); + + it("maps authentication error to APIキーが無効です", async () => { + vi.mocked(Anthropic).mockImplementation(function (this: unknown) { + return { + messages: { + create: vi.fn().mockRejectedValue(new Error("authentication failed")), + }, + } as unknown as Anthropic; + }); + const result = await testConnection("anthropic", "sk-ant-x"); + expect(result.success).toBe(false); + expect(result.message).toContain("APIキーが無効"); + }); + + it("maps 401 error to APIキーが無効です", async () => { + vi.mocked(Anthropic).mockImplementation(function (this: unknown) { + return { + messages: { create: vi.fn().mockRejectedValue(new Error("401")) }, + } as unknown as Anthropic; + }); + const result = await testConnection("anthropic", "sk-ant-x"); + expect(result.success).toBe(false); + expect(result.message).toContain("APIキーが無効"); + }); + + it("maps generic error to 接続に失敗しました", async () => { + vi.mocked(Anthropic).mockImplementation(function (this: unknown) { + return { + messages: { create: vi.fn().mockRejectedValue(new Error("boom")) }, + } as unknown as Anthropic; + }); + const result = await testConnection("anthropic", "sk-ant-x"); + expect(result.success).toBe(false); + expect(result.message).toBe("接続に失敗しました"); + }); + }); + + describe("testConnection - google", () => { + function mockFetch(impl: ReturnType): void { + vi.stubGlobal("fetch", impl); + } + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("filters Gemini models, sorts by version, and caches", async () => { + const fetchSpy = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + models: [ + { + name: "models/gemini-1.5-flash", + supportedGenerationMethods: ["generateContent"], + }, + { + name: "models/gemini-1.5-pro", + supportedGenerationMethods: ["generateContent"], + }, + { + name: "models/gemini-2.0-flash", + supportedGenerationMethods: ["generateContent"], + }, + { + name: "models/gemini-2.5-pro", + supportedGenerationMethods: ["generateContent"], + }, + { + name: "models/text-bison", + supportedGenerationMethods: ["generateContent"], + }, // 除外 + { + name: "models/gemini-embedding", + supportedGenerationMethods: ["embedContent"], + }, // 除外 + ], + }), + { status: 200 }, + ), + ); + mockFetch(fetchSpy); + + const result = await testConnection("google", "AIza-x"); + expect(result.success).toBe(true); + expect(result.models).toEqual([ + "gemini-2.5-pro", + "gemini-2.0-flash", + "gemini-1.5-pro", + "gemini-1.5-flash", + ]); + expect(result.message).toContain("4個"); + expect(fetchSpy).toHaveBeenCalledWith(expect.stringContaining("AIza-x")); + + const stored = JSON.parse(localStorage.getItem(CACHE_KEY) ?? "{}"); + expect(stored.google.models).toEqual(result.models); + }); + + it("returns default models when no Gemini models match", async () => { + mockFetch( + vi.fn().mockResolvedValue(new Response(JSON.stringify({ models: [] }), { status: 200 })), + ); + const result = await testConnection("google", "AIza-x"); + expect(result.success).toBe(true); + // フォールバック先のデフォルトモデルを返す + expect(result.models?.length ?? 0).toBeGreaterThan(0); + }); + + it("maps error containing 400 to APIキーが無効です", async () => { + // 例: fetch reject の例外メッセージに 400 が含まれるケース。 + mockFetch(vi.fn().mockRejectedValue(new Error("HTTP 400 invalid key"))); + const result = await testConnection("google", "AIza-x"); + expect(result.success).toBe(false); + expect(result.message).toContain("APIキーが無効"); + }); + + it("maps API_KEY_INVALID error message to APIキーが無効です", async () => { + mockFetch(vi.fn().mockRejectedValue(new Error("API_KEY_INVALID detail"))); + const result = await testConnection("google", "AIza-x"); + expect(result.success).toBe(false); + expect(result.message).toContain("APIキーが無効"); + }); + + it("maps generic error to 接続に失敗しました", async () => { + mockFetch( + vi + .fn() + .mockResolvedValue(new Response("oops", { status: 500, statusText: "Server Error" })), + ); + const result = await testConnection("google", "AIza-x"); + expect(result.success).toBe(false); + expect(result.message).toBe("接続に失敗しました"); + }); + }); + + describe("testConnection - unknown provider", () => { + it("returns failure with 不明なプロバイダー", async () => { + const result = await testConnection("xxx" as never, "key"); + expect(result.success).toBe(false); + expect(result.message).toContain("不明なプロバイダー"); + }); + }); }); diff --git a/src/lib/aiService.test.ts b/src/lib/aiService.test.ts index 9af837d2..59f3e03e 100644 --- a/src/lib/aiService.test.ts +++ b/src/lib/aiService.test.ts @@ -607,6 +607,15 @@ describe("aiService - 回帰テスト", () => { }); describe("callAIService - APIサーバー経由モード", () => { + // assertion 失敗時にも `fetch` / env の stub を確実に解除する。tail-cleanup だと + // 失敗時に後続テストへ stub が漏れるため、afterEach で unconditional に剥がす。 + // Guarantee `fetch` / env stubs are cleared even when an assertion throws — + // tail-of-body cleanup would leak stubs into later tests. + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + it("api_serverモードでAPIサーバーURLが未設定の場合はonErrorが呼ばれる", async () => { const settings: AISettings = { provider: "openai", @@ -638,5 +647,324 @@ describe("aiService - 回帰テスト", () => { expect(callbacks.onError).toHaveBeenCalled(); expect(callbacks.onComplete).not.toHaveBeenCalled(); }); + + it("settings.modelId が指定されていればリクエストの model を上書きする", async () => { + const fetchSpy = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ content: "ok" }), { status: 200 })); + vi.stubGlobal("fetch", fetchSpy); + vi.stubEnv("VITE_API_BASE_URL", "https://api.example.com"); + + const settings: AISettings = { + provider: "openai", + apiKey: "", + apiMode: "api_server", + model: "gpt-4o", + modelId: "openai:gpt-5-pro", + isConfigured: false, + }; + const callbacks: AIServiceCallbacks = { onComplete: vi.fn(), onError: vi.fn() }; + await callAIService( + settings, + { + provider: "openai", + model: "gpt-4o", + messages: [{ role: "user", content: "hi" }], + options: { stream: false }, + }, + callbacks, + ); + + const init = fetchSpy.mock.calls[0]?.[1]; + const body = JSON.parse(init?.body ?? "{}"); + expect(body.model).toBe("openai:gpt-5-pro"); + expect(callbacks.onComplete).toHaveBeenCalled(); + }); + + it("apiMode が未設定でも api_server へフォールバックする", async () => { + const fetchSpy = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ content: "ok" }), { status: 200 })); + vi.stubGlobal("fetch", fetchSpy); + vi.stubEnv("VITE_API_BASE_URL", "https://api.example.com"); + + const settings: AISettings = { + provider: "openai", + apiKey: "should-be-ignored", + // apiMode 未設定 + model: "gpt-4o", + modelId: "openai:gpt-4o", + isConfigured: false, + }; + const callbacks: AIServiceCallbacks = { onComplete: vi.fn(), onError: vi.fn() }; + await callAIService( + settings, + { + provider: "openai", + model: "gpt-4o", + messages: [{ role: "user", content: "hi" }], + options: { stream: false }, + }, + callbacks, + ); + + // ユーザーキーモードならクライアントSDKが呼ばれるが、ここでは fetch のみ呼ばれることを確認。 + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe("callAIService - claude-code モード", () => { + /** + * Build a fake provider object for `createClaudeCodeProvider`. + * テスト用の Claude Code プロバイダのフェイクを作る。 + */ + type FakeChunk = + | { type: "text"; content: string } + | { type: "tool_use_start"; content: ""; toolName?: string } + | { type: "tool_use_complete"; content: ""; toolName?: string } + | { type: "error"; content: string } + | { type: "done"; content: "" }; + + // 実モジュールの型に揃えることで `createClaudeCodeProvider` のシグネチャ変更を + // テスト側でも型検査でき、`UnifiedAIProvider` 契約のドリフトを早期に検知する。 + // Anchor the mock to the real module type so a signature change in + // `createClaudeCodeProvider` (or `UnifiedAIProvider`) is caught here at compile time. + type ClaudeCodeProviderModule = typeof import("@/lib/aiProviders/claudeCodeProvider"); + + function buildProviderModule(opts: { + available: boolean; + chunks?: FakeChunk[]; + throwOnQuery?: unknown; + }): Pick { + return { + createClaudeCodeProvider: () => ({ + id: "claude-code" as const, + name: "Claude Code", + capabilities: { + textGeneration: true, + fileAccess: true, + commandExecution: true, + webSearch: true, + mcpIntegration: true, + agentLoop: true, + }, + isAvailable: vi.fn().mockResolvedValue(opts.available), + abort: vi.fn(), + query: () => { + if (opts.throwOnQuery !== undefined) { + // テスト用に意図的に yield せず即時 throw するジェネレータ。 + // eslint-disable-next-line require-yield + return (async function* () { + throw opts.throwOnQuery; + })(); + } + return (async function* () { + for (const c of opts.chunks ?? []) yield c; + })(); + }, + }), + }; + } + + function makeClaudeSettings(): AISettings { + return { + provider: "claude-code", + apiKey: "", + model: "claude-sonnet-4", + modelId: "claude-code:claude-sonnet-4", + isConfigured: true, + }; + } + + function makeClaudeRequest(): AIServiceRequest { + return { + provider: "claude-code", + model: "claude-sonnet-4", + messages: [ + { role: "system", content: "rules" }, + { role: "user", content: "hello" }, + ], + options: { temperature: 0.5, maxTokens: 1024, stream: true, cwd: "/work" }, + }; + } + + // `aiService.ts` は claude-code provider を動的 import するため、`vi.doMock` の効果を確実に切り替えるには + // モジュールキャッシュもリセットする必要がある。`doUnmock` はレジストリ上の mock 登録を消すだけで、 + // 既に動的 import 済みのモジュールキャッシュには手を入れないため、`resetModules()` を併用する。 + // The provider is loaded via dynamic import; `vi.doUnmock` only removes the mock registration + // and does not invalidate the cached module. Reset the module registry between tests so that + // each `vi.doMock` factory is the one returned by the next dynamic import. + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.doUnmock("@/lib/aiProviders/claudeCodeProvider"); + vi.resetModules(); + }); + + it("isAvailable が false なら onError を呼ぶ", async () => { + vi.doMock("@/lib/aiProviders/claudeCodeProvider", () => + buildProviderModule({ available: false }), + ); + const callbacks: AIServiceCallbacks = { onError: vi.fn(), onComplete: vi.fn() }; + await callAIService(makeClaudeSettings(), makeClaudeRequest(), callbacks); + expect(callbacks.onError).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Claude Code"), + }), + ); + expect(callbacks.onComplete).not.toHaveBeenCalled(); + }); + + it("text チャンクを蓄積し done で onComplete を呼ぶ", async () => { + vi.doMock("@/lib/aiProviders/claudeCodeProvider", () => + buildProviderModule({ + available: true, + chunks: [ + { type: "text", content: "Hel" }, + { type: "text", content: "lo" }, + { type: "done", content: "" }, + { type: "text", content: "ignored after done" }, + ], + }), + ); + const callbacks: AIServiceCallbacks = { + onChunk: vi.fn(), + onComplete: vi.fn(), + onError: vi.fn(), + }; + await callAIService(makeClaudeSettings(), makeClaudeRequest(), callbacks); + expect(callbacks.onChunk).toHaveBeenNthCalledWith(1, "Hel"); + expect(callbacks.onChunk).toHaveBeenNthCalledWith(2, "lo"); + expect(callbacks.onChunk).toHaveBeenCalledTimes(2); + expect(callbacks.onComplete).toHaveBeenCalledWith({ + content: "Hello", + finishReason: "stop", + }); + expect(callbacks.onError).not.toHaveBeenCalled(); + }); + + it("done が来ずに終わっても finishReason='stop' で onComplete を呼ぶ (内容あり)", async () => { + vi.doMock("@/lib/aiProviders/claudeCodeProvider", () => + buildProviderModule({ + available: true, + chunks: [{ type: "text", content: "abc" }], + }), + ); + const callbacks: AIServiceCallbacks = { + onChunk: vi.fn(), + onComplete: vi.fn(), + onError: vi.fn(), + }; + await callAIService(makeClaudeSettings(), makeClaudeRequest(), callbacks); + expect(callbacks.onComplete).toHaveBeenCalledWith({ + content: "abc", + finishReason: "stop", + }); + }); + + it("done も text も来なければ finishReason='abort'", async () => { + vi.doMock("@/lib/aiProviders/claudeCodeProvider", () => + buildProviderModule({ available: true, chunks: [] }), + ); + const callbacks: AIServiceCallbacks = { onComplete: vi.fn(), onError: vi.fn() }; + await callAIService(makeClaudeSettings(), makeClaudeRequest(), callbacks); + expect(callbacks.onComplete).toHaveBeenCalledWith({ + content: "", + finishReason: "abort", + }); + }); + + it("tool_use_start / tool_use_complete をコールバックに転送する", async () => { + vi.doMock("@/lib/aiProviders/claudeCodeProvider", () => + buildProviderModule({ + available: true, + chunks: [ + { type: "tool_use_start", content: "", toolName: "bash" }, + { type: "text", content: "ran" }, + { type: "tool_use_complete", content: "", toolName: "bash" }, + { type: "done", content: "" }, + ], + }), + ); + const callbacks: AIServiceCallbacks = { + onChunk: vi.fn(), + onComplete: vi.fn(), + onToolUseStart: vi.fn(), + onToolUseComplete: vi.fn(), + onError: vi.fn(), + }; + await callAIService(makeClaudeSettings(), makeClaudeRequest(), callbacks); + expect(callbacks.onToolUseStart).toHaveBeenCalledWith("bash"); + expect(callbacks.onToolUseComplete).toHaveBeenCalledWith("bash"); + expect(callbacks.onComplete).toHaveBeenCalledWith({ + content: "ran", + finishReason: "stop", + }); + }); + + it("toolName が無ければ 'unknown' を渡す", async () => { + vi.doMock("@/lib/aiProviders/claudeCodeProvider", () => + buildProviderModule({ + available: true, + chunks: [ + { type: "tool_use_start", content: "" }, + { type: "tool_use_complete", content: "" }, + { type: "done", content: "" }, + ], + }), + ); + const callbacks: AIServiceCallbacks = { + onToolUseStart: vi.fn(), + onToolUseComplete: vi.fn(), + onComplete: vi.fn(), + }; + await callAIService(makeClaudeSettings(), makeClaudeRequest(), callbacks); + expect(callbacks.onToolUseStart).toHaveBeenCalledWith("unknown"); + expect(callbacks.onToolUseComplete).toHaveBeenCalledWith("unknown"); + }); + + it("error チャンクは onError へラップして渡す", async () => { + vi.doMock("@/lib/aiProviders/claudeCodeProvider", () => + buildProviderModule({ + available: true, + chunks: [{ type: "error", content: "sidecar crashed" }], + }), + ); + const callbacks: AIServiceCallbacks = { onError: vi.fn(), onComplete: vi.fn() }; + await callAIService(makeClaudeSettings(), makeClaudeRequest(), callbacks); + expect(callbacks.onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "sidecar crashed" }), + ); + expect(callbacks.onComplete).not.toHaveBeenCalled(); + }); + + it("非 Error の例外は 'Claude Code 呼び出しエラー' に正規化する", async () => { + vi.doMock("@/lib/aiProviders/claudeCodeProvider", () => + buildProviderModule({ available: true, throwOnQuery: "string failure" }), + ); + const callbacks: AIServiceCallbacks = { onError: vi.fn(), onComplete: vi.fn() }; + await callAIService(makeClaudeSettings(), makeClaudeRequest(), callbacks); + expect(callbacks.onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "Claude Code 呼び出しエラー" }), + ); + }); + + it("abortSignal が aborted ならループ内で ABORTED を投げて onError へ", async () => { + vi.doMock("@/lib/aiProviders/claudeCodeProvider", () => + buildProviderModule({ + available: true, + chunks: [{ type: "text", content: "x" }], + }), + ); + const ac = new AbortController(); + ac.abort(); + const callbacks: AIServiceCallbacks = { onError: vi.fn(), onComplete: vi.fn() }; + await callAIService(makeClaudeSettings(), makeClaudeRequest(), callbacks, ac.signal); + expect(callbacks.onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "ABORTED" }), + ); + }); }); }); diff --git a/src/lib/aiServiceDirectProviders.test.ts b/src/lib/aiServiceDirectProviders.test.ts new file mode 100644 index 00000000..f66a16e0 --- /dev/null +++ b/src/lib/aiServiceDirectProviders.test.ts @@ -0,0 +1,739 @@ +/** + * Tests for direct-SDK provider calls (OpenAI / Anthropic / Google). + * 直接 SDK 経由のプロバイダ呼び出しのテスト。 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { callOpenAI, callAnthropic, callGoogle } from "./aiServiceDirectProviders"; +import type { AIServiceRequest, AIServiceCallbacks } from "./aiService"; +import type { AISettings } from "@/types/ai"; + +// --- Module mocks --------------------------------------------------------- // + +let openAIMock: { + chat: { completions: { create: ReturnType } }; +} | null = null; +let anthropicMock: { + messages: { + create: ReturnType; + stream: ReturnType; + }; +} | null = null; +let googleMock: { + models: { + generateContent: ReturnType; + generateContentStream: ReturnType; + }; +} | null = null; + +const openAICtor = vi.fn(); +const anthropicCtor = vi.fn(); +const googleCtor = vi.fn(); + +vi.mock("openai", () => ({ + default: function OpenAI(args: unknown) { + openAICtor(args); + if (!openAIMock) throw new Error("OpenAI mock is not configured"); + return openAIMock; + }, +})); +vi.mock("@anthropic-ai/sdk", () => ({ + default: function Anthropic(args: unknown) { + anthropicCtor(args); + if (!anthropicMock) throw new Error("Anthropic mock is not configured"); + return anthropicMock; + }, +})); +vi.mock("@google/genai", () => ({ + GoogleGenAI: function GoogleGenAI(args: unknown) { + googleCtor(args); + if (!googleMock) throw new Error("GoogleGenAI mock is not configured"); + return googleMock; + }, +})); + +// --- Helpers --------------------------------------------------------------- // + +async function* asyncGen(items: T[]): AsyncGenerator { + for (const it of items) yield it; +} + +function buildSettings(provider: AISettings["provider"], apiKey = "key"): AISettings { + return { + provider, + apiKey, + apiMode: "user_api_key", + model: "x", + modelId: `${provider}:x`, + isConfigured: true, + }; +} + +function buildCallbacks(): Required< + Pick +> { + return { + onChunk: vi.fn(), + onComplete: vi.fn(), + onError: vi.fn(), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + openAIMock = null; + anthropicMock = null; + googleMock = null; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// =========================================================================== // +// OpenAI +// =========================================================================== // + +describe("callOpenAI", () => { + it("API キーと dangerouslyAllowBrowser を渡してクライアントを生成する", async () => { + openAIMock = { + chat: { + completions: { + create: vi.fn().mockResolvedValue({ + choices: [{ message: { content: "ok" }, finish_reason: "stop" }], + }), + }, + }, + }; + const request: AIServiceRequest = { + provider: "openai", + model: "gpt-5", + messages: [{ role: "user", content: "hi" }], + options: { stream: false }, + }; + await callOpenAI(buildSettings("openai", "sk-test"), request, buildCallbacks()); + expect(openAICtor).toHaveBeenCalledWith({ + apiKey: "sk-test", + dangerouslyAllowBrowser: true, + }); + }); + + it("非ストリーミング: 既定の max_tokens=4000, temperature=0.7 を渡す", async () => { + const create = vi.fn().mockResolvedValue({ + choices: [{ message: { content: "Hello" }, finish_reason: "stop" }], + }); + openAIMock = { chat: { completions: { create } } }; + + const request: AIServiceRequest = { + provider: "openai", + model: "gpt-5", + messages: [{ role: "user", content: "hi" }], + options: { stream: false }, + }; + const callbacks = buildCallbacks(); + await callOpenAI(buildSettings("openai"), request, callbacks); + + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + model: "gpt-5", + messages: [{ role: "user", content: "hi" }], + max_tokens: 4000, + temperature: 0.7, + stream: false, + }), + expect.objectContaining({ signal: undefined }), + ); + expect(callbacks.onComplete).toHaveBeenCalledWith({ + content: "Hello", + finishReason: "stop", + }); + }); + + it("非ストリーミング: 明示の maxTokens / temperature を尊重する", async () => { + const create = vi.fn().mockResolvedValue({ + choices: [{ message: { content: "x" }, finish_reason: "length" }], + }); + openAIMock = { chat: { completions: { create } } }; + const request: AIServiceRequest = { + provider: "openai", + model: "gpt-5", + messages: [{ role: "user", content: "hi" }], + options: { stream: false, maxTokens: 256, temperature: 0.1 }, + }; + await callOpenAI(buildSettings("openai"), request, buildCallbacks()); + + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ max_tokens: 256, temperature: 0.1 }), + expect.anything(), + ); + }); + + it("非ストリーミング: choices[0].message.content が無いと空文字で onComplete", async () => { + openAIMock = { + chat: { + completions: { + create: vi.fn().mockResolvedValue({ + choices: [{ message: {}, finish_reason: undefined }], + }), + }, + }, + }; + const callbacks = buildCallbacks(); + await callOpenAI( + buildSettings("openai"), + { + provider: "openai", + model: "gpt-5", + messages: [{ role: "user", content: "hi" }], + options: { stream: false }, + }, + callbacks, + ); + expect(callbacks.onComplete).toHaveBeenCalledWith({ + content: "", + finishReason: undefined, + }); + }); + + it("ストリーミング: 累積した content と最終 onComplete が呼ばれる", async () => { + const stream = asyncGen([ + { choices: [{ delta: { content: "He" } }] }, + { choices: [{ delta: { content: "llo" } }] }, + { choices: [{ delta: {} }] }, // 空 delta はスキップ + ]); + const create = vi.fn().mockResolvedValue(stream); + openAIMock = { chat: { completions: { create } } }; + + const callbacks = buildCallbacks(); + await callOpenAI( + buildSettings("openai"), + { + provider: "openai", + model: "gpt-5", + messages: [{ role: "user", content: "hi" }], + options: { stream: true }, + }, + callbacks, + ); + + expect(callbacks.onChunk).toHaveBeenCalledTimes(2); + expect(callbacks.onChunk).toHaveBeenNthCalledWith(1, "He"); + expect(callbacks.onChunk).toHaveBeenNthCalledWith(2, "llo"); + expect(callbacks.onComplete).toHaveBeenCalledWith({ + content: "Hello", + finishReason: "stop", + }); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ stream: true }), + expect.anything(), + ); + }); + + it("ストリーミング: webSearchOptions が SDK に渡される", async () => { + const create = vi.fn().mockResolvedValue(asyncGen([])); + openAIMock = { chat: { completions: { create } } }; + await callOpenAI( + buildSettings("openai"), + { + provider: "openai", + model: "gpt-5", + messages: [{ role: "user", content: "hi" }], + options: { + stream: true, + webSearchOptions: { search_context_size: "high" }, + }, + }, + buildCallbacks(), + ); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + web_search_options: { search_context_size: "high" }, + }), + expect.anything(), + ); + }); + + it("ストリーミング: abortSignal が aborted の場合は ABORTED で reject", async () => { + const stream = asyncGen([ + { choices: [{ delta: { content: "hi" } }] }, + { choices: [{ delta: { content: "more" } }] }, + ]); + openAIMock = { + chat: { completions: { create: vi.fn().mockResolvedValue(stream) } }, + }; + const ac = new AbortController(); + ac.abort(); + await expect( + callOpenAI( + buildSettings("openai"), + { + provider: "openai", + model: "gpt-5", + messages: [{ role: "user", content: "hi" }], + options: { stream: true }, + }, + buildCallbacks(), + ac.signal, + ), + ).rejects.toThrow("ABORTED"); + }); +}); + +// =========================================================================== // +// Anthropic +// =========================================================================== // + +describe("callAnthropic", () => { + it("API キーでクライアントを生成する", async () => { + anthropicMock = { + messages: { + create: vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "ok" }], + stop_reason: "end_turn", + }), + stream: vi.fn(), + }, + }; + await callAnthropic( + buildSettings("anthropic", "sk-ant-x"), + { + provider: "anthropic", + model: "claude-3-5-sonnet-20241022", + messages: [{ role: "user", content: "hi" }], + options: { stream: false }, + }, + buildCallbacks(), + ); + expect(anthropicCtor).toHaveBeenCalledWith({ apiKey: "sk-ant-x" }); + }); + + it("system メッセージは system フィールドへ集約する", async () => { + const create = vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "ok" }], + stop_reason: "end_turn", + }); + anthropicMock = { messages: { create, stream: vi.fn() } }; + await callAnthropic( + buildSettings("anthropic"), + { + provider: "anthropic", + model: "claude-3-5-sonnet-20241022", + messages: [ + { role: "system", content: "rule A" }, + { role: "system", content: "rule B" }, + { role: "user", content: "go" }, + ], + options: { stream: false }, + }, + buildCallbacks(), + ); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + system: "rule A\n\nrule B", + messages: [{ role: "user", content: "go" }], + }), + expect.anything(), + ); + }); + + it("system メッセージが無ければ system フィールドは付かない", async () => { + const create = vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "ok" }], + stop_reason: "end_turn", + }); + anthropicMock = { messages: { create, stream: vi.fn() } }; + await callAnthropic( + buildSettings("anthropic"), + { + provider: "anthropic", + model: "claude-3-5-sonnet-20241022", + messages: [{ role: "user", content: "go" }], + options: { stream: false }, + }, + buildCallbacks(), + ); + const arg = create.mock.calls[0][0] as Record; + expect(arg.system).toBeUndefined(); + }); + + it("対応モデル名なら自動で web_search ツールを付ける", async () => { + const create = vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "ok" }], + stop_reason: "end_turn", + }); + anthropicMock = { messages: { create, stream: vi.fn() } }; + await callAnthropic( + buildSettings("anthropic"), + { + provider: "anthropic", + model: "claude-3-5-sonnet-20241022", + messages: [{ role: "user", content: "go" }], + options: { stream: false }, + }, + buildCallbacks(), + ); + const arg = create.mock.calls[0][0] as { tools?: Array<{ name: string }> }; + expect(arg.tools?.[0]?.name).toBe("web_search"); + }); + + it("対応外モデル名ならツールは付かない", async () => { + const create = vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "ok" }], + stop_reason: "end_turn", + }); + anthropicMock = { messages: { create, stream: vi.fn() } }; + await callAnthropic( + buildSettings("anthropic"), + { + provider: "anthropic", + model: "claude-2.1", + messages: [{ role: "user", content: "go" }], + options: { stream: false }, + }, + buildCallbacks(), + ); + const arg = create.mock.calls[0][0] as { tools?: unknown }; + expect(arg.tools).toBeUndefined(); + }); + + it("useWebSearch=false なら対応モデルでもツールを付けない", async () => { + const create = vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "ok" }], + stop_reason: "end_turn", + }); + anthropicMock = { messages: { create, stream: vi.fn() } }; + await callAnthropic( + buildSettings("anthropic"), + { + provider: "anthropic", + model: "claude-3-5-sonnet-20241022", + messages: [{ role: "user", content: "go" }], + options: { stream: false, useWebSearch: false }, + }, + buildCallbacks(), + ); + const arg = create.mock.calls[0][0] as { tools?: unknown }; + expect(arg.tools).toBeUndefined(); + }); + + it("useWebSearch=true なら対応外モデルでもツールを付ける", async () => { + const create = vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "ok" }], + stop_reason: "end_turn", + }); + anthropicMock = { messages: { create, stream: vi.fn() } }; + await callAnthropic( + buildSettings("anthropic"), + { + provider: "anthropic", + model: "claude-2.1", + messages: [{ role: "user", content: "go" }], + options: { stream: false, useWebSearch: true }, + }, + buildCallbacks(), + ); + const arg = create.mock.calls[0][0] as { tools?: Array<{ name: string }> }; + expect(arg.tools?.[0]?.name).toBe("web_search"); + }); + + it("非ストリーミング: text ブロックの内容を onComplete に渡す", async () => { + anthropicMock = { + messages: { + create: vi.fn().mockResolvedValue({ + content: [ + { type: "tool_use", id: "x", name: "y", input: {} }, + { type: "text", text: "Hello!" }, + ], + stop_reason: "end_turn", + }), + stream: vi.fn(), + }, + }; + const callbacks = buildCallbacks(); + await callAnthropic( + buildSettings("anthropic"), + { + provider: "anthropic", + model: "claude-3-5-sonnet-20241022", + messages: [{ role: "user", content: "hi" }], + options: { stream: false }, + }, + callbacks, + ); + expect(callbacks.onComplete).toHaveBeenCalledWith({ + content: "Hello!", + finishReason: "end_turn", + }); + }); + + it("非ストリーミング: text ブロックが無ければ空文字で onComplete", async () => { + anthropicMock = { + messages: { + create: vi.fn().mockResolvedValue({ + content: [{ type: "tool_use", id: "x", name: "y", input: {} }], + stop_reason: "tool_use", + }), + stream: vi.fn(), + }, + }; + const callbacks = buildCallbacks(); + await callAnthropic( + buildSettings("anthropic"), + { + provider: "anthropic", + model: "claude-3-5-sonnet-20241022", + messages: [{ role: "user", content: "hi" }], + options: { stream: false }, + }, + callbacks, + ); + expect(callbacks.onComplete).toHaveBeenCalledWith({ + content: "", + finishReason: "tool_use", + }); + }); + + it("ストリーミング: text_delta だけをチャンクとして流す", async () => { + const events = [ + { type: "message_start" }, + { + type: "content_block_delta", + delta: { type: "text_delta", text: "Hel" }, + }, + { + type: "content_block_delta", + delta: { type: "input_json_delta", partial_json: "{" }, + }, + { + type: "content_block_delta", + delta: { type: "text_delta", text: "lo" }, + }, + ]; + const streamFn = vi.fn().mockReturnValue(asyncGen(events)); + anthropicMock = { messages: { create: vi.fn(), stream: streamFn } }; + + const callbacks = buildCallbacks(); + await callAnthropic( + buildSettings("anthropic"), + { + provider: "anthropic", + model: "claude-3-5-sonnet-20241022", + messages: [{ role: "user", content: "hi" }], + options: { stream: true }, + }, + callbacks, + ); + + expect(callbacks.onChunk).toHaveBeenCalledTimes(2); + expect(callbacks.onChunk).toHaveBeenNthCalledWith(1, "Hel"); + expect(callbacks.onChunk).toHaveBeenNthCalledWith(2, "lo"); + expect(callbacks.onComplete).toHaveBeenCalledWith({ + content: "Hello", + finishReason: "stop", + }); + }); + + it("ストリーミング: 既に aborted のシグナルなら ABORTED で reject", async () => { + const events = [{ type: "content_block_delta", delta: { type: "text_delta", text: "x" } }]; + const streamFn = vi.fn().mockReturnValue(asyncGen(events)); + anthropicMock = { messages: { create: vi.fn(), stream: streamFn } }; + const ac = new AbortController(); + ac.abort(); + await expect( + callAnthropic( + buildSettings("anthropic"), + { + provider: "anthropic", + model: "claude-3-5-sonnet-20241022", + messages: [{ role: "user", content: "hi" }], + options: { stream: true }, + }, + buildCallbacks(), + ac.signal, + ), + ).rejects.toThrow("ABORTED"); + }); +}); + +// =========================================================================== // +// Google +// =========================================================================== // + +describe("callGoogle", () => { + it("API キーでクライアントを生成する", async () => { + googleMock = { + models: { + generateContent: vi.fn().mockResolvedValue({ text: "ok" }), + generateContentStream: vi.fn(), + }, + }; + await callGoogle( + buildSettings("google", "AIza-x"), + { + provider: "google", + model: "gemini-3-flash", + messages: [{ role: "user", content: "hi" }], + options: { stream: false }, + }, + buildCallbacks(), + ); + expect(googleCtor).toHaveBeenCalledWith({ apiKey: "AIza-x" }); + }); + + it("非ストリーミング: 既定で googleSearch ツールを付ける", async () => { + const generateContent = vi.fn().mockResolvedValue({ text: "Hello" }); + googleMock = { + models: { generateContent, generateContentStream: vi.fn() }, + }; + const callbacks = buildCallbacks(); + await callGoogle( + buildSettings("google"), + { + provider: "google", + model: "gemini-3-flash", + messages: [ + { role: "user", content: "msg1" }, + { role: "user", content: "msg2" }, + ], + options: { stream: false, temperature: 0.3 }, + }, + callbacks, + ); + + expect(generateContent).toHaveBeenCalledWith( + expect.objectContaining({ + model: "gemini-3-flash", + contents: "msg1\n\nmsg2", + config: expect.objectContaining({ + temperature: 0.3, + tools: [{ googleSearch: {} }], + }), + }), + ); + expect(callbacks.onComplete).toHaveBeenCalledWith({ + content: "Hello", + finishReason: "stop", + }); + }); + + it("非ストリーミング: useGoogleSearch=false ならツールを undefined にする", async () => { + const generateContent = vi.fn().mockResolvedValue({ text: "x" }); + googleMock = { + models: { generateContent, generateContentStream: vi.fn() }, + }; + await callGoogle( + buildSettings("google"), + { + provider: "google", + model: "gemini-3-flash", + messages: [{ role: "user", content: "hi" }], + options: { stream: false, useGoogleSearch: false }, + }, + buildCallbacks(), + ); + const cfg = (generateContent.mock.calls[0][0] as { config: { tools?: unknown } }).config; + expect(cfg.tools).toBeUndefined(); + }); + + it("非ストリーミング: text 未定義なら空文字で onComplete", async () => { + googleMock = { + models: { + generateContent: vi.fn().mockResolvedValue({}), + generateContentStream: vi.fn(), + }, + }; + const callbacks = buildCallbacks(); + await callGoogle( + buildSettings("google"), + { + provider: "google", + model: "gemini-3-flash", + messages: [{ role: "user", content: "hi" }], + options: { stream: false }, + }, + callbacks, + ); + expect(callbacks.onComplete).toHaveBeenCalledWith({ + content: "", + finishReason: "stop", + }); + }); + + it("ストリーミング: 既定の maxOutputTokens=4000 / temperature=0.7 を渡す", async () => { + const generateContentStream = vi.fn().mockResolvedValue(asyncGen([])); + googleMock = { + models: { generateContent: vi.fn(), generateContentStream }, + }; + await callGoogle( + buildSettings("google"), + { + provider: "google", + model: "gemini-3-flash", + messages: [{ role: "user", content: "hi" }], + options: { stream: true }, + }, + buildCallbacks(), + ); + expect(generateContentStream).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + maxOutputTokens: 4000, + temperature: 0.7, + tools: [{ googleSearch: {} }], + }), + }), + ); + }); + + it("ストリーミング: 文字列を蓄積し最終 onComplete を呼ぶ", async () => { + const chunks = [{ text: "He" }, { text: "" }, { text: "llo" }]; + googleMock = { + models: { + generateContent: vi.fn(), + generateContentStream: vi.fn().mockResolvedValue(asyncGen(chunks)), + }, + }; + const callbacks = buildCallbacks(); + await callGoogle( + buildSettings("google"), + { + provider: "google", + model: "gemini-3-flash", + messages: [{ role: "user", content: "hi" }], + options: { stream: true, useGoogleSearch: false, maxTokens: 1000 }, + }, + callbacks, + ); + expect(callbacks.onChunk).toHaveBeenCalledTimes(2); + expect(callbacks.onChunk).toHaveBeenNthCalledWith(1, "He"); + expect(callbacks.onChunk).toHaveBeenNthCalledWith(2, "llo"); + expect(callbacks.onComplete).toHaveBeenCalledWith({ + content: "Hello", + finishReason: "stop", + }); + }); + + it("ストリーミング: aborted のシグナルなら ABORTED で reject", async () => { + googleMock = { + models: { + generateContent: vi.fn(), + generateContentStream: vi.fn().mockResolvedValue(asyncGen([{ text: "x" }, { text: "y" }])), + }, + }; + const ac = new AbortController(); + ac.abort(); + await expect( + callGoogle( + buildSettings("google"), + { + provider: "google", + model: "gemini-3-flash", + messages: [{ role: "user", content: "hi" }], + options: { stream: true }, + }, + buildCallbacks(), + ac.signal, + ), + ).rejects.toThrow("ABORTED"); + }); +}); diff --git a/src/lib/aiServiceModels.test.ts b/src/lib/aiServiceModels.test.ts new file mode 100644 index 00000000..34bd1eb4 --- /dev/null +++ b/src/lib/aiServiceModels.test.ts @@ -0,0 +1,500 @@ +/** + * Tests for AI service model listing & usage helpers. + * AI サービスのモデル一覧/使用量取得のテスト。 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { FetchServerModelsError, fetchServerModels, fetchUsage } from "./aiServiceModels"; +import type { AIModel, AIUsage, UserTier } from "@/types/ai"; + +const SERVER_MODELS_CACHE_KEY = "zedi-ai-server-models"; +const TEN_MINUTES_MS = 10 * 60 * 1000; + +/** + * Build a fully-populated AIModel for cache fixtures. + * キャッシュ用のテストモデルを生成する。 + */ +function buildModel(overrides: Partial = {}): AIModel { + return { + id: "openai:gpt-5", + provider: "openai", + modelId: "gpt-5", + displayName: "GPT-5", + tierRequired: "free", + available: true, + inputCostUnits: 1, + outputCostUnits: 2, + ...overrides, + }; +} + +describe("aiServiceModels", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + vi.useRealTimers(); + localStorage.clear(); + fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + vi.stubEnv("VITE_API_BASE_URL", "https://api.example.com"); + // Suppress noisy console output triggered by error paths. + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "debug").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + localStorage.clear(); + }); + + describe("FetchServerModelsError", () => { + it("正しい name と code を保持する", () => { + const err = new FetchServerModelsError("boom", "NETWORK", { status: 500 }); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe("FetchServerModelsError"); + expect(err.message).toBe("boom"); + expect(err.code).toBe("NETWORK"); + expect(err.details).toEqual({ status: 500 }); + }); + + it("details は省略可能", () => { + const err = new FetchServerModelsError("x", "HTTP"); + expect(err.details).toBeUndefined(); + }); + }); + + describe("fetchServerModels - cache hit", () => { + it("TTL 内のキャッシュをそのまま返し、API を呼ばない", async () => { + const cached = { + models: [buildModel({ id: "openai:gpt-5", modelId: "gpt-5" })], + tier: "pro" as UserTier, + cachedAt: Date.now(), + }; + localStorage.setItem(SERVER_MODELS_CACHE_KEY, JSON.stringify(cached)); + + const result = await fetchServerModels(); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(result.tier).toBe("pro"); + expect(result.models).toHaveLength(1); + expect(result.models[0].id).toBe("openai:gpt-5"); + }); + + it("snake_case のキャッシュも camelCase へ正規化する", async () => { + // キャッシュが古いフォーマット (snake_case) で書かれていた場合の互換性。 + const rawCache = { + models: [ + { + id: "openai:gpt-5", + provider: "openai", + model_id: "gpt-5", + display_name: "GPT-5", + tier_required: "pro", + available: true, + input_cost_units: 5, + output_cost_units: 6, + }, + ], + tier: "pro", + cachedAt: Date.now(), + }; + localStorage.setItem(SERVER_MODELS_CACHE_KEY, JSON.stringify(rawCache)); + + const result = await fetchServerModels(); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(result.models[0]).toEqual({ + id: "openai:gpt-5", + provider: "openai", + modelId: "gpt-5", + displayName: "GPT-5", + tierRequired: "pro", + available: true, + inputCostUnits: 5, + outputCostUnits: 6, + }); + expect(result.tier).toBe("pro"); + }); + + it("キャッシュの tier が pro 以外の値なら free にフォールバック", async () => { + const cached = { + models: [], + tier: "enterprise", + cachedAt: Date.now(), + }; + localStorage.setItem(SERVER_MODELS_CACHE_KEY, JSON.stringify(cached)); + + const result = await fetchServerModels(); + expect(result.tier).toBe("free"); + }); + + it("TTL を過ぎたキャッシュは無視して API を叩く", async () => { + const cached = { + models: [buildModel({ id: "old:model" })], + tier: "free" as UserTier, + cachedAt: Date.now() - (TEN_MINUTES_MS + 1), + }; + localStorage.setItem(SERVER_MODELS_CACHE_KEY, JSON.stringify(cached)); + + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ models: [], tier: "free" }), { status: 200 }), + ); + + const result = await fetchServerModels(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(result.models).toEqual([]); + }); + + it("forceRefresh=true なら有効なキャッシュも無視する", async () => { + const cached = { + models: [buildModel({ id: "openai:cached", modelId: "cached" })], + tier: "pro" as UserTier, + cachedAt: Date.now(), + }; + localStorage.setItem(SERVER_MODELS_CACHE_KEY, JSON.stringify(cached)); + + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ models: [], tier: "free" }), { status: 200 }), + ); + + const result = await fetchServerModels(true); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(result.tier).toBe("free"); + }); + + it("不正な JSON のキャッシュは無視して API を叩く", async () => { + localStorage.setItem(SERVER_MODELS_CACHE_KEY, "{not valid json"); + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ models: [], tier: "free" }), { status: 200 }), + ); + + await fetchServerModels(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe("fetchServerModels - API success", () => { + it("API レスポンスを正規化して返し、キャッシュへ保存する", async () => { + fetchSpy.mockResolvedValue( + new Response( + JSON.stringify({ + models: [ + { + id: "openai:gpt-5", + provider: "openai", + model_id: "gpt-5", + display_name: "GPT-5", + tier_required: "pro", + available: true, + input_cost_units: 1, + output_cost_units: 2, + }, + { + id: "google:gemini", + provider: "google", + modelId: "gemini-3-flash", + displayName: "Gemini 3 Flash", + tierRequired: "free", + available: false, + inputCostUnits: 0.5, + outputCostUnits: 1.5, + }, + ], + tier: "pro", + }), + { status: 200 }, + ), + ); + + const result = await fetchServerModels(); + + expect(fetchSpy).toHaveBeenCalledWith( + "https://api.example.com/api/ai/models", + expect.objectContaining({ + method: "GET", + credentials: "include", + }), + ); + expect(result.tier).toBe("pro"); + expect(result.models).toHaveLength(2); + expect(result.models[0].modelId).toBe("gpt-5"); + expect(result.models[0].tierRequired).toBe("pro"); + expect(result.models[1].modelId).toBe("gemini-3-flash"); + expect(result.models[1].tierRequired).toBe("free"); + + const stored = localStorage.getItem(SERVER_MODELS_CACHE_KEY); + expect(stored).toBeTruthy(); + const parsed = JSON.parse(stored ?? "{}"); + expect(parsed.tier).toBe("pro"); + expect(parsed.models).toHaveLength(2); + expect(typeof parsed.cachedAt).toBe("number"); + }); + + it("不正なプロバイダー文字列は google にフォールバックする", async () => { + fetchSpy.mockResolvedValue( + new Response( + JSON.stringify({ + models: [ + { + id: "x:y", + provider: "weird-provider", + model_id: "x", + display_name: "X", + }, + ], + tier: "free", + }), + { status: 200 }, + ), + ); + + const result = await fetchServerModels(); + expect(result.models[0].provider).toBe("google"); + }); + + it("欠損フィールドはデフォルト値で埋める", async () => { + fetchSpy.mockResolvedValue( + new Response( + JSON.stringify({ + models: [{}], + tier: "free", + }), + { status: 200 }, + ), + ); + + const result = await fetchServerModels(); + expect(result.models[0]).toEqual({ + id: "", + provider: "google", + modelId: "", + displayName: "", + tierRequired: "free", + available: false, + inputCostUnits: 0, + outputCostUnits: 0, + }); + }); + + it("非有限数 (±Infinity) の cost は 0 に正規化する / non-finite cost values fall back to 0", async () => { + // `JSON.stringify(Infinity)` は `null` になり Number.isFinite ガードを通らないため、 + // 生 JSON リテラル `1e1000` で `JSON.parse` に実際の Infinity を作らせる。 + // (NaN は JSON 表現不可能だが、Number.isFinite ガードは符号無関係に同じ分岐へ落ちる。) + // We avoid `JSON.stringify(Infinity)` (which collapses to `null` and would only + // exercise the missing-field path). A raw `1e1000` literal yields an actual + // Infinity from `JSON.parse`, so this test really exercises the + // `Number.isFinite` guard in `toNum`. + const rawBody = + '{"models":[{"id":"x:y","provider":"openai","model_id":"x",' + + '"display_name":"X","input_cost_units":1e1000,"output_cost_units":-1e1000}],' + + '"tier":"free"}'; + fetchSpy.mockResolvedValue(new Response(rawBody, { status: 200 })); + + const result = await fetchServerModels(); + expect(result.models[0].inputCostUnits).toBe(0); + expect(result.models[0].outputCostUnits).toBe(0); + }); + + it("tier が不明なら free にフォールバックする", async () => { + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ models: [], tier: "unknown" }), { status: 200 }), + ); + + const result = await fetchServerModels(); + expect(result.tier).toBe("free"); + }); + + it("localStorage の setItem が失敗しても結果は返す", async () => { + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ models: [], tier: "free" }), { status: 200 }), + ); + const setItemSpy = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("quota exceeded"); + }); + + await expect(fetchServerModels()).resolves.toEqual({ models: [], tier: "free" }); + expect(setItemSpy).toHaveBeenCalled(); + }); + }); + + describe("fetchServerModels - error paths", () => { + it("VITE_API_BASE_URL 未設定なら NO_BASE_URL を投げる", async () => { + vi.stubEnv("VITE_API_BASE_URL", ""); + await expect(fetchServerModels()).rejects.toMatchObject({ + name: "FetchServerModelsError", + code: "NO_BASE_URL", + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("fetch が TypeError(fetch) ならネットワークエラーメッセージにする", async () => { + fetchSpy.mockRejectedValue(new TypeError("fetch failed")); + try { + await fetchServerModels(); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(FetchServerModelsError); + const e = err as FetchServerModelsError; + expect(e.code).toBe("NETWORK"); + expect(e.message).toContain("ネットワークエラー"); + } + }); + + it("fetch が一般的な Error ならリクエスト失敗メッセージにする", async () => { + fetchSpy.mockRejectedValue(new Error("DNS failure")); + try { + await fetchServerModels(); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(FetchServerModelsError); + const e = err as FetchServerModelsError; + expect(e.code).toBe("NETWORK"); + expect(e.message).toContain("リクエスト失敗"); + expect(e.message).toContain("DNS failure"); + } + }); + + it("fetch が非 Error 値を投げてもラップする", async () => { + fetchSpy.mockRejectedValue("plain string failure"); + await expect(fetchServerModels()).rejects.toMatchObject({ + code: "NETWORK", + }); + }); + + it("HTTP エラーは status / statusText / 抜粋 body を保持する", async () => { + const longBody = "x".repeat(1000); + fetchSpy.mockResolvedValue( + new Response(longBody, { status: 503, statusText: "Service Unavailable" }), + ); + + try { + await fetchServerModels(); + expect.fail("should have thrown"); + } catch (err) { + const e = err as FetchServerModelsError; + expect(e.code).toBe("HTTP"); + expect(e.details?.status).toBe(503); + expect(e.details?.statusText).toBe("Service Unavailable"); + expect(e.details?.body?.length).toBe(500); + } + }); + + it("レスポンスが JSON でなければ INVALID_RESPONSE", async () => { + fetchSpy.mockResolvedValue(new Response("not json at all", { status: 200 })); + try { + await fetchServerModels(); + expect.fail("should have thrown"); + } catch (err) { + const e = err as FetchServerModelsError; + expect(e.code).toBe("INVALID_RESPONSE"); + } + }); + + it("models フィールドが配列でなければ INVALID_RESPONSE", async () => { + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ models: "oops", tier: "free" }), { status: 200 }), + ); + try { + await fetchServerModels(); + expect.fail("should have thrown"); + } catch (err) { + const e = err as FetchServerModelsError; + expect(e.code).toBe("INVALID_RESPONSE"); + } + }); + + it("response.text() が失敗したら NETWORK エラーへ変換する", async () => { + const badResponse = { + ok: true, + status: 200, + statusText: "OK", + text: vi.fn().mockRejectedValue(new Error("read failed")), + }; + fetchSpy.mockResolvedValue(badResponse as unknown as Response); + + try { + await fetchServerModels(); + expect.fail("should have thrown"); + } catch (err) { + const e = err as FetchServerModelsError; + expect(e.code).toBe("NETWORK"); + expect(e.message).toContain("レスポンスの読み取り"); + } + }); + }); + + describe("fetchUsage", () => { + function buildUsage(overrides: Partial = {}): AIUsage { + return { + usagePercent: 12.5, + consumedUnits: 100, + budgetUnits: 800, + remaining: 700, + tier: "free", + yearMonth: "2026-04", + ...overrides, + }; + } + + it("ベース URL 未設定なら空の使用量オブジェクトを返す", async () => { + vi.stubEnv("VITE_API_BASE_URL", ""); + const usage = await fetchUsage(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(usage).toEqual({ + usagePercent: 0, + consumedUnits: 0, + budgetUnits: 0, + remaining: 0, + tier: "free", + yearMonth: "", + }); + }); + + it("正常レスポンスをそのまま返す", async () => { + const expected = buildUsage({ tier: "pro", yearMonth: "2026-04" }); + fetchSpy.mockResolvedValue(new Response(JSON.stringify(expected), { status: 200 })); + + const usage = await fetchUsage(); + expect(fetchSpy).toHaveBeenCalledWith( + "https://api.example.com/api/ai/usage", + expect.objectContaining({ method: "GET", credentials: "include" }), + ); + expect(usage).toEqual(expected); + }); + + it("401 なら AUTH_REQUIRED を投げる", async () => { + fetchSpy.mockResolvedValue(new Response("", { status: 401 })); + await expect(fetchUsage()).rejects.toThrow("AUTH_REQUIRED"); + }); + + it("その他の HTTP エラーは Failed to fetch usage を投げる", async () => { + fetchSpy.mockResolvedValue(new Response("", { status: 500 })); + await expect(fetchUsage()).rejects.toThrow("Failed to fetch usage"); + }); + + it("レスポンスが usage 形式でなければ INVALID_USAGE_RESPONSE を投げる", async () => { + fetchSpy.mockResolvedValue(new Response(JSON.stringify({ tier: "free" }), { status: 200 })); + await expect(fetchUsage()).rejects.toThrow("INVALID_USAGE_RESPONSE"); + }); + + it("tier が不正な値なら INVALID_USAGE_RESPONSE を投げる", async () => { + const invalid = { ...buildUsage(), tier: "enterprise" }; + fetchSpy.mockResolvedValue(new Response(JSON.stringify(invalid), { status: 200 })); + await expect(fetchUsage()).rejects.toThrow("INVALID_USAGE_RESPONSE"); + }); + + it("数値フィールドが欠落していたら INVALID_USAGE_RESPONSE を投げる", async () => { + const invalid = { ...buildUsage(), usagePercent: undefined }; + fetchSpy.mockResolvedValue(new Response(JSON.stringify(invalid), { status: 200 })); + await expect(fetchUsage()).rejects.toThrow("INVALID_USAGE_RESPONSE"); + }); + + it("payload が null なら INVALID_USAGE_RESPONSE を投げる", async () => { + fetchSpy.mockResolvedValue(new Response(JSON.stringify(null), { status: 200 })); + await expect(fetchUsage()).rejects.toThrow("INVALID_USAGE_RESPONSE"); + }); + }); +}); diff --git a/src/lib/aiServiceServer.test.ts b/src/lib/aiServiceServer.test.ts new file mode 100644 index 00000000..aaadf918 --- /dev/null +++ b/src/lib/aiServiceServer.test.ts @@ -0,0 +1,380 @@ +/** + * Tests for AI service server-mode (SSE streaming) calls. + * AI サービス(API サーバー経由・SSE)のテスト。 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { callAIWithServer } from "./aiServiceServer"; +import type { AIServiceRequest, AIServiceCallbacks } from "./aiService"; + +/** + * Build a mock streaming Response whose body is a `ReadableStream` + * fed by the chunks the test wants to deliver. + * テストが指定したチャンク列を流す ReadableStream 入りのレスポンスを作る。 + */ +function makeStreamingResponse(chunks: string[], status = 200): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + for (const c of chunks) { + controller.enqueue(encoder.encode(c)); + } + controller.close(); + }, + }); + return new Response(stream, { + status, + statusText: status === 200 ? "OK" : `Status ${status}`, + }); +} + +function buildRequest(overrides: Partial = {}): AIServiceRequest { + return { + provider: "openai", + model: "gpt-5", + messages: [{ role: "user", content: "hello" }], + options: { stream: true, temperature: 0.5 }, + ...overrides, + }; +} + +function buildCallbacks(): Required< + Pick +> { + return { + onChunk: vi.fn(), + onComplete: vi.fn(), + onError: vi.fn(), + onUsageUpdate: vi.fn(), + }; +} + +describe("aiServiceServer / callAIWithServer", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + vi.stubEnv("VITE_API_BASE_URL", "https://api.example.com"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + describe("URL 設定", () => { + it("VITE_API_BASE_URL 未設定時は onError(URLが設定されていません)", async () => { + vi.stubEnv("VITE_API_BASE_URL", ""); + const callbacks = buildCallbacks(); + await callAIWithServer(buildRequest(), callbacks); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(callbacks.onError).toHaveBeenCalledTimes(1); + const errArg = callbacks.onError.mock.calls[0][0] as Error; + expect(errArg.message).toContain("URL"); + expect(callbacks.onComplete).not.toHaveBeenCalled(); + }); + + it("正しい URL/JSON ボディ/credentials で fetch される", async () => { + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ content: "ok", finishReason: "stop" }), { status: 200 }), + ); + const request = buildRequest({ options: { stream: false } }); + await callAIWithServer(request, buildCallbacks()); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe("https://api.example.com/api/ai/chat"); + expect(init.method).toBe("POST"); + expect(init.credentials).toBe("include"); + expect(init.headers).toEqual({ "Content-Type": "application/json" }); + const body = JSON.parse(init.body); + expect(body).toEqual({ + provider: "openai", + model: "gpt-5", + messages: [{ role: "user", content: "hello" }], + options: { stream: false }, + }); + }); + }); + + describe("HTTP ステータスマッピング", () => { + it("401 は AUTH_REQUIRED として onError へ", async () => { + fetchSpy.mockResolvedValue(new Response("", { status: 401 })); + const callbacks = buildCallbacks(); + await callAIWithServer(buildRequest(), callbacks); + expect(callbacks.onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "AUTH_REQUIRED" }), + ); + }); + + it("ボディに error フィールドがあればそのメッセージを使う", async () => { + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ error: "rate limited" }), { status: 429 }), + ); + const callbacks = buildCallbacks(); + await callAIWithServer(buildRequest(), callbacks); + expect(callbacks.onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "rate limited" }), + ); + }); + + it("ボディが JSON でなければ statusText を使う", async () => { + fetchSpy.mockResolvedValue( + new Response("plain text", { status: 503, statusText: "Service Unavailable" }), + ); + const callbacks = buildCallbacks(); + await callAIWithServer(buildRequest(), callbacks); + expect(callbacks.onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "Service Unavailable" }), + ); + }); + + it("statusText も空ならフォールバック文字列を使う", async () => { + fetchSpy.mockResolvedValue(new Response("plain text", { status: 500, statusText: "" })); + const callbacks = buildCallbacks(); + await callAIWithServer(buildRequest(), callbacks); + expect(callbacks.onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "AI API呼び出しエラー" }), + ); + }); + + it("fetch 自体が reject したら onError(Error)", async () => { + fetchSpy.mockRejectedValue(new Error("network down")); + const callbacks = buildCallbacks(); + await callAIWithServer(buildRequest(), callbacks); + expect(callbacks.onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "network down" }), + ); + }); + + it("fetch が非 Error 値で reject しても汎用メッセージへ変換する", async () => { + fetchSpy.mockRejectedValue("string failure"); + const callbacks = buildCallbacks(); + await callAIWithServer(buildRequest(), callbacks); + expect(callbacks.onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "AI API呼び出しエラー" }), + ); + }); + }); + + describe("非ストリーミング", () => { + it("usage 付きレスポンスで onUsageUpdate と onComplete が呼ばれる", async () => { + const usage = { inputTokens: 5, outputTokens: 8, costUnits: 1, usagePercent: 0.1 }; + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ content: "Hello!", finishReason: "stop", usage }), { + status: 200, + }), + ); + const callbacks = buildCallbacks(); + await callAIWithServer(buildRequest({ options: { stream: false } }), callbacks); + + expect(callbacks.onUsageUpdate).toHaveBeenCalledWith(usage); + expect(callbacks.onComplete).toHaveBeenCalledWith({ + content: "Hello!", + finishReason: "stop", + usage, + }); + expect(callbacks.onError).not.toHaveBeenCalled(); + }); + + it("content 欠落時は空文字で onComplete が呼ばれる", async () => { + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ finishReason: "stop" }), { status: 200 }), + ); + const callbacks = buildCallbacks(); + await callAIWithServer(buildRequest({ options: { stream: false } }), callbacks); + + expect(callbacks.onComplete).toHaveBeenCalledWith({ + content: "", + finishReason: "stop", + usage: undefined, + }); + }); + + it("usage が無ければ onUsageUpdate は呼ばれない", async () => { + fetchSpy.mockResolvedValue(new Response(JSON.stringify({ content: "ok" }), { status: 200 })); + const callbacks = buildCallbacks(); + await callAIWithServer(buildRequest({ options: { stream: false } }), callbacks); + expect(callbacks.onUsageUpdate).not.toHaveBeenCalled(); + expect(callbacks.onComplete).toHaveBeenCalledWith({ + content: "ok", + finishReason: undefined, + usage: undefined, + }); + }); + }); + + describe("ストリーミング (SSE)", () => { + it("複数の data 行を順次 onChunk で受け取り、done で onComplete を呼ぶ", async () => { + fetchSpy.mockResolvedValue( + makeStreamingResponse([ + 'data: {"content":"Hel"}\n', + 'data: {"content":"lo"}\n', + 'data: {"content":"!","done":true,"finishReason":"stop"}\n', + ]), + ); + const callbacks = buildCallbacks(); + await callAIWithServer(buildRequest(), callbacks); + + expect(callbacks.onChunk).toHaveBeenCalledTimes(3); + expect(callbacks.onChunk).toHaveBeenNthCalledWith(1, "Hel"); + expect(callbacks.onChunk).toHaveBeenNthCalledWith(2, "lo"); + expect(callbacks.onChunk).toHaveBeenNthCalledWith(3, "!"); + expect(callbacks.onComplete).toHaveBeenCalledWith({ + content: "Hello!", + finishReason: "stop", + usage: undefined, + }); + expect(callbacks.onError).not.toHaveBeenCalled(); + }); + + it("data: 以外の行はスキップする", async () => { + fetchSpy.mockResolvedValue( + makeStreamingResponse([ + ": comment\n", + "event: ping\n", + 'data: {"content":"X","done":true,"finishReason":"stop"}\n', + ]), + ); + const callbacks = buildCallbacks(); + await callAIWithServer(buildRequest(), callbacks); + expect(callbacks.onChunk).toHaveBeenCalledTimes(1); + expect(callbacks.onChunk).toHaveBeenCalledWith("X"); + expect(callbacks.onComplete).toHaveBeenCalled(); + }); + + it("`data:` の後ろが空のペイロードはスキップする", async () => { + fetchSpy.mockResolvedValue( + makeStreamingResponse(["data: \n", 'data: {"done":true,"finishReason":"stop"}\n']), + ); + const callbacks = buildCallbacks(); + await callAIWithServer(buildRequest(), callbacks); + expect(callbacks.onComplete).toHaveBeenCalledWith({ + content: "", + finishReason: "stop", + usage: undefined, + }); + }); + + it("usage 含むイベントで onUsageUpdate と最終 onComplete が呼ばれる", async () => { + const usage = { inputTokens: 1, outputTokens: 2, costUnits: 3, usagePercent: 0.4 }; + fetchSpy.mockResolvedValue( + makeStreamingResponse([ + 'data: {"content":"hi"}\n', + `data: ${JSON.stringify({ usage })}\n`, + 'data: {"done":true,"finishReason":"stop"}\n', + ]), + ); + const callbacks = buildCallbacks(); + await callAIWithServer(buildRequest(), callbacks); + + expect(callbacks.onUsageUpdate).toHaveBeenCalledWith(usage); + expect(callbacks.onComplete).toHaveBeenCalledWith({ + content: "hi", + finishReason: "stop", + usage, + }); + }); + + it("チャンクを跨いで分割された行を再構築する", async () => { + fetchSpy.mockResolvedValue( + makeStreamingResponse([ + 'data: {"content":"He', + 'llo"}\n', + 'data: {"done":true,"finishReason":"stop"}\n', + ]), + ); + const callbacks = buildCallbacks(); + await callAIWithServer(buildRequest(), callbacks); + expect(callbacks.onChunk).toHaveBeenCalledWith("Hello"); + }); + + it("末尾改行なしの最終 data 行も処理する", async () => { + fetchSpy.mockResolvedValue( + makeStreamingResponse([ + 'data: {"content":"a"}\n', + 'data: {"done":true,"finishReason":"stop"}', + ]), + ); + const callbacks = buildCallbacks(); + await callAIWithServer(buildRequest(), callbacks); + expect(callbacks.onComplete).toHaveBeenCalledWith({ + content: "a", + finishReason: "stop", + usage: undefined, + }); + }); + + it("done が来ずに切断されたが内容があれば onComplete を呼ぶ", async () => { + fetchSpy.mockResolvedValue(makeStreamingResponse(['data: {"content":"partial"}\n'])); + const callbacks = buildCallbacks(); + await callAIWithServer(buildRequest(), callbacks); + expect(callbacks.onComplete).toHaveBeenCalledWith({ + content: "partial", + finishReason: undefined, + usage: undefined, + }); + expect(callbacks.onError).not.toHaveBeenCalled(); + }); + + it("done が来ず内容も無ければ onError(空のまま切断)", async () => { + fetchSpy.mockResolvedValue(makeStreamingResponse([": comment only\n"])); + const callbacks = buildCallbacks(); + await callAIWithServer(buildRequest(), callbacks); + expect(callbacks.onError).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("空のまま") }), + ); + expect(callbacks.onComplete).not.toHaveBeenCalled(); + }); + + it("data の error フィールドは onError へ", async () => { + fetchSpy.mockResolvedValue(makeStreamingResponse(['data: {"error":"upstream failure"}\n'])); + const callbacks = buildCallbacks(); + await callAIWithServer(buildRequest(), callbacks); + expect(callbacks.onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "upstream failure" }), + ); + }); + + it("response.body が無ければ onError(取得できません)", async () => { + fetchSpy.mockResolvedValue(new Response(null, { status: 200 }) as unknown as Response); + const callbacks = buildCallbacks(); + await callAIWithServer(buildRequest(), callbacks); + expect(callbacks.onError).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("取得できません") }), + ); + }); + + it("abortSignal が aborted なら ABORTED エラーを onError へ送る", async () => { + const abortController = new AbortController(); + // 1 つ流したあとループの先頭で abort を検知させる。 + const encoder = new TextEncoder(); + let pulledOnce = false; + const stream = new ReadableStream({ + async pull(controller) { + if (!pulledOnce) { + pulledOnce = true; + controller.enqueue(encoder.encode('data: {"content":"a"}\n')); + // 次の pull の前に abort + // Abort before the next pull. + abortController.abort(); + return; + } + controller.enqueue(encoder.encode(":\n")); + controller.close(); + }, + }); + fetchSpy.mockResolvedValue(new Response(stream, { status: 200 })); + + const callbacks = buildCallbacks(); + await callAIWithServer(buildRequest(), callbacks, abortController.signal); + + expect(callbacks.onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "ABORTED" }), + ); + expect(callbacks.onComplete).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 01255bc6..9c130cfa 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -37,22 +37,28 @@ export interface SyncPageItem { } /** - * WikiLink グラフのリンク行。`/api/sync/pages` 応答内で使われる。 - * Link row in the wiki-link graph, returned by `/api/sync/pages`. + * WikiLink / タググラフのリンク行。`/api/sync/pages` 応答内で使われる。 + * `link_type` は issue #725 Phase 1 で追加。未提供クライアント互換のため任意。 + * + * Link row in the wiki/tag link graph returned by `/api/sync/pages`. + * `link_type` was added in issue #725 Phase 1; optional so legacy clients + * without the field still parse. */ export interface SyncLinkItem { source_id: string; target_id: string; + link_type?: "wiki" | "tag"; created_at: string; } /** - * 未解決 WikiLink(ゴーストリンク)の行。`/api/sync/pages` で同期される。 - * Ghost-link (unresolved WikiLink) row synced via `/api/sync/pages`. + * 未解決 WikiLink / タグ(ゴーストリンク)の行。`/api/sync/pages` で同期される。 + * Ghost-link row (unresolved WikiLink or tag) synced via `/api/sync/pages`. */ export interface SyncGhostLinkItem { link_text: string; source_page_id: string; + link_type?: "wiki" | "tag"; created_at: string; original_target_page_id?: string | null; original_note_id?: string | null; @@ -71,10 +77,21 @@ export interface PostSyncPagesBody { updated_at: string; is_deleted?: boolean; }>; - links?: Array<{ source_id: string; target_id: string; created_at?: string }>; + links?: Array<{ + source_id: string; + target_id: string; + /** + * `'wiki'` | `'tag'`。未指定はサーバー側で `'wiki'` にフォールバック + * (issue #725 Phase 1)。Omit → server defaults to `'wiki'`. + */ + link_type?: "wiki" | "tag"; + created_at?: string; + }>; ghost_links?: Array<{ link_text: string; source_page_id: string; + /** 同上 / Same contract as above. */ + link_type?: "wiki" | "tag"; created_at?: string; original_target_page_id?: string | null; original_note_id?: string | null; diff --git a/src/lib/encryption.test.ts b/src/lib/encryption.test.ts index a9d1d7cb..9ac67ff2 100644 --- a/src/lib/encryption.test.ts +++ b/src/lib/encryption.test.ts @@ -2,47 +2,212 @@ import { describe, it, expect, beforeEach } from "vitest"; import { encrypt, decrypt, clearEncryptionKey } from "./encryption"; const hasSubtle = typeof globalThis.crypto?.subtle !== "undefined"; +const ENCRYPTION_KEY_NAME = "zedi-encryption-key"; +const IV_LENGTH = 12; + +/** + * Decode a Base64 ciphertext into the underlying byte array. + * Base64 文字列を生バイト列に戻す(IV と暗号化データの境界検証に使う)。 + */ +function decodeBase64(s: string): Uint8Array { + return Uint8Array.from(atob(s), (c) => c.charCodeAt(0)); +} + +/** + * Re-encode the given bytes as Base64 and assert that `decrypt` rejects. + * Centralizes the "tamper bytes → expect rejection" flow used across IV / + * payload tamper tests so future drift only needs one update. + * + * 渡されたバイト列を Base64 化し、`decrypt` が必ず失敗することを検証する。 + * IV / 暗号化データ改竄テストで繰り返される手順をここに集約する。 + */ +async function expectDecryptRejectsFromBytes(bytes: Uint8Array): Promise { + const b64 = btoa(String.fromCharCode(...bytes)); + await expect(decrypt(b64)).rejects.toBeDefined(); +} describe("encryption", () => { beforeEach(() => { localStorage.clear(); }); - it.skipIf(!hasSubtle)("encrypt then decrypt returns original plaintext", async () => { - const plaintext = "Hello, World!"; - const encrypted = await encrypt(plaintext); - const decrypted = await decrypt(encrypted); - expect(decrypted).toBe(plaintext); + describe("round-trip", () => { + it.skipIf(!hasSubtle)("encrypt then decrypt returns original plaintext", async () => { + const plaintext = "Hello, World!"; + const encrypted = await encrypt(plaintext); + const decrypted = await decrypt(encrypted); + expect(decrypted).toBe(plaintext); + }); + + it.skipIf(!hasSubtle)("works with empty string (decrypt returns '')", async () => { + // 空文字でも IV 付きで暗号化され、復号で空文字に戻る。 + // Pin that empty input round-trips to empty output (catches off-by-one slice mutations). + const encrypted = await encrypt(""); + const decrypted = await decrypt(encrypted); + expect(decrypted).toBe(""); + // 暗号文の長さは IV(12B) + GCM tag(16B) = 28B 以上のはず。 + // Even an empty plaintext yields at least 28 bytes (IV + 16-byte tag). + expect(decodeBase64(encrypted).length).toBeGreaterThanOrEqual(IV_LENGTH + 16); + }); + + it.skipIf(!hasSubtle)("works with unicode (multibyte) text", async () => { + const plaintext = "日本語テスト 🎉 émojis & spëcial chars"; + const encrypted = await encrypt(plaintext); + const decrypted = await decrypt(encrypted); + expect(decrypted).toBe(plaintext); + }); }); - it.skipIf(!hasSubtle)("encrypt produces different output each time", async () => { - const plaintext = "Same input"; - const a = await encrypt(plaintext); - const b = await encrypt(plaintext); - expect(a).not.toBe(b); + describe("IV semantics", () => { + it.skipIf(!hasSubtle)( + "produces a fresh IV per call (different ciphertexts for same input)", + async () => { + // 同一平文でも IV が毎回ランダムに生成されるため出力は異なる。 + // Kills mutations to `crypto.getRandomValues` (e.g., zero-IV) and the IV-length constant. + const a = await encrypt("Same input"); + const b = await encrypt("Same input"); + expect(a).not.toBe(b); + + // 先頭 IV_LENGTH バイト (= IV) が異なることを直接検証する。 + // Pin that the first 12 bytes (the IV) differ between calls. + const ivA = decodeBase64(a).slice(0, IV_LENGTH); + const ivB = decodeBase64(b).slice(0, IV_LENGTH); + expect(Array.from(ivA)).not.toEqual(Array.from(ivB)); + }, + ); + + it.skipIf(!hasSubtle)( + "prepends the IV (not appends) and produces non-zero IV bytes", + async () => { + // IV は先頭に置かれ (`combined.set(iv, 0)`)、暗号化データはその後ろに続く + // (`combined.set(encryptedData, iv.length)`)。両 set 呼び出しのオフセット変異を殺す。 + // Kills mutations to the offsets in the two `combined.set(...)` calls. + const ciphertext = await encrypt("payload"); + const bytes = decodeBase64(ciphertext); + + // 先頭 12 バイトはランダム IV である (= 全部 0 ではない)。 + // The first 12 bytes form a random IV — not all zeros. + const iv = bytes.slice(0, IV_LENGTH); + expect(iv.length).toBe(IV_LENGTH); + expect(iv.some((b) => b !== 0)).toBe(true); + + // IV を別物に書き換えると AES-GCM の認証タグ検証が必ず失敗する。 + // Tampering the IV must trigger an authentication failure on decrypt. + const tampered = new Uint8Array(bytes); + tampered[0] ^= 0xff; + await expectDecryptRejectsFromBytes(tampered); + }, + ); + + it.skipIf(!hasSubtle)("reads the IV from the FIRST 12 bytes on decrypt", async () => { + // 復号時の `combined.slice(0, IV_LENGTH)` と `combined.slice(IV_LENGTH)` の境界を検証する。 + // Truncating the first byte (so the IV starts at offset 1) must fail to decrypt. + // Kills mutations to the slice arguments on decrypt. + const ciphertext = await encrypt("payload"); + const bytes = decodeBase64(ciphertext); + const shifted = bytes.slice(1); // 先頭 1 バイト落とす → IV 領域がズレる + await expectDecryptRejectsFromBytes(shifted); + }); }); - it("clearEncryptionKey removes key from localStorage", async () => { - if (hasSubtle) { - await encrypt("trigger key generation"); - expect(localStorage.getItem("zedi-encryption-key")).not.toBeNull(); - } else { - localStorage.setItem("zedi-encryption-key", "dummy"); - } - clearEncryptionKey(); - expect(localStorage.getItem("zedi-encryption-key")).toBeNull(); + describe("authentication / tamper resistance", () => { + it.skipIf(!hasSubtle)( + "decrypt throws when the encrypted data portion is tampered", + async () => { + // GCM の認証タグにより、暗号化データの 1 バイト改竄でも復号は失敗する。 + // Pin that AES-GCM tag verification rejects payload tampering. + const ciphertext = await encrypt("authenticated payload"); + const bytes = decodeBase64(ciphertext); + const tampered = new Uint8Array(bytes); + // IV ではなく暗号化データ部 (offset = IV_LENGTH 以降) を 1 バイト改竄する。 + // Flip a bit in the encrypted-data portion (after the IV). + tampered[IV_LENGTH] ^= 0x01; + await expectDecryptRejectsFromBytes(tampered); + }, + ); }); - it.skipIf(!hasSubtle)("works with empty string", async () => { - const encrypted = await encrypt(""); - const decrypted = await decrypt(encrypted); - expect(decrypted).toBe(""); + describe("key persistence and reuse", () => { + it.skipIf(!hasSubtle)("stores the generated key in localStorage on first encrypt", async () => { + // 初回 encrypt はキーを生成し localStorage に Base64 で保存する。 + // Pin the side effect that lets a future browser session decrypt past content. + expect(localStorage.getItem(ENCRYPTION_KEY_NAME)).toBeNull(); + await encrypt("trigger key"); + expect(localStorage.getItem(ENCRYPTION_KEY_NAME)).not.toBeNull(); + }); + + it.skipIf(!hasSubtle)( + "reuses the stored key on subsequent encrypt calls (does not regenerate)", + async () => { + // 2 回目以降の encrypt は `if (storedKey)` 分岐に入り、保存済みキーをインポートして使う。 + // Pin the stored-key branch; without this test it stays NoCoverage / killed by removal. + await encrypt("first"); + const keyAfterFirst = localStorage.getItem(ENCRYPTION_KEY_NAME); + expect(keyAfterFirst).not.toBeNull(); + + await encrypt("second"); + const keyAfterSecond = localStorage.getItem(ENCRYPTION_KEY_NAME); + expect(keyAfterSecond).toBe(keyAfterFirst); + }, + ); + + it.skipIf(!hasSubtle)( + "decrypts ciphertext produced by an earlier encrypt call (key reuse end-to-end)", + async () => { + // 同一プロセス内で 2 つの異なる暗号文が同じキーで復号できることを確認する。 + // End-to-end check that the stored-key branch is functional, not just present. + const ct1 = await encrypt("alpha"); + const ct2 = await encrypt("beta"); + expect(await decrypt(ct1)).toBe("alpha"); + expect(await decrypt(ct2)).toBe("beta"); + }, + ); + + it.skipIf(!hasSubtle)( + "regenerates a new key after clearEncryptionKey, breaking old ciphertext", + async () => { + // クリア後は新しいキーが生成され、過去の暗号文は復号できなくなる。 + // Pin both: (1) clearEncryptionKey removes the stored key, (2) new encrypt + // generates a fresh key (different bytes) so old ciphertext fails to decrypt. + const ct = await encrypt("before clear"); + const oldKey = localStorage.getItem(ENCRYPTION_KEY_NAME); + expect(oldKey).not.toBeNull(); + + clearEncryptionKey(); + expect(localStorage.getItem(ENCRYPTION_KEY_NAME)).toBeNull(); + + await encrypt("after clear"); + const newKey = localStorage.getItem(ENCRYPTION_KEY_NAME); + expect(newKey).not.toBeNull(); + expect(newKey).not.toBe(oldKey); + + // 古い暗号文は新しいキーでは復号できない。 + // Old ciphertext must fail under the new key. + await expect(decrypt(ct)).rejects.toBeDefined(); + }, + ); }); - it.skipIf(!hasSubtle)("works with unicode text", async () => { - const plaintext = "日本語テスト 🎉 émojis & spëcial chars"; - const encrypted = await encrypt(plaintext); - const decrypted = await decrypt(encrypted); - expect(decrypted).toBe(plaintext); + describe("clearEncryptionKey", () => { + it("removes the key from localStorage when present", async () => { + // クリアにより localStorage の該当キーが削除される。 + // Pin the side effect of clearEncryptionKey (key name and removal semantics). + if (hasSubtle) { + await encrypt("trigger key generation"); + expect(localStorage.getItem(ENCRYPTION_KEY_NAME)).not.toBeNull(); + } else { + localStorage.setItem(ENCRYPTION_KEY_NAME, "dummy"); + } + clearEncryptionKey(); + expect(localStorage.getItem(ENCRYPTION_KEY_NAME)).toBeNull(); + }); + + it("is a no-op (no throw) when no key is stored", () => { + // 未保存状態でもエラーにならず、なにも起こらない。 + // Pin the no-op semantics so a stricter "must exist" mutation surfaces. + localStorage.clear(); + expect(() => clearEncryptionKey()).not.toThrow(); + expect(localStorage.getItem(ENCRYPTION_KEY_NAME)).toBeNull(); + }); }); }); diff --git a/src/lib/pageRepository.test.ts b/src/lib/pageRepository.test.ts new file mode 100644 index 00000000..cd444ccc --- /dev/null +++ b/src/lib/pageRepository.test.ts @@ -0,0 +1,528 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { IPageRepository, CreatePageOptions, PageRepositoryOptions } from "./pageRepository"; +import type { Page, PageSummary, Link, GhostLink, LinkType } from "@/types/page"; +import { StorageAdapterPageRepository } from "./pageRepository/StorageAdapterPageRepository"; +import type { StorageAdapter } from "./storageAdapter/StorageAdapter"; +import type { ApiClient } from "./api/apiClient"; + +/** + * `pageRepository.ts` は型のみのインターフェース層であり、ランタイムロジックを + * 持たない。本テストは「インターフェースを満たす実装が、各 method を 1:1 で + * 下位ストア (=adapter) に委譲する」という契約をスパイで検証する。 + * + * Concrete adapters (e.g. `StorageAdapterPageRepository`) carry their own + * behaviour tests; here we only verify the interface contract — every method + * exists, has the documented signature, and delegates verbatim. + * + * `pageRepository.ts` is a types-only module with no runtime behaviour. This + * test pins the contract: any implementation must expose every documented + * method and forward arguments to its underlying store. + */ + +interface PageStoreLike { + pages: Map; + links: Link[]; + ghostLinks: GhostLink[]; +} + +function makeStore(): PageStoreLike { + return { pages: new Map(), links: [], ghostLinks: [] }; +} + +/** + * 最小の `IPageRepository` 実装。各 method は store にしか触らず、CRUD と link + * 系は spy 越しに呼び出し回数を検証できる。 + * + * Minimal repository implementation that delegates each method straight to a + * mutable store map; we then spy on it to verify delegation in tests. + */ +function createInMemoryRepository( + store: PageStoreLike, + options?: PageRepositoryOptions, +): IPageRepository { + const fireMutate = async (): Promise => { + if (options?.onMutate) await options.onMutate(); + }; + + const repo: IPageRepository = { + async createPage(userId, title = "", content = "", opts) { + const id = `id-${store.pages.size + 1}`; + const now = Date.now(); + const page: Page = { + id, + ownerUserId: userId, + noteId: null, + title, + content, + thumbnailUrl: opts?.thumbnailUrl ?? undefined, + sourceUrl: opts?.sourceUrl ?? undefined, + createdAt: now, + updatedAt: now, + isDeleted: false, + }; + store.pages.set(id, page); + await fireMutate(); + return page; + }, + async getPage(_userId, pageId) { + const p = store.pages.get(pageId); + return p && !p.isDeleted ? p : null; + }, + async getPages(_userId) { + return [...store.pages.values()].filter((p) => !p.isDeleted); + }, + async getPagesSummary(_userId) { + return [...store.pages.values()] + .filter((p) => !p.isDeleted) + .map((p) => ({ + id: p.id, + ownerUserId: p.ownerUserId, + noteId: p.noteId, + title: p.title, + contentPreview: p.contentPreview, + thumbnailUrl: p.thumbnailUrl, + sourceUrl: p.sourceUrl, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + isDeleted: p.isDeleted, + })); + }, + async getPagesByIds(_userId, ids) { + return ids + .map((id) => store.pages.get(id)) + .filter((p): p is Page => p !== undefined && !p.isDeleted); + }, + async getPageByTitle(_userId, title) { + return [...store.pages.values()].find((p) => p.title === title && !p.isDeleted) ?? null; + }, + async checkDuplicateTitle(_userId, title, excludePageId) { + return ( + [...store.pages.values()].find( + (p) => p.title === title && p.id !== excludePageId && !p.isDeleted, + ) ?? null + ); + }, + async updatePage(_userId, pageId, updates) { + const p = store.pages.get(pageId); + if (!p) return; + store.pages.set(pageId, { ...p, ...updates, updatedAt: Date.now() }); + await fireMutate(); + }, + async deletePage(_userId, pageId) { + const p = store.pages.get(pageId); + if (p) store.pages.set(pageId, { ...p, isDeleted: true }); + await fireMutate(); + }, + async searchPages(_userId, query) { + const normalized = query.toLowerCase().trim(); + if (!normalized) return []; + return [...store.pages.values()].filter((p) => { + if (p.isDeleted) return false; + return ( + p.title.toLowerCase().includes(normalized) || p.content.toLowerCase().includes(normalized) + ); + }); + }, + async addLink(sourceId, targetId, linkType: LinkType = "wiki") { + if ( + store.links.some( + (l) => l.sourceId === sourceId && l.targetId === targetId && l.linkType === linkType, + ) + ) { + return; + } + store.links.push({ sourceId, targetId, linkType, createdAt: Date.now() }); + await fireMutate(); + }, + async removeLink(sourceId, targetId, linkType: LinkType = "wiki") { + store.links = store.links.filter( + (l) => !(l.sourceId === sourceId && l.targetId === targetId && l.linkType === linkType), + ); + await fireMutate(); + }, + async getOutgoingLinks(pageId, linkType: LinkType = "wiki") { + return store.links + .filter((l) => l.sourceId === pageId && l.linkType === linkType) + .map((l) => l.targetId); + }, + async getBacklinks(pageId, linkType: LinkType = "wiki") { + return store.links + .filter((l) => l.targetId === pageId && l.linkType === linkType) + .map((l) => l.sourceId); + }, + async getLinks(_userId) { + return [...store.links]; + }, + async addGhostLink(linkText, sourcePageId, linkType: LinkType = "wiki") { + if ( + store.ghostLinks.some( + (g) => + g.linkText === linkText && g.sourcePageId === sourcePageId && g.linkType === linkType, + ) + ) { + return; + } + store.ghostLinks.push({ + linkText, + sourcePageId, + linkType, + createdAt: Date.now(), + }); + await fireMutate(); + }, + async removeGhostLink(linkText, sourcePageId, linkType: LinkType = "wiki") { + store.ghostLinks = store.ghostLinks.filter( + (g) => + !(g.linkText === linkText && g.sourcePageId === sourcePageId && g.linkType === linkType), + ); + await fireMutate(); + }, + async getGhostLinkSources(linkText, linkType: LinkType = "wiki") { + return store.ghostLinks + .filter((g) => g.linkText === linkText && g.linkType === linkType) + .map((g) => g.sourcePageId); + }, + async getGhostLinks(_userId) { + return [...store.ghostLinks]; + }, + async getGhostLinksBySourcePage(sourcePageId, linkType: LinkType = "wiki") { + return store.ghostLinks + .filter((g) => g.sourcePageId === sourcePageId && g.linkType === linkType) + .map((g) => g.linkText); + }, + async promoteGhostLink(userId, linkText) { + const sources = await repo.getGhostLinkSources(linkText, "wiki"); + if (sources.length < 2) return null; + const created = await repo.createPage(userId, linkText, ""); + for (const sourceId of sources) { + await repo.addLink(sourceId, created.id, "wiki"); + await repo.removeGhostLink(linkText, sourceId, "wiki"); + } + return created; + }, + }; + + return repo; +} + +describe("IPageRepository contract", () => { + let store: PageStoreLike; + let repo: IPageRepository; + + beforeEach(() => { + store = makeStore(); + repo = createInMemoryRepository(store); + }); + + it("exposes every documented method as a function", () => { + const required = [ + "createPage", + "getPage", + "getPages", + "getPagesSummary", + "getPagesByIds", + "getPageByTitle", + "checkDuplicateTitle", + "updatePage", + "deletePage", + "searchPages", + "addLink", + "removeLink", + "getOutgoingLinks", + "getBacklinks", + "getLinks", + "addGhostLink", + "removeGhostLink", + "getGhostLinkSources", + "getGhostLinks", + "getGhostLinksBySourcePage", + "promoteGhostLink", + ] as const; + + for (const name of required) { + expect(typeof (repo as unknown as Record)[name]).toBe("function"); + } + }); + + it("createPage stores a Page and respects CreatePageOptions", async () => { + const options: CreatePageOptions = { + sourceUrl: "https://example.com", + thumbnailUrl: "https://thumb.example.com/x.png", + }; + const page = await repo.createPage("user-1", "Title", "body", options); + + expect(page.title).toBe("Title"); + expect(page.ownerUserId).toBe("user-1"); + expect(page.sourceUrl).toBe(options.sourceUrl); + expect(page.thumbnailUrl).toBe(options.thumbnailUrl); + expect(store.pages.size).toBe(1); + }); + + it("read methods return what the underlying store contains", async () => { + await repo.createPage("u1", "Alpha"); + await repo.createPage("u1", "Bravo"); + + const all = await repo.getPages("u1"); + expect(all.map((p) => p.title).sort()).toEqual(["Alpha", "Bravo"]); + + const byTitle = await repo.getPageByTitle("u1", "Alpha"); + expect(byTitle?.title).toBe("Alpha"); + + const ids = all.map((p) => p.id); + const byIds = await repo.getPagesByIds("u1", ids); + expect(byIds).toHaveLength(2); + + const summaries = await repo.getPagesSummary("u1"); + expect(summaries).toHaveLength(2); + // contentPreview / thumbnailUrl は省略されていてよいが、必須キーは存在する + // contentPreview / thumbnailUrl may be omitted, but required keys are present + for (const s of summaries) { + expect(s).toHaveProperty("id"); + expect(s).toHaveProperty("title"); + expect(s).toHaveProperty("noteId"); + } + }); + + it("checkDuplicateTitle excludes the given page id", async () => { + const a = await repo.createPage("u1", "Same"); + await repo.createPage("u1", "Same"); + + const dup = await repo.checkDuplicateTitle("u1", "Same", a.id); + expect(dup).not.toBeNull(); + expect(dup?.id).not.toBe(a.id); + }); + + it("updatePage merges fields and deletePage soft-deletes", async () => { + const page = await repo.createPage("u1", "Old"); + await repo.updatePage("u1", page.id, { title: "New" }); + expect((await repo.getPage("u1", page.id))?.title).toBe("New"); + + await repo.deletePage("u1", page.id); + expect(await repo.getPage("u1", page.id)).toBeNull(); + }); + + it("read methods consistently hide soft-deleted pages", async () => { + // モックの soft-delete 挙動が read 系全体で一貫しているかを担保する。 + // gemini-code-assist のレビューで指摘されたモック内一貫性のリグレッション防止。 + // + // Pin the mock's soft-delete contract across every read path so tests that + // exercise deletion paths get consistent results. Regression guard for the + // gemini-code-assist review feedback. + const alive = await repo.createPage("u1", "Alive"); + const tombstone = await repo.createPage("u1", "Tombstone"); + await repo.deletePage("u1", tombstone.id); + + expect((await repo.getPages("u1")).map((p) => p.id)).toEqual([alive.id]); + expect((await repo.getPagesSummary("u1")).map((p) => p.id)).toEqual([alive.id]); + expect((await repo.getPagesByIds("u1", [alive.id, tombstone.id])).map((p) => p.id)).toEqual([ + alive.id, + ]); + expect(await repo.getPageByTitle("u1", "Tombstone")).toBeNull(); + expect(await repo.checkDuplicateTitle("u1", "Tombstone")).toBeNull(); + }); + + it("searchPages mirrors pageStore semantics: title+content, trim, hide deleted", async () => { + // gemini-code-assist のレビュー対応: 実装側 (pageStore.searchPages) の + // 「title+content の部分一致」「空クエリは []」「論理削除は除外」をモックも遵守する。 + // + // Address gemini-code-assist review: the mock now matches `pageStore.searchPages` + // — title+content match, blank queries return [], soft-deleted pages excluded. + await repo.createPage("u1", "Hello", "alpha body"); + await repo.createPage("u1", "World", "beta body"); + const trashed = await repo.createPage("u1", "Trash", "alpha trashed"); + await repo.deletePage("u1", trashed.id); + + expect((await repo.searchPages("u1", "alpha")).map((p) => p.title)).toEqual(["Hello"]); + expect((await repo.searchPages("u1", "BODY")).map((p) => p.title).sort()).toEqual([ + "Hello", + "World", + ]); + expect(await repo.searchPages("u1", " ")).toEqual([]); + }); + + it("link methods default linkType to 'wiki' (issue #725 Phase 1)", async () => { + await repo.addLink("a", "b"); + await repo.addLink("a", "c", "tag"); + + expect(await repo.getOutgoingLinks("a")).toEqual(["b"]); + expect(await repo.getOutgoingLinks("a", "tag")).toEqual(["c"]); + expect(await repo.getBacklinks("b")).toEqual(["a"]); + + const all = await repo.getLinks("u1"); + expect(all.map((l) => l.linkType).sort()).toEqual(["tag", "wiki"]); + + await repo.removeLink("a", "b"); + expect(await repo.getOutgoingLinks("a")).toEqual([]); + // タグ側は削除されないことを確認 / tag-typed edge survives wiki removal + expect(await repo.getOutgoingLinks("a", "tag")).toEqual(["c"]); + }); + + it("ghost link methods support linkType scoping and source listing", async () => { + await repo.addGhostLink("Topic", "p1"); + await repo.addGhostLink("Topic", "p2"); + await repo.addGhostLink("Topic", "p3", "tag"); + + expect((await repo.getGhostLinkSources("Topic")).sort()).toEqual(["p1", "p2"]); + expect(await repo.getGhostLinkSources("Topic", "tag")).toEqual(["p3"]); + expect(await repo.getGhostLinksBySourcePage("p1")).toEqual(["Topic"]); + + const all = await repo.getGhostLinks("u1"); + expect(all).toHaveLength(3); + + await repo.removeGhostLink("Topic", "p1"); + expect((await repo.getGhostLinkSources("Topic")).sort()).toEqual(["p2"]); + }); + + it("promoteGhostLink only promotes when 2+ wiki ghosts exist", async () => { + expect(await repo.promoteGhostLink("u1", "Solo")).toBeNull(); + + await repo.addGhostLink("Pair", "p1"); + await repo.addGhostLink("Pair", "p2"); + const created = await repo.promoteGhostLink("u1", "Pair"); + expect(created).not.toBeNull(); + expect(created?.title).toBe("Pair"); + + expect(await repo.getGhostLinkSources("Pair")).toEqual([]); + const out1 = await repo.getOutgoingLinks("p1"); + const out2 = await repo.getOutgoingLinks("p2"); + expect(out1).toContain(created?.id); + expect(out2).toContain(created?.id); + }); + + it("delegates each mutation through the same wrapped object (spy contract)", async () => { + const wrapped = createInMemoryRepository(makeStore()); + const spy = { + createPage: vi.spyOn(wrapped, "createPage"), + updatePage: vi.spyOn(wrapped, "updatePage"), + deletePage: vi.spyOn(wrapped, "deletePage"), + addLink: vi.spyOn(wrapped, "addLink"), + addGhostLink: vi.spyOn(wrapped, "addGhostLink"), + }; + + const p = await wrapped.createPage("u1", "T"); + await wrapped.updatePage("u1", p.id, { title: "T2" }); + await wrapped.addLink(p.id, "other"); + await wrapped.addGhostLink("ghost", p.id); + await wrapped.deletePage("u1", p.id); + + expect(spy.createPage).toHaveBeenCalledWith("u1", "T"); + expect(spy.updatePage).toHaveBeenCalledWith("u1", p.id, { title: "T2" }); + expect(spy.addLink).toHaveBeenCalledWith(p.id, "other"); + expect(spy.addGhostLink).toHaveBeenCalledWith("ghost", p.id); + expect(spy.deletePage).toHaveBeenCalledWith("u1", p.id); + }); +}); + +/** + * 実プロダクションの `StorageAdapterPageRepository` が `IPageRepository` を満たし、 + * かつ adapter / API へ正しく委譲することを spy で確認するスモーク層。 + * CodeRabbit のレビュー対応: in-memory モックだけだと実装側の reg を見逃すため、 + * 実装に直接アンカーした薄い契約スイートを置く(網羅検証は + * `pageRepository/StorageAdapterPageRepository.test.ts` 側)。 + * + * Smoke layer that anchors `IPageRepository` contract assertions to the real + * runtime implementation so adapter regressions surface here too. Exhaustive + * delegation tests live alongside the implementation + * (`pageRepository/StorageAdapterPageRepository.test.ts`); this block exists so + * the interface file's test suite catches contract drift in the production class. + */ +function createMockAdapter(): StorageAdapter { + return { + getAllPages: vi.fn().mockResolvedValue([]), + getPage: vi.fn().mockResolvedValue(null), + upsertPage: vi.fn().mockResolvedValue(undefined), + deletePage: vi.fn().mockResolvedValue(undefined), + getYDocState: vi.fn().mockResolvedValue(null), + saveYDocState: vi.fn().mockResolvedValue(undefined), + getYDocVersion: vi.fn().mockResolvedValue(0), + getLinks: vi.fn().mockResolvedValue([]), + getBacklinks: vi.fn().mockResolvedValue([]), + saveLinks: vi.fn().mockResolvedValue(undefined), + getGhostLinks: vi.fn().mockResolvedValue([]), + saveGhostLinks: vi.fn().mockResolvedValue(undefined), + searchPages: vi.fn().mockResolvedValue([]), + updateSearchIndex: vi.fn().mockResolvedValue(undefined), + getLastSyncTime: vi.fn().mockResolvedValue(0), + setLastSyncTime: vi.fn().mockResolvedValue(undefined), + initialize: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + resetDatabase: vi.fn().mockResolvedValue(undefined), + }; +} + +function createMockApi(): Partial { + return { + deletePage: vi.fn(), + createPage: vi.fn(), + }; +} + +describe("StorageAdapterPageRepository (production) satisfies IPageRepository", () => { + let adapter: ReturnType; + let api: Partial; + let repo: IPageRepository; + + beforeEach(() => { + adapter = createMockAdapter(); + api = createMockApi(); + // 型レベルで `IPageRepository` を満たすかをここで強制する。 + // Compile-time assertion that the production class satisfies the interface. + repo = new StorageAdapterPageRepository(adapter, api as ApiClient); + }); + + it("delegates createPage (guest) to adapter.upsertPage and skips api.createPage", async () => { + await repo.createPage("local-user", "Hello"); + expect(adapter.upsertPage).toHaveBeenCalledOnce(); + // CodeRabbit のレビュー対応: ゲスト経路で API が呼ばれない不変条件をガード。 + // Guard the guest-path invariant: API must not be invoked for `local-user`. + expect(api.createPage).not.toHaveBeenCalled(); + }); + + it("delegates getPage to adapter.getPage", async () => { + await repo.getPage("local-user", "p1"); + expect(adapter.getPage).toHaveBeenCalledWith("p1"); + }); + + it("delegates addLink to adapter.saveLinks (defaulting linkType to 'wiki')", async () => { + await repo.addLink("a", "b"); + expect(adapter.getLinks).toHaveBeenCalledWith("a", "wiki"); + expect(adapter.saveLinks).toHaveBeenCalledOnce(); + const call = (adapter.saveLinks as ReturnType).mock.calls[0]; + expect(call[2]).toBe("wiki"); + }); + + it("delegates deletePage (auth) to adapter.deletePage and api.deletePage", async () => { + await repo.deletePage("auth-user", "p1"); + expect(adapter.deletePage).toHaveBeenCalledWith("p1"); + expect(api.deletePage).toHaveBeenCalledWith("p1"); + }); +}); + +describe("PageRepositoryOptions.onMutate", () => { + it("fires after every mutating method (CRUD + link/ghost link paths)", async () => { + // CodeRabbit のレビュー対応: onMutate は CRUD だけでなく link/ghost link + // 系の mutation でも発火する契約。書き込み経路全体でリグレッションを検出できる。 + // + // Address CodeRabbit feedback: `onMutate` is fired on every mutating path + // (page CRUD + link / ghost link writes), so a regression on any of them + // is caught here. + const onMutate = vi.fn(); + const r = createInMemoryRepository(makeStore(), { onMutate }); + + const page = await r.createPage("u1", "T"); + await r.updatePage("u1", page.id, { title: "T2" }); + await r.addLink(page.id, "target"); + await r.removeLink(page.id, "target"); + await r.addGhostLink("Ghost", page.id); + await r.removeGhostLink("Ghost", page.id); + await r.deletePage("u1", page.id); + + // 7 mutations (create / update / addLink / removeLink / addGhost / removeGhost / delete) + expect(onMutate).toHaveBeenCalledTimes(7); + }); + + it("is optional: implementations may skip the callback", async () => { + const r = createInMemoryRepository(makeStore()); + await expect(r.createPage("u1", "T")).resolves.toBeDefined(); + }); +}); diff --git a/src/lib/pageRepository.ts b/src/lib/pageRepository.ts index 721dbd44..97d4ac6d 100644 --- a/src/lib/pageRepository.ts +++ b/src/lib/pageRepository.ts @@ -1,4 +1,4 @@ -import type { Page, PageSummary, Link, GhostLink } from "@/types/page"; +import type { Page, PageSummary, Link, GhostLink, LinkType } from "@/types/page"; /** * C3-7: Common interface for page repository implementations. @@ -10,6 +10,16 @@ export interface CreatePageOptions { thumbnailUrl?: string | null; } +/** + * Repository interface for pages + their link graph. + * + * Link-related methods accept an optional `linkType` (`'wiki'` | `'tag'`) + * introduced in issue #725 Phase 1. Legacy call sites may omit it; the + * implementation defaults to `'wiki'` for backward compatibility. + * + * ページとリンクグラフのリポジトリ。リンク系メソッドの `linkType` は + * issue #725 Phase 1 で追加。省略時は `'wiki'` にフォールバック。 + */ export interface IPageRepository { createPage( userId: string, @@ -30,17 +40,17 @@ export interface IPageRepository { ): Promise; deletePage(userId: string, pageId: string): Promise; searchPages(userId: string, query: string): Promise; - addLink(sourceId: string, targetId: string): Promise; - removeLink(sourceId: string, targetId: string): Promise; - getOutgoingLinks(pageId: string): Promise; - getBacklinks(pageId: string): Promise; + addLink(sourceId: string, targetId: string, linkType?: LinkType): Promise; + removeLink(sourceId: string, targetId: string, linkType?: LinkType): Promise; + getOutgoingLinks(pageId: string, linkType?: LinkType): Promise; + getBacklinks(pageId: string, linkType?: LinkType): Promise; getLinks(userId: string): Promise; - addGhostLink(linkText: string, sourcePageId: string): Promise; - removeGhostLink(linkText: string, sourcePageId: string): Promise; - getGhostLinkSources(linkText: string): Promise; + addGhostLink(linkText: string, sourcePageId: string, linkType?: LinkType): Promise; + removeGhostLink(linkText: string, sourcePageId: string, linkType?: LinkType): Promise; + getGhostLinkSources(linkText: string, linkType?: LinkType): Promise; getGhostLinks(userId: string): Promise; /** Link texts (titles) for ghost links from a single source page. Used for delta sync. */ - getGhostLinksBySourcePage(sourcePageId: string): Promise; + getGhostLinksBySourcePage(sourcePageId: string, linkType?: LinkType): Promise; promoteGhostLink(userId: string, linkText: string): Promise; } diff --git a/src/lib/pageRepository/StorageAdapterPageRepository.test.ts b/src/lib/pageRepository/StorageAdapterPageRepository.test.ts index 154bdbe3..68395480 100644 --- a/src/lib/pageRepository/StorageAdapterPageRepository.test.ts +++ b/src/lib/pageRepository/StorageAdapterPageRepository.test.ts @@ -225,21 +225,62 @@ describe("StorageAdapterPageRepository", () => { }); describe("addLink / removeLink", () => { - it("adds a link via adapter", async () => { + it("adds a link via adapter (defaults linkType to 'wiki')", async () => { (adapter.getLinks as ReturnType).mockResolvedValue([]); await repo.addLink("source-1", "target-1"); expect(adapter.saveLinks).toHaveBeenCalledOnce(); - const saved = (adapter.saveLinks as ReturnType).mock.calls[0][1] as Link[]; + const call = (adapter.saveLinks as ReturnType).mock.calls[0]; + const saved = call[1] as Link[]; + const linkType = call[2] as string; expect(saved).toHaveLength(1); expect(saved[0].sourceId).toBe("source-1"); expect(saved[0].targetId).toBe("target-1"); + expect(saved[0].linkType).toBe("wiki"); + expect(linkType).toBe("wiki"); + }); + + it("adds a tag edge when linkType='tag' is passed (issue #725 Phase 1)", async () => { + (adapter.getLinks as ReturnType).mockResolvedValue([]); + + await repo.addLink("source-1", "target-1", "tag"); + + expect(adapter.getLinks).toHaveBeenCalledWith("source-1", "tag"); + const call = (adapter.saveLinks as ReturnType).mock.calls[0]; + const saved = call[1] as Link[]; + const linkType = call[2] as string; + expect(saved[0].linkType).toBe("tag"); + expect(linkType).toBe("tag"); + }); + + it("addLink(wiki) does not wipe an existing tag edge on the same pair", async () => { + // adapter.getLinks("source-1", "wiki") returns only the wiki row; the tag + // row is in a separate linkType bucket so it is never read or written by + // this call. 一方で tag バケットは触れない。 + (adapter.getLinks as ReturnType).mockImplementation((_src, type) => { + if (type === "wiki") + return Promise.resolve([ + { sourceId: "s", targetId: "t", linkType: "wiki", createdAt: 1 }, + ]); + return Promise.resolve([]); + }); + + await repo.addLink("s", "t", "wiki"); + + // saveLinks called only for 'wiki' bucket (idempotent since row exists), + // or not called if duplicate detection kicks in. Either way 'tag' bucket + // is never touched. getLinks for 'tag' must not have been called from + // this addLink. + const tagCalls = (adapter.getLinks as ReturnType).mock.calls.filter( + (c: unknown[]) => c[1] === "tag", + ); + expect(tagCalls).toHaveLength(0); }); it("does not add duplicate link", async () => { (adapter.getLinks as ReturnType).mockResolvedValue([ - { sourceId: "source-1", targetId: "target-1", createdAt: 1000 }, + { sourceId: "source-1", targetId: "target-1", linkType: "wiki", createdAt: 1000 }, ]); await repo.addLink("source-1", "target-1"); @@ -248,8 +289,8 @@ describe("StorageAdapterPageRepository", () => { it("removes a link via adapter", async () => { (adapter.getLinks as ReturnType).mockResolvedValue([ - { sourceId: "s1", targetId: "t1", createdAt: 1000 }, - { sourceId: "s1", targetId: "t2", createdAt: 2000 }, + { sourceId: "s1", targetId: "t1", linkType: "wiki", createdAt: 1000 }, + { sourceId: "s1", targetId: "t2", linkType: "wiki", createdAt: 2000 }, ]); await repo.removeLink("s1", "t1"); diff --git a/src/lib/pageRepository/StorageAdapterPageRepository.ts b/src/lib/pageRepository/StorageAdapterPageRepository.ts index 44fc9839..c533e7f7 100644 --- a/src/lib/pageRepository/StorageAdapterPageRepository.ts +++ b/src/lib/pageRepository/StorageAdapterPageRepository.ts @@ -8,7 +8,7 @@ import type { StorageAdapter } from "@/lib/storageAdapter/StorageAdapter"; import type { PageMetadata } from "@/lib/storageAdapter/types"; import type { ApiClient } from "@/lib/api/apiClient"; import type { SyncPageItem } from "@/lib/api/types"; -import type { Page, PageSummary, Link, GhostLink } from "@/types/page"; +import type { Page, PageSummary, Link, GhostLink, LinkType } from "@/types/page"; import type { CreatePageOptions } from "@/lib/pageRepository"; import { getPageListPreview, extractPlainText } from "@/lib/contentUtils"; @@ -304,33 +304,41 @@ export class StorageAdapterPageRepository { /** * 2 ページ間のリンクを追加する(重複追加はスキップ)。 - * Add a link between two pages; duplicate inserts are a no-op. + * `linkType` 省略時は `'wiki'`(issue #725 Phase 1)。 + * + * Add a link between two pages; duplicate inserts are a no-op. Defaults + * `linkType` to `'wiki'` for legacy call sites. */ - async addLink(sourceId: string, targetId: string): Promise { - const links = await this.adapter.getLinks(sourceId); + async addLink(sourceId: string, targetId: string, linkType: LinkType = "wiki"): Promise { + const links = await this.adapter.getLinks(sourceId, linkType); const now = Date.now(); if (links.some((l) => l.targetId === targetId)) return; - await this.adapter.saveLinks(sourceId, [...links, { sourceId, targetId, createdAt: now }]); + await this.adapter.saveLinks( + sourceId, + [...links, { sourceId, targetId, linkType, createdAt: now }], + linkType, + ); } /** * 2 ページ間のリンクを削除する。 - * Remove a link between two pages. + * Remove a link between two pages. `linkType` scopes the removal. */ - async removeLink(sourceId: string, targetId: string): Promise { - const links = await this.adapter.getLinks(sourceId); + async removeLink(sourceId: string, targetId: string, linkType: LinkType = "wiki"): Promise { + const links = await this.adapter.getLinks(sourceId, linkType); await this.adapter.saveLinks( sourceId, links.filter((l) => l.targetId !== targetId), + linkType, ); } /** * 指定ページから出ているリンクの target ID 一覧を返す。 - * Return target IDs of outgoing links for a page. + * Return target IDs of outgoing links for a page. `linkType` scopes results. */ - async getOutgoingLinks(pageId: string): Promise { - const links = await this.adapter.getLinks(pageId); + async getOutgoingLinks(pageId: string, linkType: LinkType = "wiki"): Promise { + const links = await this.adapter.getLinks(pageId, linkType); return links.map((l) => l.targetId); } @@ -338,14 +346,14 @@ export class StorageAdapterPageRepository { * 指定ページへの被リンク(バックリンク)の source ID 一覧を返す。 * Return source IDs of backlinks pointing at the page. */ - async getBacklinks(pageId: string): Promise { - const links = await this.adapter.getBacklinks(pageId); + async getBacklinks(pageId: string, linkType: LinkType = "wiki"): Promise { + const links = await this.adapter.getBacklinks(pageId, linkType); return links.map((l) => l.sourceId); } /** - * ユーザー配下の全ページに対する全リンクを集めて返す。 - * Collect every link across all pages owned by the user. + * ユーザー配下の全ページに対する全リンクを集めて返す(全種別)。 + * Collect every link across all pages owned by the user (all link types). */ async getLinks(_userId: string): Promise { const pages = await this.adapter.getAllPages(); @@ -358,28 +366,38 @@ export class StorageAdapterPageRepository { } /** - * ゴーストリンク(未解決 WikiLink)を追加する。重複は無視。 - * Add a ghost link (unresolved WikiLink); duplicates are ignored. + * ゴーストリンク(未解決 WikiLink / タグ)を追加する。重複は無視。 + * Add a ghost link (unresolved WikiLink or tag); duplicates are ignored. */ - async addGhostLink(linkText: string, sourcePageId: string): Promise { - const ghosts = await this.adapter.getGhostLinks(sourcePageId); + async addGhostLink( + linkText: string, + sourcePageId: string, + linkType: LinkType = "wiki", + ): Promise { + const ghosts = await this.adapter.getGhostLinks(sourcePageId, linkType); const now = Date.now(); if (ghosts.some((g) => g.linkText === linkText)) return; - await this.adapter.saveGhostLinks(sourcePageId, [ - ...ghosts, - { linkText, sourcePageId, createdAt: now }, - ]); + await this.adapter.saveGhostLinks( + sourcePageId, + [...ghosts, { linkText, sourcePageId, linkType, createdAt: now }], + linkType, + ); } /** * ゴーストリンクを削除する。 - * Remove a ghost link from a source page. + * Remove a ghost link from a source page, scoped by `linkType`. */ - async removeGhostLink(linkText: string, sourcePageId: string): Promise { - const ghosts = await this.adapter.getGhostLinks(sourcePageId); + async removeGhostLink( + linkText: string, + sourcePageId: string, + linkType: LinkType = "wiki", + ): Promise { + const ghosts = await this.adapter.getGhostLinks(sourcePageId, linkType); await this.adapter.saveGhostLinks( sourcePageId, ghosts.filter((g) => g.linkText !== linkText), + linkType, ); } @@ -387,32 +405,26 @@ export class StorageAdapterPageRepository { * 指定リンクテキストのゴーストを持つページ ID 一覧を返す。 * Return IDs of pages that carry a ghost link for the given text. */ - async getGhostLinkSources(linkText: string): Promise { + async getGhostLinkSources(linkText: string, linkType: LinkType = "wiki"): Promise { const pages = await this.adapter.getAllPages(); const sources: string[] = []; for (const p of pages) { - const ghosts = await this.adapter.getGhostLinks(p.id); + const ghosts = await this.adapter.getGhostLinks(p.id, linkType); if (ghosts.some((g) => g.linkText === linkText)) sources.push(p.id); } return sources; } /** - * ユーザー配下の全ページについてゴーストリンクを集めて返す。 - * Aggregate every ghost link across all pages owned by the user. + * ユーザー配下の全ページについて全種別のゴーストリンクを集めて返す。 + * Aggregate every ghost link (all link types) across pages owned by the user. */ async getGhostLinks(_userId: string): Promise { const pages = await this.adapter.getAllPages(); const all: GhostLink[] = []; for (const p of pages) { const ghosts = await this.adapter.getGhostLinks(p.id); - all.push( - ...ghosts.map((g) => ({ - linkText: g.linkText, - sourcePageId: g.sourcePageId, - createdAt: g.createdAt, - })), - ); + all.push(...ghosts); } return all; } @@ -421,25 +433,30 @@ export class StorageAdapterPageRepository { * 単一ソースページに属するゴーストリンクのリンクテキスト一覧を返す(差分同期用)。 * Return ghost-link texts for a single source page (used by delta sync). */ - async getGhostLinksBySourcePage(sourcePageId: string): Promise { - const ghosts = await this.adapter.getGhostLinks(sourcePageId); + async getGhostLinksBySourcePage( + sourcePageId: string, + linkType: LinkType = "wiki", + ): Promise { + const ghosts = await this.adapter.getGhostLinks(sourcePageId, linkType); return ghosts.map((g) => g.linkText); } /** * 2 箇所以上から参照されているゴーストリンクを、実在ページとして昇格させる。 - * 新規ページを作成し、各ソースからのリンクへ置き換える。 + * 新規ページを作成し、各ソースからのリンクへ置き換える。WikiLink 種別限定。 * - * Promote a ghost link referenced by two or more source pages into a real - * page, rewiring each source to link into the new page. + * Promote a WikiLink ghost link referenced by two or more source pages into + * a real page, rewiring each source to link into the new page. Tag ghosts + * stay as-is (tag promotion is handled via normal tag sync, not multi-source + * promotion). */ async promoteGhostLink(userId: string, linkText: string): Promise { - const sources = await this.getGhostLinkSources(linkText); + const sources = await this.getGhostLinkSources(linkText, "wiki"); if (sources.length < 2) return null; const newPage = await this.createPage(userId, linkText, ""); for (const sourceId of sources) { - await this.addLink(sourceId, newPage.id); - await this.removeGhostLink(linkText, sourceId); + await this.addLink(sourceId, newPage.id, "wiki"); + await this.removeGhostLink(linkText, sourceId, "wiki"); } return newPage; } diff --git a/src/lib/parseStdioArgs.test.ts b/src/lib/parseStdioArgs.test.ts index 5ace859e..f0783938 100644 --- a/src/lib/parseStdioArgs.test.ts +++ b/src/lib/parseStdioArgs.test.ts @@ -2,26 +2,120 @@ import { describe, expect, it } from "vitest"; import { parseStdioArgsLine } from "./parseStdioArgs"; describe("parseStdioArgsLine", () => { - it("splits on whitespace", () => { - expect(parseStdioArgsLine("a b c")).toEqual(["a", "b", "c"]); - }); + describe("basic splitting", () => { + it("splits on whitespace", () => { + expect(parseStdioArgsLine("a b c")).toEqual(["a", "b", "c"]); + }); + + it("collapses consecutive whitespace into a single split (no empty tokens)", () => { + // 連続する空白は 1 つの境界として扱われ、空トークンは生まれない。 + // Pin that runs of whitespace produce no empty tokens — kills mutations + // that flip the regex `+` to `*`. + expect(parseStdioArgsLine("a b\t c")).toEqual(["a", "b", "c"]); + }); + + it("returns an empty array for an empty string", () => { + // `if (!trimmed) return []` の早期 return を検証する。 + // Pin the early-return on empty input. + expect(parseStdioArgsLine("")).toEqual([]); + }); + + it("returns an empty array for whitespace-only input", () => { + // `args.trim()` が効くこと。 + // Pin the trim() guard so a removal mutation surfaces here. + expect(parseStdioArgsLine(" \t\n ")).toEqual([]); + }); - it("preserves quoted segments with spaces", () => { - expect(parseStdioArgsLine('--path "C:\\Program Files\\foo"')).toEqual([ - "--path", - "C:\\Program Files\\foo", - ]); + it("trims leading and trailing whitespace before tokenizing", () => { + // 前後の空白を剥がしてからトークナイズする。 + // Pin trim() vs returning leading/trailing empty tokens. + expect(parseStdioArgsLine(" a b ")).toEqual(["a", "b"]); + }); }); - it("handles single-quoted segments", () => { - expect(parseStdioArgsLine("--x '/my path/file'")).toEqual(["--x", "/my path/file"]); + describe("double-quoted segments", () => { + it("preserves quoted segments with spaces (Windows path)", () => { + expect(parseStdioArgsLine('--path "C:\\Program Files\\foo"')).toEqual([ + "--path", + "C:\\Program Files\\foo", + ]); + }); + + it("strips outer double quotes from a single token", () => { + expect(parseStdioArgsLine('"one two" three')).toEqual(["one two", "three"]); + }); + + it("unescapes backslash-escaped double quotes inside the segment", () => { + // `\"` → `"`。`replace(/\\"/g, '"')` のロジックを直接的に検証する。 + // Pin the inner-quote unescape; without this test the replace can be deleted + // and the surviving mutant goes undetected. + // 入力: "say \"hi\" please" → say "hi" please + const input = String.raw`"say \"hi\" please"`; + expect(parseStdioArgsLine(input)).toEqual([`say "hi" please`]); + }); + + it("unescapes backslash-escaped backslashes inside the segment", () => { + // `\\` → `\`。`replace(/\\\\/g, "\\")` を検証する。 + // Pin the backslash-unescape; the replace order matters so a swap breaks this. + // 入力: "a\\b" → a\b + const input = String.raw`"a\\b"`; + expect(parseStdioArgsLine(input)).toEqual([String.raw`a\b`]); + }); + + it("treats an empty quoted segment as the empty string token", () => { + // 空文字 "" は長さ 2 (`""`) なので length >= 2 を満たし、空文字に解釈される。 + // Pin that empty `""` produces an empty string token (length-2 boundary). + expect(parseStdioArgsLine('""')).toEqual([""]); + }); + + it("preserves single quotes inside a double-quoted segment", () => { + // 二重引用内の単一引用はそのまま残る(unescape 対象は \" と \\ のみ)。 + // Kills mutations to the unescape regex that would remove single quotes. + expect(parseStdioArgsLine(`"it's fine"`)).toEqual(["it's fine"]); + }); }); - it("returns empty array for blank", () => { - expect(parseStdioArgsLine(" ")).toEqual([]); + describe("single-quoted segments", () => { + it("handles single-quoted segments with embedded spaces", () => { + expect(parseStdioArgsLine("--x '/my path/file'")).toEqual(["--x", "/my path/file"]); + }); + + it("does NOT process backslash escapes inside single quotes (literal preserved)", () => { + // 単一引用内ではバックスラッシュエスケープを解釈しない(literal)。 + // Pin the asymmetry between single and double quotes; without this test a + // mutation that adds replace() to the single-quote branch survives. + const input = String.raw`'a\nb'`; + expect(parseStdioArgsLine(input)).toEqual([String.raw`a\nb`]); + }); + + it("treats an empty single-quoted segment as the empty string token", () => { + expect(parseStdioArgsLine("''")).toEqual([""]); + }); + + it("preserves double quotes inside a single-quoted segment", () => { + expect(parseStdioArgsLine(`'say "hi"'`)).toEqual([`say "hi"`]); + }); }); - it("strips outer quotes from double-quoted token", () => { - expect(parseStdioArgsLine('"one two" three')).toEqual(["one two", "three"]); + describe("mixed and adjacent segments", () => { + it("returns multi-token strings in input order", () => { + // 並び順を厳密に検証する(map/filter の順序逆転変異を殺す)。 + // Pin token order so a `.reverse()` mutation surfaces here. + expect(parseStdioArgsLine("--a one --b two")).toEqual(["--a", "one", "--b", "two"]); + }); + + it("concatenates adjacent quoted/unquoted parts into a single raw token (regex `+`)", () => { + // 正規表現末尾の `+` により隣接した引用・非引用が 1 トークンに結合される。 + // 結合後トークンは `"` で始まらないため slice/unescape 経路を通らず、引用記号がそのまま残る。 + // Pin the concat semantic: a single token comes back, and because it + // doesn't begin with `"`, the slice/unescape branch is bypassed (quotes preserved). + expect(parseStdioArgsLine(`pre"mid"post`)).toEqual([`pre"mid"post`]); + }); + + it("returns an unquoted token verbatim (no slice/no replace)", () => { + // 非引用トークンには slice/replace を適用しない経路を検証する。 + // Pin the third branch (return t verbatim) so removing it shifts to slicing. + expect(parseStdioArgsLine("plain")).toEqual(["plain"]); + }); }); }); diff --git a/src/lib/storage/index.test.ts b/src/lib/storage/index.test.ts new file mode 100644 index 00000000..a7a067e5 --- /dev/null +++ b/src/lib/storage/index.test.ts @@ -0,0 +1,251 @@ +import { describe, it, expect } from "vitest"; +import { + getStorageProvider, + isProviderConfigured, + getSettingsForUpload, + isStorageConfiguredForUpload, + type StorageProviderContext, +} from "./index"; +import { GyazoProvider } from "./providers/GyazoProvider"; +import { GitHubProvider } from "./providers/GitHubProvider"; +import { GoogleDriveProvider } from "./providers/GoogleDriveProvider"; +import { S3Provider } from "./providers/S3Provider"; +import type { StorageSettings, StorageProviderConfig, StorageProviderType } from "@/types/storage"; + +const ctx: StorageProviderContext = { + getToken: async () => "fake-token", + baseUrl: "https://api.example.com", +}; + +function settings( + provider: StorageProviderType | "cloudflare-r2", + config: StorageProviderConfig = {}, + overrides: Partial = {}, +): StorageSettings { + return { + provider: provider as StorageProviderType, + config, + isConfigured: true, + preferDefaultStorage: false, + ...overrides, + }; +} + +describe("getStorageProvider factory", () => { + describe("s3 (default storage)", () => { + it("returns an S3Provider when context.getToken is supplied", () => { + const provider = getStorageProvider(settings("s3"), ctx); + expect(provider).toBeInstanceOf(S3Provider); + }); + + it("throws when context is missing", () => { + expect(() => getStorageProvider(settings("s3"))).toThrow(/getToken が必要です/); + }); + + it("throws when context.getToken is missing", () => { + expect(() => + getStorageProvider(settings("s3"), { + // 型を維持するため明示的に未定義を渡す + // explicitly pass undefined to keep the type contract + getToken: undefined as unknown as StorageProviderContext["getToken"], + }), + ).toThrow(/getToken が必要です/); + }); + + it("treats legacy 'cloudflare-r2' as 's3' (mutation: legacy migration branch)", () => { + const provider = getStorageProvider(settings("cloudflare-r2"), ctx); + expect(provider).toBeInstanceOf(S3Provider); + }); + }); + + describe("gyazo", () => { + it("returns a GyazoProvider when token is configured", () => { + const provider = getStorageProvider(settings("gyazo", { gyazoAccessToken: "tok" })); + expect(provider).toBeInstanceOf(GyazoProvider); + }); + + it("throws when gyazoAccessToken is missing", () => { + expect(() => getStorageProvider(settings("gyazo", {}))).toThrow( + /Gyazo Access Token が設定されていません/, + ); + }); + }); + + describe("github", () => { + it("returns a GitHubProvider when repository + token are present", () => { + const provider = getStorageProvider( + settings("github", { + githubRepository: "owner/repo", + githubToken: "tok", + githubBranch: "main", + githubPath: "images", + }), + ); + expect(provider).toBeInstanceOf(GitHubProvider); + }); + + it("throws when repository is missing", () => { + expect(() => getStorageProvider(settings("github", { githubToken: "tok" }))).toThrow( + /GitHub の設定が不完全です/, + ); + }); + + it("throws when token is missing", () => { + expect(() => + getStorageProvider(settings("github", { githubRepository: "owner/repo" })), + ).toThrow(/GitHub の設定が不完全です/); + }); + }); + + describe("google-drive", () => { + it("returns a GoogleDriveProvider when clientId + accessToken are set", () => { + const provider = getStorageProvider( + settings("google-drive", { + googleDriveClientId: "id", + googleDriveClientSecret: "secret", + googleDriveAccessToken: "access", + googleDriveRefreshToken: "refresh", + googleDriveFolderId: "folder", + }), + ); + expect(provider).toBeInstanceOf(GoogleDriveProvider); + }); + + it("falls back to empty strings when optional clientSecret/refreshToken are missing", () => { + const provider = getStorageProvider( + settings("google-drive", { + googleDriveClientId: "id", + googleDriveAccessToken: "access", + }), + ); + expect(provider).toBeInstanceOf(GoogleDriveProvider); + }); + + it("throws when clientId is missing", () => { + expect(() => + getStorageProvider(settings("google-drive", { googleDriveAccessToken: "access" })), + ).toThrow(/Google Drive の設定が不完全です/); + }); + + it("throws when accessToken is missing", () => { + expect(() => + getStorageProvider(settings("google-drive", { googleDriveClientId: "id" })), + ).toThrow(/Google Drive の設定が不完全です/); + }); + }); + + it("throws on an unknown provider", () => { + // any-cast で未知 provider をシミュレート / cast to simulate an unknown provider + expect(() => + getStorageProvider(settings("totally-unknown" as unknown as StorageProviderType)), + ).toThrow(/Unknown storage provider/); + }); +}); + +describe("isProviderConfigured", () => { + it.each([ + ["gyazo", { gyazoAccessToken: "tok" }, true], + ["gyazo", {}, false], + ["github", { githubRepository: "o/r", githubToken: "t" }, true], + ["github", { githubRepository: "o/r" }, false], + ["github", { githubToken: "t" }, false], + ["google-drive", { googleDriveClientId: "id", googleDriveAccessToken: "ac" }, true], + ["google-drive", { googleDriveClientId: "id" }, false], + ["google-drive", { googleDriveAccessToken: "ac" }, false], + ["s3", {}, true], + ] as const)("(%s, %o) → %s", (provider, config, expected) => { + expect(isProviderConfigured(provider as StorageProviderType, config)).toBe(expected); + }); + + it("returns false for an unknown provider", () => { + expect(isProviderConfigured("nope" as unknown as StorageProviderType, {})).toBe(false); + }); +}); + +describe("getSettingsForUpload", () => { + it("returns s3 defaults when preferDefaultStorage is undefined (default branch)", () => { + const out = getSettingsForUpload({ + provider: "gyazo", + config: { gyazoAccessToken: "tok" }, + isConfigured: true, + }); + expect(out).toEqual({ + provider: "s3", + config: {}, + isConfigured: true, + }); + }); + + it("returns s3 defaults when preferDefaultStorage is true", () => { + const out = getSettingsForUpload({ + provider: "github", + config: { githubRepository: "o/r", githubToken: "t" }, + isConfigured: true, + preferDefaultStorage: true, + }); + expect(out.provider).toBe("s3"); + expect(out.config).toEqual({}); + }); + + it("preserves the original settings when preferDefaultStorage is false", () => { + const original: StorageSettings = { + provider: "gyazo", + config: { gyazoAccessToken: "tok" }, + isConfigured: true, + preferDefaultStorage: false, + }; + expect(getSettingsForUpload(original)).toBe(original); + }); +}); + +describe("isStorageConfiguredForUpload", () => { + it("returns true when default storage is preferred", () => { + expect( + isStorageConfiguredForUpload({ + provider: "s3", + config: {}, + isConfigured: true, + }), + ).toBe(true); + + expect( + isStorageConfiguredForUpload({ + provider: "gyazo", + config: {}, + isConfigured: true, + preferDefaultStorage: true, + }), + ).toBe(true); + }); + + it("returns false for s3 when external storage is preferred (s3 cannot be 'external')", () => { + expect( + isStorageConfiguredForUpload({ + provider: "s3", + config: {}, + isConfigured: true, + preferDefaultStorage: false, + }), + ).toBe(false); + }); + + it("returns true for an external provider only when its config is complete", () => { + expect( + isStorageConfiguredForUpload({ + provider: "gyazo", + config: { gyazoAccessToken: "tok" }, + isConfigured: true, + preferDefaultStorage: false, + }), + ).toBe(true); + + expect( + isStorageConfiguredForUpload({ + provider: "gyazo", + config: {}, + isConfigured: true, + preferDefaultStorage: false, + }), + ).toBe(false); + }); +}); diff --git a/src/lib/storageAdapter/IndexedDBStorageAdapter.ts b/src/lib/storageAdapter/IndexedDBStorageAdapter.ts index 241e1831..6e3a06a1 100644 --- a/src/lib/storageAdapter/IndexedDBStorageAdapter.ts +++ b/src/lib/storageAdapter/IndexedDBStorageAdapter.ts @@ -6,18 +6,25 @@ import * as Y from "yjs"; import { IndexeddbPersistence } from "y-indexeddb"; import type { StorageAdapter } from "./StorageAdapter"; -import type { PageMetadata, Link, GhostLink, SearchResult } from "./types"; +import type { PageMetadata, Link, GhostLink, LinkType, SearchResult } from "./types"; const DB_NAME_PREFIX = "zedi-storage-"; // v2: add `noteId` column on `my_pages` to mirror Aurora `pages.note_id` // (issue #713). v1 rows are upgraded in place by `onupgradeneeded` — // existing rows are personal pages, so `noteId = null` is the correct // backfill value. +// v3: add `linkType` to `my_links` / `my_ghost_links` (issue #725 Phase 1). +// Key paths change to include `linkType` so the same (source, target) +// pair can hold independent wiki and tag edges. Existing rows are +// backfilled to `linkType: 'wiki'` — see `migrateLinksToV3`. // // v2: `pages.note_id` (issue #713) を反映するため `my_pages` に `noteId` 列を // 追加。v1 の既存行は全て個人ページなので、`onupgradeneeded` で `noteId = null` // を埋めれば移行完了。 -const DB_VERSION = 2; +// v3: `my_links` / `my_ghost_links` に `linkType` を追加(issue #725 Phase 1)。 +// 同一 (source, target) ペアでも WikiLink エッジとタグエッジを独立に保持できる +// よう、キーパスに `linkType` を含める。既存行は `linkType: 'wiki'` で埋め直す。 +const DB_VERSION = 3; const YDOC_NAME_PREFIX = "zedi-doc-"; /** Stored page row (camelCase for consistency with PageMetadata). */ @@ -42,17 +49,19 @@ interface StoredPage { isDeleted: boolean; } -/** Stored link row. */ +/** Stored link row. `linkType` added in v3 (issue #725 Phase 1). */ interface StoredLink { sourceId: string; targetId: string; + linkType: LinkType; createdAt: number; } -/** Stored ghost link row. */ +/** Stored ghost link row. `linkType` added in v3 (issue #725 Phase 1). */ interface StoredGhostLink { linkText: string; sourcePageId: string; + linkType: LinkType; createdAt: number; originalTargetPageId?: string | null; originalNoteId?: string | null; @@ -108,15 +117,19 @@ function openDb(userId: string): Promise { pages.createIndex("created_at", "createdAt", { unique: false }); } if (!db.objectStoreNames.contains("my_links")) { - const links = db.createObjectStore("my_links", { keyPath: ["sourceId", "targetId"] }); + const links = db.createObjectStore("my_links", { + keyPath: ["sourceId", "targetId", "linkType"], + }); links.createIndex("by_source", "sourceId", { unique: false }); links.createIndex("by_target", "targetId", { unique: false }); + links.createIndex("by_source_type", ["sourceId", "linkType"], { unique: false }); } if (!db.objectStoreNames.contains("my_ghost_links")) { const ghost = db.createObjectStore("my_ghost_links", { - keyPath: ["linkText", "sourcePageId"], + keyPath: ["linkText", "sourcePageId", "linkType"], }); ghost.createIndex("by_source", "sourcePageId", { unique: false }); + ghost.createIndex("by_source_type", ["sourcePageId", "linkType"], { unique: false }); } if (!db.objectStoreNames.contains("search_index")) { db.createObjectStore("search_index", { keyPath: "pageId" }); @@ -150,10 +163,108 @@ function openDb(userId: string): Promise { cursor.continue(); }; } + + // v3 (issue #725 Phase 1): recreate `my_links` / `my_ghost_links` with a + // wider key path (`linkType` appended) and backfill existing rows with + // `linkType: 'wiki'`. IndexedDB cannot mutate an existing keyPath, so we + // read, delete, recreate, and re-insert. + // + // v3 (issue #725 Phase 1): `my_links` / `my_ghost_links` のキーパスに + // `linkType` を足す必要があるが、IndexedDB はキーパスを変えられないので、 + // 一旦読み出し → ストア再作成 → `linkType: 'wiki'` で書き戻す。 + if (event.oldVersion < 3 && tx) { + migrateLinkStoreToV3(db, tx, "my_links", ["sourceId", "targetId"], (row) => ({ + store: "my_links", + keyPath: ["sourceId", "targetId", "linkType"], + indexes: [ + { name: "by_source", keyPath: "sourceId" }, + { name: "by_target", keyPath: "targetId" }, + { name: "by_source_type", keyPath: ["sourceId", "linkType"] }, + ], + value: { ...row, linkType: "wiki" as LinkType }, + })); + migrateLinkStoreToV3(db, tx, "my_ghost_links", ["linkText", "sourcePageId"], (row) => ({ + store: "my_ghost_links", + keyPath: ["linkText", "sourcePageId", "linkType"], + indexes: [ + { name: "by_source", keyPath: "sourcePageId" }, + { name: "by_source_type", keyPath: ["sourcePageId", "linkType"] }, + ], + value: { ...row, linkType: "wiki" as LinkType }, + })); + } }; }); } +interface V3StoreSpec { + store: string; + keyPath: string[]; + indexes: { name: string; keyPath: string | string[] }[]; + value: TRow; +} + +/** + * Read all rows from `storeName`, drop the store, recreate it with the wider + * v3 key path and backfill-derived rows. Runs inside `onupgradeneeded`'s + * version change transaction so the entire recreation is atomic. + * + * `storeName` の全行を読み出し、ストアを作り直して `mapRow` が返す新 keyPath と + * インデックスで再生成する。version-change transaction 内で完結させることで、 + * 中途半端な状態が残らないようにする。 + */ +function migrateLinkStoreToV3>( + db: IDBDatabase, + tx: IDBTransaction, + storeName: string, + _oldKeyPath: string[], + mapRow: (row: TRow) => V3StoreSpec, +): void { + if (!db.objectStoreNames.contains(storeName)) return; + const oldStore = tx.objectStore(storeName); + const getAllReq = oldStore.getAll(); + getAllReq.onsuccess = () => { + const rows = (getAllReq.result as TRow[]) ?? []; + const firstRow = rows[0]; + const spec = firstRow !== undefined ? mapRow(firstRow) : undefined; + + db.deleteObjectStore(storeName); + const newStore = db.createObjectStore( + storeName, + spec + ? { keyPath: spec.keyPath } + : // Empty store: use hard-coded v3 shape keyed per `storeName`. + // 行が 1 件も無い場合は mapRow を呼べないため、ストア名から v3 の + // keyPath を決め打ちで作る。 + { + keyPath: + storeName === "my_links" + ? ["sourceId", "targetId", "linkType"] + : ["linkText", "sourcePageId", "linkType"], + }, + ); + const indexSpecs: { name: string; keyPath: string | string[] }[] = + spec?.indexes ?? + (storeName === "my_links" + ? [ + { name: "by_source", keyPath: "sourceId" }, + { name: "by_target", keyPath: "targetId" }, + { name: "by_source_type", keyPath: ["sourceId", "linkType"] }, + ] + : [ + { name: "by_source", keyPath: "sourcePageId" }, + { name: "by_source_type", keyPath: ["sourcePageId", "linkType"] }, + ]); + for (const idx of indexSpecs) { + newStore.createIndex(idx.name, idx.keyPath, { unique: false }); + } + for (const row of rows) { + const migrated = mapRow(row); + newStore.put(migrated.value); + } + }; +} + /** Load Y.Doc from y-indexeddb, return state, then destroy. Doc name must match CollaborationManager. */ function loadYDocState(pageId: string): Promise { return new Promise((resolve, reject) => { @@ -506,18 +617,27 @@ export class IndexedDBStorageAdapter implements StorageAdapter { /** * Return forward links emanating from the given page. - * 指定ページから出ているリンクを返す。 + * When `linkType` is provided, scope to that type only (issue #725 Phase 1). + * + * 指定ページから出ているリンクを返す。`linkType` 指定時はその種別のみ返す。 */ - async getLinks(pageId: string): Promise { + async getLinks(pageId: string, linkType?: LinkType): Promise { const db = await ensureDb(); return new Promise((resolve, reject) => { const tx = db.transaction("my_links", "readonly"); - const index = tx.objectStore("my_links").index("by_source"); - const req = index.getAll(pageId); + const store = tx.objectStore("my_links"); + const index = + linkType !== undefined ? store.index("by_source_type") : store.index("by_source"); + const req = linkType !== undefined ? index.getAll([pageId, linkType]) : index.getAll(pageId); req.onsuccess = () => { const rows = (req.result as StoredLink[]) || []; resolve( - rows.map((r) => ({ sourceId: r.sourceId, targetId: r.targetId, createdAt: r.createdAt })), + rows.map((r) => ({ + sourceId: r.sourceId, + targetId: r.targetId, + linkType: r.linkType, + createdAt: r.createdAt, + })), ); }; req.onerror = () => reject(req.error); @@ -526,9 +646,9 @@ export class IndexedDBStorageAdapter implements StorageAdapter { /** * Return backlinks pointing at the given page. - * 指定ページに入ってくる被リンク(バックリンク)を返す。 + * 指定ページに入ってくる被リンク(バックリンク)を返す。`linkType` 指定で絞る。 */ - async getBacklinks(pageId: string): Promise { + async getBacklinks(pageId: string, linkType?: LinkType): Promise { const db = await ensureDb(); return new Promise((resolve, reject) => { const tx = db.transaction("my_links", "readonly"); @@ -536,8 +656,15 @@ export class IndexedDBStorageAdapter implements StorageAdapter { const req = index.getAll(pageId); req.onsuccess = () => { const rows = (req.result as StoredLink[]) || []; + const filtered = + linkType !== undefined ? rows.filter((r) => r.linkType === linkType) : rows; resolve( - rows.map((r) => ({ sourceId: r.sourceId, targetId: r.targetId, createdAt: r.createdAt })), + filtered.map((r) => ({ + sourceId: r.sourceId, + targetId: r.targetId, + linkType: r.linkType, + createdAt: r.createdAt, + })), ); }; req.onerror = () => reject(req.error); @@ -545,24 +672,29 @@ export class IndexedDBStorageAdapter implements StorageAdapter { } /** - * Replace all forward links for a source page (delete existing then insert). - * 指定ソースページの forward links を全置換する(既存削除→新規追加)。 + * Replace all forward links of a single `linkType` for a source page + * (delete existing of that type, then insert). Rows of other link types are + * left untouched so that e.g. tag sync cannot wipe wiki edges. + * + * 指定ソースページの forward links を `linkType` スコープで全置換する。 + * 他種別のエッジには触れない(issue #725 Phase 1)。 */ - async saveLinks(sourcePageId: string, links: Link[]): Promise { + async saveLinks(sourcePageId: string, links: Link[], linkType: LinkType): Promise { const db = await ensureDb(); return new Promise((resolve, reject) => { const tx = db.transaction("my_links", "readwrite"); const store = tx.objectStore("my_links"); - const index = store.index("by_source"); - const getAllReq = index.getAll(sourcePageId); + const index = store.index("by_source_type"); + const getAllReq = index.getAll([sourcePageId, linkType]); getAllReq.onsuccess = () => { const existing = getAllReq.result as StoredLink[]; - existing.forEach((r) => store.delete([r.sourceId, r.targetId])); + existing.forEach((r) => store.delete([r.sourceId, r.targetId, r.linkType])); const now = Date.now(); links.forEach((l) => { store.put({ sourceId: l.sourceId, targetId: l.targetId, + linkType, createdAt: l.createdAt ?? now, }); }); @@ -574,21 +706,24 @@ export class IndexedDBStorageAdapter implements StorageAdapter { } /** - * Return ghost links (unresolved wiki link targets) for a source page. - * 指定ソースページの ghost link(未解決リンク)を返す。 + * Return ghost links (unresolved wiki link / tag targets) for a source page. + * 指定ソースページの ghost link を返す。`linkType` 指定で種別ごとに絞る。 */ - async getGhostLinks(pageId: string): Promise { + async getGhostLinks(pageId: string, linkType?: LinkType): Promise { const db = await ensureDb(); return new Promise((resolve, reject) => { const tx = db.transaction("my_ghost_links", "readonly"); - const index = tx.objectStore("my_ghost_links").index("by_source"); - const req = index.getAll(pageId); + const store = tx.objectStore("my_ghost_links"); + const index = + linkType !== undefined ? store.index("by_source_type") : store.index("by_source"); + const req = linkType !== undefined ? index.getAll([pageId, linkType]) : index.getAll(pageId); req.onsuccess = () => { const rows = (req.result as StoredGhostLink[]) || []; resolve( rows.map((r) => ({ linkText: r.linkText, sourcePageId: r.sourcePageId, + linkType: r.linkType, createdAt: r.createdAt, originalTargetPageId: r.originalTargetPageId ?? null, originalNoteId: r.originalNoteId ?? null, @@ -600,24 +735,32 @@ export class IndexedDBStorageAdapter implements StorageAdapter { } /** - * Replace all ghost links for a source page. - * 指定ソースページの ghost link を全置換する。 + * Replace all ghost links of a single `linkType` for a source page. Rows of + * other link types are preserved so tag-ghost and wiki-ghost can coexist. + * + * 指定ソースページの ghost link を `linkType` スコープで全置換する。他種別の + * ゴーストは残る(issue #725 Phase 1)。 */ - async saveGhostLinks(sourcePageId: string, ghostLinks: GhostLink[]): Promise { + async saveGhostLinks( + sourcePageId: string, + ghostLinks: GhostLink[], + linkType: LinkType, + ): Promise { const db = await ensureDb(); return new Promise((resolve, reject) => { const tx = db.transaction("my_ghost_links", "readwrite"); const store = tx.objectStore("my_ghost_links"); - const index = store.index("by_source"); - const getAllReq = index.getAll(sourcePageId); + const index = store.index("by_source_type"); + const getAllReq = index.getAll([sourcePageId, linkType]); getAllReq.onsuccess = () => { const existing = getAllReq.result as StoredGhostLink[]; - existing.forEach((r) => store.delete([r.linkText, r.sourcePageId])); + existing.forEach((r) => store.delete([r.linkText, r.sourcePageId, r.linkType])); const now = Date.now(); ghostLinks.forEach((g) => { store.put({ linkText: g.linkText, sourcePageId: g.sourcePageId, + linkType, createdAt: g.createdAt ?? now, originalTargetPageId: g.originalTargetPageId ?? null, originalNoteId: g.originalNoteId ?? null, diff --git a/src/lib/storageAdapter/StorageAdapter.ts b/src/lib/storageAdapter/StorageAdapter.ts index ec06c73b..f81b8475 100644 --- a/src/lib/storageAdapter/StorageAdapter.ts +++ b/src/lib/storageAdapter/StorageAdapter.ts @@ -4,8 +4,17 @@ * Web: IndexedDBStorageAdapter. Tauri: TauriStorageAdapter (Phase D). */ -import type { PageMetadata, Link, GhostLink, SearchResult } from "./types"; +import type { PageMetadata, Link, GhostLink, LinkType, SearchResult } from "./types"; +/** + * ストレージアダプタインターフェース (§6.1 zedi-rearchitecture-spec.md)。 + * メタデータ、Y.Doc、リンク、検索、同期タイムスタンプのプラットフォーム抽象化。 + * Web は `IndexedDBStorageAdapter`、Tauri は `TauriStorageAdapter` (Phase D)。 + * + * Platform abstraction for metadata, Y.Doc, links, search, and sync timestamp + * (§6.1 zedi-rearchitecture-spec.md). Web is backed by `IndexedDBStorageAdapter`; + * Tauri will use `TauriStorageAdapter` (Phase D). + */ export interface StorageAdapter { // ── メタデータ ── getAllPages(): Promise; @@ -18,12 +27,21 @@ export interface StorageAdapter { saveYDocState(pageId: string, state: Uint8Array, version: number): Promise; getYDocVersion(pageId: string): Promise; - // ── リンク ── - getLinks(pageId: string): Promise; - getBacklinks(pageId: string): Promise; - saveLinks(sourcePageId: string, links: Link[]): Promise; - getGhostLinks(pageId: string): Promise; - saveGhostLinks(sourcePageId: string, ghostLinks: GhostLink[]): Promise; + // ── リンク / Links ── + // + // `linkType` 省略時は全種別 (`'wiki'` + `'tag'`) を対象にする。Issue #725 Phase 1 + // で `link_type` を導入して以降、書き込み系 (`saveLinks` / `saveGhostLinks`) は + // `linkType` を明示して「その種別のみを全置換」させる契約。未指定だった既存 + // 呼び出し元は段階的に移行する。 + // + // When `linkType` is omitted, read methods return rows of any type. Write + // methods require an explicit `linkType` (issue #725) and replace only that + // type's rows for the given source — they never wipe edges of other types. + getLinks(pageId: string, linkType?: LinkType): Promise; + getBacklinks(pageId: string, linkType?: LinkType): Promise; + saveLinks(sourcePageId: string, links: Link[], linkType: LinkType): Promise; + getGhostLinks(pageId: string, linkType?: LinkType): Promise; + saveGhostLinks(sourcePageId: string, ghostLinks: GhostLink[], linkType: LinkType): Promise; // ── 検索 ── searchPages(query: string): Promise; diff --git a/src/lib/storageAdapter/types.ts b/src/lib/storageAdapter/types.ts index 431a5012..329db794 100644 --- a/src/lib/storageAdapter/types.ts +++ b/src/lib/storageAdapter/types.ts @@ -28,17 +28,30 @@ export interface PageMetadata { isDeleted: boolean; } -/** Link between two pages (source → target). */ +/** + * `links` / `ghost_links` の種別識別子。サーバ側 `link_type` カラムに対応。 + * `'wiki'` は既存 WikiLink、`'tag'` は issue #725 で追加されたタグ記法。 + * + * Discriminator shared by `links` / `ghost_links`; mirrors server `link_type`. + */ +export type LinkType = "wiki" | "tag"; + +/** Link between two pages (source → target). `linkType` distinguishes wiki vs. tag edges. */ export interface Link { sourceId: string; targetId: string; + linkType: LinkType; createdAt: number; } -/** Ghost link (unresolved wiki link). C2-6: optional original_target_page_id / original_note_id. */ +/** + * Ghost link (unresolved wiki link or tag). C2-6: optional original_target_page_id / + * original_note_id. `linkType` distinguishes WikiLink vs. tag (issue #725 Phase 1). + */ export interface GhostLink { linkText: string; sourcePageId: string; + linkType: LinkType; createdAt: number; originalTargetPageId?: string | null; originalNoteId?: string | null; diff --git a/src/lib/sync/syncWithApi.test.ts b/src/lib/sync/syncWithApi.test.ts index 13ab5f92..d19f3fad 100644 --- a/src/lib/sync/syncWithApi.test.ts +++ b/src/lib/sync/syncWithApi.test.ts @@ -178,12 +178,17 @@ describe("syncWithApi", () => { await syncWithApi(adapter, api, TEST_USER_ID); + // Issue #725 Phase 1: saveLinks は (sourceId, links, linkType) の 3 引数で + // 呼ばれる。wiki 種別の pull を確認する。 + // saveLinks takes `(sourceId, links, linkType)` since issue #725 Phase 1; + // assert the wiki bucket specifically. expect(adapter.saveLinks).toHaveBeenCalledWith( "p1", expect.arrayContaining([ - expect.objectContaining({ sourceId: "p1", targetId: "p2" }), - expect.objectContaining({ sourceId: "p1", targetId: "p3" }), + expect.objectContaining({ sourceId: "p1", targetId: "p2", linkType: "wiki" }), + expect.objectContaining({ sourceId: "p1", targetId: "p3", linkType: "wiki" }), ]), + "wiki", ); }); @@ -212,7 +217,110 @@ describe("syncWithApi", () => { await syncWithApi(adapter, api, TEST_USER_ID); - expect(adapter.saveLinks).toHaveBeenCalledWith("p1", []); + // Issue #725 Phase 1 + ロールアウト安全: レスポンスに 1 行も `link_type` + // が含まれない(pre-#725 のサーバ or レガシーキャッシュ)ときは wiki + // バケットのみを touch し、tag バケットは保持する。tag バケットまで空保存 + // するとローカルの tag エッジを誤って消してしまう。 + // + // Safety: when the pull payload carries no `link_type` at all (pre-#725 + // server / cached legacy response), only clear the `'wiki'` bucket. Tag + // edges must be preserved so we do not wipe them during mixed-version + // rollout. + expect(adapter.saveLinks).toHaveBeenCalledWith("p1", [], "wiki"); + expect(adapter.saveLinks).not.toHaveBeenCalledWith("p1", [], "tag"); + }); + + it("clears both wiki and tag buckets when response proves link_type is on the wire (issue #725 Phase 1 rollout safety)", async () => { + // `res.links` に 1 行でも explicit な `link_type` があれば「サーバは link_type + // を理解している」とみなし、同じページの他 linkType バケットも stale + // クリアの対象にする。ここでは `link_type='tag'` の 1 行を別ソースで混ぜ、 + // `p1` については links 無しでも wiki / tag 両方が空保存されることを確認。 + // + // If any row in the response carries an explicit `link_type`, we trust the + // wire for every bucket and clear stale edges in all of them for every + // pulled page, including pages whose slice happens to be empty. + const serverPage = { + id: "p1", + owner_id: TEST_USER_ID, + source_page_id: null, + title: "Page", + content_preview: null, + thumbnail_url: null, + source_url: null, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-05-01T00:00:00Z", + is_deleted: false, + }; + const adapter = createMockAdapter(); + const api = createMockApi({ + getSyncPages: vi.fn().mockResolvedValue({ + pages: [serverPage], + links: [ + { + source_id: "other-page", + target_id: "x", + link_type: "tag", + created_at: "2025-01-01T00:00:00Z", + }, + ], + ghost_links: [], + server_time: new Date().toISOString(), + }), + }); + + await syncWithApi(adapter, api, TEST_USER_ID); + + expect(adapter.saveLinks).toHaveBeenCalledWith("p1", [], "wiki"); + expect(adapter.saveLinks).toHaveBeenCalledWith("p1", [], "tag"); + }); + + it("links の link_type を根拠に ghost_links 側も tag バケットをクリアする(ghost_links が空のとき stale タグゴーストが残らないこと) / clears tag ghost bucket using evidence from links even when ghost_links is empty (issue #725 Phase 1; Devin review)", async () => { + // Devin review: links と ghost_links を独立に判定すると、`links` に + // `link_type='tag'` があっても `ghost_links: []` のレスポンスでは + // ghost の tag バケットがクリアされず stale が残る。レスポンス全体から + // server の link_type サポートを推定し、両方のバケットで tag もクリア + // 対象に含める必要がある。 + // + // When `res.links` proves the server speaks `link_type`, the ghost_links + // path must also clear the tag bucket — otherwise an empty ghost_links + // array leaves stale local tag ghosts untouched. + const serverPage = { + id: "p1", + owner_id: TEST_USER_ID, + source_page_id: null, + title: "Page", + content_preview: null, + thumbnail_url: null, + source_url: null, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-05-01T00:00:00Z", + is_deleted: false, + }; + const adapter = createMockAdapter(); + const api = createMockApi({ + getSyncPages: vi.fn().mockResolvedValue({ + pages: [serverPage], + links: [ + { + source_id: "other-page", + target_id: "x", + link_type: "tag", + created_at: "2025-01-01T00:00:00Z", + }, + ], + // ghost_links は空(この配列単独では link_type 非対応に見える) + // ghost_links empty (looks legacy on its own) + ghost_links: [], + server_time: new Date().toISOString(), + }), + }); + + await syncWithApi(adapter, api, TEST_USER_ID); + + // saveGhostLinks も tag バケットで呼ばれることを検証。 + // saveGhostLinks must also be called for the tag bucket. + expect(adapter.saveGhostLinks).toHaveBeenCalledWith("p1", [], "wiki"); + expect(adapter.saveGhostLinks).toHaveBeenCalledWith("p1", [], "tag"); }); it("preserves local thumbnailUrl when server returns null", async () => { @@ -366,13 +474,18 @@ describe("syncWithApi", () => { const adapter = createMockAdapter({ getLastSyncTime: vi.fn().mockResolvedValue(0), getAllPages: vi.fn().mockResolvedValue(manyPages), - getLinks: vi - .fn() - .mockImplementation((pageId: string) => - pageId === "page-0" - ? Promise.resolve([{ sourceId: "page-0", targetId: "page-1", createdAt: Date.now() }]) - : Promise.resolve([]), - ), + getLinks: vi.fn().mockImplementation((pageId: string) => + pageId === "page-0" + ? Promise.resolve([ + { + sourceId: "page-0", + targetId: "page-1", + linkType: "wiki", + createdAt: Date.now(), + }, + ]) + : Promise.resolve([]), + ), getGhostLinks: vi.fn().mockResolvedValue([]), }); const postSyncPages = vi.fn().mockResolvedValue({ @@ -399,6 +512,56 @@ describe("syncWithApi", () => { expect(secondCall[0].pages).toHaveLength(1); expect(secondCall[0].links).toBeDefined(); expect(secondCall[0].ghost_links).toBeDefined(); + // Issue #725 Phase 1: push payload carries `link_type` per link row. + // Issue #725 Phase 1: push に `link_type` を含める。 + expect(secondCall[0].links[0]).toMatchObject({ link_type: "wiki" }); + }); + + it("ローカルにタグエッジがある場合、push に link_type='tag' を含める / includes link_type='tag' on push when local has tag edges (issue #725 Phase 1)", async () => { + const localPage: PageMetadata = { + id: "p1", + ownerId: TEST_USER_ID, + noteId: null, + sourcePageId: null, + title: "Local", + contentPreview: null, + thumbnailUrl: null, + sourceUrl: null, + createdAt: Date.now(), + updatedAt: Date.now(), + isDeleted: false, + }; + const adapter = createMockAdapter({ + getLastSyncTime: vi.fn().mockResolvedValue(Date.now() - 60_000), + getAllPages: vi.fn().mockResolvedValue([localPage]), + getLinks: vi.fn().mockResolvedValue([ + { sourceId: "p1", targetId: "w", linkType: "wiki", createdAt: 1 }, + { sourceId: "p1", targetId: "t", linkType: "tag", createdAt: 2 }, + ]), + getGhostLinks: vi + .fn() + .mockResolvedValue([ + { linkText: "NewTag", sourcePageId: "p1", linkType: "tag", createdAt: 3 }, + ]), + }); + const postSyncPages = vi + .fn() + .mockResolvedValue({ server_time: new Date().toISOString(), conflicts: [] }); + const api = createMockApi({ postSyncPages }); + + await syncWithApi(adapter, api, TEST_USER_ID); + + expect(postSyncPages).toHaveBeenCalledTimes(1); + const body = postSyncPages.mock.calls[0][0]; + expect(body.links).toEqual( + expect.arrayContaining([ + expect.objectContaining({ source_id: "p1", target_id: "w", link_type: "wiki" }), + expect.objectContaining({ source_id: "p1", target_id: "t", link_type: "tag" }), + ]), + ); + expect(body.ghost_links).toEqual( + expect.arrayContaining([expect.objectContaining({ link_text: "NewTag", link_type: "tag" })]), + ); }); it("excludes note-native pages from push (issue #713 Phase 2 defensive filter)", async () => { diff --git a/src/lib/sync/syncWithApi.ts b/src/lib/sync/syncWithApi.ts index a4c08d91..a198e965 100644 --- a/src/lib/sync/syncWithApi.ts +++ b/src/lib/sync/syncWithApi.ts @@ -5,10 +5,23 @@ */ import type { StorageAdapter } from "@/lib/storageAdapter/StorageAdapter"; -import type { PageMetadata, GhostLink } from "@/lib/storageAdapter/types"; +import type { PageMetadata, GhostLink, Link, LinkType } from "@/lib/storageAdapter/types"; import type { ApiClient } from "@/lib/api/apiClient"; import type { SyncPageItem, SyncLinkItem, SyncGhostLinkItem } from "@/lib/api/types"; +/** + * `SyncLinkItem.link_type` / `SyncGhostLinkItem.link_type` のラベルを + * `LinkType` に正規化する。未指定および未知の値は `'wiki'` にフォールバック + * し、サーバ migration 前の旧データと互換を保つ (issue #725 Phase 1)。 + * + * Normalize wire-format `link_type` into the adapter's `LinkType`. Unknown / + * missing values fall back to `'wiki'` so legacy rows remain readable while + * the v3 migration rolls out. + */ +function normalizeWireLinkType(value: "wiki" | "tag" | undefined | null): LinkType { + return value === "tag" ? "tag" : "wiki"; +} + function syncPageToMetadata(row: SyncPageItem): PageMetadata { return { id: row.id, @@ -212,25 +225,42 @@ function computeSince( return lastSync ? new Date(lastSync).toISOString() : undefined; } -/** links/ghost_links を source ごとにグループ化し、pulledPageIds を含む全 source に対して保存する(stale クリア含む) */ +/** + * links / ghost_links を `(source, linkType)` ごとにグループ化し、 + * pulledPageIds × 全 linkType を含む組に対して保存する(stale クリア含む)。 + * issue #725 Phase 1 で linkType 次元を追加。 + * + * Group links / ghost_links by `(source, linkType)` and persist every + * `pulledPageIds × linkType` pair so stale edges of any type get cleared + * even when the server returns nothing for that slot (issue #725 Phase 1). + */ async function applyRelatedItems( items: T[], pulledPageIds: Set, + linkTypes: readonly LinkType[], getSourceId: (item: T) => string, + getLinkType: (item: T) => LinkType, mapToLocal: (items: T[]) => U[], - saveFn: (sourceId: string, localItems: U[]) => Promise, + saveFn: (sourceId: string, localItems: U[], linkType: LinkType) => Promise, ): Promise { - const bySource = new Map(); + const bySourceType = new Map(); + const key = (sid: string, t: LinkType) => `${sid}${t}`; + const presentSourceIds = new Set(); for (const item of items) { const sid = getSourceId(item); - const list = bySource.get(sid) ?? []; + const t = getLinkType(item); + presentSourceIds.add(sid); + const k = key(sid, t); + const list = bySourceType.get(k) ?? []; list.push(item); - bySource.set(sid, list); + bySourceType.set(k, list); } - const allSourceIds = new Set([...pulledPageIds, ...bySource.keys()]); + const allSourceIds = new Set([...pulledPageIds, ...presentSourceIds]); for (const sourceId of allSourceIds) { - const sourceItems = bySource.get(sourceId) ?? []; - await saveFn(sourceId, mapToLocal(sourceItems)); + for (const t of linkTypes) { + const bucket = bySourceType.get(key(sourceId, t)) ?? []; + await saveFn(sourceId, mapToLocal(bucket), t); + } } } @@ -258,34 +288,70 @@ async function applyPull( await adapter.upsertPage(meta); } - await applyRelatedItems( + // issue #725 Phase 1: `link_type` を明示的に含むクライアント/サーバの組み合わせ + // でのみ tag バケットの stale クリアを走らせる。pre-#725 のサーバやキャッシュ + // された旧レスポンスは `link_type` を含まないため、そうした payload で tag + // バケットを強制的に空保存するとローカルの tag エッジを誤って消してしまう。 + // そこで「レスポンス全体(links or ghost_links いずれか)に 1 行でも explicit + // な `link_type` があれば wire が link_type を理解している」とみなし、その + // 場合は links / ghost_links 両方で全種別を対象にする。links と ghost_links + // を独立に判定すると、片方が空(例: `ghost_links: []`)のときに tag バケット + // のクリアが発動せず、stale なタグゴーストが残る問題があった(PR #733 レビュー)。 + // + // Only clear the `'tag'` bucket when the payload proves the server speaks + // `link_type` (issue #725 Phase 1). A pre-#725 server or cached legacy + // response omits `link_type`, and enumerating all link types would otherwise + // silently erase local tag edges during mixed-version rollout. If **any** + // row across the whole response carries an explicit `link_type`, we trust + // the wire for both links and ghost_links. Checking them independently + // left stale tag ghost edges behind when the ghost_links array happened to + // be empty (PR #733 review: Devin). + const hasExplicitLinkType = (items: Array<{ link_type?: "wiki" | "tag" }>): boolean => + items.some((row) => row.link_type !== undefined); + const serverSpeaksLinkType = + hasExplicitLinkType(res.links) || hasExplicitLinkType(res.ghost_links); + const linkTypesForLinks: readonly LinkType[] = serverSpeaksLinkType + ? (["wiki", "tag"] as const) + : (["wiki"] as const); + const linkTypesForGhosts: readonly LinkType[] = serverSpeaksLinkType + ? (["wiki", "tag"] as const) + : (["wiki"] as const); + + await applyRelatedItems( res.links, pulledPageIds, + linkTypesForLinks, (l) => l.source_id, + (l) => normalizeWireLinkType(l.link_type), (items) => items.map((l) => ({ sourceId: l.source_id, targetId: l.target_id, + linkType: normalizeWireLinkType(l.link_type), createdAt: typeof l.created_at === "string" ? new Date(l.created_at).getTime() : l.created_at, })), - (sourceId, links) => adapter.saveLinks(sourceId, links), + (sourceId, links, linkType) => adapter.saveLinks(sourceId, links, linkType), ); - await applyRelatedItems( + await applyRelatedItems( res.ghost_links, pulledPageIds, + linkTypesForGhosts, (g) => g.source_page_id, + (g) => normalizeWireLinkType(g.link_type), (items) => items.map((g) => ({ linkText: g.link_text, sourcePageId: g.source_page_id, + linkType: normalizeWireLinkType(g.link_type), createdAt: typeof g.created_at === "string" ? new Date(g.created_at).getTime() : g.created_at, originalTargetPageId: g.original_target_page_id ?? null, originalNoteId: g.original_note_id ?? null, })), - (sourcePageId, ghostLinks) => adapter.saveGhostLinks(sourcePageId, ghostLinks), + (sourcePageId, ghostLinks, linkType) => + adapter.saveGhostLinks(sourcePageId, ghostLinks, linkType), ); } @@ -330,10 +396,16 @@ function finishSyncIfNoPushNeeded( async function pushPagesToApi( api: ApiClient, pushPages: PostSyncPageItem[], - pushLinks: Array<{ source_id: string; target_id: string; created_at: string }>, + pushLinks: Array<{ + source_id: string; + target_id: string; + link_type: LinkType; + created_at: string; + }>, pushGhostLinks: Array<{ link_text: string; source_page_id: string; + link_type: LinkType; created_at: string; original_target_page_id: string | null; original_note_id: string | null; @@ -397,9 +469,13 @@ export async function syncWithApi( } const pushPages: PostSyncPageItem[] = pagesForPush.map(metadataToSyncPage); - const allLinks: Array<{ sourceId: string; targetId: string; createdAt: number }> = []; - const allGhostLinks: Array = []; + const allLinks: Link[] = []; + const allGhostLinks: GhostLink[] = []; for (const p of pagesForPush) { + // 全種別をまとめて引く(linkType 指定なし = すべて)。サーバ側で + // `(source_id, link_type)` ペア単位に DELETE/INSERT される。 + // Read every linkType; the server applies DELETE/INSERT scoped per + // `(source_id, link_type)` pair (issue #725 Phase 1). const links = await adapter.getLinks(p.id); allLinks.push(...links); const ghosts = await adapter.getGhostLinks(p.id); @@ -408,11 +484,13 @@ export async function syncWithApi( const pushLinks = allLinks.map((l) => ({ source_id: l.sourceId, target_id: l.targetId, + link_type: l.linkType, created_at: new Date(l.createdAt).toISOString(), })); const pushGhostLinks = allGhostLinks.map((g) => ({ link_text: g.linkText, source_page_id: g.sourcePageId, + link_type: g.linkType, created_at: new Date(g.createdAt).toISOString(), original_target_page_id: g.originalTargetPageId ?? null, original_note_id: g.originalNoteId ?? null, diff --git a/src/lib/syncWikiLinks.ts b/src/lib/syncWikiLinks.ts index eb7249fc..cfe68c39 100644 --- a/src/lib/syncWikiLinks.ts +++ b/src/lib/syncWikiLinks.ts @@ -1,5 +1,5 @@ import type { IPageRepository } from "@/lib/pageRepository"; -import type { PageSummary } from "@/types/page"; +import type { PageSummary, LinkType } from "@/types/page"; /** * `syncLinksWithRepo` が受け取る WikiLink の最小情報。 @@ -45,6 +45,16 @@ export interface SyncLinksOptions { * candidates and every WikiLink becomes a ghost link. */ notePages?: Array>; + /** + * 同期対象の `link_type`。既定は `'wiki'`(WikiLink)。`'tag'` を指定すると + * タグ記法 (`#name`) のエッジを同期する(issue #725 Phase 1)。指定種別の + * バケットだけを読み書きし、別種別のエッジには触れない。 + * + * Which link bucket to sync. Defaults to `'wiki'`; pass `'tag'` to sync + * hashtag edges (issue #725 Phase 1). Other link types are never read or + * written by this call. + */ + linkType?: LinkType; } /** @@ -83,6 +93,11 @@ export async function syncLinksWithRepo( // an empty string as personal scope; compare to `null` explicitly so the // scope switch follows the documented contract. const pageNoteId = options.pageNoteId ?? null; + // issue #725 Phase 1: `linkType` 省略時は `'wiki'`(既存の WikiLink 同期)。 + // `'tag'` を渡すとタグエッジバケットのみを同期し、他種別のエッジには触れない。 + // Default `linkType` to `'wiki'` (issue #725 Phase 1); `'tag'` scopes sync + // to the tag bucket only, leaving wiki edges intact. + const linkType: LinkType = options.linkType ?? "wiki"; const candidateSource: Array> = pageNoteId !== null ? (options.notePages ?? []) : await repo.getPagesSummary(userId); @@ -101,19 +116,19 @@ export async function syncLinksWithRepo( // the current scope: remove the stale edge so we do not accumulate dangling // graph entries when `notePages` is empty. const [oldOutgoingTargetIds, oldGhostTexts] = await Promise.all([ - repo.getOutgoingLinks(sourcePageId), - repo.getGhostLinksBySourcePage(sourcePageId), + repo.getOutgoingLinks(sourcePageId, linkType), + repo.getGhostLinksBySourcePage(sourcePageId, linkType), ]); for (const targetId of oldOutgoingTargetIds) { const norm = idToNormalizedTitle.get(targetId); if (norm === undefined || !currentNormalizedTitles.has(norm)) { - await repo.removeLink(sourcePageId, targetId); + await repo.removeLink(sourcePageId, targetId, linkType); } } for (const linkText of oldGhostTexts) { const norm = linkText.toLowerCase().trim(); if (!currentNormalizedTitles.has(norm)) { - await repo.removeGhostLink(linkText, sourcePageId); + await repo.removeGhostLink(linkText, sourcePageId, linkType); } } @@ -123,10 +138,10 @@ export async function syncLinksWithRepo( const targetPageId = pageTitleToId.get(normalizedTitle); if (targetPageId && targetPageId !== sourcePageId) { - await repo.addLink(sourcePageId, targetPageId); - await repo.removeGhostLink(link.title, sourcePageId); + await repo.addLink(sourcePageId, targetPageId, linkType); + await repo.removeGhostLink(link.title, sourcePageId, linkType); } else if (!targetPageId) { - await repo.addGhostLink(link.title, sourcePageId); + await repo.addGhostLink(link.title, sourcePageId, linkType); } } } diff --git a/src/lib/tagCharacterClassSync.test.ts b/src/lib/tagCharacterClassSync.test.ts new file mode 100644 index 00000000..4ee40ce7 --- /dev/null +++ b/src/lib/tagCharacterClassSync.test.ts @@ -0,0 +1,56 @@ +/** + * `@zedi/shared` の `TAG_NAME_CHAR_CLASS` と、`server/api` 側で同じ文字列を + * 二重定義している `TAG_NAME_CHAR_CLASS_STRING` がドリフトしていないことを + * CI で保証するテスト。 + * + * `server/api` はルートの Bun workspace から意図的に外れており(Railway は + * `server/api/` 自体を build context にする)、`@zedi/shared` を直接 import + * することができない。そのためサーバ側にも同一文字列を持たせ、本テストが + * クライアント側の vitest で両者の一致を検証する。文字クラスを更新する際は + * `packages/shared/src/tagCharacterClass.ts` と + * `server/api/src/services/ydocRenameRewrite.ts` を必ず同時に書き換える。 + * + * Drift detector that fails CI when `@zedi/shared`'s `TAG_NAME_CHAR_CLASS` + * and the server-side duplicate `TAG_NAME_CHAR_CLASS_STRING` in + * `server/api/src/services/ydocRenameRewrite.ts` disagree. `server/api` + * intentionally lives outside the Bun workspace (Railway uses `server/api/` + * as the build context), so it cannot import `@zedi/shared`. This test reads + * the server file from disk and compares the literal value against the + * source-of-truth constant. + */ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, it, expect } from "vitest"; +import { TAG_NAME_CHAR_CLASS } from "@zedi/shared/tagCharacterClass"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +describe("TAG_NAME_CHAR_CLASS sync between @zedi/shared and server/api", () => { + it("server/api/src/services/ydocRenameRewrite.ts mirrors the shared constant", () => { + const serverFilePath = resolve(__dirname, "../../server/api/src/services/ydocRenameRewrite.ts"); + const source = readFileSync(serverFilePath, "utf8"); + + // 抽出パターンは `export const TAG_NAME_CHAR_CLASS_STRING = "..."` の + // 値部分を取り出す。文字列リテラルはダブルクォート前提(プロジェクト + // 全体の Prettier 設定)。テンプレートリテラル化したい場合は本テストも + // 拡張する。 + // Extract the literal value of `export const TAG_NAME_CHAR_CLASS_STRING`. + // Uses a double-quoted string literal (matches Prettier defaults). If the + // server file ever switches to a template literal, extend this regex. + const match = source.match( + /export const TAG_NAME_CHAR_CLASS_STRING\s*=\s*"((?:[^"\\]|\\.)*)";?/, + ); + + expect(match, "TAG_NAME_CHAR_CLASS_STRING export not found in server file").not.toBeNull(); + if (!match) return; + + // ソース内のエスケープシーケンス(`\\-` 等)を実値に解決して比較する。 + // JSON.parse でデコードできるようにダブルクォートを再付与する。 + // Decode JS-escape sequences (`\\-` etc.) so the comparison is between + // semantic string values, not raw source bytes. + const literalValue = JSON.parse(`"${match[1]}"`) as string; + expect(literalValue).toBe(TAG_NAME_CHAR_CLASS); + }); +}); diff --git a/src/lib/tagUtils.test.ts b/src/lib/tagUtils.test.ts index 59292d9d..c49b28d9 100644 --- a/src/lib/tagUtils.test.ts +++ b/src/lib/tagUtils.test.ts @@ -256,6 +256,54 @@ describe("updateTagAttributes", () => { expect(result.content).toBe(bad); expect(result.hasChanges).toBe(false); }); + + describe("targetId plumbing (issue #737)", () => { + // `pageTitleToId` を渡すと resolved タグに `targetId` を埋める。 + // Pin the `targetId` plumbing introduced for issue #737. + const TARGET_ID = "11111111-aaaa-bbbb-cccc-000000000001"; + + function buildContent(extraAttrs: Record = {}): string { + return JSON.stringify({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "tech", + marks: [ + { + type: "tag", + attrs: { name: "tech", exists: false, referenced: false, ...extraAttrs }, + }, + ], + }, + ], + }, + ], + }); + } + + it("populates targetId when pageTitleToId is provided and tag resolves", () => { + const content = buildContent(); + const map = new Map([["tech", TARGET_ID]]); + const result = updateTagAttributes(content, new Set(["tech"]), new Set(), map); + expect(result.hasChanges).toBe(true); + const attrs = JSON.parse(result.content).content[0].content[0].marks[0].attrs; + expect(attrs.exists).toBe(true); + expect(attrs.targetId).toBe(TARGET_ID); + }); + + it("leaves targetId untouched when pageTitleToId is omitted", () => { + // 既存マークの `targetId` は触らない契約を固定する。 + // Pin that omitting the map preserves any pre-existing id. + const content = buildContent({ targetId: "preexisting-id" }); + const result = updateTagAttributes(content, new Set(["tech"]), new Set()); + const attrs = JSON.parse(result.content).content[0].content[0].marks[0].attrs; + expect(attrs.targetId).toBe("preexisting-id"); + }); + }); }); describe("getUniqueTagNames", () => { diff --git a/src/lib/tagUtils.ts b/src/lib/tagUtils.ts index f9bde2a6..40e1ed35 100644 --- a/src/lib/tagUtils.ts +++ b/src/lib/tagUtils.ts @@ -104,6 +104,11 @@ export function extractTagsFromContent(content: string): TagInfo[] { * 属性を更新する。`pageTitles` / `referencedTitles` は呼び出し側で解決済みの * ページタイトル集合(小文字・トリム正規化済み)。 * + * `pageTitleToId` を渡すと、解決時に `targetId` 属性も埋める(issue #737)。 + * 省略時は既存の `targetId` を温存する。 + * Pass `pageTitleToId` (issue #737) to also populate the `targetId` + * attribute on resolved marks; omitted → preserve existing id. + * * @returns 更新後の JSON と、属性変更が発生したかどうか。 * The updated JSON and a flag indicating whether any mark changed. */ @@ -111,6 +116,7 @@ export function updateTagAttributes( content: string, pageTitles: Set, referencedTitles: Set, + pageTitleToId?: Map, ): { content: string; hasChanges: boolean } { if (!content) return { content, hasChanges: false }; @@ -138,16 +144,35 @@ export function updateTagAttributes( const normalizedName = name.toLowerCase(); const newExists = pageTitles.has(normalizedName); const newReferenced = referencedTitles.has(normalizedName); - - if (attrs.exists !== newExists || attrs.referenced !== newReferenced) { + // 解決済みターゲット ID を埋める (issue #737)。`null` のまま + // 上書きしない (既存値温存) ため、resolved 時のみ書き換え対象。 + // Populate the resolved target id (issue #737). Never overwrite + // an existing id with `null`; only update when resolved. + const resolvedTargetId = + newExists && pageTitleToId !== undefined + ? (pageTitleToId.get(normalizedName) ?? null) + : null; + const currentTargetId = typeof attrs.targetId === "string" ? attrs.targetId : null; + const targetIdChanged = + resolvedTargetId !== null && resolvedTargetId !== currentTargetId; + + if ( + attrs.exists !== newExists || + attrs.referenced !== newReferenced || + targetIdChanged + ) { hasChanges = true; + const nextAttrs: Record = { + ...attrs, + exists: newExists, + referenced: newReferenced, + }; + if (targetIdChanged) { + nextAttrs.targetId = resolvedTargetId; + } return { ...mark, - attrs: { - ...attrs, - exists: newExists, - referenced: newReferenced, - }, + attrs: nextAttrs, }; } } diff --git a/src/lib/wikiLinkUtils.test.ts b/src/lib/wikiLinkUtils.test.ts index 781e35f3..e1e59aef 100644 --- a/src/lib/wikiLinkUtils.test.ts +++ b/src/lib/wikiLinkUtils.test.ts @@ -489,6 +489,67 @@ describe("updateWikiLinkAttributes", () => { expect(result.content).toBe(bad); expect(result.hasChanges).toBe(false); }); + + describe("targetId plumbing (issue #737)", () => { + // `pageTitleToId` を渡すと resolved リンクに `targetId` を埋める。 + // Pin the `targetId` plumbing introduced for issue #737. + const TARGET_ID = "11111111-aaaa-bbbb-cccc-000000000001"; + + function buildContent(extraAttrs: Record = {}): string { + return JSON.stringify({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Link", + marks: [ + { + type: "wikiLink", + attrs: { title: "Page", exists: false, referenced: false, ...extraAttrs }, + }, + ], + }, + ], + }, + ], + }); + } + + it("populates targetId when pageTitleToId is provided and link resolves", () => { + const content = buildContent(); + const map = new Map([["page", TARGET_ID]]); + const result = updateWikiLinkAttributes(content, new Set(["page"]), new Set(), map); + expect(result.hasChanges).toBe(true); + const attrs = JSON.parse(result.content).content[0].content[0].marks[0].attrs; + expect(attrs.exists).toBe(true); + expect(attrs.targetId).toBe(TARGET_ID); + }); + + it("leaves targetId untouched when pageTitleToId is omitted", () => { + // 既存マークの `targetId` は触らない契約を固定する。 + // Pin the contract that omitting the map does not blank a stale id. + const content = buildContent({ targetId: "preexisting-id" }); + const result = updateWikiLinkAttributes(content, new Set(["page"]), new Set()); + const attrs = JSON.parse(result.content).content[0].content[0].marks[0].attrs; + expect(attrs.targetId).toBe("preexisting-id"); + }); + + it("does not set targetId when the link does not resolve", () => { + const content = buildContent(); + const map = new Map([["other", TARGET_ID]]); + const result = updateWikiLinkAttributes(content, new Set(["other"]), new Set(), map); + // exists changed for "other"-not-page? No — the link title is "page" but + // pageTitles only contains "other", so newExists stays false. + // Reflect that hasChanges should be false for this configuration. + // タイトルは "page" だが pageTitles は "other" だけ持つので未解決のまま。 + expect(result.hasChanges).toBe(false); + const attrs = JSON.parse(result.content).content[0].content[0].marks[0].attrs; + expect(attrs.targetId).toBeUndefined(); + }); + }); }); describe("getUniqueWikiLinkTitles", () => { diff --git a/src/lib/wikiLinkUtils.ts b/src/lib/wikiLinkUtils.ts index f7202c6a..23b4a5b8 100644 --- a/src/lib/wikiLinkUtils.ts +++ b/src/lib/wikiLinkUtils.ts @@ -3,6 +3,15 @@ * ページ内のWikiLinkの解析と状態更新を行う */ +/** + * 同期・描画フローで扱う WikiLink マークの最小形。`exists` は同名ページが + * 解決可能かどうか、`referenced` は他ページからゴーストリンクで参照されて + * いるかを表す。 + * + * Minimal shape of a WikiLink consumed by sync / render flows. `exists` + * indicates whether a same-titled page resolves; `referenced` tracks ghost + * references from other pages. + */ export interface WikiLinkInfo { title: string; exists: boolean; @@ -66,12 +75,21 @@ export function extractWikiLinksFromContent(content: string): WikiLinkInfo[] { * @param content 元のTiptap JSONコンテンツ * @param pageTitles 存在するページのタイトルセット(小文字正規化済み) * @param referencedTitles 他ページから参照されているリンクテキストのセット(小文字正規化済み) + * @param pageTitleToId 正規化済みタイトル → ターゲットページ id のマップ。issue #737 で + * 追加。`exists` が true になるリンクに `targetId` 属性を埋めることで、リネーム伝播 + * が同名ページとの衝突を ID 一致で回避できるようにする。省略時は `targetId` を更新 + * しない(既存マークの値を温存する)。 + * Optional normalized title → target page id map (issue #737). When provided, + * resolved links get their `targetId` populated so server-side rename + * propagation can disambiguate same-title pages by id. When omitted, the + * existing `targetId` is left untouched. * @returns 更新されたコンテンツと変更があったかどうか */ export function updateWikiLinkAttributes( content: string, pageTitles: Set, referencedTitles: Set, + pageTitleToId?: Map, ): { content: string; hasChanges: boolean } { if (!content) return { content, hasChanges: false }; @@ -99,17 +117,37 @@ export function updateWikiLinkAttributes( const normalizedTitle = (attrs.title as string).toLowerCase().trim(); const newExists = pageTitles.has(normalizedTitle); const newReferenced = referencedTitles.has(normalizedTitle); + // 解決済みターゲット ID を埋める (issue #737)。`null` のまま + // 上書きしない (既存値温存) ため、resolved 時のみ書き換え対象。 + // Populate the resolved target id (issue #737). Never overwrite + // an existing id with `null`; only update when the link + // resolves and the map provides a fresh id. + const resolvedTargetId = + newExists && pageTitleToId !== undefined + ? (pageTitleToId.get(normalizedTitle) ?? null) + : null; + const currentTargetId = typeof attrs.targetId === "string" ? attrs.targetId : null; + const targetIdChanged = + resolvedTargetId !== null && resolvedTargetId !== currentTargetId; // 状態が変わった場合のみ更新 - if (attrs.exists !== newExists || attrs.referenced !== newReferenced) { + if ( + attrs.exists !== newExists || + attrs.referenced !== newReferenced || + targetIdChanged + ) { hasChanges = true; + const nextAttrs: Record = { + ...attrs, + exists: newExists, + referenced: newReferenced, + }; + if (targetIdChanged) { + nextAttrs.targetId = resolvedTargetId; + } return { ...mark, - attrs: { - ...attrs, - exists: newExists, - referenced: newReferenced, - }, + attrs: nextAttrs, }; } } diff --git a/src/pages/NoteSettings/index.tsx b/src/pages/NoteSettings/index.tsx index 5c6d5983..d9c1fe95 100644 --- a/src/pages/NoteSettings/index.tsx +++ b/src/pages/NoteSettings/index.tsx @@ -88,6 +88,14 @@ const NoteSettings: React.FC = () => { await deleteNoteMutation.mutateAsync(noteId); toast({ title: t("notes.noteDeleted") }); setIsDeleteDialogOpen(false); + // ノート(コンテナ)削除後はノート一覧 (`/notes`) へ戻す。個人ページ削除 + // (`usePageDeletion` / `NotePageView`) がホーム (`/home`) へ戻すのは、 + // 削除対象がページであり個人ホームに属しているため。両者は対象が異なる + // ので遷移先も異なる(PR #719 CodeRabbit の指摘への明示)。 + // After deleting a note (the container), return to the notes index + // (`/notes`). Page-level deletes go to `/home` because the deleted + // entity belongs to the personal home—different entities, different + // landing pages by design (clarified per PR #719 review feedback). navigate("/notes"); } catch (error) { console.error("Failed to delete note:", error); diff --git a/src/pages/SearchResults.tsx b/src/pages/SearchResults.tsx index 98c1c8c3..e399e766 100644 --- a/src/pages/SearchResults.tsx +++ b/src/pages/SearchResults.tsx @@ -16,6 +16,7 @@ import { calculateEnhancedScore, } from "@/lib/searchUtils"; import { useGlobalSearchContext } from "@/contexts/GlobalSearchContext"; +import { dedupSharedRowsAgainstPersonal } from "@/hooks/useGlobalSearch"; interface SearchResultItem extends SearchResultCardItem { snippet: string; @@ -68,14 +69,25 @@ export default function SearchResults() { }; }); - const shared: SearchResultItem[] = sharedResults.map((r) => { + // Issue #718 Phase 5-4: dedup 契約は `dedupSharedRowsAgainstPersonal` に集約。 + // 個人 IDB に既に出ている page id だけを shared から落とす。`note_id` が + // null でも他ユーザー所有のリンク済み個人ページは IDB に無いので残す + // (Codex / CodeRabbit 指摘)。 + // + // Issue #718 Phase 5-4: dedup is centralized in + // `dedupSharedRowsAgainstPersonal` and works by `pageId` so linked personal + // pages owned by other note members (which IDB does not have) survive + // (Codex / CodeRabbit review). + const personalIds = new Set(personal.map((item) => item.pageId)); + const shared: SearchResultItem[] = dedupSharedRowsAgainstPersonal( + sharedResults, + personalIds, + ).map((r) => { const preview = r.content_preview ?? ""; const snippet = extractSmartSnippet(preview, keywords, 200); const highlightedSnippet = highlightKeywords(snippet || "(共有ノート)", keywords); return { pageId: r.id, - // `note_id` は個人ページが混ざると null になり得るので undefined に正規化する。 - // `note_id` may be null when personal pages are mixed into shared results. noteId: r.note_id ?? undefined, title: r.title ?? "無題のページ", snippet, diff --git a/src/stores/aiChatStore.test.ts b/src/stores/aiChatStore.test.ts new file mode 100644 index 00000000..6700d056 --- /dev/null +++ b/src/stores/aiChatStore.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { act } from "@testing-library/react"; +import { useAIChatStore } from "./aiChatStore"; + +const INITIAL_STATE = { + isOpen: false, + activeConversationId: null, + isStreaming: false, + contextEnabled: true, + showConversationList: false, + selectedModel: null, +} as const; + +/** + * 各テストの前に zustand state と localStorage をリセットする。persist は + * 同じ key (`ai-chat-storage`) に書き込むため、明示クリアしないと前テストの + * 永続値が次テストの初期値になってしまう。 + * + * Reset zustand state and localStorage before each test. Without explicit + * clearing, persisted state from a previous test bleeds into the next one. + */ +function resetStore(): void { + act(() => { + useAIChatStore.setState(INITIAL_STATE); + }); +} + +describe("aiChatStore", () => { + beforeEach(() => { + localStorage.clear(); + resetStore(); + }); + + it("starts with the documented default state", () => { + expect(useAIChatStore.getState()).toMatchObject(INITIAL_STATE); + }); + + describe("panel actions", () => { + it("togglePanel flips isOpen", () => { + useAIChatStore.getState().togglePanel(); + expect(useAIChatStore.getState().isOpen).toBe(true); + + useAIChatStore.getState().togglePanel(); + expect(useAIChatStore.getState().isOpen).toBe(false); + }); + + it("openPanel / closePanel set absolute states", () => { + useAIChatStore.getState().openPanel(); + expect(useAIChatStore.getState().isOpen).toBe(true); + + useAIChatStore.getState().closePanel(); + expect(useAIChatStore.getState().isOpen).toBe(false); + + // 二重 close でも false のまま / closing twice stays false + useAIChatStore.getState().closePanel(); + expect(useAIChatStore.getState().isOpen).toBe(false); + }); + }); + + describe("conversation + streaming", () => { + it("setActiveConversation accepts ids and null", () => { + useAIChatStore.getState().setActiveConversation("conv-1"); + expect(useAIChatStore.getState().activeConversationId).toBe("conv-1"); + + useAIChatStore.getState().setActiveConversation(null); + expect(useAIChatStore.getState().activeConversationId).toBeNull(); + }); + + it("setStreaming toggles streaming flag", () => { + useAIChatStore.getState().setStreaming(true); + expect(useAIChatStore.getState().isStreaming).toBe(true); + + useAIChatStore.getState().setStreaming(false); + expect(useAIChatStore.getState().isStreaming).toBe(false); + }); + + it("toggleConversationList flips list visibility", () => { + useAIChatStore.getState().toggleConversationList(); + expect(useAIChatStore.getState().showConversationList).toBe(true); + + useAIChatStore.getState().toggleConversationList(); + expect(useAIChatStore.getState().showConversationList).toBe(false); + }); + }); + + describe("toggleContext", () => { + it("starts enabled and flips on each call", () => { + expect(useAIChatStore.getState().contextEnabled).toBe(true); + + useAIChatStore.getState().toggleContext(); + expect(useAIChatStore.getState().contextEnabled).toBe(false); + + useAIChatStore.getState().toggleContext(); + expect(useAIChatStore.getState().contextEnabled).toBe(true); + }); + }); + + describe("setSelectedModel", () => { + it("stores a model selection and clears with null", () => { + const model = { + id: "openai:gpt-4o-mini", + provider: "openai" as const, + model: "gpt-4o-mini", + displayName: "GPT-4o mini", + inputCostUnits: 1, + outputCostUnits: 2, + }; + + useAIChatStore.getState().setSelectedModel(model); + expect(useAIChatStore.getState().selectedModel).toEqual(model); + + useAIChatStore.getState().setSelectedModel(null); + expect(useAIChatStore.getState().selectedModel).toBeNull(); + }); + }); + + describe("persist + partialize", () => { + /** + * `partialize` は `isOpen` / `contextEnabled` / `selectedModel` のみを + * localStorage に書く契約。アクティブ会話やストリーミング状態は永続化しない + * (UI 立ち上げ直後にゾンビ "streaming" になるのを避けるため)。 + * + * The store is contracted to persist only `isOpen` / `contextEnabled` / + * `selectedModel`. Volatile UI state stays in memory. + */ + it("only persists the partialized fields", () => { + const model = { + id: "anthropic:claude-haiku", + provider: "anthropic" as const, + model: "claude-haiku", + displayName: "Claude Haiku", + }; + + useAIChatStore.getState().openPanel(); + useAIChatStore.getState().toggleContext(); // → false + useAIChatStore.getState().setSelectedModel(model); + // 揮発キー: これらは partialize で除外されるので localStorage に出ない + // volatile keys: excluded by partialize, must not be in localStorage + useAIChatStore.getState().setActiveConversation("conv-X"); + useAIChatStore.getState().setStreaming(true); + useAIChatStore.getState().toggleConversationList(); + + const raw = localStorage.getItem("ai-chat-storage"); + expect(raw).toBeTruthy(); + const parsed = JSON.parse(raw as string) as { + state: Record; + }; + + expect(parsed.state).toEqual({ + isOpen: true, + contextEnabled: false, + selectedModel: model, + }); + expect(parsed.state).not.toHaveProperty("activeConversationId"); + expect(parsed.state).not.toHaveProperty("isStreaming"); + expect(parsed.state).not.toHaveProperty("showConversationList"); + }); + + it("rehydrate restores persisted fields and leaves unspecified volatile fields at defaults", async () => { + const model = { + id: "openai:gpt-4o", + provider: "openai" as const, + model: "gpt-4o", + displayName: "GPT-4o", + }; + + localStorage.setItem( + "ai-chat-storage", + JSON.stringify({ + version: 1, + state: { + isOpen: true, + contextEnabled: false, + selectedModel: model, + // 永続化されてはいけないフィールドが万一 localStorage にあっても、 + // partialize されるので rehydrate 後に取り込まれない…はずが、zustand + // の persist はそのまま反映してしまう。partialize は書き込み側のみ。 + // ここでは契約として「書き込み時に partialize される」ことを担保する。 + // Even if volatile fields exist in storage, persist rehydrate can reflect them as-is. + // `partialize` only applies on write, so this test guards the write-side contract. + }, + }), + ); + + await useAIChatStore.persist.rehydrate(); + + const state = useAIChatStore.getState(); + expect(state.isOpen).toBe(true); + expect(state.contextEnabled).toBe(false); + expect(state.selectedModel).toEqual(model); + // CodeRabbit のレビュー対応: 揮発フィールドが rehydrate で蘇らないことを明示確認。 + // Pin volatile fields explicitly so the test name matches its assertions. + // テスト名と検証内容の整合を保つため、揮発フィールドを明示的に検証する。 + expect(state.activeConversationId).toBeNull(); + expect(state.isStreaming).toBe(false); + expect(state.showConversationList).toBe(false); + }); + }); +}); diff --git a/src/stores/pageStore.test.ts b/src/stores/pageStore.test.ts new file mode 100644 index 00000000..a5434f0c --- /dev/null +++ b/src/stores/pageStore.test.ts @@ -0,0 +1,324 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { act } from "@testing-library/react"; +import { usePageStore } from "./pageStore"; + +/** + * pageStore はゲストセッション向けの zustand + persist ストア。 + * 各テストで `setState` でリセットし、localStorage も明示的にクリアする。 + * + * pageStore is the zustand + persist guest store. Reset state via `setState` + * and clear localStorage between tests so persisted data does not leak. + */ +function resetStore(): void { + act(() => { + usePageStore.setState({ pages: [], links: [], ghostLinks: [] }); + }); +} + +describe("pageStore", () => { + beforeEach(() => { + localStorage.clear(); + resetStore(); + }); + + describe("createPage", () => { + it("creates a personal page with default empty title and content", () => { + const before = Date.now() - 1; + const page = usePageStore.getState().createPage(); + + expect(page.id).toBeTruthy(); + expect(page.title).toBe(""); + expect(page.content).toBe(""); + expect(page.ownerUserId).toBe("local-user"); + expect(page.noteId).toBeNull(); + expect(page.isDeleted).toBe(false); + expect(page.createdAt).toBeGreaterThan(before); + expect(page.updatedAt).toBe(page.createdAt); + + expect(usePageStore.getState().pages).toHaveLength(1); + expect(usePageStore.getState().pages[0]).toEqual(page); + }); + + it("prepends newly created pages to the list", () => { + const first = usePageStore.getState().createPage("first"); + const second = usePageStore.getState().createPage("second"); + + const ids = usePageStore.getState().pages.map((p) => p.id); + expect(ids).toEqual([second.id, first.id]); + }); + + it("persists created pages to localStorage", () => { + usePageStore.getState().createPage("Persisted", "body"); + + const raw = localStorage.getItem("zedi-pages"); + expect(raw).toBeTruthy(); + const parsed = JSON.parse(raw as string) as { state: { pages: Array<{ title: string }> } }; + expect(parsed.state.pages[0].title).toBe("Persisted"); + }); + }); + + describe("updatePage", () => { + it("merges updates and bumps updatedAt", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date(1_000_000)); + const page = usePageStore.getState().createPage("orig", "body"); + vi.setSystemTime(new Date(2_000_000)); + + usePageStore.getState().updatePage(page.id, { title: "updated" }); + + const stored = usePageStore.getState().getPage(page.id); + expect(stored?.title).toBe("updated"); + expect(stored?.content).toBe("body"); + expect(stored?.updatedAt).toBe(2_000_000); + } finally { + vi.useRealTimers(); + } + }); + + it("is a no-op for an unknown id", () => { + const page = usePageStore.getState().createPage("orig"); + const before = usePageStore.getState().pages; + + usePageStore.getState().updatePage("missing", { title: "x" }); + + expect(usePageStore.getState().pages).toEqual(before); + expect(usePageStore.getState().getPage(page.id)?.title).toBe("orig"); + }); + }); + + describe("deletePage", () => { + it("marks the page as deleted and drops attached links", () => { + const a = usePageStore.getState().createPage("A"); + const b = usePageStore.getState().createPage("B"); + const c = usePageStore.getState().createPage("C"); + + usePageStore.getState().addLink(a.id, b.id); + usePageStore.getState().addLink(c.id, a.id); + usePageStore.getState().addLink(b.id, c.id); + + usePageStore.getState().deletePage(a.id); + + const state = usePageStore.getState(); + expect(state.pages.find((p) => p.id === a.id)?.isDeleted).toBe(true); + // a を含むリンクは消え、b → c のリンクだけ残る + // links touching a are removed; b → c survives + expect(state.links).toEqual([ + expect.objectContaining({ sourceId: b.id, targetId: c.id, linkType: "wiki" }), + ]); + }); + + it("hides deleted pages from getPage / getPageByTitle", () => { + const page = usePageStore.getState().createPage("Hidden"); + usePageStore.getState().deletePage(page.id); + + expect(usePageStore.getState().getPage(page.id)).toBeUndefined(); + expect(usePageStore.getState().getPageByTitle("Hidden")).toBeUndefined(); + }); + }); + + describe("getPage / getPageByTitle", () => { + it("returns undefined for a missing id", () => { + expect(usePageStore.getState().getPage("missing")).toBeUndefined(); + }); + + it("getPageByTitle is case-insensitive and trims whitespace", () => { + const page = usePageStore.getState().createPage("Hello World"); + expect(usePageStore.getState().getPageByTitle(" hello world ")?.id).toBe(page.id); + expect(usePageStore.getState().getPageByTitle("HELLO WORLD")?.id).toBe(page.id); + }); + }); + + describe("addLink / removeLink", () => { + it("adds a wiki link by default and de-duplicates repeat inserts", () => { + usePageStore.getState().addLink("a", "b"); + usePageStore.getState().addLink("a", "b"); + + expect(usePageStore.getState().links).toEqual([ + expect.objectContaining({ sourceId: "a", targetId: "b", linkType: "wiki" }), + ]); + }); + + it("treats wiki and tag edges on the same pair as distinct rows (issue #725)", () => { + usePageStore.getState().addLink("a", "b", "wiki"); + usePageStore.getState().addLink("a", "b", "tag"); + + expect(usePageStore.getState().links).toHaveLength(2); + const types = usePageStore.getState().links.map((l) => l.linkType); + expect(types.sort()).toEqual(["tag", "wiki"]); + }); + + it("removeLink only deletes rows of the matching linkType", () => { + usePageStore.getState().addLink("a", "b", "wiki"); + usePageStore.getState().addLink("a", "b", "tag"); + + usePageStore.getState().removeLink("a", "b", "wiki"); + + expect(usePageStore.getState().links).toEqual([ + expect.objectContaining({ sourceId: "a", targetId: "b", linkType: "tag" }), + ]); + }); + }); + + describe("getOutgoingLinks / getBacklinks", () => { + it("filters by linkType (default 'wiki')", () => { + usePageStore.getState().addLink("a", "b", "wiki"); + usePageStore.getState().addLink("a", "c", "wiki"); + usePageStore.getState().addLink("a", "d", "tag"); + + expect(usePageStore.getState().getOutgoingLinks("a").sort()).toEqual(["b", "c"]); + expect(usePageStore.getState().getOutgoingLinks("a", "tag")).toEqual(["d"]); + }); + + it("getBacklinks returns sources pointing at the page", () => { + usePageStore.getState().addLink("p1", "target"); + usePageStore.getState().addLink("p2", "target"); + usePageStore.getState().addLink("p3", "other"); + + expect(usePageStore.getState().getBacklinks("target").sort()).toEqual(["p1", "p2"]); + expect(usePageStore.getState().getBacklinks("other")).toEqual(["p3"]); + }); + }); + + describe("ghost links", () => { + it("addGhostLink de-duplicates and supports linkType scoping", () => { + usePageStore.getState().addGhostLink("Topic", "p1"); + usePageStore.getState().addGhostLink("Topic", "p1"); + usePageStore.getState().addGhostLink("Topic", "p1", "tag"); + + expect(usePageStore.getState().ghostLinks).toHaveLength(2); + }); + + it("removeGhostLink only deletes the matching (text, source, type) tuple", () => { + usePageStore.getState().addGhostLink("Topic", "p1"); + usePageStore.getState().addGhostLink("Topic", "p2"); + + usePageStore.getState().removeGhostLink("Topic", "p1"); + + expect(usePageStore.getState().ghostLinks).toEqual([ + expect.objectContaining({ linkText: "Topic", sourcePageId: "p2", linkType: "wiki" }), + ]); + }); + + it("getGhostLinkSources collects pages by linkText + linkType", () => { + usePageStore.getState().addGhostLink("Topic", "p1"); + usePageStore.getState().addGhostLink("Topic", "p2"); + usePageStore.getState().addGhostLink("Topic", "p3", "tag"); + + expect(usePageStore.getState().getGhostLinkSources("Topic").sort()).toEqual(["p1", "p2"]); + expect(usePageStore.getState().getGhostLinkSources("Topic", "tag")).toEqual(["p3"]); + }); + + it("promoteGhostLink only promotes when 2+ sources exist for the wiki bucket", () => { + usePageStore.getState().addGhostLink("Solo", "p1"); + expect(usePageStore.getState().promoteGhostLink("Solo")).toBeNull(); + + usePageStore.getState().addGhostLink("Pair", "p1"); + usePageStore.getState().addGhostLink("Pair", "p2"); + + const promoted = usePageStore.getState().promoteGhostLink("Pair"); + expect(promoted).not.toBeNull(); + expect(promoted?.title).toBe("Pair"); + + const state = usePageStore.getState(); + // ゴーストは消費され、各ソースから新ページへの実リンクが張られる + // ghosts consumed; real links from each source to the promoted page exist + expect(state.ghostLinks.find((g) => g.linkText === "Pair")).toBeUndefined(); + const targets = state.links.filter((l) => l.targetId === promoted?.id).map((l) => l.sourceId); + expect(targets.sort()).toEqual(["p1", "p2"]); + }); + + it("promoteGhostLink ignores tag-only ghosts (issue #725 Phase 1)", () => { + usePageStore.getState().addGhostLink("Tagged", "p1", "tag"); + usePageStore.getState().addGhostLink("Tagged", "p2", "tag"); + + expect(usePageStore.getState().promoteGhostLink("Tagged")).toBeNull(); + expect(usePageStore.getState().pages).toHaveLength(0); + }); + }); + + describe("searchPages", () => { + beforeEach(() => { + usePageStore.getState().createPage("Hello World", "first body"); + usePageStore.getState().createPage("Other", "contains hello"); + usePageStore.getState().createPage("Trash", "ignored"); + }); + + it("returns title and content matches case-insensitively", () => { + const results = usePageStore.getState().searchPages("HELLO"); + expect(results.map((p) => p.title).sort()).toEqual(["Hello World", "Other"]); + }); + + it("returns [] for a blank query", () => { + expect(usePageStore.getState().searchPages(" ")).toEqual([]); + }); + + it("excludes soft-deleted pages from search results", () => { + const target = usePageStore.getState().getPageByTitle("Hello World"); + if (!target) throw new Error("fixture page missing"); + usePageStore.getState().deletePage(target.id); + + const results = usePageStore.getState().searchPages("hello"); + expect(results.map((p) => p.title)).toEqual(["Other"]); + }); + }); + + describe("persist migrate", () => { + /** + * persist の `migrate` は zustand 内部から呼ばれる private 関数なので、ここでは + * 永続化キーに古いバージョンの payload を直接書き、ストアを `rehydrate` で + * 再水和して結果を確認する。 + * + * `migrate` is invoked internally by zustand. We seed localStorage with an + * older-versioned payload and trigger `rehydrate()` to validate the upgrade. + */ + it("backfills missing noteId to null (v1 → v2)", async () => { + localStorage.setItem( + "zedi-pages", + JSON.stringify({ + version: 1, + state: { + pages: [ + { + id: "old-1", + ownerUserId: "local-user", + title: "Legacy", + content: "", + createdAt: 1, + updatedAt: 1, + isDeleted: false, + }, + ], + links: [], + ghostLinks: [], + }, + }), + ); + + await usePageStore.persist.rehydrate(); + + const page = usePageStore.getState().pages.find((p) => p.id === "old-1"); + expect(page?.noteId).toBeNull(); + }); + + it("backfills missing linkType to 'wiki' for links and ghost links (v2 → v3)", async () => { + localStorage.setItem( + "zedi-pages", + JSON.stringify({ + version: 2, + state: { + pages: [], + links: [{ sourceId: "a", targetId: "b", createdAt: 1 }], + ghostLinks: [{ linkText: "X", sourcePageId: "a", createdAt: 1 }], + }, + }), + ); + + await usePageStore.persist.rehydrate(); + + const state = usePageStore.getState(); + expect(state.links[0].linkType).toBe("wiki"); + expect(state.ghostLinks[0].linkType).toBe("wiki"); + }); + }); +}); diff --git a/src/stores/pageStore.ts b/src/stores/pageStore.ts index c7edd42c..8c7ef596 100644 --- a/src/stores/pageStore.ts +++ b/src/stores/pageStore.ts @@ -1,7 +1,7 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import { v4 as uuidv4 } from "uuid"; -import type { Page, Link, GhostLink } from "@/types/page"; +import type { Page, Link, GhostLink, LinkType } from "@/types/page"; /** * ゲストセッション向けのインメモリページストアのインターフェース。 @@ -25,16 +25,17 @@ interface PageStore { getPage: (id: string) => Page | undefined; getPageByTitle: (title: string) => Page | undefined; - // Link operations - addLink: (sourceId: string, targetId: string) => void; - removeLink: (sourceId: string, targetId: string) => void; - getOutgoingLinks: (pageId: string) => string[]; - getBacklinks: (pageId: string) => string[]; + // Link operations. `linkType` は issue #725 Phase 1 で追加。未指定は `'wiki'`。 + // `linkType` added in issue #725 Phase 1; defaults to `'wiki'`. + addLink: (sourceId: string, targetId: string, linkType?: LinkType) => void; + removeLink: (sourceId: string, targetId: string, linkType?: LinkType) => void; + getOutgoingLinks: (pageId: string, linkType?: LinkType) => string[]; + getBacklinks: (pageId: string, linkType?: LinkType) => string[]; // Ghost link operations - addGhostLink: (linkText: string, sourcePageId: string) => void; - removeGhostLink: (linkText: string, sourcePageId: string) => void; - getGhostLinkSources: (linkText: string) => string[]; + addGhostLink: (linkText: string, sourcePageId: string, linkType?: LinkType) => void; + removeGhostLink: (linkText: string, sourcePageId: string, linkType?: LinkType) => void; + getGhostLinkSources: (linkText: string, linkType?: LinkType) => string[]; promoteGhostLink: (linkText: string) => Page | null; // Search @@ -103,76 +104,99 @@ export const usePageStore = create()( ); }, - addLink: (sourceId, targetId) => { + addLink: (sourceId, targetId, linkType = "wiki") => { const exists = get().links.some( - (link) => link.sourceId === sourceId && link.targetId === targetId, + (link) => + link.sourceId === sourceId && link.targetId === targetId && link.linkType === linkType, ); if (!exists) { set((state) => ({ - links: [...state.links, { sourceId, targetId, createdAt: Date.now() }], + links: [...state.links, { sourceId, targetId, linkType, createdAt: Date.now() }], })); } }, - removeLink: (sourceId, targetId) => { + removeLink: (sourceId, targetId, linkType = "wiki") => { set((state) => ({ links: state.links.filter( - (link) => !(link.sourceId === sourceId && link.targetId === targetId), + (link) => + !( + link.sourceId === sourceId && + link.targetId === targetId && + link.linkType === linkType + ), ), })); }, - getOutgoingLinks: (pageId) => { + getOutgoingLinks: (pageId, linkType = "wiki") => { return get() - .links.filter((link) => link.sourceId === pageId) + .links.filter((link) => link.sourceId === pageId && link.linkType === linkType) .map((link) => link.targetId); }, - getBacklinks: (pageId) => { + getBacklinks: (pageId, linkType = "wiki") => { return get() - .links.filter((link) => link.targetId === pageId) + .links.filter((link) => link.targetId === pageId && link.linkType === linkType) .map((link) => link.sourceId); }, - addGhostLink: (linkText, sourcePageId) => { + addGhostLink: (linkText, sourcePageId, linkType = "wiki") => { const exists = get().ghostLinks.some( - (gl) => gl.linkText === linkText && gl.sourcePageId === sourcePageId, + (gl) => + gl.linkText === linkText && + gl.sourcePageId === sourcePageId && + gl.linkType === linkType, ); if (!exists) { set((state) => ({ - ghostLinks: [...state.ghostLinks, { linkText, sourcePageId, createdAt: Date.now() }], + ghostLinks: [ + ...state.ghostLinks, + { linkText, sourcePageId, linkType, createdAt: Date.now() }, + ], })); } }, - removeGhostLink: (linkText, sourcePageId) => { + removeGhostLink: (linkText, sourcePageId, linkType = "wiki") => { set((state) => ({ ghostLinks: state.ghostLinks.filter( - (gl) => !(gl.linkText === linkText && gl.sourcePageId === sourcePageId), + (gl) => + !( + gl.linkText === linkText && + gl.sourcePageId === sourcePageId && + gl.linkType === linkType + ), ), })); }, - getGhostLinkSources: (linkText) => { + getGhostLinkSources: (linkText, linkType = "wiki") => { return get() - .ghostLinks.filter((gl) => gl.linkText === linkText) + .ghostLinks.filter((gl) => gl.linkText === linkText && gl.linkType === linkType) .map((gl) => gl.sourcePageId); }, promoteGhostLink: (linkText) => { - const sources = get().getGhostLinkSources(linkText); + // ゴースト昇格は WikiLink 限定。タグゴーストは通常のタグ同期で解決する + // 想定のため、多元ソース昇格の対象外(issue #725 Phase 1)。 + // Promotion is wiki-only; tag ghosts are resolved via tag sync, not + // multi-source promotion (issue #725 Phase 1). + const sources = get().getGhostLinkSources(linkText, "wiki"); if (sources.length >= 2) { // Create a new page from the ghost link const newPage = get().createPage(linkText); // Convert ghost links to real links sources.forEach((sourceId) => { - get().addLink(sourceId, newPage.id); + get().addLink(sourceId, newPage.id, "wiki"); }); // Remove ghost links set((state) => ({ - ghostLinks: state.ghostLinks.filter((gl) => gl.linkText !== linkText), + ghostLinks: state.ghostLinks.filter( + (gl) => !(gl.linkText === linkText && gl.linkType === "wiki"), + ), })); return newPage; @@ -201,22 +225,45 @@ export const usePageStore = create()( // これをしないと deserialize 後 `page.noteId === undefined` となり、 // `noteId === null` を期待するコード(個人ページ判定)で取りこぼす。 // + // v3: `Link.linkType` / `GhostLink.linkType` (Issue #725 Phase 1) を必須化 + // したため、v2 以前で永続化された `linkType` 未設定の行を `'wiki'` に寄せる。 + // これをしないと `addLink` / `removeLink` 等の `linkType === linkType` 比較 + // が失敗し、重複 insert や削除漏れが起きる(IndexedDB 側は `migrateLinkStoreToV3` + // で対処済、その対応物をゲストストアでも実行する)。 + // // v2: persisted pages from v1 (pre-#713) lack `noteId`. Backfill them to - // `null` on load so the `Page` type contract (`noteId: string | null`) - // holds. Otherwise `page.noteId === undefined` would slip past any - // `noteId === null` check intended to identify personal pages. - version: 2, + // `null` on load so the `Page` type contract (`noteId: string | null`) holds. + // v3: persisted links / ghost links from v1–v2 lack `linkType` (issue + // #725 Phase 1). Backfill to `'wiki'` so the new `linkType === linkType` + // comparisons in `addLink` / `removeLink` don't silently drop to the + // `undefined === 'wiki'` branch. Mirrors the IndexedDB v3 migration. + version: 3, migrate: (persistedState: unknown, version: number) => { - if ( - version < 2 && - persistedState && - typeof persistedState === "object" && - "pages" in persistedState && - Array.isArray((persistedState as { pages: unknown }).pages) - ) { - const state = persistedState as { pages: Array> }; + if (!persistedState || typeof persistedState !== "object") { + return persistedState; + } + const state = persistedState as { + pages?: Array>; + links?: Array>; + ghostLinks?: Array>; + }; + + if (version < 2 && Array.isArray(state.pages)) { state.pages = state.pages.map((p) => ({ ...p, noteId: p.noteId ?? null })); } + + if (version < 3) { + if (Array.isArray(state.links)) { + state.links = state.links.map((l) => ({ ...l, linkType: l.linkType ?? "wiki" })); + } + if (Array.isArray(state.ghostLinks)) { + state.ghostLinks = state.ghostLinks.map((g) => ({ + ...g, + linkType: g.linkType ?? "wiki", + })); + } + } + return persistedState; }, }, diff --git a/src/types/page.ts b/src/types/page.ts index 311ba2b9..dd765cda 100644 --- a/src/types/page.ts +++ b/src/types/page.ts @@ -52,22 +52,40 @@ export interface PageSummary { } /** - * ページ間のリンク(source → target)。 - * Link between two pages (source → target). + * `links` / `ghost_links` で共有する種別識別子。サーバ側 `link_type` カラムに対応。 + * Link kind shared by `links` / `ghost_links`; mirrors the server `link_type` column. + * + * - `"wiki"`: WikiLink `[[Title]]` (legacy default). + * - `"tag"`: Hashtag `#name` (issue #725 Phase 1)。 + */ +export type LinkType = "wiki" | "tag"; + +/** `link_type` に許容される文字列値。 / Allowed `link_type` values. */ +export const LINK_TYPES: readonly LinkType[] = ["wiki", "tag"] as const; + +/** + * ページ間のリンク(source → target)。`linkType` で WikiLink とタグを区別する。 + * Link between two pages (source → target); `linkType` distinguishes WikiLink vs. tag. */ export interface Link { sourceId: string; targetId: string; + /** + * `'wiki'` | `'tag'`。Issue #725 で追加。未指定の旧コードパスは `'wiki'` として扱う。 + * Added by issue #725; legacy callers default to `'wiki'`. + */ + linkType: LinkType; createdAt: number; } /** - * 対象ページがまだ存在しない WikiLink(未解決リンク)。 - * Unresolved WikiLink whose target page does not yet exist. + * 対象ページがまだ存在しない WikiLink / タグ(未解決リンク)。`linkType` で種別を区別する。 + * Unresolved WikiLink or tag; `linkType` distinguishes which flavor is ghosted. */ export interface GhostLink { linkText: string; sourcePageId: string; + linkType: LinkType; createdAt: number; }