389 lines
14 KiB
TypeScript
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(),
|
|
};
|
|
}
|