// @vitest-environment happy-dom // // Contrato del blindaje de licencia (LicenseGate + banner + helpers): // - Sin enforcement o estado operable → children, sin candado. // - stale/grace → children + banner degradado (grace descartable, expired no). // - enforcement && missing/invalid/expired → modal bloqueante con activación. // - trial vencido → copy "tu prueba terminó". // - onActivate: éxito desbloquea (el host refresca), error muestra el mensaje. // // Gotcha conocido (reference_radix_select_happydom_reset): nada de Radix Select // aquí — el gate usa un plano y el texto visible es el gate de los asserts. import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react' vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (_k: string, opts?: Record) => { let s = (opts?.defaultValue as string) ?? _k // interpolación mínima {{x}} para los mensajes del banner. for (const [k, v] of Object.entries(opts ?? {})) { if (k === 'defaultValue') continue s = s.replace(new RegExp(`{{\\s*${k}\\s*}}`, 'g'), String(v)) } return s }, }), })) afterEach(() => { cleanup() localStorage.clear() }) import { LicenseGate, LicenseExpiryBanner, LicenseStatusBadge, isLicenseBlocking, isLicenseOperable, isPresetEntitled, isTrialExpired, type LicenseState, } from '../license' function make(partial: Partial): LicenseState { return { enforced: true, configured: true, valid: false, status: 'missing', entitlements: [], wildcard: false, stale: false, in_grace: false, days_remaining: 0, ...partial, } } const CHILD =
contenido protegido
describe('helpers de estado', () => { it('operable/blocking espejan al backend', () => { expect(isLicenseOperable(make({ status: 'valid', valid: true }))).toBe(true) expect(isLicenseOperable(make({ status: 'grace', in_grace: true }))).toBe(true) expect(isLicenseOperable(make({ status: 'missing' }))).toBe(false) expect(isLicenseBlocking(make({ status: 'expired' }))).toBe(true) // Sin enforcement nunca bloquea, aunque el status sea feo. expect(isLicenseBlocking(make({ enforced: false, status: 'expired' }))).toBe(false) // undefined (aún cargando) → fail-open. expect(isLicenseBlocking(undefined)).toBe(false) }) it('entitlement respeta wildcard y lista explícita', () => { expect(isPresetEntitled(make({ wildcard: true }), 'pitsline')).toBe(true) expect(isPresetEntitled(make({ entitlements: ['pitsline'] }), 'pitsline')).toBe(true) expect(isPresetEntitled(make({ entitlements: ['otro'] }), 'pitsline')).toBe(false) expect(isPresetEntitled(make({ enforced: false }), 'lo-que-sea')).toBe(true) }) it('trial vencido se detecta', () => { expect(isTrialExpired(make({ plan: 'trial', status: 'expired' }))).toBe(true) expect(isTrialExpired(make({ plan: 'pro', status: 'expired' }))).toBe(false) }) }) describe('LicenseGate', () => { const noop = () => Promise.resolve() it('renderiza children sin candado cuando la licencia es válida', () => { render( {CHILD} , ) expect(screen.getByText('contenido protegido')).toBeTruthy() expect(screen.queryByRole('dialog')).toBeNull() }) it('sin enforcement es transparente', () => { render( {CHILD} , ) expect(screen.getByText('contenido protegido')).toBeTruthy() expect(screen.queryByRole('dialog')).toBeNull() }) it('bloquea con modal cuando falta la licencia', () => { render( {CHILD} , ) expect(screen.getByRole('dialog')).toBeTruthy() expect(screen.getByText('Activa tu licencia')).toBeTruthy() // children siguen montados detrás del overlay (no reload). expect(screen.getByText('contenido protegido')).toBeTruthy() }) it('trial vencido muestra el copy de prueba terminada', () => { render( {CHILD} , ) expect(screen.getByText('Tu prueba gratuita terminó')).toBeTruthy() }) it('activación exitosa llama onActivate con el código pegado', async () => { const onActivate = vi.fn().mockResolvedValue(undefined) render( {CHILD} , ) fireEvent.change(screen.getByLabelText('Clave o token de licencia'), { target: { value: ' lic_abc123 ' }, }) fireEvent.click(screen.getByText('Activar licencia')) await waitFor(() => expect(onActivate).toHaveBeenCalledWith('lic_abc123')) }) it('muestra el mensaje de error del backend al fallar', async () => { const onActivate = vi.fn().mockRejectedValue(new Error('Token no corresponde a esta instancia')) render( {CHILD} , ) fireEvent.change(screen.getByLabelText('Clave o token de licencia'), { target: { value: 'lic_bad' }, }) fireEvent.click(screen.getByText('Activar licencia')) await waitFor(() => expect(screen.getByText('Token no corresponde a esta instancia')).toBeTruthy(), ) }) it('el botón activar está deshabilitado sin código', () => { render( {CHILD} , ) const btn = screen.getByText('Activar licencia').closest('button')! expect(btn.disabled).toBe(true) }) it('sin canActivate oculta el form y muestra el mensaje read-only por defecto', () => { render( {CHILD} , ) expect(screen.getByRole('dialog')).toBeTruthy() expect(screen.queryByLabelText('Clave o token de licencia')).toBeNull() expect(screen.queryByText('Activar licencia')).toBeNull() expect( screen.getByText('Contacta al administrador de la plataforma para activar la licencia.'), ).toBeTruthy() }) it('sin canActivate respeta el readOnlyMessage custom, incl. trial vencido', () => { render( {CHILD} , ) expect(screen.getByText('Tu prueba gratuita terminó')).toBeTruthy() expect(screen.queryByLabelText('Clave o token de licencia')).toBeNull() expect(screen.getByText('Pídele a Ana que active la licencia.')).toBeTruthy() }) it('canActivate true (default) mantiene el form de activación', () => { render( {CHILD} , ) expect(screen.getByLabelText('Clave o token de licencia')).toBeTruthy() expect(screen.getByText('Activar licencia')).toBeTruthy() }) it('grace degrada con banner en vez de bloquear', () => { render( {CHILD} , ) expect(screen.queryByRole('dialog')).toBeNull() expect(screen.getByText('contenido protegido')).toBeTruthy() expect(screen.getByRole('alert')).toBeTruthy() }) }) describe('LicenseExpiryBanner', () => { it('silencioso sin enforcement', () => { const { container } = render( , ) expect(container.firstChild).toBeNull() }) it('expired no es descartable (no hay botón cerrar)', () => { render() expect(screen.getByRole('alert')).toBeTruthy() expect(screen.queryByTitle('Descartar')).toBeNull() }) it('grace es descartable y persiste el descarte', () => { const { rerender } = render( , ) fireEvent.click(screen.getByTitle('Descartar')) expect(screen.queryByRole('alert')).toBeNull() // Re-render: sigue descartado por el TTL en localStorage. rerender( , ) expect(screen.queryByRole('alert')).toBeNull() }) it('aviso de renovación próxima cuando quedan pocos días', () => { render( , ) expect(screen.getByText('Tu licencia vence en 7 días.')).toBeTruthy() }) it('botón gestionar solo cuando el host pasa onManage', () => { const onManage = vi.fn() render( , ) fireEvent.click(screen.getByText('Gestionar licencia')) expect(onManage).toHaveBeenCalled() }) }) describe('LicenseStatusBadge', () => { it('traduce cada status a etiqueta legible', () => { const { rerender } = render() expect(screen.getByText('Activa')).toBeTruthy() rerender() expect(screen.getByText('Vencida')).toBeTruthy() rerender() expect(screen.getByText('Sin verificar')).toBeTruthy() }) })