geocrop-platform./apps/nextgen/server/tests/notifications.test.js

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);
});
});