/** * System Notices API integration tests. * Covers GET /api/system-notices/active and POST /api/system-notices/:id/dismiss. */ import { ADDON_IDS } from '../../src/addons'; import { buildApp } from '../../src/bootstrap'; import { db as testDb } from '../../src/db/database'; import { Addons } from '../../src/db/entities/Addons.entity'; import { UserNoticeDismissals } from '../../src/db/entities/UserNoticeDismissals.entity'; import { Users } from '../../src/db/entities/Users.entity'; import { SYSTEM_NOTICES } from '../../src/systemNotices/registry'; import { getCurrentAppVersion } from '../../src/systemNotices/service'; import type { SystemNotice } from '../../src/systemNotices/types'; import { authCookie } from '../helpers/auth'; import { createUser, createAdmin } from '../helpers/factories'; import type { FactoryOrm } from '../helpers/factories/context'; import { insertRow, updateRows } from '../helpers/factories/rows'; import { setAppSetting } from '../helpers/factories/settings'; import { resetTestDb } from '../helpers/test-db'; import { MikroORM } from '@mikro-orm/core'; import type { INestApplication } from '@nestjs/common'; import type { Application } from 'express'; import request from 'supertest'; import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; // ───────────────────────────────────────────────────────────────────────────── // Bare in-memory DB — schema applied in beforeAll after mocks register // ───────────────────────────────────────────────────────────────────────────── vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); let nestApp: INestApplication; let app: Application; let orm: FactoryOrm; // Test notice injected into the registry for notice-specific tests const TEST_NOTICE: SystemNotice = { id: 'test-first-login-notice', display: 'modal', severity: 'info', titleKey: 'system_notice.test_first_login_notice.title', bodyKey: 'system_notice.test_first_login_notice.body', dismissible: true, conditions: [{ kind: 'firstLogin' }], publishedAt: '2026-01-01T00:00:00Z', priority: 0, }; // Test notice gated on an addon, so the addonFlags lookup in getActiveNoticesFor // (built once per call from every addonEnabled condition in SYSTEM_NOTICES) is // exercised for both the enabled and the disabled case. const TEST_NOTICE_ADDON: SystemNotice = { id: 'test-addon-notice', display: 'modal', severity: 'info', titleKey: 'system_notice.test_addon_notice.title', bodyKey: 'system_notice.test_addon_notice.body', dismissible: true, conditions: [{ kind: 'addonEnabled', addonId: ADDON_IDS.JOURNEY }], publishedAt: '2026-01-01T00:00:00Z', priority: 0, }; beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); orm = nestApp.get(MikroORM); }); beforeEach(() => { resetTestDb(testDb); }); afterAll(async () => { await nestApp.close(); testDb.close(); }); // ───────────────────────────────────────────────────────────────────────────── // GET /api/system-notices/active // ───────────────────────────────────────────────────────────────────────────── describe('GET /api/system-notices/active', () => { it('returns 401 without auth', async () => { const res = await request(app).get('/api/system-notices/active'); expect(res.status).toBe(401); }); it('returns no login/version-gated notices for an established user', async () => { const { user } = createUser(testDb); // login_count > 1 means firstLogin does not match; first_seen_version >= 3.0.0 means // existingUserBeforeVersion('3.0.0') does not match either. Notices that gate on // nothing but the install being self-hosted still apply, and which ones those are // changes every release (the thank-you modal handed over to the release notes at 4.0.0). // Read the set out of the registry rather than naming them, or this ages out again. await updateRows(orm, Users, { id: user.id }, { login_count: 5, first_seen_version: '3.0.0' }); const alwaysOn = new Set( SYSTEM_NOTICES.filter((n) => (n.conditions ?? []).every((c) => c.kind === 'managed')).map((n) => n.id), ); const res = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.filter((n: { id: string }) => !alwaysOn.has(n.id))).toEqual([]); }); it('returns firstLogin notice for user with login_count <= 1', async () => { SYSTEM_NOTICES.push(TEST_NOTICE); try { const { user } = createUser(testDb); // Set login_count to 1 (first login) await updateRows(orm, Users, { id: user.id }, { login_count: 1 }); const res = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); // The always-on thank-you notice may also be present, so just assert TEST_NOTICE is there const testNotice = res.body.find((n: { id: string }) => n.id === TEST_NOTICE.id); expect(testNotice).toBeDefined(); // DTO should not expose conditions, publishedAt, minVersion, maxVersion, priority expect(testNotice.conditions).toBeUndefined(); expect(testNotice.publishedAt).toBeUndefined(); expect(testNotice.minVersion).toBeUndefined(); expect(testNotice.maxVersion).toBeUndefined(); } finally { const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE); if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1); } }); it('an addonEnabled-gated notice appears only while the addon is on', async () => { SYSTEM_NOTICES.push(TEST_NOTICE_ADDON); try { const { user } = createUser(testDb); await updateRows(orm, Addons, { id: ADDON_IDS.JOURNEY }, { enabled: false }); const off = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id)); expect(off.status).toBe(200); expect(off.body.find((n: { id: string }) => n.id === TEST_NOTICE_ADDON.id)).toBeUndefined(); await updateRows(orm, Addons, { id: ADDON_IDS.JOURNEY }, { enabled: true }); const on = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id)); expect(on.status).toBe(200); expect(on.body.find((n: { id: string }) => n.id === TEST_NOTICE_ADDON.id)).toBeDefined(); } finally { const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE_ADDON); if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1); } }); it('does not return firstLogin notice for user with login_count > 1', async () => { SYSTEM_NOTICES.push(TEST_NOTICE); try { const { user } = createUser(testDb); await updateRows(orm, Users, { id: user.id }, { login_count: 5, first_seen_version: '3.0.0' }); const res = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.find((n: { id: string }) => n.id === TEST_NOTICE.id)).toBeUndefined(); } finally { const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE); if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1); } }); it('filters out dismissed notices', async () => { SYSTEM_NOTICES.push(TEST_NOTICE); try { const { user } = createUser(testDb); await updateRows(orm, Users, { id: user.id }, { login_count: 1 }); // Dismiss the notice directly in DB await insertRow(orm, UserNoticeDismissals, { user: user.id, notice_id: TEST_NOTICE.id, dismissed_at: Date.now(), }); const res = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); // TEST_NOTICE should be filtered out; the thank-you notice may still appear const found = res.body.find((n: { id: string }) => n.id === TEST_NOTICE.id); expect(found).toBeUndefined(); } finally { const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE); if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1); } }); // Drives the recurrence mechanism through a notice this test owns. It used to // ride on thank-you-support, which stopped applying the moment that notice was // capped at 4.0.0 — the mechanism was fine, the fixture had retired. it('re-surfaces a per-version notice after an upgrade but hides it within the same version', async () => { const RECURRING: SystemNotice = { id: 'test-per-version-notice', display: 'modal', severity: 'info', titleKey: 'system_notice.test_per_version_notice.title', bodyKey: 'system_notice.test_per_version_notice.body', dismissible: true, conditions: [], recurring: 'per-version', publishedAt: '2026-01-01T00:00:00Z', priority: 0, }; SYSTEM_NOTICES.push(RECURRING); try { const { user } = createUser(testDb); await updateRows(orm, Users, { id: user.id }, { login_count: 5, first_seen_version: '3.0.0' }); const shows = async () => { const res = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); return res.body.some((n: { id: string }) => n.id === RECURRING.id); }; // Fresh user with no dismissal: the recurring notice shows. expect(await shows()).toBe(true); // Dismissed at an old version → it returns once the running version is newer. await insertRow(orm, UserNoticeDismissals, { user: user.id, notice_id: RECURRING.id, dismissed_at: Date.now(), dismissed_app_version: '0.0.1', }); expect(await shows()).toBe(true); // Dismissed at a version >= the running one → stays hidden until the next upgrade. await updateRows( orm, UserNoticeDismissals, { user: user.id, notice_id: RECURRING.id }, { dismissed_app_version: '99.0.0' }, ); expect(await shows()).toBe(false); } finally { const idx = SYSTEM_NOTICES.indexOf(RECURRING); if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1); } }); // The release notes carry the support links, so the promise is exact: once per // update for every user, whatever they did with it before, and never again on a // reload of the same version. Driven through the real registry entry and the real // dismiss route, so neither half can drift away from the other unnoticed. it('shows the release notes once per update, however often they were closed before', async () => { const { user } = createUser(testDb); await updateRows(orm, Users, { id: user.id }, { login_count: 5, first_seen_version: '3.0.0' }); // The client announces the release layout and the version it was built as; a // bundle that predates that, or one built for another version, never gets the // notice (pinned separately below). const shows = async () => { const res = await request(app) .get('/api/system-notices/active') .query({ supports: 'release', ui: getCurrentAppVersion() }) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); return res.body.some((n: { id: string }) => n.id === 'release-notes'); }; const dismiss = async () => { const res = await request(app) .post('/api/system-notices/release-notes/dismiss') .set('Cookie', authCookie(user.id)); expect(res.status).toBe(204); }; expect(await shows()).toBe(true); // Closed on this version: gone for every reload after it. await dismiss(); expect(await shows()).toBe(false); expect(await shows()).toBe(false); // The next update brings it back, although it was closed before... await updateRows( orm, UserNoticeDismissals, { user: user.id, notice_id: 'release-notes' }, { dismissed_app_version: '4.0.0' }, ); expect(await shows()).toBe(true); expect(await shows()).toBe(true); // ...and closing it again holds until the one after that. await dismiss(); expect(await shows()).toBe(false); }); // Right after an update the browser still runs the bundle the service worker cached, // which has neither the copy for the new release notice nor a way to tell. It would // draw the keys, or its own older texts, and on close spend the notice for this whole // version. So the notice waits for a client that announces the release layout AND // was built for the version now running; the reload brings one. it('keeps the release notes from a client that does not announce the release layout for this version', async () => { const { user } = createUser(testDb); await updateRows(orm, Users, { id: user.id }, { login_count: 5, first_seen_version: '3.0.0' }); const fetchActive = (query: Record) => request(app).get('/api/system-notices/active').query(query).set('Cookie', authCookie(user.id)); const without = await fetchActive({}); expect(without.status).toBe(200); expect(without.body.some((n: { id: string }) => n.id === 'release-notes')).toBe(false); expect(without.body.some((n: { release?: unknown }) => n.release !== undefined)).toBe(false); // The layout alone is the shell of the previous version after an update. const layoutOnly = await fetchActive({ supports: 'release' }); expect(layoutOnly.body.some((n: { id: string }) => n.id === 'release-notes')).toBe(false); const olderBundle = await fetchActive({ supports: 'release', ui: '4.0.0' }); expect(olderBundle.body.some((n: { id: string }) => n.id === 'release-notes')).toBe(false); const matching = await fetchActive({ supports: 'release', ui: getCurrentAppVersion() }); expect(matching.status).toBe(200); expect(matching.body.some((n: { id: string }) => n.id === 'release-notes')).toBe(true); }); }); // ───────────────────────────────────────────────────────────────────────────── // POST /api/system-notices/:id/dismiss // ───────────────────────────────────────────────────────────────────────────── describe('POST /api/system-notices/:id/dismiss', () => { it('returns 401 without auth', async () => { const res = await request(app).post('/api/system-notices/test-id/dismiss'); expect(res.status).toBe(401); }); it('returns 404 for unknown notice id', async () => { const { user } = createUser(testDb); const res = await request(app) .post('/api/system-notices/nonexistent-id/dismiss') .set('Cookie', authCookie(user.id)); expect(res.status).toBe(404); expect(res.body.error).toBe('NOTICE_NOT_FOUND'); }); it('returns 204 for valid notice id', async () => { SYSTEM_NOTICES.push(TEST_NOTICE); try { const { user } = createUser(testDb); const res = await request(app) .post(`/api/system-notices/${TEST_NOTICE.id}/dismiss`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(204); } finally { const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE); if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1); } }); it('is idempotent — second dismiss also returns 204', async () => { SYSTEM_NOTICES.push(TEST_NOTICE); try { const { user } = createUser(testDb); const first = await request(app) .post(`/api/system-notices/${TEST_NOTICE.id}/dismiss`) .set('Cookie', authCookie(user.id)); expect(first.status).toBe(204); const second = await request(app) .post(`/api/system-notices/${TEST_NOTICE.id}/dismiss`) .set('Cookie', authCookie(user.id)); expect(second.status).toBe(204); } finally { const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE); if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1); } }); it('dismiss appears in GET /active as filtered out', async () => { SYSTEM_NOTICES.push(TEST_NOTICE); try { const { user } = createUser(testDb); await updateRows(orm, Users, { id: user.id }, { login_count: 1 }); // Confirm TEST_NOTICE is visible before dismiss const before = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id)); expect(before.body.find((n: { id: string }) => n.id === TEST_NOTICE.id)).toBeDefined(); // Dismiss it await request(app).post(`/api/system-notices/${TEST_NOTICE.id}/dismiss`).set('Cookie', authCookie(user.id)); // Confirm TEST_NOTICE is gone; other notices (e.g. welcome-v1) may still appear const after = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id)); expect(after.status).toBe(200); expect(after.body.find((n: { id: string }) => n.id === TEST_NOTICE.id)).toBeUndefined(); } finally { const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE); if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1); } }); }); // ───────────────────────────────────────────────────────────────────────────── // v3014-whitespace-collision notice // ───────────────────────────────────────────────────────────────────────────── /** * Helper: creates an admin user whose first_seen_version is before 3.0.14 * (so existingUserBeforeVersion('3.0.14') passes) and whose login_count is * high enough to suppress the firstLogin and v3-upgrade notice conditions. */ async function setupCollisionAdmin() { const { user } = createAdmin(testDb); await updateRows(orm, Users, { id: user.id }, { login_count: 5, first_seen_version: '3.0.0' }); return user; } describe('v3014-whitespace-collision notice', () => { const NOTICE_ID = 'v3014-whitespace-collision'; const originalAppVersion = process.env.APP_VERSION; beforeEach(() => { process.env.APP_VERSION = '3.0.14'; }); afterEach(() => { if (originalAppVersion === undefined) { delete process.env.APP_VERSION; } else { process.env.APP_VERSION = originalAppVersion; } }); it('SN-COLLISION-1 — shown to admin when collision flag is set and user predates 3.0.14', async () => { const user = await setupCollisionAdmin(); await setAppSetting(orm, 'whitespace_migration_collision', 'true'); const res = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeDefined(); }); it('SN-COLLISION-2 — hidden when collision flag is absent', async () => { const user = await setupCollisionAdmin(); const res = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined(); }); it('SN-COLLISION-3 — hidden when collision flag is explicitly false', async () => { const user = await setupCollisionAdmin(); await setAppSetting(orm, 'whitespace_migration_collision', 'false'); const res = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined(); }); it('SN-COLLISION-4 — hidden for non-admin user even when collision flag is set', async () => { const { user } = createUser(testDb); await updateRows(orm, Users, { id: user.id }, { login_count: 5, first_seen_version: '3.0.0' }); await setAppSetting(orm, 'whitespace_migration_collision', 'true'); const res = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined(); }); it('SN-COLLISION-5 — hidden for user whose first_seen_version is >= 3.0.14 (new account)', async () => { const { user } = createAdmin(testDb); await updateRows(orm, Users, { id: user.id }, { login_count: 5, first_seen_version: '3.0.14' }); await setAppSetting(orm, 'whitespace_migration_collision', 'true'); const res = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined(); }); it('SN-COLLISION-6 — hidden when app version is below 3.0.14', async () => { process.env.APP_VERSION = '3.0.13'; const user = await setupCollisionAdmin(); await setAppSetting(orm, 'whitespace_migration_collision', 'true'); const res = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined(); }); it('SN-COLLISION-7 — hidden after admin dismisses it', async () => { const user = await setupCollisionAdmin(); await setAppSetting(orm, 'whitespace_migration_collision', 'true'); const before = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id)); expect(before.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeDefined(); const dismiss = await request(app) .post(`/api/system-notices/${NOTICE_ID}/dismiss`) .set('Cookie', authCookie(user.id)); expect(dismiss.status).toBe(204); const after = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id)); expect(after.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined(); }); });