geocrop-platform./apps/nextgen/server/tests/messages-conversations-orph...

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