# JavaScript testing

The `@calmlens/sdk/testing` subpath provides an offline in-memory mock environment with `MockCalmLensClient`, `InMemoryMockDatabaseStorage`, `InMemoryMockImageStorage`, mock API key generation, webhook delivery builders and `generateMediaTestCode`.

The main `@calmlens/sdk` entry point never imports testing utilities. Keep `/testing` imports inside test files so mock storage and generators are not bundled into production builds.

### Basic setup

```ts
import { MockCalmLensClient, createMockApiKey } from "@calmlens/sdk/testing";

const client = new MockCalmLensClient({
  apiKey: createMockApiKey(),
  projectId: "123e4567-e89b-12d3-a456-426614174000",
});
```

`createMockApiKey()` mints mock keys locally without contacting CalmLens.

### Mock client options (`mockOptions`)

Configure execution behavior, deterministic clocks, or custom storage drivers via `mockOptions`:

```ts
const client = new MockCalmLensClient({
  apiKey: createMockApiKey(),
  projectId: "123e4567-e89b-12d3-a456-426614174000",
  mockOptions: {
    // Generate deterministic IDs for snapshot testing
    createId: () => "00000000-0000-0000-0000-000000000001",

    // Fix timestamps for reproducible assertions
    now: () => 1_700_000_000_000,

    // Status assigned to asynchronous uploads (defaults to "pending")
    queuedStatus: "pending",

    // Status assigned to synchronous moderate calls (defaults to "approved")
    analyzedStatus: "approved",

    // Base URL for mock signed URLs
    signedUrlBaseUrl: "mock://calmlens",
  },
});
```

The mock accepts the same options as `CalmLensClient` (`baseUrl`, `cdnBaseUrl`, `timeoutMs` and so on) and rejects invalid ones the same way. `cdn.getAssetUrl` uses `cdnBaseUrl`, which defaults to `mock://calmlens/v1`.

### Behaviour that matches the API

The mock reproduces the API where tests usually depend on it:

- Errors carry the API's `code`, HTTP `status`, a `requestId` and response `headers`, so `error.isNotFound()` and status checks work as they do against CalmLens.
- `assets.list` applies `ordering`, `folderId` and simple `filter` expressions: comparisons (`==`, `!=`, `<`, `<=`, `>`, `>=`, `=in=`, `=out=`) joined by `;` (and) or `,` (or). A filter it can't evaluate throws `sdk/invalid-argument` instead of returning the wrong assets. `assets.iterate` pages through the results.
- `assets.search` matches the query against names, IDs, external IDs, descriptions and tags.
- A second asset with the same `externalId` fails with `asset/duplicate-external-id`.
- Multipart parts must arrive in order (`upload/invalid-part-number` with `details.expectedPartNumber`), the queue mode chosen at the start applies to the final part, and parts after it fail with `upload/not-open`.
- Reusing an `idempotencyKey` returns the first result; reusing it for a different request fails with `idempotency/key-reused`.

### Events

`events.list` starts empty. Add events with `client.enqueueEvent`, then read them with a cursor as you would from the API:

```ts
const { cursor } = await client.events.list(); // a cursor for "now"
client.enqueueEvent({ type: "asset.classified", assetId: asset.id });

const page = await client.events.list({ after: cursor });
expect(page.events.map((event) => event.type)).toEqual(["asset.classified"]);
```

### Testing webhook handlers

`toWebhookEventPayload` turns an event into a delivery body, and `createWebhookHeaders` signs it the way CalmLens does, so your handler's `parseWebhookEvent` or `verifyWebhookSignature` call runs unchanged:

```ts
import { createWebhookHeaders, toWebhookEventPayload } from "@calmlens/sdk/testing";

const event = client.enqueueEvent({ type: "asset.classified", assetId: asset.id });
const body = JSON.stringify(toWebhookEventPayload(event));
const headers = await createWebhookHeaders({ secret, body, deliveryId: "delivery-1" });
const response = await handler(new Request("http://localhost/webhooks", { method: "POST", body, headers }));
```

### Recorded calls

Every call is recorded in `client.calls` as `{ operation, options }`: the method path, such as `"text.moderate"` or `"assets.retrieve"`, and the options object it received. `client.callsTo(operation)` returns the calls to one method:

```ts
await client.text.moderate({ text: "hello", externalId: "comment-1" });

expect(client.callsTo("text.moderate")).toEqual([
  { operation: "text.moderate", options: { text: "hello", externalId: "comment-1" } },
]);
```

### Simulating network failures and timeouts

`client.failNextRequest()` makes the next call throw the `CalmLensError` that `CalmLensClient` throws when a request cannot complete (`code: "sdk/transport-error"`, with the network error as `cause`). Pass `"timeout"` to throw `sdk/timeout` instead, or any `Error` to throw it as is. `{ operation }` limits the failure to one method. Each queued failure is used once:

```ts
client.failNextRequest("timeout", { operation: "text.moderate" });

await expect(client.text.moderate({ text: "hello" })).rejects.toMatchObject({
  code: "sdk/timeout",
});
await expect(client.text.moderate({ text: "hello" })).resolves.toMatchObject({
  status: "approved",
});
```

### Inspecting in-memory storage

`MockCalmLensClient` exposes its internal storage instances as `client.databaseStorage` and `client.imageStorage`. Use these in assertions to verify saved records without extra API calls:

```ts
// Inspect database records directly
const asset = await client.databaseStorage.getAsset(createdAsset.id);
const allAssets = await client.databaseStorage.listAssets(projectId);

const folder = await client.databaseStorage.getFolder(createdFolder.id);
const allFolders = await client.databaseStorage.listFolders(projectId);

// Inspect raw binary upload content and multipart parts
const storedContent = await client.imageStorage.getContent(createdAsset.id);
console.log(storedContent?.type); // "image/png"
console.log(storedContent?.parts.get(1)); // Uint8Array of bytes
```

### Customising test storage

You can inject custom storage implementations implementing `MockDatabaseStorage` or `MockImageStorage` to track method calls or simulate database failures:

```ts
import {
  MockCalmLensClient,
  InMemoryMockDatabaseStorage,
  InMemoryMockImageStorage,
  createMockApiKey,
} from "@calmlens/sdk/testing";

const databaseStorage = new InMemoryMockDatabaseStorage();
const imageStorage = new InMemoryMockImageStorage();

const client = new MockCalmLensClient({
  apiKey: createMockApiKey(),
  projectId: "123e4567-e89b-12d3-a456-426614174000",
  mockOptions: { databaseStorage, imageStorage },
});
```

### Simulating CDN serving and access control

`MockCalmLensClient.cdn.serve` fetches raw bytes directly from in-memory storage and checks access control without network calls. Like the CDN, it returns `Content-Type`, `Content-Length`, `ETag` and `X-Request-Id` headers, and a JSON error body with `404` or `403`:

```ts
// Public assets can be served directly
const publicAsset = await client.assets.create({
  name: "public.png",
  content: { base64: pngBase64, type: "image/png" },
  visibility: "public",
});

const response = await client.cdn.serve({ assetIdentifier: publicAsset.id });
expect(response.status).toBe(200);

// A key without assets:download cannot read private assets directly
const privateAsset = await client.assets.create({
  name: "secret.png",
  content: { base64: pngBase64, type: "image/png" },
  visibility: "private",
});

const privateResponse = await client.cdn.serve({ assetIdentifier: privateAsset.id });
expect(privateResponse.status).toBe(403);
```
