Files
MOPC-Portal/tests/unit/multi-mentor-assignment.test.ts

510 lines
18 KiB
TypeScript
Raw Normal View History

/**
* PR8 Multi-mentor stacking + change-request procedures
*
* Covers the API surface added by PR8 Tasks 4 + 6:
* - mentor.assign: per-team stacking, P2002 on duplicate (projectId, mentorId),
* idempotent per-row email notification (via MentorAssignment.notificationSentAt),
* re-assignment after drop creates a new row and re-fires the email.
* - mentor.requestChange: auth (team-member or admin), validation, single open
* request per (user, project), target-assignment cross-project guard.
* - mentor.listChangeRequests: admin-only, PENDING-first ordering.
* - mentor.resolveChangeRequest: admin-only, BAD_REQUEST on already-resolved.
*/
import { afterAll, describe, expect, it } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
createTestUser,
createTestProgram,
createTestProject,
cleanupTestData,
uid,
} from '../helpers'
import { mentorRouter } from '../../src/server/routers/mentor'
import type { UserRole } from '@prisma/client'
async function createUserWithRoles(
primaryRole: UserRole,
rolesArray: UserRole[],
overrides: { name?: string; expertiseTags?: string[] } = {},
) {
const id = uid('user')
return prisma.user.create({
data: {
id,
email: `${id}@test.local`,
name: overrides.name ?? `Test ${primaryRole}`,
role: primaryRole,
roles: rolesArray,
status: 'ACTIVE',
expertiseTags: overrides.expertiseTags ?? [],
},
})
}
describe('mentor.assign — stacking + per-team email idempotency', () => {
const programIds: string[] = []
const userIds: string[] = []
afterAll(async () => {
for (const programId of programIds) {
await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } })
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
it('stacks two different mentors on the same project (both rows active)', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `assign-stack-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'Stacking Project' })
const m1 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'M1' })
const m2 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'M2' })
userIds.push(m1.id, m2.id)
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const a1 = await caller.assign({ projectId: project.id, mentorId: m1.id })
const a2 = await caller.assign({ projectId: project.id, mentorId: m2.id })
expect(a1.id).not.toBe(a2.id)
expect(a1.mentorId).toBe(m1.id)
expect(a2.mentorId).toBe(m2.id)
const rows = await prisma.mentorAssignment.findMany({
where: { projectId: project.id },
orderBy: { assignedAt: 'asc' },
})
expect(rows).toHaveLength(2)
expect(rows.every((r) => r.droppedAt === null)).toBe(true)
})
it('rejects duplicate (projectId, mentorId) pair with CONFLICT', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `assign-dup-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'Dup Project' })
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
userIds.push(mentor.id)
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await caller.assign({ projectId: project.id, mentorId: mentor.id })
await expect(
caller.assign({ projectId: project.id, mentorId: mentor.id }),
).rejects.toThrow(/already assigned/i)
})
it('stamps notificationSentAt on first assignment; fires fresh email when same mentor is added to a different project', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `assign-email-${uid()}` })
programIds.push(program.id)
const project1 = await createTestProject(program.id, { title: 'Project Alpha' })
const project2 = await createTestProject(program.id, { title: 'Project Beta' })
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
userIds.push(mentor.id)
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const a1 = await caller.assign({ projectId: project1.id, mentorId: mentor.id })
const a2 = await caller.assign({ projectId: project2.id, mentorId: mentor.id })
// assign() returns the row before the post-write stamp; re-read for the
// current value.
const row1 = await prisma.mentorAssignment.findUnique({ where: { id: a1.id } })
const row2 = await prisma.mentorAssignment.findUnique({ where: { id: a2.id } })
expect(row1?.notificationSentAt).not.toBeNull()
expect(row2?.notificationSentAt).not.toBeNull()
// Each row carries its own timestamp — they're independent.
expect(row1?.id).not.toBe(row2?.id)
})
it('stamps notificationSentAt independently for each co-mentor on the same project', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `assign-comentor-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'Co-mentor Project' })
const m1 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'Co-1' })
const m2 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'Co-2' })
userIds.push(m1.id, m2.id)
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const a1 = await caller.assign({ projectId: project.id, mentorId: m1.id })
const a2 = await caller.assign({ projectId: project.id, mentorId: m2.id })
const row1 = await prisma.mentorAssignment.findUnique({ where: { id: a1.id } })
const row2 = await prisma.mentorAssignment.findUnique({ where: { id: a2.id } })
expect(row1?.notificationSentAt).not.toBeNull()
expect(row2?.notificationSentAt).not.toBeNull()
})
it('after a mentor is dropped (assignment row deleted), re-assigning creates a fresh row with a new notificationSentAt', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `assign-redrop-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'Re-assign Project' })
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
userIds.push(mentor.id)
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const a1 = await caller.assign({ projectId: project.id, mentorId: mentor.id })
const stamp1 = (
await prisma.mentorAssignment.findUnique({ where: { id: a1.id } })
)?.notificationSentAt
expect(stamp1).not.toBeNull()
// Hard-delete the first row (simulates a "fully dropped → repository clean"
// state — the unique constraint also blocks any re-assign while the row
// exists, so the row must go away).
await prisma.mentorAssignment.delete({ where: { id: a1.id } })
// Re-assign: new row, new notificationSentAt stamp.
const a2 = await caller.assign({ projectId: project.id, mentorId: mentor.id })
const stamp2 = (
await prisma.mentorAssignment.findUnique({ where: { id: a2.id } })
)?.notificationSentAt
expect(a2.id).not.toBe(a1.id)
expect(stamp2).not.toBeNull()
})
})
describe('mentor.requestChange / listChangeRequests / resolveChangeRequest', () => {
const programIds: string[] = []
const userIds: string[] = []
afterAll(async () => {
for (const programId of programIds) {
await prisma.mentorChangeRequest.deleteMany({ where: { project: { programId } } })
await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } })
await prisma.teamMember.deleteMany({ where: { project: { programId } } })
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
/**
* Builds a project with a LEAD team member (applicant), an unrelated
* non-team-member (applicant), and an admin. Returns the IDs.
*/
async function setupProjectWithTeam(label: string) {
const admin = await createTestUser('SUPER_ADMIN', { name: `Admin ${label}` })
userIds.push(admin.id)
const program = await createTestProgram({ name: `${label}-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: `Project ${label}` })
const teamMember = await createUserWithRoles('APPLICANT', ['APPLICANT'], {
name: `Team ${label}`,
})
const outsider = await createUserWithRoles('APPLICANT', ['APPLICANT'], {
name: `Outsider ${label}`,
})
userIds.push(teamMember.id, outsider.id)
await prisma.teamMember.create({
data: {
projectId: project.id,
userId: teamMember.id,
role: 'LEAD',
},
})
return { admin, program, project, teamMember, outsider }
}
it('team member can open a change request (PENDING)', async () => {
const { project, teamMember } = await setupProjectWithTeam('rc-teamok')
const caller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
const created = await caller.requestChange({
projectId: project.id,
reason: 'We would like a mentor with deeper marine biology experience.',
})
expect(created.status).toBe('PENDING')
const persisted = await prisma.mentorChangeRequest.findUnique({
where: { id: created.id },
})
expect(persisted?.requestedByUserId).toBe(teamMember.id)
expect(persisted?.projectId).toBe(project.id)
})
it('non-team-member non-admin is rejected with FORBIDDEN', async () => {
const { project, outsider } = await setupProjectWithTeam('rc-outsider')
const caller = createCaller(mentorRouter, {
id: outsider.id,
email: outsider.email,
role: 'APPLICANT',
})
await expect(
caller.requestChange({
projectId: project.id,
reason: 'I have no business asking for this.',
}),
).rejects.toThrow(/FORBIDDEN|not a member/i)
})
it('admin (no team membership) can open a change request', async () => {
const { admin, project } = await setupProjectWithTeam('rc-admin')
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const created = await caller.requestChange({
projectId: project.id,
reason: 'Admin-initiated mentor swap due to internal escalation.',
})
expect(created.status).toBe('PENDING')
})
it('reason < 10 chars is rejected (Zod validation)', async () => {
const { project, teamMember } = await setupProjectWithTeam('rc-short')
const caller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
await expect(
caller.requestChange({ projectId: project.id, reason: 'too short' }),
).rejects.toThrow()
})
it('opening a second request while the first is still PENDING throws CONFLICT', async () => {
const { project, teamMember } = await setupProjectWithTeam('rc-conflict')
const caller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
await caller.requestChange({
projectId: project.id,
reason: 'First request — still pending please review.',
})
await expect(
caller.requestChange({
projectId: project.id,
reason: 'Second request while first is open.',
}),
).rejects.toThrow(/already.*open|CONFLICT/i)
})
it('after the first request is resolved, the same user can open a new one', async () => {
const { admin, project, teamMember } = await setupProjectWithTeam('rc-reopen')
const teamCaller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
const adminCaller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const first = await teamCaller.requestChange({
projectId: project.id,
reason: 'First request — please address my concerns.',
})
await adminCaller.resolveChangeRequest({
id: first.id,
status: 'RESOLVED',
resolutionNote: 'Mentor swapped.',
})
const second = await teamCaller.requestChange({
projectId: project.id,
reason: 'Second request — new concern after resolution.',
})
expect(second.status).toBe('PENDING')
expect(second.id).not.toBe(first.id)
})
it('targetAssignmentId belonging to a different project is rejected with BAD_REQUEST', async () => {
const { admin, project: projectA, teamMember } = await setupProjectWithTeam('rc-crossproj')
// Make a second project + mentor assignment NOT on the requester's project.
const otherProgram = await createTestProgram({ name: `rc-other-${uid()}` })
programIds.push(otherProgram.id)
const otherProject = await createTestProject(otherProgram.id, { title: 'Other proj' })
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
userIds.push(mentor.id)
const foreignAssignment = await prisma.mentorAssignment.create({
data: {
projectId: otherProject.id,
mentorId: mentor.id,
method: 'MANUAL',
assignedBy: admin.id,
},
})
const caller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
await expect(
caller.requestChange({
projectId: projectA.id,
targetAssignmentId: foreignAssignment.id,
reason: 'Trying to point at a foreign assignment row.',
}),
).rejects.toThrow(/does not belong|BAD_REQUEST/i)
})
it('listChangeRequests is FORBIDDEN for applicant', async () => {
const { project, teamMember } = await setupProjectWithTeam('rc-list-forbidden')
const teamCaller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
await teamCaller.requestChange({
projectId: project.id,
reason: 'A real request, but list should still be admin-only.',
})
await expect(teamCaller.listChangeRequests({})).rejects.toThrow()
})
it('listChangeRequests returns PENDING rows before non-PENDING rows', async () => {
const { admin, project, teamMember } = await setupProjectWithTeam('rc-list-order')
const teamCaller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
const adminCaller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
// Create two requests: open one, resolve it; open a second one (still PENDING).
const resolvedReq = await teamCaller.requestChange({
projectId: project.id,
reason: 'Will be resolved before the second request opens.',
})
await adminCaller.resolveChangeRequest({
id: resolvedReq.id,
status: 'RESOLVED',
})
const pendingReq = await teamCaller.requestChange({
projectId: project.id,
reason: 'Still pending — should be listed first.',
})
const rows = (await adminCaller.listChangeRequests({ projectId: project.id })) as Array<{
id: string
status: string
}>
const ids = rows.map((r) => r.id)
// PENDING must come before RESOLVED in the listing.
expect(ids.indexOf(pendingReq.id)).toBeLessThan(ids.indexOf(resolvedReq.id))
expect(rows[0].status).toBe('PENDING')
})
it('resolveChangeRequest sets resolvedBy/resolvedAt/resolutionNote', async () => {
const { admin, project, teamMember } = await setupProjectWithTeam('rc-resolve')
const teamCaller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
const adminCaller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const req = await teamCaller.requestChange({
projectId: project.id,
reason: 'Please resolve this request.',
})
const result = await adminCaller.resolveChangeRequest({
id: req.id,
status: 'RESOLVED',
resolutionNote: 'Replacement mentor assigned.',
})
expect(result.status).toBe('RESOLVED')
expect(result.resolvedByUserId).toBe(admin.id)
expect(result.resolvedAt).not.toBeNull()
expect(result.resolutionNote).toBe('Replacement mentor assigned.')
})
it('resolveChangeRequest by non-admin is FORBIDDEN', async () => {
const { admin, project, teamMember } = await setupProjectWithTeam('rc-resolve-forbid')
const teamCaller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
const adminCaller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const req = await adminCaller.requestChange({
projectId: project.id,
reason: 'Admin opens, applicant should not resolve.',
})
await expect(
teamCaller.resolveChangeRequest({ id: req.id, status: 'RESOLVED' }),
).rejects.toThrow()
})
it('resolveChangeRequest on an already-resolved request throws BAD_REQUEST', async () => {
const { admin, project, teamMember } = await setupProjectWithTeam('rc-resolve-twice')
const teamCaller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
const adminCaller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const req = await teamCaller.requestChange({
projectId: project.id,
reason: 'Will resolve, then try to resolve again.',
})
await adminCaller.resolveChangeRequest({ id: req.id, status: 'RESOLVED' })
await expect(
adminCaller.resolveChangeRequest({ id: req.id, status: 'DISMISSED' }),
).rejects.toThrow(/already resolved|BAD_REQUEST/i)
})
})