grocery-app/backend/tests/group-invites.service.test.js

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");
});
});