Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
599 changes: 566 additions & 33 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"test": "npm run test:types && vitest run",
"test:types": "tsc --noEmit -p tsconfig.type-tests.json",
"test:unit": "vitest run tests/unit",
"test:e2e": "vitest run tests/e2e",
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"docs": "typedoc",
Expand Down Expand Up @@ -42,7 +42,7 @@
"dotenv": "^16.3.1",
"eslint": "^9.39.2",
"eslint-plugin-import": "^2.32.0",
"nock": "^13.4.0",
"msw": "^2.12.11",
Comment thread
base44-os-gremlins[bot] marked this conversation as resolved.
"typedoc": "^0.28.14",
"typedoc-plugin-markdown": "^4.9.0",
"typescript": "^5.3.2",
Expand Down
9 changes: 5 additions & 4 deletions src/modules/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,11 @@ export function createFunctionsModule(
let contentType: string;

// Handle file uploads with FormData
if (
data instanceof FormData ||
(data && Object.values(data).some((value) => value instanceof File))
) {
if (data instanceof FormData) {
// Preserve fields, repeated keys, and files already encoded by callers.
formData = data;
contentType = "multipart/form-data";
} else if (data && Object.values(data).some((value) => value instanceof File)) {
formData = new FormData();
Object.keys(data).forEach((key) => {
if (data[key] instanceof File) {
Expand Down
56 changes: 56 additions & 0 deletions tests/README.md
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.
74 changes: 74 additions & 0 deletions tests/mocks/platform/actors.ts
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 },
);
},
),
];
126 changes: 126 additions & 0 deletions tests/mocks/platform/agents.ts
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);
},
),
];
10 changes: 10 additions & 0 deletions tests/mocks/platform/analytics.ts
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 });
}),
];
46 changes: 46 additions & 0 deletions tests/mocks/platform/app.ts
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 },
);
},
),
];
Loading
Loading