refactor(chat): split machine by business flow
This commit is contained in:
@@ -0,0 +1,538 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createActor, waitFor } from "xstate";
|
||||
|
||||
import { ChatSendResponse } from "@/data/dto/chat";
|
||||
|
||||
import {
|
||||
createTestChatMachine,
|
||||
makeUnlockPrivateResponse,
|
||||
} from "./chat-machine.test-utils";
|
||||
|
||||
describe("chat unlock flow", () => {
|
||||
it("prompts before unlocking multiple locked history messages after payment", async () => {
|
||||
const actor = createActor(
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
locked: true,
|
||||
lockReason: "private_message",
|
||||
},
|
||||
{
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
locked: true,
|
||||
lockReason: "voice_message",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
|
||||
actor.send({ type: "ChatPaymentSucceeded" });
|
||||
|
||||
expect(actor.getSnapshot().context.unlockHistoryPromptVisible).toBe(true);
|
||||
expect(actor.getSnapshot().context.lockedHistoryCount).toBe(2);
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("keeps a single locked image message locked after payment", async () => {
|
||||
const actor = createActor(
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
id: "msg-image-locked",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
imageUrl: "https://example.com/locked.jpg",
|
||||
imagePaywalled: true,
|
||||
locked: true,
|
||||
lockReason: "image",
|
||||
},
|
||||
],
|
||||
unlockHistoryOutput: {
|
||||
unlocked: true,
|
||||
reason: "ok",
|
||||
shortfallCredits: 0,
|
||||
messages: [
|
||||
{
|
||||
id: "msg-image-locked",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
imageUrl: "https://example.com/unlocked.jpg",
|
||||
locked: false,
|
||||
lockReason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
|
||||
actor.send({ type: "ChatPaymentSucceeded" });
|
||||
|
||||
expect(actor.getSnapshot().matches({ userSession: "ready" })).toBe(true);
|
||||
expect(actor.getSnapshot().context.unlockHistoryPromptVisible).toBe(false);
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{
|
||||
id: "msg-image-locked",
|
||||
imageUrl: "https://example.com/locked.jpg",
|
||||
imagePaywalled: true,
|
||||
locked: true,
|
||||
},
|
||||
]);
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("unlocks history after the prompt is confirmed", async () => {
|
||||
const actor = createActor(
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
locked: true,
|
||||
lockReason: "private_message",
|
||||
},
|
||||
{
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
locked: true,
|
||||
lockReason: "voice_message",
|
||||
},
|
||||
],
|
||||
unlockHistoryOutput: {
|
||||
unlocked: true,
|
||||
reason: "ok",
|
||||
shortfallCredits: 0,
|
||||
messages: [
|
||||
{
|
||||
content: "unlocked",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
locked: false,
|
||||
lockReason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
|
||||
actor.send({ type: "ChatPaymentSucceeded" });
|
||||
actor.send({ type: "ChatUnlockHistoryConfirmed" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
|
||||
expect(actor.getSnapshot().context.unlockHistoryPromptVisible).toBe(false);
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{ content: "unlocked", locked: false },
|
||||
]);
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("restores the unlock prompt when the history actor fails", async () => {
|
||||
const actor = createActor(
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
locked: true,
|
||||
lockReason: "private_message",
|
||||
},
|
||||
{
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
locked: true,
|
||||
lockReason: "voice_message",
|
||||
},
|
||||
],
|
||||
unlockHistoryError: new Error("unlock failed"),
|
||||
}),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
|
||||
actor.send({ type: "ChatPaymentSucceeded" });
|
||||
actor.send({ type: "ChatUnlockHistoryConfirmed" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
|
||||
expect(actor.getSnapshot().context).toMatchObject({
|
||||
isUnlockingHistory: false,
|
||||
unlockHistoryPromptVisible: true,
|
||||
unlockHistoryError: "Failed to unlock messages. Please try again.",
|
||||
});
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("unlocks a single private message without navigating to payment", async () => {
|
||||
const actor = createActor(
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
id: "msg-private-locked",
|
||||
content: "Original private message content.",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
locked: true,
|
||||
lockReason: "private_message",
|
||||
lockedPrivate: true,
|
||||
privateMessageHint: "A private message is waiting.",
|
||||
},
|
||||
],
|
||||
unlockMessageOutput: {
|
||||
messageId: "msg-private-locked",
|
||||
response: makeUnlockPrivateResponse({
|
||||
content: "Unlocked private message content.",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
|
||||
actor.send({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
messageId: "msg-private-locked",
|
||||
kind: "private",
|
||||
});
|
||||
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
|
||||
expect(actor.getSnapshot().context.unlockPaywallRequest).toBeNull();
|
||||
expect(actor.getSnapshot().context.isUnlockingMessage).toBe(false);
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{
|
||||
id: "msg-private-locked",
|
||||
content: "Unlocked private message content.",
|
||||
locked: false,
|
||||
lockReason: null,
|
||||
lockedPrivate: false,
|
||||
privateMessageHint: null,
|
||||
},
|
||||
]);
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("does not overwrite a user message when it shares the private message id", async () => {
|
||||
const actor = createActor(
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
id: "msg-shared-id",
|
||||
content: "User original question",
|
||||
isFromAI: false,
|
||||
date: "2026-06-29",
|
||||
},
|
||||
{
|
||||
id: "msg-shared-id",
|
||||
content: "Original AI private message",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
locked: true,
|
||||
lockReason: "private_message",
|
||||
lockedPrivate: true,
|
||||
privateMessageHint: "A private message is waiting.",
|
||||
},
|
||||
],
|
||||
unlockMessageOutput: {
|
||||
messageId: "msg-shared-id",
|
||||
response: makeUnlockPrivateResponse({
|
||||
content: "Unlocked private AI message.",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
|
||||
actor.send({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
messageId: "msg-shared-id",
|
||||
kind: "private",
|
||||
});
|
||||
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{
|
||||
id: "msg-shared-id",
|
||||
content: "User original question",
|
||||
isFromAI: false,
|
||||
},
|
||||
{
|
||||
id: "msg-shared-id",
|
||||
content: "Unlocked private AI message.",
|
||||
isFromAI: true,
|
||||
locked: false,
|
||||
lockReason: null,
|
||||
lockedPrivate: false,
|
||||
privateMessageHint: null,
|
||||
},
|
||||
]);
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("keeps image message text empty after a single image unlock succeeds", async () => {
|
||||
const actor = createActor(
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
id: "msg-image-locked",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
imageUrl: "https://example.com/locked.jpg",
|
||||
imagePaywalled: true,
|
||||
locked: true,
|
||||
lockReason: "image",
|
||||
},
|
||||
],
|
||||
unlockMessageOutput: {
|
||||
messageId: "msg-image-locked",
|
||||
response: makeUnlockPrivateResponse({
|
||||
content: "This text should not render below the image.",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
|
||||
actor.send({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
messageId: "msg-image-locked",
|
||||
kind: "image",
|
||||
});
|
||||
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{
|
||||
id: "msg-image-locked",
|
||||
content: "",
|
||||
imageUrl: "https://example.com/locked.jpg",
|
||||
imagePaywalled: false,
|
||||
locked: false,
|
||||
lockReason: null,
|
||||
},
|
||||
]);
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("applies the real voice audio url after unlock succeeds", async () => {
|
||||
const actor = createActor(
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
id: "msg-voice-locked",
|
||||
content: "Original voice transcript.",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
locked: true,
|
||||
lockReason: "voice_message",
|
||||
privateMessageHint: "A voice message is waiting.",
|
||||
},
|
||||
],
|
||||
unlockMessageOutput: {
|
||||
messageId: "msg-voice-locked",
|
||||
response: makeUnlockPrivateResponse({
|
||||
content: "This response content must be ignored.",
|
||||
audioUrl: "https://example.com/unlocked-voice.mp3",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
|
||||
actor.send({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
messageId: "msg-voice-locked",
|
||||
kind: "voice",
|
||||
});
|
||||
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{
|
||||
id: "msg-voice-locked",
|
||||
content: "Original voice transcript.",
|
||||
audioUrl: "https://example.com/unlocked-voice.mp3",
|
||||
locked: false,
|
||||
lockReason: null,
|
||||
privateMessageHint: null,
|
||||
},
|
||||
]);
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("requests payment when single message unlock lacks credits", async () => {
|
||||
const actor = createActor(
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
id: "msg-voice-locked",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
locked: true,
|
||||
lockReason: "voice_message",
|
||||
privateMessageHint: "A voice message is waiting.",
|
||||
},
|
||||
],
|
||||
unlockMessageOutput: {
|
||||
messageId: "msg-voice-locked",
|
||||
response: makeUnlockPrivateResponse({
|
||||
unlocked: false,
|
||||
content: "",
|
||||
reason: "insufficient_balance",
|
||||
creditBalance: 3,
|
||||
creditsCharged: 0,
|
||||
requiredCredits: 10,
|
||||
shortfallCredits: 7,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
|
||||
actor.send({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
messageId: "msg-voice-locked",
|
||||
kind: "voice",
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
actor,
|
||||
(snapshot) => snapshot.context.unlockPaywallRequest !== null,
|
||||
);
|
||||
|
||||
expect(actor.getSnapshot().context.unlockPaywallRequest).toEqual({
|
||||
displayMessageId: "msg-voice-locked",
|
||||
messageId: "msg-voice-locked",
|
||||
kind: "voice",
|
||||
reason: "insufficient_balance",
|
||||
creditBalance: 3,
|
||||
requiredCredits: 10,
|
||||
shortfallCredits: 7,
|
||||
});
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{
|
||||
id: "msg-voice-locked",
|
||||
locked: true,
|
||||
lockReason: "voice_message",
|
||||
},
|
||||
]);
|
||||
expect(actor.getSnapshot().context.messages[0]?.audioUrl).toBeUndefined();
|
||||
expect(actor.getSnapshot().context.isUnlockingMessage).toBe(false);
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("clears the insufficient credits prompt after payment succeeds", async () => {
|
||||
const actor = createActor(createTestChatMachine()).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
|
||||
actor.send({
|
||||
type: "ChatQueuedHttpDone",
|
||||
output: {
|
||||
response: ChatSendResponse.from({
|
||||
reply: "",
|
||||
audioUrl: "",
|
||||
messageId: "",
|
||||
isGuest: false,
|
||||
timestamp: Date.now(),
|
||||
image: { type: null, url: null },
|
||||
lockDetail: {
|
||||
locked: false,
|
||||
reason: null,
|
||||
},
|
||||
canSendMessage: false,
|
||||
creditBalance: 0,
|
||||
requiredCredits: 2,
|
||||
shortfallCredits: 2,
|
||||
}),
|
||||
reply: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(actor.getSnapshot().context.upgradePromptVisible).toBe(true);
|
||||
expect(actor.getSnapshot().context.upgradeReason).toBe(
|
||||
"insufficient_credits",
|
||||
);
|
||||
expect(actor.getSnapshot().context.canSendMessage).toBe(false);
|
||||
|
||||
actor.send({ type: "ChatPaymentSucceeded" });
|
||||
|
||||
expect(actor.getSnapshot().context.upgradePromptVisible).toBe(false);
|
||||
expect(actor.getSnapshot().context.upgradeReason).toBeNull();
|
||||
expect(actor.getSnapshot().context.canSendMessage).toBe(true);
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user