492 lines
17 KiB
TypeScript
492 lines
17 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { ChatMemberChannelDetail, ChatMemberChannelRecord } from "@hivegrid/chat";
|
|
import type { ChatRealtimeSystemMessage } from "@hivegrid/realtime";
|
|
|
|
import { buildMemberChatChannelDetailVM, buildMemberChatChannelListVM, type ChatViewModelLabels } from "./chatViewModel.server";
|
|
|
|
const labels: ChatViewModelLabels = {
|
|
directMessage: "Direktnachricht",
|
|
unnamedGroup: "Unbenannte Gruppe",
|
|
unnamedProject: "Projektchat",
|
|
unknownUser: "Unbekannte Person",
|
|
unknownMention: "Unbekannte Erwähnung",
|
|
};
|
|
|
|
const channel = (overrides: Partial<ChatMemberChannelRecord> = {}): ChatMemberChannelRecord => ({
|
|
id: "chat_1",
|
|
kind: "dm",
|
|
name: null,
|
|
description: null,
|
|
projektId: null,
|
|
isPrivate: true,
|
|
archivedAtIso: null,
|
|
lastMessageAtIso: "2026-07-15T10:00:00.000Z",
|
|
eventStreamId: "stream_1",
|
|
eventCursor: "stream_1:8",
|
|
member: {
|
|
roleInChat: "member",
|
|
notificationPreference: "all_messages",
|
|
lastReadMessageId: null,
|
|
lastReadAtIso: null,
|
|
isPinned: false,
|
|
canWrite: true,
|
|
},
|
|
memberCount: 2,
|
|
participants: [
|
|
{ userId: "user_me", displayName: "Raffael Sheikh", avatarUrl: "/avatars/user_me?v=1" },
|
|
{ userId: "user_other", displayName: "Ada Lovelace", avatarUrl: null },
|
|
],
|
|
unreadCount: 1,
|
|
hasUnreadMention: true,
|
|
lastMessage: {
|
|
id: "message_1",
|
|
clientMessageId: "client_1",
|
|
authorUserId: "user_other",
|
|
body: "Hallo Raffael",
|
|
bodyFormat: "markdown",
|
|
parentMessageId: null,
|
|
mentions: [{ type: "user", id: "user_me", label: null }],
|
|
attachmentIds: [],
|
|
linkPreviews: [],
|
|
createdAt: "2026-07-15T10:00:00.000Z",
|
|
updatedAt: "2026-07-15T10:00:00.000Z",
|
|
editedAt: null,
|
|
deletedAt: null,
|
|
},
|
|
...overrides,
|
|
});
|
|
|
|
const systemMessage = (overrides: Partial<ChatRealtimeSystemMessage> = {}): ChatRealtimeSystemMessage => ({
|
|
id: "system_message_1",
|
|
clientMessageId: "system:project-created:project_1",
|
|
authorUserId: null,
|
|
body: "stored fallback copy",
|
|
bodyFormat: "system",
|
|
systemEvent: { kind: "project_created", metadata: { projectName: "Wärmepumpe Müller" } },
|
|
parentMessageId: null,
|
|
mentions: [],
|
|
attachmentIds: [],
|
|
linkPreviews: [],
|
|
createdAt: "2026-07-15T10:00:00.000Z",
|
|
updatedAt: "2026-07-15T10:00:00.000Z",
|
|
editedAt: null,
|
|
deletedAt: null,
|
|
...overrides,
|
|
});
|
|
|
|
describe("chatViewModel.server", () => {
|
|
it("maps the repository page without applying query, view, or archive filters a second time", () => {
|
|
const vm = buildMemberChatChannelListVM({
|
|
channels: [channel(), channel({ id: "chat_archived", archivedAtIso: "2026-07-14T10:00:00.000Z", unreadCount: 3 })],
|
|
tenantSlug: "demo",
|
|
currentUserId: "user_me",
|
|
permissions: ["chat.read_own_channels", "chat.write", "chat.create_dm"],
|
|
filter: { query: "ada", view: "mentions", showArchived: false },
|
|
nextCursor: "next_cursor",
|
|
labels,
|
|
});
|
|
|
|
expect(vm.status).toBe("ready");
|
|
if (vm.status !== "ready") return;
|
|
expect(vm.data.rows).toHaveLength(2);
|
|
expect(vm.data.rows[0]).toMatchObject({
|
|
id: "chat_1",
|
|
title: "Ada Lovelace",
|
|
detailHref: "/demo/chat/chat_1",
|
|
unreadCount: 1,
|
|
hasMention: true,
|
|
avatar: { fallbackLabel: "AL" },
|
|
});
|
|
expect(vm.data.totals).toEqual({ unread: 4, mentions: 2 });
|
|
expect(vm.data.nextCursor).toBe("next_cursor");
|
|
expect(vm.data.permissions).toMatchObject({ canWrite: true, canCreateDm: true, canCreateGroup: false, canSearch: true });
|
|
expect(vm.data.hrefs).toMatchObject({
|
|
newChannel: "/demo/chat/new?kind=group",
|
|
newDm: "/demo/chat/new?kind=dm",
|
|
});
|
|
});
|
|
|
|
it("renders a bounded plain-text channel snippet from long markdown", () => {
|
|
const markdown = `**Wichtig** [Plan](https://example.com) ${"x".repeat(300)}`;
|
|
const vm = buildMemberChatChannelListVM({
|
|
channels: [channel({ lastMessage: { ...channel().lastMessage!, body: markdown } })],
|
|
tenantSlug: "demo",
|
|
currentUserId: "user_me",
|
|
permissions: ["chat.read_own_channels"],
|
|
filter: { query: "", view: "all", showArchived: false },
|
|
nextCursor: null,
|
|
labels,
|
|
});
|
|
|
|
expect(vm.status).toBe("ready");
|
|
if (vm.status !== "ready") return;
|
|
expect(vm.data.rows[0]?.lastMessage?.snippet).toMatch(/^Wichtig Plan x+…$/);
|
|
expect(vm.data.rows[0]?.lastMessage?.snippet).toHaveLength(160);
|
|
});
|
|
|
|
it("maps discoverable public groups to join-only rows without exposing a detail route", () => {
|
|
const vm = buildMemberChatChannelListVM({
|
|
channels: [],
|
|
discoverChannels: [{ id: "chat_public", name: "Montage Nord", description: "Offener Austausch", memberCount: 7 }],
|
|
tenantSlug: "demo",
|
|
currentUserId: "user_me",
|
|
permissions: ["chat.read_own_channels"],
|
|
filter: { query: "", view: "all", showArchived: false },
|
|
nextCursor: null,
|
|
labels,
|
|
});
|
|
|
|
expect(vm.status).toBe("ready");
|
|
if (vm.status !== "ready") return;
|
|
expect(vm.data.permissions.canDiscover).toBe(true);
|
|
expect(vm.data.discoverRows).toEqual([
|
|
{
|
|
id: "chat_public",
|
|
title: "Montage Nord",
|
|
description: "Offener Austausch",
|
|
avatar: { imageUrl: null, fallbackLabel: "MN" },
|
|
memberCount: 7,
|
|
isJoined: false,
|
|
canJoin: true,
|
|
joinHref: "/demo/chat",
|
|
detailHref: null,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("maps the latest system event to an i18n preview without a fake author", () => {
|
|
const vm = buildMemberChatChannelListVM({
|
|
channels: [channel({ lastMessage: systemMessage() })],
|
|
tenantSlug: "demo",
|
|
currentUserId: "user_me",
|
|
permissions: ["chat.read_own_channels"],
|
|
filter: { query: "", view: "all", showArchived: false },
|
|
nextCursor: null,
|
|
labels,
|
|
});
|
|
|
|
expect(vm.status).toBe("ready");
|
|
if (vm.status !== "ready") return;
|
|
expect(vm.data.rows[0]?.lastMessage).toMatchObject({
|
|
authorLabel: null,
|
|
snippet: "",
|
|
systemEvent: {
|
|
kind: "project_created",
|
|
labelKey: "chat.systemEvent.projectCreated",
|
|
values: { project: "Wärmepumpe Müller" },
|
|
},
|
|
});
|
|
});
|
|
|
|
it("maps persisted messages, resolved mentions, reads and the enabled thread/reaction actions", () => {
|
|
const detail: ChatMemberChannelDetail = {
|
|
channel: channel(),
|
|
messages: [channel().lastMessage!],
|
|
readReceipts: [{ messageId: "message_1", userId: "user_me", readAtIso: "2026-07-15T10:01:00.000Z" }],
|
|
attachments: [
|
|
{
|
|
messageId: "message_1",
|
|
fileId: "file_1",
|
|
displayName: "camera.jpg",
|
|
mimeType: "image/jpeg",
|
|
sizeBytes: 1024,
|
|
linkRole: "image",
|
|
previewStatus: "ready",
|
|
scanStatus: "clean",
|
|
},
|
|
],
|
|
hasOlderMessages: true,
|
|
olderMessageCursor: "opaque_cursor",
|
|
eventCursor: "stream_1:8",
|
|
};
|
|
|
|
const vm = buildMemberChatChannelDetailVM({
|
|
detail,
|
|
tenantSlug: "demo",
|
|
currentUserId: "user_me",
|
|
permissions: ["chat.read_own_channels", "chat.write", "files.write", "tasks.write"],
|
|
featureAvailability: { files: true, tasks: true },
|
|
labels,
|
|
});
|
|
|
|
expect(vm.status).toBe("ready");
|
|
if (vm.status !== "ready") return;
|
|
expect(vm.data.header).toMatchObject({ title: "Ada Lovelace", kind: "dm", pinnedHref: null });
|
|
expect(vm.data.history).toEqual({ hasOlder: true, olderCursor: "opaque_cursor" });
|
|
expect(vm.data.messages[0]).toMatchObject({
|
|
source: "persisted",
|
|
deliveryState: "sent",
|
|
author: { displayName: "Ada Lovelace", isCurrentUser: false },
|
|
mentions: [{ kind: "user", id: "user_me", label: "Raffael Sheikh", href: null }],
|
|
readAggregate: { readCount: 1, recipientCount: 1, readerUserIds: ["user_me"], readerLabels: ["Raffael Sheikh"] },
|
|
attachments: [{ fileId: "file_1", kind: "image", previewHref: "/demo/media/file_1/preview", scanStatus: "clean" }],
|
|
actions: { canReact: true, canReply: true, canEdit: false, canDelete: false, canPin: false, canMarkAsTask: true },
|
|
});
|
|
expect(vm.data.composer).toMatchObject({
|
|
enabled: true,
|
|
canSend: true,
|
|
canMention: true,
|
|
canAttachFiles: true,
|
|
canRecordVoice: true,
|
|
canMarkAsTask: true,
|
|
});
|
|
});
|
|
|
|
it("projects reactions, one-level thread aggregates, and pins for non-DM channels", () => {
|
|
const group = channel({
|
|
kind: "group",
|
|
name: "Montage Nord",
|
|
isPrivate: false,
|
|
member: { ...channel().member, roleInChat: "admin" },
|
|
});
|
|
const vm = buildMemberChatChannelDetailVM({
|
|
detail: {
|
|
channel: group,
|
|
messages: [group.lastMessage!],
|
|
readReceipts: [],
|
|
attachments: [],
|
|
messageFeatures: [
|
|
{
|
|
messageId: "message_1",
|
|
reactions: [
|
|
{ id: "reaction_1", messageId: "message_1", userId: "user_me", emoji: "👍", reactedAtIso: "2026-07-15T10:02:00.000Z" },
|
|
{ id: "reaction_2", messageId: "message_1", userId: "user_other", emoji: "👍", reactedAtIso: "2026-07-15T10:03:00.000Z" },
|
|
],
|
|
thread: {
|
|
id: "message_1",
|
|
parentMessageId: "message_1",
|
|
replyCount: 2,
|
|
lastReplyAtIso: "2026-07-15T10:04:00.000Z",
|
|
participantUserIds: ["user_me", "user_other"],
|
|
},
|
|
pin: { messageId: "message_1", pinnedByUserId: "user_me", pinnedAtIso: "2026-07-15T10:05:00.000Z", note: null },
|
|
},
|
|
],
|
|
hasOlderMessages: false,
|
|
olderMessageCursor: null,
|
|
eventCursor: "stream_1:8",
|
|
},
|
|
tenantSlug: "demo",
|
|
currentUserId: "user_me",
|
|
permissions: ["chat.read_own_channels", "chat.write"],
|
|
labels,
|
|
});
|
|
|
|
expect(vm.status).toBe("ready");
|
|
if (vm.status !== "ready") return;
|
|
expect(vm.data.header.pinnedHref).toBe("/demo/chat/chat_1/pinned");
|
|
expect(vm.data.messages[0]).toMatchObject({
|
|
reactions: [{ emoji: "👍", count: 2, reactedByViewer: true, reactorLabels: ["Raffael Sheikh", "Ada Lovelace"] }],
|
|
thread: { replyCount: 2, href: "/demo/chat/chat_1/thread/message_1", isFollowing: true },
|
|
pin: { pinnedByLabel: "Raffael Sheikh" },
|
|
actions: { canReact: true, canReply: true, canPin: true },
|
|
});
|
|
expect(vm.data.pinnedMessages).toHaveLength(1);
|
|
});
|
|
|
|
it("keeps archived channels read-only", () => {
|
|
const archived = channel({ archivedAtIso: "2026-07-15T09:00:00.000Z" });
|
|
const vm = buildMemberChatChannelDetailVM({
|
|
detail: {
|
|
channel: archived,
|
|
messages: [],
|
|
readReceipts: [],
|
|
attachments: [],
|
|
hasOlderMessages: false,
|
|
olderMessageCursor: null,
|
|
eventCursor: "stream_1:0",
|
|
},
|
|
tenantSlug: "demo",
|
|
currentUserId: "user_me",
|
|
permissions: ["chat.read_own_channels", "chat.write"],
|
|
labels,
|
|
});
|
|
|
|
expect(vm.status).toBe("ready");
|
|
if (vm.status !== "ready") return;
|
|
expect(vm.data.composer).toMatchObject({ enabled: false, canSend: false, disabledReasonKey: "chat.composer.archived" });
|
|
});
|
|
|
|
it("derives edit and delete actions from the deterministic 24-hour window and fresh admin capabilities", () => {
|
|
const ownMessage = { ...channel().lastMessage!, authorUserId: "user_me" };
|
|
const detail = (memberRole: "admin" | "member", message = ownMessage): ChatMemberChannelDetail => ({
|
|
channel: channel({ kind: "group", name: "Team", member: { ...channel().member, roleInChat: memberRole }, lastMessage: message }),
|
|
messages: [message],
|
|
readReceipts: [],
|
|
attachments: [],
|
|
hasOlderMessages: false,
|
|
olderMessageCursor: null,
|
|
eventCursor: "stream_1:8",
|
|
});
|
|
|
|
const freshOwn = buildMemberChatChannelDetailVM({
|
|
detail: detail("member"),
|
|
tenantSlug: "demo",
|
|
currentUserId: "user_me",
|
|
permissions: ["chat.read_own_channels", "chat.write"],
|
|
nowIso: "2026-07-16T09:00:00.000Z",
|
|
labels,
|
|
});
|
|
const expiredOwn = buildMemberChatChannelDetailVM({
|
|
detail: detail("member"),
|
|
tenantSlug: "demo",
|
|
currentUserId: "user_me",
|
|
permissions: ["chat.read_own_channels", "chat.write"],
|
|
nowIso: "2026-07-17T11:00:00.000Z",
|
|
labels,
|
|
});
|
|
const channelAdmin = buildMemberChatChannelDetailVM({
|
|
detail: detail("admin", channel().lastMessage!),
|
|
tenantSlug: "demo",
|
|
currentUserId: "user_me",
|
|
permissions: ["chat.read_own_channels", "chat.write"],
|
|
nowIso: "2026-07-17T11:00:00.000Z",
|
|
labels,
|
|
});
|
|
const globalAdmin = buildMemberChatChannelDetailVM({
|
|
detail: detail("member", channel().lastMessage!),
|
|
tenantSlug: "demo",
|
|
currentUserId: "user_me",
|
|
permissions: ["chat.read_own_channels", "chat.write", "chat.admin"],
|
|
nowIso: "2026-07-17T11:00:00.000Z",
|
|
labels,
|
|
});
|
|
|
|
expect(freshOwn.status === "ready" && freshOwn.data.messages[0]?.actions).toMatchObject({ canEdit: true, canDelete: true });
|
|
expect(expiredOwn.status === "ready" && expiredOwn.data.messages[0]?.actions).toMatchObject({ canEdit: false, canDelete: false });
|
|
expect(channelAdmin.status === "ready" && channelAdmin.data.messages[0]?.actions).toMatchObject({ canEdit: false, canDelete: true });
|
|
expect(globalAdmin.status === "ready" && globalAdmin.data.messages[0]?.actions).toMatchObject({ canEdit: true, canDelete: true });
|
|
});
|
|
|
|
it("maps actorless system messages fail-soft without author, receipts, or message actions", () => {
|
|
const vm = buildMemberChatChannelDetailVM({
|
|
detail: {
|
|
channel: channel({ lastMessage: systemMessage() }),
|
|
messages: [
|
|
systemMessage(),
|
|
systemMessage({
|
|
id: "system_message_2",
|
|
clientMessageId: "system:future",
|
|
systemEvent: { kind: "channel_renamed", metadata: { internalId: "secret" } },
|
|
}),
|
|
],
|
|
readReceipts: [{ messageId: "system_message_1", userId: "user_me", readAtIso: "2026-07-15T10:01:00.000Z" }],
|
|
attachments: [],
|
|
hasOlderMessages: false,
|
|
olderMessageCursor: null,
|
|
eventCursor: "stream_1:8",
|
|
},
|
|
tenantSlug: "demo",
|
|
currentUserId: "user_me",
|
|
permissions: ["chat.read_own_channels", "chat.write"],
|
|
labels,
|
|
});
|
|
|
|
expect(vm.status).toBe("ready");
|
|
if (vm.status !== "ready") return;
|
|
expect(vm.data.messages[0]).toMatchObject({
|
|
author: null,
|
|
bodyMarkdown: null,
|
|
bodyFormat: "system",
|
|
readAggregate: null,
|
|
systemEvent: {
|
|
kind: "project_created",
|
|
labelKey: "chat.systemEvent.projectCreated",
|
|
values: { project: "Wärmepumpe Müller" },
|
|
},
|
|
actions: { canReact: false, canReply: false, canEdit: false, canDelete: false, canPin: false, canMarkAsTask: false },
|
|
});
|
|
expect(vm.data.messages[1]?.systemEvent).toEqual({
|
|
kind: "channel_renamed",
|
|
labelKey: "chat.systemEvent.generic",
|
|
values: {},
|
|
});
|
|
});
|
|
|
|
it("keeps deleted message projections free of stale mention and preview metadata", () => {
|
|
const deletedMessage = {
|
|
...channel().lastMessage!,
|
|
body: "deleted secret",
|
|
deletedAt: "2026-07-15T10:02:00.000Z",
|
|
mentions: [{ type: "user" as const, id: "user_me", label: null }],
|
|
linkPreviews: [
|
|
{
|
|
url: "https://example.com/secret",
|
|
title: "Secret",
|
|
description: null,
|
|
imageUrl: null,
|
|
siteName: "Example",
|
|
},
|
|
],
|
|
};
|
|
const vm = buildMemberChatChannelDetailVM({
|
|
detail: {
|
|
channel: channel(),
|
|
messages: [deletedMessage],
|
|
readReceipts: [],
|
|
attachments: [
|
|
{
|
|
messageId: deletedMessage.id,
|
|
fileId: "file_secret",
|
|
displayName: "secret.pdf",
|
|
mimeType: "application/pdf",
|
|
sizeBytes: 10,
|
|
linkRole: "file",
|
|
previewStatus: "not_supported",
|
|
scanStatus: "clean",
|
|
},
|
|
],
|
|
hasOlderMessages: false,
|
|
olderMessageCursor: null,
|
|
eventCursor: "stream_1:1",
|
|
},
|
|
tenantSlug: "demo",
|
|
currentUserId: "user_me",
|
|
permissions: ["chat.read_own_channels", "chat.write"],
|
|
labels,
|
|
});
|
|
|
|
expect(vm.status).toBe("ready");
|
|
if (vm.status !== "ready") return;
|
|
expect(vm.data.messages[0]).toMatchObject({
|
|
bodyMarkdown: null,
|
|
mentions: [],
|
|
linkPreviews: [],
|
|
attachments: [],
|
|
deletedAtIso: "2026-07-15T10:02:00.000Z",
|
|
});
|
|
});
|
|
|
|
it("maps skipped attachment scans fail-closed instead of exposing a download", () => {
|
|
const persisted = channel().lastMessage!;
|
|
const vm = buildMemberChatChannelDetailVM({
|
|
detail: {
|
|
channel: channel(),
|
|
messages: [persisted],
|
|
readReceipts: [],
|
|
attachments: [
|
|
{
|
|
messageId: persisted.id,
|
|
fileId: "file_skipped",
|
|
displayName: "unchecked.pdf",
|
|
mimeType: "application/pdf",
|
|
sizeBytes: 20,
|
|
linkRole: "file",
|
|
previewStatus: "not_supported",
|
|
scanStatus: "skipped",
|
|
},
|
|
],
|
|
hasOlderMessages: false,
|
|
olderMessageCursor: null,
|
|
eventCursor: "stream_1:1",
|
|
},
|
|
tenantSlug: "demo",
|
|
currentUserId: "user_me",
|
|
permissions: ["chat.read_own_channels"],
|
|
labels,
|
|
});
|
|
|
|
expect(vm.status === "ready" ? vm.data.messages[0]?.attachments[0]?.scanStatus : null).toBe("blocked");
|
|
});
|
|
});
|