import { describe, it, expect, vi, beforeEach } from 'vitest' import { ALERT_THRESHOLDS, computeSignature, detectNoSlackHandleAfterWeek, detectStuckPendingPayment, detectSuspendedMembers, detectPreRegistrantSelectedNotInvited, detectPreRegistrantExpired, detectDraftEventsImminent, detectEventsNearCapacity, detectPendingTagSuggestions , computeAllAlerts } from '../../../server/utils/adminAlerts.js' import Member from '../../../server/models/member.js' import PreRegistration from '../../../server/models/preRegistration.js' import Event from '../../../server/models/event.js' import TagSuggestion from '../../../server/models/tagSuggestion.js' import AdminAlertDismissal from '../../../server/models/adminAlertDismissal.js' 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: [ 'no_slack_handle_week', 'stuck_pending_payment', 'member_suspended', 'preregistrant_selected_not_invited', 'preregistrant_expired', 'event_draft_imminent', 'event_near_capacity', 'tag_suggestions_pending' ] })) 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('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' }) }) }) }) describe('event alerts', () => { beforeEach(() => { vi.clearAllMocks() }) function mockEventFind(result) { Event.find.mockReturnValue({ select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(result) }) }) } describe('detectDraftEventsImminent', () => { it('returns hidden, non-cancelled events starting in the next 14 days', async () => { const startDate = new Date(Date.now() + 5 * 86400000) mockEventFind([ { _id: 'e1', title: 'Workshop', startDate } ]) const alert = await detectDraftEventsImminent() expect(alert.type).toBe('event_draft_imminent') expect(alert.severity).toBe('critical') expect(alert.items).toHaveLength(1) expect(alert.items[0]).toEqual({ id: 'e1', label: 'Workshop', sublabel: expect.stringMatching(/starts in 5 days/i), href: '/admin/events' }) const [query] = Event.find.mock.calls[0] expect(query.isVisible).toBe(false) expect(query.isCancelled).toEqual({ $ne: true }) expect(query.startDate.$gte).toBeInstanceOf(Date) expect(query.startDate.$lte).toBeInstanceOf(Date) }) }) describe('detectEventsNearCapacity', () => { it('returns events at >= 80% of capacity', async () => { mockEventFind([ { _id: 'e2', title: 'Sold Out Soon', startDate: new Date(Date.now() + 3 * 86400000), tickets: { enabled: true, capacity: { total: 10, reserved: 1 } }, registrations: [ { name: 'a' }, { name: 'b' }, { name: 'c' }, { name: 'd' }, { name: 'e' }, { name: 'f' }, { name: 'g' } ] }, { _id: 'e3', title: 'Plenty of Room', startDate: new Date(Date.now() + 3 * 86400000), tickets: { enabled: true, capacity: { total: 10, reserved: 0 } }, registrations: [{ name: 'a' }] } ]) const alert = await detectEventsNearCapacity() expect(alert.type).toBe('event_near_capacity') expect(alert.severity).toBe('attention') expect(alert.items).toHaveLength(1) expect(alert.items[0].id).toBe('e2') expect(alert.items[0].sublabel).toMatch(/8\s*\/\s*10/) }) it('ignores events with no capacity set', async () => { mockEventFind([ { _id: 'e4', title: 'Uncapped', startDate: new Date(Date.now() + 3 * 86400000), tickets: { enabled: true, capacity: {} }, registrations: [] } ]) const alert = await detectEventsNearCapacity() expect(alert.items).toEqual([]) }) }) }) describe('tag suggestion alert', () => { beforeEach(() => { vi.clearAllMocks() }) function mockTagSuggestionFind(result) { TagSuggestion.find.mockReturnValue({ select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(result) }) }) } it('returns a single aggregated item with the count', async () => { mockTagSuggestionFind([ { _id: 't1' }, { _id: 't2' }, { _id: 't3' } ]) const alert = await detectPendingTagSuggestions() expect(alert.type).toBe('tag_suggestions_pending') expect(alert.severity).toBe('attention') expect(alert.items).toHaveLength(1) expect(alert.items[0]).toEqual({ id: 'tag-suggestions', label: '3 pending tag suggestions', sublabel: 'Review and approve in Tags', href: '/admin/tags' }) expect(TagSuggestion.find).toHaveBeenCalledWith({ status: 'pending' }) }) it('returns an empty list when nothing is pending', async () => { mockTagSuggestionFind([]) const alert = await detectPendingTagSuggestions() expect(alert.items).toEqual([]) }) }) }) describe('computeAllAlerts aggregator', () => { beforeEach(() => { vi.clearAllMocks() // Default: every detector returns empty Member.find.mockReturnValue({ select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([]) }) }) Event.find.mockReturnValue({ select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([]) }) }) PreRegistration.find.mockReturnValue({ select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([]) }) }) TagSuggestion.find.mockReturnValue({ select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([]) }) }) AdminAlertDismissal.find.mockReturnValue({ lean: vi.fn().mockResolvedValue([]) }) }) it('returns an empty list when nothing matches', async () => { const alerts = await computeAllAlerts('admin-1') expect(alerts).toEqual([]) }) it('returns alerts that have items and have not been dismissed', async () => { Member.find.mockImplementation((query) => { if (query.status === 'suspended') { return { select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([ { _id: 'm1', name: 'Alex', email: 'alex@example.com' } ]) }) } } return { select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([]) }) } }) const alerts = await computeAllAlerts('admin-1') expect(alerts).toHaveLength(1) expect(alerts[0].type).toBe('member_suspended') expect(alerts[0].signature).toMatch(/^[a-f0-9]+$/) }) it('hides alerts whose signature matches an existing dismissal', async () => { Member.find.mockImplementation((query) => { if (query.status === 'suspended') { return { select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([ { _id: 'm1', name: 'Alex', email: 'alex@example.com' } ]) }) } } return { select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([]) }) } }) const sig = computeSignature(['m1']) AdminAlertDismissal.find.mockReturnValue({ lean: vi.fn().mockResolvedValue([ { adminId: 'admin-1', alertType: 'member_suspended', signature: sig } ]) }) const alerts = await computeAllAlerts('admin-1') expect(alerts).toEqual([]) }) it('shows an alert again when the underlying set changes', async () => { Member.find.mockImplementation((query) => { if (query.status === 'suspended') { return { select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([ { _id: 'm1', name: 'Alex', email: 'alex@example.com' }, { _id: 'm2', name: 'Bea', email: 'bea@example.com' } ]) }) } } return { select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([]) }) } }) const staleSig = computeSignature(['m1']) // dismissal was for the old single-member state AdminAlertDismissal.find.mockReturnValue({ lean: vi.fn().mockResolvedValue([ { adminId: 'admin-1', alertType: 'member_suspended', signature: staleSig } ]) }) const alerts = await computeAllAlerts('admin-1') expect(alerts).toHaveLength(1) expect(alerts[0].count).toBe(2) }) it('uses signatureIds for tag_suggestions_pending', async () => { TagSuggestion.find.mockReturnValue({ select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([ { _id: 't1' }, { _id: 't2' } ]) }) }) const alerts = await computeAllAlerts('admin-1') const tagAlert = alerts.find((a) => a.type === 'tag_suggestions_pending') expect(tagAlert.signature).toBe(computeSignature(['t1', 't2'])) // The synthetic item id should not appear in the signature expect(tagAlert.signature).not.toBe(computeSignature(['tag-suggestions'])) }) })