468 lines
16 KiB
TypeScript
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);
|
|
},
|
|
};
|
|
}
|