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,
})
}