/** * Notifications controller — inbox + unread-count + mark-read behavior. * * Covers the wired paths: * - GET /api/notifications (inbox + unread filter) * - GET /api/notifications/unread-count * - POST /api/notifications/:id/read * - POST /api/notifications/read-all * - notifyMessageRecipients() helper used by the messages controller */ const express = require('express'); const request = require('supertest'); const bcrypt = require('bcryptjs'); const Database = require('better-sqlite3'); const path = require('path'); const os = require('os'); const { jwtSecret: JWT_SECRET } = require('../src/config'); function makeDb() { const tmp = path.join(os.tmpdir(), `notifications-${Date.now()}.db`); const db = new Database(tmp); db.pragma('foreign_keys = ON'); db.exec(` CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, uid TEXT UNIQUE, email TEXT, password TEXT, role TEXT, first_name TEXT, last_name TEXT, is_active INTEGER DEFAULT 1, is_deleted INTEGER DEFAULT 0 ); CREATE TABLE notifications ( id INTEGER PRIMARY KEY AUTOINCREMENT, uid TEXT UNIQUE, recipient_id INTEGER NOT NULL REFERENCES users(id), type TEXT NOT NULL CHECK (type IN ('in_app','email','sms')), subject TEXT, body TEXT, related_resource TEXT, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','failed','read')), sent_at DATETIME, read_at DATETIME, error_message TEXT, metadata TEXT, created_at DATETIME DEFAULT (datetime('now','localtime')), updated_at DATETIME DEFAULT (datetime('now','localtime')), last_synced_at DATETIME, sync_status TEXT DEFAULT 'pending', is_deleted INTEGER DEFAULT 0 ); `); const hash = bcrypt.hashSync('secret123', 4); const alice = db.prepare(`INSERT INTO users (uid,email,password,role,first_name,last_name) VALUES (?,?,?,?,?,?)`) .run('u-alice', 'alice@x', hash, 'teacher', 'Alice', 'A'); const bob = db.prepare(`INSERT INTO users (uid,email,password,role,first_name,last_name) VALUES (?,?,?,?,?,?)`) .run('u-bob', 'bob@x', hash, 'teacher', 'Bob', 'B'); return { db, aliceId: alice.lastInsertRowid, bobId: bob.lastInsertRowid }; } function buildApp(db) { // Load the controller fresh against our test DB. The module holds a // cached Database singleton internally, so we swap `process.env.DB_PATH` // before requiring it. process.env.DB_PATH = db.name; // Clear require cache so the controller re-initializes against the // swapped DB path. const ctrlPath = require.resolve('../src/controllers/notifications.controller'); delete require.cache[ctrlPath]; const controller = require('../src/controllers/notifications.controller'); const app = express(); app.use(express.json()); app.use('/api/notifications', controller); return app; } function token(id, role) { const jwt = require('jsonwebtoken'); return jwt.sign({ id, email: 'x@x', role }, JWT_SECRET, { expiresIn: '5m' }); } describe('Notifications controller', () => { let app; let db; let aliceToken; let bobToken; let aliceId; let bobId; beforeAll(() => { const built = makeDb(); db = built.db; aliceId = built.aliceId; bobId = built.bobId; app = buildApp(db); aliceToken = token(aliceId, 'teacher'); bobToken = token(bobId, 'teacher'); }); afterAll(() => { db.close(); }); test('GET /api/notifications returns 401 without a token', async () => { const res = await request(app).get('/api/notifications'); expect(res.status).toBe(401); }); test('GET /api/notifications/unread-count starts at 0', async () => { const res = await request(app) .get('/api/notifications/unread-count') .set('Authorization', `Bearer ${aliceToken}`); expect(res.status).toBe(200); expect(res.body.count).toBe(0); }); test('notifyMessageRecipients writes one row per recipient and skips the sender', async () => { const { notifyMessageRecipients } = require('../src/controllers/notifications.controller'); notifyMessageRecipients({ recipients: [aliceId, bobId, aliceId], // alice appears twice (dedupe by id logic in messages.controller, here we just write per id) sender_id: aliceId, subject: 'Hi from Alice', body: 'Hello', kind: 'message', related_resource: 'message:42', }); // Helper writes per recipient; dedupe is handled by the caller in // production. Two rows here (alice + bob) — but the helper skips // the sender, so only one row lands in bob's inbox. const bobInbox = await request(app) .get('/api/notifications') .set('Authorization', `Bearer ${bobToken}`); expect(bobInbox.body.length).toBe(1); expect(bobInbox.body[0].subject).toBe('Hi from Alice'); expect(bobInbox.body[0].related_resource).toBe('message:42'); const aliceInbox = await request(app) .get('/api/notifications') .set('Authorization', `Bearer ${aliceToken}`); expect(aliceInbox.body.length).toBe(0); }); test('GET /api/notifications/unread-count reflects new messages', async () => { const res = await request(app) .get('/api/notifications/unread-count') .set('Authorization', `Bearer ${bobToken}`); expect(res.body.count).toBe(1); }); test('GET /api/notifications?unread=true filters to unread only', async () => { const res = await request(app) .get('/api/notifications?unread=true') .set('Authorization', `Bearer ${bobToken}`); expect(res.status).toBe(200); expect(res.body.length).toBe(1); }); test('POST /api/notifications/:id/read flips the row and returns changed=true', async () => { const inbox = await request(app) .get('/api/notifications') .set('Authorization', `Bearer ${bobToken}`); const id = inbox.body[0].id; const res = await request(app) .post(`/api/notifications/${id}/read`) .set('Authorization', `Bearer ${bobToken}`); expect(res.status).toBe(200); expect(res.body.changed).toBe(true); const after = await request(app) .get('/api/notifications/unread-count') .set('Authorization', `Bearer ${bobToken}`); expect(after.body.count).toBe(0); const second = await request(app) .post(`/api/notifications/${id}/read`) .set('Authorization', `Bearer ${bobToken}`); expect(second.body.changed).toBe(false); }); test('POST /api/notifications/:id/read returns 400 for an invalid id', async () => { const res = await request(app) .post('/api/notifications/not-a-number/read') .set('Authorization', `Bearer ${bobToken}`); expect(res.status).toBe(400); }); test('POST /api/notifications/read-all marks every row for the caller', async () => { // Add two new rows to Bob's inbox const { notifyMessageRecipients } = require('../src/controllers/notifications.controller'); notifyMessageRecipients({ recipients: [bobId], sender_id: aliceId, subject: 'second', body: '', kind: 'message', }); notifyMessageRecipients({ recipients: [bobId], sender_id: aliceId, subject: 'third', body: '', kind: 'message', }); const before = await request(app) .get('/api/notifications/unread-count') .set('Authorization', `Bearer ${bobToken}`); expect(before.body.count).toBe(2); const res = await request(app) .post('/api/notifications/read-all') .set('Authorization', `Bearer ${bobToken}`); expect(res.status).toBe(200); expect(res.body.changed).toBe(2); const after = await request(app) .get('/api/notifications/unread-count') .set('Authorization', `Bearer ${bobToken}`); expect(after.body.count).toBe(0); }); test('mark-read only affects the caller\'s notifications', async () => { const { notifyMessageRecipients } = require('../src/controllers/notifications.controller'); notifyMessageRecipients({ recipients: [aliceId], sender_id: bobId, subject: 'for alice', body: '', }); const aliceInbox = await request(app) .get('/api/notifications') .set('Authorization', `Bearer ${aliceToken}`); const id = aliceInbox.body[0].id; // Bob should not be able to mark Alice's notification read. const res = await request(app) .post(`/api/notifications/${id}/read`) .set('Authorization', `Bearer ${bobToken}`); expect(res.body.changed).toBe(false); const still = await request(app) .get('/api/notifications/unread-count') .set('Authorization', `Bearer ${aliceToken}`); expect(still.body.count).toBe(1); }); });