test(board): unit + e2e tests for board posts and channels
This commit is contained in:
parent
f3df1945bd
commit
5fb069a80e
4 changed files with 707 additions and 0 deletions
64
e2e/admin-board-channels.spec.js
Normal file
64
e2e/admin-board-channels.spec.js
Normal file
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
87
e2e/board.spec.js
Normal file
87
e2e/board.spec.js
Normal file
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
239
tests/server/api/board-channels.test.js
Normal file
239
tests/server/api/board-channels.test.js
Normal file
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
317
tests/server/api/board-posts.test.js
Normal file
317
tests/server/api/board-posts.test.js
Normal file
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue