252 lines
8.5 KiB
JavaScript
252 lines
8.5 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|
|
}); |