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

389 lines
14 KiB
TypeScript

// @vitest-environment jsdom
import {
CHAT_EVENT_NAMES,
type ChatLocalEcho,
type ChatMessageCreatedEvent,
type ChatReadReceiptEvent,
type ChatRealtimeClient,
type ChatRealtimeClientOptions,
} from "@hivegrid/realtime";
import type { ChatMessageCandidate, ChatResolvedAuthorVM } from "@hivegrid/view-models";
import { act, renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { ChatVisibilitySource } from "./chatInteractionControllers";
import { useChatChannelRealtime, type UseChatChannelRealtimeInput } from "./useChatChannelRealtime";
const viewer: ChatResolvedAuthorVM = {
userId: "user_viewer",
displayName: "Raffael",
avatar: { imageUrl: null, fallbackLabel: "R" },
isCurrentUser: true,
};
const reader: ChatResolvedAuthorVM = {
userId: "user_reader",
displayName: "Mara",
avatar: { imageUrl: null, fallbackLabel: "M" },
isCurrentUser: false,
};
function hydratedMessage(): ChatMessageCandidate {
return {
id: "message_read",
clientMessageId: "client_read",
source: "persisted",
deliveryState: "sent",
failureReasonKey: null,
retryable: false,
author: { userId: "user_author", displayName: "Ada", avatar: { imageUrl: null, fallbackLabel: "A" }, isCurrentUser: false },
bodyMarkdown: "Ältere Nachricht",
bodyFormat: "markdown",
systemEvent: null,
createdAtIso: "2026-07-15T07:00:00.000Z",
updatedAtIso: "2026-07-15T07:00:00.000Z",
editedAtIso: null,
deletedAtIso: null,
parentMessageId: null,
mentions: [],
reactions: [],
thread: null,
attachments: [],
linkPreviews: [],
readAggregate: { readCount: 0, recipientCount: 2, readerUserIds: [], readerLabels: [], labelKey: "chat.message.read.none" },
pin: null,
actions: { canReact: false, canReply: false, canEdit: false, canDelete: false, canPin: false, canMarkAsTask: false },
};
}
function createdEvent(clientMessageId = "client_remote"): ChatMessageCreatedEvent {
return {
event: CHAT_EVENT_NAMES.messageCreated,
cursor: "stream_1:2",
requestId: "request_remote",
tenantId: "tenant_1",
chatId: "chat_1",
occurredAt: "2026-07-15T08:00:00.000Z",
data: {
message: {
id: "message_remote",
clientMessageId,
authorUserId: "user_missing",
body: "Realtime",
bodyFormat: "markdown",
parentMessageId: null,
mentions: [],
attachmentIds: [],
linkPreviews: [],
createdAt: "2026-07-15T08:00:00.000Z",
updatedAt: "2026-07-15T08:00:00.000Z",
editedAt: null,
deletedAt: null,
},
},
};
}
function readReceiptEvent(userId = viewer.userId): ChatReadReceiptEvent {
return {
event: CHAT_EVENT_NAMES.readReceipt,
cursor: "stream_1:3",
requestId: "request_read",
tenantId: "tenant_1",
chatId: "chat_1",
occurredAt: "2026-07-15T08:02:00.000Z",
data: { messageId: "message_read", userId, readAt: "2026-07-15T08:02:00.000Z" },
};
}
function localEcho(overrides: Partial<ChatLocalEcho> = {}): ChatLocalEcho {
return {
chatId: "chat_1",
requestId: "request_local",
clientMessageId: "client_local",
body: "Optimistisch",
mentions: [],
status: "sending",
failureI18nKey: null,
retryable: false,
attempts: 1,
createdAtMs: Date.parse("2026-07-15T08:01:00.000Z"),
canonicalMessage: null,
...overrides,
};
}
function createHarness() {
const calls: string[] = [];
let callbacks: ChatRealtimeClientOptions | null = null;
const callbackHistory: ChatRealtimeClientOptions[] = [];
const client: ChatRealtimeClient = {
status: "offline",
connect: vi.fn(() => calls.push("connect")),
retryNow: vi.fn(),
close: vi.fn(() => calls.push("close")),
subscribe: vi.fn((chatId, cursor) => calls.push(`subscribe:${chatId}:${cursor ?? ""}`)),
unsubscribe: vi.fn((chatId) => calls.push(`unsubscribe:${chatId}`)),
sendMessage: vi.fn((request) => localEcho({ chatId: request.chatId, body: request.body })),
restoreFailedMessage: vi.fn((request) =>
localEcho({
chatId: request.chatId,
body: request.body,
clientMessageId: request.clientMessageId,
requestId: request.requestId,
status: "failed",
failureI18nKey: request.failureI18nKey,
retryable: request.retryable,
createdAtMs: request.createdAtMs,
})
),
retryMessage: vi.fn(() => localEcho({ status: "sending" })),
discardMessage: vi.fn(() => true),
markRead: vi.fn(() => ({ requestId: "request_read", sent: true })),
setTyping: vi.fn(() => true),
setPresence: vi.fn(() => true),
ping: vi.fn(() => true),
getLocalEchoes: vi.fn(() => []),
getTyping: vi.fn(() => []),
getPresence: vi.fn(() => []),
getSubscriptions: vi.fn(() => []),
};
const clientFactory = vi.fn((options: ChatRealtimeClientOptions) => {
callbacks = options;
callbackHistory.push(options);
return client;
});
return {
calls,
client,
clientFactory,
getCallbacks: () => callbacks as ChatRealtimeClientOptions,
getCallbackHistory: () => callbackHistory,
};
}
function options(
harness: ReturnType<typeof createHarness>,
revalidate: ReturnType<typeof vi.fn>,
visibility: ChatVisibilitySource,
initialCursor = "stream_1:1",
tenantSlug = "hivegrid-demo",
chatId = "chat_1"
): UseChatChannelRealtimeInput {
return {
tenantSlug,
chatId,
initialCursor,
csrfToken: "csrf",
routing: { protocol: "https", apiHost: "api.example.test" },
initialMessages: [],
currentUser: viewer,
unknownAuthorLabel: "Unbekannt",
resolveAuthor: () => {
throw new Error("projection miss");
},
revalidate,
clientFactory: harness.clientFactory,
tokenProviderFactory: () => async () => "token",
visibility,
};
}
describe("useChatChannelRealtime", () => {
it("subscribes before connecting and reapplies a newer loader cursor without reconnecting", () => {
const harness = createHarness();
const visibility = fakeVisibility();
const revalidate = vi.fn();
const { rerender, unmount } = renderHook(({ cursor }) => useChatChannelRealtime(options(harness, revalidate, visibility, cursor)), {
initialProps: { cursor: "stream_1:1" },
});
expect(harness.calls.slice(0, 2)).toEqual(["subscribe:chat_1:stream_1:1", "connect"]);
rerender({ cursor: "stream_1:3" });
expect(harness.calls).toContain("subscribe:chat_1:stream_1:3");
expect(harness.client.connect).toHaveBeenCalledTimes(1);
unmount();
expect(harness.calls.slice(-2)).toEqual(["unsubscribe:chat_1", "close"]);
});
it("revalidates after reconnect and reset, and safely renders unknown event authors", async () => {
const harness = createHarness();
const visibility = fakeVisibility();
const revalidate = vi.fn();
const { result } = renderHook(() => useChatChannelRealtime(options(harness, revalidate, visibility)));
const callbacks = harness.getCallbacks();
act(() => {
callbacks.onStatus?.("reconnecting", { reconnectAttempt: 1, retryAtMs: 1_000 });
callbacks.onStatus?.("online", { reconnectAttempt: 0, retryAtMs: null });
callbacks.onResetRequired?.({ chatId: "chat_1", currentStreamId: "stream_2" });
expect(() => callbacks.onEvent?.(createdEvent(), { source: "live", reconciledClientMessageId: null })).not.toThrow();
});
await waitFor(() => expect(revalidate).toHaveBeenCalled());
expect(harness.client.setPresence).toHaveBeenCalledWith("online");
expect(result.current.connection.state).toBe("online");
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]?.author).toMatchObject({ userId: "user_missing", displayName: "Unbekannt" });
});
it("projects local echo, failure, acknowledgement and discard while delegating controls", () => {
const harness = createHarness();
const visibility = fakeVisibility();
const { result } = renderHook(() => useChatChannelRealtime(options(harness, vi.fn(), visibility)));
const callbacks = harness.getCallbacks();
act(() => callbacks.onLocalEcho?.(localEcho(), "created"));
expect(result.current.messages[0]).toMatchObject({ source: "local_echo", deliveryState: "sending" });
act(() => callbacks.onLocalEcho?.(localEcho({ status: "failed", retryable: true, failureI18nKey: "chat.errors.realtimeUnavailable" }), "updated"));
expect(result.current.messages[0]).toMatchObject({ deliveryState: "failed", failureReasonKey: "chat.errors.realtimeUnavailable" });
act(() =>
callbacks.onLocalEcho?.(
localEcho({
status: "sent",
canonicalMessage: { ...createdEvent("client_local").data.message, authorUserId: viewer.userId },
}),
"updated"
)
);
expect(result.current.messages[0]).toMatchObject({ source: "http_ack", deliveryState: "sent" });
expect(result.current.sendMessage("Neu")).not.toBeNull();
expect(harness.client.sendMessage).toHaveBeenCalledWith({ chatId: "chat_1", body: "Neu", mentions: [] });
expect(result.current.sendMessage("Retry", [], "persisted_client_1", "persisted_request_1")).not.toBeNull();
expect(harness.client.sendMessage).toHaveBeenCalledWith({
chatId: "chat_1",
body: "Retry",
mentions: [],
clientMessageId: "persisted_client_1",
requestId: "persisted_request_1",
});
expect(
result.current.restoreFailedMessage("Fehlgeschlagen", "persisted_client_2", "persisted_request_2", "chat.errors.realtimeUnavailable", true, 500)
).not.toBeNull();
expect(harness.client.restoreFailedMessage).toHaveBeenCalledWith({
chatId: "chat_1",
body: "Fehlgeschlagen",
mentions: [],
clientMessageId: "persisted_client_2",
requestId: "persisted_request_2",
failureI18nKey: "chat.errors.realtimeUnavailable",
retryable: true,
createdAtMs: 500,
});
expect(result.current.retryMessage("client_local")).not.toBeNull();
expect(result.current.discardMessage("client_local")).toBe(true);
result.current.retryConnection();
expect(harness.client.retryNow).toHaveBeenCalledOnce();
act(() => callbacks.onLocalEcho?.(localEcho(), "discarded"));
expect(result.current.messages).toEqual([]);
});
it("does not expose candidates from the previous tenant or channel scope", () => {
const harness = createHarness();
const visibility = fakeVisibility();
const revalidate = vi.fn();
const { result, rerender } = renderHook(
({ tenantSlug, chatId }) => useChatChannelRealtime(options(harness, revalidate, visibility, "stream_1:1", tenantSlug, chatId)),
{ initialProps: { tenantSlug: "tenant-one", chatId: "chat_1" } }
);
const oldCallbacks = harness.getCallbacks();
act(() => oldCallbacks.onEvent?.(createdEvent(), { source: "live", reconciledClientMessageId: null }));
expect(result.current.messages).toHaveLength(1);
rerender({ tenantSlug: "tenant-two", chatId: "chat_2" });
expect(result.current.messages).toEqual([]);
expect(result.current.typingLabels).toEqual([]);
expect(result.current.connection.state).toBe("offline");
act(() => oldCallbacks.onLocalEcho?.(localEcho(), "created"));
expect(result.current.messages).toEqual([]);
expect(harness.getCallbackHistory()).toHaveLength(2);
});
it("tracks begin/end visibility sessions before delegating exact read receipts", () => {
vi.useFakeTimers();
const harness = createHarness();
const visibility = fakeVisibility();
const { result, unmount } = renderHook(() => useChatChannelRealtime(options(harness, vi.fn(), visibility)));
act(() => {
result.current.beginMessageVisible("message_short", "2026-07-15T08:00:00.000Z");
vi.advanceTimersByTime(999);
result.current.endMessageVisible("message_short");
vi.advanceTimersByTime(5_000);
});
expect(harness.client.markRead).not.toHaveBeenCalled();
act(() => {
result.current.beginMessageVisible("message_read", "2026-07-15T08:01:00.000Z");
vi.advanceTimersByTime(3_000);
});
expect(harness.client.markRead).toHaveBeenCalledWith("chat_1", "message_read");
act(() => {
callbacksFor(harness).onStatus?.("reconnecting", { reconnectAttempt: 1, retryAtMs: 1_000 });
callbacksFor(harness).onStatus?.("online", { reconnectAttempt: 0, retryAtMs: null });
});
expect(harness.client.markRead).toHaveBeenLastCalledWith("chat_1", "message_read", "request_read");
act(() => {
callbacksFor(harness).onStatus?.("reconnecting", { reconnectAttempt: 1, retryAtMs: 1_000 });
callbacksFor(harness).onEvent?.(readReceiptEvent(), { source: "gap", reconciledClientMessageId: null });
callbacksFor(harness).onStatus?.("online", { reconnectAttempt: 0, retryAtMs: null });
});
expect(harness.client.markRead).toHaveBeenCalledTimes(2);
unmount();
vi.useRealTimers();
});
it("keeps live read aggregates on older hydrated messages across loader revalidation", () => {
const harness = createHarness();
const visibility = fakeVisibility();
const revalidate = vi.fn();
const { result, rerender } = renderHook(
({ revision }) => {
const base = options(harness, revalidate, visibility);
return useChatChannelRealtime({
...base,
initialMessages: [{ ...hydratedMessage(), updatedAtIso: `2026-07-15T07:00:0${revision}.000Z` }],
resolveAuthor: (userId) => (userId === reader.userId ? reader : null),
});
},
{ initialProps: { revision: 0 } }
);
act(() => callbacksFor(harness).onEvent?.(readReceiptEvent(reader.userId), { source: "live", reconciledClientMessageId: null }));
expect(result.current.messages[0]?.readAggregate).toMatchObject({
readCount: 1,
readerUserIds: [reader.userId],
readerLabels: [reader.displayName],
});
rerender({ revision: 1 });
expect(result.current.messages[0]?.readAggregate).toMatchObject({
readCount: 1,
readerUserIds: [reader.userId],
readerLabels: [reader.displayName],
});
});
});
function callbacksFor(harness: ReturnType<typeof createHarness>): ChatRealtimeClientOptions {
return harness.getCallbacks();
}
function fakeVisibility(): ChatVisibilitySource {
return {
visibilityState: "visible",
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
}