import { describe, it, expect, vi, beforeEach } from 'vitest' vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn() })) vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) vi.mock('../../../server/models/member.js', () => ({ default: { findByIdAndUpdate: vi.fn(), findOneAndUpdate: vi.fn() } })) vi.mock('../../../server/utils/validateBody.js', () => ({ validateBody: vi.fn() })) vi.mock('../../../server/utils/schemas.js', () => ({ onboardingTrackSchema: {} })) vi.mock('../../../server/utils/activityLog.js', () => ({ logActivity: vi.fn() })) import { requireAuth } from '../../../server/utils/auth.js' import { validateBody } from '../../../server/utils/validateBody.js' import Member from '../../../server/models/member.js' import { logActivity } from '../../../server/utils/activityLog.js' import handler from '../../../server/api/onboarding/track.post.js' import { createMockEvent } from '../helpers/createMockEvent.js' describe('POST /api/onboarding/track', () => { beforeEach(() => { vi.clearAllMocks() requireAuth.mockResolvedValue({ _id: 'member-1', onboarding: { completedAt: null, eventPageVisited: false, boardPageVisited: false, wikiClicked: false, }, }) Member.findByIdAndUpdate.mockResolvedValue({}) Member.findOneAndUpdate.mockResolvedValue(null) // no graduation by default }) // 2.1: Sets eventPageVisited to true it('sets eventPageVisited to true and returns success', async () => { validateBody.mockResolvedValue({ goal: 'eventPageVisited' }) const event = createMockEvent({ method: 'POST', path: '/api/onboarding/track', body: { goal: 'eventPageVisited' }, }) const result = await handler(event) expect(result).toEqual({ success: true, graduated: false }) expect(Member.findByIdAndUpdate).toHaveBeenCalledWith('member-1', { $set: { 'onboarding.eventPageVisited': true }, }) expect(logActivity).toHaveBeenCalledWith( 'member-1', 'member_onboarding_goal_completed', { goal: 'eventPageVisited' }, { visibility: 'admin' } ) }) // 2.4: Rejects invalid goal name (400) it('rejects an invalid goal name with 400', async () => { validateBody.mockRejectedValue( createError({ statusCode: 400, statusMessage: 'Validation failed' }) ) const event = createMockEvent({ method: 'POST', path: '/api/onboarding/track', body: { goal: 'invalidGoal' }, }) await expect(handler(event)).rejects.toMatchObject({ statusCode: 400 }) }) // 2.6: Rejects computed goal names like 'hasProfileTags' (400) it('rejects computed goal names like hasProfileTags with 400', async () => { validateBody.mockRejectedValue( createError({ statusCode: 400, statusMessage: 'Validation failed' }) ) const event = createMockEvent({ method: 'POST', path: '/api/onboarding/track', body: { goal: 'hasProfileTags' }, }) await expect(handler(event)).rejects.toMatchObject({ statusCode: 400 }) }) // 2.7: Idempotent — short-circuits when already true it('short-circuits without DB write when goal is already true', async () => { requireAuth.mockResolvedValue({ _id: 'member-1', onboarding: { completedAt: null, eventPageVisited: true, boardPageVisited: false, wikiClicked: false, }, }) validateBody.mockResolvedValue({ goal: 'eventPageVisited' }) const event = createMockEvent({ method: 'POST', path: '/api/onboarding/track', body: { goal: 'eventPageVisited' }, }) const result = await handler(event) expect(result).toEqual({ success: true }) expect(Member.findByIdAndUpdate).not.toHaveBeenCalled() expect(Member.findOneAndUpdate).not.toHaveBeenCalled() expect(logActivity).not.toHaveBeenCalled() }) // 2.10: Triggers graduation when all four goals met it('triggers graduation when all goals are met', async () => { validateBody.mockResolvedValue({ goal: 'wikiClicked' }) // findOneAndUpdate returns a doc when graduation conditions are met const graduatedMember = { _id: 'member-1', onboarding: { completedAt: new Date(), eventPageVisited: true, boardPageVisited: true, wikiClicked: true, }, } Member.findOneAndUpdate.mockResolvedValue(graduatedMember) const event = createMockEvent({ method: 'POST', path: '/api/onboarding/track', body: { goal: 'wikiClicked' }, }) const result = await handler(event) expect(result).toEqual({ success: true, graduated: true }) // Should have logged both goal completion and graduation expect(logActivity).toHaveBeenCalledTimes(2) expect(logActivity).toHaveBeenCalledWith( 'member-1', 'member_onboarding_goal_completed', { goal: 'wikiClicked' }, { visibility: 'admin' } ) expect(logActivity).toHaveBeenCalledWith( 'member-1', 'member_onboarding_completed', {}, { visibility: 'admin' } ) }) // 2.12: Graduation no-ops if tags were removed concurrently it('does not graduate if tags were removed concurrently', async () => { validateBody.mockResolvedValue({ goal: 'wikiClicked' }) // findOneAndUpdate returns null when conditions aren't met (tags removed) Member.findOneAndUpdate.mockResolvedValue(null) const event = createMockEvent({ method: 'POST', path: '/api/onboarding/track', body: { goal: 'wikiClicked' }, }) const result = await handler(event) expect(result).toEqual({ success: true, graduated: false }) // Should have logged only goal completion, not graduation expect(logActivity).toHaveBeenCalledTimes(1) expect(logActivity).toHaveBeenCalledWith( 'member-1', 'member_onboarding_goal_completed', { goal: 'wikiClicked' }, { visibility: 'admin' } ) }) // 2.15: Requires auth (401) it('returns 401 when not authenticated', async () => { requireAuth.mockRejectedValue( createError({ statusCode: 401, statusMessage: 'Authentication required' }) ) const event = createMockEvent({ method: 'POST', path: '/api/onboarding/track', body: { goal: 'eventPageVisited' }, }) await expect(handler(event)).rejects.toMatchObject({ statusCode: 401 }) }) })