160 lines
5.8 KiB
JavaScript
160 lines
5.8 KiB
JavaScript
/**
|
|
* 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');
|
|
});
|
|
}); |