diff --git a/app/(marketing)/pricing/page.tsx b/app/(marketing)/pricing/page.tsx index 91396ba59..53b2cd854 100644 --- a/app/(marketing)/pricing/page.tsx +++ b/app/(marketing)/pricing/page.tsx @@ -6,8 +6,7 @@ import { getPublicStats } from '@/lib/marketing/stats'; import { sampleBriefNumbers } from '@/lib/marketing/handled-sample'; import { getPublishedCaseStudies } from '@/lib/marketing/reputation-server'; import { SwitchingBand } from '@/components/marketing/switching-band'; -import { createServiceClient } from '@/lib/supabase/server'; -import { getResolvedFeatureTiers } from '@/lib/server/feature-tiers'; +import { getPricingFeatureTiers } from '@/lib/marketing/pricing-tiers'; import { FEATURE_CATALOG, FEATURE_SECTIONS } from '@/lib/constants/feature-catalog'; import type { FeatureTier } from '@/lib/constants/feature-catalog'; import { MarketingAeoSection } from '@/components/marketing/marketing-aeo-section'; @@ -30,16 +29,15 @@ export default async function PricingPage() { // brief composer over a fixed week) and the admin-published case studies. // pricing-content.tsx is a client module and can reach none of them itself. // - // Neither cached reader can reject — each catches its own failure and - // returns zeros or an empty list, which the page then HIDES rather than - // prints — so nothing here rides in settleAll and nothing is orphaned if - // the feature-tier read throws. + // Optional database reads have deadlines. Their public fallbacks stay outside the + // caches, so an outage cannot replace valid counts or stories with a cached + // zero/empty result. The page HIDES those fallbacks rather than printing them. const [stats, caseStudies, resolved] = await Promise.all([ getPublicStats(), getPublishedCaseStudies(), // Build the admin-controlled feature matrix (Off/Free/Basic/Plus per service) // so the pricing page reflects the Tier & Features admin live. - getResolvedFeatureTiers(createServiceClient()), + getPricingFeatureTiers(), ]); const matrix = FEATURE_SECTIONS.map((section) => ({ section, diff --git a/lib/marketing/pricing-tiers.ts b/lib/marketing/pricing-tiers.ts new file mode 100644 index 000000000..5efc209a0 --- /dev/null +++ b/lib/marketing/pricing-tiers.ts @@ -0,0 +1,28 @@ +import 'server-only'; +import { createServiceClient } from '@/lib/supabase/server'; +import { resolveFeatureTiers, type FeatureOverrides } from '@/lib/features/tiers'; +import type { FeatureTier } from '@/lib/constants/feature-catalog'; +import { withPublicReadBudget } from '@/lib/marketing/public-read'; + +/** + * The public comparison matrix has the existing catalog-default fallback. + * Its deadline is deliberately separate from shared entitlement/admin reads. + * Neither a failed read nor its fallback is cached as live admin configuration. + */ +export async function getPricingFeatureTiers(): Promise> { + try { + return await withPublicReadBudget(async (signal) => { + const { data, error } = await createServiceClient() + .from('app_settings').select('value').eq('key', 'feature_tiers') + .abortSignal(signal).maybeSingle(); + if (error) throw error; + const value = data?.value; + const overrides = value && typeof value === 'object' && !Array.isArray(value) + ? value as FeatureOverrides : undefined; + return resolveFeatureTiers(overrides); + }); + } catch (err) { + console.error('[marketing-pricing] feature tiers unavailable (using catalog defaults)', err); + return resolveFeatureTiers(undefined); + } +} diff --git a/lib/marketing/public-read.ts b/lib/marketing/public-read.ts new file mode 100644 index 000000000..140ba269d --- /dev/null +++ b/lib/marketing/public-read.ts @@ -0,0 +1,31 @@ +import 'server-only'; + +export const PUBLIC_READ_BUDGET_MS = 4_000; + +/** + * A deadline for optional public presentation data, never an authorization gate. + * Abort alone cannot bound an SDK token lookup or a transport/body that ignores + * the signal, so the caller also races the complete read. A late result cannot + * turn this rejected read into a successful unstable_cache value. + * This bounds the read callback, not Next's cache backend or an entire page. + */ +export async function withPublicReadBudget(read: (signal: AbortSignal) => PromiseLike): Promise { + const controller = new AbortController(); + let timer: ReturnType | undefined; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { + const error = new Error('Optional public data exceeded its read budget'); + reject(error); + controller.abort(error); + }, PUBLIC_READ_BUDGET_MS); + }); + try { + return await Promise.race([ + Promise.resolve().then(() => read(controller.signal)), + deadline, + ]); + } finally { + clearTimeout(timer); + controller.abort(); + } +} diff --git a/lib/marketing/reputation-server.ts b/lib/marketing/reputation-server.ts index a744a44ee..e3bf5e237 100644 --- a/lib/marketing/reputation-server.ts +++ b/lib/marketing/reputation-server.ts @@ -7,6 +7,7 @@ import { CASE_STUDIES_CACHE_TAG } from './case-study'; import { unstable_cache } from 'next/cache'; import { createServiceClient } from '@/lib/supabase/server'; import { clampRating, publishedOnly } from '@/lib/marketing/reputation'; +import { withPublicReadBudget } from '@/lib/marketing/public-read'; export type PublicTestimonial = { id: string; @@ -37,6 +38,16 @@ export type PublicCaseStudy = { const TESTIMONIAL_LIMIT = 6; const CASE_STUDY_LIMIT = 3; +function isCaseStudyRow(value: unknown): boolean { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const row = value as Record; + return ['id', 'title', 'slug'].every(key => typeof row[key] === 'string') + && ['customer_name', 'summary', 'result_metric'].every(key => row[key] === null || typeof row[key] === 'string') + && typeof row.is_published === 'boolean' + // This column is absent before its migration; absence never earns a badge. + && (row.verified_at === undefined || row.verified_at === null || typeof row.verified_at === 'string'); +} + export const getPublishedTestimonials = unstable_cache( async (): Promise => { try { @@ -64,30 +75,39 @@ export const getPublishedTestimonials = unstable_cache( { revalidate: 3600 }, ); -export const getPublishedCaseStudies = unstable_cache( - async (): Promise => { - try { - const { data, error } = await createServiceClient() - .from('case_studies') - .select('*') - .eq('is_published', true) - .order('created_at', { ascending: false }) - .limit(CASE_STUDY_LIMIT); - if (error) throw error; - return publishedOnly(data ?? []).map((row) => ({ - id: row.id, - title: row.title, - slug: row.slug, - customerName: row.customer_name, - summary: row.summary, - resultMetric: row.result_metric, - verifiedAt: (row as { verified_at?: string | null }).verified_at ?? null, - })); - } catch (err) { - console.error('[marketing-reputation] case studies read failed', err); - return []; - } - }, - ['public-case-studies'], +const cachedPublishedCaseStudies = unstable_cache( + (): Promise => withPublicReadBudget(async (signal) => { + const { data, error } = await createServiceClient() + .from('case_studies') + .select('*') + .eq('is_published', true) + .order('created_at', { ascending: false }) + .limit(CASE_STUDY_LIMIT) + .abortSignal(signal); + if (error) throw error; + // Validate before filtering: a malformed row must not masquerade as a + // legitimately empty publication list and become successful cached data. + if (!Array.isArray(data) || !data.every(isCaseStudyRow)) throw new Error('Invalid published case studies response'); + return publishedOnly(data).map((row) => ({ + id: row.id, + title: row.title, + slug: row.slug, + customerName: row.customer_name, + summary: row.summary, + resultMetric: row.result_metric, + verifiedAt: (row as { verified_at?: string | null }).verified_at ?? null, + })); + }), + ['public-case-studies-validated-v2'], { revalidate: 3600, tags: [CASE_STUDIES_CACHE_TAG] }, ); + +/** An outage is not a successfully empty publication list. */ +export async function getPublishedCaseStudies(): Promise { + try { + return await cachedPublishedCaseStudies(); + } catch (err) { + console.error('[marketing-reputation] case studies read failed', err); + return []; + } +} diff --git a/lib/marketing/stats.ts b/lib/marketing/stats.ts index ba29055c9..b2d937367 100644 --- a/lib/marketing/stats.ts +++ b/lib/marketing/stats.ts @@ -2,6 +2,7 @@ import 'server-only'; import { unstable_cache } from 'next/cache'; import { createClient, type SupabaseClient } from '@supabase/supabase-js'; import type { Database } from '@/lib/database.types'; +import { withPublicReadBudget } from '@/lib/marketing/public-read'; export type PublicStats = { families: number; @@ -59,15 +60,18 @@ function num(row: Row, key: string): number { * directly: the RPC is the only door, and it is SECURITY DEFINER + aggregate. */ type HandledStatsRpc = { - rpc: (fn: 'public_handled_stats') => PromiseLike<{ data: unknown; error: unknown }>; + rpc: (fn: 'public_handled_stats') => { + abortSignal: (signal: AbortSignal) => PromiseLike<{ data: unknown; error: unknown }>; + }; }; type HandledStats = Pick; type AccountStats = Pick; -async function readHandledStats(client: SupabaseClient): Promise { - try { - const { data, error } = await (client as unknown as HandledStatsRpc).rpc('public_handled_stats'); +async function readHandledStats(): Promise { + return withPublicReadBudget(async (signal) => { + const client = anonClient(); + const { data, error } = await (client as unknown as HandledStatsRpc).rpc('public_handled_stats').abortSignal(signal); if (error) throw error; const row = firstRow(data); const counts = { @@ -79,18 +83,13 @@ async function readHandledStats(client: SupabaseClient): Promise): Promise { - try { - const { data, error } = await client.rpc('public_stats'); +async function readAccountStats(): Promise { + return withPublicReadBudget(async (signal) => { + const client = anonClient(); + const { data, error } = await client.rpc('public_stats').abortSignal(signal); if (error) throw error; const row = firstRow(data); return { @@ -98,28 +97,34 @@ async function readAccountStats(client: SupabaseClient): Promise => { - let client: SupabaseClient; - try { - client = anonClient(); - } catch (err) { - console.error('[marketing-stats] public client unavailable (rendering no aggregates)', err); - return { ...EMPTY_PUBLIC_STATS }; - } - const [accounts, handled] = await Promise.all([readAccountStats(client), readHandledStats(client)]); - return { ...accounts, ...handled }; - }, - ['public-stats-validated-v2'], +// Each successful group is cached independently. Throwing before the cache +// write keeps a failed refresh from replacing last-good data with hidden zeros. +// New keys exclude fallback values previously cached as successful evidence. +const cachedAccountStats = unstable_cache( + readAccountStats, + ['public-account-stats-validated-v3'], + { revalidate: 3600 }, +); +const cachedHandledStats = unstable_cache( + readHandledStats, + ['public-handled-stats-validated-v3'], { revalidate: 3600 }, ); + +/** Validated public counts, with per-group hidden-zero fallbacks outside cache. */ +export async function getPublicStats(): Promise { + const [accounts, handled] = await Promise.all([ + cachedAccountStats().catch((err): AccountStats => { + console.error('[marketing-stats] public_stats read failed (rendering no family counts)', err); + return { families: 0, members: 0, tasksCompleted: 0 }; + }), + cachedHandledStats().catch((err): HandledStats => { + console.error('[marketing-stats] public_handled_stats read failed (rendering no handled aggregates)', err); + return { handledCompleted: 0, handled30d: 0, familiesWithRuns: 0 }; + }), + ]); + return { ...accounts, ...handled }; +} diff --git a/tests/marketing-public-read-availability.test.ts b/tests/marketing-public-read-availability.test.ts new file mode 100644 index 000000000..a49e2e2c9 --- /dev/null +++ b/tests/marketing-public-read-availability.test.ts @@ -0,0 +1,241 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { createClient as realCreateClient } from '@supabase/supabase-js'; + +type Kind = 'accounts' | 'handled' | 'studies'; +type Mode = 'healthy' | 'zero' | 'error' | 'invalid' | 'hold' | 'hold-body' | 'null' | 'object'; +type CacheValue = { data: { body: string }; revalidate: number }; +const state = vi.hoisted(() => ({ + modes: {} as Partial>, + requests: [] as { kind: Kind; signal?: AbortSignal | null }[], + releases: [] as (() => void)[], + entries: new Map(), + writes: [] as CacheValue[], + run: undefined as undefined | ((fn: () => Promise) => Promise), + makeClient: undefined as undefined | (() => ReturnType), + studyRows: undefined as unknown[] | undefined, +})); + +// Exercise the installed Next cache algorithm. Only its persistence adapter is +// in memory; unlike an identity mock, a successful fallback is actually cached. +vi.mock('next/cache', async () => { + const { createRequire } = await import('node:module'); + const { AsyncLocalStorage } = await import('node:async_hooks'); + Object.defineProperty(globalThis, 'AsyncLocalStorage', { value: AsyncLocalStorage, configurable: true }); + const require = createRequire(import.meta.url); + const { unstable_cache } = require('next/dist/server/web/spec-extension/unstable-cache'); + const { workAsyncStorage } = require('next/dist/server/app-render/work-async-storage.external') as { + workAsyncStorage: { run(store: object, fn: () => T): T }; + }; + state.run = async (fn: () => Promise) => { + const store = { + route: '/pricing', page: '/pricing', forceDynamic: true, + isStaticGeneration: false, isDraftMode: false, nextFetchId: 1, + pendingRevalidates: {} as Record>, + incrementalCache: { + async generateSimpleCacheKey(key: string) { return key; }, + async get(key: string) { + const entry = state.entries.get(key); + return entry ? { value: entry.value, isStale: Date.now() - entry.at >= entry.value.revalidate * 1000 } : null; + }, + async set(key: string, value: CacheValue) { + state.entries.set(key, { value, at: Date.now() }); + state.writes.push(value); + }, + }, + }; + const result = await workAsyncStorage.run(store, fn); + await Promise.all(Object.values(store.pendingRevalidates)); + return result; + }; + return { unstable_cache }; +}); +vi.mock('@/lib/supabase/server', () => ({ createServiceClient: () => state.makeClient!() })); +vi.mock('@supabase/supabase-js', async (original) => { + const actual = await original(); + return { ...actual, createClient: () => state.makeClient!() }; +}); + +import { getPublicStats } from '@/lib/marketing/stats'; +import { getPublishedCaseStudies } from '@/lib/marketing/reputation-server'; + +const accounts = { families: 40, members: 100, tasks_completed: 150 }; +const handled = { runs_completed: 80, runs_completed_30d: 30, families_with_runs: 20 }; +const study = { id: 'published', slug: 'family-story', title: 'Fixture story', customer_name: null, summary: null, result_metric: null, verified_at: null, is_published: true }; + +async function held() { await new Promise(resolve => state.releases.push(resolve)); } +async function fetchFixture(input: RequestInfo | URL, init?: RequestInit): Promise { + const url = new URL(input instanceof Request ? input.url : String(input)); + expect(url.origin).toBe('https://pricing-audit.invalid'); + const kinds: Record = { + '/rest/v1/rpc/public_stats': 'accounts', + '/rest/v1/rpc/public_handled_stats': 'handled', + '/rest/v1/case_studies': 'studies', + }; + const kind = kinds[url.pathname]; + if (!kind) throw new Error(`Unexpected fixture endpoint: ${url.pathname}`); + state.requests.push({ kind, signal: init?.signal }); + const mode = state.modes[kind] ?? 'healthy'; + // Deliberately ignore abort so the caller's own deadline is load-bearing. + if (mode === 'hold') await held(); + if (mode === 'error') return Response.json({ message: 'Synthetic outage' }, { status: 503 }); + let rows: unknown; + if (kind === 'accounts') rows = [mode === 'zero' ? { families: 0, members: 0, tasks_completed: 0 } : mode === 'invalid' ? { ...accounts, families: -1 } : accounts]; + if (kind === 'handled') rows = [mode === 'zero' ? { runs_completed: 0, runs_completed_30d: 0, families_with_runs: 0 } : handled]; + if (kind === 'studies') rows = mode === 'zero' ? [] : mode === 'null' ? null : mode === 'object' ? {} : state.studyRows ?? [study, { ...study, id: 'seed', slug: 'seed-case_studies-450' }, { ...study, id: 'hidden', is_published: false }]; + const response = Response.json(rows); + if (mode === 'hold-body') { + const text = response.text.bind(response); + response.text = async () => { await held(); return text(); }; + } + return response; +} + +async function run(fn: () => Promise) { + const result = state.run!(fn); + await vi.advanceTimersByTimeAsync(8_000); // Includes unmodified SDK retry backoff. + return result; +} +beforeEach(async () => { + vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-01T00:00:00Z')); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.stubGlobal('fetch', () => { throw new Error('Real network is forbidden by this fixture'); }); + state.modes = {}; state.requests = []; state.releases = []; state.writes = []; state.entries.clear(); + state.studyRows = undefined; + // importActual keeps the anonymous-client seam synthetic while using the + // installed query builder, retry/response parsing and cancellation handling. + const actual = await vi.importActual('@supabase/supabase-js'); + state.makeClient = () => actual.createClient('https://pricing-audit.invalid', 'synthetic-key', { + auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false }, + global: { fetch: fetchFixture }, + }); +}); +afterEach(async () => { + state.releases.splice(0).forEach(release => release()); + await vi.advanceTimersByTimeAsync(0); + vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks(); +}); + +describe('optional public data does not turn an outage into cached evidence', () => { + it.each(['accounts', 'handled'] as const)('does not cache the failing %s group and preserves its healthy peer', async kind => { + state.modes[kind] = 'error'; + const failed = await run(getPublicStats); + expect(failed.families).toBe(kind === 'accounts' ? 0 : 40); + expect(failed.handledCompleted).toBe(kind === 'handled' ? 0 : 80); + const peerReads = state.requests.filter(r => r.kind !== kind).length; + state.modes = {}; + const healthy = await run(getPublicStats); + expect(healthy.families).toBe(40); expect(healthy.handledCompleted).toBe(80); + expect(state.requests.filter(r => r.kind !== kind)).toHaveLength(peerReads); + expect(state.requests.filter(r => r.kind === kind)).toHaveLength(2); + }); + + it('does not cache a malformed aggregate as a valid zero', async () => { + state.modes.accounts = 'invalid'; + expect((await run(getPublicStats)).families).toBe(0); + state.modes.accounts = 'healthy'; + expect((await run(getPublicStats)).families).toBe(40); + }); + + it('does not cache a failed study list as a legitimately empty one', async () => { + state.modes.studies = 'error'; + expect(await run(getPublishedCaseStudies)).toEqual([]); + expect(state.writes).toHaveLength(0); + state.modes.studies = 'healthy'; + expect((await run(getPublishedCaseStudies)).map(row => row.id)).toEqual(['published']); + }); + + it.each(['null', 'object'] as const)('does not cache malformed successful study payload %s as empty', async mode => { + state.modes.studies = mode; + expect(await run(getPublishedCaseStudies)).toEqual([]); + expect(state.writes).toHaveLength(0); + state.modes.studies = 'healthy'; + expect((await run(getPublishedCaseStudies)).map(row => row.id)).toEqual(['published']); + }); + + it.each([ + {}, false, null, [], + { ...study, title: { invalid: true } }, + { ...study, id: 7 }, + { ...study, slug: null }, + { ...study, customer_name: 7 }, + { ...study, summary: [] }, + { ...study, result_metric: {} }, + { ...study, is_published: 'true' }, + { ...study, verified_at: true }, + ])('rejects malformed study row %j before it can become cached publication data', async row => { + state.studyRows = [row]; + expect(await run(getPublishedCaseStudies)).toEqual([]); + expect(state.writes).toHaveLength(0); + state.studyRows = undefined; + expect((await run(getPublishedCaseStudies)).map(item => item.id)).toEqual(['published']); + }); + + it('preserves pre-migration records without verified_at and verified strings', async () => { + const { verified_at: _legacy, ...legacy } = study; + state.studyRows = [legacy, { ...study, id: 'verified', verified_at: '2026-09-30T12:00:00Z' }]; + expect((await run(getPublishedCaseStudies)).map(item => item.verifiedAt)).toEqual([null, '2026-09-30T12:00:00Z']); + expect(state.writes).toHaveLength(1); + }); + + it.each(['stats', 'studies'] as const)('keeps a validated zero/empty %s result cacheable', async kind => { + state.modes = { accounts: 'zero', handled: 'zero', studies: 'zero' }; + const read = kind === 'stats' ? getPublicStats : getPublishedCaseStudies; + const first = await run(read as () => Promise); + const requestCount = state.requests.length; + state.modes = {}; + expect(await run(read as () => Promise)).toEqual(first); + expect(state.requests).toHaveLength(requestCount); + expect(state.writes.every(entry => entry.revalidate === 3600)).toBe(true); + expect(console.error).not.toHaveBeenCalled(); + }); + + it.each(['stats', 'studies'] as const)('keeps last-good %s when its stale refresh fails', async kind => { + const read = kind === 'stats' ? getPublicStats : getPublishedCaseStudies; + const first = await run(read as () => Promise); + const writes = state.writes.length; + vi.setSystemTime(Date.now() + 3_600_001); + state.modes = kind === 'stats' ? { accounts: 'error', handled: 'error' } : { studies: 'error' }; + expect(await run(read as () => Promise)).toEqual(first); + expect(state.writes).toHaveLength(writes); + state.modes = {}; + expect(await run(read as () => Promise)).toEqual(first); + }); +}); + +describe('optional SDK reads have one four-second caller budget', () => { + it.each(['accounts', 'handled', 'studies'] as const)('settles a held %s fetch even when transport ignores abort', async kind => { + state.modes[kind] = 'hold'; + const read = kind === 'studies' ? getPublishedCaseStudies : getPublicStats; + let settled = false; + const pending = state.run!(read as () => Promise).then(value => { settled = true; return value; }); + try { + await vi.advanceTimersByTimeAsync(0); + expect(state.requests.some(request => request.kind === kind)).toBe(true); + await vi.advanceTimersByTimeAsync(3_999); expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(2); expect(settled).toBe(true); + expect(state.requests.find(request => request.kind === kind)?.signal?.aborted).toBe(true); + const written = state.writes.length; + state.releases.splice(0).forEach(release => release()); + await vi.advanceTimersByTimeAsync(0); await pending; + expect(state.writes).toHaveLength(written); // Late success is not the timed-out cached result. + expect(vi.getTimerCount()).toBe(0); + } finally { + state.releases.splice(0).forEach(release => release()); + await pending; + } + }); + + it('includes response body parsing in the budget', async () => { + state.modes.studies = 'hold-body'; + let settled = false; + const pending = state.run!(getPublishedCaseStudies).then(value => { settled = true; return value; }); + try { + await vi.advanceTimersByTimeAsync(4_001); + expect(settled).toBe(true); expect(await pending).toEqual([]); + expect(state.writes).toHaveLength(0); + state.releases.splice(0).forEach(release => release()); + await vi.advanceTimersByTimeAsync(0); + expect(state.writes).toHaveLength(0); expect(vi.getTimerCount()).toBe(0); + } finally { state.releases.splice(0).forEach(release => release()); await pending; } + }); +}); diff --git a/tests/marketing-stats-reader.test.ts b/tests/marketing-stats-reader.test.ts index f2e87dbf7..72cfeff3a 100644 --- a/tests/marketing-stats-reader.test.ts +++ b/tests/marketing-stats-reader.test.ts @@ -22,7 +22,12 @@ function results(handledData: unknown, accountData: unknown = [accounts]) { beforeEach(() => { vi.resetAllMocks(); vi.spyOn(console, 'error').mockImplementation(() => {}); - boundary.createClient.mockReturnValue({ rpc: boundary.rpc }); + boundary.createClient.mockReturnValue({ + rpc: (name: string) => { + const reply = boundary.rpc(name); + return Object.assign(Promise.resolve(reply), { abortSignal: () => reply }); + }, + }); results([handled]); }); @@ -94,6 +99,7 @@ describe('public aggregate reader', () => { boundary.createClient.mockImplementation(() => { throw new Error('unavailable'); }); expect(await getPublicStats()).toEqual(EMPTY_PUBLIC_STATS); expect(boundary.rpc).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalledTimes(1); + // The independently cached groups each report their unavailable client. + expect(console.error).toHaveBeenCalledTimes(2); }); }); diff --git a/tests/pricing-public-tier-budget.test.ts b/tests/pricing-public-tier-budget.test.ts new file mode 100644 index 000000000..8e9be3817 --- /dev/null +++ b/tests/pricing-public-tier-budget.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createClient } from '@supabase/supabase-js'; +import type { FeatureTier } from '@/lib/constants/feature-catalog'; + +type Mode = 'healthy' | 'absent' | 'malformed' | 'error' | 'hold' | 'body' | 'token' | 'constructor'; +const state = vi.hoisted(() => ({ + mode: 'healthy' as Mode, + releases: [] as (() => void)[], + signals: [] as (AbortSignal | null | undefined)[], + overrideValue: undefined as unknown, + makeClient: undefined as undefined | (() => ReturnType), +})); +vi.mock('@/lib/supabase/server', () => ({ createServiceClient: () => state.makeClient!() })); +vi.mock('@/lib/i18n/server', () => ({ getTranslations: async () => (key: string) => key })); +vi.mock('@/lib/marketing/seo', () => ({ resolveMarketingMetadata: () => ({}) })); +vi.mock('@/lib/marketing/stats', () => ({ getPublicStats: async () => ({ families: 40, handledCompleted: 80, handled30d: 30 }) })); +vi.mock('@/lib/marketing/reputation-server', () => ({ getPublishedCaseStudies: async () => [] })); +vi.mock('@/lib/marketing/handled-sample', () => ({ sampleBriefNumbers: () => ({ today: 1, clashes: 2, handled: 3, minutes: 4 }) })); +vi.mock('@/components/marketing/switching-band', () => ({ SwitchingBand: () => null })); +vi.mock('@/components/marketing/marketing-aeo-section', () => ({ MarketingAeoSection: () => null })); +vi.mock('@/app/(marketing)/pricing/pricing-content', () => ({ PricingContent: () => null })); + +import PricingPage from '@/app/(marketing)/pricing/page'; +import { getPricingFeatureTiers } from '@/lib/marketing/pricing-tiers'; +import { getResolvedFeatureTiers } from '@/lib/server/feature-tiers'; + +async function hold() { await new Promise(resolve => state.releases.push(resolve)); } +async function transport(input: RequestInfo | URL, init?: RequestInit): Promise { + const url = new URL(input instanceof Request ? input.url : String(input)); + expect(url.origin).toBe('https://pricing-audit.invalid'); + expect(url.pathname).toBe('/rest/v1/app_settings'); + expect(url.searchParams.get('key')).toBe('eq.feature_tiers'); + expect(url.searchParams.get('select')).toBe('value'); + state.signals.push(init?.signal); + if (state.mode === 'hold') await hold(); // Ignores abort, deliberately. + if (state.mode === 'error') return Response.json({ message: 'Synthetic outage' }, { status: 503 }); + const response = Response.json(state.mode === 'absent' ? [] : [{ + value: state.mode === 'malformed' ? ['not-an-override-map'] : state.overrideValue, + }]); + if (state.mode === 'body') { + const text = response.text.bind(response); + response.text = async () => { await hold(); return text(); }; + } + return response; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.stubGlobal('fetch', () => { throw new Error('Unexpected real fetch'); }); + state.mode = 'healthy'; state.releases = []; state.signals = []; + state.overrideValue = { calendar: 'basic', recipes: 'off', 'unknown-feature': 'plus', 'ai-assistant': 'invalid' }; + state.makeClient = () => { + if (state.mode === 'constructor') throw new Error('Synthetic client unavailable'); + return createClient('https://pricing-audit.invalid', 'synthetic-key', { + auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false }, + global: { fetch: transport }, + ...(state.mode === 'token' ? { accessToken: async () => { await hold(); return 'synthetic-token'; } } : {}), + }); + }; +}); +afterEach(async () => { + state.releases.splice(0).forEach(release => release()); + await vi.advanceTimersByTimeAsync(0); + vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks(); +}); + +function matrix(tree: Awaited>) { + return tree.props.children[0].props.featureMatrix as { section: string; items: { label: string; tier: FeatureTier }[] }[]; +} +function calendar(tree: Awaited>) { + return matrix(tree).flatMap(section => section.items).find(item => item.label === 'Calendar')?.tier; +} + +describe('the public pricing matrix does not borrow entitlement deadlines', () => { + it('preserves healthy admin tiers, off exclusions and invalid-key defaults', async () => { + const page = await PricingPage(); + expect(calendar(page)).toBe('basic'); + expect(matrix(page).flatMap(section => section.items).some(item => item.label === 'Recipes')).toBe(false); + expect(treeProps(page).familiesCount).toBe(40); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each(['off', 'free', 'basic', 'plus'] as const)('preserves the public %s override and agrees with the unchanged shared resolver', async tier => { + state.overrideValue = { calendar: tier, recipes: 'off', 'unknown-feature': 'plus' }; + expect(await getPricingFeatureTiers()).toEqual(await getResolvedFeatureTiers(state.makeClient!())); + expect(calendar(await PricingPage())).toBe(tier === 'off' ? undefined : tier); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each(['not-a-tier', null, 5, true, {}, ['plus']])('keeps the shared invalid-value fallback for %j', async invalid => { + state.overrideValue = { calendar: invalid, recipes: 'off', 'unknown-feature': 'plus' }; + const publicResult = await getPricingFeatureTiers(); + expect(publicResult).toEqual(await getResolvedFeatureTiers(state.makeClient!())); + expect(publicResult.calendar).toBe('free'); + expect(publicResult.recipes).toBe('off'); + expect(publicResult).not.toHaveProperty('unknown-feature'); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each(['absent', 'malformed', 'constructor'] as const)('keeps the existing public catalog fallback for %s configuration', async mode => { + state.mode = mode; + const page = await PricingPage(); + expect(calendar(page)).toBe('free'); + expect(treeProps(page).familiesCount).toBe(40); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each(['hold', 'body', 'token'] as const)('includes %s waiting in the same four-second page read budget', async mode => { + state.mode = mode; + let settled = false; + const pending = PricingPage().then(page => { settled = true; return page; }); + try { + await vi.advanceTimersByTimeAsync(3_999); expect(settled).toBe(false); + if (mode === 'token') expect(state.signals).toHaveLength(0); + await vi.advanceTimersByTimeAsync(2); expect(settled).toBe(true); + const page = await pending; + expect(calendar(page)).toBe('free'); expect(treeProps(page).familiesCount).toBe(40); + state.releases.splice(0).forEach(release => release()); + await vi.advanceTimersByTimeAsync(0); + expect(state.signals.every(signal => signal?.aborted)).toBe(true); + expect(calendar(page)).toBe('free'); expect(vi.getTimerCount()).toBe(0); + } finally { state.releases.splice(0).forEach(release => release()); await pending; } + }); + + it('bounds SDK retry backoff without changing retry settings or caching the fallback', async () => { + state.mode = 'error'; + let settled = false; + const pending = PricingPage().then(page => { settled = true; return page; }); + try { + await vi.advanceTimersByTimeAsync(4_001); + expect(settled).toBe(true); expect(calendar(await pending)).toBe('free'); + const attempts = state.signals.length; + await vi.advanceTimersByTimeAsync(60_000); + expect(state.signals).toHaveLength(attempts); expect(vi.getTimerCount()).toBe(0); + state.mode = 'healthy'; + expect(calendar(await PricingPage())).toBe('basic'); + expect(state.signals.length).toBeGreaterThan(attempts); + } finally { await vi.advanceTimersByTimeAsync(8_000); await pending; } + }); +}); + +function treeProps(tree: Awaited>) { + return tree.props.children[0].props as { familiesCount: number }; +}