diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 078b2812..81312d87 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -39,6 +39,14 @@ jobs: working-directory: frontend run: npm run stylelint + - name: Check formatting (Prettier) + working-directory: frontend + run: npm run format:check + - name: Run svelte-check working-directory: frontend run: npm run check + + - name: Verify production build + working-directory: frontend + run: npm run build diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c77fcbda..389bd80c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -46,10 +46,18 @@ repos: files: ^frontend/src/.*\.css$ pass_filenames: false + # Prettier - matches CI: cd frontend && npx prettier --check + - id: prettier-frontend + name: prettier (frontend) + entry: bash -c 'cd frontend && npx prettier --check "src/**/*.{ts,svelte,json}"' + language: system + files: ^frontend/src/.*\.(ts|svelte|json)$ + pass_filenames: false + # Svelte Check - matches CI: cd frontend && npx svelte-check - id: svelte-check-frontend name: svelte-check (frontend) - entry: bash -c 'cd frontend && npx svelte-check --tsconfig ./tsconfig.json' + entry: bash -c 'cd frontend && npx svelte-check --tsconfig ./tsconfig.json --fail-on-warnings' language: system files: ^frontend/src/.*\.(ts|svelte)$ pass_filenames: false diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 00000000..1ae07bbb --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,8 @@ +node_modules/ +public/build/ +dist/ +coverage/ +test-results/ +playwright-report/ +src/lib/api/ +*.css diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 00000000..e0a93da8 --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,8 @@ +{ + "singleQuote": true, + "tabWidth": 4, + "printWidth": 120, + "trailingComma": "all", + "plugins": ["prettier-plugin-svelte"], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 9ba89eea..10fb20ac 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -64,7 +64,7 @@ export default [ }, rules: { ...svelte.configs.recommended.rules, - 'svelte/button-has-type': 'warn', + 'svelte/button-has-type': 'error', 'no-unused-vars': 'off', 'no-undef': 'off', }, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7f622b6a..381e22bd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -64,6 +64,8 @@ "monocart-reporter": "^2.10.0", "postcss": "^8.4.47", "postcss-lightningcss": "^1.0.2", + "prettier": "^3.8.1", + "prettier-plugin-svelte": "^3.5.1", "rollup": "^4.59.0", "rollup-plugin-css-only": "^4.3.0", "rollup-plugin-livereload": "^2.0.0", @@ -7978,6 +7980,31 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.1.tgz", + "integrity": "sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg==", + "dev": true, + "peerDependencies": { + "prettier": "^3.0.0", + "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 22de3249..88cd8215 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,13 +9,15 @@ "start": "sirv public --single --no-clear --dev --host", "generate:api": "openapi-ts", "lint": "eslint src --ext .ts,.svelte", - "check": "svelte-check --tsconfig ./tsconfig.json", + "check": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings", "test": "vitest run", "test:watch": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", "test:e2e": "playwright test", - "stylelint": "stylelint \"src/**/*.css\"" + "stylelint": "stylelint \"src/**/*.css\"", + "format": "prettier --write \"src/**/*.{ts,svelte,json}\"", + "format:check": "prettier --check \"src/**/*.{ts,svelte,json}\"" }, "dependencies": { "@codemirror/autocomplete": "^6.17.0", @@ -69,20 +71,22 @@ "eslint-plugin-svelte": "^3.15.0", "express": "^5.2.1", "globals": "^17.3.0", - "jsdom": "^28.1.0", "http-proxy": "^1.18.1", + "jsdom": "^28.1.0", "monocart-reporter": "^2.10.0", "postcss": "^8.4.47", "postcss-lightningcss": "^1.0.2", + "prettier": "^3.8.1", + "prettier-plugin-svelte": "^3.5.1", "rollup": "^4.59.0", "rollup-plugin-css-only": "^4.3.0", "rollup-plugin-livereload": "^2.0.0", "rollup-plugin-postcss": "^4.0.2", "rollup-plugin-serve": "^3.0.0", "rollup-plugin-svelte": "^7.2.2", + "sirv-cli": "^3.0.1", "stylelint": "^17.0.0", "stylelint-config-standard": "^40.0.0", - "sirv-cli": "^3.0.1", "svelte-check": "^4.3.6", "svelte-eslint-parser": "^1.4.1", "svelte-preprocess": "^6.0.3", diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index f71ba4eb..c197c8bc 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,11 +1,11 @@ @@ -83,7 +83,7 @@ {:else}
-
+
{#if !authInitialized} @@ -94,6 +94,6 @@ {/if}
-
+
{/if} diff --git a/frontend/src/__tests__/test-utils.ts b/frontend/src/__tests__/test-utils.ts index 8bc33704..ae049433 100644 --- a/frontend/src/__tests__/test-utils.ts +++ b/frontend/src/__tests__/test-utils.ts @@ -10,25 +10,25 @@ import { vi, type Mock } from 'vitest'; import userEvent from '@testing-library/user-event'; import { EVENT_TYPES } from '$lib/admin/events/eventTypes'; import type { - ExecutionCompletedEvent, - EventBrowseResponse, - EventDetailResponse, - EventStatsResponse, - AdminUserOverview, - NotificationResponse, - EventMetadata, - EventType, - AdminExecutionResponse, - QueueStatusResponse, - SagaStatusResponse, - UserResponse, + ExecutionCompletedEvent, + EventBrowseResponse, + EventDetailResponse, + EventStatsResponse, + AdminUserOverview, + NotificationResponse, + EventMetadata, + EventType, + AdminExecutionResponse, + QueueStatusResponse, + SagaStatusResponse, + UserResponse, } from '$lib/api'; export type UserEventInstance = ReturnType; export const user: UserEventInstance = userEvent.setup({ - delay: null, - pointerEventsCheck: 0, + delay: null, + pointerEventsCheck: 0, }); /** @@ -36,17 +36,17 @@ export const user: UserEventInstance = userEvent.setup({ * Use this for type annotations in test files. */ export interface MockStore { - set(v: T): void; - subscribe(fn: (v: T) => void): () => void; - update(fn: (v: T) => T): void; - _getValue?(): T; + set(v: T): void; + subscribe(fn: (v: T) => void): () => void; + update(fn: (v: T) => T): void; + _getValue?(): T; } /** * Type definition for mock derived stores. */ export interface MockDerivedStore { - subscribe(fn: (v: T) => void): () => void; + subscribe(fn: (v: T) => void): () => void; } /** @@ -54,8 +54,8 @@ export interface MockDerivedStore { * Returns a function to restore the original behavior. */ export function suppressConsoleError(): () => void { - const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); - return () => spy.mockRestore(); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + return () => spy.mockRestore(); } /** @@ -63,8 +63,8 @@ export function suppressConsoleError(): () => void { * Returns a function to restore the original behavior. */ export function suppressConsoleWarn(): () => void { - const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - return () => spy.mockRestore(); + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + return () => spy.mockRestore(); } /** @@ -74,258 +74,281 @@ export function suppressConsoleWarn(): () => void { * @param components - Record mapping export names to HTML strings */ export function createMockNamedComponents(components: Record): Record { - const module: Record = {}; - for (const [name, html] of Object.entries(components)) { - const Mock = function () { - return {}; - } as unknown as { new (): object; render: () => { html: string; css: { code: string; map: null }; head: string } }; - Mock.render = () => ({ html, css: { code: '', map: null }, head: '' }); - module[name] = Mock; - } - return module; + const module: Record = {}; + for (const [name, html] of Object.entries(components)) { + const Mock = function () { + return {}; + } as unknown as { + new (): object; + render: () => { html: string; css: { code: string; map: null }; head: string }; + }; + Mock.render = () => ({ html, css: { code: '', map: null }, head: '' }); + module[name] = Mock; + } + return module; } /** * Creates a mock notification for testing. */ export function createMockNotification(overrides: Partial = {}): NotificationResponse { - return { - notification_id: 'notif-1', - subject: 'Test Notification', - body: 'This is a test notification body', - channel: 'in_app', - status: 'delivered', - severity: 'medium', - tags: [], - created_at: new Date().toISOString(), - action_url: '', - read_at: null, - ...overrides, - }; + return { + notification_id: 'notif-1', + subject: 'Test Notification', + body: 'This is a test notification body', + channel: 'in_app', + status: 'delivered', + severity: 'medium', + tags: [], + created_at: new Date().toISOString(), + action_url: '', + read_at: null, + ...overrides, + }; } /** * Creates multiple mock notifications for testing. */ export function createMockNotifications(count: number): NotificationResponse[] { - return Array.from({ length: count }, (_, i) => - createMockNotification({ - notification_id: `notif-${i + 1}`, - subject: `Notification ${i + 1}`, - body: `Body for notification ${i + 1}`, - status: i % 2 === 0 ? 'delivered' : 'read', - }) - ); + return Array.from({ length: count }, (_, i) => + createMockNotification({ + notification_id: `notif-${i + 1}`, + subject: `Notification ${i + 1}`, + body: `Body for notification ${i + 1}`, + status: i % 2 === 0 ? 'delivered' : 'read', + }), + ); } export function mockWindowGlobals(openMock: Mock, confirmMock: Mock): void { - vi.stubGlobal('open', openMock); - vi.stubGlobal('confirm', confirmMock); + vi.stubGlobal('open', openMock); + vi.stubGlobal('confirm', confirmMock); } export type MockEventOverrides = Omit, 'event_type' | 'metadata'> & { - event_type?: EventType; - metadata?: Partial; + event_type?: EventType; + metadata?: Partial; }; export const DEFAULT_EVENT: ExecutionCompletedEvent = { - event_id: 'evt-1', - event_type: 'execution_completed', - event_version: '1', - timestamp: '2024-01-15T10:30:00Z', - aggregate_id: 'exec-456', - metadata: { - service_name: 'test-service', - service_version: '1.0.0', - user_id: 'user-1', - }, - execution_id: 'exec-456', - exit_code: 0, - stdout: 'hello', + event_id: 'evt-1', + event_type: 'execution_completed', + event_version: '1', + timestamp: '2024-01-15T10:30:00Z', + aggregate_id: 'exec-456', + metadata: { + service_name: 'test-service', + service_version: '1.0.0', + user_id: 'user-1', + }, + execution_id: 'exec-456', + exit_code: 0, + stdout: 'hello', }; export { EVENT_TYPES }; export const createMockEvent = (overrides: MockEventOverrides = {}): ExecutionCompletedEvent => { - const { metadata: metadataOverrides, ...rest } = overrides; - return { - ...DEFAULT_EVENT, - ...rest, - metadata: { ...DEFAULT_EVENT.metadata, ...metadataOverrides }, - } as ExecutionCompletedEvent; + const { metadata: metadataOverrides, ...rest } = overrides; + return { + ...DEFAULT_EVENT, + ...rest, + metadata: { ...DEFAULT_EVENT.metadata, ...metadataOverrides }, + } as ExecutionCompletedEvent; }; export const createMockEvents = (count: number): EventBrowseResponse['events'] => - Array.from({ length: count }, (_, i) => ({ - ...createMockEvent({ - event_id: `evt-${i + 1}`, - aggregate_id: `exec-${i + 1}`, - metadata: { - user_id: `user-${(i % 3) + 1}`, - service_name: 'execution-service', - }, - }), - event_type: EVENT_TYPES[i % EVENT_TYPES.length], - timestamp: new Date(Date.now() - i * 60000).toISOString(), - } as EventBrowseResponse['events'][number])); + Array.from( + { length: count }, + (_, i) => + ({ + ...createMockEvent({ + event_id: `evt-${i + 1}`, + aggregate_id: `exec-${i + 1}`, + metadata: { + user_id: `user-${(i % 3) + 1}`, + service_name: 'execution-service', + }, + }), + event_type: EVENT_TYPES[i % EVENT_TYPES.length], + timestamp: new Date(Date.now() - i * 60000).toISOString(), + }) as EventBrowseResponse['events'][number], + ); export function createMockStats(overrides: Partial = {}): EventStatsResponse { - return { - total_events: 150, - error_rate: 2.5, - avg_processing_time: 1.23, - top_users: [{ user_id: 'user-1', event_count: 50 }], - events_by_type: [], - events_by_hour: [], - ...overrides, - }; + return { + total_events: 150, + error_rate: 2.5, + avg_processing_time: 1.23, + top_users: [{ user_id: 'user-1', event_count: 50 }], + events_by_type: [], + events_by_hour: [], + ...overrides, + }; } export function createMockEventDetail(event = createMockEvent()): EventDetailResponse { - return { - event: event as EventDetailResponse['event'], - related_events: [ - { event_id: 'rel-1', event_type: 'execution_started', timestamp: '2024-01-15T10:29:00Z' }, - { event_id: 'rel-2', event_type: 'pod_created', timestamp: '2024-01-15T10:29:30Z' }, - ], - timeline: [], - }; + return { + event: event as EventDetailResponse['event'], + related_events: [ + { event_id: 'rel-1', event_type: 'execution_started', timestamp: '2024-01-15T10:29:00Z' }, + { event_id: 'rel-2', event_type: 'pod_created', timestamp: '2024-01-15T10:29:30Z' }, + ], + timeline: [], + }; } export function createMockUserOverview(): AdminUserOverview { - return { - user: { - user_id: 'user-1', - username: 'testuser', - email: 'test@example.com', - role: 'user', - is_active: true, - is_superuser: false, - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - bypass_rate_limit: null, - global_multiplier: null, - has_custom_limits: null, - }, - stats: { - total_events: 100, - events_by_type: [], - events_by_service: [], - events_by_hour: [], - top_users: [], - error_rate: 0, - avg_processing_time: 0, - start_time: null, - end_time: null, - }, - derived_counts: { succeeded: 80, failed: 10, timeout: 5, cancelled: 5, terminal_total: 100 }, - rate_limit_summary: { bypass_rate_limit: false, global_multiplier: 1, has_custom_limits: false }, - recent_events: [createMockEvent() as AdminUserOverview['recent_events'][number]], - }; + return { + user: { + user_id: 'user-1', + username: 'testuser', + email: 'test@example.com', + role: 'user', + is_active: true, + is_superuser: false, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + bypass_rate_limit: null, + global_multiplier: null, + has_custom_limits: null, + }, + stats: { + total_events: 100, + events_by_type: [], + events_by_service: [], + events_by_hour: [], + top_users: [], + error_rate: 0, + avg_processing_time: 0, + start_time: null, + end_time: null, + }, + derived_counts: { succeeded: 80, failed: 10, timeout: 5, cancelled: 5, terminal_total: 100 }, + rate_limit_summary: { bypass_rate_limit: false, global_multiplier: 1, has_custom_limits: false }, + recent_events: [createMockEvent() as AdminUserOverview['recent_events'][number]], + }; } export const DEFAULT_EXECUTION: AdminExecutionResponse = { - execution_id: 'exec-1', - script: 'print("hi")', - status: 'queued', - lang: 'python', - lang_version: '3.11', - priority: 'normal', - user_id: 'user-1', - stdout: null, - stderr: null, - exit_code: null, - error_type: null, - created_at: '2024-01-15T10:30:00Z', - updated_at: '2024-01-15T10:30:00Z', + execution_id: 'exec-1', + script: 'print("hi")', + status: 'queued', + lang: 'python', + lang_version: '3.11', + priority: 'normal', + user_id: 'user-1', + stdout: null, + stderr: null, + exit_code: null, + error_type: null, + created_at: '2024-01-15T10:30:00Z', + updated_at: '2024-01-15T10:30:00Z', }; export const createMockExecution = (overrides: Partial = {}): AdminExecutionResponse => ({ - ...DEFAULT_EXECUTION, - ...overrides, + ...DEFAULT_EXECUTION, + ...overrides, }); const EXECUTION_STATUSES: AdminExecutionResponse['status'][] = [ - 'queued', 'scheduled', 'running', 'completed', 'failed', 'timeout', 'cancelled', 'error', -]; -const EXECUTION_PRIORITIES: AdminExecutionResponse['priority'][] = [ - 'critical', 'high', 'normal', 'low', 'background', + 'queued', + 'scheduled', + 'running', + 'completed', + 'failed', + 'timeout', + 'cancelled', + 'error', ]; +const EXECUTION_PRIORITIES: AdminExecutionResponse['priority'][] = ['critical', 'high', 'normal', 'low', 'background']; export const createMockExecutions = (count: number): AdminExecutionResponse[] => - Array.from({ length: count }, (_, i) => createMockExecution({ - execution_id: `exec-${i + 1}`, - status: EXECUTION_STATUSES[i % EXECUTION_STATUSES.length], - priority: EXECUTION_PRIORITIES[i % EXECUTION_PRIORITIES.length], - user_id: `user-${(i % 3) + 1}`, - created_at: new Date(Date.now() - i * 60000).toISOString(), - })); + Array.from({ length: count }, (_, i) => + createMockExecution({ + execution_id: `exec-${i + 1}`, + status: EXECUTION_STATUSES[i % EXECUTION_STATUSES.length], + priority: EXECUTION_PRIORITIES[i % EXECUTION_PRIORITIES.length], + user_id: `user-${(i % 3) + 1}`, + created_at: new Date(Date.now() - i * 60000).toISOString(), + }), + ); export const createMockQueueStatus = (overrides: Partial = {}): QueueStatusResponse => ({ - queue_depth: 5, - active_count: 2, - max_concurrent: 10, - by_priority: { normal: 3, high: 2 }, - ...overrides, + queue_depth: 5, + active_count: 2, + max_concurrent: 10, + by_priority: { normal: 3, high: 2 }, + ...overrides, }); export const DEFAULT_SAGA: SagaStatusResponse = { - saga_id: 'saga-1', - saga_name: 'execution_saga', - execution_id: 'exec-123', - state: 'running', - current_step: 'create_pod', - completed_steps: ['validate_execution', 'allocate_resources', 'queue_execution'], - compensated_steps: [], - retry_count: 0, - error_message: null, - created_at: '2024-01-15T10:30:00Z', - updated_at: '2024-01-15T10:31:00Z', - completed_at: null, + saga_id: 'saga-1', + saga_name: 'execution_saga', + execution_id: 'exec-123', + state: 'running', + current_step: 'create_pod', + completed_steps: ['validate_execution', 'allocate_resources', 'queue_execution'], + compensated_steps: [], + retry_count: 0, + error_message: null, + created_at: '2024-01-15T10:30:00Z', + updated_at: '2024-01-15T10:31:00Z', + completed_at: null, }; export const createMockSaga = (overrides: Partial = {}): SagaStatusResponse => ({ - ...DEFAULT_SAGA, - ...overrides, + ...DEFAULT_SAGA, + ...overrides, }); const SAGA_STATES: SagaStatusResponse['state'][] = [ - 'created', 'running', 'completed', 'failed', 'compensating', 'timeout', + 'created', + 'running', + 'completed', + 'failed', + 'compensating', + 'timeout', ]; export const createMockSagas = (count: number): SagaStatusResponse[] => - Array.from({ length: count }, (_, i) => createMockSaga({ - saga_id: `saga-${i + 1}`, - execution_id: `exec-${i + 1}`, - state: SAGA_STATES[i % SAGA_STATES.length], - created_at: new Date(Date.now() - i * 60000).toISOString(), - updated_at: new Date(Date.now() - i * 30000).toISOString(), - })); + Array.from({ length: count }, (_, i) => + createMockSaga({ + saga_id: `saga-${i + 1}`, + execution_id: `exec-${i + 1}`, + state: SAGA_STATES[i % SAGA_STATES.length], + created_at: new Date(Date.now() - i * 60000).toISOString(), + updated_at: new Date(Date.now() - i * 30000).toISOString(), + }), + ); export const DEFAULT_USER: UserResponse = { - user_id: 'user-1', - username: 'testuser', - email: 'test@example.com', - role: 'user', - is_active: true, - is_superuser: false, - created_at: '2024-01-15T10:30:00Z', - updated_at: '2024-01-15T10:30:00Z', - bypass_rate_limit: false, - global_multiplier: 1.0, - has_custom_limits: false, + user_id: 'user-1', + username: 'testuser', + email: 'test@example.com', + role: 'user', + is_active: true, + is_superuser: false, + created_at: '2024-01-15T10:30:00Z', + updated_at: '2024-01-15T10:30:00Z', + bypass_rate_limit: false, + global_multiplier: 1.0, + has_custom_limits: false, }; export const createMockUser = (overrides: Partial = {}): UserResponse => ({ - ...DEFAULT_USER, - ...overrides, + ...DEFAULT_USER, + ...overrides, }); export const createMockUsers = (count: number): UserResponse[] => - Array.from({ length: count }, (_, i) => createMockUser({ - user_id: `user-${i + 1}`, - username: `user${i + 1}`, - email: `user${i + 1}@example.com`, - role: i === 0 ? 'admin' : 'user', - is_active: i % 3 !== 0, - })); + Array.from({ length: count }, (_, i) => + createMockUser({ + user_id: `user-${i + 1}`, + username: `user${i + 1}`, + email: `user${i + 1}@example.com`, + role: i === 0 ? 'admin' : 'user', + is_active: i % 3 !== 0, + }), + ); diff --git a/frontend/src/components/ErrorDisplay.svelte b/frontend/src/components/ErrorDisplay.svelte index 77868c12..3870960f 100644 --- a/frontend/src/components/ErrorDisplay.svelte +++ b/frontend/src/components/ErrorDisplay.svelte @@ -1,73 +1,78 @@
-
- -
-
- -
-
+
+ +
+
+ +
+
- -

- {title} -

+ +

+ {title} +

- -

- {userMessage} -

+ +

+ {userMessage} +

- -
- - -
+ +
+ + +
- -

- If this problem persists, please contact support. -

-
+ +

+ If this problem persists, please contact support. +

+
diff --git a/frontend/src/components/EventTypeIcon.svelte b/frontend/src/components/EventTypeIcon.svelte index f00b03ac..0ac70e42 100644 --- a/frontend/src/components/EventTypeIcon.svelte +++ b/frontend/src/components/EventTypeIcon.svelte @@ -1,51 +1,51 @@ diff --git a/frontend/src/components/Footer.svelte b/frontend/src/components/Footer.svelte index ff1f9ca2..f203502a 100644 --- a/frontend/src/components/Footer.svelte +++ b/frontend/src/components/Footer.svelte @@ -2,15 +2,13 @@ import { Github, Send } from '@lucide/svelte'; - diff --git a/frontend/src/components/Header.svelte b/frontend/src/components/Header.svelte index 79428a28..04516d04 100644 --- a/frontend/src/components/Header.svelte +++ b/frontend/src/components/Header.svelte @@ -1,225 +1,307 @@ -
-
- +
-
- -
- - - - - {#if isMenuActive} -
-
- -
- {#if authStore.isAuthenticated} -
-
{authStore.username}
-
- {#if authStore.userRole === 'admin'} - Administrator - {:else} - Logged in - {/if} -
+ {#if isMenuActive} +
+
+
+ {#if authStore.isAuthenticated} +
+
+ {authStore.username} +
+
+ {#if authStore.userRole === 'admin'} + Administrator + {:else} + Logged in + {/if} +
+
+ {#if authStore.userRole === 'admin'} + + Admin Panel + + {/if} + + Settings + + + {:else} + + Login + + + Register + + {/if} +
- {#if authStore.userRole === 'admin'} - - Admin Panel - - {/if} - - Settings - - - {:else} - - Login - - - Register - - {/if}
-
-
- {/if} + {/if}
diff --git a/frontend/src/components/Modal.svelte b/frontend/src/components/Modal.svelte index f4adc3c8..f2742ff1 100644 --- a/frontend/src/components/Modal.svelte +++ b/frontend/src/components/Modal.svelte @@ -1,58 +1,57 @@ - open && e.key === 'Escape' && onClose()} /> - {#if open} - - {/if} diff --git a/frontend/src/components/Spinner.svelte b/frontend/src/components/Spinner.svelte index 2dabee59..55b1ed44 100644 --- a/frontend/src/components/Spinner.svelte +++ b/frontend/src/components/Spinner.svelte @@ -1,47 +1,40 @@ - - + + diff --git a/frontend/src/components/__tests__/ErrorDisplay.test.ts b/frontend/src/components/__tests__/ErrorDisplay.test.ts index c2ae5253..60e4c61c 100644 --- a/frontend/src/components/__tests__/ErrorDisplay.test.ts +++ b/frontend/src/components/__tests__/ErrorDisplay.test.ts @@ -4,146 +4,146 @@ import { user } from '$test/test-utils'; import ErrorDisplay from '$components/ErrorDisplay.svelte'; describe('ErrorDisplay', () => { - let originalLocation: Location; - - beforeEach(() => { - // Mock window.location - originalLocation = window.location; - Object.defineProperty(window, 'location', { - value: { - ...originalLocation, - reload: vi.fn(), - href: 'http://localhost:5001/test', - }, - writable: true, + let originalLocation: Location; + + beforeEach(() => { + // Mock window.location + originalLocation = window.location; + Object.defineProperty(window, 'location', { + value: { + ...originalLocation, + reload: vi.fn(), + href: 'http://localhost:5001/test', + }, + writable: true, + }); }); - }); - afterEach(() => { - Object.defineProperty(window, 'location', { - value: originalLocation, - writable: true, + afterEach(() => { + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + }); }); - }); - describe('rendering', () => { - it('renders with string error', () => { - render(ErrorDisplay, { props: { error: 'Something went wrong' } }); + describe('rendering', () => { + it('renders with string error', () => { + render(ErrorDisplay, { props: { error: 'Something went wrong' } }); - expect(screen.getByRole('heading')).toHaveTextContent('Application Error'); - }); + expect(screen.getByRole('heading')).toHaveTextContent('Application Error'); + }); - it('renders with Error object', () => { - render(ErrorDisplay, { props: { error: new Error('Test error') } }); + it('renders with Error object', () => { + render(ErrorDisplay, { props: { error: new Error('Test error') } }); - expect(screen.getByRole('heading')).toHaveTextContent('Application Error'); - }); + expect(screen.getByRole('heading')).toHaveTextContent('Application Error'); + }); - it('displays custom title', () => { - render(ErrorDisplay, { - props: { error: 'Error', title: 'Custom Error Title' } - }); + it('displays custom title', () => { + render(ErrorDisplay, { + props: { error: 'Error', title: 'Custom Error Title' }, + }); - expect(screen.getByRole('heading')).toHaveTextContent('Custom Error Title'); - }); + expect(screen.getByRole('heading')).toHaveTextContent('Custom Error Title'); + }); - it('uses default title when not provided', () => { - render(ErrorDisplay, { props: { error: 'Error' } }); + it('uses default title when not provided', () => { + render(ErrorDisplay, { props: { error: 'Error' } }); - expect(screen.getByRole('heading')).toHaveTextContent('Application Error'); + expect(screen.getByRole('heading')).toHaveTextContent('Application Error'); + }); }); - }); - describe('user-friendly messages', () => { - it('shows network error message for network errors', () => { - render(ErrorDisplay, { props: { error: 'Network connection failed' } }); + describe('user-friendly messages', () => { + it('shows network error message for network errors', () => { + render(ErrorDisplay, { props: { error: 'Network connection failed' } }); - expect(screen.getByText(/Unable to connect to the server/)).toBeInTheDocument(); - }); + expect(screen.getByText(/Unable to connect to the server/)).toBeInTheDocument(); + }); - it('shows network error message for fetch errors', () => { - render(ErrorDisplay, { props: { error: new Error('Fetch failed') } }); + it('shows network error message for fetch errors', () => { + render(ErrorDisplay, { props: { error: new Error('Fetch failed') } }); - expect(screen.getByText(/Unable to connect to the server/)).toBeInTheDocument(); - }); + expect(screen.getByText(/Unable to connect to the server/)).toBeInTheDocument(); + }); - it('shows network error message for connection errors', () => { - render(ErrorDisplay, { props: { error: 'Connection refused' } }); + it('shows network error message for connection errors', () => { + render(ErrorDisplay, { props: { error: 'Connection refused' } }); - expect(screen.getByText(/Unable to connect to the server/)).toBeInTheDocument(); - }); + expect(screen.getByText(/Unable to connect to the server/)).toBeInTheDocument(); + }); - it('shows generic message for non-network errors', () => { - render(ErrorDisplay, { props: { error: 'Internal server error' } }); + it('shows generic message for non-network errors', () => { + render(ErrorDisplay, { props: { error: 'Internal server error' } }); - expect(screen.getByText(/Something went wrong/)).toBeInTheDocument(); - }); + expect(screen.getByText(/Something went wrong/)).toBeInTheDocument(); + }); - it('never exposes raw error details to user', () => { - const sensitiveError = 'Database connection string: postgres://user:password@host'; - render(ErrorDisplay, { props: { error: sensitiveError } }); + it('never exposes raw error details to user', () => { + const sensitiveError = 'Database connection string: postgres://user:password@host'; + render(ErrorDisplay, { props: { error: sensitiveError } }); - expect(screen.queryByText(/postgres/)).not.toBeInTheDocument(); - expect(screen.queryByText(/password/)).not.toBeInTheDocument(); + expect(screen.queryByText(/postgres/)).not.toBeInTheDocument(); + expect(screen.queryByText(/password/)).not.toBeInTheDocument(); + }); }); - }); - describe('action buttons', () => { - it('renders Reload Page button', () => { - render(ErrorDisplay, { props: { error: 'Error' } }); + describe('action buttons', () => { + it('renders Reload Page button', () => { + render(ErrorDisplay, { props: { error: 'Error' } }); - expect(screen.getByRole('button', { name: /Reload Page/i })).toBeInTheDocument(); - }); + expect(screen.getByRole('button', { name: /Reload Page/i })).toBeInTheDocument(); + }); - it('renders Go to Home button', () => { - render(ErrorDisplay, { props: { error: 'Error' } }); + it('renders Go to Home button', () => { + render(ErrorDisplay, { props: { error: 'Error' } }); - expect(screen.getByRole('button', { name: /Go to Home/i })).toBeInTheDocument(); - }); + expect(screen.getByRole('button', { name: /Go to Home/i })).toBeInTheDocument(); + }); - it('reloads page when Reload button clicked', async () => { - render(ErrorDisplay, { props: { error: 'Error' } }); + it('reloads page when Reload button clicked', async () => { + render(ErrorDisplay, { props: { error: 'Error' } }); - const reloadButton = screen.getByRole('button', { name: /Reload Page/i }); - await user.click(reloadButton); + const reloadButton = screen.getByRole('button', { name: /Reload Page/i }); + await user.click(reloadButton); - expect(window.location.reload).toHaveBeenCalled(); - }); + expect(window.location.reload).toHaveBeenCalled(); + }); - it('navigates to home when Go to Home clicked', async () => { - render(ErrorDisplay, { props: { error: 'Error' } }); + it('navigates to home when Go to Home clicked', async () => { + render(ErrorDisplay, { props: { error: 'Error' } }); - const homeButton = screen.getByRole('button', { name: /Go to Home/i }); - await user.click(homeButton); + const homeButton = screen.getByRole('button', { name: /Go to Home/i }); + await user.click(homeButton); - expect(window.location.href).toBe('/'); + expect(window.location.href).toBe('/'); + }); }); - }); - describe('visual elements', () => { - it('displays error icon', () => { - render(ErrorDisplay, { props: { error: 'Error' } }); + describe('visual elements', () => { + it('displays error icon', () => { + render(ErrorDisplay, { props: { error: 'Error' } }); - const svg = document.querySelector('svg'); - expect(svg).toBeInTheDocument(); - }); + const svg = document.querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); - it('displays help text', () => { - render(ErrorDisplay, { props: { error: 'Error' } }); + it('displays help text', () => { + render(ErrorDisplay, { props: { error: 'Error' } }); - expect(screen.getByText(/If this problem persists/)).toBeInTheDocument(); + expect(screen.getByText(/If this problem persists/)).toBeInTheDocument(); + }); }); - }); - describe('styling', () => { - it('has full-screen centered layout', () => { - const { container } = render(ErrorDisplay, { props: { error: 'Error' } }); + describe('styling', () => { + it('has full-screen centered layout', () => { + const { container } = render(ErrorDisplay, { props: { error: 'Error' } }); - const wrapper = container.firstChild as HTMLElement; - expect(wrapper.classList.contains('min-h-screen')).toBe(true); - expect(wrapper.classList.contains('flex')).toBe(true); - expect(wrapper.classList.contains('items-center')).toBe(true); - expect(wrapper.classList.contains('justify-center')).toBe(true); + const wrapper = container.firstChild as HTMLElement; + expect(wrapper.classList.contains('min-h-screen')).toBe(true); + expect(wrapper.classList.contains('flex')).toBe(true); + expect(wrapper.classList.contains('items-center')).toBe(true); + expect(wrapper.classList.contains('justify-center')).toBe(true); + }); }); - }); }); diff --git a/frontend/src/components/__tests__/EventTypeIcon.test.ts b/frontend/src/components/__tests__/EventTypeIcon.test.ts index 78000c27..7b3345f2 100644 --- a/frontend/src/components/__tests__/EventTypeIcon.test.ts +++ b/frontend/src/components/__tests__/EventTypeIcon.test.ts @@ -3,70 +3,70 @@ import { render } from '@testing-library/svelte'; import EventTypeIcon from '$components/EventTypeIcon.svelte'; describe('EventTypeIcon', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('known event types', () => { - it.each([ - 'execution.requested', - 'execution_requested', - 'execution.started', - 'execution_started', - 'execution.completed', - 'execution_completed', - 'execution.failed', - 'execution_failed', - 'execution.timeout', - 'execution_timeout', - 'pod.created', - 'pod_created', - 'pod.running', - 'pod_running', - 'pod.succeeded', - 'pod_succeeded', - 'pod.failed', - 'pod_failed', - 'pod.terminated', - 'pod_terminated', - ])('renders SVG for "%s"', (eventType) => { - const { container } = render(EventTypeIcon, { props: { eventType } }); - const svg = container.querySelector('svg'); - expect(svg).toBeInTheDocument(); - expect(svg?.classList.contains('lucide-help-circle')).toBe(false); + beforeEach(() => { + vi.clearAllMocks(); }); - }); - describe('unknown event type', () => { - it('renders fallback icon for unknown type', () => { - const { container } = render(EventTypeIcon, { props: { eventType: 'unknown.event' } }); - expect(container.querySelector('svg')).toBeInTheDocument(); + describe('known event types', () => { + it.each([ + 'execution.requested', + 'execution_requested', + 'execution.started', + 'execution_started', + 'execution.completed', + 'execution_completed', + 'execution.failed', + 'execution_failed', + 'execution.timeout', + 'execution_timeout', + 'pod.created', + 'pod_created', + 'pod.running', + 'pod_running', + 'pod.succeeded', + 'pod_succeeded', + 'pod.failed', + 'pod_failed', + 'pod.terminated', + 'pod_terminated', + ])('renders SVG for "%s"', (eventType) => { + const { container } = render(EventTypeIcon, { props: { eventType } }); + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + expect(svg?.classList.contains('lucide-help-circle')).toBe(false); + }); }); - it('renders a different icon than known types', () => { - const { container: unknownContainer } = render(EventTypeIcon, { - props: { eventType: 'unknown.event' }, - }); - const { container: knownContainer } = render(EventTypeIcon, { - props: { eventType: 'execution.started' }, - }); - expect(unknownContainer.querySelector('svg')?.innerHTML).not.toBe( - knownContainer.querySelector('svg')?.innerHTML - ); + describe('unknown event type', () => { + it('renders fallback icon for unknown type', () => { + const { container } = render(EventTypeIcon, { props: { eventType: 'unknown.event' } }); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + + it('renders a different icon than known types', () => { + const { container: unknownContainer } = render(EventTypeIcon, { + props: { eventType: 'unknown.event' }, + }); + const { container: knownContainer } = render(EventTypeIcon, { + props: { eventType: 'execution.started' }, + }); + expect(unknownContainer.querySelector('svg')?.innerHTML).not.toBe( + knownContainer.querySelector('svg')?.innerHTML, + ); + }); }); - }); - describe('size prop', () => { - it.each([ - { size: undefined, expected: '20', desc: 'defaults to 20' }, - { size: 32, expected: '32', desc: 'passes custom size' }, - ])('$desc', ({ size, expected }) => { - const { container } = render(EventTypeIcon, { - props: { eventType: 'execution.started', ...(size ? { size } : {}) }, - }); - const svg = container.querySelector('svg'); - expect(svg).toHaveAttribute('width', expected); - expect(svg).toHaveAttribute('height', expected); + describe('size prop', () => { + it.each([ + { size: undefined, expected: '20', desc: 'defaults to 20' }, + { size: 32, expected: '32', desc: 'passes custom size' }, + ])('$desc', ({ size, expected }) => { + const { container } = render(EventTypeIcon, { + props: { eventType: 'execution.started', ...(size ? { size } : {}) }, + }); + const svg = container.querySelector('svg'); + expect(svg).toHaveAttribute('width', expected); + expect(svg).toHaveAttribute('height', expected); + }); }); - }); }); diff --git a/frontend/src/components/__tests__/Footer.test.ts b/frontend/src/components/__tests__/Footer.test.ts index 7b827204..bf1d545d 100644 --- a/frontend/src/components/__tests__/Footer.test.ts +++ b/frontend/src/components/__tests__/Footer.test.ts @@ -3,177 +3,177 @@ import { render, screen } from '@testing-library/svelte'; import Footer from '$components/Footer.svelte'; describe('Footer', () => { - let originalDate: DateConstructor; - - beforeEach(() => { - // Mock Date to have consistent year in tests - originalDate = globalThis.Date; - const mockDate = class extends Date { - constructor() { - super('2025-01-01T00:00:00.000Z'); - } - static now() { - return new originalDate('2025-01-01T00:00:00.000Z').getTime(); - } - }; - vi.stubGlobal('Date', mockDate); - }); - - afterEach(() => { - vi.stubGlobal('Date', originalDate); - }); - - describe('branding', () => { - it('displays brand name', () => { - render(Footer); - - expect(screen.getByRole('heading', { name: /Integr8sCode/i })).toBeInTheDocument(); - }); + let originalDate: DateConstructor; - it('displays tagline', () => { - render(Footer); + beforeEach(() => { + // Mock Date to have consistent year in tests + originalDate = globalThis.Date; + const mockDate = class extends Date { + constructor() { + super('2025-01-01T00:00:00.000Z'); + } + static now() { + return new originalDate('2025-01-01T00:00:00.000Z').getTime(); + } + }; + vi.stubGlobal('Date', mockDate); + }); - expect(screen.getByText(/Run code online with ease and security/i)).toBeInTheDocument(); + afterEach(() => { + vi.stubGlobal('Date', originalDate); }); - }); - describe('navigation links', () => { - it('displays Navigation section', () => { - render(Footer); + describe('branding', () => { + it('displays brand name', () => { + render(Footer); - expect(screen.getByRole('heading', { name: /Navigation/i })).toBeInTheDocument(); - }); + expect(screen.getByRole('heading', { name: /Integr8sCode/i })).toBeInTheDocument(); + }); - it('has Home link', () => { - render(Footer); + it('displays tagline', () => { + render(Footer); - const homeLink = screen.getByRole('link', { name: /^Home$/i }); - expect(homeLink).toBeInTheDocument(); - expect(homeLink.getAttribute('href')).toBe('/'); + expect(screen.getByText(/Run code online with ease and security/i)).toBeInTheDocument(); + }); }); - it('has Code Editor link', () => { - render(Footer); + describe('navigation links', () => { + it('displays Navigation section', () => { + render(Footer); - const editorLink = screen.getByRole('link', { name: /Code Editor/i }); - expect(editorLink).toBeInTheDocument(); - expect(editorLink.getAttribute('href')).toBe('/editor'); - }); - }); + expect(screen.getByRole('heading', { name: /Navigation/i })).toBeInTheDocument(); + }); - describe('tools and info section', () => { - it('displays Tools & Info section', () => { - render(Footer); + it('has Home link', () => { + render(Footer); - expect(screen.getByRole('heading', { name: /Tools & Info/i })).toBeInTheDocument(); - }); + const homeLink = screen.getByRole('link', { name: /^Home$/i }); + expect(homeLink).toBeInTheDocument(); + expect(homeLink.getAttribute('href')).toBe('/'); + }); - it('has Grafana link with external attributes', () => { - render(Footer); + it('has Code Editor link', () => { + render(Footer); - const grafanaLink = screen.getByRole('link', { name: /Grafana/i }); - expect(grafanaLink).toBeInTheDocument(); - expect(grafanaLink.getAttribute('target')).toBe('_blank'); - expect(grafanaLink.getAttribute('rel')).toContain('noopener'); + const editorLink = screen.getByRole('link', { name: /Code Editor/i }); + expect(editorLink).toBeInTheDocument(); + expect(editorLink.getAttribute('href')).toBe('/editor'); + }); }); - it('has Privacy Policy link', () => { - render(Footer); + describe('tools and info section', () => { + it('displays Tools & Info section', () => { + render(Footer); - const privacyLink = screen.getByRole('link', { name: /Privacy Policy/i }); - expect(privacyLink).toBeInTheDocument(); - expect(privacyLink.getAttribute('href')).toBe('/privacy'); - }); - }); + expect(screen.getByRole('heading', { name: /Tools & Info/i })).toBeInTheDocument(); + }); - describe('social links', () => { - it('has Telegram link with accessibility', () => { - render(Footer); + it('has Grafana link with external attributes', () => { + render(Footer); - const telegramLink = screen.getByRole('link', { name: /Telegram/i }); - expect(telegramLink).toBeInTheDocument(); - expect(telegramLink.getAttribute('href')).toBe('https://t.me/MaxAzatian'); - expect(telegramLink.getAttribute('target')).toBe('_blank'); - expect(telegramLink.getAttribute('rel')).toContain('noopener'); - }); + const grafanaLink = screen.getByRole('link', { name: /Grafana/i }); + expect(grafanaLink).toBeInTheDocument(); + expect(grafanaLink.getAttribute('target')).toBe('_blank'); + expect(grafanaLink.getAttribute('rel')).toContain('noopener'); + }); - it('has GitHub link with accessibility', () => { - render(Footer); + it('has Privacy Policy link', () => { + render(Footer); - const githubLink = screen.getByRole('link', { name: /GitHub/i }); - expect(githubLink).toBeInTheDocument(); - expect(githubLink.getAttribute('href')).toBe('https://github.com/HardMax71/Integr8sCode'); - expect(githubLink.getAttribute('target')).toBe('_blank'); - expect(githubLink.getAttribute('rel')).toContain('noopener'); + const privacyLink = screen.getByRole('link', { name: /Privacy Policy/i }); + expect(privacyLink).toBeInTheDocument(); + expect(privacyLink.getAttribute('href')).toBe('/privacy'); + }); }); - it('has screen reader text for social icons', () => { - render(Footer); + describe('social links', () => { + it('has Telegram link with accessibility', () => { + render(Footer); - // Check for sr-only spans - expect(screen.getByText('Telegram', { selector: '.sr-only' })).toBeInTheDocument(); - expect(screen.getByText('GitHub', { selector: '.sr-only' })).toBeInTheDocument(); - }); - }); + const telegramLink = screen.getByRole('link', { name: /Telegram/i }); + expect(telegramLink).toBeInTheDocument(); + expect(telegramLink.getAttribute('href')).toBe('https://t.me/MaxAzatian'); + expect(telegramLink.getAttribute('target')).toBe('_blank'); + expect(telegramLink.getAttribute('rel')).toContain('noopener'); + }); - describe('copyright', () => { - it('displays current year', () => { - render(Footer); + it('has GitHub link with accessibility', () => { + render(Footer); - expect(screen.getByText(/ยฉ 2025 Integr8sCode/)).toBeInTheDocument(); - }); + const githubLink = screen.getByRole('link', { name: /GitHub/i }); + expect(githubLink).toBeInTheDocument(); + expect(githubLink.getAttribute('href')).toBe('https://github.com/HardMax71/Integr8sCode'); + expect(githubLink.getAttribute('target')).toBe('_blank'); + expect(githubLink.getAttribute('rel')).toContain('noopener'); + }); - it('credits the author', () => { - render(Footer); + it('has screen reader text for social icons', () => { + render(Footer); - expect(screen.getByText(/Max Azatian/)).toBeInTheDocument(); + // Check for sr-only spans + expect(screen.getByText('Telegram', { selector: '.sr-only' })).toBeInTheDocument(); + expect(screen.getByText('GitHub', { selector: '.sr-only' })).toBeInTheDocument(); + }); }); - it('includes rights reserved text', () => { - render(Footer); + describe('copyright', () => { + it('displays current year', () => { + render(Footer); - expect(screen.getByText(/All rights reserved/)).toBeInTheDocument(); - }); - }); + expect(screen.getByText(/ยฉ 2025 Integr8sCode/)).toBeInTheDocument(); + }); + + it('credits the author', () => { + render(Footer); + + expect(screen.getByText(/Max Azatian/)).toBeInTheDocument(); + }); - describe('accessibility', () => { - it('uses semantic footer element', () => { - const { container } = render(Footer); + it('includes rights reserved text', () => { + render(Footer); - expect(container.querySelector('footer')).toBeInTheDocument(); + expect(screen.getByText(/All rights reserved/)).toBeInTheDocument(); + }); }); - it('has proper heading hierarchy', () => { - render(Footer); + describe('accessibility', () => { + it('uses semantic footer element', () => { + const { container } = render(Footer); - const h2 = screen.getByRole('heading', { level: 2 }); - expect(h2).toHaveTextContent('Integr8sCode'); + expect(container.querySelector('footer')).toBeInTheDocument(); + }); - const h3s = screen.getAllByRole('heading', { level: 3 }); - expect(h3s.length).toBeGreaterThanOrEqual(2); - }); + it('has proper heading hierarchy', () => { + render(Footer); + + const h2 = screen.getByRole('heading', { level: 2 }); + expect(h2).toHaveTextContent('Integr8sCode'); + + const h3s = screen.getAllByRole('heading', { level: 3 }); + expect(h3s.length).toBeGreaterThanOrEqual(2); + }); - it('all external links have noopener noreferrer', () => { - render(Footer); + it('all external links have noopener noreferrer', () => { + render(Footer); - const externalLinks = screen.getAllByRole('link').filter( - link => link.getAttribute('target') === '_blank' - ); + const externalLinks = screen + .getAllByRole('link') + .filter((link) => link.getAttribute('target') === '_blank'); - externalLinks.forEach(link => { - expect(link.getAttribute('rel')).toContain('noopener'); - expect(link.getAttribute('rel')).toContain('noreferrer'); - }); + externalLinks.forEach((link) => { + expect(link.getAttribute('rel')).toContain('noopener'); + expect(link.getAttribute('rel')).toContain('noreferrer'); + }); + }); }); - }); - describe('responsive design', () => { - it('has responsive grid classes', () => { - const { container } = render(Footer); + describe('responsive design', () => { + it('has responsive grid classes', () => { + const { container } = render(Footer); - const grid = container.querySelector('.grid'); - expect(grid).toBeInTheDocument(); - expect(grid?.classList.contains('md:grid-cols-12')).toBe(true); + const grid = container.querySelector('.grid'); + expect(grid).toBeInTheDocument(); + expect(grid?.classList.contains('md:grid-cols-12')).toBe(true); + }); }); - }); }); diff --git a/frontend/src/components/__tests__/Header.test.ts b/frontend/src/components/__tests__/Header.test.ts index 4aaccc45..e74cd8b2 100644 --- a/frontend/src/components/__tests__/Header.test.ts +++ b/frontend/src/components/__tests__/Header.test.ts @@ -5,274 +5,324 @@ import { user, suppressConsoleError } from '$test/test-utils'; import * as router from '@mateothegreat/svelte5-router'; const mocks = vi.hoisted(() => ({ - mockAuthStore: { - isAuthenticated: false as boolean | null, - username: null as string | null, - userRole: null as string | null, - userEmail: null as string | null, - userId: null as string | null, - csrfToken: null as string | null, - logout: vi.fn(), - login: vi.fn(), - verifyAuth: vi.fn(), - }, - mockThemeStore: { - value: 'auto' as string, - }, - mockToggleTheme: vi.fn(), + mockAuthStore: { + isAuthenticated: false as boolean | null, + username: null as string | null, + userRole: null as string | null, + userEmail: null as string | null, + userId: null as string | null, + csrfToken: null as string | null, + logout: vi.fn(), + login: vi.fn(), + verifyAuth: vi.fn(), + }, + mockThemeStore: { + value: 'auto' as string, + }, + mockToggleTheme: vi.fn(), })); vi.mock('../../stores/auth.svelte', () => ({ - get authStore() { return mocks.mockAuthStore; }, + get authStore() { + return mocks.mockAuthStore; + }, })); vi.mock('../../stores/theme.svelte', () => ({ - get themeStore() { return mocks.mockThemeStore; }, - get toggleTheme() { return mocks.mockToggleTheme; }, + get themeStore() { + return mocks.mockThemeStore; + }, + get toggleTheme() { + return mocks.mockToggleTheme; + }, })); vi.mock('../NotificationCenter.svelte', () => { - const M = function() { return {}; } as any; - M.render = () => ({ html: '
NotificationCenter
', css: { code: '', map: null }, head: '' }); - return { default: M }; + const M = function () { + return {}; + } as any; + M.render = () => ({ + html: '
NotificationCenter
', + css: { code: '', map: null }, + head: '', + }); + return { default: M }; }); import Header from '$components/Header.svelte'; // Test helpers -const setAuth = (isAuth: boolean, username: string | null = null, role: string | null = null, email: string | null = null) => { - mocks.mockAuthStore.isAuthenticated = isAuth; - mocks.mockAuthStore.username = username; - mocks.mockAuthStore.userRole = role; - mocks.mockAuthStore.userEmail = email; +const setAuth = ( + isAuth: boolean, + username: string | null = null, + role: string | null = null, + email: string | null = null, +) => { + mocks.mockAuthStore.isAuthenticated = isAuth; + mocks.mockAuthStore.username = username; + mocks.mockAuthStore.userRole = role; + mocks.mockAuthStore.userEmail = email; }; describe('Header', () => { - const openUserDropdown = async () => { - render(Header); - await user.click(screen.getByRole('button', { name: 'User menu' })); - }; - - const openMobileMenu = async () => { - render(Header); - await user.click(screen.getByRole('button', { name: 'Open menu' })); - }; - - let originalInnerWidth: number; - - beforeEach(() => { - setAuth(false); - mocks.mockThemeStore.value = 'auto'; - mocks.mockAuthStore.logout.mockReset(); - mocks.mockToggleTheme.mockReset(); - vi.spyOn(router, 'goto'); - originalInnerWidth = window.innerWidth; - Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1200 }); - Object.defineProperty(window, 'matchMedia', { - writable: true, - value: vi.fn().mockImplementation((query: string) => ({ - matches: false, media: query, onchange: null, - addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), - })), - }); - }); - - afterEach(() => { - Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: originalInnerWidth }); - }); - - describe('branding', () => { - it('displays logo with link to home page', () => { - render(Header); - const logo = screen.getByAltText('Integr8sCode Logo'); - expect(logo).toBeInTheDocument(); - expect(logo.getAttribute('src')).toBe('/favicon.png'); - expect(screen.getByText('Integr8sCode')).toBeInTheDocument(); - expect(screen.getByRole('link', { name: /Integr8sCode/i }).getAttribute('href')).toBe('/'); - }); - }); - - describe('theme toggle', () => { - it('renders and calls toggleTheme when clicked', async () => { - render(Header); - const themeButton = screen.getByTitle('Toggle theme'); - expect(themeButton).toBeInTheDocument(); - await user.click(themeButton); - expect(mocks.mockToggleTheme).toHaveBeenCalled(); - }); + const openUserDropdown = async () => { + render(Header); + await user.click(screen.getByRole('button', { name: 'User menu' })); + }; - it.each([ - { theme: 'light', iconClass: 'lucide-sun' }, - { theme: 'dark', iconClass: 'lucide-moon' }, - { theme: 'auto', iconClass: 'lucide-monitor-cog' }, - ])('shows correct icon for $theme theme', async ({ theme, iconClass }) => { - mocks.mockThemeStore.value = theme; - const { container } = render(Header); - await waitFor(() => { - const svg = container.querySelector('[title="Toggle theme"] svg'); - expect(svg?.classList.contains(iconClass)).toBe(true); - }); - }); - }); - - describe('unauthenticated state', () => { - it('shows Login and Register buttons, no user dropdown', () => { - render(Header); - expect(screen.getByRole('link', { name: /^Login$/i })).toHaveAttribute('href', '/login'); - expect(screen.getByRole('link', { name: /^Register$/i })).toHaveAttribute('href', '/register'); - expect(screen.queryByText('Settings')).not.toBeInTheDocument(); - }); - }); - - describe('authenticated state', () => { - beforeEach(() => { setAuth(true, 'testuser', 'user', 'test@example.com'); }); - - it('shows username and opens dropdown with user info', async () => { - await openUserDropdown(); - await waitFor(() => { - expect(screen.getAllByText(/testuser/i).length).toBeGreaterThan(0); - expect(screen.getByText('test@example.com')).toBeInTheDocument(); - expect(screen.getByText('T')).toBeInTheDocument(); // initial - expect(screen.getByRole('link', { name: /Settings/i })).toHaveAttribute('href', '/settings'); - expect(screen.getByRole('button', { name: /Logout/i })).toBeInTheDocument(); - }); - }); + const openMobileMenu = async () => { + render(Header); + await user.click(screen.getByRole('button', { name: 'Open menu' })); + }; - it('shows "No email set" when email is null', async () => { - mocks.mockAuthStore.userEmail = null; - await openUserDropdown(); - await waitFor(() => { expect(screen.getByText('No email set')).toBeInTheDocument(); }); - }); + let originalInnerWidth: number; - it('logout calls logout and redirects', async () => { - await openUserDropdown(); - await waitFor(() => { expect(screen.getByRole('button', { name: /Logout/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /Logout/i })); - expect(mocks.mockAuthStore.logout).toHaveBeenCalled(); - expect(router.goto).toHaveBeenCalledWith('/login'); + beforeEach(() => { + setAuth(false); + mocks.mockThemeStore.value = 'auto'; + mocks.mockAuthStore.logout.mockReset(); + mocks.mockToggleTheme.mockReset(); + vi.spyOn(router, 'goto'); + originalInnerWidth = window.innerWidth; + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1200 }); + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); }); - }); - - describe('admin user', () => { - beforeEach(() => { setAuth(true, 'admin', 'admin', 'admin@example.com'); }); - it('shows Admin indicator and button in dropdown', async () => { - await openUserDropdown(); - await waitFor(() => { - expect(screen.getByText('(Admin)')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /^Admin$/ })).toBeInTheDocument(); - }); + afterEach(() => { + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: originalInnerWidth }); }); - it('Admin button navigates to admin panel', async () => { - await openUserDropdown(); - await waitFor(() => { expect(screen.getByRole('button', { name: /^Admin$/ })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /^Admin$/ })); - await waitFor(() => { expect(router.goto).toHaveBeenCalledWith('/admin/events'); }); + describe('branding', () => { + it('displays logo with link to home page', () => { + render(Header); + const logo = screen.getByAltText('Integr8sCode Logo'); + expect(logo).toBeInTheDocument(); + expect(logo.getAttribute('src')).toBe('/favicon.png'); + expect(screen.getByText('Integr8sCode')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Integr8sCode/i }).getAttribute('href')).toBe('/'); + }); }); - }); - describe('mobile menu', () => { - beforeEach(() => { - Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 800 }); + describe('theme toggle', () => { + it('renders and calls toggleTheme when clicked', async () => { + render(Header); + const themeButton = screen.getByTitle('Toggle theme'); + expect(themeButton).toBeInTheDocument(); + await user.click(themeButton); + expect(mocks.mockToggleTheme).toHaveBeenCalled(); + }); + + it.each([ + { theme: 'light', iconClass: 'lucide-sun' }, + { theme: 'dark', iconClass: 'lucide-moon' }, + { theme: 'auto', iconClass: 'lucide-monitor-cog' }, + ])('shows correct icon for $theme theme', async ({ theme, iconClass }) => { + mocks.mockThemeStore.value = theme; + const { container } = render(Header); + await waitFor(() => { + const svg = container.querySelector('[title="Toggle theme"] svg'); + expect(svg?.classList.contains(iconClass)).toBe(true); + }); + }); }); - it('shows hamburger menu and toggles on click', async () => { - await openMobileMenu(); - await waitFor(() => { expect(screen.getByTestId('mobile-menu')).toBeInTheDocument(); }); + describe('unauthenticated state', () => { + it('shows Login and Register buttons, no user dropdown', () => { + render(Header); + expect(screen.getByRole('link', { name: /^Login$/i })).toHaveAttribute('href', '/login'); + expect(screen.getByRole('link', { name: /^Register$/i })).toHaveAttribute('href', '/register'); + expect(screen.queryByText('Settings')).not.toBeInTheDocument(); + }); }); - it.each([ - { isAuth: false, username: null, role: null, expectedContent: ['Login', 'Register'] }, - { isAuth: true, username: 'mobileuser', role: 'user', expectedContent: ['mobileuser', 'Settings', 'Logout'] }, - { isAuth: true, username: 'admin', role: 'admin', expectedContent: ['Admin Panel', 'Administrator'] }, - ])('shows correct content for auth=$isAuth role=$role', async ({ isAuth, username, role, expectedContent }) => { - setAuth(isAuth, username, role); - await openMobileMenu(); - await waitFor(() => { - const mobileMenu = screen.getByTestId('mobile-menu'); - expectedContent.forEach(text => expect(mobileMenu.textContent).toContain(text)); - }); - }); - }); - - describe('header structure', () => { - it('has fixed header with nav and backdrop blur', () => { - render(Header); - const header = screen.getByRole('banner'); - expect(header).toBeInTheDocument(); - expect(header).toHaveClass('fixed'); - expect(header).toHaveClass('top-0'); - expect(header).toHaveClass('backdrop-blur-md'); - expect(screen.getByRole('navigation')).toBeInTheDocument(); + describe('authenticated state', () => { + beforeEach(() => { + setAuth(true, 'testuser', 'user', 'test@example.com'); + }); + + it('shows username and opens dropdown with user info', async () => { + await openUserDropdown(); + await waitFor(() => { + expect(screen.getAllByText(/testuser/i).length).toBeGreaterThan(0); + expect(screen.getByText('test@example.com')).toBeInTheDocument(); + expect(screen.getByText('T')).toBeInTheDocument(); // initial + expect(screen.getByRole('link', { name: /Settings/i })).toHaveAttribute('href', '/settings'); + expect(screen.getByRole('button', { name: /Logout/i })).toBeInTheDocument(); + }); + }); + + it('shows "No email set" when email is null', async () => { + mocks.mockAuthStore.userEmail = null; + await openUserDropdown(); + await waitFor(() => { + expect(screen.getByText('No email set')).toBeInTheDocument(); + }); + }); + + it('logout calls logout and redirects', async () => { + await openUserDropdown(); + await waitFor(() => { + expect(screen.getByRole('button', { name: /Logout/i })).toBeInTheDocument(); + }); + await user.click(screen.getByRole('button', { name: /Logout/i })); + expect(mocks.mockAuthStore.logout).toHaveBeenCalled(); + expect(router.goto).toHaveBeenCalledWith('/login'); + }); }); - }); - - describe('dropdown toggle behavior', () => { - it('closes dropdown when clicking a menu item', async () => { - const restoreConsole = suppressConsoleError(); - setAuth(true, 'testuser', 'user'); - await openUserDropdown(); - await waitFor(() => { expect(screen.getByRole('link', { name: /Settings/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('link', { name: /Settings/i })); - await waitFor(() => { expect(screen.queryByRole('link', { name: /Settings/i })).not.toBeInTheDocument(); }); - restoreConsole(); + + describe('admin user', () => { + beforeEach(() => { + setAuth(true, 'admin', 'admin', 'admin@example.com'); + }); + + it('shows Admin indicator and button in dropdown', async () => { + await openUserDropdown(); + await waitFor(() => { + expect(screen.getByText('(Admin)')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Admin$/ })).toBeInTheDocument(); + }); + }); + + it('Admin button navigates to admin panel', async () => { + await openUserDropdown(); + await waitFor(() => { + expect(screen.getByRole('button', { name: /^Admin$/ })).toBeInTheDocument(); + }); + await user.click(screen.getByRole('button', { name: /^Admin$/ })); + await waitFor(() => { + expect(router.goto).toHaveBeenCalledWith('/admin/events'); + }); + }); }); - it('closes dropdown when clicking outside', async () => { - setAuth(true, 'testuser', 'user'); - await openUserDropdown(); - await waitFor(() => { expect(screen.getByRole('link', { name: /Settings/i })).toBeInTheDocument(); }); + describe('mobile menu', () => { + beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 800 }); + }); + + it('shows hamburger menu and toggles on click', async () => { + await openMobileMenu(); + await waitFor(() => { + expect(screen.getByTestId('mobile-menu')).toBeInTheDocument(); + }); + }); + + it.each([ + { isAuth: false, username: null, role: null, expectedContent: ['Login', 'Register'] }, + { + isAuth: true, + username: 'mobileuser', + role: 'user', + expectedContent: ['mobileuser', 'Settings', 'Logout'], + }, + { isAuth: true, username: 'admin', role: 'admin', expectedContent: ['Admin Panel', 'Administrator'] }, + ])('shows correct content for auth=$isAuth role=$role', async ({ isAuth, username, role, expectedContent }) => { + setAuth(isAuth, username, role); + await openMobileMenu(); + await waitFor(() => { + const mobileMenu = screen.getByTestId('mobile-menu'); + expectedContent.forEach((text) => expect(mobileMenu.textContent).toContain(text)); + }); + }); + }); - // Click outside the dropdown - document.body.click(); + describe('header structure', () => { + it('has fixed header with nav and backdrop blur', () => { + render(Header); + const header = screen.getByRole('banner'); + expect(header).toBeInTheDocument(); + expect(header).toHaveClass('fixed'); + expect(header).toHaveClass('top-0'); + expect(header).toHaveClass('backdrop-blur-md'); + expect(screen.getByRole('navigation')).toBeInTheDocument(); + }); + }); - await waitFor(() => { - expect(screen.queryByRole('link', { name: /Settings/i })).not.toBeInTheDocument(); - }); + describe('dropdown toggle behavior', () => { + it('closes dropdown when clicking a menu item', async () => { + const restoreConsole = suppressConsoleError(); + setAuth(true, 'testuser', 'user'); + await openUserDropdown(); + await waitFor(() => { + expect(screen.getByRole('link', { name: /Settings/i })).toBeInTheDocument(); + }); + await user.click(screen.getByRole('link', { name: /Settings/i })); + await waitFor(() => { + expect(screen.queryByRole('link', { name: /Settings/i })).not.toBeInTheDocument(); + }); + restoreConsole(); + }); + + it('closes dropdown when clicking outside', async () => { + setAuth(true, 'testuser', 'user'); + await openUserDropdown(); + await waitFor(() => { + expect(screen.getByRole('link', { name: /Settings/i })).toBeInTheDocument(); + }); + + // Click outside the dropdown + document.body.click(); + + await waitFor(() => { + expect(screen.queryByRole('link', { name: /Settings/i })).not.toBeInTheDocument(); + }); + }); }); - }); - describe('resize behavior', () => { - it('closes mobile menu when resizing to desktop width', async () => { - // Start in mobile mode - Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 800 }); + describe('resize behavior', () => { + it('closes mobile menu when resizing to desktop width', async () => { + // Start in mobile mode + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 800 }); - await openMobileMenu(); - await waitFor(() => { expect(screen.getByTestId('mobile-menu')).toBeInTheDocument(); }); + await openMobileMenu(); + await waitFor(() => { + expect(screen.getByTestId('mobile-menu')).toBeInTheDocument(); + }); - // Resize to desktop width - Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1200 }); - window.dispatchEvent(new Event('resize')); + // Resize to desktop width + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1200 }); + window.dispatchEvent(new Event('resize')); - await waitFor(() => { - expect(screen.queryByTestId('mobile-menu')).not.toBeInTheDocument(); - }); - }); + await waitFor(() => { + expect(screen.queryByTestId('mobile-menu')).not.toBeInTheDocument(); + }); + }); - it('detects mobile on mount when window is narrow', () => { - Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 500 }); - render(Header); + it('detects mobile on mount when window is narrow', () => { + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 500 }); + render(Header); - // Mobile menu toggle should be visible - expect(screen.getByTestId('mobile-menu-toggle')).toBeInTheDocument(); + // Mobile menu toggle should be visible + expect(screen.getByTestId('mobile-menu-toggle')).toBeInTheDocument(); + }); }); - }); - describe('mobile menu logout', () => { - it('calls logout and navigates from mobile menu', async () => { - Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 800 }); - setAuth(true, 'mobileuser', 'user'); + describe('mobile menu logout', () => { + it('calls logout and navigates from mobile menu', async () => { + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 800 }); + setAuth(true, 'mobileuser', 'user'); - await openMobileMenu(); - await waitFor(() => { - const mobileMenu = screen.getByTestId('mobile-menu'); - expect(mobileMenu.textContent).toContain('Logout'); - }); + await openMobileMenu(); + await waitFor(() => { + const mobileMenu = screen.getByTestId('mobile-menu'); + expect(mobileMenu.textContent).toContain('Logout'); + }); - const logoutButton = screen.getByRole('button', { name: /Logout/i }); - await user.click(logoutButton); + const logoutButton = screen.getByRole('button', { name: /Logout/i }); + await user.click(logoutButton); - expect(mocks.mockAuthStore.logout).toHaveBeenCalled(); - expect(router.goto).toHaveBeenCalledWith('/login'); + expect(mocks.mockAuthStore.logout).toHaveBeenCalled(); + expect(router.goto).toHaveBeenCalledWith('/login'); + }); }); - }); }); diff --git a/frontend/src/components/__tests__/Modal.test.ts b/frontend/src/components/__tests__/Modal.test.ts index f1750f48..f77c1198 100644 --- a/frontend/src/components/__tests__/Modal.test.ts +++ b/frontend/src/components/__tests__/Modal.test.ts @@ -4,93 +4,94 @@ import { user } from '$test/test-utils'; import ModalWrapper from './ModalWrapper.svelte'; describe('Modal', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('open/closed', () => { - it.each([ - { open: true, visible: true }, - { open: false, visible: false }, - ])('content visible=$visible when open=$open', ({ open, visible }) => { - render(ModalWrapper, { props: { open } }); - if (visible) { - expect(screen.getByTestId('modal-body')).toBeInTheDocument(); - } else { - expect(screen.queryByTestId('modal-body')).not.toBeInTheDocument(); - } + beforeEach(() => { + vi.clearAllMocks(); }); - }); - describe('accessibility', () => { - it('has correct dialog a11y attributes', () => { - render(ModalWrapper, { props: { open: true } }); - const dialog = screen.getByRole('dialog'); - expect(dialog).toHaveAttribute('aria-modal', 'true'); - expect(dialog).toHaveAttribute('aria-labelledby', 'modal-title'); - expect(screen.getByText('Test Modal')).toHaveAttribute('id', 'modal-title'); - expect(screen.getByRole('button', { name: 'Close modal' })).toBeInTheDocument(); + describe('open/closed', () => { + it.each([ + { open: true, visible: true }, + { open: false, visible: false }, + ])('content visible=$visible when open=$open', ({ open, visible }) => { + render(ModalWrapper, { props: { open } }); + if (visible) { + expect(screen.getByTestId('modal-body')).toBeInTheDocument(); + } else { + expect(screen.queryByTestId('modal-body')).not.toBeInTheDocument(); + } + }); }); - }); - describe('close interactions', () => { - it('fires onClose when X button clicked', async () => { - const onClose = vi.fn(); - render(ModalWrapper, { props: { open: true, onClose } }); - await user.click(screen.getByRole('button', { name: 'Close modal' })); - expect(onClose).toHaveBeenCalledOnce(); + describe('accessibility', () => { + it('has correct dialog a11y attributes', () => { + render(ModalWrapper, { props: { open: true } }); + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + expect(dialog).toHaveAttribute('aria-labelledby', 'modal-title'); + expect(screen.getByText('Test Modal')).toHaveAttribute('id', 'modal-title'); + expect(screen.getByRole('button', { name: 'Close modal' })).toBeInTheDocument(); + }); }); - it('fires onClose on Escape keydown', async () => { - const onClose = vi.fn(); - render(ModalWrapper, { props: { open: true, onClose } }); - await fireEvent.keyDown(window, { key: 'Escape' }); - expect(onClose).toHaveBeenCalled(); - }); + describe('close interactions', () => { + it('fires onClose when X button clicked', async () => { + const onClose = vi.fn(); + render(ModalWrapper, { props: { open: true, onClose } }); + await user.click(screen.getByRole('button', { name: 'Close modal' })); + expect(onClose).toHaveBeenCalledOnce(); + }); - it('fires onClose on backdrop click', async () => { - const onClose = vi.fn(); - render(ModalWrapper, { props: { open: true, onClose } }); - await fireEvent.click(screen.getByRole('dialog')); - expect(onClose).toHaveBeenCalledOnce(); - }); + it('fires onClose on Escape keydown', async () => { + const onClose = vi.fn(); + render(ModalWrapper, { props: { open: true, onClose } }); + const dialog = screen.getByRole('dialog'); + await fireEvent.keyDown(dialog, { key: 'Escape' }); + expect(onClose).toHaveBeenCalled(); + }); - it('does not fire onClose when clicking body content', async () => { - const onClose = vi.fn(); - render(ModalWrapper, { props: { open: true, onClose } }); - await user.click(screen.getByTestId('modal-body')); - expect(onClose).not.toHaveBeenCalled(); - }); - }); + it('fires onClose on backdrop click', async () => { + const onClose = vi.fn(); + render(ModalWrapper, { props: { open: true, onClose } }); + await fireEvent.click(screen.getByRole('dialog')); + expect(onClose).toHaveBeenCalledOnce(); + }); - describe('size classes', () => { - it.each([ - { size: 'sm' as const, expectedClass: 'max-w-md' }, - { size: 'md' as const, expectedClass: 'max-w-2xl' }, - { size: 'lg' as const, expectedClass: 'max-w-4xl' }, - { size: 'xl' as const, expectedClass: 'max-w-6xl' }, - ])('applies $expectedClass for size=$size', ({ size, expectedClass }) => { - const { container } = render(ModalWrapper, { props: { open: true, size } }); - expect(container.querySelector('.modal-container')?.classList.contains(expectedClass)).toBe(true); + it('does not fire onClose when clicking body content', async () => { + const onClose = vi.fn(); + render(ModalWrapper, { props: { open: true, onClose } }); + await user.click(screen.getByTestId('modal-body')); + expect(onClose).not.toHaveBeenCalled(); + }); }); - it('defaults to lg (max-w-4xl)', () => { - const { container } = render(ModalWrapper, { props: { open: true } }); - expect(container.querySelector('.modal-container')?.classList.contains('max-w-4xl')).toBe(true); + describe('size classes', () => { + it.each([ + { size: 'sm' as const, expectedClass: 'max-w-md' }, + { size: 'md' as const, expectedClass: 'max-w-2xl' }, + { size: 'lg' as const, expectedClass: 'max-w-4xl' }, + { size: 'xl' as const, expectedClass: 'max-w-6xl' }, + ])('applies $expectedClass for size=$size', ({ size, expectedClass }) => { + const { container } = render(ModalWrapper, { props: { open: true, size } }); + expect(container.querySelector('.modal-container')?.classList.contains(expectedClass)).toBe(true); + }); + + it('defaults to lg (max-w-4xl)', () => { + const { container } = render(ModalWrapper, { props: { open: true } }); + expect(container.querySelector('.modal-container')?.classList.contains('max-w-4xl')).toBe(true); + }); }); - }); - describe('footer', () => { - it.each([ - { showFooter: true, hasFooter: true }, - { showFooter: false, hasFooter: false }, - ])('footer present=$hasFooter when showFooter=$showFooter', ({ showFooter, hasFooter }) => { - const { container } = render(ModalWrapper, { props: { open: true, showFooter } }); - if (hasFooter) { - expect(screen.getByTestId('modal-footer-content')).toBeInTheDocument(); - } else { - expect(container.querySelector('.modal-footer')).not.toBeInTheDocument(); - } + describe('footer', () => { + it.each([ + { showFooter: true, hasFooter: true }, + { showFooter: false, hasFooter: false }, + ])('footer present=$hasFooter when showFooter=$showFooter', ({ showFooter, hasFooter }) => { + const { container } = render(ModalWrapper, { props: { open: true, showFooter } }); + if (hasFooter) { + expect(screen.getByTestId('modal-footer-content')).toBeInTheDocument(); + } else { + expect(container.querySelector('.modal-footer')).not.toBeInTheDocument(); + } + }); }); - }); }); diff --git a/frontend/src/components/__tests__/ModalWrapper.svelte b/frontend/src/components/__tests__/ModalWrapper.svelte index 634cdf1e..4fd700ea 100644 --- a/frontend/src/components/__tests__/ModalWrapper.svelte +++ b/frontend/src/components/__tests__/ModalWrapper.svelte @@ -1,32 +1,26 @@ {#if showFooter} - -

Modal body content

- {#snippet footer()} -
Footer content
- {/snippet} -
+ +

Modal body content

+ {#snippet footer()} +
Footer content
+ {/snippet} +
{:else} - -

Modal body content

-
+ +

Modal body content

+
{/if} diff --git a/frontend/src/components/__tests__/NotificationCenter.test.ts b/frontend/src/components/__tests__/NotificationCenter.test.ts index 2760accd..52931f3e 100644 --- a/frontend/src/components/__tests__/NotificationCenter.test.ts +++ b/frontend/src/components/__tests__/NotificationCenter.test.ts @@ -3,387 +3,433 @@ import { render, screen, waitFor } from '@testing-library/svelte'; import { user, type UserEventInstance } from '$test/test-utils'; // Types for mock notification state interface MockNotification { - notification_id: string; - subject: string; - body: string; - status: 'unread' | 'read'; - severity: 'low' | 'medium' | 'high' | 'urgent'; - tags: string[]; - created_at: string; - action_url?: string; + notification_id: string; + subject: string; + body: string; + status: 'unread' | 'read'; + severity: 'low' | 'medium' | 'high' | 'urgent'; + tags: string[]; + created_at: string; + action_url?: string; } const mocks = vi.hoisted(() => ({ - mockAuthStore: { - isAuthenticated: true as boolean | null, - username: 'testuser' as string | null, - userId: 'user-123' as string | null, - userRole: null as string | null, - userEmail: null as string | null, - csrfToken: null as string | null, - }, - mockNotificationStore: { - notifications: [] as MockNotification[], - loading: false, - error: null as string | null, - unreadCount: 0, - load: vi.fn().mockResolvedValue([]), - add: vi.fn(), - markAsRead: vi.fn().mockResolvedValue(true), - markAllAsRead: vi.fn().mockResolvedValue(true), - delete: vi.fn().mockResolvedValue(true), - clear: vi.fn(), - refresh: vi.fn(), - }, - mockGoto: vi.fn(), - mockStreamConnect: vi.fn(), - mockStreamDisconnect: vi.fn(), + mockAuthStore: { + isAuthenticated: true as boolean | null, + username: 'testuser' as string | null, + userId: 'user-123' as string | null, + userRole: null as string | null, + userEmail: null as string | null, + csrfToken: null as string | null, + }, + mockNotificationStore: { + notifications: [] as MockNotification[], + loading: false, + error: null as string | null, + unreadCount: 0, + load: vi.fn().mockResolvedValue([]), + add: vi.fn(), + markAsRead: vi.fn().mockResolvedValue(true), + markAllAsRead: vi.fn().mockResolvedValue(true), + delete: vi.fn().mockResolvedValue(true), + clear: vi.fn(), + refresh: vi.fn(), + }, + mockGoto: vi.fn(), + mockStreamConnect: vi.fn(), + mockStreamDisconnect: vi.fn(), })); vi.mock('@mateothegreat/svelte5-router', () => ({ goto: mocks.mockGoto })); vi.mock('../../stores/auth.svelte', () => ({ - get authStore() { return mocks.mockAuthStore; }, + get authStore() { + return mocks.mockAuthStore; + }, })); vi.mock('../../stores/notificationStore.svelte', () => ({ - get notificationStore() { return mocks.mockNotificationStore; }, + get notificationStore() { + return mocks.mockNotificationStore; + }, })); vi.mock('../../lib/notifications/stream.svelte', () => ({ - notificationStream: { - connect: mocks.mockStreamConnect, - disconnect: mocks.mockStreamDisconnect, - }, + notificationStream: { + connect: mocks.mockStreamConnect, + disconnect: mocks.mockStreamDisconnect, + }, })); // Configurable Notification mock const mockRequestPermission = vi.fn().mockResolvedValue('granted'); let mockNotificationPermission = 'default'; vi.stubGlobal('Notification', { - get permission() { return mockNotificationPermission; }, - requestPermission: mockRequestPermission, + get permission() { + return mockNotificationPermission; + }, + requestPermission: mockRequestPermission, }); import NotificationCenter from '$components/NotificationCenter.svelte'; // Test Helpers const createNotification = (overrides: Partial = {}): MockNotification => ({ - notification_id: '1', subject: 'Test', body: 'Body', status: 'unread', - severity: 'medium', tags: [], created_at: new Date().toISOString(), ...overrides, + notification_id: '1', + subject: 'Test', + body: 'Body', + status: 'unread', + severity: 'medium', + tags: [], + created_at: new Date().toISOString(), + ...overrides, }); const setNotifications = (notifications: MockNotification[]) => { - mocks.mockNotificationStore.notifications = notifications; - mocks.mockNotificationStore.loading = false; - mocks.mockNotificationStore.error = null; - mocks.mockNotificationStore.unreadCount = notifications.filter(n => n.status !== 'read').length; + mocks.mockNotificationStore.notifications = notifications; + mocks.mockNotificationStore.loading = false; + mocks.mockNotificationStore.error = null; + mocks.mockNotificationStore.unreadCount = notifications.filter((n) => n.status !== 'read').length; }; /** Mocks window.location.href for external URL testing */ const withMockedLocation = async (testFn: (mockHref: ReturnType) => Promise) => { - const originalLocation = window.location; - const mockHref = vi.fn(); - Object.defineProperty(window, 'location', { value: { ...originalLocation, href: '' }, writable: true, configurable: true }); - Object.defineProperty(window.location, 'href', { set: mockHref, configurable: true }); - try { await testFn(mockHref); } - finally { Object.defineProperty(window, 'location', { value: originalLocation, writable: true, configurable: true }); } + const originalLocation = window.location; + const mockHref = vi.fn(); + Object.defineProperty(window, 'location', { + value: { ...originalLocation, href: '' }, + writable: true, + configurable: true, + }); + Object.defineProperty(window.location, 'href', { set: mockHref, configurable: true }); + try { + await testFn(mockHref); + } finally { + Object.defineProperty(window, 'location', { value: originalLocation, writable: true, configurable: true }); + } }; /** Interacts with a notification button via click or keyboard */ -const interactWithButton = async ( - user: UserEventInstance, - button: HTMLElement, - method: 'click' | 'keyboard' -) => { - if (method === 'click') await user.click(button); - else { button.focus(); await user.keyboard('{Enter}'); } +const interactWithButton = async (user: UserEventInstance, button: HTMLElement, method: 'click' | 'keyboard') => { + if (method === 'click') await user.click(button); + else { + button.focus(); + await user.keyboard('{Enter}'); + } }; // Test Data (consolidated arrays for it.each) const iconTestCases = [ - { tags: ['completed'], iconClass: 'lucide-circle-check', desc: 'check' }, - { tags: ['success'], iconClass: 'lucide-circle-check', desc: 'check' }, - { tags: ['failed'], iconClass: 'lucide-circle-alert', desc: 'error' }, - { tags: ['error'], iconClass: 'lucide-circle-alert', desc: 'error' }, - { tags: ['security'], iconClass: 'lucide-circle-alert', desc: 'error' }, - { tags: ['timeout'], iconClass: 'lucide-triangle-alert', desc: 'warning' }, - { tags: ['warning'], iconClass: 'lucide-triangle-alert', desc: 'warning' }, - { tags: ['unknown'], iconClass: 'lucide-info', desc: 'info' }, - { tags: [] as string[], iconClass: 'lucide-info', desc: 'info' }, + { tags: ['completed'], iconClass: 'lucide-circle-check', desc: 'check' }, + { tags: ['success'], iconClass: 'lucide-circle-check', desc: 'check' }, + { tags: ['failed'], iconClass: 'lucide-circle-alert', desc: 'error' }, + { tags: ['error'], iconClass: 'lucide-circle-alert', desc: 'error' }, + { tags: ['security'], iconClass: 'lucide-circle-alert', desc: 'error' }, + { tags: ['timeout'], iconClass: 'lucide-triangle-alert', desc: 'warning' }, + { tags: ['warning'], iconClass: 'lucide-triangle-alert', desc: 'warning' }, + { tags: ['unknown'], iconClass: 'lucide-info', desc: 'info' }, + { tags: [] as string[], iconClass: 'lucide-info', desc: 'info' }, ]; const priorityTestCases = [ - { severity: 'low' as const, css: '.text-fg-muted' }, - { severity: 'medium' as const, css: '.text-blue-600' }, - { severity: 'high' as const, css: '.text-orange-600' }, - { severity: 'urgent' as const, css: '.text-red-600' }, + { severity: 'low' as const, css: '.text-fg-muted' }, + { severity: 'medium' as const, css: '.text-blue-600' }, + { severity: 'high' as const, css: '.text-orange-600' }, + { severity: 'urgent' as const, css: '.text-red-600' }, ]; const timeTestCases = [ - { offsetMs: 0, expected: 'just now' }, - { offsetMs: 5 * 60 * 1000, expected: '5m ago' }, - { offsetMs: 3 * 60 * 60 * 1000, expected: '3h ago' }, + { offsetMs: 0, expected: 'just now' }, + { offsetMs: 5 * 60 * 1000, expected: '5m ago' }, + { offsetMs: 3 * 60 * 60 * 1000, expected: '3h ago' }, ]; const badgeTestCases = [ - { count: 2, expected: '2' }, - { count: 9, expected: '9' }, - { count: 12, expected: '9+' }, + { count: 2, expected: '2' }, + { count: 9, expected: '9' }, + { count: 12, expected: '9+' }, ]; const interactionTestCases = [ - { method: 'click' as const, hasUrl: true, url: '/builds/123' }, - { method: 'click' as const, hasUrl: false, url: undefined }, - { method: 'keyboard' as const, hasUrl: true, url: '/test' }, - { method: 'keyboard' as const, hasUrl: false, url: undefined }, + { method: 'click' as const, hasUrl: true, url: '/builds/123' }, + { method: 'click' as const, hasUrl: false, url: undefined }, + { method: 'keyboard' as const, hasUrl: true, url: '/test' }, + { method: 'keyboard' as const, hasUrl: false, url: undefined }, ]; // Tests describe('NotificationCenter', () => { - const openDropdown = async () => { - render(NotificationCenter); - await user.click(screen.getByRole('button', { name: /Notifications/i })); - }; - - const openDropdownWithContainer = async () => { - const { container } = render(NotificationCenter); - await user.click(screen.getByRole('button', { name: /Notifications/i })); - return { container }; - }; - - beforeEach(() => { - mocks.mockAuthStore.isAuthenticated = true; - mocks.mockAuthStore.username = 'testuser'; - mocks.mockAuthStore.userId = 'user-123'; - setNotifications([]); - mocks.mockGoto.mockReset(); - mocks.mockNotificationStore.load.mockReset().mockResolvedValue([]); - mocks.mockNotificationStore.markAsRead.mockReset().mockResolvedValue(true); - mocks.mockNotificationStore.markAllAsRead.mockReset().mockResolvedValue(true); - mocks.mockNotificationStore.clear.mockReset(); - mocks.mockNotificationStore.add.mockReset(); - mocks.mockStreamConnect.mockReset(); - mocks.mockStreamDisconnect.mockReset(); - mockNotificationPermission = 'default'; - mockRequestPermission.mockReset().mockResolvedValue('granted'); - vi.spyOn(console, 'log').mockImplementation(() => {}); - vi.spyOn(console, 'debug').mockImplementation(() => {}); - vi.spyOn(console, 'error').mockImplementation(() => {}); - }); - - afterEach(() => { vi.restoreAllMocks(); }); - - describe('bell icon and badge', () => { - it('renders notification button with bell icon', () => { - render(NotificationCenter); - const button = screen.getByRole('button', { name: /Notifications/i }); - expect(button).toBeInTheDocument(); - expect(button.querySelector('svg')).toBeInTheDocument(); - }); - - it('shows no badge when no unread notifications', () => { - const { container } = render(NotificationCenter); - expect(container.querySelector('.bg-red-500')).not.toBeInTheDocument(); + const openDropdown = async () => { + render(NotificationCenter); + await user.click(screen.getByRole('button', { name: /Notifications/i })); + }; + + const openDropdownWithContainer = async () => { + const { container } = render(NotificationCenter); + await user.click(screen.getByRole('button', { name: /Notifications/i })); + return { container }; + }; + + beforeEach(() => { + mocks.mockAuthStore.isAuthenticated = true; + mocks.mockAuthStore.username = 'testuser'; + mocks.mockAuthStore.userId = 'user-123'; + setNotifications([]); + mocks.mockGoto.mockReset(); + mocks.mockNotificationStore.load.mockReset().mockResolvedValue([]); + mocks.mockNotificationStore.markAsRead.mockReset().mockResolvedValue(true); + mocks.mockNotificationStore.markAllAsRead.mockReset().mockResolvedValue(true); + mocks.mockNotificationStore.clear.mockReset(); + mocks.mockNotificationStore.add.mockReset(); + mocks.mockStreamConnect.mockReset(); + mocks.mockStreamDisconnect.mockReset(); + mockNotificationPermission = 'default'; + mockRequestPermission.mockReset().mockResolvedValue('granted'); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'debug').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); }); - it.each(badgeTestCases)('shows badge "$expected" for $count unread', async ({ count, expected }) => { - setNotifications(Array.from({ length: count }, (_, i) => createNotification({ notification_id: String(i) }))); - const { container } = render(NotificationCenter); - await waitFor(() => { expect(container.querySelector('.bg-red-500')?.textContent).toBe(expected); }); + afterEach(() => { + vi.restoreAllMocks(); }); - }); - describe('dropdown behavior', () => { - it('opens dropdown when bell clicked', async () => { - await openDropdown(); - await waitFor(() => { expect(screen.getByText('Notifications')).toBeInTheDocument(); }); + describe('bell icon and badge', () => { + it('renders notification button with bell icon', () => { + render(NotificationCenter); + const button = screen.getByRole('button', { name: /Notifications/i }); + expect(button).toBeInTheDocument(); + expect(button.querySelector('svg')).toBeInTheDocument(); + }); + + it('shows no badge when no unread notifications', () => { + const { container } = render(NotificationCenter); + expect(container.querySelector('.bg-red-500')).not.toBeInTheDocument(); + }); + + it.each(badgeTestCases)('shows badge "$expected" for $count unread', async ({ count, expected }) => { + setNotifications( + Array.from({ length: count }, (_, i) => createNotification({ notification_id: String(i) })), + ); + const { container } = render(NotificationCenter); + await waitFor(() => { + expect(container.querySelector('.bg-red-500')?.textContent).toBe(expected); + }); + }); }); - it('shows empty state when no notifications', async () => { - await openDropdown(); - await waitFor(() => { expect(screen.getByText('No notifications yet')).toBeInTheDocument(); }); + describe('dropdown behavior', () => { + it('opens dropdown when bell clicked', async () => { + await openDropdown(); + await waitFor(() => { + expect(screen.getByText('Notifications')).toBeInTheDocument(); + }); + }); + + it('shows empty state when no notifications', async () => { + await openDropdown(); + await waitFor(() => { + expect(screen.getByText('No notifications yet')).toBeInTheDocument(); + }); + }); + + it('navigates to /notifications when View all clicked', async () => { + await openDropdown(); + await user.click(await screen.findByText('View all notifications')); + expect(mocks.mockGoto).toHaveBeenCalledWith('/notifications'); + }); }); - it('navigates to /notifications when View all clicked', async () => { - await openDropdown(); - await user.click(await screen.findByText('View all notifications')); - expect(mocks.mockGoto).toHaveBeenCalledWith('/notifications'); - }); - }); - - describe('notification list display', () => { - it('displays notification subjects and shows unread indicator', async () => { - setNotifications([ - createNotification({ notification_id: '1', subject: 'Build Completed' }), - createNotification({ notification_id: '2', subject: 'Build Failed', status: 'read' }), - ]); - const { container } = await openDropdownWithContainer(); - await waitFor(() => { - expect(screen.getByText('Build Completed')).toBeInTheDocument(); - expect(screen.getByText('Build Failed')).toBeInTheDocument(); - expect(container.querySelector('.bg-blue-500.rounded-full')).toBeInTheDocument(); - }); - }); - }); - - describe('mark as read functionality', () => { - it.each([ - { status: 'unread' as const, visible: true }, - { status: 'read' as const, visible: false }, - ])('Mark all button visible=$visible for $status', async ({ status, visible }) => { - setNotifications([createNotification({ status })]); - await openDropdown(); - await waitFor(() => { - expect(!!screen.queryByText('Mark all as read')).toBe(visible); - }); + describe('notification list display', () => { + it('displays notification subjects and shows unread indicator', async () => { + setNotifications([ + createNotification({ notification_id: '1', subject: 'Build Completed' }), + createNotification({ notification_id: '2', subject: 'Build Failed', status: 'read' }), + ]); + const { container } = await openDropdownWithContainer(); + await waitFor(() => { + expect(screen.getByText('Build Completed')).toBeInTheDocument(); + expect(screen.getByText('Build Failed')).toBeInTheDocument(); + expect(container.querySelector('.bg-blue-500.rounded-full')).toBeInTheDocument(); + }); + }); }); - it('calls markAllAsRead when button clicked', async () => { - setNotifications([createNotification()]); - await openDropdown(); - await user.click(await screen.findByText('Mark all as read')); - expect(mocks.mockNotificationStore.markAllAsRead).toHaveBeenCalled(); + describe('mark as read functionality', () => { + it.each([ + { status: 'unread' as const, visible: true }, + { status: 'read' as const, visible: false }, + ])('Mark all button visible=$visible for $status', async ({ status, visible }) => { + setNotifications([createNotification({ status })]); + await openDropdown(); + await waitFor(() => { + expect(!!screen.queryByText('Mark all as read')).toBe(visible); + }); + }); + + it('calls markAllAsRead when button clicked', async () => { + setNotifications([createNotification()]); + await openDropdown(); + await user.click(await screen.findByText('Mark all as read')); + expect(mocks.mockNotificationStore.markAllAsRead).toHaveBeenCalled(); + }); + + it('skips markAsRead for already-read notifications', async () => { + setNotifications([createNotification({ subject: 'Read', status: 'read' })]); + await openDropdown(); + await user.click(await screen.findByRole('button', { name: /View notification: Read/i })); + expect(mocks.mockNotificationStore.markAsRead).not.toHaveBeenCalled(); + }); }); - it('skips markAsRead for already-read notifications', async () => { - setNotifications([createNotification({ subject: 'Read', status: 'read' })]); - await openDropdown(); - await user.click(await screen.findByRole('button', { name: /View notification: Read/i })); - expect(mocks.mockNotificationStore.markAsRead).not.toHaveBeenCalled(); - }); - }); - - describe('notification icons', () => { - it.each(iconTestCases)('shows $desc icon for tags=$tags', async ({ tags, iconClass }) => { - const subject = tags[0] || 'NoTags'; - setNotifications([createNotification({ tags, subject })]); - const { container } = await openDropdownWithContainer(); - await waitFor(() => { - const svg = container.querySelector(`[aria-label*="${subject}"] svg`); - expect(svg?.classList.contains(iconClass)).toBe(true); - }); + describe('notification icons', () => { + it.each(iconTestCases)('shows $desc icon for tags=$tags', async ({ tags, iconClass }) => { + const subject = tags[0] || 'NoTags'; + setNotifications([createNotification({ tags, subject })]); + const { container } = await openDropdownWithContainer(); + await waitFor(() => { + const svg = container.querySelector(`[aria-label*="${subject}"] svg`); + expect(svg?.classList.contains(iconClass)).toBe(true); + }); + }); }); - }); - describe('priority colors', () => { - it.each(priorityTestCases)('applies $css for $severity', async ({ severity, css }) => { - setNotifications([createNotification({ severity })]); - const { container } = await openDropdownWithContainer(); - await waitFor(() => { expect(container.querySelector(css)).toBeInTheDocument(); }); + describe('priority colors', () => { + it.each(priorityTestCases)('applies $css for $severity', async ({ severity, css }) => { + setNotifications([createNotification({ severity })]); + const { container } = await openDropdownWithContainer(); + await waitFor(() => { + expect(container.querySelector(css)).toBeInTheDocument(); + }); + }); }); - }); - describe('time formatting', () => { - it.each(timeTestCases)('shows "$expected" for $offsetMs ms ago', async ({ offsetMs, expected }) => { - setNotifications([createNotification({ created_at: new Date(Date.now() - offsetMs).toISOString() })]); - await openDropdown(); - await waitFor(() => { expect(screen.getByText(expected)).toBeInTheDocument(); }); + describe('time formatting', () => { + it.each(timeTestCases)('shows "$expected" for $offsetMs ms ago', async ({ offsetMs, expected }) => { + setNotifications([createNotification({ created_at: new Date(Date.now() - offsetMs).toISOString() })]); + await openDropdown(); + await waitFor(() => { + expect(screen.getByText(expected)).toBeInTheDocument(); + }); + }); + + it('shows relative days for >24h old notifications', async () => { + const oldDate = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + setNotifications([createNotification({ created_at: oldDate.toISOString() })]); + await openDropdown(); + await waitFor(() => { + expect(screen.getByText('2d ago')).toBeInTheDocument(); + }); + }); }); - it('shows relative days for >24h old notifications', async () => { - const oldDate = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); - setNotifications([createNotification({ created_at: oldDate.toISOString() })]); - await openDropdown(); - await waitFor(() => { expect(screen.getByText('2d ago')).toBeInTheDocument(); }); - }); - }); - - describe('notification interaction', () => { - it.each(interactionTestCases)('$method: navigates=$hasUrl', async ({ method, hasUrl, url }) => { - const subject = `${method}-${hasUrl}`; - setNotifications([createNotification({ subject, action_url: url })]); - await openDropdown(); - const button = await screen.findByRole('button', { name: new RegExp(`View notification: ${subject}`, 'i') }); - await interactWithButton(user, button, method); - - expect(mocks.mockNotificationStore.markAsRead).toHaveBeenCalledWith('1'); - hasUrl ? expect(mocks.mockGoto).toHaveBeenCalledWith(url) : expect(mocks.mockGoto).not.toHaveBeenCalled(); + describe('notification interaction', () => { + it.each(interactionTestCases)('$method: navigates=$hasUrl', async ({ method, hasUrl, url }) => { + const subject = `${method}-${hasUrl}`; + setNotifications([createNotification({ subject, action_url: url })]); + await openDropdown(); + const button = await screen.findByRole('button', { + name: new RegExp(`View notification: ${subject}`, 'i'), + }); + await interactWithButton(user, button, method); + + expect(mocks.mockNotificationStore.markAsRead).toHaveBeenCalledWith('1'); + hasUrl ? expect(mocks.mockGoto).toHaveBeenCalledWith(url) : expect(mocks.mockGoto).not.toHaveBeenCalled(); + }); + + it('ignores non-Enter keydown', async () => { + setNotifications([createNotification({ subject: 'Test', action_url: '/test' })]); + render(NotificationCenter); + await user.click(screen.getByRole('button', { name: /Notifications/i })); + screen.getByRole('button', { name: /View notification: Test/i }).focus(); + await user.keyboard('{Tab}'); + expect(mocks.mockNotificationStore.markAsRead).not.toHaveBeenCalled(); + }); }); - it('ignores non-Enter keydown', async () => { - setNotifications([createNotification({ subject: 'Test', action_url: '/test' })]); - render(NotificationCenter); - await user.click(screen.getByRole('button', { name: /Notifications/i })); - screen.getByRole('button', { name: /View notification: Test/i }).focus(); - await user.keyboard('{Tab}'); - expect(mocks.mockNotificationStore.markAsRead).not.toHaveBeenCalled(); - }); - }); - - describe('external URL navigation', () => { - it.each(['click', 'keyboard'] as const)('navigates via %s', async (method) => { - await withMockedLocation(async (mockHref) => { - const url = `https://example.com/${method}`; - setNotifications([createNotification({ subject: method, action_url: url })]); - await openDropdown(); - const button = await screen.findByRole('button', { name: new RegExp(`View notification: ${method}`, 'i') }); - await interactWithButton(user, button, method); - expect(mockHref).toHaveBeenCalledWith(url); - }); - }); - }); - - describe('accessibility', () => { - it('button has aria-label and items are focusable', async () => { - setNotifications([createNotification({ subject: 'Test' })]); - render(NotificationCenter); - expect(screen.getByRole('button', { name: /Notifications/i })).toHaveAttribute('aria-label', 'Notifications'); - await user.click(screen.getByRole('button', { name: /Notifications/i })); - await waitFor(() => { - expect(screen.getByRole('button', { name: /View notification: Test/i })).toHaveAttribute('tabindex', '0'); - }); + describe('external URL navigation', () => { + it.each(['click', 'keyboard'] as const)('navigates via %s', async (method) => { + await withMockedLocation(async (mockHref) => { + const url = `https://example.com/${method}`; + setNotifications([createNotification({ subject: method, action_url: url })]); + await openDropdown(); + const button = await screen.findByRole('button', { + name: new RegExp(`View notification: ${method}`, 'i'), + }); + await interactWithButton(user, button, method); + expect(mockHref).toHaveBeenCalledWith(url); + }); + }); }); - }); - - describe('auto-mark as read', () => { - it('marks notifications after 2s delay', async () => { - setNotifications([createNotification({ notification_id: '1' }), createNotification({ notification_id: '2' })]); - render(NotificationCenter); - await user.click(screen.getByRole('button', { name: /Notifications/i })); - await vi.advanceTimersByTimeAsync(2500); - expect(mocks.mockNotificationStore.markAsRead).toHaveBeenCalled(); - }); - }); - - describe('stream connection', () => { - it('connects when authenticated', async () => { - mocks.mockAuthStore.isAuthenticated = true; - render(NotificationCenter); - await waitFor(() => { - expect(mocks.mockNotificationStore.load).toHaveBeenCalled(); - }); - }); - - }); - describe('desktop notification permission', () => { - it('shows enable button when permission is default', async () => { - mockNotificationPermission = 'default'; - await openDropdown(); - await waitFor(() => { - expect(screen.getByText('Enable desktop notifications')).toBeInTheDocument(); - }); + describe('accessibility', () => { + it('button has aria-label and items are focusable', async () => { + setNotifications([createNotification({ subject: 'Test' })]); + render(NotificationCenter); + expect(screen.getByRole('button', { name: /Notifications/i })).toHaveAttribute( + 'aria-label', + 'Notifications', + ); + await user.click(screen.getByRole('button', { name: /Notifications/i })); + await waitFor(() => { + expect(screen.getByRole('button', { name: /View notification: Test/i })).toHaveAttribute( + 'tabindex', + '0', + ); + }); + }); }); - it('hides enable button when permission is granted', async () => { - mockNotificationPermission = 'granted'; - await openDropdown(); - await waitFor(() => { - expect(screen.queryByText('Enable desktop notifications')).not.toBeInTheDocument(); - }); + describe('auto-mark as read', () => { + it('marks notifications after 2s delay', async () => { + setNotifications([ + createNotification({ notification_id: '1' }), + createNotification({ notification_id: '2' }), + ]); + render(NotificationCenter); + await user.click(screen.getByRole('button', { name: /Notifications/i })); + await vi.advanceTimersByTimeAsync(2500); + expect(mocks.mockNotificationStore.markAsRead).toHaveBeenCalled(); + }); }); - it('hides enable button when permission is denied', async () => { - mockNotificationPermission = 'denied'; - await openDropdown(); - await waitFor(() => { - expect(screen.queryByText('Enable desktop notifications')).not.toBeInTheDocument(); - }); + describe('stream connection', () => { + it('connects when authenticated', async () => { + mocks.mockAuthStore.isAuthenticated = true; + render(NotificationCenter); + await waitFor(() => { + expect(mocks.mockNotificationStore.load).toHaveBeenCalled(); + }); + }); }); - it('calls requestPermission when enable button clicked', async () => { - mockNotificationPermission = 'default'; - await openDropdown(); - await user.click(await screen.findByText('Enable desktop notifications')); - expect(mockRequestPermission).toHaveBeenCalled(); + describe('desktop notification permission', () => { + it('shows enable button when permission is default', async () => { + mockNotificationPermission = 'default'; + await openDropdown(); + await waitFor(() => { + expect(screen.getByText('Enable desktop notifications')).toBeInTheDocument(); + }); + }); + + it('hides enable button when permission is granted', async () => { + mockNotificationPermission = 'granted'; + await openDropdown(); + await waitFor(() => { + expect(screen.queryByText('Enable desktop notifications')).not.toBeInTheDocument(); + }); + }); + + it('hides enable button when permission is denied', async () => { + mockNotificationPermission = 'denied'; + await openDropdown(); + await waitFor(() => { + expect(screen.queryByText('Enable desktop notifications')).not.toBeInTheDocument(); + }); + }); + + it('calls requestPermission when enable button clicked', async () => { + mockNotificationPermission = 'default'; + await openDropdown(); + await user.click(await screen.findByText('Enable desktop notifications')); + expect(mockRequestPermission).toHaveBeenCalled(); + }); }); - }); }); diff --git a/frontend/src/components/__tests__/Pagination.test.ts b/frontend/src/components/__tests__/Pagination.test.ts index 10a01e17..59c16df6 100644 --- a/frontend/src/components/__tests__/Pagination.test.ts +++ b/frontend/src/components/__tests__/Pagination.test.ts @@ -4,106 +4,116 @@ import { user } from '$test/test-utils'; import Pagination from '$components/Pagination.svelte'; const defaultProps = { - currentPage: 1, - totalPages: 5, - totalItems: 50, - pageSize: 10, - onPageChange: vi.fn(), + currentPage: 1, + totalPages: 5, + totalItems: 50, + pageSize: 10, + onPageChange: vi.fn(), }; function renderPagination(overrides: Partial = {}) { - const props = { ...defaultProps, onPageChange: vi.fn(), ...overrides }; - return { ...render(Pagination, { props }), onPageChange: props.onPageChange }; + const props = { ...defaultProps, onPageChange: vi.fn(), ...overrides }; + return { ...render(Pagination, { props }), onPageChange: props.onPageChange }; } describe('Pagination', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('page info text', () => { - it.each([ - { page: 1, totalPages: 5, totalItems: 50, pageSize: 10, expected: /Showing 1 - 10 of 50 items/ }, - { page: 3, totalPages: 5, totalItems: 50, pageSize: 10, expected: /Showing 21 - 30 of 50 items/ }, - { page: 3, totalPages: 3, totalItems: 23, pageSize: 10, expected: /Showing 21 - 23 of 23 items/ }, - ])('shows "$expected" for page $page of $totalPages ($totalItems items)', ({ page, totalPages, totalItems, pageSize, expected }) => { - renderPagination({ currentPage: page, totalPages, totalItems, pageSize }); - expect(screen.getByText(expected)).toBeInTheDocument(); + beforeEach(() => { + vi.clearAllMocks(); }); - it('uses custom itemName', () => { - renderPagination({ itemName: 'events' } as Record); - expect(screen.getByText(/of 50 events/)).toBeInTheDocument(); - }); - }); + describe('page info text', () => { + it.each([ + { page: 1, totalPages: 5, totalItems: 50, pageSize: 10, expected: /Showing 1 - 10 of 50 items/ }, + { page: 3, totalPages: 5, totalItems: 50, pageSize: 10, expected: /Showing 21 - 30 of 50 items/ }, + { page: 3, totalPages: 3, totalItems: 23, pageSize: 10, expected: /Showing 21 - 23 of 23 items/ }, + ])( + 'shows "$expected" for page $page of $totalPages ($totalItems items)', + ({ page, totalPages, totalItems, pageSize, expected }) => { + renderPagination({ currentPage: page, totalPages, totalItems, pageSize }); + expect(screen.getByText(expected)).toBeInTheDocument(); + }, + ); - describe('navigation buttons', () => { - it.each([ - { name: 'First page', expectedPage: 1 }, - { name: 'Previous page', expectedPage: 2 }, - { name: 'Next page', expectedPage: 4 }, - { name: 'Last page', expectedPage: 5 }, - ])('$name calls onPageChange($expectedPage)', async ({ name, expectedPage }) => { - const { onPageChange } = renderPagination({ currentPage: 3 }); - await user.click(screen.getByRole('button', { name })); - expect(onPageChange).toHaveBeenCalledWith(expectedPage); + it('uses custom itemName', () => { + renderPagination({ itemName: 'events' } as Record); + expect(screen.getByText(/of 50 events/)).toBeInTheDocument(); + }); }); - }); - describe('disabled states', () => { - it.each([ - { page: 1, disabled: ['First page', 'Previous page'] }, - { page: 5, disabled: ['Next page', 'Last page'] }, - ])('disables $disabled on page $page', ({ page, disabled }) => { - renderPagination({ currentPage: page }); - for (const name of disabled) { - expect(screen.getByRole('button', { name })).toBeDisabled(); - } + describe('navigation buttons', () => { + it.each([ + { name: 'First page', expectedPage: 1 }, + { name: 'Previous page', expectedPage: 2 }, + { name: 'Next page', expectedPage: 4 }, + { name: 'Last page', expectedPage: 5 }, + ])('$name calls onPageChange($expectedPage)', async ({ name, expectedPage }) => { + const { onPageChange } = renderPagination({ currentPage: 3 }); + await user.click(screen.getByRole('button', { name })); + expect(onPageChange).toHaveBeenCalledWith(expectedPage); + }); }); - }); - describe('page size selector', () => { - it('hidden when onPageSizeChange not provided', () => { - renderPagination(); - expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + describe('disabled states', () => { + it.each([ + { page: 1, disabled: ['First page', 'Previous page'] }, + { page: 5, disabled: ['Next page', 'Last page'] }, + ])('disables $disabled on page $page', ({ page, disabled }) => { + renderPagination({ currentPage: page }); + for (const name of disabled) { + expect(screen.getByRole('button', { name })).toBeDisabled(); + } + }); }); - it('renders select with default options when onPageSizeChange provided', () => { - renderPagination({ onPageSizeChange: vi.fn() } as Record); - expect(screen.getByRole('combobox')).toBeInTheDocument(); - expect(screen.getAllByRole('option').map((o) => o.textContent)).toEqual([ - '10 / page', '25 / page', '50 / page', '100 / page', - ]); - }); + describe('page size selector', () => { + it('hidden when onPageSizeChange not provided', () => { + renderPagination(); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + }); - it('fires onPageSizeChange on select change', async () => { - const onPageSizeChange = vi.fn(); - renderPagination({ onPageSizeChange } as Record); - await user.selectOptions(screen.getByRole('combobox'), '25'); - expect(onPageSizeChange).toHaveBeenCalledWith(25); - }); - }); + it('renders select with default options when onPageSizeChange provided', () => { + renderPagination({ onPageSizeChange: vi.fn() } as Record); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + expect(screen.getAllByRole('option').map((o) => o.textContent)).toEqual([ + '10 / page', + '25 / page', + '50 / page', + '100 / page', + ]); + }); - describe('visibility', () => { - it('renders nothing when totalPages=1 and no onPageSizeChange', () => { - const { container } = renderPagination({ totalPages: 1, totalItems: 5, pageSize: 10 }); - expect(container.querySelector('.pagination-container')).not.toBeInTheDocument(); + it('fires onPageSizeChange on select change', async () => { + const onPageSizeChange = vi.fn(); + renderPagination({ onPageSizeChange } as Record); + await user.selectOptions(screen.getByRole('combobox'), '25'); + expect(onPageSizeChange).toHaveBeenCalledWith(25); + }); }); - it('still shows when totalPages=1 if onPageSizeChange present', () => { - const { container } = renderPagination({ - totalPages: 1, totalItems: 5, pageSize: 10, - onPageSizeChange: vi.fn(), - } as Record); - expect(container.querySelector('.pagination-container')).toBeInTheDocument(); - }); + describe('visibility', () => { + it('renders nothing when totalPages=1 and no onPageSizeChange', () => { + const { container } = renderPagination({ totalPages: 1, totalItems: 5, pageSize: 10 }); + expect(container.querySelector('.pagination-container')).not.toBeInTheDocument(); + }); + + it('still shows when totalPages=1 if onPageSizeChange present', () => { + const { container } = renderPagination({ + totalPages: 1, + totalItems: 5, + pageSize: 10, + onPageSizeChange: vi.fn(), + } as Record); + expect(container.querySelector('.pagination-container')).toBeInTheDocument(); + }); - it('hides nav buttons when totalPages=1', () => { - renderPagination({ - totalPages: 1, totalItems: 5, pageSize: 10, - onPageSizeChange: vi.fn(), - } as Record); - expect(screen.queryByRole('button', { name: 'First page' })).not.toBeInTheDocument(); + it('hides nav buttons when totalPages=1', () => { + renderPagination({ + totalPages: 1, + totalItems: 5, + pageSize: 10, + onPageSizeChange: vi.fn(), + } as Record); + expect(screen.queryByRole('button', { name: 'First page' })).not.toBeInTheDocument(); + }); }); - }); }); diff --git a/frontend/src/components/__tests__/Spinner.test.ts b/frontend/src/components/__tests__/Spinner.test.ts index 943a7da6..d07a7156 100644 --- a/frontend/src/components/__tests__/Spinner.test.ts +++ b/frontend/src/components/__tests__/Spinner.test.ts @@ -3,103 +3,103 @@ import { render, screen } from '@testing-library/svelte'; import Spinner from '$components/Spinner.svelte'; describe('Spinner', () => { - const getSpinner = () => screen.getByRole('status'); + const getSpinner = () => screen.getByRole('status'); - describe('rendering', () => { - it('renders an accessible SVG spinner', () => { - render(Spinner); - const svg = getSpinner(); - expect(svg).toBeInTheDocument(); - expect(svg.tagName.toLowerCase()).toBe('svg'); - expect(svg).toHaveAttribute('aria-label', 'Loading'); - expect(svg.classList.contains('animate-spin')).toBe(true); + describe('rendering', () => { + it('renders an accessible SVG spinner', () => { + render(Spinner); + const svg = getSpinner(); + expect(svg).toBeInTheDocument(); + expect(svg.tagName.toLowerCase()).toBe('svg'); + expect(svg).toHaveAttribute('aria-label', 'Loading'); + expect(svg.classList.contains('animate-spin')).toBe(true); + }); }); - }); - describe('size prop', () => { - it.each([ - { size: 'small', heightClass: 'h-4', widthClass: 'w-4' }, - { size: 'medium', heightClass: 'h-6', widthClass: 'w-6' }, - { size: 'large', heightClass: 'h-8', widthClass: 'w-8' }, - { size: 'xlarge', heightClass: 'h-12', widthClass: 'w-12' }, - ] as const)('applies $heightClass/$widthClass for size="$size"', ({ size, heightClass, widthClass }) => { - render(Spinner, { props: { size } }); - const svg = getSpinner(); - expect(svg.classList.contains(heightClass)).toBe(true); - expect(svg.classList.contains(widthClass)).toBe(true); - }); + describe('size prop', () => { + it.each([ + { size: 'small', heightClass: 'h-4', widthClass: 'w-4' }, + { size: 'medium', heightClass: 'h-6', widthClass: 'w-6' }, + { size: 'large', heightClass: 'h-8', widthClass: 'w-8' }, + { size: 'xlarge', heightClass: 'h-12', widthClass: 'w-12' }, + ] as const)('applies $heightClass/$widthClass for size="$size"', ({ size, heightClass, widthClass }) => { + render(Spinner, { props: { size } }); + const svg = getSpinner(); + expect(svg.classList.contains(heightClass)).toBe(true); + expect(svg.classList.contains(widthClass)).toBe(true); + }); - it('defaults to medium size', () => { - render(Spinner); - const svg = getSpinner(); - expect(svg.classList.contains('h-6')).toBe(true); - expect(svg.classList.contains('w-6')).toBe(true); + it('defaults to medium size', () => { + render(Spinner); + const svg = getSpinner(); + expect(svg.classList.contains('h-6')).toBe(true); + expect(svg.classList.contains('w-6')).toBe(true); + }); }); - }); - describe('color prop', () => { - it.each([ - { color: 'primary', expectedClass: 'text-primary' }, - { color: 'white', expectedClass: 'text-white' }, - { color: 'current', expectedClass: 'text-current' }, - { color: 'muted', expectedClass: 'text-fg-subtle' }, - ] as const)('applies $expectedClass for color="$color"', ({ color, expectedClass }) => { - render(Spinner, { props: { color } }); - expect(getSpinner().classList.contains(expectedClass)).toBe(true); - }); + describe('color prop', () => { + it.each([ + { color: 'primary', expectedClass: 'text-primary' }, + { color: 'white', expectedClass: 'text-white' }, + { color: 'current', expectedClass: 'text-current' }, + { color: 'muted', expectedClass: 'text-fg-subtle' }, + ] as const)('applies $expectedClass for color="$color"', ({ color, expectedClass }) => { + render(Spinner, { props: { color } }); + expect(getSpinner().classList.contains(expectedClass)).toBe(true); + }); - it('defaults to primary color', () => { - render(Spinner); - expect(getSpinner().classList.contains('text-primary')).toBe(true); + it('defaults to primary color', () => { + render(Spinner); + expect(getSpinner().classList.contains('text-primary')).toBe(true); + }); }); - }); - describe('className prop', () => { - it('applies and combines custom className with defaults', () => { - render(Spinner, { props: { className: 'my-spinner', size: 'large' } }); - const svg = getSpinner(); - expect(svg.classList.contains('my-spinner')).toBe(true); - expect(svg.classList.contains('animate-spin')).toBe(true); - expect(svg.classList.contains('h-8')).toBe(true); + describe('className prop', () => { + it('applies and combines custom className with defaults', () => { + render(Spinner, { props: { className: 'my-spinner', size: 'large' } }); + const svg = getSpinner(); + expect(svg.classList.contains('my-spinner')).toBe(true); + expect(svg.classList.contains('animate-spin')).toBe(true); + expect(svg.classList.contains('h-8')).toBe(true); + }); }); - }); - describe('SVG structure', () => { - it('contains required SVG elements with correct viewBox', () => { - render(Spinner); - const svg = getSpinner(); - expect(svg.querySelector('circle')).toBeInTheDocument(); - expect(svg.querySelector('path')).toBeInTheDocument(); - expect(svg.getAttribute('viewBox')).toBe('0 0 24 24'); + describe('SVG structure', () => { + it('contains required SVG elements with correct viewBox', () => { + render(Spinner); + const svg = getSpinner(); + expect(svg.querySelector('circle')).toBeInTheDocument(); + expect(svg.querySelector('path')).toBeInTheDocument(); + expect(svg.getAttribute('viewBox')).toBe('0 0 24 24'); + }); }); - }); - describe('fallback behavior for invalid props', () => { - it('falls back to medium size for invalid size value', () => { - // @ts-expect-error - intentionally passing invalid value to test fallback - render(Spinner, { props: { size: 'invalid-size' } }); - const svg = getSpinner(); - // Should fall back to medium (h-6 w-6) - expect(svg.classList.contains('h-6')).toBe(true); - expect(svg.classList.contains('w-6')).toBe(true); - }); + describe('fallback behavior for invalid props', () => { + it('falls back to medium size for invalid size value', () => { + // @ts-expect-error - intentionally passing invalid value to test fallback + render(Spinner, { props: { size: 'invalid-size' } }); + const svg = getSpinner(); + // Should fall back to medium (h-6 w-6) + expect(svg.classList.contains('h-6')).toBe(true); + expect(svg.classList.contains('w-6')).toBe(true); + }); - it('falls back to primary color for invalid color value', () => { - // @ts-expect-error - intentionally passing invalid value to test fallback - render(Spinner, { props: { color: 'invalid-color' } }); - const svg = getSpinner(); - // Should fall back to primary - expect(svg.classList.contains('text-primary')).toBe(true); - }); + it('falls back to primary color for invalid color value', () => { + // @ts-expect-error - intentionally passing invalid value to test fallback + render(Spinner, { props: { color: 'invalid-color' } }); + const svg = getSpinner(); + // Should fall back to primary + expect(svg.classList.contains('text-primary')).toBe(true); + }); - it('handles both invalid size and color gracefully', () => { - // @ts-expect-error - intentionally passing invalid values to test fallback - render(Spinner, { props: { size: 'unknown', color: 'unknown' } }); - const svg = getSpinner(); - // Should fall back to defaults - expect(svg.classList.contains('h-6')).toBe(true); - expect(svg.classList.contains('w-6')).toBe(true); - expect(svg.classList.contains('text-primary')).toBe(true); + it('handles both invalid size and color gracefully', () => { + // @ts-expect-error - intentionally passing invalid values to test fallback + render(Spinner, { props: { size: 'unknown', color: 'unknown' } }); + const svg = getSpinner(); + // Should fall back to defaults + expect(svg.classList.contains('h-6')).toBe(true); + expect(svg.classList.contains('w-6')).toBe(true); + expect(svg.classList.contains('text-primary')).toBe(true); + }); }); - }); }); diff --git a/frontend/src/components/admin/AutoRefreshControl.svelte b/frontend/src/components/admin/AutoRefreshControl.svelte index 975e49ac..2cff796d 100644 --- a/frontend/src/components/admin/AutoRefreshControl.svelte +++ b/frontend/src/components/admin/AutoRefreshControl.svelte @@ -24,12 +24,12 @@ { value: 5, label: '5 seconds' }, { value: 10, label: '10 seconds' }, { value: 30, label: '30 seconds' }, - { value: 60, label: '1 minute' } + { value: 60, label: '1 minute' }, ], loading = false, onRefresh, onEnabledChange, - onRateChange + onRateChange, }: Props = $props(); function handleEnabledChange(e: Event): void { @@ -60,12 +60,7 @@ {#if enabled}
- {#each rateOptions as option} {/each} @@ -73,7 +68,8 @@
{/if} - + {/if} {#if onApply} - + {/if} diff --git a/frontend/src/components/admin/__tests__/AutoRefreshControl.test.ts b/frontend/src/components/admin/__tests__/AutoRefreshControl.test.ts index 0d4f5af5..8adc33dc 100644 --- a/frontend/src/components/admin/__tests__/AutoRefreshControl.test.ts +++ b/frontend/src/components/admin/__tests__/AutoRefreshControl.test.ts @@ -4,69 +4,69 @@ import { user } from '$test/test-utils'; import AutoRefreshControl from '$components/admin/AutoRefreshControl.svelte'; vi.mock('$components/Spinner.svelte', async () => { - const utils = await import('$test/test-utils'); - return { default: utils.createMockNamedComponents({ default: '...' }).default }; + const utils = await import('$test/test-utils'); + return { default: utils.createMockNamedComponents({ default: '...' }).default }; }); function renderControl(overrides: Record = {}) { - const onRefresh = vi.fn(); - const onEnabledChange = vi.fn(); - const onRateChange = vi.fn(); - const result = render(AutoRefreshControl, { - props: { - enabled: true, - rate: 5, - loading: false, - onRefresh, - onEnabledChange, - onRateChange, - ...overrides, - }, - }); - return { ...result, onRefresh, onEnabledChange, onRateChange }; + const onRefresh = vi.fn(); + const onEnabledChange = vi.fn(); + const onRateChange = vi.fn(); + const result = render(AutoRefreshControl, { + props: { + enabled: true, + rate: 5, + loading: false, + onRefresh, + onEnabledChange, + onRateChange, + ...overrides, + }, + }); + return { ...result, onRefresh, onEnabledChange, onRateChange }; } describe('AutoRefreshControl', () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => vi.clearAllMocks()); - it('renders auto-refresh checkbox', () => { - renderControl(); - expect(screen.getByText('Auto-refresh')).toBeInTheDocument(); - }); + it('renders auto-refresh checkbox', () => { + renderControl(); + expect(screen.getByText('Auto-refresh')).toBeInTheDocument(); + }); - it.each([ - [true, true, true], - [false, false, false], - ] as const)('when enabled=%s: checkbox checked=%s, rate selector visible=%s', (enabled, checked, rateVisible) => { - renderControl({ enabled }); - const checkbox = screen.getByRole('checkbox'); - if (checked) expect(checkbox).toBeChecked(); - else expect(checkbox).not.toBeChecked(); - if (rateVisible) expect(screen.getByLabelText(/Every/)).toBeInTheDocument(); - else expect(screen.queryByLabelText(/Every/)).not.toBeInTheDocument(); - }); + it.each([ + [true, true, true], + [false, false, false], + ] as const)('when enabled=%s: checkbox checked=%s, rate selector visible=%s', (enabled, checked, rateVisible) => { + renderControl({ enabled }); + const checkbox = screen.getByRole('checkbox'); + if (checked) expect(checkbox).toBeChecked(); + else expect(checkbox).not.toBeChecked(); + if (rateVisible) expect(screen.getByLabelText(/Every/)).toBeInTheDocument(); + else expect(screen.queryByLabelText(/Every/)).not.toBeInTheDocument(); + }); - it.each([ - [false, 'Refresh Now', false], - [true, 'Refreshing...', true], - ] as const)('when loading=%s: shows "%s" button, disabled=%s', (loading, text, disabled) => { - renderControl({ loading }); - const btn = screen.getByText(text).closest('button')!; - expect(btn).toBeInTheDocument(); - if (disabled) expect(btn).toBeDisabled(); - else expect(btn).toBeEnabled(); - }); + it.each([ + [false, 'Refresh Now', false], + [true, 'Refreshing...', true], + ] as const)('when loading=%s: shows "%s" button, disabled=%s', (loading, text, disabled) => { + renderControl({ loading }); + const btn = screen.getByText(text).closest('button')!; + expect(btn).toBeInTheDocument(); + if (disabled) expect(btn).toBeDisabled(); + else expect(btn).toBeEnabled(); + }); - it('calls onRefresh when Refresh Now clicked', async () => { - const { onRefresh } = renderControl(); - await user.click(screen.getByText('Refresh Now')); - expect(onRefresh).toHaveBeenCalledOnce(); - }); + it('calls onRefresh when Refresh Now clicked', async () => { + const { onRefresh } = renderControl(); + await user.click(screen.getByText('Refresh Now')); + expect(onRefresh).toHaveBeenCalledOnce(); + }); - it('rate selector has default options', () => { - renderControl({ enabled: true }); - const select = screen.getByLabelText(/Every/) as HTMLSelectElement; - const labels = Array.from(select.options).map(o => o.text); - expect(labels).toEqual(['5 seconds', '10 seconds', '30 seconds', '1 minute']); - }); + it('rate selector has default options', () => { + renderControl({ enabled: true }); + const select = screen.getByLabelText(/Every/) as HTMLSelectElement; + const labels = Array.from(select.options).map((o) => o.text); + expect(labels).toEqual(['5 seconds', '10 seconds', '30 seconds', '1 minute']); + }); }); diff --git a/frontend/src/components/admin/__tests__/FilterPanel.test.ts b/frontend/src/components/admin/__tests__/FilterPanel.test.ts index ee94a957..a592e543 100644 --- a/frontend/src/components/admin/__tests__/FilterPanel.test.ts +++ b/frontend/src/components/admin/__tests__/FilterPanel.test.ts @@ -4,89 +4,92 @@ import { user } from '$test/test-utils'; import FilterPanelWrapper from './FilterPanelWrapper.svelte'; describe('FilterPanel', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('toggle button', () => { - it('renders toggle button by default', () => { - render(FilterPanelWrapper); - expect(screen.getByRole('button', { name: /Filters/i })).toBeInTheDocument(); + beforeEach(() => { + vi.clearAllMocks(); }); - it('hides toggle button when showToggleButton=false', () => { - render(FilterPanelWrapper, { props: { showToggleButton: false } }); - expect(screen.queryByRole('button', { name: /Filters/i })).not.toBeInTheDocument(); - }); + describe('toggle button', () => { + it('renders toggle button by default', () => { + render(FilterPanelWrapper); + expect(screen.getByRole('button', { name: /Filters/i })).toBeInTheDocument(); + }); + + it('hides toggle button when showToggleButton=false', () => { + render(FilterPanelWrapper, { props: { showToggleButton: false } }); + expect(screen.queryByRole('button', { name: /Filters/i })).not.toBeInTheDocument(); + }); - it('flips open and fires onToggle on click', async () => { - const onToggle = vi.fn(); - render(FilterPanelWrapper, { props: { onToggle } }); - await user.click(screen.getByRole('button', { name: /Filters/i })); - expect(onToggle).toHaveBeenCalledOnce(); - expect(screen.getByTestId('filter-content')).toBeInTheDocument(); + it('flips open and fires onToggle on click', async () => { + const onToggle = vi.fn(); + render(FilterPanelWrapper, { props: { onToggle } }); + await user.click(screen.getByRole('button', { name: /Filters/i })); + expect(onToggle).toHaveBeenCalledOnce(); + expect(screen.getByTestId('filter-content')).toBeInTheDocument(); + }); }); - }); - describe('active filter badge', () => { - it.each([ - { hasActiveFilters: true, visible: true }, - { hasActiveFilters: false, visible: false }, - ])('badge visible=$visible when hasActiveFilters=$hasActiveFilters', ({ hasActiveFilters, visible }) => { - render(FilterPanelWrapper, { - props: { hasActiveFilters, activeFilterCount: 3 }, - }); - if (visible) { - expect(screen.getByText('3')).toBeInTheDocument(); - } else { - expect(screen.queryByText('3')).not.toBeInTheDocument(); - } + describe('active filter badge', () => { + it.each([ + { hasActiveFilters: true, visible: true }, + { hasActiveFilters: false, visible: false }, + ])('badge visible=$visible when hasActiveFilters=$hasActiveFilters', ({ hasActiveFilters, visible }) => { + render(FilterPanelWrapper, { + props: { hasActiveFilters, activeFilterCount: 3 }, + }); + if (visible) { + expect(screen.getByText('3')).toBeInTheDocument(); + } else { + expect(screen.queryByText('3')).not.toBeInTheDocument(); + } + }); }); - }); - describe('panel content', () => { - it.each([ - { open: true, visible: true }, - { open: false, visible: false }, - ])('children visible=$visible when open=$open', ({ open, visible }) => { - render(FilterPanelWrapper, { props: { open } }); - if (visible) { - expect(screen.getByTestId('filter-content')).toBeInTheDocument(); - } else { - expect(screen.queryByTestId('filter-content')).not.toBeInTheDocument(); - } + describe('panel content', () => { + it.each([ + { open: true, visible: true }, + { open: false, visible: false }, + ])('children visible=$visible when open=$open', ({ open, visible }) => { + render(FilterPanelWrapper, { props: { open } }); + if (visible) { + expect(screen.getByTestId('filter-content')).toBeInTheDocument(); + } else { + expect(screen.queryByTestId('filter-content')).not.toBeInTheDocument(); + } + }); }); - }); - describe('title', () => { - it.each([ - { title: undefined, expected: 'Filter' }, - { title: 'Advanced', expected: 'Advanced' }, - ])('renders "$expected" when title=$title', ({ title, expected }) => { - render(FilterPanelWrapper, { props: { open: true, title } }); - expect(screen.getByText(expected)).toBeInTheDocument(); + describe('title', () => { + it.each([ + { title: undefined, expected: 'Filter' }, + { title: 'Advanced', expected: 'Advanced' }, + ])('renders "$expected" when title=$title', ({ title, expected }) => { + render(FilterPanelWrapper, { props: { open: true, title } }); + expect(screen.getByText(expected)).toBeInTheDocument(); + }); }); - }); - describe('action buttons', () => { - it.each([ - { button: 'Clear All', callbackProp: 'onClear' }, - { button: 'Apply', callbackProp: 'onApply' }, - ] as const)('$button: visible only when $callbackProp provided, fires callback', async ({ button, callbackProp }) => { - // Hidden when no callback - render(FilterPanelWrapper, { props: { open: true } }); - expect(screen.queryByRole('button', { name: new RegExp(button, 'i') })).not.toBeInTheDocument(); + describe('action buttons', () => { + it.each([ + { button: 'Clear All', callbackProp: 'onClear' }, + { button: 'Apply', callbackProp: 'onApply' }, + ] as const)( + '$button: visible only when $callbackProp provided, fires callback', + async ({ button, callbackProp }) => { + // Hidden when no callback + render(FilterPanelWrapper, { props: { open: true } }); + expect(screen.queryByRole('button', { name: new RegExp(button, 'i') })).not.toBeInTheDocument(); - // Visible and fires callback - const callback = vi.fn(); - const { unmount } = render(FilterPanelWrapper, { - props: { open: true, [callbackProp]: callback }, - }); - const btn = screen.getByRole('button', { name: new RegExp(button, 'i') }); - expect(btn).toBeInTheDocument(); - await user.click(btn); - expect(callback).toHaveBeenCalledOnce(); - unmount(); + // Visible and fires callback + const callback = vi.fn(); + const { unmount } = render(FilterPanelWrapper, { + props: { open: true, [callbackProp]: callback }, + }); + const btn = screen.getByRole('button', { name: new RegExp(button, 'i') }); + expect(btn).toBeInTheDocument(); + await user.click(btn); + expect(callback).toHaveBeenCalledOnce(); + unmount(); + }, + ); }); - }); }); diff --git a/frontend/src/components/admin/__tests__/FilterPanelWrapper.svelte b/frontend/src/components/admin/__tests__/FilterPanelWrapper.svelte index 7b67a3aa..cacb8849 100644 --- a/frontend/src/components/admin/__tests__/FilterPanelWrapper.svelte +++ b/frontend/src/components/admin/__tests__/FilterPanelWrapper.svelte @@ -1,29 +1,29 @@ -
Filter slot
+
Filter slot
diff --git a/frontend/src/components/admin/events/EventDetailsModal.svelte b/frontend/src/components/admin/events/EventDetailsModal.svelte index da94101b..6f5a680b 100644 --- a/frontend/src/components/admin/events/EventDetailsModal.svelte +++ b/frontend/src/components/admin/events/EventDetailsModal.svelte @@ -29,13 +29,19 @@ Event ID - {eventData.event_id} + {eventData.event_id} - Event Type + Event Type
- + @@ -46,11 +52,17 @@ Timestamp - {formatTimestamp(eventData.timestamp)} + {formatTimestamp(eventData.timestamp)} - Aggregate ID - {eventData.aggregate_id || '-'} + Aggregate ID + {eventData.aggregate_id || '-'} @@ -58,7 +70,12 @@

Full Event Data

-
{JSON.stringify(eventData, null, 2)}
+
{JSON.stringify(
+                        eventData,
+                        null,
+                        2,
+                    )}
{#if relatedEvents.length > 0} @@ -66,7 +83,8 @@

Related Events

{#each relatedEvents as related} - - + {/snippet} diff --git a/frontend/src/components/admin/events/EventFilters.svelte b/frontend/src/components/admin/events/EventFilters.svelte index 0e353f06..7943d654 100644 --- a/frontend/src/components/admin/events/EventFilters.svelte +++ b/frontend/src/components/admin/events/EventFilters.svelte @@ -18,12 +18,8 @@ Filter Events
- - + +
@@ -31,62 +27,84 @@
-
-
-
-