Tests, UX improvements.

This commit is contained in:
Jennie Robinson Faber 2026-04-05 14:25:29 +01:00
parent 4e6f5d36b8
commit 0ae18f495e
63 changed files with 1384 additions and 2330 deletions

View file

@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
vi.mock('../../../server/models/member.js', () => ({
default: { findByIdAndUpdate: vi.fn() }
default: { findById: vi.fn(), findByIdAndUpdate: vi.fn() }
}))
vi.mock('../../../server/utils/mongoose.js', () => ({
@ -104,7 +104,7 @@ describe('admin role PATCH endpoint', () => {
describe('member not found', () => {
it('returns 404 when member does not exist', async () => {
Member.findByIdAndUpdate.mockResolvedValue(null)
Member.findById.mockResolvedValue(null)
const event = createMockEvent({
method: 'PATCH',
@ -122,7 +122,9 @@ describe('admin role PATCH endpoint', () => {
describe('successful role changes', () => {
it('promotes a member to admin', async () => {
validateBody.mockResolvedValue({ role: 'admin' })
const existingMember = { _id: 'target-member-id', role: 'member', name: 'Test User' }
const updatedMember = { _id: 'target-member-id', role: 'admin', name: 'Test User' }
Member.findById.mockResolvedValue(existingMember)
Member.findByIdAndUpdate.mockResolvedValue(updatedMember)
const event = createMockEvent({
@ -143,7 +145,9 @@ describe('admin role PATCH endpoint', () => {
it('demotes a member to regular role', async () => {
validateBody.mockResolvedValue({ role: 'member' })
const existingMember = { _id: 'target-member-id', role: 'admin', name: 'Test User' }
const updatedMember = { _id: 'target-member-id', role: 'member', name: 'Test User' }
Member.findById.mockResolvedValue(existingMember)
Member.findByIdAndUpdate.mockResolvedValue(updatedMember)
const event = createMockEvent({

View file

@ -1,112 +0,0 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const updatesDir = resolve(import.meta.dirname, '../../../server/api/updates')
describe('Updates API auth guards', () => {
describe('index.post.js (create)', () => {
const source = readFileSync(resolve(updatesDir, 'index.post.js'), 'utf-8')
it('requires auth via requireAuth(event)', () => {
expect(source).toContain('requireAuth(event)')
})
it('does not wrap requireAuth in a try/catch (auth is mandatory)', () => {
// The public GET routes wrap requireAuth in try/catch to make it optional.
// The create route must NOT do that — auth failure should halt the request.
const lines = source.split('\n')
const authLine = lines.findIndex(l => l.includes('requireAuth(event)'))
expect(authLine).toBeGreaterThan(-1)
// Check the line before requireAuth is not a try {
const preceding = lines.slice(Math.max(0, authLine - 2), authLine).join(' ')
expect(preceding).not.toMatch(/try\s*\{/)
})
})
describe('[id].patch.js (edit)', () => {
const source = readFileSync(resolve(updatesDir, '[id].patch.js'), 'utf-8')
it('requires auth via requireAuth(event)', () => {
expect(source).toContain('requireAuth(event)')
})
it('does not wrap requireAuth in a try/catch (auth is mandatory)', () => {
const lines = source.split('\n')
const authLine = lines.findIndex(l => l.includes('requireAuth(event)'))
expect(authLine).toBeGreaterThan(-1)
const preceding = lines.slice(Math.max(0, authLine - 2), authLine).join(' ')
expect(preceding).not.toMatch(/try\s*\{/)
})
it('verifies ownership by comparing update.author with authenticated member ID', () => {
expect(source).toContain('update.author.toString() !== memberId')
})
it('throws 403 when user is not the author', () => {
expect(source).toContain('statusCode: 403')
})
})
describe('[id].delete.js (delete)', () => {
const source = readFileSync(resolve(updatesDir, '[id].delete.js'), 'utf-8')
it('requires auth via requireAuth(event)', () => {
expect(source).toContain('requireAuth(event)')
})
it('does not wrap requireAuth in a try/catch (auth is mandatory)', () => {
const lines = source.split('\n')
const authLine = lines.findIndex(l => l.includes('requireAuth(event)'))
expect(authLine).toBeGreaterThan(-1)
const preceding = lines.slice(Math.max(0, authLine - 2), authLine).join(' ')
expect(preceding).not.toMatch(/try\s*\{/)
})
it('verifies ownership by comparing update.author with authenticated member ID', () => {
expect(source).toContain('update.author.toString() !== memberId')
})
it('throws 403 when user is not the author', () => {
expect(source).toContain('statusCode: 403')
})
})
describe('index.get.js (list — public)', () => {
const source = readFileSync(resolve(updatesDir, 'index.get.js'), 'utf-8')
it('does NOT enforce requireAuth (public access allowed)', () => {
// The route uses requireAuth inside a try/catch so unauthenticated
// users can still access it — auth failure is caught and ignored.
const lines = source.split('\n')
const authLine = lines.findIndex(l => l.includes('requireAuth(event)'))
// If requireAuth is present, it must be wrapped in try/catch
if (authLine > -1) {
const preceding = lines.slice(Math.max(0, authLine - 2), authLine).join(' ')
expect(preceding).toMatch(/try\s*\{/)
}
// Either way, the route must not throw on unauthenticated access
})
it('does not call requireAdmin', () => {
expect(source).not.toContain('requireAdmin')
})
})
describe('[id].get.js (get — public)', () => {
const source = readFileSync(resolve(updatesDir, '[id].get.js'), 'utf-8')
it('does NOT enforce requireAuth (public access allowed)', () => {
const lines = source.split('\n')
const authLine = lines.findIndex(l => l.includes('requireAuth(event)'))
if (authLine > -1) {
const preceding = lines.slice(Math.max(0, authLine - 2), authLine).join(' ')
expect(preceding).toMatch(/try\s*\{/)
}
})
it('does not call requireAdmin', () => {
expect(source).not.toContain('requireAdmin')
})
})
})

View file

@ -16,7 +16,6 @@ import {
eventPaymentSchema,
updateContributionSchema,
peerSupportUpdateSchema,
updatePatchSchema,
seriesTicketPurchaseSchema,
seriesTicketEligibilitySchema,
adminSeriesCreateSchema,
@ -329,28 +328,6 @@ describe('peerSupportUpdateSchema', () => {
})
})
// --- Update schemas ---
describe('updatePatchSchema', () => {
it('accepts valid update patch', () => {
const result = updatePatchSchema.safeParse({
content: 'Updated content',
privacy: 'members'
})
expect(result.success).toBe(true)
})
it('accepts empty object (all optional)', () => {
const result = updatePatchSchema.safeParse({})
expect(result.success).toBe(true)
})
it('rejects invalid privacy enum', () => {
const result = updatePatchSchema.safeParse({ privacy: 'invalid' })
expect(result.success).toBe(false)
})
})
// --- Series schemas ---
describe('seriesTicketPurchaseSchema', () => {
@ -529,7 +506,6 @@ describe('validateBody migration coverage', () => {
'events/[id]/payment.post.js',
'members/update-contribution.post.js',
'members/me/peer-support.patch.js',
'updates/[id].patch.js',
'series/[id]/tickets/purchase.post.js',
'series/[id]/tickets/check-eligibility.post.js',
'admin/series.post.js',

View file

@ -5,7 +5,6 @@ import {
memberCreateSchema,
memberProfileUpdateSchema,
eventRegistrationSchema,
updateCreateSchema,
paymentVerifySchema,
adminEventCreateSchema
} from '../../../server/utils/schemas.js'
@ -120,55 +119,6 @@ describe('eventRegistrationSchema', () => {
})
})
describe('updateCreateSchema', () => {
it('accepts valid content', () => {
const result = updateCreateSchema.safeParse({ content: 'Hello world' })
expect(result.success).toBe(true)
})
it('rejects empty content', () => {
const result = updateCreateSchema.safeParse({ content: '' })
expect(result.success).toBe(false)
})
it('rejects content exceeding 50000 chars', () => {
const result = updateCreateSchema.safeParse({ content: 'a'.repeat(50001) })
expect(result.success).toBe(false)
})
it('accepts content at exactly 50000 chars', () => {
const result = updateCreateSchema.safeParse({ content: 'a'.repeat(50000) })
expect(result.success).toBe(true)
})
it('validates images are URLs', () => {
const result = updateCreateSchema.safeParse({
content: 'test',
images: ['not-a-url']
})
expect(result.success).toBe(false)
})
it('accepts valid images array', () => {
const result = updateCreateSchema.safeParse({
content: 'test',
images: ['https://example.com/img.png']
})
expect(result.success).toBe(true)
})
it('rejects more than 20 images', () => {
const images = Array.from({ length: 21 }, (_, i) => `https://example.com/img${i}.png`)
const result = updateCreateSchema.safeParse({ content: 'test', images })
expect(result.success).toBe(false)
})
it('validates privacy enum', () => {
const result = updateCreateSchema.safeParse({ content: 'test', privacy: 'invalid' })
expect(result.success).toBe(false)
})
})
describe('paymentVerifySchema', () => {
it('accepts valid card token and customer ID', () => {
const result = paymentVerifySchema.safeParse({ cardToken: 'tok_123', customerId: 'cust_456' })

View file

@ -40,3 +40,4 @@ vi.stubGlobal('useRuntimeConfig', () => ({
vi.stubGlobal('requireAuth', vi.fn())
vi.stubGlobal('requireAdmin', vi.fn())
vi.stubGlobal('validateBody', vi.fn(async (event) => readBody(event)))
vi.stubGlobal('logActivity', vi.fn())