Skip to content

Commit 1af6e1b

Browse files
committed
test: migrate current SDK HTTP contracts to strict MSW
1 parent 136985c commit 1af6e1b

26 files changed

Lines changed: 3080 additions & 1721 deletions

‎package-lock.json‎

Lines changed: 566 additions & 33 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"test": "npm run test:types && vitest run",
1515
"test:types": "tsc --noEmit -p tsconfig.type-tests.json",
1616
"test:unit": "vitest run tests/unit",
17-
"test:e2e": "vitest run tests/e2e",
17+
"test:e2e": "vitest run --config vitest.e2e.config.ts",
1818
"test:watch": "vitest",
1919
"test:coverage": "vitest run --coverage",
2020
"docs": "typedoc",
@@ -42,7 +42,7 @@
4242
"dotenv": "^16.3.1",
4343
"eslint": "^9.39.2",
4444
"eslint-plugin-import": "^2.32.0",
45-
"nock": "^13.4.0",
45+
"msw": "^2.12.11",
4646
"typedoc": "^0.28.14",
4747
"typedoc-plugin-markdown": "^4.9.0",
4848
"typescript": "^5.3.2",

‎tests/README.md‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# SDK HTTP tests
2+
3+
`npm test` runs TypeScript API tests and the hermetic unit suite. `npm run test:coverage` reports unit coverage. Tests exercise the actual SDK HTTP clients through MSW v2; no API credentials or `tests/.env` are loaded. Unexpected traffic fails the test even when the SDK catches the network error. Never change the unit server to `warn` or `bypass` to make a test pass.
4+
5+
## Add an HTTP contract
6+
7+
Use `mockHttp` for finite request expectations. It registers native MSW handlers, captures requests, and checks bodies, headers, query parameters and call counts in teardown. This preserves request assertions on error paths: throwing directly in an MSW resolver becomes a 500 response, which an error-handling test may accidentally accept.
8+
9+
```ts
10+
import { mockHttp } from '../mocks/http';
11+
12+
mockHttp({
13+
method: 'post',
14+
url: 'https://api.base44.com/api/apps/test-app/entities/Todo',
15+
body: { title: 'Write a test' },
16+
headers: [['authorization', 'Bearer test-token']],
17+
status: 201,
18+
response: { id: 'todo-1', title: 'Write a test' },
19+
});
20+
const todo = await client.entities.Todo.create({ title: 'Write a test' });
21+
expect(todo.id).toBe('todo-1');
22+
```
23+
24+
Each expectation defaults to one call; set `times` for repeated requests. Register the same method/URL several times for ordered responses. URL matching is exact, including escaped operation IDs; query checks are explicit. `networkError: true` simulates a transport failure; `delayMs` tests concurrent request behavior.
25+
26+
For multipart or binary payloads, use `inspect: async (request) => { ... }`. Parse `await request.formData()`, then assert field values, repeated keys, file names/MIME types and file bytes. These assertions are captured and rethrown in teardown, independently of the HTTP response. `body` predicates can capture JSON requests for assertions in the test. `mockHttp` is intentionally a small test fixture, not a simulation of backend business logic.
27+
28+
For streams, dynamic state or other specialized behavior, use MSW directly:
29+
30+
```ts
31+
import { http, HttpResponse } from 'msw';
32+
import { server } from '../mocks/server';
33+
34+
let received: unknown;
35+
server.use(http.post('https://example.test/api/example', async ({ request }) => {
36+
received = await request.json();
37+
return HttpResponse.json({ ok: true });
38+
}));
39+
await clientOperation();
40+
expect(received).toEqual({ expected: 'payload' });
41+
```
42+
43+
Keep assertions outside direct resolvers. Return explicit status codes/error bodies; do not add permissive fallback handlers. Cleanup clients with `client.cleanup()` after each test, and reset any browser globals/timers installed by the test. Global setup always removes per-test handlers and verifies expected/unexpected traffic. Tests using timers must drain pending SDK work before teardown.
44+
45+
## Coverage locations
46+
47+
- `entities.test.ts`: list/filter/get/create/update/delete/deleteMany/bulkCreate/updateMany, including advanced query syntax.
48+
- `functions.test.ts`: JSON, multipart objects, caller-supplied FormData (including repeated keys and binary files), raw fetch and user/service-role headers.
49+
- `auth.test.js`, `auth-registration.test.ts`, `sso.test.ts`: current user, login, concurrent identity transitions, registration, password reset and SSO token transport.
50+
- `agents.test.ts`, `actors.test.ts`: agent conversations/messages and actor connection-token HTTP contracts. WebSocket constructors remain separate non-HTTP test doubles.
51+
- `integrations.test.js`, `integrations.test.ts`, `custom-integrations.test.ts`, `connectors*.test.ts`: integration payloads/errors, tokens, scoped connections and metered proxy calls.
52+
- `fetch-with-auth.test.ts`, `analytics.test.ts`, `app.test.ts`, `client.test.js`: fetch auth/path behavior, analytics traffic, public settings and request-derived headers.
53+
54+
The fixtures are grounded in current SDK wire contracts, not a claim that every real backend route has been independently validated. Live E2E tests remain a separate check.
55+
56+
## Explicit live E2E tests
57+
58+
`BASE44_RUN_E2E=true npm run test:e2e` uses `vitest.e2e.config.ts`, loads `tests/.env`, and bypasses MSW entirely. Supply a dedicated disposable test application via `BASE44_SERVER_URL`, `BASE44_APP_ID`, and `BASE44_AUTH_TOKEN`. These tests can create/delete platform data. They are excluded from `npm test` and unit coverage. Running `npm run test:e2e` without opt-in fails before tests or network calls begin.

‎tests/mocks/http.ts‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { expect } from "vitest";
2+
import { http, HttpResponse, delay } from "msw";
3+
import { server } from "./server";
4+
5+
type HeaderExpectation =
6+
string | RegExp | ((value: string | undefined) => boolean);
7+
interface HttpExpectation {
8+
method: "get" | "post" | "put" | "patch" | "delete";
9+
url: string;
10+
body?: any;
11+
inspect?: (request: Request) => void | Promise<void>;
12+
query?:
13+
| true
14+
| Record<string, unknown>
15+
| ((query: Record<string, string>) => boolean);
16+
headers?: [string, HeaderExpectation][];
17+
reqheaders?: Record<string, HeaderExpectation>;
18+
badheaders?: string[];
19+
times?: number;
20+
delayMs?: number;
21+
networkError?: boolean;
22+
status?: number;
23+
response?: any;
24+
responseHeaders?: Record<string, string>;
25+
respond?: (request: Request) => [number, any];
26+
}
27+
interface Capture {
28+
request: Request;
29+
body: unknown;
30+
}
31+
const expectations: {
32+
expected: HttpExpectation;
33+
requests: Capture[];
34+
failures: unknown[];
35+
}[] = [];
36+
37+
/** A native MSW handler with after-test request contract verification.
38+
* Resolver assertions cannot fail a test reliably: MSW translates exceptions
39+
* into HTTP 500 responses. Capturing them separately also covers error paths.
40+
* Repeated registrations for one route form a response sequence, in order.
41+
*/
42+
export function mockHttp(expected: HttpExpectation) {
43+
expectations.push({ expected, requests: [], failures: [] });
44+
// Use an exact regex: operation IDs may contain MSW path-pattern metacharacters.
45+
const url = new RegExp(
46+
"^" + expected.url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "(?:\\?.*)?$",
47+
);
48+
server.use(
49+
http[expected.method](url, async ({ request }) => {
50+
const sameRoute = expectations.filter(
51+
(item) =>
52+
item.expected.method === expected.method &&
53+
item.expected.url === expected.url,
54+
);
55+
const item =
56+
sameRoute.find(
57+
(item) => item.requests.length < (item.expected.times ?? 1),
58+
) ?? sameRoute.at(-1)!;
59+
const rule = item.expected;
60+
const text = request.body ? await request.clone().text() : "";
61+
let body: unknown = text;
62+
if (
63+
text &&
64+
request.headers.get("content-type")?.includes("application/json")
65+
)
66+
body = JSON.parse(text);
67+
item.requests.push({ request, body });
68+
if (typeof rule.body === "function") {
69+
try {
70+
expect(rule.body(body)).toBe(true);
71+
} catch (error) {
72+
item.failures.push(error);
73+
}
74+
}
75+
if (rule.inspect) {
76+
try {
77+
await rule.inspect(request.clone());
78+
} catch (error) {
79+
item.failures.push(error);
80+
}
81+
}
82+
if (rule.delayMs) await delay(rule.delayMs);
83+
if (rule.networkError) return HttpResponse.error();
84+
const [status, response] = rule.respond?.(request) ?? [
85+
rule.status ?? 200,
86+
rule.response,
87+
];
88+
const init = { status, headers: rule.responseHeaders };
89+
return typeof response === "string"
90+
? new HttpResponse(response, init)
91+
: HttpResponse.json(response, init);
92+
}),
93+
);
94+
}
95+
96+
export function verifyHttpExpectations() {
97+
const pending = expectations.splice(0);
98+
for (const { expected, requests, failures } of pending) {
99+
if (failures.length) throw failures[0];
100+
expect(
101+
requests,
102+
`${expected.method.toUpperCase()} ${expected.url} request count`,
103+
).toHaveLength(expected.times ?? 1);
104+
for (const { request, body } of requests) {
105+
if ("body" in expected && typeof expected.body !== "function")
106+
expect(body).toEqual(expected.body);
107+
if (expected.query && expected.query !== true) {
108+
const query = Object.fromEntries(new URL(request.url).searchParams);
109+
if (typeof expected.query === "function")
110+
expect(expected.query(query)).toBe(true);
111+
else expect(query).toEqual(expected.query);
112+
}
113+
for (const [name, value] of [
114+
...Object.entries(expected.reqheaders ?? {}),
115+
...(expected.headers ?? []),
116+
]) {
117+
const actual = request.headers.get(name) ?? undefined;
118+
if (typeof value === "function") expect(value(actual)).toBe(true);
119+
else if (value instanceof RegExp) expect(actual).toMatch(value);
120+
else expect(actual, `header ${name}`).toBe(value);
121+
}
122+
for (const name of expected.badheaders ?? [])
123+
expect(request.headers.has(name), `absent header ${name}`).toBe(false);
124+
}
125+
}
126+
}

‎tests/mocks/server.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* MSW (Mock Service Worker) server for unit tests.
3+
*
4+
* ## How to add new handlers
5+
*
6+
* Call `server.use()` inside a test to register per-test handlers.
7+
* They are automatically removed after each test by the global `afterEach`
8+
* in `tests/setup.js` (via `server.resetHandlers()`).
9+
*
10+
* ```ts
11+
* import { http, HttpResponse } from 'msw';
12+
* import { server } from '../mocks/server';
13+
*
14+
* test('my test', async () => {
15+
* server.use(
16+
* http.get('https://api.base44.com/api/apps/test-app-id/entities/Todo', () =>
17+
* HttpResponse.json([{ id: '1', title: 'Test' }])
18+
* )
19+
* );
20+
* // ... test code
21+
* });
22+
* ```
23+
*
24+
* ## Architecture
25+
*
26+
* ```
27+
* Vitest test → SDK (axios / fetch) → MSW Node server → handler → fake response
28+
* ```
29+
*
30+
* MSW intercepts requests at the Node.js http layer (`@mswjs/interceptors`)
31+
* and also intercepts native `fetch` calls. No axios mocking or `vi.stubGlobal`
32+
* needed.
33+
*
34+
* ## Modules and their base URL patterns
35+
*
36+
* | Module | Base path |
37+
* |--------------|------------------------------------------------------------------|
38+
* | entities | `/api/apps/:appId/entities/:entityName` |
39+
* | auth | `/api/apps/:appId/entities/User/me`, `/api/apps/:appId/auth/...` |
40+
* | functions | `/api/apps/:appId/functions/:name`, `/api/functions/:name` |
41+
* | integrations | `/api/apps/:appId/integration-endpoints/:pkg/:endpoint` |
42+
* | custom-int | `/api/apps/:appId/integrations/custom/:slug/:operationId` |
43+
* | connectors | `/api/apps/:appId/external-auth/tokens/:type` |
44+
*/
45+
import { setupServer } from "msw/node";
46+
47+
export const server = setupServer();

‎tests/setup.e2e.js‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import dotenv from "dotenv";
2+
import "./utils/circular-json-handler.js";
3+
// Explicit live-E2E configuration only. Unit tests never read this file.
4+
dotenv.config({ path: "./tests/.env" });

‎tests/setup.js‎

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,35 @@
1-
// Load environment variables from .env file
2-
import dotenv from 'dotenv';
3-
import './utils/circular-json-handler.js';
4-
import { beforeAll, afterAll, test } from 'vitest';
1+
import { beforeAll, afterAll, afterEach, expect } from "vitest";
2+
import { server } from "./mocks/server.ts";
3+
import { verifyHttpExpectations } from "./mocks/http.ts";
54

6-
try {
7-
dotenv.config({ path: './tests/.env' });
8-
} catch (err) {
9-
console.warn('dotenv package not found or .env file missing, skipping environment loading');
10-
}
11-
12-
// Load circular JSON reference handler to prevent errors in Jest
13-
try {
14-
console.log('Loaded circular JSON reference handler');
15-
} catch (err) {
16-
console.warn('Failed to load circular JSON handler:', err.message);
17-
}
18-
19-
// Global beforeAll and afterAll hooks
5+
const unexpected = [];
206
beforeAll(() => {
21-
console.log('Starting Base44 SDK tests...');
22-
// Add any global setup here
7+
server.listen({
8+
onUnhandledRequest(request, print) {
9+
unexpected.push(`${request.method} ${request.url}`);
10+
print.error(); // Never allow a unit test to reach the real network.
11+
},
12+
});
2313
});
24-
25-
afterAll(() => {
26-
console.log('Completed Base44 SDK tests');
27-
// Add any global teardown here
28-
});
14+
afterEach(() => {
15+
const failures = [];
16+
try {
17+
// A method swallowing network errors must still fail on unexpected traffic.
18+
try {
19+
expect(unexpected.splice(0), "Unhandled HTTP requests").toEqual([]);
20+
} catch (error) {
21+
failures.push(error);
22+
}
23+
// This also drains expectations when the unexpected-request check failed.
24+
try {
25+
verifyHttpExpectations();
26+
} catch (error) {
27+
failures.push(error);
28+
}
29+
} finally {
30+
server.resetHandlers();
31+
}
32+
if (failures.length)
33+
throw new AggregateError(failures, "HTTP mock contract failed");
34+
});
35+
afterAll(() => server.close());

0 commit comments

Comments
 (0)