by Pramod
Test notification systems for spam behavior including duplicate alerts, missing throttling, incorrect delivery channels, and notification preference violations
npx @qaskills/cli add notification-spam-detectorAuto-detects your AI agent and installs the skill. Works with Claude Code, Cursor, Copilot, and more.
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.ts
Before 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=300000
Run notification tests sequentially -- Parallel execution can cause cross-test contamination when notifications from one test leak into another test's assertions. Use workers: 1 in the Playwright configuration for notification test suites.
Clear notification state between tests -- Always reset the notification interceptor, user preferences, and any queued notifications between test runs. Use the fixture teardown to ensure clean state.
Test at the API level first -- Before writing full E2E notification tests, build integration tests that exercise the notification service directly. This catches logic bugs faster than browser-based tests.
Use deterministic time -- Mock the system clock when testing time-dependent behavior like quiet hours, batching windows, and cooldown periods. Do not rely on real-time waits for time-sensitive logic.
Verify both positive and negative cases -- For every "notification should be delivered" test, write a corresponding "notification should NOT be delivered" test. Spam detection is as much about absence as presence.
Test preference migration -- When adding new notification categories or channels, verify that existing users receive sensible defaults and that their existing preferences are not overwritten.
Monitor notification counts in CI -- Add assertions that verify the total number of notifications sent during a test suite run. A sudden increase often indicates a deduplication regression.
Test with multiple users simultaneously -- Verify that throttling and deduplication work correctly when multiple users trigger the same event. Per-user isolation must not leak across user boundaries.
Validate notification metadata -- Beyond subject and body, verify that notifications carry correct metadata such as category, priority, action URLs, and tracking identifiers.
Test notification delivery order -- When multiple notifications are queued, verify they are delivered in the expected order (typically chronological or priority-based).
Include load testing for notification pipelines -- The spam detection logic itself can become a bottleneck. Verify that deduplication, throttling, and preference lookups perform within latency budgets under load.
Test across notification service restarts -- Ensure that in-flight notifications, queued deliveries, and throttle counters survive service restarts without causing duplicates or drops.
Relying on sleep-based timing -- Do not use 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.
Testing only the happy path -- If you only test "notification arrives," you miss the entire purpose of spam detection testing. The majority of your tests should verify that unwanted notifications are suppressed.
Hardcoding rate limits in tests -- Do not hardcode throttle values like expect(notifications).toHaveLength(10). Read the configuration from the same source as the application to keep tests in sync with production settings.
Ignoring cross-channel interactions -- Testing email deduplication in isolation while ignoring that push and in-app notifications may also be sent is a common oversight. Always verify the complete notification footprint across all channels.
Sharing user state across tests -- Using the same user ID across multiple test files without cleanup creates flaky tests because throttle counters, preference caches, and notification history persist. Generate unique user IDs or clean up thoroughly.
Skipping unsubscribe verification -- Unsubscribe is a legal requirement in many jurisdictions. Treating it as a low-priority test is a compliance risk. Always include unsubscribe flow tests in your notification test suite.
Not testing notification content -- Verifying that a notification was delivered is only half the job. If the notification body contains unresolved template variables like {{user.name}} or raw HTML, it is still a bug.
Enable notification service logging -- Set the notification service log level to DEBUG in test environments. Every decision point (deduplicated, throttled, queued, delivered, suppressed) should produce a log entry with the notification ID and reason.
Add trace IDs to notifications -- Include a unique trace ID in every notification that links back to the triggering event. When debugging duplicate deliveries, the trace ID reveals whether two notifications came from the same event or different events.
Inspect the deduplication cache -- Most deduplication systems use a cache (Redis, in-memory) to track recently sent notifications. When duplicates slip through, check whether the cache key format is correct and whether the TTL is appropriate.
Check throttle counter state -- When throttling appears to malfunction, inspect the raw counter values in the backing store. Common issues include counters not resetting at window boundaries, race conditions in counter increments, and timezone mismatches in window calculations.
Verify event ordering -- When batching produces unexpected results, log the timestamps of all events in the batch window. Out-of-order event delivery can cause the batching algorithm to create incorrect groups.
Use Playwright's trace viewer -- Enable 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.
Monitor the dead letter queue -- Failed notifications typically end up in a dead letter queue. If notifications appear to be missing, check whether they failed delivery and were moved to the DLQ instead of being dropped silently.
Test preference cache invalidation -- When preference changes do not take effect immediately, the issue is almost always cache invalidation. Verify that the preference cache is cleared or updated when the user modifies their settings.
Compare event counts vs notification counts -- Maintain counters for events received and notifications sent. The ratio between these numbers should be predictable based on your deduplication and throttling configuration. A deviation indicates a logic bug.
Isolate channel-specific failures -- When debugging cross-channel issues, temporarily disable all channels except one. This isolates whether the problem is in the routing logic, the channel adapter, or the deduplication layer.
- name: Install QA Skills
run: npx @qaskills/cli add notification-spam-detector12 of 29 agents supported