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 }) }) })