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