diff --git a/server/api/admin/members/[id]/slack-status.patch.js b/server/api/admin/members/[id]/slack-status.patch.js new file mode 100644 index 0000000..6c2ca6c --- /dev/null +++ b/server/api/admin/members/[id]/slack-status.patch.js @@ -0,0 +1,35 @@ +import Member from '../../../../models/member.js' +import { connectDB } from '../../../../utils/mongoose.js' +import { validateBody } from '../../../../utils/validateBody.js' +import { adminSlackStatusSchema } from '../../../../utils/schemas.js' + +export default defineEventHandler(async (event) => { + const admin = await requireAdmin(event) + await validateBody(event, adminSlackStatusSchema) + await connectDB() + + const memberId = getRouterParam(event, 'id') + + const existing = await Member.findById(memberId) + if (!existing) { + throw createError({ + statusCode: 404, + statusMessage: 'Member not found.' + }) + } + + // Idempotent: if already invited, no-op (preserve original slackInvitedAt, no log). + if (existing.slackInvited === true) { + return { success: true, member: existing } + } + + const member = await Member.findByIdAndUpdate( + memberId, + { slackInvited: true, slackInvitedAt: new Date() }, + { new: true, runValidators: false } + ) + + logActivity(memberId, 'slack_invited_manually', {}, { performedBy: admin._id }) + + return { success: true, member } +}) diff --git a/server/utils/schemas.js b/server/utils/schemas.js index cb75944..7345224 100644 --- a/server/utils/schemas.js +++ b/server/utils/schemas.js @@ -314,6 +314,10 @@ export const adminRoleUpdateSchema = z.object({ role: z.enum(['admin', 'member']) }) +export const adminSlackStatusSchema = z.object({ + slackInvited: z.literal(true) +}).strict() + export const bulkMemberImportSchema = z.object({ members: z.array(z.object({ name: z.string().min(1).max(200), diff --git a/tests/server/api/admin-members-slack-status.test.js b/tests/server/api/admin-members-slack-status.test.js new file mode 100644 index 0000000..782d62a --- /dev/null +++ b/tests/server/api/admin-members-slack-status.test.js @@ -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)') +})