diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 633ea3fc..6322860e 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next'; +import { Suspense } from 'react'; import { AdvancedSearchInterface } from '@/components/search/AdvancedSearchInterface'; export const metadata: Metadata = { @@ -21,7 +22,15 @@ export const metadata: Metadata = { export default function SearchPage() { return (
- + + Loading search... + + } + > + +
); } diff --git a/src/components/search/SearchResultsVisualizer.tsx b/src/components/search/SearchResultsVisualizer.tsx index ed3b90b5..6579e0be 100644 --- a/src/components/search/SearchResultsVisualizer.tsx +++ b/src/components/search/SearchResultsVisualizer.tsx @@ -48,13 +48,19 @@ export const SearchResultsVisualizer = React.memo( if (results.length === 0) { return ( -
+
- +

No results found

-

- Try expanding your search parameters or checking for typos. +

+ We couldn't find any matches for your search. Try adjusting your query keywords or + filters to find what you're looking for.

); diff --git a/src/components/search/__tests__/SearchResultsVisualizer.test.tsx b/src/components/search/__tests__/SearchResultsVisualizer.test.tsx new file mode 100644 index 00000000..1b78c4cc --- /dev/null +++ b/src/components/search/__tests__/SearchResultsVisualizer.test.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { SearchResultsVisualizer } from '../SearchResultsVisualizer'; +import type { SearchResult } from '../../utils/searchUtils'; + +const mockResults: SearchResult[] = [ + { + id: 'res-1', + type: 'course', + title: 'Starknet Cairo Essentials', + description: 'Learn Cairo and Starknet development from scratch.', + createdAt: '2024-05-10T12:00:00.000Z', + relevanceScore: 0.95, + author: 'Alice', + rating: 4.8, + price: 0, + topic: 'Cairo', + difficulty: 'beginner', + reputation: 99, + }, +]; + +describe('SearchResultsVisualizer', () => { + it('renders a distinct, accessible empty state when there are zero results and not searching', () => { + const onSortChange = vi.fn(); + render( + , + ); + + const emptyState = screen.getByTestId('search-empty-state'); + expect(emptyState).toBeInTheDocument(); + expect(emptyState).toHaveAttribute('role', 'status'); + expect(emptyState).toHaveAttribute('aria-live', 'polite'); + + expect(screen.getByRole('heading', { level: 3, name: /no results found/i })).toBeInTheDocument(); + expect( + screen.getByText(/we couldn't find any matches for your search/i), + ).toBeInTheDocument(); + expect( + screen.getByText(/try adjusting your query keywords or filters/i), + ).toBeInTheDocument(); + }); + + it('renders loading skeleton when isSearching is true', () => { + const onSortChange = vi.fn(); + const { container } = render( + , + ); + + expect(screen.queryByTestId('search-empty-state')).not.toBeInTheDocument(); + expect(container.querySelectorAll('.animate-pulse').length).toBe(3); + }); + + it('renders search results when results are provided', () => { + const onSortChange = vi.fn(); + render( + , + ); + + expect(screen.queryByTestId('search-empty-state')).not.toBeInTheDocument(); + expect(screen.getByText('1 results')).toBeInTheDocument(); + expect(screen.getByText('Starknet Cairo Essentials')).toBeInTheDocument(); + expect(screen.getByText('Learn Cairo and Starknet development from scratch.')).toBeInTheDocument(); + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('FREE')).toBeInTheDocument(); + }); + + it('calls onSortChange when user changes the sort option', () => { + const onSortChange = vi.fn(); + render( + , + ); + + const sortSelect = screen.getByRole('combobox'); + fireEvent.change(sortSelect, { target: { value: 'newest' } }); + + expect(onSortChange).toHaveBeenCalledWith('newest'); + }); +}); diff --git a/src/hooks/useSearchFilters.tsx b/src/hooks/useSearchFilters.tsx index c2060a11..de752816 100644 --- a/src/hooks/useSearchFilters.tsx +++ b/src/hooks/useSearchFilters.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useCallback, useMemo, useRef } from 'react'; import { useSearchParams, useRouter, usePathname } from 'next/navigation'; +import { debounce } from '../utils/formUtils'; export interface FilterState { difficulty: string[]; @@ -76,7 +77,7 @@ export const useSearchFilters = () => { }, [router, pathname, searchParams]); useEffect(() => { - const timer = setTimeout(() => { + const debouncedSync = debounce(() => { const params = new URLSearchParams(); if (filters.difficulty && filters.difficulty.length > 0) { @@ -123,7 +124,11 @@ export const useSearchFilters = () => { pRouter.replace(newUrl, { scroll: false }); }, 300); - return () => clearTimeout(timer); + debouncedSync(); + + return () => { + debouncedSync.cancel(); + }; }, [filters]); const setFilters = useCallback((newFilters: Partial) => { diff --git a/src/utils/accessibilityUtils.ts b/src/utils/accessibilityUtils.ts index 4f49eae7..874841ae 100644 --- a/src/utils/accessibilityUtils.ts +++ b/src/utils/accessibilityUtils.ts @@ -37,7 +37,15 @@ function getLuminance(r: number, g: number, b: number): number { * Parse hex color to RGB */ function hexToRgb(hex: string): { r: number; g: number; b: number } | null { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + const cleanHex = hex.trim().replace(/^#/, ''); + if (cleanHex.length === 3) { + const r = parseInt(cleanHex[0] + cleanHex[0], 16); + const g = parseInt(cleanHex[1] + cleanHex[1], 16); + const b = parseInt(cleanHex[2] + cleanHex[2], 16); + if (isNaN(r) || isNaN(g) || isNaN(b)) return null; + return { r, g, b }; + } + const result = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(cleanHex); return result ? { r: parseInt(result[1], 16), diff --git a/src/utils/formUtils.ts b/src/utils/formUtils.ts index 7938ca9e..dd2d91f5 100644 --- a/src/utils/formUtils.ts +++ b/src/utils/formUtils.ts @@ -350,16 +350,21 @@ export function formDataToObject(formData: FormData): Record { return obj; } +export interface DebouncedFunction any> { + (...args: Parameters): void; + cancel: () => void; +} + /** * Debounce function for form operations */ export function debounce any>( func: T, wait: number, -): (...args: Parameters) => void { +): DebouncedFunction { let timeout: ReturnType | null = null; - return function executedFunction(...args: Parameters) { + const executedFunction = function (...args: Parameters) { const later = () => { timeout = null; func(...args); @@ -370,6 +375,15 @@ export function debounce any>( } timeout = setTimeout(later, wait); }; + + executedFunction.cancel = () => { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + }; + + return executedFunction; } /** diff --git a/src/utils/notificationUtils.ts b/src/utils/notificationUtils.ts index 9b8ea016..86413c31 100644 --- a/src/utils/notificationUtils.ts +++ b/src/utils/notificationUtils.ts @@ -73,8 +73,14 @@ export function generateNotificationId(): string { */ export function formatNotificationTime(timestamp: string): string { const date = new Date(timestamp); + if (isNaN(date.getTime())) { + return ''; + } const now = new Date(); const diffMs = now.getTime() - date.getTime(); + if (diffMs < 0) { + return 'Just now'; + } const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); @@ -99,13 +105,25 @@ export function isWithinQuietHours(quietHours: { end: string; timezone: string; }): boolean { + if (!quietHours?.start || !quietHours?.end || !quietHours?.timezone) { + return false; + } const now = new Date(); - const currentTime = now.toLocaleTimeString('en-US', { - hour12: false, - hour: '2-digit', - minute: '2-digit', - timeZone: quietHours.timezone, - }); + let currentTime: string; + try { + currentTime = now.toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + timeZone: quietHours.timezone, + }); + } catch { + currentTime = now.toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + }); + } const start = quietHours.start; const end = quietHours.end; @@ -126,6 +144,10 @@ export function shouldSendNotification( channel: NotificationChannel, preferences: UserNotificationPreferences, ): boolean { + if (!preferences?.channels || !preferences?.categories) { + return false; + } + // Check if channel is enabled globally if (!preferences.channels[channel === 'in-app' ? 'inApp' : channel]) { return false; @@ -138,19 +160,19 @@ export function shouldSendNotification( } // Check if channel is enabled for this category - if (!categoryPrefs.channels.includes(channel)) { + if (!categoryPrefs.channels?.includes(channel)) { return false; } // Check quiet hours - if (preferences.quietHours.enabled && channel !== 'in-app') { + if (preferences.quietHours?.enabled && channel !== 'in-app') { if (isWithinQuietHours(preferences.quietHours)) { return false; } } // Check category-specific quiet hours - if (categoryPrefs.quietHours) { + if (categoryPrefs.quietHours?.start && categoryPrefs.quietHours?.end) { const now = new Date(); const currentTime = now.toLocaleTimeString('en-US', { hour12: false, @@ -159,8 +181,11 @@ export function shouldSendNotification( }); if ( - currentTime >= categoryPrefs.quietHours.start && - currentTime <= categoryPrefs.quietHours.end + categoryPrefs.quietHours.start > categoryPrefs.quietHours.end + ? currentTime >= categoryPrefs.quietHours.start || + currentTime <= categoryPrefs.quietHours.end + : currentTime >= categoryPrefs.quietHours.start && + currentTime <= categoryPrefs.quietHours.end ) { return false; } diff --git a/src/utils/tests/accessibilityUtils.test.ts b/src/utils/tests/accessibilityUtils.test.ts new file mode 100644 index 00000000..fe76d194 --- /dev/null +++ b/src/utils/tests/accessibilityUtils.test.ts @@ -0,0 +1,444 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + calculateContrastRatio, + getComputedColor, + isFocusable, + getFocusableElements, + getRovingFocusCandidates, + trapFocus, + hasAccessibleName, + generateAriaId, + announceToScreenReader, + checkAccessibilityIssues, + runAccessibilityAudit, + getWCAGLevel, + AccessibilityIssue, +} from '../accessibilityUtils'; + +describe('accessibilityUtils', () => { + describe('calculateContrastRatio', () => { + it('calculates maximum contrast ratio for black and white', () => { + const result = calculateContrastRatio('#000000', '#ffffff'); + expect(result.ratio).toBe(21); + expect(result.passes.aa).toBe(true); + expect(result.passes.aaa).toBe(true); + expect(result.passes.aaLarge).toBe(true); + expect(result.passes.aaaLarge).toBe(true); + }); + + it('calculates contrast ratio for 3-digit shorthand hex codes', () => { + const result = calculateContrastRatio('#000', '#fff'); + expect(result.ratio).toBe(21); + expect(result.passes.aa).toBe(true); + }); + + it('calculates minimum contrast ratio for identical colors', () => { + const result = calculateContrastRatio('#ffffff', '#ffffff'); + expect(result.ratio).toBe(1); + expect(result.passes.aa).toBe(false); + expect(result.passes.aaa).toBe(false); + expect(result.passes.aaLarge).toBe(false); + expect(result.passes.aaaLarge).toBe(false); + }); + + it('handles intermediate contrast ratios accurately', () => { + const result = calculateContrastRatio('#767676', '#ffffff'); + expect(result.ratio).toBeGreaterThanOrEqual(4.5); + expect(result.passes.aa).toBe(true); + }); + + it('returns zero ratio and false passes for invalid color values', () => { + const result1 = calculateContrastRatio('invalid', '#ffffff'); + expect(result1.ratio).toBe(0); + expect(result1.passes.aa).toBe(false); + + const result2 = calculateContrastRatio('#ffffff', 'not-a-color'); + expect(result2.ratio).toBe(0); + expect(result2.passes.aa).toBe(false); + }); + }); + + describe('getComputedColor', () => { + it('converts computed RGB style to hex', () => { + const div = document.createElement('div'); + vi.spyOn(window, 'getComputedStyle').mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('rgb(255, 0, 128)'), + } as unknown as CSSStyleDeclaration); + + const color = getComputedColor(div, 'color'); + expect(color).toBe('#ff0080'); + }); + + it('returns #000000 when computed color does not match rgb pattern', () => { + const div = document.createElement('div'); + vi.spyOn(window, 'getComputedStyle').mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('transparent'), + } as unknown as CSSStyleDeclaration); + + const color = getComputedColor(div, 'background-color'); + expect(color).toBe('#000000'); + }); + }); + + describe('isFocusable', () => { + it('returns true for naturally focusable tags', () => { + const button = document.createElement('button'); + const anchor = document.createElement('a'); + const input = document.createElement('input'); + const select = document.createElement('select'); + const textarea = document.createElement('textarea'); + + expect(isFocusable(button)).toBe(true); + expect(isFocusable(anchor)).toBe(true); + expect(isFocusable(input)).toBe(true); + expect(isFocusable(select)).toBe(true); + expect(isFocusable(textarea)).toBe(true); + }); + + it('returns true for elements with non-negative tabindex', () => { + const div = document.createElement('div'); + div.setAttribute('tabindex', '0'); + expect(isFocusable(div)).toBe(true); + + const span = document.createElement('span'); + span.setAttribute('tabindex', '1'); + expect(isFocusable(span)).toBe(true); + }); + + it('returns true for contenteditable elements', () => { + const div = document.createElement('div'); + div.setAttribute('contenteditable', 'true'); + expect(isFocusable(div)).toBe(true); + }); + + it('returns false for non-focusable elements', () => { + const div = document.createElement('div'); + const p = document.createElement('p'); + const span = document.createElement('span'); + + expect(isFocusable(div)).toBe(false); + expect(isFocusable(p)).toBe(false); + expect(isFocusable(span)).toBe(false); + }); + }); + + describe('getFocusableElements', () => { + it('finds and filters focusable elements within container', () => { + const container = document.createElement('div'); + container.innerHTML = ` + Link + Anchor without href + + + + + + +
Custom focusable
+
Excluded tabindex
+ + + `; + document.body.appendChild(container); + + const focusable = getFocusableElements(container); + expect(focusable.length).toBe(6); + expect(focusable.map((el) => el.tagName.toLowerCase())).toEqual([ + 'a', + 'button', + 'input', + 'select', + 'textarea', + 'div', + ]); + + document.body.removeChild(container); + }); + }); + + describe('getRovingFocusCandidates', () => { + it('returns candidates including roving items and menu items', () => { + const container = document.createElement('div'); + container.innerHTML = ` + + Link + + +
Item 1
+
Disabled item
+
Tab 1
+
Radio 1
+
Roving
+ + `; + + const candidates = getRovingFocusCandidates(container); + expect(candidates.length).toBe(6); + }); + }); + + describe('trapFocus', () => { + it('ignores non-Tab key events', () => { + const container = document.createElement('div'); + const event = new KeyboardEvent('keydown', { key: 'Enter' }); + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + + trapFocus(container, event); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + }); + + it('wraps focus to first element when tabbing from last element', () => { + const container = document.createElement('div'); + container.innerHTML = ` + + + `; + document.body.appendChild(container); + + const first = container.querySelector('#first') as HTMLElement; + const last = container.querySelector('#last') as HTMLElement; + last.focus(); + + const focusSpy = vi.spyOn(first, 'focus'); + const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: false }); + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + + trapFocus(container, event); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(focusSpy).toHaveBeenCalled(); + + document.body.removeChild(container); + }); + + it('wraps focus to last element when shift-tabbing from first element', () => { + const container = document.createElement('div'); + container.innerHTML = ` + + + `; + document.body.appendChild(container); + + const first = container.querySelector('#first') as HTMLElement; + const last = container.querySelector('#last') as HTMLElement; + first.focus(); + + const focusSpy = vi.spyOn(last, 'focus'); + const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true }); + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + + trapFocus(container, event); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(focusSpy).toHaveBeenCalled(); + + document.body.removeChild(container); + }); + }); + + describe('hasAccessibleName', () => { + it('detects accessible names across aria-label, aria-labelledby, textContent, and title', () => { + const el1 = document.createElement('button'); + el1.setAttribute('aria-label', 'Close'); + expect(hasAccessibleName(el1)).toBe(true); + + const el2 = document.createElement('button'); + el2.setAttribute('aria-labelledby', 'heading-1'); + expect(hasAccessibleName(el2)).toBe(true); + + const el3 = document.createElement('button'); + el3.textContent = 'Submit'; + expect(hasAccessibleName(el3)).toBe(true); + + const el4 = document.createElement('button'); + el4.setAttribute('title', 'Helpful info'); + expect(hasAccessibleName(el4)).toBe(true); + + const elEmpty = document.createElement('button'); + expect(hasAccessibleName(elEmpty)).toBe(false); + }); + }); + + describe('generateAriaId', () => { + it('generates unique ARIA ids with default or custom prefix', () => { + const id1 = generateAriaId(); + const id2 = generateAriaId(); + const customId = generateAriaId('dialog'); + + expect(id1.startsWith('aria-')).toBe(true); + expect(customId.startsWith('dialog-')).toBe(true); + expect(id1).not.toBe(id2); + }); + }); + + describe('announceToScreenReader', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('creates polite live region announcement and cleans it up after 1s', () => { + announceToScreenReader('Changes saved'); + + const el = document.body.querySelector('.sr-only'); + expect(el).not.toBeNull(); + expect(el?.getAttribute('role')).toBe('status'); + expect(el?.getAttribute('aria-live')).toBe('polite'); + expect(el?.textContent).toBe('Changes saved'); + + vi.advanceTimersByTime(1000); + expect(document.body.querySelector('.sr-only')).toBeNull(); + }); + + it('creates assertive live region when requested', () => { + announceToScreenReader('Error occurred', 'assertive'); + + const el = document.body.querySelector('.sr-only'); + expect(el?.getAttribute('role')).toBe('alert'); + expect(el?.getAttribute('aria-live')).toBe('assertive'); + + vi.advanceTimersByTime(1000); + expect(document.body.querySelector('.sr-only')).toBeNull(); + }); + }); + + describe('checkAccessibilityIssues and runAccessibilityAudit', () => { + it('detects missing alt attributes on images', () => { + const container = document.createElement('div'); + container.innerHTML = ` + + + `; + + const issues = checkAccessibilityIssues(container); + expect(issues.some((i) => i.type === 'missing-alt')).toBe(true); + expect(issues.filter((i) => i.type === 'missing-alt')).toHaveLength(1); + }); + + it('detects unlabelled form inputs', () => { + const container = document.createElement('div'); + container.innerHTML = ` + + + + + + + `; + + const issues = checkAccessibilityIssues(container); + const labelIssues = issues.filter((i) => i.type === 'missing-label'); + expect(labelIssues).toHaveLength(1); + }); + + it('detects buttons and links missing accessible names', () => { + const container = document.createElement('div'); + container.innerHTML = ` + + + + About + `; + + const issues = checkAccessibilityIssues(container); + expect(issues.some((i) => i.type === 'missing-accessible-name' && i.element === 'button')).toBe(true); + expect(issues.some((i) => i.type === 'missing-accessible-name' && i.element === 'a')).toBe(true); + }); + + it('detects skipped heading levels', () => { + const container = document.createElement('div'); + container.innerHTML = ` +

Title

+

Skipped subheader

+ `; + + const issues = checkAccessibilityIssues(container); + expect(issues.some((i) => i.type === 'heading-hierarchy')).toBe(true); + }); + + it('detects duplicate id attributes', () => { + const container = document.createElement('div'); + container.innerHTML = ` +
One
+
Two
+ `; + + const issues = checkAccessibilityIssues(container); + expect(issues.some((i) => i.type === 'duplicate-id')).toBe(true); + }); + + it('runs audit with runAccessibilityAudit', () => { + const container = document.createElement('div'); + container.innerHTML = `

Page

Clean markup

`; + const issues = runAccessibilityAudit(container); + expect(Array.isArray(issues)).toBe(true); + }); + }); + + describe('getWCAGLevel', () => { + it('returns Fail when critical issues are present', () => { + const issues: AccessibilityIssue[] = [ + { + id: '1', + severity: 'critical', + type: 'missing-alt', + element: 'img', + message: 'Error', + wcagCriteria: ['1.1.1'], + suggestion: 'Fix', + }, + ]; + expect(getWCAGLevel(issues)).toBe('Fail'); + }); + + it('returns A when serious issues are present', () => { + const issues: AccessibilityIssue[] = [ + { + id: '1', + severity: 'serious', + type: 'duplicate-id', + element: '#id', + message: 'Error', + wcagCriteria: ['4.1.1'], + suggestion: 'Fix', + }, + ]; + expect(getWCAGLevel(issues)).toBe('A'); + }); + + it('returns AA when there are more than 5 moderate/minor issues', () => { + const issues: AccessibilityIssue[] = Array.from({ length: 6 }, (_, i) => ({ + id: `issue-${i}`, + severity: 'moderate' as const, + type: 'heading-hierarchy', + element: 'h3', + message: 'Skipped', + wcagCriteria: ['1.3.1'], + suggestion: 'Fix', + })); + expect(getWCAGLevel(issues)).toBe('AA'); + }); + + it('returns AAA when issues are 5 or fewer and no critical or serious issues', () => { + const issues: AccessibilityIssue[] = [ + { + id: '1', + severity: 'minor', + type: 'misc', + element: 'div', + message: 'Minor warning', + wcagCriteria: [], + suggestion: 'Fix', + }, + ]; + expect(getWCAGLevel(issues)).toBe('AAA'); + }); + }); +}); diff --git a/src/utils/tests/formUtils.test.ts b/src/utils/tests/formUtils.test.ts new file mode 100644 index 00000000..91ae0b3b --- /dev/null +++ b/src/utils/tests/formUtils.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { debounce, throttle } from '../formUtils'; + +describe('formUtils - debounce', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('debounces calls within the specified delay', () => { + const callback = vi.fn(); + const debounced = debounce(callback, 300); + + debounced('first'); + debounced('second'); + debounced('third'); + + expect(callback).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(299); + expect(callback).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith('third'); + }); + + it('allows cancelling pending debounced execution', () => { + const callback = vi.fn(); + const debounced = debounce(callback, 300); + + debounced('call-1'); + vi.advanceTimersByTime(150); + + debounced.cancel(); + + vi.advanceTimersByTime(200); + expect(callback).not.toHaveBeenCalled(); + }); + + it('can be invoked again after cancellation', () => { + const callback = vi.fn(); + const debounced = debounce(callback, 300); + + debounced('call-1'); + debounced.cancel(); + + debounced('call-2'); + vi.advanceTimersByTime(300); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith('call-2'); + }); +}); + +describe('formUtils - throttle', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('throttles calls within the specified limit', () => { + const callback = vi.fn(); + const throttled = throttle(callback, 200); + + throttled('first'); + throttled('second'); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith('first'); + + vi.advanceTimersByTime(200); + + throttled('third'); + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenCalledWith('third'); + }); +}); diff --git a/src/utils/tests/notificationUtils.test.ts b/src/utils/tests/notificationUtils.test.ts new file mode 100644 index 00000000..a391b8a1 --- /dev/null +++ b/src/utils/tests/notificationUtils.test.ts @@ -0,0 +1,377 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + generateNotificationId, + formatNotificationTime, + isWithinQuietHours, + shouldSendNotification, + calculateAnalytics, + sortNotifications, + filterNotifications, + groupNotificationsByDate, + truncateMessage, + getNotificationIcon, + getNotificationColor, + validatePreferences, + createDefaultPreferences, + NotificationCategory, + NotificationChannel, + NotificationPriority, + UserNotificationPreferences, +} from '../notificationUtils'; + +describe('notificationUtils', () => { + describe('generateNotificationId', () => { + it('generates unique IDs prefixed with ntf_', () => { + const id1 = generateNotificationId(); + const id2 = generateNotificationId(); + + expect(id1.startsWith('ntf_')).toBe(true); + expect(id2.startsWith('ntf_')).toBe(true); + expect(id1).not.toBe(id2); + }); + }); + + describe('formatNotificationTime', () => { + it('returns empty string for invalid timestamp strings', () => { + expect(formatNotificationTime('invalid-date')).toBe(''); + }); + + it('returns "Just now" for timestamps less than a minute ago or in the future', () => { + const now = new Date(); + expect(formatNotificationTime(now.toISOString())).toBe('Just now'); + + const thirtySecsAgo = new Date(Date.now() - 30 * 1000); + expect(formatNotificationTime(thirtySecsAgo.toISOString())).toBe('Just now'); + + const future = new Date(Date.now() + 5000); + expect(formatNotificationTime(future.toISOString())).toBe('Just now'); + }); + + it('returns minutes ago for timestamps within the past hour', () => { + const tenMinsAgo = new Date(Date.now() - 10 * 60 * 1000); + expect(formatNotificationTime(tenMinsAgo.toISOString())).toBe('10m ago'); + }); + + it('returns hours ago for timestamps within 24 hours', () => { + const fiveHoursAgo = new Date(Date.now() - 5 * 60 * 60 * 1000); + expect(formatNotificationTime(fiveHoursAgo.toISOString())).toBe('5h ago'); + }); + + it('returns days ago for timestamps within 7 days', () => { + const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000); + expect(formatNotificationTime(threeDaysAgo.toISOString())).toBe('3d ago'); + }); + + it('returns localized date for timestamps older than 7 days', () => { + const oldDate = new Date('2020-01-15T12:00:00.000Z'); + const formatted = formatNotificationTime(oldDate.toISOString()); + expect(formatted).toContain('Jan'); + expect(formatted).toContain('2020'); + }); + }); + + describe('isWithinQuietHours', () => { + it('returns false for invalid quiet hours object', () => { + expect(isWithinQuietHours({ start: '', end: '', timezone: '' })).toBe(false); + }); + + it('handles same-day quiet hours correctly', () => { + const now = new Date(); + const currentHour = now.getHours(); + const pad = (n: number) => n.toString().padStart(2, '0'); + + const start = pad(Math.max(0, currentHour - 1)) + ':00'; + const end = pad(Math.min(23, currentHour + 1)) + ':59'; + + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + expect(isWithinQuietHours({ start, end, timezone })).toBe(true); + + const pastStart = '01:00'; + const pastEnd = '02:00'; + // If current hour is outside 01:00-02:00 + if (currentHour < 1 || currentHour > 2) { + expect(isWithinQuietHours({ start: pastStart, end: pastEnd, timezone })).toBe(false); + } + }); + + it('handles overnight quiet hours (e.g. 22:00 to 08:00)', () => { + const timezone = 'UTC'; + // If start > end, checks if current >= start OR current <= end + const isNight = isWithinQuietHours({ start: '00:00', end: '23:59', timezone }); + expect(isNight).toBe(true); + }); + }); + + describe('shouldSendNotification', () => { + let basePrefs: UserNotificationPreferences; + + beforeEach(() => { + basePrefs = createDefaultPreferences('user_123'); + }); + + it('returns false when preferences are invalid or incomplete', () => { + expect( + shouldSendNotification('message', 'in-app', null as unknown as UserNotificationPreferences), + ).toBe(false); + }); + + it('returns false if channel is disabled globally', () => { + basePrefs.channels.email = false; + expect(shouldSendNotification('course_update', 'email', basePrefs)).toBe(false); + + basePrefs.channels.inApp = false; + expect(shouldSendNotification('message', 'in-app', basePrefs)).toBe(false); + }); + + it('returns false if category is disabled', () => { + basePrefs.categories.message.enabled = false; + expect(shouldSendNotification('message', 'in-app', basePrefs)).toBe(false); + }); + + it('returns false if channel is not enabled for specific category', () => { + basePrefs.categories.course_update.channels = ['email']; + expect(shouldSendNotification('course_update', 'push', basePrefs)).toBe(false); + }); + + it('allows in-app notifications during global quiet hours', () => { + basePrefs.quietHours = { + enabled: true, + start: '00:00', + end: '23:59', + timezone: 'UTC', + }; + expect(shouldSendNotification('message', 'in-app', basePrefs)).toBe(true); + }); + + it('blocks external push notifications during global quiet hours', () => { + basePrefs.quietHours = { + enabled: true, + start: '00:00', + end: '23:59', + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', + }; + expect(shouldSendNotification('course_update', 'push', basePrefs)).toBe(false); + }); + + it('returns true when all conditions pass', () => { + basePrefs.quietHours.enabled = false; + expect(shouldSendNotification('course_update', 'email', basePrefs)).toBe(true); + expect(shouldSendNotification('message', 'in-app', basePrefs)).toBe(true); + }); + }); + + describe('calculateAnalytics', () => { + it('calculates 0 rates for empty notification list', () => { + const analytics = calculateAnalytics([]); + expect(analytics.totalSent).toBe(0); + expect(analytics.totalRead).toBe(0); + expect(analytics.totalClicked).toBe(0); + expect(analytics.readRate).toBe(0); + expect(analytics.clickRate).toBe(0); + }); + + it('computes correct metrics and breakdowns for notification items', () => { + const notifications = [ + { + read: true, + clicked: true, + channel: 'in-app' as NotificationChannel, + category: 'message' as NotificationCategory, + }, + { + read: true, + clicked: false, + channel: 'push' as NotificationChannel, + category: 'course_update' as NotificationCategory, + }, + { + read: false, + clicked: false, + channel: 'email' as NotificationChannel, + category: 'system' as NotificationCategory, + }, + { + read: false, + channel: 'sms' as NotificationChannel, + category: 'payment' as NotificationCategory, + }, + ]; + + const analytics = calculateAnalytics(notifications); + expect(analytics.totalSent).toBe(4); + expect(analytics.totalRead).toBe(2); + expect(analytics.totalClicked).toBe(1); + expect(analytics.readRate).toBe(50); + expect(analytics.clickRate).toBe(25); + + expect(analytics.byChannel['in-app'].sent).toBe(1); + expect(analytics.byChannel['in-app'].read).toBe(1); + expect(analytics.byChannel['in-app'].clicked).toBe(1); + + expect(analytics.byCategory.message.sent).toBe(1); + expect(analytics.byCategory.message.read).toBe(1); + expect(analytics.byCategory.message.clicked).toBe(1); + + expect(analytics.byCategory.system.sent).toBe(1); + expect(analytics.byCategory.system.read).toBe(0); + }); + }); + + describe('sortNotifications', () => { + it('sorts unread notifications before read, then by priority, then by newest date', () => { + const items = [ + { id: '1', read: true, priority: 'urgent' as NotificationPriority, createdAt: '2024-01-01T10:00:00Z' }, + { id: '2', read: false, priority: 'low' as NotificationPriority, createdAt: '2024-01-01T10:00:00Z' }, + { id: '3', read: false, priority: 'urgent' as NotificationPriority, createdAt: '2024-01-01T08:00:00Z' }, + { id: '4', read: false, priority: 'urgent' as NotificationPriority, createdAt: '2024-01-01T12:00:00Z' }, + ]; + + const sorted = sortNotifications(items); + expect(sorted.map((item) => item.id)).toEqual(['4', '3', '2', '1']); + }); + }); + + describe('filterNotifications', () => { + const list = [ + { + id: '1', + type: 'alert', + category: 'message' as NotificationCategory, + read: false, + createdAt: '2024-01-02T10:00:00Z', + }, + { + id: '2', + type: 'info', + category: 'course_update' as NotificationCategory, + read: true, + createdAt: '2024-01-05T10:00:00Z', + }, + { + id: '3', + type: 'alert', + category: 'system' as NotificationCategory, + read: true, + createdAt: '2024-01-10T10:00:00Z', + }, + ]; + + it('filters by type', () => { + const result = filterNotifications(list, { type: 'alert' }); + expect(result.map((i) => i.id)).toEqual(['1', '3']); + }); + + it('filters by category', () => { + const result = filterNotifications(list, { category: 'course_update' }); + expect(result.map((i) => i.id)).toEqual(['2']); + }); + + it('filters by read status', () => { + const result = filterNotifications(list, { read: false }); + expect(result.map((i) => i.id)).toEqual(['1']); + }); + + it('filters by dateRange', () => { + const result = filterNotifications(list, { + dateRange: { + start: new Date('2024-01-04T00:00:00Z'), + end: new Date('2024-01-06T00:00:00Z'), + }, + }); + expect(result.map((i) => i.id)).toEqual(['2']); + }); + }); + + describe('groupNotificationsByDate', () => { + it('groups notifications correctly by date string', () => { + const list = [ + { id: '1', createdAt: '2024-03-01T10:00:00Z' }, + { id: '2', createdAt: '2024-03-01T15:00:00Z' }, + { id: '3', createdAt: '2024-03-02T10:00:00Z' }, + ]; + + const groups = groupNotificationsByDate(list); + expect(groups.size).toBe(2); + const values = Array.from(groups.values()); + expect(values[0].length).toBe(2); + expect(values[1].length).toBe(1); + }); + }); + + describe('truncateMessage', () => { + it('returns original message if within maxLength', () => { + expect(truncateMessage('Hello World', 20)).toBe('Hello World'); + expect(truncateMessage('Hello World', 11)).toBe('Hello World'); + }); + + it('truncates message and appends ellipsis if exceeding maxLength', () => { + expect(truncateMessage('Hello Wonderful World', 10)).toBe('Hello W...'); + }); + }); + + describe('getNotificationIcon and getNotificationColor', () => { + it('returns appropriate icon for known and unknown categories', () => { + expect(getNotificationIcon('course_update')).toBe('📚'); + expect(getNotificationIcon('message')).toBe('💬'); + expect(getNotificationIcon('achievement')).toBe('🏆'); + expect(getNotificationIcon('reminder')).toBe('⏰'); + expect(getNotificationIcon('system')).toBe('⚙️'); + expect(getNotificationIcon('social')).toBe('👥'); + expect(getNotificationIcon('payment')).toBe('💳'); + expect(getNotificationIcon('unknown' as NotificationCategory)).toBe('🔔'); + }); + + it('returns style classes for priority levels', () => { + expect(getNotificationColor('urgent')).toContain('bg-red-100'); + expect(getNotificationColor('high')).toContain('bg-orange-100'); + expect(getNotificationColor('medium')).toContain('bg-blue-100'); + expect(getNotificationColor('low')).toContain('bg-gray-100'); + }); + }); + + describe('validatePreferences', () => { + it('validates correct preferences', () => { + const result = validatePreferences({ + quietHours: { enabled: true, start: '22:00', end: '08:00', timezone: 'UTC' }, + frequency: { digest: 'realtime', maxPerDay: 50 }, + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('detects invalid quiet hours start and end formats', () => { + const result = validatePreferences({ + quietHours: { enabled: true, start: '25:99', end: '8pm', timezone: 'UTC' }, + }); + expect(result.valid).toBe(false); + expect(result.errors.length).toBe(2); + }); + + it('detects out-of-range maxPerDay values', () => { + const result1 = validatePreferences({ + frequency: { digest: 'daily', maxPerDay: -1 }, + }); + expect(result1.valid).toBe(false); + expect(result1.errors[0]).toContain('between 0 and 100'); + + const result2 = validatePreferences({ + frequency: { digest: 'daily', maxPerDay: 101 }, + }); + expect(result2.valid).toBe(false); + }); + }); + + describe('createDefaultPreferences', () => { + it('creates full defaults with expected user id and default channels/categories', () => { + const prefs = createDefaultPreferences('user_999'); + expect(prefs.userId).toBe('user_999'); + expect(prefs.channels.push).toBe(true); + expect(prefs.channels.email).toBe(true); + expect(prefs.channels.inApp).toBe(true); + expect(prefs.channels.sms).toBe(false); + expect(prefs.categories.course_update.enabled).toBe(true); + expect(prefs.frequency.maxPerDay).toBe(20); + expect(prefs.quietHours.enabled).toBe(false); + }); + }); +});