diff --git a/server/api/src/__tests__/createMockDb.ts b/server/api/src/__tests__/createMockDb.ts index f14587c7..6029b0df 100644 --- a/server/api/src/__tests__/createMockDb.ts +++ b/server/api/src/__tests__/createMockDb.ts @@ -50,6 +50,14 @@ export function createMockDb(results: unknown[]) { if (prop === "transaction") { return (fn: (tx: typeof db) => Promise) => fn(db); } + if (prop === "execute") { + return (...args: unknown[]) => { + const idx = chainIndex++; + const ops: { method: string; args: unknown[] }[] = []; + chains.push({ startMethod: "execute", startArgs: args, ops }); + return makeChainProxy(idx, ops); + }; + } return (...args: unknown[]) => { const idx = chainIndex++; const ops: { method: string; args: unknown[] }[] = []; diff --git a/server/api/src/__tests__/routes/clip.test.ts b/server/api/src/__tests__/routes/clip.test.ts index e3a7689e..82c19ffc 100644 --- a/server/api/src/__tests__/routes/clip.test.ts +++ b/server/api/src/__tests__/routes/clip.test.ts @@ -1,64 +1,96 @@ /** - * /api/clip ルートのテスト(fetch の SSRF 拒否・認証) - * Tests for clip routes: fetch SSRF rejection and auth. + * /api/clip ルートのテスト(fetch の SSRF 拒否・認証、YouTube クリップ) + * Tests for clip routes: fetch SSRF rejection, auth, and YouTube clip. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { Context, Next } from "hono"; +import type { AppEnv } from "../../types/index.js"; -type AuthSession = Awaited>; - -// youtube-transcript は CJS/ESM 互換性問題があるのでモック -// Mock youtube-transcript (CJS/ESM compatibility workaround) vi.mock("youtube-transcript", () => ({ YoutubeTranscript: { fetchTranscript: vi.fn().mockResolvedValue([]), }, })); -vi.mock("../../db/client.js", () => ({ - getDb: vi.fn(() => ({})), +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 mockSessionUser = { - id: "user-1", - email: "u@e.com", - name: "", - image: null as string | null, - emailVerified: true, - createdAt: new Date(), - updatedAt: new Date(), - role: null as string | null, -}; - -vi.mock("../../auth.js", () => ({ - auth: { api: { getSession: vi.fn() } }, +const { + mockExtractYouTubeContent, + mockResolveAiConfigForRequest, + mockCalculateCost, + mockRecordUsage, +} = vi.hoisted(() => ({ + mockExtractYouTubeContent: vi.fn(), + mockResolveAiConfigForRequest: vi.fn(), + mockCalculateCost: vi.fn(), + mockRecordUsage: vi.fn(), +})); + +vi.mock("../../services/youtubeExtractor.js", () => ({ + extractYouTubeContent: (...args: unknown[]) => mockExtractYouTubeContent(...args), +})); + +vi.mock("../../services/aiAccessHelpers.js", () => ({ + resolveAiConfigForRequest: (...args: unknown[]) => mockResolveAiConfigForRequest(...args), +})); + +vi.mock("../../services/usageService.js", () => ({ + calculateCost: (...args: unknown[]) => mockCalculateCost(...args), + recordUsage: (...args: unknown[]) => mockRecordUsage(...args), })); -import { auth } from "../../auth.js"; import { Hono } from "hono"; import { errorHandler } from "../../middleware/errorHandler.js"; import clipRoutes from "../../routes/clip.js"; -import type { AppEnv } from "../../types/index.js"; +import { createMockDb } from "../createMockDb.js"; + +const TEST_USER_ID = "user-clip-1"; function createClipApp() { + const { db } = createMockDb([]); const app = new Hono(); app.onError(errorHandler); + app.use("*", async (c, next) => { + c.set("db", db as unknown as AppEnv["Variables"]["db"]); + await next(); + }); app.route("/api/clip", clipRoutes); return app; } +function authHeaders(): Record { + return { + "x-test-user-id": TEST_USER_ID, + "Content-Type": "application/json", + }; +} + describe("POST /api/clip/fetch", () => { const originalFetch = globalThis.fetch; beforeEach(() => { - vi.mocked(auth.api.getSession).mockResolvedValue({ user: mockSessionUser } as AuthSession); + mockResolveAiConfigForRequest.mockReset().mockResolvedValue(null); }); afterEach(() => { globalThis.fetch = originalFetch; + vi.restoreAllMocks(); }); it("returns 401 when session is missing", async () => { - vi.mocked(auth.api.getSession).mockResolvedValue(null); const app = createClipApp(); const res = await app.request("/api/clip/fetch", { method: "POST", @@ -72,7 +104,7 @@ describe("POST /api/clip/fetch", () => { const app = createClipApp(); const res = await app.request("/api/clip/fetch", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: authHeaders(), body: JSON.stringify({}), }); expect(res.status).toBe(400); @@ -82,7 +114,7 @@ describe("POST /api/clip/fetch", () => { const app = createClipApp(); const res = await app.request("/api/clip/fetch", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: authHeaders(), body: JSON.stringify({ url: "http://localhost/page" }), }); expect(res.status).toBe(400); @@ -94,7 +126,7 @@ describe("POST /api/clip/fetch", () => { const app = createClipApp(); const res = await app.request("/api/clip/fetch", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: authHeaders(), body: JSON.stringify({ url: "http://127.0.0.1:8080/" }), }); expect(res.status).toBe(400); @@ -113,7 +145,7 @@ describe("POST /api/clip/fetch", () => { const app = createClipApp(); const res = await app.request("/api/clip/fetch", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: authHeaders(), body: JSON.stringify({ url: "http://8.8.8.8/article" }), }); expect(res.status).toBe(200); @@ -121,4 +153,153 @@ describe("POST /api/clip/fetch", () => { expect(body.html).toBe("hi"); expect(body.content_type).toBe("text/html"); }); + + it("returns 502 when fetch times out", async () => { + globalThis.fetch = vi.fn().mockImplementation( + () => + new Promise((_resolve, reject) => { + const err = new Error("aborted"); + err.name = "AbortError"; + reject(err); + }), + ) as unknown as typeof fetch; + + const app = createClipApp(); + const res = await app.request("/api/clip/fetch", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ url: "http://8.8.8.8/slow" }), + }); + expect(res.status).toBe(502); + const body = (await res.json()) as { error?: string }; + expect(body.error).toMatch(/timed out/i); + }); +}); + +describe("POST /api/clip/youtube", () => { + const youtubeUrl = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; + + beforeEach(() => { + mockExtractYouTubeContent.mockReset().mockResolvedValue({ + title: "Video title", + thumbnailUrl: "https://img.youtube.com/thumb.jpg", + tiptapJson: { type: "doc", content: [] }, + contentText: "transcript text", + contentHash: "hash-vid", + finalUrl: youtubeUrl, + aiUsage: null, + }); + mockResolveAiConfigForRequest.mockReset().mockResolvedValue(null); + mockCalculateCost.mockReset().mockReturnValue(5); + mockRecordUsage.mockReset().mockResolvedValue(undefined); + }); + + it("returns 401 without auth", async () => { + const app = createClipApp(); + const res = await app.request("/api/clip/youtube", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: youtubeUrl }), + }); + expect(res.status).toBe(401); + }); + + it("returns 400 when url is missing", async () => { + const app = createClipApp(); + const res = await app.request("/api/clip/youtube", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + }); + + it("returns 400 for non-YouTube URLs", async () => { + const app = createClipApp(); + const res = await app.request("/api/clip/youtube", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ url: "https://example.com/not-youtube" }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error?: string }; + expect(body.error).toMatch(/not a valid YouTube/i); + }); + + it("returns 400 for invalid JSON body", async () => { + const app = createClipApp(); + const res = await app.request("/api/clip/youtube", { + method: "POST", + headers: authHeaders(), + body: "not-json", + }); + expect(res.status).toBe(400); + }); + + it("returns clip payload on success", async () => { + const app = createClipApp(); + const res = await app.request("/api/clip/youtube", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ url: youtubeUrl }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { title: string; contentHash: string }; + expect(body.title).toBe("Video title"); + expect(body.contentHash).toBe("hash-vid"); + expect(mockExtractYouTubeContent).toHaveBeenCalledWith( + expect.objectContaining({ videoId: "dQw4w9WgXcQ" }), + ); + expect(mockResolveAiConfigForRequest).toHaveBeenCalledWith( + expect.objectContaining({ userId: TEST_USER_ID }), + ); + }); + + it("records usage when AI summary succeeds", async () => { + mockResolveAiConfigForRequest.mockResolvedValue({ + provider: "openai", + apiModelId: "gpt-4o-mini", + internalModelId: "gpt-4o-mini", + apiKey: "sk-test", + modelInfo: { inputCostUnits: 1, outputCostUnits: 2 }, + }); + mockExtractYouTubeContent.mockResolvedValue({ + title: "AI summary", + thumbnailUrl: null, + tiptapJson: {}, + contentText: "text", + contentHash: "h2", + finalUrl: youtubeUrl, + aiUsage: { inputTokens: 100, outputTokens: 50 }, + }); + const app = createClipApp(); + const res = await app.request("/api/clip/youtube", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ url: youtubeUrl, provider: "openai", model: "gpt-4o-mini" }), + }); + expect(res.status).toBe(200); + expect(mockRecordUsage).toHaveBeenCalledWith( + TEST_USER_ID, + "gpt-4o-mini", + "youtube_summary", + { inputTokens: 100, outputTokens: 50 }, + 5, + "system", + expect.anything(), + ); + }); + + it("returns 502 when extraction fails", async () => { + mockExtractYouTubeContent.mockRejectedValue(new Error("YouTube API down")); + const app = createClipApp(); + const res = await app.request("/api/clip/youtube", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ url: youtubeUrl }), + }); + expect(res.status).toBe(502); + const body = (await res.json()) as { error?: string }; + expect(body.error).toMatch(/YouTube API down/i); + }); }); diff --git a/server/api/src/__tests__/routes/composeSessionProjection.test.ts b/server/api/src/__tests__/routes/composeSessionProjection.test.ts index 30d1a00a..9a8c8d93 100644 --- a/server/api/src/__tests__/routes/composeSessionProjection.test.ts +++ b/server/api/src/__tests__/routes/composeSessionProjection.test.ts @@ -2,8 +2,30 @@ * `composeSessionProjection` のユニットテスト (#950)。 * Unit tests for `composeSessionProjection`. */ -import { describe, expect, it } from "vitest"; -import { projectComposeStateValues } from "../../routes/composeSessionProjection.js"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const { mockResolveCheckpointerForRun, mockGetRegisteredGraph } = vi.hoisted(() => ({ + mockResolveCheckpointerForRun: vi.fn(), + mockGetRegisteredGraph: vi.fn(), +})); + +vi.mock("../../agents/core/checkpoint/index.js", () => ({ + resolveCheckpointerForRun: (...args: unknown[]) => mockResolveCheckpointerForRun(...args), +})); + +vi.mock("../../agents/registry/graphRegistry.js", () => ({ + getRegisteredGraph: (...args: unknown[]) => mockGetRegisteredGraph(...args), +})); + +import { + loadComposeSessionProjection, + projectComposeStateValues, +} from "../../routes/composeSessionProjection.js"; + +beforeEach(() => { + mockResolveCheckpointerForRun.mockReset(); + mockGetRegisteredGraph.mockReset(); +}); describe("projectComposeStateValues", () => { it("projects a Brief interrupt from __interrupt__", () => { @@ -83,4 +105,124 @@ describe("projectComposeStateValues", () => { expect(projection.draftedSections).toHaveLength(1); expect(projection.phase).toBe("completed"); }); + + it("projects human_review_outline interrupt", () => { + const projection = projectComposeStateValues({ + __interrupt__: [ + { + value: { + kind: "human_review_outline", + outline: [{ sectionId: "s1", heading: "Intro" }], + approvedSources: [{ id: "src:1" }], + }, + }, + ], + }); + expect(projection.phase).toBe("structure"); + expect(projection.outlineProposal).toHaveLength(1); + expect(projection.approvedSources).toHaveLength(1); + }); + + it("falls back to approvedOutline sections when outlineProposal is absent", () => { + const projection = projectComposeStateValues({ + approvedOutline: { sections: [{ sectionId: "s2", heading: "Body" }] }, + }); + expect(projection.outlineProposal).toHaveLength(1); + }); + + it("uses row phase fallback when no interrupt phase is derived", () => { + const projection = projectComposeStateValues({ + phase: "draft:writing", + draftedSections: [{ sectionId: "d1" }], + }); + expect(projection.phase).toBe("draft"); + }); + + it("maps batches to latestBatch", () => { + const projection = projectComposeStateValues({ + batches: [{ id: "b1" }, { id: "b2" }], + }); + expect(projection.latestBatch).toMatchObject({ id: "b2" }); + }); +}); + +describe("loadComposeSessionProjection", () => { + const baseInput = { + sessionId: "sess-1", + pageId: "page-1", + graphId: "graph-1", + status: "interrupted" as const, + phase: "brief:await_user", + context: { + threadId: "sess-1", + sessionId: "sess-1", + userId: "user-1", + userEmail: null, + pageId: "page-1", + graphId: "graph-1", + backend: "zedi_managed" as const, + tier: "free" as const, + db: {} as never, + feature: "compose_projection_test", + contentLocale: "ja" as const, + }, + }; + + it("returns null for pending sessions", async () => { + const result = await loadComposeSessionProjection({ + ...baseInput, + status: "pending", + }); + expect(result).toBeNull(); + }); + + it("returns null when checkpointing is disabled", async () => { + mockResolveCheckpointerForRun.mockResolvedValue(false); + const result = await loadComposeSessionProjection(baseInput); + expect(result).toBeNull(); + }); + + it("returns null when graph is not registered", async () => { + mockResolveCheckpointerForRun.mockResolvedValue({}); + mockGetRegisteredGraph.mockReturnValue(undefined); + const result = await loadComposeSessionProjection(baseInput); + expect(result).toBeNull(); + }); + + it("loads projection from checkpoint state", async () => { + mockResolveCheckpointerForRun.mockResolvedValue({}); + mockGetRegisteredGraph.mockReturnValue({ + factory: () => ({ + getState: async () => ({ + values: { + phase: "brief:await_user", + __interrupt__: [ + { + value: { + kind: "human_review_brief", + questions: [{ id: "q1", question: "Q?" }], + }, + }, + ], + }, + }), + }), + }); + const result = await loadComposeSessionProjection(baseInput); + expect(result?.phase).toBe("brief"); + expect(result?.briefQuestions).toHaveLength(1); + }); + + it("returns null when getState throws", async () => { + mockResolveCheckpointerForRun.mockResolvedValue({}); + mockGetRegisteredGraph.mockReturnValue({ + factory: () => ({ + getState: async () => { + throw new Error("checkpoint missing"); + }, + }), + }); + const result = await loadComposeSessionProjection(baseInput); + expect(result).toBeNull(); + }); }); diff --git a/server/api/src/__tests__/routes/composeSessions.test.ts b/server/api/src/__tests__/routes/composeSessions.test.ts index e40e4359..fa22bf1d 100644 --- a/server/api/src/__tests__/routes/composeSessions.test.ts +++ b/server/api/src/__tests__/routes/composeSessions.test.ts @@ -45,17 +45,64 @@ vi.mock("../../services/userAiCredentialService.js", () => ({ getUserAiCredentialPlaintext: (...args: unknown[]) => mockGetUserAiCredentialPlaintext(...args), })); +const { mockLoadComposeSessionProjection, mockGraphRunnerStreamEvents, mockGraphRunnerResume } = + vi.hoisted(() => ({ + mockLoadComposeSessionProjection: vi.fn(), + mockGraphRunnerStreamEvents: vi.fn(), + mockGraphRunnerResume: vi.fn(), + })); + +vi.mock("../../routes/composeSessionProjection.js", () => ({ + loadComposeSessionProjection: (...args: unknown[]) => mockLoadComposeSessionProjection(...args), + projectComposeStateValues: vi.fn(), +})); + +vi.mock("../../agents/runner/graphRunner.js", () => ({ + GraphRunner: class { + streamEvents = (...args: unknown[]) => mockGraphRunnerStreamEvents(...args); + invoke = vi.fn(); + resume = (...args: unknown[]) => mockGraphRunnerResume(...args); + }, +})); + +vi.mock("../../agents/core/checkpoint/index.js", () => ({ + resolveCheckpointerForRun: vi.fn().mockResolvedValue(false), +})); + import { Hono } from "hono"; import composeSessionRoutes from "../../routes/composeSessions.js"; import { errorHandler } from "../../middleware/errorHandler.js"; import { createMockDb } from "../createMockDb.js"; -import { __resetRegistryForTests, registerGraph } from "../../agents/registry/graphRegistry.js"; +import { + __resetRegistryForTests, + GraphNotRegisteredError, + registerGraph, +} from "../../agents/registry/graphRegistry.js"; const OWNER_ID = "owner-1"; +const OTHER_USER_ID = "other-user-99"; const PAGE_ID = "page-1"; const NOTE_ID = "note-1"; const GRAPH_ID = "test-graph"; +function sessionRow(overrides: Record = {}) { + return { + id: "sess-default", + pageId: PAGE_ID, + userId: OWNER_ID, + graphId: GRAPH_ID, + phase: "init", + backend: "zedi_managed", + status: "pending", + metadata: null, + lastError: null, + createdAt: new Date(), + updatedAt: new Date(), + closedAt: null, + ...overrides, + }; +} + function authHeaders(userId: string = OWNER_ID) { return { "x-test-user-id": userId, @@ -112,6 +159,11 @@ beforeEach(() => { inputCostUnits: 1, outputCostUnits: 2, }); + mockLoadComposeSessionProjection.mockReset().mockResolvedValue(null); + mockGraphRunnerStreamEvents.mockReset().mockImplementation(async function* () { + yield { event: "on_chain_end", data: {} }; + }); + mockGraphRunnerResume.mockReset().mockRejectedValue(new GraphNotRegisteredError("graph-removed")); __resetRegistryForTests(); // Register a graph the routes can resolve. Body is irrelevant for CRUD tests. registerGraph({ @@ -261,6 +313,38 @@ describe("POST /api/pages/:pageId/compose-sessions", () => { }); describe("GET /api/pages/:pageId/compose-sessions/:id", () => { + it("returns 401 without auth", async () => { + const { app } = createComposeApp([]); + const res = await app.request(`/api/pages/${PAGE_ID}/compose-sessions/sess-2`); + expect(res.status).toBe(401); + }); + + it("returns 403 when caller has no role on the note (private note, non-member)", async () => { + const privateNote = { + id: NOTE_ID, + ownerId: OTHER_USER_ID, + title: "n", + visibility: "private" as const, + editPermission: "owner_only" as const, + isOfficial: false, + viewCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + isDeleted: false, + }; + const { app } = createComposeApp([ + [{ id: PAGE_ID, ownerId: OTHER_USER_ID, noteId: NOTE_ID }], + [{ email: "owner@example.com" }], + [privateNote], + [], + [], + ]); + const res = await app.request(`/api/pages/${PAGE_ID}/compose-sessions/sess-2`, { + headers: authHeaders(OWNER_ID), + }); + expect(res.status).toBe(403); + }); + it("returns 404 when the session row is not found", async () => { const { app } = createComposeApp([ ...pageAccessPrefix(), @@ -295,6 +379,181 @@ describe("GET /api/pages/:pageId/compose-sessions/:id", () => { const body = (await res.json()) as { session: { id: string } }; expect(body.session.id).toBe("sess-2"); }); + + it("returns session without projection when backend is unsupported (stale row)", async () => { + const row = sessionRow({ id: "sess-byok-read", backend: "byok", status: "interrupted" }); + const { app } = createComposeApp([...pageAccessPrefix(), [row]]); + const res = await app.request(`/api/pages/${PAGE_ID}/compose-sessions/sess-byok-read`, { + headers: authHeaders(), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { session: { backend: string }; projection: null }; + expect(body.session.backend).toBe("byok"); + expect(body.projection).toBeNull(); + expect(mockLoadComposeSessionProjection).not.toHaveBeenCalled(); + }); + + it("includes projection for interrupted sessions", async () => { + const row = sessionRow({ id: "sess-int", status: "interrupted", phase: "brief:await_user" }); + mockLoadComposeSessionProjection.mockResolvedValue({ + phase: "brief", + briefQuestions: [{ id: "q1" }], + }); + const { app } = createComposeApp([...pageAccessPrefix(), [row]]); + const res = await app.request(`/api/pages/${PAGE_ID}/compose-sessions/sess-int`, { + headers: authHeaders(), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + session: { status: string }; + projection: { phase: string }; + }; + expect(body.session.status).toBe("interrupted"); + expect(body.projection.phase).toBe("brief"); + expect(mockLoadComposeSessionProjection).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: "sess-int", graphId: GRAPH_ID }), + ); + }); +}); + +describe("POST /api/pages/:pageId/compose-sessions/:id/run", () => { + it("returns 401 without auth", async () => { + const { app } = createComposeApp([]); + const res = await app.request(`/api/pages/${PAGE_ID}/compose-sessions/sess-run/run`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(401); + }); + + it("returns 404 when session is missing", async () => { + const { app } = createComposeApp([...pageAccessPrefix(), []]); + const res = await app.request(`/api/pages/${PAGE_ID}/compose-sessions/missing/run`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({}), + }); + expect(res.status).toBe(404); + }); + + it("returns 409 when session is interrupted", async () => { + const row = sessionRow({ id: "sess-int", status: "interrupted" }); + const { app } = createComposeApp([...pageAccessPrefix(), [row]]); + const res = await app.request(`/api/pages/${PAGE_ID}/compose-sessions/sess-int/run`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({}), + }); + expect(res.status).toBe(409); + const body = (await res.json()) as { error?: string }; + expect(body.error).toMatch(/PATCH \/resume/i); + }); + + it("returns 409 when session is already completed", async () => { + const row = sessionRow({ id: "sess-done", status: "completed" }); + const { app } = createComposeApp([...pageAccessPrefix(), [row]]); + const res = await app.request(`/api/pages/${PAGE_ID}/compose-sessions/sess-done/run`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({}), + }); + expect(res.status).toBe(409); + }); + + it("returns 400 when session backend is unsupported at run time", async () => { + const row = sessionRow({ id: "sess-byok-run", status: "pending", backend: "byok" }); + const { app } = createComposeApp([...pageAccessPrefix(), [row]]); + const res = await app.request(`/api/pages/${PAGE_ID}/compose-sessions/sess-byok-run/run`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + }); + + it("marks session interrupted when stream emits interrupt events", async () => { + mockGraphRunnerStreamEvents.mockImplementation(async function* () { + yield { + event: "on_chain_end", + data: { output: { __interrupt__: [{ value: { kind: "human_review_brief" } }] } }, + }; + }); + const row = sessionRow({ id: "sess-interrupt", status: "pending" }); + const claimed = { ...row, status: "running" }; + const { app } = createComposeApp([ + ...pageAccessPrefix(), + [row], + [claimed], + [{ id: "sess-interrupt" }], + ]); + const res = await app.request(`/api/pages/${PAGE_ID}/compose-sessions/sess-interrupt/run`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ input: {} }), + }); + expect(res.status).toBe(200); + const text = await res.text(); + expect(text).toMatch(/interrupt|done/); + }); + + it("streams SSE events for a pending session", async () => { + const row = sessionRow({ id: "sess-run", status: "pending" }); + const claimed = { ...row, status: "running" }; + const { app } = createComposeApp([ + ...pageAccessPrefix(), + [row], + [claimed], + [{ id: "sess-run" }], + ]); + const res = await app.request(`/api/pages/${PAGE_ID}/compose-sessions/sess-run/run`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ input: {} }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/event-stream"); + const text = await res.text(); + expect(text).toContain("event:"); + expect(mockGraphRunnerStreamEvents).toHaveBeenCalled(); + }); +}); + +describe("PATCH /api/pages/:pageId/compose-sessions/:id/resume", () => { + it("returns 200 when resume completes successfully", async () => { + mockGraphRunnerResume.mockResolvedValue({ + status: "completed", + output: { markdown: "## Done" }, + }); + const row = sessionRow({ id: "sess-resume-ok", status: "interrupted" }); + const { app } = createComposeApp([ + ...pageAccessPrefix(), + [row], + [{ ...row, status: "running" }], + [{ id: "sess-resume-ok" }], + ]); + const res = await app.request(`/api/pages/${PAGE_ID}/compose-sessions/sess-resume-ok/resume`, { + method: "PATCH", + headers: authHeaders(), + body: JSON.stringify({ resume: { approvedSourceIds: ["s1"] } }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { status: string; output: unknown }; + expect(body.status).toBe("completed"); + expect(body.output).toEqual({ markdown: "## Done" }); + expect(mockGraphRunnerResume).toHaveBeenCalled(); + }); + + it("returns 409 when session is not interrupted", async () => { + const row = sessionRow({ id: "sess-pending", status: "pending" }); + const { app } = createComposeApp([...pageAccessPrefix(), [row]]); + const res = await app.request(`/api/pages/${PAGE_ID}/compose-sessions/sess-pending/resume`, { + method: "PATCH", + headers: authHeaders(), + body: JSON.stringify({ resume: { ok: true } }), + }); + expect(res.status).toBe(409); + }); }); describe("DELETE /api/pages/:pageId/compose-sessions/:id", () => { diff --git a/server/api/src/__tests__/routes/ingest.test.ts b/server/api/src/__tests__/routes/ingest.test.ts index e60cc0c2..da8cba7b 100644 --- a/server/api/src/__tests__/routes/ingest.test.ts +++ b/server/api/src/__tests__/routes/ingest.test.ts @@ -1,57 +1,199 @@ /** - * Tests for /api/ingest (otomatty/zedi#595). - * /api/ingest のテスト。 + * Tests for /api/ingest (otomatty/zedi#595, graph #952, apply). + * /api/ingest のルート統合テスト。 */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { Context, Next } from "hono"; +import type { AppEnv } from "../../types/index.js"; import { extractTitleKeywords } from "../../routes/ingest.js"; -type AuthSession = Awaited>; +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); + c.set("userEmail", c.req.header("x-test-user-email") ?? "test@example.com"); + await next(); + }, +})); -vi.mock("../../db/client.js", () => ({ - getDb: vi.fn(() => ({})), +vi.mock("../../middleware/rateLimit.js", () => ({ + rateLimit: () => async (_c: Context, next: Next) => { + await next(); + }, })); -const mockSessionUser = { - id: "user-1", - email: "u@e.com", - name: "", - image: null as string | null, - emailVerified: true, - createdAt: new Date(), - updatedAt: new Date(), - role: null as string | null, - status: "active" as string | null, -}; +const { + mockGetUserTier, + mockValidateModelAccessOrThrow, + mockCheckUsage, + mockCalculateCost, + mockRecordUsage, + mockExtractArticleFromUrl, + mockCreateIngestLlmDriver, + mockParseIngestPlanResponse, + mockGetProviderApiKeyName, + mockRecordActivity, + mockGraphRunnerInvoke, + mockGraphRunnerResume, + mockResolveCheckpointerForRun, + mockAssertComposeBackendReady, + mockGetRegisteredGraph, +} = vi.hoisted(() => ({ + mockGetUserTier: vi.fn(), + mockValidateModelAccessOrThrow: vi.fn(), + mockCheckUsage: vi.fn(), + mockCalculateCost: vi.fn(), + mockRecordUsage: vi.fn(), + mockExtractArticleFromUrl: vi.fn(), + mockCreateIngestLlmDriver: vi.fn(), + mockParseIngestPlanResponse: vi.fn(), + mockGetProviderApiKeyName: vi.fn(), + mockRecordActivity: vi.fn(), + mockGraphRunnerInvoke: vi.fn(), + mockGraphRunnerResume: vi.fn(), + mockResolveCheckpointerForRun: vi.fn(), + mockAssertComposeBackendReady: vi.fn(), + mockGetRegisteredGraph: vi.fn(), +})); + +vi.mock("../../services/subscriptionService.js", () => ({ + getUserTier: (...args: unknown[]) => mockGetUserTier(...args), +})); + +vi.mock("../../services/aiAccessHelpers.js", () => ({ + validateModelAccessOrThrow: (...args: unknown[]) => mockValidateModelAccessOrThrow(...args), +})); + +vi.mock("../../services/usageService.js", () => ({ + checkUsage: (...args: unknown[]) => mockCheckUsage(...args), + calculateCost: (...args: unknown[]) => mockCalculateCost(...args), + recordUsage: (...args: unknown[]) => mockRecordUsage(...args), +})); -vi.mock("../../auth.js", () => ({ - auth: { api: { getSession: vi.fn() } }, +vi.mock("../../services/articleExtractor.js", () => ({ + extractArticleFromUrl: (...args: unknown[]) => mockExtractArticleFromUrl(...args), })); -// Middleware DB lookup should find an active user. -vi.mock("../../middleware/db.js", async () => { - const actual = - await vi.importActual("../../middleware/db.js"); - return actual; +vi.mock("../../services/ingestPlanner.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createIngestLlmDriver: (...args: unknown[]) => mockCreateIngestLlmDriver(...args), + parseIngestPlanResponse: (...args: unknown[]) => mockParseIngestPlanResponse(...args), + }; +}); + +vi.mock("../../services/aiProviders.js", () => ({ + callProvider: vi.fn(), + getProviderApiKeyName: (...args: unknown[]) => mockGetProviderApiKeyName(...args), +})); + +vi.mock("../../services/activityLogService.js", () => ({ + recordActivity: (...args: unknown[]) => mockRecordActivity(...args), +})); + +vi.mock("../../agents/runner/graphRunner.js", () => ({ + GraphRunner: class { + invoke = (...args: unknown[]) => mockGraphRunnerInvoke(...args); + resume = (...args: unknown[]) => mockGraphRunnerResume(...args); + }, +})); + +vi.mock("../../agents/core/checkpoint/index.js", () => ({ + resolveCheckpointerForRun: (...args: unknown[]) => mockResolveCheckpointerForRun(...args), +})); + +vi.mock("../../agents/registry/graphRegistry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getRegisteredGraph: (...args: unknown[]) => mockGetRegisteredGraph(...args), + }; }); -import { auth } from "../../auth.js"; +vi.mock("../../agents/core/composeBackendValidation.js", () => ({ + assertComposeBackendReady: (...args: unknown[]) => mockAssertComposeBackendReady(...args), +})); + import { Hono } from "hono"; import { errorHandler } from "../../middleware/errorHandler.js"; import ingestRoutes from "../../routes/ingest.js"; -import type { AppEnv } from "../../types/index.js"; +import { createMockDb } from "../createMockDb.js"; +import { IngestPlanParseError } from "../../services/ingestPlanner.js"; + +const TEST_USER_ID = "user-ingest-1"; +const OTHER_USER_ID = "user-other-99"; +const ORIGINAL_ENV = { ...process.env }; + +function authHeaders(userId = TEST_USER_ID): Record { + return { + "x-test-user-id": userId, + "x-test-user-email": "ingest@example.com", + "Content-Type": "application/json", + }; +} -function createIngestApp(dbMock: unknown) { +function createIngestApp(dbResults: unknown[]) { + const { db } = createMockDb(dbResults); const app = new Hono(); app.onError(errorHandler); - // Inject a pre-set db into the context for each request. app.use("*", async (c, next) => { - c.set("db", dbMock as never); + c.set("db", db as unknown as AppEnv["Variables"]["db"]); await next(); }); app.route("/api/ingest", ingestRoutes); return app; } +const sampleArticle = { + title: "Ripgrep guide", + finalUrl: "https://example.com/rg", + contentText: "ripgrep is fast", + thumbnailUrl: null, + contentHash: "hash-abc", +}; + +const samplePlan = { + action: "skip" as const, + reason: "no merge needed", +}; + +beforeEach(() => { + mockGetUserTier.mockReset().mockResolvedValue("pro"); + mockValidateModelAccessOrThrow.mockReset().mockResolvedValue({ + provider: "openai", + apiModelId: "gpt-4o-mini", + inputCostUnits: 1, + outputCostUnits: 2, + }); + mockCheckUsage.mockReset().mockResolvedValue({ allowed: true }); + mockCalculateCost.mockReset().mockReturnValue(10); + mockRecordUsage.mockReset().mockResolvedValue(undefined); + mockExtractArticleFromUrl.mockReset().mockResolvedValue(sampleArticle); + mockCreateIngestLlmDriver.mockReset().mockReturnValue(async () => '{"action":"skip"}'); + mockParseIngestPlanResponse.mockReset().mockReturnValue(samplePlan); + mockGetProviderApiKeyName.mockReset().mockReturnValue("OPENAI_API_KEY"); + mockRecordActivity.mockReset().mockResolvedValue(undefined); + mockGraphRunnerInvoke.mockReset().mockResolvedValue({ + status: "completed", + output: { ingestPlan: samplePlan }, + }); + mockGraphRunnerResume.mockReset().mockResolvedValue({ + status: "completed", + output: { ingestPlan: samplePlan }, + }); + mockResolveCheckpointerForRun.mockReset().mockResolvedValue(false); + mockAssertComposeBackendReady.mockReset().mockResolvedValue(undefined); + mockGetRegisteredGraph.mockReset().mockReturnValue(undefined); + process.env = { ...ORIGINAL_ENV, OPENAI_API_KEY: "sk-test" }; +}); + +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + vi.restoreAllMocks(); +}); + describe("extractTitleKeywords", () => { it("splits title by whitespace and keeps tokens of length >= 2", () => { expect(extractTitleKeywords("ripgrep is fast")).toEqual(["ripgrep", "is", "fast"]); @@ -66,55 +208,37 @@ describe("extractTitleKeywords", () => { }); it("caps result at 5 tokens", () => { - // 2 文字以上のトークンが 5 件を超えた場合に 5 件に制限されることを確認する。 expect(extractTitleKeywords("ab cd ef gh ij kl mn op qr st uv")).toHaveLength(5); }); it("returns empty array when nothing qualifies", () => { expect(extractTitleKeywords("")).toEqual([]); - // Single-char tokens are filtered out expect(extractTitleKeywords("a b c")).toEqual([]); }); }); describe("POST /api/ingest/plan", () => { - // status lookup の 1 件目結果を返す最小モック - const activeUserDb = { - select: () => ({ - from: () => ({ - where: () => ({ - limit: async () => [{ status: "active" }], - }), - }), - }), + const planBody = { + url: "https://example.com/article", + provider: "openai", + model: "gpt-4o-mini", }; - beforeEach(() => { - vi.mocked(auth.api.getSession).mockResolvedValue({ - user: mockSessionUser, - } as unknown as AuthSession); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - it("returns 401 when session is missing", async () => { - vi.mocked(auth.api.getSession).mockResolvedValue(null); - const app = createIngestApp(activeUserDb); + const app = createIngestApp([]); const res = await app.request("/api/ingest/plan", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url: "https://example.com/" }), + body: JSON.stringify(planBody), }); expect(res.status).toBe(401); }); it("returns 400 when url is missing", async () => { - const app = createIngestApp(activeUserDb); + const app = createIngestApp([]); const res = await app.request("/api/ingest/plan", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: authHeaders(), body: JSON.stringify({ provider: "openai", model: "gpt-4o-mini" }), }); expect(res.status).toBe(400); @@ -123,14 +247,365 @@ describe("POST /api/ingest/plan", () => { }); it("returns 400 when provider or model is missing", async () => { - const app = createIngestApp(activeUserDb); + const app = createIngestApp([]); const res = await app.request("/api/ingest/plan", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: authHeaders(), body: JSON.stringify({ url: "https://example.com/" }), }); expect(res.status).toBe(400); const body = (await res.json()) as { error?: string }; expect(body.error).toMatch(/provider and model/i); }); + + it("returns 400 for unsupported provider", async () => { + const app = createIngestApp([]); + const res = await app.request("/api/ingest/plan", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ ...planBody, provider: "unknown" }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error?: string }; + expect(body.error).toMatch(/unsupported provider/i); + }); + + it("returns 429 when monthly budget is exceeded", async () => { + mockCheckUsage.mockResolvedValue({ allowed: false }); + const app = createIngestApp([]); + const res = await app.request("/api/ingest/plan", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(planBody), + }); + expect(res.status).toBe(429); + expect(mockValidateModelAccessOrThrow).toHaveBeenCalledWith( + "gpt-4o-mini", + "pro", + expect.anything(), + ); + }); + + it("returns 503 when API key is not configured", async () => { + delete process.env.OPENAI_API_KEY; + const app = createIngestApp([]); + const res = await app.request("/api/ingest/plan", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(planBody), + }); + expect(res.status).toBe(503); + }); + + it("returns 400 when article extraction fails", async () => { + mockExtractArticleFromUrl.mockRejectedValue(new Error("URL not allowed")); + const app = createIngestApp([]); + const res = await app.request("/api/ingest/plan", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(planBody), + }); + expect(res.status).toBe(400); + expect(mockExtractArticleFromUrl).toHaveBeenCalledWith({ + url: planBody.url, + previewLength: 4000, + }); + }); + + it("returns 502 when LLM call fails", async () => { + mockCreateIngestLlmDriver.mockReturnValue(async () => { + throw new Error("upstream down"); + }); + const app = createIngestApp([{ rows: [] }, []]); + const res = await app.request("/api/ingest/plan", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(planBody), + }); + expect(res.status).toBe(502); + }); + + it("returns 502 when plan parse fails", async () => { + mockParseIngestPlanResponse.mockImplementation(() => { + throw new IngestPlanParseError("bad json"); + }); + const app = createIngestApp([{ rows: [] }, []]); + const res = await app.request("/api/ingest/plan", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(planBody), + }); + expect(res.status).toBe(502); + const body = (await res.json()) as { error?: string }; + expect(body.error).toMatch(/invalid plan/i); + }); + + it("returns plan JSON on success and records usage", async () => { + const candidateRow = { + id: "page-cand-1", + title: "Ripgrep", + content_preview: "preview", + content_text: "body text", + }; + const app = createIngestApp([{ rows: [candidateRow] }, [{ contentText: "# My schema" }]]); + const res = await app.request("/api/ingest/plan", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(planBody), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + plan: { action: string }; + source: { url: string }; + candidates: unknown[]; + }; + expect(body.plan.action).toBe("skip"); + expect(body.source.url).toBe(sampleArticle.finalUrl); + expect(body.candidates).toHaveLength(1); + expect(mockRecordUsage).toHaveBeenCalledWith( + TEST_USER_ID, + "gpt-4o-mini", + "ingest_plan", + expect.objectContaining({ inputTokens: expect.any(Number) }), + 10, + "system", + expect.anything(), + ); + }); +}); + +describe("POST /api/ingest/graph/run", () => { + const graphBody = { + article: { title: "T", url: "https://ex.com", excerpt: "e" }, + candidates: [{ id: "c1", title: "C", excerpt: "x" }], + }; + + it("returns 401 without auth", async () => { + const app = createIngestApp([]); + const res = await app.request("/api/ingest/graph/run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(graphBody), + }); + expect(res.status).toBe(401); + }); + + it("returns 400 when article is missing", async () => { + const app = createIngestApp([]); + const res = await app.request("/api/ingest/graph/run", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + }); + + it("returns 400 when article fields are incomplete", async () => { + const app = createIngestApp([]); + const res = await app.request("/api/ingest/graph/run", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ article: { title: "", url: "https://ex.com", excerpt: "" } }), + }); + expect(res.status).toBe(400); + }); + + it("returns 400 when graph run fails with client error", async () => { + mockGraphRunnerInvoke.mockResolvedValue({ + status: "failed", + error: "invalid resume payload", + }); + const app = createIngestApp([]); + const res = await app.request("/api/ingest/graph/run", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(graphBody), + }); + expect(res.status).toBe(400); + expect(mockAssertComposeBackendReady).toHaveBeenCalledWith( + expect.objectContaining({ userId: TEST_USER_ID }), + ); + }); + + it("returns 403 when threadId is tied to another user's checkpoint", async () => { + mockResolveCheckpointerForRun.mockResolvedValue({}); + mockGetRegisteredGraph.mockReturnValue({ + factory: () => ({ + getState: async () => ({ values: { userId: OTHER_USER_ID } }), + }), + }); + const app = createIngestApp([]); + const res = await app.request("/api/ingest/graph/run", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + ...graphBody, + threadId: "shared-thread", + }), + }); + expect(res.status).toBe(403); + expect(mockGraphRunnerInvoke).not.toHaveBeenCalled(); + }); + + it("returns graph output on success", async () => { + const app = createIngestApp([]); + const res = await app.request("/api/ingest/graph/run", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ ...graphBody, threadId: "thread-1" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { status: string; threadId: string; plan: unknown }; + expect(body.status).toBe("completed"); + expect(body.threadId).toBe("thread-1"); + expect(body.plan).toEqual(samplePlan); + expect(mockGraphRunnerInvoke).toHaveBeenCalled(); + }); +}); + +describe("POST /api/ingest/graph/resume", () => { + it("returns 400 when threadId is missing", async () => { + const app = createIngestApp([]); + const res = await app.request("/api/ingest/graph/resume", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ resume: { ok: true } }), + }); + expect(res.status).toBe(400); + }); + + it("returns 400 when resume payload is missing", async () => { + const app = createIngestApp([]); + const res = await app.request("/api/ingest/graph/resume", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ threadId: "t1" }), + }); + expect(res.status).toBe(400); + }); + + it("returns 503 when checkpointing is unavailable", async () => { + mockResolveCheckpointerForRun.mockResolvedValue(false); + const app = createIngestApp([]); + const res = await app.request("/api/ingest/graph/resume", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ threadId: "t1", resume: { approvedSourceIds: [] } }), + }); + expect(res.status).toBe(503); + }); + + it("returns 403 when resuming another user's threadId", async () => { + mockResolveCheckpointerForRun.mockResolvedValue({}); + mockGetRegisteredGraph.mockReturnValue({ + factory: () => ({ + getState: async () => ({ values: { userId: OTHER_USER_ID } }), + }), + }); + const app = createIngestApp([]); + const res = await app.request("/api/ingest/graph/resume", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ threadId: "t1", resume: { approvedSourceIds: [] } }), + }); + expect(res.status).toBe(403); + expect(mockGraphRunnerResume).not.toHaveBeenCalled(); + }); + + it("resumes graph when checkpointing is enabled", async () => { + mockResolveCheckpointerForRun.mockResolvedValue({}); + const app = createIngestApp([]); + const res = await app.request("/api/ingest/graph/resume", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ threadId: "t1", resume: { approvedSourceIds: ["s1"] } }), + }); + expect(res.status).toBe(200); + expect(mockGraphRunnerResume).toHaveBeenCalled(); + }); +}); + +describe("POST /api/ingest/apply", () => { + const applyBody = { + kind: "url" as const, + url: "https://example.com/src", + title: "Source title", + contentHash: "hash-1", + }; + + it("returns 401 without auth", async () => { + const app = createIngestApp([]); + const res = await app.request("/api/ingest/apply", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(applyBody), + }); + expect(res.status).toBe(401); + }); + + it("returns 400 when kind is invalid", async () => { + const app = createIngestApp([]); + const res = await app.request("/api/ingest/apply", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ kind: "invalid", title: "T" }), + }); + expect(res.status).toBe(400); + }); + + it("returns 400 when url is missing for kind=url", async () => { + const app = createIngestApp([]); + const res = await app.request("/api/ingest/apply", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ kind: "url", title: "T" }), + }); + expect(res.status).toBe(400); + }); + + it("returns 403 when target page is not owned by caller", async () => { + const app = createIngestApp([[]]); + const res = await app.request("/api/ingest/apply", { + method: "POST", + headers: authHeaders(OTHER_USER_ID), + body: JSON.stringify({ ...applyBody, targetPageId: "page-other" }), + }); + expect(res.status).toBe(403); + }); + + it("creates source and records activity on success", async () => { + const newSourceId = "src-new-1"; + const targetPageId = "page-owned-1"; + const app = createIngestApp([[{ id: targetPageId }], [], [{ id: newSourceId }], undefined]); + const res = await app.request("/api/ingest/apply", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ ...applyBody, targetPageId }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { sourceId: string; targetPageId: string }; + expect(body.sourceId).toBe(newSourceId); + expect(body.targetPageId).toBe(targetPageId); + expect(mockRecordActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + ownerId: TEST_USER_ID, + kind: "clip_ingest", + targetPageIds: [targetPageId], + }), + ); + }); + + it("reuses existing source when content hash matches", async () => { + const existingId = "src-existing"; + const app = createIngestApp([[{ id: existingId }]]); + const res = await app.request("/api/ingest/apply", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(applyBody), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { sourceId: string }; + expect(body.sourceId).toBe(existingId); + }); }); diff --git a/server/api/src/__tests__/routes/notes/tags.test.ts b/server/api/src/__tests__/routes/notes/tags.test.ts new file mode 100644 index 00000000..97a229ef --- /dev/null +++ b/server/api/src/__tests__/routes/notes/tags.test.ts @@ -0,0 +1,166 @@ +/** + * `GET /api/notes/:noteId/tags` のテスト。 + * Tests for note-wide tag aggregation endpoint. + */ +import { describe, it, expect, vi } 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"); + const userEmail = c.req.header("x-test-user-email"); + if (!userId) return c.json({ message: "Unauthorized" }, 401); + c.set("userId", userId); + if (userEmail) c.set("userEmail", userEmail); + await next(); + }, + authOptional: async (c: Context, next: Next) => { + const userId = c.req.header("x-test-user-id"); + const userEmail = c.req.header("x-test-user-email"); + if (userId) c.set("userId", userId); + if (userEmail) c.set("userEmail", userEmail); + await next(); + }, +})); + +import { + TEST_USER_ID, + TEST_USER_EMAIL, + OTHER_USER_ID, + createMockNote, + createTestApp, + authHeaders, +} from "./setup.js"; + +const NOTE_ID = "note-test-001"; + +function mockTagsSignal(overrides: Record = {}) { + return { + rows: [ + { + pages_max_updated_at: new Date("2026-01-01T00:00:00Z"), + pages_count: 3, + links_max_created_at: new Date("2026-02-01T00:00:00Z"), + links_count: 2, + ghost_max_created_at: null, + ghost_count: 0, + none_count: 1, + ...overrides, + }, + ], + }; +} + +function mockTagRows() { + return { + rows: [ + { + name_lower: "rust", + display_name: "Rust", + page_count: 2, + resolved: true, + }, + { + name_lower: "todo", + display_name: "todo", + page_count: 1, + resolved: false, + }, + ], + }; +} + +describe("GET /api/notes/:noteId/tags", () => { + it("returns aggregated tags for the note owner", async () => { + const mockNote = createMockNote(); + const { app } = createTestApp([[mockNote], mockTagsSignal(), mockTagRows()]); + + const res = await app.request(`/api/notes/${NOTE_ID}/tags`, { + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + items: Array<{ name: string; page_count: number; resolved: boolean }>; + none_count: number; + total_pages: number; + }; + expect(body.items).toHaveLength(2); + expect(body.items[0]).toMatchObject({ name: "Rust", page_count: 2, resolved: true }); + expect(body.none_count).toBe(1); + expect(body.total_pages).toBe(3); + expect(res.headers.get("ETag")).toMatch(/^W\/".+"$/); + expect(res.headers.get("Cache-Control")).toContain("private"); + }); + + it("allows guest access on public notes (authOptional)", async () => { + const publicNote = createMockNote({ ownerId: OTHER_USER_ID, visibility: "public" }); + const { app } = createTestApp([ + [publicNote], + mockTagsSignal({ pages_count: 1, none_count: 0 }), + { rows: [] }, + ]); + + const res = await app.request(`/api/notes/${NOTE_ID}/tags`); + expect(res.status).toBe(200); + const body = (await res.json()) as { items: unknown[]; total_pages: number }; + expect(body.items).toEqual([]); + expect(body.total_pages).toBe(1); + }); + + it("returns 404 when the note does not exist", async () => { + const { app } = createTestApp([[]]); + + const res = await app.request(`/api/notes/${NOTE_ID}/tags`, { + headers: authHeaders(), + }); + expect(res.status).toBe(404); + }); + + it("returns 403 when caller has no role on a private note", async () => { + const privateNote = createMockNote({ ownerId: OTHER_USER_ID, visibility: "private" }); + const { app } = createTestApp([[privateNote], [], []]); + + const res = await app.request(`/api/notes/${NOTE_ID}/tags`, { + headers: authHeaders(TEST_USER_ID, TEST_USER_EMAIL), + }); + expect(res.status).toBe(403); + }); + + it("returns 403 when private note is accessed unauthenticated", async () => { + const privateNote = createMockNote({ ownerId: OTHER_USER_ID, visibility: "private" }); + const { app } = createTestApp([[privateNote]]); + + const res = await app.request(`/api/notes/${NOTE_ID}/tags`); + expect(res.status).toBe(403); + }); + + describe("ETag / 304", () => { + it("returns 304 when If-None-Match matches and skips the tag query", async () => { + const mockNote = createMockNote(); + const { app, chains } = createTestApp([ + [mockNote], + mockTagsSignal(), + mockTagRows(), + [mockNote], + mockTagsSignal(), + ]); + + const res1 = await app.request(`/api/notes/${NOTE_ID}/tags`, { + headers: authHeaders(), + }); + const etag = res1.headers.get("ETag"); + if (!etag) throw new Error("ETag missing"); + + const chainsBefore = chains.length; + const res2 = await app.request(`/api/notes/${NOTE_ID}/tags`, { + headers: { ...authHeaders(), "If-None-Match": etag }, + }); + + expect(res2.status).toBe(304); + expect(await res2.text()).toBe(""); + expect(chains.length - chainsBefore).toBe(2); + }); + }); +}); diff --git a/server/api/src/__tests__/routes/thumbnail/serve.test.ts b/server/api/src/__tests__/routes/thumbnail/serve.test.ts index c28311c2..536b9a99 100644 --- a/server/api/src/__tests__/routes/thumbnail/serve.test.ts +++ b/server/api/src/__tests__/routes/thumbnail/serve.test.ts @@ -70,17 +70,24 @@ const TEST_USER_ID = "user-test-123"; const ATTACKER_ID = "attacker-456"; const OBJECT_ID = "thumb-uuid-001"; -function createServeApp() { - const mockDb = createMockDb([]); +function createServeApp(dbResults: unknown[] = []) { + const mockDb = createMockDb(dbResults); const app = new Hono(); app.use("*", async (c, next) => { c.set("db", mockDb.db as unknown as AppEnv["Variables"]["db"]); await next(); }); app.route("/api/thumbnail/serve", serveRoutes); - return { app }; + return { app, chains: mockDb.chains }; } +const THUMB_ROW = { + id: OBJECT_ID, + userId: TEST_USER_ID, + s3Key: "thumbnails/user-test-123/thumb-uuid-001.png", + createdAt: new Date(), +}; + beforeEach(() => { mockS3Send.mockReset(); mockDeleteThumbnailObject.mockReset(); @@ -147,3 +154,80 @@ describe("DELETE /api/thumbnail/serve/:id", () => { expect(mockDeleteThumbnailObject).not.toHaveBeenCalled(); }); }); + +describe("GET /api/thumbnail/serve/:id", () => { + it("returns 401 without auth", async () => { + const { app } = createServeApp([[THUMB_ROW]]); + const res = await app.request(`/api/thumbnail/serve/${OBJECT_ID}`); + expect(res.status).toBe(401); + expect(mockS3Send).not.toHaveBeenCalled(); + }); + + it("returns 404 when thumbnail row is not found for caller", async () => { + const { app } = createServeApp([[]]); + const res = await app.request(`/api/thumbnail/serve/${OBJECT_ID}`, { + headers: { "x-test-user-id": ATTACKER_ID }, + }); + expect(res.status).toBe(404); + expect(mockS3Send).not.toHaveBeenCalled(); + }); + + it("scopes GET lookup by caller userId so foreign-owned rows are not served", async () => { + // Proxy DB returns [] when userId does not match (row may exist for another user). + const { app, chains } = createServeApp([[]]); + const res = await app.request(`/api/thumbnail/serve/${OBJECT_ID}`, { + headers: { "x-test-user-id": ATTACKER_ID }, + }); + expect(res.status).toBe(404); + expect(mockS3Send).not.toHaveBeenCalled(); + const selectChain = chains.find((c) => c.startMethod === "select"); + expect(selectChain?.ops.some((op) => op.method === "where")).toBe(true); + expect(selectChain?.ops.some((op) => op.method === "limit")).toBe(true); + }); + + it("streams image bytes when owned row exists", async () => { + const imageBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + mockS3Send.mockResolvedValueOnce({ + Body: new ReadableStream({ + start(controller) { + controller.enqueue(imageBytes); + controller.close(); + }, + }), + }); + const { app } = createServeApp([[THUMB_ROW]]); + const res = await app.request(`/api/thumbnail/serve/${OBJECT_ID}`, { + headers: { "x-test-user-id": TEST_USER_ID }, + }); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("image/png"); + expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff"); + const buf = new Uint8Array(await res.arrayBuffer()); + expect(buf).toEqual(imageBytes); + expect(mockS3Send).toHaveBeenCalledTimes(1); + }); + + it("returns 404 when S3 object is missing", async () => { + const err = Object.assign(new Error("NoSuchKey"), { + name: "NoSuchKey", + $metadata: { httpStatusCode: 404 }, + }); + mockS3Send.mockRejectedValueOnce(err); + const { app } = createServeApp([[THUMB_ROW]]); + const res = await app.request(`/api/thumbnail/serve/${OBJECT_ID}`, { + headers: { "x-test-user-id": TEST_USER_ID }, + }); + expect(res.status).toBe(404); + }); + + it("returns 502 when S3 retrieval fails unexpectedly", async () => { + mockS3Send.mockRejectedValueOnce(new Error("network down")); + const { app } = createServeApp([[THUMB_ROW]]); + const res = await app.request(`/api/thumbnail/serve/${OBJECT_ID}`, { + headers: { "x-test-user-id": TEST_USER_ID }, + }); + expect(res.status).toBe(502); + const body = (await res.json()) as { error: string }; + expect(body.error).toMatch(/retrieve/i); + }); +}); diff --git a/server/api/src/__tests__/services/userAiCredentialService.test.ts b/server/api/src/__tests__/services/userAiCredentialService.test.ts index 4347322b..5377386c 100644 --- a/server/api/src/__tests__/services/userAiCredentialService.test.ts +++ b/server/api/src/__tests__/services/userAiCredentialService.test.ts @@ -7,7 +7,7 @@ * while the real crypto module is used so the encryption round-trip and the * decrypt-failure fallback are exercised end to end. */ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, afterEach } from "vitest"; import { deleteUserAiCredential, getUserAiCredentialPlaintext, diff --git a/src/components/pdfReader/usePdfDocument.ts b/src/components/pdfReader/usePdfDocument.ts index b99f390b..db5eae86 100644 --- a/src/components/pdfReader/usePdfDocument.ts +++ b/src/components/pdfReader/usePdfDocument.ts @@ -7,9 +7,10 @@ * * 重要 / Important: * - 1 つの `sourceId` に対し doc は 1 度だけロードされる(依存配列で制御)。 - * - アンマウント時は doc の破棄を行い、内部 worker への参照を解放する。 + * - アンマウント時は {@link PdfDocumentProxy.loadingTask}.destroy() を呼び、 + * worker 参照を解放する。pdfjs-dist v6 で PDFDocumentProxy.destroy() は廃止。 * - The document is loaded exactly once per `sourceId`; on unmount we call - * `pdfDoc.loadingTask.destroy()` to release the worker reference. + * `pdfDoc.loadingTask.destroy()` (pdfjs-dist v6 removed `PDFDocumentProxy.destroy`). */ import { useEffect, useRef, useState } from "react"; import { readPdfBytes, PdfKnowledgeUnsupportedError } from "@/lib/pdfKnowledge/tauriBridge";