Files
Jake BarnbyandClaude Opus 4.7 ac87c0e2d6 test(notifications): add regression and happy-path coverage for review-fix critical gaps
Locks the bug-fix invariants from PR #12195's last review pass and rounds out
worker channel coverage:

C1 testEmailSendFailureDoesNotPersistAlert — SMTP throw must NOT leave a dedup
   row behind, retry must deliver and persist exactly once.
C2 testNotificationEventResetClearsAllState — reset() drops every state-bearing
   field including preview (regression: missed in original reset body).
C3 testWebhookSendAlertResetsBetweenCalls — Webhooks::sendAlert must reset()
   the DI-shared Notification event so two paused-webhook alerts in one worker
   pass do not bleed recipients/subject/body into each other.
C4 testConsoleAdapterTreatsDuplicateAsDelivered — Duplicate on createDocument
   must surface as a successful idempotent send, not a per-recipient error.
M7 testTrackingPixelRejectsJwtWithoutPurposeClaim — Track endpoint silently
   ignores JWTs missing or with the wrong purpose claim (defends against
   replaying session/reset JWTs to mark alerts read).

Worker happy-path tests: testEmailChannelHappyPath, testConsoleChannelHappyPath,
testWebhookChannelHappyPath cover the full per-channel dispatch contract end
to end, including HMAC signing for webhooks and the tracking pixel injection
+ post-send persistence for email.

Also extracts CapturingWebhook into its own PSR-4 file so reused across tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 18:02:12 +12:00

167 lines
5.7 KiB
PHP

<?php
namespace Tests\Unit\Utopia\Messaging\Adapter;
use Appwrite\Utopia\Messaging\Messages\Webhook as WebhookMessage;
use PHPUnit\Framework\TestCase;
class WebhookTest extends TestCase
{
public function testPostsExpectedBodyShape(): void
{
$adapter = new CapturingWebhook();
$payload = [
'subject' => 'Hello',
'body' => 'World',
'recipient' => 'ops@example.test',
'metadata' => ['foo' => 'bar'],
];
$message = new WebhookMessage(
urls: ['https://hooks.example.test/notify'],
payload: $payload,
);
$result = $adapter->send($message);
$this->assertSame(1, $result['deliveredTo']);
$this->assertCount(1, $adapter->captured);
$request = $adapter->captured[0];
$this->assertSame('POST', $request['method']);
$this->assertSame('https://hooks.example.test/notify', $request['url']);
$sent = \json_decode($request['body'], true);
$this->assertSame($payload, $sent);
$headerLine = \implode("\n", $request['headers']);
$this->assertStringContainsString('Content-Type: application/json', $headerLine);
$this->assertStringContainsString('X-Appwrite-Webhook-Timestamp:', $headerLine);
}
public function testSigningSecretProducesHmacSha256Signature(): void
{
$adapter = new CapturingWebhook();
$payload = ['subject' => 'Signed', 'body' => 'B'];
$secret = 'super-secret';
$message = new WebhookMessage(
urls: ['https://hooks.example.test/signed'],
payload: $payload,
signingSecret: $secret,
);
$adapter->send($message);
$headers = $adapter->captured[0]['headers'];
$body = $adapter->captured[0]['body'];
$timestamp = null;
$signature = null;
foreach ($headers as $header) {
if (\str_starts_with($header, 'X-Appwrite-Webhook-Timestamp: ')) {
$timestamp = \substr($header, \strlen('X-Appwrite-Webhook-Timestamp: '));
} elseif (\str_starts_with($header, 'X-Appwrite-Webhook-Signature: ')) {
$signature = \substr($header, \strlen('X-Appwrite-Webhook-Signature: '));
}
}
$this->assertNotNull($timestamp, 'timestamp header must be present');
$this->assertNotNull($signature, 'signature header must be present when secret is set');
$this->assertStringStartsWith('sha256=', $signature);
$expected = 'sha256=' . \hash_hmac('sha256', $timestamp . '.' . $body, $secret);
$this->assertSame($expected, $signature);
}
public function testNoSecretLeavesPayloadUnsigned(): void
{
$adapter = new CapturingWebhook();
$message = new WebhookMessage(
urls: ['https://hooks.example.test/unsigned'],
payload: ['x' => 1],
signingSecret: null,
);
$adapter->send($message);
$headerLine = \implode("\n", $adapter->captured[0]['headers']);
$this->assertStringNotContainsString('X-Appwrite-Webhook-Signature', $headerLine);
}
public function testEmptySecretIsTreatedAsUnsigned(): void
{
$adapter = new CapturingWebhook();
$message = new WebhookMessage(
urls: ['https://hooks.example.test/empty-secret'],
payload: ['x' => 1],
signingSecret: '',
);
$adapter->send($message);
$headerLine = \implode("\n", $adapter->captured[0]['headers']);
$this->assertStringNotContainsString('X-Appwrite-Webhook-Signature', $headerLine);
}
public function testTwoXxIsSuccess(): void
{
$adapter = new CapturingWebhook();
$adapter->response = ['statusCode' => 204, 'response' => '', 'error' => null];
$message = new WebhookMessage(urls: ['https://hooks.example.test/ok'], payload: []);
$result = $adapter->send($message);
$this->assertSame(1, $result['deliveredTo']);
}
public function testNonTwoXxSurfacesError(): void
{
$adapter = new CapturingWebhook();
$adapter->response = ['statusCode' => 503, 'response' => 'Server', 'error' => null];
$message = new WebhookMessage(urls: ['https://hooks.example.test/fail'], payload: []);
$result = $adapter->send($message);
$this->assertSame(0, $result['deliveredTo']);
$error = $result['results'][0]['error'] ?? null;
$this->assertSame('HTTP 503', $error);
}
public function testCurlErrorSurfacesAsResultError(): void
{
$adapter = new CapturingWebhook();
$adapter->response = ['statusCode' => 0, 'response' => null, 'error' => 'connection refused'];
$message = new WebhookMessage(urls: ['https://hooks.example.test/down'], payload: []);
$result = $adapter->send($message);
$this->assertSame(0, $result['deliveredTo']);
$this->assertSame('connection refused', $result['results'][0]['error']);
}
public function testCustomHeadersForwarded(): void
{
$adapter = new CapturingWebhook();
$message = new WebhookMessage(
urls: ['https://hooks.example.test/with-headers'],
payload: [],
headers: ['X-Custom' => 'value'],
);
$adapter->send($message);
$headerLine = \implode("\n", $adapter->captured[0]['headers']);
$this->assertStringContainsString('X-Custom: value', $headerLine);
}
public function testRejectsForeignMessageType(): void
{
$adapter = new CapturingWebhook();
$this->expectException(\Exception::class);
$this->expectExceptionMessage('Invalid message type.');
$adapter->send(new \Appwrite\Utopia\Messaging\Messages\Console(recipients: [['userId' => 'u']], title: 't', body: 'b'));
}
}