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
102 lines
4.0 KiB
TypeScript
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)
|
|
})
|
|
})
|