Adds AdminGhost bot token for admin-only Slack channel creation, refreshes BoardPostCard/Form layouts, and expands admin board-channels management.
323 lines
10 KiB
JavaScript
323 lines
10 KiB
JavaScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { setResponseStatus } from 'h3'
|
|
|
|
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', () => ({
|
|
getSlackServiceNoVetting: () => ({
|
|
createChannel: mockCreateSlackChannel,
|
|
}),
|
|
}))
|
|
|
|
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' })
|
|
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 })
|
|
})
|
|
})
|