301 lines
8.6 KiB
TypeScript
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",
|
|
};
|
|
}
|