}> */
public array $dispatched = [];
/** @var array must be present');
$this->assertStringContainsString('/v1/notifications/logos/appwrite?jwt=', $body);
$this->assertStringContainsString('alt="Appwrite logo"', $body);
$this->assertStringContainsString('width="120"', $body);
$this->assertStringContainsString('height="28"', $body);
$this->assertStringNotContainsString('display:none', $body);
$this->assertStringContainsString('http://api.example.test/v1/notifications/logos/appwrite?jwt=', \html_entity_decode($body));
$this->assertStringNotContainsString('console.example.test/v1/notifications/logos/appwrite', \html_entity_decode($body));
\preg_match('/logos\/appwrite\?jwt=([^"&]+)/', \html_entity_decode($body), $matches);
$this->assertNotEmpty($matches[1] ?? '');
$claims = (new JWT('test-key-32bytes-min-aaaaaaaaaaaaaa', 'HS256', NOTIFICATION_TRACKING_JWT_TTL, 0))
->decode(\urldecode($matches[1]));
$this->assertSame(\md5('logo-key'), $claims['messageId'] ?? null);
$this->assertSame(NOTIFICATION_TYPE_EMAIL, $claims['channel'] ?? null);
$this->assertSame($this->recipientHash(
NOTIFICATION_TYPE_EMAIL,
'user@example.test',
RESOURCE_TYPE_USERS,
'user-7',
'user-7-internal',
), $claims['recipientHash'] ?? null);
$this->assertSame('project-x', $claims['projectId'] ?? null);
$this->assertSame('project-internal-x', $claims['projectInternalId'] ?? null);
$this->assertSame('notification_track', $claims['purpose'] ?? null);
$this->assertArrayNotHasKey('alertId', $claims);
// The logo must sit BEFORE the last
.
$lastBodyClose = \strripos($body, '');
$logoPosition = \strripos($body, '');
$this->assertNotFalse($logoPosition);
$this->assertLessThan($lastBodyClose, $logoPosition, 'logo must be spliced before the final ');
}
public function testTrackingLogoDoesNotUseOpenSslKeyFallback(): void
{
$spy = new SpyEmailAdapter();
$this->registry->set('smtp', static fn () => $spy);
$previousSmtpHost = \getenv('_APP_SMTP_HOST');
$previousTrackingSecret = \getenv('_APP_NOTIFICATIONS_TRACKING_SECRET');
$previousOpenSslKey = \getenv('_APP_OPENSSL_KEY_V1');
\putenv('_APP_SMTP_HOST=spy.smtp.test');
\putenv('_APP_NOTIFICATIONS_TRACKING_SECRET=');
\putenv('_APP_OPENSSL_KEY_V1=openssl-key-must-not-sign-tracking');
try {
$worker = new Notifications();
$payload = [
'project' => ['$id' => 'project-x'],
'recipients' => [
$this->userRecipient('user@example.test', NOTIFICATION_TYPE_EMAIL, 'user-8'),
],
'subject' => 'Heads up',
'body' => 'plain body',
'deduplicationKey' => 'logo-key-no-tracking-secret',
];
$worker->action($this->buildMessage($payload), $this->project, $this->registry, $this->database, $this->log);
} finally {
\putenv($previousSmtpHost === false ? '_APP_SMTP_HOST' : '_APP_SMTP_HOST=' . $previousSmtpHost);
\putenv($previousTrackingSecret === false ? '_APP_NOTIFICATIONS_TRACKING_SECRET' : '_APP_NOTIFICATIONS_TRACKING_SECRET=' . $previousTrackingSecret);
\putenv($previousOpenSslKey === false ? '_APP_OPENSSL_KEY_V1' : '_APP_OPENSSL_KEY_V1=' . $previousOpenSslKey);
}
$this->assertNotNull($spy->captured, 'SpyEmailAdapter must capture exactly one EmailMessage');
$this->assertStringNotContainsString('/v1/notifications/logos/appwrite?jwt=', $spy->captured->getContent());
}
public function testPersistAlertReturnsExistingAlertIdOnDuplicate(): void
{
$spy = new SpyEmailAdapter();
$this->registry->set('smtp', static fn () => $spy);
$previousSmtpHost = \getenv('_APP_SMTP_HOST');
\putenv('_APP_SMTP_HOST=spy.smtp.test');
try {
$worker = new CountingPersistAlertNotifications();
$payload = [
'project' => ['$id' => 'project-x'],
'recipients' => [
$this->userRecipient('user@example.test', NOTIFICATION_TYPE_EMAIL, 'user-7'),
],
'subject' => 'Heads up',
'body' => 'b',
'deduplicationKey' => 'persist-dup',
];
// First dispatch: writes a row through the action loop and
// returns the deterministic alertId.
$worker->action($this->buildMessage($payload), $this->project, $this->registry, $this->database, $this->log);
$this->assertSame(1, $worker->persistAlertCalls);
$firstAlertId = $worker->persistedIds[0];
$messageId = \md5('persist-dup');
$recipient = [
'address' => 'user@example.test',
'channel' => NOTIFICATION_TYPE_EMAIL,
'resourceType' => RESOURCE_TYPE_USERS,
'resourceId' => 'user-7',
'resourceInternalId' => 'user-7-internal',
'parentResourceType' => RESOURCE_TYPE_PROJECTS,
'parentResourceId' => 'project-x',
'parentResourceInternalId' => 'project-internal-x',
];
// Second invocation with the SAME messageId/recipient. The
// action loop's alreadyDelivered() check short-circuits before
// persistAlert, so call persistAlert directly to actually hit
// the duplicate branch. The deterministic $id collides on the
// primary key -> DuplicateException -> branch returns the
// existing alertId without throwing.
$reflection = new \ReflectionMethod($worker, 'persistAlert');
$secondAlertId = $reflection->invoke($worker, $this->database, $messageId, $recipient, $payload, $this->project);
$this->assertSame($firstAlertId, $secondAlertId, 'duplicate persist must return the existing alertId');
// Third write: bypass the deterministic $id path and use a
// distinct $id with the same recipient tuple. The
// `_key_recipient` UNIQUE composite must reject it, proving
// the unique-index (not just primary-key) is what backstops the
// duplicate-handling branch.
$sameTupleDoc = new Document([
'$id' => 'sibling-id-' . \uniqid(),
'$permissions' => [Permission::read(Role::any())],
'messageId' => $messageId,
'recipientHash' => $this->recipientHash(NOTIFICATION_TYPE_EMAIL, 'user@example.test', RESOURCE_TYPE_USERS, 'user-7', 'user-7-internal'),
'channel' => NOTIFICATION_TYPE_EMAIL,
'projectId' => 'project-x',
'projectInternalId' => 'project-internal-x',
'resourceType' => RESOURCE_TYPE_USERS,
'resourceId' => 'user-7',
'resourceInternalId' => 'user-7-internal',
'parentResourceType' => RESOURCE_TYPE_PROJECTS,
'parentResourceId' => 'project-x',
'parentResourceInternalId' => 'project-internal-x',
'title' => 'sibling',
'body' => 'sibling',
'read' => false,
]);
$threw = false;
try {
$this->database->createDocument('alerts', $sameTupleDoc);
} catch (\Utopia\Database\Exception\Duplicate) {
$threw = true;
}
$this->assertTrue($threw, 'unique-index `_key_recipient` must reject a second row sharing the recipient tuple');
$rows = $this->database->find('alerts', [
Query::equal('messageId', [$messageId]),
]);
$this->assertCount(1, $rows, 'unique-index must prevent a second row from being persisted');
} finally {
\putenv($previousSmtpHost === false ? '_APP_SMTP_HOST' : '_APP_SMTP_HOST=' . $previousSmtpHost);
}
}
public function testPersistAlertReturnsAlertIdAndStoresResource(): void
{
$spy = new SpyEmailAdapter();
$this->registry->set('smtp', static fn () => $spy);
$previousSmtpHost = \getenv('_APP_SMTP_HOST');
\putenv('_APP_SMTP_HOST=spy.smtp.test');
try {
$worker = new CountingPersistAlertNotifications();
$payload = [
'project' => ['$id' => 'project-x'],
'recipients' => [
$this->userRecipient('user@example.test', NOTIFICATION_TYPE_EMAIL, 'user-7'),
],
'subject' => 'Heads up',
'body' => 'b',
'deduplicationKey' => 'persist-email',
];
$worker->action($this->buildMessage($payload), $this->project, $this->registry, $this->database, $this->log);
} finally {
\putenv($previousSmtpHost === false ? '_APP_SMTP_HOST' : '_APP_SMTP_HOST=' . $previousSmtpHost);
}
$this->assertSame(1, $worker->persistAlertCalls, 'email channel must persist exactly once');
$this->assertCount(1, $worker->persistedIds);
$alertId = $worker->persistedIds[0];
$row = $this->database->getDocument('alerts', $alertId);
$this->assertFalse($row->isEmpty(), 'persistAlert must return an id resolvable via getDocument');
$this->assertSame(RESOURCE_TYPE_USERS, $row->getAttribute('resourceType'));
$this->assertSame('user-7', $row->getAttribute('resourceId'));
$this->assertSame('user-7-internal', $row->getAttribute('resourceInternalId'));
$this->assertFalse($row->getAttribute('read'), 'new alerts default to unread');
$this->assertSame(\md5('persist-email'), $row->getAttribute('messageId'));
}
/**
* Reviewer C1: SMTP failure must NOT orphan a dedup row.
*
* Order in the worker matters: persist BEFORE send leaves a poisoned
* `messageId` row that the next retry will dedup-hit and never deliver.
* The fix is to persist only after a successful adapter send. A retry
* with the same payload must therefore actually deliver and produce
* exactly one alert row.
*/
public function testEmailSendFailureDoesNotPersistAlert(): void
{
$failing = new SpyEmailAdapter();
$failing->throwOnSend = true;
$this->registry->set('smtp', static fn () => $failing);
$previousSmtpHost = \getenv('_APP_SMTP_HOST');
\putenv('_APP_SMTP_HOST=spy.smtp.test');
$payload = [
'project' => ['$id' => 'project-x'],
'recipients' => [
$this->userRecipient('user@example.test', NOTIFICATION_TYPE_EMAIL, 'user-9'),
],
'subject' => 'Subj',
'body' => 'Body',
'deduplicationKey' => 'smtp-fail-key',
];
$messageId = \md5('smtp-fail-key');
try {
$worker = new Notifications();
$threw = false;
try {
$worker->action($this->buildMessage($payload), $this->project, $this->registry, $this->database, $this->log);
} catch (\Throwable $error) {
$threw = true;
$this->assertStringContainsString('SMTP unavailable', $error->getMessage());
}
$this->assertTrue($threw, 'SMTP failure must propagate so the queue retries');
$this->assertSame(1, $failing->sendCount, 'adapter must have been invoked exactly once');
// Critical: no orphan dedup row. If there is one, the retry below
// will short-circuit and the user never gets the email.
$orphans = $this->database->find('alerts', [
Query::equal('messageId', [$messageId]),
]);
$this->assertCount(0, $orphans, 'failed SMTP send must not leave a dedup row behind');
// Retry with a working adapter using the same payload — must deliver
// AND persist exactly one alert row.
$working = new SpyEmailAdapter();
$this->registry->set('smtp', static fn () => $working);
$retryWorker = new Notifications();
$retryWorker->action($this->buildMessage($payload), $this->project, $this->registry, $this->database, $this->log);
$this->assertSame(1, $working->sendCount, 'retry must invoke the working adapter');
$rows = $this->database->find('alerts', [
Query::equal('messageId', [$messageId]),
]);
$this->assertCount(1, $rows, 'retry must persist exactly one alert row');
$this->assertSame('user-9', $rows[0]->getAttribute('resourceId'));
$this->assertFalse($rows[0]->getAttribute('read'));
} finally {
\putenv($previousSmtpHost === false ? '_APP_SMTP_HOST' : '_APP_SMTP_HOST=' . $previousSmtpHost);
}
}
public function testEmailFailureDoesNotBlockConsoleRecipient(): void
{
$failing = new SpyEmailAdapter();
$failing->throwOnSend = true;
$this->registry->set('smtp', static fn () => $failing);
$previousSmtpHost = \getenv('_APP_SMTP_HOST');
\putenv('_APP_SMTP_HOST=spy.smtp.test');
$payload = [
'project' => ['$id' => 'project-x', '$sequence' => 'project-internal-x'],
'recipients' => [
$this->userRecipient('user@example.test', NOTIFICATION_TYPE_EMAIL, 'user-9'),
$this->userRecipient('user-9', NOTIFICATION_TYPE_CONSOLE, 'user-9'),
],
'subject' => 'Subj',
'body' => 'Body',
'deduplicationKey' => 'smtp-fail-console-key',
];
$messageId = \md5('smtp-fail-console-key');
try {
$worker = new Notifications();
$threw = false;
try {
$worker->action($this->buildMessage($payload), $this->project, $this->registry, $this->database, $this->log);
} catch (\Throwable $error) {
$threw = true;
$this->assertStringContainsString('SMTP unavailable', $error->getMessage());
}
$this->assertTrue($threw, 'SMTP failure must still propagate so the email recipient is retried');
$consoleRows = $this->database->find('alerts', [
Query::equal('messageId', [$messageId]),
Query::equal('channel', [NOTIFICATION_TYPE_CONSOLE]),
]);
$this->assertCount(1, $consoleRows, 'console recipient must be persisted even when email fails first');
$this->assertSame('project-x', $consoleRows[0]->getAttribute('projectId'));
$this->assertSame('project-internal-x', $consoleRows[0]->getAttribute('projectInternalId'));
$emailRows = $this->database->find('alerts', [
Query::equal('messageId', [$messageId]),
Query::equal('channel', [NOTIFICATION_TYPE_EMAIL]),
]);
$this->assertCount(0, $emailRows, 'failed email recipient must not leave an orphan dedup row');
$working = new SpyEmailAdapter();
$this->registry->set('smtp', static fn () => $working);
$retryWorker = new Notifications();
$retryWorker->action($this->buildMessage($payload), $this->project, $this->registry, $this->database, $this->log);
$this->assertSame(1, $working->sendCount, 'retry must still deliver the email recipient');
$rows = $this->database->find('alerts', [
Query::equal('messageId', [$messageId]),
]);
$this->assertCount(2, $rows, 'retry must add email without duplicating the already-delivered console alert');
} finally {
\putenv($previousSmtpHost === false ? '_APP_SMTP_HOST' : '_APP_SMTP_HOST=' . $previousSmtpHost);
}
}
/**
* Worker happy-path: email channel.
*
* Asserts the SMTP adapter is invoked once with the expected
* to/subject/body, the rendered body carries the tracking logo before
* ``, and an alert row is persisted AFTER the send returns
* successfully (see C1: persist-after-send invariant).
*/
public function testEmailChannelHappyPath(): void
{
$spy = new SpyEmailAdapter();
$this->registry->set('smtp', static fn () => $spy);
$previousSmtpHost = \getenv('_APP_SMTP_HOST');
$previousTrackingSecret = \getenv('_APP_NOTIFICATIONS_TRACKING_SECRET');
\putenv('_APP_SMTP_HOST=spy.smtp.test');
\putenv('_APP_NOTIFICATIONS_TRACKING_SECRET=test-key-32bytes-min-aaaaaaaaaaaaaa');
try {
$worker = new CountingPersistAlertNotifications();
$payload = [
'project' => ['$id' => 'project-x'],
'recipients' => [
$this->userRecipient('happy@example.test', NOTIFICATION_TYPE_EMAIL, 'user-happy'),
],
'subject' => 'Welcome aboard',
'body' => 'plain body',
'deduplicationKey' => 'happy-email',
];
$worker->action($this->buildMessage($payload), $this->project, $this->registry, $this->database, $this->log);
} finally {
\putenv($previousSmtpHost === false ? '_APP_SMTP_HOST' : '_APP_SMTP_HOST=' . $previousSmtpHost);
\putenv($previousTrackingSecret === false ? '_APP_NOTIFICATIONS_TRACKING_SECRET' : '_APP_NOTIFICATIONS_TRACKING_SECRET=' . $previousTrackingSecret);
}
$this->assertSame(1, $spy->sendCount, 'SMTP send must be invoked exactly once');
$this->assertNotNull($spy->captured);
$message = $spy->captured;
$this->assertSame('happy@example.test', $message->getTo()[0]['email'] ?? '');
$this->assertSame('Welcome aboard', $message->getSubject());
$body = $message->getContent();
$this->assertStringContainsString('
assertStringContainsString('/v1/notifications/logos/appwrite?jwt=', $body);
$closing = \strripos($body, '');
$logo = \strripos($body, '
');
$this->assertSame(1, $worker->persistAlertCalls, 'email channel must persist exactly once after a successful send');
$messageId = \md5('happy-email');
$rows = $this->database->find('alerts', [
Query::equal('messageId', [$messageId]),
]);
$this->assertCount(1, $rows);
$row = $rows[0];
$this->assertSame('user-happy', $row->getAttribute('resourceId'));
$this->assertSame(NOTIFICATION_TYPE_EMAIL, $row->getAttribute('channel'));
$this->assertFalse($row->getAttribute('read'));
// dispatchEmail's returned alertId must match the row $id.
$this->assertSame($worker->persistedIds[0], $row->getId());
$this->assertLessThanOrEqual(36, \strlen($row->getId()), 'alert ids must pass the UID route validator');
}
/**
* Worker happy-path: console channel.
*
* The Console adapter writes the alert directly; the action loop must
* NOT call `persistAlert` for console recipients. Permissions must grant
* the recipient resource read/update/delete access.
*/
public function testConsoleChannelHappyPath(): void
{
$worker = new CountingPersistAlertNotifications();
$payload = [
'project' => ['$id' => 'project-x'],
'recipients' => [
[
'address' => 'console-recipient',
'channel' => NOTIFICATION_TYPE_CONSOLE,
'resourceType' => RESOURCE_TYPE_TEAMS,
'resourceId' => 't1',
'resourceInternalId' => 't1-internal',
'parentResourceType' => RESOURCE_TYPE_PROJECTS,
'parentResourceId' => 'project-x',
'parentResourceInternalId' => 'project-internal-x',
],
],
'subject' => 'Heads up',
'body' => 'console body',
'deduplicationKey' => 'happy-console',
];
$worker->action($this->buildMessage($payload), $this->project, $this->registry, $this->database, $this->log);
$rows = $this->database->find('alerts', [
Query::equal('channel', ['console']),
]);
$this->assertCount(1, $rows, 'console adapter must write exactly one alert');
$row = $rows[0];
$this->assertSame(RESOURCE_TYPE_TEAMS, $row->getAttribute('resourceType'));
$this->assertSame('t1', $row->getAttribute('resourceId'));
$this->assertSame('t1-internal', $row->getAttribute('resourceInternalId'));
$this->assertSame(NOTIFICATION_TYPE_CONSOLE, $row->getAttribute('channel'));
$this->assertFalse($row->getAttribute('read'));
$messageId = \md5('happy-console');
$expectedId = $this->alertId($messageId, NOTIFICATION_TYPE_CONSOLE, 'console-recipient', RESOURCE_TYPE_TEAMS, 't1');
$this->assertSame($expectedId, $row->getId(), 'row $id must match adapter suffix scheme');
$this->assertLessThanOrEqual(36, \strlen($row->getId()), 'alert ids must pass the UID route validator');
$permissions = $row->getPermissions();
$this->assertContains(Permission::read(Role::team('t1')), $permissions);
$this->assertContains(Permission::update(Role::team('t1', 'owner')), $permissions);
$this->assertContains(Permission::delete(Role::team('t1', 'owner')), $permissions);
$this->assertSame(0, $worker->persistAlertCalls, 'console channel must NOT trigger action-loop persistAlert');
}
public function testConsoleChannelUsesPreviewBodyInsteadOfRenderedEmailHtml(): void
{
$worker = new CountingPersistAlertNotifications();
$payload = [
'project' => ['$id' => 'project-x'],
'recipients' => [
$this->userRecipient('user-preview', NOTIFICATION_TYPE_CONSOLE, 'user-preview'),
],
'subject' => 'Webhook {{name}} paused',
'preview' => 'Plain alert for {{name}}.',
'body' => '