diff --git a/apps/api/src/routes/public.test.ts b/apps/api/src/routes/public.test.ts index 53a3a67..1185e8d 100644 --- a/apps/api/src/routes/public.test.ts +++ b/apps/api/src/routes/public.test.ts @@ -202,4 +202,125 @@ describe("public routes", () => { expect(catalogResponse.body.byCategory.scrape.length).toBeGreaterThan(0); }); + it("returns safe default analytics shape for fresh storage", async () => { + const app = await createPublicApp(); + + const analyticsResponse = await request(app).get("/api/analytics"); + + expect(analyticsResponse.status).toBe(200); + expect(analyticsResponse.body).toMatchObject({ + totalQueries: 0, + totalSpendUsd: 0, + spendByCategory: { + search: 0, + news: 0, + scrape: 0 + }, + executionSummary: { + totalExecutions: 0, + liveExecutions: 0, + fallbackExecutions: 0, + unavailableExecutions: 0, + timeoutExecutions: 0, + circuitOpenExecutions: 0 + }, + recentUsage: [], + recentTransactions: [] + }); + }); + + it("returns usage and analytics summaries from isolated sqlite storage", async () => { + const app = await createPublicApp(); + const { saveUsageEvent } = await import("../lib/persistence.js"); + + await saveUsageEvent( + buildTestUsageEvent({ + id: "use_test_1", + queryOrUrl: "stellar x402", + paymentStatus: "demo-paid", + traceId: "trace_test_1", + createdAt: "2026-06-21T10:00:00.000Z", + latencyMs: 12 + }) + ); + + const usageResponse = await request(app).get("/api/usage"); + const analyticsResponse = await request(app).get("/api/analytics"); + + expect(usageResponse.status).toBe(200); + expect(usageResponse.body.usage).toHaveLength(1); + expect(usageResponse.body.pagination).toMatchObject({ + count: 1, + offset: 0 + }); + + expect(analyticsResponse.status).toBe(200); + expect(analyticsResponse.body.totalQueries).toBe(1); + expect(analyticsResponse.body.totalSpendUsd).toBe(0.01); + expect(analyticsResponse.body.spendByCategory.search).toBe(0.01); + }); + + describe("demo scenario manifest", () => { + it("returns stable JSON with scenarios array", async () => { + const app = await createPublicApp(); + const response = await request(app).get("/api/scenarios"); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty("scenarios"); + expect(Array.isArray(response.body.scenarios)).toBe(true); + expect(response.body.scenarios.length).toBeGreaterThanOrEqual(3); + }); + + it("includes at least one scenario per mode (search, news, scrape)", async () => { + const app = await createPublicApp(); + const response = await request(app).get("/api/scenarios"); + + const modes = response.body.scenarios.map((s: { mode: string }) => s.mode); + expect(modes).toContain("search"); + expect(modes).toContain("news"); + expect(modes).toContain("scrape"); + }); + + it("each scenario has required shape", async () => { + const app = await createPublicApp(); + const response = await request(app).get("/api/scenarios"); + + for (const scenario of response.body.scenarios) { + expect(scenario).toHaveProperty("id"); + expect(scenario).toHaveProperty("mode"); + expect(scenario).toHaveProperty("recommendedProvider"); + expect(scenario).toHaveProperty("sampleQuery"); + expect(scenario).toHaveProperty("expectedEvidenceFields"); + expect(Array.isArray(scenario.expectedEvidenceFields)).toBe(true); + expect(scenario.expectedEvidenceFields.length).toBeGreaterThan(0); + expect(scenario).toHaveProperty("worksInDemoMode"); + expect(scenario).toHaveProperty("worksInRealMode"); + expect(typeof scenario.worksInDemoMode).toBe("boolean"); + expect(typeof scenario.worksInRealMode).toBe("boolean"); + } + }); + + it("returns identical response on repeated calls (stable manifest)", async () => { + const app = await createPublicApp(); + const first = await request(app).get("/api/scenarios"); + const second = await request(app).get("/api/scenarios"); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(first.body).toEqual(second.body); + }); + + it("does not trigger any provider execution", async () => { + const { providers } = await import("../lib/pricing.js"); + const { getCatalog } = await import("../services/query-service.js"); + + const app = await createPublicApp(); + const response = await request(app).get("/api/scenarios"); + + expect(response.status).toBe(200); + // Confirm no live provider state changed — catalog and providers untouched + const catalog = getCatalog(); + expect(catalog.providerCount).toBe(providers.length); + }); + }); }); diff --git a/apps/api/src/routes/public.ts b/apps/api/src/routes/public.ts index 84b2a82..e547776 100644 --- a/apps/api/src/routes/public.ts +++ b/apps/api/src/routes/public.ts @@ -1,5 +1,6 @@ import { Router } from "express"; import { z } from "zod"; +import type { DemoScenarioManifest } from "@query402/shared"; import { getSortedProviders } from "../lib/pricing.js"; import { getAnalyticsSummary, getUsageEvents } from "../lib/persistence.js"; import { config, getConfigSnapshot } from "../lib/config.js"; @@ -82,3 +83,69 @@ publicRouter.get("/api/analytics", async (req, res, next) => { next(error); } }); + +const DEMO_SCENARIO_MANIFEST: DemoScenarioManifest = { + scenarios: [ + { + id: "search-provider-comparison", + mode: "search", + recommendedProvider: "search.basic", + sampleQuery: "latest stellar x402 updates", + expectedEvidenceFields: [ + "providerId", + "providerName", + "priceUsd", + "latencyMs", + "timestamp", + "traceId", + "items", + "source", + "execution" + ], + worksInDemoMode: true, + worksInRealMode: true + }, + { + id: "news-payment-flow", + mode: "news", + recommendedProvider: "news.fast", + sampleQuery: "stablecoin micropayments", + expectedEvidenceFields: [ + "providerId", + "providerName", + "priceUsd", + "latencyMs", + "timestamp", + "traceId", + "items", + "source", + "execution" + ], + worksInDemoMode: true, + worksInRealMode: true + }, + { + id: "scrape-result-display", + mode: "scrape", + recommendedProvider: "scrape.page", + sampleQuery: "https://developers.stellar.org", + expectedEvidenceFields: [ + "providerId", + "providerName", + "priceUsd", + "latencyMs", + "timestamp", + "traceId", + "items", + "source", + "execution" + ], + worksInDemoMode: true, + worksInRealMode: true + } + ] +}; + +publicRouter.get("/api/scenarios", (_req, res) => { + res.json(DEMO_SCENARIO_MANIFEST); +}); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 1c5e254..d24141f 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,3 +1,5 @@ +import type { DemoScenarioManifest } from "@query402/shared"; + export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3001"; export async function fetchJson(url: string, init?: RequestInit): Promise { @@ -22,6 +24,10 @@ export async function fetchJson(url: string, init?: RequestInit): Promise return (await response.json()) as T; } +export async function fetchDemoScenarios(baseUrl?: string): Promise { + return fetchJson(`${baseUrl ?? API_BASE_URL}/api/scenarios`); +} + export function money(value: number) { return `$${value.toFixed(3)}`; } diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index 2fe1fa0..b749a30 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -69,6 +69,20 @@ export const sponsorshipPreviewRequestSchema = z.object({ // IMPORTANT: This schema intentionally omits signature and nonce. // The preview endpoint MUST NOT surface a fully signed grant, // otherwise it would bypass the SEP-53 challenge/signature flow. +export const demoScenarioSchema = z.object({ + id: z.string().min(1), + mode: queryModeSchema, + recommendedProvider: z.string().min(1), + sampleQuery: z.string().min(1), + expectedEvidenceFields: z.array(z.string().min(1)).nonempty(), + worksInDemoMode: z.boolean(), + worksInRealMode: z.boolean() +}); + +export const demoScenarioManifestSchema = z.object({ + scenarios: z.array(demoScenarioSchema) +}); + export const sponsorshipPreviewResponseSchema = z.object({ sponsorshipEnabled: z.boolean(), storageAvailable: z.boolean(), diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 13abdac..9ac5d94 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -189,6 +189,20 @@ export interface SponsorshipPreview { reason?: string; } +export interface DemoScenario { + id: string; + mode: QueryMode; + recommendedProvider: string; + sampleQuery: string; + expectedEvidenceFields: string[]; + worksInDemoMode: boolean; + worksInRealMode: boolean; +} + +export interface DemoScenarioManifest { + scenarios: DemoScenario[]; +} + export interface SponsorshipPreviewRequest { wallet: string; mode: QueryMode;