mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* test(server): admin/handlers/channels — test-audit 2026-08-19 fixes * test(server): api/constants — test-audit 2026-08-19 fixes * test(server): api/middleware — test-audit 2026-08-19 fixes * test(server): api/waf — test-audit 2026-08-19 fixes * test(server): auth/totp/encrypt — test-audit 2026-08-19 fixes * test(server): db/session/expiry/test — test-audit 2026-08-19 fixes * test(server): migrations/030/attachments/unlink/on/message/delete — test-audit 2026-08-19 fixes * test(server): updater/download — test-audit 2026-08-19 fixes * test(server): ws/handlers_command — test-audit 2026-08-19 fixes * test(server): ws/hub/broadcast — test-audit 2026-08-19 fixes * test(server): ws/hub/events — test-audit 2026-08-19 fixes * test(server): ws/livekit/webhook — test-audit 2026-08-19 fixes * test(server): ws/voice/controls — test-audit 2026-08-19 fixes * test(server): ws/voice/join — test-audit 2026-08-19 fixes * test(server): ws/voice/moderation — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/commands.rs — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/secret_store.rs — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/update_commands.rs — test-audit 2026-08-19 fixes * test(client): src/components/ChannelSidebar.ts — test-audit 2026-08-19 fixes * test(client): src/lib/ws.ts — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/credentials.rs — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/tofu.rs — test-audit 2026-08-19 fixes * test(client): src/lib/hostValidation.ts — test-audit 2026-08-19 fixes * test(client): src/lib/rate-limiter.ts — test-audit 2026-08-19 fixes * test(client): src/pages/connect-page/LoginForm.ts — test-audit 2026-08-19 fixes * test(client): src/pages/main-page/SidebarArea.ts — test-audit 2026-08-19 fixes * test(client): src/stores/voice.store.ts — test-audit 2026-08-19 fixes * test(client): tests/browser/smoke.test.ts — test-audit 2026-08-19 fixes * test(client): tests/unit/media.test.ts — test-audit 2026-08-19 fixes * test(client): tests/unit/renderers.test.ts — test-audit 2026-08-19 fixes * test(client): src/components/UserProfilePopup.ts — test-audit 2026-08-19 fixes * test(client): src/lib/e2eeCrypto.ts — test-audit 2026-08-19 fixes * test(client): tests/unit/log-persistence.test.ts — test-audit 2026-08-19 fixes * test(client): keep tests/browser out of the jsdom suite and run it in CI * test(server): ws/hub_broadcast_test.go — bytes.Equal payload compare (gocritic) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(client): src/lib/credentials.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/dispatcher.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/permissions.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/rate-limiter.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/hostValidation.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/stores/messages.store.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/e2eeCrypto.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/ws.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/identity.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/livekitE2EE.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/stores/auth.store.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/stores/voice.store.ts — test-audit 2026-08-19 round 2 (Stryker) * docs: test audit 2026-08-19 — findings, fixes, measured baselines Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(graph): refresh the knowledge graph after the 2026-08-19 test audit Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1755 lines
63 KiB
TypeScript
1755 lines
63 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import {
|
|
formatTime,
|
|
formatFullDate,
|
|
formatMessageTimestamp,
|
|
isSameDay,
|
|
shouldGroup,
|
|
renderDayDivider,
|
|
renderMessage,
|
|
renderMentions,
|
|
renderMentionSegment,
|
|
renderInlineContent,
|
|
renderMessageContent,
|
|
getUserRole,
|
|
roleColorVar,
|
|
GROUP_THRESHOLD_MS,
|
|
setServerHost,
|
|
} from "../../src/components/message-list/renderers";
|
|
import type { Message } from "../../src/stores/messages.store";
|
|
import { membersStore } from "../../src/stores/members.store";
|
|
import { channelsStore, setRoles } from "../../src/stores/channels.store";
|
|
import { authStore } from "../../src/stores/auth.store";
|
|
import type { MessageListOptions } from "../../src/components/MessageList";
|
|
import {
|
|
clearReactionUsersCache,
|
|
setReactionUsersFetcher,
|
|
} from "../../src/components/message-list/reaction-tooltip";
|
|
|
|
function resetStores(): void {
|
|
membersStore.setState(() => ({
|
|
members: new Map(),
|
|
typingUsers: new Map(),
|
|
}));
|
|
channelsStore.setState((prev) => ({ ...prev, roles: [] }));
|
|
authStore.setState(() => ({
|
|
token: null,
|
|
user: null,
|
|
serverName: null,
|
|
motd: null,
|
|
isAuthenticated: false,
|
|
}));
|
|
}
|
|
|
|
/** Seed the member list so @tokens resolve — unresolvable tokens stay plain text. */
|
|
function seedMentionMembers(): void {
|
|
membersStore.setState((prev) => ({
|
|
...prev,
|
|
members: new Map([
|
|
[10, { id: 10, username: "alice", avatar: null, role: "member", status: "online" as const }],
|
|
[11, { id: 11, username: "bob", avatar: null, role: "member", status: "online" as const }],
|
|
]),
|
|
}));
|
|
}
|
|
|
|
function makeMessage(overrides: Partial<Message> = {}): Message {
|
|
return {
|
|
id: 1,
|
|
channelId: 1,
|
|
user: { id: 10, username: "Alice", avatar: null },
|
|
content: "Hello world",
|
|
replyTo: null,
|
|
attachments: [],
|
|
reactions: [],
|
|
pinned: false,
|
|
editedAt: null,
|
|
deleted: false,
|
|
timestamp: "2025-01-15T12:30:00Z",
|
|
status: "sent",
|
|
correlationId: null,
|
|
errorCode: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeOpts(overrides: Partial<MessageListOptions> = {}): MessageListOptions {
|
|
return {
|
|
channelId: 1,
|
|
channelName: "general",
|
|
currentUserId: 10,
|
|
onScrollTop: vi.fn(),
|
|
onReplyClick: vi.fn(),
|
|
onEditClick: vi.fn(),
|
|
onDeleteClick: vi.fn(),
|
|
onReactionClick: vi.fn(),
|
|
onPinClick: vi.fn(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("renderers", () => {
|
|
let container: HTMLDivElement;
|
|
|
|
beforeEach(() => {
|
|
resetStores();
|
|
// jsdom sets window.location.origin to "null", which breaks new URL(relativeUrl, origin).
|
|
// Set a server host so resolveServerUrl converts relative paths to absolute URLs before isSafeUrl parses them.
|
|
setServerHost("localhost:8080");
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
setServerHost("");
|
|
container.remove();
|
|
});
|
|
|
|
describe("formatTime", () => {
|
|
it("formats ISO timestamp to HH:MM", () => {
|
|
const result = formatTime("2025-01-15T09:05:00Z");
|
|
// Result depends on timezone but should be formatted as HH:MM
|
|
expect(result).toMatch(/^\d{2}:\d{2}$/);
|
|
});
|
|
});
|
|
|
|
describe("formatFullDate", () => {
|
|
it("formats ISO timestamp to full date string", () => {
|
|
const result = formatFullDate("2025-01-15T12:00:00Z");
|
|
expect(result).toContain("2025");
|
|
expect(result).toContain("January");
|
|
});
|
|
});
|
|
|
|
describe("isSameDay", () => {
|
|
it("returns true for timestamps on the same day", () => {
|
|
expect(isSameDay("2025-01-15T08:00:00Z", "2025-01-15T20:00:00Z")).toBe(true);
|
|
});
|
|
|
|
it("returns false for timestamps on different days", () => {
|
|
expect(isSameDay("2025-01-15T08:00:00Z", "2025-01-16T08:00:00Z")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("shouldGroup", () => {
|
|
it("returns true for same user within threshold", () => {
|
|
const prev = makeMessage({ timestamp: "2025-01-15T12:00:00Z" });
|
|
const curr = makeMessage({ id: 2, timestamp: "2025-01-15T12:04:00Z" });
|
|
expect(shouldGroup(prev, curr)).toBe(true);
|
|
});
|
|
|
|
it("returns false for different users", () => {
|
|
const prev = makeMessage({ user: { id: 10, username: "Alice", avatar: null } });
|
|
const curr = makeMessage({
|
|
id: 2,
|
|
user: { id: 20, username: "Bob", avatar: null },
|
|
timestamp: "2025-01-15T12:31:00Z",
|
|
});
|
|
expect(shouldGroup(prev, curr)).toBe(false);
|
|
});
|
|
|
|
it("returns false when time difference exceeds threshold", () => {
|
|
const prev = makeMessage({ timestamp: "2025-01-15T12:00:00Z" });
|
|
const curr = makeMessage({
|
|
id: 2,
|
|
timestamp: "2025-01-15T12:06:00Z",
|
|
});
|
|
expect(shouldGroup(prev, curr)).toBe(false);
|
|
});
|
|
|
|
it("returns false when either message is deleted", () => {
|
|
const prev = makeMessage({ deleted: true });
|
|
const curr = makeMessage({ id: 2, timestamp: "2025-01-15T12:31:00Z" });
|
|
expect(shouldGroup(prev, curr)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("renderDayDivider", () => {
|
|
it("creates a day divider element with formatted date", () => {
|
|
const divider = renderDayDivider("2025-01-15T12:00:00Z");
|
|
container.appendChild(divider);
|
|
|
|
expect(divider.classList.contains("msg-day-divider")).toBe(true);
|
|
const dateEl = divider.querySelector(".date");
|
|
expect(dateEl).not.toBeNull();
|
|
expect(dateEl!.textContent).toContain("January");
|
|
expect(dateEl!.textContent).toContain("2025");
|
|
});
|
|
|
|
it("includes line elements", () => {
|
|
const divider = renderDayDivider("2025-01-15T12:00:00Z");
|
|
const lines = divider.querySelectorAll(".line");
|
|
expect(lines.length).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("renderMentions", () => {
|
|
it("wraps resolvable @mentions in span with mention class", () => {
|
|
seedMentionMembers();
|
|
const fragment = renderMentions("Hello @alice how are you?");
|
|
container.appendChild(fragment);
|
|
|
|
const mention = container.querySelector(".mention");
|
|
expect(mention).not.toBeNull();
|
|
expect(mention!.textContent).toBe("@alice");
|
|
});
|
|
|
|
it("renders plain text without mentions", () => {
|
|
const fragment = renderMentions("Hello world");
|
|
container.appendChild(fragment);
|
|
|
|
expect(container.querySelector(".mention")).toBeNull();
|
|
expect(container.textContent).toBe("Hello world");
|
|
});
|
|
|
|
it("handles multiple mentions", () => {
|
|
seedMentionMembers();
|
|
const fragment = renderMentions("@alice and @bob");
|
|
container.appendChild(fragment);
|
|
|
|
const mentions = container.querySelectorAll(".mention");
|
|
expect(mentions.length).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("renderMessage", () => {
|
|
it("renders a basic message with author and content", () => {
|
|
const msg = makeMessage();
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
expect(el.getAttribute("data-testid")).toBe("message-1");
|
|
expect(container.querySelector(".msg-author")?.textContent).toBe("Alice");
|
|
expect(container.querySelector(".msg-text")?.textContent).toBe("Hello world");
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("renders grouped messages with grouped class", () => {
|
|
const msg = makeMessage();
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, true, [msg], makeOpts(), ac.signal);
|
|
|
|
expect(el.classList.contains("grouped")).toBe(true);
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("marks a pending optimistic row and hides the action bar", () => {
|
|
const msg = makeMessage({ status: "pending", correlationId: "c1", id: 0 });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
expect(el.classList.contains("pending")).toBe(true);
|
|
// No hover actions on an unsent row.
|
|
expect(container.querySelector(".msg-actions-bar")).toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("renders a failed row with reason, retry, and discard", () => {
|
|
const onRetry = vi.fn();
|
|
const onDeleteDraft = vi.fn();
|
|
const msg = makeMessage({
|
|
status: "failed",
|
|
correlationId: "c1",
|
|
id: 0,
|
|
errorCode: "SLOW_MODE",
|
|
});
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts({ onRetry, onDeleteDraft }), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
expect(el.classList.contains("failed")).toBe(true);
|
|
expect(container.querySelector(".msg-send-failed-text")?.textContent).toContain("Slow mode");
|
|
|
|
const retry = container.querySelector<HTMLButtonElement>('[data-testid="msg-retry-c1"]');
|
|
const discard = container.querySelector<HTMLButtonElement>('[data-testid="msg-discard-c1"]');
|
|
expect(retry).not.toBeNull();
|
|
expect(discard).not.toBeNull();
|
|
|
|
retry!.click();
|
|
discard!.click();
|
|
expect(onRetry).toHaveBeenCalledWith("c1");
|
|
expect(onDeleteDraft).toHaveBeenCalledWith("c1");
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("renders a transport-failure (NETWORK) row with the connection-problem reason", () => {
|
|
const msg = makeMessage({
|
|
status: "failed",
|
|
correlationId: "c2",
|
|
id: 0,
|
|
errorCode: "NETWORK",
|
|
});
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
expect(el.classList.contains("failed")).toBe(true);
|
|
expect(container.querySelector(".msg-send-failed-text")?.textContent).toBe(
|
|
"Connection problem — message not sent",
|
|
);
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("renders deleted message with italic text", () => {
|
|
const msg = makeMessage({ deleted: true });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const text = container.querySelector(".msg-text");
|
|
expect(text?.textContent).toBe("[message deleted]");
|
|
expect((text as HTMLElement)?.style.fontStyle).toBe("italic");
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("shows (edited) tag for edited messages", () => {
|
|
const msg = makeMessage({ editedAt: "2025-01-15T13:00:00Z" });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const edited = container.querySelector(".msg-edited");
|
|
expect(edited).not.toBeNull();
|
|
expect(edited!.textContent).toBe("(edited)");
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("renders system messages differently", () => {
|
|
const msg = makeMessage({
|
|
user: { id: 0, username: "System", avatar: null },
|
|
content: "Alice joined the server",
|
|
});
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
expect(container.querySelector(".system-msg")).not.toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("renders reply reference when replyTo is set", () => {
|
|
const original = makeMessage({ id: 1, content: "Original message" });
|
|
const reply = makeMessage({ id: 2, replyTo: 1, content: "This is a reply" });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(reply, false, [original, reply], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const replyRef = container.querySelector(".msg-reply-ref");
|
|
expect(replyRef).not.toBeNull();
|
|
expect(replyRef!.querySelector(".rr-author")?.textContent).toBe("Alice");
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("shows action buttons for non-deleted messages", () => {
|
|
const msg = makeMessage();
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const actionsBar = container.querySelector(".msg-actions-bar");
|
|
expect(actionsBar).not.toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("gives icon-only action buttons explicit accessible names", () => {
|
|
const msg = makeMessage();
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
expect(
|
|
container.querySelector("[data-testid='msg-react-1']")?.getAttribute("aria-label"),
|
|
).toBe("React");
|
|
expect(
|
|
container.querySelector("[data-testid='msg-reply-1']")?.getAttribute("aria-label"),
|
|
).toBe("Reply");
|
|
expect(container.querySelector("[data-testid='msg-pin-1']")?.getAttribute("aria-label")).toBe(
|
|
"Pin",
|
|
);
|
|
expect(
|
|
container.querySelector("[data-testid='msg-edit-1']")?.getAttribute("aria-label"),
|
|
).toBe("Edit");
|
|
expect(
|
|
container.querySelector("[data-testid='msg-delete-1']")?.getAttribute("aria-label"),
|
|
).toBe("Delete");
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("updates the pin button accessible name for pinned messages", () => {
|
|
const msg = makeMessage({ pinned: true });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
expect(container.querySelector("[data-testid='msg-pin-1']")?.getAttribute("aria-label")).toBe(
|
|
"Unpin",
|
|
);
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("does not show action buttons for deleted messages", () => {
|
|
const msg = makeMessage({ deleted: true });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const actionsBar = container.querySelector(".msg-actions-bar");
|
|
expect(actionsBar).toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("renders reactions when present", () => {
|
|
const msg = makeMessage({
|
|
reactions: [
|
|
{ emoji: "\uD83D\uDC4D", count: 3, me: false },
|
|
{ emoji: "\u2764\uFE0F", count: 1, me: true },
|
|
],
|
|
});
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const reactionChips = container.querySelectorAll(".reaction-chip:not(.add-reaction)");
|
|
expect(reactionChips.length).toBe(2);
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
// The who-reacted tooltip hangs off the pill, so the pill has to carry its
|
|
// emoji and be focusable for a keyboard user to reach the tooltip at all.
|
|
it("makes reaction pills focusable and tags them with their emoji", () => {
|
|
const msg = makeMessage({
|
|
reactions: [{ emoji: "\uD83D\uDC4D", count: 3, me: false }],
|
|
});
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const chip = container.querySelector(
|
|
".reaction-chip:not(.add-reaction)",
|
|
) as HTMLElement | null;
|
|
expect(chip?.dataset.emoji).toBe("\uD83D\uDC4D");
|
|
expect(chip?.getAttribute("tabindex")).toBe("0");
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("shows the who-reacted tooltip after hovering a pill", async () => {
|
|
setReactionUsersFetcher(() =>
|
|
Promise.resolve([
|
|
{ id: 1, username: "alice", avatar: "" },
|
|
{ id: 2, username: "bob", avatar: "" },
|
|
]),
|
|
);
|
|
clearReactionUsersCache();
|
|
|
|
const msg = makeMessage({
|
|
reactions: [{ emoji: "\uD83D\uDC4D", count: 2, me: false }],
|
|
});
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const chip = container.querySelector(".reaction-chip:not(.add-reaction)") as HTMLElement;
|
|
chip.dispatchEvent(new Event("mouseenter"));
|
|
|
|
await vi.waitFor(() => {
|
|
expect(chip.querySelector(".reaction-tooltip-names")?.textContent).toBe("alice and bob");
|
|
});
|
|
|
|
ac.abort();
|
|
setReactionUsersFetcher(null);
|
|
});
|
|
|
|
it("renders attachments for image types", () => {
|
|
const msg = makeMessage({
|
|
attachments: [
|
|
{
|
|
id: "1",
|
|
filename: "photo.png",
|
|
size: 1024,
|
|
mime: "image/png",
|
|
url: "/uploads/photo.png",
|
|
},
|
|
],
|
|
});
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
expect(container.querySelector(".msg-image")).not.toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("renders attachments for file types", () => {
|
|
const msg = makeMessage({
|
|
attachments: [
|
|
{
|
|
id: "1",
|
|
filename: "doc.pdf",
|
|
size: 2048,
|
|
mime: "application/pdf",
|
|
url: "/uploads/doc.pdf",
|
|
},
|
|
],
|
|
});
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
expect(container.querySelector(".msg-file")).not.toBeNull();
|
|
expect(container.querySelector(".msg-file-name")?.textContent).toBe("doc.pdf");
|
|
|
|
ac.abort();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// parseTimestamp (via formatTime / formatFullDate)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("parseTimestamp — UTC treatment of bare SQLite timestamps", () => {
|
|
it("treats a SQLite datetime string without timezone as UTC", () => {
|
|
// "2026-03-19 08:00:00" is SQLite output — no Z, no T, no offset.
|
|
// parseTimestamp must append Z so it is read as 08:00 UTC, not local.
|
|
const withZ = formatTime("2026-03-19T08:00:00Z");
|
|
const sqliteBare = formatTime("2026-03-19 08:00:00");
|
|
expect(sqliteBare).toBe(withZ);
|
|
});
|
|
|
|
it("preserves explicit UTC timestamps with Z suffix unchanged", () => {
|
|
// Verify that a Z-suffixed timestamp produces a valid HH:MM string.
|
|
// We cannot assert a specific value because formatTime uses local clock
|
|
// hours — instead verify that the bare and Z forms agree.
|
|
const withZ = formatTime("2026-03-19T15:30:00Z");
|
|
expect(withZ).toMatch(/^\d{2}:\d{2}$/);
|
|
});
|
|
|
|
it("preserves explicit positive UTC-offset timestamps", () => {
|
|
// +02:00 is two hours ahead of UTC, so 17:30+02:00 == 15:30Z.
|
|
// Both forms should produce the same local-clock HH:MM string.
|
|
const withOffset = formatTime("2026-03-19T17:30:00+02:00");
|
|
const utcEquiv = formatTime("2026-03-19T15:30:00Z");
|
|
expect(withOffset).toBe(utcEquiv);
|
|
});
|
|
|
|
it("formats full date correctly for bare SQLite timestamp", () => {
|
|
// Midday UTC, not midnight: formatFullDate renders in the local zone, and
|
|
// a UTC-midnight fixture rolls back to the 18th in any zone west of UTC
|
|
// (e.g. America/New_York). Noon UTC stays on the 19th for every zone
|
|
// from UTC-11 to UTC+11, which covers every developer/CI machine zone
|
|
// (only the rare UTC+12..+14 Pacific zones roll it forward to the 20th).
|
|
const result = formatFullDate("2026-03-19 12:00:00");
|
|
expect(result).toContain("2026");
|
|
expect(result).toContain("March");
|
|
expect(result).toContain("19");
|
|
});
|
|
|
|
it("handles ISO 8601 datetime with T separator and Z suffix", () => {
|
|
// Standard ISO — must not double-append Z. The result must be a
|
|
// valid HH:MM string and must equal what the same timestamp produces
|
|
// when formatted normally.
|
|
const iso = "2026-01-01T06:00:00Z";
|
|
const result = formatTime(iso);
|
|
expect(result).toMatch(/^\d{2}:\d{2}$/);
|
|
// Confirm idempotency: calling again with the same input gives same output
|
|
expect(formatTime(iso)).toBe(result);
|
|
});
|
|
|
|
it("returns a valid HH:MM string for every supported timestamp format", () => {
|
|
const formats = ["2026-03-19T08:30:00Z", "2026-03-19 08:30:00", "2026-03-19T08:30:00+00:00"];
|
|
for (const ts of formats) {
|
|
expect(formatTime(ts)).toMatch(/^\d{2}:\d{2}$/);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// isDirectImageUrl (via renderMessage embed output)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("isDirectImageUrl — image extension detection via renderMessage", () => {
|
|
const imageExtensions = [".gif", ".png", ".jpg", ".jpeg", ".webp"] as const;
|
|
|
|
for (const ext of imageExtensions) {
|
|
it(`produces a .msg-image embed for a ${ext} URL`, () => {
|
|
const url = `https://example.com/image${ext}`;
|
|
const msg = makeMessage({ content: url });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
// renderUrlEmbeds calls isDirectImageUrl → renderInlineImage
|
|
// which produces a div.msg-image inside the message element
|
|
const imageEmbeds = container.querySelectorAll(".msg-image");
|
|
// There may be multiple .msg-image (attachments share the class),
|
|
// but at least one must exist and have an <img src="...ext">
|
|
const imgEl = container.querySelector(`.msg-image img[src="${url}"]`);
|
|
expect(imgEl).not.toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
}
|
|
|
|
it("does not produce a .msg-image embed for a non-image URL", () => {
|
|
const url = "https://example.com/page.html";
|
|
const msg = makeMessage({ content: url });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
// A generic link card (.msg-embed-link) should appear instead
|
|
expect(container.querySelector(`.msg-image img[src="${url}"]`)).toBeNull();
|
|
expect(container.querySelector(".msg-embed-link")).not.toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("does not produce a .msg-image embed for a URL with image extension as query param", () => {
|
|
// The path itself has no image extension — only a query string does
|
|
const url = "https://example.com/image?format=png";
|
|
const msg = makeMessage({ content: url });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
expect(container.querySelector(`.msg-image img[src="${url}"]`)).toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("matches image extensions case-insensitively", () => {
|
|
// Uppercase extensions must also be detected
|
|
const url = "https://example.com/photo.PNG";
|
|
const msg = makeMessage({ content: url });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const imgEl = container.querySelector(`.msg-image img[src="${url}"]`);
|
|
expect(imgEl).not.toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("does not treat a YouTube URL with no image extension as a direct image", () => {
|
|
const url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
|
|
const msg = makeMessage({ content: url });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
// YouTube gets a player embed, not a .msg-image with that src
|
|
expect(container.querySelector(`.msg-image img[src="${url}"]`)).toBeNull();
|
|
expect(container.querySelector(".msg-embed-youtube")).not.toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// renderInlineImage (via renderMessage)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("renderInlineImage — img element and lightbox behaviour", () => {
|
|
it("creates an img element with the correct src attribute", () => {
|
|
const url = "https://example.com/photo.jpg";
|
|
const msg = makeMessage({ content: url });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const img = container.querySelector(`.msg-image img`) as HTMLImageElement | null;
|
|
expect(img).not.toBeNull();
|
|
expect(img!.getAttribute("src")).toBe(url);
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("wraps the img in a div with class msg-image", () => {
|
|
const url = "https://cdn.example.com/banner.gif";
|
|
const msg = makeMessage({ content: url });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const wrap = container.querySelector(".msg-image");
|
|
expect(wrap).not.toBeNull();
|
|
expect(wrap!.querySelector("img")).not.toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("appends a lightbox overlay to document.body on img click", () => {
|
|
const url = "https://example.com/photo.png";
|
|
const msg = makeMessage({ content: url });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const img = container.querySelector(`.msg-image img[src="${url}"]`) as HTMLElement | null;
|
|
expect(img).not.toBeNull();
|
|
|
|
// No lightbox before click
|
|
expect(document.body.querySelector(".image-lightbox")).toBeNull();
|
|
|
|
img!.click();
|
|
|
|
// Lightbox should now be in the body
|
|
const lightbox = document.body.querySelector(".image-lightbox");
|
|
expect(lightbox).not.toBeNull();
|
|
|
|
// Clean up lightbox
|
|
lightbox!.remove();
|
|
ac.abort();
|
|
});
|
|
|
|
it("lightbox contains a close button that removes the overlay", () => {
|
|
const url = "https://example.com/photo.webp";
|
|
const msg = makeMessage({ content: url });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const img = container.querySelector(`.msg-image img[src="${url}"]`) as HTMLElement | null;
|
|
img!.click();
|
|
|
|
const lightbox = document.body.querySelector(".image-lightbox")!;
|
|
const closeBtn = lightbox.querySelector(".image-lightbox-close") as HTMLElement | null;
|
|
expect(closeBtn).not.toBeNull();
|
|
|
|
closeBtn!.click();
|
|
|
|
// Lightbox should be removed from DOM after close
|
|
expect(document.body.querySelector(".image-lightbox")).toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("lightbox contains an img element with the same src as the inline image", () => {
|
|
const url = "https://example.com/pic.jpeg";
|
|
const msg = makeMessage({ content: url });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const img = container.querySelector(`.msg-image img[src="${url}"]`) as HTMLElement | null;
|
|
img!.click();
|
|
|
|
const lightbox = document.body.querySelector(".image-lightbox")!;
|
|
const lbImg = lightbox.querySelector("img") as HTMLImageElement | null;
|
|
expect(lbImg).not.toBeNull();
|
|
expect(lbImg!.getAttribute("src")).toBe(url);
|
|
|
|
// Clean up
|
|
lightbox.remove();
|
|
ac.abort();
|
|
});
|
|
|
|
it("closes lightbox when Escape key is pressed", () => {
|
|
const url = "https://example.com/escape-test.png";
|
|
const msg = makeMessage({ content: url });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const img = container.querySelector(`.msg-image img[src="${url}"]`) as HTMLElement | null;
|
|
img!.click();
|
|
|
|
expect(document.body.querySelector(".image-lightbox")).not.toBeNull();
|
|
|
|
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
|
|
expect(document.body.querySelector(".image-lightbox")).toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// System messages
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("renderSystemMessage (via renderMessage)", () => {
|
|
it("renders system message with icon, text, and time", () => {
|
|
const msg = makeMessage({
|
|
user: { id: 0, username: "System", avatar: null },
|
|
content: "@alice joined the server",
|
|
});
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
expect(container.querySelector(".system-msg")).not.toBeNull();
|
|
expect(container.querySelector(".sm-icon")).not.toBeNull();
|
|
expect(container.querySelector(".sm-text")).not.toBeNull();
|
|
expect(container.querySelector(".sm-time")).not.toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("renders mentions inside system message text", () => {
|
|
const msg = makeMessage({
|
|
user: { id: 0, username: "System", avatar: null },
|
|
content: "@alice was promoted to admin",
|
|
});
|
|
seedMentionMembers();
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const mention = el.querySelector(".mention");
|
|
expect(mention).not.toBeNull();
|
|
expect(mention!.textContent).toBe("@alice");
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("does not treat a real registered user named 'System' as a system notice", () => {
|
|
// Real user ids are DB autoincrement and start at 1 — only the
|
|
// synthetic sentinel row (id 0) is a genuine system message. A user
|
|
// who registered the display name "System" must render normally,
|
|
// with an author name and full moderation controls.
|
|
const msg = makeMessage({
|
|
user: { id: 42, username: "System", avatar: null },
|
|
content: "Your session was flagged — re-enter your password at evil.example",
|
|
});
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
expect(container.querySelector(".system-msg")).toBeNull();
|
|
expect(container.querySelector(".msg-author")).not.toBeNull();
|
|
// The hover action bar (react/reply/pin/…) is only built for real
|
|
// messages — renderSystemMessage returns before it exists at all.
|
|
expect(container.querySelector('[data-testid="msg-reply-1"]')).not.toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Reply reference — unknown message
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("renderReplyRef — unknown message", () => {
|
|
it("renders 'Reply to unknown message' when referenced message is missing", () => {
|
|
const reply = makeMessage({ id: 2, replyTo: 999, content: "replying" });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(reply, false, [reply], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const replyRef = container.querySelector(".msg-reply-ref");
|
|
expect(replyRef).not.toBeNull();
|
|
expect(replyRef!.textContent).toBe("Reply to unknown message");
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("renders '[message deleted]' preview for deleted referenced message", () => {
|
|
const original = makeMessage({ id: 1, content: "Original", deleted: true });
|
|
const reply = makeMessage({ id: 2, replyTo: 1, content: "reply" });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(reply, false, [original, reply], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const replyText = container.querySelector(".rr-text");
|
|
expect(replyText).not.toBeNull();
|
|
expect(replyText!.textContent).toBe("[message deleted]");
|
|
|
|
ac.abort();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Developer mode — copy ID button
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("developer mode — Copy ID button", () => {
|
|
it("renders Copy ID button when developerMode is enabled", () => {
|
|
// loadPref reads from localStorage with the "owncord:settings:" prefix
|
|
localStorage.setItem("owncord:settings:developerMode", "true");
|
|
// Dispatch pref-change to invalidate the cached developerModeEnabled value
|
|
window.dispatchEvent(
|
|
new CustomEvent("owncord:pref-change", {
|
|
detail: { key: "developerMode" },
|
|
}),
|
|
);
|
|
|
|
const msg = makeMessage();
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const copyIdBtn = container.querySelector("[data-testid='msg-copy-id-1']");
|
|
expect(copyIdBtn).not.toBeNull();
|
|
expect(copyIdBtn!.getAttribute("aria-label")).toBe("Copy ID");
|
|
|
|
// Clean up: restore developer mode to false
|
|
localStorage.setItem("owncord:settings:developerMode", "false");
|
|
window.dispatchEvent(
|
|
new CustomEvent("owncord:pref-change", {
|
|
detail: { key: "developerMode" },
|
|
}),
|
|
);
|
|
ac.abort();
|
|
});
|
|
|
|
it("Copy ID button calls clipboard.writeText on click", () => {
|
|
localStorage.setItem("owncord:settings:developerMode", "true");
|
|
window.dispatchEvent(
|
|
new CustomEvent("owncord:pref-change", {
|
|
detail: { key: "developerMode" },
|
|
}),
|
|
);
|
|
|
|
const writeTextMock = vi.fn().mockResolvedValue(undefined);
|
|
Object.assign(navigator, { clipboard: { writeText: writeTextMock } });
|
|
|
|
const msg = makeMessage({ id: 42 });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const copyIdBtn = container.querySelector("[data-testid='msg-copy-id-42']") as HTMLElement;
|
|
expect(copyIdBtn).not.toBeNull();
|
|
copyIdBtn.click();
|
|
|
|
expect(writeTextMock).toHaveBeenCalledWith("42");
|
|
|
|
// Clean up
|
|
localStorage.setItem("owncord:settings:developerMode", "false");
|
|
window.dispatchEvent(
|
|
new CustomEvent("owncord:pref-change", {
|
|
detail: { key: "developerMode" },
|
|
}),
|
|
);
|
|
ac.abort();
|
|
});
|
|
|
|
it("Copy ID button handles clipboard failure gracefully", () => {
|
|
localStorage.setItem("owncord:settings:developerMode", "true");
|
|
window.dispatchEvent(
|
|
new CustomEvent("owncord:pref-change", {
|
|
detail: { key: "developerMode" },
|
|
}),
|
|
);
|
|
|
|
Object.assign(navigator, {
|
|
clipboard: { writeText: vi.fn().mockRejectedValue(new Error("clipboard unavailable")) },
|
|
});
|
|
|
|
const msg = makeMessage({ id: 42 });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const copyIdBtn = container.querySelector("[data-testid='msg-copy-id-42']") as HTMLElement;
|
|
// Should not throw
|
|
expect(() => copyIdBtn.click()).not.toThrow();
|
|
|
|
// Clean up
|
|
localStorage.setItem("owncord:settings:developerMode", "false");
|
|
window.dispatchEvent(
|
|
new CustomEvent("owncord:pref-change", {
|
|
detail: { key: "developerMode" },
|
|
}),
|
|
);
|
|
ac.abort();
|
|
});
|
|
|
|
it("does not render Copy ID button when developerMode is disabled", () => {
|
|
localStorage.setItem("owncord:settings:developerMode", "false");
|
|
window.dispatchEvent(
|
|
new CustomEvent("owncord:pref-change", {
|
|
detail: { key: "developerMode" },
|
|
}),
|
|
);
|
|
|
|
const msg = makeMessage();
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const copyIdBtn = container.querySelector("[data-testid='msg-copy-id-1']");
|
|
expect(copyIdBtn).toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Deleted messages — no action bar, no embeds
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("deleted message edge cases", () => {
|
|
it("does not render embeds for deleted messages", () => {
|
|
const msg = makeMessage({
|
|
deleted: true,
|
|
content: "https://example.com/photo.png",
|
|
});
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
expect(container.querySelector(".msg-image")).toBeNull();
|
|
expect(container.querySelector(".msg-embed-link")).toBeNull();
|
|
expect(container.querySelector(".msg-actions-bar")).toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("does not render (edited) tag for deleted messages", () => {
|
|
const msg = makeMessage({
|
|
deleted: true,
|
|
editedAt: "2025-01-15T13:00:00Z",
|
|
});
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
expect(container.querySelector(".msg-edited")).toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("does not render reactions for deleted messages", () => {
|
|
const msg = makeMessage({
|
|
deleted: true,
|
|
reactions: [{ emoji: "\uD83D\uDC4D", count: 1, me: false }],
|
|
});
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
expect(container.querySelector(".reaction-chip")).toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Action button callbacks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("action button callbacks", () => {
|
|
it("calls onReplyClick when reply button is clicked", () => {
|
|
const opts = makeOpts();
|
|
const msg = makeMessage();
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], opts, ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const replyBtn = container.querySelector("[data-testid='msg-reply-1']") as HTMLElement;
|
|
replyBtn.click();
|
|
expect(opts.onReplyClick).toHaveBeenCalledWith(1);
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("calls onReactionClick when react button is clicked", () => {
|
|
const opts = makeOpts();
|
|
const msg = makeMessage();
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], opts, ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const reactBtn = container.querySelector("[data-testid='msg-react-1']") as HTMLElement;
|
|
reactBtn.click();
|
|
expect(opts.onReactionClick).toHaveBeenCalledWith(1, "");
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("calls onPinClick with correct arguments", () => {
|
|
const opts = makeOpts();
|
|
const msg = makeMessage({ pinned: false });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], opts, ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const pinBtn = container.querySelector("[data-testid='msg-pin-1']") as HTMLElement;
|
|
pinBtn.click();
|
|
expect(opts.onPinClick).toHaveBeenCalledWith(1, 1, false);
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("calls onEditClick when edit button is clicked", () => {
|
|
const opts = makeOpts();
|
|
const msg = makeMessage();
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], opts, ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const editBtn = container.querySelector("[data-testid='msg-edit-1']") as HTMLElement;
|
|
editBtn.click();
|
|
expect(opts.onEditClick).toHaveBeenCalledWith(1);
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("calls onDeleteClick when delete button is clicked", () => {
|
|
const opts = makeOpts();
|
|
const msg = makeMessage();
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], opts, ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const deleteBtn = container.querySelector("[data-testid='msg-delete-1']") as HTMLElement;
|
|
deleteBtn.click();
|
|
expect(opts.onDeleteClick).toHaveBeenCalledWith(1);
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("does not render edit/delete buttons for other users' messages", () => {
|
|
const opts = makeOpts({ currentUserId: 999 });
|
|
const msg = makeMessage({ user: { id: 10, username: "Alice", avatar: null } });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], opts, ac.signal);
|
|
container.appendChild(el);
|
|
|
|
expect(container.querySelector("[data-testid='msg-edit-1']")).toBeNull();
|
|
expect(container.querySelector("[data-testid='msg-delete-1']")).toBeNull();
|
|
// But react, reply, pin should still be present
|
|
expect(container.querySelector("[data-testid='msg-react-1']")).not.toBeNull();
|
|
expect(container.querySelector("[data-testid='msg-reply-1']")).not.toBeNull();
|
|
expect(container.querySelector("[data-testid='msg-pin-1']")).not.toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("offers delete on others' messages to a role with MANAGE_MESSAGES", () => {
|
|
// MANAGE_MESSAGES = 0x10000 (see lib/types Permission).
|
|
setRoles([{ id: 2, name: "moderator", color: null, permissions: 0x10000 }]);
|
|
authStore.setState(() => ({
|
|
token: "tok",
|
|
user: { id: 999, username: "Mod", avatar: null, role: "moderator" },
|
|
serverName: null,
|
|
motd: null,
|
|
isAuthenticated: true,
|
|
}));
|
|
|
|
const opts = makeOpts({ currentUserId: 999 });
|
|
const msg = makeMessage({ user: { id: 10, username: "Alice", avatar: null } });
|
|
const ac = new AbortController();
|
|
container.appendChild(renderMessage(msg, false, [msg], opts, ac.signal));
|
|
|
|
expect(container.querySelector("[data-testid='msg-delete-1']")).not.toBeNull();
|
|
// Editing someone else's message is still not a thing.
|
|
expect(container.querySelector("[data-testid='msg-edit-1']")).toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("withholds delete from a role without MANAGE_MESSAGES", () => {
|
|
setRoles([{ id: 3, name: "member", color: null, permissions: 0 }]);
|
|
authStore.setState(() => ({
|
|
token: "tok",
|
|
user: { id: 999, username: "Nobody", avatar: null, role: "member" },
|
|
serverName: null,
|
|
motd: null,
|
|
isAuthenticated: true,
|
|
}));
|
|
|
|
const opts = makeOpts({ currentUserId: 999 });
|
|
const msg = makeMessage({ user: { id: 10, username: "Alice", avatar: null } });
|
|
const ac = new AbortController();
|
|
container.appendChild(renderMessage(msg, false, [msg], opts, ac.signal));
|
|
|
|
expect(container.querySelector("[data-testid='msg-delete-1']")).toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Grouped message hover time
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("grouped message hover time", () => {
|
|
it("renders hover time element for grouped messages", () => {
|
|
const msg = makeMessage();
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, true, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
const hoverTime = container.querySelector(".msg-hover-time");
|
|
expect(hoverTime).not.toBeNull();
|
|
expect(hoverTime!.textContent).toMatch(/^\d{2}:\d{2}$/);
|
|
|
|
ac.abort();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// formatMessageTimestamp
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("formatMessageTimestamp", () => {
|
|
it("returns 'Today at ...' for today's timestamps", () => {
|
|
const now = new Date();
|
|
const iso = now.toISOString();
|
|
const result = formatMessageTimestamp(iso);
|
|
expect(result).toMatch(/^Today at /);
|
|
});
|
|
|
|
it("returns 'Yesterday at ...' for yesterday's timestamps", () => {
|
|
const yesterday = new Date(Date.now() - 86_400_000);
|
|
const iso = yesterday.toISOString();
|
|
const result = formatMessageTimestamp(iso);
|
|
expect(result).toMatch(/^Yesterday at /);
|
|
});
|
|
|
|
it("returns MM/DD/YYYY format for older timestamps", () => {
|
|
const result = formatMessageTimestamp("2020-06-15T12:00:00Z");
|
|
expect(result).toMatch(/^\d{2}\/\d{2}\/\d{4} /);
|
|
});
|
|
});
|
|
|
|
// Probe once, before the suite is even registered: `process.env.TZ`
|
|
// mutations only reach Date's local-time engine on the main thread/forks
|
|
// pool. A worker-thread pool (e.g. Stryker's vitest runner) spawns an
|
|
// isolate that never observes the change, even though the assignment
|
|
// itself succeeds. Skip the whole block there instead of failing on a pool
|
|
// limitation nothing in this file can fix; `npx vitest run` (forks, the
|
|
// documented way to run this suite) always honors the pin and runs it for
|
|
// real.
|
|
const dstProbeOriginalTZ = process.env.TZ;
|
|
process.env.TZ = "America/New_York";
|
|
const dstTZPinHonored =
|
|
new Date(2026, 0, 15).getTimezoneOffset() === 300 &&
|
|
new Date(2026, 6, 15).getTimezoneOffset() === 240;
|
|
if (dstProbeOriginalTZ === undefined) {
|
|
delete process.env.TZ;
|
|
} else {
|
|
process.env.TZ = dstProbeOriginalTZ;
|
|
}
|
|
|
|
describe.skipIf(!dstTZPinHonored)("formatMessageTimestamp — DST day boundaries", () => {
|
|
// These cases only exist in a DST-observing zone, so pin one for the
|
|
// duration of this block. The precondition assertion in each test is
|
|
// cheap insurance against the pin drifting mid-run.
|
|
const originalTZ = process.env.TZ;
|
|
|
|
beforeEach(() => {
|
|
process.env.TZ = "America/New_York";
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
if (originalTZ === undefined) {
|
|
delete process.env.TZ;
|
|
} else {
|
|
process.env.TZ = originalTZ;
|
|
}
|
|
});
|
|
|
|
function assertEasternTime(): void {
|
|
// EST is UTC-5 (offset 300), EDT is UTC-4 (offset 240). If these differ
|
|
// the runtime is genuinely observing DST transitions.
|
|
expect(new Date(2026, 0, 15).getTimezoneOffset()).toBe(300);
|
|
expect(new Date(2026, 6, 15).getTimezoneOffset()).toBe(240);
|
|
}
|
|
|
|
it("does not label a two-day-old message 'Yesterday' across spring forward", () => {
|
|
assertEasternTime();
|
|
// DST starts Sun Mar 8, 2026 (23-hour local day). Now: Mon Mar 9, 11:00 EDT.
|
|
vi.setSystemTime(new Date("2026-03-09T15:00:00Z"));
|
|
// Sat Mar 7, 23:30 EST — two calendar days before "today".
|
|
const result = formatMessageTimestamp("2026-03-08T04:30:00Z");
|
|
expect(result).not.toMatch(/^Yesterday/);
|
|
expect(result).toMatch(/^03\/07\/2026 /);
|
|
// Sun Mar 8, 08:00 EDT really is yesterday.
|
|
expect(formatMessageTimestamp("2026-03-08T12:00:00Z")).toMatch(/^Yesterday at /);
|
|
});
|
|
|
|
it("labels the whole previous calendar day 'Yesterday' across fall back", () => {
|
|
assertEasternTime();
|
|
// DST ends Sun Nov 1, 2026 (25-hour local day). Now: Mon Nov 2, 10:00 EST.
|
|
vi.setSystemTime(new Date("2026-11-02T15:00:00Z"));
|
|
// Sun Nov 1, 00:30 EDT — inside the previous calendar day.
|
|
expect(formatMessageTimestamp("2026-11-01T04:30:00Z")).toMatch(/^Yesterday at /);
|
|
// Sat Oct 31, 23:30 EDT — two calendar days back stays absolute.
|
|
expect(formatMessageTimestamp("2026-11-01T03:30:00Z")).toMatch(/^10\/31\/2026 /);
|
|
});
|
|
|
|
it("keeps Today/Yesterday/absolute labels on an ordinary day", () => {
|
|
assertEasternTime();
|
|
// Wed Jun 17, 2026, 11:00 EDT — nowhere near a DST transition.
|
|
vi.setSystemTime(new Date("2026-06-17T15:00:00Z"));
|
|
expect(formatMessageTimestamp("2026-06-17T14:00:00Z")).toMatch(/^Today at /);
|
|
expect(formatMessageTimestamp("2026-06-17T03:30:00Z")).toMatch(/^Yesterday at /);
|
|
expect(formatMessageTimestamp("2026-06-15T16:00:00Z")).toMatch(/^06\/15\/2026 /);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getUserRole / roleColorVar
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("getUserRole", () => {
|
|
it("returns 'member' for unknown userId", () => {
|
|
expect(getUserRole(999999)).toBe("member");
|
|
});
|
|
|
|
it("returns role for known member", () => {
|
|
membersStore.setState((prev) => ({
|
|
...prev,
|
|
members: new Map([
|
|
[42, { id: 42, username: "admin", avatar: null, role: "admin", status: "online" }],
|
|
]),
|
|
}));
|
|
expect(getUserRole(42)).toBe("admin");
|
|
});
|
|
});
|
|
|
|
describe("roleColorVar", () => {
|
|
afterEach(() => {
|
|
// Restore roleColors to default (true)
|
|
localStorage.setItem("owncord:settings:roleColors", "true");
|
|
window.dispatchEvent(
|
|
new CustomEvent("owncord:pref-change", {
|
|
detail: { key: "roleColors" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns owner color for 'owner' role", () => {
|
|
expect(roleColorVar("owner")).toBe("var(--role-owner)");
|
|
});
|
|
|
|
it("returns admin color for 'admin' role", () => {
|
|
expect(roleColorVar("admin")).toBe("var(--role-admin)");
|
|
});
|
|
|
|
it("returns mod color for 'moderator' role", () => {
|
|
expect(roleColorVar("moderator")).toBe("var(--role-mod)");
|
|
});
|
|
|
|
it("returns member color for unknown role", () => {
|
|
expect(roleColorVar("custom")).toBe("var(--role-member)");
|
|
});
|
|
|
|
it("returns member color for 'member' role", () => {
|
|
expect(roleColorVar("member")).toBe("var(--role-member)");
|
|
});
|
|
|
|
it("returns member color for all roles when roleColors is disabled", () => {
|
|
localStorage.setItem("owncord:settings:roleColors", "false");
|
|
window.dispatchEvent(
|
|
new CustomEvent("owncord:pref-change", {
|
|
detail: { key: "roleColors" },
|
|
}),
|
|
);
|
|
|
|
expect(roleColorVar("owner")).toBe("var(--role-member)");
|
|
expect(roleColorVar("admin")).toBe("var(--role-member)");
|
|
expect(roleColorVar("moderator")).toBe("var(--role-member)");
|
|
expect(roleColorVar("member")).toBe("var(--role-member)");
|
|
});
|
|
|
|
it("re-enables role colors when pref changes back to true", () => {
|
|
localStorage.setItem("owncord:settings:roleColors", "false");
|
|
window.dispatchEvent(
|
|
new CustomEvent("owncord:pref-change", {
|
|
detail: { key: "roleColors" },
|
|
}),
|
|
);
|
|
expect(roleColorVar("owner")).toBe("var(--role-member)");
|
|
|
|
localStorage.setItem("owncord:settings:roleColors", "true");
|
|
window.dispatchEvent(
|
|
new CustomEvent("owncord:pref-change", {
|
|
detail: { key: "roleColors" },
|
|
}),
|
|
);
|
|
expect(roleColorVar("owner")).toBe("var(--role-owner)");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// renderMentionSegment
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("renderMentionSegment", () => {
|
|
it("renders plain text without mentions", () => {
|
|
const fragment = renderMentionSegment("just text");
|
|
container.appendChild(fragment);
|
|
expect(container.textContent).toBe("just text");
|
|
expect(container.querySelector(".mention")).toBeNull();
|
|
});
|
|
|
|
it("renders @mention at start of text", () => {
|
|
seedMentionMembers();
|
|
const fragment = renderMentionSegment("@alice hello");
|
|
container.appendChild(fragment);
|
|
const mention = container.querySelector(".mention");
|
|
expect(mention).not.toBeNull();
|
|
expect(mention!.textContent).toBe("@alice");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// renderInlineContent
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("renderInlineContent", () => {
|
|
it("renders inline code segments", () => {
|
|
const fragment = renderInlineContent("use `npm install` to install");
|
|
container.appendChild(fragment);
|
|
const code = container.querySelector("code");
|
|
expect(code).not.toBeNull();
|
|
expect(code!.textContent).toBe("npm install");
|
|
});
|
|
|
|
it("renders text with no inline code", () => {
|
|
const fragment = renderInlineContent("plain text");
|
|
container.appendChild(fragment);
|
|
expect(container.textContent).toBe("plain text");
|
|
expect(container.querySelector("code")).toBeNull();
|
|
});
|
|
|
|
it("renders mention inside non-code text", () => {
|
|
seedMentionMembers();
|
|
const fragment = renderInlineContent("hello @alice and `code`");
|
|
container.appendChild(fragment);
|
|
expect(container.querySelector(".mention")).not.toBeNull();
|
|
expect(container.querySelector("code")).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// renderMessageContent — code blocks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("renderMessageContent", () => {
|
|
it("renders code blocks with copy button", () => {
|
|
const fragment = renderMessageContent("```console.log('hi')```");
|
|
container.appendChild(fragment);
|
|
|
|
const codeBlock = container.querySelector(".msg-codeblock");
|
|
expect(codeBlock).not.toBeNull();
|
|
expect(codeBlock!.textContent).toBe("console.log('hi')");
|
|
|
|
const copyBtn = container.querySelector(".msg-codeblock-copy");
|
|
expect(copyBtn).not.toBeNull();
|
|
expect(copyBtn!.textContent).toBe("Copy");
|
|
});
|
|
|
|
it("renders prose and code blocks together", () => {
|
|
const fragment = renderMessageContent("Hello```code here```world");
|
|
container.appendChild(fragment);
|
|
|
|
expect(container.querySelector(".msg-text")).not.toBeNull();
|
|
expect(container.querySelector(".msg-codeblock")).not.toBeNull();
|
|
});
|
|
|
|
it("renders empty content as a single msg-text", () => {
|
|
const fragment = renderMessageContent("");
|
|
container.appendChild(fragment);
|
|
|
|
// Empty string content: parts = [""], single prose segment
|
|
const textEls = container.querySelectorAll(".msg-text");
|
|
expect(textEls.length).toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
it("handles code block copy button click with clipboard", async () => {
|
|
const writeTextMock = vi.fn().mockResolvedValue(undefined);
|
|
Object.assign(navigator, {
|
|
clipboard: { writeText: writeTextMock },
|
|
});
|
|
|
|
const fragment = renderMessageContent("```test code```");
|
|
container.appendChild(fragment);
|
|
|
|
const copyBtn = container.querySelector(".msg-codeblock-copy") as HTMLElement;
|
|
copyBtn.click();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(writeTextMock).toHaveBeenCalledWith("test code");
|
|
});
|
|
});
|
|
|
|
it("handles code block copy button click when clipboard fails", async () => {
|
|
Object.assign(navigator, {
|
|
clipboard: { writeText: vi.fn().mockRejectedValue(new Error("fail")) },
|
|
});
|
|
|
|
const fragment = renderMessageContent("```some code```");
|
|
container.appendChild(fragment);
|
|
|
|
const copyBtn = container.querySelector(".msg-codeblock-copy") as HTMLElement;
|
|
copyBtn.click();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(copyBtn.textContent).toBe("Failed");
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// renderMentions — URL handling
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("renderMentions — URL linkification", () => {
|
|
it("creates a clickable link for safe URLs", () => {
|
|
const fragment = renderMentions("check https://example.com out");
|
|
container.appendChild(fragment);
|
|
|
|
const link = container.querySelector("a.msg-link");
|
|
expect(link).not.toBeNull();
|
|
expect(link!.getAttribute("href")).toBe("https://example.com");
|
|
expect(link!.getAttribute("target")).toBe("_blank");
|
|
});
|
|
|
|
it("renders unsafe URLs as plain text", () => {
|
|
const fragment = renderMentions("see javascript:alert(1) here");
|
|
container.appendChild(fragment);
|
|
|
|
// No anchor tag for javascript: URL (doesn't match URL_REGEX at all)
|
|
expect(container.querySelector("a")).toBeNull();
|
|
});
|
|
|
|
it("handles URLs between text segments", () => {
|
|
const fragment = renderMentions("before https://a.com after");
|
|
container.appendChild(fragment);
|
|
|
|
const link = container.querySelector("a.msg-link");
|
|
expect(link).not.toBeNull();
|
|
// Text before and after the link
|
|
expect(container.textContent).toContain("before");
|
|
expect(container.textContent).toContain("after");
|
|
});
|
|
|
|
it("keeps a balanced trailing paren that is part of the URL", () => {
|
|
const url = "https://en.wikipedia.org/wiki/Rust_(programming_language)";
|
|
const fragment = renderMentions(url);
|
|
container.appendChild(fragment);
|
|
|
|
const link = container.querySelector("a.msg-link") as HTMLAnchorElement;
|
|
expect(link).not.toBeNull();
|
|
expect(link.getAttribute("href")).toBe(url);
|
|
expect(link.textContent).toBe(url);
|
|
// No stray ")" left dangling as separate trailing text
|
|
expect(container.textContent).toBe(url);
|
|
});
|
|
|
|
it("still strips a genuinely unbalanced trailing paren used as sentence punctuation", () => {
|
|
const fragment = renderMentions("(see https://example.com/page)");
|
|
container.appendChild(fragment);
|
|
|
|
const link = container.querySelector("a.msg-link") as HTMLAnchorElement;
|
|
expect(link).not.toBeNull();
|
|
expect(link.getAttribute("href")).toBe("https://example.com/page");
|
|
expect(container.textContent).toBe("(see https://example.com/page)");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// renderUrlEmbeds (via renderMessage)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("renderUrlEmbeds — embed routing via renderMessage", () => {
|
|
it("produces a .msg-image element for a direct image URL instead of a generic link card", () => {
|
|
const url = "https://static.example.com/hero.png";
|
|
const msg = makeMessage({ content: url });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
expect(container.querySelector(".msg-image")).not.toBeNull();
|
|
expect(container.querySelector(".msg-embed-link")).toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("does not duplicate embeds for the same URL appearing twice in content", () => {
|
|
const url = "https://example.com/img.gif";
|
|
const msg = makeMessage({ content: `${url} and again ${url}` });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
// URL dedup inside renderUrlEmbeds — only one img embed produced
|
|
const imgEmbeds = container.querySelectorAll(`.msg-image img[src="${url}"]`);
|
|
expect(imgEmbeds.length).toBe(1);
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("skips image URLs that appear inside code blocks", () => {
|
|
const url = "https://example.com/hidden.png";
|
|
const msg = makeMessage({ content: `\`\`\`${url}\`\`\`` });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
// URL inside a code block must not produce an embed
|
|
expect(container.querySelector(`.msg-image img[src="${url}"]`)).toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("skips image URLs inside inline code", () => {
|
|
const url = "https://example.com/inline.png";
|
|
const msg = makeMessage({ content: `Look at \`${url}\`` });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
expect(container.querySelector(`.msg-image img[src="${url}"]`)).toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("renders multiple different image URLs as separate .msg-image embeds", () => {
|
|
const url1 = "https://example.com/a.png";
|
|
const url2 = "https://example.com/b.gif";
|
|
const msg = makeMessage({ content: `${url1} and ${url2}` });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
expect(container.querySelector(`img[src="${url1}"]`)).not.toBeNull();
|
|
expect(container.querySelector(`img[src="${url2}"]`)).not.toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("renders a generic link card for a plain https URL with no image extension", () => {
|
|
const url = "https://example.com/some-article";
|
|
const msg = makeMessage({ content: url });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
expect(container.querySelector(".msg-embed-link")).not.toBeNull();
|
|
expect(container.querySelector(`.msg-image img[src="${url}"]`)).toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
|
|
it("produces no embeds for a message with no URLs", () => {
|
|
const msg = makeMessage({ content: "just plain text, no links" });
|
|
const ac = new AbortController();
|
|
const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal);
|
|
container.appendChild(el);
|
|
|
|
expect(container.querySelector(".msg-embed")).toBeNull();
|
|
expect(container.querySelector(".msg-image")).toBeNull();
|
|
|
|
ac.abort();
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Phase 6: display names and avatars on message rows ──────────────────────
|
|
|
|
describe("message row author identity", () => {
|
|
beforeEach(() => {
|
|
resetStores();
|
|
});
|
|
|
|
afterEach(() => {
|
|
resetStores();
|
|
});
|
|
|
|
it("renders the display name from the member store, keeping the username as a title", () => {
|
|
membersStore.setState((prev) => ({
|
|
...prev,
|
|
members: new Map([
|
|
[
|
|
10,
|
|
{
|
|
id: 10,
|
|
username: "alice",
|
|
displayName: "Alice A.",
|
|
avatar: null,
|
|
role: "member",
|
|
status: "online" as const,
|
|
},
|
|
],
|
|
]),
|
|
}));
|
|
|
|
const el = renderMessage(
|
|
makeMessage({ user: { id: 10, username: "alice", avatar: null } }),
|
|
false,
|
|
[],
|
|
makeOpts(),
|
|
new AbortController().signal,
|
|
);
|
|
|
|
const author = el.querySelector(".msg-author");
|
|
expect(author?.textContent).toBe("Alice A.");
|
|
// The handle you would @mention is one hover away.
|
|
expect(author?.getAttribute("title")).toBe("alice");
|
|
// And the letter follows the rendered name.
|
|
expect(el.querySelector(".msg-avatar .avatar-initial")?.textContent).toBe("A");
|
|
});
|
|
|
|
it("prefers the live member store over the identity frozen into the payload", () => {
|
|
// A rename arrives as a user_update and patches the member store; the
|
|
// messages already on screen still carry the old name in their payload.
|
|
membersStore.setState((prev) => ({
|
|
...prev,
|
|
members: new Map([
|
|
[
|
|
10,
|
|
{
|
|
id: 10,
|
|
username: "renamed",
|
|
displayName: null,
|
|
avatar: null,
|
|
role: "member",
|
|
status: "online" as const,
|
|
},
|
|
],
|
|
]),
|
|
}));
|
|
|
|
const el = renderMessage(
|
|
makeMessage({ user: { id: 10, username: "old-name", avatar: null } }),
|
|
false,
|
|
[],
|
|
makeOpts(),
|
|
new AbortController().signal,
|
|
);
|
|
|
|
expect(el.querySelector(".msg-author")?.textContent).toBe("renamed");
|
|
});
|
|
|
|
it("falls back to the payload for an author who is not in the member list", () => {
|
|
const el = renderMessage(
|
|
makeMessage({
|
|
user: { id: 99, username: "ghost", avatar: null, display_name: "Ghosty" },
|
|
}),
|
|
false,
|
|
[],
|
|
makeOpts(),
|
|
new AbortController().signal,
|
|
);
|
|
|
|
expect(el.querySelector(".msg-author")?.textContent).toBe("Ghosty");
|
|
expect(el.querySelector(".msg-avatar .avatar-initial")?.textContent).toBe("G");
|
|
});
|
|
});
|