Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
121 changes: 121 additions & 0 deletions apps/api/src/routes/public.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
67 changes: 67 additions & 0 deletions apps/api/src/routes/public.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
});
6 changes: 6 additions & 0 deletions apps/web/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -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<T>(url: string, init?: RequestInit): Promise<T> {
Expand All @@ -22,6 +24,10 @@ export async function fetchJson<T>(url: string, init?: RequestInit): Promise<T>
return (await response.json()) as T;
}

export async function fetchDemoScenarios(baseUrl?: string): Promise<DemoScenarioManifest> {
return fetchJson<DemoScenarioManifest>(`${baseUrl ?? API_BASE_URL}/api/scenarios`);
}

export function money(value: number) {
return `$${value.toFixed(3)}`;
}
14 changes: 14 additions & 0 deletions packages/shared/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
14 changes: 14 additions & 0 deletions packages/shared/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down