Files
technitium-console/src/screens/zones/Zones.stale.test.tsx
T
DenozordecandClaude Opus 5.5 ba3cdd08d2
CI / check (push) Successful in 2m39s
Hub / check (push) Successful in 50s
CI / image (push) Successful in 1m30s
Hub / image (push) Successful in 1m37s
UI polish across sections: one server selector, skeletons, inline errors
- Every section puts the hub's server selector in the chrome slot, the
  same place and gap as the Dashboard.
- Data regions load with a Skeleton and fail inline with Retry; KPIs show
  the no-data dash instead of 0 when a server did not answer. Settings
  keeps its header and selector while loading or failing.
- Cache, Apps and Logs headers group secondary or hub-refused actions in
  menus; app descriptions clamp to two lines; Unknown cells say why.
- DNS Client: Protocol label, Resolve and Import together. DHCP and Query
  Logs empty states with actions. View Logs list capped with scroll.
- Blocking: Lists KPI tiles like the Overview, app list table fits its
  panel with one-line URLs. Status re-reads on tab return at most once
  a minute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:42:28 +07:00

165 lines
6.8 KiB
TypeScript

/*
Stale data: the list is still there and you can see it is no longer current.
It is a decision of the Zones design, and the console's design rule: *old data
never looks like new data*.
What was there already kept the zones when a refresh failed, and that is right:
throwing them away would leave the user with nothing over a network error. What
was missing was SAYING SO. Without the mark, a ten-minute-old list reads like a
one-second-old one, and in a DNS console that means believing a state that no
longer exists.
*/
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import * as client from '../../api/client'
import { Zones } from './Zones'
afterEach(() => vi.restoreAllMocks())
const ZONE = {
name: 'casa.test', type: 'Primary', lastModified: '2026-08-26T10:00:00Z',
disabled: false, soaSerial: 7, catalog: null, dnssecStatus: 'Unsigned',
hasDnssecPrivateKeys: false, notifyFailed: false, notifyFailedFor: [],
}
const LIST = { zones: [ZONE], pageNumber: 1, totalPages: 1, totalZones: 1 }
/** Answers well the first time and badly from the second on. */
function goodThenBroken() {
let n = 0
return vi.spyOn(client, 'apiRequest').mockImplementation(async (route) => {
const base = route.split('?')[0]
if (base !== 'zones/list') return { kind: 'ok', data: { status: 'ok', response: {} } } as never
n += 1
return n === 1
? ({ kind: 'ok', data: { status: 'ok', response: LIST } } as never)
: ({ kind: 'error', message: 'boom' } as never)
})
}
const clickGo = async (user: ReturnType<typeof userEvent.setup>) =>
user.click(screen.getByRole('button', { name: 'Go' }))
describe('stale data', () => {
it('while the refresh goes well, there is no mark', async () => {
vi.spyOn(client, 'apiRequest').mockResolvedValue({
kind: 'ok', data: { status: 'ok', response: LIST },
} as never)
render(<Zones token="t" canModify canDelete />)
expect(await screen.findByRole('button', { name: 'casa.test' })).toBeInTheDocument()
expect(screen.queryByText(/Could not refresh/)).not.toBeInTheDocument()
})
/* The one that matters: the list STAYS, and it is also said to be stale. */
it('when the refresh fails, the list stays and it is reported', async () => {
const user = userEvent.setup()
goodThenBroken()
render(<Zones token="t" canModify canDelete />)
await screen.findByRole('button', { name: 'casa.test' })
await clickGo(user)
await screen.findByText(/Could not refresh/)
/* The previous one is not thrown away: that would leave the user with nothing over a network error. */
expect(screen.getByRole('button', { name: 'casa.test' })).toBeInTheDocument()
})
it('the strip carries the time of the last good data and a Retry', async () => {
const user = userEvent.setup()
goodThenBroken()
render(<Zones token="t" canModify canDelete />)
await screen.findByRole('button', { name: 'casa.test' })
await clickGo(user)
const banner = (await screen.findByText(/Could not refresh/)).closest('[role=alert]')!
expect(banner).toHaveTextContent(/Last good data:/)
expect(within(banner as HTMLElement).getByRole('button', { name: 'Retry' })).toBeInTheDocument()
})
/* No selection: acting in bulk on a list that no longer reflects the server is
how the wrong zone gets deleted. */
it('with stale data there is no selecting', async () => {
const user = userEvent.setup()
goodThenBroken()
render(<Zones token="t" canModify canDelete />)
await screen.findByRole('button', { name: 'casa.test' })
expect(screen.getByLabelText('Select casa.test')).toBeEnabled()
await clickGo(user)
await screen.findByText(/Could not refresh/)
expect(screen.getByLabelText('Select casa.test')).toBeDisabled()
expect(screen.getByLabelText('Select all zones')).toBeDisabled()
})
/* And with no previous data nothing goes stale: that is a plain error, and the
notice reports it. Marking a list that never existed promises data there is none of. */
it('when the FIRST load fails, it does not say anything is stale', async () => {
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'boom' } as never)
render(<Zones token="t" canModify canDelete />)
await waitFor(() => expect(screen.queryByRole('button', { name: 'casa.test' })).not.toBeInTheDocument())
expect(screen.queryByText(/Could not refresh/)).not.toBeInTheDocument()
})
it('and when it goes well again, the mark goes away', async () => {
const user = userEvent.setup()
let n = 0
vi.spyOn(client, 'apiRequest').mockImplementation(async (route) => {
const base = route.split('?')[0]
if (base !== 'zones/list') return { kind: 'ok', data: { status: 'ok', response: {} } } as never
n += 1
return n === 2
? ({ kind: 'error', message: 'boom' } as never)
: ({ kind: 'ok', data: { status: 'ok', response: LIST } } as never)
})
render(<Zones token="t" canModify canDelete />)
await screen.findByRole('button', { name: 'casa.test' })
await clickGo(user)
await screen.findByText(/Could not refresh/)
await clickGo(user)
await waitFor(() => expect(screen.queryByText(/Could not refresh/)).not.toBeInTheDocument())
expect(screen.getByLabelText('Select casa.test')).toBeEnabled()
})
})
/*
A failure is reported ONCE.
With previous data the strip reports it (and it also says since when and offers a
retry), and the notice at the top keeps quiet. If both fired, the same failure
would appear twice and you would have to decide which to read. With no previous
data it is the other way round: there is nothing to go stale, so the notice
speaks, carrying the server's message.
*/
describe('who reports the failure', () => {
it('with previous data the strip speaks, and only the strip', async () => {
const user = userEvent.setup()
goodThenBroken()
render(<Zones token="t" canModify canDelete />)
await screen.findByRole('button', { name: 'casa.test' })
await clickGo(user)
await screen.findByText(/Could not refresh/)
/* A single `role=alert` on the screen: the strip. The server notice does not
duplicate the same event. */
expect(screen.getAllByRole('alert')).toHaveLength(1)
expect(screen.queryByText(/boom/)).not.toBeInTheDocument()
})
it('with no previous data the notice speaks, with the server message', async () => {
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'boom' } as never)
render(<Zones token="t" canModify canDelete />)
/* The notice and the region both carry it: the region stays when the notice is dismissed. */
expect((await screen.findAllByText(/boom/)).length).toBeGreaterThan(0)
expect(screen.queryByText(/Could not refresh/)).not.toBeInTheDocument()
})
})