188 lines
6.5 KiB
TypeScript
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);
|
|
});
|