diff --git a/src/app/characters/[characterSlug]/tip/layout.tsx b/src/app/characters/[characterSlug]/tip/layout.tsx index 68390c1a..884a916a 100644 --- a/src/app/characters/[characterSlug]/tip/layout.tsx +++ b/src/app/characters/[characterSlug]/tip/layout.tsx @@ -1,11 +1,23 @@ import type { ReactNode } from "react"; +import { notFound } from "next/navigation"; +import { getCharacterBySlug } from "@/data/constants/character"; import { PaymentRouteProvider } from "@/providers/payment-route-provider"; -export default function CharacterTipLayout({ +export default async function CharacterTipLayout({ children, + params, }: { children: ReactNode; + params: Promise<{ characterSlug: string }>; }) { - return {children}; + const { characterSlug } = await params; + const character = getCharacterBySlug(characterSlug); + if (!character) notFound(); + + return ( + + {children} + + ); } diff --git a/src/providers/__tests__/character-actor-scope-providers.test.tsx b/src/providers/__tests__/character-actor-scope-providers.test.tsx new file mode 100644 index 00000000..8f90ca07 --- /dev/null +++ b/src/providers/__tests__/character-actor-scope-providers.test.tsx @@ -0,0 +1,186 @@ +/* @vitest-environment jsdom */ + +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const instances = vi.hoisted(() => ({ + chat: 0, + payment: 0, + privateRoom: 0, +})); + +vi.mock("@/stores/chat/chat-context", async () => { + const { createElement, useState } = await import("react"); + return { + ChatProvider({ + characterId, + children, + }: { + characterId: string; + children: ReactNode; + }) { + const [instanceId] = useState(() => ++instances.chat); + return createElement( + "div", + { + "data-character-id": characterId, + "data-instance-id": instanceId, + "data-testid": "chat-provider", + }, + children, + ); + }, + }; +}); + +vi.mock("@/stores/payment/payment-context", async () => { + const { createElement, useState } = await import("react"); + return { + PaymentProvider({ children }: { children: ReactNode }) { + const [instanceId] = useState(() => ++instances.payment); + return createElement( + "div", + { + "data-instance-id": instanceId, + "data-testid": "payment-provider", + }, + children, + ); + }, + }; +}); + +vi.mock("@/stores/private-room", async () => { + const { createElement, useState } = await import("react"); + return { + PrivateRoomProvider({ + characterId, + children, + }: { + characterId: string; + children: ReactNode; + }) { + const [instanceId] = useState(() => ++instances.privateRoom); + return createElement( + "div", + { + "data-character-id": characterId, + "data-instance-id": instanceId, + "data-testid": "private-room-provider", + }, + children, + ); + }, + }; +}); + +vi.mock("@/stores/sync/chat-auth-sync", () => ({ ChatAuthSync: () => null })); +vi.mock("@/stores/sync/chat-payment-success-sync", () => ({ + ChatPaymentSuccessSync: () => null, +})); +vi.mock("@/stores/sync/payment-success-sync", () => ({ + PaymentSuccessSync: () => null, +})); + +import { ChatRouteProviders } from "@/providers/chat-route-providers"; +import { PaymentRouteProvider } from "@/providers/payment-route-provider"; +import { PrivateRoomRouteProvider } from "@/providers/private-room-route-provider"; + +describe("character actor route providers", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + instances.chat = 0; + instances.payment = 0; + instances.privateRoom = 0; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("recreates chat and chat payment actors when the character changes", () => { + renderChat("character_elio"); + const firstChatInstance = instanceId("chat-provider"); + const firstPaymentInstance = instanceId("payment-provider"); + + renderChat("character_maya"); + + expect(instanceId("chat-provider")).not.toBe(firstChatInstance); + expect(instanceId("payment-provider")).not.toBe(firstPaymentInstance); + expect(testNode("chat-provider").dataset.characterId).toBe( + "character_maya", + ); + }); + + it("recreates the private-room actor when the character changes", () => { + renderPrivateRoom("character_elio"); + const firstInstance = instanceId("private-room-provider"); + + renderPrivateRoom("character_maya"); + + expect(instanceId("private-room-provider")).not.toBe(firstInstance); + expect(testNode("private-room-provider").dataset.characterId).toBe( + "character_maya", + ); + }); + + it("recreates the route payment actor when its character scope changes", () => { + renderPayment("character_elio"); + const firstInstance = instanceId("payment-provider"); + + renderPayment("character_maya"); + + expect(instanceId("payment-provider")).not.toBe(firstInstance); + }); + + function renderChat(characterId: string): void { + act(() => { + root.render( + + chat + , + ); + }); + } + + function renderPrivateRoom(characterId: string): void { + act(() => { + root.render( + + private room + , + ); + }); + } + + function renderPayment(scopeKey: string): void { + act(() => { + root.render( + + payment + , + ); + }); + } + + function testNode(testId: string): HTMLElement { + const node = container.querySelector( + `[data-testid="${testId}"]`, + ); + if (!node) throw new Error(`Missing ${testId}`); + return node; + } + + function instanceId(testId: string): string | undefined { + return testNode(testId).dataset.instanceId; + } +}); diff --git a/src/providers/chat-route-providers.tsx b/src/providers/chat-route-providers.tsx index 4971d88a..1faa7da0 100644 --- a/src/providers/chat-route-providers.tsx +++ b/src/providers/chat-route-providers.tsx @@ -19,9 +19,9 @@ export function ChatRouteProviders({ characterId = DEFAULT_CHARACTER_ID, }: ChatRouteProvidersProps) { return ( - + - + {children} diff --git a/src/providers/payment-route-provider.tsx b/src/providers/payment-route-provider.tsx index 7c4a11bb..32e88dff 100644 --- a/src/providers/payment-route-provider.tsx +++ b/src/providers/payment-route-provider.tsx @@ -5,9 +5,17 @@ import type { ReactNode } from "react"; import { PaymentProvider } from "@/stores/payment/payment-context"; import { PaymentSuccessSync } from "@/stores/sync/payment-success-sync"; -export function PaymentRouteProvider({ children }: { children: ReactNode }) { +export interface PaymentRouteProviderProps { + children: ReactNode; + scopeKey?: string; +} + +export function PaymentRouteProvider({ + children, + scopeKey, +}: PaymentRouteProviderProps) { return ( - + {children} diff --git a/src/providers/private-room-route-provider.tsx b/src/providers/private-room-route-provider.tsx index cc1a70e1..ebec0732 100644 --- a/src/providers/private-room-route-provider.tsx +++ b/src/providers/private-room-route-provider.tsx @@ -15,7 +15,7 @@ export function PrivateRoomRouteProvider({ characterId = DEFAULT_CHARACTER_ID, }: PrivateRoomRouteProviderProps) { return ( - + {children} );