Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 134 additions & 1 deletion docs/mocks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -24,3 +23,137 @@ 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<T>`, 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<GetRegistryV01ServersResponse>({
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<GetRegistryV01ServersResponse>({
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(<App />);
expect(screen.getByText("Something went wrong")).toBeVisible();
});

it("handles partial failures gracefully", async () => {
// Start with all APIs returning errors
activateMockScenario(MockScenarios.ServerError);

// Then customize specific endpoints to succeed
mockedGetRegistryV01Servers.override((data) => data);

// Now only other endpoints return errors, servers endpoint works
render(<Dashboard />);
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.
62 changes: 62 additions & 0 deletions src/app/catalog/actions.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
7 changes: 6 additions & 1 deletion src/lib/auth/__tests__/token.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
140 changes: 140 additions & 0 deletions src/mocks/autoAPIMock.ts
Original file line number Diff line number Diff line change
@@ -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<HttpResponseResolver>[0];

type OverrideHandlerFn<T> = (data: T, info: ResponseResolverInfo) => Response;
type OverrideFn<T> = (data: T, info: ResponseResolverInfo) => T;
type ScenarioFn<T> = (
instance: AutoAPIMockInstance<T>,
) => AutoAPIMockInstance<T>;

export interface ActivateScenarioOptions {
/** If true, silently falls back to default when scenario doesn't exist. Default: false (throws) */
fallbackToDefault?: boolean;
}

export interface AutoAPIMockInstance<T> {
/** 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<T>) => AutoAPIMockInstance<T>;

/** Override the full handler. Use for errors, network failures, or invalid data. */
overrideHandler: (fn: OverrideHandlerFn<T>) => AutoAPIMockInstance<T>;

/** Define a reusable named scenario for this mock. */
scenario: (
name: MockScenarioName,
fn: ScenarioFn<T>,
) => AutoAPIMockInstance<T>;

/** Activate a named scenario for the current test. */
activateScenario: (
name: MockScenarioName,
options?: ActivateScenarioOptions,
) => AutoAPIMockInstance<T>;

/** Reset to default behavior. Called automatically before each test. */
reset: () => AutoAPIMockInstance<T>;

/** The default fixture data. */
defaultValue: T;
}

// Registry to track all instances for bulk reset
const registry: Set<AutoAPIMockInstance<unknown>> = new Set();

export function AutoAPIMock<T>(defaultValue: T): AutoAPIMockInstance<T> {
let overrideHandlerFn: OverrideHandlerFn<T> | null = null;
const scenarios = new Map<MockScenarioName, ScenarioFn<T>>();

const instance: AutoAPIMockInstance<T> = {
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<T>) {
return instance.overrideHandler((data, info) =>
HttpResponse.json(fn(data, info) as JsonBodyType),
);
},

overrideHandler(fn: OverrideHandlerFn<T>) {
overrideHandlerFn = fn;
return instance;
},

scenario(name: MockScenarioName, fn: ScenarioFn<T>) {
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<unknown>);

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 });
}
}
Loading