311 lines
10 KiB
TypeScript
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 }) });
|
|
});
|
|
});
|