/** * Regression tests for Cell Identity save behaviour in Settings.jsx. * * The Settings page uses a global Accept/Discard flow (DraftConfig). * Changes accumulate in React state; nothing is sent to the API until * the user presses Accept (which calls flushAll → identity flusher → * saveIdentity). Auto-save only runs for non-DDNS-registration changes. * * Covers: * - Changing cell_name marks identity dirty (Accept/Discard banner appears) * - Auto-save does NOT fire for pic_ngo cell_name changes (requires Accept) * - Auto-save DOES fire for ip_range-only changes in pic_ngo mode * - Availability check is NOT sent on page load when name is unchanged * - Availability check IS sent after the user types a new cell name * - saveIdentity (called by the Accept flusher) saves the new cell name */ import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; // ── API mocks ───────────────────────────────────────────────────────────────── const mockGetConfig = vi.fn(); const mockUpdateConfig = vi.fn(); const mockListBackups = vi.fn(); const mockGetStatus = vi.fn(); const mockCheckName = vi.fn(); const mockGetCertStatus = vi.fn(); const mockSetDirty = vi.fn(); vi.mock('../services/api', () => ({ cellAPI: { getConfig: (...a) => mockGetConfig(...a), updateConfig: (...a) => mockUpdateConfig(...a), listBackups: (...a) => mockListBackups(...a), }, ddnsAPI: { getStatus: (...a) => mockGetStatus(...a), checkName: (...a) => mockCheckName(...a), }, caddyAPI: { getCertStatus: (...a) => mockGetCertStatus(...a), }, })); vi.mock('../contexts/ConfigContext', () => ({ useConfig: () => ({ domain: 'cell', cell_name: 'pic1', refresh: vi.fn() }), })); vi.mock('../contexts/DraftConfigContext', () => ({ useDraftConfig: () => ({ registerFlusher: vi.fn(() => vi.fn()), setDirty: (...a) => mockSetDirty(...a), hasDirty: vi.fn(() => false), isDirty: false, flushAll: vi.fn(), clearAllDirty: vi.fn(), }), })); vi.mock('../utils/serviceConfig', () => ({ PORT_CONFLICT_FIELDS: {}, detectPortConflicts: vi.fn(() => ({})), validateServiceConfig: vi.fn(() => ({})), SERVICE_DEFS: [], })); // ── helpers ─────────────────────────────────────────────────────────────────── function makeCfg(overrides = {}) { return { cell_name: 'pic1', domain: 'cell', ip_range: '172.20.0.0/16', domain_mode: 'pic_ngo', domain_name: 'pic1.pic.ngo', effective_domain: 'pic1.pic.ngo', ddns: { has_token: true }, service_configs: {}, installed_services: [], ...overrides, }; } function defaultMocks() { mockGetConfig.mockResolvedValue({ data: makeCfg() }); mockUpdateConfig.mockResolvedValue({ data: { warnings: [] } }); mockListBackups.mockResolvedValue({ data: [] }); mockGetStatus.mockResolvedValue({ data: { registered: true, public_ip: '1.2.3.4', last_ip: '1.2.3.4' } }); mockGetCertStatus.mockResolvedValue({ data: null }); mockCheckName.mockResolvedValue({ data: { available: true } }); } async function renderSettings() { const { default: Settings } = await import('../pages/Settings.jsx'); let result; await act(async () => { result = render(); await Promise.resolve(); await Promise.resolve(); }); await waitFor(() => screen.getByDisplayValue('pic1')); return result; } // ── tests ───────────────────────────────────────────────────────────────────── describe('Cell Identity — dirty state triggers Accept/Discard banner', () => { beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); vi.clearAllMocks(); defaultMocks(); }); afterEach(async () => { await act(async () => { vi.runAllTimers(); }); vi.useRealTimers(); vi.resetModules(); }); it('changing cell_name calls draftConfig.setDirty("identity", true)', async () => { await renderSettings(); mockSetDirty.mockClear(); fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic2' } }); expect(mockSetDirty).toHaveBeenCalledWith('identity', true); }); it('changing ip_range calls draftConfig.setDirty("identity", true)', async () => { await renderSettings(); mockSetDirty.mockClear(); fireEvent.change(screen.getByDisplayValue('172.20.0.0/16'), { target: { value: '10.0.0.0/8' } }); expect(mockSetDirty).toHaveBeenCalledWith('identity', true); }); }); describe('Cell Identity — auto-save behaviour', () => { beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); vi.clearAllMocks(); defaultMocks(); }); afterEach(async () => { await act(async () => { vi.runAllTimers(); }); vi.useRealTimers(); vi.resetModules(); }); it('auto-save does NOT fire for pic_ngo cell_name changes — Accept is required', async () => { mockCheckName.mockResolvedValue({ data: { available: true } }); await renderSettings(); fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic2' } }); // Advance well past both debounces (availability 900 ms + auto-save 800 ms) await act(async () => { vi.advanceTimersByTime(2500); }); await act(async () => { await Promise.resolve(); }); expect(mockUpdateConfig).not.toHaveBeenCalled(); }); it('auto-save fires after 800 ms when only ip_range changes (name unchanged)', async () => { mockGetConfig .mockResolvedValueOnce({ data: makeCfg() }) .mockResolvedValue({ data: makeCfg({ ip_range: '10.0.0.0/8' }) }); await renderSettings(); fireEvent.change(screen.getByDisplayValue('172.20.0.0/16'), { target: { value: '10.0.0.0/8' } }); await act(async () => { vi.advanceTimersByTime(500); }); expect(mockUpdateConfig).not.toHaveBeenCalled(); // not yet await act(async () => { vi.advanceTimersByTime(400); }); await act(async () => { await Promise.resolve(); }); expect(mockUpdateConfig).toHaveBeenCalledOnce(); expect(mockUpdateConfig.mock.calls[0][0]).toMatchObject({ ip_range: '10.0.0.0/8' }); }); }); describe('Cell Identity — availability check', () => { beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); vi.clearAllMocks(); defaultMocks(); }); afterEach(async () => { await act(async () => { vi.runAllTimers(); }); vi.useRealTimers(); vi.resetModules(); }); it('does NOT check availability on page load (name unchanged)', async () => { await renderSettings(); // Advance past the 900 ms debounce await act(async () => { vi.advanceTimersByTime(1200); }); expect(mockCheckName).not.toHaveBeenCalled(); }); it('checks availability after user types a new cell name', async () => { await renderSettings(); fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic2' } }); await act(async () => { vi.advanceTimersByTime(950); }); await act(async () => { await Promise.resolve(); }); expect(mockCheckName).toHaveBeenCalledWith('pic2'); }); }); describe('Cell Identity — Accept path (saveIdentity called by flusher)', () => { beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); vi.clearAllMocks(); defaultMocks(); }); afterEach(async () => { await act(async () => { vi.runAllTimers(); }); vi.useRealTimers(); vi.resetModules(); }); it('saveIdentity saves new cell_name when called directly (simulates Accept press)', async () => { mockCheckName.mockResolvedValue({ data: { available: true } }); mockGetConfig .mockResolvedValueOnce({ data: makeCfg() }) .mockResolvedValue({ data: makeCfg({ cell_name: 'pic2', domain_name: 'pic2.pic.ngo' }) }); const { default: Settings } = await import('../pages/Settings.jsx'); // Capture the registered flusher so we can call it like Accept would let identityFlusher; const mockRegisterFlusher = vi.fn((key, fn) => { if (key === 'identity') identityFlusher = fn; return vi.fn(); }); vi.mocked(await import('../contexts/DraftConfigContext')).useDraftConfig = () => ({ registerFlusher: mockRegisterFlusher, setDirty: mockSetDirty, hasDirty: vi.fn(() => true), flushAll: vi.fn(), }); await act(async () => { render(); await Promise.resolve(); await Promise.resolve(); }); await waitFor(() => screen.getByDisplayValue('pic1')); fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic2' } }); // Confirm availability await act(async () => { vi.advanceTimersByTime(950); }); await act(async () => { await Promise.resolve(); }); // Simulate Accept press (calls the flusher directly) if (identityFlusher) { await act(async () => { await identityFlusher(); }); } expect(mockUpdateConfig).toHaveBeenCalledOnce(); expect(mockUpdateConfig.mock.calls[0][0]).toMatchObject({ cell_name: 'pic2' }); }); }); describe('Cell Identity — DraftConfig dirty state is set synchronously on change', () => { /** * Verifies that draftConfig.setDirty is called in the same synchronous * event handler as the identity state change, not in a deferred effect. * This is what allows the banner to appear immediately (the isDirty reactive * state in DraftConfigContext re-renders App.jsx without waiting for a poll). */ beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); vi.clearAllMocks(); defaultMocks(); }); afterEach(async () => { await act(async () => { vi.runAllTimers(); }); vi.useRealTimers(); vi.resetModules(); }); it('setDirty("identity", true) is called before any timer fires after cell_name change', async () => { await renderSettings(); mockSetDirty.mockClear(); fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic2' } }); // setDirty must be called synchronously within the event, not in a timer expect(mockSetDirty).toHaveBeenCalledWith('identity', true); expect(mockSetDirty).toHaveBeenCalledTimes(1); }); it('setDirty("identity", true) is called before any timer fires after ip_range change', async () => { await renderSettings(); mockSetDirty.mockClear(); fireEvent.change(screen.getByDisplayValue('172.20.0.0/16'), { target: { value: '10.0.0.0/8' } }); expect(mockSetDirty).toHaveBeenCalledWith('identity', true); expect(mockSetDirty).toHaveBeenCalledTimes(1); }); it('setDirty("identity", false) is called after saveIdentity completes', async () => { mockGetConfig .mockResolvedValueOnce({ data: makeCfg() }) .mockResolvedValue({ data: makeCfg({ ip_range: '10.0.0.0/8' }) }); await renderSettings(); mockSetDirty.mockClear(); fireEvent.change(screen.getByDisplayValue('172.20.0.0/16'), { target: { value: '10.0.0.0/8' } }); // auto-save fires after 800 ms await act(async () => { vi.advanceTimersByTime(900); }); await act(async () => { await Promise.resolve(); }); const calls = mockSetDirty.mock.calls; expect(calls.some(([k, v]) => k === 'identity' && v === true)).toBe(true); expect(calls.some(([k, v]) => k === 'identity' && v === false)).toBe(true); }); });