From b2e76ba8cad01eeddae0229dffa7dfd5144af25d Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Tue, 9 Dec 2025 14:23:32 +0100 Subject: [PATCH 01/16] test: add mock wrapper --- src/mocks/autoAPIMock.ts | 51 +++++ .../fixtures/registry_v0_1_servers/get.ts | 187 ++++-------------- .../get.ts | 103 +++++----- src/mocks/index.ts | 2 + src/mocks/mockTemplate.ts | 16 +- src/mocks/mocker.ts | 110 ++--------- src/mocks/server-detail/index.ts | 4 +- src/mocks/test.setup.ts | 4 +- tsconfig.json | 4 +- 9 files changed, 184 insertions(+), 297 deletions(-) create mode 100644 src/mocks/autoAPIMock.ts create mode 100644 src/mocks/index.ts diff --git a/src/mocks/autoAPIMock.ts b/src/mocks/autoAPIMock.ts new file mode 100644 index 0000000..4c85109 --- /dev/null +++ b/src/mocks/autoAPIMock.ts @@ -0,0 +1,51 @@ +import type { HttpResponseResolver, JsonBodyType } from "msw"; +import { HttpResponse } from "msw"; + +type ResponseResolverInfo = Parameters[0]; + +type OverrideFn = (data: T, info: ResponseResolverInfo) => Response; + +export interface AutoAPIMockInstance { + generatedHandler: HttpResponseResolver; + override: (fn: OverrideFn) => AutoAPIMockInstance; + reset: () => AutoAPIMockInstance; + defaultValue: T; +} + +// Registry to track all instances for bulk reset +const registry: Set> = new Set(); + +export function AutoAPIMock(defaultValue: T): AutoAPIMockInstance { + let overrideFn: OverrideFn | null = null; + + const instance: AutoAPIMockInstance = { + defaultValue, + + generatedHandler(info: ResponseResolverInfo) { + if (overrideFn) { + return overrideFn(defaultValue, info); + } + return HttpResponse.json(defaultValue as JsonBodyType); + }, + + override(fn: OverrideFn) { + overrideFn = fn; + return instance; + }, + + reset() { + overrideFn = null; + return instance; + }, + }; + + registry.add(instance as AutoAPIMockInstance); + + return instance; +} + +export function resetAllAutoAPIMocks(): void { + for (const instance of registry) { + instance.reset(); + } +} diff --git a/src/mocks/fixtures/registry_v0_1_servers/get.ts b/src/mocks/fixtures/registry_v0_1_servers/get.ts index 4b64bf1..d2871a8 100644 --- a/src/mocks/fixtures/registry_v0_1_servers/get.ts +++ b/src/mocks/fixtures/registry_v0_1_servers/get.ts @@ -1,8 +1,11 @@ -export default { +import type { GetRegistryV01ServersResponse } from "@api/types.gen"; +import { AutoAPIMock } from "@mocks"; + +export default AutoAPIMock({ servers: [ { server: { - title: "consequat", + title: "AWS Nova Canvas", name: "awslabs/aws-nova-canvas", version: "1.0.0", description: @@ -13,48 +16,23 @@ export default { url: "https://github.com/awslabs/aws-nova-canvas", }, _meta: { - ullamco_566: false, - "io.modelcontextprotocol.registry/publisher-provided": { - est_84: {}, - sed3: {}, - voluptate_: {}, - }, + "io.modelcontextprotocol.registry/publisher-provided": {}, }, icons: [ { - sizes: ["nostrud"], - mimeType: "laboris", - src: "http://AZUjhw.iemoiU+bM+FdKbi8L+QcXuAdmepZez7WVN,gwb6k.fLABJ", + sizes: ["32x32"], + mimeType: "image/x-icon", + src: "https://www.amazon.com/favicon.ico", }, ], packages: [ { - version: "velit occaecat", + version: "1.0.0", environmentVariables: [ { - name: "nisi labore anim laborum", - description: "occaecat nostrud ipsum sit non", - choices: ["sunt reprehenderit"], - default: "cillum", - format: "reprehenderit Ut sit", - }, - ], - packageArguments: [ - { - name: "incididunt dolore aute", - description: "reprehenderit veniam est labore", - choices: ["dolor Lorem"], - default: "veniam in elit", - format: "aliqua aute", - }, - ], - runtimeArguments: [ - { - name: "pariatur laboris", - description: "culpa elit do", - choices: ["dolore laborum cupidatat velit sint"], - default: "aliquip dolore nisi cupidatat", - format: "ea", + name: "AWS_ACCESS_KEY_ID", + description: "AWS Access Key ID", + format: "string", }, ], }, @@ -70,14 +48,14 @@ export default { _meta: { "io.modelcontextprotocol.registry/official": { isLatest: false, - publishedAt: "1928-01-16T07:47:41.0Z", - status: "Lorem", + publishedAt: "2024-01-16T07:47:41.0Z", + status: "active", }, }, }, { server: { - title: "nisi in consectetur ut dolore", + title: "AgentQL MCP", name: "tinyfish/agentql-mcp", version: "1.0.1", description: "A powerful MCP server for building AI agents", @@ -87,51 +65,10 @@ export default { url: "https://github.com/tinyfish/agentql-mcp", }, _meta: { - id58a: -54358865.657930315, - qui2: -45037479, - "io.modelcontextprotocol.registry/publisher-provided": { - id949: {}, - }, + "io.modelcontextprotocol.registry/publisher-provided": {}, }, - icons: [ - { - sizes: ["irure"], - mimeType: "nostrud", - src: "http://qpklHhfRUakxqQziHQlJvkYBCQ.schO4z0B", - }, - ], - packages: [ - { - version: "anim aute", - environmentVariables: [ - { - name: "veniam", - description: "dolore aliqua", - choices: ["velit ex in et magna"], - default: "Lorem quis cillum sit dolore", - format: "sint sed", - }, - ], - packageArguments: [ - { - name: "id nostrud cupidatat exercitation", - description: "ullamco tempor Excepteur fugiat et", - choices: ["fugiat eu mollit"], - default: "sint nostrud", - format: "proident occaecat pariatur", - }, - ], - runtimeArguments: [ - { - name: "eiusmod", - description: "anim Lorem", - choices: ["culpa exercitation minim"], - default: "labore cupidatat ea qui voluptate", - format: "minim exercitation dolor", - }, - ], - }, - ], + icons: [], + packages: [], remotes: [ { type: "http", @@ -141,18 +78,16 @@ export default { ], }, _meta: { - dolor_3e9: 35203589, - consequat_a: 71612484, "io.modelcontextprotocol.registry/official": { isLatest: true, - publishedAt: "1906-02-09T18:53:24.0Z", - status: "aute", + publishedAt: "2024-02-09T18:53:24.0Z", + status: "active", }, }, }, { server: { - title: "id", + title: "Astra DB MCP", name: "datastax/astra-db-mcp", version: "1.0.2", description: "Integrate AI assistants with Astra DB", @@ -162,52 +97,10 @@ export default { url: "https://github.com/datastax/astra-db-mcp", }, _meta: { - laborum_c_4: 59098891.74366808, - "io.modelcontextprotocol.registry/publisher-provided": { - culpabcc: {}, - dolor0: {}, - consectetur_5: {}, - }, + "io.modelcontextprotocol.registry/publisher-provided": {}, }, - icons: [ - { - sizes: ["sint est dolor exercitation"], - mimeType: "deserunt in ea", - src: "https://tN.xun..YtDqhkkWdXBxzPIXssrZHM.O5d", - }, - ], - packages: [ - { - version: "exercitation culpa mollit", - environmentVariables: [ - { - name: "nostrud sint", - description: "qui eiusmod", - choices: ["in in ad elit anim"], - default: "culpa sed fugiat laboris", - format: "quis eiusmod", - }, - ], - packageArguments: [ - { - name: "ullamco in officia esse", - description: "magna in qui eu adipisicing", - choices: ["do quis"], - default: "reprehenderit", - format: "voluptate sint consequat cupidatat irure", - }, - ], - runtimeArguments: [ - { - name: "in exercitation", - description: "occaecat", - choices: ["enim"], - default: "officia eu qui elit sed", - format: "voluptate ipsum dolore ullamco", - }, - ], - }, - ], + icons: [], + packages: [], remotes: [ { type: "http", @@ -219,14 +112,14 @@ export default { _meta: { "io.modelcontextprotocol.registry/official": { isLatest: true, - publishedAt: "1941-06-16T06:09:48.0Z", - status: "magna aliqua consequat deserunt", + publishedAt: "2024-06-16T06:09:48.0Z", + status: "active", }, }, }, { server: { - title: "microsoft azure", + title: "Microsoft Azure", name: "microsoft/azure-mcp", version: "1.0.0", description: "Connect AI assistants to Azure services", @@ -256,7 +149,7 @@ export default { }, { server: { - title: "google workspace", + title: "Google Workspace", name: "google/mcp-google-apps", version: "1.0.0", description: @@ -287,7 +180,7 @@ export default { }, { server: { - title: "figma desktop", + title: "Figma Desktop", name: "figma/mcp-desktop", version: "1.0.0", description: @@ -318,7 +211,7 @@ export default { }, { server: { - title: "slack workspace", + title: "Slack Workspace", name: "slack/mcp-slack", version: "1.0.0", description: @@ -349,7 +242,7 @@ export default { }, { server: { - title: "github api", + title: "GitHub API", name: "github/mcp-github", version: "1.0.0", description: @@ -380,7 +273,7 @@ export default { }, { server: { - title: "stripe payments", + title: "Stripe Payments", name: "stripe/mcp-stripe", version: "1.0.0", description: "Manage Stripe payments, subscriptions, and customer data", @@ -410,7 +303,7 @@ export default { }, { server: { - title: "notion workspace", + title: "Notion Workspace", name: "notion/mcp-notion", version: "1.0.0", description: "Access and manage Notion pages, databases, and content", @@ -440,7 +333,7 @@ export default { }, { server: { - title: "salesforce crm", + title: "Salesforce CRM", name: "salesforce/mcp-salesforce", version: "1.0.0", description: @@ -471,7 +364,7 @@ export default { }, { server: { - title: "hubspot marketing", + title: "HubSpot Marketing", name: "hubspot/mcp-hubspot", version: "1.0.0", description: "Integrate with HubSpot for marketing automation and CRM", @@ -501,7 +394,7 @@ export default { }, { server: { - title: "linear project", + title: "Linear Project", name: "linear/mcp-linear", version: "1.0.0", description: "Manage Linear issues, projects, and team workflows", @@ -531,7 +424,7 @@ export default { }, { server: { - title: "trello boards", + title: "Trello Boards", name: "trello/mcp-trello", version: "1.0.0", description: "Access and manage Trello boards, cards, and lists", @@ -561,7 +454,7 @@ export default { }, { server: { - title: "jira management", + title: "Jira Management", name: "atlassian/mcp-jira", version: "1.0.0", description: @@ -590,4 +483,4 @@ export default { count: 15, nextCursor: "next-page", }, -}; +}); diff --git a/src/mocks/fixtures/registry_v0_1_servers__serverName__versions__version_/get.ts b/src/mocks/fixtures/registry_v0_1_servers__serverName__versions__version_/get.ts index 42b462b..88e5d8e 100644 --- a/src/mocks/fixtures/registry_v0_1_servers__serverName__versions__version_/get.ts +++ b/src/mocks/fixtures/registry_v0_1_servers__serverName__versions__version_/get.ts @@ -1,53 +1,58 @@ -export default { - server: { - name: "awslabs/aws-nova-canvas", - title: "AWS Nova Canvas MCP Server", - version: "1.0.0", - description: - "Image generation using Amazon Nova Canvas. A Model Context Protocol server that integrates with AWS services for AI-powered image generation.\n\nAmazon Nova Canvas is a cutting-edge image generation service that leverages advanced AI models to create high-quality images from text descriptions. This MCP server provides seamless integration with AWS services, allowing you to generate images programmatically within your applications.\n\nKey Features:\n- High-quality image generation with customizable parameters\n- Support for multiple image formats (PNG, JPEG, WebP)\n- Configurable image dimensions and aspect ratios\n- Advanced prompt engineering capabilities\n- Cost-effective pricing with pay-as-you-go model\n- Enterprise-grade security and compliance\n- Real-time generation with low latency\n- Batch processing support for multiple images\n\nUse Cases:\n- Content creation for marketing and advertising\n- Product visualization and mockups\n- Social media content generation\n- E-commerce product images\n- Game asset creation\n- Architectural visualization\n- Educational materials and illustrations\n\nThis server requires valid AWS credentials with appropriate permissions to access the Amazon Nova Canvas service. Make sure your IAM role has the necessary policies attached before using this integration.\n\nFor more information about pricing, limits, and best practices, please refer to the official AWS documentation.", - repository: { - source: "github", - id: "awslabs", - url: "https://github.com/awslabs/aws-nova-canvas", - }, - websiteUrl: "https://github.com/awslabs/aws-nova-canvas", - icons: [ - { - src: "https://www.amazon.com/favicon.ico", - sizes: ["32x32"], - mimeType: "image/x-icon", - }, - ], - packages: [ - { - version: "1.0.0", - environmentVariables: [ - { - name: "AWS_ACCESS_KEY_ID", - description: "AWS Access Key ID", - format: "string", - }, - { - name: "AWS_SECRET_ACCESS_KEY", - description: "AWS Secret Access Key", - format: "string", - }, - ], +import type { GetRegistryV01ServersByServerNameVersionsByVersionResponse } from "@api/types.gen"; +import { AutoAPIMock } from "@mocks"; + +export default AutoAPIMock( + { + server: { + name: "awslabs/aws-nova-canvas", + title: "AWS Nova Canvas MCP Server", + version: "1.0.0", + description: + "Image generation using Amazon Nova Canvas. A Model Context Protocol server that integrates with AWS services for AI-powered image generation.\n\nAmazon Nova Canvas is a cutting-edge image generation service that leverages advanced AI models to create high-quality images from text descriptions. This MCP server provides seamless integration with AWS services, allowing you to generate images programmatically within your applications.\n\nKey Features:\n- High-quality image generation with customizable parameters\n- Support for multiple image formats (PNG, JPEG, WebP)\n- Configurable image dimensions and aspect ratios\n- Advanced prompt engineering capabilities\n- Cost-effective pricing with pay-as-you-go model\n- Enterprise-grade security and compliance\n- Real-time generation with low latency\n- Batch processing support for multiple images\n\nUse Cases:\n- Content creation for marketing and advertising\n- Product visualization and mockups\n- Social media content generation\n- E-commerce product images\n- Game asset creation\n- Architectural visualization\n- Educational materials and illustrations\n\nThis server requires valid AWS credentials with appropriate permissions to access the Amazon Nova Canvas service. Make sure your IAM role has the necessary policies attached before using this integration.\n\nFor more information about pricing, limits, and best practices, please refer to the official AWS documentation.", + repository: { + source: "github", + id: "awslabs", + url: "https://github.com/awslabs/aws-nova-canvas", }, - ], - remotes: [ - { - type: "http", - url: "https://example.com/awslabs/aws-nova-canvas", - headers: [], + websiteUrl: "https://github.com/awslabs/aws-nova-canvas", + icons: [ + { + src: "https://www.amazon.com/favicon.ico", + sizes: ["32x32"], + mimeType: "image/x-icon", + }, + ], + packages: [ + { + version: "1.0.0", + environmentVariables: [ + { + name: "AWS_ACCESS_KEY_ID", + description: "AWS Access Key ID", + format: "string", + }, + { + name: "AWS_SECRET_ACCESS_KEY", + description: "AWS Secret Access Key", + format: "string", + }, + ], + }, + ], + remotes: [ + { + type: "http", + url: "https://example.com/awslabs/aws-nova-canvas", + headers: [], + }, + ], + }, + _meta: { + "io.modelcontextprotocol.registry/official": { + isLatest: true, + publishedAt: "2024-11-20T10:00:00.0Z", + status: "active", }, - ], - }, - _meta: { - "io.modelcontextprotocol.registry/official": { - isLatest: true, - publishedAt: "2024-11-20T10:00:00.0Z", - status: "active", }, }, -}; +); diff --git a/src/mocks/index.ts b/src/mocks/index.ts new file mode 100644 index 0000000..9606a8c --- /dev/null +++ b/src/mocks/index.ts @@ -0,0 +1,2 @@ +export type { AutoAPIMockInstance } from "./autoAPIMock"; +export { AutoAPIMock, resetAllAutoAPIMocks } from "./autoAPIMock"; diff --git a/src/mocks/mockTemplate.ts b/src/mocks/mockTemplate.ts index 62aeef7..4d9d1d2 100644 --- a/src/mocks/mockTemplate.ts +++ b/src/mocks/mockTemplate.ts @@ -2,12 +2,18 @@ * Renders a TypeScript module for a generated mock fixture. * When a response type name is provided, includes a type import * from '@api/types.gen' and a `satisfies` clause for type safety. + * The fixture is wrapped in AutoAPIMock for test-scoped overrides. */ export function buildMockModule(payload: unknown, opType?: string): string { const typeName = opType?.trim(); - const typeImport = typeName - ? `import type { ${typeName} } from '@api/types.gen'\n\n` - : ""; - const typeSatisfies = typeName ? ` satisfies ${typeName}` : ""; - return `${typeImport}export default ${JSON.stringify(payload, null, 2)}${typeSatisfies}\n`; + + // Type imports first, then value imports (biome import order) + const imports = [ + ...(typeName ? [`import type { ${typeName} } from "@api/types.gen";`] : []), + `import { AutoAPIMock } from "@mocks";`, + ].join("\n"); + + const typeParam = typeName ? `<${typeName}>` : ""; + + return `${imports}\n\nexport default AutoAPIMock${typeParam}(${JSON.stringify(payload, null, 2)})\n`; } diff --git a/src/mocks/mocker.ts b/src/mocks/mocker.ts index 80e2b22..b09c9a2 100644 --- a/src/mocks/mocker.ts +++ b/src/mocks/mocker.ts @@ -1,12 +1,10 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import type { AnySchema } from "ajv"; -import Ajv from "ajv"; -import addFormats from "ajv-formats"; import { JSONSchemaFaker as jsf } from "json-schema-faker"; import type { RequestHandler } from "msw"; import { HttpResponse, http } from "msw"; +import type { AutoAPIMockInstance } from "./autoAPIMock"; import { buildMockModule } from "./mockTemplate"; // ===== Config ===== @@ -26,13 +24,6 @@ const FIXTURE_EXT = "ts"; // Load OpenAPI JSON (resolveJsonModule is enabled in tsconfig) import openapi from "../../swagger.json"; -// Ajv configuration -const ajv = new Ajv({ strict: true }); -addFormats(ajv); -// Allow vendor/annotation keywords present in OpenAPI-derived schemas -ajv.addKeyword("x-enum-varnames"); -ajv.addKeyword("example"); - // json-schema-faker options jsf.option({ alwaysFakeOptionals: true }); jsf.option({ fillProperties: true }); @@ -338,19 +329,6 @@ function enrichServersFixture(payload: unknown): unknown { return obj; } -function asJson( - value: unknown, -): string | number | boolean | null | Record | unknown[] { - if (value === null) return null; - const t = typeof value; - if (t === "string" || t === "number" || t === "boolean") { - return value as string | number | boolean; - } - if (Array.isArray(value)) return value as unknown[]; - if (t === "object") return value as Record; - return String(value); -} - function getJsonSchemaFromOperation( operation: unknown, status: string, @@ -399,7 +377,7 @@ export function autoGenerateHandlers() { .replace(/\{([^}]+)\}/g, ":$1")}`; result.push( - handlersByMethod[method](mswPath, async () => { + handlersByMethod[method](mswPath, async (info) => { const responsesObj = (operation as { responses?: Record }).responses ?? {}; @@ -489,16 +467,18 @@ export function autoGenerateHandlers() { return new HttpResponse(null, { status: 204 }); } - let data: unknown; + let fixture: AutoAPIMockInstance; const relPath = getFixtureRelPath(safePath, method); try { const importer = fixtureImporters?.[relPath]; if (importer) { const mod = (await importer()) as unknown; - data = (mod as { default?: unknown })?.default ?? mod; + fixture = (mod as { default?: unknown }) + ?.default as AutoAPIMockInstance; } else { const mod = (await import(relPath)) as unknown; - data = (mod as { default?: unknown })?.default ?? mod; + fixture = (mod as { default?: unknown }) + ?.default as AutoAPIMockInstance; } } catch (e) { return new HttpResponse( @@ -509,74 +489,18 @@ export function autoGenerateHandlers() { ); } - const validateSchema = getJsonSchemaFromOperation( - operation, - successStatus ?? "200", - ); - if (validateSchema) { - // Fully dereference before validation to avoid local $ref to components - let resolved = derefSchema(validateSchema); - let guard = 0; - while (hasRef(resolved) && guard++ < 5) { - resolved = derefSchema(resolved); - } - let isValid = ajv.validate(resolved as AnySchema, data as unknown); - // Treat empty object as invalid when schema exposes properties. - if ( - isValid && - data && - typeof data === "object" && - !Array.isArray(data) && - Object.keys(data as Record).length === 0 && - (resolved as { properties?: Record }) - ?.properties && - Object.keys( - (resolved as { properties?: Record }) - .properties ?? {}, - ).length > 0 - ) { - isValid = false; - } - if (!isValid) { - const message = `fixture validation failed for ${method.toUpperCase()} ${rawPath} -> ${fixtureFileName}`; - console.error("[auto-mocker]", message, ajv.errors || []); - return new HttpResponse(`[auto-mocker] ${message}`, { - status: 500, - }); - } - } else { - // No JSON schema to validate against: explicit failure - const message = `no JSON schema for ${method.toUpperCase()} ${rawPath} status ${ - successStatus ?? "200" - }`; - console.error("[auto-mocker]", message); - return new HttpResponse(`[auto-mocker] ${message}`, { - status: 500, - }); + if (!fixture || typeof fixture.generatedHandler !== "function") { + return new HttpResponse( + `[auto-mocker] Invalid fixture format: ${relPath}. Expected AutoAPIMock wrapper.`, + { status: 500 }, + ); } - const jsonValue = asJson(data); - try { - let serversLen: number | undefined; - if ( - jsonValue && - typeof jsonValue === "object" && - Object.hasOwn(jsonValue, "servers") - ) { - const s = (jsonValue as Record).servers; - if (Array.isArray(s)) serversLen = s.length; - } - console.log( - `[auto-mocker] respond ${method.toUpperCase()} ${rawPath} -> ${ - successStatus ? Number(successStatus) : 200 - } ${ - serversLen !== undefined ? `servers=${serversLen}` : "" - } (${fixtureFileName})`, - ); - } catch {} - return HttpResponse.json(jsonValue, { - status: successStatus ? Number(successStatus) : 200, - }); + console.log( + `[auto-mocker] respond ${method.toUpperCase()} ${rawPath} (${fixtureFileName})`, + ); + + return fixture.generatedHandler(info); }), ); } diff --git a/src/mocks/server-detail/index.ts b/src/mocks/server-detail/index.ts index 68b6938..fcfcb3b 100644 --- a/src/mocks/server-detail/index.ts +++ b/src/mocks/server-detail/index.ts @@ -1,6 +1,6 @@ import type { RequestHandler } from "msw"; import { HttpResponse, http } from "msw"; -import serversListFixture from "../fixtures/registry_v0_1_servers/get"; +import serversListMock from "../fixtures/registry_v0_1_servers/get"; // Add non-schema, hand-written mocks here. // These take precedence over the schema-based mocks. @@ -18,6 +18,8 @@ export const serverDetailHandlers: RequestHandler[] = [ version, }); + const serversListFixture = serversListMock.defaultValue; + // Find matching server from the list const serverResponse = serversListFixture.servers?.find((item) => { const nameMatch = item.server?.name === serverName; diff --git a/src/mocks/test.setup.ts b/src/mocks/test.setup.ts index a2fe449..dd82587 100644 --- a/src/mocks/test.setup.ts +++ b/src/mocks/test.setup.ts @@ -1,6 +1,8 @@ -import { afterAll, afterEach, beforeAll } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach } from "vitest"; +import { resetAllAutoAPIMocks } from "./autoAPIMock"; import { server } from "./node"; beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +beforeEach(() => resetAllAutoAPIMocks()); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); diff --git a/tsconfig.json b/tsconfig.json index f55bef4..4b74df5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,7 +20,9 @@ ], "paths": { "@/*": ["./src/*"], - "@api/*": ["./src/generated/*"] + "@api/*": ["./src/generated/*"], + "@mocks": ["./src/mocks"], + "@mocks/*": ["./src/mocks/*"] } }, "include": [ From 69b7265f7670013fd1a252e24f19c840c442df25 Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Tue, 9 Dec 2025 16:17:11 +0100 Subject: [PATCH 02/16] better import experience --- .../fixtures/registry_v0_1_servers/get.ts | 871 +++++++++--------- .../get.ts | 7 +- src/mocks/mockTemplate.ts | 20 +- src/mocks/mocker.ts | 19 +- src/mocks/server-detail/index.ts | 4 +- 5 files changed, 472 insertions(+), 449 deletions(-) diff --git a/src/mocks/fixtures/registry_v0_1_servers/get.ts b/src/mocks/fixtures/registry_v0_1_servers/get.ts index d2871a8..f88a96d 100644 --- a/src/mocks/fixtures/registry_v0_1_servers/get.ts +++ b/src/mocks/fixtures/registry_v0_1_servers/get.ts @@ -1,486 +1,489 @@ import type { GetRegistryV01ServersResponse } from "@api/types.gen"; import { AutoAPIMock } from "@mocks"; -export default AutoAPIMock({ - servers: [ - { - server: { - title: "AWS Nova Canvas", - name: "awslabs/aws-nova-canvas", - version: "1.0.0", - description: - "MCP server for AI-powered image generation using Amazon Nova Canvas and AWS services", - repository: { - source: "github", - id: "awslabs", - url: "https://github.com/awslabs/aws-nova-canvas", +export const mockedGetRegistryV01Servers = + AutoAPIMock({ + servers: [ + { + server: { + title: "AWS Nova Canvas", + name: "awslabs/aws-nova-canvas", + version: "1.0.0", + description: + "MCP server for AI-powered image generation using Amazon Nova Canvas and AWS services", + repository: { + source: "github", + id: "awslabs", + url: "https://github.com/awslabs/aws-nova-canvas", + }, + _meta: { + "io.modelcontextprotocol.registry/publisher-provided": {}, + }, + icons: [ + { + sizes: ["32x32"], + mimeType: "image/x-icon", + src: "https://www.amazon.com/favicon.ico", + }, + ], + packages: [ + { + version: "1.0.0", + environmentVariables: [ + { + name: "AWS_ACCESS_KEY_ID", + description: "AWS Access Key ID", + format: "string", + }, + ], + }, + ], + remotes: [ + { + type: "http", + url: "https://example.com/awslabs/aws-nova-canvas", + headers: [], + }, + ], }, _meta: { - "io.modelcontextprotocol.registry/publisher-provided": {}, - }, - icons: [ - { - sizes: ["32x32"], - mimeType: "image/x-icon", - src: "https://www.amazon.com/favicon.ico", - }, - ], - packages: [ - { - version: "1.0.0", - environmentVariables: [ - { - name: "AWS_ACCESS_KEY_ID", - description: "AWS Access Key ID", - format: "string", - }, - ], - }, - ], - remotes: [ - { - type: "http", - url: "https://example.com/awslabs/aws-nova-canvas", - headers: [], - }, - ], - }, - _meta: { - "io.modelcontextprotocol.registry/official": { - isLatest: false, - publishedAt: "2024-01-16T07:47:41.0Z", - status: "active", + "io.modelcontextprotocol.registry/official": { + isLatest: false, + publishedAt: "2024-01-16T07:47:41.0Z", + status: "active", + }, }, }, - }, - { - server: { - title: "AgentQL MCP", - name: "tinyfish/agentql-mcp", - version: "1.0.1", - description: "A powerful MCP server for building AI agents", - repository: { - source: "github", - id: "tinyfish", - url: "https://github.com/tinyfish/agentql-mcp", + { + server: { + title: "AgentQL MCP", + name: "tinyfish/agentql-mcp", + version: "1.0.1", + description: "A powerful MCP server for building AI agents", + repository: { + source: "github", + id: "tinyfish", + url: "https://github.com/tinyfish/agentql-mcp", + }, + _meta: { + "io.modelcontextprotocol.registry/publisher-provided": {}, + }, + icons: [], + packages: [], + remotes: [ + { + type: "http", + url: "https://example.com/tinyfish/agentql-mcp", + headers: [], + }, + ], }, _meta: { - "io.modelcontextprotocol.registry/publisher-provided": {}, - }, - icons: [], - packages: [], - remotes: [ - { - type: "http", - url: "https://example.com/tinyfish/agentql-mcp", - headers: [], - }, - ], - }, - _meta: { - "io.modelcontextprotocol.registry/official": { - isLatest: true, - publishedAt: "2024-02-09T18:53:24.0Z", - status: "active", + "io.modelcontextprotocol.registry/official": { + isLatest: true, + publishedAt: "2024-02-09T18:53:24.0Z", + status: "active", + }, }, }, - }, - { - server: { - title: "Astra DB MCP", - name: "datastax/astra-db-mcp", - version: "1.0.2", - description: "Integrate AI assistants with Astra DB", - repository: { - source: "github", - id: "datastax", - url: "https://github.com/datastax/astra-db-mcp", + { + server: { + title: "Astra DB MCP", + name: "datastax/astra-db-mcp", + version: "1.0.2", + description: "Integrate AI assistants with Astra DB", + repository: { + source: "github", + id: "datastax", + url: "https://github.com/datastax/astra-db-mcp", + }, + _meta: { + "io.modelcontextprotocol.registry/publisher-provided": {}, + }, + icons: [], + packages: [], + remotes: [ + { + type: "http", + url: "https://example.com/datastax/astra-db-mcp", + headers: [], + }, + ], }, _meta: { - "io.modelcontextprotocol.registry/publisher-provided": {}, - }, - icons: [], - packages: [], - remotes: [ - { - type: "http", - url: "https://example.com/datastax/astra-db-mcp", - headers: [], - }, - ], - }, - _meta: { - "io.modelcontextprotocol.registry/official": { - isLatest: true, - publishedAt: "2024-06-16T06:09:48.0Z", - status: "active", + "io.modelcontextprotocol.registry/official": { + isLatest: true, + publishedAt: "2024-06-16T06:09:48.0Z", + status: "active", + }, }, }, - }, - { - server: { - title: "Microsoft Azure", - name: "microsoft/azure-mcp", - version: "1.0.0", - description: "Connect AI assistants to Azure services", - repository: { - source: "github", - id: "microsoft", - url: "https://github.com/microsoft/azure-mcp", + { + server: { + title: "Microsoft Azure", + name: "microsoft/azure-mcp", + version: "1.0.0", + description: "Connect AI assistants to Azure services", + repository: { + source: "github", + id: "microsoft", + url: "https://github.com/microsoft/azure-mcp", + }, + _meta: {}, + icons: [], + packages: [], + remotes: [ + { + type: "http", + url: "https://example.com/microsoft/azure-mcp", + headers: [], + }, + ], }, - _meta: {}, - icons: [], - packages: [], - remotes: [ - { - type: "http", - url: "https://example.com/microsoft/azure-mcp", - headers: [], - }, - ], - }, - _meta: { - "io.modelcontextprotocol.registry/official": { - isLatest: true, - publishedAt: "2024-01-16T07:47:41.0Z", - status: "active", + _meta: { + "io.modelcontextprotocol.registry/official": { + isLatest: true, + publishedAt: "2024-01-16T07:47:41.0Z", + status: "active", + }, }, }, - }, - { - server: { - title: "Google Workspace", - name: "google/mcp-google-apps", - version: "1.0.0", - description: - "Access your Google Workspace apps, including calendar, mail, drive, docs, slides and sheets", - repository: { - source: "github", - id: "google", - url: "https://github.com/google/mcp-google-apps", + { + server: { + title: "Google Workspace", + name: "google/mcp-google-apps", + version: "1.0.0", + description: + "Access your Google Workspace apps, including calendar, mail, drive, docs, slides and sheets", + repository: { + source: "github", + id: "google", + url: "https://github.com/google/mcp-google-apps", + }, + _meta: {}, + icons: [], + packages: [], + remotes: [ + { + type: "http", + url: "https://example.com/google", + headers: [], + }, + ], }, - _meta: {}, - icons: [], - packages: [], - remotes: [ - { - type: "http", - url: "https://example.com/google", - headers: [], - }, - ], - }, - _meta: { - "io.modelcontextprotocol.registry/official": { - isLatest: true, - publishedAt: "2024-01-16T07:47:41.0Z", - status: "active", + _meta: { + "io.modelcontextprotocol.registry/official": { + isLatest: true, + publishedAt: "2024-01-16T07:47:41.0Z", + status: "active", + }, }, }, - }, - { - server: { - title: "Figma Desktop", - name: "figma/mcp-desktop", - version: "1.0.0", - description: - "Connect AI assistants to Figma Desktop for design collaboration and automation", - repository: { - source: "github", - id: "figma", - url: "https://github.com/figma/mcp-desktop", + { + server: { + title: "Figma Desktop", + name: "figma/mcp-desktop", + version: "1.0.0", + description: + "Connect AI assistants to Figma Desktop for design collaboration and automation", + repository: { + source: "github", + id: "figma", + url: "https://github.com/figma/mcp-desktop", + }, + _meta: {}, + icons: [], + packages: [], + remotes: [ + { + type: "http", + url: "https://example.com/figma", + headers: [], + }, + ], }, - _meta: {}, - icons: [], - packages: [], - remotes: [ - { - type: "http", - url: "https://example.com/figma", - headers: [], - }, - ], - }, - _meta: { - "io.modelcontextprotocol.registry/official": { - isLatest: true, - publishedAt: "2024-01-16T07:47:41.0Z", - status: "active", + _meta: { + "io.modelcontextprotocol.registry/official": { + isLatest: true, + publishedAt: "2024-01-16T07:47:41.0Z", + status: "active", + }, }, }, - }, - { - server: { - title: "Slack Workspace", - name: "slack/mcp-slack", - version: "1.0.0", - description: - "Integrate AI assistants with Slack for team communication and automation", - repository: { - source: "github", - id: "slack", - url: "https://github.com/slack/mcp-slack", + { + server: { + title: "Slack Workspace", + name: "slack/mcp-slack", + version: "1.0.0", + description: + "Integrate AI assistants with Slack for team communication and automation", + repository: { + source: "github", + id: "slack", + url: "https://github.com/slack/mcp-slack", + }, + _meta: {}, + icons: [], + packages: [], + remotes: [ + { + type: "http", + url: "https://example.com/slack", + headers: [], + }, + ], }, - _meta: {}, - icons: [], - packages: [], - remotes: [ - { - type: "http", - url: "https://example.com/slack", - headers: [], - }, - ], - }, - _meta: { - "io.modelcontextprotocol.registry/official": { - isLatest: true, - publishedAt: "2024-01-16T07:47:41.0Z", - status: "active", + _meta: { + "io.modelcontextprotocol.registry/official": { + isLatest: true, + publishedAt: "2024-01-16T07:47:41.0Z", + status: "active", + }, }, }, - }, - { - server: { - title: "GitHub API", - name: "github/mcp-github", - version: "1.0.0", - description: - "Interact with GitHub repositories, issues, and pull requests", - repository: { - source: "github", - id: "github", - url: "https://github.com/github/mcp-github", + { + server: { + title: "GitHub API", + name: "github/mcp-github", + version: "1.0.0", + description: + "Interact with GitHub repositories, issues, and pull requests", + repository: { + source: "github", + id: "github", + url: "https://github.com/github/mcp-github", + }, + _meta: {}, + icons: [], + packages: [], + remotes: [ + { + type: "http", + url: "https://example.com/github", + headers: [], + }, + ], }, - _meta: {}, - icons: [], - packages: [], - remotes: [ - { - type: "http", - url: "https://example.com/github", - headers: [], - }, - ], - }, - _meta: { - "io.modelcontextprotocol.registry/official": { - isLatest: true, - publishedAt: "2024-01-16T07:47:41.0Z", - status: "active", + _meta: { + "io.modelcontextprotocol.registry/official": { + isLatest: true, + publishedAt: "2024-01-16T07:47:41.0Z", + status: "active", + }, }, }, - }, - { - server: { - title: "Stripe Payments", - name: "stripe/mcp-stripe", - version: "1.0.0", - description: "Manage Stripe payments, subscriptions, and customer data", - repository: { - source: "github", - id: "stripe", - url: "https://github.com/stripe/mcp-stripe", + { + server: { + title: "Stripe Payments", + name: "stripe/mcp-stripe", + version: "1.0.0", + description: + "Manage Stripe payments, subscriptions, and customer data", + repository: { + source: "github", + id: "stripe", + url: "https://github.com/stripe/mcp-stripe", + }, + _meta: {}, + icons: [], + packages: [], + remotes: [ + { + type: "http", + url: "https://example.com/stripe", + headers: [], + }, + ], }, - _meta: {}, - icons: [], - packages: [], - remotes: [ - { - type: "http", - url: "https://example.com/stripe", - headers: [], - }, - ], - }, - _meta: { - "io.modelcontextprotocol.registry/official": { - isLatest: true, - publishedAt: "2024-01-16T07:47:41.0Z", - status: "active", + _meta: { + "io.modelcontextprotocol.registry/official": { + isLatest: true, + publishedAt: "2024-01-16T07:47:41.0Z", + status: "active", + }, }, }, - }, - { - server: { - title: "Notion Workspace", - name: "notion/mcp-notion", - version: "1.0.0", - description: "Access and manage Notion pages, databases, and content", - repository: { - source: "github", - id: "notion", - url: "https://github.com/notion/mcp-notion", + { + server: { + title: "Notion Workspace", + name: "notion/mcp-notion", + version: "1.0.0", + description: "Access and manage Notion pages, databases, and content", + repository: { + source: "github", + id: "notion", + url: "https://github.com/notion/mcp-notion", + }, + _meta: {}, + icons: [], + packages: [], + remotes: [ + { + type: "http", + url: "https://example.com/notion", + headers: [], + }, + ], }, - _meta: {}, - icons: [], - packages: [], - remotes: [ - { - type: "http", - url: "https://example.com/notion", - headers: [], - }, - ], - }, - _meta: { - "io.modelcontextprotocol.registry/official": { - isLatest: true, - publishedAt: "2024-01-16T07:47:41.0Z", - status: "active", + _meta: { + "io.modelcontextprotocol.registry/official": { + isLatest: true, + publishedAt: "2024-01-16T07:47:41.0Z", + status: "active", + }, }, }, - }, - { - server: { - title: "Salesforce CRM", - name: "salesforce/mcp-salesforce", - version: "1.0.0", - description: - "Connect to Salesforce CRM for customer management and automation", - repository: { - source: "github", - id: "salesforce", - url: "https://github.com/salesforce/mcp-salesforce", + { + server: { + title: "Salesforce CRM", + name: "salesforce/mcp-salesforce", + version: "1.0.0", + description: + "Connect to Salesforce CRM for customer management and automation", + repository: { + source: "github", + id: "salesforce", + url: "https://github.com/salesforce/mcp-salesforce", + }, + _meta: {}, + icons: [], + packages: [], + remotes: [ + { + type: "http", + url: "https://example.com/salesforce", + headers: [], + }, + ], }, - _meta: {}, - icons: [], - packages: [], - remotes: [ - { - type: "http", - url: "https://example.com/salesforce", - headers: [], - }, - ], - }, - _meta: { - "io.modelcontextprotocol.registry/official": { - isLatest: true, - publishedAt: "2024-01-16T07:47:41.0Z", - status: "active", + _meta: { + "io.modelcontextprotocol.registry/official": { + isLatest: true, + publishedAt: "2024-01-16T07:47:41.0Z", + status: "active", + }, }, }, - }, - { - server: { - title: "HubSpot Marketing", - name: "hubspot/mcp-hubspot", - version: "1.0.0", - description: "Integrate with HubSpot for marketing automation and CRM", - repository: { - source: "github", - id: "hubspot", - url: "https://github.com/hubspot/mcp-hubspot", + { + server: { + title: "HubSpot Marketing", + name: "hubspot/mcp-hubspot", + version: "1.0.0", + description: + "Integrate with HubSpot for marketing automation and CRM", + repository: { + source: "github", + id: "hubspot", + url: "https://github.com/hubspot/mcp-hubspot", + }, + _meta: {}, + icons: [], + packages: [], + remotes: [ + { + type: "http", + url: "https://example.com/hubspot", + headers: [], + }, + ], }, - _meta: {}, - icons: [], - packages: [], - remotes: [ - { - type: "http", - url: "https://example.com/hubspot", - headers: [], - }, - ], - }, - _meta: { - "io.modelcontextprotocol.registry/official": { - isLatest: true, - publishedAt: "2024-01-16T07:47:41.0Z", - status: "active", + _meta: { + "io.modelcontextprotocol.registry/official": { + isLatest: true, + publishedAt: "2024-01-16T07:47:41.0Z", + status: "active", + }, }, }, - }, - { - server: { - title: "Linear Project", - name: "linear/mcp-linear", - version: "1.0.0", - description: "Manage Linear issues, projects, and team workflows", - repository: { - source: "github", - id: "linear", - url: "https://github.com/linear/mcp-linear", + { + server: { + title: "Linear Project", + name: "linear/mcp-linear", + version: "1.0.0", + description: "Manage Linear issues, projects, and team workflows", + repository: { + source: "github", + id: "linear", + url: "https://github.com/linear/mcp-linear", + }, + _meta: {}, + icons: [], + packages: [], + remotes: [ + { + type: "http", + url: "https://example.com/linear", + headers: [], + }, + ], }, - _meta: {}, - icons: [], - packages: [], - remotes: [ - { - type: "http", - url: "https://example.com/linear", - headers: [], - }, - ], - }, - _meta: { - "io.modelcontextprotocol.registry/official": { - isLatest: true, - publishedAt: "2024-01-16T07:47:41.0Z", - status: "active", + _meta: { + "io.modelcontextprotocol.registry/official": { + isLatest: true, + publishedAt: "2024-01-16T07:47:41.0Z", + status: "active", + }, }, }, - }, - { - server: { - title: "Trello Boards", - name: "trello/mcp-trello", - version: "1.0.0", - description: "Access and manage Trello boards, cards, and lists", - repository: { - source: "github", - id: "trello", - url: "https://github.com/trello/mcp-trello", + { + server: { + title: "Trello Boards", + name: "trello/mcp-trello", + version: "1.0.0", + description: "Access and manage Trello boards, cards, and lists", + repository: { + source: "github", + id: "trello", + url: "https://github.com/trello/mcp-trello", + }, + _meta: {}, + icons: [], + packages: [], + remotes: [ + { + type: "http", + url: "https://example.com/trello", + headers: [], + }, + ], }, - _meta: {}, - icons: [], - packages: [], - remotes: [ - { - type: "http", - url: "https://example.com/trello", - headers: [], - }, - ], - }, - _meta: { - "io.modelcontextprotocol.registry/official": { - isLatest: true, - publishedAt: "2024-01-16T07:47:41.0Z", - status: "active", + _meta: { + "io.modelcontextprotocol.registry/official": { + isLatest: true, + publishedAt: "2024-01-16T07:47:41.0Z", + status: "active", + }, }, }, - }, - { - server: { - title: "Jira Management", - name: "atlassian/mcp-jira", - version: "1.0.0", - description: - "Manage Jira issues, projects, and workflows through AI assistants", - repository: { - source: "github", - id: "atlassian", - url: "https://github.com/atlassian/mcp-jira", + { + server: { + title: "Jira Management", + name: "atlassian/mcp-jira", + version: "1.0.0", + description: + "Manage Jira issues, projects, and workflows through AI assistants", + repository: { + source: "github", + id: "atlassian", + url: "https://github.com/atlassian/mcp-jira", + }, + websiteUrl: "https://github.com/atlassian/mcp-jira", + _meta: {}, + icons: [], + packages: [], + remotes: [], }, - websiteUrl: "https://github.com/atlassian/mcp-jira", - _meta: {}, - icons: [], - packages: [], - remotes: [], - }, - _meta: { - "io.modelcontextprotocol.registry/official": { - isLatest: true, - publishedAt: "2024-01-16T07:47:41.0Z", - status: "active", + _meta: { + "io.modelcontextprotocol.registry/official": { + isLatest: true, + publishedAt: "2024-01-16T07:47:41.0Z", + status: "active", + }, }, }, + ], + metadata: { + count: 15, + nextCursor: "next-page", }, - ], - metadata: { - count: 15, - nextCursor: "next-page", - }, -}); + }); diff --git a/src/mocks/fixtures/registry_v0_1_servers__serverName__versions__version_/get.ts b/src/mocks/fixtures/registry_v0_1_servers__serverName__versions__version_/get.ts index 88e5d8e..9221692 100644 --- a/src/mocks/fixtures/registry_v0_1_servers__serverName__versions__version_/get.ts +++ b/src/mocks/fixtures/registry_v0_1_servers__serverName__versions__version_/get.ts @@ -1,8 +1,8 @@ import type { GetRegistryV01ServersByServerNameVersionsByVersionResponse } from "@api/types.gen"; import { AutoAPIMock } from "@mocks"; -export default AutoAPIMock( - { +export const mockedGetRegistryV01ServersByServerNameVersionsByVersion = + AutoAPIMock({ server: { name: "awslabs/aws-nova-canvas", title: "AWS Nova Canvas MCP Server", @@ -54,5 +54,4 @@ export default AutoAPIMock "mockedGetRegistryV01Servers" + */ +export function deriveMockName(responseTypeName: string): string { + // Strip "Response" or "Responses" suffix and add "mocked" prefix + const baseName = responseTypeName + .replace(/Responses?$/, "") + .replace(/^Get/, "get") + .replace(/^Post/, "post") + .replace(/^Put/, "put") + .replace(/^Patch/, "patch") + .replace(/^Delete/, "delete"); + + return `mocked${baseName.charAt(0).toUpperCase()}${baseName.slice(1)}`; +} + /** * Renders a TypeScript module for a generated mock fixture. * When a response type name is provided, includes a type import @@ -6,6 +23,7 @@ */ export function buildMockModule(payload: unknown, opType?: string): string { const typeName = opType?.trim(); + const mockName = typeName ? deriveMockName(typeName) : "mockedResponse"; // Type imports first, then value imports (biome import order) const imports = [ @@ -15,5 +33,5 @@ export function buildMockModule(payload: unknown, opType?: string): string { const typeParam = typeName ? `<${typeName}>` : ""; - return `${imports}\n\nexport default AutoAPIMock${typeParam}(${JSON.stringify(payload, null, 2)})\n`; + return `${imports}\n\nexport const ${mockName} = AutoAPIMock${typeParam}(${JSON.stringify(payload, null, 2)});\n`; } diff --git a/src/mocks/mocker.ts b/src/mocks/mocker.ts index b09c9a2..e38116d 100644 --- a/src/mocks/mocker.ts +++ b/src/mocks/mocker.ts @@ -5,7 +5,7 @@ import { JSONSchemaFaker as jsf } from "json-schema-faker"; import type { RequestHandler } from "msw"; import { HttpResponse, http } from "msw"; import type { AutoAPIMockInstance } from "./autoAPIMock"; -import { buildMockModule } from "./mockTemplate"; +import { buildMockModule, deriveMockName } from "./mockTemplate"; // ===== Config ===== // Adjust the path of the OpenAPI JSON here if needed. @@ -469,16 +469,19 @@ export function autoGenerateHandlers() { let fixture: AutoAPIMockInstance; const relPath = getFixtureRelPath(safePath, method); + const opType = successStatus + ? opResponseTypeName(method, rawPath) + : undefined; + const mockName = opType ? deriveMockName(opType) : "mockedResponse"; + try { const importer = fixtureImporters?.[relPath]; if (importer) { - const mod = (await importer()) as unknown; - fixture = (mod as { default?: unknown }) - ?.default as AutoAPIMockInstance; + const mod = (await importer()) as Record; + fixture = mod[mockName] as AutoAPIMockInstance; } else { - const mod = (await import(relPath)) as unknown; - fixture = (mod as { default?: unknown }) - ?.default as AutoAPIMockInstance; + const mod = (await import(relPath)) as Record; + fixture = mod[mockName] as AutoAPIMockInstance; } } catch (e) { return new HttpResponse( @@ -491,7 +494,7 @@ export function autoGenerateHandlers() { if (!fixture || typeof fixture.generatedHandler !== "function") { return new HttpResponse( - `[auto-mocker] Invalid fixture format: ${relPath}. Expected AutoAPIMock wrapper.`, + `[auto-mocker] Invalid fixture format: ${relPath}. Expected named export "${mockName}" with AutoAPIMock wrapper.`, { status: 500 }, ); } diff --git a/src/mocks/server-detail/index.ts b/src/mocks/server-detail/index.ts index fcfcb3b..84559e0 100644 --- a/src/mocks/server-detail/index.ts +++ b/src/mocks/server-detail/index.ts @@ -1,6 +1,6 @@ import type { RequestHandler } from "msw"; import { HttpResponse, http } from "msw"; -import serversListMock from "../fixtures/registry_v0_1_servers/get"; +import { mockedGetRegistryV01Servers } from "../fixtures/registry_v0_1_servers/get"; // Add non-schema, hand-written mocks here. // These take precedence over the schema-based mocks. @@ -18,7 +18,7 @@ export const serverDetailHandlers: RequestHandler[] = [ version, }); - const serversListFixture = serversListMock.defaultValue; + const serversListFixture = mockedGetRegistryV01Servers.defaultValue; // Find matching server from the list const serverResponse = serversListFixture.servers?.find((item) => { From 91db28b8ed86f9e5ed0e018d396f6b7c0ad957a3 Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Tue, 9 Dec 2025 16:26:40 +0100 Subject: [PATCH 03/16] update mocks documentation --- docs/mocks.md | 113 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 1 deletion(-) diff --git a/docs/mocks.md b/docs/mocks.md index 42dabf1..98112ab 100644 --- a/docs/mocks.md +++ b/docs/mocks.md @@ -3,7 +3,6 @@ MSW Auto-Mocker - Handlers: `src/mocks/handlers.ts` combines non-schema mocks and auto-generated mocks. - Non-schema mocks: add hand-written handlers in `src/mocks/customHandlers/index.ts`. These take precedence over schema-based mocks. - Auto-generated: `src/mocks/mocker.ts` reads `swagger.json` and creates fixtures under `src/mocks/fixtures` on first run. -- Validation: Loaded fixtures are validated with Ajv; errors log to console. Usage - Vitest: tests initialize MSW in `src/mocks/test.setup.ts`. Run `pnpm test`. @@ -24,3 +23,115 @@ Failure behavior (always strict) Types - Fixtures default to strict types. Generated modules import response types from `@api/types.gen` and use a `satisfies` clause to ensure compatibility. - Make sure `tsconfig.json` includes: `"paths": { "@api/*": ["./src/generated/*"] }`. + +## Test-Scoped Overrides with AutoAPIMock + +Each fixture is wrapped in `AutoAPIMock`, which provides test-scoped override capabilities. + +### Fixture Structure + +Generated fixtures use named exports with a consistent naming convention: + +```typescript +// src/mocks/fixtures/registry_v0_1_servers/get.ts +import type { GetRegistryV01ServersResponse } from "@api/types.gen"; +import { AutoAPIMock } from "@mocks"; + +export const mockedGetRegistryV01Servers = AutoAPIMock({ + servers: [...], + metadata: { count: 15 }, +}); +``` + +### Overriding in Tests + +Use `.override()` to customize responses for specific tests: + +```typescript +import { HttpResponse } from "msw"; +import { mockedGetRegistryV01Servers } from "@mocks/fixtures/registry_v0_1_servers/get"; + +describe("ServerList", () => { + it("shows empty state when no servers", async () => { + // Override to return empty list for this test only + mockedGetRegistryV01Servers.override(() => + HttpResponse.json({ servers: [], metadata: { count: 0 } }) + ); + + render(); + expect(screen.getByText("No servers available")).toBeVisible(); + }); + + it("shows error state on API failure", async () => { + // Override to return an error + mockedGetRegistryV01Servers.override(() => + HttpResponse.json({ error: "Server error" }, { status: 500 }) + ); + + render(); + expect(screen.getByText("Failed to load servers")).toBeVisible(); + }); + + it("shows servers from default fixture", async () => { + // No override - uses default fixture data + render(); + expect(screen.getByText("AWS Nova Canvas")).toBeVisible(); + }); +}); +``` + +### API Reference + +```typescript +interface AutoAPIMockInstance { + // The default fixture data + defaultValue: T; + + // Override the response for the current test + override(fn: (data: T, info: ResponseResolverInfo) => Response): this; + + // Reset to default behavior (called automatically before each test) + reset(): this; + + // Internal handler used by MSW (don't call directly) + generatedHandler: HttpResponseResolver; +} +``` + +### Override Function + +The override function receives: +1. `data: T` - The default fixture data (useful for partial modifications) +2. `info: ResponseResolverInfo` - MSW request info (request, params, cookies) + +Return a `Response` object (use `HttpResponse.json()` from MSW). + +### Automatic Reset + +Overrides are automatically reset before each test via `beforeEach()` in `src/mocks/test.setup.ts`. You don't need to manually reset mocks between tests. + +### Using Default Data in Overrides + +Access the default fixture data to make partial modifications: + +```typescript +mockedGetRegistryV01Servers.override((data) => + HttpResponse.json({ + ...data, + servers: data.servers?.slice(0, 1), // Keep only first server + }) +); +``` + +### Accessing Request Info + +Use the `info` parameter to vary responses based on request: + +```typescript +mockedGetRegistryV01Servers.override((data, info) => { + const cursor = new URL(info.request.url).searchParams.get("cursor"); + if (cursor === "page2") { + return HttpResponse.json({ servers: [], metadata: { count: 0 } }); + } + return HttpResponse.json(data); +}); From 65cef62cb7b5e583f789a532b36ad32bc749e9a5 Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Tue, 9 Dec 2025 16:38:06 +0100 Subject: [PATCH 04/16] add some usage examples for mock overrides --- .../[serverName]/[version]/actions.test.ts | 77 ++++++ src/app/catalog/actions.test.ts | 223 ++++++++++++++++++ src/mocks/mocker.ts | 3 +- 3 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 src/app/catalog/[repoName]/[serverName]/[version]/actions.test.ts create mode 100644 src/app/catalog/actions.test.ts diff --git a/src/app/catalog/[repoName]/[serverName]/[version]/actions.test.ts b/src/app/catalog/[repoName]/[serverName]/[version]/actions.test.ts new file mode 100644 index 0000000..b2c7044 --- /dev/null +++ b/src/app/catalog/[repoName]/[serverName]/[version]/actions.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getServerDetails } from "./actions"; + +// Mock the auth to bypass authentication +vi.mock("@/lib/api-client", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + getAuthenticatedClient: vi.fn(() => + original.getAuthenticatedClient("mock-token"), + ), + }; +}); + +describe("getServerDetails", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("successful responses", () => { + it("returns server data for valid server name and version", async () => { + const result = await getServerDetails("awslabs/aws-nova-canvas", "1.0.0"); + + expect(result.error).toBeUndefined(); + expect(result.data?.server?.name).toBe("awslabs/aws-nova-canvas"); + expect(result.data?.server?.title).toBe("AWS Nova Canvas"); + }); + + it("returns server data when version is 'latest'", async () => { + const result = await getServerDetails( + "awslabs/aws-nova-canvas", + "latest", + ); + + expect(result.error).toBeUndefined(); + expect(result.data?.server?.name).toBe("awslabs/aws-nova-canvas"); + }); + + it("returns different servers from fixture", async () => { + const result = await getServerDetails("google/mcp-google-apps", "1.0.0"); + + expect(result.error).toBeUndefined(); + expect(result.data?.server?.name).toBe("google/mcp-google-apps"); + expect(result.data?.server?.title).toBe("Google Workspace"); + }); + }); + + describe("error handling", () => { + it("returns 404 for non-existent server", async () => { + const result = await getServerDetails("non-existent/server", "1.0.0"); + + expect(result.response.status).toBe(404); + }); + + it("returns 404 for wrong version", async () => { + const result = await getServerDetails("awslabs/aws-nova-canvas", "9.9.9"); + + expect(result.response.status).toBe(404); + }); + }); + + describe("fixture data", () => { + it("includes metadata in response", async () => { + const result = await getServerDetails("awslabs/aws-nova-canvas", "1.0.0"); + + expect(result.data?._meta).toBeDefined(); + }); + + it("returns full server details from fixture", async () => { + const result = await getServerDetails("awslabs/aws-nova-canvas", "1.0.0"); + + expect(result.data?.server?.name).toBe("awslabs/aws-nova-canvas"); + expect(result.data?.server?.version).toBe("1.0.0"); + expect(result.data?.server?.description).toContain("MCP server"); + }); + }); +}); diff --git a/src/app/catalog/actions.test.ts b/src/app/catalog/actions.test.ts new file mode 100644 index 0000000..9bb10bc --- /dev/null +++ b/src/app/catalog/actions.test.ts @@ -0,0 +1,223 @@ +import { mockedGetRegistryV01Servers } from "@mocks/fixtures/registry_v0_1_servers/get"; +import { HttpResponse } from "msw"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getServers } from "./actions"; + +// Mock the auth to bypass authentication +vi.mock("@/lib/api-client", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + getAuthenticatedClient: vi.fn(() => + original.getAuthenticatedClient("mock-token"), + ), + }; +}); + +describe("getServers", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("successful responses", () => { + it("returns servers from default fixture", async () => { + const servers = await getServers(); + + expect(servers.length).toBeGreaterThan(0); + expect(servers[0].name).toBe("awslabs/aws-nova-canvas"); + expect(servers[0].title).toBe("AWS Nova Canvas"); + }); + + it("returns empty array when API returns no servers", async () => { + mockedGetRegistryV01Servers.override(() => + HttpResponse.json({ servers: [], metadata: { count: 0 } }), + ); + + const servers = await getServers(); + + expect(servers).toEqual([]); + }); + + it("returns empty array when API returns null data", async () => { + mockedGetRegistryV01Servers.override(() => HttpResponse.json(null)); + + const servers = await getServers(); + + expect(servers).toEqual([]); + }); + }); + + describe("error handling", () => { + it("throws on 500 server error", async () => { + mockedGetRegistryV01Servers.override(() => + HttpResponse.json({ error: "Internal Server Error" }, { status: 500 }), + ); + + await expect(getServers()).rejects.toBeDefined(); + }); + + it("throws on 401 unauthorized", async () => { + mockedGetRegistryV01Servers.override(() => + HttpResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); + + await expect(getServers()).rejects.toBeDefined(); + }); + + it("throws on network error", async () => { + mockedGetRegistryV01Servers.override(() => HttpResponse.error()); + + await expect(getServers()).rejects.toBeDefined(); + }); + }); + + describe("data transformation", () => { + it("filters out null servers from response", async () => { + mockedGetRegistryV01Servers.override((data) => + HttpResponse.json({ + ...data, + servers: [ + { server: { name: "valid/server", title: "Valid" } }, + { server: null }, + { server: { name: "another/server", title: "Another" } }, + ], + metadata: { count: 3 }, + }), + ); + + const servers = await getServers(); + + expect(servers).toHaveLength(2); + expect(servers.map((s) => s.name)).toEqual([ + "valid/server", + "another/server", + ]); + }); + + it("filters out undefined servers from response", async () => { + mockedGetRegistryV01Servers.override(() => + HttpResponse.json({ + servers: [ + { server: { name: "valid/server", title: "Valid" } }, + { server: undefined }, + {}, + ], + metadata: { count: 3 }, + }), + ); + + const servers = await getServers(); + + expect(servers).toHaveLength(1); + expect(servers[0].name).toBe("valid/server"); + }); + + it("extracts server objects from nested response structure", async () => { + mockedGetRegistryV01Servers.override(() => + HttpResponse.json({ + servers: [ + { + server: { + name: "test/server", + title: "Test Server", + description: "A test server", + version: "2.0.0", + }, + _meta: { "some/key": { status: "active" } }, + }, + ], + metadata: { count: 1 }, + }), + ); + + const servers = await getServers(); + + expect(servers).toHaveLength(1); + expect(servers[0]).toEqual({ + name: "test/server", + title: "Test Server", + description: "A test server", + version: "2.0.0", + }); + }); + }); + + describe("using default data in overrides", () => { + it("can modify specific server titles", async () => { + mockedGetRegistryV01Servers.override((data) => + HttpResponse.json({ + ...data, + servers: data.servers?.map((item) => ({ + ...item, + server: { + ...item.server, + title: `Modified: ${item.server?.title}`, + }, + })), + }), + ); + + const servers = await getServers(); + + expect(servers[0].title).toBe("Modified: AWS Nova Canvas"); + }); + + it("can limit the number of returned servers", async () => { + mockedGetRegistryV01Servers.override((data) => + HttpResponse.json({ + ...data, + servers: data.servers?.slice(0, 3), + metadata: { count: 3 }, + }), + ); + + const servers = await getServers(); + + expect(servers).toHaveLength(3); + }); + + it("can filter servers by criteria", async () => { + mockedGetRegistryV01Servers.override((data) => + HttpResponse.json({ + ...data, + servers: data.servers?.filter((item) => + item.server?.name?.includes("google"), + ), + }), + ); + + const servers = await getServers(); + + expect(servers.every((s) => s.name?.includes("google"))).toBe(true); + }); + }); + + describe("request-aware overrides", () => { + it("can access request info in override", async () => { + let capturedUrl: string | undefined; + + mockedGetRegistryV01Servers.override((data, info) => { + capturedUrl = info.request.url; + return HttpResponse.json(data); + }); + + await getServers(); + + expect(capturedUrl).toContain("/registry/v0.1/servers"); + }); + + it("can vary response based on request headers", async () => { + mockedGetRegistryV01Servers.override((data, info) => { + const authHeader = info.request.headers.get("Authorization"); + if (authHeader?.includes("mock-token")) { + return HttpResponse.json(data); + } + return HttpResponse.json({ error: "Unauthorized" }, { status: 401 }); + }); + + // Our mock provides "mock-token", so this should succeed + const servers = await getServers(); + expect(servers.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/mocks/mocker.ts b/src/mocks/mocker.ts index e38116d..69a57cf 100644 --- a/src/mocks/mocker.ts +++ b/src/mocks/mocker.ts @@ -347,9 +347,10 @@ export function autoGenerateHandlers() { const result: RequestHandler[] = []; // Prefer Vite glob import when available (Vitest/Vite runtime) + // Note: We don't use { import: "default" } since fixtures use named exports const fixtureImporters: Record Promise> = typeof import.meta.glob === "function" - ? import.meta.glob("./fixtures/**", { import: "default" }) + ? import.meta.glob("./fixtures/**") : {}; const specPaths = Object.entries( From 2e7dbf29205845a5476887559c78a47624c6bcd3 Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Tue, 9 Dec 2025 16:47:31 +0100 Subject: [PATCH 05/16] add overrideResponse helper --- docs/mocks.md | 72 ++++++++++++++++++++---- src/app/catalog/actions.test.ts | 97 +++++++++++++++++---------------- src/mocks/autoAPIMock.ts | 8 +++ 3 files changed, 118 insertions(+), 59 deletions(-) diff --git a/docs/mocks.md b/docs/mocks.md index 98112ab..d401bd9 100644 --- a/docs/mocks.md +++ b/docs/mocks.md @@ -87,9 +87,12 @@ interface AutoAPIMockInstance { // The default fixture data defaultValue: T; - // Override the response for the current test + // Override the response for the current test (full control) override(fn: (data: T, info: ResponseResolverInfo) => Response): this; + // Override just the response data (simpler API) + overrideResponse(fn: (data: T, info: ResponseResolverInfo) => T): this; + // Reset to default behavior (called automatically before each test) reset(): this; @@ -98,13 +101,44 @@ interface AutoAPIMockInstance { } ``` -### Override Function +### Choosing Between `override` and `overrideResponse` + +Use **`overrideResponse`** (simpler) when you just want to change the response data: + +```typescript +// Simple - just return the data you want +mockedGetRegistryV01Servers.overrideResponse(() => ({ + servers: [], + metadata: { count: 0 }, +})); + +// With default data modifications +mockedGetRegistryV01Servers.overrideResponse((data) => ({ + ...data, + servers: data.servers?.slice(0, 3), +})); +``` + +Use **`override`** (full control) when you need: +- Custom HTTP status codes (errors) +- Non-JSON responses +- Network errors +- Invalid/malformed data for edge case testing + +```typescript +// Return error status +mockedGetRegistryV01Servers.override(() => + HttpResponse.json({ error: "Server error" }, { status: 500 }) +); -The override function receives: -1. `data: T` - The default fixture data (useful for partial modifications) -2. `info: ResponseResolverInfo` - MSW request info (request, params, cookies) +// Network error +mockedGetRegistryV01Servers.override(() => HttpResponse.error()); -Return a `Response` object (use `HttpResponse.json()` from MSW). +// Testing invalid data shapes +mockedGetRegistryV01Servers.override(() => + HttpResponse.json({ servers: [{ server: null }] }) +); +``` ### Automatic Reset @@ -115,23 +149,39 @@ Overrides are automatically reset before each test via `beforeEach()` in `src/mo Access the default fixture data to make partial modifications: ```typescript +// With overrideResponse (cleaner) +mockedGetRegistryV01Servers.overrideResponse((data) => ({ + ...data, + servers: data.servers?.slice(0, 1), +})); + +// With override (when you need the Response wrapper) mockedGetRegistryV01Servers.override((data) => HttpResponse.json({ ...data, - servers: data.servers?.slice(0, 1), // Keep only first server + servers: data.servers?.slice(0, 1), }) ); ``` ### Accessing Request Info -Use the `info` parameter to vary responses based on request: +Both methods receive request info as the second parameter: ```typescript -mockedGetRegistryV01Servers.override((data, info) => { +// With overrideResponse +mockedGetRegistryV01Servers.overrideResponse((data, info) => { const cursor = new URL(info.request.url).searchParams.get("cursor"); - if (cursor === "page2") { - return HttpResponse.json({ servers: [], metadata: { count: 0 } }); + return cursor === "page2" + ? { servers: [], metadata: { count: 0 } } + : data; +}); + +// With override (when you need different status codes based on request) +mockedGetRegistryV01Servers.override((data, info) => { + const authHeader = info.request.headers.get("Authorization"); + if (!authHeader) { + return HttpResponse.json({ error: "Unauthorized" }, { status: 401 }); } return HttpResponse.json(data); }); diff --git a/src/app/catalog/actions.test.ts b/src/app/catalog/actions.test.ts index 9bb10bc..a2d8a0f 100644 --- a/src/app/catalog/actions.test.ts +++ b/src/app/catalog/actions.test.ts @@ -28,16 +28,19 @@ describe("getServers", () => { expect(servers[0].title).toBe("AWS Nova Canvas"); }); + // Using overrideResponse - simpler API when you just want to change the data it("returns empty array when API returns no servers", async () => { - mockedGetRegistryV01Servers.override(() => - HttpResponse.json({ servers: [], metadata: { count: 0 } }), - ); + mockedGetRegistryV01Servers.overrideResponse(() => ({ + servers: [], + metadata: { count: 0 }, + })); const servers = await getServers(); expect(servers).toEqual([]); }); + // Using override - needed when returning non-JSON responses like null it("returns empty array when API returns null data", async () => { mockedGetRegistryV01Servers.override(() => HttpResponse.json(null)); @@ -48,6 +51,7 @@ describe("getServers", () => { }); describe("error handling", () => { + // Using override - needed for error status codes it("throws on 500 server error", async () => { mockedGetRegistryV01Servers.override(() => HttpResponse.json({ error: "Internal Server Error" }, { status: 500 }), @@ -72,6 +76,7 @@ describe("getServers", () => { }); describe("data transformation", () => { + // Using override - needed when testing invalid data shapes (null servers) it("filters out null servers from response", async () => { mockedGetRegistryV01Servers.override((data) => HttpResponse.json({ @@ -94,6 +99,7 @@ describe("getServers", () => { ]); }); + // Using override - needed when testing invalid data shapes (undefined servers) it("filters out undefined servers from response", async () => { mockedGetRegistryV01Servers.override(() => HttpResponse.json({ @@ -112,23 +118,21 @@ describe("getServers", () => { expect(servers[0].name).toBe("valid/server"); }); + // Using overrideResponse - valid response structure it("extracts server objects from nested response structure", async () => { - mockedGetRegistryV01Servers.override(() => - HttpResponse.json({ - servers: [ - { - server: { - name: "test/server", - title: "Test Server", - description: "A test server", - version: "2.0.0", - }, - _meta: { "some/key": { status: "active" } }, + mockedGetRegistryV01Servers.overrideResponse(() => ({ + servers: [ + { + server: { + name: "test/server", + title: "Test Server", + description: "A test server", + version: "2.0.0", }, - ], - metadata: { count: 1 }, - }), - ); + }, + ], + metadata: { count: 1 }, + })); const servers = await getServers(); @@ -143,19 +147,18 @@ describe("getServers", () => { }); describe("using default data in overrides", () => { + // Using overrideResponse - cleaner syntax for data modifications it("can modify specific server titles", async () => { - mockedGetRegistryV01Servers.override((data) => - HttpResponse.json({ - ...data, - servers: data.servers?.map((item) => ({ - ...item, - server: { - ...item.server, - title: `Modified: ${item.server?.title}`, - }, - })), - }), - ); + mockedGetRegistryV01Servers.overrideResponse((data) => ({ + ...data, + servers: data.servers?.map((item) => ({ + ...item, + server: { + ...item.server, + title: `Modified: ${item.server?.title}`, + }, + })), + })); const servers = await getServers(); @@ -163,13 +166,11 @@ describe("getServers", () => { }); it("can limit the number of returned servers", async () => { - mockedGetRegistryV01Servers.override((data) => - HttpResponse.json({ - ...data, - servers: data.servers?.slice(0, 3), - metadata: { count: 3 }, - }), - ); + mockedGetRegistryV01Servers.overrideResponse((data) => ({ + ...data, + servers: data.servers?.slice(0, 3), + metadata: { count: 3 }, + })); const servers = await getServers(); @@ -177,14 +178,12 @@ describe("getServers", () => { }); it("can filter servers by criteria", async () => { - mockedGetRegistryV01Servers.override((data) => - HttpResponse.json({ - ...data, - servers: data.servers?.filter((item) => - item.server?.name?.includes("google"), - ), - }), - ); + mockedGetRegistryV01Servers.overrideResponse((data) => ({ + ...data, + servers: data.servers?.filter((item) => + item.server?.name?.includes("google"), + ), + })); const servers = await getServers(); @@ -193,12 +192,13 @@ describe("getServers", () => { }); describe("request-aware overrides", () => { - it("can access request info in override", async () => { + // Using overrideResponse with request info + it("can access request info in overrideResponse", async () => { let capturedUrl: string | undefined; - mockedGetRegistryV01Servers.override((data, info) => { + mockedGetRegistryV01Servers.overrideResponse((data, info) => { capturedUrl = info.request.url; - return HttpResponse.json(data); + return data; }); await getServers(); @@ -206,6 +206,7 @@ describe("getServers", () => { expect(capturedUrl).toContain("/registry/v0.1/servers"); }); + // Using override - needed when response depends on request and may return different status codes it("can vary response based on request headers", async () => { mockedGetRegistryV01Servers.override((data, info) => { const authHeader = info.request.headers.get("Authorization"); diff --git a/src/mocks/autoAPIMock.ts b/src/mocks/autoAPIMock.ts index 4c85109..69a2b9f 100644 --- a/src/mocks/autoAPIMock.ts +++ b/src/mocks/autoAPIMock.ts @@ -4,10 +4,12 @@ import { HttpResponse } from "msw"; type ResponseResolverInfo = Parameters[0]; type OverrideFn = (data: T, info: ResponseResolverInfo) => Response; +type OverrideResponseFn = (data: T, info: ResponseResolverInfo) => T; export interface AutoAPIMockInstance { generatedHandler: HttpResponseResolver; override: (fn: OverrideFn) => AutoAPIMockInstance; + overrideResponse: (fn: OverrideResponseFn) => AutoAPIMockInstance; reset: () => AutoAPIMockInstance; defaultValue: T; } @@ -33,6 +35,12 @@ export function AutoAPIMock(defaultValue: T): AutoAPIMockInstance { return instance; }, + overrideResponse(fn: OverrideResponseFn) { + return instance.override((data, info) => + HttpResponse.json(fn(data, info) as JsonBodyType), + ); + }, + reset() { overrideFn = null; return instance; From bc387f04ec6b26a07a9e3bc183eff2411ebb7fb4 Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Tue, 9 Dec 2025 16:57:31 +0100 Subject: [PATCH 06/16] encourage type safety in mock overrides --- docs/mocks.md | 53 +++++++++++++++++---------------- src/app/catalog/actions.test.ts | 44 +++++++++++++-------------- src/mocks/autoAPIMock.ts | 26 ++++++++-------- 3 files changed, 61 insertions(+), 62 deletions(-) diff --git a/docs/mocks.md b/docs/mocks.md index d401bd9..a82a603 100644 --- a/docs/mocks.md +++ b/docs/mocks.md @@ -53,18 +53,19 @@ import { mockedGetRegistryV01Servers } from "@mocks/fixtures/registry_v0_1_serve describe("ServerList", () => { it("shows empty state when no servers", async () => { - // Override to return empty list for this test only - mockedGetRegistryV01Servers.override(() => - HttpResponse.json({ servers: [], metadata: { count: 0 } }) - ); + // Override to return empty list for this test only (type-safe) + mockedGetRegistryV01Servers.override(() => ({ + servers: [], + metadata: { count: 0 }, + })); render(); expect(screen.getByText("No servers available")).toBeVisible(); }); it("shows error state on API failure", async () => { - // Override to return an error - mockedGetRegistryV01Servers.override(() => + // Use overrideHandler for error responses + mockedGetRegistryV01Servers.overrideHandler(() => HttpResponse.json({ error: "Server error" }, { status: 500 }) ); @@ -87,11 +88,11 @@ interface AutoAPIMockInstance { // The default fixture data defaultValue: T; - // Override the response for the current test (full control) - override(fn: (data: T, info: ResponseResolverInfo) => Response): this; + // Override the response data (type-safe, preferred) + override(fn: (data: T, info: ResponseResolverInfo) => T): this; - // Override just the response data (simpler API) - overrideResponse(fn: (data: T, info: ResponseResolverInfo) => T): this; + // Override the full handler (for errors, network failures, invalid data) + overrideHandler(fn: (data: T, info: ResponseResolverInfo) => Response): this; // Reset to default behavior (called automatically before each test) reset(): this; @@ -101,25 +102,25 @@ interface AutoAPIMockInstance { } ``` -### Choosing Between `override` and `overrideResponse` +### Choosing Between `override` and `overrideHandler` -Use **`overrideResponse`** (simpler) when you just want to change the response data: +Use **`override`** (type-safe, preferred) when you just want to change the response data: ```typescript // Simple - just return the data you want -mockedGetRegistryV01Servers.overrideResponse(() => ({ +mockedGetRegistryV01Servers.override(() => ({ servers: [], metadata: { count: 0 }, })); // With default data modifications -mockedGetRegistryV01Servers.overrideResponse((data) => ({ +mockedGetRegistryV01Servers.override((data) => ({ ...data, servers: data.servers?.slice(0, 3), })); ``` -Use **`override`** (full control) when you need: +Use **`overrideHandler`** (full control) when you need: - Custom HTTP status codes (errors) - Non-JSON responses - Network errors @@ -127,15 +128,15 @@ Use **`override`** (full control) when you need: ```typescript // Return error status -mockedGetRegistryV01Servers.override(() => +mockedGetRegistryV01Servers.overrideHandler(() => HttpResponse.json({ error: "Server error" }, { status: 500 }) ); // Network error -mockedGetRegistryV01Servers.override(() => HttpResponse.error()); +mockedGetRegistryV01Servers.overrideHandler(() => HttpResponse.error()); // Testing invalid data shapes -mockedGetRegistryV01Servers.override(() => +mockedGetRegistryV01Servers.overrideHandler(() => HttpResponse.json({ servers: [{ server: null }] }) ); ``` @@ -149,14 +150,14 @@ Overrides are automatically reset before each test via `beforeEach()` in `src/mo Access the default fixture data to make partial modifications: ```typescript -// With overrideResponse (cleaner) -mockedGetRegistryV01Servers.overrideResponse((data) => ({ +// With override (type-safe, preferred) +mockedGetRegistryV01Servers.override((data) => ({ ...data, servers: data.servers?.slice(0, 1), })); -// With override (when you need the Response wrapper) -mockedGetRegistryV01Servers.override((data) => +// With overrideHandler (when you need the Response wrapper) +mockedGetRegistryV01Servers.overrideHandler((data) => HttpResponse.json({ ...data, servers: data.servers?.slice(0, 1), @@ -169,16 +170,16 @@ mockedGetRegistryV01Servers.override((data) => Both methods receive request info as the second parameter: ```typescript -// With overrideResponse -mockedGetRegistryV01Servers.overrideResponse((data, info) => { +// With override (type-safe) +mockedGetRegistryV01Servers.override((data, info) => { const cursor = new URL(info.request.url).searchParams.get("cursor"); return cursor === "page2" ? { servers: [], metadata: { count: 0 } } : data; }); -// With override (when you need different status codes based on request) -mockedGetRegistryV01Servers.override((data, info) => { +// With overrideHandler (when you need different status codes based on request) +mockedGetRegistryV01Servers.overrideHandler((data, info) => { const authHeader = info.request.headers.get("Authorization"); if (!authHeader) { return HttpResponse.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/src/app/catalog/actions.test.ts b/src/app/catalog/actions.test.ts index a2d8a0f..d992f13 100644 --- a/src/app/catalog/actions.test.ts +++ b/src/app/catalog/actions.test.ts @@ -28,9 +28,8 @@ describe("getServers", () => { expect(servers[0].title).toBe("AWS Nova Canvas"); }); - // Using overrideResponse - simpler API when you just want to change the data it("returns empty array when API returns no servers", async () => { - mockedGetRegistryV01Servers.overrideResponse(() => ({ + mockedGetRegistryV01Servers.override(() => ({ servers: [], metadata: { count: 0 }, })); @@ -40,9 +39,11 @@ describe("getServers", () => { expect(servers).toEqual([]); }); - // Using override - needed when returning non-JSON responses like null + // Using overrideHandler - needed when returning non-JSON responses like null it("returns empty array when API returns null data", async () => { - mockedGetRegistryV01Servers.override(() => HttpResponse.json(null)); + mockedGetRegistryV01Servers.overrideHandler(() => + HttpResponse.json(null), + ); const servers = await getServers(); @@ -51,9 +52,9 @@ describe("getServers", () => { }); describe("error handling", () => { - // Using override - needed for error status codes + // Using overrideHandler - needed for error status codes it("throws on 500 server error", async () => { - mockedGetRegistryV01Servers.override(() => + mockedGetRegistryV01Servers.overrideHandler(() => HttpResponse.json({ error: "Internal Server Error" }, { status: 500 }), ); @@ -61,7 +62,7 @@ describe("getServers", () => { }); it("throws on 401 unauthorized", async () => { - mockedGetRegistryV01Servers.override(() => + mockedGetRegistryV01Servers.overrideHandler(() => HttpResponse.json({ error: "Unauthorized" }, { status: 401 }), ); @@ -69,16 +70,16 @@ describe("getServers", () => { }); it("throws on network error", async () => { - mockedGetRegistryV01Servers.override(() => HttpResponse.error()); + mockedGetRegistryV01Servers.overrideHandler(() => HttpResponse.error()); await expect(getServers()).rejects.toBeDefined(); }); }); describe("data transformation", () => { - // Using override - needed when testing invalid data shapes (null servers) + // Using overrideHandler - needed when testing invalid data shapes (null servers) it("filters out null servers from response", async () => { - mockedGetRegistryV01Servers.override((data) => + mockedGetRegistryV01Servers.overrideHandler((data) => HttpResponse.json({ ...data, servers: [ @@ -99,9 +100,9 @@ describe("getServers", () => { ]); }); - // Using override - needed when testing invalid data shapes (undefined servers) + // Using overrideHandler - needed when testing invalid data shapes (undefined servers) it("filters out undefined servers from response", async () => { - mockedGetRegistryV01Servers.override(() => + mockedGetRegistryV01Servers.overrideHandler(() => HttpResponse.json({ servers: [ { server: { name: "valid/server", title: "Valid" } }, @@ -118,9 +119,8 @@ describe("getServers", () => { expect(servers[0].name).toBe("valid/server"); }); - // Using overrideResponse - valid response structure it("extracts server objects from nested response structure", async () => { - mockedGetRegistryV01Servers.overrideResponse(() => ({ + mockedGetRegistryV01Servers.override(() => ({ servers: [ { server: { @@ -147,9 +147,8 @@ describe("getServers", () => { }); describe("using default data in overrides", () => { - // Using overrideResponse - cleaner syntax for data modifications it("can modify specific server titles", async () => { - mockedGetRegistryV01Servers.overrideResponse((data) => ({ + mockedGetRegistryV01Servers.override((data) => ({ ...data, servers: data.servers?.map((item) => ({ ...item, @@ -166,7 +165,7 @@ describe("getServers", () => { }); it("can limit the number of returned servers", async () => { - mockedGetRegistryV01Servers.overrideResponse((data) => ({ + mockedGetRegistryV01Servers.override((data) => ({ ...data, servers: data.servers?.slice(0, 3), metadata: { count: 3 }, @@ -178,7 +177,7 @@ describe("getServers", () => { }); it("can filter servers by criteria", async () => { - mockedGetRegistryV01Servers.overrideResponse((data) => ({ + mockedGetRegistryV01Servers.override((data) => ({ ...data, servers: data.servers?.filter((item) => item.server?.name?.includes("google"), @@ -192,11 +191,10 @@ describe("getServers", () => { }); describe("request-aware overrides", () => { - // Using overrideResponse with request info - it("can access request info in overrideResponse", async () => { + it("can access request info in override", async () => { let capturedUrl: string | undefined; - mockedGetRegistryV01Servers.overrideResponse((data, info) => { + mockedGetRegistryV01Servers.override((data, info) => { capturedUrl = info.request.url; return data; }); @@ -206,9 +204,9 @@ describe("getServers", () => { expect(capturedUrl).toContain("/registry/v0.1/servers"); }); - // Using override - needed when response depends on request and may return different status codes + // Using overrideHandler - needed when response depends on request and may return different status codes it("can vary response based on request headers", async () => { - mockedGetRegistryV01Servers.override((data, info) => { + mockedGetRegistryV01Servers.overrideHandler((data, info) => { const authHeader = info.request.headers.get("Authorization"); if (authHeader?.includes("mock-token")) { return HttpResponse.json(data); diff --git a/src/mocks/autoAPIMock.ts b/src/mocks/autoAPIMock.ts index 69a2b9f..6937638 100644 --- a/src/mocks/autoAPIMock.ts +++ b/src/mocks/autoAPIMock.ts @@ -3,13 +3,13 @@ import { HttpResponse } from "msw"; type ResponseResolverInfo = Parameters[0]; -type OverrideFn = (data: T, info: ResponseResolverInfo) => Response; -type OverrideResponseFn = (data: T, info: ResponseResolverInfo) => T; +type OverrideHandlerFn = (data: T, info: ResponseResolverInfo) => Response; +type OverrideFn = (data: T, info: ResponseResolverInfo) => T; export interface AutoAPIMockInstance { generatedHandler: HttpResponseResolver; override: (fn: OverrideFn) => AutoAPIMockInstance; - overrideResponse: (fn: OverrideResponseFn) => AutoAPIMockInstance; + overrideHandler: (fn: OverrideHandlerFn) => AutoAPIMockInstance; reset: () => AutoAPIMockInstance; defaultValue: T; } @@ -18,31 +18,31 @@ export interface AutoAPIMockInstance { const registry: Set> = new Set(); export function AutoAPIMock(defaultValue: T): AutoAPIMockInstance { - let overrideFn: OverrideFn | null = null; + let overrideHandlerFn: OverrideHandlerFn | null = null; const instance: AutoAPIMockInstance = { defaultValue, generatedHandler(info: ResponseResolverInfo) { - if (overrideFn) { - return overrideFn(defaultValue, info); + if (overrideHandlerFn) { + return overrideHandlerFn(defaultValue, info); } return HttpResponse.json(defaultValue as JsonBodyType); }, override(fn: OverrideFn) { - overrideFn = fn; - return instance; - }, - - overrideResponse(fn: OverrideResponseFn) { - return instance.override((data, info) => + return instance.overrideHandler((data, info) => HttpResponse.json(fn(data, info) as JsonBodyType), ); }, + overrideHandler(fn: OverrideHandlerFn) { + overrideHandlerFn = fn; + return instance; + }, + reset() { - overrideFn = null; + overrideHandlerFn = null; return instance; }, }; From 4c1d01bbb71259a5a8a0236ff51bb78d4521f980 Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Tue, 9 Dec 2025 17:41:57 +0100 Subject: [PATCH 07/16] allow creating reusable scenarios? --- docs/mocks.md | 54 +++++++++++++++++++ src/app/catalog/actions.test.ts | 11 ++-- src/mocks/autoAPIMock.ts | 21 ++++++++ .../fixtures/registry_v0_1_servers/get.ts | 14 ++++- 4 files changed, 91 insertions(+), 9 deletions(-) diff --git a/docs/mocks.md b/docs/mocks.md index a82a603..fbf17b6 100644 --- a/docs/mocks.md +++ b/docs/mocks.md @@ -94,6 +94,12 @@ interface AutoAPIMockInstance { // Override the full handler (for errors, network failures, invalid data) overrideHandler(fn: (data: T, info: ResponseResolverInfo) => Response): this; + // Define a reusable named scenario + scenario(name: string, fn: (instance: AutoAPIMockInstance) => AutoAPIMockInstance): this; + + // Activate a named scenario for the current test + useScenario(name: string): this; + // Reset to default behavior (called automatically before each test) reset(): this; @@ -186,3 +192,51 @@ mockedGetRegistryV01Servers.overrideHandler((data, info) => { } return HttpResponse.json(data); }); +``` + +### Reusable Scenarios + +Define named scenarios in your fixture for commonly used test states: + +```typescript +// src/mocks/fixtures/registry_v0_1_servers/get.ts +import type { GetRegistryV01ServersResponse } from "@api/types.gen"; +import { AutoAPIMock } from "@mocks"; +import { HttpResponse } from "msw"; + +export const mockedGetRegistryV01Servers = AutoAPIMock({ + servers: [...], + metadata: { count: 15 }, +}) + .scenario("empty-servers", (self) => + self.override(() => ({ + servers: [], + metadata: { count: 0 }, + })), + ) + .scenario("server-error", (self) => + self.overrideHandler(() => + HttpResponse.json({ error: "Internal Server Error" }, { status: 500 }), + ), + ); +``` + +Then use them in tests: + +```typescript +import { mockedGetRegistryV01Servers } from "@mocks/fixtures/registry_v0_1_servers/get"; + +describe("getServers", () => { + it("returns empty array when API returns no servers", async () => { + mockedGetRegistryV01Servers.useScenario("empty-servers"); + + const servers = await getServers(); + expect(servers).toEqual([]); + }); + + it("throws on 500 server error", async () => { + mockedGetRegistryV01Servers.useScenario("server-error"); + + await expect(getServers()).rejects.toBeDefined(); + }); +}); diff --git a/src/app/catalog/actions.test.ts b/src/app/catalog/actions.test.ts index d992f13..faf79e6 100644 --- a/src/app/catalog/actions.test.ts +++ b/src/app/catalog/actions.test.ts @@ -29,10 +29,7 @@ describe("getServers", () => { }); it("returns empty array when API returns no servers", async () => { - mockedGetRegistryV01Servers.override(() => ({ - servers: [], - metadata: { count: 0 }, - })); + mockedGetRegistryV01Servers.useScenario("empty-servers"); const servers = await getServers(); @@ -52,11 +49,9 @@ describe("getServers", () => { }); describe("error handling", () => { - // Using overrideHandler - needed for error status codes + // Using scenario - reusable error scenario defined in fixture it("throws on 500 server error", async () => { - mockedGetRegistryV01Servers.overrideHandler(() => - HttpResponse.json({ error: "Internal Server Error" }, { status: 500 }), - ); + mockedGetRegistryV01Servers.useScenario("server-error"); await expect(getServers()).rejects.toBeDefined(); }); diff --git a/src/mocks/autoAPIMock.ts b/src/mocks/autoAPIMock.ts index 6937638..78274c2 100644 --- a/src/mocks/autoAPIMock.ts +++ b/src/mocks/autoAPIMock.ts @@ -5,11 +5,16 @@ type ResponseResolverInfo = Parameters[0]; type OverrideHandlerFn = (data: T, info: ResponseResolverInfo) => Response; type OverrideFn = (data: T, info: ResponseResolverInfo) => T; +type ScenarioFn = ( + instance: AutoAPIMockInstance, +) => AutoAPIMockInstance; export interface AutoAPIMockInstance { generatedHandler: HttpResponseResolver; override: (fn: OverrideFn) => AutoAPIMockInstance; overrideHandler: (fn: OverrideHandlerFn) => AutoAPIMockInstance; + scenario: (name: string, fn: ScenarioFn) => AutoAPIMockInstance; + useScenario: (name: string) => AutoAPIMockInstance; reset: () => AutoAPIMockInstance; defaultValue: T; } @@ -19,6 +24,7 @@ const registry: Set> = new Set(); export function AutoAPIMock(defaultValue: T): AutoAPIMockInstance { let overrideHandlerFn: OverrideHandlerFn | null = null; + const scenarios = new Map>(); const instance: AutoAPIMockInstance = { defaultValue, @@ -41,6 +47,21 @@ export function AutoAPIMock(defaultValue: T): AutoAPIMockInstance { return instance; }, + scenario(name: string, fn: ScenarioFn) { + scenarios.set(name, fn); + return instance; + }, + + useScenario(name: string) { + const scenarioFn = scenarios.get(name); + if (!scenarioFn) { + throw new Error( + `Scenario "${name}" not found. Available scenarios: ${[...scenarios.keys()].join(", ") || "(none)"}`, + ); + } + return scenarioFn(instance); + }, + reset() { overrideHandlerFn = null; return instance; diff --git a/src/mocks/fixtures/registry_v0_1_servers/get.ts b/src/mocks/fixtures/registry_v0_1_servers/get.ts index f88a96d..6e4706d 100644 --- a/src/mocks/fixtures/registry_v0_1_servers/get.ts +++ b/src/mocks/fixtures/registry_v0_1_servers/get.ts @@ -1,5 +1,6 @@ import type { GetRegistryV01ServersResponse } from "@api/types.gen"; import { AutoAPIMock } from "@mocks"; +import { HttpResponse } from "msw"; export const mockedGetRegistryV01Servers = AutoAPIMock({ @@ -486,4 +487,15 @@ export const mockedGetRegistryV01Servers = count: 15, nextCursor: "next-page", }, - }); + }) + .scenario("empty-servers", (self) => + self.override(() => ({ + servers: [], + metadata: { count: 0 }, + })), + ) + .scenario("server-error", (self) => + self.overrideHandler(() => + HttpResponse.json({ error: "Internal Server Error" }, { status: 500 }), + ), + ); From 5f64128e281e2c58396a358af1a674a4f7e7ce23 Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Tue, 9 Dec 2025 17:48:03 +0100 Subject: [PATCH 08/16] add shared type for mock scenarios --- src/mocks/autoAPIMock.ts | 14 +++++++++----- src/mocks/index.ts | 1 + src/mocks/scenarioNames.ts | 19 +++++++++++++++++++ 3 files changed, 29 insertions(+), 5 deletions(-) create mode 100644 src/mocks/scenarioNames.ts diff --git a/src/mocks/autoAPIMock.ts b/src/mocks/autoAPIMock.ts index 78274c2..cdff659 100644 --- a/src/mocks/autoAPIMock.ts +++ b/src/mocks/autoAPIMock.ts @@ -1,5 +1,6 @@ import type { HttpResponseResolver, JsonBodyType } from "msw"; import { HttpResponse } from "msw"; +import type { MockScenarioName } from "./scenarioNames"; type ResponseResolverInfo = Parameters[0]; @@ -13,8 +14,11 @@ export interface AutoAPIMockInstance { generatedHandler: HttpResponseResolver; override: (fn: OverrideFn) => AutoAPIMockInstance; overrideHandler: (fn: OverrideHandlerFn) => AutoAPIMockInstance; - scenario: (name: string, fn: ScenarioFn) => AutoAPIMockInstance; - useScenario: (name: string) => AutoAPIMockInstance; + scenario: ( + name: MockScenarioName, + fn: ScenarioFn, + ) => AutoAPIMockInstance; + useScenario: (name: MockScenarioName) => AutoAPIMockInstance; reset: () => AutoAPIMockInstance; defaultValue: T; } @@ -24,7 +28,7 @@ const registry: Set> = new Set(); export function AutoAPIMock(defaultValue: T): AutoAPIMockInstance { let overrideHandlerFn: OverrideHandlerFn | null = null; - const scenarios = new Map>(); + const scenarios = new Map>(); const instance: AutoAPIMockInstance = { defaultValue, @@ -47,12 +51,12 @@ export function AutoAPIMock(defaultValue: T): AutoAPIMockInstance { return instance; }, - scenario(name: string, fn: ScenarioFn) { + scenario(name: MockScenarioName, fn: ScenarioFn) { scenarios.set(name, fn); return instance; }, - useScenario(name: string) { + useScenario(name: MockScenarioName) { const scenarioFn = scenarios.get(name); if (!scenarioFn) { throw new Error( diff --git a/src/mocks/index.ts b/src/mocks/index.ts index 9606a8c..1aa2e86 100644 --- a/src/mocks/index.ts +++ b/src/mocks/index.ts @@ -1,2 +1,3 @@ export type { AutoAPIMockInstance } from "./autoAPIMock"; export { AutoAPIMock, resetAllAutoAPIMocks } from "./autoAPIMock"; +export type { MockScenarioName } from "./scenarioNames"; diff --git a/src/mocks/scenarioNames.ts b/src/mocks/scenarioNames.ts new file mode 100644 index 0000000..f44d9ae --- /dev/null +++ b/src/mocks/scenarioNames.ts @@ -0,0 +1,19 @@ +/** + * Global scenario names for API mocks. + * + * Define scenario names here so they can be reused across different mocks + * with consistent naming and documentation. + */ + +/** Empty state - API returns no data */ +export type EmptyServers = "empty-servers"; + +/** API returns 500 Internal Server Error */ +export type ServerError = "server-error"; + +/** + * Union of all available mock scenario names. + * + * Add new scenario types above and include them in this union. + */ +export type MockScenarioName = EmptyServers | ServerError; From 3924281db6f5e8f03b11637d08173eae40794ac6 Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Tue, 9 Dec 2025 17:56:39 +0100 Subject: [PATCH 09/16] simple way to activate same scenario across multipl mocks --- docs/mocks.md | 36 +++++++++++++++++++ .../[serverName]/[version]/actions.test.ts | 2 ++ src/mocks/autoAPIMock.ts | 26 ++++++++++++-- src/mocks/index.ts | 8 +++-- 4 files changed, 68 insertions(+), 4 deletions(-) diff --git a/docs/mocks.md b/docs/mocks.md index fbf17b6..e32d73e 100644 --- a/docs/mocks.md +++ b/docs/mocks.md @@ -240,3 +240,39 @@ describe("getServers", () => { await expect(getServers()).rejects.toBeDefined(); }); }); +``` + +### Global Scenario Activation + +Use `activateMockScenario` to activate a scenario across all registered mocks at once. This is useful for setting up a consistent state across multiple endpoints, with the option to further customize individual mocks afterwards. + +```typescript +import { activateMockScenario } from "@mocks"; +import { mockedGetRegistryV01Servers } from "@mocks/fixtures/registry_v0_1_servers/get"; + +describe("error handling", () => { + it("shows error page when all APIs fail", async () => { + // Activate "server-error" on all mocks that define it + // Mocks without this scenario will use their default response + activateMockScenario("server-error"); + + // Test that the app handles the error state correctly + render(); + expect(screen.getByText("Something went wrong")).toBeVisible(); + }); + + it("handles partial failures gracefully", async () => { + // Start with all APIs returning errors + activateMockScenario("server-error"); + + // Then customize specific endpoints to succeed + mockedGetRegistryV01Servers.override((data) => data); + + // Now only other endpoints return errors, servers endpoint works + render(); + expect(screen.getByText("Servers loaded")).toBeVisible(); + }); +}); +``` + +Scenario names are defined in `src/mocks/scenarioNames.ts` and provide autocomplete and documentation across all mocks. Global scenarios are automatically reset before each test via `resetAllAutoAPIMocks()` in the test setup. diff --git a/src/app/catalog/[repoName]/[serverName]/[version]/actions.test.ts b/src/app/catalog/[repoName]/[serverName]/[version]/actions.test.ts index b2c7044..be3b292 100644 --- a/src/app/catalog/[repoName]/[serverName]/[version]/actions.test.ts +++ b/src/app/catalog/[repoName]/[serverName]/[version]/actions.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { activateMockScenario } from "@/mocks"; import { getServerDetails } from "./actions"; // Mock the auth to bypass authentication @@ -47,6 +48,7 @@ describe("getServerDetails", () => { describe("error handling", () => { it("returns 404 for non-existent server", async () => { + activateMockScenario("empty-servers"); const result = await getServerDetails("non-existent/server", "1.0.0"); expect(result.response.status).toBe(404); diff --git a/src/mocks/autoAPIMock.ts b/src/mocks/autoAPIMock.ts index cdff659..48b6d5c 100644 --- a/src/mocks/autoAPIMock.ts +++ b/src/mocks/autoAPIMock.ts @@ -10,6 +10,11 @@ type ScenarioFn = ( instance: AutoAPIMockInstance, ) => AutoAPIMockInstance; +export interface UseScenarioOptions { + /** If true, silently falls back to default when scenario doesn't exist. Default: false (throws) */ + fallbackToDefault?: boolean; +} + export interface AutoAPIMockInstance { generatedHandler: HttpResponseResolver; override: (fn: OverrideFn) => AutoAPIMockInstance; @@ -18,7 +23,10 @@ export interface AutoAPIMockInstance { name: MockScenarioName, fn: ScenarioFn, ) => AutoAPIMockInstance; - useScenario: (name: MockScenarioName) => AutoAPIMockInstance; + useScenario: ( + name: MockScenarioName, + options?: UseScenarioOptions, + ) => AutoAPIMockInstance; reset: () => AutoAPIMockInstance; defaultValue: T; } @@ -56,9 +64,12 @@ export function AutoAPIMock(defaultValue: T): AutoAPIMockInstance { return instance; }, - useScenario(name: MockScenarioName) { + useScenario(name: MockScenarioName, options?: UseScenarioOptions) { const scenarioFn = scenarios.get(name); if (!scenarioFn) { + if (options?.fallbackToDefault) { + return instance; + } throw new Error( `Scenario "${name}" not found. Available scenarios: ${[...scenarios.keys()].join(", ") || "(none)"}`, ); @@ -82,3 +93,14 @@ export function resetAllAutoAPIMocks(): void { instance.reset(); } } + +/** + * Activate a scenario globally across all registered mocks. + * Mocks that don't have the scenario defined will silently use their default. + */ +export function activateMockScenario(name: MockScenarioName): void { + for (const instance of registry) { + // biome-ignore lint/correctness/useHookAtTopLevel: useScenario is not a React hook + instance.useScenario(name, { fallbackToDefault: true }); + } +} diff --git a/src/mocks/index.ts b/src/mocks/index.ts index 1aa2e86..cdb2466 100644 --- a/src/mocks/index.ts +++ b/src/mocks/index.ts @@ -1,3 +1,7 @@ -export type { AutoAPIMockInstance } from "./autoAPIMock"; -export { AutoAPIMock, resetAllAutoAPIMocks } from "./autoAPIMock"; +export type { AutoAPIMockInstance, UseScenarioOptions } from "./autoAPIMock"; +export { + AutoAPIMock, + activateMockScenario, + resetAllAutoAPIMocks, +} from "./autoAPIMock"; export type { MockScenarioName } from "./scenarioNames"; From 6f304465af2e7b0e757ac8fa8fef20b78df83826 Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Tue, 9 Dec 2025 18:02:56 +0100 Subject: [PATCH 10/16] jsdoc for mock scenarios --- docs/mocks.md | 13 ++++++------ .../[serverName]/[version]/actions.test.ts | 2 -- src/mocks/index.ts | 1 + src/mocks/scenarioNames.ts | 21 +++++++++---------- 4 files changed, 18 insertions(+), 19 deletions(-) diff --git a/docs/mocks.md b/docs/mocks.md index e32d73e..5c2cfd4 100644 --- a/docs/mocks.md +++ b/docs/mocks.md @@ -224,18 +224,19 @@ export const mockedGetRegistryV01Servers = AutoAPIMock { it("returns empty array when API returns no servers", async () => { - mockedGetRegistryV01Servers.useScenario("empty-servers"); + mockedGetRegistryV01Servers.useScenario(MockScenarios.EmptyServers); const servers = await getServers(); expect(servers).toEqual([]); }); it("throws on 500 server error", async () => { - mockedGetRegistryV01Servers.useScenario("server-error"); + mockedGetRegistryV01Servers.useScenario(MockScenarios.ServerError); await expect(getServers()).rejects.toBeDefined(); }); @@ -247,14 +248,14 @@ describe("getServers", () => { Use `activateMockScenario` to activate a scenario across all registered mocks at once. This is useful for setting up a consistent state across multiple endpoints, with the option to further customize individual mocks afterwards. ```typescript -import { activateMockScenario } from "@mocks"; +import { activateMockScenario, MockScenarios } from "@mocks"; import { mockedGetRegistryV01Servers } from "@mocks/fixtures/registry_v0_1_servers/get"; describe("error handling", () => { it("shows error page when all APIs fail", async () => { // Activate "server-error" on all mocks that define it // Mocks without this scenario will use their default response - activateMockScenario("server-error"); + activateMockScenario(MockScenarios.ServerError); // Test that the app handles the error state correctly render(); @@ -263,7 +264,7 @@ describe("error handling", () => { it("handles partial failures gracefully", async () => { // Start with all APIs returning errors - activateMockScenario("server-error"); + activateMockScenario(MockScenarios.ServerError); // Then customize specific endpoints to succeed mockedGetRegistryV01Servers.override((data) => data); @@ -275,4 +276,4 @@ describe("error handling", () => { }); ``` -Scenario names are defined in `src/mocks/scenarioNames.ts` and provide autocomplete and documentation across all mocks. Global scenarios are automatically reset before each test via `resetAllAutoAPIMocks()` in the test setup. +Scenario names are defined in `src/mocks/scenarioNames.ts` via the `MockScenarios` object, which provides autocomplete and JSDoc documentation. Global scenarios are automatically reset before each test via `resetAllAutoAPIMocks()` in the test setup. diff --git a/src/app/catalog/[repoName]/[serverName]/[version]/actions.test.ts b/src/app/catalog/[repoName]/[serverName]/[version]/actions.test.ts index be3b292..b2c7044 100644 --- a/src/app/catalog/[repoName]/[serverName]/[version]/actions.test.ts +++ b/src/app/catalog/[repoName]/[serverName]/[version]/actions.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { activateMockScenario } from "@/mocks"; import { getServerDetails } from "./actions"; // Mock the auth to bypass authentication @@ -48,7 +47,6 @@ describe("getServerDetails", () => { describe("error handling", () => { it("returns 404 for non-existent server", async () => { - activateMockScenario("empty-servers"); const result = await getServerDetails("non-existent/server", "1.0.0"); expect(result.response.status).toBe(404); diff --git a/src/mocks/index.ts b/src/mocks/index.ts index cdb2466..6c91784 100644 --- a/src/mocks/index.ts +++ b/src/mocks/index.ts @@ -5,3 +5,4 @@ export { resetAllAutoAPIMocks, } from "./autoAPIMock"; export type { MockScenarioName } from "./scenarioNames"; +export { MockScenarios } from "./scenarioNames"; diff --git a/src/mocks/scenarioNames.ts b/src/mocks/scenarioNames.ts index f44d9ae..299a7e8 100644 --- a/src/mocks/scenarioNames.ts +++ b/src/mocks/scenarioNames.ts @@ -1,19 +1,18 @@ /** * Global scenario names for API mocks. * - * Define scenario names here so they can be reused across different mocks - * with consistent naming and documentation. + * Use `MockScenarios.X` for autocomplete with documentation, + * or use the string literals directly. */ - -/** Empty state - API returns no data */ -export type EmptyServers = "empty-servers"; - -/** API returns 500 Internal Server Error */ -export type ServerError = "server-error"; +export const MockScenarios = { + /** Empty state - API returns no data */ + EmptyServers: "empty-servers", + /** API returns 500 Internal Server Error */ + ServerError: "server-error", +} as const; /** * Union of all available mock scenario names. - * - * Add new scenario types above and include them in this union. */ -export type MockScenarioName = EmptyServers | ServerError; +export type MockScenarioName = + (typeof MockScenarios)[keyof typeof MockScenarios]; From f9dd3525baab0ac36444ae2834266a41013f71a8 Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Tue, 9 Dec 2025 18:34:55 +0100 Subject: [PATCH 11/16] reduce pr size --- .../[serverName]/[version]/actions.test.ts | 77 ------- src/app/catalog/actions.test.ts | 210 +++--------------- src/mocks/autoAPIMock.ts | 19 ++ src/mocks/handlers.ts | 18 +- src/mocks/mockScenario.ts | 40 ---- 5 files changed, 54 insertions(+), 310 deletions(-) delete mode 100644 src/app/catalog/[repoName]/[serverName]/[version]/actions.test.ts delete mode 100644 src/mocks/mockScenario.ts diff --git a/src/app/catalog/[repoName]/[serverName]/[version]/actions.test.ts b/src/app/catalog/[repoName]/[serverName]/[version]/actions.test.ts deleted file mode 100644 index b2c7044..0000000 --- a/src/app/catalog/[repoName]/[serverName]/[version]/actions.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { getServerDetails } from "./actions"; - -// Mock the auth to bypass authentication -vi.mock("@/lib/api-client", async (importOriginal) => { - const original = await importOriginal(); - return { - ...original, - getAuthenticatedClient: vi.fn(() => - original.getAuthenticatedClient("mock-token"), - ), - }; -}); - -describe("getServerDetails", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe("successful responses", () => { - it("returns server data for valid server name and version", async () => { - const result = await getServerDetails("awslabs/aws-nova-canvas", "1.0.0"); - - expect(result.error).toBeUndefined(); - expect(result.data?.server?.name).toBe("awslabs/aws-nova-canvas"); - expect(result.data?.server?.title).toBe("AWS Nova Canvas"); - }); - - it("returns server data when version is 'latest'", async () => { - const result = await getServerDetails( - "awslabs/aws-nova-canvas", - "latest", - ); - - expect(result.error).toBeUndefined(); - expect(result.data?.server?.name).toBe("awslabs/aws-nova-canvas"); - }); - - it("returns different servers from fixture", async () => { - const result = await getServerDetails("google/mcp-google-apps", "1.0.0"); - - expect(result.error).toBeUndefined(); - expect(result.data?.server?.name).toBe("google/mcp-google-apps"); - expect(result.data?.server?.title).toBe("Google Workspace"); - }); - }); - - describe("error handling", () => { - it("returns 404 for non-existent server", async () => { - const result = await getServerDetails("non-existent/server", "1.0.0"); - - expect(result.response.status).toBe(404); - }); - - it("returns 404 for wrong version", async () => { - const result = await getServerDetails("awslabs/aws-nova-canvas", "9.9.9"); - - expect(result.response.status).toBe(404); - }); - }); - - describe("fixture data", () => { - it("includes metadata in response", async () => { - const result = await getServerDetails("awslabs/aws-nova-canvas", "1.0.0"); - - expect(result.data?._meta).toBeDefined(); - }); - - it("returns full server details from fixture", async () => { - const result = await getServerDetails("awslabs/aws-nova-canvas", "1.0.0"); - - expect(result.data?.server?.name).toBe("awslabs/aws-nova-canvas"); - expect(result.data?.server?.version).toBe("1.0.0"); - expect(result.data?.server?.description).toContain("MCP server"); - }); - }); -}); diff --git a/src/app/catalog/actions.test.ts b/src/app/catalog/actions.test.ts index faf79e6..4e64af9 100644 --- a/src/app/catalog/actions.test.ts +++ b/src/app/catalog/actions.test.ts @@ -19,199 +19,55 @@ describe("getServers", () => { vi.clearAllMocks(); }); - describe("successful responses", () => { - it("returns servers from default fixture", async () => { - const servers = await getServers(); + it("returns servers from default fixture", async () => { + const servers = await getServers(); - expect(servers.length).toBeGreaterThan(0); - expect(servers[0].name).toBe("awslabs/aws-nova-canvas"); - expect(servers[0].title).toBe("AWS Nova Canvas"); - }); - - it("returns empty array when API returns no servers", async () => { - mockedGetRegistryV01Servers.useScenario("empty-servers"); - - const servers = await getServers(); - - expect(servers).toEqual([]); - }); - - // Using overrideHandler - needed when returning non-JSON responses like null - it("returns empty array when API returns null data", async () => { - mockedGetRegistryV01Servers.overrideHandler(() => - HttpResponse.json(null), - ); - - const servers = await getServers(); - - expect(servers).toEqual([]); - }); + expect(servers.length).toBeGreaterThan(0); + expect(servers[0].name).toBe("awslabs/aws-nova-canvas"); }); - describe("error handling", () => { - // Using scenario - reusable error scenario defined in fixture - it("throws on 500 server error", async () => { - mockedGetRegistryV01Servers.useScenario("server-error"); - - await expect(getServers()).rejects.toBeDefined(); - }); + // Demo: using .useScenario() for reusable test scenarios + it("returns empty array when using empty-servers scenario", async () => { + mockedGetRegistryV01Servers.useScenario("empty-servers"); - it("throws on 401 unauthorized", async () => { - mockedGetRegistryV01Servers.overrideHandler(() => - HttpResponse.json({ error: "Unauthorized" }, { status: 401 }), - ); + const servers = await getServers(); - await expect(getServers()).rejects.toBeDefined(); - }); - - it("throws on network error", async () => { - mockedGetRegistryV01Servers.overrideHandler(() => HttpResponse.error()); - - await expect(getServers()).rejects.toBeDefined(); - }); + expect(servers).toEqual([]); }); - describe("data transformation", () => { - // Using overrideHandler - needed when testing invalid data shapes (null servers) - it("filters out null servers from response", async () => { - mockedGetRegistryV01Servers.overrideHandler((data) => - HttpResponse.json({ - ...data, - servers: [ - { server: { name: "valid/server", title: "Valid" } }, - { server: null }, - { server: { name: "another/server", title: "Another" } }, - ], - metadata: { count: 3 }, - }), - ); - - const servers = await getServers(); - - expect(servers).toHaveLength(2); - expect(servers.map((s) => s.name)).toEqual([ - "valid/server", - "another/server", - ]); - }); - - // Using overrideHandler - needed when testing invalid data shapes (undefined servers) - it("filters out undefined servers from response", async () => { - mockedGetRegistryV01Servers.overrideHandler(() => - HttpResponse.json({ - servers: [ - { server: { name: "valid/server", title: "Valid" } }, - { server: undefined }, - {}, - ], - metadata: { count: 3 }, - }), - ); - - const servers = await getServers(); - - expect(servers).toHaveLength(1); - expect(servers[0].name).toBe("valid/server"); - }); + // Demo: using .useScenario() for error scenarios + it("throws on server error scenario", async () => { + mockedGetRegistryV01Servers.useScenario("server-error"); - it("extracts server objects from nested response structure", async () => { - mockedGetRegistryV01Servers.override(() => ({ - servers: [ - { - server: { - name: "test/server", - title: "Test Server", - description: "A test server", - version: "2.0.0", - }, - }, - ], - metadata: { count: 1 }, - })); - - const servers = await getServers(); - - expect(servers).toHaveLength(1); - expect(servers[0]).toEqual({ - name: "test/server", - title: "Test Server", - description: "A test server", - version: "2.0.0", - }); - }); + await expect(getServers()).rejects.toBeDefined(); }); - describe("using default data in overrides", () => { - it("can modify specific server titles", async () => { - mockedGetRegistryV01Servers.override((data) => ({ - ...data, - servers: data.servers?.map((item) => ({ - ...item, + // Demo: using .override() for type-safe response modifications + it("can override response data with type safety", async () => { + mockedGetRegistryV01Servers.override(() => ({ + servers: [ + { server: { - ...item.server, - title: `Modified: ${item.server?.title}`, + name: "test/server", + title: "Test Server", }, - })), - })); - - const servers = await getServers(); - - expect(servers[0].title).toBe("Modified: AWS Nova Canvas"); - }); - - it("can limit the number of returned servers", async () => { - mockedGetRegistryV01Servers.override((data) => ({ - ...data, - servers: data.servers?.slice(0, 3), - metadata: { count: 3 }, - })); - - const servers = await getServers(); + }, + ], + metadata: { count: 1 }, + })); - expect(servers).toHaveLength(3); - }); + const servers = await getServers(); - it("can filter servers by criteria", async () => { - mockedGetRegistryV01Servers.override((data) => ({ - ...data, - servers: data.servers?.filter((item) => - item.server?.name?.includes("google"), - ), - })); - - const servers = await getServers(); - - expect(servers.every((s) => s.name?.includes("google"))).toBe(true); - }); + expect(servers).toHaveLength(1); + expect(servers[0].name).toBe("test/server"); }); - describe("request-aware overrides", () => { - it("can access request info in override", async () => { - let capturedUrl: string | undefined; - - mockedGetRegistryV01Servers.override((data, info) => { - capturedUrl = info.request.url; - return data; - }); - - await getServers(); - - expect(capturedUrl).toContain("/registry/v0.1/servers"); - }); - - // Using overrideHandler - needed when response depends on request and may return different status codes - it("can vary response based on request headers", async () => { - mockedGetRegistryV01Servers.overrideHandler((data, info) => { - const authHeader = info.request.headers.get("Authorization"); - if (authHeader?.includes("mock-token")) { - return HttpResponse.json(data); - } - return HttpResponse.json({ error: "Unauthorized" }, { status: 401 }); - }); + // Demo: using .overrideHandler() for error status codes + it("can use overrideHandler for custom error responses", async () => { + mockedGetRegistryV01Servers.overrideHandler(() => + HttpResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); - // Our mock provides "mock-token", so this should succeed - const servers = await getServers(); - expect(servers.length).toBeGreaterThan(0); - }); + await expect(getServers()).rejects.toBeDefined(); }); }); diff --git a/src/mocks/autoAPIMock.ts b/src/mocks/autoAPIMock.ts index 48b6d5c..2586308 100644 --- a/src/mocks/autoAPIMock.ts +++ b/src/mocks/autoAPIMock.ts @@ -2,6 +2,8 @@ import type { HttpResponseResolver, JsonBodyType } from "msw"; import { HttpResponse } from "msw"; import type { MockScenarioName } from "./scenarioNames"; +const SCENARIO_HEADER = "x-mock-scenario"; + type ResponseResolverInfo = Parameters[0]; type OverrideHandlerFn = (data: T, info: ResponseResolverInfo) => Response; @@ -42,6 +44,23 @@ export function AutoAPIMock(defaultValue: T): AutoAPIMockInstance { defaultValue, generatedHandler(info: ResponseResolverInfo) { + // Check for header-based scenario activation (for browser/dev testing) + const headerScenario = info.request.headers.get(SCENARIO_HEADER); + if (headerScenario) { + const scenarioFn = scenarios.get(headerScenario as MockScenarioName); + if (scenarioFn) { + // Temporarily apply scenario and get the handler + const previousHandler = overrideHandlerFn; + scenarioFn(instance); + const result = overrideHandlerFn + ? overrideHandlerFn(defaultValue, info) + : HttpResponse.json(defaultValue as JsonBodyType); + // Restore previous state + overrideHandlerFn = previousHandler; + return result; + } + } + if (overrideHandlerFn) { return overrideHandlerFn(defaultValue, info); } diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 05b8bc0..2226c73 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -1,25 +1,11 @@ import type { RequestHandler } from "msw"; -import { HttpResponse } from "msw"; import { autoGeneratedHandlers } from "./mocker"; -import { mockScenario } from "./mockScenario"; import { serverDetailHandlers } from "./server-detail"; -// Scenario handlers (activate via cookie: mock-scenario=) -const scenarioHandlers = [ - mockScenario("empty-servers").get("*/registry/v0.1/servers", () => { - return HttpResponse.json({ servers: [], metadata: { count: 0 } }); - }), - mockScenario("server-error").get("*/registry/v0.1/servers", () => { - return HttpResponse.json( - { error: "Internal Server Error" }, - { status: 500 }, - ); - }), -]; +// Scenarios are now handled via the x-mock-scenario header in AutoAPIMock.generatedHandler +// See src/mocks/scenarioNames.ts for available scenarios export const handlers: RequestHandler[] = [ - // Scenario handlers must come first (MSW uses first match) - ...scenarioHandlers, ...serverDetailHandlers, ...autoGeneratedHandlers, ]; diff --git a/src/mocks/mockScenario.ts b/src/mocks/mockScenario.ts deleted file mode 100644 index 4269c60..0000000 --- a/src/mocks/mockScenario.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { type HttpHandler, http } from "msw"; - -type HttpMethod = - | "get" - | "post" - | "put" - | "patch" - | "delete" - | "head" - | "options"; - -const SCENARIO_HEADER = "x-mock-scenario"; - -/** - * Creates scenario-specific mock handlers that only activate when the X-Mock-Scenario header matches. - * The header is set by the API client based on the "mock-scenario" cookie. - */ -export function mockScenario( - scenario: string, -): Record { - return new Proxy({} as Record, { - get(_, method: HttpMethod) { - const httpMethod = http[method]; - if (typeof httpMethod !== "function") return undefined; - - return ( - path: string, - handler: Parameters[1], - ): HttpHandler => - httpMethod(path, (info) => { - const headerValue = info.request.headers.get(SCENARIO_HEADER); - - if (headerValue !== scenario) { - return; - } - return handler(info); - }); - }, - }); -} From 3183f97a24b7f2016fa0f92bddf9af9eddd88ae1 Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Tue, 9 Dec 2025 18:42:44 +0100 Subject: [PATCH 12/16] address claude code review points --- docs/mocks.md | 6 +++--- src/app/catalog/actions.test.ts | 8 ++++---- src/mocks/autoAPIMock.ts | 27 +++++++++++++++++++++------ src/mocks/index.ts | 5 ++++- 4 files changed, 32 insertions(+), 14 deletions(-) diff --git a/docs/mocks.md b/docs/mocks.md index 5c2cfd4..4de47f4 100644 --- a/docs/mocks.md +++ b/docs/mocks.md @@ -98,7 +98,7 @@ interface AutoAPIMockInstance { scenario(name: string, fn: (instance: AutoAPIMockInstance) => AutoAPIMockInstance): this; // Activate a named scenario for the current test - useScenario(name: string): this; + activateScenario(name: string): this; // Reset to default behavior (called automatically before each test) reset(): this; @@ -229,14 +229,14 @@ import { mockedGetRegistryV01Servers } from "@mocks/fixtures/registry_v0_1_serve describe("getServers", () => { it("returns empty array when API returns no servers", async () => { - mockedGetRegistryV01Servers.useScenario(MockScenarios.EmptyServers); + mockedGetRegistryV01Servers.activateScenario(MockScenarios.EmptyServers); const servers = await getServers(); expect(servers).toEqual([]); }); it("throws on 500 server error", async () => { - mockedGetRegistryV01Servers.useScenario(MockScenarios.ServerError); + mockedGetRegistryV01Servers.activateScenario(MockScenarios.ServerError); await expect(getServers()).rejects.toBeDefined(); }); diff --git a/src/app/catalog/actions.test.ts b/src/app/catalog/actions.test.ts index 4e64af9..6633eb6 100644 --- a/src/app/catalog/actions.test.ts +++ b/src/app/catalog/actions.test.ts @@ -26,18 +26,18 @@ describe("getServers", () => { expect(servers[0].name).toBe("awslabs/aws-nova-canvas"); }); - // Demo: using .useScenario() for reusable test scenarios + // Demo: using .activateScenario() for reusable test scenarios it("returns empty array when using empty-servers scenario", async () => { - mockedGetRegistryV01Servers.useScenario("empty-servers"); + mockedGetRegistryV01Servers.activateScenario("empty-servers"); const servers = await getServers(); expect(servers).toEqual([]); }); - // Demo: using .useScenario() for error scenarios + // Demo: using .activateScenario() for error scenarios it("throws on server error scenario", async () => { - mockedGetRegistryV01Servers.useScenario("server-error"); + mockedGetRegistryV01Servers.activateScenario("server-error"); await expect(getServers()).rejects.toBeDefined(); }); diff --git a/src/mocks/autoAPIMock.ts b/src/mocks/autoAPIMock.ts index 2586308..3ca1090 100644 --- a/src/mocks/autoAPIMock.ts +++ b/src/mocks/autoAPIMock.ts @@ -12,24 +12,37 @@ type ScenarioFn = ( instance: AutoAPIMockInstance, ) => AutoAPIMockInstance; -export interface UseScenarioOptions { +export interface ActivateScenarioOptions { /** If true, silently falls back to default when scenario doesn't exist. Default: false (throws) */ fallbackToDefault?: boolean; } export interface AutoAPIMockInstance { + /** Internal MSW handler - don't call directly */ generatedHandler: HttpResponseResolver; + + /** Override response data with type safety. Preferred for simple data changes. */ override: (fn: OverrideFn) => AutoAPIMockInstance; + + /** Override the full handler. Use for errors, network failures, or invalid data. */ overrideHandler: (fn: OverrideHandlerFn) => AutoAPIMockInstance; + + /** Define a reusable named scenario for this mock. */ scenario: ( name: MockScenarioName, fn: ScenarioFn, ) => AutoAPIMockInstance; - useScenario: ( + + /** Activate a named scenario for the current test. */ + activateScenario: ( name: MockScenarioName, - options?: UseScenarioOptions, + options?: ActivateScenarioOptions, ) => AutoAPIMockInstance; + + /** Reset to default behavior. Called automatically before each test. */ reset: () => AutoAPIMockInstance; + + /** The default fixture data. */ defaultValue: T; } @@ -83,7 +96,10 @@ export function AutoAPIMock(defaultValue: T): AutoAPIMockInstance { return instance; }, - useScenario(name: MockScenarioName, options?: UseScenarioOptions) { + activateScenario( + name: MockScenarioName, + options?: ActivateScenarioOptions, + ) { const scenarioFn = scenarios.get(name); if (!scenarioFn) { if (options?.fallbackToDefault) { @@ -119,7 +135,6 @@ export function resetAllAutoAPIMocks(): void { */ export function activateMockScenario(name: MockScenarioName): void { for (const instance of registry) { - // biome-ignore lint/correctness/useHookAtTopLevel: useScenario is not a React hook - instance.useScenario(name, { fallbackToDefault: true }); + instance.activateScenario(name, { fallbackToDefault: true }); } } diff --git a/src/mocks/index.ts b/src/mocks/index.ts index 6c91784..2bb3acd 100644 --- a/src/mocks/index.ts +++ b/src/mocks/index.ts @@ -1,4 +1,7 @@ -export type { AutoAPIMockInstance, UseScenarioOptions } from "./autoAPIMock"; +export type { + ActivateScenarioOptions, + AutoAPIMockInstance, +} from "./autoAPIMock"; export { AutoAPIMock, activateMockScenario, From 6228d1aeec595cd16eba9e1e7140c06470fa8709 Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Tue, 9 Dec 2025 18:43:14 +0100 Subject: [PATCH 13/16] improve wording in jsdoc --- docs/mocks.md | 2 +- src/mocks/autoAPIMock.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/mocks.md b/docs/mocks.md index 4de47f4..4d484d4 100644 --- a/docs/mocks.md +++ b/docs/mocks.md @@ -103,7 +103,7 @@ interface AutoAPIMockInstance { // Reset to default behavior (called automatically before each test) reset(): this; - // Internal handler used by MSW (don't call directly) + // MSW handler to use in handler registration. Respects overrides and scenarios. generatedHandler: HttpResponseResolver; } ``` diff --git a/src/mocks/autoAPIMock.ts b/src/mocks/autoAPIMock.ts index 3ca1090..a218bbb 100644 --- a/src/mocks/autoAPIMock.ts +++ b/src/mocks/autoAPIMock.ts @@ -18,7 +18,7 @@ export interface ActivateScenarioOptions { } export interface AutoAPIMockInstance { - /** Internal MSW handler - don't call directly */ + /** MSW handler to use in handler registration. Respects overrides and scenarios. */ generatedHandler: HttpResponseResolver; /** Override response data with type safety. Preferred for simple data changes. */ From 10a9ecf29a3fd4ae7f7a6c6e8cf31920291c7d58 Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Tue, 9 Dec 2025 18:47:57 +0100 Subject: [PATCH 14/16] make docs less verbose --- docs/mocks.md | 130 ++------------------------------------------------ 1 file changed, 5 insertions(+), 125 deletions(-) diff --git a/docs/mocks.md b/docs/mocks.md index 4d484d4..03dd53c 100644 --- a/docs/mocks.md +++ b/docs/mocks.md @@ -45,154 +45,34 @@ export const mockedGetRegistryV01Servers = AutoAPIMock { - it("shows empty state when no servers", async () => { - // Override to return empty list for this test only (type-safe) - mockedGetRegistryV01Servers.override(() => ({ - servers: [], - metadata: { count: 0 }, - })); - - render(); - expect(screen.getByText("No servers available")).toBeVisible(); - }); - - it("shows error state on API failure", async () => { - // Use overrideHandler for error responses - mockedGetRegistryV01Servers.overrideHandler(() => - HttpResponse.json({ error: "Server error" }, { status: 500 }) - ); - - render(); - expect(screen.getByText("Failed to load servers")).toBeVisible(); - }); - - it("shows servers from default fixture", async () => { - // No override - uses default fixture data - render(); - expect(screen.getByText("AWS Nova Canvas")).toBeVisible(); - }); -}); -``` - -### API Reference - -```typescript -interface AutoAPIMockInstance { - // The default fixture data - defaultValue: T; - - // Override the response data (type-safe, preferred) - override(fn: (data: T, info: ResponseResolverInfo) => T): this; - - // Override the full handler (for errors, network failures, invalid data) - overrideHandler(fn: (data: T, info: ResponseResolverInfo) => Response): this; - - // Define a reusable named scenario - scenario(name: string, fn: (instance: AutoAPIMockInstance) => AutoAPIMockInstance): this; - - // Activate a named scenario for the current test - activateScenario(name: string): this; - - // Reset to default behavior (called automatically before each test) - reset(): this; - - // MSW handler to use in handler registration. Respects overrides and scenarios. - generatedHandler: HttpResponseResolver; -} -``` - -### Choosing Between `override` and `overrideHandler` - -Use **`override`** (type-safe, preferred) when you just want to change the response data: - -```typescript -// Simple - just return the data you want +// Type-safe data override mockedGetRegistryV01Servers.override(() => ({ servers: [], metadata: { count: 0 }, })); -// With default data modifications +// Modify default data mockedGetRegistryV01Servers.override((data) => ({ ...data, servers: data.servers?.slice(0, 3), })); -``` - -Use **`overrideHandler`** (full control) when you need: -- Custom HTTP status codes (errors) -- Non-JSON responses -- Network errors -- Invalid/malformed data for edge case testing -```typescript -// Return error status +// Error responses (use overrideHandler) mockedGetRegistryV01Servers.overrideHandler(() => HttpResponse.json({ error: "Server error" }, { status: 500 }) ); // Network error mockedGetRegistryV01Servers.overrideHandler(() => HttpResponse.error()); - -// Testing invalid data shapes -mockedGetRegistryV01Servers.overrideHandler(() => - HttpResponse.json({ servers: [{ server: null }] }) -); ``` -### Automatic Reset - -Overrides are automatically reset before each test via `beforeEach()` in `src/mocks/test.setup.ts`. You don't need to manually reset mocks between tests. - -### Using Default Data in Overrides - -Access the default fixture data to make partial modifications: - -```typescript -// With override (type-safe, preferred) -mockedGetRegistryV01Servers.override((data) => ({ - ...data, - servers: data.servers?.slice(0, 1), -})); - -// With overrideHandler (when you need the Response wrapper) -mockedGetRegistryV01Servers.overrideHandler((data) => - HttpResponse.json({ - ...data, - servers: data.servers?.slice(0, 1), - }) -); -``` - -### Accessing Request Info - -Both methods receive request info as the second parameter: - -```typescript -// With override (type-safe) -mockedGetRegistryV01Servers.override((data, info) => { - const cursor = new URL(info.request.url).searchParams.get("cursor"); - return cursor === "page2" - ? { servers: [], metadata: { count: 0 } } - : data; -}); - -// With overrideHandler (when you need different status codes based on request) -mockedGetRegistryV01Servers.overrideHandler((data, info) => { - const authHeader = info.request.headers.get("Authorization"); - if (!authHeader) { - return HttpResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - return HttpResponse.json(data); -}); -``` +Overrides are automatically reset before each test via `beforeEach()` in `src/mocks/test.setup.ts`. ### Reusable Scenarios From a0b56a3b49a0beeb4010b056d66964086e3c3f1e Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Wed, 10 Dec 2025 10:47:52 +0100 Subject: [PATCH 15/16] test: mock token retrieval globally --- src/app/catalog/actions.test.ts | 19 ++++--------------- src/lib/auth/__tests__/token.test.ts | 7 ++++++- vitest.setup.ts | 12 +++++++++++- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/app/catalog/actions.test.ts b/src/app/catalog/actions.test.ts index 6633eb6..e390ef2 100644 --- a/src/app/catalog/actions.test.ts +++ b/src/app/catalog/actions.test.ts @@ -1,24 +1,13 @@ import { mockedGetRegistryV01Servers } from "@mocks/fixtures/registry_v0_1_servers/get"; import { HttpResponse } from "msw"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { getServers } from "./actions"; -// Mock the auth to bypass authentication -vi.mock("@/lib/api-client", async (importOriginal) => { - const original = await importOriginal(); - return { - ...original, - getAuthenticatedClient: vi.fn(() => - original.getAuthenticatedClient("mock-token"), - ), - }; -}); +// Authentication is mocked globally in vitest.setup.ts: +// - auth.api.getSession returns a mock session +// - getValidOidcToken returns "mock-test-token" describe("getServers", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - it("returns servers from default fixture", async () => { const servers = await getServers(); diff --git a/src/lib/auth/__tests__/token.test.ts b/src/lib/auth/__tests__/token.test.ts index 6e750b1..3d64954 100644 --- a/src/lib/auth/__tests__/token.test.ts +++ b/src/lib/auth/__tests__/token.test.ts @@ -1,10 +1,15 @@ import { HttpResponse, http } from "msw"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { server } from "@/mocks/node"; -import { getValidOidcToken } from "../token"; import type { OidcTokenData } from "../types"; import { encrypt } from "../utils"; +// Unmock @/lib/auth/token to test the real implementation +// (overrides the global mock from vitest.setup.ts) +vi.unmock("@/lib/auth/token"); +// Import after unmocking to get the real function +const { getValidOidcToken } = await import("../token"); + const REFRESH_API_URL = "http://localhost:3000/api/auth/refresh-token"; // Mock jose library diff --git a/vitest.setup.ts b/vitest.setup.ts index 63df588..9fdb125 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -41,7 +41,11 @@ vi.mock("@/lib/auth/auth", async (importOriginal) => { ...actual.auth.api, getSession: vi.fn(() => Promise.resolve({ - user: { email: "test@example.com", name: "Test User" }, + user: { + id: "mock-user-id", + email: "test@example.com", + name: "Test User", + }, }), ), }, @@ -73,6 +77,12 @@ vi.mock("next-themes", () => ({ ThemeProvider: ({ children }: { children: React.ReactNode }) => children, })); +// Mock OIDC token retrieval to return a test token by default +// This allows server action tests to bypass the full auth flow +vi.mock("@/lib/auth/token", () => ({ + getValidOidcToken: vi.fn(() => Promise.resolve("mock-test-token")), +})); + // Auth client baseline mock; individual tests can customize return values vi.mock("@/lib/auth/auth-client", () => ({ authClient: { From 23f3f459114b6da91853056d5bc160dc0aa35b81 Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Wed, 10 Dec 2025 10:57:20 +0100 Subject: [PATCH 16/16] apply some of copilot's suggestions --- docs/mocks.md | 6 +++--- src/mocks/mockTemplate.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/mocks.md b/docs/mocks.md index 03dd53c..2b531dd 100644 --- a/docs/mocks.md +++ b/docs/mocks.md @@ -21,7 +21,7 @@ Failure behavior (always strict) - Invalid fixtures (including empty `{}` when the schema defines properties) respond 500. Types -- Fixtures default to strict types. Generated modules import response types from `@api/types.gen` and use a `satisfies` clause to ensure compatibility. +- Fixtures use strict types via the `AutoAPIMock` wrapper. Generated modules import response types from `@api/types.gen` and pass them as generic parameters to `AutoAPIMock` for type safety. - Make sure `tsconfig.json` includes: `"paths": { "@api/*": ["./src/generated/*"] }`. ## Test-Scoped Overrides with AutoAPIMock @@ -146,8 +146,8 @@ describe("error handling", () => { // Start with all APIs returning errors activateMockScenario(MockScenarios.ServerError); - // Then customize specific endpoints to succeed - mockedGetRegistryV01Servers.override((data) => data); + // Then reset specific endpoints to use their default response + mockedGetRegistryV01Servers.reset(); // Now only other endpoints return errors, servers endpoint works render(); diff --git a/src/mocks/mockTemplate.ts b/src/mocks/mockTemplate.ts index 2d9d55a..440898b 100644 --- a/src/mocks/mockTemplate.ts +++ b/src/mocks/mockTemplate.ts @@ -18,8 +18,8 @@ export function deriveMockName(responseTypeName: string): string { /** * Renders a TypeScript module for a generated mock fixture. * When a response type name is provided, includes a type import - * from '@api/types.gen' and a `satisfies` clause for type safety. - * The fixture is wrapped in AutoAPIMock for test-scoped overrides. + * from '@api/types.gen' and wraps the fixture in `AutoAPIMock` + * for type-safe test overrides. */ export function buildMockModule(payload: unknown, opType?: string): string { const typeName = opType?.trim();