feat: enhance validation schemas with comprehensive tests for user roles, settings, exams, and collections
Test / backend (push) Successful in 13s
Test / frontend (push) Failing after 19s

This commit is contained in:
LaurensBartelds committed 2026-05-02 17:39:55 +02:00
1 parent 2715e6f9b3
commit 76dc09901a
6 files changed
+437 -28

No files matched your search

+97 -11
View File
@@ -2,11 +2,16 @@ import { describe, it, expect } from 'vitest'
import { updateUserRoleSchema, updateSettingSchema, updateSettingsBatchSchema, listUsersQuerySchema, userIdParamSchema } from './adminSchemas'
describe('updateUserRoleSchema', () => {
it('accepts valid role change', () => {
it('accepts valid role change to admin', () => {
const result = updateUserRoleSchema.safeParse({ user_id: 1, role: 'admin' })
expect(result.success).toBe(true)
})
it('accepts valid role change to user', () => {
const result = updateUserRoleSchema.safeParse({ user_id: 5, role: 'user' })
expect(result.success).toBe(true)
})
it('rejects invalid role', () => {
const result = updateUserRoleSchema.safeParse({ user_id: 1, role: 'superadmin' })
expect(result.success).toBe(false)
@@ -16,6 +21,21 @@ describe('updateUserRoleSchema', () => {
const result = updateUserRoleSchema.safeParse({ user_id: 0, role: 'admin' })
expect(result.success).toBe(false)
})
it('rejects negative user_id', () => {
const result = updateUserRoleSchema.safeParse({ user_id: -1, role: 'admin' })
expect(result.success).toBe(false)
})
it('rejects non-numeric user_id', () => {
const result = updateUserRoleSchema.safeParse({ user_id: '1', role: 'admin' })
expect(result.success).toBe(false)
})
it('rejects missing fields', () => {
expect(updateUserRoleSchema.safeParse({ user_id: 1 }).success).toBe(false)
expect(updateUserRoleSchema.safeParse({ role: 'admin' }).success).toBe(false)
})
})
describe('updateSettingSchema', () => {
@@ -24,15 +44,38 @@ describe('updateSettingSchema', () => {
expect(result.success).toBe(true)
})
it('rejects unknown key', () => {
const result = updateSettingSchema.safeParse({ key: 'unknown_key', value: 'x' })
expect(result.success).toBe(false)
})
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: 'site_name', value: '' })
it('accepts empty value (clearing a setting)', () => {
const result = updateSettingSchema.safeParse({ key: 'smtp_pass', value: '' })
expect(result.success).toBe(true)
})
it('rejects non-string value', () => {
const result = updateSettingSchema.safeParse({ key: 'site_name', value: 123 })
expect(result.success).toBe(false)
})
it('accepts each allowed key', () => {
const keys = [
'site_name', 'site_language', 'maintenance_mode', 'maintenance_message',
'allow_guest_mode', 'registration_enabled', 'default_theme',
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_secure',
'app_url', 'max_upload_mb', 'ai_provider', 'ai_model', 'ai_api_key', 'ai_base_url',
]
for (const key of keys) {
const result = updateSettingSchema.safeParse({ key, value: 'x' })
expect(result.success, `key ${key} should be accepted`).toBe(true)
}
})
})
describe('updateSettingsBatchSchema', () => {
@@ -43,29 +86,39 @@ describe('updateSettingsBatchSchema', () => {
expect(result.success).toBe(true)
})
it('rejects invalid key in batch', () => {
it('accepts empty settings record', () => {
const result = updateSettingsBatchSchema.safeParse({ settings: {} })
expect(result.success).toBe(true)
})
it('accepts empty value in batch (clearing)', () => {
const result = updateSettingsBatchSchema.safeParse({
settings: { site_name: 'BrainWave', unknown_key: 'value' },
settings: { smtp_pass: '' },
})
expect(result.success).toBe(true)
})
it('rejects non-string value in batch', () => {
const result = updateSettingsBatchSchema.safeParse({
settings: { site_name: 42 },
})
expect(result.success).toBe(false)
})
it('rejects empty value in batch', () => {
const result = updateSettingsBatchSchema.safeParse({
settings: { site_name: '' },
})
it('rejects missing settings property', () => {
const result = updateSettingsBatchSchema.safeParse({})
expect(result.success).toBe(false)
})
})
describe('listUsersQuerySchema', () => {
it('accepts empty query', () => {
it('accepts empty query and defaults page to 1', () => {
const result = listUsersQuerySchema.safeParse({})
expect(result.success).toBe(true)
expect((result as any).data.page).toBe(1)
})
it('accepts page string', () => {
it('parses page string into number', () => {
const result = listUsersQuerySchema.safeParse({ page: '3' })
expect(result.success).toBe(true)
expect((result as any).data.page).toBe(3)
@@ -76,9 +129,26 @@ describe('listUsersQuerySchema', () => {
expect(result.success).toBe(false)
})
it('rejects negative page', () => {
const result = listUsersQuerySchema.safeParse({ page: '-1' })
expect(result.success).toBe(false)
})
it('rejects decimal page', () => {
const result = listUsersQuerySchema.safeParse({ page: '1.5' })
expect(result.success).toBe(false)
})
it('accepts search', () => {
const result = listUsersQuerySchema.safeParse({ search: 'john' })
expect(result.success).toBe(true)
expect((result as any).data.search).toBe('john')
})
it('accepts both page and search', () => {
const result = listUsersQuerySchema.safeParse({ page: '2', search: 'jane' })
expect(result.success).toBe(true)
expect((result as any).data).toEqual({ page: 2, search: 'jane' })
})
})
@@ -88,8 +158,24 @@ describe('userIdParamSchema', () => {
expect(result.success).toBe(true)
})
it('accepts multi-digit id', () => {
const result = userIdParamSchema.safeParse({ id: '12345' })
expect(result.success).toBe(true)
})
it('rejects non-numeric id', () => {
const result = userIdParamSchema.safeParse({ id: 'seven' })
expect(result.success).toBe(false)
})
it('rejects empty id', () => {
const result = userIdParamSchema.safeParse({ id: '' })
expect(result.success).toBe(false)
})
it('rejects id with non-digit characters', () => {
expect(userIdParamSchema.safeParse({ id: '1a' }).success).toBe(false)
expect(userIdParamSchema.safeParse({ id: '-1' }).success).toBe(false)
expect(userIdParamSchema.safeParse({ id: '1.0' }).success).toBe(false)
})
})
@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest'
import {
createCollectionSchema,
updateCollectionSchema,
collectionIdParamSchema,
moveExamSchema,
} from './collectionSchemas'
describe('createCollectionSchema', () => {
it('accepts valid name', () => {
const result = createCollectionSchema.safeParse({ name: 'Math 101' })
expect(result.success).toBe(true)
})
it('rejects empty name', () => {
const result = createCollectionSchema.safeParse({ name: '' })
expect(result.success).toBe(false)
})
it('rejects missing name', () => {
const result = createCollectionSchema.safeParse({})
expect(result.success).toBe(false)
})
it('rejects name longer than 100 chars', () => {
const result = createCollectionSchema.safeParse({ name: 'a'.repeat(101) })
expect(result.success).toBe(false)
})
it('accepts name at max length (100)', () => {
const result = createCollectionSchema.safeParse({ name: 'a'.repeat(100) })
expect(result.success).toBe(true)
})
it('rejects non-string name', () => {
const result = createCollectionSchema.safeParse({ name: 42 })
expect(result.success).toBe(false)
})
})
describe('updateCollectionSchema', () => {
it('accepts valid name', () => {
const result = updateCollectionSchema.safeParse({ name: 'Renamed' })
expect(result.success).toBe(true)
})
it('rejects empty name', () => {
const result = updateCollectionSchema.safeParse({ name: '' })
expect(result.success).toBe(false)
})
it('rejects name over 100 chars', () => {
const result = updateCollectionSchema.safeParse({ name: 'b'.repeat(101) })
expect(result.success).toBe(false)
})
})
describe('collectionIdParamSchema', () => {
it('accepts numeric id', () => {
const result = collectionIdParamSchema.safeParse({ id: '12' })
expect(result.success).toBe(true)
})
it('rejects non-numeric id', () => {
const result = collectionIdParamSchema.safeParse({ id: 'abc' })
expect(result.success).toBe(false)
})
it('rejects empty id', () => {
const result = collectionIdParamSchema.safeParse({ id: '' })
expect(result.success).toBe(false)
})
it('rejects negative id string', () => {
const result = collectionIdParamSchema.safeParse({ id: '-3' })
expect(result.success).toBe(false)
})
})
describe('moveExamSchema', () => {
it('accepts numeric collectionId', () => {
const result = moveExamSchema.safeParse({ collectionId: 4 })
expect(result.success).toBe(true)
})
it('accepts null collectionId (moving out of any collection)', () => {
const result = moveExamSchema.safeParse({ collectionId: null })
expect(result.success).toBe(true)
})
it('accepts empty payload (collectionId omitted)', () => {
const result = moveExamSchema.safeParse({})
expect(result.success).toBe(true)
})
it('rejects string collectionId', () => {
const result = moveExamSchema.safeParse({ collectionId: '4' })
expect(result.success).toBe(false)
})
})
+159 -1
View File
@@ -1,5 +1,13 @@
import { describe, it, expect } from 'vitest'
import { examSchema, saveExamSchema, updateExamSchema, examIdParamSchema } from './examSchemas'
import {
examSchema,
saveExamSchema,
updateExamSchema,
examIdParamSchema,
favoriteSchema,
shareModeSchema,
batchDeleteSchema,
} from './examSchemas'
describe('examSchema', () => {
it('accepts minimal valid exam', () => {
@@ -10,6 +18,14 @@ describe('examSchema', () => {
expect(result.success).toBe(true)
})
it('rejects empty title', () => {
const result = examSchema.safeParse({
title: '',
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
})
expect(result.success).toBe(false)
})
it('rejects missing title', () => {
const result = examSchema.safeParse({
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
@@ -41,6 +57,93 @@ describe('examSchema', () => {
})
expect(result.success).toBe(true)
})
it('accepts each supported question type', () => {
const types = [
'multiple-choice', 'multiple-select', 'short-answer', 'translation',
'math-input', 'essay', 'match', 'order',
]
for (const type of types) {
const result = examSchema.safeParse({
title: 'X',
questions: [{ id: '1', type, prompt: 'Q' }],
})
expect(result.success, `type ${type} should be accepted`).toBe(true)
}
})
it('rejects unsupported question type', () => {
const result = examSchema.safeParse({
title: 'X',
questions: [{ id: '1', type: 'true-false', prompt: 'Q' }],
})
expect(result.success).toBe(false)
})
it('accepts answer as string, array, or record', () => {
const variants = ['answer', ['a', 'b'], { left1: 'right1' }]
for (const answer of variants) {
const result = examSchema.safeParse({
title: 'X',
questions: [{ id: '1', type: 'short-answer', prompt: 'Q', answer }],
})
expect(result.success).toBe(true)
}
})
it('rejects non-string/array/record answer', () => {
const result = examSchema.safeParse({
title: 'X',
questions: [{ id: '1', type: 'short-answer', prompt: 'Q', answer: 42 }],
})
expect(result.success).toBe(false)
})
it('accepts null timeLimitMinutes', () => {
const result = examSchema.safeParse({
title: 'X',
timeLimitMinutes: null,
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
})
expect(result.success).toBe(true)
})
it('accepts feedbackMode end and immediate', () => {
for (const feedbackMode of ['end', 'immediate'] as const) {
const result = examSchema.safeParse({
title: 'X',
feedbackMode,
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
})
expect(result.success).toBe(true)
}
})
it('rejects invalid feedbackMode', () => {
const result = examSchema.safeParse({
title: 'X',
feedbackMode: 'never',
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
})
expect(result.success).toBe(false)
})
it('accepts question with rich content (image, audio, code, table)', () => {
const result = examSchema.safeParse({
title: 'Rich',
questions: [{
id: '1',
type: 'multiple-choice',
prompt: 'Pick one',
image: { src: 'a.png', alt: 'desc', caption: 'cap' },
audio: { src: 'a.mp3' },
code: { language: 'js', content: 'x=1' },
table: { headers: ['a'], rows: [['b']] },
choices: [{ id: 'c1', text: 'one' }, { id: 'c2', text: 'two' }],
}],
})
expect(result.success).toBe(true)
})
})
describe('saveExamSchema', () => {
@@ -82,4 +185,59 @@ describe('examIdParamSchema', () => {
const result = examIdParamSchema.safeParse({ id: 'abc' })
expect(result.success).toBe(false)
})
it('rejects empty id', () => {
const result = examIdParamSchema.safeParse({ id: '' })
expect(result.success).toBe(false)
})
})
describe('favoriteSchema', () => {
it('accepts true', () => {
expect(favoriteSchema.safeParse({ isFavorite: true }).success).toBe(true)
})
it('accepts false', () => {
expect(favoriteSchema.safeParse({ isFavorite: false }).success).toBe(true)
})
it('rejects non-boolean', () => {
expect(favoriteSchema.safeParse({ isFavorite: 'true' }).success).toBe(false)
})
it('rejects missing field', () => {
expect(favoriteSchema.safeParse({}).success).toBe(false)
})
})
describe('shareModeSchema', () => {
it('accepts view', () => {
expect(shareModeSchema.safeParse({ mode: 'view' }).success).toBe(true)
})
it('accepts clone', () => {
expect(shareModeSchema.safeParse({ mode: 'clone' }).success).toBe(true)
})
it('rejects unknown mode', () => {
expect(shareModeSchema.safeParse({ mode: 'edit' }).success).toBe(false)
})
})
describe('batchDeleteSchema', () => {
it('accepts array of ids', () => {
expect(batchDeleteSchema.safeParse({ ids: [1, 2, 3] }).success).toBe(true)
})
it('accepts empty array', () => {
expect(batchDeleteSchema.safeParse({ ids: [] }).success).toBe(true)
})
it('rejects string ids', () => {
expect(batchDeleteSchema.safeParse({ ids: ['1', '2'] }).success).toBe(false)
})
it('rejects missing ids', () => {
expect(batchDeleteSchema.safeParse({}).success).toBe(false)
})
})
+78 -15
View File
@@ -2,34 +2,97 @@ import { describe, it, expect } from 'vitest'
import { setupSchema, setupStatusSchema } from './setupSchemas'
describe('setupSchema', () => {
it('accepts valid setup', () => {
const baseValid = {
email: 'admin@example.com',
password: 'AdminPass123!',
displayName: 'Admin',
}
it('accepts minimal valid setup', () => {
const result = setupSchema.safeParse(baseValid)
expect(result.success).toBe(true)
})
it('accepts full setup including SMTP fields', () => {
const result = setupSchema.safeParse({
email: 'admin@example.com',
password: 'AdminPass123!',
displayName: 'Admin',
...baseValid,
siteTitle: 'BrainWave',
language: 'en',
language: 'nl',
smtpHost: 'smtp.example.com',
smtpPort: '587',
smtpUser: 'user',
smtpPass: 'pass',
smtpFrom: 'noreply@example.com',
smtpSecure: 'auto',
appUrl: 'https://example.com',
})
expect(result.success).toBe(true)
})
it('rejects invalid email', () => {
const result = setupSchema.safeParse({
email: 'bad',
password: 'AdminPass123!',
displayName: 'Admin',
})
const result = setupSchema.safeParse({ ...baseValid, email: 'bad' })
expect(result.success).toBe(false)
})
it('rejects short password', () => {
const result = setupSchema.safeParse({
email: 'admin@example.com',
password: '123',
displayName: 'Admin',
})
const result = setupSchema.safeParse({ ...baseValid, password: '123' })
expect(result.success).toBe(false)
})
it('rejects password missing complexity (no special char)', () => {
const result = setupSchema.safeParse({ ...baseValid, password: 'AdminPass123' })
expect(result.success).toBe(false)
})
it('rejects password missing uppercase', () => {
const result = setupSchema.safeParse({ ...baseValid, password: 'adminpass123!' })
expect(result.success).toBe(false)
})
it('rejects password missing digit', () => {
const result = setupSchema.safeParse({ ...baseValid, password: 'AdminPass!' })
expect(result.success).toBe(false)
})
it('rejects empty displayName', () => {
const result = setupSchema.safeParse({ ...baseValid, displayName: '' })
expect(result.success).toBe(false)
})
it('rejects displayName over 100 chars', () => {
const result = setupSchema.safeParse({ ...baseValid, displayName: 'a'.repeat(101) })
expect(result.success).toBe(false)
})
it('rejects email longer than 255 chars', () => {
const longLocal = 'a'.repeat(250)
const result = setupSchema.safeParse({ ...baseValid, email: `${longLocal}@x.com` })
expect(result.success).toBe(false)
})
it('rejects invalid language', () => {
const result = setupSchema.safeParse({ ...baseValid, language: 'fr' })
expect(result.success).toBe(false)
})
it('accepts each language enum value', () => {
for (const language of ['en', 'nl'] as const) {
const result = setupSchema.safeParse({ ...baseValid, language })
expect(result.success).toBe(true)
}
})
it('rejects invalid smtpSecure value', () => {
const result = setupSchema.safeParse({ ...baseValid, smtpSecure: 'maybe' })
expect(result.success).toBe(false)
})
it('accepts each smtpSecure enum value', () => {
for (const smtpSecure of ['auto', 'true', 'false'] as const) {
const result = setupSchema.safeParse({ ...baseValid, smtpSecure })
expect(result.success).toBe(true)
}
})
})
describe('setupStatusSchema', () => {
+1 -1
View File
@@ -16,5 +16,5 @@
"isolatedModules": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/*.spec.ts"]
}
+2
View File
@@ -3,5 +3,7 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
setupFiles: ['./vitest.setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist'],
},
})