@jmruthers/pace-core 0.6.7 → 0.6.8

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 (100) hide show
  1. package/audit-tool/00-dependencies.cjs +215 -9
  2. package/audit-tool/audits/02-project-structure.cjs +3 -18
  3. package/audit-tool/audits/03-architecture.cjs +34 -6
  4. package/audit-tool/audits/06-security-rbac.cjs +10 -0
  5. package/audit-tool/audits/07-api-tech-stack.cjs +55 -1
  6. package/audit-tool/index.cjs +23 -19
  7. package/audit-tool/utils/report-utils.cjs +141 -2
  8. package/dist/{DataTable-7PMH7XN7.js → DataTable-6RMSCQJ6.js} +5 -5
  9. package/dist/{PublicPageProvider-DlsCaR5v.d.ts → PublicPageProvider-CIGSujI2.d.ts} +14 -8
  10. package/dist/{UnifiedAuthProvider-ZT6TIGM7.js → UnifiedAuthProvider-7SNDOWYD.js} +2 -2
  11. package/dist/{api-Y4MQWOFW.js → api-7P7DI652.js} +1 -1
  12. package/dist/{chunk-L4XMVJKY.js → chunk-4DDCYDQ3.js} +8 -7
  13. package/dist/{chunk-ZKAWKYT4.js → chunk-5W2A3DRC.js} +2 -1
  14. package/dist/{chunk-VBCS3DUA.js → chunk-EF2UGZWY.js} +3 -3
  15. package/dist/{chunk-JGWDVX64.js → chunk-EURB7QFZ.js} +123 -53
  16. package/dist/{chunk-BM4CQ5P3.js → chunk-GS5672WG.js} +6 -6
  17. package/dist/{chunk-ZFYPMX46.js → chunk-LX6U42O3.js} +1 -1
  18. package/dist/{chunk-5X4QLXRG.js → chunk-MPBLMWVR.js} +5 -3
  19. package/dist/{chunk-Q7Q7V5NV.js → chunk-NKHKXPI4.js} +7 -7
  20. package/dist/{chunk-6F3IILHI.js → chunk-S6ZQKDY6.js} +1 -1
  21. package/dist/{chunk-FTCRZOG2.js → chunk-T5CVK4R3.js} +5 -5
  22. package/dist/{chunk-GHYHJTYV.js → chunk-Z2FNRKF3.js} +13 -13
  23. package/dist/components.d.ts +1 -1
  24. package/dist/components.js +12 -12
  25. package/dist/eslint-rules/rules/04-code-quality.cjs +66 -10
  26. package/dist/eslint-rules/rules/06-security-rbac.cjs +8 -3
  27. package/dist/eslint-rules/rules/07-api-tech-stack.cjs +190 -68
  28. package/dist/{functions-DHebl8-F.d.ts → functions-lBy5L2ry.d.ts} +1 -1
  29. package/dist/hooks.js +7 -7
  30. package/dist/index.d.ts +2 -2
  31. package/dist/index.js +15 -15
  32. package/dist/providers.js +2 -2
  33. package/dist/rbac/index.d.ts +1 -1
  34. package/dist/rbac/index.js +6 -6
  35. package/dist/theming/runtime.d.ts +48 -1
  36. package/dist/theming/runtime.js +1 -1
  37. package/dist/types.d.ts +2 -2
  38. package/dist/utils.js +1 -1
  39. package/docs/api/modules.md +63 -14
  40. package/docs/getting-started/dependencies.md +23 -0
  41. package/docs/implementation-guides/app-layout.md +1 -1
  42. package/docs/implementation-guides/data-tables.md +1 -1
  43. package/docs/standards/1-pace-core-compliance-standards.md +38 -1
  44. package/eslint-config-pace-core.cjs +30 -11
  45. package/package.json +45 -15
  46. package/scripts/eslint-audit.cjs +123 -0
  47. package/scripts/install-eslint-config.cjs +67 -2
  48. package/scripts/validate-dependencies.cjs +248 -0
  49. package/src/__tests__/helpers/__tests__/test-utils.test.tsx +20 -8
  50. package/src/__tests__/templates/accessibility.test.template.tsx +1 -0
  51. package/src/components/AddressField/AddressField.tsx +26 -1
  52. package/src/components/Alert/Alert.test.tsx +86 -22
  53. package/src/components/Alert/Alert.tsx +19 -11
  54. package/src/components/Badge/Badge.tsx +1 -1
  55. package/src/components/Checkbox/Checkbox.test.tsx +2 -1
  56. package/src/components/ContextSelector/ContextSelector.tsx +39 -41
  57. package/src/components/DataTable/DataTable.tsx +1 -19
  58. package/src/components/DataTable/__tests__/DataTableCore.test.tsx +6 -10
  59. package/src/components/DataTable/__tests__/a11y.basic.test.tsx +18 -9
  60. package/src/components/DataTable/__tests__/pagination.modes.test.tsx +3 -2
  61. package/src/components/DataTable/components/EmptyState.tsx +1 -1
  62. package/src/components/DataTable/components/__tests__/DataTableErrorBoundary.test.tsx +1 -1
  63. package/src/components/DataTable/components/__tests__/EmptyState.test.tsx +3 -3
  64. package/src/components/DataTable/components/__tests__/LoadingState.test.tsx +33 -29
  65. package/src/components/DatePickerWithTimezone/DatePickerWithTimezone.test.tsx +1 -2
  66. package/src/components/FileUpload/FileUpload.test.tsx +22 -31
  67. package/src/components/FileUpload/FileUpload.tsx +29 -0
  68. package/src/components/NavigationMenu/NavigationMenu.test.tsx +48 -12
  69. package/src/components/PaceAppLayout/PaceAppLayout.performance.test.tsx +9 -9
  70. package/src/components/PaceAppLayout/PaceAppLayout.security.test.tsx +30 -30
  71. package/src/components/PaceAppLayout/PaceAppLayout.test.tsx +4 -4
  72. package/src/components/PaceLoginPage/PaceLoginPage.test.tsx +7 -1
  73. package/src/hooks/__tests__/useDataTablePerformance.unit.test.ts +8 -5
  74. package/src/hooks/__tests__/useFileUrl.unit.test.ts +4 -0
  75. package/src/hooks/__tests__/useFocusTrap.unit.test.tsx +3 -3
  76. package/src/hooks/__tests__/useInactivityTracker.unit.test.ts +45 -8
  77. package/src/hooks/__tests__/usePerformanceMonitor.unit.test.ts +22 -2
  78. package/src/hooks/public/usePublicRouteParams.ts +8 -4
  79. package/src/hooks/useAddressAutocomplete.test.ts +18 -18
  80. package/src/hooks/useEventTheme.ts +5 -1
  81. package/src/hooks/useFileUrl.ts +52 -8
  82. package/src/hooks/useOrganisationSecurity.test.ts +2 -1
  83. package/src/providers/__tests__/ProviderLifecycle.test.tsx +1 -1
  84. package/src/rbac/__tests__/auth-rbac.e2e.test.tsx +15 -6
  85. package/src/rbac/__tests__/rbac-functions.test.ts +3 -3
  86. package/src/rbac/api.test.ts +104 -0
  87. package/src/rbac/engine.ts +1 -1
  88. package/src/rbac/hooks/useCan.test.ts +2 -2
  89. package/src/rbac/secureClient.ts +1 -1
  90. package/src/rbac/types/functions.ts +1 -1
  91. package/src/theming/__tests__/parseEventColours.test.ts +117 -8
  92. package/src/theming/parseEventColours.ts +56 -2
  93. package/src/types/supabase.ts +2 -3
  94. package/src/utils/__tests__/bundleAnalysis.unit.test.ts +9 -9
  95. package/src/utils/file-reference/__tests__/file-reference.test.ts +4 -0
  96. package/src/utils/formatting/formatDate.test.ts +3 -2
  97. package/src/utils/formatting/formatDateTime.test.ts +2 -2
  98. package/src/utils/google-places/googlePlacesUtils.test.ts +36 -24
  99. package/src/utils/storage/__tests__/helpers.unit.test.ts +19 -12
  100. package/src/utils/storage/helpers.test.ts +69 -3
@@ -444,7 +444,7 @@ describe('PaceAppLayout Security', () => {
444
444
  });
445
445
  });
446
446
 
447
- it('prevents access when user lacks permission', async () => {
447
+ it('prevents access when user lacks permission', { timeout: 3000 }, async () => {
448
448
  // Ensure super admin check completes first
449
449
  mockIsSuperAdmin.mockResolvedValueOnce(false);
450
450
 
@@ -465,7 +465,7 @@ describe('PaceAppLayout Security', () => {
465
465
  // Wait for super admin check to complete and component to re-render
466
466
  await waitFor(() => {
467
467
  expect(mockIsSuperAdmin).toHaveBeenCalled();
468
- }, { timeout: 1000 });
468
+ });
469
469
 
470
470
  // Wait a bit for the component to process the super admin check result
471
471
  await new Promise(resolve => setTimeout(resolve, 100));
@@ -474,8 +474,8 @@ describe('PaceAppLayout Security', () => {
474
474
  await waitFor(() => {
475
475
  expect(screen.getByText('Access Denied')).toBeInTheDocument();
476
476
  expect(screen.getByText("You don't have permission to access this page.")).toBeInTheDocument();
477
- }, { timeout: 2000 });
478
- }, { timeout: 3000 });
477
+ });
478
+ });
479
479
 
480
480
  it('enforces route-specific permissions', async () => {
481
481
  const routePermissions: Record<string, Operation> = {
@@ -501,7 +501,7 @@ describe('PaceAppLayout Security', () => {
501
501
  });
502
502
  });
503
503
 
504
- it('handles permission check failures securely', async () => {
504
+ it('handles permission check failures securely', { timeout: 3000 }, async () => {
505
505
  // Ensure super admin check completes first
506
506
  mockIsSuperAdmin.mockResolvedValueOnce(false);
507
507
 
@@ -522,7 +522,7 @@ describe('PaceAppLayout Security', () => {
522
522
  // Wait for super admin check to complete and component to re-render
523
523
  await waitFor(() => {
524
524
  expect(mockIsSuperAdmin).toHaveBeenCalled();
525
- }, { timeout: 1000 });
525
+ });
526
526
 
527
527
  // Wait a bit for the component to process the super admin check result
528
528
  await new Promise(resolve => setTimeout(resolve, 100));
@@ -531,8 +531,8 @@ describe('PaceAppLayout Security', () => {
531
531
  // When permission check throws an error, should show Permission Error page
532
532
  expect(screen.getByText('Permission Error')).toBeInTheDocument();
533
533
  expect(screen.getByText('Permission check failed')).toBeInTheDocument();
534
- }, { timeout: 2000 });
535
- }, { timeout: 3000 });
534
+ });
535
+ });
536
536
 
537
537
  it('prevents bypassing permission checks', async () => {
538
538
  // Test that permission checks cannot be bypassed by manipulating props
@@ -562,7 +562,7 @@ describe('PaceAppLayout Security', () => {
562
562
  });
563
563
 
564
564
  describe('Navigation Security', () => {
565
- it('filters navigation items based on permissions', async () => {
565
+ it('filters navigation items based on permissions', { timeout: 3000 }, async () => {
566
566
  // Mock permission check to allow all permissions for navigation filtering
567
567
  // The navigation filtering uses getPermissionMap which is already mocked
568
568
  render(
@@ -582,8 +582,8 @@ describe('PaceAppLayout Security', () => {
582
582
  // With permission enforcement enabled, the component should render normally
583
583
  expect(screen.getByTestId('mock-header')).toBeInTheDocument();
584
584
  expect(screen.getByTestId('mock-outlet')).toBeInTheDocument();
585
- }, { timeout: 2000 });
586
- }, { timeout: 3000 });
585
+ });
586
+ });
587
587
 
588
588
  it('prevents navigation to unauthorized routes', () => {
589
589
  render(
@@ -843,7 +843,7 @@ describe('PaceAppLayout Security', () => {
843
843
  });
844
844
  });
845
845
 
846
- it('handles permission errors securely', async () => {
846
+ it('handles permission errors securely', { timeout: 3000 }, async () => {
847
847
  // Ensure super admin check completes first
848
848
  mockIsSuperAdmin.mockResolvedValueOnce(false);
849
849
 
@@ -864,17 +864,17 @@ describe('PaceAppLayout Security', () => {
864
864
  // Wait for super admin check to complete and component to re-render
865
865
  await waitFor(() => {
866
866
  expect(mockIsSuperAdmin).toHaveBeenCalled();
867
- }, { timeout: 1000 });
867
+ });
868
868
 
869
869
  // Wait a bit for the component to process the super admin check result
870
870
  await new Promise(resolve => setTimeout(resolve, 100));
871
871
 
872
872
  await waitFor(() => {
873
873
  expect(screen.getByText('Access Denied')).toBeInTheDocument();
874
- }, { timeout: 2000 });
875
- }, { timeout: 3000 });
874
+ });
875
+ });
876
876
 
877
- it('prevents information leakage in error messages', async () => {
877
+ it('prevents information leakage in error messages', { timeout: 3000 }, async () => {
878
878
  // Ensure super admin check completes first
879
879
  mockIsSuperAdmin.mockResolvedValueOnce(false);
880
880
 
@@ -902,7 +902,7 @@ describe('PaceAppLayout Security', () => {
902
902
  // Wait for super admin check to complete and component to re-render
903
903
  await waitFor(() => {
904
904
  expect(mockIsSuperAdmin).toHaveBeenCalled();
905
- }, { timeout: 1000 });
905
+ });
906
906
 
907
907
  // Wait a bit for the component to process the super admin check result
908
908
  await new Promise(resolve => setTimeout(resolve, 100));
@@ -911,8 +911,8 @@ describe('PaceAppLayout Security', () => {
911
911
  expect(screen.getByText('Permission Error')).toBeInTheDocument();
912
912
  // Should not expose sensitive information
913
913
  expect(screen.queryByText('password=secret123')).not.toBeInTheDocument();
914
- }, { timeout: 2000 });
915
- }, { timeout: 3000 });
914
+ });
915
+ });
916
916
  });
917
917
 
918
918
  describe('Session Security', () => {
@@ -987,7 +987,7 @@ describe('PaceAppLayout Security', () => {
987
987
  });
988
988
  });
989
989
 
990
- it('allows super admin to bypass all permission checks', async () => {
990
+ it('allows super admin to bypass all permission checks', { timeout: 3000 }, async () => {
991
991
  // Mock super admin status
992
992
  mockIsSuperAdmin.mockResolvedValueOnce(true);
993
993
 
@@ -1017,10 +1017,10 @@ describe('PaceAppLayout Security', () => {
1017
1017
  expect(screen.getByTestId('mock-outlet')).toBeInTheDocument();
1018
1018
  // Should NOT show access denied
1019
1019
  expect(screen.queryByText('Access Denied')).not.toBeInTheDocument();
1020
- }, { timeout: 2000 });
1021
- }, { timeout: 3000 });
1020
+ });
1021
+ });
1022
1022
 
1023
- it('does not log strict mode violations for super admins', async () => {
1023
+ it('does not log strict mode violations for super admins', { timeout: 5000 }, async () => {
1024
1024
  // Mock super admin status (resolve immediately)
1025
1025
  mockIsSuperAdmin.mockResolvedValueOnce(true);
1026
1026
 
@@ -1052,12 +1052,12 @@ describe('PaceAppLayout Security', () => {
1052
1052
  // Wait for super admin check to complete
1053
1053
  await waitFor(() => {
1054
1054
  expect(mockIsSuperAdmin).toHaveBeenCalled();
1055
- }, { timeout: 1000 });
1055
+ });
1056
1056
 
1057
1057
  // Wait for component to fully render (super admin should bypass checks)
1058
1058
  await waitFor(() => {
1059
1059
  expect(screen.getByTestId('mock-header')).toBeInTheDocument();
1060
- }, { timeout: 2000 });
1060
+ });
1061
1061
 
1062
1062
  // Clear any violations that might have been logged before super admin check completed
1063
1063
  consoleSpy.mockClear();
@@ -1073,9 +1073,9 @@ describe('PaceAppLayout Security', () => {
1073
1073
  expect(violationLogs).toHaveLength(0);
1074
1074
 
1075
1075
  consoleSpy.mockRestore();
1076
- }, { timeout: 5000 });
1076
+ });
1077
1077
 
1078
- it('prevents privilege escalation', async () => {
1078
+ it('prevents privilege escalation', { timeout: 3000 }, async () => {
1079
1079
  // Test that users cannot escalate their privileges
1080
1080
  // Create a test wrapper with admin path for privilege escalation test
1081
1081
  const AdminTestWrapper = ({ children }: { children: React.ReactNode }) => (
@@ -1115,7 +1115,7 @@ describe('PaceAppLayout Security', () => {
1115
1115
  // Wait for super admin check to complete and component to re-render
1116
1116
  await waitFor(() => {
1117
1117
  expect(mockIsSuperAdmin).toHaveBeenCalled();
1118
- }, { timeout: 1000 });
1118
+ });
1119
1119
 
1120
1120
  // Wait a bit for the component to process the super admin check result
1121
1121
  await new Promise(resolve => setTimeout(resolve, 100));
@@ -1124,7 +1124,7 @@ describe('PaceAppLayout Security', () => {
1124
1124
  // With privilege escalation prevention, should show access denied for admin
1125
1125
  expect(screen.getByText('Access Denied')).toBeInTheDocument();
1126
1126
  expect(screen.getByText("You don't have permission to access this page.")).toBeInTheDocument();
1127
- }, { timeout: 2000 });
1128
- }, { timeout: 3000 });
1127
+ });
1128
+ });
1129
1129
  });
1130
1130
  });
@@ -873,7 +873,7 @@ describe('PaceAppLayout Component', () => {
873
873
  });
874
874
 
875
875
  describe('Route-Specific Permissions', () => {
876
- it('uses route-specific permissions when provided', async () => {
876
+ it('uses route-specific permissions when provided', { timeout: 3000 }, async () => {
877
877
  renderWithProviders(
878
878
  <TestWrapper>
879
879
  <PaceAppLayout
@@ -906,9 +906,9 @@ describe('PaceAppLayout Component', () => {
906
906
  'Test App'
907
907
  );
908
908
  }, { timeout: 2000 });
909
- }, { timeout: 3000 });
909
+ });
910
910
 
911
- it('uses default permission when route not in routePermissions', async () => {
911
+ it('uses default permission when route not in routePermissions', { timeout: 3000 }, async () => {
912
912
  renderWithProviders(
913
913
  <TestWrapper>
914
914
  <PaceAppLayout
@@ -940,7 +940,7 @@ describe('PaceAppLayout Component', () => {
940
940
  'Test App'
941
941
  );
942
942
  }, { timeout: 2000 });
943
- }, { timeout: 3000 });
943
+ });
944
944
  });
945
945
 
946
946
  describe('Super Admin Bypass', () => {
@@ -673,7 +673,9 @@ describe('PaceLoginPage Component', () => {
673
673
  });
674
674
 
675
675
  it('shows access error when required app configuration is missing', async () => {
676
+ vi.mocked(isSuperAdmin).mockResolvedValue(false);
676
677
  mockAuthContext.isAuthenticated = true;
678
+ mockAuthContext.isLoading = false;
677
679
  mockAuthContext.user = { id: 'user-1' } as User;
678
680
  const missingAppSupabase = {
679
681
  from: vi.fn((table: string) => {
@@ -707,12 +709,15 @@ describe('PaceLoginPage Component', () => {
707
709
  );
708
710
 
709
711
  await waitFor(() => {
712
+ // Error message format: Application "${appName}" is not configured. Please contact your administrator.
710
713
  expect(screen.getByText(/is not configured/i)).toBeInTheDocument();
711
- });
714
+ }, { timeout: 5000 });
712
715
  });
713
716
 
714
717
  it('shows organisation access error when user lacks active organisations', async () => {
718
+ vi.mocked(isSuperAdmin).mockResolvedValue(false);
715
719
  mockAuthContext.isAuthenticated = true;
720
+ mockAuthContext.isLoading = false;
716
721
  mockAuthContext.user = { id: 'user-2' } as User;
717
722
 
718
723
  const createOrganisationRolesQuery = (result: { data: unknown; error: unknown }) => {
@@ -779,6 +784,7 @@ describe('PaceLoginPage Component', () => {
779
784
  );
780
785
 
781
786
  await waitFor(() => {
787
+ // Error message format: You do not have permission to access ${appName}. You are not assigned to any organisation. Please contact your administrator.
782
788
  expect(screen.getByText(/not assigned to any organisation/i)).toBeInTheDocument();
783
789
  }, { timeout: 5000 });
784
790
  });
@@ -67,11 +67,14 @@ vi.mock('../../components/DataTable/utils/errorHandling', () => ({
67
67
  // Constructor
68
68
  }
69
69
  } as any,
70
- CircuitBreaker: vi.fn().mockImplementation(() => ({
71
- isOpen: false,
72
- recordSuccess: vi.fn(),
73
- recordFailure: vi.fn()
74
- })),
70
+ CircuitBreaker: vi.fn(function CircuitBreaker() {
71
+ return {
72
+ isOpen: vi.fn(() => false),
73
+ recordSuccess: vi.fn(),
74
+ recordFailure: vi.fn(),
75
+ reset: vi.fn()
76
+ };
77
+ }) as any,
75
78
  DEFAULT_FALLBACK_CONFIG: {
76
79
  enableFallbacks: true,
77
80
  maxRetries: 3
@@ -820,12 +820,16 @@ describe('useFileUrl Hook', () => {
820
820
  }
821
821
  );
822
822
 
823
+ // Wait for initial URL to load
823
824
  await waitFor(
824
825
  () => {
825
826
  expect(result.current.url).not.toBe(null);
826
827
  },
827
828
  { timeout: 2000 }
828
829
  );
830
+
831
+ // Verify initial URL is set
832
+ expect(result.current.url).toBe('https://example.com/org-123/logos/logo.png');
829
833
 
830
834
  const newFile = { ...mockPublicFileReference, id: 'file-999', file_path: 'org-123/logos/new-logo.png' };
831
835
  rerender({ fileRef: newFile });
@@ -634,7 +634,7 @@ describe('useFocusTrap', () => {
634
634
  getByTestId('toggle').click();
635
635
  });
636
636
 
637
- expect(screen.queryByTestId('container')).not.toBeInTheDocument();
637
+ expect(screen.queryByTestId('container')).toBeNull();
638
638
  });
639
639
  });
640
640
 
@@ -711,13 +711,13 @@ describe('useFocusTrap', () => {
711
711
 
712
712
  // Wait for React to update the DOM after state change
713
713
  await waitFor(() => {
714
- expect(screen.queryByTestId('button-Button 2')).not.toBeInTheDocument();
714
+ expect(screen.queryByTestId('button-Button 2')).toBeNull();
715
715
  });
716
716
 
717
717
  // Verify the removed button is gone and others remain
718
718
  expect(screen.getByTestId('button-Button 1')).toBeDefined();
719
719
  expect(screen.getByTestId('button-Button 3')).toBeDefined();
720
- expect(screen.queryByTestId('button-Button 2')).not.toBeInTheDocument();
720
+ expect(screen.queryByTestId('button-Button 2')).toBeNull();
721
721
  });
722
722
 
723
723
  it('should handle elements becoming disabled/enabled', () => {
@@ -288,15 +288,29 @@ describe('useInactivityTracker', () => {
288
288
  });
289
289
 
290
290
  it('broadcasts activity to other tabs', () => {
291
- const { result } = renderHook(() =>
292
- useInactivityTracker({
293
- enabled: true,
291
+ // Ensure no persisted time to force resetActivity call
292
+ localStorageMock.getItem.mockReturnValue(null);
293
+
294
+ // Start with enabled: true to allow tracking
295
+ const { result } = renderHook(
296
+ ({ enabled }) => useInactivityTracker({
297
+ enabled,
294
298
  channelName: 'test-channel'
295
- })
299
+ }),
300
+ { initialProps: { enabled: true } }
296
301
  );
297
302
 
303
+ // Wait for automatic initialization
298
304
  act(() => {
299
- result.current.startTracking();
305
+ vi.advanceTimersByTime(0);
306
+ });
307
+
308
+ // Clear any existing calls from initialization
309
+ mockBroadcastChannel.postMessage.mockClear();
310
+
311
+ // Trigger activity reset which should broadcast
312
+ act(() => {
313
+ result.current.resetActivity();
300
314
  });
301
315
 
302
316
  expect(mockBroadcastChannel.postMessage).toHaveBeenCalledWith({
@@ -337,16 +351,39 @@ describe('useInactivityTracker', () => {
337
351
 
338
352
  it('cleans up timers and listeners on unmount', () => {
339
353
  const { result, unmount } = renderHook(() =>
340
- useInactivityTracker({ enabled: true })
354
+ useInactivityTracker({ enabled: true, channelName: 'test-channel' })
341
355
  );
342
356
 
357
+ // Wait for automatic initialization
343
358
  act(() => {
344
- result.current.startTracking();
359
+ vi.advanceTimersByTime(0);
360
+ });
361
+
362
+ // Ensure tracking is active and channel/listeners are set up
363
+ expect(result.current.isTracking).toBe(true);
364
+ expect(mockAddEventListener).toHaveBeenCalled();
365
+
366
+ // Verify channel was created
367
+ expect(window.BroadcastChannel).toHaveBeenCalled();
368
+
369
+ // Clear mocks to track cleanup calls
370
+ mockRemoveEventListener.mockClear();
371
+ mockBroadcastChannel.close.mockClear();
372
+
373
+ // Unmount should trigger cleanup
374
+ act(() => {
375
+ unmount();
345
376
  });
346
377
 
347
- unmount();
378
+ // Advance timers to ensure cleanup completes
379
+ act(() => {
380
+ vi.advanceTimersByTime(100);
381
+ });
348
382
 
383
+ // Verify cleanup was called
384
+ // The hook should remove all event listeners that were added
349
385
  expect(mockRemoveEventListener).toHaveBeenCalled();
386
+ // Channel cleanup happens in the unmount effect
350
387
  expect(mockBroadcastChannel.close).toHaveBeenCalled();
351
388
  });
352
389
 
@@ -610,7 +610,13 @@ describe('useOperationPerformance', () => {
610
610
  useOperationPerformance('testOperation', 'OPERATION')
611
611
  );
612
612
 
613
- mockPerformanceNow.mockReturnValueOnce(0).mockReturnValueOnce(20);
613
+ // Clear any previous calls
614
+ vi.clearAllMocks();
615
+
616
+ // Use mockReturnValueOnce to ensure exact sequence
617
+ mockPerformanceNow
618
+ .mockReturnValueOnce(0) // Start time
619
+ .mockReturnValueOnce(20); // End time
614
620
 
615
621
  const operation = () => 'result';
616
622
 
@@ -623,12 +629,22 @@ describe('useOperationPerformance', () => {
623
629
  operation: 'testOperation',
624
630
  })
625
631
  );
632
+
633
+ // Reset mock
634
+ mockPerformanceNow.mockImplementation(() => mockPerformanceNowValue);
626
635
  });
627
636
 
628
637
  it('includes context in measurement', async () => {
629
638
  const { result } = renderHook(() => useOperationPerformance('testOperation'));
630
639
 
631
- mockPerformanceNow.mockReturnValueOnce(0).mockReturnValueOnce(15);
640
+ // Use a sequence to ensure correct values are returned
641
+ let callCount = 0;
642
+ mockPerformanceNow.mockImplementation(() => {
643
+ callCount++;
644
+ if (callCount === 1) return 0; // Start time
645
+ if (callCount === 2) return 15; // End time
646
+ return mockPerformanceNowValue; // Fallback
647
+ });
632
648
 
633
649
  const operation = () => 'result';
634
650
  const context = { userId: '123', action: 'test' };
@@ -644,6 +660,10 @@ describe('useOperationPerformance', () => {
644
660
  action: 'test',
645
661
  })
646
662
  );
663
+
664
+ // Reset mock
665
+ callCount = 0;
666
+ mockPerformanceNow.mockImplementation(() => mockPerformanceNowValue);
647
667
  });
648
668
 
649
669
  it('handles operation errors', async () => {
@@ -182,16 +182,20 @@ export function usePublicRouteParams(
182
182
 
183
183
  // Determine the final error state
184
184
  const finalError = useMemo(() => {
185
+ // Always show validation errors (format validation)
185
186
  if (error) return error;
186
- if (eventError) return eventError;
187
+ // When validation is disabled, ignore event errors (invalid codes are allowed)
188
+ if (!validateEventCode) return null;
189
+ // Include eventError if we're fetching event data and validation is enabled
190
+ if (fetchEventData && eventError) return eventError;
187
191
  return null;
188
- }, [error, eventError]);
192
+ }, [error, eventError, fetchEventData, validateEventCode]);
189
193
 
190
194
  // Extract event ID from event data
191
195
  const eventId = useMemo(() => {
192
- if (!event) return null;
196
+ if (!fetchEventData || !event) return null;
193
197
  return event.event_id || event.id;
194
- }, [event]);
198
+ }, [fetchEventData, event]);
195
199
 
196
200
  // Refetch function
197
201
  const refetch = useCallback(async (): Promise<void> => {
@@ -45,17 +45,17 @@ describe('useAddressAutocomplete', () => {
45
45
  });
46
46
 
47
47
  describe('Initial state', () => {
48
- it('returns initial state with empty suggestions', () => {
48
+ it('returns initial state with empty suggestions', { timeout: 5000 }, () => {
49
49
  const { result } = renderHook(() => useAddressAutocomplete(mockApiKey, ''));
50
50
 
51
51
  expect(result.current.suggestions).toEqual([]);
52
52
  expect(result.current.isLoading).toBe(false);
53
53
  expect(result.current.error).toBeNull();
54
- }, { timeout: 5000 });
54
+ });
55
55
  });
56
56
 
57
57
  describe('Autocomplete suggestions', () => {
58
- it('fetches suggestions when input value changes', async () => {
58
+ it('fetches suggestions when input value changes', { timeout: 5000 }, async () => {
59
59
  const mockPredictions = [
60
60
  {
61
61
  description: '123 Main St, Melbourne VIC, Australia',
@@ -81,9 +81,9 @@ describe('useAddressAutocomplete', () => {
81
81
  );
82
82
 
83
83
  expect(result.current.suggestions[0].place_id).toBe('ChIJ123');
84
- }, { timeout: 5000 });
84
+ });
85
85
 
86
- it('debounces input value', async () => {
86
+ it('debounces input value', { timeout: 5000 }, async () => {
87
87
  vi.mocked(googlePlacesUtils.fetchPlaceAutocomplete).mockResolvedValue([]);
88
88
 
89
89
  // Since we're mocking useDebounce to return immediately, this test verifies
@@ -108,9 +108,9 @@ describe('useAddressAutocomplete', () => {
108
108
 
109
109
  // With mocked debounce, it will be called for each change
110
110
  expect(googlePlacesUtils.fetchPlaceAutocomplete).toHaveBeenCalled();
111
- }, { timeout: 5000 });
111
+ });
112
112
 
113
- it('clears suggestions when input is empty', async () => {
113
+ it('clears suggestions when input is empty', { timeout: 5000 }, async () => {
114
114
  vi.mocked(googlePlacesUtils.fetchPlaceAutocomplete).mockResolvedValueOnce([
115
115
  { description: '123 Main St', place_id: 'ChIJ123' },
116
116
  ]);
@@ -137,9 +137,9 @@ describe('useAddressAutocomplete', () => {
137
137
  },
138
138
  { timeout: 1000 }
139
139
  );
140
- }, { timeout: 5000 });
140
+ });
141
141
 
142
- it('handles API errors', async () => {
142
+ it('handles API errors', { timeout: 5000 }, async () => {
143
143
  const error = new Error('API request denied');
144
144
  vi.mocked(googlePlacesUtils.fetchPlaceAutocomplete).mockRejectedValueOnce(error);
145
145
 
@@ -156,11 +156,11 @@ describe('useAddressAutocomplete', () => {
156
156
 
157
157
  expect(result.current.error?.message).toBe('API request denied');
158
158
  expect(result.current.suggestions).toEqual([]);
159
- }, { timeout: 5000 });
159
+ });
160
160
  });
161
161
 
162
162
  describe('Address selection', () => {
163
- it('selects address by place_id', async () => {
163
+ it('selects address by place_id', { timeout: 5000 }, async () => {
164
164
  const mockPlaceDetails = {
165
165
  place_id: 'ChIJ123',
166
166
  formatted_address: '123 Main St, Melbourne VIC 3000, Australia',
@@ -191,7 +191,7 @@ describe('useAddressAutocomplete', () => {
191
191
  expect(address).not.toBeNull();
192
192
  expect(address?.place_id).toBe('ChIJ123');
193
193
  expect(googlePlacesUtils.fetchPlaceDetails).toHaveBeenCalledWith('ChIJ123', mockApiKey);
194
- }, { timeout: 5000 });
194
+ });
195
195
 
196
196
  it('returns null when place_id is invalid', async () => {
197
197
  vi.mocked(googlePlacesUtils.fetchPlaceDetails).mockRejectedValueOnce(new Error('Place not found'));
@@ -205,7 +205,7 @@ describe('useAddressAutocomplete', () => {
205
205
  });
206
206
 
207
207
  describe('getAddressByPlaceId', () => {
208
- it('retrieves address by place_id', async () => {
208
+ it('retrieves address by place_id', { timeout: 5000 }, async () => {
209
209
  const mockAddress = {
210
210
  place_id: 'ChIJ123',
211
211
  full_address: '123 Main St',
@@ -227,7 +227,7 @@ describe('useAddressAutocomplete', () => {
227
227
 
228
228
  expect(address).not.toBeNull();
229
229
  expect(address?.place_id).toBe('ChIJ123');
230
- }, { timeout: 5000 });
230
+ });
231
231
 
232
232
  it('returns null on error', async () => {
233
233
  vi.mocked(googlePlacesUtils.getAddressByPlaceId).mockResolvedValueOnce(null);
@@ -237,11 +237,11 @@ describe('useAddressAutocomplete', () => {
237
237
  const address = await result.current.getAddressByPlaceId('invalid');
238
238
 
239
239
  expect(address).toBeNull();
240
- }, { timeout: 5000 });
240
+ });
241
241
  });
242
242
 
243
243
  describe('clearSuggestions', () => {
244
- it('clears suggestions', async () => {
244
+ it('clears suggestions', { timeout: 5000 }, async () => {
245
245
  vi.mocked(googlePlacesUtils.fetchPlaceAutocomplete).mockResolvedValueOnce([
246
246
  { description: '123 Main St', place_id: 'ChIJ123' },
247
247
  ]);
@@ -268,7 +268,7 @@ describe('useAddressAutocomplete', () => {
268
268
  },
269
269
  { timeout: 1000 }
270
270
  );
271
- }, { timeout: 5000 });
271
+ });
272
272
  });
273
273
 
274
274
  describe('Caching', () => {
@@ -312,7 +312,7 @@ describe('useAddressAutocomplete', () => {
312
312
  );
313
313
 
314
314
  expect(googlePlacesUtils.fetchPlaceAutocomplete).toHaveBeenCalled();
315
- }, { timeout: 5000 });
315
+ });
316
316
  });
317
317
  });
318
318
 
@@ -43,8 +43,11 @@ import { useLocation } from 'react-router-dom';
43
43
  import { EventServiceContext } from '../providers/services/EventServiceProvider';
44
44
  import { applyPalette, clearPalette } from '../theming/runtime';
45
45
  import { parseAndNormalizeEventColours } from '../theming/parseEventColours';
46
+ import { createLogger } from '../utils/core/logger';
46
47
  import type { Event } from '../types/event';
47
48
 
49
+ const log = createLogger('useEventTheme');
50
+
48
51
  /**
49
52
  * Hook that automatically applies event-specific theming
50
53
  *
@@ -144,7 +147,8 @@ export function useEventTheme(event?: Event | null): void {
144
147
  try {
145
148
  applyPalette(normalized);
146
149
  } catch (error) {
147
- // Silently fail - theming is not critical
150
+ // Log error but don't throw - theming is not critical
151
+ log.error('Failed to apply event palette:', error);
148
152
  }
149
153
 
150
154
  // Cleanup function to clear palette when component unmounts or event changes