import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, waitFor, fireEvent } from '@testing-library/react' import { I18nProvider } from '../i18n' import { ConsentControls } from './ConsentControls' import * as ai from '../api/ai' import { ApiRequestError } from '../api/client' vi.mock('../api/ai') function mkStatus(over: Partial>> = {}): ai.ProfileStatus { const opt = (status: ai.ConsentStatus, version: number): ai.PurposeMeta => ({ purpose_kind: 'optional_consent', withdrawable: true, status, version, updated_at: 1 }) const req: ai.PurposeMeta = { purpose_kind: 'required_processing', withdrawable: false, state: 'active', version: 1, updated_at: 1 } const purposes = { service_essential: { ...req, ...(over.service_essential ?? {}) }, optional_personalization: { ...opt('not_granted', 0), ...(over.optional_personalization ?? {}) }, optional_federation: { ...opt('not_granted', 0), ...(over.optional_federation ?? {}) }, } as ai.ProfileStatus['purposes'] return { subject: 'alice', policy_version: 'c4-policy-v1', record_version: 3, profile_generation: 1, purposes, personalization_enabled: purposes.optional_personalization.status === 'granted', federation_enrolled: purposes.optional_federation.status === 'granted', personalization_without_shared_learning: purposes.optional_personalization.status === 'granted' && purposes.optional_federation.status !== 'granted', affects_payment: false, authoritative: false, storage_consistency: { backend: 'memory', durable_multi_replica: false, note: 'ephemeral' }, label: 'Advisory only', } } const renderCtl = () => render() describe('ConsentControls', () => { beforeEach(() => { vi.spyOn(ai, 'profileStatus').mockResolvedValue(mkStatus()) }) afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() }) it('shows required processing as informational (no toggle, not "granted consent")', async () => { renderCtl() await screen.findByTestId('consent-service_essential') expect(screen.getByTestId('required-state')).toHaveTextContent('Active') expect(screen.getByText('Required')).toBeInTheDocument() expect(screen.queryByTestId('consent-toggle-service_essential')).toBeNull() // required processing must not be labelled as granted consent expect(screen.getByTestId('consent-service_essential').textContent).not.toMatch(/granted/i) }) it('optional purposes default to not-granted (opt-in)', async () => { renderCtl() expect(await screen.findByTestId('consent-state-optional_personalization')).toHaveTextContent('Not granted') expect(screen.getByTestId('consent-state-optional_federation')).toHaveTextContent('Not granted') }) it('granting personalization sends expected_version and reflects the new state', async () => { vi.spyOn(ai, 'setConsent').mockResolvedValue(mkStatus({ optional_personalization: { status: 'granted', version: 1 } })) renderCtl() fireEvent.click(await screen.findByTestId('consent-toggle-optional_personalization')) await waitFor(() => expect(ai.setConsent).toHaveBeenCalledWith('optional_personalization', true, 3)) expect(await screen.findByTestId('consent-state-optional_personalization')).toHaveTextContent('Granted') expect(screen.getByTestId('pwsl')).toBeInTheDocument() // personalization without shared learning }) it('personalization and federation are independent toggles', async () => { vi.spyOn(ai, 'setConsent').mockResolvedValue(mkStatus({ optional_federation: { status: 'granted', version: 1 } })) renderCtl() fireEvent.click(await screen.findByTestId('consent-toggle-optional_federation')) await waitFor(() => expect(ai.setConsent).toHaveBeenCalledWith('optional_federation', true, 3)) // federation granted, personalization still not granted expect(screen.getByTestId('consent-state-optional_personalization')).toHaveTextContent('Not granted') }) it('on a 409 conflict, refetches and shows a conflict notice', async () => { vi.spyOn(ai, 'setConsent').mockRejectedValue(new ApiRequestError({ status: 409, message: 'version_conflict' })) const refetch = vi.spyOn(ai, 'profileStatus') renderCtl() fireEvent.click(await screen.findByTestId('consent-toggle-optional_personalization')) await waitFor(() => expect(screen.getByText(/changed elsewhere/i)).toBeInTheDocument()) expect(refetch.mock.calls.length).toBeGreaterThanOrEqual(2) // initial + refetch after conflict }) it('does not delete when the confirm dialog is cancelled', async () => { const confirmMock = vi.fn(() => false) vi.stubGlobal('confirm', confirmMock) const del = vi.spyOn(ai, 'deleteProfile') renderCtl() fireEvent.click(await screen.findByTestId('consent-delete')) await waitFor(() => expect(confirmMock).toHaveBeenCalled()) expect(del).not.toHaveBeenCalled() }) it('deletes eligible data and shows deleted/retained categories', async () => { vi.stubGlobal('confirm', vi.fn(() => true)) vi.spyOn(ai, 'deleteProfile').mockResolvedValue({ subject: 'alice', profile: 'deleted', deleted_now: true, deleted_categories: ['derived_behavioural_features', 'optional_personalization_state'], retained_categories: ['required_security_processing_records', 'minimal_deletion_tombstone'], retention_reason: 'required', retired_generation: 1, audit_reference: 'abc123', erasure_type: 'eligible_scope_deletion', affects_payment: false, note: 'eligible-scope', }) renderCtl() fireEvent.click(await screen.findByTestId('consent-delete')) await waitFor(() => expect(ai.deleteProfile).toHaveBeenCalled()) const receipt = await screen.findByTestId('deletion-receipt') expect(receipt).toHaveTextContent(/derived_behavioural_features/) expect(receipt).toHaveTextContent(/required_security_processing_records/) expect(receipt).toHaveTextContent(/not perfect erasure or machine unlearning/i) }) })