/** * Notification lifecycle integration tests. * * Verifies: * - createNotification() inserts a row and returns it * - Calling again with same dedupeKey within cooldown returns null (suppressed) * - Calling after cooldown expiry creates a new notification * - system_alert type bypasses preference check * - markRead → isRead becomes true * - markAllRead → all notifications for user become read * - getUnreadCount returns correct count * * Skips gracefully when TEST_DATABASE_URL is not reachable. */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; const TEST_DB_URL = process.env.TEST_DATABASE_URL || 'postgresql://test:test@localhost:5433/portnimara_test'; let dbAvailable = false; beforeAll(async () => { try { const postgres = (await import('postgres')).default; const sql = postgres(TEST_DB_URL, { max: 1, idle_timeout: 3, connect_timeout: 3 }); await sql`SELECT 1`; await sql.end(); dbAvailable = true; } catch { console.warn( '[notification-lifecycle] Test database not available — skipping integration tests', ); } }); function itDb(name: string, fn: () => Promise) { it(name, async () => { if (!dbAvailable) return; await fn(); }); } // ─── Helpers ───────────────────────────────────────────────────────────────── async function seedPortAndUser(): Promise<{ portId: string; userId: string }> { const postgres = (await import('postgres')).default; const sql = postgres(TEST_DB_URL, { max: 1 }); const portId = crypto.randomUUID(); const userId = crypto.randomUUID(); await sql` INSERT INTO ports (id, name, slug, country, currency, timezone) VALUES (${portId}, 'Notif Test Port', ${'notif-' + portId.slice(0, 8)}, 'AU', 'AUD', 'UTC') `; await sql` INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at) VALUES (${userId}, 'Notif User', ${'notif-' + userId.slice(0, 8) + '@test.local'}, true, NOW(), NOW()) `; await sql` INSERT INTO user_profiles (id, user_id, display_name, is_super_admin, is_active, preferences) VALUES (${crypto.randomUUID()}, ${userId}, 'Notif User', false, true, '{}') `; await sql.end(); return { portId, userId }; } async function cleanupPortAndUser(portId: string, userId: string): Promise { const postgres = (await import('postgres')).default; const sql = postgres(TEST_DB_URL, { max: 1 }); await sql`DELETE FROM ports WHERE id = ${portId}`; await sql`DELETE FROM user_profiles WHERE user_id = ${userId}`; await sql`DELETE FROM "user" WHERE id = ${userId}`; await sql.end(); } // ─── Tests ──────────────────────────────────────────────────────────────────── describe('Notification Lifecycle', () => { let portId: string; let userId: string; // Mock socket and queue — these are tested in isolation here vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn() })); vi.mock('@/lib/queue', () => ({ getQueue: () => ({ add: vi.fn().mockResolvedValue(undefined) }), })); beforeAll(async () => { if (!dbAvailable) return; ({ portId, userId } = await seedPortAndUser()); }); afterAll(async () => { if (!dbAvailable) return; await cleanupPortAndUser(portId, userId); }); itDb('createNotification inserts a row and returns it', async () => { const { createNotification } = await import('@/lib/services/notifications.service'); const notif = await createNotification({ portId, userId, type: 'interest_stage_changed', title: 'Test notification', description: 'A test', link: '/interests/123', entityType: 'interest', entityId: 'test-entity-1', }); expect(notif).not.toBeNull(); expect(notif!.id).toBeDefined(); expect(notif!.portId).toBe(portId); expect(notif!.userId).toBe(userId); expect(notif!.isRead).toBe(false); expect(notif!.title).toBe('Test notification'); }); itDb('duplicate dedupeKey within cooldown returns null (suppressed)', async () => { const { createNotification } = await import('@/lib/services/notifications.service'); const dedupeKey = `interest:dedup-test-${crypto.randomUUID()}:stage:details_sent`; const params = { portId, userId, type: 'interest_stage_changed', title: 'Dedup test', dedupeKey, cooldownMs: 300_000, }; const first = await createNotification(params); expect(first).not.toBeNull(); const second = await createNotification(params); expect(second).toBeNull(); }); itDb('dedupeKey with expired cooldown creates a new notification', async () => { const { createNotification } = await import('@/lib/services/notifications.service'); const dedupeKey = `interest:expired-cooldown-${crypto.randomUUID()}:stage:open`; const params = { portId, userId, type: 'interest_stage_changed', title: 'Expired cooldown test', dedupeKey, cooldownMs: 0, }; const first = await createNotification(params); expect(first).not.toBeNull(); const second = await createNotification(params); expect(second).not.toBeNull(); expect(second!.id).not.toBe(first!.id); }); itDb('system_alert type bypasses preference check and is always inserted', async () => { const { createNotification } = await import('@/lib/services/notifications.service'); const postgres = (await import('postgres')).default; const sql = postgres(TEST_DB_URL, { max: 1 }); // Insert a preference that would block a non-system notification await sql` INSERT INTO user_notification_preferences (id, user_id, port_id, notification_type, in_app, email) VALUES (${crypto.randomUUID()}, ${userId}, ${portId}, 'blocked_type', false, false) ON CONFLICT DO NOTHING `; await sql.end(); // system_alert MUST still be inserted regardless of any preference const notif = await createNotification({ portId, userId, type: 'system_alert', title: 'System alert test', }); expect(notif).not.toBeNull(); expect(notif!.type).toBe('system_alert'); }); itDb('markRead sets isRead to true', async () => { const { createNotification, markRead } = await import('@/lib/services/notifications.service'); const postgres = (await import('postgres')).default; const notif = await createNotification({ portId, userId, type: 'system_alert', title: 'Mark-read test', }); expect(notif).not.toBeNull(); expect(notif!.isRead).toBe(false); await markRead(notif!.id, userId); const sql = postgres(TEST_DB_URL, { max: 1 }); const rows = await sql>` SELECT is_read FROM notifications WHERE id = ${notif!.id} `; await sql.end(); expect(rows[0]?.is_read).toBe(true); }); itDb('markAllRead sets all unread notifications for the user to read', async () => { const { createNotification, markAllRead, getUnreadCount } = await import( '@/lib/services/notifications.service' ); await createNotification({ portId, userId, type: 'system_alert', title: 'Unread 1' }); await createNotification({ portId, userId, type: 'system_alert', title: 'Unread 2' }); const before = await getUnreadCount(userId, portId); expect(before.count).toBeGreaterThan(0); await markAllRead(userId, portId); const after = await getUnreadCount(userId, portId); expect(after.count).toBe(0); }); itDb('getUnreadCount returns accurate count', async () => { const { createNotification, getUnreadCount, markAllRead } = await import( '@/lib/services/notifications.service' ); await markAllRead(userId, portId); const baseline = await getUnreadCount(userId, portId); expect(baseline.count).toBe(0); await createNotification({ portId, userId, type: 'system_alert', title: 'Count test 1' }); await createNotification({ portId, userId, type: 'system_alert', title: 'Count test 2' }); const after = await getUnreadCount(userId, portId); expect(after.count).toBe(2); }); });