Files
MOPC-Portal/tests/unit/bulk-role-updates.test.ts
Matt 432470083c feat(admin): bulk role updates + mentor-onboarding email (§D.2-3)
user.bulkUpdateRoles({userIds, addRole?, removeRole?}) batches role
changes across up to 200 users with a SUPER_ADMIN self-demote guard.
When MENTOR is freshly added, fires sendMentorOnboardingEmail once per
user, gated by User.mentorOnboardingSentAt for idempotency. Audit log
entry per user changed.

UI: 'Add MENTOR role' button surfaces in the existing /admin/members
bulk-selection toolbar when ≥1 user is selected. Other roles
(OBSERVER / AWARD_MASTER) supported by the procedure but not yet wired
to UI; one button keeps the toolbar minimal until a clear need arises.

Tests cover happy path, idempotency on second call, removeRole semantics,
and the SUPER_ADMIN self-demote guard.

Plan: docs/superpowers/plans/2026-04-28-pr6-multi-role-and-workspace-previews.md
2026-04-28 16:05:16 +02:00

102 lines
4.0 KiB
TypeScript

import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { prisma, createCaller } from '../setup'
import { createTestUser, cleanupTestData, uid } from '../helpers'
import { userRouter } from '../../src/server/routers/user'
import * as emailModule from '../../src/lib/email'
import type { UserRole } from '@prisma/client'
async function createUserWithRoles(primaryRole: UserRole, rolesArray: UserRole[]) {
const id = uid('user')
return prisma.user.create({
data: {
id,
email: `${id}@test.local`,
name: `Test ${primaryRole}`,
role: primaryRole,
roles: rolesArray,
status: 'ACTIVE',
},
})
}
describe('user.bulkUpdateRoles', () => {
const userIds: string[] = []
afterAll(async () => {
if (userIds.length > 0) {
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } })
await prisma.decisionAuditLog.deleteMany({ where: { actorId: { in: userIds } } })
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
beforeEach(() => {
vi.restoreAllMocks()
})
it('adds MENTOR role to multiple users and sends one onboarding email each', async () => {
const admin = await createTestUser('SUPER_ADMIN')
const j1 = await createUserWithRoles('JURY_MEMBER', ['JURY_MEMBER'])
const j2 = await createUserWithRoles('JURY_MEMBER', ['JURY_MEMBER'])
userIds.push(admin.id, j1.id, j2.id)
const sendSpy = vi.spyOn(emailModule, 'sendMentorOnboardingEmail').mockResolvedValue()
const caller = createCaller(userRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN' })
const result = await caller.bulkUpdateRoles({
userIds: [j1.id, j2.id],
addRole: 'MENTOR',
})
expect(result.updated).toBe(2)
expect(result.alreadyHadRole).toBe(0)
expect(sendSpy).toHaveBeenCalledTimes(2)
const updatedJ1 = await prisma.user.findUniqueOrThrow({ where: { id: j1.id } })
expect(updatedJ1.roles).toContain('MENTOR')
expect(updatedJ1.roles).toContain('JURY_MEMBER')
expect(updatedJ1.mentorOnboardingSentAt).not.toBeNull()
})
it('is idempotent — second call with same input does NOT resend onboarding email', async () => {
const admin = await createTestUser('SUPER_ADMIN')
const j = await createUserWithRoles('JURY_MEMBER', ['JURY_MEMBER'])
userIds.push(admin.id, j.id)
const sendSpy = vi.spyOn(emailModule, 'sendMentorOnboardingEmail').mockResolvedValue()
const caller = createCaller(userRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN' })
await caller.bulkUpdateRoles({ userIds: [j.id], addRole: 'MENTOR' })
expect(sendSpy).toHaveBeenCalledTimes(1)
const second = await caller.bulkUpdateRoles({ userIds: [j.id], addRole: 'MENTOR' })
expect(sendSpy).toHaveBeenCalledTimes(1) // still 1
expect(second.updated).toBe(0)
expect(second.alreadyHadRole).toBe(1)
})
it('removeRole strips the role; does not affect onboarding stamp', async () => {
const admin = await createTestUser('SUPER_ADMIN')
const m = await createUserWithRoles('MENTOR', ['MENTOR', 'JURY_MEMBER'])
userIds.push(admin.id, m.id)
const caller = createCaller(userRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN' })
const result = await caller.bulkUpdateRoles({ userIds: [m.id], removeRole: 'JURY_MEMBER' })
expect(result.updated).toBe(1)
const updated = await prisma.user.findUniqueOrThrow({ where: { id: m.id } })
expect(updated.roles).toEqual(['MENTOR'])
})
it('refuses to remove SUPER_ADMIN from self', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
await prisma.user.update({ where: { id: admin.id }, data: { roles: ['SUPER_ADMIN'] } })
const caller = createCaller(userRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN' })
await expect(
caller.bulkUpdateRoles({ userIds: [admin.id], removeRole: 'SUPER_ADMIN' }),
).rejects.toThrow(/cannot remove SUPER_ADMIN from self|self-demote/i)
})
})