Files
cozsweet-frontend-nextjs/src/stores/private-room/__tests__/private-room-machine.test.ts
T

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();
});
});