310 lines
9.3 KiB
JavaScript
310 lines
9.3 KiB
JavaScript
jest.mock("../models/group-invites.model", () => ({
|
|
addGroupMember: jest.fn(),
|
|
createGroupAuditLog: jest.fn(),
|
|
createInviteLink: jest.fn(),
|
|
createOrTouchPendingJoinRequest: jest.fn(),
|
|
consumeSingleUseInvite: jest.fn(),
|
|
deleteInviteLink: jest.fn(),
|
|
getGroupById: jest.fn(),
|
|
getGroupSettings: jest.fn(),
|
|
getInviteLinkById: jest.fn(),
|
|
getInviteLinkSummaryByToken: jest.fn(),
|
|
getManageableGroupsForUser: jest.fn(),
|
|
getPendingJoinRequestById: jest.fn(),
|
|
getPendingJoinRequest: jest.fn(),
|
|
getUserGroupRole: jest.fn(),
|
|
isGroupMember: jest.fn(),
|
|
listPendingJoinRequests: jest.fn(),
|
|
listInviteLinks: jest.fn(),
|
|
revokeInviteLink: jest.fn(),
|
|
reviveInviteLink: jest.fn(),
|
|
updateJoinRequestDecision: jest.fn(),
|
|
upsertGroupSettings: jest.fn(),
|
|
withTransaction: jest.fn(),
|
|
}));
|
|
|
|
const invitesModel = require("../models/group-invites.model");
|
|
const invitesService = require("../services/group-invites.service");
|
|
|
|
function inviteSummary(overrides = {}) {
|
|
return {
|
|
id: 30,
|
|
group_id: 10,
|
|
group_name: "Test Group",
|
|
token: "1234567890abcdef1234567890fedcba",
|
|
policy: "AUTO_ACCEPT",
|
|
current_join_policy: "AUTO_ACCEPT",
|
|
single_use: false,
|
|
expires_at: "2030-01-01T00:00:00.000Z",
|
|
used_at: null,
|
|
revoked_at: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("group invites service", () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
invitesModel.withTransaction.mockImplementation(async (handler) => handler({}));
|
|
});
|
|
|
|
test("create link success writes audit with request_id and token last4 only", async () => {
|
|
invitesModel.getGroupById.mockResolvedValue({ id: 1, name: "G1" });
|
|
invitesModel.getUserGroupRole.mockResolvedValue("admin");
|
|
invitesModel.createInviteLink.mockResolvedValue({
|
|
id: 55,
|
|
group_id: 1,
|
|
token: "1234567890abcdef1234567890fedcba",
|
|
policy: "AUTO_ACCEPT",
|
|
single_use: true,
|
|
expires_at: "2030-01-01T00:00:00.000Z",
|
|
created_at: "2026-01-01T00:00:00.000Z",
|
|
});
|
|
|
|
const link = await invitesService.createInviteLink(
|
|
7,
|
|
1,
|
|
"AUTO_ACCEPT",
|
|
true,
|
|
"2030-01-01T00:00:00.000Z",
|
|
"req-123",
|
|
"127.0.0.1",
|
|
"ua"
|
|
);
|
|
|
|
expect(link.id).toBe(55);
|
|
expect(invitesModel.createGroupAuditLog).toHaveBeenCalledTimes(1);
|
|
const auditPayload = invitesModel.createGroupAuditLog.mock.calls[0][0];
|
|
expect(auditPayload.requestId).toBe("req-123");
|
|
expect(auditPayload.metadata).toEqual({ inviteCodeLast4: "dcba" });
|
|
});
|
|
|
|
test("accept auto-accept adds membership", async () => {
|
|
invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(inviteSummary());
|
|
invitesModel.isGroupMember.mockResolvedValue(false);
|
|
invitesModel.getPendingJoinRequest.mockResolvedValue(null);
|
|
invitesModel.getUserGroupRole.mockResolvedValue(null);
|
|
invitesModel.addGroupMember.mockResolvedValue(true);
|
|
|
|
const result = await invitesService.acceptInviteLink(
|
|
99,
|
|
"token-1",
|
|
"req-1",
|
|
"127.0.0.1",
|
|
"ua"
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
status: "JOINED",
|
|
group: { id: 10, name: "Test Group" },
|
|
});
|
|
expect(invitesModel.addGroupMember).toHaveBeenCalled();
|
|
expect(invitesModel.createGroupAuditLog.mock.calls[0][0].eventType).toBe(
|
|
"GROUP_INVITE_USED"
|
|
);
|
|
});
|
|
|
|
test("accept manual policy creates pending request", async () => {
|
|
invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(
|
|
inviteSummary({
|
|
current_join_policy: "APPROVAL_REQUIRED",
|
|
single_use: true,
|
|
})
|
|
);
|
|
invitesModel.isGroupMember.mockResolvedValue(false);
|
|
invitesModel.getPendingJoinRequest.mockResolvedValue(null);
|
|
invitesModel.getUserGroupRole.mockResolvedValue(null);
|
|
invitesModel.createOrTouchPendingJoinRequest.mockResolvedValue({
|
|
id: 1,
|
|
status: "PENDING",
|
|
});
|
|
|
|
const result = await invitesService.acceptInviteLink(
|
|
99,
|
|
"token-2",
|
|
"req-2",
|
|
"127.0.0.1",
|
|
"ua"
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
status: "PENDING",
|
|
group: { id: 10, name: "Test Group" },
|
|
});
|
|
expect(invitesModel.createOrTouchPendingJoinRequest).toHaveBeenCalled();
|
|
expect(invitesModel.consumeSingleUseInvite).toHaveBeenCalledWith(30, {});
|
|
expect(invitesModel.createGroupAuditLog.mock.calls[0][0].eventType).toBe(
|
|
"GROUP_INVITE_REQUESTED"
|
|
);
|
|
});
|
|
|
|
test.each([
|
|
["INVITE_EXPIRED", inviteSummary({ expires_at: "2020-01-01T00:00:00.000Z" })],
|
|
["INVITE_REVOKED", inviteSummary({ revoked_at: "2026-01-01T00:00:00.000Z" })],
|
|
[
|
|
"INVITE_USED",
|
|
inviteSummary({ single_use: true, used_at: "2026-01-01T00:00:00.000Z" }),
|
|
],
|
|
])("rejects %s links", async (expectedCode, summary) => {
|
|
invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(summary);
|
|
invitesModel.isGroupMember.mockResolvedValue(false);
|
|
invitesModel.getPendingJoinRequest.mockResolvedValue(null);
|
|
|
|
await expect(
|
|
invitesService.acceptInviteLink(99, "token-3", "req-3", "127.0.0.1", "ua")
|
|
).rejects.toMatchObject({ code: expectedCode });
|
|
});
|
|
|
|
test("accept returns ALREADY_MEMBER before pending checks", async () => {
|
|
invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(inviteSummary());
|
|
invitesModel.isGroupMember.mockResolvedValue(true);
|
|
|
|
const result = await invitesService.acceptInviteLink(
|
|
99,
|
|
"token-4",
|
|
"req-4",
|
|
"127.0.0.1",
|
|
"ua"
|
|
);
|
|
|
|
expect(result.status).toBe("ALREADY_MEMBER");
|
|
expect(invitesModel.getPendingJoinRequest).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("accept returns PENDING when request already exists", async () => {
|
|
invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(inviteSummary());
|
|
invitesModel.isGroupMember.mockResolvedValue(false);
|
|
invitesModel.getPendingJoinRequest.mockResolvedValue({
|
|
id: 5,
|
|
status: "PENDING",
|
|
});
|
|
|
|
const result = await invitesService.acceptInviteLink(
|
|
99,
|
|
"token-5",
|
|
"req-5",
|
|
"127.0.0.1",
|
|
"ua"
|
|
);
|
|
|
|
expect(result.status).toBe("PENDING");
|
|
expect(invitesModel.addGroupMember).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("listPendingJoinRequests requires manager role and returns pending requests", async () => {
|
|
invitesModel.getGroupById.mockResolvedValue({ id: 10, name: "Test Group" });
|
|
invitesModel.getUserGroupRole.mockResolvedValue("owner");
|
|
invitesModel.listPendingJoinRequests.mockResolvedValue([
|
|
{ id: 12, user_id: 88, username: "pending-user", status: "PENDING" },
|
|
]);
|
|
|
|
const result = await invitesService.listPendingJoinRequests(99, 10);
|
|
|
|
expect(invitesModel.listPendingJoinRequests).toHaveBeenCalledWith(10);
|
|
expect(result).toEqual([
|
|
{ id: 12, user_id: 88, username: "pending-user", status: "PENDING" },
|
|
]);
|
|
});
|
|
|
|
test("approve join request adds membership, updates request, and audits decision", async () => {
|
|
invitesModel.getGroupById.mockResolvedValue({ id: 10, name: "Test Group" });
|
|
invitesModel.getUserGroupRole.mockResolvedValue("admin");
|
|
invitesModel.getPendingJoinRequestById.mockResolvedValue({
|
|
id: 77,
|
|
group_id: 10,
|
|
user_id: 55,
|
|
username: "pending-user",
|
|
status: "PENDING",
|
|
});
|
|
invitesModel.isGroupMember.mockResolvedValue(false);
|
|
invitesModel.addGroupMember.mockResolvedValue(true);
|
|
invitesModel.updateJoinRequestDecision.mockResolvedValue({
|
|
id: 77,
|
|
group_id: 10,
|
|
user_id: 55,
|
|
status: "APPROVED",
|
|
decided_by: 99,
|
|
});
|
|
|
|
const result = await invitesService.decideJoinRequest(
|
|
99,
|
|
10,
|
|
77,
|
|
"APPROVE",
|
|
"req-approve",
|
|
"127.0.0.1",
|
|
"ua"
|
|
);
|
|
|
|
expect(invitesModel.getPendingJoinRequestById).toHaveBeenCalledWith(
|
|
10,
|
|
77,
|
|
{},
|
|
true
|
|
);
|
|
expect(invitesModel.addGroupMember).toHaveBeenCalledWith(10, 55, "member", {});
|
|
expect(invitesModel.updateJoinRequestDecision).toHaveBeenCalledWith(
|
|
10,
|
|
77,
|
|
"APPROVED",
|
|
99,
|
|
{}
|
|
);
|
|
expect(invitesModel.createGroupAuditLog.mock.calls[0][0]).toMatchObject({
|
|
eventType: "GROUP_JOIN_REQUEST_APPROVED",
|
|
requestId: "req-approve",
|
|
metadata: {
|
|
joinRequestId: 77,
|
|
targetUserId: 55,
|
|
},
|
|
});
|
|
expect(result.status).toBe("APPROVED");
|
|
});
|
|
|
|
test("deny join request updates request and audits decision", async () => {
|
|
invitesModel.getGroupById.mockResolvedValue({ id: 10, name: "Test Group" });
|
|
invitesModel.getUserGroupRole.mockResolvedValue("owner");
|
|
invitesModel.getPendingJoinRequestById.mockResolvedValue({
|
|
id: 78,
|
|
group_id: 10,
|
|
user_id: 56,
|
|
status: "PENDING",
|
|
});
|
|
invitesModel.updateJoinRequestDecision.mockResolvedValue({
|
|
id: 78,
|
|
group_id: 10,
|
|
user_id: 56,
|
|
status: "DENIED",
|
|
decided_by: 99,
|
|
});
|
|
|
|
const result = await invitesService.decideJoinRequest(
|
|
99,
|
|
10,
|
|
78,
|
|
"DENY",
|
|
"req-deny",
|
|
"127.0.0.1",
|
|
"ua"
|
|
);
|
|
|
|
expect(invitesModel.addGroupMember).not.toHaveBeenCalled();
|
|
expect(invitesModel.updateJoinRequestDecision).toHaveBeenCalledWith(
|
|
10,
|
|
78,
|
|
"DENIED",
|
|
99,
|
|
{}
|
|
);
|
|
expect(invitesModel.createGroupAuditLog.mock.calls[0][0]).toMatchObject({
|
|
eventType: "GROUP_JOIN_REQUEST_DENIED",
|
|
requestId: "req-deny",
|
|
metadata: {
|
|
joinRequestId: 78,
|
|
targetUserId: 56,
|
|
},
|
|
});
|
|
expect(result.status).toBe("DENIED");
|
|
});
|
|
});
|