amanpay / web /src /components /ConsentControls.test.tsx
MHamdan's picture
CI deploy d3d1cf1
0f4d9ea verified
Raw
History Blame Contribute Delete
6.11 kB
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()
// 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)
})
})