import { buildChatChannelDetailVM, type ChatMessageCandidate } from "@hivegrid/view-models"; import { describe, expect, it } from "vitest"; import { overlayChatChannelDetailRealtime } from "./chatRealtimeViewModel"; const message: ChatMessageCandidate = { id: "message_1", clientMessageId: "client_1", source: "websocket", deliveryState: "sent", failureReasonKey: null, author: { userId: "user_1", displayName: "Ada", avatar: { imageUrl: null, fallbackLabel: "A" }, isCurrentUser: false, }, bodyMarkdown: "Hallo", bodyFormat: "markdown", systemEvent: null, createdAtIso: "2026-07-15T08:00:00.000Z", updatedAtIso: "2026-07-15T08:00:00.000Z", editedAtIso: null, deletedAtIso: null, parentMessageId: null, mentions: [], reactions: [], thread: null, attachments: [], linkPreviews: [], readAggregate: { readCount: 0, recipientCount: 1, readerUserIds: [], readerLabels: [], labelKey: "chat.message.read.none" }, pin: null, actions: { canReact: false, canReply: false, canEdit: false, canDelete: false, canPin: false, canMarkAsTask: false }, }; function readyVm() { return buildChatChannelDetailVM({ header: { id: "chat_1", kind: "group", title: "Team", subtitle: null, avatar: { imageUrl: null, fallbackLabel: "T" }, project: null, memberCount: 2, isPrivate: false, isArchived: false, isAuditMode: false, canManage: false, settingsHref: null, pinnedHref: null, searchHref: "/tenant/chat/chat_1/search", }, messages: [], pinnedMessages: [], typingLabels: [], history: { olderCursor: null, hasOlder: false }, composer: { disabledReasonKey: null, canSend: true, canAttachFiles: false, canRecordVoice: false, canMention: false, canMarkAsTask: false, draftStorageKey: "chat:draft", maxBodyLength: 8_000, }, connection: { state: "connecting", reconnectAttempt: 0, retryAtIso: null }, }); } describe("overlayChatChannelDetailRealtime", () => { it("overlays transport state on a ready loader VM without replacing domain data", () => { const renderedMessage = buildChatChannelDetailVM({ ...readyVmInput(), messages: [message] }); if (renderedMessage.status !== "ready") throw new Error("fixture must be ready"); const vm = readyVm(); const result = overlayChatChannelDetailRealtime(vm, { messages: renderedMessage.data.messages, messageGroups: renderedMessage.data.messageGroups, presence: [{ userId: "user_2", status: "online" }], typingLabels: ["Ada"], connection: { state: "reconnecting", reconnectAttempt: 2, retryAtIso: "2026-07-15T08:01:00.000Z" }, }); expect(result.status).toBe("ready"); if (result.status !== "ready" || vm.status !== "ready") throw new Error("fixture must be ready"); expect(result.data.header).toBe(vm.data.header); expect(result.data.messages).toEqual(renderedMessage.data.messages); expect(result.data.presence).toEqual([{ userId: "user_2", status: "online" }]); expect(result.data.typingLabels).toEqual(["Ada"]); expect(result.data.connection).toEqual({ state: "reconnecting", stateLabelKey: "chat.connection.reconnecting", reconnectAttempt: 2, retryAtIso: "2026-07-15T08:01:00.000Z", canRetry: true, }); }); it("leaves non-ready loader states untouched", () => { const vm = { status: "loading" } as const; expect( overlayChatChannelDetailRealtime(vm, { messages: [], messageGroups: [], presence: [], typingLabels: [], connection: { state: "online", reconnectAttempt: 0, retryAtIso: null }, }) ).toBe(vm); }); it("makes a terminally blocked connection read-only without hiding the draft", () => { const vm = readyVm(); const result = overlayChatChannelDetailRealtime(vm, { messages: [], messageGroups: [], presence: [], typingLabels: [], connection: { state: "blocked", reconnectAttempt: 0, retryAtIso: null }, }); expect(result.status).toBe("ready"); if (result.status !== "ready") return; expect(result.data.composer).toMatchObject({ enabled: false, canSend: false, disabledReasonKey: "chat.composer.connectionBlocked", }); expect(result.data.connection.canRetry).toBe(false); }); }); function readyVmInput(): Parameters[0] { const vm = readyVm(); if (vm.status !== "ready") throw new Error("fixture must be ready"); return { header: { id: vm.data.header.id, kind: vm.data.header.kind, title: vm.data.header.title, subtitle: vm.data.header.subtitle, avatar: vm.data.header.avatar, project: vm.data.header.project, memberCount: vm.data.header.memberCount, isPrivate: vm.data.header.isPrivate, isArchived: vm.data.header.isArchived, isAuditMode: vm.data.header.isAuditMode, canManage: vm.data.header.canManage, settingsHref: vm.data.header.settingsHref, pinnedHref: vm.data.header.pinnedHref, searchHref: vm.data.header.searchHref, }, messages: [], pinnedMessages: [], typingLabels: [], history: vm.data.history, composer: { disabledReasonKey: vm.data.composer.disabledReasonKey, canSend: vm.data.composer.canSend, canAttachFiles: vm.data.composer.canAttachFiles, canRecordVoice: vm.data.composer.canRecordVoice, canMention: vm.data.composer.canMention, canMarkAsTask: vm.data.composer.canMarkAsTask, draftStorageKey: vm.data.composer.draftStorageKey, maxBodyLength: vm.data.composer.maxBodyLength, }, connection: { state: vm.data.connection.state, reconnectAttempt: vm.data.connection.reconnectAttempt, retryAtIso: vm.data.connection.retryAtIso, }, }; }