From 2166ee32ca5acf1d87349ec49488840fa79acce3 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 9 Apr 2026 22:32:32 +0100 Subject: [PATCH] feat(events): add tag validation to admin event create/edit routes --- server/api/admin/events.post.js | 14 +++ server/api/admin/events/[id].put.js | 14 +++ tests/server/api/event-tags.test.js | 157 ++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+) create mode 100644 tests/server/api/event-tags.test.js diff --git a/server/api/admin/events.post.js b/server/api/admin/events.post.js index 875499b..08a3543 100644 --- a/server/api/admin/events.post.js +++ b/server/api/admin/events.post.js @@ -1,4 +1,5 @@ import Event from "../../models/event.js"; +import Tag from "../../models/tag.js"; import { connectDB } from "../../utils/mongoose.js"; import { requireAdmin } from "../../utils/auth.js"; import { validateBody } from "../../utils/validateBody.js"; @@ -12,6 +13,19 @@ export default defineEventHandler(async (event) => { await connectDB(); + // Validate tag slugs against Tag collection + if (body.tags && body.tags.length > 0) { + const foundTags = await Tag.find({ slug: { $in: body.tags } }); + const foundSlugs = new Set(foundTags.map((t) => t.slug)); + const invalid = body.tags.filter((s) => !foundSlugs.has(s)); + if (invalid.length > 0) { + throw createError({ + statusCode: 400, + statusMessage: `Unknown tag slugs: ${invalid.join(", ")}`, + }); + } + } + const eventData = { ...body, createdBy: admin.email, diff --git a/server/api/admin/events/[id].put.js b/server/api/admin/events/[id].put.js index 3928344..60809a7 100644 --- a/server/api/admin/events/[id].put.js +++ b/server/api/admin/events/[id].put.js @@ -1,4 +1,5 @@ import Event from '../../../models/event.js' +import Tag from '../../../models/tag.js' import { connectDB } from '../../../utils/mongoose.js' import { requireAdmin } from '../../../utils/auth.js' @@ -11,6 +12,19 @@ export default defineEventHandler(async (event) => { await connectDB() + // Validate tag slugs against Tag collection + if (body.tags && body.tags.length > 0) { + const foundTags = await Tag.find({ slug: { $in: body.tags } }) + const foundSlugs = new Set(foundTags.map(t => t.slug)) + const invalid = body.tags.filter(s => !foundSlugs.has(s)) + if (invalid.length > 0) { + throw createError({ + statusCode: 400, + statusMessage: `Unknown tag slugs: ${invalid.join(', ')}` + }) + } + } + const updateData = { ...body, startDate: new Date(body.startDate), diff --git a/tests/server/api/event-tags.test.js b/tests/server/api/event-tags.test.js new file mode 100644 index 0000000..37a0eb9 --- /dev/null +++ b/tests/server/api/event-tags.test.js @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// --- Mocks (must be before imports) --- + +const mockEventSave = vi.fn() +function MockEvent(data) { + Object.assign(this, data) + this._id = 'evt-123' + this.slug = '2026-04-01-test-event' + this.save = mockEventSave.mockResolvedValue(this) +} +MockEvent.findByIdAndUpdate = vi.fn() + +vi.mock('../../../server/models/event.js', () => ({ default: MockEvent })) + +vi.mock('../../../server/models/tag.js', () => ({ + default: { find: vi.fn() } +})) + +vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) +vi.mock('../../../server/utils/auth.js', () => ({ + requireAdmin: vi.fn().mockResolvedValue({ email: 'admin@example.com', role: 'admin' }) +})) +vi.mock('../../../server/utils/validateBody.js', () => ({ validateBody: vi.fn() })) +vi.mock('../../../server/utils/schemas.js', () => ({ + adminEventCreateSchema: {}, + adminEventUpdateSchema: {} +})) + +import Tag from '../../../server/models/tag.js' +import { validateBody } from '../../../server/utils/validateBody.js' +import createHandler from '../../../server/api/admin/events.post.js' +import updateHandler from '../../../server/api/admin/events/[id].put.js' +import { createMockEvent } from '../helpers/createMockEvent.js' + +// Stub the global adminEventUpdateSchema used by the update route (auto-imported by Nitro) +vi.stubGlobal('adminEventUpdateSchema', {}) + +// --- Helpers --- + +const validEventBody = { + title: 'Test Event', + description: 'A test event', + startDate: '2026-04-01T10:00:00Z', + endDate: '2026-04-01T12:00:00Z', + location: 'https://meet.example.com' +} + +// --- Tests --- + +describe('Event tag validation', () => { + beforeEach(() => { + vi.clearAllMocks() + // Override the global validateBody stub (from setup.js) to use our mock + globalThis.validateBody = validateBody + }) + + describe('create route (events.post)', () => { + it('accepts valid tags', async () => { + validateBody.mockResolvedValue({ + ...validEventBody, + tags: ['game-design', 'cooperatives'] + }) + Tag.find.mockResolvedValue([ + { slug: 'game-design' }, + { slug: 'cooperatives' } + ]) + + const event = createMockEvent({ method: 'POST', path: '/api/admin/events' }) + const result = await createHandler(event) + + expect(Tag.find).toHaveBeenCalledWith({ slug: { $in: ['game-design', 'cooperatives'] } }) + expect(result.tags).toEqual(['game-design', 'cooperatives']) + }) + + it('rejects unknown tag slugs with 400', async () => { + validateBody.mockResolvedValue({ + ...validEventBody, + tags: ['game-design', 'nonexistent-tag'] + }) + Tag.find.mockResolvedValue([ + { slug: 'game-design' } + ]) + + const event = createMockEvent({ method: 'POST', path: '/api/admin/events' }) + + await expect(createHandler(event)).rejects.toMatchObject({ + statusCode: 400, + statusMessage: 'Unknown tag slugs: nonexistent-tag' + }) + }) + + it('skips validation when tags are omitted', async () => { + validateBody.mockResolvedValue({ ...validEventBody }) + + const event = createMockEvent({ method: 'POST', path: '/api/admin/events' }) + await createHandler(event) + + expect(Tag.find).not.toHaveBeenCalled() + }) + + it('skips validation when tags array is empty', async () => { + validateBody.mockResolvedValue({ ...validEventBody, tags: [] }) + + const event = createMockEvent({ method: 'POST', path: '/api/admin/events' }) + await createHandler(event) + + expect(Tag.find).not.toHaveBeenCalled() + }) + }) + + describe('update route (events/[id].put)', () => { + beforeEach(() => { + MockEvent.findByIdAndUpdate.mockResolvedValue({ + _id: 'evt-123', + ...validEventBody, + tags: ['game-design'] + }) + }) + + it('accepts valid tags', async () => { + validateBody.mockResolvedValue({ + ...validEventBody, + tags: ['game-design'] + }) + Tag.find.mockResolvedValue([ + { slug: 'game-design' } + ]) + + const event = createMockEvent({ method: 'PUT', path: '/api/admin/events/evt-123' }) + event.context = { ...event.context, params: { id: 'evt-123' } } + + const result = await updateHandler(event) + + expect(Tag.find).toHaveBeenCalledWith({ slug: { $in: ['game-design'] } }) + expect(result.tags).toEqual(['game-design']) + }) + + it('rejects unknown tag slugs with 400', async () => { + validateBody.mockResolvedValue({ + ...validEventBody, + tags: ['valid-tag', 'bogus-tag', 'also-bogus'] + }) + Tag.find.mockResolvedValue([ + { slug: 'valid-tag' } + ]) + + const event = createMockEvent({ method: 'PUT', path: '/api/admin/events/evt-123' }) + event.context = { ...event.context, params: { id: 'evt-123' } } + + await expect(updateHandler(event)).rejects.toMatchObject({ + statusCode: 400, + statusMessage: 'Unknown tag slugs: bogus-tag, also-bogus' + }) + }) + }) +})