// @vitest-environment jsdom // // Projects page — the live list + the paywall branch. We mock the app's TYPED // hook binding `src/lib/api.ts` (useSubscription streams the rows; useMutation // is the create) — mocking `@voltro/client` underneath it would leave the real // `createHooks` running over a stub, i.e. the wrong seam. We drive the // create form, asserting: the SSR'd rows render, the empty state shows, and a // `projects.create` that rejects with a typed `EntitlementExceeded` flips the // page into the upgrade CTA (NOT a raw error). import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { act, createElement, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { I18nProvider } from '@voltro/i18n' import enCatalog from '../../locales/en' const subData = vi.fn<() => { data: unknown }>() const mutate = vi.fn<(input: { name: string }) => Promise>() // Only the HOOKS are faked. `isEntitlementExceeded` comes through from the real // module — an earlier version of this file re-implemented it inside the mock, // which meant the paywall test asserted that a copy of the predicate agreed // with the literal the same test had just written. That passes in every world, // including the one where the framework hands `catch` a value with no `_tag` // at all. `import type` is erased, so importing the original pulls in nothing // but `@voltro/client`. vi.mock('../../lib/api', async (importOriginal) => ({ ...(await importOriginal()), useSubscription: () => subData(), useMutation: () => ({ mutate, pending: false, error: undefined, data: undefined }), })) const { default: Projects, meta, renderMode } = await import('./page') ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true let container: HTMLDivElement let root: Root const render = (node: ReactNode): void => { container = document.createElement('div') document.body.appendChild(container) act(() => { root = createRoot(container) root.render( createElement(I18nProvider, { locale: 'en', messages: enCatalog, defaultLocale: 'en', children: node }), ) }) } beforeEach(() => { subData.mockReset() subData.mockReturnValue({ data: undefined }) mutate.mockReset() mutate.mockResolvedValue({ id: 'proj_1', name: 'x', tenantId: 'acme' }) }) afterEach(() => { if (root) act(() => root.unmount()) container?.remove() document.body.innerHTML = '' }) describe('projects — config', () => { test('is an SSR page titled Projects (so the parent gate runs per request)', () => { expect(renderMode).toBe('ssr') expect(meta({ locale: 'en' }).title).toBe('Projects') }) }) describe('projects — render', () => { test('shows the empty state when there are no projects', () => { render(createElement(Projects)) expect(container.textContent).toContain('No projects yet') }) test('renders one card per streamed project', () => { subData.mockReturnValue({ data: [ { id: 'proj_1', name: 'Acme', tenantId: 'acme', createdAt: '2026-01-01T00:00:00Z' }, { id: 'proj_2', name: 'Beta', tenantId: 'acme', createdAt: '2026-01-02T00:00:00Z' }, ] }) render(createElement(Projects)) expect(container.querySelectorAll('.card')).toHaveLength(2) expect(container.textContent).toContain('Acme') expect(container.textContent).toContain('Beta') }) }) describe('projects — paywall', () => { // React 19 tracks the input's value, so a raw `input.value = …` is ignored — // set it through the prototype's native setter so the tracked value changes // and `onChange` fires with the new value. const setInputValue = (el: HTMLInputElement, value: string): void => { const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set setter?.call(el, value) el.dispatchEvent(new Event('input', { bubbles: true })) } const submit = async (): Promise => { const input = container.querySelector('input')! const form = container.querySelector('form')! act(() => { setInputValue(input, 'Fourth') }) await act(async () => { form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) // Flush the mutate() promise chain (.then/.catch) + its setState. await Promise.resolve() await Promise.resolve() }) } test('a create that succeeds does NOT show the paywall', async () => { render(createElement(Projects)) await submit() expect(mutate).toHaveBeenCalledWith({ name: 'Fourth' }) expect(container.querySelector('.paywall')).toBeNull() }) test('a typed EntitlementExceeded flips the page into the upgrade CTA', async () => { // The rejection value is the TYPED ERROR ITSELF — that is the framework's // guarantee for `mutate` / `run` / `signal` / `upload`, and the whole reason // this page can branch on `_tag` instead of string-matching a message. mutate.mockRejectedValue({ _tag: 'EntitlementExceeded', entitlement: 'projects', limit: 3 }) render(createElement(Projects)) await submit() expect(container.querySelector('.paywall')).not.toBeNull() expect(container.textContent).toContain('hit your plan limit') }) test('an UNTAGGED failure does not paywall — that branch is for a declared outcome', async () => { // The negative control, and the exact state the app was in while a rejected // write handed back a wrapper with no `_tag`: the create fails, and the user // is shown nothing about why. A paywall that appears for every failure is // as wrong as one that never appears. mutate.mockRejectedValue(new Error('socket hang up')) render(createElement(Projects)) await submit() expect(container.querySelector('.paywall')).toBeNull() }) })