210 lines
6.0 KiB
TypeScript
210 lines
6.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { createActor, fromPromise, waitFor } from "xstate";
|
|
|
|
import {
|
|
PrivateAlbumsResponse,
|
|
PrivateAlbumUnlockResponse,
|
|
} from "@/data/dto/private-room";
|
|
import { privateRoomMachine } from "@/stores/private-room/private-room-machine";
|
|
|
|
const ALBUM_ID = "a1b2c3d4-0000-0000-0000-000000000000";
|
|
const COVER_URL =
|
|
"https://dbapi.banlv-ai.com/storage/v1/object/public/elio-schedules/01.jpg";
|
|
|
|
function makeAlbum(index = 0) {
|
|
return {
|
|
albumId: index === 0 ? ALBUM_ID : `album-${index}`,
|
|
title: `Private album ${index + 1}`,
|
|
content: null,
|
|
previewText: "Only for you.",
|
|
imageCount: 8,
|
|
images: [{ url: `${COVER_URL}?album=${index}`, locked: true, index: 0 }],
|
|
locked: true,
|
|
unlocked: false,
|
|
unlockCost: 320,
|
|
publishedAt: "2026-07-13T00:00:00+00:00",
|
|
lockDetail: { locked: true },
|
|
};
|
|
}
|
|
|
|
function makeAlbumsResponse(
|
|
overrides: Partial<Parameters<typeof PrivateAlbumsResponse.from>[0]> = {},
|
|
): PrivateAlbumsResponse {
|
|
return PrivateAlbumsResponse.from({
|
|
items: [makeAlbum()],
|
|
creditBalance: 500,
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
function makeUnlockResponse(
|
|
overrides: Partial<Parameters<typeof PrivateAlbumUnlockResponse.from>[0]> = {},
|
|
): PrivateAlbumUnlockResponse {
|
|
return PrivateAlbumUnlockResponse.from({
|
|
albumId: ALBUM_ID,
|
|
locked: false,
|
|
unlocked: true,
|
|
reason: "ok",
|
|
unlockCost: 320,
|
|
requiredCredits: 320,
|
|
creditBalance: 180,
|
|
shortfallCredits: 0,
|
|
images: Array.from({ length: 8 }, (_, index) => ({
|
|
url: `${COVER_URL}?image=${index}`,
|
|
locked: false,
|
|
index,
|
|
})),
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
function createTestMachine(options: {
|
|
loadResponse?: PrivateAlbumsResponse;
|
|
unlockResponse?: PrivateAlbumUnlockResponse;
|
|
onLoad?: () => void;
|
|
} = {}) {
|
|
return privateRoomMachine.provide({
|
|
actors: {
|
|
loadAlbums: fromPromise(async () => {
|
|
options.onLoad?.();
|
|
return options.loadResponse ?? makeAlbumsResponse();
|
|
}),
|
|
unlockAlbum: fromPromise(async () =>
|
|
(options.unlockResponse ?? makeUnlockResponse()),
|
|
),
|
|
},
|
|
});
|
|
}
|
|
|
|
async function loadMachine(options: Parameters<typeof createTestMachine>[0] = {}) {
|
|
const actor = createActor(createTestMachine(options)).start();
|
|
actor.send({ type: "PrivateRoomInit" });
|
|
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
|
return actor;
|
|
}
|
|
|
|
async function unlockFirstAlbum(
|
|
actor: Awaited<ReturnType<typeof loadMachine>>,
|
|
) {
|
|
actor.send({
|
|
type: "PrivateRoomUnlockRequested",
|
|
albumId: ALBUM_ID,
|
|
});
|
|
actor.send({ type: "PrivateRoomUnlockConfirmed" });
|
|
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
|
}
|
|
|
|
describe("privateRoomMachine", () => {
|
|
it("loads only the first album page into its non-paginated state", async () => {
|
|
const actor = await loadMachine({
|
|
loadResponse: makeAlbumsResponse({
|
|
items: Array.from({ length: 20 }, (_, index) => makeAlbum(index)),
|
|
}),
|
|
});
|
|
const context = actor.getSnapshot().context;
|
|
|
|
expect(context.items).toHaveLength(20);
|
|
expect(context.creditBalance).toBe(500);
|
|
expect(context).not.toHaveProperty("hasMore");
|
|
expect(context).not.toHaveProperty("nextCursor");
|
|
actor.stop();
|
|
});
|
|
|
|
it("patches the album images after a successful unlock", async () => {
|
|
const actor = await loadMachine();
|
|
await unlockFirstAlbum(actor);
|
|
const context = actor.getSnapshot().context;
|
|
|
|
expect(context.items[0]).toMatchObject({
|
|
albumId: ALBUM_ID,
|
|
locked: false,
|
|
unlocked: true,
|
|
title: "Private album 1",
|
|
});
|
|
expect(context.items[0]?.images).toHaveLength(8);
|
|
expect(context.items[0]?.lockDetail).toEqual({ locked: false });
|
|
expect(context.unlockSuccessNonce).toBe(1);
|
|
actor.stop();
|
|
});
|
|
|
|
it("keeps the album locked and exposes a paywall request when credits are low", async () => {
|
|
const actor = await loadMachine({
|
|
unlockResponse: makeUnlockResponse({
|
|
locked: true,
|
|
unlocked: false,
|
|
reason: "insufficient_credits",
|
|
creditBalance: 100,
|
|
shortfallCredits: 220,
|
|
images: [{ url: COVER_URL, locked: true, index: 0 }],
|
|
}),
|
|
});
|
|
await unlockFirstAlbum(actor);
|
|
const context = actor.getSnapshot().context;
|
|
|
|
expect(context.items[0]?.locked).toBe(true);
|
|
expect(context.unlockPaywallRequest).toEqual({
|
|
albumId: ALBUM_ID,
|
|
reason: "insufficient_credits",
|
|
requiredCredits: 320,
|
|
currentCredits: 100,
|
|
shortfallCredits: 220,
|
|
});
|
|
actor.stop();
|
|
});
|
|
|
|
it("reloads the first page when the expected cost changed", async () => {
|
|
let loadCount = 0;
|
|
const actor = await loadMachine({
|
|
onLoad: () => {
|
|
loadCount += 1;
|
|
},
|
|
unlockResponse: makeUnlockResponse({
|
|
locked: true,
|
|
unlocked: false,
|
|
reason: "cost_changed",
|
|
}),
|
|
});
|
|
await unlockFirstAlbum(actor);
|
|
|
|
expect(loadCount).toBe(2);
|
|
expect(actor.getSnapshot().context.unlockErrorMessage).toBe(
|
|
"Unlock price changed. Please confirm again.",
|
|
);
|
|
actor.stop();
|
|
});
|
|
|
|
it("removes an album that no longer exists", async () => {
|
|
const actor = await loadMachine({
|
|
unlockResponse: makeUnlockResponse({
|
|
locked: true,
|
|
unlocked: false,
|
|
reason: "not_found",
|
|
images: [],
|
|
}),
|
|
});
|
|
await unlockFirstAlbum(actor);
|
|
|
|
expect(actor.getSnapshot().context.items).toHaveLength(0);
|
|
actor.stop();
|
|
});
|
|
|
|
it("keeps a refunded persistence failure locked and updates the balance", async () => {
|
|
const actor = await loadMachine({
|
|
unlockResponse: makeUnlockResponse({
|
|
locked: true,
|
|
unlocked: false,
|
|
reason: "persist_failed_refunded",
|
|
creditBalance: 500,
|
|
images: [{ url: COVER_URL, locked: true, index: 0 }],
|
|
}),
|
|
});
|
|
await unlockFirstAlbum(actor);
|
|
const context = actor.getSnapshot().context;
|
|
|
|
expect(context.items[0]?.locked).toBe(true);
|
|
expect(context.creditBalance).toBe(500);
|
|
expect(context.unlockErrorMessage).toContain("refunded");
|
|
actor.stop();
|
|
});
|
|
});
|