mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
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>
223 lines
9.2 KiB
PHP
223 lines
9.2 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Utopia\Messaging\Adapter;
|
|
|
|
use Appwrite\Utopia\Messaging\Adapter\Console;
|
|
use Appwrite\Utopia\Messaging\Messages\Console as ConsoleMessage;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Utopia\Cache\Adapter\None as NoCache;
|
|
use Utopia\Cache\Cache;
|
|
use Utopia\Database\Adapter\Memory;
|
|
use Utopia\Database\Database;
|
|
use Utopia\Database\Helpers\ID;
|
|
use Utopia\Database\Helpers\Permission;
|
|
use Utopia\Database\Helpers\Role;
|
|
use Utopia\Database\Validator\Authorization;
|
|
|
|
class ConsoleTest extends TestCase
|
|
{
|
|
private Database $database;
|
|
private Authorization $authorization;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->authorization = new Authorization();
|
|
$this->authorization->addRole(Role::any()->toString());
|
|
|
|
$this->database = new Database(new Memory(), new Cache(new NoCache()));
|
|
$this->database
|
|
->setAuthorization($this->authorization)
|
|
->setDatabase('alertsTests')
|
|
->setNamespace('alerts_' . \uniqid());
|
|
|
|
$this->database->create();
|
|
$this->database->createCollection('alerts', [], [], [Permission::create(Role::any()), Permission::read(Role::any())], false);
|
|
$this->database->createAttribute('alerts', 'messageId', Database::VAR_STRING, 255, false);
|
|
$this->database->createAttribute('alerts', 'type', Database::VAR_STRING, 64, false, 'info');
|
|
$this->database->createAttribute('alerts', 'channel', Database::VAR_STRING, 64, true);
|
|
$this->database->createAttribute('alerts', 'userId', Database::VAR_STRING, 255, false);
|
|
$this->database->createAttribute('alerts', 'teamId', Database::VAR_STRING, 255, false);
|
|
$this->database->createAttribute('alerts', 'projectId', Database::VAR_STRING, 255, false);
|
|
$this->database->createAttribute('alerts', 'title', Database::VAR_STRING, 256, true);
|
|
$this->database->createAttribute('alerts', 'body', Database::VAR_STRING, 16384, true);
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
$this->authorization->cleanRoles();
|
|
$this->authorization->addRole(Role::any()->toString());
|
|
}
|
|
|
|
/**
|
|
* Mirrors the per-recipient `$id` derivation in
|
|
* Appwrite\Utopia\Messaging\Adapter\Console::process().
|
|
*/
|
|
private function alertId(string $messageId, string $userId = '', string $teamId = ''): string
|
|
{
|
|
$key = $userId !== '' ? 'user:' . $userId : 'team:' . $teamId;
|
|
return $messageId . '_' . \substr(\md5($key), 0, 8);
|
|
}
|
|
|
|
public function testWritesAlertWithCorrectSchema(): void
|
|
{
|
|
$message = new ConsoleMessage(
|
|
recipients: [['userId' => 'user-1']],
|
|
title: 'Hello',
|
|
body: 'World',
|
|
type: 'info',
|
|
messageId: ID::custom('msg-aaa'),
|
|
projectId: 'project-1',
|
|
);
|
|
|
|
$adapter = new Console($this->database);
|
|
$result = $adapter->send($message);
|
|
|
|
$this->assertSame(1, $result['deliveredTo']);
|
|
|
|
$stored = $this->database->getDocument('alerts', $this->alertId('msg-aaa', userId: 'user-1'));
|
|
$this->assertFalse($stored->isEmpty());
|
|
$this->assertSame('msg-aaa', $stored->getAttribute('messageId'));
|
|
$this->assertSame('console', $stored->getAttribute('channel'));
|
|
$this->assertSame('user-1', $stored->getAttribute('userId'));
|
|
$this->assertSame('project-1', $stored->getAttribute('projectId'));
|
|
$this->assertSame('Hello', $stored->getAttribute('title'));
|
|
$this->assertSame('World', $stored->getAttribute('body'));
|
|
$this->assertSame('info', $stored->getAttribute('type'));
|
|
}
|
|
|
|
public function testUserPermissionsScopedToRecipient(): void
|
|
{
|
|
$message = new ConsoleMessage(
|
|
recipients: [['userId' => 'user-2']],
|
|
title: 'Title',
|
|
body: 'Body',
|
|
messageId: ID::custom('msg-perms-user'),
|
|
);
|
|
|
|
(new Console($this->database))->send($message);
|
|
|
|
$stored = $this->database->getDocument('alerts', $this->alertId('msg-perms-user', userId: 'user-2'));
|
|
$permissions = $stored->getPermissions();
|
|
|
|
$this->assertContains(Permission::read(Role::user('user-2')), $permissions);
|
|
$this->assertContains(Permission::update(Role::user('user-2')), $permissions);
|
|
$this->assertContains(Permission::delete(Role::user('user-2')), $permissions);
|
|
}
|
|
|
|
public function testTeamRecipientGrantsTeamReadAndOwnerWrite(): void
|
|
{
|
|
$message = new ConsoleMessage(
|
|
recipients: [['teamId' => 'team-9']],
|
|
title: 'Heads up',
|
|
body: '...',
|
|
messageId: ID::custom('msg-team'),
|
|
);
|
|
|
|
(new Console($this->database))->send($message);
|
|
|
|
$stored = $this->database->getDocument('alerts', $this->alertId('msg-team', teamId: 'team-9'));
|
|
$permissions = $stored->getPermissions();
|
|
|
|
$this->assertContains(Permission::read(Role::team('team-9')), $permissions);
|
|
$this->assertContains(Permission::update(Role::team('team-9', 'owner')), $permissions);
|
|
$this->assertContains(Permission::delete(Role::team('team-9', 'owner')), $permissions);
|
|
}
|
|
|
|
public function testMultiRecipientWithSameMessageIdGeneratesDistinctIds(): void
|
|
{
|
|
$message = new ConsoleMessage(
|
|
recipients: [
|
|
['userId' => 'a'],
|
|
['userId' => 'b'],
|
|
],
|
|
title: 'Heads up',
|
|
body: 'multi',
|
|
messageId: ID::custom('same-msg'),
|
|
);
|
|
|
|
$adapter = new Console($this->database);
|
|
$result = $adapter->send($message);
|
|
|
|
$this->assertSame(2, $result['deliveredTo']);
|
|
|
|
$idA = $this->alertId('same-msg', userId: 'a');
|
|
$idB = $this->alertId('same-msg', userId: 'b');
|
|
|
|
$this->assertNotSame($idA, $idB, 'recipient suffixes must be distinct');
|
|
|
|
$rowA = $this->database->getDocument('alerts', $idA);
|
|
$rowB = $this->database->getDocument('alerts', $idB);
|
|
$this->assertFalse($rowA->isEmpty(), 'first recipient row must exist');
|
|
$this->assertFalse($rowB->isEmpty(), 'second recipient row must exist');
|
|
|
|
$this->assertSame('same-msg', $rowA->getAttribute('messageId'));
|
|
$this->assertSame('same-msg', $rowB->getAttribute('messageId'));
|
|
$this->assertSame('a', $rowA->getAttribute('userId'));
|
|
$this->assertSame('b', $rowB->getAttribute('userId'));
|
|
|
|
// $id values must be `messageId_<8-hex>` and the suffixes differ.
|
|
$this->assertMatchesRegularExpression('/^same-msg_[0-9a-f]{8}$/', $idA);
|
|
$this->assertMatchesRegularExpression('/^same-msg_[0-9a-f]{8}$/', $idB);
|
|
}
|
|
|
|
public function testRejectsForeignMessageType(): void
|
|
{
|
|
$adapter = new Console($this->database);
|
|
$this->expectException(\Exception::class);
|
|
$this->expectExceptionMessage('Invalid message type.');
|
|
|
|
// ConsoleMessage extends nothing — pass an unrelated Message implementation
|
|
$adapter->send(new \Appwrite\Utopia\Messaging\Messages\Webhook(urls: ['https://example.test'], payload: []));
|
|
}
|
|
|
|
/**
|
|
* Reviewer C4: a `DuplicateException` thrown by `createDocument` must be
|
|
* treated as a SUCCESSFUL delivery, not a failure. The adapter previously
|
|
* lumped Duplicate into the generic Throwable catch, which surfaced as a
|
|
* per-recipient `error` and caused the worker to throw — re-queueing the
|
|
* notification and never marking the duplicate as delivered.
|
|
*/
|
|
public function testConsoleAdapterTreatsDuplicateAsDelivered(): void
|
|
{
|
|
$userId = 'user-dup';
|
|
$messageId = 'msg-dup';
|
|
$documentId = $this->alertId($messageId, userId: $userId);
|
|
|
|
// Pre-insert an alert with the SAME id the adapter will compute. The
|
|
// adapter's createDocument will hit the primary-key DuplicateException
|
|
// and must treat it as a successful (idempotent) send.
|
|
$this->database->createDocument('alerts', new \Utopia\Database\Document([
|
|
'$id' => $documentId,
|
|
'$permissions' => [Permission::read(Role::any())],
|
|
'messageId' => $messageId,
|
|
'channel' => 'console',
|
|
'userId' => $userId,
|
|
'title' => 'pre-existing',
|
|
'body' => 'pre-existing',
|
|
]));
|
|
|
|
$message = new ConsoleMessage(
|
|
recipients: [['userId' => $userId]],
|
|
title: 'Same alert resent',
|
|
body: 'b',
|
|
messageId: ID::custom($messageId),
|
|
projectId: 'project-x',
|
|
);
|
|
|
|
$adapter = new Console($this->database);
|
|
$result = $adapter->send($message);
|
|
|
|
$this->assertSame(1, $result['deliveredTo'], 'duplicate must count as a successful delivery');
|
|
$this->assertCount(1, $result['results']);
|
|
$this->assertSame('success', $result['results'][0]['status'] ?? '', 'duplicate must report success status');
|
|
$this->assertSame('', $result['results'][0]['error'] ?? 'unset', 'duplicate must not surface a per-recipient error');
|
|
|
|
// Still exactly ONE row — the pre-existing one. The adapter must not
|
|
// overwrite it nor create a sibling.
|
|
$rows = $this->database->find('alerts');
|
|
$this->assertCount(1, $rows);
|
|
$this->assertSame($documentId, $rows[0]->getId());
|
|
$this->assertSame('pre-existing', $rows[0]->getAttribute('title'), 'duplicate path must not overwrite existing row');
|
|
}
|
|
}
|