mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Merge branch 'feat/system-notices' into dev
This commit is contained in:
@@ -91,6 +91,8 @@ const RESET_TABLES = [
|
||||
'notification_channel_preferences',
|
||||
'notifications',
|
||||
'audit_log',
|
||||
// System notices
|
||||
'user_notice_dismissals',
|
||||
// User data
|
||||
'settings',
|
||||
'mcp_tokens',
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { SYSTEM_NOTICES } from '../../src/systemNotices/registry';
|
||||
import type { SystemNotice } from '../../src/systemNotices/types';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
// 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(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
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
|
||||
testDb.prepare('UPDATE users SET login_count = 5 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);
|
||||
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, expiresAt, priority
|
||||
expect(testNotice.conditions).toBeUndefined();
|
||||
expect(testNotice.publishedAt).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 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);
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { evaluate } from '../../../src/systemNotices/conditions.js';
|
||||
import type { SystemNotice } from '../../../src/systemNotices/types.js';
|
||||
|
||||
const baseNotice: SystemNotice = {
|
||||
id: 'test',
|
||||
display: 'modal',
|
||||
severity: 'info',
|
||||
titleKey: 'k.title',
|
||||
bodyKey: 'k.body',
|
||||
dismissible: true,
|
||||
conditions: [],
|
||||
publishedAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const baseCtx = {
|
||||
user: { login_count: 5, first_seen_version: '1.0.0', role: 'user' },
|
||||
currentAppVersion: '2.0.0',
|
||||
now: new Date('2026-06-01T00:00:00Z'),
|
||||
};
|
||||
|
||||
describe('firstLogin', () => {
|
||||
const notice = { ...baseNotice, conditions: [{ kind: 'firstLogin' as const }] };
|
||||
it('passes when login_count <= 1', () => {
|
||||
expect(evaluate(notice, { ...baseCtx, user: { ...baseCtx.user, login_count: 1 } })).toBe(true);
|
||||
});
|
||||
it('fails when login_count > 1', () => {
|
||||
expect(evaluate(notice, baseCtx)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('existingUserBeforeVersion', () => {
|
||||
const notice = { ...baseNotice, conditions: [{ kind: 'existingUserBeforeVersion' as const, version: '2.0.0' }] };
|
||||
it('passes for user with first_seen_version < notice version when current >= notice version', () => {
|
||||
expect(evaluate(notice, baseCtx)).toBe(true);
|
||||
});
|
||||
it('fails for new user (first_seen_version >= notice version)', () => {
|
||||
expect(evaluate(notice, { ...baseCtx, user: { ...baseCtx.user, first_seen_version: '2.0.0' } })).toBe(false);
|
||||
});
|
||||
it('fails when current app version < notice version', () => {
|
||||
expect(evaluate(notice, { ...baseCtx, currentAppVersion: '1.5.0' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dateWindow', () => {
|
||||
it('passes when now is inside window', () => {
|
||||
const notice = { ...baseNotice, conditions: [{ kind: 'dateWindow' as const, startsAt: '2026-05-01T00:00:00Z', endsAt: '2026-07-01T00:00:00Z' }] };
|
||||
expect(evaluate(notice, baseCtx)).toBe(true);
|
||||
});
|
||||
it('fails when now is before start', () => {
|
||||
const notice = { ...baseNotice, conditions: [{ kind: 'dateWindow' as const, startsAt: '2026-07-01T00:00:00Z' }] };
|
||||
expect(evaluate(notice, baseCtx)).toBe(false);
|
||||
});
|
||||
it('passes when no endsAt', () => {
|
||||
const notice = { ...baseNotice, conditions: [{ kind: 'dateWindow' as const, startsAt: '2026-01-01T00:00:00Z' }] };
|
||||
expect(evaluate(notice, baseCtx)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('role', () => {
|
||||
it('passes for matching role', () => {
|
||||
const notice = { ...baseNotice, conditions: [{ kind: 'role' as const, roles: ['user'] }] };
|
||||
expect(evaluate(notice, baseCtx)).toBe(true);
|
||||
});
|
||||
it('fails for non-matching role', () => {
|
||||
const notice = { ...baseNotice, conditions: [{ kind: 'role' as const, roles: ['admin'] }] };
|
||||
expect(evaluate(notice, baseCtx)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AND logic', () => {
|
||||
it('requires all conditions to pass', () => {
|
||||
const notice = { ...baseNotice, conditions: [
|
||||
{ kind: 'firstLogin' as const },
|
||||
{ kind: 'role' as const, roles: ['user'] },
|
||||
]};
|
||||
// login_count=1 passes firstLogin, role=user passes role → true
|
||||
expect(evaluate(notice, { ...baseCtx, user: { ...baseCtx.user, login_count: 1 } })).toBe(true);
|
||||
// login_count=2 fails firstLogin → false
|
||||
expect(evaluate(notice, baseCtx)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty conditions', () => {
|
||||
it('always passes when conditions array is empty', () => {
|
||||
expect(evaluate(baseNotice, baseCtx)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { SYSTEM_NOTICES } from '../../../src/systemNotices/registry.js';
|
||||
|
||||
/** Collect all actionIds registered via registerNoticeAction() in client source files. */
|
||||
function collectRegisteredActionIds(): Set<string> {
|
||||
const clientSrc = path.resolve(__dirname, '../../../../client/src');
|
||||
const ids = new Set<string>();
|
||||
const queue = [clientSrc];
|
||||
while (queue.length) {
|
||||
const dir = queue.pop()!;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) { queue.push(full); continue; }
|
||||
if (!entry.name.endsWith('noticeActions.ts') && !entry.name.endsWith('noticeActions.js')) continue;
|
||||
const src = fs.readFileSync(full, 'utf8');
|
||||
for (const m of src.matchAll(/registerNoticeAction\(\s*['"]([^'"]+)['"]/g)) {
|
||||
ids.add(m[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
describe('registry integrity', () => {
|
||||
it('has no duplicate ids', () => {
|
||||
const ids = SYSTEM_NOTICES.map(n => n.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('all action CTAs reference a registered actionId', () => {
|
||||
const registeredActionIds = collectRegisteredActionIds();
|
||||
const actionCtaIds = SYSTEM_NOTICES
|
||||
.filter(n => n.cta?.kind === 'action')
|
||||
.map(n => (n.cta as { actionId: string }).actionId);
|
||||
|
||||
for (const id of actionCtaIds) {
|
||||
expect(registeredActionIds, `actionId "${id}" not found in any client noticeActions.ts`).toContain(id);
|
||||
}
|
||||
});
|
||||
|
||||
it('all publishedAt are valid ISO dates', () => {
|
||||
for (const n of SYSTEM_NOTICES) {
|
||||
expect(() => new Date(n.publishedAt).toISOString()).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user