import { getPayload, Payload } from "payload"; import config from "@/payload.config"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { AssignmentTransfer, User } from "@/payload-types"; import { notifyUser } from "@/lib/notifications"; import { EventTypes } from "@/utils/event-log/eventTypes"; import { appealTransfer, cancelTransfer, createTransferRequest, recordTransferDecision, resolveAppeal, } from "@/lib/transfers"; let payload: Payload; const RUN = `trf-${Date.now().toString(36)}`; const TIMEOUT = 30_000; const ROLE_NAMES = ["user", "admin", "guest", "trusted", "developer"] as const; type RoleName = (typeof ROLE_NAMES)[number]; describe("Assignment transfer requests", () => { const users: Record = {}; const squads: Record = {}; const userIds: number[] = []; const assignmentIds: number[] = []; const transferIds: number[] = []; let noLeadersDoc: AssignmentTransfer | null = null; const makeUser = async ( label: string, roles: RoleName[] = ["user"], extra?: Partial, ): Promise => { const user = (await payload.create({ collection: "users", data: { username: `${RUN}-${label}`, discordUsername: `${RUN}-${label}`, displayName: label.toUpperCase(), steamId: `7656119${Math.floor(Math.random() * 1e9)}`, password: "Test123", roles, ...extra, }, overrideAccess: true, depth: 0, })) as unknown as User; userIds.push(user.id); return user; }; const makeSquad = async (label: string, leader: number | null, members: number[]): Promise => { const doc = await payload.create({ collection: "assignments", data: { name: `${RUN} ${label}`, type: "squad", leader, members }, overrideAccess: true, depth: 0, }); assignmentIds.push(doc.id); return doc.id; }; const getAssignment = async (id: number): Promise<{ leader: number | null; members: number[] }> => { const doc = await payload.findByID({ collection: "assignments", id, depth: 0 }); return { leader: (doc.leader as number | null) ?? null, members: (doc.members as number[]) ?? [], }; }; const trackTransfer = (doc: AssignmentTransfer): AssignmentTransfer => { transferIds.push(doc.id); return doc; }; const notificationsFor = async (userId: number, type?: string) => { const res = await payload.find({ collection: "user-notifications", where: type ? { and: [{ user: { equals: userId } }, { type: { equals: type } }] } : { user: { equals: userId } }, limit: 100, depth: 0, overrideAccess: true, }); return res.docs; }; const eventsFor = async (requestId: number) => { const res = await payload.find({ collection: "game-event-logs", where: { targetCollection: { equals: "assignment-transfers" }, targetId: { equals: requestId } }, limit: 100, depth: 0, overrideAccess: true, }); return res.docs; }; beforeAll(async () => { const payloadConfig = await config; payload = await getPayload({ config: payloadConfig }); users.req1 = await makeUser("req-1"); users.leadA = await makeUser("lead-a"); users.leadB = await makeUser("lead-b"); users.req2 = await makeUser("req-2"); users.leadR1 = await makeUser("lead-r1"); users.leadR2 = await makeUser("lead-r2"); users.admin = await makeUser("admin", ["admin"]); users.outsider = await makeUser("outsider"); users.req3 = await makeUser("req-3"); users.req4 = await makeUser("req-4"); users.leadT = await makeUser("lead-t"); users.req5 = await makeUser("req-5"); users.leadU1 = await makeUser("lead-u1"); users.leadU2 = await makeUser("lead-u2"); users.reqLead = await makeUser("req-lead"); users.mutedLead = await makeUser("muted-lead", ["user"], { preferences: { notifications: { mutedAll: true } }, }); users.reqM = await makeUser("req-m"); users.reqV = await makeUser("req-v"); users.leadV1 = await makeUser("lead-v1"); users.leadC = await makeUser("lead-c"); squads.squadA = await makeSquad("Squad A", users.leadA.id, [users.req1.id]); squads.squadB = await makeSquad("Squad B", users.leadB.id, []); squads.squadR1 = await makeSquad("Squad R1", users.leadR1.id, [users.req2.id]); squads.squadR2 = await makeSquad("Squad R2", users.leadR2.id, []); squads.squadC = await makeSquad("Squad C", null, [users.req3.id]); squads.squadD = await makeSquad("Squad D", null, []); squads.squadS1 = await makeSquad("Squad S1", null, [users.req4.id]); squads.squadT = await makeSquad("Squad T", users.leadT.id, []); squads.squadU1 = await makeSquad("Squad U1", users.leadU1.id, [users.req5.id]); squads.squadU2 = await makeSquad("Squad U2", users.leadU2.id, []); // squadL: the leader is also an explicit member so the membership guard passes // before the leader guard fires. squads.squadL = await makeSquad("Squad L", users.leadA.id, [users.leadA.id, users.req1.id]); squads.squadP = await makeSquad("Squad P", null, [users.reqLead.id]); // squadQ: the requester leads the destination side. squads.squadQ = await makeSquad("Squad Q", users.reqLead.id, [users.reqLead.id]); squads.squadM = await makeSquad("Squad M", users.mutedLead.id, []); squads.squadN = await makeSquad("Squad N", null, [users.reqM.id]); squads.squadV1 = await makeSquad("Squad V1", users.leadV1.id, [users.reqV.id]); squads.squadV2 = await makeSquad("Squad V2", null, []); }, TIMEOUT); afterAll(async () => { if (!payload) return; // Notifications and event log rows first — they reference users and transfers. const notifs = await payload .find({ collection: "user-notifications", where: { user: { in: userIds } }, limit: 500, depth: 0, overrideAccess: true, }) .catch(() => null); for (const n of notifs?.docs ?? []) { await payload.delete({ collection: "user-notifications", id: n.id, overrideAccess: true }).catch(() => {}); } const events = await payload .find({ collection: "game-event-logs", where: { or: [ { actor: { in: userIds } }, { and: [{ targetCollection: { equals: "assignment-transfers" } }, { targetId: { in: transferIds } }] }, ], }, limit: 500, depth: 0, overrideAccess: true, }) .catch(() => null); for (const e of events?.docs ?? []) { await payload.delete({ collection: "game-event-logs", id: e.id, overrideAccess: true }).catch(() => {}); } for (const id of transferIds) { await payload.delete({ collection: "assignment-transfers", id, overrideAccess: true }).catch(() => {}); } for (const id of assignmentIds) { await payload.delete({ collection: "assignments", id, overrideAccess: true }).catch(() => {}); } for (const id of userIds) { const profiles = await payload .find({ collection: "profiles", where: { user: { equals: id } }, limit: 5, depth: 0, overrideAccess: true }) .catch(() => null); for (const p of profiles?.docs ?? []) { await payload.delete({ collection: "profiles", id: p.id, overrideAccess: true }).catch(() => {}); } const accounts = await payload .find({ collection: "bank-accounts", where: { ownerUser: { equals: id } }, limit: 5, depth: 0, overrideAccess: true, }) .catch(() => null); for (const a of accounts?.docs ?? []) { await payload.delete({ collection: "bank-accounts", id: a.id, overrideAccess: true }).catch(() => {}); } await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {}); } }); it("completes when both leaders approve and moves membership", async () => { const doc = trackTransfer( await createTransferRequest(payload, { requesterId: users.req1.id, fromAssignmentId: squads.squadA, toAssignmentId: squads.squadB, note: "relocating", }), ); expect(doc.status).toBe("pending"); expect(doc.fromLeaderDecision).toBe("pending"); expect(doc.toLeaderDecision).toBe("pending"); // A non-leader member cannot record a decision. await expect( recordTransferDecision(payload, { requestId: doc.id, side: "from", decision: "approved", actorUserId: users.req1.id, }), ).rejects.toThrow("Only the current leader of"); const afterFrom = await recordTransferDecision(payload, { requestId: doc.id, side: "from", decision: "approved", actorUserId: users.leadA.id, }); expect(afterFrom.status).toBe("pending"); expect((await notificationsFor(users.req1.id, "assignment-transfer:approved")).length).toBeGreaterThan(0); const done = await recordTransferDecision(payload, { requestId: doc.id, side: "to", decision: "approved", actorUserId: users.leadB.id, }); expect(done.status).toBe("completed"); const from = await getAssignment(squads.squadA); const to = await getAssignment(squads.squadB); expect(from.members).not.toContain(users.req1.id); expect(to.members).toContain(users.req1.id); expect((await notificationsFor(users.req1.id, "assignment-transfer:completed")).length).toBeGreaterThan(0); const types = (await eventsFor(doc.id)).map((e) => e.type); expect(types).toContain(EventTypes.AssignmentTransferRequest); expect(types).toContain(EventTypes.AssignmentTransferApprove); expect(types).toContain(EventTypes.AssignmentTransferComplete); }, TIMEOUT); it("rejects with a reason, escalates on appeal, and completes on superuser approval", async () => { const doc = trackTransfer( await createTransferRequest(payload, { requesterId: users.req2.id, fromAssignmentId: squads.squadR1, toAssignmentId: squads.squadR2, }), ); // A rejection without a reason is rejected. await expect( recordTransferDecision(payload, { requestId: doc.id, side: "from", decision: "rejected", actorUserId: users.leadR1.id, }), ).rejects.toThrow("A reason is required when rejecting a transfer request."); const rejected = await recordTransferDecision(payload, { requestId: doc.id, side: "from", decision: "rejected", reason: "no room in the squad", actorUserId: users.leadR1.id, }); expect(rejected.status).toBe("rejected"); expect(rejected.rejectionReason).toBe("no room in the squad"); expect((await notificationsFor(users.req2.id, "assignment-transfer:rejected")).length).toBeGreaterThan(0); // Only the requester can appeal. await expect(appealTransfer(payload, { requestId: doc.id, actorUserId: users.leadR1.id })).rejects.toThrow( "Only the requester can appeal a transfer request.", ); const appealed = await appealTransfer(payload, { requestId: doc.id, actorUserId: users.req2.id, note: "please reconsider", }); expect(appealed.status).toBe("awaiting_superuser"); expect(appealed.escalationReason).toBe("appeal"); expect((await notificationsFor(users.admin.id, "assignment-transfer:escalated")).length).toBeGreaterThan(0); // A second appeal is impossible (no longer rejected). await expect(appealTransfer(payload, { requestId: doc.id, actorUserId: users.req2.id })).rejects.toThrow( "Only rejected requests can be appealed", ); // Non-superusers cannot resolve. await expect(resolveAppeal(payload, { requestId: doc.id, approve: true, actorUserId: users.req2.id })).rejects.toThrow( "Only an admin or developer can resolve a transfer request.", ); const resolved = await resolveAppeal(payload, { requestId: doc.id, approve: true, actorUserId: users.admin.id, note: "approved on appeal", }); expect(resolved.status).toBe("completed"); expect(resolved.resolvedBy).toBe(users.admin.id); const from = await getAssignment(squads.squadR1); const to = await getAssignment(squads.squadR2); expect(from.members).not.toContain(users.req2.id); expect(to.members).toContain(users.req2.id); }, TIMEOUT); it("creates straight into awaiting_superuser when both sides are leaderless", async () => { const doc = trackTransfer( await createTransferRequest(payload, { requesterId: users.req3.id, fromAssignmentId: squads.squadC, toAssignmentId: squads.squadD, }), ); noLeadersDoc = doc; expect(doc.status).toBe("awaiting_superuser"); expect(doc.escalationReason).toBe("no_leaders"); expect(doc.fromLeaderDecision).toBe("not_required"); expect(doc.toLeaderDecision).toBe("not_required"); expect((await notificationsFor(users.admin.id, "assignment-transfer:escalated")).length).toBeGreaterThan(0); }, TIMEOUT); it("superuser denial is terminal", async () => { expect(noLeadersDoc).toBeTruthy(); const denied = await resolveAppeal(payload, { requestId: noLeadersDoc!.id, approve: false, actorUserId: users.admin.id, note: "stay where you are", }); expect(denied.status).toBe("denied"); expect(denied.resolvedBy).toBe(users.admin.id); expect((await notificationsFor(users.req3.id, "assignment-transfer:resolved")).length).toBeGreaterThan(0); // Terminal: no further resolution or appeal is possible. await expect(resolveAppeal(payload, { requestId: denied.id, approve: true, actorUserId: users.admin.id })).rejects.toThrow( "Only escalated requests can be resolved", ); await expect(appealTransfer(payload, { requestId: denied.id, actorUserId: users.req3.id })).rejects.toThrow( "Only rejected requests can be appealed", ); const from = await getAssignment(squads.squadC); expect(from.members).toContain(users.req3.id); }, TIMEOUT); it("completes after destination approval alone when the source has no leader", async () => { const doc = trackTransfer( await createTransferRequest(payload, { requesterId: users.req4.id, fromAssignmentId: squads.squadS1, toAssignmentId: squads.squadT, }), ); expect(doc.status).toBe("pending"); expect(doc.fromLeaderDecision).toBe("not_required"); expect(doc.toLeaderDecision).toBe("pending"); // The leaderless side cannot (and must not) record a decision. await expect( recordTransferDecision(payload, { requestId: doc.id, side: "from", decision: "approved", actorUserId: users.req4.id, }), ).rejects.toThrow("This assignment has no leader, so no decision is required from it."); const done = await recordTransferDecision(payload, { requestId: doc.id, side: "to", decision: "approved", actorUserId: users.leadT.id, }); expect(done.status).toBe("completed"); const from = await getAssignment(squads.squadS1); const to = await getAssignment(squads.squadT); expect(from.members).not.toContain(users.req4.id); expect(to.members).toContain(users.req4.id); }, TIMEOUT); it("cancels a pending request without membership changes", async () => { const doc = trackTransfer( await createTransferRequest(payload, { requesterId: users.req5.id, fromAssignmentId: squads.squadU1, toAssignmentId: squads.squadU2, }), ); // Only the requester can cancel. await expect(cancelTransfer(payload, { requestId: doc.id, actorUserId: users.leadU1.id })).rejects.toThrow( "Only the requester can cancel a transfer request.", ); const cancelled = await cancelTransfer(payload, { requestId: doc.id, actorUserId: users.req5.id }); expect(cancelled.status).toBe("cancelled"); await expect(cancelTransfer(payload, { requestId: doc.id, actorUserId: users.req5.id })).rejects.toThrow( "Only pending requests can be cancelled", ); const from = await getAssignment(squads.squadU1); const to = await getAssignment(squads.squadU2); expect(from.members).toContain(users.req5.id); expect(to.members).not.toContain(users.req5.id); }, TIMEOUT); it("enforces creation guards", async () => { // Non-member requester. await expect( createTransferRequest(payload, { requesterId: users.outsider.id, fromAssignmentId: squads.squadA, toAssignmentId: squads.squadB, }), ).rejects.toThrow("You are not a member of the source assignment."); // Same assignment on both sides. (req1 sits in squadB after the first test.) await expect( createTransferRequest(payload, { requesterId: users.req1.id, fromAssignmentId: squads.squadB, toAssignmentId: squads.squadB, }), ).rejects.toThrow("The source and destination assignments must be different."); // Requester is the leader of the source side. await expect( createTransferRequest(payload, { requesterId: users.leadA.id, fromAssignmentId: squads.squadL, toAssignmentId: squads.squadB, }), ).rejects.toThrow("Leaders cannot request transfers"); // Requester is the leader of the destination side. await expect( createTransferRequest(payload, { requesterId: users.reqLead.id, fromAssignmentId: squads.squadP, toAssignmentId: squads.squadQ, }), ).rejects.toThrow("Leaders cannot request transfers"); }, TIMEOUT); it("blocks a second open request to the same destination and allows one after cancellation", async () => { const first = trackTransfer( await createTransferRequest(payload, { requesterId: users.req1.id, fromAssignmentId: squads.squadB, toAssignmentId: squads.squadA, }), ); await expect( createTransferRequest(payload, { requesterId: users.req1.id, fromAssignmentId: squads.squadB, toAssignmentId: squads.squadA, }), ).rejects.toThrow("You already have an open transfer request to this assignment."); const cancelled = await cancelTransfer(payload, { requestId: first.id, actorUserId: users.req1.id }); expect(cancelled.status).toBe("cancelled"); // Cancelled is not "open" — a fresh request to the same destination is allowed again. const second = trackTransfer( await createTransferRequest(payload, { requesterId: users.req1.id, fromAssignmentId: squads.squadB, toAssignmentId: squads.squadA, }), ); expect(second.status).toBe("pending"); await cancelTransfer(payload, { requestId: second.id, actorUserId: users.req1.id }); }, TIMEOUT); it("escalates when leadership changes mid-pending", async () => { const doc = trackTransfer( await createTransferRequest(payload, { requesterId: users.reqV.id, fromAssignmentId: squads.squadV1, toAssignmentId: squads.squadV2, }), ); expect(doc.status).toBe("pending"); // The source gets a new leader — the former one can no longer decide. await payload.update({ collection: "assignments", id: squads.squadV1, data: { leader: users.leadC.id }, overrideAccess: true, }); await expect( recordTransferDecision(payload, { requestId: doc.id, side: "from", decision: "approved", actorUserId: users.leadV1.id, }), ).rejects.toThrow("Only the current leader of"); // ...and when the side is left leaderless entirely, a decision attempt escalates // instead of deadlocking. await payload.update({ collection: "assignments", id: squads.squadV1, data: { leader: null }, overrideAccess: true }); const escalated = await recordTransferDecision(payload, { requestId: doc.id, side: "from", decision: "approved", actorUserId: users.leadC.id, }); expect(escalated.status).toBe("awaiting_superuser"); expect(escalated.escalationReason).toBe("leader_changed"); expect((await notificationsFor(users.admin.id, "assignment-transfer:escalated")).length).toBeGreaterThan(0); }, TIMEOUT); it("delivers transfer notifications to muted users but suppresses ordinary ones", async () => { const doc = trackTransfer( await createTransferRequest(payload, { requesterId: users.reqM.id, fromAssignmentId: squads.squadN, toAssignmentId: squads.squadM, }), ); expect(doc.status).toBe("pending"); // The destination leader has preferences.notifications.mutedAll = true, yet the // workflow-critical request notification (sent with bypassMute) still lands. const delivered = await notificationsFor(users.mutedLead.id, "assignment-transfer:request"); expect(delivered.length).toBeGreaterThan(0); // A non-transfer notification sent without bypass is suppressed by the mute. await notifyUser(payload, { userId: users.mutedLead.id, type: "market:offer", title: "Mute suppression check" }); const suppressed = await notificationsFor(users.mutedLead.id, "market:offer"); expect(suppressed.length).toBe(0); }, TIMEOUT); });