import { describe, it, expect, vi, beforeEach } from 'vitest' import { setResponseStatus } from 'h3' 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' vi.stubGlobal('setResponseStatus', setResponseStatus) const { mockFind, mockFindOne, mockCreate, mockFindByIdAndUpdate, mockFindByIdAndDelete } = vi.hoisted(() => ({ mockFind: vi.fn(), mockFindOne: vi.fn(), mockCreate: vi.fn(), mockFindByIdAndUpdate: vi.fn(), mockFindByIdAndDelete: vi.fn(), })) vi.mock('../../../server/models/boardChannel.js', () => ({ default: { find: mockFind, findOne: mockFindOne, 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(), })) const { mockCreateSlackChannel } = vi.hoisted(() => ({ mockCreateSlackChannel: vi.fn(), })) vi.mock('../../../server/utils/slack.ts', () => ({ getSlackAdminService: () => ({ createChannel: mockCreateSlackChannel, }), })) 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' }) mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue(null) }) }) 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 }) }) it('creates Slack channel via API when slackChannelId not provided', async () => { validateBody.mockResolvedValue({ name: 'coop-formation', tagSlugs: [], }) mockCreateSlackChannel.mockResolvedValue({ id: 'C_NEW_123', name: 'coop-formation' }) const created = { _id: 'new-ch', name: 'coop-formation', slackChannelId: 'C_NEW_123', tagSlugs: [], 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(mockCreateSlackChannel).toHaveBeenCalledWith('coop-formation') expect(mockCreate).toHaveBeenCalledWith({ name: 'coop-formation', slackChannelId: 'C_NEW_123', tagSlugs: [], }) expect(result.channel.slackChannelId).toBe('C_NEW_123') }) it('returns 409 when a tag is already mapped to another channel', async () => { validateBody.mockResolvedValue({ name: 'new-ch', slackChannelId: 'C99999', tagSlugs: ['coop-formation'], }) mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue({ _id: 'existing', name: 'old-ch', tagSlugs: ['coop-formation'], }), }) const event = createMockEvent({ method: 'POST', path: '/api/admin/board-channels' }) await expect(postHandler(event)).rejects.toMatchObject({ statusCode: 409 }) expect(mockCreate).not.toHaveBeenCalled() }) }) describe('PATCH /api/admin/board-channels/[id]', () => { beforeEach(() => { vi.clearAllMocks() requireAdmin.mockResolvedValue({ _id: 'admin-1', role: 'admin' }) mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue(null) }) }) 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('returns 409 when PATCH assigns a tag already owned by another channel', async () => { validateBody.mockResolvedValue({ tagSlugs: ['coop-formation'] }) mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue({ _id: 'other', name: 'other-ch', tagSlugs: ['coop-formation'], }), }) const event = createMockEvent({ method: 'PATCH', path: '/api/admin/board-channels/c1' }) event.context = { params: { id: 'c1' } } await expect(patchHandler(event)).rejects.toMatchObject({ statusCode: 409 }) expect(mockFindByIdAndUpdate).not.toHaveBeenCalled() }) 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 }) }) })