| 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<Record<ai.ConsentPurpose, Partial<ai.PurposeMeta>>> = {}): 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(<I18nProvider initial="en"><ConsentControls /></I18nProvider>) |
|
|
| 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() |
| |
| 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() |
| }) |
|
|
| 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)) |
| |
| 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) |
| }) |
|
|
| 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) |
| }) |
| }) |
|
|