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

468 lines
16 KiB
TypeScript

// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useChatDraft, type ChatDraftDelivery, type ChatDraftStorage } from "./useChatDraft";
const STORAGE_KEY = "chat:draft:v1:tenant:user:chat";
const PENDING_STORAGE_KEY = `${STORAGE_KEY}:pending`;
afterEach(() => {
vi.useRealTimers();
});
describe("useChatDraft", () => {
it("loads a channel-scoped draft and saves edits only after the debounce", () => {
vi.useFakeTimers();
const storage = createMemoryStorage();
storage.setItem(STORAGE_KEY, "Vorhanden");
const { result } = renderHook(() =>
useChatDraft({
storageKey: STORAGE_KEY,
deliveries: [],
sendMessage: () => null,
updateTyping: vi.fn(),
storage,
})
);
expect(result.current).toMatchObject({ draft: "Vorhanden", loaded: true });
act(() => result.current.updateDraft("Neu"));
expect(storage.getItem(STORAGE_KEY)).toBe("Vorhanden");
act(() => vi.advanceTimersByTime(300));
expect(storage.getItem(STORAGE_KEY)).toBe("Neu");
});
it("keeps the draft when the realtime client cannot accept the send", () => {
const storage = createMemoryStorage();
const { result } = renderHook(() =>
useChatDraft({
storageKey: STORAGE_KEY,
deliveries: [],
sendMessage: () => null,
updateTyping: vi.fn(),
storage,
})
);
act(() => result.current.updateDraft("Nicht verlieren"));
let accepted = true;
act(() => {
accepted = result.current.sendDraft("Nicht verlieren");
});
expect(accepted).toBe(false);
expect(result.current.draft).toBe("Nicht verlieren");
expect(storage.getItem(STORAGE_KEY)).toBe("Nicht verlieren");
});
it("rehydrates an unresolved send after navigation with the original correlation ids", () => {
let deliveries: readonly ChatDraftDelivery[] = [];
const storage = createMemoryStorage();
const sendMessage = vi.fn(() => ({ clientMessageId: "client_1", requestId: "request_1" }));
const first = renderHook(() =>
useChatDraft({
storageKey: STORAGE_KEY,
deliveries,
sendMessage,
updateTyping: vi.fn(),
storage,
})
);
act(() => first.result.current.updateDraft("Ack abwarten"));
act(() => {
expect(first.result.current.sendDraft("Ack abwarten")).toBe(true);
});
expect(first.result.current.draft).toBe("");
expect(storage.getItem(STORAGE_KEY)).toBeNull();
expect(storage.getItem(PENDING_STORAGE_KEY)).not.toBeNull();
first.unmount();
const second = renderHook(() =>
useChatDraft({
storageKey: STORAGE_KEY,
deliveries,
sendMessage,
updateTyping: vi.fn(),
storage,
})
);
expect(second.result.current.draft).toBe("");
expect(sendMessage).toHaveBeenCalledTimes(2);
expect(sendMessage).toHaveBeenLastCalledWith("Ack abwarten", "client_1", "request_1");
deliveries = [{ clientMessageId: "client_1", isCurrentUser: true, deliveryState: "sent", failureReasonKey: null, retryable: false }];
second.rerender();
expect(second.result.current.draft).toBe("");
expect(storage.getItem(STORAGE_KEY)).toBeNull();
expect(storage.getItem(PENDING_STORAGE_KEY)).toBeNull();
});
it("rehydrates every older pending send without replacing a newer composer draft", () => {
let deliveries: readonly ChatDraftDelivery[] = [];
const storage = createMemoryStorage();
const sendMessage = vi
.fn()
.mockReturnValueOnce({ clientMessageId: "client_1", requestId: "request_1" })
.mockReturnValueOnce({ clientMessageId: "client_2", requestId: "request_2" })
.mockImplementation((_body: string, clientMessageId?: string, requestId?: string) =>
clientMessageId && requestId ? { clientMessageId, requestId } : null
);
const first = renderHook(() =>
useChatDraft({
storageKey: STORAGE_KEY,
deliveries,
sendMessage,
updateTyping: vi.fn(),
storage,
})
);
act(() => first.result.current.updateDraft("Nachricht A"));
act(() => {
expect(first.result.current.sendDraft("Nachricht A")).toBe(true);
first.result.current.updateDraft("Nachricht B");
expect(first.result.current.sendDraft("Nachricht B")).toBe(true);
first.result.current.updateDraft("Neuer Entwurf C");
});
first.unmount();
const second = renderHook(() =>
useChatDraft({
storageKey: STORAGE_KEY,
deliveries,
sendMessage,
updateTyping: vi.fn(),
storage,
})
);
expect(second.result.current.draft).toBe("Neuer Entwurf C");
expect(sendMessage).toHaveBeenNthCalledWith(3, "Nachricht A", "client_1", "request_1");
expect(sendMessage).toHaveBeenNthCalledWith(4, "Nachricht B", "client_2", "request_2");
deliveries = [
{ clientMessageId: "client_1", isCurrentUser: true, deliveryState: "sent", failureReasonKey: null, retryable: false },
{ clientMessageId: "client_2", isCurrentUser: true, deliveryState: "sent", failureReasonKey: null, retryable: false },
];
second.rerender();
expect(second.result.current.draft).toBe("Neuer Entwurf C");
expect(storage.getItem(STORAGE_KEY)).toBe("Neuer Entwurf C");
expect(storage.getItem(PENDING_STORAGE_KEY)).toBeNull();
});
it("dispatches an explicit retry for a failed echo that still belongs to the active client", () => {
let deliveries: readonly ChatDraftDelivery[] = [];
const storage = createMemoryStorage();
const sendMessage = vi.fn(() => ({ clientMessageId: "client_1", requestId: "request_1" }));
const retryMessage = vi.fn(() => ({ clientMessageId: "client_1", requestId: "request_1" }));
const hook = renderHook(() =>
useChatDraft({
storageKey: STORAGE_KEY,
deliveries,
sendMessage,
retryMessage,
updateTyping: vi.fn(),
storage,
})
);
act(() => hook.result.current.updateDraft("Direkt erneut versuchen"));
act(() => {
expect(hook.result.current.sendDraft("Direkt erneut versuchen")).toBe(true);
});
deliveries = [
{
clientMessageId: "client_1",
isCurrentUser: true,
deliveryState: "failed",
failureReasonKey: "chat.errors.realtimeUnavailable",
retryable: true,
},
];
hook.rerender();
expect(hook.result.current.draft).toBe("Direkt erneut versuchen");
act(() => {
expect(hook.result.current.sendDraft("Direkt erneut versuchen")).toBe(true);
});
expect(retryMessage).toHaveBeenCalledWith("client_1");
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(hook.result.current.draft).toBe("");
});
it("restores an observed failure as a local echo without sending it again", () => {
let deliveries: readonly ChatDraftDelivery[] = [];
const storage = createMemoryStorage();
const sendMessage = vi.fn(() => ({ clientMessageId: "client_1", requestId: "request_1" }));
const first = renderHook(() =>
useChatDraft({
storageKey: STORAGE_KEY,
deliveries,
sendMessage,
updateTyping: vi.fn(),
storage,
})
);
act(() => first.result.current.updateDraft("Nicht automatisch senden"));
act(() => {
expect(first.result.current.sendDraft("Nicht automatisch senden")).toBe(true);
});
deliveries = [
{
clientMessageId: "client_1",
isCurrentUser: true,
deliveryState: "failed",
failureReasonKey: "chat.errors.invalidInput",
retryable: false,
},
];
first.rerender();
first.unmount();
deliveries = [];
const restoreFailedMessage = vi.fn(() => ({ clientMessageId: "client_1", requestId: "request_1" }));
const second = renderHook(() =>
useChatDraft({
storageKey: STORAGE_KEY,
deliveries,
sendMessage,
restoreFailedMessage,
updateTyping: vi.fn(),
storage,
})
);
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(restoreFailedMessage).toHaveBeenCalledWith(
"Nicht automatisch senden",
"client_1",
"request_1",
"chat.errors.invalidInput",
false,
expect.any(Number)
);
expect(second.result.current.draft).toBe("");
});
it("persists a later terminal retry failure with its new reason", () => {
let deliveries: readonly ChatDraftDelivery[] = [];
const storage = createMemoryStorage();
const sendMessage = vi.fn(() => ({ clientMessageId: "client_1", requestId: "request_1" }));
const retryMessage = vi.fn(() => ({ clientMessageId: "client_1", requestId: "request_1" }));
const first = renderHook(() => useChatDraft({ storageKey: STORAGE_KEY, deliveries, sendMessage, retryMessage, updateTyping: vi.fn(), storage }));
act(() => first.result.current.updateDraft("Noch einmal"));
act(() => {
expect(first.result.current.sendDraft("Noch einmal")).toBe(true);
});
deliveries = [
{
clientMessageId: "client_1",
isCurrentUser: true,
deliveryState: "failed",
failureReasonKey: "chat.errors.realtimeUnavailable",
retryable: true,
},
];
first.rerender();
act(() => {
expect(first.result.current.sendDraft("Noch einmal")).toBe(true);
});
deliveries = [{ clientMessageId: "client_1", isCurrentUser: true, deliveryState: "sending", failureReasonKey: null, retryable: false }];
first.rerender();
deliveries = [
{
clientMessageId: "client_1",
isCurrentUser: true,
deliveryState: "failed",
failureReasonKey: "chat.errors.invalidInput",
retryable: false,
},
];
first.rerender();
first.unmount();
deliveries = [];
const restoreFailedMessage = vi.fn(() => ({ clientMessageId: "client_1", requestId: "request_1" }));
renderHook(() => useChatDraft({ storageKey: STORAGE_KEY, deliveries, sendMessage, restoreFailedMessage, updateTyping: vi.fn(), storage }));
expect(restoreFailedMessage).toHaveBeenCalledWith("Noch einmal", "client_1", "request_1", "chat.errors.invalidInput", false, expect.any(Number));
});
it("refuses a seventeenth unresolved send without dropping the sixteen pending messages", () => {
const storage = createMemoryStorage();
const now = Date.now();
storage.setItem(
PENDING_STORAGE_KEY,
JSON.stringify({
version: 2,
revision: 16,
pending: Array.from({ length: 16 }, (_, index) => ({
body: `Nachricht ${index + 1}`,
clientMessageId: `client_${index + 1}`,
requestId: `request_${index + 1}`,
createdAt: now - 16 + index,
failureObserved: false,
failureReasonKey: null,
retryable: false,
restored: false,
revision: index + 1,
})),
})
);
const sendMessage = vi.fn((_body: string, clientMessageId?: string, requestId?: string) =>
clientMessageId && requestId ? { clientMessageId, requestId } : { clientMessageId: "client_17", requestId: "request_17" }
);
const first = renderHook(() => useChatDraft({ storageKey: STORAGE_KEY, deliveries: [], sendMessage, updateTyping: vi.fn(), storage }));
act(() => first.result.current.updateDraft("Nachricht 17"));
act(() => {
expect(first.result.current.sendDraft("Nachricht 17")).toBe(false);
});
expect(first.result.current.draft).toBe("Nachricht 17");
expect(JSON.parse(storage.getItem(PENDING_STORAGE_KEY) ?? "{}").pending).toHaveLength(16);
expect(sendMessage).toHaveBeenCalledTimes(16);
first.unmount();
renderHook(() => useChatDraft({ storageKey: STORAGE_KEY, deliveries: [], sendMessage, updateTyping: vi.fn(), storage }));
expect(sendMessage).toHaveBeenCalledTimes(32);
expect(JSON.parse(storage.getItem(PENDING_STORAGE_KEY) ?? "{}").pending).toHaveLength(16);
});
it("restores a failed send across remounts and clears that restoration after acknowledgement", () => {
let deliveries: readonly ChatDraftDelivery[] = [];
const storage = createMemoryStorage();
const input = () => ({
storageKey: STORAGE_KEY,
deliveries,
sendMessage: () => ({ clientMessageId: "client_1", requestId: "request_1" }),
updateTyping: vi.fn(),
storage,
});
const first = renderHook(() => useChatDraft(input()));
act(() => first.result.current.updateDraft("Erneut versuchen"));
act(() => {
expect(first.result.current.sendDraft("Erneut versuchen")).toBe(true);
});
first.unmount();
deliveries = [
{
clientMessageId: "client_1",
isCurrentUser: true,
deliveryState: "failed",
failureReasonKey: "chat.errors.realtimeUnavailable",
retryable: true,
},
];
const failed = renderHook(() => useChatDraft(input()));
expect(failed.result.current.draft).toBe("Erneut versuchen");
expect(storage.getItem(STORAGE_KEY)).toBe("Erneut versuchen");
failed.unmount();
const restored = renderHook(() => useChatDraft(input()));
expect(restored.result.current.draft).toBe("Erneut versuchen");
deliveries = [{ clientMessageId: "client_1", isCurrentUser: true, deliveryState: "sent", failureReasonKey: null, retryable: false }];
restored.rerender();
expect(restored.result.current.draft).toBe("");
expect(storage.getItem(STORAGE_KEY)).toBeNull();
expect(storage.getItem(PENDING_STORAGE_KEY)).toBeNull();
});
it("does not restore or clear a newer persisted draft when an older send settles", () => {
let deliveries: readonly ChatDraftDelivery[] = [];
const storage = createMemoryStorage();
const input = () => ({
storageKey: STORAGE_KEY,
deliveries,
sendMessage: () => ({ clientMessageId: "client_1", requestId: "request_1" }),
updateTyping: vi.fn(),
storage,
});
const first = renderHook(() =>
useChatDraft({
...input(),
})
);
act(() => first.result.current.updateDraft("Erste Nachricht"));
act(() => {
first.result.current.sendDraft("Erste Nachricht");
first.result.current.updateDraft("Nächster Entwurf");
});
first.unmount();
deliveries = [
{
clientMessageId: "client_1",
isCurrentUser: true,
deliveryState: "failed",
failureReasonKey: "chat.errors.realtimeUnavailable",
retryable: true,
},
];
const remounted = renderHook(() => useChatDraft(input()));
expect(remounted.result.current.draft).toBe("Nächster Entwurf");
deliveries = [{ clientMessageId: "client_1", isCurrentUser: true, deliveryState: "sent", failureReasonKey: null, retryable: false }];
remounted.rerender();
expect(remounted.result.current.draft).toBe("Nächster Entwurf");
expect(storage.getItem(STORAGE_KEY)).toBe("Nächster Entwurf");
expect(storage.getItem(PENDING_STORAGE_KEY)).toBeNull();
});
it.each([
["invalid JSON", "{not-json"],
[
"an oversized pending body",
JSON.stringify({
version: 2,
revision: 0,
pending: [
{
body: "x".repeat(8_001),
clientMessageId: "client_1",
createdAt: Date.now(),
failureObserved: false,
restored: false,
revision: 0,
},
],
}),
],
])("ignores %s without hiding a legacy plain-text draft", (_label, corruptMetadata) => {
const storage = createMemoryStorage();
storage.setItem(STORAGE_KEY, "Legacy-Entwurf");
storage.setItem(PENDING_STORAGE_KEY, corruptMetadata);
const { result } = renderHook(() =>
useChatDraft({
storageKey: STORAGE_KEY,
deliveries: [],
sendMessage: () => null,
updateTyping: vi.fn(),
storage,
})
);
expect(result.current.draft).toBe("Legacy-Entwurf");
expect(storage.getItem(PENDING_STORAGE_KEY)).toBeNull();
});
});
function createMemoryStorage(): ChatDraftStorage {
const values = new Map<string, string>();
return {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => {
values.set(key, value);
},
removeItem: (key) => {
values.delete(key);
},
};
}