diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ab3c44a..9eeebb7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,5 @@ # * Runs unit tests on push/PR to main and staging. +# * Uses self-hosted runner for push events, GitHub-hosted for PRs (public repo security). name: Test on: @@ -7,6 +8,10 @@ on: pull_request: branches: [main, staging] +concurrency: + group: test-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read packages: read @@ -14,7 +19,7 @@ permissions: jobs: test: name: unit-tests - runs-on: ubuntu-latest + runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-latest' || 'self-hosted' }} steps: - uses: actions/checkout@v4 diff --git a/lib/__tests__/filters.test.ts b/lib/__tests__/filters.test.ts new file mode 100644 index 0000000..214ea6f --- /dev/null +++ b/lib/__tests__/filters.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from 'vitest' +import { + serializeFilters, + parseFiltersFromURL, + filterLabel, + DIMENSIONS, + OPERATORS, + type DimensionFilter, +} from '../filters' + +describe('serializeFilters', () => { + it('returns empty string for empty array', () => { + expect(serializeFilters([])).toBe('') + }) + + it('serializes a single filter', () => { + const filters: DimensionFilter[] = [ + { dimension: 'browser', operator: 'is', values: ['Chrome'] }, + ] + expect(serializeFilters(filters)).toBe('browser|is|Chrome') + }) + + it('serializes multiple values with semicolons', () => { + const filters: DimensionFilter[] = [ + { dimension: 'country', operator: 'is', values: ['US', 'GB', 'DE'] }, + ] + expect(serializeFilters(filters)).toBe('country|is|US;GB;DE') + }) + + it('serializes multiple filters with commas', () => { + const filters: DimensionFilter[] = [ + { dimension: 'browser', operator: 'is', values: ['Chrome'] }, + { dimension: 'country', operator: 'is_not', values: ['CN'] }, + ] + expect(serializeFilters(filters)).toBe('browser|is|Chrome,country|is_not|CN') + }) +}) + +describe('parseFiltersFromURL', () => { + it('returns empty array for empty string', () => { + expect(parseFiltersFromURL('')).toEqual([]) + }) + + it('parses a single filter', () => { + const result = parseFiltersFromURL('browser|is|Chrome') + expect(result).toEqual([ + { dimension: 'browser', operator: 'is', values: ['Chrome'] }, + ]) + }) + + it('parses multiple values', () => { + const result = parseFiltersFromURL('country|is|US;GB;DE') + expect(result).toEqual([ + { dimension: 'country', operator: 'is', values: ['US', 'GB', 'DE'] }, + ]) + }) + + it('parses multiple filters', () => { + const result = parseFiltersFromURL('browser|is|Chrome,country|is_not|CN') + expect(result).toHaveLength(2) + expect(result[0].dimension).toBe('browser') + expect(result[1].dimension).toBe('country') + }) + + it('drops filters with missing values', () => { + const result = parseFiltersFromURL('browser|is') + expect(result).toEqual([]) + }) + + it('handles completely invalid input', () => { + const result = parseFiltersFromURL('|||') + expect(result).toEqual([]) + }) + + it('drops malformed entries but keeps valid ones', () => { + const result = parseFiltersFromURL('browser|is|Chrome,bad|input,country|is|US') + expect(result).toHaveLength(2) + expect(result[0].dimension).toBe('browser') + expect(result[1].dimension).toBe('country') + }) +}) + +describe('serialize/parse roundtrip', () => { + it('roundtrips a complex filter set', () => { + const filters: DimensionFilter[] = [ + { dimension: 'page', operator: 'contains', values: ['/blog'] }, + { dimension: 'country', operator: 'is', values: ['US', 'GB'] }, + { dimension: 'browser', operator: 'is_not', values: ['IE'] }, + ] + const serialized = serializeFilters(filters) + const parsed = parseFiltersFromURL(serialized) + expect(parsed).toEqual(filters) + }) +}) + +describe('filterLabel', () => { + it('returns human-readable label for known dimension', () => { + const f: DimensionFilter = { dimension: 'browser', operator: 'is', values: ['Chrome'] } + expect(filterLabel(f)).toBe('Browser is Chrome') + }) + + it('shows count for multiple values', () => { + const f: DimensionFilter = { dimension: 'country', operator: 'is', values: ['US', 'GB', 'DE'] } + expect(filterLabel(f)).toBe('Country is US +2') + }) + + it('falls back to raw dimension name if unknown', () => { + const f: DimensionFilter = { dimension: 'custom_dim', operator: 'contains', values: ['foo'] } + expect(filterLabel(f)).toBe('custom_dim contains foo') + }) + + it('uses readable operator labels', () => { + const f: DimensionFilter = { dimension: 'page', operator: 'not_contains', values: ['/admin'] } + expect(filterLabel(f)).toBe('Page does not contain /admin') + }) +}) + +describe('constants', () => { + it('DIMENSIONS includes expected entries', () => { + expect(DIMENSIONS).toContain('page') + expect(DIMENSIONS).toContain('browser') + expect(DIMENSIONS).toContain('utm_source') + }) + + it('OPERATORS includes all four types', () => { + expect(OPERATORS).toEqual(['is', 'is_not', 'contains', 'not_contains']) + }) +}) diff --git a/lib/api/__tests__/client.test.ts b/lib/api/__tests__/client.test.ts new file mode 100644 index 0000000..de4a2f1 --- /dev/null +++ b/lib/api/__tests__/client.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@ciphera-net/ui', () => ({ + authMessageFromStatus: (status: number) => `Error ${status}`, + AUTH_ERROR_MESSAGES: { NETWORK: 'Network error, please try again.' }, +})) + +const { getLoginUrl, getSignupUrl, ApiError } = await import('../client') + +describe('getLoginUrl', () => { + it('builds login URL with default redirect', () => { + const url = getLoginUrl() + expect(url).toContain('/login') + expect(url).toContain('client_id=pulse-app') + expect(url).toContain('response_type=code') + expect(url).toContain(encodeURIComponent('/auth/callback')) + }) + + it('builds login URL with custom redirect', () => { + const url = getLoginUrl('/custom/path') + expect(url).toContain(encodeURIComponent('/custom/path')) + }) +}) + +describe('getSignupUrl', () => { + it('builds signup URL with default redirect', () => { + const url = getSignupUrl() + expect(url).toContain('/signup') + expect(url).toContain('client_id=pulse-app') + expect(url).toContain('response_type=code') + }) + + it('builds signup URL with custom redirect', () => { + const url = getSignupUrl('/onboarding') + expect(url).toContain(encodeURIComponent('/onboarding')) + }) +}) + +describe('ApiError', () => { + it('creates error with message and status', () => { + const err = new ApiError('Not found', 404) + expect(err.message).toBe('Not found') + expect(err.status).toBe(404) + expect(err.data).toBeUndefined() + expect(err).toBeInstanceOf(Error) + }) + + it('creates error with data payload', () => { + const data = { retryAfter: 30 } + const err = new ApiError('Rate limited', 429, data) + expect(err.status).toBe(429) + expect(err.data).toEqual({ retryAfter: 30 }) + }) + + it('is catchable as a standard Error', () => { + const fn = () => { throw new ApiError('fail', 500) } + expect(fn).toThrow(Error) + expect(fn).toThrow('fail') + }) +}) diff --git a/lib/utils/__tests__/formatDate.test.ts b/lib/utils/__tests__/formatDate.test.ts new file mode 100644 index 0000000..392edaa --- /dev/null +++ b/lib/utils/__tests__/formatDate.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { + formatDate, + formatDateShort, + formatDateTime, + formatTime, + formatMonth, + formatDateISO, + formatDateFull, + formatDateTimeFull, + formatDateLong, + formatRelativeTime, + formatDateTimeShort, +} from '../formatDate' + +// Fixed date: Friday 14 March 2025, 14:30:00 UTC +const date = new Date('2025-03-14T14:30:00Z') + +describe('formatDate', () => { + it('returns day-first format with short month', () => { + const result = formatDate(date) + expect(result).toContain('14') + expect(result).toContain('Mar') + expect(result).toContain('2025') + }) +}) + +describe('formatDateShort', () => { + it('omits year when same as current year', () => { + const now = new Date() + const sameYear = new Date(`${now.getFullYear()}-06-15T10:00:00Z`) + const result = formatDateShort(sameYear) + expect(result).toContain('15') + expect(result).toContain('Jun') + expect(result).not.toContain(String(now.getFullYear())) + }) + + it('includes year when different from current year', () => { + const oldDate = new Date('2020-06-15T10:00:00Z') + const result = formatDateShort(oldDate) + expect(result).toContain('2020') + }) +}) + +describe('formatDateTime', () => { + it('includes date and 24-hour time', () => { + const result = formatDateTime(date) + expect(result).toContain('14') + expect(result).toContain('Mar') + expect(result).toContain('2025') + // 24-hour format check: should contain 14:30 (UTC) or local equivalent + expect(result).toMatch(/\d{2}:\d{2}/) + }) +}) + +describe('formatTime', () => { + it('returns HH:MM in 24-hour format', () => { + const result = formatTime(date) + expect(result).toMatch(/^\d{2}:\d{2}$/) + }) +}) + +describe('formatMonth', () => { + it('returns full month name and year', () => { + const result = formatMonth(date) + expect(result).toContain('March') + expect(result).toContain('2025') + }) +}) + +describe('formatDateISO', () => { + it('returns YYYY-MM-DD format', () => { + expect(formatDateISO(date)).toBe('2025-03-14') + }) +}) + +describe('formatDateFull', () => { + it('includes weekday', () => { + const result = formatDateFull(date) + expect(result).toContain('Fri') + expect(result).toContain('14') + expect(result).toContain('Mar') + expect(result).toContain('2025') + }) +}) + +describe('formatDateTimeFull', () => { + it('includes weekday and time', () => { + const result = formatDateTimeFull(date) + expect(result).toContain('Fri') + expect(result).toMatch(/\d{2}:\d{2}/) + }) +}) + +describe('formatDateLong', () => { + it('uses full month name', () => { + const result = formatDateLong(date) + expect(result).toContain('March') + expect(result).toContain('2025') + expect(result).toContain('14') + }) +}) + +describe('formatRelativeTime', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('returns "Just now" for times less than a minute ago', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-03-14T14:30:30Z')) + expect(formatRelativeTime('2025-03-14T14:30:00Z')).toBe('Just now') + }) + + it('returns minutes ago', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-03-14T14:35:00Z')) + expect(formatRelativeTime('2025-03-14T14:30:00Z')).toBe('5m ago') + }) + + it('returns hours ago', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-03-14T16:30:00Z')) + expect(formatRelativeTime('2025-03-14T14:30:00Z')).toBe('2h ago') + }) + + it('returns days ago', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-03-17T14:30:00Z')) + expect(formatRelativeTime('2025-03-14T14:30:00Z')).toBe('3d ago') + }) + + it('falls back to short date after 7 days', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-03-25T14:30:00Z')) + const result = formatRelativeTime('2025-03-14T14:30:00Z') + expect(result).toContain('14') + expect(result).toContain('Mar') + }) +}) + +describe('formatDateTimeShort', () => { + it('includes date and time', () => { + const result = formatDateTimeShort(date) + expect(result).toContain('14') + expect(result).toContain('Mar') + expect(result).toMatch(/\d{2}:\d{2}/) + }) +})