import { afterEach, describe, expect, it, vi } from 'vitest' import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { AppConfigSync } from './AppConfigSync' import { AppsMatrix } from './AppsMatrix' import * as api from '../../api/apps' import type { ConfigCompare, ServerConfigView } from '../../api/apps' afterEach(() => vi.restoreAllMocks()) const A = JSON.stringify({ ttl: 30, db: '/a.db' }, null, 2) const B = JSON.stringify({ ttl: 60, db: '/b.db' }, null, 2) function view(id: string, over: Partial = {}): ServerConfigView { return { id, ok: true, installed: true, writable: true, version: '1.0', text: A, hash: 'h-' + id, warnings: [], inSync: null, vsOthers: {}, ...over } } /* mdns is the baseline; ndns differs in ttl and in the local db path. */ const WITH_BASELINE: ConfigCompare = { name: 'Blocker', baseline: { app: 'Blocker', text: A, importedFrom: 'mdns', updatedAt: '2026-10-31T08:00:00Z', updatedBy: 'alice' }, overrides: { paths: ['db'], values: { mdns: { db: '/a.db' }, ndns: { db: '/b.db' } } }, servers: [ view('mdns', { inSync: true, vsBaseline: { kind: 'json', changes: [] } }), view('ndns', { text: B, inSync: false, vsBaseline: { kind: 'json', changes: [{ path: 'ttl', type: 'changed', a: 60, b: 30 }, { path: 'db', type: 'changed', a: '/x.db', b: '/b.db' }] }, }), ], } const NO_BASELINE: ConfigCompare = { name: 'Blocker', baseline: null, overrides: { paths: [], values: {} }, servers: [ view('mdns', { vsOthers: { ndns: { kind: 'json', changes: [{ path: 'ttl', type: 'changed', a: 60, b: 30 }] } } }), view('ndns', { text: B }), ], } function mockCompare(data: ConfigCompare) { return vi.spyOn(api, 'getConfigCompare').mockResolvedValue({ kind: 'ok', data: { response: data } } as never) } const ok = (results: api.TargetResult[]) => ({ kind: 'ok', data: { response: { results } } }) as never function mount(canWrite: boolean) { const onChanged = vi.fn() render( {}} onChanged={onChanged} />) return { onChanged } } describe('Compare', () => { it('shows a skeleton while loading, then the badges and the fields that differ', async () => { mockCompare(WITH_BASELINE) mount(false) expect(screen.getByRole('status', { name: 'Loading the configs' })).toBeInTheDocument() expect(await screen.findByText('In sync')).toBeInTheDocument() expect(screen.getByText('Differs')).toBeInTheDocument() const dialog = screen.getByRole('dialog', { name: 'App config: Blocker' }) expect(within(dialog).getByRole('columnheader', { name: 'ndns' })).toBeInTheDocument() expect(within(dialog).getByText('ttl')).toBeInTheDocument() expect(within(dialog).getByText('60')).toBeInTheDocument() expect(within(dialog).getByText('expected 30')).toBeInTheDocument() // The local field is marked. const dbRow = within(dialog).getByText('db').closest('tr') as HTMLElement expect(within(dbRow).getByText('Local')).toBeInTheDocument() }) it('compares the servers to each other when there is no baseline', async () => { mockCompare(NO_BASELINE) mount(false) expect(await screen.findByText('No baseline yet')).toBeInTheDocument() expect(screen.getAllByText('No baseline').length).toBeGreaterThan(0) expect(screen.getByText('ttl')).toBeInTheDocument() expect(screen.getByText('30')).toBeInTheDocument() expect(screen.getByText('60')).toBeInTheDocument() }) it('shows lines for a text config', async () => { mockCompare({ ...WITH_BASELINE, servers: [ view('mdns', { inSync: true, vsBaseline: { kind: 'text', lines: [], truncated: false, changed: 0 } }), view('ndns', { inSync: false, vsBaseline: { kind: 'text', lines: [{ op: 'same', text: 'a = 1' }, { op: 'remove', text: 'b = 2' }, { op: 'add', text: 'b = 3' }], truncated: false, changed: 2 } }), ], }) mount(false) const lines = await screen.findByLabelText('Line differences') expect(within(lines).getByText(/b = 3/)).toHaveAttribute('data-op', 'add') expect(within(lines).getByText(/b = 2/)).toHaveAttribute('data-op', 'remove') }) it('shows an error with Retry', async () => { const spy = vi.spyOn(api, 'getConfigCompare').mockResolvedValueOnce({ kind: 'error', message: 'No server answered.' } as never) mount(false) expect(await screen.findByText(/Could not read the configs/)).toHaveTextContent('No server answered.') spy.mockResolvedValue({ kind: 'ok', data: { response: WITH_BASELINE } } as never) await userEvent.click(screen.getByRole('button', { name: 'Retry' })) expect(await screen.findByText('In sync')).toBeInTheDocument() }) }) describe('role gating', () => { it('gives a reader no editing controls', async () => { mockCompare(WITH_BASELINE) mount(false) await screen.findByText('In sync') expect(screen.queryByRole('button', { name: 'Apply…' })).not.toBeInTheDocument() await userEvent.click(screen.getByRole('tab', { name: 'Baseline' })) expect(screen.getByLabelText('Baseline text')).toHaveAttribute('readonly') expect(screen.queryByRole('button', { name: 'Save baseline' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: /^Import from/ })).not.toBeInTheDocument() await userEvent.click(screen.getByRole('tab', { name: 'Local fields' })) expect(screen.getByLabelText('db on mdns')).toHaveAttribute('readonly') expect(screen.queryByRole('button', { name: 'Save local fields' })).not.toBeInTheDocument() }) it('tells a reader who sees no baseline to ask, without import buttons', async () => { mockCompare(NO_BASELINE) mount(false) await screen.findByText('No baseline yet') expect(screen.queryByRole('button', { name: /^Import from/ })).not.toBeInTheDocument() expect(screen.getByText(/apps administrator/)).toBeInTheDocument() }) it('offers a writer the import from each server when there is no baseline', async () => { const spy = mockCompare(NO_BASELINE) const imp = vi.spyOn(api, 'importBaseline').mockResolvedValue({ kind: 'ok', data: { response: { baseline: WITH_BASELINE.baseline } } } as never) const { onChanged } = mount(true) await userEvent.click(await screen.findByRole('button', { name: 'Import from mdns' })) expect(imp).toHaveBeenCalledWith('t', 'Blocker', 'mdns') await waitFor(() => expect(spy.mock.calls.length).toBeGreaterThan(1)) expect(onChanged).toHaveBeenCalled() expect(await screen.findByText('Baseline imported from mdns.')).toBeInTheDocument() }) }) describe('Baseline and Local fields', () => { it('shows who updated the baseline and a validation error from the hub inline', async () => { mockCompare(WITH_BASELINE) const save = vi.spyOn(api, 'saveBaseline').mockResolvedValue({ kind: 'error', message: 'The baseline is not valid JSON: Unexpected token at position 4' } as never) mount(true) await userEvent.click(await screen.findByRole('tab', { name: 'Baseline' })) expect(screen.getByText(/Imported from mdns\. Updated by alice/)).toBeInTheDocument() const box = screen.getByLabelText('Baseline text') await userEvent.clear(box) await userEvent.type(box, 'x') await userEvent.click(screen.getByRole('button', { name: 'Save baseline' })) expect(save).toHaveBeenCalledWith('t', 'Blocker', 'x') expect(await screen.findByText(/not valid JSON: Unexpected token at position 4/)).toBeInTheDocument() }) it('adds a local field, fills the values from the servers and saves them', async () => { mockCompare({ ...WITH_BASELINE, overrides: { paths: [], values: {} } }) const current = vi.spyOn(api, 'getCurrentLocalValues').mockResolvedValue({ kind: 'ok', data: { response: { values: { mdns: { db: '/a.db' }, ndns: { db: '/b.db' } } } } } as never) const save = vi.spyOn(api, 'saveOverrides').mockResolvedValue({ kind: 'ok', data: { response: { overrides: WITH_BASELINE.overrides } } } as never) mount(true) await userEvent.click(await screen.findByRole('tab', { name: 'Local fields' })) expect(screen.getByText('No local fields yet.')).toBeInTheDocument() await userEvent.type(screen.getByLabelText('New local field'), 'db') await userEvent.click(screen.getByRole('button', { name: 'Add field' })) await userEvent.click(screen.getByRole('button', { name: 'Fill from the current server configs' })) expect(current).toHaveBeenCalledWith('t', 'Blocker', ['db']) expect(await screen.findByDisplayValue('"/b.db"')).toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: 'Save local fields' })) expect(save).toHaveBeenCalledWith('t', 'Blocker', { paths: ['db'], values: { mdns: { db: '/a.db' }, ndns: { db: '/b.db' } } }) }) it('refuses a value that is not JSON, without calling the hub', async () => { mockCompare(WITH_BASELINE) const save = vi.spyOn(api, 'saveOverrides') mount(true) await userEvent.click(await screen.findByRole('tab', { name: 'Local fields' })) const box = screen.getByLabelText('db on mdns') await userEvent.clear(box) await userEvent.type(box, 'no quotes') await userEvent.click(screen.getByRole('button', { name: 'Save local fields' })) expect(await screen.findByText(/db on mdns is not valid JSON/)).toBeInTheDocument() expect(save).not.toHaveBeenCalled() }) }) describe('Apply', () => { it('picks servers, previews, applies with the preview hashes, and retries only what failed', async () => { mockCompare({ ...WITH_BASELINE, servers: [WITH_BASELINE.servers[0], { ...WITH_BASELINE.servers[1], writable: true }] }) const preview = vi.spyOn(api, 'previewConfig').mockResolvedValue({ kind: 'ok', data: { response: { targets: [ { server: 'ndns', ok: true, writable: true, current: B, currentHash: 'hash-ndns', effective: A, unchanged: false, warnings: ['Version 0.9 here, 1.0 on mdns: versions differ, align versions first.'], changes: WITH_BASELINE.servers[1].vsBaseline }, ], }, }, } as never) const apply = vi .spyOn(api, 'applyConfig') .mockResolvedValueOnce(ok([{ server: 'ndns', ok: false, message: 'The config is invalid.' }])) .mockResolvedValueOnce(ok([{ server: 'ndns', ok: true, message: 'Applied and verified.' }])) const { onChanged } = mount(true) await userEvent.click(await screen.findByRole('button', { name: 'Apply…' })) const picker = await screen.findByRole('dialog', { name: 'Apply the baseline of Blocker' }) // Only the server that differs is ticked. expect(within(picker).getByRole('checkbox', { name: 'ndns' })).toBeChecked() expect(within(picker).getByRole('checkbox', { name: 'mdns' })).not.toBeChecked() await userEvent.click(within(picker).getByRole('button', { name: 'Preview' })) expect(preview).toHaveBeenCalledWith('t', 'Blocker', ['ndns']) const review = await screen.findByRole('dialog', { name: 'Apply the baseline of Blocker: review' }) expect(within(review).getByText('Will change')).toBeInTheDocument() expect(within(review).getByText(/align versions first/)).toBeInTheDocument() expect(within(review).getByText('ttl')).toBeInTheDocument() expect(apply).not.toHaveBeenCalled() await userEvent.click(within(review).getByRole('button', { name: 'Apply' })) expect(apply).toHaveBeenLastCalledWith('t', 'Blocker', ['ndns'], { ndns: 'hash-ndns' }) const results = await screen.findByRole('dialog', { name: 'Apply the baseline of Blocker: results' }) expect(within(results).getByText('The config is invalid.')).toBeInTheDocument() await userEvent.click(within(results).getByRole('button', { name: 'Retry failed' })) expect(apply).toHaveBeenCalledTimes(2) await waitFor(() => expect(within(screen.getByRole('dialog')).getByText('Applied and verified.')).toBeInTheDocument()) await userEvent.click(within(screen.getByRole('dialog')).getAllByRole('button', { name: 'Close' }).at(-1) as HTMLElement) await waitFor(() => expect(onChanged).toHaveBeenCalled()) }) it('cannot apply without a baseline, and lists a server without a token as disabled', async () => { mockCompare(NO_BASELINE) mount(true) await screen.findByText('No baseline yet') expect(screen.getByRole('button', { name: 'Apply…' })).toBeDisabled() }) it('disables a server the hub cannot write to in the picker', async () => { mockCompare({ ...WITH_BASELINE, servers: [WITH_BASELINE.servers[0], { ...WITH_BASELINE.servers[1], writable: false }] }) mount(true) await userEvent.click(await screen.findByRole('button', { name: 'Apply…' })) const picker = await screen.findByRole('dialog', { name: 'Apply the baseline of Blocker' }) expect(within(picker).getByRole('checkbox', { name: 'ndns' })).toBeDisabled() expect(within(picker).getByText('No admin token configured on the hub')).toBeInTheDocument() }) it('shows a review failure from the hub', async () => { mockCompare(WITH_BASELINE) vi.spyOn(api, 'previewConfig').mockResolvedValue({ kind: 'error', message: 'This app has no baseline yet.' } as never) mount(true) await userEvent.click(await screen.findByRole('button', { name: 'Apply…' })) await userEvent.click(within(await screen.findByRole('dialog', { name: 'Apply the baseline of Blocker' })).getByRole('button', { name: 'Preview' })) expect(await screen.findByText('This app has no baseline yet.')).toBeInTheDocument() }) }) describe('from the matrix', () => { const DATA: api.AppsMatrixData = { servers: [{ id: 'mdns', ok: true, writable: true }, { id: 'ndns', ok: true, writable: true }], apps: [ { name: 'Blocker', description: '', configs: 'different', diffCount: 2, versions: 'same', servers: { mdns: { installed: true, version: '1.0', configStatus: 'read' }, ndns: { installed: true, version: '1.0', configStatus: 'read' } }, }, ], } it('opens the config view from the row menu for a reader and from the Different badge', async () => { vi.spyOn(api, 'getAppsMatrix').mockResolvedValue({ kind: 'ok', data: { response: DATA } } as never) const compare = mockCompare(WITH_BASELINE) render() await userEvent.click(await screen.findByRole('button', { name: 'Actions: Blocker' })) await userEvent.click(screen.getByRole('menuitem', { name: 'Config…' })) expect(await screen.findByRole('dialog', { name: 'App config: Blocker' })).toBeInTheDocument() expect(compare).toHaveBeenCalledWith('t', 'Blocker') await userEvent.click(within(screen.getByRole('dialog')).getAllByRole('button', { name: 'Close' }).at(-1) as HTMLElement) await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) await userEvent.click(screen.getByRole('button', { name: 'Compare the configs of Blocker' })) expect(await screen.findByRole('dialog', { name: 'App config: Blocker' })).toBeInTheDocument() }) })