76b1e712b9
Works through every numbered item in NextSteps260913.md section 3, plus the three known verification issues in section 4.1. Surface what the backend already computed: - 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies - 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible - 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt) Calendar cluster (one pass over the holiday data): - new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides, paginated four years at a time - 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal - 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact - 3.7 click month/year to advance - 3.8 per-year red-days list in Settings, statutory days plus overrides i18n: - 3.5 five backend-generated Swedish surfaces converted to message_key + data, following the tips_service pattern; old string fields replaced, not kept Flexible wishes (3.9): - PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function, deterministic tie-break, two-pass ordering, test matrix rows 15-24 - backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator - frontend: season-preset toggle in WishEditor, unplaced outcome rendering Fixes (4.1): - pin typecheck to the local tsc rather than npx resolving a newer TypeScript - ignore generated coverage/ and dist/ in the ESLint flat config - fix the flaky usePlanApply test properly, with waitFor rather than a retry Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines. Not yet verified in a browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
142 lines
4.5 KiB
TypeScript
142 lines
4.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { render, screen, waitFor } from '@testing-library/react'
|
|
import * as api from '@/services/api'
|
|
import { App } from './App'
|
|
|
|
// Mock API service - AuthProvider calls refreshToken on mount
|
|
vi.mock('@/services/api')
|
|
|
|
// Mock WorkspaceProvider to avoid workspace loading logic
|
|
vi.mock('@/context/WorkspaceContext', () => ({
|
|
WorkspaceProvider: ({ children }: { children: React.ReactNode }) => (
|
|
<div data-testid="workspace-provider">{children}</div>
|
|
),
|
|
}))
|
|
|
|
// Mock all page components to verify routing
|
|
vi.mock('@/pages/Auth', () => ({
|
|
Auth: () => <div data-testid="auth-page">Auth Page</div>,
|
|
}))
|
|
|
|
vi.mock('@/pages/Calendar', () => ({
|
|
Calendar: () => <div data-testid="calendar-page">Calendar Page</div>,
|
|
}))
|
|
|
|
vi.mock('@/pages/Dashboard', () => ({
|
|
Dashboard: () => <div data-testid="dashboard-page">Dashboard Page</div>,
|
|
}))
|
|
|
|
vi.mock('@/pages/Settings', () => ({
|
|
Settings: () => <div data-testid="settings-page">Settings Page</div>,
|
|
}))
|
|
|
|
vi.mock('@/pages/Tips', () => ({
|
|
Tips: () => <div data-testid="tips-page">Tips Page</div>,
|
|
}))
|
|
|
|
vi.mock('@/pages/DevFetch', () => ({
|
|
DevFetch: () => <div data-testid="dev-fetch-page">DevFetch Page</div>,
|
|
}))
|
|
|
|
// App supplies its own BrowserRouter. Replace it with a pass-through
|
|
// so MemoryRouter can control routing in tests.
|
|
vi.mock('react-router-dom', async () => {
|
|
const actual = await vi.importActual('react-router-dom')
|
|
return {
|
|
...actual,
|
|
BrowserRouter: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
}
|
|
})
|
|
|
|
// Import MemoryRouter from the mocked module (gets the real one via spread)
|
|
import { MemoryRouter } from 'react-router-dom'
|
|
|
|
describe('App', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
const renderWithRoute = (route: string = '/') => {
|
|
return render(
|
|
<MemoryRouter initialEntries={[route]}>
|
|
<App />
|
|
</MemoryRouter>
|
|
)
|
|
}
|
|
|
|
describe('auth state handling', () => {
|
|
it('shows loading state when auth is initializing', () => {
|
|
// Mock refreshToken to never resolve (simulates loading state)
|
|
vi.mocked(api.refreshToken).mockImplementation(() => new Promise(() => {}))
|
|
|
|
renderWithRoute('/')
|
|
expect(screen.getByText('Laddar…')).toBeInTheDocument()
|
|
})
|
|
|
|
it('navigates to auth page when not authenticated', async () => {
|
|
// Mock refreshToken to reject (no valid session)
|
|
vi.mocked(api.refreshToken).mockRejectedValue(new Error('Unauthorized'))
|
|
|
|
renderWithRoute('/')
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('auth-page')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('shows dashboard when authenticated', async () => {
|
|
// Mock refreshToken to return a token (user is authenticated)
|
|
vi.mocked(api.refreshToken).mockResolvedValue({
|
|
access_token: 'test-token',
|
|
token_type: 'bearer',
|
|
expires_in: 3600,
|
|
})
|
|
vi.mocked(api.setAccessToken).mockImplementation(() => {})
|
|
|
|
renderWithRoute('/')
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('dashboard-page')).toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('protected routes', () => {
|
|
it('renders protected route when authenticated', async () => {
|
|
// Test that calendar (another protected route) also works when authenticated
|
|
vi.mocked(api.refreshToken).mockResolvedValue({
|
|
access_token: 'test-token',
|
|
token_type: 'bearer',
|
|
expires_in: 3600,
|
|
})
|
|
vi.mocked(api.setAccessToken).mockImplementation(() => {})
|
|
|
|
renderWithRoute('/calendar')
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('calendar-page')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('redirects to auth page when accessing protected route without authentication', async () => {
|
|
// Even on a protected route like /calendar, unauthenticated users are sent to auth
|
|
vi.mocked(api.refreshToken).mockRejectedValue(new Error('Unauthorized'))
|
|
|
|
renderWithRoute('/calendar')
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('auth-page')).toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('app structure', () => {
|
|
it('wraps app with AuthProvider and WorkspaceProvider', async () => {
|
|
// Verify the provider wrappers are in place by checking that auth flow works
|
|
// (both depend on these providers existing)
|
|
vi.mocked(api.refreshToken).mockRejectedValue(new Error('Unauthorized'))
|
|
|
|
renderWithRoute('/')
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('workspace-provider')).toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|
|
})
|