diff --git a/src/app/providers/TenantGate.test.tsx b/src/app/providers/TenantGate.test.tsx index b744ff0..81ce18d 100644 --- a/src/app/providers/TenantGate.test.tsx +++ b/src/app/providers/TenantGate.test.tsx @@ -1,4 +1,4 @@ -import { screen } from '@testing-library/react' +import { fireEvent, screen } from '@testing-library/react' import type { TenantConfig } from 'shared/api' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -8,7 +8,12 @@ import { TenantGate } from './TenantGate' // Sólo interesa en cuál de sus tres estados está el pedido de config; el pedido // en sí ya se prueba del lado del cliente HTTP. -const tenantConfigQuery = vi.hoisted(() => ({ isError: false })) +const tenantConfigQuery = vi.hoisted(() => ({ + isError: false, + isFetching: false, + error: null as { status?: number } | null, + refetch: vi.fn(), +})) vi.mock('shared/hooks/useTenantConfig', () => ({ useTenantConfig: () => tenantConfigQuery, @@ -28,8 +33,24 @@ async function setTenant(state: { slug: string | null; config: TenantConfig | nu beforeEach(() => { tenantConfigQuery.isError = false + tenantConfigQuery.error = null + tenantConfigQuery.refetch.mockReset() }) +/** El pedido de config falló con este status (sin status: red, respuesta inválida). */ +function failWith(status?: number) { + tenantConfigQuery.isError = true + tenantConfigQuery.error = status === undefined ? {} : { status } +} + +function renderGate() { + renderWithTheme( + +

panel

+
, + ) +} + describe('TenantGate', () => { it('holds the app behind a splash with the identity of the tenant', async () => { await setTenant({ slug: 'norte', config: null }) @@ -72,7 +93,7 @@ describe('TenantGate', () => { // Un slug inexistente o una empresa inactiva responden 404 (§3 del contrato). it('says the tenant is unknown when the backend does not recognise the slug', async () => { await setTenant({ slug: 'ninguna', config: null }) - tenantConfigQuery.isError = true + failWith(404) renderWithTheme( @@ -83,4 +104,37 @@ describe('TenantGate', () => { expect(screen.getByText('No encontramos esta empresa')).toBeInTheDocument() expect(screen.getByText('Identificador buscado: ninguna')).toBeInTheDocument() }) + + // Hallazgo de auditoría (TESIS-89): cualquier falla de `/tenant-config` + // tapaba la app con «No encontramos esta empresa». + describe('when the config request fails for another reason', () => { + it('keeps the app running with the config it already had', async () => { + await setTenant({ slug: 'norte', config: norteConfig }) + failWith(500) + + renderGate() + + expect(screen.getByText('panel')).toBeInTheDocument() + }) + + it('offers to retry instead of saying the tenant does not exist', async () => { + await setTenant({ slug: 'norte', config: null }) + failWith(500) + + renderGate() + + expect(screen.getByText('No pudimos conectarnos')).toBeInTheDocument() + expect(screen.queryByText('No encontramos esta empresa')).not.toBeInTheDocument() + }) + + it('asks for the config again on retry', async () => { + await setTenant({ slug: 'norte', config: null }) + failWith() + renderGate() + + fireEvent.click(screen.getByRole('button', { name: 'Reintentar' })) + + expect(tenantConfigQuery.refetch).toHaveBeenCalled() + }) + }) }) diff --git a/src/app/providers/TenantGate.tsx b/src/app/providers/TenantGate.tsx index c4de4b3..ac86a04 100644 --- a/src/app/providers/TenantGate.tsx +++ b/src/app/providers/TenantGate.tsx @@ -1,4 +1,4 @@ -import { Box, CircularProgress, Stack, Typography } from '@mui/material' +import { Box, Button, CircularProgress, Stack, Typography } from '@mui/material' import { displayNameFromSlug } from 'app/theme/branding' import type { ReactNode } from 'react' import { useTenantConfig } from 'shared/hooks/useTenantConfig' @@ -13,8 +13,19 @@ const tenantGateContent = { // nada y es lo único que le sirve a quien tiene que corregir el enlace. attempted: (slug: string) => `Identificador buscado: ${slug}`, }, + // Cualquier otra falla de `/tenant-config` (la API reiniciándose, un corte de + // red): no dice nada sobre si la empresa existe, así que no es «no la + // encontramos». + unavailable: { + title: 'No pudimos conectarnos', + body: 'No logramos cargar la configuración de tu empresa. Revisá tu conexión y volvé a intentar.', + retry: 'Reintentar', + }, } as const +/** El único status que dice que el slug no corresponde a una empresa (§3 del contrato). */ +const UNKNOWN_TENANT_STATUS = 404 + function FullScreen({ children }: { children: ReactNode }) { return ( void }) { + return ( + + + {tenantGateContent.unavailable.title} + + {tenantGateContent.unavailable.body} + + + + + ) +} + /** * Gate de arranque: nada de la app se monta antes de saber para qué empresa se * está sirviendo. @@ -76,9 +103,10 @@ function UnknownTenant({ slug }: { slug: string | null }) { * * - Sin slug resoluble, o `/tenant-config` que responde 404 → pantalla explícita * de tenant desconocido. + * - Config disponible (también la guardada) → la app, aunque el request falle. + * - Cualquier otra falla sin config → pantalla de error con reintento. * - Config en vuelo → splash con la identidad mínima derivada del slug, nunca el * tema base genérico. - * - Config disponible → la app. * * La config rehidratada de `localStorage` cuenta como disponible, así que un * reload no vuelve a pasar por el splash: el request igual sale y actualiza el @@ -87,10 +115,19 @@ function UnknownTenant({ slug }: { slug: string | null }) { export function TenantGate({ children }: { children: ReactNode }) { const slug = useTenantStore((state) => state.slug) const config = useTenantStore((state) => state.config) - const { isError } = useTenantConfig() + const { error, isError, isFetching, refetch } = useTenantConfig() - if (slug === null || isError) return - if (!config) return + // Sólo el 404 dice que la empresa no existe. Antes cualquier falla —un 500, + // la red, una respuesta que no valida— tapaba la app entera con «No + // encontramos esta empresa», incluso con una config válida guardada y sin + // forma de reintentar (hallazgo de auditoría, TESIS-89). + if (slug === null || error?.status === UNKNOWN_TENANT_STATUS) return + // Con la config rehidratada se sigue: el branding guardado es del mismo slug, + // y la próxima carga lo vuelve a pedir. + if (config) return children + if (isError) { + return void refetch()} /> + } - return children + return } diff --git a/src/shared/hooks/useTenantConfig.ts b/src/shared/hooks/useTenantConfig.ts index e706848..1e0c65a 100644 --- a/src/shared/hooks/useTenantConfig.ts +++ b/src/shared/hooks/useTenantConfig.ts @@ -2,8 +2,12 @@ import { useQuery } from '@tanstack/react-query' import { fetchTenantConfig } from '../api/tenant' import type { TenantConfig } from '../api/tenant' +import type { ApiRequestError } from '../api/types' import { setTenantConfig, useTenantStore } from '../store/tenantStore' +/** El status con el que la API dice que el slug no es de ninguna empresa. */ +const UNKNOWN_TENANT_STATUS = 404 + export const tenantKeys = { all: ['tenant'] as const, config: (slug: string | null) => [...tenantKeys.all, 'config', slug] as const, @@ -16,7 +20,7 @@ export const tenantKeys = { export function useTenantConfig() { const slug = useTenantStore((state) => state.slug) - return useQuery({ + return useQuery({ queryKey: tenantKeys.config(slug), queryFn: async () => { const config = await fetchTenantConfig() @@ -33,7 +37,8 @@ export function useTenantConfig() { // entera sin motivo. staleTime: Infinity, // Un slug inexistente responde 404 (§3 del contrato): reintentar sólo demora - // la pantalla de error sin cambiar el desenlace. - retry: false, + // la pantalla de error sin cambiar el desenlace. Cualquier otra falla (la + // API reiniciándose, la red) puede ser pasajera: se reintenta una vez. + retry: (failures, error) => error.status !== UNKNOWN_TENANT_STATUS && failures < 1, }) }