feat(admin): PATCH /api/admin/members/:id/slack-status

Endpoint that flips a member's slackInvited flag manually after the
admin has actually sent the Slack invitation through Slack's UI. No
Slack API call is made from this app.

- Body validated via Zod literal-true schema (no undo path for the
  pilot — admins correct mistakes in the database if needed).
- Idempotent: re-marking an already-invited member is a no-op,
  preserving the original slackInvitedAt and not duplicating the
  activity log entry.
- Activity log: slack_invited_manually, actor = admin from
  requireAdmin, subject = the target member.
This commit is contained in:
Jennie Robinson Faber 2026-04-29 12:23:07 +01:00
parent 55029e7eb7
commit 0981596ea2
3 changed files with 227 additions and 0 deletions

View file

@ -0,0 +1,188 @@
// Spec: docs/specs/wave-based-slack-onboarding.md
// Test plan: docs/specs/wave-based-slack-onboarding-tests.md §5
//
// SCAFFOLD: `describe.skip` until the route at
// server/api/admin/members/[id]/slack-status.patch.js
// exists. Unskip during implementation (TDD).
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../../../server/models/member.js', () => ({
default: {
findById: vi.fn(),
findByIdAndUpdate: vi.fn()
}
}))
vi.mock('../../../server/utils/mongoose.js', () => ({
connectDB: vi.fn()
}))
vi.mock('../../../server/utils/validateBody.js', () => ({
validateBody: vi.fn()
}))
vi.mock('../../../server/utils/schemas.js', () => ({
adminSlackStatusSchema: {}
}))
// Slack service must NOT be invoked from this endpoint.
vi.mock('../../../server/utils/slack.ts', () => ({
getSlackService: vi.fn().mockReturnValue({
inviteUserToSlack: vi.fn(),
findUserByEmail: vi.fn()
})
}))
describe('PATCH /api/admin/members/[id]/slack-status', () => {
let handler
let Member
let validateBody
let getSlackService
beforeEach(async () => {
vi.clearAllMocks()
Member = (await import('../../../server/models/member.js')).default
validateBody = (await import('../../../server/utils/validateBody.js')).validateBody
getSlackService = (await import('../../../server/utils/slack.ts')).getSlackService
handler = (
await import('../../../server/api/admin/members/[id]/slack-status.patch.js')
).default
globalThis.requireAdmin.mockResolvedValue({
_id: { toString: () => 'admin-1' }
})
validateBody.mockResolvedValue({ slackInvited: true })
vi.stubGlobal('getRouterParam', vi.fn().mockReturnValue('member-1'))
globalThis.logActivity.mockClear?.()
})
function makeEvent() {
return { node: { req: {}, res: {} } } // handler reads via auto-imports + stubs
}
describe('auth (5.4 / 5.5)', () => {
it('rejects unauthenticated requests with 401', async () => {
globalThis.requireAdmin.mockRejectedValue(
createError({ statusCode: 401, statusMessage: 'Unauthorized' })
)
await expect(handler(makeEvent())).rejects.toMatchObject({ statusCode: 401 })
})
it('rejects non-admin members with 403', async () => {
globalThis.requireAdmin.mockRejectedValue(
createError({ statusCode: 403, statusMessage: 'Forbidden' })
)
await expect(handler(makeEvent())).rejects.toMatchObject({ statusCode: 403 })
})
})
describe('validation (5.6)', () => {
it('rejects invalid body via validateBody (zod)', async () => {
validateBody.mockRejectedValue(
createError({ statusCode: 400, statusMessage: 'Invalid body' })
)
await expect(handler(makeEvent())).rejects.toMatchObject({ statusCode: 400 })
})
})
describe('member lookup (5.7)', () => {
it('returns 404 when member does not exist', async () => {
Member.findById.mockResolvedValue(null)
await expect(handler(makeEvent())).rejects.toMatchObject({ statusCode: 404 })
})
})
describe('happy path: false → true transition (5.1)', () => {
it('marks member invited, stamps date, logs activity', async () => {
Member.findById.mockResolvedValue({
_id: 'member-1',
slackInvited: false,
slackInvitedAt: undefined
})
const updated = {
_id: 'member-1',
slackInvited: true,
slackInvitedAt: new Date('2026-04-29T00:00:00Z')
}
Member.findByIdAndUpdate.mockResolvedValue(updated)
const result = await handler(makeEvent())
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
'member-1',
expect.objectContaining({
slackInvited: true,
slackInvitedAt: expect.any(Date)
}),
expect.objectContaining({ runValidators: false, new: true })
)
expect(globalThis.logActivity).toHaveBeenCalledWith(
'member-1',
'slack_invited_manually',
expect.any(Object),
expect.objectContaining({ performedBy: expect.anything() })
)
expect(result).toMatchObject({ success: true })
expect(result.member).toMatchObject({
slackInvited: true,
slackInvitedAt: expect.any(Date)
})
})
it('uses findByIdAndUpdate with runValidators:false (5.9)', async () => {
Member.findById.mockResolvedValue({ _id: 'member-1', slackInvited: false })
Member.findByIdAndUpdate.mockResolvedValue({ _id: 'member-1', slackInvited: true })
await handler(makeEvent())
const opts = Member.findByIdAndUpdate.mock.calls.at(-1)?.[2]
expect(opts).toMatchObject({ runValidators: false })
})
})
describe('idempotency: true → true (5.2)', () => {
it('preserves original slackInvitedAt and writes no new activity-log entry', async () => {
const original = new Date('2026-01-15T00:00:00Z')
Member.findById.mockResolvedValue({
_id: 'member-1',
slackInvited: true,
slackInvitedAt: original
})
await handler(makeEvent())
// Either the endpoint short-circuits with no write, OR the update payload
// carefully omits slackInvitedAt. Both are acceptable; assert outcome:
const updateCall = Member.findByIdAndUpdate.mock.calls.at(-1)
if (updateCall) {
expect(updateCall[1]).not.toHaveProperty('slackInvitedAt')
}
expect(globalThis.logActivity).not.toHaveBeenCalled()
})
})
describe('no Slack API call (5.3)', () => {
it('does not invoke any Slack write API on success', async () => {
Member.findById.mockResolvedValue({ _id: 'member-1', slackInvited: false })
Member.findByIdAndUpdate.mockResolvedValue({ _id: 'member-1', slackInvited: true })
await handler(makeEvent())
const slack = getSlackService()
expect(slack.inviteUserToSlack).not.toHaveBeenCalled()
expect(slack.findUserByEmail).not.toHaveBeenCalled()
})
})
describe('source inspection (handler shape)', () => {
// Mirrors the pattern in admin-role-patch.test.js — verifies route
// construction order without booting the handler.
it.todo('calls requireAdmin before validateBody')
it.todo('does not import getSlackService')
})
it.todo('PATCH { slackInvited: false } behavior — decided in spec gap #2 (5.11)')
})