ci: use self-hosted runner, add filter/date/client tests
This commit is contained in:
7
.github/workflows/test.yml
vendored
7
.github/workflows/test.yml
vendored
@@ -1,4 +1,5 @@
|
|||||||
# * Runs unit tests on push/PR to main and staging.
|
# * 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
|
name: Test
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -7,6 +8,10 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, staging]
|
branches: [main, staging]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: test-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: read
|
packages: read
|
||||||
@@ -14,7 +19,7 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: unit-tests
|
name: unit-tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-latest' || 'self-hosted' }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|||||||
128
lib/__tests__/filters.test.ts
Normal file
128
lib/__tests__/filters.test.ts
Normal file
@@ -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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
60
lib/api/__tests__/client.test.ts
Normal file
60
lib/api/__tests__/client.test.ts
Normal file
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
149
lib/utils/__tests__/formatDate.test.ts
Normal file
149
lib/utils/__tests__/formatDate.test.ts
Normal file
@@ -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}/)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user