ghostguild-org/tests/server/api/board-channels.test.js
Jennie Robinson Faber 5fb2f18cab
Some checks failed
Test / vitest (push) Successful in 12m0s
Test / playwright (push) Failing after 10m1s
Test / visual (push) Failing after 9m30s
Test / Notify on failure (push) Successful in 2s
test: align board-channels and wiki-sync mocks with current source
board-channels: source renamed getSlackServiceNoVetting → getSlackAdminService.
wiki-sync: syncWikiArticles now also calls fetchCollections; URLs starting
with / are normalized to https://wiki.ghostguild.org.
2026-04-17 09:50:50 +01:00

323 lines
10 KiB
JavaScript

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