Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 57 additions & 3 deletions src/app/providers/TenantGate.test.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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,
Expand All @@ -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(
<TenantGate>
<p>panel</p>
</TenantGate>,
)
}

describe('TenantGate', () => {
it('holds the app behind a splash with the identity of the tenant', async () => {
await setTenant({ slug: 'norte', config: null })
Expand Down Expand Up @@ -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(
<TenantGate>
Expand All @@ -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()
})
})
})
49 changes: 43 additions & 6 deletions src/app/providers/TenantGate.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 (
<Box
Expand Down Expand Up @@ -68,6 +79,22 @@ function UnknownTenant({ slug }: { slug: string | null }) {
)
}

function TenantUnavailable({ retrying, onRetry }: { retrying: boolean; onRetry: () => void }) {
return (
<FullScreen>
<Stack spacing={2} alignItems="center" sx={{ maxWidth: 480, textAlign: 'center' }}>
<Typography variant="h1">{tenantGateContent.unavailable.title}</Typography>
<Typography variant="bodyLg" color="text.secondary">
{tenantGateContent.unavailable.body}
</Typography>
<Button variant="contained" onClick={onRetry} disabled={retrying}>
{tenantGateContent.unavailable.retry}
</Button>
</Stack>
</FullScreen>
)
}

/**
* Gate de arranque: nada de la app se monta antes de saber para qué empresa se
* está sirviendo.
Expand All @@ -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
Expand All @@ -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 <UnknownTenant slug={slug} />
if (!config) return <TenantSplash name={displayNameFromSlug(slug)} />
// 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 <UnknownTenant slug={slug} />
// 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 <TenantUnavailable retrying={isFetching} onRetry={() => void refetch()} />
}

return children
return <TenantSplash name={displayNameFromSlug(slug)} />
}
11 changes: 8 additions & 3 deletions src/shared/hooks/useTenantConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -16,7 +20,7 @@ export const tenantKeys = {
export function useTenantConfig() {
const slug = useTenantStore((state) => state.slug)

return useQuery<TenantConfig>({
return useQuery<TenantConfig, ApiRequestError>({
queryKey: tenantKeys.config(slug),
queryFn: async () => {
const config = await fetchTenantConfig()
Expand All @@ -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,
})
}
Loading