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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions server/api/src/__tests__/createMockDb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ export function createMockDb(results: unknown[]) {
if (prop === "transaction") {
return (fn: (tx: typeof db) => Promise<unknown>) => 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[] }[] = [];
Expand Down
239 changes: 210 additions & 29 deletions server/api/src/__tests__/routes/clip.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof import("../../auth.js").auth.api.getSession>>;

// 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<AppEnv>, 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<AppEnv>, 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<AppEnv>();
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<string, string> {
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",
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -113,12 +145,161 @@ 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);
const body = (await res.json()) as { html?: string; url?: string; content_type?: string };
expect(body.html).toBe("<html>hi</html>");
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);
});
});
Loading
Loading