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.blocked

Options

The constructor takes:

ArgumentEffect
moderateDecides each asset the mock moderates. See Moderation decisions.
apiKeyA key from MockServerClient::createApiKey($grants). The default key has no grants.
now, createIdDeterministic 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, uploadTimeoutClient 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, 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
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 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), 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-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 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:

  • 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.

$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:

$failureFails 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 exceptionThat 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, 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:

$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.

Last updated

Signing in

Signing out

Cookies on CalmLens

We use essential cookies for sign-in and security. With your permission, functional cookies remember your preferences and Google Analytics measures website use and signups. Optional ad measurement cookies link Google ad clicks to signups. Personalised advertising stays off. Manage cookies