1
0
Fork 0
polaris-task-force/tests/int/transfer-requests.int.spec.ts
Z8MB1E 76e948fd59 feat: Add remaining transfer components and utilities
Add transfer workflow components and utilities:

1. Transfer types and core functions (src/lib/transfers/index.ts):
   - Type-safe transfer request and decision creation
   - Appeal, cancel, and resolve core functions
   - Server action conventions with error handling

2. Transfer UI components (src/components/frontend/transfers/):
   - TransferActionDialog.tsx: Handle approval/rejection appeals
   - TransferApprovalCard.tsx: Display approval decisions
   - TransferNoteDialog.tsx: Add notes for transfers/appeals
   - TransferRequestCard.tsx: Show transfer request details
   - TransferResolutionCard.tsx: Display superuser resolutions
   - TransfersView.tsx: Main transfers list page
   - transferTypes.ts: Shared TypeScript types

3. Notification types (src/lib/transfers/notificationTypes.ts):
   - Define notification type constants for transfer events
   - Include TRANSFER_NOTIFICATION_TYPES for bridge filtering

4. Integration tests (tests/int/):
   - transfer-requests.int.spec.ts: Test bot request polling
   - transfer-bot-delivery.int.spec.ts: Test Discord notification delivery

These components and utilities complete the transfer workflow
implementation including approvals, appeals, and delivery.
2026-08-31 23:41:29 -04:00

569 lines
21 KiB
TypeScript

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<string, User> = {};
const squads: Record<string, number> = {};
const userIds: number[] = [];
const assignmentIds: number[] = [];
const transferIds: number[] = [];
let noLeadersDoc: AssignmentTransfer | null = null;
const makeUser = async (
label: string,
roles: RoleName[] = ["user"],
extra?: Partial<User>,
): Promise<User> => {
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<number> => {
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);
});