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(), getPendingJoinRequest: jest.fn(), getUserGroupRole: jest.fn(), isGroupMember: jest.fn(), listInviteLinks: jest.fn(), revokeInviteLink: jest.fn(), reviveInviteLink: 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(() => { 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(); }); });