// Chat history, cursors and read markers. The live side (send, edit, delete // over the socket) is covered in ws.test.ts. import { createUser, db, get, test } from "./helpers.ts"; import { assertEquals, assertThrows } from "@std/assert"; import { createChatMessage, deleteChatMessage, getChatMessagesAfter, getChatReadState, getRecentChatMessages, markChatRead, updateChatMessage, } from "../services/chat-service.ts"; import { APIException } from "../model/interfaces.ts"; function insertMessage( userId: string, body: string, createdAt: string, id?: string, ) { const messageId = id ?? crypto.randomUUID(); db.prepare( `INSERT INTO chat_messages (id, user_id, body, created_at) VALUES (?, ?, ?, ?);`, ).run(messageId, userId, body, createdAt); return messageId; } const T = (n: number) => new Date(Date.UTC(2024, 0, 1, 0, 0, n)).toISOString(); test("createChatMessage: trims, bounds, and drops a dangling reply target", async () => { const user = await createUser(); const m = createChatMessage(user.id, " hello "); assertEquals(m.body, "hello"); assertEquals(m.authorUsername, user.username); assertThrows(() => createChatMessage(user.id, " "), APIException, "empty"); assertThrows( () => createChatMessage(user.id, "x".repeat(2001)), APIException, "2000", ); createChatMessage(user.id, "x".repeat(2000)); const dangling = createChatMessage(user.id, "re", crypto.randomUUID()); assertEquals(dangling.replyToId, undefined); }); test("replies carry a snippet of their target, which goes when it's deleted", async () => { const user = await createUser(); const long = createChatMessage(user.id, "a".repeat(500)); const reply = createChatMessage(user.id, "re", long.id); assertEquals(reply.replyToId, long.id); assertEquals(reply.replyToAuthor, user.username); assertEquals(reply.replyToBody, "a".repeat(300)); deleteChatMessage(long.id, user.id, false); const [after] = getRecentChatMessages(); assertEquals(after.replyToId, long.id); assertEquals(after.replyToBody, undefined); }); test("edit and delete: the author or a moderator", async () => { const author = await createUser(); const other = await createUser(); const m = createChatMessage(author.id, "orig"); assertThrows( () => updateChatMessage(m.id, "x", other.id, false), APIException, "Not authorized", ); assertThrows( () => deleteChatMessage(m.id, other.id, false), APIException, "Not authorized", ); assertThrows( () => updateChatMessage(m.id, " ", author.id, false), APIException, "empty", ); const edited = updateChatMessage(m.id, " edited ", author.id, false); assertEquals(edited.body, "edited"); assertEquals(typeof edited.updatedAt?.getTime(), "number"); assertEquals(updateChatMessage(m.id, "mod", other.id, true).body, "mod"); deleteChatMessage(m.id, other.id, true); assertThrows( () => deleteChatMessage(m.id, author.id, false), APIException, "not found", ); }); test("history pages: newest page in ascending order, older pages by cursor", async () => { const user = await createUser(); // Two messages share a timestamp; the id breaks the tie. const ids = [ insertMessage(user.id, "m1", T(1)), insertMessage(user.id, "m2a", T(2), "00000000-0000-4000-8000-00000000000a"), insertMessage(user.id, "m2b", T(2), "00000000-0000-4000-8000-00000000000b"), insertMessage(user.id, "m3", T(3)), ]; const bodies = (ms: { body: string }[]) => ms.map((m) => m.body); assertEquals(bodies(getRecentChatMessages()), ["m1", "m2a", "m2b", "m3"]); assertEquals(bodies(getRecentChatMessages(2)), ["m2b", "m3"]); // Older than m2b: the page boundary falls between the two tied messages. assertEquals( bodies(getRecentChatMessages(10, { createdAt: T(2), id: ids[2] })), ["m1", "m2a"], ); // No id: everything strictly before the timestamp. assertEquals(bodies(getRecentChatMessages(10, { createdAt: T(2) })), ["m1"]); assertEquals( bodies(getChatMessagesAfter({ createdAt: T(2), id: ids[1] })), ["m2b", "m3"], ); // No id: inclusive of the timestamp. assertEquals(bodies(getChatMessagesAfter({ createdAt: T(2) })), [ "m2a", "m2b", "m3", ]); assertEquals(bodies(getChatMessagesAfter({ createdAt: T(0) }, 1)), ["m1"]); // Limits are clamped to 1..100. assertEquals(getRecentChatMessages(0).length, 1); assertEquals(getRecentChatMessages(-5).length, 1); }); test("GET /api/chat: members only, with limit and both cursors", async () => { const user = await createUser(); const ids = [1, 2, 3].map((n) => insertMessage(user.id, `m${n}`, T(n))); assertEquals((await get("/api/chat")).status, 401); const bodies = async (q: string) => (await get(`/api/chat${q}`, { auth: user.auth })).body.data.map( (m: { body: string }) => m.body, ); assertEquals(await bodies(""), ["m1", "m2", "m3"]); assertEquals(await bodies("?limit=1"), ["m3"]); assertEquals(await bodies("?limit=abc"), ["m1", "m2", "m3"]); assertEquals(await bodies(`?before=${T(3)}&beforeId=${ids[2]}`), [ "m1", "m2", ]); assertEquals(await bodies(`?after=${T(1)}&afterId=${ids[0]}`), ["m2", "m3"]); }); test("read state: unread counts others' messages since the marker", async () => { const me = await createUser({ createdAt: T(0) }); const other = await createUser(); insertMessage(other.id, "a", T(1)); insertMessage(me.id, "mine", T(2)); insertMessage(other.id, "b", T(3)); // No marker yet: everything since the account was created. assertEquals(getChatReadState(me.id), { readAt: T(0), unread: 2 }); assertEquals(markChatRead(me.id, T(1)), { readAt: T(1), unread: 1 }); // Never backward. assertEquals(markChatRead(me.id, T(0)).readAt, T(1)); // Never past the newest message. assertEquals(markChatRead(me.id, "2999-01-01T00:00:00Z"), { readAt: T(3), unread: 0, }); assertThrows( () => markChatRead(me.id, "not a date"), APIException, "Invalid", ); assertThrows( () => getChatReadState(crypto.randomUUID()), APIException, "not found", ); }); test("read state: with no messages at all the marker can't be moved", async () => { const me = await createUser({ createdAt: T(0) }); assertEquals(markChatRead(me.id, "2999-01-01T00:00:00Z"), { readAt: T(0), unread: 0, }); const other = await createUser(); insertMessage(other.id, "later", T(5)); assertEquals(getChatReadState(me.id).unread, 1); });