import React from 'react'
import { render, screen, waitFor, act } from '@testing-library/react'
import { MemoryRouter, useLocation } from 'react-router'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../tests/helpers/msw/server'
import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore'
import { resetAllStores } from '../tests/helpers/store'
import { buildUser, buildSettings, buildTrip } from '../tests/helpers/factories'
import { offlineDb } from './db/offlineDb'
import { SETTINGS_WAIT_MS } from './utils/startDestination'
import App from './App'
// ── Mock page components ───────────────────────────────────────────────────────
vi.mock('./pages/LoginPage', () => ({ default: () =>
Login
}))
vi.mock('./pages/DashboardPage', () => ({ default: () => Dashboard
}))
vi.mock('./pages/TripPlannerPage', () => ({ default: () => TripPlanner
}))
vi.mock('./pages/FilesPage', () => ({ default: () => Files
}))
vi.mock('./pages/AdminPage', () => ({ default: () => Admin
}))
vi.mock('./pages/SettingsPage', () => ({ default: () => Settings
}))
vi.mock('./pages/VacayPage', () => ({ default: () => Vacay
}))
vi.mock('./pages/AtlasPage', () => ({ default: () => Atlas
}))
vi.mock('./pages/SharedTripPage', () => ({ default: () => SharedTrip
}))
vi.mock('./pages/InAppNotificationsPage.tsx', () => ({ default: () => Notifications
}))
// Prevent WebSocket side effects from the notification listener
vi.mock('./hooks/useInAppNotificationListener.ts', () => ({
useInAppNotificationListener: vi.fn(),
}))
// ── Helpers ────────────────────────────────────────────────────────────────────
/** Reports the router's current location, so a test can assert where App sent it. */
function LocationProbe() {
const loc = useLocation()
return {loc.pathname + loc.search}
}
const currentPath = () => screen.getByTestId('loc').textContent
function renderApp(initialPath = '/') {
return render(
)
}
/**
* Seeds authStore with sensible defaults for a test, replacing loadUser with a
* no-op spy so the MSW /api/auth/me response does not overwrite the seeded state.
*/
function seedAuth(overrides: Record = {}) {
useAuthStore.setState({
isLoading: false,
isAuthenticated: false,
user: null,
appRequireMfa: false,
loadUser: vi.fn().mockResolvedValue(undefined),
...overrides,
})
}
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
document.documentElement.classList.remove('dark')
})
// ── RootRedirect ───────────────────────────────────────────────────────────────
describe('RootRedirect', () => {
it('FE-COMP-APP-001: / redirects to /login when not authenticated', async () => {
seedAuth({ isAuthenticated: false })
renderApp('/')
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
})
it('FE-COMP-APP-002: / redirects to /dashboard when authenticated', async () => {
seedAuth({ isAuthenticated: true, user: buildUser() })
renderApp('/')
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
})
it('FE-COMP-APP-003: / shows loading spinner while auth is loading', () => {
seedAuth({ isLoading: true, isAuthenticated: false })
renderApp('/')
expect(document.querySelector('.animate-spin')).toBeInTheDocument()
expect(screen.queryByText('Login')).not.toBeInTheDocument()
})
})
// ── RootRedirect — startup destination ────────────────────────────────────────
describe('RootRedirect — startup destination', () => {
/** Serves GET /api/trips/active and reports whether it was asked at all. */
function stubActiveTrip(trip: { id: number; title: string } | null) {
const calls: string[] = []
server.use(
http.get('/api/trips/active', ({ request }) => {
calls.push(request.url)
return HttpResponse.json({ trip })
}),
)
return calls
}
it('FE-COMP-APP-026: opens the active trip on the chosen tab', async () => {
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({
isLoaded: true,
settings: buildSettings({ start_page: 'active_trip', start_trip_tab: 'finanzplan' }),
})
stubActiveTrip({ id: 42, title: 'Japan' })
renderApp('/')
await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument())
})
it('FE-COMP-APP-027: falls back to the dashboard when the user has no trip', async () => {
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({
isLoaded: true,
settings: buildSettings({ start_page: 'active_trip', start_trip_tab: 'finanzplan' }),
})
stubActiveTrip(null)
renderApp('/')
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
})
it('FE-COMP-APP-028: falls back to the dashboard when the lookup fails', async () => {
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({
isLoaded: true,
settings: buildSettings({ start_page: 'active_trip' }),
})
server.use(http.get('/api/trips/active', () => HttpResponse.error()))
renderApp('/')
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
})
it('FE-COMP-APP-028b: opens the cached active trip when the launch is offline', async () => {
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({
isLoaded: true,
settings: buildSettings({ start_page: 'active_trip' }),
})
const today = new Date()
const iso = (d: Date) => d.toISOString().slice(0, 10)
await offlineDb.trips.put(buildTrip({ id: 42, title: 'Japan', start_date: iso(today), end_date: iso(today) }))
const onLine = Object.getOwnPropertyDescriptor(Navigator.prototype, 'onLine')
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true })
// navigator and the Dexie table are shared with every other test in the
// file, so a failed assertion must not leave the rest of them offline.
try {
renderApp('/')
await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument())
} finally {
if (onLine) Object.defineProperty(Navigator.prototype, 'onLine', onLine)
delete (navigator as unknown as { onLine?: boolean }).onLine
await offlineDb.trips.clear()
}
})
// The whole point of the localStorage mirror: the default launch must not pay
// for a lookup it doesn't need.
it('FE-COMP-APP-029: never asks for the active trip when starting on the dashboard', async () => {
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({ isLoaded: true, settings: buildSettings({ start_page: 'dashboard' }) })
const calls = stubActiveTrip({ id: 42, title: 'Japan' })
renderApp('/')
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
expect(calls).toHaveLength(0)
})
it('FE-COMP-APP-030: reads the preference from localStorage before settings have loaded', async () => {
localStorage.setItem('trek_start_page', 'active_trip')
localStorage.setItem('trek_start_trip_tab', 'finanzplan')
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({ isLoaded: false })
stubActiveTrip({ id: 42, title: 'Japan' })
renderApp('/')
await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument())
})
})
// ── ProtectedRoute — unauthenticated ──────────────────────────────────────────
describe('ProtectedRoute — unauthenticated', () => {
it('FE-COMP-APP-004: /dashboard redirects to /login with redirect param when not authenticated', async () => {
seedAuth({ isAuthenticated: false })
renderApp('/dashboard')
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
})
// A session that ran out should return you to the page you were on. Pressing
// "log out" should not: clearing isAuthenticated re-renders this route for the
// page still on screen, and the ?redirect= it stamped there beat the user's
// startup destination on every login after the first.
it('FE-COMP-APP-034: keeps the return ticket when the session merely ended', async () => {
seedAuth({ isAuthenticated: false, loggingOut: false })
renderApp('/trips/1/files')
await waitFor(() => expect(currentPath()).toBe('/login?redirect=%2Ftrips%2F1%2Ffiles'))
})
it('FE-COMP-APP-035: drops it on a deliberate sign-out, so the startup destination decides', async () => {
seedAuth({ isAuthenticated: false, loggingOut: true })
renderApp('/dashboard')
await waitFor(() => expect(currentPath()).toBe('/login'))
})
it('FE-COMP-APP-005: /trips/42 redirects to /login when not authenticated', async () => {
seedAuth({ isAuthenticated: false })
renderApp('/trips/42')
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
})
})
// ── ProtectedRoute — loading ───────────────────────────────────────────────────
describe('ProtectedRoute — loading state', () => {
it('FE-COMP-APP-006: protected route shows loading spinner while isLoading is true', () => {
seedAuth({ isLoading: true, isAuthenticated: false })
renderApp('/dashboard')
expect(document.querySelector('.animate-spin')).toBeInTheDocument()
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument()
})
})
// ── ProtectedRoute — MFA enforcement ──────────────────────────────────────────
describe('ProtectedRoute — MFA enforcement', () => {
it('FE-COMP-APP-007: redirects to /settings?mfa=required when appRequireMfa is true and MFA is disabled', async () => {
seedAuth({
isAuthenticated: true,
appRequireMfa: true,
user: buildUser({ mfa_enabled: false }),
})
renderApp('/dashboard')
await waitFor(() => expect(screen.getByText('Settings')).toBeInTheDocument())
})
it('FE-COMP-APP-008: does NOT redirect when already on /settings even with MFA required', async () => {
seedAuth({
isAuthenticated: true,
appRequireMfa: true,
user: buildUser({ mfa_enabled: false }),
})
renderApp('/settings')
await waitFor(() => expect(screen.getByText('Settings')).toBeInTheDocument())
expect(screen.queryByText('Login')).not.toBeInTheDocument()
})
it('FE-COMP-APP-009: does NOT redirect when user has MFA enabled', async () => {
seedAuth({
isAuthenticated: true,
appRequireMfa: true,
user: buildUser({ mfa_enabled: true }),
})
renderApp('/dashboard')
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
})
})
// ── ProtectedRoute — admin role ────────────────────────────────────────────────
describe('ProtectedRoute — admin role check', () => {
it('FE-COMP-APP-010: /admin redirects to /dashboard for non-admin user', async () => {
seedAuth({
isAuthenticated: true,
user: buildUser({ role: 'user' }),
})
renderApp('/admin')
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
expect(screen.queryByText('Admin')).not.toBeInTheDocument()
})
it('FE-COMP-APP-011: /admin is accessible for admin user', async () => {
seedAuth({
isAuthenticated: true,
user: buildUser({ role: 'admin' }),
})
renderApp('/admin')
await waitFor(() => expect(screen.getByText('Admin')).toBeInTheDocument())
})
})
// ── Public routes ──────────────────────────────────────────────────────────────
describe('Public routes', () => {
// Synchronous on purpose: LoginPage is the one page still statically imported,
// so this also holds the line against someone making it lazy later.
it('FE-COMP-APP-012: /login is accessible without authentication', async () => {
seedAuth({ isAuthenticated: false })
renderApp('/login')
expect(screen.getByText('Login')).toBeInTheDocument()
})
it('FE-COMP-APP-013: /shared/:token is accessible without authentication', async () => {
seedAuth({ isAuthenticated: false })
renderApp('/shared/sometoken')
expect(await screen.findByText('SharedTrip')).toBeInTheDocument()
})
it('FE-COMP-APP-014: unknown routes redirect to / which then redirects to /login', async () => {
seedAuth({ isAuthenticated: false })
renderApp('/does-not-exist')
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
})
})
// ── PublicRoute — redirect already-authenticated visitors ─────────────────────
describe('PublicRoute — already authenticated', () => {
/**
* The bounce goes to START_DESTINATION_ROUTE, so RootRedirect resolves where
* it actually lands — which needs settings, exactly like the cases above.
*/
function seedAuthedOnDashboard() {
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({
isLoaded: true,
settings: buildSettings({ start_page: 'dashboard' }),
})
}
it('FE-COMP-APP-012a: /login sends an authenticated visitor to the start destination', async () => {
seedAuthedOnDashboard()
renderApp('/login')
await waitFor(() => expect(currentPath()).toBe('/dashboard'))
expect(screen.queryByText('Login')).not.toBeInTheDocument()
})
it('FE-COMP-APP-012b: /register does the same', async () => {
seedAuthedOnDashboard()
renderApp('/register')
await waitFor(() => expect(currentPath()).toBe('/dashboard'))
expect(screen.queryByText('Login')).not.toBeInTheDocument()
})
it('FE-COMP-APP-012c: /login with a ?redirect= target is left to useLogin (OAuth consent handoff), not bounced', async () => {
// The consent page parks its URL in ?redirect= when it needs a login; that
// flow must reach the form even for an authenticated visitor, so the guard
// stays out of the way whenever a redirect target is present.
seedAuthedOnDashboard()
const target = '/login?redirect=' + encodeURIComponent('/oauth/consent?client_id=x')
renderApp(target)
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
expect(currentPath()).toBe(target)
})
it('FE-COMP-APP-012d: /register?invite= reaches the form so the token can be validated', async () => {
// An invite link is the one way someone joins a trip they were invited to
// from an account they are already signed into; bouncing it would swallow
// the token silently.
seedAuthedOnDashboard()
renderApp('/register?invite=abc123')
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
expect(currentPath()).toBe('/register?invite=abc123')
})
})
// ── App — on-mount effects ─────────────────────────────────────────────────────
describe('App — on-mount effects', () => {
it('FE-COMP-APP-015: loadUser is called on mount for non-shared paths', async () => {
const loadUser = vi.fn().mockResolvedValue(undefined)
useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser })
renderApp('/dashboard')
expect(loadUser).toHaveBeenCalled()
})
it('FE-COMP-APP-016: loadUser is NOT called on /shared/ paths', async () => {
const loadUser = vi.fn().mockResolvedValue(undefined)
useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser })
renderApp('/shared/token123')
expect(loadUser).not.toHaveBeenCalled()
})
it('FE-COMP-APP-017: GET /api/auth/app-config is called on mount', async () => {
let configCalled = false
server.use(
http.get('/api/auth/app-config', () => {
configCalled = true
return HttpResponse.json({})
})
)
seedAuth()
renderApp('/')
await waitFor(() => expect(configCalled).toBe(true))
})
it('FE-COMP-APP-018: setDemoMode(true) is called when config returns demo_mode: true', async () => {
server.use(
http.get('/api/auth/app-config', () => HttpResponse.json({ demo_mode: true }))
)
const setDemoMode = vi.fn()
useAuthStore.setState({
isLoading: false,
isAuthenticated: false,
loadUser: vi.fn().mockResolvedValue(undefined),
setDemoMode,
})
renderApp('/')
await waitFor(() => expect(setDemoMode).toHaveBeenCalledWith(true))
})
it('FE-COMP-APP-018b: setManaged mirrors the config flag, and defaults to false when it is absent', async () => {
server.use(
http.get('/api/auth/app-config', () => HttpResponse.json({ managed: true }))
)
const setManaged = vi.fn()
useAuthStore.setState({
isLoading: false,
isAuthenticated: false,
loadUser: vi.fn().mockResolvedValue(undefined),
setManaged,
})
renderApp('/')
await waitFor(() => expect(setManaged).toHaveBeenCalledWith(true))
// An older server that does not send the field must not leave the flag
// stuck on from a previous install in the same browser profile.
setManaged.mockClear()
server.use(
http.get('/api/auth/app-config', () => HttpResponse.json({}))
)
renderApp('/')
await waitFor(() => expect(setManaged).toHaveBeenCalledWith(false))
})
it('FE-COMP-APP-019: loadSettings is called once the user is authenticated', async () => {
const loadSettings = vi.fn().mockResolvedValue(undefined)
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({ loadSettings })
renderApp('/dashboard')
await waitFor(() => expect(loadSettings).toHaveBeenCalled())
})
})
// ── Dark mode effects ──────────────────────────────────────────────────────────
describe('Dark mode effects', () => {
it('FE-COMP-APP-020: adds dark class to documentElement when dark_mode is true', async () => {
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({ settings: buildSettings({ dark_mode: true }) })
renderApp('/dashboard')
await waitFor(() =>
expect(document.documentElement.classList.contains('dark')).toBe(true)
)
})
it('FE-COMP-APP-021: removes dark class when dark_mode is false', async () => {
document.documentElement.classList.add('dark')
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({ settings: buildSettings({ dark_mode: false }) })
renderApp('/dashboard')
await waitFor(() =>
expect(document.documentElement.classList.contains('dark')).toBe(false)
)
})
it('FE-COMP-APP-022: forces light mode on /shared/ path even when dark_mode is true', async () => {
document.documentElement.classList.add('dark')
useSettingsStore.setState({ settings: buildSettings({ dark_mode: true }) })
seedAuth({ isAuthenticated: false, loadUser: vi.fn().mockResolvedValue(undefined) })
renderApp('/shared/tok')
await waitFor(() =>
expect(document.documentElement.classList.contains('dark')).toBe(false)
)
})
it('FE-COMP-APP-023: auto mode applies dark based on matchMedia result', async () => {
// matchMedia stub returns matches: false by default (from setup.ts)
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({ settings: buildSettings({ dark_mode: 'auto' as any }) })
renderApp('/dashboard')
// With matches: false, dark should NOT be added
await waitFor(() =>
expect(document.documentElement.classList.contains('dark')).toBe(false)
)
})
})
// ── Version cache-busting ──────────────────────────────────────────────────────
describe('Version cache-busting', () => {
it('FE-COMP-APP-024: stores version in localStorage when config returns a version', async () => {
server.use(
http.get('/api/auth/app-config', () =>
HttpResponse.json({ version: '2.9.10' })
)
)
seedAuth()
renderApp('/')
await waitFor(() =>
expect(localStorage.getItem('trek_app_version')).toBe('2.9.10')
)
})
it('FE-COMP-APP-025: calls window.location.reload() when version changes', async () => {
localStorage.setItem('trek_app_version', '2.9.9')
const reload = vi.fn()
Object.defineProperty(window, 'location', {
writable: true,
value: { ...window.location, reload },
})
server.use(
http.get('/api/auth/app-config', () =>
HttpResponse.json({ version: '2.9.10' })
)
)
seedAuth()
renderApp('/')
await waitFor(() => expect(reload).toHaveBeenCalled())
})
it('FE-COMP-APP-026: a version bump never deletes the caches or unregisters the worker (#2228)', async () => {
// The old handler wiped every Cache Storage bucket and every registration
// here, which left the device with no app shell until a fresh ~22 MB
// precache finished: anyone who closed the app or lost signal inside that
// window could no longer start the PWA offline at all, and it also threw
// away the map tiles and documents the user had downloaded on purpose.
localStorage.setItem('trek_app_version', '2.9.9')
const cachesDelete = vi.fn().mockResolvedValue(true)
const unregister = vi.fn().mockResolvedValue(true)
const update = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('caches', { keys: vi.fn().mockResolvedValue(['workbox-precache-v2', 'map-tiles']), delete: cachesDelete })
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: {
getRegistration: vi.fn().mockResolvedValue({ update, unregister }),
getRegistrations: vi.fn().mockResolvedValue([{ unregister }]),
addEventListener: vi.fn(),
},
})
server.use(
http.get('/api/auth/app-config', () =>
HttpResponse.json({ version: '2.9.10' })
)
)
seedAuth()
renderApp('/')
// It asks the worker to fetch the new build; Workbox swaps the precache
// atomically once that install completes.
await waitFor(() => expect(update).toHaveBeenCalled())
expect(cachesDelete).not.toHaveBeenCalled()
expect(unregister).not.toHaveBeenCalled()
})
it('FE-COMP-APP-028: a version that does not look like a release tag is ignored', async () => {
// The value arrives over the wire and is written to this device's storage,
// so it is validated rather than trusted. An unusable one must also not be
// compared against the stored marker, or every launch would see a mismatch
// and try to update.
localStorage.setItem('trek_app_version', '2.9.9')
const reload = vi.fn()
Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } })
server.use(
http.get('/api/auth/app-config', () =>
HttpResponse.json({ version: '', managed: false })
)
)
seedAuth()
renderApp('/')
await waitFor(() => expect(screen.queryByText('common.loading')).not.toBeInTheDocument())
expect(localStorage.getItem('trek_app_version')).toBe('2.9.9')
expect(reload).not.toHaveBeenCalled()
})
it('FE-COMP-APP-027: an update path that throws is retried on the next launch, harmlessly (#2228)', async () => {
// A handover that never happened must not be recorded as done, or the
// device stays on the old build for good. The retry this buys has to be
// harmless: the purge that once repeated on every launch is gone, and a
// failure neither deletes anything nor reloads into the old worker's shell.
localStorage.setItem('trek_app_version', '2.9.9')
const reload = vi.fn()
Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } })
const cachesDelete = vi.fn().mockResolvedValue(true)
const unregister = vi.fn().mockResolvedValue(true)
vi.stubGlobal('caches', { keys: vi.fn().mockResolvedValue(['workbox-precache-v2', 'map-tiles']), delete: cachesDelete })
const getRegistration = vi.fn().mockRejectedValue(new Error('no worker'))
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: { getRegistration, getRegistrations: vi.fn().mockResolvedValue([{ unregister }]), addEventListener: vi.fn() },
})
server.use(
http.get('/api/auth/app-config', () =>
HttpResponse.json({ version: '2.9.10', places_provider: 'google' })
)
)
seedAuth()
const first = renderApp('/')
await waitFor(() => expect(getRegistration).toHaveBeenCalledTimes(1))
await act(async () => { await Promise.resolve() })
first.unmount()
expect(localStorage.getItem('trek_app_version')).toBe('2.9.9')
renderApp('/')
await waitFor(() => expect(getRegistration).toHaveBeenCalledTimes(2))
await act(async () => { await Promise.resolve() })
expect(localStorage.getItem('trek_app_version')).toBe('2.9.9')
expect(reload).not.toHaveBeenCalled()
expect(cachesDelete).not.toHaveBeenCalled()
expect(unregister).not.toHaveBeenCalled()
// Without a reload the session carries on, so the rest of the config applies.
expect(useAuthStore.getState().placesProvider).toBe('google')
})
})
// Regression: a device that has never mirrored the preference must not treat
// "nothing mirrored" as "user wants the dashboard".
describe('RootRedirect — preference not mirrored on this device', () => {
it('FE-COMP-APP-031: honours the server setting when localStorage is empty', async () => {
seedAuth({ isAuthenticated: true, user: buildUser() })
// Cold start: settings are still in flight when RootRedirect first runs.
useSettingsStore.setState({ isLoaded: false })
server.use(http.get('/api/trips/active', () => HttpResponse.json({ trip: { id: 42, title: 'Japan' } })))
renderApp('/')
// ...and land a moment later, exactly as loadSettings() does.
await act(async () => {
useSettingsStore.setState({
isLoaded: true,
settings: buildSettings({ start_page: 'active_trip', start_trip_tab: 'finanzplan' }),
})
})
await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument())
})
// Waiting for someone else's effect to fetch the settings made the decision
// ride on where that request landed in the queue — behind ~380 others on a
// cold start, which is how the redirect silently lost the race and fell
// through to the dashboard. It now asks for them itself.
it('FE-COMP-APP-033: asks for the settings itself instead of waiting to be told', async () => {
const loadSettings = vi.fn().mockResolvedValue(undefined)
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({ isLoaded: false, loadSettings })
renderApp('/')
await waitFor(() => expect(loadSettings).toHaveBeenCalled())
})
// loadSettings leaves isLoaded false on a failed request so it can retry, so
// the wait above needs a floor or a launch with no backend never resolves.
it('FE-COMP-APP-032: gives up on the settings after the timeout and opens the dashboard', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({ isLoaded: false })
renderApp('/')
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument()
await act(async () => { vi.advanceTimersByTime(SETTINGS_WAIT_MS + 100) })
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
vi.useRealTimers()
})
})