diff --git a/docs/mocks.md b/docs/mocks.md index 42dabf1..2b531dd 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`. @@ -22,5 +21,139 @@ 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 + +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()` for type-safe response modifications, or `.overrideHandler()` for full control (errors, network failures): + +```typescript +import { HttpResponse } from "msw"; +import { mockedGetRegistryV01Servers } from "@mocks/fixtures/registry_v0_1_servers/get"; + +// Type-safe data override +mockedGetRegistryV01Servers.override(() => ({ + servers: [], + metadata: { count: 0 }, +})); + +// Modify default data +mockedGetRegistryV01Servers.override((data) => ({ + ...data, + servers: data.servers?.slice(0, 3), +})); + +// Error responses (use overrideHandler) +mockedGetRegistryV01Servers.overrideHandler(() => + HttpResponse.json({ error: "Server error" }, { status: 500 }) +); + +// Network error +mockedGetRegistryV01Servers.overrideHandler(() => HttpResponse.error()); +``` + +Overrides are automatically reset before each test via `beforeEach()` in `src/mocks/test.setup.ts`. + +### 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 { MockScenarios } from "@mocks"; +import { mockedGetRegistryV01Servers } from "@mocks/fixtures/registry_v0_1_servers/get"; + +describe("getServers", () => { + it("returns empty array when API returns no servers", async () => { + mockedGetRegistryV01Servers.activateScenario(MockScenarios.EmptyServers); + + const servers = await getServers(); + expect(servers).toEqual([]); + }); + + it("throws on 500 server error", async () => { + mockedGetRegistryV01Servers.activateScenario(MockScenarios.ServerError); + + 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, 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(MockScenarios.ServerError); + + // 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(MockScenarios.ServerError); + + // Then reset specific endpoints to use their default response + mockedGetRegistryV01Servers.reset(); + + // 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` 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/actions.test.ts b/src/app/catalog/actions.test.ts new file mode 100644 index 0000000..e390ef2 --- /dev/null +++ b/src/app/catalog/actions.test.ts @@ -0,0 +1,62 @@ +import { mockedGetRegistryV01Servers } from "@mocks/fixtures/registry_v0_1_servers/get"; +import { HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; +import { getServers } from "./actions"; + +// Authentication is mocked globally in vitest.setup.ts: +// - auth.api.getSession returns a mock session +// - getValidOidcToken returns "mock-test-token" + +describe("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"); + }); + + // Demo: using .activateScenario() for reusable test scenarios + it("returns empty array when using empty-servers scenario", async () => { + mockedGetRegistryV01Servers.activateScenario("empty-servers"); + + const servers = await getServers(); + + expect(servers).toEqual([]); + }); + + // Demo: using .activateScenario() for error scenarios + it("throws on server error scenario", async () => { + mockedGetRegistryV01Servers.activateScenario("server-error"); + + await expect(getServers()).rejects.toBeDefined(); + }); + + // Demo: using .override() for type-safe response modifications + it("can override response data with type safety", async () => { + mockedGetRegistryV01Servers.override(() => ({ + servers: [ + { + server: { + name: "test/server", + title: "Test Server", + }, + }, + ], + metadata: { count: 1 }, + })); + + const servers = await getServers(); + + expect(servers).toHaveLength(1); + expect(servers[0].name).toBe("test/server"); + }); + + // Demo: using .overrideHandler() for error status codes + it("can use overrideHandler for custom error responses", async () => { + mockedGetRegistryV01Servers.overrideHandler(() => + HttpResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); + + await expect(getServers()).rejects.toBeDefined(); + }); +}); 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/src/mocks/autoAPIMock.ts b/src/mocks/autoAPIMock.ts new file mode 100644 index 0000000..a218bbb --- /dev/null +++ b/src/mocks/autoAPIMock.ts @@ -0,0 +1,140 @@ +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; +type OverrideFn = (data: T, info: ResponseResolverInfo) => T; +type ScenarioFn = ( + instance: AutoAPIMockInstance, +) => AutoAPIMockInstance; + +export interface ActivateScenarioOptions { + /** If true, silently falls back to default when scenario doesn't exist. Default: false (throws) */ + fallbackToDefault?: boolean; +} + +export interface AutoAPIMockInstance { + /** MSW handler to use in handler registration. Respects overrides and scenarios. */ + 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; + + /** Activate a named scenario for the current test. */ + activateScenario: ( + name: MockScenarioName, + options?: ActivateScenarioOptions, + ) => AutoAPIMockInstance; + + /** Reset to default behavior. Called automatically before each test. */ + reset: () => AutoAPIMockInstance; + + /** The default fixture data. */ + defaultValue: T; +} + +// Registry to track all instances for bulk reset +const registry: Set> = new Set(); + +export function AutoAPIMock(defaultValue: T): AutoAPIMockInstance { + let overrideHandlerFn: OverrideHandlerFn | null = null; + const scenarios = new Map>(); + + const instance: 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); + } + return HttpResponse.json(defaultValue as JsonBodyType); + }, + + override(fn: OverrideFn) { + return instance.overrideHandler((data, info) => + HttpResponse.json(fn(data, info) as JsonBodyType), + ); + }, + + overrideHandler(fn: OverrideHandlerFn) { + overrideHandlerFn = fn; + return instance; + }, + + scenario(name: MockScenarioName, fn: ScenarioFn) { + scenarios.set(name, fn); + return instance; + }, + + activateScenario( + name: MockScenarioName, + options?: ActivateScenarioOptions, + ) { + 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)"}`, + ); + } + return scenarioFn(instance); + }, + + reset() { + overrideHandlerFn = null; + return instance; + }, + }; + + registry.add(instance as AutoAPIMockInstance); + + return instance; +} + +export function resetAllAutoAPIMocks(): void { + for (const instance of registry) { + 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) { + instance.activateScenario(name, { fallbackToDefault: true }); + } +} diff --git a/src/mocks/fixtures/registry_v0_1_servers/get.ts b/src/mocks/fixtures/registry_v0_1_servers/get.ts index 4b64bf1..6e4706d 100644 --- a/src/mocks/fixtures/registry_v0_1_servers/get.ts +++ b/src/mocks/fixtures/registry_v0_1_servers/get.ts @@ -1,593 +1,501 @@ -export default { - servers: [ - { - server: { - title: "consequat", - 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", +import type { GetRegistryV01ServersResponse } from "@api/types.gen"; +import { AutoAPIMock } from "@mocks"; +import { HttpResponse } from "msw"; + +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: { - ullamco_566: false, - "io.modelcontextprotocol.registry/publisher-provided": { - est_84: {}, - sed3: {}, - voluptate_: {}, + "io.modelcontextprotocol.registry/official": { + isLatest: false, + publishedAt: "2024-01-16T07:47:41.0Z", + status: "active", }, }, - icons: [ - { - sizes: ["nostrud"], - mimeType: "laboris", - src: "http://AZUjhw.iemoiU+bM+FdKbi8L+QcXuAdmepZez7WVN,gwb6k.fLABJ", - }, - ], - packages: [ - { - version: "velit occaecat", - 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", - }, - ], - }, - ], - remotes: [ - { - type: "http", - url: "https://example.com/awslabs/aws-nova-canvas", - headers: [], - }, - ], }, - _meta: { - "io.modelcontextprotocol.registry/official": { - isLatest: false, - publishedAt: "1928-01-16T07:47:41.0Z", - status: "Lorem", - }, - }, - }, - { - server: { - title: "nisi in consectetur ut dolore", - 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: { - id58a: -54358865.657930315, - qui2: -45037479, - "io.modelcontextprotocol.registry/publisher-provided": { - id949: {}, + "io.modelcontextprotocol.registry/official": { + isLatest: true, + publishedAt: "2024-02-09T18:53:24.0Z", + status: "active", }, }, - 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", - }, - ], - }, - ], - remotes: [ - { - type: "http", - url: "https://example.com/tinyfish/agentql-mcp", - headers: [], - }, - ], }, - _meta: { - dolor_3e9: 35203589, - consequat_a: 71612484, - "io.modelcontextprotocol.registry/official": { - isLatest: true, - publishedAt: "1906-02-09T18:53:24.0Z", - status: "aute", - }, - }, - }, - { - server: { - title: "id", - 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: { - laborum_c_4: 59098891.74366808, - "io.modelcontextprotocol.registry/publisher-provided": { - culpabcc: {}, - dolor0: {}, - consectetur_5: {}, + "io.modelcontextprotocol.registry/official": { + isLatest: true, + publishedAt: "2024-06-16T06:09:48.0Z", + status: "active", }, }, - 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", - }, - ], - }, - ], - remotes: [ - { - type: "http", - url: "https://example.com/datastax/astra-db-mcp", - headers: [], - }, - ], }, - _meta: { - "io.modelcontextprotocol.registry/official": { - isLatest: true, - publishedAt: "1941-06-16T06:09:48.0Z", - status: "magna aliqua consequat deserunt", - }, - }, - }, - { - 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", - }, -}; + }) + .scenario("empty-servers", (self) => + self.override(() => ({ + servers: [], + metadata: { count: 0 }, + })), + ) + .scenario("server-error", (self) => + self.overrideHandler(() => + HttpResponse.json({ error: "Internal Server Error" }, { status: 500 }), + ), + ); 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..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,53 +1,57 @@ -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 const mockedGetRegistryV01ServersByServerNameVersionsByVersion = + 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/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/index.ts b/src/mocks/index.ts new file mode 100644 index 0000000..2bb3acd --- /dev/null +++ b/src/mocks/index.ts @@ -0,0 +1,11 @@ +export type { + ActivateScenarioOptions, + AutoAPIMockInstance, +} from "./autoAPIMock"; +export { + AutoAPIMock, + activateMockScenario, + resetAllAutoAPIMocks, +} from "./autoAPIMock"; +export type { MockScenarioName } from "./scenarioNames"; +export { MockScenarios } from "./scenarioNames"; 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); - }); - }, - }); -} diff --git a/src/mocks/mockTemplate.ts b/src/mocks/mockTemplate.ts index 62aeef7..440898b 100644 --- a/src/mocks/mockTemplate.ts +++ b/src/mocks/mockTemplate.ts @@ -1,13 +1,37 @@ +/** + * Derives a mock export name from a response type name. + * e.g., "GetRegistryV01ServersResponse" -> "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 - * from '@api/types.gen' and a `satisfies` clause for type safety. + * 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(); - 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`; + const mockName = typeName ? deriveMockName(typeName) : "mockedResponse"; + + // 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 const ${mockName} = AutoAPIMock${typeParam}(${JSON.stringify(payload, null, 2)});\n`; } diff --git a/src/mocks/mocker.ts b/src/mocks/mocker.ts index 80e2b22..69a57cf 100644 --- a/src/mocks/mocker.ts +++ b/src/mocks/mocker.ts @@ -1,13 +1,11 @@ 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 { buildMockModule } from "./mockTemplate"; +import type { AutoAPIMockInstance } from "./autoAPIMock"; +import { buildMockModule, deriveMockName } from "./mockTemplate"; // ===== Config ===== // Adjust the path of the OpenAPI JSON here if needed. @@ -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, @@ -369,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( @@ -399,7 +378,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 +468,21 @@ export function autoGenerateHandlers() { return new HttpResponse(null, { status: 204 }); } - let data: unknown; + 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; - data = (mod as { default?: unknown })?.default ?? mod; + const mod = (await importer()) as Record; + fixture = mod[mockName] as AutoAPIMockInstance; } else { - const mod = (await import(relPath)) as unknown; - data = (mod as { default?: unknown })?.default ?? mod; + const mod = (await import(relPath)) as Record; + fixture = mod[mockName] as AutoAPIMockInstance; } } catch (e) { return new HttpResponse( @@ -509,74 +493,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 named export "${mockName}" with 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/scenarioNames.ts b/src/mocks/scenarioNames.ts new file mode 100644 index 0000000..299a7e8 --- /dev/null +++ b/src/mocks/scenarioNames.ts @@ -0,0 +1,18 @@ +/** + * Global scenario names for API mocks. + * + * Use `MockScenarios.X` for autocomplete with documentation, + * or use the string literals directly. + */ +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. + */ +export type MockScenarioName = + (typeof MockScenarios)[keyof typeof MockScenarios]; diff --git a/src/mocks/server-detail/index.ts b/src/mocks/server-detail/index.ts index 68b6938..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 serversListFixture 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,6 +18,8 @@ export const serverDetailHandlers: RequestHandler[] = [ version, }); + const serversListFixture = mockedGetRegistryV01Servers.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": [ 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: {