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:
parent
55029e7eb7
commit
0981596ea2
3 changed files with 227 additions and 0 deletions
188
tests/server/api/admin-members-slack-status.test.js
Normal file
188
tests/server/api/admin-members-slack-status.test.js
Normal 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)')
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue