Files
gerbeur/api/test/services/chat-service.test.ts
khannurien 134d1a0b97 v4: a moderator's or an admin's @everyone in the chat notifies every user — there was no way to reach everyone at once. The new chat:everyone permission, held by moderators and admins, lets a chat message saying @everyone notify every user but its author and those reading the chat, through notifyEveryone, after notifyMentions, so a user also named is told once, by name; the notification is a user_mentioned with everyone set, which the notifications page and the digest word as "mentioned everyone in the chat". chat_messages.mentions_everyone, sent as ChatMessage.mentionsEveryone, is set from the author's role each time the body is written, so a user's @everyone reaches no one, a moderator's edit of a user's message can't make it reach everyone, and a demoted author's next edit loses it. Only an edit that adds @everyone notifies, so fixing an announcement's typo reaches no one new. The client highlights and chimes by the flag, never by the text; Markdown shows @everyone as a mention only in a flagged message, and never links it to a profile. The chat composer offers @everyone first to those who may say it. No account may be named everyone, in any case: migration 0023 adds the column, flags the moderators' and admins' past @everyone messages, renames a user named everyone to everyone_N and drops an alias everyone. 265 backend tests and 681 frontend tests pass, and deno check, tsc, eslint, doc:lint and i18n:compile --strict are clean; checked in screenshots of the chat at 1280 and 390px, with a moderator's and a user's @everyone and the suggestions open.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:54:09 +00:00

188 lines
6.5 KiB
TypeScript

import { createUser, db, test } from "../helpers.ts";
import { assertEquals, assertThrows } from "@std/assert";
import {
chatMessageMentionsEveryone,
createChatMessage,
deleteChatMessage,
getChatMessagesAfter,
getChatReadState,
getRecentChatMessages,
markChatRead,
updateChatMessage,
} from "../../services/chat-service.ts";
import { APIException } from "../../lib/api-exception.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("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);
});
test("mentionsEveryone: set from the author's role when the body is written", async () => {
const mod = await createUser({ role: "moderator" });
const user = await createUser();
const flagged = createChatMessage(mod.id, "hi @everyone");
assertEquals(flagged.mentionsEveryone, true);
assertEquals(chatMessageMentionsEveryone(flagged.id), true);
assertEquals(
createChatMessage(user.id, "hi @everyone").mentionsEveryone,
undefined,
);
assertEquals(
createChatMessage(mod.id, "`@everyone`").mentionsEveryone,
undefined,
);
assertEquals(chatMessageMentionsEveryone(crypto.randomUUID()), false);
// Demoted, the author's next edit loses it.
db.prepare(`UPDATE users SET role = 'user' WHERE id = ?;`).run(mod.id);
const edited = updateChatMessage(flagged.id, "hi @everyone!", mod.id, false);
assertEquals(edited.mentionsEveryone, undefined);
assertEquals(chatMessageMentionsEveryone(flagged.id), false);
});