ghostguild-org/tests/server/api/activation-auto-flag.test.js
Jennie Robinson Faber 55029e7eb7 feat(activation): wire autoFlagPreExistingSlackAccess into self-serve paths
Replaces the per-file inviteToSlack helpers with a single auto-flag
call. Self-serve activation paths now check for pre-existing workspace
membership (silent on miss) instead of attempting an admin-only invite.

- helcim/subscription.post.js: removed local inviteToSlack; both
  free- and paid-tier activation branches now call the helper, then
  notifyNewMember with the canonical 'manual_invitation_required' arg.
- members/create.post.js: same shape — helper + canonical notify arg.
- invite/accept.post.js (free-tier branch): added the helper call after
  member creation. Free-tier had no prior Slack call (audit confirmed);
  paid-tier remains untouched and activates via the Helcim webhook.

Admin-created and CSV-imported members intentionally do NOT call the
helper — admins flip the flag manually after sending the invite.

Test stub for autoFlagPreExistingSlackAccess added to server setup.
2026-04-29 12:21:12 +01:00

211 lines
7.7 KiB
JavaScript

// 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(),
getPaymentBridgeMember: 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' })
)
})
})