import { describe, it, expect, vi, beforeEach } from 'vitest' vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) vi.mock('../../../server/models/member.js', () => ({ default: { find: vi.fn(), countDocuments: vi.fn() } })) vi.mock('../../../server/models/event.js', () => ({ default: { find: vi.fn() } })) vi.mock('../../../server/models/preRegistration.js', () => ({ default: { find: vi.fn() } })) vi.mock('../../../server/models/tagSuggestion.js', () => ({ default: { find: vi.fn() } })) vi.mock('../../../server/models/adminAlertDismissal.js', () => ({ default: { find: vi.fn() }, ADMIN_ALERT_TYPES: [ 'slack_invite_failed', 'no_slack_handle_week', 'stuck_pending_payment', 'member_suspended', 'preregistrant_selected_not_invited', 'preregistrant_expired', 'event_draft_imminent', 'event_near_capacity', 'tag_suggestions_pending' ] })) import { ALERT_THRESHOLDS, computeSignature, detectSlackInviteFailed, detectNoSlackHandleAfterWeek, detectStuckPendingPayment, detectSuspendedMembers, detectPreRegistrantSelectedNotInvited, detectPreRegistrantExpired } from '../../../server/utils/adminAlerts.js' import Member from '../../../server/models/member.js' import PreRegistration from '../../../server/models/preRegistration.js' describe('adminAlerts module shell', () => { describe('ALERT_THRESHOLDS', () => { it('exposes the four documented thresholds', () => { expect(ALERT_THRESHOLDS.NO_SLACK_DAYS).toBe(7) expect(ALERT_THRESHOLDS.STUCK_PAYMENT_DAYS).toBe(7) expect(ALERT_THRESHOLDS.PREREG_SELECTED_DAYS).toBe(3) expect(ALERT_THRESHOLDS.DRAFT_IMMINENT_DAYS).toBe(14) expect(ALERT_THRESHOLDS.NEAR_CAPACITY_RATIO).toBe(0.8) }) }) describe('computeSignature', () => { it('returns a hex string', () => { const sig = computeSignature(['a', 'b', 'c']) expect(typeof sig).toBe('string') expect(sig).toMatch(/^[a-f0-9]+$/) }) it('is order-independent', () => { expect(computeSignature(['a', 'b', 'c'])).toBe(computeSignature(['c', 'a', 'b'])) }) it('changes when membership changes', () => { expect(computeSignature(['a', 'b'])).not.toBe(computeSignature(['a', 'b', 'c'])) }) it('returns a stable empty-set signature', () => { expect(computeSignature([])).toBe(computeSignature([])) }) it('coerces non-string ids to strings', () => { expect(computeSignature([{ toString: () => 'x' }, 'y'])) .toBe(computeSignature(['x', 'y'])) }) }) describe('member alerts', () => { beforeEach(() => { vi.clearAllMocks() }) function mockMemberFind(result) { Member.find.mockReturnValue({ select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(result) }) }) } describe('detectSlackInviteFailed', () => { it('returns matching members with critical severity', async () => { mockMemberFind([ { _id: 'm1', name: 'Alex', email: 'alex@example.com' }, { _id: 'm2', name: 'Bea', email: 'bea@example.com' } ]) const alert = await detectSlackInviteFailed() expect(alert.type).toBe('slack_invite_failed') expect(alert.severity).toBe('critical') expect(alert.items).toHaveLength(2) expect(alert.items[0]).toEqual({ id: 'm1', label: 'Alex', sublabel: 'alex@example.com', href: '/admin/members/m1' }) expect(Member.find).toHaveBeenCalledWith( { slackInviteStatus: 'failed' } ) }) it('returns an empty list when nothing matches', async () => { mockMemberFind([]) const alert = await detectSlackInviteFailed() expect(alert.items).toEqual([]) }) }) describe('detectNoSlackHandleAfterWeek', () => { it('queries active members joined more than 7 days ago without a slackUserId', async () => { mockMemberFind([ { _id: 'm3', name: 'Cam', email: 'cam@example.com', createdAt: new Date(Date.now() - 9 * 86400000) } ]) const alert = await detectNoSlackHandleAfterWeek() expect(alert.type).toBe('no_slack_handle_week') expect(alert.severity).toBe('attention') expect(alert.items).toHaveLength(1) expect(alert.items[0].sublabel).toMatch(/9 days/) const [query] = Member.find.mock.calls[0] expect(query.status).toBe('active') expect(query.createdAt).toEqual({ $lte: expect.any(Date) }) // slackUserId condition: missing or empty expect(query.$or).toEqual([ { slackUserId: { $exists: false } }, { slackUserId: null }, { slackUserId: '' } ]) }) }) describe('detectStuckPendingPayment', () => { it('returns members in pending_payment older than 7 days', async () => { mockMemberFind([ { _id: 'm4', name: 'Dee', email: 'dee@example.com', createdAt: new Date(Date.now() - 10 * 86400000) } ]) const alert = await detectStuckPendingPayment() expect(alert.type).toBe('stuck_pending_payment') expect(alert.severity).toBe('attention') expect(alert.items[0].sublabel).toMatch(/10 days/) const [query] = Member.find.mock.calls[0] expect(query.status).toBe('pending_payment') expect(query.createdAt).toEqual({ $lte: expect.any(Date) }) }) }) describe('detectSuspendedMembers', () => { it('returns suspended members', async () => { mockMemberFind([ { _id: 'm5', name: 'Eli', email: 'eli@example.com' } ]) const alert = await detectSuspendedMembers() expect(alert.type).toBe('member_suspended') expect(alert.severity).toBe('attention') expect(alert.items).toHaveLength(1) expect(Member.find).toHaveBeenCalledWith({ status: 'suspended' }) }) }) }) describe('pre-registrant alerts', () => { beforeEach(() => { vi.clearAllMocks() }) function mockPreRegFind(result) { PreRegistration.find.mockReturnValue({ select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(result) }) }) } describe('detectPreRegistrantSelectedNotInvited', () => { it('returns selected pre-registrants older than 3 days', async () => { const fourDaysAgo = new Date(Date.now() - 4 * 86400000) mockPreRegFind([ { _id: 'p1', name: 'Fin', email: 'fin@example.com', updatedAt: fourDaysAgo } ]) const alert = await detectPreRegistrantSelectedNotInvited() expect(alert.type).toBe('preregistrant_selected_not_invited') expect(alert.severity).toBe('attention') expect(alert.items).toHaveLength(1) expect(alert.items[0]).toEqual({ id: 'p1', label: 'Fin', sublabel: 'fin@example.com — 4 days selected', href: '/admin/pre-registrants' }) const [query] = PreRegistration.find.mock.calls[0] expect(query.status).toBe('selected') expect(query.updatedAt).toEqual({ $lte: expect.any(Date) }) }) }) describe('detectPreRegistrantExpired', () => { it('returns expired pre-registrants', async () => { const expiredAt = new Date(Date.now() - 2 * 86400000) mockPreRegFind([ { _id: 'p2', name: 'Gus', email: 'gus@example.com', updatedAt: expiredAt } ]) const alert = await detectPreRegistrantExpired() expect(alert.type).toBe('preregistrant_expired') expect(alert.severity).toBe('attention') expect(alert.items[0].label).toBe('Gus') expect(PreRegistration.find).toHaveBeenCalledWith({ status: 'expired' }) }) }) }) })