test(mentor): cover multi-mentor stacking + change-request procedures (PR8 Task 10)
- multi-mentor-assignment.test.ts: stacking, P2002 dup-pair, per-team email idempotency via notificationSentAt, requestChange/list/resolve auth + conflict semantics - mentor-file-scope.test.ts: schema invariant (projectId required, dropping the originating assignment leaves the file in place via SetNull) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
509
tests/unit/multi-mentor-assignment.test.ts
Normal file
509
tests/unit/multi-mentor-assignment.test.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user