Files
cozsweet-frontend-nextjs/src/stores/chat/__tests__/chat-history-flow.test.ts
T

301 lines
8.6 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { createActor, fromCallback, waitFor } from "xstate";
import type { UiMessage } from "@/stores/chat/ui-message";
import type { ChatEvent } from "@/stores/chat/chat-events";
import { chatMachine } from "@/stores/chat/chat-machine";
import type {
ChatHistoryActorInput,
LoadMoreHistoryActorEvent,
} from "@/stores/chat/machine/actors/history";
import {
createLoadHistoryCallback,
createTestChatMachine,
TEST_CHAT_MACHINE_INPUT,
} from "./chat-machine.test-utils";
describe("chat history flow", () => {
it("enters user ready after history is loaded", async () => {
const actor = createActor(createTestChatMachine(), {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
const snapshot = actor.getSnapshot();
expect(snapshot.context.historyLoaded).toBe(true);
actor.stop();
});
it("renders local history before network history sync finishes", async () => {
let resolveNetwork!: () => void;
const networkReleased = new Promise<void>((resolve) => {
resolveNetwork = resolve;
});
const localMessage: UiMessage = {
id: "local-msg",
content: "cached local message",
isFromAI: true,
date: "2026-07-02",
};
const networkMessage: UiMessage = {
id: "network-msg",
content: "fresh network message",
isFromAI: true,
date: "2026-07-02",
};
const machine = chatMachine.provide({
actors: {
loadHistory: fromCallback<
ChatEvent,
ChatHistoryActorInput
>(({ sendBack }) => {
sendBack({
type: "ChatLocalHistoryLoaded",
output: {
messages: [localMessage],
localCount: 1,
},
});
void networkReleased.then(() => {
sendBack({
type: "ChatNetworkHistoryLoaded",
output: {
messages: [networkMessage],
localOverwritten: true,
localCount: 1,
networkCount: 1,
total: 1,
limit: 50,
},
});
});
return () => undefined;
}),
},
});
const actor = createActor(machine, {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
expect(actor.getSnapshot().context.messages).toMatchObject([
{ id: "local-msg", content: "cached local message" },
]);
resolveNetwork();
await waitFor(
actor,
(snapshot) => snapshot.context.messages[0]?.id === "network-msg",
);
expect(actor.getSnapshot().context.messages).toMatchObject([
{ id: "network-msg", content: "fresh network message" },
]);
actor.stop();
});
it("loads older pages until total is exhausted and keeps existing messages", async () => {
const requests: LoadMoreHistoryActorEvent[] = [];
const latestMessage: UiMessage = {
id: "latest",
content: "current unlocked message",
isFromAI: true,
date: "2026-07-15",
};
const machine = chatMachine.provide({
actors: {
loadHistory: createLoadHistoryCallback(
[latestMessage],
[latestMessage],
{ total: 120, limit: 50 },
),
loadMoreHistory: fromCallback<
LoadMoreHistoryActorEvent,
ChatHistoryActorInput
>(
({ receive, sendBack }) => {
receive((event) => {
requests.push(event);
if (event.offset === 50) {
sendBack({
type: "ChatOlderHistoryLoaded",
output: {
messages: [
createHistoryMessage("older-50"),
{ ...latestMessage, content: "stale duplicate" },
],
offset: event.offset,
total: 120,
limit: 50,
},
});
return;
}
sendBack({
type: "ChatOlderHistoryLoaded",
output: {
messages: [createHistoryMessage("oldest-100")],
offset: event.offset,
total: 120,
limit: 50,
},
});
});
return () => undefined;
},
),
},
});
const actor = createActor(machine, {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
actor.send({ type: "ChatLoadMoreHistoryRequested" });
await waitFor(
actor,
(snapshot) => snapshot.context.nextHistoryOffset === 100,
);
expect(requests[0]).toMatchObject({ offset: 50, limit: 50 });
expect(actor.getSnapshot().context.messages).toMatchObject([
{ id: "older-50" },
{ id: "latest", content: "current unlocked message" },
]);
actor.send({ type: "ChatLoadMoreHistoryRequested" });
await waitFor(
actor,
(snapshot) => snapshot.context.nextHistoryOffset === 150,
);
actor.send({ type: "ChatLoadMoreHistoryRequested" });
expect(requests).toHaveLength(2);
expect(actor.getSnapshot().context.nextHistoryOffset).toBeGreaterThanOrEqual(
actor.getSnapshot().context.historyTotal,
);
actor.stop();
});
it("keeps the offset after a failed page and allows retrying", async () => {
let requestCount = 0;
const machine = chatMachine.provide({
actors: {
loadHistory: createLoadHistoryCallback([], [], {
total: 75,
limit: 50,
}),
loadMoreHistory: fromCallback<
LoadMoreHistoryActorEvent,
ChatHistoryActorInput
>(
({ receive, sendBack }) => {
receive((event) => {
requestCount += 1;
if (requestCount === 1) {
sendBack({
type: "ChatOlderHistoryLoadFailed",
error: new Error("network failed"),
});
return;
}
sendBack({
type: "ChatOlderHistoryLoaded",
output: {
messages: [createHistoryMessage("retry-message")],
offset: event.offset,
total: 75,
limit: 50,
},
});
});
return () => undefined;
},
),
},
});
const actor = createActor(machine, {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatGuestLogin" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ guestSession: "ready" }),
);
actor.send({ type: "ChatLoadMoreHistoryRequested" });
await waitFor(
actor,
(snapshot) =>
requestCount === 1 && !snapshot.context.isLoadingMoreHistory,
);
expect(actor.getSnapshot().context.nextHistoryOffset).toBe(50);
actor.send({ type: "ChatLoadMoreHistoryRequested" });
await waitFor(
actor,
(snapshot) => snapshot.context.nextHistoryOffset === 100,
);
expect(requestCount).toBe(2);
actor.stop();
});
it("does not request another page when total does not exceed limit", async () => {
let requested = false;
const machine = chatMachine.provide({
actors: {
loadHistory: createLoadHistoryCallback([], [], {
total: 50,
limit: 50,
}),
loadMoreHistory: fromCallback<
LoadMoreHistoryActorEvent,
ChatHistoryActorInput
>(
({ receive }) => {
receive(() => {
requested = true;
});
return () => undefined;
},
),
},
});
const actor = createActor(machine, {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
actor.send({ type: "ChatLoadMoreHistoryRequested" });
expect(requested).toBe(false);
expect(actor.getSnapshot().context.isLoadingMoreHistory).toBe(false);
actor.stop();
});
});
function createHistoryMessage(id: string): UiMessage {
return {
id,
content: `Message ${id}`,
isFromAI: true,
date: "2026-07-15",
};
}