feat: add unit tests and CI configuration

This commit is contained in:
Usman Baig
2026-03-01 00:11:54 +01:00
parent bce56fa64d
commit b5f83ce582
12 changed files with 2798 additions and 3 deletions

27
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
# * Runs unit tests on push/PR to main and staging.
name: Test
on:
push:
branches: [main, staging]
pull_request:
branches: [main, staging]
jobs:
test:
name: unit-tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test

View File

@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Added ### Added
- **Automated testing for improved reliability.** Pulse now has a comprehensive test suite that verifies critical parts of the app work correctly before every release. This covers login and session protection, error tracking, online/offline detection, and background data refreshing. These checks run automatically so regressions are caught before they reach you.
- **App Switcher in User Menu.** Click your profile in the top right and you'll now see a "Ciphera Apps" section. Expand it to quickly jump between Pulse, Drop (file sharing), and your Ciphera Account settings. This makes it easier to discover and navigate between Ciphera products without signing in again. - **App Switcher in User Menu.** Click your profile in the top right and you'll now see a "Ciphera Apps" section. Expand it to quickly jump between Pulse, Drop (file sharing), and your Ciphera Account settings. This makes it easier to discover and navigate between Ciphera products without signing in again.
- **Session synchronization across tabs.** When you sign out in one browser tab, you're now automatically signed out in all other tabs of the same app. This prevents situations where you might still appear signed in on another tab after logging out. The same applies to signing in — when you sign in on one tab, other tabs will update to reflect your authenticated state. - **Session synchronization across tabs.** When you sign out in one browser tab, you're now automatically signed out in all other tabs of the same app. This prevents situations where you might still appear signed in on another tab after logging out. The same applies to signing in — when you sign in on one tab, other tabs will update to reflect your authenticated state.
- **Session expiration warning.** You'll now see a heads-up banner 3 minutes before your session expires, giving you time to click "Stay signed in" to extend your session. If you ignore it or dismiss it, your session will end naturally after the 15-minute timeout for security. If you interact with the app (click, type, scroll) while the warning is showing, it automatically extends your session. - **Session expiration warning.** You'll now see a heads-up banner 3 minutes before your session expires, giving you time to click "Stay signed in" to extend your session. If you ignore it or dismiss it, your session will end naturally after the 15-minute timeout for security. If you interact with the app (click, type, scroll) while the warning is showing, it automatically extends your session.

View File

@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest'
import { NextRequest } from 'next/server'
import { middleware } from '../middleware'
function createRequest(path: string, cookies: Record<string, string> = {}): NextRequest {
const url = new URL(path, 'http://localhost:3000')
const req = new NextRequest(url)
for (const [name, value] of Object.entries(cookies)) {
req.cookies.set(name, value)
}
return req
}
describe('middleware', () => {
describe('public routes', () => {
const publicPaths = [
'/',
'/login',
'/signup',
'/auth/callback',
'/pricing',
'/features',
'/about',
'/faq',
'/changelog',
'/installation',
'/script.js',
]
publicPaths.forEach((path) => {
it(`allows unauthenticated access to ${path}`, () => {
const res = middleware(createRequest(path))
// NextResponse.next() does not set a Location header
expect(res.headers.get('Location')).toBeNull()
})
})
})
describe('public prefixes', () => {
it('allows /share/* without auth', () => {
const res = middleware(createRequest('/share/abc123'))
expect(res.headers.get('Location')).toBeNull()
})
it('allows /integrations without auth', () => {
const res = middleware(createRequest('/integrations'))
expect(res.headers.get('Location')).toBeNull()
})
it('allows /docs without auth', () => {
const res = middleware(createRequest('/docs'))
expect(res.headers.get('Location')).toBeNull()
})
})
describe('protected routes', () => {
it('redirects unauthenticated users to /login', () => {
const res = middleware(createRequest('/sites'))
expect(res.headers.get('Location')).toContain('/login')
})
it('redirects unauthenticated users from /settings to /login', () => {
const res = middleware(createRequest('/settings'))
expect(res.headers.get('Location')).toContain('/login')
})
it('allows access with access_token cookie', () => {
const res = middleware(createRequest('/sites', { access_token: 'tok' }))
expect(res.headers.get('Location')).toBeNull()
})
it('allows access with refresh_token cookie only', () => {
const res = middleware(createRequest('/sites', { refresh_token: 'tok' }))
expect(res.headers.get('Location')).toBeNull()
})
})
describe('auth-only route redirects', () => {
it('redirects authenticated user from /login to /', () => {
const res = middleware(createRequest('/login', { access_token: 'tok' }))
const location = res.headers.get('Location')
expect(location).not.toBeNull()
expect(new URL(location!).pathname).toBe('/')
})
it('redirects authenticated user from /signup to /', () => {
const res = middleware(createRequest('/signup', { access_token: 'tok' }))
const location = res.headers.get('Location')
expect(location).not.toBeNull()
expect(new URL(location!).pathname).toBe('/')
})
it('does NOT redirect from /login with only refresh_token (stale session)', () => {
const res = middleware(createRequest('/login', { refresh_token: 'tok' }))
// Should allow through to /login since only refresh_token is present
expect(res.headers.get('Location')).toBeNull()
})
})
})

View File

@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useOnlineStatus } from '../useOnlineStatus'
describe('useOnlineStatus', () => {
it('returns true initially', () => {
const { result } = renderHook(() => useOnlineStatus())
expect(result.current).toBe(true)
})
it('returns false when offline event fires', () => {
const { result } = renderHook(() => useOnlineStatus())
act(() => {
window.dispatchEvent(new Event('offline'))
})
expect(result.current).toBe(false)
})
it('returns true when online event fires after offline', () => {
const { result } = renderHook(() => useOnlineStatus())
act(() => {
window.dispatchEvent(new Event('offline'))
})
expect(result.current).toBe(false)
act(() => {
window.dispatchEvent(new Event('online'))
})
expect(result.current).toBe(true)
})
})

View File

@@ -0,0 +1,99 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useVisibilityPolling } from '../useVisibilityPolling'
describe('useVisibilityPolling', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('starts polling and calls callback at the visible interval', () => {
const callback = vi.fn()
renderHook(() =>
useVisibilityPolling(callback, {
visibleInterval: 1000,
hiddenInterval: null,
})
)
// Initial call might not happen immediately; advance to trigger interval
act(() => {
vi.advanceTimersByTime(1000)
})
expect(callback).toHaveBeenCalled()
})
it('reports isPolling as true when active', () => {
const callback = vi.fn()
const { result } = renderHook(() =>
useVisibilityPolling(callback, {
visibleInterval: 1000,
hiddenInterval: null,
})
)
expect(result.current.isPolling).toBe(true)
})
it('calls callback multiple times over multiple intervals', () => {
const callback = vi.fn()
renderHook(() =>
useVisibilityPolling(callback, {
visibleInterval: 500,
hiddenInterval: null,
})
)
act(() => {
vi.advanceTimersByTime(1500)
})
expect(callback.mock.calls.length).toBeGreaterThanOrEqual(2)
})
it('triggerPoll calls callback immediately', () => {
const callback = vi.fn()
const { result } = renderHook(() =>
useVisibilityPolling(callback, {
visibleInterval: 10000,
hiddenInterval: null,
})
)
act(() => {
result.current.triggerPoll()
})
expect(callback).toHaveBeenCalled()
expect(result.current.lastPollTime).not.toBeNull()
})
it('cleans up intervals on unmount', () => {
const callback = vi.fn()
const { unmount } = renderHook(() =>
useVisibilityPolling(callback, {
visibleInterval: 1000,
hiddenInterval: null,
})
)
unmount()
callback.mockClear()
act(() => {
vi.advanceTimersByTime(5000)
})
expect(callback).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,95 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
getRequestIdFromError,
formatErrorMessage,
logErrorWithRequestId,
getSupportMessage,
} from '../errorHandler'
import { setLastRequestId, clearLastRequestId } from '../requestId'
beforeEach(() => {
clearLastRequestId()
})
describe('getRequestIdFromError', () => {
it('extracts request ID from error response body', () => {
const errorData = { error: { request_id: 'REQ123_abc' } }
expect(getRequestIdFromError(errorData)).toBe('REQ123_abc')
})
it('falls back to last stored request ID when not in response', () => {
setLastRequestId('REQfallback_xyz')
expect(getRequestIdFromError({ error: {} })).toBe('REQfallback_xyz')
})
it('falls back to last stored request ID when no error data', () => {
setLastRequestId('REQfallback_xyz')
expect(getRequestIdFromError()).toBe('REQfallback_xyz')
})
it('returns null when no ID available anywhere', () => {
expect(getRequestIdFromError()).toBeNull()
})
})
describe('formatErrorMessage', () => {
it('returns plain message when no request ID available', () => {
expect(formatErrorMessage('Something failed')).toBe('Something failed')
})
it('appends request ID in development mode', () => {
const original = process.env.NODE_ENV
process.env.NODE_ENV = 'development'
setLastRequestId('REQ123_abc')
const msg = formatErrorMessage('Something failed')
expect(msg).toContain('Something failed')
expect(msg).toContain('REQ123_abc')
process.env.NODE_ENV = original
})
it('appends request ID when showRequestId option is set', () => {
setLastRequestId('REQ123_abc')
const msg = formatErrorMessage('Something failed', undefined, { showRequestId: true })
expect(msg).toContain('REQ123_abc')
})
})
describe('logErrorWithRequestId', () => {
it('logs with request ID when available', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
setLastRequestId('REQ123_abc')
logErrorWithRequestId('TestContext', new Error('fail'))
expect(spy).toHaveBeenCalledWith(
expect.stringContaining('REQ123_abc'),
expect.any(Error)
)
spy.mockRestore()
})
it('logs without request ID when not available', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
logErrorWithRequestId('TestContext', new Error('fail'))
expect(spy).toHaveBeenCalledWith('[TestContext]', expect.any(Error))
spy.mockRestore()
})
})
describe('getSupportMessage', () => {
it('includes request ID when available', () => {
const errorData = { error: { request_id: 'REQ123_abc' } }
const msg = getSupportMessage(errorData)
expect(msg).toContain('REQ123_abc')
expect(msg).toContain('contact support')
})
it('returns generic message when no request ID', () => {
const msg = getSupportMessage()
expect(msg).toBe('If this persists, please contact support.')
})
})

View File

@@ -0,0 +1,29 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
describe('logger', () => {
beforeEach(() => {
vi.resetModules()
})
it('calls console.error in development', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
process.env.NODE_ENV = 'development'
const { logger } = await import('../logger')
logger.error('test error')
expect(spy).toHaveBeenCalledWith('test error')
spy.mockRestore()
})
it('calls console.warn in development', async () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
process.env.NODE_ENV = 'development'
const { logger } = await import('../logger')
logger.warn('test warning')
expect(spy).toHaveBeenCalledWith('test warning')
spy.mockRestore()
})
})

View File

@@ -0,0 +1,61 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
generateRequestId,
getRequestIdHeader,
setLastRequestId,
getLastRequestId,
clearLastRequestId,
} from '../requestId'
describe('generateRequestId', () => {
it('returns a string starting with REQ', () => {
const id = generateRequestId()
expect(id).toMatch(/^REQ/)
})
it('contains a timestamp and random segment separated by underscore', () => {
const id = generateRequestId()
const parts = id.replace('REQ', '').split('_')
expect(parts).toHaveLength(2)
expect(parts[0].length).toBeGreaterThan(0)
expect(parts[1].length).toBeGreaterThan(0)
})
it('generates unique IDs across calls', () => {
const ids = new Set(Array.from({ length: 100 }, () => generateRequestId()))
expect(ids.size).toBe(100)
})
})
describe('getRequestIdHeader', () => {
it('returns X-Request-ID', () => {
expect(getRequestIdHeader()).toBe('X-Request-ID')
})
})
describe('lastRequestId storage', () => {
beforeEach(() => {
clearLastRequestId()
})
it('returns null when no ID has been set', () => {
expect(getLastRequestId()).toBeNull()
})
it('stores and retrieves a request ID', () => {
setLastRequestId('REQ123_abc')
expect(getLastRequestId()).toBe('REQ123_abc')
})
it('overwrites previous ID on set', () => {
setLastRequestId('first')
setLastRequestId('second')
expect(getLastRequestId()).toBe('second')
})
it('clears the stored ID', () => {
setLastRequestId('REQ123_abc')
clearLastRequestId()
expect(getLastRequestId()).toBeNull()
})
})

2326
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,9 @@
"build": "next build --webpack", "build": "next build --webpack",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"type-check": "tsc --noEmit" "type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@ciphera-net/ui": "^0.0.78", "@ciphera-net/ui": "^0.0.78",
@@ -44,16 +46,21 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/d3-scale": "^4.0.9", "@types/d3-scale": "^4.0.9",
"@types/node": "^20.14.12", "@types/node": "^20.14.12",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/react-simple-maps": "^3.0.6", "@types/react-simple-maps": "^3.0.6",
"@vitejs/plugin-react": "^5.1.4",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-next": "^16.1.1", "eslint-config-next": "^16.1.1",
"jsdom": "^28.1.0",
"postcss": "^8.4.40", "postcss": "^8.4.40",
"tailwindcss": "^3.4.7", "tailwindcss": "^3.4.7",
"typescript": "5.9.3" "typescript": "5.9.3",
"vitest": "^4.0.18"
} }
} }

18
vitest.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
include: ['**/__tests__/**/*.test.{ts,tsx}', '**/*.test.{ts,tsx}'],
globals: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
})

1
vitest.setup.ts Normal file
View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest'