import { describe, it, expect, vi, beforeEach } from 'vitest' import Member from '../../../server/models/member.js' import { cancelHelcimSubscription } from '../../../server/utils/helcim.js' import handler from '../../../server/api/members/cancel-subscription.post.js' import { createMockEvent } from '../helpers/createMockEvent.js' vi.mock('../../../server/models/member.js', () => ({ default: { findByIdAndUpdate: vi.fn() } })) vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn() })) vi.mock('../../../server/utils/helcim.js', () => ({ cancelHelcimSubscription: vi.fn(), })) // Nitro auto-imports — stub as globals const logActivityMock = vi.fn() vi.stubGlobal('logActivity', logActivityMock) function setMember(mockMember) { globalThis.requireAuth = vi.fn().mockResolvedValue(mockMember) } function buildEvent() { return createMockEvent({ method: 'POST', path: '/api/members/cancel-subscription', body: {}, }) } describe('cancel-subscription endpoint', () => { beforeEach(() => { vi.clearAllMocks() }) it('happy path: active paid member → cancels Helcim, drops to free tier (status stays active), returns success', async () => { const mockMember = { _id: 'member-1', status: 'active', contributionAmount: 15, helcimSubscriptionId: 'sub-1', } setMember(mockMember) cancelHelcimSubscription.mockResolvedValue({}) Member.findByIdAndUpdate.mockResolvedValue({}) const result = await handler(buildEvent()) expect(cancelHelcimSubscription).toHaveBeenCalledWith('sub-1') expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( 'member-1', expect.objectContaining({ $set: expect.objectContaining({ status: 'active', contributionAmount: 0, helcimSubscriptionId: null, paymentMethod: 'none', }), $unset: expect.objectContaining({ nextBillingDate: 1 }), }), { runValidators: false } ) // Per Fix #9: lastCancelledAt must be set as a Date const updateArg = Member.findByIdAndUpdate.mock.calls[0][1] expect(updateArg.$set.lastCancelledAt).toBeInstanceOf(Date) expect(result).toEqual(expect.objectContaining({ success: true, message: 'Subscription cancelled successfully', status: 'active', contributionAmount: 0, })) }) it('persists subscriptionEndDate as a Date instance', async () => { setMember({ _id: 'member-2', status: 'active', contributionAmount: 5, helcimSubscriptionId: 'sub-2', }) cancelHelcimSubscription.mockResolvedValue({}) Member.findByIdAndUpdate.mockResolvedValue({}) await handler(buildEvent()) const call = Member.findByIdAndUpdate.mock.calls[0] expect(call[1].$set.subscriptionEndDate).toBeInstanceOf(Date) }) it('emits subscription_cancelled activity log entry', async () => { setMember({ _id: 'member-3', status: 'active', contributionAmount: 15, helcimSubscriptionId: 'sub-3', }) cancelHelcimSubscription.mockResolvedValue({}) Member.findByIdAndUpdate.mockResolvedValue({}) await handler(buildEvent()) expect(logActivityMock).toHaveBeenCalledWith( 'member-3', 'subscription_cancelled', expect.objectContaining({ effectiveDate: expect.any(String) }) ) }) it('free-tier member (contributionAmount=0): no Helcim call, no DB write, returns no-op success', async () => { const mockMember = { _id: 'member-4', status: 'active', contributionAmount: 0, helcimSubscriptionId: null, } setMember(mockMember) const result = await handler(buildEvent()) expect(cancelHelcimSubscription).not.toHaveBeenCalled() expect(Member.findByIdAndUpdate).not.toHaveBeenCalled() expect(logActivityMock).not.toHaveBeenCalled() expect(result).toEqual({ success: true, message: 'No active subscription to cancel', status: 'active', contributionAmount: 0, }) }) it('member without helcimSubscriptionId (data inconsistency): no Helcim call, no DB write', async () => { setMember({ _id: 'member-5', status: 'active', contributionAmount: 15, helcimSubscriptionId: null, }) const result = await handler(buildEvent()) expect(cancelHelcimSubscription).not.toHaveBeenCalled() expect(Member.findByIdAndUpdate).not.toHaveBeenCalled() expect(result.success).toBe(true) expect(result.message).toBe('No active subscription to cancel') }) it('Helcim cancel failure: swallows error, still updates Member to free tier (status stays active)', async () => { setMember({ _id: 'member-6', status: 'active', contributionAmount: 15, helcimSubscriptionId: 'sub-6', }) cancelHelcimSubscription.mockRejectedValue(new Error('Helcim 503')) Member.findByIdAndUpdate.mockResolvedValue({}) const result = await handler(buildEvent()) expect(cancelHelcimSubscription).toHaveBeenCalledWith('sub-6') expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( 'member-6', expect.objectContaining({ $set: expect.objectContaining({ status: 'active', helcimSubscriptionId: null }), }), { runValidators: false } ) expect(result.success).toBe(true) expect(result.status).toBe('active') }) it('unauthenticated: requireAuth throws → handler rejects with that statusCode', async () => { globalThis.requireAuth = vi.fn().mockRejectedValue( Object.assign(new Error('Unauthorized'), { statusCode: 401 }) ) await expect(handler(buildEvent())).rejects.toMatchObject({ statusCode: 401, }) expect(cancelHelcimSubscription).not.toHaveBeenCalled() expect(Member.findByIdAndUpdate).not.toHaveBeenCalled() }) it('Mongo update failure: rejects with 500', async () => { setMember({ _id: 'member-7', status: 'active', contributionAmount: 15, helcimSubscriptionId: 'sub-7', }) cancelHelcimSubscription.mockResolvedValue({}) Member.findByIdAndUpdate.mockRejectedValue(new Error('Mongo down')) await expect(handler(buildEvent())).rejects.toMatchObject({ statusCode: 500, statusMessage: 'Mongo down', }) }) })