refactor(chat): consolidate unlock state

This commit is contained in:
2026-07-16 19:49:08 +08:00
parent b5990bca8b
commit acabf165cd
7 changed files with 152 additions and 110 deletions
@@ -1,7 +1,11 @@
import { describe, expect, it } from "vitest";
import { createActor, waitFor } from "xstate";
import { createActor, fromPromise, waitFor } from "xstate";
import { ChatSendResponse } from "@/data/dto/chat";
import type {
UnlockMessageOutput,
UnlockMessageRequest,
} from "@/stores/chat/helper/unlock";
import {
createTestChatMachine,
@@ -189,7 +193,6 @@ describe("chat unlock flow", () => {
);
expect(actor.getSnapshot().context).toMatchObject({
isUnlockingHistory: false,
unlockHistoryPromptVisible: true,
unlockHistoryError: "Failed to unlock messages. Please try again.",
});
@@ -237,7 +240,7 @@ describe("chat unlock flow", () => {
);
expect(actor.getSnapshot().context.unlockPaywallRequest).toBeNull();
expect(actor.getSnapshot().context.isUnlockingMessage).toBe(false);
expect(actor.getSnapshot().context.unlockingMessage).toBeNull();
expect(actor.getSnapshot().context.messages).toMatchObject([
{
id: "msg-private-locked",
@@ -485,7 +488,7 @@ describe("chat unlock flow", () => {
},
]);
expect(actor.getSnapshot().context.messages[0]?.audioUrl).toBeUndefined();
expect(actor.getSnapshot().context.isUnlockingMessage).toBe(false);
expect(actor.getSnapshot().context.unlockingMessage).toBeNull();
actor.stop();
});
@@ -536,8 +539,7 @@ describe("chat unlock flow", () => {
);
expect(actor.getSnapshot().context).toMatchObject({
isUnlockingMessage: false,
unlockingMessageId: null,
unlockingMessage: null,
unlockMessageError: "not_found",
unlockPaywallRequest: null,
});
@@ -552,6 +554,92 @@ describe("chat unlock flow", () => {
actor.stop();
});
it("owns the active request in one object and ignores history refreshes while unlocking", async () => {
let capturedRequest: UnlockMessageRequest | null = null;
let resolveUnlock!: (output: UnlockMessageOutput) => void;
const unlockDeferred = new Promise<UnlockMessageOutput>((resolve) => {
resolveUnlock = resolve;
});
const originalMessage = {
id: "promotion:lock-1",
content: "",
isFromAI: true,
date: "2026-07-16",
locked: true,
lockReason: "image_paywall" as const,
imagePaywalled: true,
};
const machine = createTestChatMachine({
historyMessages: [originalMessage],
}).provide({
actors: {
unlockMessage: fromPromise<
UnlockMessageOutput,
UnlockMessageRequest
>(async ({ input }) => {
capturedRequest = input;
return unlockDeferred;
}),
},
});
const actor = createActor(machine).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
actor.send({
type: "ChatUnlockMessageRequested",
messageId: "promotion:lock-1",
remoteMessageId: "remote-1",
kind: "image",
lockType: "image_paywall",
clientLockId: "lock-1",
});
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "unlockingMessage" }),
);
expect(actor.getSnapshot().context.unlockingMessage).toEqual({
displayMessageId: "promotion:lock-1",
messageId: "remote-1",
kind: "image",
lockType: "image_paywall",
clientLockId: "lock-1",
});
actor.send({
type: "ChatNetworkHistoryLoaded",
output: {
messages: [],
localOverwritten: true,
localCount: 0,
networkCount: 0,
total: 0,
limit: 50,
},
});
expect(actor.getSnapshot().context.messages).toEqual([originalMessage]);
if (!capturedRequest) throw new Error("unlock actor did not start");
resolveUnlock({
displayMessageId: "promotion:lock-1",
request: capturedRequest,
response: makeUnlockPrivateResponse({
messageId: "remote-1",
image: {
type: "promotion",
url: "https://example.com/unlocked.jpg",
},
}),
});
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
expect(actor.getSnapshot().context.unlockingMessage).toBeNull();
actor.stop();
});
it("clears the insufficient credits prompt after payment succeeds", async () => {
const actor = createActor(createTestChatMachine()).start();