Public Access
feat: enhance testing framework with new test cases and configurations
This commit is contained in:
1 parent
a37c093cc4
commit
ef8fe6f938
18 files changed
+793
-6
No files matched your search
@@ -6,11 +6,17 @@ on:
|
||||
- '**'
|
||||
|
||||
jobs:
|
||||
backend-tests:
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./backend
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret
|
||||
NODE_ENV: test
|
||||
DB_PATH: ':memory:'
|
||||
CORS_ORIGIN: 'http://localhost:5173'
|
||||
APP_URL: 'http://localhost:5173'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
@@ -19,9 +25,14 @@ jobs:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: ./backend/package-lock.json
|
||||
- run: npm ci
|
||||
- run: npm test
|
||||
- name: Type check
|
||||
run: npx tsc --noEmit
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
frontend-tests:
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -34,4 +45,11 @@ jobs:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: ./frontend/package-lock.json
|
||||
- run: npm ci
|
||||
- run: npm run test:run
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
- name: Type check
|
||||
run: npx tsc -b --noEmit
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Run tests
|
||||
run: npm run test:run
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
describe('env helpers', () => {
|
||||
it('uses default value when env var is missing', () => {
|
||||
delete process.env.TEST_VAR_UNIQUE
|
||||
const env = (key: string, defaultValue?: string): string => {
|
||||
const value = process.env[key]
|
||||
if (value === undefined || value === '') {
|
||||
if (defaultValue !== undefined) return defaultValue
|
||||
throw new Error(`Missing ${key}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
expect(env('TEST_VAR_UNIQUE', 'fallback')).toBe('fallback')
|
||||
})
|
||||
|
||||
it('returns env value when present', () => {
|
||||
process.env.TEST_VAR_UNIQUE = 'value'
|
||||
const env = (key: string, defaultValue?: string): string => {
|
||||
const value = process.env[key]
|
||||
if (value === undefined || value === '') {
|
||||
if (defaultValue !== undefined) return defaultValue
|
||||
throw new Error(`Missing ${key}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
expect(env('TEST_VAR_UNIQUE')).toBe('value')
|
||||
delete process.env.TEST_VAR_UNIQUE
|
||||
})
|
||||
|
||||
it('envInt parses integer', () => {
|
||||
process.env.TEST_INT = '42'
|
||||
const envInt = (key: string, defaultValue?: number): number => {
|
||||
const raw = process.env[key]
|
||||
if (raw === undefined || raw === '') {
|
||||
if (defaultValue !== undefined) return defaultValue
|
||||
throw new Error(`Missing ${key}`)
|
||||
}
|
||||
const parsed = parseInt(raw, 10)
|
||||
if (Number.isNaN(parsed)) throw new Error(`Invalid int ${key}`)
|
||||
return parsed
|
||||
}
|
||||
expect(envInt('TEST_INT')).toBe(42)
|
||||
delete process.env.TEST_INT
|
||||
})
|
||||
|
||||
it('envInt throws on NaN', () => {
|
||||
process.env.TEST_INT = 'abc'
|
||||
const envInt = (key: string, defaultValue?: number): number => {
|
||||
const raw = process.env[key]
|
||||
if (raw === undefined || raw === '') {
|
||||
if (defaultValue !== undefined) return defaultValue
|
||||
throw new Error(`Missing ${key}`)
|
||||
}
|
||||
const parsed = parseInt(raw, 10)
|
||||
if (Number.isNaN(parsed)) throw new Error(`Invalid int ${key}`)
|
||||
return parsed
|
||||
}
|
||||
expect(() => envInt('TEST_INT')).toThrow('Invalid int TEST_INT')
|
||||
delete process.env.TEST_INT
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { AppError } from './error'
|
||||
|
||||
describe('AppError', () => {
|
||||
it('is instance of Error and AppError', () => {
|
||||
const err = new AppError(404, 'Not found')
|
||||
expect(err).toBeInstanceOf(AppError)
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
it('sets statusCode and message', () => {
|
||||
const err = new AppError(500, 'Server error')
|
||||
expect(err.statusCode).toBe(500)
|
||||
expect(err.message).toBe('Server error')
|
||||
})
|
||||
|
||||
it('sets name to AppError', () => {
|
||||
const err = new AppError(400, 'Bad request')
|
||||
expect(err.name).toBe('AppError')
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,8 @@ export class AppError extends Error {
|
||||
public isOperational = true
|
||||
) {
|
||||
super(message);
|
||||
Object.setPrototypeOf(this, new Error().constructor);
|
||||
this.name = 'AppError';
|
||||
Object.setPrototypeOf(this, AppError.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { asyncHandler } from './asyncHandler'
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
|
||||
const mockReq = {} as Request
|
||||
const mockRes = {} as Response
|
||||
let mockNext: NextFunction
|
||||
|
||||
beforeEach(() => {
|
||||
mockNext = vi.fn() as unknown as NextFunction
|
||||
})
|
||||
|
||||
describe('asyncHandler', () => {
|
||||
it('calls next with error when async handler throws', async () => {
|
||||
const error = new Error('boom')
|
||||
const handler = asyncHandler(async () => {
|
||||
throw error
|
||||
})
|
||||
await handler(mockReq, mockRes, mockNext)
|
||||
expect(mockNext).toHaveBeenCalledWith(error)
|
||||
})
|
||||
|
||||
it('does not call next when handler resolves', async () => {
|
||||
const handler = asyncHandler(async () => {
|
||||
// success
|
||||
})
|
||||
await handler(mockReq, mockRes, mockNext)
|
||||
expect(mockNext).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls next with error when handler rejects', async () => {
|
||||
const error = new Error('rejected')
|
||||
const handler = asyncHandler(() => Promise.reject(error))
|
||||
await handler(mockReq, mockRes, mockNext)
|
||||
expect(mockNext).toHaveBeenCalledWith(error)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
describe('tokens', () => {
|
||||
it('round-trips JWT payload', async () => {
|
||||
process.env.JWT_SECRET = 'test-secret-for-unit-tests-only'
|
||||
const { signToken, verifyToken } = await import('./tokens')
|
||||
const payload = { userId: 1, email: 'a@b.com', displayName: 'Test', role: 'user' as const }
|
||||
const token = signToken(payload)
|
||||
const decoded = verifyToken(token)
|
||||
expect(decoded.userId).toBe(1)
|
||||
expect(decoded.email).toBe('a@b.com')
|
||||
expect(decoded.displayName).toBe('Test')
|
||||
expect(decoded.role).toBe('user')
|
||||
})
|
||||
|
||||
it('throws on invalid token', async () => {
|
||||
process.env.JWT_SECRET = 'test-secret-for-unit-tests-only'
|
||||
const { verifyToken } = await import('./tokens')
|
||||
expect(() => verifyToken('invalid.token.here')).toThrow()
|
||||
})
|
||||
|
||||
it('throws on tampered token', async () => {
|
||||
process.env.JWT_SECRET = 'test-secret-for-unit-tests-only'
|
||||
const { signToken, verifyToken } = await import('./tokens')
|
||||
const payload = { userId: 1, email: 'a@b.com', displayName: 'Test', role: 'user' as const }
|
||||
const token = signToken(payload)
|
||||
const tampered = token.slice(0, -5) + 'xxxxx'
|
||||
expect(() => verifyToken(tampered)).toThrow()
|
||||
})
|
||||
|
||||
it('generates hex verification token of expected length', async () => {
|
||||
const { generateVerificationToken } = await import('./tokens')
|
||||
const token = generateVerificationToken()
|
||||
expect(token).toMatch(/^[a-f0-9]+$/)
|
||||
expect(token.length).toBe(64)
|
||||
})
|
||||
|
||||
it('generates unique verification tokens', async () => {
|
||||
const { generateVerificationToken } = await import('./tokens')
|
||||
const t1 = generateVerificationToken()
|
||||
const t2 = generateVerificationToken()
|
||||
expect(t1).not.toBe(t2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { updateUserRoleSchema, updateSettingSchema, listUsersQuerySchema, userIdParamSchema } from './adminSchemas'
|
||||
|
||||
describe('updateUserRoleSchema', () => {
|
||||
it('accepts valid role change', () => {
|
||||
const result = updateUserRoleSchema.safeParse({ user_id: 1, role: 'admin' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid role', () => {
|
||||
const result = updateUserRoleSchema.safeParse({ user_id: 1, role: 'superadmin' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects zero user_id', () => {
|
||||
const result = updateUserRoleSchema.safeParse({ user_id: 0, role: 'admin' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateSettingSchema', () => {
|
||||
it('accepts valid setting', () => {
|
||||
const result = updateSettingSchema.safeParse({ key: 'app_name', value: 'BrainWave' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects empty key', () => {
|
||||
const result = updateSettingSchema.safeParse({ key: '', value: 'BrainWave' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty value', () => {
|
||||
const result = updateSettingSchema.safeParse({ key: 'app_name', value: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('listUsersQuerySchema', () => {
|
||||
it('accepts empty query', () => {
|
||||
const result = listUsersQuerySchema.safeParse({})
|
||||
expect(result.success).toBe(true)
|
||||
expect((result as any).data.page).toBe(1)
|
||||
})
|
||||
|
||||
it('accepts page string', () => {
|
||||
const result = listUsersQuerySchema.safeParse({ page: '3' })
|
||||
expect(result.success).toBe(true)
|
||||
expect((result as any).data.page).toBe(3)
|
||||
})
|
||||
|
||||
it('rejects non-numeric page', () => {
|
||||
const result = listUsersQuerySchema.safeParse({ page: 'abc' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts search', () => {
|
||||
const result = listUsersQuerySchema.safeParse({ search: 'john' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('userIdParamSchema', () => {
|
||||
it('accepts numeric id', () => {
|
||||
const result = userIdParamSchema.safeParse({ id: '7' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-numeric id', () => {
|
||||
const result = userIdParamSchema.safeParse({ id: 'seven' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { registerSchema, loginSchema, verifyEmailSchema, resetPasswordSchema, updateProfileSchema, changePasswordSchema, forgotPasswordSchema } from './authSchemas'
|
||||
|
||||
describe('registerSchema', () => {
|
||||
it('accepts valid registration', () => {
|
||||
const result = registerSchema.safeParse({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
displayName: 'Test User',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid email', () => {
|
||||
const result = registerSchema.safeParse({
|
||||
email: 'not-an-email',
|
||||
password: 'password123',
|
||||
displayName: 'Test User',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects short password', () => {
|
||||
const result = registerSchema.safeParse({
|
||||
email: 'test@example.com',
|
||||
password: '123',
|
||||
displayName: 'Test User',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty display name', () => {
|
||||
const result = registerSchema.safeParse({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
displayName: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loginSchema', () => {
|
||||
it('accepts valid login', () => {
|
||||
const result = loginSchema.safeParse({ email: 'test@example.com', password: 'pass' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects missing email', () => {
|
||||
const result = loginSchema.safeParse({ password: 'pass' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyEmailSchema', () => {
|
||||
it('accepts valid token', () => {
|
||||
const result = verifyEmailSchema.safeParse({ token: 'abc123' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects empty token', () => {
|
||||
const result = verifyEmailSchema.safeParse({ token: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetPasswordSchema', () => {
|
||||
it('accepts valid reset', () => {
|
||||
const result = resetPasswordSchema.safeParse({ token: 'abc', newPassword: 'newpass123' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects short password', () => {
|
||||
const result = resetPasswordSchema.safeParse({ token: 'abc', password: '123' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateProfileSchema', () => {
|
||||
it('accepts valid profile', () => {
|
||||
const result = updateProfileSchema.safeParse({ displayName: 'New Name' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects empty displayName', () => {
|
||||
const result = updateProfileSchema.safeParse({ displayName: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('changePasswordSchema', () => {
|
||||
it('accepts valid change', () => {
|
||||
const result = changePasswordSchema.safeParse({
|
||||
currentPassword: 'oldpass',
|
||||
newPassword: 'newpass123',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('forgotPasswordSchema', () => {
|
||||
it('accepts valid email', () => {
|
||||
const result = forgotPasswordSchema.safeParse({ email: 'test@example.com' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts empty object', () => {
|
||||
const result = forgotPasswordSchema.safeParse({})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid email', () => {
|
||||
const result = forgotPasswordSchema.safeParse({ email: 'bad' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { examSchema, saveExamSchema, updateExamSchema, examIdParamSchema } from './examSchemas'
|
||||
|
||||
describe('examSchema', () => {
|
||||
it('accepts minimal valid exam', () => {
|
||||
const result = examSchema.safeParse({
|
||||
title: 'My Exam',
|
||||
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects missing title', () => {
|
||||
const result = examSchema.safeParse({
|
||||
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty questions array', () => {
|
||||
const result = examSchema.safeParse({
|
||||
title: 'My Exam',
|
||||
questions: [],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects question missing id', () => {
|
||||
const result = examSchema.safeParse({
|
||||
title: 'My Exam',
|
||||
questions: [{ type: 'essay', prompt: 'Q' }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts optional id', () => {
|
||||
const result = examSchema.safeParse({
|
||||
id: 'abc',
|
||||
title: 'My Exam',
|
||||
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveExamSchema', () => {
|
||||
it('accepts valid save payload', () => {
|
||||
const result = saveExamSchema.safeParse({
|
||||
exam: {
|
||||
title: 'My Exam',
|
||||
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
|
||||
},
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects missing exam', () => {
|
||||
const result = saveExamSchema.safeParse({})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateExamSchema', () => {
|
||||
it('accepts valid update payload', () => {
|
||||
const result = updateExamSchema.safeParse({
|
||||
exam: {
|
||||
title: 'Updated',
|
||||
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
|
||||
},
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('examIdParamSchema', () => {
|
||||
it('accepts numeric id string', () => {
|
||||
const result = examIdParamSchema.safeParse({ id: '42' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-numeric id', () => {
|
||||
const result = examIdParamSchema.safeParse({ id: 'abc' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { submitResultSchema, resultIdParamSchema } from './resultSchemas'
|
||||
|
||||
describe('submitResultSchema', () => {
|
||||
it('accepts valid result', () => {
|
||||
const result = submitResultSchema.safeParse({
|
||||
title: 'My Exam',
|
||||
score_points: 8,
|
||||
total_points: 10,
|
||||
correct_count: 8,
|
||||
graded_count: 10,
|
||||
elapsed_ms: 120000,
|
||||
state: {
|
||||
answers: { q1: 'a' },
|
||||
gradedAnswers: { q1: { correct: true, points: 1 } },
|
||||
answeredCount: 10,
|
||||
score: 80,
|
||||
correctCount: 8,
|
||||
gradedCount: 10,
|
||||
},
|
||||
exam: {
|
||||
title: 'My Exam',
|
||||
questions: [],
|
||||
},
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects missing title', () => {
|
||||
const result = submitResultSchema.safeParse({
|
||||
score_points: 8,
|
||||
total_points: 10,
|
||||
correct_count: 8,
|
||||
graded_count: 10,
|
||||
elapsed_ms: 120000,
|
||||
state: {
|
||||
answers: {},
|
||||
gradedAnswers: {},
|
||||
answeredCount: 0,
|
||||
score: 0,
|
||||
correctCount: 0,
|
||||
gradedCount: 0,
|
||||
},
|
||||
exam: { title: 'My Exam', questions: [] },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts optional exam_id', () => {
|
||||
const result = submitResultSchema.safeParse({
|
||||
exam_id: 1,
|
||||
title: 'My Exam',
|
||||
score_points: 8,
|
||||
total_points: 10,
|
||||
correct_count: 8,
|
||||
graded_count: 10,
|
||||
elapsed_ms: 120000,
|
||||
state: {
|
||||
answers: {},
|
||||
gradedAnswers: {},
|
||||
answeredCount: 0,
|
||||
score: 0,
|
||||
correctCount: 0,
|
||||
gradedCount: 0,
|
||||
},
|
||||
exam: { title: 'My Exam', questions: [] },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts negative score_points because schema allows any number', () => {
|
||||
const result = submitResultSchema.safeParse({
|
||||
title: 'My Exam',
|
||||
score_points: -1,
|
||||
total_points: 10,
|
||||
correct_count: 8,
|
||||
graded_count: 10,
|
||||
elapsed_ms: 120000,
|
||||
state: {
|
||||
answers: {},
|
||||
gradedAnswers: {},
|
||||
answeredCount: 0,
|
||||
score: 0,
|
||||
correctCount: 0,
|
||||
gradedCount: 0,
|
||||
},
|
||||
exam: { title: 'My Exam', questions: [] },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resultIdParamSchema', () => {
|
||||
it('accepts numeric id string', () => {
|
||||
const result = resultIdParamSchema.safeParse({ id: '42' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-numeric id', () => {
|
||||
const result = resultIdParamSchema.safeParse({ id: 'abc' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { setupSchema, setupStatusSchema } from './setupSchemas'
|
||||
|
||||
describe('setupSchema', () => {
|
||||
it('accepts valid setup', () => {
|
||||
const result = setupSchema.safeParse({
|
||||
email: 'admin@example.com',
|
||||
password: 'adminpass123',
|
||||
displayName: 'Admin',
|
||||
siteTitle: 'BrainWave',
|
||||
language: 'en',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid email', () => {
|
||||
const result = setupSchema.safeParse({
|
||||
email: 'bad',
|
||||
password: 'adminpass123',
|
||||
displayName: 'Admin',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects short password', () => {
|
||||
const result = setupSchema.safeParse({
|
||||
email: 'admin@example.com',
|
||||
password: '123',
|
||||
displayName: 'Admin',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setupStatusSchema', () => {
|
||||
it('accepts empty object', () => {
|
||||
const result = setupStatusSchema.safeParse({})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts optional email', () => {
|
||||
const result = setupStatusSchema.safeParse({ email: 'admin@example.com' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid optional email', () => {
|
||||
const result = setupStatusSchema.safeParse({ email: 'bad' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret-for-unit-tests-only')
|
||||
vi.stubEnv('NODE_ENV', 'test')
|
||||
vi.stubEnv('DB_PATH', ':memory:')
|
||||
vi.stubEnv('CORS_ORIGIN', 'http://localhost:5173')
|
||||
vi.stubEnv('APP_URL', 'http://localhost:5173')
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { ApiError } from './api'
|
||||
|
||||
describe('ApiError', () => {
|
||||
it('extends Error', () => {
|
||||
const err = new ApiError(404, 'Not found')
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
it('sets status and message', () => {
|
||||
const err = new ApiError(500, 'Server error')
|
||||
expect(err.status).toBe(500)
|
||||
expect(err.message).toBe('Server error')
|
||||
})
|
||||
|
||||
it('sets name to ApiError', () => {
|
||||
const err = new ApiError(401, 'Unauthorized')
|
||||
expect(err.name).toBe('ApiError')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { slugify } from './slug'
|
||||
import { slugify, getExamUrlId, extractExamSlug } from './slug'
|
||||
|
||||
describe('slugify', () => {
|
||||
it('lowercases', () => {
|
||||
@@ -24,3 +24,31 @@ describe('slugify', () => {
|
||||
expect(slugify('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getExamUrlId', () => {
|
||||
it('returns slug with id when id exists', () => {
|
||||
expect(getExamUrlId({ id: 'abc12345', title: 'My Exam' })).toBe('my-exam-abc12345')
|
||||
})
|
||||
|
||||
it('returns slug only when id is missing', () => {
|
||||
expect(getExamUrlId({ title: 'My Exam' })).toBe('my-exam')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractExamSlug', () => {
|
||||
it('extracts slug from slug-id format', () => {
|
||||
expect(extractExamSlug('my-exam-abc12345')).toBe('my-exam')
|
||||
})
|
||||
|
||||
it('returns full string when no dash', () => {
|
||||
expect(extractExamSlug('myexam')).toBe('myexam')
|
||||
})
|
||||
|
||||
it('returns full string when suffix is too short to be an id', () => {
|
||||
expect(extractExamSlug('my-exam-abc')).toBe('my-exam-abc')
|
||||
})
|
||||
|
||||
it('handles slug with multiple dashes', () => {
|
||||
expect(extractExamSlug('my-cool-exam-abc12345678')).toBe('my-cool-exam')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { loadState, saveState, clearState, loadHistory, appendHistory, clearHistory } from './storage'
|
||||
|
||||
const storage: Record<string, string> = {}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.keys(storage).forEach((k) => delete storage[k])
|
||||
globalThis.localStorage = {
|
||||
getItem: (key: string) => storage[key] ?? null,
|
||||
setItem: (key: string, value: string) => { storage[key] = value },
|
||||
removeItem: (key: string) => { delete storage[key] },
|
||||
length: 0,
|
||||
key: () => null,
|
||||
clear: () => { Object.keys(storage).forEach((k) => delete storage[k]) },
|
||||
} as Storage
|
||||
})
|
||||
|
||||
describe('loadState / saveState / clearState', () => {
|
||||
it('returns null when nothing saved', () => {
|
||||
expect(loadState()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns saved state', () => {
|
||||
const data = { exam: { id: '1', title: 'E', questions: [] }, state: { currentIdx: 0, answers: {}, flags: {}, notes: '', startedAt: Date.now(), feedbackMode: 'end' as const } }
|
||||
saveState(data)
|
||||
expect(loadState()).toEqual(data)
|
||||
})
|
||||
|
||||
it('clears saved state', () => {
|
||||
saveState({ exam: { id: '1', title: 'E', questions: [] }, state: { currentIdx: 0, answers: {}, flags: {}, notes: '', startedAt: Date.now(), feedbackMode: 'end' as const } })
|
||||
clearState()
|
||||
expect(loadState()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for corrupted json', () => {
|
||||
localStorage.setItem('brainwave.exam.state', 'not-json')
|
||||
expect(loadState()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadHistory / appendHistory / clearHistory', () => {
|
||||
it('returns empty array when nothing saved', () => {
|
||||
expect(loadHistory()).toEqual([])
|
||||
})
|
||||
|
||||
it('appends entries and limits to 30', () => {
|
||||
for (let i = 0; i < 35; i++) {
|
||||
appendHistory({ id: String(i), title: `Exam ${i}`, completedAt: Date.now(), elapsedMs: 0, gradedCount: 10, correctCount: i, totalPoints: 10, scorePoints: i, passingScore: 5, exam: { title: `Exam ${i}`, questions: [] }, state: { currentIdx: 0, answers: {}, flags: {}, notes: '', startedAt: Date.now(), feedbackMode: 'end' as const } })
|
||||
}
|
||||
const hist = loadHistory()
|
||||
expect(hist.length).toBe(30)
|
||||
expect(hist[0].id).toBe('34')
|
||||
})
|
||||
|
||||
it('clears history', () => {
|
||||
appendHistory({ id: '1', title: 'E', completedAt: Date.now(), elapsedMs: 0, gradedCount: 1, correctCount: 1, totalPoints: 1, scorePoints: 1, passingScore: 1, exam: { title: 'E', questions: [] }, state: { currentIdx: 0, answers: {}, flags: {}, notes: '', startedAt: Date.now(), feedbackMode: 'end' as const } })
|
||||
clearHistory()
|
||||
expect(loadHistory()).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for corrupted json', () => {
|
||||
localStorage.setItem('brainwave.exam.history', 'not-json')
|
||||
expect(loadHistory()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { validateExam, parseExam } from './validation'
|
||||
|
||||
describe('validateExam', () => {
|
||||
it('returns error for non-object', () => {
|
||||
expect(validateExam(null)).toBe('Not a JSON object')
|
||||
expect(validateExam('string')).toBe('Not a JSON object')
|
||||
expect(validateExam(123)).toBe('Not a JSON object')
|
||||
})
|
||||
|
||||
it('returns error for missing title', () => {
|
||||
expect(validateExam({ questions: [] })).toBe('Missing "title"')
|
||||
})
|
||||
|
||||
it('returns error for missing questions array', () => {
|
||||
expect(validateExam({ title: 'Exam' })).toBe('Missing "questions" array')
|
||||
})
|
||||
|
||||
it('returns error for empty questions array', () => {
|
||||
expect(validateExam({ title: 'Exam', questions: [] })).toBe('Missing "questions" array')
|
||||
})
|
||||
|
||||
it('returns error for question missing id', () => {
|
||||
expect(validateExam({ title: 'Exam', questions: [{ type: 'essay', prompt: 'Q' }] })).toBe('Question missing id')
|
||||
})
|
||||
|
||||
it('returns error for question missing type', () => {
|
||||
expect(validateExam({ title: 'Exam', questions: [{ id: '1', prompt: 'Q' }] })).toBe('Question "1" missing type')
|
||||
})
|
||||
|
||||
it('returns error for question missing prompt', () => {
|
||||
expect(validateExam({ title: 'Exam', questions: [{ id: '1', type: 'essay' }] })).toBe('Question "1" missing prompt')
|
||||
})
|
||||
|
||||
it('returns null for valid exam', () => {
|
||||
expect(validateExam({ title: 'Exam', questions: [{ id: '1', type: 'essay', prompt: 'Q' }] })).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseExam', () => {
|
||||
it('generates id when missing', () => {
|
||||
const exam = parseExam({ title: 'T', questions: [] })
|
||||
expect(exam.id).toBeTruthy()
|
||||
expect(typeof exam.id).toBe('string')
|
||||
})
|
||||
|
||||
it('preserves existing id', () => {
|
||||
const exam = parseExam({ id: 'abc123', title: 'T', questions: [] })
|
||||
expect(exam.id).toBe('abc123')
|
||||
})
|
||||
})
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":"4.1.5","results":[[":frontend/src/utils/grading.test.ts",{"duration":9.39569999999992,"failed":false}],[":frontend/src/utils/latex.test.ts",{"duration":14.06759999999997,"failed":false}],[":backend/src/validators/authSchemas.test.ts",{"duration":10.843899999999962,"failed":false}],[":frontend/src/utils/validation.test.ts",{"duration":7.211099999999988,"failed":false}],[":frontend/src/utils/storage.test.ts",{"duration":12.619899999999916,"failed":false}],[":backend/src/validators/examSchemas.test.ts",{"duration":9.806400000000053,"failed":false}],[":backend/src/validators/adminSchemas.test.ts",{"duration":8.930499999999938,"failed":false}],[":backend/src/middleware/error.test.ts",{"duration":4.177999999999997,"failed":false}],[":frontend/src/utils/slug.test.ts",{"duration":11.914499999999975,"failed":false}],[":frontend/src/utils/formatting.test.ts",{"duration":6.8618000000000166,"failed":false}],[":backend/src/config.test.ts",{"duration":6.659400000000005,"failed":false}],[":backend/src/validators/resultSchemas.test.ts",{"duration":8.660599999999931,"failed":false}],[":backend/src/utils/tokens.test.ts",{"duration":181.15880000000004,"failed":false}],[":backend/src/validators/setupSchemas.test.ts",{"duration":8.40420000000006,"failed":false}],[":backend/src/utils/asyncHandler.test.ts",{"duration":7.896400000000028,"failed":false}],[":frontend/src/utils/api.test.ts",{"duration":4.411400000000015,"failed":false}]]}
|
||||
Reference in new issue
Block a user