@redacto.io/consent-sdk-react 9.2.0-beta.1 → 9.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/privacy-center.d.mts +6 -10
  3. package/dist/privacy-center.d.ts +6 -10
  4. package/dist/privacy-center.js +208 -850
  5. package/dist/privacy-center.mjs +208 -850
  6. package/package.json +1 -1
  7. package/src/RedactoPrivacyCenter/components/DataRequest/ProductSelectionSection.tsx +4 -4
  8. package/src/RedactoPrivacyCenter/components/Form.tsx +214 -866
  9. package/src/RedactoPrivacyCenter/components/RequestSubmit.tsx +44 -219
  10. package/src/RedactoPrivacyCenter/components/types.ts +14 -20
  11. package/src/RedactoPrivacyCenter/lib/api.ts +23 -58
  12. package/src/RedactoPrivacyCenter/lib/types.ts +0 -31
  13. package/src/RedactoPrivacyCenter/locales/as/translation.json +2 -2
  14. package/src/RedactoPrivacyCenter/locales/bho/translation.json +2 -2
  15. package/src/RedactoPrivacyCenter/locales/bn/translation.json +2 -2
  16. package/src/RedactoPrivacyCenter/locales/brx/translation.json +2 -2
  17. package/src/RedactoPrivacyCenter/locales/doi/translation.json +2 -2
  18. package/src/RedactoPrivacyCenter/locales/en/translation.json +2 -2
  19. package/src/RedactoPrivacyCenter/locales/gom/translation.json +2 -2
  20. package/src/RedactoPrivacyCenter/locales/gu/translation.json +2 -2
  21. package/src/RedactoPrivacyCenter/locales/hi/translation.json +2 -2
  22. package/src/RedactoPrivacyCenter/locales/kn/translation.json +2 -2
  23. package/src/RedactoPrivacyCenter/locales/ks/translation.json +2 -2
  24. package/src/RedactoPrivacyCenter/locales/mai/translation.json +2 -2
  25. package/src/RedactoPrivacyCenter/locales/ml/translation.json +2 -2
  26. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +2 -2
  27. package/src/RedactoPrivacyCenter/locales/mr/translation.json +2 -2
  28. package/src/RedactoPrivacyCenter/locales/ne/translation.json +2 -2
  29. package/src/RedactoPrivacyCenter/locales/or/translation.json +2 -2
  30. package/src/RedactoPrivacyCenter/locales/pa/translation.json +2 -2
  31. package/src/RedactoPrivacyCenter/locales/sa/translation.json +2 -2
  32. package/src/RedactoPrivacyCenter/locales/sat/translation.json +2 -2
  33. package/src/RedactoPrivacyCenter/locales/sd/translation.json +2 -2
  34. package/src/RedactoPrivacyCenter/locales/ta/translation.json +2 -2
  35. package/src/RedactoPrivacyCenter/locales/te/translation.json +2 -2
  36. package/src/RedactoPrivacyCenter/locales/ur/translation.json +2 -2
  37. package/tests/Form.test.tsx +123 -149
  38. package/tests/ProductSelectionSection.test.tsx +39 -4
  39. package/tests/RequestSubmit.test.tsx +27 -187
@@ -314,8 +314,8 @@
314
314
  "selectProductsForRequestGeneric": "Select one or more products to continue",
315
315
  "yourData": "Your data",
316
316
  "actingOnBehalfOf": "Acting on behalf of {{nominator}}",
317
- "activePurposes_one": "{{count}} active purpose",
318
- "activePurposes_other": "{{count}} active purposes",
317
+ "activePurposes_one": "{{count}} उद्देश",
318
+ "activePurposes_other": "{{count}} उद्देश",
319
319
  "autoSelected": "Auto-selected",
320
320
  "noProductsAvailable": "No products available",
321
321
  "noProductsAvailableDescription": "You have no consents to manage yet.",
@@ -314,8 +314,8 @@
314
314
  "selectProductsForRequestGeneric": "Select one or more products to continue",
315
315
  "yourData": "Your data",
316
316
  "actingOnBehalfOf": "Acting on behalf of {{nominator}}",
317
- "activePurposes_one": "{{count}} active purpose",
318
- "activePurposes_other": "{{count}} active purposes",
317
+ "activePurposes_one": "{{count}} उद्देश्य",
318
+ "activePurposes_other": "{{count}} उद्देश्य",
319
319
  "autoSelected": "Auto-selected",
320
320
  "noProductsAvailable": "No products available",
321
321
  "noProductsAvailableDescription": "You have no consents to manage yet.",
@@ -314,8 +314,8 @@
314
314
  "selectProductsForRequestGeneric": "Select one or more products to continue",
315
315
  "yourData": "Your data",
316
316
  "actingOnBehalfOf": "Acting on behalf of {{nominator}}",
317
- "activePurposes_one": "{{count}} active purpose",
318
- "activePurposes_other": "{{count}} active purposes",
317
+ "activePurposes_one": "{{count}} ଉଦ୍ଦେଶ୍ୟ",
318
+ "activePurposes_other": "{{count}} ଉଦ୍ଦେଶ୍ୟଗୁଡ଼ିକ",
319
319
  "autoSelected": "Auto-selected",
320
320
  "noProductsAvailable": "No products available",
321
321
  "noProductsAvailableDescription": "You have no consents to manage yet.",
@@ -314,8 +314,8 @@
314
314
  "selectProductsForRequestGeneric": "Select one or more products to continue",
315
315
  "yourData": "Your data",
316
316
  "actingOnBehalfOf": "Acting on behalf of {{nominator}}",
317
- "activePurposes_one": "{{count}} active purpose",
318
- "activePurposes_other": "{{count}} active purposes",
317
+ "activePurposes_one": "{{count}} ਮਕਸਦ",
318
+ "activePurposes_other": "{{count}} ਮਕਸਦ",
319
319
  "autoSelected": "Auto-selected",
320
320
  "noProductsAvailable": "No products available",
321
321
  "noProductsAvailableDescription": "You have no consents to manage yet.",
@@ -314,8 +314,8 @@
314
314
  "selectProductsForRequestGeneric": "Select one or more products to continue",
315
315
  "yourData": "Your data",
316
316
  "actingOnBehalfOf": "Acting on behalf of {{nominator}}",
317
- "activePurposes_one": "{{count}} active purpose",
318
- "activePurposes_other": "{{count}} active purposes",
317
+ "activePurposes_one": "{{count}} प्रयोजनम्",
318
+ "activePurposes_other": "{{count}} प्रयोजनानि",
319
319
  "autoSelected": "Auto-selected",
320
320
  "noProductsAvailable": "No products available",
321
321
  "noProductsAvailableDescription": "You have no consents to manage yet.",
@@ -314,8 +314,8 @@
314
314
  "selectProductsForRequestGeneric": "Select one or more products to continue",
315
315
  "yourData": "Your data",
316
316
  "actingOnBehalfOf": "Acting on behalf of {{nominator}}",
317
- "activePurposes_one": "{{count}} active purpose",
318
- "activePurposes_other": "{{count}} active purposes",
317
+ "activePurposes_one": "{{count}} ᱯᱷᱮᱰᱟᱛ",
318
+ "activePurposes_other": "{{count}} ᱯᱷᱮᱰᱟᱛ",
319
319
  "autoSelected": "Auto-selected",
320
320
  "noProductsAvailable": "No products available",
321
321
  "noProductsAvailableDescription": "You have no consents to manage yet.",
@@ -314,8 +314,8 @@
314
314
  "selectProductsForRequestGeneric": "Select one or more products to continue",
315
315
  "yourData": "Your data",
316
316
  "actingOnBehalfOf": "Acting on behalf of {{nominator}}",
317
- "activePurposes_one": "{{count}} active purpose",
318
- "activePurposes_other": "{{count}} active purposes",
317
+ "activePurposes_one": "{{count}} مقصد",
318
+ "activePurposes_other": "{{count}} مقصد",
319
319
  "autoSelected": "Auto-selected",
320
320
  "noProductsAvailable": "No products available",
321
321
  "noProductsAvailableDescription": "You have no consents to manage yet.",
@@ -314,8 +314,8 @@
314
314
  "selectProductsForRequestGeneric": "Select one or more products to continue",
315
315
  "yourData": "Your data",
316
316
  "actingOnBehalfOf": "Acting on behalf of {{nominator}}",
317
- "activePurposes_one": "{{count}} active purpose",
318
- "activePurposes_other": "{{count}} active purposes",
317
+ "activePurposes_one": "{{count}} நோக்கம்",
318
+ "activePurposes_other": "{{count}} நோக்கங்கள்",
319
319
  "autoSelected": "Auto-selected",
320
320
  "noProductsAvailable": "No products available",
321
321
  "noProductsAvailableDescription": "You have no consents to manage yet.",
@@ -314,8 +314,8 @@
314
314
  "selectProductsForRequestGeneric": "Select one or more products to continue",
315
315
  "yourData": "Your data",
316
316
  "actingOnBehalfOf": "Acting on behalf of {{nominator}}",
317
- "activePurposes_one": "{{count}} active purpose",
318
- "activePurposes_other": "{{count}} active purposes",
317
+ "activePurposes_one": "{{count}} ఉద్దేశ్యం",
318
+ "activePurposes_other": "{{count}} ఉద్దేశ్యాలు",
319
319
  "autoSelected": "Auto-selected",
320
320
  "noProductsAvailable": "No products available",
321
321
  "noProductsAvailableDescription": "You have no consents to manage yet.",
@@ -314,8 +314,8 @@
314
314
  "selectProductsForRequestGeneric": "Select one or more products to continue",
315
315
  "yourData": "Your data",
316
316
  "actingOnBehalfOf": "Acting on behalf of {{nominator}}",
317
- "activePurposes_one": "{{count}} active purpose",
318
- "activePurposes_other": "{{count}} active purposes",
317
+ "activePurposes_one": "{{count}} مقصد",
318
+ "activePurposes_other": "{{count}} مقاصد",
319
319
  "autoSelected": "Auto-selected",
320
320
  "noProductsAvailable": "No products available",
321
321
  "noProductsAvailableDescription": "You have no consents to manage yet.",
@@ -38,8 +38,8 @@ vi.mock('react-i18next', () => {
38
38
  nominationChipSuffix: '· {{nominator}}',
39
39
  yourData: 'Your data',
40
40
  actingOnBehalfOf: 'Acting on behalf of {{nominator}}',
41
- activePurposes_one: '{{count}} active purpose',
42
- activePurposes_other: '{{count}} active purposes',
41
+ activePurposes_one: '{{count}} purpose',
42
+ activePurposes_other: '{{count}} purposes',
43
43
  autoSelected: 'Auto-selected',
44
44
  noProductsAvailable: 'No products available',
45
45
  noProductsAvailableDescription: 'You have no consents to manage yet.',
@@ -95,16 +95,11 @@ vi.mock('../src/RedactoPrivacyCenter/lib/i18n', () => ({
95
95
  }));
96
96
 
97
97
  // ─── API stubs ───────────────────────────────────────────────────────────────
98
- // `createCaseRequest` (legacy single-case path) and `createCaseRequestUnsafe`
99
- // (batch path) are both shipped at runtime. We route them through one
100
- // shared mock so existing tests can keep using `createCaseRequest.mock...`
101
- // regardless of which code path the Form picks. Tests that need to assert
102
- // throw-vs-null semantics override the relevant mock individually.
98
+ // 1-form -> 1-case: only `createCaseRequest` is consumed by Form.tsx now.
103
99
  const createCaseRequest = vi.fn();
104
100
  const getFormData = vi.fn();
105
101
  vi.mock('../src/RedactoPrivacyCenter/lib/api', () => ({
106
102
  createCaseRequest: (...args: unknown[]) => createCaseRequest(...args),
107
- createCaseRequestUnsafe: (...args: unknown[]) => createCaseRequest(...args),
108
103
  getFormData: (...args: unknown[]) => getFormData(...args),
109
104
  }));
110
105
 
@@ -355,54 +350,45 @@ vi.mock(
355
350
  vi.mock(
356
351
  '../src/RedactoPrivacyCenter/components/DataRequest/NominationDataSection',
357
352
  () => ({
358
- default: () => <div data-testid="nomination-section" />,
353
+ // Test-only stub: exposes an email input that calls the real
354
+ // `setNominationData` setter, so tests can drive the form to a
355
+ // valid NOMINATION state and exercise the actual submit path
356
+ // (not just the "button disabled" gate).
357
+ default: ({ nominationData, setNominationData }: any) => (
358
+ <div data-testid="nomination-section">
359
+ <input
360
+ data-testid="nominee-email-input"
361
+ value={nominationData?.nominee_email ?? ''}
362
+ onChange={(e) =>
363
+ setNominationData((prev: any) => ({
364
+ ...prev,
365
+ nominee_email: e.target.value,
366
+ }))
367
+ }
368
+ />
369
+ </div>
370
+ ),
359
371
  }),
360
372
  );
361
373
  vi.mock('../src/RedactoPrivacyCenter/components/RequestSubmit', () => ({
362
374
  default: ({
363
375
  result,
364
- onRetry,
365
376
  }: {
366
377
  result: {
367
- succeeded: Array<{
368
- case_id: string;
369
- product_uuid: string;
370
- product_name: string;
371
- group_key: string;
372
- }>;
373
- failed: Array<{
374
- group_key: string;
375
- product_uuid: string;
376
- product_name: string;
377
- error: string;
378
- }>;
378
+ case_id: string;
379
+ uuid: string;
380
+ product_uuid: string;
381
+ product_name: string;
379
382
  };
380
- onRetry?: (groupKey: string) => void;
381
383
  }) => (
382
384
  <div data-testid="request-submitted">
383
- <ul data-testid="request-submitted-succeeded">
384
- {result.succeeded.map((s) => (
385
- <li key={`${s.group_key}:${s.case_id}`} data-group-key={s.group_key}>
386
- {s.case_id}
387
- </li>
388
- ))}
389
- </ul>
390
- <ul data-testid="request-submitted-failed">
391
- {result.failed.map((f) => (
392
- <li key={f.group_key} data-group-key={f.group_key}>
393
- {f.product_name}
394
- {onRetry !== undefined && (
395
- <button
396
- type="button"
397
- onClick={() => onRetry(f.group_key)}
398
- aria-label={`retry-${f.group_key}`}
399
- >
400
- Retry
401
- </button>
402
- )}
403
- </li>
404
- ))}
405
- </ul>
385
+ <span data-testid="request-submitted-case-id">{result.case_id}</span>
386
+ <span data-testid="request-submitted-product-uuid">
387
+ {result.product_uuid}
388
+ </span>
389
+ <span data-testid="request-submitted-product-name">
390
+ {result.product_name}
391
+ </span>
406
392
  </div>
407
393
  ),
408
394
  }));
@@ -497,40 +483,35 @@ describe('Form (P2-SDK-3)', () => {
497
483
  sessionStorage.clear();
498
484
  });
499
485
 
500
- it('falls back to the legacy flat render when direct_groups is undefined', async () => {
486
+ it('blocks submit for product-scoped types when the DP has no consent groups', async () => {
487
+ // 1-form -> 1-case -> 1-product means product-scoped request types
488
+ // (ACCESS/ERASURE/CORRECTION) MUST have a product anchor. When the
489
+ // DP has zero consent groups (direct + nominated), the picker shows
490
+ // its empty state and submit must be hard-blocked at the validity
491
+ // gate so the case can never be created without an anchor.
501
492
  setupSession();
502
- // Legacy backend shape: no direct_groups field at all.
503
- const legacyData = {
504
- uuid: 'form-session-uuid',
505
- name: 'Jane',
506
- contact: 'jane@example.com',
507
- grievance_options: [],
508
- purposes: [{ purpose: basePurpose('purp-1', 'Marketing') }],
509
- } as unknown as FormData;
510
- getFormData.mockResolvedValue(legacyData);
511
-
512
- // The Form emits a one-time console.warn when it falls back to the
513
- // legacy render so customer developers notice their backend hasn't
514
- // shipped Phase 1. Spy on it so we can both silence and assert.
515
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
493
+ getFormData.mockResolvedValue(
494
+ makeFormData({ direct_groups: [], nominated_groups: [] }),
495
+ );
516
496
 
517
497
  render(<Form onCreateRequest={vi.fn()} />);
518
498
  await waitFor(() => expect(getFormData).toHaveBeenCalled());
519
499
 
520
500
  pickRequestType(RequestTypeEnum.ERASURE);
521
501
 
522
- // Legacy purpose row should appear; the grouped header must not.
523
- await waitFor(() => {
524
- expect(screen.getByText('Marketing')).toBeInTheDocument();
525
- });
526
- expect(
527
- screen.queryByText(/Select a product/i),
528
- ).not.toBeInTheDocument();
502
+ // Confirm checkbox alone is not enough — the validity gate has to
503
+ // see at least one product selected.
504
+ const confirmCheckbox = (
505
+ screen.getAllByRole('checkbox') as HTMLInputElement[]
506
+ ).find((c) => c.closest('label')?.textContent?.includes('I confirm'));
507
+ expect(confirmCheckbox).toBeTruthy();
508
+ fireEvent.click(confirmCheckbox!);
529
509
 
530
- expect(warnSpy).toHaveBeenCalledWith(
531
- expect.stringContaining('Backend response lacks direct_groups'),
532
- );
533
- warnSpy.mockRestore();
510
+ const submitButton = screen.getByRole(
511
+ 'button',
512
+ { name: /^submit$/i },
513
+ ) as HTMLButtonElement;
514
+ expect(submitButton.disabled).toBe(true);
534
515
  });
535
516
 
536
517
  it('renders the product step for DSAR types when direct_groups is present', async () => {
@@ -722,7 +703,11 @@ describe('Form (P2-SDK-3)', () => {
722
703
  expect('data_principal_uuid' in payload).toBe(false);
723
704
  expect(payload.uuid).toBe('form-session-uuid');
724
705
  expect(payload.request_type).toBe('erasure');
725
- expect(payload.revoke_consent_on_fulfilment).toBe(false);
706
+ // ERASURE submits must set this flag so the backend's
707
+ // auto-revoke task fires when the case is fulfilled (DPDPA Sec 12:
708
+ // erasing data implies revoking the consent that authorized
709
+ // processing it).
710
+ expect(payload.revoke_consent_on_fulfilment).toBe(true);
726
711
 
727
712
  await waitFor(() => {
728
713
  expect(screen.getByTestId('request-submitted')).toBeInTheDocument();
@@ -936,97 +921,78 @@ describe('Form (P2-SDK-3)', () => {
936
921
  });
937
922
  });
938
923
 
939
- it('falls back to the legacy flat CorrectionDataSection when direct_groups is undefined', async () => {
940
- // TECH-3712 (correction grouped path) regression guard: when the
941
- // backend hasn't shipped Phase 1 yet (`direct_groups` absent), the
942
- // CORRECTION render path must drop back to the legacy flat-data-element
943
- // CorrectionDataSection and the submit must produce a single POST
944
- // without `product_uuid`.
924
+ it('submits NOMINATION as an org-level case without product_uuid or data_principal_uuid', async () => {
925
+ // NOMINATION is org-level — the picker is not rendered and the
926
+ // payload must omit `product_uuid` (backend rejects it on these
927
+ // types) AND `data_principal_uuid` (this is a direct grant, not a
928
+ // nominee acting on someone's behalf). Even when the contact has
929
+ // consent groups available, NOMINATION must not leak them onto
930
+ // the case anchor.
945
931
  setupSession();
946
- const legacyData = {
947
- uuid: 'form-session-uuid',
948
- name: 'Jane',
949
- contact: 'jane@example.com',
950
- grievance_options: [],
951
- purposes: [
952
- {
953
- purpose: basePurpose('purp-1', 'Marketing', [
954
- baseDataElement('de-1', 'email'),
955
- ]),
956
- },
957
- ],
958
- } as unknown as FormData;
959
- getFormData.mockResolvedValue(legacyData);
932
+ const direct = makeGroup();
933
+ getFormData.mockResolvedValue(
934
+ makeFormData({ direct_groups: [direct], nominated_groups: [] }),
935
+ );
960
936
  createCaseRequest.mockResolvedValueOnce({
961
- case_id: 'CASE-LEGACY',
962
- uuid: 'uuid-legacy',
937
+ case_id: 'CASE-NOM',
938
+ uuid: 'uuid-nom',
963
939
  });
964
940
 
965
- // Silence the legacy-mode warning emitted by Form.tsx so test output
966
- // stays focused on the assertions.
967
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
968
-
969
941
  render(<Form onCreateRequest={vi.fn()} />);
970
942
  await waitFor(() => expect(getFormData).toHaveBeenCalled());
971
943
 
972
- pickRequestType(RequestTypeEnum.CORRECTION);
944
+ pickRequestType(RequestTypeEnum.NOMINATION);
973
945
 
974
- // The product-step header must NOT appear — legacy fallback skips it.
946
+ await waitFor(() => {
947
+ expect(screen.getByTestId('nomination-section')).toBeInTheDocument();
948
+ });
949
+ // Product picker must not render for NOMINATION even with groups.
975
950
  expect(
976
951
  screen.queryByText(/Select a product/i),
977
952
  ).not.toBeInTheDocument();
978
953
 
979
- // The legacy flat CorrectionDataSection should render with the
980
- // backend's flat `purposes` data elements.
981
- const correctionSection = await screen.findByTestId('correction-section');
982
- expect(
983
- within(correctionSection).getByLabelText('correction-de-email'),
984
- ).toBeInTheDocument();
954
+ const submitButton = screen.getByRole(
955
+ 'button',
956
+ { name: /^submit$/i },
957
+ ) as HTMLButtonElement;
958
+ // Sanity: gate is closed before a valid nominee email is provided.
959
+ expect(submitButton.disabled).toBe(true);
985
960
 
986
- fireEvent.click(
987
- within(correctionSection).getByLabelText('correction-de-email'),
988
- );
989
- fireEvent.change(
990
- within(correctionSection).getByLabelText('correction-current-email'),
991
- { target: { value: 'old' } },
992
- );
993
- fireEvent.change(
994
- within(correctionSection).getByLabelText('correction-updated-email'),
995
- { target: { value: 'new' } },
996
- );
961
+ // Drive the form to a valid NOMINATION state via the stubbed
962
+ // nomination input (see vi.mock above).
963
+ const emailInput = screen.getByTestId('nominee-email-input');
964
+ fireEvent.change(emailInput, { target: { value: 'nominee@example.com' } });
997
965
 
998
- const allCheckboxes = screen.getAllByRole(
999
- 'checkbox',
1000
- ) as HTMLInputElement[];
1001
- const confirmCheckbox = allCheckboxes.find((c) =>
1002
- c.closest('label')?.textContent?.includes('I confirm'),
1003
- );
966
+ const confirmCheckbox = (
967
+ screen.getAllByRole('checkbox') as HTMLInputElement[]
968
+ ).find((c) => c.closest('label')?.textContent?.includes('I confirm'));
1004
969
  expect(confirmCheckbox).toBeTruthy();
1005
970
  fireEvent.click(confirmCheckbox!);
1006
971
 
1007
- const submitButton = screen.getByRole('button', { name: /^submit$/i });
1008
- expect(submitButton).not.toBeDisabled();
1009
- await act(async () => {
1010
- fireEvent.click(submitButton);
1011
- });
1012
-
1013
972
  await waitFor(() => {
1014
- expect(createCaseRequest).toHaveBeenCalledTimes(1);
973
+ const btn = screen.getByRole(
974
+ 'button',
975
+ { name: /^submit$/i },
976
+ ) as HTMLButtonElement;
977
+ expect(btn.disabled).toBe(false);
1015
978
  });
1016
979
 
1017
- // Single POST with no product anchor — the legacy single-case path.
1018
- const payload = createCaseRequest.mock.calls[0][2];
1019
- expect(payload.request_type).toBe('correction');
1020
- expect(payload.product_uuid).toBeUndefined();
1021
- expect('data_principal_uuid' in payload).toBe(false);
1022
- expect(payload.request_details.correction_data).toEqual([
1023
- { name: 'email', curr_value: 'old', new_value: 'new' },
1024
- ]);
980
+ fireEvent.click(
981
+ screen.getByRole('button', { name: /^submit$/i }),
982
+ );
983
+
984
+ await waitFor(() => expect(createCaseRequest).toHaveBeenCalled());
1025
985
 
1026
- warnSpy.mockRestore();
986
+ const payload = createCaseRequest.mock.calls[0][2];
987
+ expect(payload.request_type).toBe('nomination');
988
+ expect(payload).not.toHaveProperty('product_uuid');
989
+ expect(payload).not.toHaveProperty('data_principal_uuid');
990
+ expect(payload.request_details.nomination_data).toMatchObject({
991
+ nominee_email: 'nominee@example.com',
992
+ });
1027
993
  });
1028
994
 
1029
- it('records group_key on successful single-product batch submission', async () => {
995
+ it('sends data_principal_uuid and product anchors on a nominated-card submit', async () => {
1030
996
  setupSession();
1031
997
  const otherDirect = makeGroup({
1032
998
  product_uuid: 'prod-other',
@@ -1087,16 +1053,24 @@ describe('Form (P2-SDK-3)', () => {
1087
1053
  });
1088
1054
 
1089
1055
  await waitFor(() => {
1090
- expect(screen.getByTestId('request-submitted')).toBeInTheDocument();
1056
+ expect(createCaseRequest).toHaveBeenCalledTimes(1);
1091
1057
  });
1092
1058
 
1093
- const succeededItems = within(
1094
- screen.getByTestId('request-submitted-succeeded'),
1095
- ).getAllByRole('listitem');
1096
- expect(succeededItems).toHaveLength(1);
1097
- expect(succeededItems[0].getAttribute('data-group-key')).toBe(
1098
- 'prod-cc:alice-uuid',
1099
- );
1059
+ const payload = createCaseRequest.mock.calls[0][2];
1060
+ expect(payload.product_uuid).toBe('prod-cc');
1061
+ // Nominated cards send data_principal_uuid sourced from the
1062
+ // nominator's ConsentUser uuid — NOT from formData.uuid.
1063
+ expect(payload.data_principal_uuid).toBe('alice-uuid');
1064
+
1065
+ await waitFor(() => {
1066
+ expect(screen.getByTestId('request-submitted')).toBeInTheDocument();
1067
+ });
1068
+ expect(
1069
+ screen.getByTestId('request-submitted-product-uuid').textContent,
1070
+ ).toBe('prod-cc');
1071
+ expect(
1072
+ screen.getByTestId('request-submitted-case-id').textContent,
1073
+ ).toBe('CASE-NOMINATED');
1100
1074
  });
1101
1075
 
1102
1076
  it('disables submit when the selected CORRECTION product has incomplete entries', async () => {
@@ -3,8 +3,8 @@ import { describe, it, expect, vi } from 'vitest';
3
3
 
4
4
  vi.mock('react-i18next', () => {
5
5
  const en: Record<string, string> = {
6
- activePurposes_one: '{{count}} active purpose',
7
- activePurposes_other: '{{count}} active purposes',
6
+ activePurposes_one: '{{count}} purpose',
7
+ activePurposes_other: '{{count}} purposes',
8
8
  noProductsAvailable: 'No products available',
9
9
  noProductsAvailableDescription: 'You have no consents to manage yet.',
10
10
  nominationChipSuffix: '· {{nominator}}',
@@ -96,7 +96,42 @@ describe('ProductSelectionSection', () => {
96
96
  expect(screen.getByTestId('product-selection-chips')).toHaveClass(
97
97
  'pc-product-select-chips',
98
98
  );
99
- expect(screen.getByText('2 active purposes')).toBeInTheDocument();
99
+ expect(screen.getByText('2 purposes')).toBeInTheDocument();
100
+ });
101
+
102
+ it('chip count reflects total raisable purposes regardless of status', () => {
103
+ // Per TECH-4089, withdrawn purposes still appear in the picker (revoking
104
+ // consent is not deletion — DP must still be able to raise requests for
105
+ // them). The chip label is a plain count of raisable purposes — no
106
+ // active/withdrawn distinction is surfaced in the label.
107
+ const direct = makeGroup({
108
+ product_uuid: 'prod-mixed',
109
+ product_name: 'Acme Loyalty',
110
+ purposes: [
111
+ {
112
+ uuid: 'p-active',
113
+ data_elements: [{ uuid: 'de-a', selected: true }],
114
+ status: 'ACTIVE',
115
+ } as never,
116
+ {
117
+ uuid: 'p-withdrawn',
118
+ data_elements: [{ uuid: 'de-w', selected: false }],
119
+ status: 'WITHDRAW',
120
+ } as never,
121
+ ],
122
+ });
123
+
124
+ render(
125
+ <ProductSelectionSection
126
+ directGroups={[direct]}
127
+ nominatedGroups={[]}
128
+ selectedProductKey={null}
129
+ onSelect={vi.fn()}
130
+ />,
131
+ );
132
+
133
+ expect(screen.getByText('2 purposes')).toBeInTheDocument();
134
+ expect(screen.queryByText(/active/)).not.toBeInTheDocument();
100
135
  });
101
136
 
102
137
  it('marks the selected chip with aria-selected and selected style class', () => {
@@ -148,7 +183,7 @@ describe('ProductSelectionSection', () => {
148
183
 
149
184
  expect(screen.getByText('Partner Bank')).toBeInTheDocument();
150
185
  expect(screen.getByText('· Alice Johnson')).toBeInTheDocument();
151
- expect(screen.getByText('1 active purpose')).toBeInTheDocument();
186
+ expect(screen.getByText('1 purpose')).toBeInTheDocument();
152
187
  });
153
188
 
154
189
  it('falls back to nominator email in the chip suffix when name is null', () => {