Admins can now surface dismissed alert types without waiting for the underlying data to change. Adds a collapsible "Restore dismissed" section below the active alerts with per-type checkboxes. - ALERT_METADATA map in adminAlerts.js as the single source of truth for slug → title/severity; detectors refactored to reference it - GET /api/admin/alerts/dismissed returns this admin's dismissals joined with metadata (title, severity, dismissedAt) - POST /api/admin/alerts/restore deletes dismissals by alertType[], returns the deleted count - AdminAlertsPanel fetches both active + dismissed; stays visible when either is non-empty; checkboxes + "Restore selected" button - adminAlertRestoreSchema validates the POST body against the enum - Auth guards test covers both new routes
320 lines
12 KiB
JavaScript
320 lines
12 KiB
JavaScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { readFileSync } from 'node:fs'
|
|
import { resolve } from 'node:path'
|
|
|
|
vi.mock('../../../server/utils/adminAlerts.js', () => ({
|
|
computeAllAlerts: vi.fn(),
|
|
ALERT_METADATA: {
|
|
slack_invite_failed: { title: 'Slack invites failed', severity: 'critical' },
|
|
no_slack_handle_week: { title: 'Active members without a Slack handle', severity: 'attention' },
|
|
stuck_pending_payment: { title: 'Members stuck in pending payment', severity: 'attention' },
|
|
member_suspended: { title: 'Suspended members', severity: 'attention' },
|
|
preregistrant_selected_not_invited: { title: 'Pre-registrants selected but not invited', severity: 'attention' },
|
|
preregistrant_expired: { title: 'Expired pre-registrant invitations', severity: 'attention' },
|
|
event_draft_imminent: { title: 'Draft events with imminent start', severity: 'critical' },
|
|
event_near_capacity: { title: 'Events approaching capacity', severity: 'attention' },
|
|
tag_suggestions_pending: { title: 'Pending tag suggestions', severity: 'attention' }
|
|
}
|
|
}))
|
|
|
|
vi.mock('../../../server/models/adminAlertDismissal.js', () => ({
|
|
default: {
|
|
findOneAndUpdate: vi.fn(),
|
|
find: vi.fn(),
|
|
deleteMany: 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'
|
|
]
|
|
}))
|
|
|
|
vi.mock('../../../server/utils/mongoose.js', () => ({
|
|
connectDB: vi.fn()
|
|
}))
|
|
|
|
vi.stubGlobal('adminAlertDismissSchema', {})
|
|
vi.stubGlobal('adminAlertRestoreSchema', {})
|
|
|
|
import getHandler from '../../../server/api/admin/alerts/index.get.js'
|
|
import dismissHandler from '../../../server/api/admin/alerts/dismiss.post.js'
|
|
import dismissedHandler from '../../../server/api/admin/alerts/dismissed.get.js'
|
|
import restoreHandler from '../../../server/api/admin/alerts/restore.post.js'
|
|
import { computeAllAlerts } from '../../../server/utils/adminAlerts.js'
|
|
import AdminAlertDismissal from '../../../server/models/adminAlertDismissal.js'
|
|
import { createMockEvent } from '../helpers/createMockEvent.js'
|
|
|
|
describe('GET /api/admin/alerts', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
requireAdmin.mockResolvedValue({ _id: { toString: () => 'admin-1' } })
|
|
})
|
|
|
|
describe('source inspection', () => {
|
|
const source = readFileSync(
|
|
resolve(import.meta.dirname, '../../../server/api/admin/alerts/index.get.js'),
|
|
'utf-8'
|
|
)
|
|
it('calls requireAdmin before any business logic', () => {
|
|
const adminIdx = source.indexOf('requireAdmin(event)')
|
|
const computeIdx = source.indexOf('computeAllAlerts(')
|
|
expect(adminIdx).toBeGreaterThan(-1)
|
|
expect(computeIdx).toBeGreaterThan(-1)
|
|
expect(adminIdx).toBeLessThan(computeIdx)
|
|
})
|
|
})
|
|
|
|
it('returns the active alerts for the current admin', async () => {
|
|
computeAllAlerts.mockResolvedValue([
|
|
{
|
|
type: 'slack_invite_failed',
|
|
severity: 'critical',
|
|
title: 'Slack invites failed',
|
|
count: 1,
|
|
items: [{ id: 'm1', label: 'Alex', sublabel: 'alex@example.com', href: '/admin/members/m1' }],
|
|
signature: 'abc'
|
|
}
|
|
])
|
|
|
|
const event = createMockEvent({ method: 'GET', path: '/api/admin/alerts' })
|
|
const result = await getHandler(event)
|
|
|
|
expect(result).toEqual({
|
|
alerts: [
|
|
{
|
|
type: 'slack_invite_failed',
|
|
severity: 'critical',
|
|
title: 'Slack invites failed',
|
|
count: 1,
|
|
items: [{ id: 'm1', label: 'Alex', sublabel: 'alex@example.com', href: '/admin/members/m1' }],
|
|
signature: 'abc'
|
|
}
|
|
]
|
|
})
|
|
expect(computeAllAlerts).toHaveBeenCalledWith('admin-1')
|
|
})
|
|
|
|
it('rejects non-admin requests', async () => {
|
|
requireAdmin.mockRejectedValue(createError({ statusCode: 403, statusMessage: 'Forbidden' }))
|
|
const event = createMockEvent({ method: 'GET', path: '/api/admin/alerts' })
|
|
await expect(getHandler(event)).rejects.toMatchObject({ statusCode: 403 })
|
|
})
|
|
})
|
|
|
|
describe('POST /api/admin/alerts/dismiss', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
requireAdmin.mockResolvedValue({ _id: { toString: () => 'admin-1' } })
|
|
validateBody.mockResolvedValue({ alertType: 'slack_invite_failed', signature: 'abc' })
|
|
})
|
|
|
|
describe('source inspection', () => {
|
|
const source = readFileSync(
|
|
resolve(import.meta.dirname, '../../../server/api/admin/alerts/dismiss.post.js'),
|
|
'utf-8'
|
|
)
|
|
it('calls requireAdmin before validateBody', () => {
|
|
const adminIdx = source.indexOf('requireAdmin(event)')
|
|
const validateIdx = source.indexOf('validateBody(event')
|
|
expect(adminIdx).toBeGreaterThan(-1)
|
|
expect(validateIdx).toBeGreaterThan(-1)
|
|
expect(adminIdx).toBeLessThan(validateIdx)
|
|
})
|
|
})
|
|
|
|
it('upserts a dismissal with the current signature', async () => {
|
|
AdminAlertDismissal.findOneAndUpdate.mockResolvedValue({})
|
|
|
|
const event = createMockEvent({
|
|
method: 'POST',
|
|
path: '/api/admin/alerts/dismiss',
|
|
body: { alertType: 'slack_invite_failed', signature: 'abc' }
|
|
})
|
|
const result = await dismissHandler(event)
|
|
|
|
expect(result).toEqual({ ok: true })
|
|
expect(AdminAlertDismissal.findOneAndUpdate).toHaveBeenCalledWith(
|
|
{ adminId: 'admin-1', alertType: 'slack_invite_failed' },
|
|
{ $set: { signature: 'abc', dismissedAt: expect.any(Date) } },
|
|
{ upsert: true, new: true }
|
|
)
|
|
})
|
|
|
|
it('rejects non-admin requests', async () => {
|
|
requireAdmin.mockRejectedValue(createError({ statusCode: 403, statusMessage: 'Forbidden' }))
|
|
const event = createMockEvent({
|
|
method: 'POST',
|
|
path: '/api/admin/alerts/dismiss',
|
|
body: { alertType: 'slack_invite_failed', signature: 'abc' }
|
|
})
|
|
await expect(dismissHandler(event)).rejects.toMatchObject({ statusCode: 403 })
|
|
})
|
|
|
|
it('rejects invalid body via validateBody', async () => {
|
|
validateBody.mockRejectedValue(createError({ statusCode: 400, statusMessage: 'Validation failed' }))
|
|
const event = createMockEvent({
|
|
method: 'POST',
|
|
path: '/api/admin/alerts/dismiss',
|
|
body: { alertType: 'unknown', signature: 'abc' }
|
|
})
|
|
await expect(dismissHandler(event)).rejects.toMatchObject({ statusCode: 400 })
|
|
})
|
|
})
|
|
|
|
describe('GET /api/admin/alerts/dismissed', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
requireAdmin.mockResolvedValue({ _id: { toString: () => 'admin-1' } })
|
|
})
|
|
|
|
describe('source inspection', () => {
|
|
const source = readFileSync(
|
|
resolve(import.meta.dirname, '../../../server/api/admin/alerts/dismissed.get.js'),
|
|
'utf-8'
|
|
)
|
|
it('calls requireAdmin before querying dismissals', () => {
|
|
const adminIdx = source.indexOf('requireAdmin(event)')
|
|
const findIdx = source.indexOf('AdminAlertDismissal.find')
|
|
expect(adminIdx).toBeGreaterThan(-1)
|
|
expect(findIdx).toBeGreaterThan(-1)
|
|
expect(adminIdx).toBeLessThan(findIdx)
|
|
})
|
|
})
|
|
|
|
it('returns dismissed alerts with title and severity joined from metadata', async () => {
|
|
AdminAlertDismissal.find.mockReturnValue({
|
|
sort: () => ({
|
|
lean: () => Promise.resolve([
|
|
{ alertType: 'slack_invite_failed', signature: 'abc', dismissedAt: new Date('2026-04-08T10:00:00Z') },
|
|
{ alertType: 'member_suspended', signature: 'def', dismissedAt: new Date('2026-04-07T10:00:00Z') }
|
|
])
|
|
})
|
|
})
|
|
|
|
const event = createMockEvent({ method: 'GET', path: '/api/admin/alerts/dismissed' })
|
|
const result = await dismissedHandler(event)
|
|
|
|
expect(AdminAlertDismissal.find).toHaveBeenCalledWith({ adminId: 'admin-1' })
|
|
expect(result.dismissed).toEqual([
|
|
{
|
|
alertType: 'slack_invite_failed',
|
|
title: 'Slack invites failed',
|
|
severity: 'critical',
|
|
dismissedAt: new Date('2026-04-08T10:00:00Z')
|
|
},
|
|
{
|
|
alertType: 'member_suspended',
|
|
title: 'Suspended members',
|
|
severity: 'attention',
|
|
dismissedAt: new Date('2026-04-07T10:00:00Z')
|
|
}
|
|
])
|
|
})
|
|
|
|
it('returns empty list when no dismissals exist', async () => {
|
|
AdminAlertDismissal.find.mockReturnValue({
|
|
sort: () => ({ lean: () => Promise.resolve([]) })
|
|
})
|
|
const event = createMockEvent({ method: 'GET', path: '/api/admin/alerts/dismissed' })
|
|
const result = await dismissedHandler(event)
|
|
expect(result).toEqual({ dismissed: [] })
|
|
})
|
|
|
|
it('filters out orphaned slugs not present in ALERT_METADATA', async () => {
|
|
AdminAlertDismissal.find.mockReturnValue({
|
|
sort: () => ({
|
|
lean: () => Promise.resolve([
|
|
{ alertType: 'slack_invite_failed', signature: 'abc', dismissedAt: new Date() },
|
|
{ alertType: 'legacy_removed_alert', signature: 'xyz', dismissedAt: new Date() }
|
|
])
|
|
})
|
|
})
|
|
const event = createMockEvent({ method: 'GET', path: '/api/admin/alerts/dismissed' })
|
|
const result = await dismissedHandler(event)
|
|
expect(result.dismissed).toHaveLength(1)
|
|
expect(result.dismissed[0].alertType).toBe('slack_invite_failed')
|
|
})
|
|
|
|
it('rejects non-admin requests', async () => {
|
|
requireAdmin.mockRejectedValue(createError({ statusCode: 403, statusMessage: 'Forbidden' }))
|
|
const event = createMockEvent({ method: 'GET', path: '/api/admin/alerts/dismissed' })
|
|
await expect(dismissedHandler(event)).rejects.toMatchObject({ statusCode: 403 })
|
|
})
|
|
})
|
|
|
|
describe('POST /api/admin/alerts/restore', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
requireAdmin.mockResolvedValue({ _id: { toString: () => 'admin-1' } })
|
|
validateBody.mockResolvedValue({ alertTypes: ['slack_invite_failed', 'member_suspended'] })
|
|
})
|
|
|
|
describe('source inspection', () => {
|
|
const source = readFileSync(
|
|
resolve(import.meta.dirname, '../../../server/api/admin/alerts/restore.post.js'),
|
|
'utf-8'
|
|
)
|
|
it('calls requireAdmin before validateBody', () => {
|
|
const adminIdx = source.indexOf('requireAdmin(event)')
|
|
const validateIdx = source.indexOf('validateBody(event')
|
|
expect(adminIdx).toBeGreaterThan(-1)
|
|
expect(validateIdx).toBeGreaterThan(-1)
|
|
expect(adminIdx).toBeLessThan(validateIdx)
|
|
})
|
|
})
|
|
|
|
it('deletes dismissals matching the provided alertTypes and returns count', async () => {
|
|
AdminAlertDismissal.deleteMany.mockResolvedValue({ deletedCount: 2 })
|
|
|
|
const event = createMockEvent({
|
|
method: 'POST',
|
|
path: '/api/admin/alerts/restore',
|
|
body: { alertTypes: ['slack_invite_failed', 'member_suspended'] }
|
|
})
|
|
const result = await restoreHandler(event)
|
|
|
|
expect(result).toEqual({ ok: true, restored: 2 })
|
|
expect(AdminAlertDismissal.deleteMany).toHaveBeenCalledWith({
|
|
adminId: 'admin-1',
|
|
alertType: { $in: ['slack_invite_failed', 'member_suspended'] }
|
|
})
|
|
})
|
|
|
|
it('returns restored: 0 when deleteMany returns no deletedCount', async () => {
|
|
AdminAlertDismissal.deleteMany.mockResolvedValue({})
|
|
const event = createMockEvent({
|
|
method: 'POST',
|
|
path: '/api/admin/alerts/restore',
|
|
body: { alertTypes: ['slack_invite_failed'] }
|
|
})
|
|
const result = await restoreHandler(event)
|
|
expect(result).toEqual({ ok: true, restored: 0 })
|
|
})
|
|
|
|
it('rejects non-admin requests', async () => {
|
|
requireAdmin.mockRejectedValue(createError({ statusCode: 403, statusMessage: 'Forbidden' }))
|
|
const event = createMockEvent({
|
|
method: 'POST',
|
|
path: '/api/admin/alerts/restore',
|
|
body: { alertTypes: ['slack_invite_failed'] }
|
|
})
|
|
await expect(restoreHandler(event)).rejects.toMatchObject({ statusCode: 403 })
|
|
})
|
|
|
|
it('rejects invalid body via validateBody', async () => {
|
|
validateBody.mockRejectedValue(createError({ statusCode: 400, statusMessage: 'Validation failed' }))
|
|
const event = createMockEvent({
|
|
method: 'POST',
|
|
path: '/api/admin/alerts/restore',
|
|
body: { alertTypes: [] }
|
|
})
|
|
await expect(restoreHandler(event)).rejects.toMatchObject({ statusCode: 400 })
|
|
})
|
|
})
|