feat(private-room): connect moments feed
Docker Image / Build and Push Docker Image (push) Successful in 9m29s

This commit is contained in:
2026-07-08 16:29:08 +08:00
parent 77b496da2b
commit c46b9b4cdd
41 changed files with 3107 additions and 281 deletions
@@ -0,0 +1,260 @@
import { describe, expect, it } from "vitest";
import { createActor, fromPromise, waitFor } from "xstate";
import {
PrivateRoomMomentsResponse,
PrivateRoomUnlockResponse,
} from "@/data/dto/private-room";
import { privateRoomMachine } from "@/stores/private-room/private-room-machine";
const baseMoment = {
momentId: "schedule:91",
source: "elio_schedules",
sourceId: "91",
characterId: "elio",
author: {
id: "elio",
name: "Elio Silvestri",
avatarUrl: null,
},
createdAt: "2026-07-01T08:11:35+00:00",
publishedAt: "2026-07-01T08:11:35+00:00",
timeText: "7 days ago",
title: "Paris morning",
content: null,
text: null,
textPreview: "Unlock to view private room photos",
mediaCount: 1,
images: [
{
url: null,
type: "image",
locked: true,
index: 0,
},
],
locked: true,
unlocked: false,
unlockCost: 40,
unlockCostPerImage: 40,
requiredCredits: 40,
currency: "credits",
lockDetail: {
locked: true,
showContent: false,
showUpgrade: true,
reason: "private_room_moment",
hint: "40 credits · Unlock photo",
actionLabel: "Unlock",
type: "private_room_moment",
requiredCredits: 40,
currentCredits: 100,
shortfallCredits: 0,
mediaCount: 1,
unlockCostPerImage: 40,
},
};
function makeMomentsResponse(
overrides: Partial<Parameters<typeof PrivateRoomMomentsResponse.from>[0]> = {},
): PrivateRoomMomentsResponse {
return PrivateRoomMomentsResponse.from({
profile: {
characterId: "elio",
displayName: "Elio Silvestri",
authorName: "Elio Silvestri",
avatarUrl: null,
coverUrl: null,
title: "Elio Private room",
subtitle: "Join me, unlock my private room",
},
items: [baseMoment],
nextCursor: null,
hasMore: false,
creditBalance: 100,
unlockCostDefault: 40,
unlockCostPerImage: 40,
currency: "credits",
source: "elio_schedules",
...overrides,
});
}
function makeUnlockResponse(
overrides: Partial<Parameters<typeof PrivateRoomUnlockResponse.from>[0]> = {},
): PrivateRoomUnlockResponse {
return PrivateRoomUnlockResponse.from({
...baseMoment,
locked: false,
unlocked: true,
images: [
{
url: "https://example.supabase.co/storage/v1/object/public/elio-schedules/photo.jpg",
type: "image",
locked: false,
index: 0,
},
],
reason: "ok",
creditsCharged: 40,
creditBalance: 60,
...overrides,
});
}
function createTestMachine(
options: {
firstPage?: PrivateRoomMomentsResponse;
morePage?: PrivateRoomMomentsResponse;
unlockResponse?: PrivateRoomUnlockResponse;
} = {},
) {
return privateRoomMachine.provide({
actors: {
loadMoments: fromPromise(async () => options.firstPage ?? makeMomentsResponse()),
loadMoreMoments: fromPromise(async () =>
options.morePage ??
makeMomentsResponse({
items: [
{
...baseMoment,
momentId: "schedule:92",
sourceId: "92",
},
],
hasMore: false,
nextCursor: null,
}),
),
unlockMoment: fromPromise(async () =>
options.unlockResponse ?? makeUnlockResponse(),
),
},
});
}
describe("privateRoomMachine", () => {
it("loads the first page", async () => {
const actor = createActor(createTestMachine()).start();
actor.send({ type: "PrivateRoomInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
const context = actor.getSnapshot().context;
expect(context.profile?.displayName).toBe("Elio Silvestri");
expect(context.items).toHaveLength(1);
expect(context.creditBalance).toBe(100);
actor.stop();
});
it("appends more moments without overwriting existing items", async () => {
const actor = createActor(
createTestMachine({
firstPage: makeMomentsResponse({
hasMore: true,
nextCursor: "cursor-1",
}),
}),
).start();
actor.send({ type: "PrivateRoomInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
actor.send({ type: "PrivateRoomLoadMore" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
expect(actor.getSnapshot().context.items.map((item) => item.momentId)).toEqual([
"schedule:91",
"schedule:92",
]);
actor.stop();
});
it("replaces a locked moment after unlock success", async () => {
const actor = createActor(createTestMachine()).start();
actor.send({ type: "PrivateRoomInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
actor.send({
type: "PrivateRoomUnlockRequested",
momentId: "schedule:91",
});
actor.send({ type: "PrivateRoomUnlockConfirmed" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
const context = actor.getSnapshot().context;
expect(context.items[0]?.unlocked).toBe(true);
expect(context.items[0]?.images[0]?.url).toContain("https://");
expect(context.unlockSuccessNonce).toBe(1);
actor.stop();
});
it("exposes a paywall request for insufficient credits", async () => {
const actor = createActor(
createTestMachine({
unlockResponse: makeUnlockResponse({
...baseMoment,
reason: "insufficient_credits",
locked: true,
unlocked: false,
creditBalance: 10,
currentCredits: 10,
shortfallCredits: 30,
}),
}),
).start();
actor.send({ type: "PrivateRoomInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
actor.send({
type: "PrivateRoomUnlockRequested",
momentId: "schedule:91",
});
actor.send({ type: "PrivateRoomUnlockConfirmed" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
const context = actor.getSnapshot().context;
expect(context.items[0]?.unlocked).toBe(false);
expect(context.unlockPaywallRequest).toMatchObject({
momentId: "schedule:91",
reason: "insufficient_credits",
shortfallCredits: 30,
});
actor.stop();
});
it("refreshes and keeps an unlock error when the cost changes", async () => {
const actor = createActor(
createTestMachine({
unlockResponse: makeUnlockResponse({
...baseMoment,
reason: "cost_changed",
locked: true,
unlocked: false,
}),
}),
).start();
actor.send({ type: "PrivateRoomInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
actor.send({
type: "PrivateRoomUnlockRequested",
momentId: "schedule:91",
});
actor.send({ type: "PrivateRoomUnlockConfirmed" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
expect(actor.getSnapshot().context.unlockErrorMessage).toBe(
"Unlock price changed. Please confirm again.",
);
actor.stop();
});
});