Files
technitium-console/src/screens/blocking/Overview.test.tsx
T
DenozordecandClaude Sonnet 5.5 f3531c64f9 Move the console onto ReUI PRO
Install ReUI (MCP, skill, @reui registry, Tailwind v4, shadcn base-nova) and move
the shared UI onto it: Button, Tag, Check, Input/Textarea, Segmented, Alert, Panel,
Dialog, Tooltip, Menu and Select (Popover), Toast (sonner), the Table frame
(DataGrid), the Dashboard KPI strip, the shnt Ops palette, Geist and the type scale.

Design only: behaviour, texts and roles are unchanged. Tests that asserted the old
DOM were updated; the tests now format numbers in English wherever they run.
docs/reui-migration-plan.md records the plan and its decisions.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-27 00:06:06 +07:00

379 lines
18 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as dashboard from '../../api/dashboard'
import * as settings from '../../api/settings'
import * as apps from '../../api/apps'
import * as zonelists from '../../api/zonelists'
import { SlotProvider } from '../../app/ChromeSlot'
import { Overview, blockingChart } from './Overview'
/** Your Rules: each number with the word under it, in the card's two columns. */
function rulePairs() {
const text = screen.getByText('Your Rules', { selector: 'span' }).parentElement!
const [numbers, words] = [...text.children].slice(0, 2).map((row) => [...row.children].map((c) => c.textContent))
return numbers.map((n, i) => [n, words[i]])
}
/* Every `data` the charts were handed, by chart: Chart.tsx rebuilds its canvas on a
new reference, so identity across renders is what the tests look at. */
const drawn = vi.hoisted(() => new Map<string, unknown[]>())
const legends = vi.hoisted(() => new Map<string, unknown>())
/* What each chart was told to hide, and whether it was told not to draw its own legend. */
const hiddenOf = vi.hoisted(() => new Map<string, { hidden?: ReadonlySet<string>; separate?: boolean }>())
vi.mock('../dashboard/Chart', () => ({
Chart: ({ aria, data, legendOrder, hidden, separateLegend }: {
aria: string; data: unknown; legendOrder?: readonly string[]; hidden?: ReadonlySet<string>; separateLegend?: boolean
}) => {
const key = aria.startsWith('Blocked share') ? 'ring' : 'bars'
drawn.set(key, [...(drawn.get(key) ?? []), data])
legends.set(key, legendOrder)
hiddenOf.set(key, { hidden, separate: separateLegend })
return <div role="img" aria-label={aria} />
},
}))
afterEach(() => {
vi.restoreAllMocks()
localStorage.clear()
drawn.clear()
})
const STATS = {
totalQueries: 20354, totalNoError: 0, totalServerFailure: 0, totalNxDomain: 0, totalRefused: 0,
totalAuthoritative: 0, totalRecursive: 0, totalCached: 0, totalBlocked: 5310, totalDropped: 0,
totalClients: 11, zones: 5, cachedEntries: 0, allowedZones: 1, blockedZones: 1, allowListZones: 0, blockListZones: 74779,
}
const MAIN = { labels: ['a', 'b'], datasets: [
{ label: 'Total', data: [10, 30] }, { label: 'No Error', data: [5, 5] }, { label: 'Blocked', data: [4, 6] },
] }
const OK = {
kind: 'ok' as const,
data: {
stats: STATS, mainChartData: MAIN, queryResponseChartData: MAIN, queryTypeChartData: MAIN,
protocolTypeChartData: MAIN, topClients: [], topDomains: [], topBlockedDomains: [],
},
}
function serve() {
vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: true } as never)
vi.spyOn(apps, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: [] } } } as never)
return vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue(OK)
}
describe('blockingChart', () => {
/* Chart.js stacks in dataset order from the axis up: Blocked first puts it on the
baseline, as drawn, where its own series reads off a common zero. */
it('keeps only what got through and what was blocked, Blocked at the bottom of the stack', () => {
expect(blockingChart(MAIN)).toEqual({
labels: ['a', 'b'],
datasets: [{ label: 'Blocked', data: [4, 6] }, { label: 'Allowed', data: [6, 24] }],
})
})
})
describe('Overview', () => {
/* As drawn, and as the bars stack: the ring starts at the top with Blocked. */
it('the ring draws Blocked first, then what got through', async () => {
serve()
render(<Overview token="T" permissions={undefined} />)
await screen.findByRole('img', { name: 'Blocked share: 26.09%' })
expect(drawn.get('ring')?.at(-1)).toEqual({
labels: ['Blocked', 'Allowed'],
datasets: [{ label: 'Share', data: [5310, 20354 - 5310] }],
})
})
it('the bars read Allowed, then Blocked, in their legend', async () => {
serve()
render(<Overview token="T" permissions={undefined} />)
await screen.findByRole('img', { name: 'Allowed and blocked queries over time' })
expect(legends.get('bars')).toEqual(['Allowed', 'Blocked'])
})
it('the bars carry the Dashboard legend: each series with its total, and a click switches it off', async () => {
serve()
render(<Overview token="T" permissions={undefined} />)
await screen.findByRole('img', { name: 'Allowed and blocked queries over time' })
expect(hiddenOf.get('bars')?.separate).toBe(true)
/* MAIN: Total 10 + 30, Blocked 4 + 6, so 30 got through. In BAR_LEGEND's order. */
const allowed = screen.getByRole('button', { name: 'Allowed 30' })
const blocked = screen.getByRole('button', { name: 'Blocked 10' })
expect(allowed.compareDocumentPosition(blocked) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(allowed).toHaveAttribute('aria-pressed', 'true')
await userEvent.click(blocked)
expect(blocked).toHaveAttribute('aria-pressed', 'false')
expect([...(hiddenOf.get('bars')?.hidden ?? [])]).toEqual(['Blocked'])
})
it('draws the four figures, Blocked with its share', async () => {
serve()
render(<Overview token="T" permissions={undefined} />)
expect(await screen.findByText('20,354')).toBeInTheDocument()
expect(screen.getByText('5,310')).toBeInTheDocument()
/* The ring writes the same share in its hole: the figure is looked for in its card. */
expect(within(screen.getByText('5,310').parentElement!).getByText('26.09% of total')).toBeInTheDocument()
expect(screen.getByText('74,779')).toBeInTheDocument()
expect(rulePairs()).toEqual([['1', 'blocked'], ['1', 'allowed']])
})
it('draws the stacked bars and the blocked share', async () => {
serve()
render(<Overview token="T" permissions={undefined} />)
expect(await screen.findByRole('img', { name: 'Allowed and blocked queries over time' })).toBeInTheDocument()
expect(screen.getByRole('img', { name: 'Blocked share: 26.09%' })).toBeInTheDocument()
})
it('the period asks the server again', async () => {
const spy = serve()
render(<Overview token="T" permissions={undefined} />)
await screen.findByText('20,354')
await userEvent.click(screen.getByRole('button', { name: 'Last Day' }))
expect(spy).toHaveBeenLastCalledWith('T', 'LastDay', undefined, 'cluster')
})
it('offers the five fixed periods and not Custom', async () => {
serve()
render(<Overview token="T" permissions={undefined} />)
await screen.findByText('20,354')
const group = screen.getByRole('group', { name: 'Period' })
expect([...group.querySelectorAll('button')].map((b) => b.textContent)).toEqual([
'Last Hour', 'Last Day', 'Last Week', 'Last Month', 'Last Year',
])
})
it('reads the node the Dashboard remembers', async () => {
localStorage.setItem('dashboardClusterNode', 'dns2.example')
const spy = serve()
render(<Overview token="T" permissions={undefined} />)
await screen.findByText('20,354')
expect(spy).toHaveBeenCalledWith('T', 'LastHour', undefined, 'dns2.example')
})
it('with the cluster aggregate, Recently Blocked names the node it reads', async () => {
serve()
render(
<Overview
token="T"
permissions={undefined}
nodes={[{ name: 'dns1.example', type: 'Primary' }]}
clusterInitialised
serverDomain="dns1.example"
/>,
)
await screen.findByText('20,354')
expect(screen.getByRole('heading', { name: 'Recently Blocked on dns1.example' })).toBeInTheDocument()
})
it('without a cluster, Recently Blocked names no node', async () => {
serve()
render(<Overview token="T" permissions={undefined} serverDomain="dns1.example" />)
await screen.findByText('20,354')
expect(screen.getByRole('heading', { name: 'Recently Blocked' })).toBeInTheDocument()
})
it('without Dashboard.canView the figures are locked and nothing is asked', () => {
const spy = serve()
render(<Overview token="T" permissions={{ Dashboard: { canView: false, canModify: false, canDelete: false } }} />)
expect(screen.getAllByText('Requires Dashboard: View').length).toBeGreaterThan(0)
expect(spy).not.toHaveBeenCalled()
})
/* A panel without its view permission keeps its place and its title, with the padlock,
so the screen does not change shape with who looks. */
it('without Dashboard.canView the charts and both tops keep their place, locked', () => {
serve()
render(<Overview token="T" permissions={{ Dashboard: { canView: false, canModify: false, canDelete: false } }} />)
for (const title of ['Statistics', 'Queries over time', 'Blocked share', 'Top Blocked Domains', 'Top Domains']) {
/* The heading sits in the panel's header; the panel is the header's parent. */
const panel = screen.getByRole('heading', { name: title }).parentElement!.parentElement!
expect(within(panel).getByText('Requires Dashboard: View')).toBeInTheDocument()
}
expect(screen.queryByRole('table')).toBeNull()
})
/* Every figure is localised; 10,000 rules are not drawn `10000`. */
it('Your Rules is localised like the other figures', async () => {
serve().mockResolvedValue({ ...OK, data: { ...OK.data, stats: { ...STATS, blockedZones: 10000, allowedZones: 1234 } } })
render(<Overview token="T" permissions={undefined} />)
await screen.findByText((10000).toLocaleString())
expect(rulePairs()).toEqual([[(10000).toLocaleString(), 'blocked'], [(1234).toLocaleString(), 'allowed']])
})
it('while the figures travel the top tables do not claim there are no domains', () => {
serve().mockReturnValue(new Promise(() => {}))
render(<Overview token="T" permissions={undefined} />)
expect(screen.queryByText('No domains for this period.')).not.toBeInTheDocument()
expect(screen.getAllByText('—').length).toBe(4)
})
it('a first read that fails says so with the message from the server', async () => {
serve().mockResolvedValue({ kind: 'error', message: 'The server is busy.' })
render(<Overview token="T" permissions={undefined} />)
expect(await screen.findByText('The server is busy.')).toBeInTheDocument()
expect(screen.getAllByText('Could not load this data.').length).toBeGreaterThan(0)
expect(screen.queryByText('No domains for this period.')).not.toBeInTheDocument()
expect(screen.queryByText('Could not refresh.')).not.toBeInTheDocument()
})
it('a refresh that fails keeps the figures and says they are stale', async () => {
const spy = serve()
vi.spyOn(zonelists, 'addDomain').mockResolvedValue({ kind: 'ok', data: {} })
render(<Overview token="T" permissions={undefined} />)
await screen.findByText('20,354')
spy.mockResolvedValue({ kind: 'error', message: 'The server is busy.' })
await userEvent.type(screen.getByLabelText('Domain'), 'ads.example.com{Enter}')
expect(await screen.findByText('Could not refresh.')).toBeInTheDocument()
expect(screen.getByText('20,354')).toBeInTheDocument()
spy.mockResolvedValue(OK)
await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('20,354')).toBeInTheDocument()
expect(screen.queryByText('Could not refresh.')).not.toBeInTheDocument()
})
it('a new period that fails does not keep the figures of the previous one', async () => {
const spy = serve()
render(<Overview token="T" permissions={undefined} />)
await screen.findByText('20,354')
spy.mockResolvedValue({ kind: 'error', message: 'The server is busy.' })
await userEvent.click(screen.getByRole('button', { name: 'Last Day' }))
expect(await screen.findByText('The server is busy.')).toBeInTheDocument()
expect(screen.queryByText('20,354')).not.toBeInTheDocument()
expect(screen.queryByText('Could not refresh.')).not.toBeInTheDocument()
})
it('a render for another reason does not hand the charts new data', async () => {
serve()
render(<Overview token="T" permissions={undefined} />)
await screen.findByText('20,354')
const bars = drawn.get('bars')!.at(-1)
const ring = drawn.get('ring')!.at(-1)
const renders = drawn.get('bars')!.length
// An empty Block raises a notice: the screen renders again, the stats do not change.
await userEvent.click(screen.getByRole('button', { name: 'Block' }))
expect(await screen.findByText('Please enter a domain name to block.')).toBeInTheDocument()
expect(drawn.get('bars')!.length).toBeGreaterThan(renders)
expect(drawn.get('bars')!.at(-1)).toBe(bars)
expect(drawn.get('ring')!.at(-1)).toBe(ring)
})
it('choosing a node asks that node and remembers it for the Dashboard', async () => {
const spy = serve()
render(
<SlotProvider>
{(slot) => (
<>
<div ref={slot} />
<Overview
token="T"
permissions={undefined}
nodes={[{ name: 'dns1.example', type: 'Primary' }, { name: 'dns2.example', type: 'Secondary' }]}
clusterInitialised
/>
</>
)}
</SlotProvider>,
)
await screen.findByText('20,354')
await userEvent.click(screen.getByRole('combobox', { name: 'Node' }))
await userEvent.click(screen.getByRole('option', { name: 'dns2.example (secondary)' }))
expect(localStorage.getItem('dashboardClusterNode')).toBe('dns2.example')
expect(spy).toHaveBeenLastCalledWith('T', 'LastHour', undefined, 'dns2.example')
})
it('a slow answer to an earlier period does not overwrite a later one', async () => {
const spy = serve()
let answerFirst: (v: typeof OK) => void = () => {}
spy.mockImplementationOnce(() => new Promise((resolve) => { answerFirst = resolve }))
spy.mockResolvedValueOnce({ kind: 'ok', data: { ...OK.data, stats: { ...STATS, totalQueries: 99999 } } })
render(<Overview token="T" permissions={undefined} />)
await userEvent.click(screen.getByRole('button', { name: 'Last Day' }))
expect(await screen.findByText('99,999')).toBeInTheDocument()
await act(async () => answerFirst(OK))
expect(screen.getByText('99,999')).toBeInTheDocument()
expect(screen.queryByText('20,354')).not.toBeInTheDocument()
})
it('a period with no queries draws zero and no empty chart', async () => {
const zero = { ...STATS, totalQueries: 0, totalBlocked: 0 }
const flat = { labels: ['a'], datasets: [{ label: 'Total', data: [0] }, { label: 'Blocked', data: [0] }] }
serve().mockResolvedValue({ kind: 'ok', data: { ...OK.data, stats: zero, mainChartData: flat } })
render(<Overview token="T" permissions={undefined} />)
expect(await screen.findByText('0% of total')).toBeInTheDocument()
expect(screen.getAllByText('No queries for this period.').length).toBe(2)
expect(screen.queryByRole('img', { name: 'Allowed and blocked queries over time' })).not.toBeInTheDocument()
})
})
/* Last Hour in real time, as on the Dashboard: the same `stats/get`, the same live
figures over it (dashboard/useLive.ts), the same minute reload. */
describe('Overview: Last Hour in real time', () => {
const counters = (q: number, blocked: number) => ({
kind: 'ok' as const,
data: {
uptimestamp: 'u1',
lifetimeCounters: {
totalQueries: q, totalNoError: 0, totalServerFailure: 0, totalNxDomain: 0, totalRefused: 0,
totalAuthoritative: 0, totalRecursive: 0, totalCached: 0, totalBlocked: blocked, totalDropped: 0,
totalClients: 1,
},
},
})
afterEach(() => vi.useRealTimers())
it('Total Queries and Blocked move with the server between reloads', async () => {
vi.useFakeTimers()
serve()
vi.spyOn(dashboard, 'getMetrics').mockResolvedValueOnce(counters(1000, 50)).mockResolvedValue(counters(1004, 52))
render(<Overview token="T" permissions={undefined} />)
await act(async () => { await vi.advanceTimersByTimeAsync(0) })
expect(screen.getByText((20354).toLocaleString())).toBeInTheDocument()
await act(async () => { await vi.advanceTimersByTimeAsync(2_000) })
expect(screen.getByText((20358).toLocaleString())).toBeInTheDocument()
expect(screen.getByText((5312).toLocaleString())).toBeInTheDocument()
})
it('reloads a minute after each answer, waits while the tab is hidden, and reloads on return', async () => {
vi.useFakeTimers()
let hidden = false
Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden })
const spy = serve()
vi.spyOn(dashboard, 'getMetrics').mockResolvedValue({ kind: 'error', message: 'not here' })
render(<Overview token="T" permissions={undefined} />)
await act(async () => { await vi.advanceTimersByTimeAsync(0) })
expect(spy).toHaveBeenCalledTimes(1)
await act(async () => { await vi.advanceTimersByTimeAsync(60_000) })
expect(spy).toHaveBeenCalledTimes(2)
hidden = true
document.dispatchEvent(new Event('visibilitychange'))
await act(async () => { await vi.advanceTimersByTimeAsync(180_000) })
expect(spy).toHaveBeenCalledTimes(2)
hidden = false
await act(async () => {
document.dispatchEvent(new Event('visibilitychange'))
await vi.advanceTimersByTimeAsync(0)
})
expect(spy).toHaveBeenCalledTimes(3)
})
it('other periods neither reload nor read the counters', async () => {
vi.useFakeTimers()
const spy = serve()
const live = vi.spyOn(dashboard, 'getMetrics').mockResolvedValue(counters(1, 0))
render(<Overview token="T" permissions={undefined} />)
await act(async () => { await vi.advanceTimersByTimeAsync(0) })
await act(async () => { screen.getByRole('button', { name: 'Last Day' }).click() })
await act(async () => { await vi.advanceTimersByTimeAsync(0) })
spy.mockClear()
live.mockClear()
await act(async () => { await vi.advanceTimersByTimeAsync(130_000) })
expect(spy).not.toHaveBeenCalled()
expect(live).not.toHaveBeenCalled()
})
})