# PHP testing

Use `CalmLens\Testing\MockServerClient` to test your [PHP SDK](/docs/sdk/php) integration offline against an in-memory CalmLens, or `CalmLens\Testing\MockTransport` to replay responses you queue for exact request and error tests.

## Setup

`MockServerClient` runs the real client's validation, encoding, retries and error handling, then answers from an in-memory stand-in for the API and CDN. `$mock->client()` returns the `ServerClient` to pass to the code under test.

```php
<?php
require 'vendor/autoload.php';

$mock = new CalmLens\Testing\MockServerClient();
$client = $mock->client();
$asset = $client->assets->create(['name' => 'photo.png', 'visibility' => 'public',
    'content' => ['data' => $bytes, 'type' => 'image/png']]); // pending
$mock->completeProcessing($asset['id'], 'age-restricted'); // as moderation would
$mock->decide($asset['id'], 'blocked'); // as a reviewer would; adds asset.blocked
```

## Options

The constructor takes:

| Argument                                                                                | Effect                                                                                                                                      |
| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `moderate`                                                                              | Decides each asset the mock moderates. See [Moderation decisions](#moderation-decisions).                                                   |
| `apiKey`                                                                                | A key from `MockServerClient::createApiKey($grants)`. The default key has no grants.                                                        |
| `now`, `createId`                                                                       | Deterministic clock and IDs. Assets created in a row get increasing `createdAt` values by default, so newest-first results are predictable. |
| `projectId`, `baseUrl`, `cdnBaseUrl`, `maxRetries`, `maxResponseBytes`, `uploadTimeout` | Client settings.                                                                                                                            |

Content is kept only with `keepContentAfterProcessing`.

To read private assets, pass a key with a download grant:

```php
$mock = new CalmLens\Testing\MockServerClient(
    apiKey: CalmLens\Testing\MockServerClient::createApiKey([
        ['actions' => ['assets:download'], 'folderId' => null, 'includeSubfolders' => true],
    ]),
);
```

A `folderId` limits the grant to that folder, and `$mock->setApiKeyGrants($grants)` changes the same key's grants later. The stored records are public properties, such as `$mock->assets`, so you can change them to arrange a test.

## Moderation decisions

The mock doesn't classify content. The `moderate` callable decides each asset the mock moderates instead. It receives a `CalmLens\Testing\MockModerationInput` with:

- `asset`: the stored asset, still processing.
- `bytes`: the uploaded bytes, with the parts of a multipart upload joined in order, and `text`: the bytes as text for a plain-text upload such as `text->classify`. Both are `null` when there is no content, such as for a website.
- `trigger`: why the asset is moderated: `upload`, `retry` (after `assets->retry`), `rerun` (after `assets->explain` with `'rerun' => true`) or `complete` (see below).
- `queued`: `true` when CalmLens would queue the work rather than answer with the decision: queued uploads, retries, reruns and `complete`.

It returns a status, `pending`, `failed`, `allowed`, `limited`, `age-restricted`, `blocked` or `purged`, or a decision built with `MockDecision`. `pending` leaves the asset processing, a `failed` asset gets `classification.failureReason` `processing-error`, and `purged` also removes the content, with reason `policy`. Without `moderate`, queued uploads, retries and reruns stay `pending` and everything else is allowed.

`CalmLens\Testing\MockDecision::of($status)` builds a decision with findings: `flag($category, $score, $qualifiers)` records a category's score from 0 to 1 and, optionally, its qualifiers' scores, and `notEvaluated($category)` marks a category as not assessed. Both return the decision, so calls chain. A decided asset gets a complete report, as from CalmLens: every category is present (unflagged ones evaluated with score 0), `score` is the highest score, and `policy` holds each category's action and the `flaggedCategories` the decision acted on (categories scored 0.5 or more, unless the status is `allowed`). An unknown status, category or qualifier, a score outside 0 to 1, a category used twice and findings on a `pending` or `failed` decision throw `InvalidArgumentException` with `sdk/invalid-argument`. `CalmLens\Testing\Classification` lists the categories and qualifiers.

```php
<?php
require 'vendor/autoload.php';

use CalmLens\Testing\MockDecision;
use CalmLens\Testing\MockModerationInput;
use CalmLens\Testing\MockServerClient;

$mock = new MockServerClient(moderate: fn (MockModerationInput $input) => str_contains((string) $input->text, 'kill')
    ? MockDecision::of('blocked')->flag('violence', 0.92, ['violence-threat' => 0.9])
    : 'allowed');

$comment = $mock->client()->text->classify(['text' => 'I will kill you']);
$moderation = $comment['classification']['moderation'];
// $moderation['categories']['violence']['score'] is 0.92
// $moderation['flaggedCategories'] is ['violence']
```

A `moderate` callable decides queued uploads straight away too. Return `pending` for them (`$input->queued && $input->trigger === 'upload'`) to finish them later.

### Finish processing

`$mock->completeProcessing($assetId, $decision)` finishes processing a pending asset as CalmLens would, with the decision you pass (a status or a `MockDecision`) or else the one `moderate` returns for the `complete` trigger (allowed by default). It records no review, so the asset can still be moderated again with `assets->explain`. It throws `NotFoundException` with [`404 asset/not-found`](/docs/api/assets#upload-asset-part-error-asset-not-found) for an unknown asset and `InvalidArgumentException` with `sdk/invalid-argument` for an asset that isn't processing:

```php
$upload = $client->assets->create(['name' => 'photo.png',
    'content' => ['data' => $bytes, 'type' => 'image/png']]); // pending

$mock->completeProcessing($upload['id'], MockDecision::of('age-restricted')->flag('sexual', 0.8));
$decided = $client->assets->waitForDecision(['assetId' => $upload['id']]);
// $decided['classification']['moderation']['status'] is 'age-restricted'
```

## What the mock simulates

- **Assets** you upload can be retrieved, listed, updated, moved and deleted. Unset asset fields are `null`, as in the API, and requests the API's schema refuses fail with `validation/<issue>` and the field in `details['path']`. Error responses carry the API's body, with a `requestId` matching the `X-Request-Id` header.
- **Folders, hash lists, child listings, multipart uploads and idempotency keys** behave like the API. Folder pages are ordered by name or creation time, with the folder ID breaking ties. Uploads are not matched against hash lists.
- **IDs** look like the API's: a prefix, `_` and 20 random base62 characters, such as `asset_5Gm2Rk8XqT3wLp9VbN4c`. For predictable IDs, pass `createId`, a callable that receives the prefix (`asset`, `fldr`, `hl`, `hle`, `lens`, `evt` or `rev`) and returns the ID.
- **Reviewer decisions** from `$client->assets->decide()` are refused like the API's: an asset pending moderation can only be blocked or purged ([`409 asset/decision-not-allowed`](/docs/api/assets#decide-asset-error-asset-decision-not-allowed)), and its current status gets [`409 asset/decision-unchanged`](/docs/api/assets#decide-asset-error-asset-decision-unchanged).
- **Lists** return 50 items by default and accept the same filters: `==` (with `*` wildcards), `!=`, `=in=`, `=out=`, `=gt=`, `=ge=`, `=lt=` and `=le=`, joined by `;` (and), which binds tighter than `,` (or), and grouped with `( … )`. Other syntax throws instead of being ignored. Cursors resume after the last item returned, so assets created or deleted between pages are neither repeated nor skipped.
- **Search** matches every word of the query in an asset's name, ID, external ID, description, tags, type, content format or extension, or moderation status, and ranks results with the API's keyword scores.
- **Waiting** for a decision runs on a virtual clock, so a timeout doesn't slow your tests.
- **CDN access**: like the CDN, the mock refuses blocked, purged and failed assets, and assets whose content was removed, with [`403 asset/not-available`](/docs/api/cdn#error-asset-not-available) whatever the key. It refuses age-restricted assets requested without `'viewerClaims' => ['ageConfirmed' => true]` with [`403 viewer/age-confirmation-required`](/docs/api/cdn#error-viewer-age-confirmation-required), whatever the key. Without an `assets:download` grant, it also refuses private assets with [`403 asset/api-key-missing-permission`](/docs/api/cdn#error-asset-api-key-missing-permission).
- **CDN responses** carry the stored `Content-Type` and `Cache-Control` as the CDN sets it (`public` only when anyone may see the asset, otherwise `private`; `max-age=31536000, immutable` for images and `max-age=3600` for other content; `private, no-store` when the request carries viewer claims), plus `Cache-Tag`, `X-Content-Type-Options: nosniff`, a sandboxing `Content-Security-Policy` and, for images, an `ETag` and `Expires`. A classified image gets a ThumbHash, which the `placeholder` resource serves as a PNG and which goes with the content.

## Events and reviewer decisions

Every upload adds [`asset.uploaded`](/docs/api/events#event-asset-uploaded) to the events feed, then [`asset.classified`](/docs/api/events#event-asset-classified) and the automated `asset.<status>` (with a `decided-by: automated` fact) once it is decided, or [`asset.failed`](/docs/api/events#event-asset-failed). Like the API, deleting an asset, removing its content and retrying it add [`asset.deleted`](/docs/api/events#event-asset-deleted), [`asset.content-removed`](/docs/api/events#event-asset-content-removed) and [`asset.retried`](/docs/api/events#event-asset-retried) events. `$client->assets->decide()` adds the `asset.<status>` event for a reviewer's decision, with its note. `$mock->enqueueEvent($event)` adds others to the feed, for cursors taken earlier, and returns the stored event; an event about a stored asset carries the asset as it is, as from CalmLens.

`$mock->decide($assetId, $decision)` applies a reviewer's decision (`allowed`, `limited`, `age-restricted`, `blocked` or `purged`) to a stored asset: it records the review in the asset's `manualReviews` and adds the `asset.<decision>` event with its usual severity (`low` for `allowed` and `limited`, `high` otherwise) and a `decided-by: reviewer` fact. A reviewer's `purged` removes the content with reason `requested` and first adds [`asset.content-removed`](/docs/api/events#event-asset-content-removed). The moderation report stays as it was. An unknown asset throws `NotFoundException` with [`404 asset/not-found`](/docs/api/assets#upload-asset-part-error-asset-not-found). `pending` and `failed` are not decisions and throw `InvalidArgumentException` with `sdk/invalid-argument`; to finish processing as CalmLens's moderation would, use `completeProcessing`.

## Recorded requests and failures

`$mock->requests()` returns every request received, oldest first, as `CalmLens\Testing\RecordedRequest` objects:

- `method` and `path`, which is relative to the API or CDN base URL, so it leaves out `/v1`, with each segment decoded, such as `/assets/<id>` or `/projects/project-1/assets`.
- `query`: the query parameters as sent, decoded, such as `['ordering[0][field]' => 'createdAt']`.
- `status`: the HTTP status the mock answered with, or `null` for a request that failed without a response.
- `request`: the PSR-7 request, with its headers and a readable body.

`$mock->clearRequests()` forgets the requests recorded so far.

```php
$client->text->classify(['text' => 'hello', 'externalId' => 'comment-1']);
$request = $mock->requests()[0];
// $request->method is 'POST', $request->path is '/projects/project-1/assets'
// $request->query['externalId'] is 'comment-1', $request->status is 200
```

`$mock->failNextRequest($failure)` fails the next request:

| `$failure`                                           | Fails with                     |
| ---------------------------------------------------- | ------------------------------ |
| `'transport-error'` (the default)                    | A connection failure           |
| `'timeout'`                                          | A timeout                      |
| An API error code, such as `'upload/limit-exceeded'` | That error and its HTTP status |
| An exception                                         | That exception                 |

Pass a client method such as `'assets.create'` as `operation` to fail the next request it sends instead; `MockServerClient::OPERATIONS` lists them, and another name throws `InvalidArgumentException` with `sdk/invalid-argument`. For an API error code, `details` sets the error's `details` and `retryAfter` answers with a `Retry-After` of that many seconds, so you can test code that reads `$error->details` or `$error->retryAfter()`. Each queued failure fails one request, so a retry takes the next queued failure or reaches the stand-in. Failures from `failNextRequest` are used first, then responses queued with `enqueueJson` or `enqueue`, before the stand-in answers.

```php
$mock->failNextRequest('timeout', operation: 'text.classify');
// The next text->classify throws TimeoutException (sdk/timeout); the one after succeeds.

$mock->failNextRequest('upload/website-rate-limit-exceeded', retryAfter: 30);
// The next request throws RateLimitException: status 429, retryAfter() 30.0.
```

The mock's `maxRetries` defaults to 0, unlike the real client, so a queued failure fails one call; pass `maxRetries` to the constructor to test retry handling. Retry backoff takes no real time.

## Queued responses

`MockTransport` supplies FIFO responses while the real SDK validates options and serialises requests. It doesn't moderate content or store assets.

```php
<?php
require 'vendor/autoload.php';

$mock = new CalmLens\Testing\MockTransport();
$mock->enqueueJson([
    'id' => 'asset-1',
    'upload' => ['status' => 'completed'],
    'classification' => ['status' => 'completed', 'moderation' => ['status' => 'blocked']],
]);
$client = $mock->client();
$asset = $client->text->classify(['text' => 'Example comment']);

if ($asset['classification']['moderation']['status'] !== 'blocked') {
    throw new RuntimeException('Expected the comment to be blocked');
}
$request = $mock->requests()[0];
```

- `enqueueJson($body, $status, $headers)` queues an API response, and `enqueue($responseOrException)` a PSR-7 response or a transport failure.
- `requests()` returns the requests sent as `RecordedRequest` objects, like the in-memory mock's, with paths relative to the default base URLs, and `pending()` counts unused responses. An exhausted queue fails.
- Uploads must be answered with an asset (an `id`, `upload.status`, `classification.status` and `classification.moderation.status`), or they fail with `sdk/invalid-response`.

`MockTransport`'s client never retries. For retry tests with queued responses, give `ServerClient` a Guzzle client whose handler is a Guzzle `MockHandler`, or use `MockServerClient` with `maxRetries`.

## Webhook handlers

`CalmLens\Webhooks::toPayload($event)` converts an event from `$client->events->list()` or `$mock->enqueueEvent()` into the body a webhook delivers, which has the same flat fields as the event, and `Webhooks::createHeaders($payload, $deliveryId, $secret)` returns the headers of a signed delivery, using the current time unless you pass a timestamp. Use them together to call your handler as CalmLens would:

```php
$event = $mock->enqueueEvent(['type' => 'asset.allowed', 'assetId' => $asset['id']]);
$body = json_encode(CalmLens\Webhooks::toPayload($event));
$headers = CalmLens\Webhooks::createHeaders($body, 'delivery-1', $secret);
```

`Webhooks::signatureHeader($payload, $timestamp, $secret)` builds only the `x-calmlens-signature` value.

## Upload tokens

`MockServerClient::signUploadToken($apiKey, $options, $now, $nonce)` signs an upload token at a fixed clock and nonce, for tests that compare exact tokens.
