/** * Render and interaction tests for the per-metric estimated-resolution-date * line in the asset sidebar (ComplianceDetailPanel.js / MetricRow). * * Feature: compliance-metric-estimated-resolution-date * Spec: .kiro/specs/compliance-metric-estimated-resolution-date * * Covers tasks 3.2 (placement, labels, placeholders), 3.3 (resolved * suppression, read-only structure, role-independence, existing editor * preserved), and 3.4 (existing save round-trip). * * Requirements covered: 1.1, 1.2, 1.4, 1.5, 1.6, 2.1, 3.1, 3.4, 4.1, 4.2, * 4.3, 4.4, 4.5, 5.1, 5.2, 5.3, 5.4. * * The component fetches the asset detail via GET * `${API_BASE}/compliance/items/:hostname` (credentials included) and saves * metadata via PATCH `${API_BASE}/compliance/items/:hostname/metadata`, so * global.fetch is mocked to serve the asset detail JSON and PATCH responses. */ import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import ComplianceDetailPanel from '../ComplianceDetailPanel'; import { RESOLUTION_DATE_LABEL, NO_DATE_PLACEHOLDER, INVALID_DATE_PLACEHOLDER, } from '../../../utils/resolutionDate'; const HOSTNAME = 'host-1.example.com'; // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- function makeMetric(overrides = {}) { return { metric_id: '2.3.6i', category: 'Vulnerability Management', status: 'active', metric_desc: 'Outbound encryption required on all endpoints', resolution_date: null, remediation_plan: null, seen_count: 1, first_seen: '2025-01-01', resolved_on: null, extra: {}, ...overrides, }; } function makeDetail(metrics, overrides = {}) { return { hostname: HOSTNAME, ip_address: '10.0.0.1', device_type: 'server', team: 'STEAM', metrics, history: [], notes: [], ...overrides, }; } const jsonResponse = (body, ok = true) => ({ ok, json: async () => body }); /** * Mock global.fetch. GET requests to the detail endpoint are served from a * queue of detail objects (the last one is reused once the queue drains, so * the initial load and any re-fetch can each return a distinct snapshot). * PATCH requests to the metadata endpoint return the configured response. */ function mockFetch({ details, patchOk = true, patchBody = {} }) { const queue = [...details]; let last = details[details.length - 1]; global.fetch = jest.fn((url, options = {}) => { const method = (options.method || 'GET').toUpperCase(); if (method === 'PATCH') { return Promise.resolve(jsonResponse(patchBody, patchOk)); } // GET detail (fetchDetail) const next = queue.length > 0 ? queue.shift() : last; last = next; return Promise.resolve(jsonResponse(next)); }); } // --------------------------------------------------------------------------- // DOM query helpers // --------------------------------------------------------------------------- // The estimated-resolution date line renders as: //
// (Calendar icon) // {RESOLUTION_DATE_LABEL} // {value | placeholder} //
// We locate each line by its label span, which contains exactly the label text. function getDateLineLabels(container) { return Array.from(container.querySelectorAll('span')).filter( (s) => s.textContent === RESOLUTION_DATE_LABEL ); } function getDateLineValueTexts(container) { return getDateLineLabels(container).map((label) => label.nextElementSibling ? label.nextElementSibling.textContent : null ); } async function renderAndLoad(detail, props = {}) { const utils = render( {}} onNoteAdded={() => {}} {...props} /> ); // Wait for the async detail load to complete (a metric description renders). await screen.findByText(detail.metrics[0].metric_desc); return utils; } beforeEach(() => { process.env.REACT_APP_API_BASE = 'http://localhost:3001/api'; }); afterEach(() => { jest.restoreAllMocks(); }); // =========================================================================== // Task 3.2 — Render tests for placement, labels, and placeholders // =========================================================================== describe('Task 3.2 — placement, labels, and placeholders', () => { test('placement (Req 1.2): estimated-resolution element precedes the metric description', async () => { const detail = makeDetail([ makeMetric({ resolution_date: '2026-07-01' }), ]); mockFetch({ details: [detail] }); const { container } = await renderAndLoad(detail); const labelEl = screen.getByText(RESOLUTION_DATE_LABEL); const descEl = screen.getByText(detail.metrics[0].metric_desc); // descEl must come AFTER labelEl in document order. expect( labelEl.compareDocumentPosition(descEl) & Node.DOCUMENT_POSITION_FOLLOWING ).toBeTruthy(); // Sanity: there is exactly one date line for the single active metric. expect(getDateLineLabels(container)).toHaveLength(1); }); test('label presence (Req 1.5): RESOLUTION_DATE_LABEL appears adjacent to the value', async () => { const detail = makeDetail([ makeMetric({ resolution_date: '2026-07-01' }), ]); mockFetch({ details: [detail] }); const { container } = await renderAndLoad(detail); const labels = getDateLineLabels(container); expect(labels).toHaveLength(1); // The value span is the immediate next sibling of the label span. expect(labels[0].nextElementSibling).not.toBeNull(); expect(labels[0].nextElementSibling.textContent).toBe('2026-07-01'); }); test('set value (Req 1.1, 1.4): an active row with 2026-07-01 renders 2026-07-01', async () => { const detail = makeDetail([ makeMetric({ resolution_date: '2026-07-01' }), ]); mockFetch({ details: [detail] }); const { container } = await renderAndLoad(detail); expect(getDateLineValueTexts(container)).toEqual(['2026-07-01']); }); test('no-date placeholder (Req 2.1, 4.5): null/empty/whitespace render NO_DATE_PLACEHOLDER and keep the description', async () => { const metrics = [ makeMetric({ metric_id: '2.3.6i', metric_desc: 'Metric with null resolution date', resolution_date: null, }), makeMetric({ metric_id: '2.3.8i', metric_desc: 'Metric with empty resolution date', resolution_date: '', }), makeMetric({ metric_id: 'Vulns_Aging', metric_desc: 'Metric with whitespace resolution date', resolution_date: ' ', }), ]; const detail = makeDetail(metrics); mockFetch({ details: [detail] }); const { container } = await renderAndLoad(detail); // All three active rows show the no-date placeholder. expect(getDateLineValueTexts(container)).toEqual([ NO_DATE_PLACEHOLDER, NO_DATE_PLACEHOLDER, NO_DATE_PLACEHOLDER, ]); // Each metric's description still renders. for (const m of metrics) { expect(screen.getByText(m.metric_desc)).toBeInTheDocument(); } }); test('invalid placeholder (Req 1.6): malformed date renders INVALID_DATE_PLACEHOLDER and keeps the description', async () => { const detail = makeDetail([ makeMetric({ metric_desc: 'Metric with a malformed resolution date', resolution_date: '2026-13-99', }), ]); mockFetch({ details: [detail] }); const { container } = await renderAndLoad(detail); expect(getDateLineValueTexts(container)).toEqual([INVALID_DATE_PLACEHOLDER]); expect( screen.getByText('Metric with a malformed resolution date') ).toBeInTheDocument(); }); }); // =========================================================================== // Task 3.3 — resolved suppression, read-only structure, role-independence // =========================================================================== describe('Task 3.3 — suppression, read-only structure, role-independence', () => { test('resolved suppression (Req 3.1, 3.4): a resolved metric with a populated date renders no estimated-resolution line', async () => { const detail = makeDetail([ makeMetric({ metric_id: '2.3.6i', status: 'resolved', metric_desc: 'Resolved metric with a populated resolution date', resolution_date: '2026-07-01', resolved_on: '2026-06-15', }), ]); mockFetch({ details: [detail] }); const { container } = await renderAndLoad(detail); // No estimated-resolution line anywhere for a resolved-only asset. expect(getDateLineLabels(container)).toHaveLength(0); expect(screen.queryByText(RESOLUTION_DATE_LABEL)).toBeNull(); }); test('mixed list (Req 3.4): the estimated-resolution line appears only within active rows', async () => { const metrics = [ makeMetric({ metric_id: '2.3.6i', status: 'active', metric_desc: 'Active metric one', resolution_date: '2026-07-01', }), makeMetric({ metric_id: '2.3.8i', status: 'active', metric_desc: 'Active metric two', resolution_date: '2026-09-30', }), makeMetric({ metric_id: 'Vulns_Aging', status: 'resolved', metric_desc: 'Resolved metric', resolution_date: '2026-01-15', resolved_on: '2026-01-10', }), ]; const detail = makeDetail(metrics); mockFetch({ details: [detail] }); const { container } = await renderAndLoad(detail); // Exactly two date lines (one per active metric), each its own value. expect(getDateLineValueTexts(container)).toEqual([ '2026-07-01', '2026-09-30', ]); }); test('read-only structure (Req 5.3): the date-line subtree has no input, button, or anchor', async () => { const detail = makeDetail([ makeMetric({ resolution_date: '2026-07-01' }), ]); mockFetch({ details: [detail] }); const { container } = await renderAndLoad(detail); const labels = getDateLineLabels(container); expect(labels).toHaveLength(1); const dateLineSubtree = labels[0].parentElement; // Plain text only — no interactive controls capable of modifying the field. expect( dateLineSubtree.querySelectorAll('input, button, a, select, textarea') ).toHaveLength(0); }); test('role-independence (Req 5.1, 5.2, 5.4): the date line is plain, non-interactive text', async () => { // ComplianceDetailPanel does not consume a role/auth context for the // estimated-resolution subtree: the line is derived purely from // metric.resolution_date and rendered as static spans. The display is // therefore role-independent by construction — a viewer, editor, and // admin all receive byte-for-byte identical output and no editing control // is introduced. We assert the plain-text value and the absence of any // interactive element in the subtree. const detail = makeDetail([ makeMetric({ resolution_date: '2026-07-01' }), ]); mockFetch({ details: [detail] }); const { container } = await renderAndLoad(detail); expect(getDateLineValueTexts(container)).toEqual(['2026-07-01']); const dateLineSubtree = getDateLineLabels(container)[0].parentElement; expect( dateLineSubtree.querySelectorAll('input, button, a, select, textarea') ).toHaveLength(0); }); test('existing editor preserved (Req 4.1): the editable Resolution Date input[type=date] still renders', async () => { const detail = makeDetail([ makeMetric({ resolution_date: '2026-07-01' }), ]); mockFetch({ details: [detail] }); const { container } = await renderAndLoad(detail); expect(container.querySelector('input[type="date"]')).not.toBeNull(); }); }); // =========================================================================== // Task 3.4 — Interaction tests for the existing save round-trip // =========================================================================== describe('Task 3.4 — save round-trip', () => { test('successful save (Req 4.2, 4.3): displayed estimated-resolution updates to the new date after save + re-fetch', async () => { const before = makeDetail([ makeMetric({ resolution_date: '2026-07-01' }), ]); const after = makeDetail([ makeMetric({ resolution_date: '2026-08-15' }), ]); // GET (initial) -> before, PATCH -> ok, GET (re-fetch) -> after mockFetch({ details: [before, after], patchOk: true }); const { container } = await renderAndLoad(before); // Pre-condition: original date displayed. expect(getDateLineValueTexts(container)).toEqual(['2026-07-01']); // Editor updates the editable Resolution Date field and saves. const dateInput = container.querySelector('input[type="date"]'); fireEvent.change(dateInput, { target: { value: '2026-08-15' } }); fireEvent.click(screen.getByRole('button', { name: /save/i })); // The displayed estimated-resolution value updates from the re-fetch. await waitFor(() => { expect(getDateLineValueTexts(container)).toEqual(['2026-08-15']); }); }); test('successful clear (Req 4.5): clearing the field renders NO_DATE_PLACEHOLDER after save + re-fetch', async () => { const before = makeDetail([ makeMetric({ resolution_date: '2026-07-01' }), ]); const after = makeDetail([makeMetric({ resolution_date: '' })]); mockFetch({ details: [before, after], patchOk: true }); const { container } = await renderAndLoad(before); expect(getDateLineValueTexts(container)).toEqual(['2026-07-01']); const dateInput = container.querySelector('input[type="date"]'); fireEvent.change(dateInput, { target: { value: '' } }); fireEvent.click(screen.getByRole('button', { name: /save/i })); await waitFor(() => { expect(getDateLineValueTexts(container)).toEqual([NO_DATE_PLACEHOLDER]); }); }); test('failed save (Req 4.4): previously displayed date is retained and an error is shown', async () => { const before = makeDetail([ makeMetric({ resolution_date: '2026-07-01' }), ]); // PATCH fails; fetchDetail is never re-issued, so the queue only needs the // initial detail. mockFetch({ details: [before], patchOk: false, patchBody: { error: 'Save failed' }, }); const { container } = await renderAndLoad(before); expect(getDateLineValueTexts(container)).toEqual(['2026-07-01']); const dateInput = container.querySelector('input[type="date"]'); fireEvent.change(dateInput, { target: { value: '2099-01-01' } }); fireEvent.click(screen.getByRole('button', { name: /save/i })); // Error indication appears. await screen.findByText('Save failed'); // The previously displayed estimated-resolution date is retained. expect(getDateLineValueTexts(container)).toEqual(['2026-07-01']); }); });