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

311 lines
10 KiB
TypeScript

import { CHAT_EVENT_NAMES, type ChatLocalEcho, type ChatMessageCreatedEvent, type ChatRealtimeMessage, type ChatThreadReplyEvent } from "@hivegrid/realtime";
import type { ChatResolvedAuthorVM } from "@hivegrid/view-models";
import { describe, expect, it } from "vitest";
import {
candidateFromChatEvent,
candidateFromLocalEcho,
chatChannelRealtimeReducer,
createChatRealtimeScopeKey,
createInitialChatChannelRealtimeState,
filterHydratedChatCandidates,
safeResolveChatAuthor,
type ChatCandidateMapping,
} from "./chatChannelRealtimeReducer";
const viewer: ChatResolvedAuthorVM = {
userId: "user_viewer",
displayName: "Raffael",
avatar: { imageUrl: null, fallbackLabel: "R" },
isCurrentUser: true,
};
const scopeKey = createChatRealtimeScopeKey("tenant_1", "chat_1");
function message(overrides: Partial<ChatRealtimeMessage> = {}): ChatRealtimeMessage {
return {
id: "message_1",
clientMessageId: "client_1",
authorUserId: "user_unknown",
body: "Hallo",
bodyFormat: "markdown",
parentMessageId: null,
mentions: [{ type: "user", id: "user_viewer", label: null }],
attachmentIds: [],
linkPreviews: [],
createdAt: "2026-07-15T08:00:00.000Z",
updatedAt: "2026-07-15T08:00:00.000Z",
editedAt: null,
deletedAt: null,
...overrides,
};
}
function mapping(overrides: Partial<ChatCandidateMapping> = {}): ChatCandidateMapping {
return {
currentUser: viewer,
unknownAuthorLabel: "Unbekannt",
resolveAuthor: () => undefined,
...overrides,
};
}
describe("chatChannelRealtimeReducer", () => {
it("maps an event with an unknown or failing author projection without throwing", () => {
const event: ChatMessageCreatedEvent = {
event: CHAT_EVENT_NAMES.messageCreated,
cursor: "stream_1:1",
requestId: "request_1",
tenantId: "tenant_1",
chatId: "chat_1",
occurredAt: "2026-07-15T08:00:00.000Z",
data: { message: message() },
};
const candidate = candidateFromChatEvent(
event,
mapping({
resolveAuthor: () => {
throw new Error("stale author cache");
},
messageActions: () => {
throw new Error("stale permission projection");
},
})
);
expect(candidate?.author).toMatchObject({ userId: "user_unknown", displayName: "Unbekannt", isCurrentUser: false });
expect(candidate?.mentions).toEqual([{ kind: "user", id: "user_viewer", label: "user_viewer", href: null }]);
expect(candidate?.actions).toEqual({
canReact: false,
canReply: false,
canEdit: false,
canDelete: false,
canPin: false,
canMarkAsTask: false,
});
});
it("reconciles local echo lifecycle by author-scoped client id", () => {
const echo: ChatLocalEcho = {
chatId: "chat_1",
requestId: "request_1",
clientMessageId: "client_local",
body: "Optimistisch",
mentions: [],
status: "sending",
failureI18nKey: null,
retryable: false,
attempts: 1,
createdAtMs: Date.parse("2026-07-15T08:00:00.000Z"),
canonicalMessage: null,
};
const sending = candidateFromLocalEcho(echo, mapping());
let state = chatChannelRealtimeReducer(createInitialChatChannelRealtimeState(scopeKey), {
type: "upsert_candidate",
scopeKey,
candidate: sending,
});
expect(state.candidates).toHaveLength(1);
expect(state.candidates[0]?.deliveryState).toBe("sending");
const failed = candidateFromLocalEcho({ ...echo, status: "failed", failureI18nKey: "chat.errors.realtimeUnavailable", retryable: true }, mapping());
state = chatChannelRealtimeReducer(state, { type: "upsert_candidate", scopeKey, candidate: failed });
expect(state.candidates).toHaveLength(1);
expect(state.candidates[0]).toMatchObject({ deliveryState: "failed", failureReasonKey: "chat.errors.realtimeUnavailable" });
const acknowledged = candidateFromLocalEcho(
{ ...echo, status: "sent", canonicalMessage: message({ authorUserId: viewer.userId, clientMessageId: echo.clientMessageId }) },
mapping()
);
state = chatChannelRealtimeReducer(state, { type: "upsert_candidate", scopeKey, candidate: acknowledged });
expect(state.candidates[0]).toMatchObject({ id: "message_1", source: "http_ack", deliveryState: "sent" });
state = chatChannelRealtimeReducer(state, {
type: "remove_candidate",
scopeKey,
authorUserId: viewer.userId,
clientMessageId: echo.clientMessageId,
});
expect(state.candidates).toEqual([]);
});
it("deduplicates typing labels and safely falls back for the current user", () => {
const author = safeResolveChatAuthor(viewer.userId, mapping({ unknownAuthorLabel: "Unbekannt" }));
expect(author).toBe(viewer);
const state = chatChannelRealtimeReducer(createInitialChatChannelRealtimeState(scopeKey), {
type: "typing",
scopeKey,
labels: ["Ada", "Ada", "Grace"],
});
expect(state.typingLabels).toEqual(["Ada", "Grace"]);
});
it("lets the richer loader projection replace a hydrated realtime candidate", () => {
const event: ChatMessageCreatedEvent = {
event: CHAT_EVENT_NAMES.messageCreated,
cursor: "stream_1:1",
requestId: "request_1",
tenantId: "tenant_1",
chatId: "chat_1",
occurredAt: "2026-07-15T08:00:00.000Z",
data: { message: message() },
};
const realtime = candidateFromChatEvent(event, mapping());
if (!realtime) throw new Error("fixture must map to a candidate");
if (!realtime.author) throw new Error("fixture must carry a markdown author");
const hydrated = {
...realtime,
source: "persisted" as const,
author: { ...realtime.author, displayName: "Grace Hopper" },
};
expect(filterHydratedChatCandidates([hydrated], [realtime])).toEqual([]);
expect(filterHydratedChatCandidates([], [realtime])).toEqual([realtime]);
});
it("drops stale-scope actions after a tenant or channel switch", () => {
const candidate = candidateFromLocalEcho(
{
chatId: "chat_1",
requestId: "request_old",
clientMessageId: "client_old",
body: "Alt",
mentions: [],
status: "sending",
failureI18nKey: null,
retryable: false,
attempts: 1,
createdAtMs: Date.parse("2026-07-15T08:00:00.000Z"),
canonicalMessage: null,
},
mapping()
);
const nextScopeKey = createChatRealtimeScopeKey("tenant_2", "chat_1");
let state = chatChannelRealtimeReducer(createInitialChatChannelRealtimeState(scopeKey), {
type: "upsert_candidate",
scopeKey,
candidate,
});
state = chatChannelRealtimeReducer(state, { type: "reset", scopeKey: nextScopeKey });
state = chatChannelRealtimeReducer(state, { type: "upsert_candidate", scopeKey, candidate });
state = chatChannelRealtimeReducer(state, {
type: "connection",
scopeKey,
connection: { state: "online", reconnectAttempt: 0, retryAtIso: null },
});
expect(state).toEqual(createInitialChatChannelRealtimeState(nextScopeKey));
});
it("never projects thread replies into the top-level channel timeline", () => {
const event: ChatThreadReplyEvent = {
event: CHAT_EVENT_NAMES.threadReply,
cursor: "stream_1:2",
requestId: "request_reply",
tenantId: "tenant_1",
chatId: "chat_1",
occurredAt: "2026-07-15T08:01:00.000Z",
data: {
thread: {
id: "thread_1",
parentMessageId: "message_parent",
replyCount: 1,
lastReplyAt: "2026-07-15T08:01:00.000Z",
participantUserIds: ["user_unknown"],
},
message: message({ id: "message_reply", parentMessageId: "message_parent" }),
},
};
expect(candidateFromChatEvent(event, mapping())).toBeNull();
});
it("projects actorless system events without fake author or user-message affordances", () => {
const event: ChatMessageCreatedEvent = {
event: CHAT_EVENT_NAMES.messageCreated,
cursor: "stream_1:4",
requestId: "request_system",
tenantId: "tenant_1",
chatId: "chat_1",
occurredAt: "2026-07-15T08:03:00.000Z",
data: {
message: {
id: "system_message_1",
clientMessageId: "system:member-write:user_1:false",
authorUserId: null,
body: "stored fallback copy",
bodyFormat: "system",
systemEvent: {
kind: "member_write_access_changed",
metadata: { memberName: "Nora Weber", canWrite: false },
},
parentMessageId: null,
mentions: [],
attachmentIds: [],
linkPreviews: [],
createdAt: "2026-07-15T08:03:00.000Z",
updatedAt: "2026-07-15T08:03:00.000Z",
editedAt: null,
deletedAt: null,
},
},
};
const candidate = candidateFromChatEvent(event, mapping());
expect(candidate).toMatchObject({
author: null,
bodyMarkdown: null,
bodyFormat: "system",
readAggregate: null,
systemEvent: {
kind: "member_write_access_changed",
labelKey: "chat.systemEvent.memberWriteAccessRevoked",
values: { member: "Nora Weber" },
},
actions: { canReact: false, canReply: false, canEdit: false, canDelete: false, canPin: false, canMarkAsTask: false },
});
});
it("strips mentions, previews and actions from deleted realtime candidates", () => {
const event: ChatMessageCreatedEvent = {
event: CHAT_EVENT_NAMES.messageCreated,
cursor: "stream_1:3",
requestId: "request_deleted",
tenantId: "tenant_1",
chatId: "chat_1",
occurredAt: "2026-07-15T08:02:00.000Z",
data: {
message: message({
deletedAt: "2026-07-15T08:02:00.000Z",
linkPreviews: [
{
url: "https://example.test",
title: "Vorschau",
description: null,
imageUrl: null,
siteName: "Example",
},
],
}),
},
};
const candidate = candidateFromChatEvent(
event,
mapping({
messageActions: () => ({
canReact: true,
canReply: true,
canEdit: true,
canDelete: true,
canPin: true,
canMarkAsTask: true,
}),
})
);
expect(candidate).toMatchObject({ bodyMarkdown: null, mentions: [], linkPreviews: [], actions: expect.objectContaining({ canReact: false }) });
});
});