From 5fb069a80ea4515a5433c8c2656503d19fd4c728 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Tue, 14 Apr 2026 17:36:12 +0100 Subject: [PATCH] test(board): unit + e2e tests for board posts and channels --- e2e/admin-board-channels.spec.js | 64 +++++ e2e/board.spec.js | 87 +++++++ tests/server/api/board-channels.test.js | 239 ++++++++++++++++++ tests/server/api/board-posts.test.js | 317 ++++++++++++++++++++++++ 4 files changed, 707 insertions(+) create mode 100644 e2e/admin-board-channels.spec.js create mode 100644 e2e/board.spec.js create mode 100644 tests/server/api/board-channels.test.js create mode 100644 tests/server/api/board-posts.test.js diff --git a/e2e/admin-board-channels.spec.js b/e2e/admin-board-channels.spec.js new file mode 100644 index 0000000..7a2d1e7 --- /dev/null +++ b/e2e/admin-board-channels.spec.js @@ -0,0 +1,64 @@ +import { test, expect } from './helpers/fixtures.js' + +test.describe('Admin board channels page', () => { + test('page loads for admin', async ({ adminPage }) => { + await adminPage.goto('/admin/board-channels') + await expect(adminPage.getByRole('heading', { name: 'Board Channels' })).toBeVisible({ + timeout: 15000, + }) + await expect(adminPage.getByRole('button', { name: '+ New Channel' })).toBeVisible() + }) + + test('create, edit, and delete a channel', async ({ adminPage }) => { + await adminPage.goto('/admin/board-channels') + await expect(adminPage.getByRole('heading', { name: 'Board Channels' })).toBeVisible({ + timeout: 15000, + }) + + const suffix = Date.now().toString().slice(-6) + const channelName = `e2e-channel-${suffix}` + const editedName = `e2e-channel-${suffix}-edited` + const slackId = `C${suffix}XYZ` + + // --- Create --- + await adminPage.getByRole('button', { name: '+ New Channel' }).click() + await expect(adminPage.getByRole('heading', { name: 'New Channel' })).toBeVisible() + + await adminPage.locator('input[placeholder="e.g., #coop-formation"]').fill(channelName) + await adminPage.locator('input[placeholder="C0123456789"]').fill(slackId) + + // Select the first available cooperative tag if any are present + const firstTagCheckbox = adminPage.locator('.tag-select input[type="checkbox"]').first() + if (await firstTagCheckbox.isVisible().catch(() => false)) { + await firstTagCheckbox.check() + } + + await adminPage.getByRole('button', { name: 'Create Channel' }).click() + + await expect(adminPage.getByRole('cell', { name: channelName })).toBeVisible({ + timeout: 10000, + }) + + // --- Edit --- + const row = adminPage.locator('tr', { hasText: channelName }) + await row.getByRole('button', { name: 'Edit' }).click() + + await expect(adminPage.getByRole('heading', { name: 'Edit Channel' })).toBeVisible() + const nameInput = adminPage.locator('input[placeholder="e.g., #coop-formation"]') + await nameInput.fill(editedName) + await adminPage.getByRole('button', { name: 'Save Changes' }).click() + + await expect(adminPage.getByRole('cell', { name: editedName })).toBeVisible({ + timeout: 10000, + }) + + // --- Delete (confirm dialog) --- + adminPage.once('dialog', (dialog) => dialog.accept()) + const editedRow = adminPage.locator('tr', { hasText: editedName }) + await editedRow.getByRole('button', { name: 'Delete' }).click() + + await expect(adminPage.getByRole('cell', { name: editedName })).not.toBeVisible({ + timeout: 10000, + }) + }) +}) diff --git a/e2e/board.spec.js b/e2e/board.spec.js new file mode 100644 index 0000000..6f2fe7e --- /dev/null +++ b/e2e/board.spec.js @@ -0,0 +1,87 @@ +import { test, expect } from './helpers/fixtures.js' + +test.describe('Board page', () => { + test('page loads for authenticated member', async ({ memberPage }) => { + await memberPage.goto('/board') + await expect(memberPage.getByRole('heading', { name: 'Board' })).toBeVisible({ timeout: 15000 }) + await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible() + }) + + test('clicking New Post reveals the form', async ({ memberPage }) => { + await memberPage.goto('/board') + await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({ + timeout: 15000, + }) + + await memberPage.getByRole('button', { name: '+ New Post' }).first().click() + + await expect(memberPage.getByRole('heading', { name: 'New post' })).toBeVisible() + await expect(memberPage.locator('#post-title')).toBeVisible() + await expect(memberPage.locator('#post-seeking')).toBeVisible() + }) + + test('tags drawer toggles open and closed', async ({ memberPage }) => { + await memberPage.goto('/board') + await expect(memberPage.getByRole('heading', { name: 'Board' })).toBeVisible({ timeout: 15000 }) + + const drawerToggle = memberPage.getByRole('button', { name: /^Tags\.\.\./ }) + // Drawer toggle only appears if cooperative tags exist — skip quietly if not + if (!(await drawerToggle.isVisible().catch(() => false))) { + test.skip(true, 'No cooperative tags seeded in this environment') + return + } + + await drawerToggle.click() + await expect(memberPage.getByText('Filter:')).toBeVisible() + + await drawerToggle.click() + await expect(memberPage.getByText('Filter:')).not.toBeVisible() + }) + + test('create, edit, and delete own post', async ({ memberPage }) => { + await memberPage.goto('/board') + await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({ + timeout: 15000, + }) + + const uniqueSuffix = Date.now().toString().slice(-6) + const originalTitle = `E2E test post ${uniqueSuffix}` + const editedTitle = `E2E test post edited ${uniqueSuffix}` + + // --- Create --- + await memberPage.getByRole('button', { name: '+ New Post' }).first().click() + await expect(memberPage.getByRole('heading', { name: 'New post' })).toBeVisible() + + await memberPage.locator('#post-title').fill(originalTitle) + await memberPage.locator('#post-seeking').fill('Playwright test seeking text') + + await memberPage.getByRole('button', { name: 'Post' }).click() + + await expect(memberPage.getByRole('heading', { name: originalTitle })).toBeVisible({ + timeout: 10000, + }) + + // --- Edit --- + // Find the post card containing our title, then click its Edit button + const postCard = memberPage.locator('article.board-post', { hasText: originalTitle }) + await postCard.getByRole('button', { name: 'Edit' }).click() + + await expect(memberPage.getByRole('heading', { name: 'Edit post' })).toBeVisible() + const titleInput = memberPage.locator('#post-title') + await titleInput.fill(editedTitle) + await memberPage.getByRole('button', { name: 'Save changes' }).click() + + await expect(memberPage.getByRole('heading', { name: editedTitle })).toBeVisible({ + timeout: 10000, + }) + + // --- Delete (confirm dialog) --- + memberPage.once('dialog', (dialog) => dialog.accept()) + const editedCard = memberPage.locator('article.board-post', { hasText: editedTitle }) + await editedCard.getByRole('button', { name: 'Delete' }).click() + + await expect(memberPage.getByRole('heading', { name: editedTitle })).not.toBeVisible({ + timeout: 10000, + }) + }) +}) diff --git a/tests/server/api/board-channels.test.js b/tests/server/api/board-channels.test.js new file mode 100644 index 0000000..7f5cbd2 --- /dev/null +++ b/tests/server/api/board-channels.test.js @@ -0,0 +1,239 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setResponseStatus } from 'h3' + +vi.stubGlobal('setResponseStatus', setResponseStatus) + +const { mockFind, mockCreate, mockFindByIdAndUpdate, mockFindByIdAndDelete } = vi.hoisted(() => ({ + mockFind: vi.fn(), + mockCreate: vi.fn(), + mockFindByIdAndUpdate: vi.fn(), + mockFindByIdAndDelete: vi.fn(), +})) + +vi.mock('../../../server/models/boardChannel.js', () => ({ + default: { + find: mockFind, + create: mockCreate, + findByIdAndUpdate: mockFindByIdAndUpdate, + findByIdAndDelete: mockFindByIdAndDelete, + }, +})) + +vi.mock('../../../server/utils/auth.js', () => ({ + requireAuth: vi.fn(), + requireAdmin: vi.fn(), +})) + +vi.mock('../../../server/utils/validateBody.js', () => ({ + validateBody: vi.fn(), +})) + +vi.mock('../../../server/utils/schemas.js', () => ({ + boardChannelCreateSchema: {}, + boardChannelUpdateSchema: {}, +})) + +vi.mock('../../../server/utils/mongoose.js', () => ({ + connectDB: vi.fn(), +})) + +import { requireAuth, requireAdmin } from '../../../server/utils/auth.js' +import { validateBody } from '../../../server/utils/validateBody.js' +import getHandler from '../../../server/api/board/channels.get.js' +import postHandler from '../../../server/api/admin/board-channels.post.js' +import patchHandler from '../../../server/api/admin/board-channels/[id].patch.js' +import deleteHandler from '../../../server/api/admin/board-channels/[id].delete.js' +import { createMockEvent } from '../helpers/createMockEvent.js' + +describe('GET /api/board/channels', () => { + beforeEach(() => { + vi.clearAllMocks() + requireAuth.mockResolvedValue({ _id: 'member-1' }) + }) + + it('returns channels for authenticated member', async () => { + const channels = [{ _id: 'c1', name: 'coop' }] + const chain = { + sort: vi.fn().mockReturnThis(), + lean: vi.fn().mockResolvedValue(channels), + } + mockFind.mockReturnValue(chain) + + const event = createMockEvent({ method: 'GET', path: '/api/board/channels' }) + const result = await getHandler(event) + + expect(mockFind).toHaveBeenCalledWith({}) + expect(chain.sort).toHaveBeenCalledWith({ name: 1 }) + expect(result).toEqual({ channels }) + }) + + it('requires auth (401)', async () => { + requireAuth.mockRejectedValue( + createError({ statusCode: 401, statusMessage: 'Unauthorized' }), + ) + const event = createMockEvent({ method: 'GET', path: '/api/board/channels' }) + await expect(getHandler(event)).rejects.toMatchObject({ statusCode: 401 }) + }) +}) + +describe('POST /api/admin/board-channels', () => { + beforeEach(() => { + vi.clearAllMocks() + requireAdmin.mockResolvedValue({ _id: 'admin-1', role: 'admin' }) + }) + + it('creates channel when admin sends valid data', async () => { + validateBody.mockResolvedValue({ + name: 'coop-formation', + slackChannelId: 'C01234ABC', + tagSlugs: ['coop-formation'], + }) + const created = { + _id: 'new-channel', + name: 'coop-formation', + slackChannelId: 'C01234ABC', + tagSlugs: ['coop-formation'], + toObject() { + return { + _id: this._id, + name: this.name, + slackChannelId: this.slackChannelId, + tagSlugs: this.tagSlugs, + } + }, + } + mockCreate.mockResolvedValue(created) + + const event = createMockEvent({ method: 'POST', path: '/api/admin/board-channels' }) + const result = await postHandler(event) + + expect(mockCreate).toHaveBeenCalledWith({ + name: 'coop-formation', + slackChannelId: 'C01234ABC', + tagSlugs: ['coop-formation'], + }) + expect(result.channel.slackChannelId).toBe('C01234ABC') + }) + + it('rejects missing required fields with 400', async () => { + validateBody.mockRejectedValue( + createError({ statusCode: 400, statusMessage: 'Validation failed' }), + ) + + const event = createMockEvent({ method: 'POST', path: '/api/admin/board-channels' }) + await expect(postHandler(event)).rejects.toMatchObject({ statusCode: 400 }) + }) + + it('rejects non-admin with 403', async () => { + requireAdmin.mockRejectedValue( + createError({ statusCode: 403, statusMessage: 'Forbidden' }), + ) + + const event = createMockEvent({ method: 'POST', path: '/api/admin/board-channels' }) + await expect(postHandler(event)).rejects.toMatchObject({ statusCode: 403 }) + }) + + it('returns 409 on duplicate slackChannelId', async () => { + validateBody.mockResolvedValue({ + name: 'x', + slackChannelId: 'C01234ABC', + tagSlugs: [], + }) + const dupErr = Object.assign(new Error('dup'), { code: 11000 }) + mockCreate.mockRejectedValue(dupErr) + + const event = createMockEvent({ method: 'POST', path: '/api/admin/board-channels' }) + await expect(postHandler(event)).rejects.toMatchObject({ statusCode: 409 }) + }) +}) + +describe('PATCH /api/admin/board-channels/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + requireAdmin.mockResolvedValue({ _id: 'admin-1', role: 'admin' }) + }) + + it('updates a channel', async () => { + validateBody.mockResolvedValue({ name: 'renamed' }) + const updated = { + _id: 'c1', + name: 'renamed', + toObject() { + return { _id: this._id, name: this.name } + }, + } + mockFindByIdAndUpdate.mockResolvedValue(updated) + + const event = createMockEvent({ method: 'PATCH', path: '/api/admin/board-channels/c1' }) + event.context = { params: { id: 'c1' } } + + const result = await patchHandler(event) + + expect(mockFindByIdAndUpdate).toHaveBeenCalledWith( + 'c1', + { $set: { name: 'renamed' } }, + { new: true, runValidators: true }, + ) + expect(result.channel.name).toBe('renamed') + }) + + it('returns 404 when channel not found', async () => { + validateBody.mockResolvedValue({ name: 'x' }) + mockFindByIdAndUpdate.mockResolvedValue(null) + + const event = createMockEvent({ method: 'PATCH', path: '/api/admin/board-channels/missing' }) + event.context = { params: { id: 'missing' } } + + await expect(patchHandler(event)).rejects.toMatchObject({ statusCode: 404 }) + }) + + it('rejects non-admin with 403', async () => { + requireAdmin.mockRejectedValue( + createError({ statusCode: 403, statusMessage: 'Forbidden' }), + ) + + const event = createMockEvent({ method: 'PATCH', path: '/api/admin/board-channels/c1' }) + event.context = { params: { id: 'c1' } } + + await expect(patchHandler(event)).rejects.toMatchObject({ statusCode: 403 }) + }) +}) + +describe('DELETE /api/admin/board-channels/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + requireAdmin.mockResolvedValue({ _id: 'admin-1', role: 'admin' }) + }) + + it('deletes a channel', async () => { + mockFindByIdAndDelete.mockResolvedValue({ _id: 'c1' }) + + const event = createMockEvent({ method: 'DELETE', path: '/api/admin/board-channels/c1' }) + event.context = { params: { id: 'c1' } } + + const result = await deleteHandler(event) + + expect(mockFindByIdAndDelete).toHaveBeenCalledWith('c1') + expect(result).toEqual({ success: true }) + }) + + it('returns 404 when channel not found', async () => { + mockFindByIdAndDelete.mockResolvedValue(null) + + const event = createMockEvent({ method: 'DELETE', path: '/api/admin/board-channels/missing' }) + event.context = { params: { id: 'missing' } } + + await expect(deleteHandler(event)).rejects.toMatchObject({ statusCode: 404 }) + }) + + it('rejects non-admin with 403', async () => { + requireAdmin.mockRejectedValue( + createError({ statusCode: 403, statusMessage: 'Forbidden' }), + ) + + const event = createMockEvent({ method: 'DELETE', path: '/api/admin/board-channels/c1' }) + event.context = { params: { id: 'c1' } } + + await expect(deleteHandler(event)).rejects.toMatchObject({ statusCode: 403 }) + }) +}) diff --git a/tests/server/api/board-posts.test.js b/tests/server/api/board-posts.test.js new file mode 100644 index 0000000..fc42b96 --- /dev/null +++ b/tests/server/api/board-posts.test.js @@ -0,0 +1,317 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setResponseStatus } from 'h3' + +vi.stubGlobal('setResponseStatus', setResponseStatus) + +// --- Mocks --- +const { mockFind, mockFindById, mockSaveInstance } = vi.hoisted(() => ({ + mockFind: vi.fn(), + mockFindById: vi.fn(), + mockSaveInstance: vi.fn(), +})) + +// Mock BoardPost as a constructor function with static methods +vi.mock('../../../server/models/boardPost.js', () => { + function BoardPost(data) { + Object.assign(this, data) + this._id = data._id || 'new-post-id' + this.save = mockSaveInstance + this.populate = vi.fn().mockResolvedValue(this) + this.toObject = function () { + const { save, populate, toObject, deleteOne, ...rest } = this + return rest + } + this.deleteOne = vi.fn().mockResolvedValue({}) + } + BoardPost.find = mockFind + BoardPost.findById = mockFindById + return { default: BoardPost } +}) + +vi.mock('../../../server/utils/auth.js', () => ({ + requireAuth: vi.fn(), +})) + +vi.mock('../../../server/utils/validateBody.js', () => ({ + validateBody: vi.fn(), +})) + +vi.mock('../../../server/utils/schemas.js', () => ({ + boardPostCreateSchema: {}, + boardPostUpdateSchema: {}, +})) + +vi.mock('../../../server/utils/activityLog.js', () => ({ + logActivity: vi.fn(), + ACTIVITY_TYPES: { BOARD_POST_CREATED: 'board_post_created' }, +})) + +vi.mock('../../../server/utils/mongoose.js', () => ({ + connectDB: vi.fn(), +})) + +import { requireAuth } from '../../../server/utils/auth.js' +import { validateBody } from '../../../server/utils/validateBody.js' +import getHandler from '../../../server/api/board/posts.get.js' +import postHandler from '../../../server/api/board/posts.post.js' +import patchHandler from '../../../server/api/board/posts/[id].patch.js' +import deleteHandler from '../../../server/api/board/posts/[id].delete.js' +import { createMockEvent } from '../helpers/createMockEvent.js' + +const MEMBER_ID = 'member-abc' + +function buildFindChain(result) { + const chain = { + sort: vi.fn().mockReturnThis(), + populate: vi.fn().mockReturnThis(), + lean: vi.fn().mockResolvedValue(result), + } + mockFind.mockReturnValue(chain) + return chain +} + +describe('GET /api/board/posts', () => { + beforeEach(() => { + vi.clearAllMocks() + requireAuth.mockResolvedValue({ _id: MEMBER_ID }) + }) + + it('returns posts sorted by createdAt desc', async () => { + const posts = [ + { _id: 'p2', title: 'newer', createdAt: '2026-04-02' }, + { _id: 'p1', title: 'older', createdAt: '2026-04-01' }, + ] + const chain = buildFindChain(posts) + + const event = createMockEvent({ method: 'GET', path: '/api/board/posts' }) + const result = await getHandler(event) + + expect(mockFind).toHaveBeenCalledWith({}) + expect(chain.sort).toHaveBeenCalledWith({ createdAt: -1 }) + expect(result).toEqual({ posts }) + }) + + it('filters by tag query param', async () => { + buildFindChain([]) + const event = createMockEvent({ method: 'GET', path: '/api/board/posts?tag=coop' }) + await getHandler(event) + expect(mockFind).toHaveBeenCalledWith({ tags: 'coop' }) + }) + + it('filters by specific author id', async () => { + buildFindChain([]) + const event = createMockEvent({ method: 'GET', path: '/api/board/posts?author=other-id' }) + await getHandler(event) + expect(mockFind).toHaveBeenCalledWith({ author: 'other-id' }) + }) + + it('filters by author=me using current member id', async () => { + buildFindChain([]) + const event = createMockEvent({ method: 'GET', path: '/api/board/posts?author=me' }) + await getHandler(event) + expect(mockFind).toHaveBeenCalledWith({ author: MEMBER_ID }) + }) + + it('requires auth (401)', async () => { + requireAuth.mockRejectedValue(createError({ statusCode: 401, statusMessage: 'Unauthorized' })) + const event = createMockEvent({ method: 'GET', path: '/api/board/posts' }) + await expect(getHandler(event)).rejects.toMatchObject({ statusCode: 401 }) + }) +}) + +describe('POST /api/board/posts', () => { + beforeEach(() => { + vi.clearAllMocks() + requireAuth.mockResolvedValue({ _id: MEMBER_ID }) + mockSaveInstance.mockResolvedValue(undefined) + }) + + it('creates a post with valid data', async () => { + validateBody.mockResolvedValue({ + title: 'Looking for co-op advice', + seeking: 'help with bylaws', + offering: '', + note: '', + tags: ['coop-formation'], + }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/board/posts', + body: { title: 'Looking for co-op advice', seeking: 'help with bylaws' }, + }) + + const result = await postHandler(event) + + expect(mockSaveInstance).toHaveBeenCalled() + expect(result.post.title).toBe('Looking for co-op advice') + expect(result.post.author).toBe(MEMBER_ID) + expect(result.post.tags).toEqual(['coop-formation']) + }) + + it('rejects when validation fails (both seeking/offering empty)', async () => { + validateBody.mockRejectedValue( + createError({ statusCode: 400, statusMessage: 'Validation failed' }), + ) + + const event = createMockEvent({ + method: 'POST', + path: '/api/board/posts', + body: { title: 'x' }, + }) + + await expect(postHandler(event)).rejects.toMatchObject({ statusCode: 400 }) + }) + + it('requires auth (401)', async () => { + requireAuth.mockRejectedValue(createError({ statusCode: 401, statusMessage: 'Unauthorized' })) + const event = createMockEvent({ + method: 'POST', + path: '/api/board/posts', + body: { title: 'x', seeking: 'y' }, + }) + await expect(postHandler(event)).rejects.toMatchObject({ statusCode: 401 }) + }) +}) + +describe('PATCH /api/board/posts/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + requireAuth.mockResolvedValue({ _id: MEMBER_ID }) + }) + + function mockPost(overrides = {}) { + const saveFn = vi.fn().mockResolvedValue(undefined) + const populateFn = vi.fn().mockResolvedValue(undefined) + const post = { + _id: 'post-1', + author: { toString: () => MEMBER_ID }, + title: 'Original', + seeking: 'need help', + offering: '', + note: '', + tags: [], + save: saveFn, + populate: populateFn, + toObject() { + return { + _id: this._id, + title: this.title, + seeking: this.seeking, + offering: this.offering, + note: this.note, + tags: this.tags, + } + }, + ...overrides, + } + mockFindById.mockResolvedValue(post) + return post + } + + it('updates own post', async () => { + const post = mockPost() + validateBody.mockResolvedValue({ title: 'Updated title' }) + + const event = createMockEvent({ + method: 'PATCH', + path: '/api/board/posts/post-1', + body: { title: 'Updated title' }, + }) + event.context = { params: { id: 'post-1' } } + + const result = await patchHandler(event) + + expect(post.save).toHaveBeenCalled() + expect(result.post.title).toBe('Updated title') + }) + + it('rejects editing another members post with 403', async () => { + mockPost({ author: { toString: () => 'other-member' } }) + validateBody.mockResolvedValue({ title: 'Hack' }) + + const event = createMockEvent({ + method: 'PATCH', + path: '/api/board/posts/post-1', + body: { title: 'Hack' }, + }) + event.context = { params: { id: 'post-1' } } + + await expect(patchHandler(event)).rejects.toMatchObject({ statusCode: 403 }) + }) + + it('rejects if merged result has neither seeking nor offering (400)', async () => { + mockPost({ seeking: 'current', offering: '' }) + validateBody.mockResolvedValue({ seeking: '', offering: '' }) + + const event = createMockEvent({ + method: 'PATCH', + path: '/api/board/posts/post-1', + body: { seeking: '', offering: '' }, + }) + event.context = { params: { id: 'post-1' } } + + await expect(patchHandler(event)).rejects.toMatchObject({ statusCode: 400 }) + }) + + it('returns 404 when post not found', async () => { + mockFindById.mockResolvedValue(null) + validateBody.mockResolvedValue({ title: 'x' }) + + const event = createMockEvent({ + method: 'PATCH', + path: '/api/board/posts/missing', + body: { title: 'x' }, + }) + event.context = { params: { id: 'missing' } } + + await expect(patchHandler(event)).rejects.toMatchObject({ statusCode: 404 }) + }) +}) + +describe('DELETE /api/board/posts/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + requireAuth.mockResolvedValue({ _id: MEMBER_ID }) + }) + + it('deletes own post', async () => { + const deleteOne = vi.fn().mockResolvedValue({}) + mockFindById.mockResolvedValue({ + _id: 'post-1', + author: { toString: () => MEMBER_ID }, + deleteOne, + }) + + const event = createMockEvent({ method: 'DELETE', path: '/api/board/posts/post-1' }) + event.context = { params: { id: 'post-1' } } + + const result = await deleteHandler(event) + + expect(deleteOne).toHaveBeenCalled() + expect(result).toEqual({ success: true }) + }) + + it('rejects deleting another members post with 403', async () => { + const deleteOne = vi.fn() + mockFindById.mockResolvedValue({ + _id: 'post-1', + author: { toString: () => 'someone-else' }, + deleteOne, + }) + + const event = createMockEvent({ method: 'DELETE', path: '/api/board/posts/post-1' }) + event.context = { params: { id: 'post-1' } } + + await expect(deleteHandler(event)).rejects.toMatchObject({ statusCode: 403 }) + expect(deleteOne).not.toHaveBeenCalled() + }) + + it('returns 404 when post not found', async () => { + mockFindById.mockResolvedValue(null) + const event = createMockEvent({ method: 'DELETE', path: '/api/board/posts/missing' }) + event.context = { params: { id: 'missing' } } + + await expect(deleteHandler(event)).rejects.toMatchObject({ statusCode: 404 }) + }) +})