PHP testing
Use CalmLens\Testing\MockServerClient to test your PHP SDK 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
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.blockedOptions
The constructor takes:
| Argument | Effect |
|---|---|
moderate | Decides each asset the mock moderates. See 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:
$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, andtext: the bytes as text for a plain-text upload such astext->classify. Both arenullwhen there is no content, such as for a website.trigger: why the asset is moderated:upload,retry(afterassets->retry),rerun(afterassets->explainwith'rerun' => true) orcomplete(see below).queued:truewhen CalmLens would queue the work rather than answer with the decision: queued uploads, retries, reruns andcomplete.
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
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 for an unknown asset and InvalidArgumentException with sdk/invalid-argument for an asset that isn't processing:
$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 withvalidation/<issue>and the field indetails['path']. Error responses carry the API's body, with arequestIdmatching theX-Request-Idheader. - 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 asasset_5Gm2Rk8XqT3wLp9VbN4c. For predictable IDs, passcreateId, a callable that receives the prefix (asset,fldr,hl,hle,lens,evtorrev) 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), and its current status gets 409 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 whatever the key. It refuses age-restricted assets requested without
'viewerClaims' => ['ageConfirmed' => true]with 403 viewer/age-confirmation-required, whatever the key. Without an assets:download grant, it also refuses private assets with 403 asset/api-key-missing-permission. - CDN responses carry the stored
Content-TypeandCache-Controlas the CDN sets it (publiconly when anyone may see the asset, otherwiseprivate;max-age=31536000, immutablefor images andmax-age=3600for other content;private, no-storewhen the request carries viewer claims), plusCache-Tag,X-Content-Type-Options: nosniff, a sandboxingContent-Security-Policyand, for images, anETagandExpires. A classified image gets a ThumbHash, which theplaceholderresource serves as a PNG and which goes with the content.
Events and reviewer decisions
Every upload adds asset.uploaded to the events feed, then asset.classified and the automated asset.<status> (with a decided-by: automated fact) once it is decided, or asset.failed. Like the API, deleting an asset, removing its content and retrying it add asset.deleted, asset.content-removed and 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. The moderation report stays as it was. An unknown asset throws NotFoundException with 404 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:
methodandpath, 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, ornullfor 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.
$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.
$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
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, andenqueue($responseOrException)a PSR-7 response or a transport failure.requests()returns the requests sent asRecordedRequestobjects, like the in-memory mock's, with paths relative to the default base URLs, andpending()counts unused responses. An exhausted queue fails.- Uploads must be answered with an asset (an
id,upload.status,classification.statusandclassification.moderation.status), or they fail withsdk/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:
$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.