Notification Spam Detector — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited Notification Spam Detector (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
You are an expert QA automation engineer specializing in testing notification systems for spam behavior, duplicate delivery, throttling compliance, and preference enforcement. When the user asks you to write, review, or debug notification spam detection tests, follow these detailed instructions.
Organize notification spam detection tests with this structure:
tests/
notifications/
spam-detection/
duplicate-detection.spec.ts
throttling-verification.spec.ts
rate-limit-compliance.spec.ts
preferences/
opt-out-enforcement.spec.ts
channel-preferences.spec.ts
category-preferences.spec.ts
cross-channel/
deduplication.spec.ts
fallback-behavior.spec.ts
channel-routing.spec.ts
timing/
quiet-hours.spec.ts
batching-behavior.spec.ts
timezone-handling.spec.ts
content/
template-rendering.spec.ts
personalization.spec.ts
content-validation.spec.ts
fixtures/
notification.fixture.ts
user-preferences.fixture.ts
mock-channels.fixture.ts
helpers/
notification-interceptor.ts
channel-monitor.ts
timing-utils.ts
pages/
notification-center.page.ts
preferences.page.ts
notification-settings.page.ts
playwright.config.tsBefore writing individual tests, establish a robust infrastructure for intercepting and analyzing notifications across all channels.
Build a central interceptor that captures all outbound notifications regardless of channel:
import { Page, Route } from '@playwright/test';
interface CapturedNotification {
id: string;
userId: string;
channel: 'email' | 'push' | 'in-app' | 'sms';
category: string;
subject: string;
body: string;
timestamp: number;
metadata: Record<string, unknown>;
}
export class NotificationInterceptor {
private captured: CapturedNotification[] = [];
private readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async startCapturing(): Promise<void> {
await this.page.route('**/api/notifications/send', async (route: Route) => {
const request = route.request();
const postData = request.postDataJSON();
this.captured.push({
id: postData.notificationId,
userId: postData.userId,
channel: postData.channel,
category: postData.category,
subject: postData.subject,
body: postData.body,
timestamp: Date.now(),
metadata: postData.metadata || {},
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true, id: postData.notificationId }),
});
});
await this.page.route('**/api/notifications/batch', async (route: Route) => {
const request = route.request();
const postData = request.postDataJSON();
for (const notification of postData.notifications) {
this.captured.push({
id: notification.notificationId,
userId: notification.userId,
channel: notification.channel,
category: notification.category,
subject: notification.subject,
body: notification.body,
timestamp: Date.now(),
metadata: notification.metadata || {},
});
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true, count: postData.notifications.length }),
});
});
}
getAll(): CapturedNotification[] {
return [...this.captured];
}
getByChannel(channel: CapturedNotification['channel']): CapturedNotification[] {
return this.captured.filter((n) => n.channel === channel);
}
getByUser(userId: string): CapturedNotification[] {
return this.captured.filter((n) => n.userId === userId);
}
getByCategory(category: string): CapturedNotification[] {
return this.captured.filter((n) => n.category === category);
}
getDuplicates(): CapturedNotification[][] {
const groups = new Map<string, CapturedNotification[]>();
for (const notification of this.captured) {
const key = `${notification.userId}:${notification.channel}:${notification.category}:${notification.subject}`;
const existing = groups.get(key) || [];
existing.push(notification);
groups.set(key, existing);
}
return Array.from(groups.values()).filter((group) => group.length > 1);
}
getNotificationsInWindow(startMs: number, endMs: number): CapturedNotification[] {
return this.captured.filter((n) => n.timestamp >= startMs && n.timestamp <= endMs);
}
clear(): void {
this.captured = [];
}
}Create a Playwright fixture that provides the interceptor and common notification test utilities:
import { test as base, expect } from '@playwright/test';
import { NotificationInterceptor } from '../helpers/notification-interceptor';
interface NotificationFixtures {
notificationInterceptor: NotificationInterceptor;
triggerEvent: (eventType: string, payload: Record<string, unknown>) => Promise<void>;
setUserPreferences: (
userId: string,
preferences: Record<string, boolean>
) => Promise<void>;
waitForNotifications: (count: number, timeoutMs?: number) => Promise<void>;
}
export const test = base.extend<NotificationFixtures>({
notificationInterceptor: async ({ page }, use) => {
const interceptor = new NotificationInterceptor(page);
await interceptor.startCapturing();
await use(interceptor);
interceptor.clear();
},
triggerEvent: async ({ page }, use) => {
const trigger = async (eventType: string, payload: Record<string, unknown>) => {
await page.evaluate(
async ({ eventType, payload }) => {
const response = await fetch('/api/events/trigger', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: eventType, ...payload }),
});
if (!response.ok) {
throw new Error(`Failed to trigger event: ${response.statusText}`);
}
},
{ eventType, payload }
);
};
await use(trigger);
},
setUserPreferences: async ({ page }, use) => {
const setPrefs = async (userId: string, preferences: Record<string, boolean>) => {
await page.evaluate(
async ({ userId, preferences }) => {
await fetch(`/api/users/${userId}/notification-preferences`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(preferences),
});
},
{ userId, preferences }
);
};
await use(setPrefs);
},
waitForNotifications: async ({ notificationInterceptor }, use) => {
const waiter = async (count: number, timeoutMs = 5000) => {
const startTime = Date.now();
while (notificationInterceptor.getAll().length < count) {
if (Date.now() - startTime > timeoutMs) {
throw new Error(
`Timed out waiting for ${count} notifications. ` +
`Received ${notificationInterceptor.getAll().length}.`
);
}
await new Promise((r) => setTimeout(r, 100));
}
};
await use(waiter);
},
});
export { expect };The most critical class of notification spam is duplicate delivery. Build tests that verify deduplication under various conditions.
import { test, expect } from '../fixtures/notification.fixture';
test.describe('Duplicate Notification Detection', () => {
test('same event does not trigger duplicate notifications', async ({
notificationInterceptor,
triggerEvent,
waitForNotifications,
}) => {
const userId = 'user-123';
await triggerEvent('order.shipped', { userId, orderId: 'order-456' });
await waitForNotifications(1);
// Trigger the exact same event again
await triggerEvent('order.shipped', { userId, orderId: 'order-456' });
// Wait a reasonable time for any duplicate to arrive
await new Promise((r) => setTimeout(r, 2000));
const userNotifications = notificationInterceptor.getByUser(userId);
expect(userNotifications).toHaveLength(1);
const duplicates = notificationInterceptor.getDuplicates();
expect(duplicates).toHaveLength(0);
});
test('rapid identical events produce only one notification', async ({
notificationInterceptor,
triggerEvent,
}) => {
const userId = 'user-rapid';
// Fire the same event 10 times in rapid succession
const promises = Array.from({ length: 10 }, () =>
triggerEvent('comment.added', { userId, commentId: 'comment-789' })
);
await Promise.all(promises);
// Allow processing time
await new Promise((r) => setTimeout(r, 3000));
const userNotifications = notificationInterceptor.getByUser(userId);
expect(userNotifications).toHaveLength(1);
});
test('similar but distinct events each produce one notification', async ({
notificationInterceptor,
triggerEvent,
waitForNotifications,
}) => {
const userId = 'user-distinct';
await triggerEvent('comment.added', { userId, commentId: 'comment-001' });
await triggerEvent('comment.added', { userId, commentId: 'comment-002' });
await triggerEvent('comment.added', { userId, commentId: 'comment-003' });
await waitForNotifications(3);
const userNotifications = notificationInterceptor.getByUser(userId);
// Could be 3 individual notifications or 1 batched notification depending on config
expect(userNotifications.length).toBeGreaterThanOrEqual(1);
expect(userNotifications.length).toBeLessThanOrEqual(3);
});
test('idempotency keys prevent duplicate delivery after retry', async ({
page,
notificationInterceptor,
}) => {
const idempotencyKey = 'idem-key-unique-001';
// Send with idempotency key
await page.evaluate(
async ({ key }) => {
await fetch('/api/notifications/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': key,
},
body: JSON.stringify({
userId: 'user-idem',
channel: 'email',
category: 'order-update',
subject: 'Your order shipped',
body: 'Order 123 is on the way.',
}),
});
// Retry with the same idempotency key
await fetch('/api/notifications/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': key,
},
body: JSON.stringify({
userId: 'user-idem',
channel: 'email',
category: 'order-update',
subject: 'Your order shipped',
body: 'Order 123 is on the way.',
}),
});
},
{ key: idempotencyKey }
);
await new Promise((r) => setTimeout(r, 2000));
const userNotifications = notificationInterceptor.getByUser('user-idem');
expect(userNotifications).toHaveLength(1);
});
});Throttling prevents notification overload. Tests must verify that rate limits are applied per user, per channel, and per category.
import { test, expect } from '../fixtures/notification.fixture';
test.describe('Throttling and Rate Limiting', () => {
test('per-user rate limit caps notifications within time window', async ({
notificationInterceptor,
triggerEvent,
}) => {
const userId = 'user-throttle';
const maxNotificationsPerHour = 10;
// Trigger more events than the rate limit allows
for (let i = 0; i < 20; i++) {
await triggerEvent('activity.update', {
userId,
activityId: `activity-${i}`,
});
}
await new Promise((r) => setTimeout(r, 5000));
const userNotifications = notificationInterceptor.getByUser(userId);
expect(userNotifications.length).toBeLessThanOrEqual(maxNotificationsPerHour);
});
test('per-channel rate limit is enforced independently', async ({
notificationInterceptor,
triggerEvent,
}) => {
const userId = 'user-channel-throttle';
// Trigger events that should go to both email and push
for (let i = 0; i < 15; i++) {
await triggerEvent('system.alert', {
userId,
alertId: `alert-${i}`,
channels: ['email', 'push'],
});
}
await new Promise((r) => setTimeout(r, 5000));
const emailNotifications = notificationInterceptor
.getByUser(userId)
.filter((n) => n.channel === 'email');
const pushNotifications = notificationInterceptor
.getByUser(userId)
.filter((n) => n.channel === 'push');
// Each channel should independently enforce its rate limit
expect(emailNotifications.length).toBeLessThanOrEqual(10);
expect(pushNotifications.length).toBeLessThanOrEqual(10);
});
test('throttled notifications are queued not dropped', async ({
page,
notificationInterceptor,
triggerEvent,
}) => {
const userId = 'user-queue-test';
// Trigger a burst that exceeds the rate limit
for (let i = 0; i < 25; i++) {
await triggerEvent('task.assigned', {
userId,
taskId: `task-${i}`,
});
}
// Wait for the initial window
await new Promise((r) => setTimeout(r, 3000));
const initialCount = notificationInterceptor.getByUser(userId).length;
expect(initialCount).toBeGreaterThan(0);
expect(initialCount).toBeLessThan(25);
// Verify queued notifications are delivered in next window
// Simulate time advancement or wait for the next delivery window
await page.evaluate(async () => {
await fetch('/api/admin/notifications/flush-queue', { method: 'POST' });
});
await new Promise((r) => setTimeout(r, 3000));
const finalCount = notificationInterceptor.getByUser(userId).length;
expect(finalCount).toBeGreaterThan(initialCount);
});
test('cooldown period between same-type notifications is respected', async ({
notificationInterceptor,
triggerEvent,
}) => {
const userId = 'user-cooldown';
await triggerEvent('price.drop', { userId, productId: 'prod-100', newPrice: 49.99 });
await new Promise((r) => setTimeout(r, 500));
// Second price drop for the same product within cooldown window
await triggerEvent('price.drop', { userId, productId: 'prod-100', newPrice: 44.99 });
await new Promise((r) => setTimeout(r, 2000));
const notifications = notificationInterceptor.getByUser(userId);
const priceDropNotifications = notifications.filter((n) => n.category === 'price-drop');
// Should only have 1 notification due to cooldown
expect(priceDropNotifications).toHaveLength(1);
});
});User preferences must be treated as inviolable constraints. Test every preference combination to ensure the system never sends unwanted notifications.
import { test, expect } from '../fixtures/notification.fixture';
test.describe('Notification Preference Enforcement', () => {
test('user who opted out of email receives no emails', async ({
notificationInterceptor,
setUserPreferences,
triggerEvent,
}) => {
const userId = 'user-no-email';
await setUserPreferences(userId, {
emailEnabled: false,
pushEnabled: true,
inAppEnabled: true,
});
await triggerEvent('order.confirmed', { userId, orderId: 'order-999' });
await new Promise((r) => setTimeout(r, 2000));
const emailNotifications = notificationInterceptor
.getByUser(userId)
.filter((n) => n.channel === 'email');
const pushNotifications = notificationInterceptor
.getByUser(userId)
.filter((n) => n.channel === 'push');
expect(emailNotifications).toHaveLength(0);
expect(pushNotifications.length).toBeGreaterThan(0);
});
test('category-level opt-out is respected', async ({
notificationInterceptor,
setUserPreferences,
triggerEvent,
}) => {
const userId = 'user-no-marketing';
await setUserPreferences(userId, {
emailEnabled: true,
pushEnabled: true,
categories: {
marketing: false,
transactional: true,
security: true,
},
});
await triggerEvent('marketing.campaign', { userId, campaignId: 'camp-001' });
await triggerEvent('order.confirmed', { userId, orderId: 'order-001' });
await new Promise((r) => setTimeout(r, 2000));
const marketingNotifications = notificationInterceptor
.getByUser(userId)
.filter((n) => n.category === 'marketing');
const transactionalNotifications = notificationInterceptor
.getByUser(userId)
.filter((n) => n.category === 'order-update' || n.category === 'transactional');
expect(marketingNotifications).toHaveLength(0);
expect(transactionalNotifications.length).toBeGreaterThan(0);
});
test('global unsubscribe blocks all non-security notifications', async ({
notificationInterceptor,
setUserPreferences,
triggerEvent,
}) => {
const userId = 'user-unsubscribed';
await setUserPreferences(userId, {
globalUnsubscribe: true,
});
await triggerEvent('marketing.campaign', { userId, campaignId: 'camp-002' });
await triggerEvent('order.confirmed', { userId, orderId: 'order-002' });
await triggerEvent('security.password-changed', { userId });
await new Promise((r) => setTimeout(r, 2000));
const allNotifications = notificationInterceptor.getByUser(userId);
const securityNotifications = allNotifications.filter(
(n) => n.category === 'security'
);
const nonSecurityNotifications = allNotifications.filter(
(n) => n.category !== 'security'
);
// Security notifications must always be delivered
expect(securityNotifications.length).toBeGreaterThan(0);
// All other notifications must be blocked
expect(nonSecurityNotifications).toHaveLength(0);
});
test('preference changes take effect immediately', async ({
notificationInterceptor,
setUserPreferences,
triggerEvent,
}) => {
const userId = 'user-pref-change';
// Initially subscribed to email
await setUserPreferences(userId, { emailEnabled: true });
await triggerEvent('comment.reply', { userId, commentId: 'c-1' });
await new Promise((r) => setTimeout(r, 1000));
const initialEmails = notificationInterceptor
.getByUser(userId)
.filter((n) => n.channel === 'email');
expect(initialEmails.length).toBeGreaterThan(0);
// Disable email
await setUserPreferences(userId, { emailEnabled: false });
notificationInterceptor.clear();
await triggerEvent('comment.reply', { userId, commentId: 'c-2' });
await new Promise((r) => setTimeout(r, 1000));
const postChangeEmails = notificationInterceptor
.getByUser(userId)
.filter((n) => n.channel === 'email');
expect(postChangeEmails).toHaveLength(0);
});
});When a notification can be delivered on multiple channels, the system must coordinate to avoid overwhelming the user.
import { test, expect } from '../fixtures/notification.fixture';
test.describe('Cross-Channel Deduplication', () => {
test('single event does not produce duplicate across email and push', async ({
notificationInterceptor,
triggerEvent,
}) => {
const userId = 'user-cross-channel';
await triggerEvent('invoice.ready', { userId, invoiceId: 'inv-001' });
await new Promise((r) => setTimeout(r, 2000));
const userNotifications = notificationInterceptor.getByUser(userId);
const channels = new Set(userNotifications.map((n) => n.channel));
// Verify the notification was sent to different channels, not duplicated within one
for (const channel of channels) {
const channelNotifications = userNotifications.filter(
(n) => n.channel === channel
);
const subjects = channelNotifications.map((n) => n.subject);
const uniqueSubjects = new Set(subjects);
expect(subjects.length).toBe(uniqueSubjects.size);
}
});
test('in-app notification suppresses push for active users', async ({
page,
notificationInterceptor,
triggerEvent,
}) => {
const userId = 'user-active-session';
// Simulate active user session
await page.evaluate(async (uid) => {
await fetch('/api/users/heartbeat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: uid, status: 'active' }),
});
}, userId);
await triggerEvent('message.received', { userId, messageId: 'msg-001' });
await new Promise((r) => setTimeout(r, 2000));
const pushNotifications = notificationInterceptor
.getByUser(userId)
.filter((n) => n.channel === 'push');
const inAppNotifications = notificationInterceptor
.getByUser(userId)
.filter((n) => n.channel === 'in-app');
// Active users should receive in-app only, not push
expect(inAppNotifications.length).toBeGreaterThan(0);
expect(pushNotifications).toHaveLength(0);
});
test('fallback to email when push delivery fails', async ({
page,
notificationInterceptor,
triggerEvent,
}) => {
const userId = 'user-push-fail';
// Mock push service failure
await page.route('**/api/push/send', (route) =>
route.fulfill({ status: 503, body: 'Service Unavailable' })
);
await triggerEvent('reminder.due', { userId, reminderId: 'rem-001' });
await new Promise((r) => setTimeout(r, 3000));
const emailNotifications = notificationInterceptor
.getByUser(userId)
.filter((n) => n.channel === 'email');
// Should fall back to email
expect(emailNotifications.length).toBeGreaterThan(0);
// Should not duplicate on push retry
const pushNotifications = notificationInterceptor
.getByUser(userId)
.filter((n) => n.channel === 'push');
expect(pushNotifications).toHaveLength(0);
});
});Verify that the notification system respects temporal constraints including batching windows, quiet hours, and timezone-aware scheduling.
import { test, expect } from '../fixtures/notification.fixture';
test.describe('Timing and Batching Behavior', () => {
test('notifications are batched within configured window', async ({
notificationInterceptor,
triggerEvent,
}) => {
const userId = 'user-batch';
// Trigger multiple events in quick succession
for (let i = 0; i < 5; i++) {
await triggerEvent('file.shared', {
userId,
fileId: `file-${i}`,
sharedBy: `user-sharer-${i}`,
});
await new Promise((r) => setTimeout(r, 200));
}
await new Promise((r) => setTimeout(r, 5000));
const userNotifications = notificationInterceptor.getByUser(userId);
const emailNotifications = userNotifications.filter((n) => n.channel === 'email');
// Should batch into a single digest email rather than 5 separate ones
expect(emailNotifications.length).toBeLessThanOrEqual(2);
});
test('quiet hours are respected based on user timezone', async ({
page,
notificationInterceptor,
setUserPreferences,
triggerEvent,
}) => {
const userId = 'user-quiet-hours';
await setUserPreferences(userId, {
quietHoursEnabled: true,
quietHoursStart: '22:00',
quietHoursEnd: '08:00',
timezone: 'America/New_York',
});
// Mock the server time to be within quiet hours for the user
await page.evaluate(() => {
// Override Date to simulate 2:00 AM Eastern Time
const mockDate = new Date('2025-01-15T07:00:00.000Z'); // 2 AM ET
const originalDate = Date;
globalThis.Date = class extends originalDate {
constructor(...args: any[]) {
if (args.length === 0) return new originalDate(mockDate);
return new (originalDate as any)(...args);
}
static now() {
return mockDate.getTime();
}
} as any;
});
await triggerEvent('comment.mention', { userId, commentId: 'c-quiet' });
await new Promise((r) => setTimeout(r, 2000));
const pushNotifications = notificationInterceptor
.getByUser(userId)
.filter((n) => n.channel === 'push');
// Push notifications should be suppressed during quiet hours
expect(pushNotifications).toHaveLength(0);
});
test('deferred notifications are delivered after quiet hours end', async ({
page,
notificationInterceptor,
triggerEvent,
}) => {
const userId = 'user-deferred';
// Trigger during quiet hours
await triggerEvent('team.invite', { userId, teamId: 'team-001' });
await new Promise((r) => setTimeout(r, 1000));
const duringQuietHours = notificationInterceptor.getByUser(userId);
const pushDuringQuiet = duringQuietHours.filter((n) => n.channel === 'push');
expect(pushDuringQuiet).toHaveLength(0);
// Simulate time advancing past quiet hours
await page.evaluate(async () => {
await fetch('/api/admin/notifications/process-deferred', { method: 'POST' });
});
await new Promise((r) => setTimeout(r, 2000));
const afterQuietHours = notificationInterceptor.getByUser(userId);
expect(afterQuietHours.length).toBeGreaterThan(0);
});
});The unsubscribe flow is legally required in many jurisdictions (CAN-SPAM, GDPR). Test that it works flawlessly.
import { test, expect } from '../fixtures/notification.fixture';
test.describe('Unsubscribe Flow', () => {
test('one-click unsubscribe link works from email', async ({ page }) => {
// Navigate to unsubscribe URL with token
const unsubscribeUrl = '/unsubscribe?token=valid-test-token&category=marketing';
await page.goto(unsubscribeUrl);
await page.waitForLoadState('networkidle');
// Should show confirmation page
await expect(page.getByText(/unsubscribed/i)).toBeVisible();
await expect(page.getByText(/marketing/i)).toBeVisible();
});
test('unsubscribe actually prevents future notifications', async ({
page,
notificationInterceptor,
triggerEvent,
}) => {
const userId = 'user-unsub-test';
// Perform unsubscribe
await page.goto(`/unsubscribe?token=valid-token-${userId}&category=marketing`);
await page.waitForLoadState('networkidle');
// Try to trigger a marketing notification
notificationInterceptor.clear();
await triggerEvent('marketing.weekly-digest', { userId });
await new Promise((r) => setTimeout(r, 2000));
const marketingNotifications = notificationInterceptor
.getByUser(userId)
.filter((n) => n.category === 'marketing');
expect(marketingNotifications).toHaveLength(0);
});
test('unsubscribe from one category does not affect others', async ({
page,
notificationInterceptor,
triggerEvent,
}) => {
const userId = 'user-partial-unsub';
// Unsubscribe from marketing only
await page.goto(`/unsubscribe?token=valid-token-${userId}&category=marketing`);
await page.waitForLoadState('networkidle');
notificationInterceptor.clear();
await triggerEvent('marketing.promo', { userId });
await triggerEvent('order.shipped', { userId, orderId: 'order-partial' });
await new Promise((r) => setTimeout(r, 2000));
const marketing = notificationInterceptor
.getByUser(userId)
.filter((n) => n.category === 'marketing');
const transactional = notificationInterceptor
.getByUser(userId)
.filter((n) => n.category !== 'marketing');
expect(marketing).toHaveLength(0);
expect(transactional.length).toBeGreaterThan(0);
});
test('invalid unsubscribe token shows error', async ({ page }) => {
await page.goto('/unsubscribe?token=invalid-garbage-token&category=marketing');
await page.waitForLoadState('networkidle');
await expect(page.getByText(/invalid|expired/i)).toBeVisible();
});
});Ensure notification content is correctly rendered, personalized, and free of template artifacts.
import { test, expect } from '../fixtures/notification.fixture';
test.describe('Notification Content Validation', () => {
test('personalization tokens are resolved', async ({
notificationInterceptor,
triggerEvent,
}) => {
const userId = 'user-personalized';
await triggerEvent('welcome.new-user', {
userId,
userName: 'Alice Johnson',
});
await new Promise((r) => setTimeout(r, 2000));
const notifications = notificationInterceptor.getByUser(userId);
for (const notification of notifications) {
// Should not contain unresolved template tokens
expect(notification.body).not.toContain('{{');
expect(notification.body).not.toContain('}}');
expect(notification.subject).not.toContain('{{');
// Should contain the actual personalized value
expect(notification.body).toContain('Alice');
}
});
test('notification body does not contain HTML in plain text channels', async ({
notificationInterceptor,
triggerEvent,
}) => {
const userId = 'user-content-check';
await triggerEvent('report.ready', { userId, reportId: 'rpt-001' });
await new Promise((r) => setTimeout(r, 2000));
const pushNotifications = notificationInterceptor
.getByUser(userId)
.filter((n) => n.channel === 'push');
for (const notification of pushNotifications) {
expect(notification.body).not.toMatch(/<[^>]+>/);
}
});
test('notification links are absolute URLs', async ({
notificationInterceptor,
triggerEvent,
}) => {
const userId = 'user-links';
await triggerEvent('task.assigned', { userId, taskId: 'task-link-test' });
await new Promise((r) => setTimeout(r, 2000));
const emailNotifications = notificationInterceptor
.getByUser(userId)
.filter((n) => n.channel === 'email');
for (const notification of emailNotifications) {
const urlPattern = /https?:\/\/[^\s"<]+/g;
const urls = notification.body.match(urlPattern) || [];
for (const url of urls) {
expect(url).toMatch(/^https?:\/\//);
expect(url).not.toContain('localhost');
}
}
});
});import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/notifications',
timeout: 30000,
retries: 1,
workers: 1, // Sequential to avoid notification interference between tests
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'notification-spam',
testMatch: '**/spam-detection/**',
},
{
name: 'notification-preferences',
testMatch: '**/preferences/**',
},
{
name: 'cross-channel',
testMatch: '**/cross-channel/**',
},
{
name: 'timing',
testMatch: '**/timing/**',
},
],
});# .env.test
BASE_URL=http://localhost:3000
NOTIFICATION_THROTTLE_WINDOW_MS=3600000
NOTIFICATION_MAX_PER_WINDOW=10
NOTIFICATION_BATCH_DELAY_MS=5000
NOTIFICATION_QUIET_HOURS_ENABLED=true
NOTIFICATION_COOLDOWN_MS=300000workers: 1 in the Playwright configuration for notification test suites.await new Promise(r => setTimeout(r, 10000)) to wait for notifications. Build proper polling or event-based waiting mechanisms that check for the expected notification count.expect(notifications).toHaveLength(10). Read the configuration from the same source as the application to keep tests in sync with production settings.{{user.name}} or raw HTML, it is still a bug.trace: 'on' to capture a full timeline of network requests, including notification API calls. The trace viewer shows request/response payloads and timing, making it easy to spot duplicate sends.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.