317 lines
9.6 KiB
JavaScript
317 lines
9.6 KiB
JavaScript
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 })
|
|
})
|
|
})
|