// Spec: docs/specs/wave-based-slack-onboarding.md // Test plan: docs/specs/wave-based-slack-onboarding-tests.md §3 + §4 // // Verifies that the three self-serve activation paths invoke // `autoFlagPreExistingSlackAccess`, while the two admin-create paths do not. // // `autoFlagPreExistingSlackAccess` is auto-imported by Nitro at runtime; in // tests it's stubbed as a global in tests/server/setup.js. Tests grab the stub // off `globalThis` and reset it per-case. import { describe, it, expect, vi, beforeEach } from 'vitest' import Member from '../../../server/models/member.js' import { requireAuth } from '../../../server/utils/auth.js' import { requiresPayment } from '../../../server/config/contributions.js' import { sendWelcomeEmail } from '../../../server/utils/resend.js' import { validateBody } from '../../../server/utils/validateBody.js' // jwt and the auto-flag helper are mocked via globals/vi.mock import PreRegistration from '../../../server/models/preRegistration.js' import { createHelcimCustomer } from '../../../server/utils/helcim.js' import subscriptionHandler from '../../../server/api/helcim/subscription.post.js' import membersCreateHandler from '../../../server/api/members/create.post.js' import inviteAcceptHandler from '../../../server/api/invite/accept.post.js' import { createMockEvent } from '../helpers/createMockEvent.js' vi.mock('../../../server/models/member.js', () => { const mockSave = vi.fn().mockResolvedValue(undefined) function MockMember(data) { Object.assign(this, data) this._id = 'new-member-123' this.status = data.status || 'pending_payment' this.save = mockSave } MockMember.findOne = vi.fn() MockMember.findOneAndUpdate = vi.fn() MockMember.findById = vi.fn() MockMember.findByIdAndUpdate = vi.fn() MockMember.create = vi.fn() MockMember._mockSave = mockSave return { default: MockMember } }) vi.mock('../../../server/models/preRegistration.js', () => ({ default: { findById: vi.fn(), findByIdAndUpdate: vi.fn() } })) vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn(), getSignupBridgeMember: vi.fn().mockResolvedValue(null), setAuthCookie: vi.fn() })) vi.mock('../../../server/utils/slack.ts', () => ({ getSlackService: vi.fn().mockReturnValue(null) })) vi.mock('../../../server/config/contributions.js', () => ({ requiresPayment: vi.fn(), getHelcimPlanId: vi.fn() })) vi.mock('../../../server/utils/resend.js', () => ({ sendWelcomeEmail: vi.fn().mockResolvedValue({ success: true }) })) vi.mock('../../../server/utils/helcim.js', () => ({ createHelcimSubscription: vi.fn(), generateIdempotencyKey: vi.fn().mockReturnValue('idem-key-1'), listHelcimCustomerTransactions: vi.fn().mockResolvedValue([]), createHelcimCustomer: vi.fn() })) vi.mock('../../../server/utils/payments.js', () => ({ upsertPaymentFromHelcim: vi.fn().mockResolvedValue({ created: true, payment: { _id: 'p1' } }) })) vi.mock('../../../server/utils/validateBody.js', () => ({ validateBody: vi.fn() })) vi.mock('../../../server/utils/schemas.js', () => ({ memberCreateSchema: {} })) vi.mock('../../../server/utils/memberNumber.js', () => ({ assignMemberNumber: vi.fn().mockResolvedValue(1) })) vi.mock('jsonwebtoken', () => { const verify = vi.fn(() => ({ type: 'prereg-invite', preRegistrationId: 'prereg-1' })) return { default: { verify }, verify } }) vi.stubGlobal('helcimSubscriptionSchema', {}) vi.stubGlobal('inviteAcceptSchema', {}) const autoFlagStub = globalThis.autoFlagPreExistingSlackAccess // --------------------------------------------------------------------------- // 3.1 — Helcim subscription success calls helper // --------------------------------------------------------------------------- describe('POST /api/helcim/subscription — auto-flag wiring (3.1)', () => { beforeEach(() => { vi.clearAllMocks() autoFlagStub.mockClear() requireAuth.mockResolvedValue(undefined) requiresPayment.mockReturnValue(false) }) it('calls autoFlagPreExistingSlackAccess after successful free-tier activation', async () => { const mockMember = { _id: 'member-1', email: 'free@example.com', name: 'Free Tier', circle: 'community', contributionAmount: 0, status: 'active' } Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-1', status: 'pending_payment' }) Member.findById.mockResolvedValue(mockMember) const event = createMockEvent({ method: 'POST', path: '/api/helcim/subscription', body: { customerId: 'cust-1', contributionAmount: 0, customerCode: 'code-1' } }) await subscriptionHandler(event) expect(autoFlagStub).toHaveBeenCalledTimes(1) expect(autoFlagStub).toHaveBeenCalledWith( expect.objectContaining({ _id: 'member-1', email: 'free@example.com' }) ) }) }) // --------------------------------------------------------------------------- // 3.2 — Free-tier /api/invite/accept calls helper // --------------------------------------------------------------------------- describe('POST /api/invite/accept (free tier) — auto-flag wiring (3.2)', () => { beforeEach(() => { vi.clearAllMocks() autoFlagStub.mockClear() PreRegistration.findById.mockResolvedValue({ _id: 'prereg-1', email: 'invitee@example.com', status: 'pending' }) PreRegistration.findByIdAndUpdate.mockResolvedValue(undefined) Member.findOne.mockResolvedValue(null) Member.create.mockImplementation(async (data) => ({ _id: 'new-member-via-invite', ...data })) // Handler uses Nitro auto-imported validateBody (global), not the explicit import globalThis.validateBody.mockResolvedValue({ token: 'tok', preRegistrationId: 'prereg-1', name: 'New Invitee', circle: 'community', contributionAmount: 0 }) }) it('calls helper after Member.create on the free-tier branch', async () => { const event = createMockEvent({ method: 'POST', path: '/api/invite/accept' }) await inviteAcceptHandler(event) expect(autoFlagStub).toHaveBeenCalledTimes(1) expect(autoFlagStub).toHaveBeenCalledWith( expect.objectContaining({ _id: 'new-member-via-invite', email: 'invitee@example.com' }) ) }) it('paid-tier branch does NOT call helper (3.3)', async () => { globalThis.validateBody.mockResolvedValue({ token: 'tok', preRegistrationId: 'prereg-1', name: 'Paid Invitee', circle: 'community', contributionAmount: 25 }) createHelcimCustomer.mockResolvedValue({ id: 'cust-2', customerCode: 'code-2' }) const event = createMockEvent({ method: 'POST', path: '/api/invite/accept' }) await inviteAcceptHandler(event) expect(autoFlagStub).not.toHaveBeenCalled() }) }) // --------------------------------------------------------------------------- // 3.8 — members/create.post.js calls helper instead of legacy inviteToSlack // --------------------------------------------------------------------------- describe('POST /api/members/create — auto-flag wiring (3.8)', () => { beforeEach(() => { vi.clearAllMocks() autoFlagStub.mockClear() Member.findOne.mockResolvedValue(null) Member._mockSave.mockResolvedValue(undefined) sendWelcomeEmail.mockResolvedValue({ success: true }) validateBody.mockResolvedValue({ email: 'mc@example.com', name: 'MC Member', circle: 'community', contributionAmount: 0 }) }) it('calls helper instead of legacy inviteToSlack', async () => { const event = createMockEvent({ method: 'POST', path: '/api/members/create' }) await membersCreateHandler(event) expect(autoFlagStub).toHaveBeenCalledTimes(1) expect(autoFlagStub).toHaveBeenCalledWith( expect.objectContaining({ email: 'mc@example.com' }) ) }) })