diff --git a/e2e/specs/mock/multi-role-commercial.spec.ts b/e2e/specs/mock/multi-role-commercial.spec.ts index 8f1b95de..3ca68efe 100644 --- a/e2e/specs/mock/multi-role-commercial.spec.ts +++ b/e2e/specs/mock/multi-role-commercial.spec.ts @@ -99,11 +99,16 @@ for (const character of characters) { page.getByRole("heading", { name: "Private albums" }), ).toBeVisible(); await expect(page.getByText(character.displayName).last()).toBeVisible(); - await expect( - page.getByRole("button", { - name: `View locked collection with 8 images from ${character.displayName}`, - }), - ).toBeVisible(); + const albumButton = page.getByRole("button", { + name: `View locked collection with 8 images from ${character.displayName}`, + }); + if (character.id === "maya-tan") { + await expect(albumButton).toHaveCount(0); + await expect(page.getByRole("button", { name: "Refresh" })).toHaveCount(0); + await expect(page.getByText("No private albums yet.")).toHaveCount(0); + } else { + await expect(albumButton).toBeVisible(); + } }); test(`${character.displayName} receives a character-scoped Tip catalog`, async ({ @@ -133,6 +138,19 @@ for (const character of characters) { }); } +test("a real Private Zone request failure still offers Retry", async ({ + page, +}) => { + await page.route("**/api/private-zone/albums**", async (route) => { + await route.fulfill({ status: 503, json: { message: "Albums unavailable" } }); + }); + + await page.goto("/characters/maya/private-zone"); + + await expect(page.getByRole("button", { name: "Retry" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Refresh" })).toHaveCount(0); +}); + async function registerMultiRoleCommercialMocks(page: Page): Promise { await page.route("**/api/private-zone/albums**", async (route) => { const url = new URL(route.request().url()); @@ -140,10 +158,13 @@ async function registerMultiRoleCommercialMocks(page: Page): Promise { const character = characters.find((candidate) => candidate.id === characterId) ?? characters[0]; + const hasCompleteAlbum = character.id !== "maya-tan"; await route.fulfill({ json: apiEnvelope({ creditBalance: 1000, - items: [ + pendingImageCount: hasCompleteAlbum ? 0 : 7, + hasIncompleteContent: !hasCompleteAlbum, + items: hasCompleteAlbum ? [ { albumId: `album-${character.id}`, title: `${character.shortName} private photos`, @@ -163,7 +184,7 @@ async function registerMultiRoleCommercialMocks(page: Page): Promise { publishedAt: "2026-07-22T10:00:00+08:00", lockDetail: { locked: true }, }, - ], + ] : [], }), }); }); diff --git a/public/images/cover/maya.webp b/public/images/cover/maya.webp index 11244a39..c2b1b738 100644 Binary files a/public/images/cover/maya.webp and b/public/images/cover/maya.webp differ diff --git a/src/app/private-zone/private-zone-screen.tsx b/src/app/private-zone/private-zone-screen.tsx index d18210ec..708297e7 100644 --- a/src/app/private-zone/private-zone-screen.tsx +++ b/src/app/private-zone/private-zone-screen.tsx @@ -257,14 +257,6 @@ export function PrivateZoneScreen() { ) : null} - {!state.isLoading && state.items.length === 0 && !state.errorMessage ? ( - dispatch({ type: "PrivateZoneRefresh" })} - /> - ) : null} -
{state.items.map((album, index) => ( { expect(response).not.toHaveProperty("packageOptions"); }); + it("keeps incomplete-content metadata without exposing pending images", () => { + const response = PrivateAlbumsResponseSchema.parse({ + items: [], + creditBalance: 100, + pendingImageCount: 7, + hasIncompleteContent: true, + pendingImages: [{ url: COVER_URL }], + }); + + expect(response.pendingImageCount).toBe(7); + expect(response.hasIncompleteContent).toBe(true); + expect(response).not.toHaveProperty("pendingImages"); + }); + it("parses an unlock response with all album images", () => { const response = PrivateAlbumUnlockResponseSchema.parse({ albumId: ALBUM_ID, diff --git a/src/data/schemas/private-zone/response/private_albums_response.ts b/src/data/schemas/private-zone/response/private_albums_response.ts index 9be1c9c8..eefe8df9 100644 --- a/src/data/schemas/private-zone/response/private_albums_response.ts +++ b/src/data/schemas/private-zone/response/private_albums_response.ts @@ -1,12 +1,18 @@ import { z } from "zod"; -import { arrayOrEmpty, numberOrZero } from "../../nullable-defaults"; +import { + arrayOrEmpty, + booleanOrFalse, + numberOrZero, +} from "../../nullable-defaults"; import { PrivateAlbumSchema } from "../private_album"; export const PrivateAlbumsResponseSchema = z .object({ items: arrayOrEmpty(PrivateAlbumSchema), creditBalance: numberOrZero, + pendingImageCount: numberOrZero, + hasIncompleteContent: booleanOrFalse, }) .readonly(); diff --git a/src/stores/private-zone/__tests__/private-zone-album-flow.test.ts b/src/stores/private-zone/__tests__/private-zone-album-flow.test.ts index 26dc5bf1..3f8ae693 100644 --- a/src/stores/private-zone/__tests__/private-zone-album-flow.test.ts +++ b/src/stores/private-zone/__tests__/private-zone-album-flow.test.ts @@ -41,6 +41,21 @@ describe("private zone album flow", () => { actor.stop(); }); + it("keeps incomplete album metadata without creating a visible album", async () => { + const actor = await loadPrivateZone({ + loadResponse: makeAlbumsResponse({ + items: [], + pendingImageCount: 7, + hasIncompleteContent: true, + }), + }); + + expect(actor.getSnapshot().context.items).toEqual([]); + expect(actor.getSnapshot().context.pendingImageCount).toBe(7); + expect(actor.getSnapshot().context.hasIncompleteContent).toBe(true); + actor.stop(); + }); + it("moves to failed when the album actor rejects", async () => { const actor = createActor( createTestPrivateZoneMachine({ diff --git a/src/stores/private-zone/helper/albums.ts b/src/stores/private-zone/helper/albums.ts index b1e2759f..1153cd41 100644 --- a/src/stores/private-zone/helper/albums.ts +++ b/src/stores/private-zone/helper/albums.ts @@ -15,6 +15,8 @@ export function applyAlbumsResponse( return { items: response.items, creditBalance: response.creditBalance, + pendingImageCount: response.pendingImageCount, + hasIncompleteContent: response.hasIncompleteContent, errorMessage: null, }; } diff --git a/src/stores/private-zone/private-zone-context.tsx b/src/stores/private-zone/private-zone-context.tsx index 5b84fdf0..af5def47 100644 --- a/src/stores/private-zone/private-zone-context.tsx +++ b/src/stores/private-zone/private-zone-context.tsx @@ -16,6 +16,8 @@ export interface PrivateZoneContextState { status: string; items: MachineContext["items"]; creditBalance: number; + pendingImageCount: number; + hasIncompleteContent: boolean; errorMessage: string | null; unlockingAlbumId: string | null; unlockErrorMessage: string | null; @@ -70,6 +72,8 @@ function selectPrivateZoneState( status: String(state.value), items: state.context.items, creditBalance: state.context.creditBalance, + pendingImageCount: state.context.pendingImageCount, + hasIncompleteContent: state.context.hasIncompleteContent, errorMessage: state.context.errorMessage, unlockingAlbumId: state.context.unlockingAlbumId, unlockErrorMessage: state.context.unlockErrorMessage, diff --git a/src/stores/private-zone/private-zone-state.ts b/src/stores/private-zone/private-zone-state.ts index 9792263b..af0eda66 100644 --- a/src/stores/private-zone/private-zone-state.ts +++ b/src/stores/private-zone/private-zone-state.ts @@ -13,6 +13,8 @@ export interface PrivateZoneState { characterId: string; items: readonly PrivateAlbum[]; creditBalance: number; + pendingImageCount: number; + hasIncompleteContent: boolean; errorMessage: string | null; unlockingAlbumId: string | null; unlockErrorMessage: string | null; @@ -28,6 +30,8 @@ export function createInitialPrivateZoneState( characterId, items: [], creditBalance: 0, + pendingImageCount: 0, + hasIncompleteContent: false, errorMessage: null, unlockingAlbumId: null, unlockErrorMessage: null,