Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
255 changes: 254 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,257 @@ 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()` 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 (type-safe)
mockedGetRegistryV01Servers.override(() => ({
servers: [],
metadata: { count: 0 },
}));

render(<ServerList />);
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(<ServerList />);
expect(screen.getByText("Failed to load servers")).toBeVisible();
});

it("shows servers from default fixture", async () => {
// No override - uses default fixture data
render(<ServerList />);
expect(screen.getByText("AWS Nova Canvas")).toBeVisible();
});
});
```

### API Reference

```typescript
interface AutoAPIMockInstance<T> {
// 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<T>) => AutoAPIMockInstance<T>): this;

// Activate a named scenario for the current test
useScenario(name: string): this;

// Reset to default behavior (called automatically before each test)
reset(): this;

// Internal handler used by MSW (don't call directly)
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
mockedGetRegistryV01Servers.override(() => ({
servers: [],
metadata: { count: 0 },
}));

// With default data modifications
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
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);
});
```

### 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.useScenario(MockScenarios.EmptyServers);

const servers = await getServers();
expect(servers).toEqual([]);
});

it("throws on 500 server error", async () => {
mockedGetRegistryV01Servers.useScenario(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.
77 changes: 77 additions & 0 deletions src/app/catalog/[repoName]/[serverName]/[version]/actions.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("@/lib/api-client")>();
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");
});
});
});
Loading
Loading