Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/chat/services/chatViewModel.server.test.ts
T

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");
});
});