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[0]> = {}, ): PrivateAlbumsResponse { return PrivateAlbumsResponse.from({ items: [makeAlbum()], creditBalance: 500, ...overrides, }); } function makeUnlockResponse( overrides: Partial[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[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>, ) { 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(); }); });