Spaces:
Sleeping
Sleeping
| /** | |
| * System Notices API integration tests. | |
| * Covers GET /api/system-notices/active and POST /api/system-notices/:id/dismiss. | |
| */ | |
| import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; | |
| import request from 'supertest'; | |
| import type { Application } from 'express'; | |
| import type { INestApplication } from '@nestjs/common'; | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Bare in-memory DB β schema applied in beforeAll after mocks register | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const { testDb, dbMock } = vi.hoisted(() => { | |
| const Database = require('better-sqlite3'); | |
| const db = new Database(':memory:'); | |
| db.exec('PRAGMA journal_mode = WAL'); | |
| db.exec('PRAGMA foreign_keys = ON'); | |
| db.exec('PRAGMA busy_timeout = 5000'); | |
| const mock = { | |
| db, | |
| closeDb: () => {}, | |
| reinitialize: () => {}, | |
| getPlaceWithTags: () => null, | |
| canAccessTrip: () => null, | |
| isOwner: () => false, | |
| }; | |
| return { testDb: db, dbMock: mock }; | |
| }); | |
| vi.mock('../../src/db/database', () => dbMock); | |
| vi.mock('../../src/config', () => ({ | |
| JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', | |
| ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', | |
| updateJwtSecret: () => {}, | |
| SESSION_DURATION: '24h', | |
| SESSION_DURATION_MS: 86400000, | |
| SESSION_DURATION_SECONDS: 86400, | |
| DEFAULT_LANGUAGE: 'en', | |
| })); | |
| vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); | |
| import { buildApp } from '../../src/bootstrap'; | |
| import { createTables } from '../../src/db/schema'; | |
| import { runMigrations } from '../../src/db/migrations'; | |
| import { resetTestDb } from '../helpers/test-db'; | |
| import { createUser, createAdmin } from '../helpers/factories'; | |
| import { authCookie } from '../helpers/auth'; | |
| import { SYSTEM_NOTICES } from '../../src/systemNotices/registry'; | |
| import type { SystemNotice } from '../../src/systemNotices/types'; | |
| let nestApp: INestApplication; | |
| let app: Application; | |
| // 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, | |
| }; | |
| beforeAll(async () => { | |
| createTables(testDb); | |
| runMigrations(testDb); | |
| nestApp = await buildApp(); | |
| app = nestApp.getHttpAdapter().getInstance(); | |
| }); | |
| 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 empty array for non-first-login user with no applicable notices', async () => { | |
| const { user } = createUser(testDb); | |
| // login_count > 1 means firstLogin condition does not match for any notice; | |
| // first_seen_version >= 3.0.0 means existingUserBeforeVersion('3.0.0') also does not match | |
| testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id); | |
| const res = await request(app) | |
| .get('/api/system-notices/active') | |
| .set('Cookie', authCookie(user.id)); | |
| expect(res.status).toBe(200); | |
| expect(res.body).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) | |
| testDb.prepare('UPDATE users SET login_count = 1 WHERE id = ?').run(user.id); | |
| const res = await request(app) | |
| .get('/api/system-notices/active') | |
| .set('Cookie', authCookie(user.id)); | |
| expect(res.status).toBe(200); | |
| // welcome-v1 is also in the registry and matches firstLogin, so at least TEST_NOTICE is present | |
| 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('does not return firstLogin notice for user with login_count > 1', async () => { | |
| SYSTEM_NOTICES.push(TEST_NOTICE); | |
| try { | |
| const { user } = createUser(testDb); | |
| testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id); | |
| const res = await request(app) | |
| .get('/api/system-notices/active') | |
| .set('Cookie', authCookie(user.id)); | |
| expect(res.status).toBe(200); | |
| expect(res.body).toEqual([]); | |
| } 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); | |
| testDb.prepare('UPDATE users SET login_count = 1 WHERE id = ?').run(user.id); | |
| // Dismiss the notice directly in DB | |
| testDb.prepare( | |
| 'INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at) VALUES (?, ?, ?)' | |
| ).run(user.id, TEST_NOTICE.id, 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; welcome-v1 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); | |
| } | |
| }); | |
| }); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // 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); | |
| testDb.prepare('UPDATE users SET login_count = 1 WHERE id = ?').run(user.id); | |
| // 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. | |
| */ | |
| function setupCollisionAdmin() { | |
| const { user } = createAdmin(testDb); | |
| testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id); | |
| 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 = setupCollisionAdmin(); | |
| testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run(); | |
| 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 = 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 = setupCollisionAdmin(); | |
| testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'false')").run(); | |
| 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); | |
| testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id); | |
| testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run(); | |
| 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); | |
| testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.14', user.id); | |
| testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run(); | |
| 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 = setupCollisionAdmin(); | |
| testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run(); | |
| 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 = setupCollisionAdmin(); | |
| testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run(); | |
| 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(); | |
| }); | |
| }); | |