import { join } from "node:path"; import type { FastifyInstance } from "fastify"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentService, ConfirmToolInput, RunInput, RunResult } from "../src/agent/service.js"; import { UsageLedger } from "../src/billing/ledger.js"; import { buildServer } from "../src/server.js"; import { SessionStore } from "../src/sessions/store.js"; import { makeCatalog, silentLogger, skillFixture, tempRoot, testConfig } from "./helpers.js"; const apps: FastifyInstance[] = []; afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); }); async function makeApp(overrides: Record = {}) { const root = tempRoot(); const config = testConfig(root, overrides); const catalog = await makeCatalog([skillFixture({ key: "demo", config: { pricePerImage: 10 } })]); const ledger = new UsageLedger(join(root, "usage.jsonl"), silentLogger); const store = new SessionStore(join(root, "sessions.json"), join(root, "messages"), silentLogger); const run = vi.fn(async (_input: RunInput): Promise => { throw new Error("not used"); }); const confirmTool = vi.fn(async (_input: ConfirmToolInput) => ({ ok: true as const, tool_call_id: "call-1", approved: true, })); const service = { isLlmConfigured: () => true, run, confirmTool, } as unknown as AgentService; const app = await buildServer({ config, logger: silentLogger, catalog, ledger, store, service }); apps.push(app); return { app, ledger, store, root, run, confirmTool }; } describe("HTTP API", () => { it("exposes health, skills, and skill detail", async () => { const { app } = await makeApp(); const health = await app.inject({ method: "GET", url: "/health" }); expect(health.statusCode).toBe(200); expect(health.json()).toMatchObject({ ok: true, billing_mode: "dry_run", skills: 1, gateway_mock: true, image_models: ["gpt-image-2"], }); const skills = await app.inject({ method: "GET", url: "/skills" }); expect(skills.json()).toMatchObject({ skills: [{ key: "demo", display_name: "Test demo" }] }); const detail = await app.inject({ method: "GET", url: "/skills/demo" }); expect(detail.statusCode).toBe(200); expect(detail.json()).toMatchObject({ key: "demo", content: expect.stringContaining("Use this skill") }); const missing = await app.inject({ method: "GET", url: "/skills/nope" }); expect(missing.statusCode).toBe(404); }); it("quotes images and validates input", async () => { const { app } = await makeApp(); const quote = await app.inject({ method: "POST", url: "/images/quote", payload: { skill_id: "demo", n: 2 }, }); expect(quote.statusCode).toBe(200); expect(quote.json()).toMatchObject({ total_credits: 20, quantity: 2, billing_mode: "dry_run" }); const invalid = await app.inject({ method: "POST", url: "/images/quote", payload: { skill_id: "demo", n: 0 } }); expect(invalid.statusCode).toBe(400); const missing = await app.inject({ method: "POST", url: "/images/quote", payload: { skill_id: "nope" } }); expect(missing.statusCode).toBe(404); }); it("passes an explicitly selected skill_id to the agent", async () => { const { app, store, run } = await makeApp(); const session = await store.create({ uid: "1", team_id: "t1" }); run.mockResolvedValueOnce({ sessionId: session.session_id, messageId: "msg-1", answer: "ok", media: [], usage: { input: 0, output: 0, total: 0, cost: 0 }, skill_key: "demo", }); const response = await app.inject({ method: "POST", url: "/agent/run", headers: { "x-uid": "1", "x-team-id": "t1" }, payload: { session_id: session.session_id, message: "做一张海报", skill_id: "demo" }, }); expect(response.statusCode).toBe(200); expect(run.mock.calls[0]?.[0]).toMatchObject({ skillKey: "demo", message: "做一张海报" }); run.mockResolvedValueOnce({ sessionId: session.session_id, messageId: "msg-2", answer: "ok", media: [], usage: { input: 0, output: 0, total: 0, cost: 0 }, }); await app.inject({ method: "POST", url: "/agent/run", headers: { "x-uid": "1", "x-team-id": "t1" }, payload: { session_id: session.session_id, message: "直接画" }, }); expect(run.mock.calls[1]?.[0]).toMatchObject({ skillKey: undefined, message: "直接画" }); }); it("resolves a pending tool confirmation over HTTP", async () => { const { app, store, confirmTool } = await makeApp(); const session = await store.create({ uid: "1", team_id: "t1" }); const response = await app.inject({ method: "POST", url: `/sessions/${session.session_id}/tool-confirmations/call-1`, headers: { "x-uid": "1", "x-team-id": "t1" }, payload: { approved: true }, }); expect(response.statusCode).toBe(200); expect(response.json()).toMatchObject({ ok: true, approved: true, tool_call_id: "call-1" }); expect(confirmTool.mock.calls[0]?.[0]).toMatchObject({ sessionId: session.session_id, toolCallId: "call-1", approved: true, }); }); it("creates and lists sessions scoped to the caller", async () => { const { app } = await makeApp(); const created = await app.inject({ method: "POST", url: "/sessions", headers: { "x-uid": "1", "x-team-id": "t1" }, payload: { title: "first" }, }); expect(created.statusCode).toBe(200); const session = created.json() as { session_id: string; uid: string }; expect(session.uid).toBe("1"); const list = await app.inject({ method: "GET", url: "/sessions", headers: { "x-uid": "1", "x-team-id": "t1" } }); expect(list.json()).toMatchObject({ total: 1, sessions: [{ session_id: session.session_id }] }); const detail = await app.inject({ method: "GET", url: `/sessions/${session.session_id}`, headers: { "x-uid": "1" }, }); expect(detail.statusCode).toBe(200); const forbidden = await app.inject({ method: "GET", url: `/sessions/${session.session_id}`, headers: { "x-uid": "2" }, }); expect(forbidden.statusCode).toBe(403); }); it("returns 400 for malformed JSON bodies", async () => { const { app } = await makeApp(); const response = await app.inject({ method: "POST", url: "/sessions", headers: { "content-type": "application/json", "x-uid": "1" }, payload: "{bad json", }); expect(response.statusCode).toBe(400); }); it("requires a JWT when JWT_KEY is configured", async () => { const { app } = await makeApp({ JWT_KEY: "secret" }); const response = await app.inject({ method: "POST", url: "/sessions", payload: {} }); expect(response.statusCode).toBe(401); }); it("summarises usage for the caller", async () => { const { app, ledger } = await makeApp(); const record = ledger.begin({ uid: "1", team_id: "t1", session_id: "s1", tool_call_id: "call-1", skill_id: "demo", skill_name: "Demo", model: "seedream@5.0", size: "1024x1536", quantity: 1, unit_credits: 10, size_multiplier: 1, total_credits: 10, currency: "credits", billing_mode: "dry_run", }); ledger.settle(record.id, "succeeded", { image_urls: ["https://img/1.png"] }); const summary = await app.inject({ method: "GET", url: "/usage/summary", headers: { "x-uid": "1" } }); expect(summary.statusCode).toBe(200); expect(summary.json()).toMatchObject({ billing_mode: "dry_run", totals: { records: 1, images: 1, credits_succeeded: 10, credits_pending: 0, credits_failed: 0 }, }); const usage = await app.inject({ method: "GET", url: "/usage", headers: { "x-uid": "1" } }); expect(usage.json()).toMatchObject({ total: 1, records: [{ skill_id: "demo", status: "succeeded" }] }); const billing = await app.inject({ method: "GET", url: "/billing" }); expect(billing.json()).toMatchObject({ mode: "dry_run", deduction_enabled: false }); }); });