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.
569 lines
21 KiB
TypeScript
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);
|
|
});
|