/** * Regression test for GET /api/messages/conversations. * * Guards against orphaned direct-message rows crashing the client. * Seeded automation DMs that were sent without a recipient collapsed the * per-conversation partition to NULL `other_user_id`, producing rows * with `other_user_name = NULL`. The client conversation filter calls * `c.other_user_name.toLowerCase()`, which threw on the null value and * prevented the Messages page from rendering for that user. * * The fix filters out rows where either side of the partition is NULL * and adds defensive tolerance on the client. */ 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 buildConversationsRouter(db) { const app = express(); app.use(express.json()); const auth = (req, res, next) => { const token = req.headers.authorization?.split(' ')[1]; if (!token) return res.status(401).json({ error: 'No token provided' }); try { const jwt = require('jsonwebtoken'); req.user = jwt.verify(token, JWT_SECRET); next(); } catch { res.status(401).json({ error: 'Invalid token' }); } }; app.get('/api/messages/conversations', auth, (req, res) => { const conversations = db.prepare(` SELECT * FROM ( SELECT CAST(other_user_id AS TEXT) as other_user_id, other_user_name, last_message, last_message_at, 0 as unread_count, last_sender_id, last_is_read, last_is_delivered, last_id FROM ( SELECT CASE WHEN m.sender_id = ? THEN m.recipient_id ELSE m.sender_id END as other_user_id, u.first_name || ' ' || u.last_name as other_user_name, m.body as last_message, m.created_at as last_message_at, m.sender_id as last_sender_id, m.is_read as last_is_read, m.is_delivered as last_is_delivered, m.id as last_id, ROW_NUMBER() OVER (PARTITION BY CASE WHEN m.sender_id = ? THEN m.recipient_id ELSE m.sender_id END ORDER BY m.created_at DESC) as rn FROM messages m LEFT JOIN users u ON CASE WHEN m.sender_id = ? THEN m.recipient_id ELSE m.sender_id END = u.id WHERE (m.sender_id = ? OR m.recipient_id = ?) AND m.is_deleted = 0 AND m.group_id IS NULL AND m.recipient_id IS NOT NULL AND m.sender_id IS NOT NULL ) sub WHERE rn = 1 AND other_user_id IS NOT NULL ) ORDER BY last_message_at DESC `).all(req.user.id, req.user.id, req.user.id, req.user.id, req.user.id); res.json(conversations); }); return app; } function makeTempDb() { const tmp = path.join(os.tmpdir(), `conv-orphan-${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 messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, uid TEXT UNIQUE, sender_id INTEGER, recipient_id INTEGER, subject TEXT, body TEXT, is_read INTEGER DEFAULT 0, is_delivered INTEGER DEFAULT 0, is_announcement INTEGER DEFAULT 0, group_id INTEGER, created_at DATETIME DEFAULT (datetime('now', 'localtime')), updated_at DATETIME DEFAULT (datetime('now', 'localtime')), 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'); // Orphan DM: recipient is NULL (simulates a buggy automation row) db.prepare(`INSERT INTO messages (uid, sender_id, recipient_id, subject, body) VALUES (?, ?, ?, ?, ?)`) .run('m-orphan', alice.lastInsertRowid, null, 'Auto', 'Hello from automation'); // Real DM db.prepare(`INSERT INTO messages (uid, sender_id, recipient_id, subject, body) VALUES (?, ?, ?, ?, ?)`) .run('m-real', alice.lastInsertRowid, bob.lastInsertRowid, 'Hi', 'Hello Bob'); return { db, aliceId: alice.lastInsertRowid, bobId: bob.lastInsertRowid }; } describe('GET /api/messages/conversations orphan handling', () => { let app; let token; let db; beforeAll(async () => { const built = makeTempDb(); db = built.db; app = buildConversationsRouter(db); const jwt = require('jsonwebtoken'); token = jwt.sign({ id: built.aliceId, email: 'alice@x', role: 'teacher' }, JWT_SECRET, { expiresIn: '5m' }); }); afterAll(() => { db.close(); }); it('returns no rows with NULL other_user_id or other_user_name', async () => { const res = await request(app) .get('/api/messages/conversations') .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); expect(res.body).toBeInstanceOf(Array); for (const conv of res.body) { expect(conv.other_user_id).not.toBeNull(); expect(conv.other_user_name).not.toBeNull(); } }); it('still returns legitimate conversations after dropping the orphan', async () => { const res = await request(app) .get('/api/messages/conversations') .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); expect(res.body.length).toBe(1); expect(res.body[0].other_user_name).toBe('Bob B'); }); });