-
Notifications
You must be signed in to change notification settings - Fork 15
Migrate SDK HTTP tests to MSW and preserve FormData payloads #145
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
base44-os-gremlins
wants to merge
10
commits into
main
Choose a base branch
from
feat/msw-test-infrastructure
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
f30776b
feat(tests): replace nock with MSW mock server infrastructure
136985c
fix(functions): preserve caller-supplied FormData contents
netanelgilad 1af6e1b
test: migrate current SDK HTTP contracts to strict MSW
netanelgilad ce89bfa
Merge original MSW proposal ancestry after rebuilding on current main
netanelgilad 1d574ef
test: honor dependency cooldown in MSW lockfile
netanelgilad ac78d8d
test: model Base44 with stateful MSW platform
ccd9d79
test: enforce app-scoped platform behavior
3f220f7
test: prove same-app client auth isolation
d64bec2
test: enforce scoped platform contracts
a5d6085
test: model integration and connector domains
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| # SDK HTTP tests | ||
|
|
||
| `npm test` runs the API type tests and hermetic unit suite. HTTP behavior tests call the real SDK Axios/fetch clients through one stateful MSW v2 mock Base44 platform. Tests never register handlers or author HTTP response bodies. Unexpected or unconfigured traffic fails teardown even when the SDK catches the request error. | ||
|
|
||
| ## Write a platform-backed test | ||
|
|
||
| Arrange domain state with `platform.given`, act only through the SDK, then assert the returned behavior and any meaningful wire detail through `platform.requests`: | ||
|
|
||
| ```ts | ||
| import { platform } from "./mocks/platform"; | ||
|
|
||
| platform.given | ||
| .app("test-app-id") | ||
| .entities.records("Todo", [{ id: "1", title: "Existing", completed: false }]); | ||
|
|
||
| const created = await client.entities.Todo.create({ | ||
| title: "Write a test", | ||
| completed: false, | ||
| }); | ||
|
|
||
| expect(await client.entities.Todo.get(created.id)).toEqual(created); | ||
| expect(await client.entities.Todo.list()).toContainEqual(created); | ||
| expect(platform.requests.last("entities.create").body).toEqual({ | ||
| title: "Write a test", | ||
| completed: false, | ||
| }); | ||
| ``` | ||
|
|
||
| Use app-scoped named fault fixtures such as `platform.given.app("test-app-id").faults.functions.notFound("missing")` for error cases. Register reusable domain behavior (for example `functions.notificationDelivery`) rather than supplying endpoint results. Tests must not choose HTTP statuses, headers, wire envelopes, or MSW resolvers. | ||
|
|
||
| Global setup resets records, identities, deterministic identifiers, request journals and faults before and after every test. Initial handlers remain installed and `server.resetHandlers()` restores that same centralized set. Do not use concurrent tests against this singleton state. | ||
|
|
||
| ## Extend the mock platform | ||
|
|
||
| 1. Confirm the SDK request and the matching backend contract. Record the exact backend revision; distinguish current apper behavior from a deliberate legacy SDK compatibility case. | ||
| 2. Add state and a domain-oriented given fixture under `tests/mocks/platform/`. | ||
| 3. Add or extend the module handler there. The handler owns status codes, response shapes, validation, mutations and error serialization. | ||
| 4. Journal normalized requests with `recordRequest`. Multipart journal entries preserve repeated fields, filenames, MIME types, sizes and bytes. | ||
| 5. Add tests that prove behavior across SDK calls (for example create → get/list) and reset isolation. Use journal assertions only for meaningful wire contracts such as auth selection, query encoding or multipart fidelity. | ||
|
|
||
| Never import `msw`, `mocks/server`, or the retired `mockHttp` helper from a behavior test. Never assert inside a resolver: MSW turns resolver exceptions into HTTP 500 responses. The architecture guard enforces these boundaries. | ||
|
|
||
| ## Coverage locations | ||
|
|
||
| - `entities.test.ts`: query/list/get and stateful create/update/delete/bulk/update-many behavior. | ||
| - `functions.test.ts`: JSON, multipart objects, direct FormData with repeated keys and binary bytes, raw fetch and user/service-role headers. | ||
| - `auth.test.js`, `auth-registration.test.ts`, `sso.test.ts`: current user, login and identity transitions, registration, recovery and legacy SSO compatibility. | ||
| - `agents.test.ts`, `actors.test.ts`: stateful conversations/messages and actor connection-token HTTP behavior. WebSockets remain a separate non-HTTP double. | ||
| - `integrations*.test.*`, `custom-integrations.test.ts`, `connectors*.test.ts`: domain outcomes, custom upstream envelopes, scoped tokens and proxy calls. | ||
| - `fetch-with-auth.test.ts`, `analytics.test.ts`, `app.test.ts`, `client.test.js`: fetch auth/path behavior, analytics batches, public settings and request-derived headers. | ||
|
|
||
| The centralized contracts are based on pinned backend source plus explicitly labeled SDK compatibility behavior. They do not prove the currently deployed production version. Live E2E remains separate. | ||
|
|
||
| ## Explicit live E2E tests | ||
|
|
||
| `BASE44_RUN_E2E=true npm run test:e2e` uses `vitest.e2e.config.ts`, loads `tests/.env`, and bypasses MSW. Supply a disposable test app via `BASE44_SERVER_URL`, `BASE44_APP_ID`, and `BASE44_AUTH_TOKEN`. These tests can mutate real data and are excluded from `npm test` and unit coverage. Running without the opt-in fails before network calls begin. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| import { http, HttpResponse } from "msw"; | ||
| import { recordRequest } from "./state"; | ||
|
|
||
| interface ActorDeployment { | ||
| scriptId: string; | ||
| websocketHost: string; | ||
| issuedToken: string; | ||
| tokenExpiresAt: string; | ||
| mode: "prod" | "preview"; | ||
| } | ||
| type ActorFault = "legacy-conflict" | "endpoint-unsupported" | "mint-failed"; | ||
| const actors = new Map<string, ActorDeployment>(); | ||
| const faults = new Map<string, ActorFault>(); | ||
| const scoped = (appId: string, name: string) => `${appId}\u0000${name}`; | ||
|
|
||
| export function actorFixturesFor(appId: string) { | ||
| return { | ||
| deployed(name: string, deployment: ActorDeployment) { | ||
| actors.set(scoped(appId, name), structuredClone(deployment)); | ||
| }, | ||
| fault(name: string, fault: ActorFault) { | ||
| faults.set(scoped(appId, name), fault); | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| export function resetActorState() { | ||
| actors.clear(); | ||
| faults.clear(); | ||
| } | ||
|
|
||
| export const actorHandlers = [ | ||
| http.post( | ||
| "*/api/apps/:appId/actors/:actorName/connection-token", | ||
| async ({ params, request }) => { | ||
| const recorded = await recordRequest( | ||
| "actors.mintConnectionToken", | ||
| request, | ||
| ); | ||
| const appId = String(params.appId); | ||
| const name = String(params.actorName); | ||
| const fault = faults.get(scoped(appId, name)); | ||
| if (fault === "legacy-conflict") | ||
| return HttpResponse.json( | ||
| { message: "Actor must be migrated before connecting directly" }, | ||
| { status: 409 }, | ||
| ); | ||
| if (fault === "endpoint-unsupported") | ||
| return HttpResponse.json( | ||
| { | ||
| error_type: "HTTPException", | ||
| message: "Method Not Allowed", | ||
| detail: "Method Not Allowed", | ||
| }, | ||
| { status: 405 }, | ||
| ); | ||
| if (fault === "mint-failed") | ||
| return HttpResponse.json({ message: "mint exploded" }, { status: 500 }); | ||
| const deployment = actors.get(scoped(appId, name)); | ||
| const body = recorded.body as { room: string; connection_id: string }; | ||
| return deployment | ||
| ? HttpResponse.json({ | ||
| websocket_url: `${deployment.websocketHost}/v1/actors/${deployment.scriptId}/rooms/${encodeURIComponent(body.room)}?_pk=${encodeURIComponent(body.connection_id)}`, | ||
| token: deployment.issuedToken, | ||
| expires_at: deployment.tokenExpiresAt, | ||
| mode: deployment.mode, | ||
| }) | ||
| : HttpResponse.json( | ||
| { detail: "Actor not found", code: "NOT_FOUND" }, | ||
| { status: 404 }, | ||
| ); | ||
| }, | ||
| ), | ||
| ]; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| import { http, HttpResponse } from "msw"; | ||
| import { recordRequest, state, type PlatformConversation } from "./state"; | ||
| import { principalFor } from "./auth"; | ||
|
|
||
| function identity(appId: string, request: Request) { | ||
| const principal = principalFor(appId, request)?.principal; | ||
| if (principal) return { kind: "user" as const, id: principal.user.id }; | ||
| const visitorId = request.headers.get("x-base44-anonymous-id"); | ||
| return visitorId ? { kind: "visitor" as const, id: visitorId } : undefined; | ||
| } | ||
|
|
||
| function forbidden(message: string) { | ||
| return HttpResponse.json({ detail: message }, { status: 403 }); | ||
| } | ||
|
|
||
| function authorizedConversation( | ||
| appId: string, | ||
| request: Request, | ||
| conversationId: string, | ||
| ) { | ||
| const stored = state.conversations.get(conversationId); | ||
| if (!stored) | ||
| return { | ||
| response: HttpResponse.json( | ||
| { detail: "Conversation not found", code: "NOT_FOUND" }, | ||
| { status: 404 }, | ||
| ), | ||
| }; | ||
| if (stored.appId !== appId) | ||
| return { | ||
| response: forbidden("Access denied: conversation belongs to another app"), | ||
| }; | ||
| const caller = identity(appId, request); | ||
| if ( | ||
| !caller || | ||
| caller.kind !== stored.owner.kind || | ||
| caller.id !== stored.owner.id | ||
| ) | ||
| return { | ||
| response: forbidden( | ||
| `Access denied: conversation belongs to another ${stored.owner.kind}`, | ||
| ), | ||
| }; | ||
| return { stored }; | ||
| } | ||
|
|
||
| // Pinned Apper d9ae151 app/owner/visitor authorization is modeled here. | ||
| // Reserved metadata stripping, public redaction, internal/room filtering and | ||
| // actual LLM generation remain explicit unsupported policy boundaries. | ||
| export const agentHandlers = [ | ||
| http.get( | ||
| "*/api/apps/:appId/agents/conversations", | ||
| async ({ params, request }) => { | ||
| await recordRequest("agents.listConversations", request); | ||
| const appId = String(params.appId); | ||
| const caller = identity(appId, request); | ||
| if (!caller) return HttpResponse.json([]); | ||
| return HttpResponse.json( | ||
| [...state.conversations.values()] | ||
| .filter( | ||
| (item) => | ||
| item.appId === appId && | ||
| item.owner.kind === caller.kind && | ||
| item.owner.id === caller.id, | ||
| ) | ||
| .map((item) => item.record), | ||
| ); | ||
| }, | ||
| ), | ||
| http.get( | ||
| "*/api/apps/:appId/agents/conversations/:conversationId", | ||
| async ({ params, request }) => { | ||
| await recordRequest("agents.getConversation", request); | ||
| const resolved = authorizedConversation( | ||
| String(params.appId), | ||
| request, | ||
| String(params.conversationId), | ||
| ); | ||
| return resolved.response ?? HttpResponse.json(resolved.stored!.record); | ||
| }, | ||
| ), | ||
| http.post( | ||
| "*/api/apps/:appId/agents/conversations", | ||
| async ({ params, request }) => { | ||
| await recordRequest("agents.createConversation", request); | ||
| const appId = String(params.appId); | ||
| const owner = identity(appId, request); | ||
| if (!owner) | ||
| return HttpResponse.json( | ||
| { detail: "User must be authenticated to create a conversation" }, | ||
| { status: 401 }, | ||
| ); | ||
| const input = (await request.clone().json()) as Pick< | ||
| PlatformConversation, | ||
| "agent_name" | ||
| >; | ||
| const conversation: PlatformConversation = { | ||
| id: `conv-${state.nextConversationId++}`, | ||
| agent_name: input.agent_name, | ||
| messages: [], | ||
| }; | ||
| state.conversations.set(conversation.id, { | ||
| appId, | ||
| owner, | ||
| record: conversation, | ||
| }); | ||
| return HttpResponse.json(conversation); | ||
| }, | ||
| ), | ||
| http.post( | ||
| "*/api/apps/:appId/agents/conversations/v2/:conversationId/messages", | ||
| async ({ params, request }) => { | ||
| await recordRequest("agents.addMessage", request); | ||
| const input = (await request.clone().json()) as Record<string, any>; | ||
| const resolved = authorizedConversation( | ||
| String(params.appId), | ||
| request, | ||
| String(params.conversationId), | ||
| ); | ||
| if (resolved.response) return resolved.response; | ||
| const message = { id: `msg-${state.nextMessageId++}`, ...input }; | ||
| resolved.stored!.record.messages.push(message); | ||
| return HttpResponse.json(message); | ||
| }, | ||
| ), | ||
| ]; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import { http, HttpResponse } from "msw"; | ||
| import { recordRequest } from "./state"; | ||
|
|
||
| export const analyticsHandlers = [ | ||
| http.post("*/api/apps/:appId/analytics/track/batch", async ({ request }) => { | ||
| await recordRequest("analytics.trackBatch", request); | ||
| const recorded = (await request.clone().json()) as { events?: unknown[] }; | ||
| return HttpResponse.json({ accepted: recorded.events?.length ?? 0 }); | ||
| }), | ||
| ]; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import { http, HttpResponse } from "msw"; | ||
| import { recordRequest } from "./state"; | ||
|
|
||
| const accessPolicies = new Map<string, string>(); | ||
| const accessFaults = new Map<string, "auth_required" | "user_not_registered">(); | ||
|
|
||
| export function appFixturesFor(appId: string) { | ||
| return { | ||
| deploymentAccess(policy: string) { | ||
| accessPolicies.set(appId, policy); | ||
| }, | ||
| /** Legacy edge response retained to verify SDK error compatibility; current | ||
| * apper's deployment settings route documents 404/500 instead. */ | ||
| legacyAccessDenied(reason: "auth_required" | "user_not_registered") { | ||
| accessFaults.set(appId, reason); | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| export function resetAppState() { | ||
| accessPolicies.clear(); | ||
| accessFaults.clear(); | ||
| } | ||
|
|
||
| export const appHandlers = [ | ||
| http.get( | ||
| "*/api/apps/public/prod/public-settings/by-id/:appId", | ||
| async ({ params, request }) => { | ||
| await recordRequest("app.getPublicSettings", request); | ||
| const appId = String(params.appId); | ||
| const reason = accessFaults.get(appId); | ||
| if (reason) | ||
| return HttpResponse.json( | ||
| { extra_data: { app_id: appId, reason } }, | ||
| { status: 403 }, | ||
| ); | ||
| const policy = accessPolicies.get(appId); | ||
| return policy | ||
| ? HttpResponse.json({ id: appId, public_settings: policy }) | ||
| : HttpResponse.json( | ||
| { detail: "App not found", code: "NOT_FOUND" }, | ||
| { status: 404 }, | ||
| ); | ||
| }, | ||
| ), | ||
| ]; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.