feat: add invite link approval management
This commit is contained in:
parent
4aff7e78f2
commit
9bdf2247f4
@ -79,6 +79,20 @@ exports.createInviteLink = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.listPendingJoinRequests = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const requestedGroupId = parseRequestedGroupId(req);
|
||||||
|
const groupId = await invitesService.resolveManagedGroupId(
|
||||||
|
req.user.id,
|
||||||
|
requestedGroupId
|
||||||
|
);
|
||||||
|
const requests = await invitesService.listPendingJoinRequests(req.user.id, groupId);
|
||||||
|
res.json({ requests });
|
||||||
|
} catch (error) {
|
||||||
|
return mapServiceError(req, res, error, "groupInvites.listPendingJoinRequests");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
exports.revokeInviteLink = async (req, res) => {
|
exports.revokeInviteLink = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const requestedGroupId = parseRequestedGroupId(req);
|
const requestedGroupId = parseRequestedGroupId(req);
|
||||||
@ -180,6 +194,28 @@ exports.setJoinPolicy = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.decideJoinRequest = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const requestedGroupId = parseRequestedGroupId(req);
|
||||||
|
const groupId = await invitesService.resolveManagedGroupId(
|
||||||
|
req.user.id,
|
||||||
|
requestedGroupId
|
||||||
|
);
|
||||||
|
const decision = await invitesService.decideJoinRequest(
|
||||||
|
req.user.id,
|
||||||
|
groupId,
|
||||||
|
req.body?.requestId,
|
||||||
|
req.body?.decision,
|
||||||
|
req.request_id,
|
||||||
|
getClientIp(req),
|
||||||
|
req.headers["user-agent"] || null
|
||||||
|
);
|
||||||
|
res.json({ request: decision });
|
||||||
|
} catch (error) {
|
||||||
|
return mapServiceError(req, res, error, "groupInvites.decideJoinRequest");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
exports.getInviteLinkSummary = async (req, res) => {
|
exports.getInviteLinkSummary = async (req, res) => {
|
||||||
const token = req.params.token;
|
const token = req.params.token;
|
||||||
const inviteLast4 = inviteCodeLast4(token);
|
const inviteLast4 = inviteCodeLast4(token);
|
||||||
|
|||||||
@ -237,6 +237,53 @@ async function getPendingJoinRequest(groupId, userId, client) {
|
|||||||
return result.rows[0] || null;
|
return result.rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function listPendingJoinRequests(groupId, client) {
|
||||||
|
const result = await getExecutor(client).query(
|
||||||
|
`SELECT
|
||||||
|
gjr.id,
|
||||||
|
gjr.group_id,
|
||||||
|
gjr.user_id,
|
||||||
|
gjr.status,
|
||||||
|
gjr.created_at,
|
||||||
|
gjr.updated_at,
|
||||||
|
u.username,
|
||||||
|
u.name,
|
||||||
|
u.display_name
|
||||||
|
FROM group_join_requests gjr
|
||||||
|
JOIN users u ON u.id = gjr.user_id
|
||||||
|
WHERE gjr.group_id = $1
|
||||||
|
AND gjr.status = 'PENDING'
|
||||||
|
ORDER BY gjr.created_at ASC`,
|
||||||
|
[groupId]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPendingJoinRequestById(groupId, requestId, client, forUpdate = false) {
|
||||||
|
const result = await getExecutor(client).query(
|
||||||
|
`SELECT
|
||||||
|
gjr.id,
|
||||||
|
gjr.group_id,
|
||||||
|
gjr.user_id,
|
||||||
|
gjr.status,
|
||||||
|
gjr.decided_by,
|
||||||
|
gjr.decided_at,
|
||||||
|
gjr.created_at,
|
||||||
|
gjr.updated_at,
|
||||||
|
u.username,
|
||||||
|
u.name,
|
||||||
|
u.display_name
|
||||||
|
FROM group_join_requests gjr
|
||||||
|
JOIN users u ON u.id = gjr.user_id
|
||||||
|
WHERE gjr.group_id = $1
|
||||||
|
AND gjr.id = $2
|
||||||
|
AND gjr.status = 'PENDING'
|
||||||
|
${forUpdate ? "FOR UPDATE OF gjr" : ""}`,
|
||||||
|
[groupId, requestId]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
async function createOrTouchPendingJoinRequest(groupId, userId, client) {
|
async function createOrTouchPendingJoinRequest(groupId, userId, client) {
|
||||||
const executor = getExecutor(client);
|
const executor = getExecutor(client);
|
||||||
const existing = await executor.query(
|
const existing = await executor.query(
|
||||||
@ -277,6 +324,22 @@ async function createOrTouchPendingJoinRequest(groupId, userId, client) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateJoinRequestDecision(groupId, requestId, status, decidedBy, client) {
|
||||||
|
const result = await getExecutor(client).query(
|
||||||
|
`UPDATE group_join_requests
|
||||||
|
SET status = $3,
|
||||||
|
decided_by = $4,
|
||||||
|
decided_at = NOW(),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE group_id = $1
|
||||||
|
AND id = $2
|
||||||
|
AND status = 'PENDING'
|
||||||
|
RETURNING id, group_id, user_id, status, decided_by, decided_at, created_at, updated_at`,
|
||||||
|
[groupId, requestId, status, decidedBy]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
async function addGroupMember(groupId, userId, role = "member", client) {
|
async function addGroupMember(groupId, userId, role = "member", client) {
|
||||||
const result = await getExecutor(client).query(
|
const result = await getExecutor(client).query(
|
||||||
`INSERT INTO household_members (household_id, user_id, role)
|
`INSERT INTO household_members (household_id, user_id, role)
|
||||||
@ -381,12 +444,15 @@ module.exports = {
|
|||||||
getInviteLinkById,
|
getInviteLinkById,
|
||||||
getInviteLinkSummaryByToken,
|
getInviteLinkSummaryByToken,
|
||||||
getManageableGroupsForUser,
|
getManageableGroupsForUser,
|
||||||
|
getPendingJoinRequestById,
|
||||||
getPendingJoinRequest,
|
getPendingJoinRequest,
|
||||||
getUserGroupRole,
|
getUserGroupRole,
|
||||||
isGroupMember,
|
isGroupMember,
|
||||||
|
listPendingJoinRequests,
|
||||||
listInviteLinks,
|
listInviteLinks,
|
||||||
revokeInviteLink,
|
revokeInviteLink,
|
||||||
reviveInviteLink,
|
reviveInviteLink,
|
||||||
|
updateJoinRequestDecision,
|
||||||
upsertGroupSettings,
|
upsertGroupSettings,
|
||||||
withTransaction,
|
withTransaction,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -28,6 +28,13 @@ const inviteWriteUserRateLimit = createRateLimit({
|
|||||||
|
|
||||||
router.get("/groups/invites", auth, controller.listInviteLinks);
|
router.get("/groups/invites", auth, controller.listInviteLinks);
|
||||||
router.post("/groups/invites", auth, inviteWriteUserRateLimit, controller.createInviteLink);
|
router.post("/groups/invites", auth, inviteWriteUserRateLimit, controller.createInviteLink);
|
||||||
|
router.get("/groups/join-requests", auth, controller.listPendingJoinRequests);
|
||||||
|
router.post(
|
||||||
|
"/groups/join-requests/decision",
|
||||||
|
auth,
|
||||||
|
inviteWriteUserRateLimit,
|
||||||
|
controller.decideJoinRequest
|
||||||
|
);
|
||||||
router.post(
|
router.post(
|
||||||
"/groups/invites/revoke",
|
"/groups/invites/revoke",
|
||||||
auth,
|
auth,
|
||||||
|
|||||||
@ -179,6 +179,12 @@ async function listInviteLinks(userId, groupId) {
|
|||||||
return invitesModel.listInviteLinks(resolvedGroupId);
|
return invitesModel.listInviteLinks(resolvedGroupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function listPendingJoinRequests(userId, groupId) {
|
||||||
|
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
|
||||||
|
await ensureGroupAndManagerRole(userId, resolvedGroupId);
|
||||||
|
return invitesModel.listPendingJoinRequests(resolvedGroupId);
|
||||||
|
}
|
||||||
|
|
||||||
async function revokeInviteLink(
|
async function revokeInviteLink(
|
||||||
userId,
|
userId,
|
||||||
groupId,
|
groupId,
|
||||||
@ -314,6 +320,116 @@ async function deleteInviteLink(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function decideJoinRequest(
|
||||||
|
userId,
|
||||||
|
groupId,
|
||||||
|
requestId,
|
||||||
|
decision,
|
||||||
|
requestIdForAudit,
|
||||||
|
ip,
|
||||||
|
userAgent
|
||||||
|
) {
|
||||||
|
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
|
||||||
|
const resolvedRequestId = ensurePositiveInteger(requestId, "requestId");
|
||||||
|
const normalizedDecision = typeof decision === "string" ? decision.trim().toUpperCase() : "";
|
||||||
|
|
||||||
|
if (!["APPROVE", "DENY"].includes(normalizedDecision)) {
|
||||||
|
throw new InviteServiceError("INVALID_INPUT", "Decision is required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return invitesModel.withTransaction(async (client) => {
|
||||||
|
const { actorRole } = await ensureGroupAndManagerRole(
|
||||||
|
userId,
|
||||||
|
resolvedGroupId,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
const pendingRequest = await invitesModel.getPendingJoinRequestById(
|
||||||
|
resolvedGroupId,
|
||||||
|
resolvedRequestId,
|
||||||
|
client,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!pendingRequest) {
|
||||||
|
throw new InviteServiceError(
|
||||||
|
"JOIN_REQUEST_NOT_FOUND",
|
||||||
|
"Pending join request not found",
|
||||||
|
404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedDecision === "APPROVE") {
|
||||||
|
const isExistingMember = await invitesModel.isGroupMember(
|
||||||
|
resolvedGroupId,
|
||||||
|
pendingRequest.user_id,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
if (!isExistingMember) {
|
||||||
|
await invitesModel.addGroupMember(
|
||||||
|
resolvedGroupId,
|
||||||
|
pendingRequest.user_id,
|
||||||
|
"member",
|
||||||
|
client
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const approvedRequest = await invitesModel.updateJoinRequestDecision(
|
||||||
|
resolvedGroupId,
|
||||||
|
resolvedRequestId,
|
||||||
|
"APPROVED",
|
||||||
|
userId,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
|
||||||
|
await invitesModel.createGroupAuditLog(
|
||||||
|
{
|
||||||
|
groupId: resolvedGroupId,
|
||||||
|
actorUserId: userId,
|
||||||
|
actorRole,
|
||||||
|
eventType: "GROUP_JOIN_REQUEST_APPROVED",
|
||||||
|
requestId: requestIdForAudit,
|
||||||
|
ip: normalizeIp(ip),
|
||||||
|
userAgent: userAgent || null,
|
||||||
|
metadata: {
|
||||||
|
joinRequestId: approvedRequest.id,
|
||||||
|
targetUserId: approvedRequest.user_id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
client
|
||||||
|
);
|
||||||
|
|
||||||
|
return approvedRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deniedRequest = await invitesModel.updateJoinRequestDecision(
|
||||||
|
resolvedGroupId,
|
||||||
|
resolvedRequestId,
|
||||||
|
"DENIED",
|
||||||
|
userId,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
|
||||||
|
await invitesModel.createGroupAuditLog(
|
||||||
|
{
|
||||||
|
groupId: resolvedGroupId,
|
||||||
|
actorUserId: userId,
|
||||||
|
actorRole,
|
||||||
|
eventType: "GROUP_JOIN_REQUEST_DENIED",
|
||||||
|
requestId: requestIdForAudit,
|
||||||
|
ip: normalizeIp(ip),
|
||||||
|
userAgent: userAgent || null,
|
||||||
|
metadata: {
|
||||||
|
joinRequestId: deniedRequest.id,
|
||||||
|
targetUserId: deniedRequest.user_id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
client
|
||||||
|
);
|
||||||
|
|
||||||
|
return deniedRequest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getInviteStatus(link) {
|
function getInviteStatus(link) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (link.single_use && link.used_at) return "USED";
|
if (link.single_use && link.used_at) return "USED";
|
||||||
@ -546,9 +662,11 @@ module.exports = {
|
|||||||
JOIN_RESULTS,
|
JOIN_RESULTS,
|
||||||
acceptInviteLink,
|
acceptInviteLink,
|
||||||
createInviteLink,
|
createInviteLink,
|
||||||
|
decideJoinRequest,
|
||||||
deleteInviteLink,
|
deleteInviteLink,
|
||||||
getGroupJoinPolicy,
|
getGroupJoinPolicy,
|
||||||
getInviteLinkSummaryByToken,
|
getInviteLinkSummaryByToken,
|
||||||
|
listPendingJoinRequests,
|
||||||
listInviteLinks,
|
listInviteLinks,
|
||||||
resolveManagedGroupId,
|
resolveManagedGroupId,
|
||||||
revokeInviteLink,
|
revokeInviteLink,
|
||||||
|
|||||||
@ -12,8 +12,10 @@ jest.mock("../services/group-invites.service", () => {
|
|||||||
acceptInviteLink: jest.fn(),
|
acceptInviteLink: jest.fn(),
|
||||||
createInviteLink: jest.fn(),
|
createInviteLink: jest.fn(),
|
||||||
deleteInviteLink: jest.fn(),
|
deleteInviteLink: jest.fn(),
|
||||||
|
decideJoinRequest: jest.fn(),
|
||||||
getGroupJoinPolicy: jest.fn(),
|
getGroupJoinPolicy: jest.fn(),
|
||||||
getInviteLinkSummaryByToken: jest.fn(),
|
getInviteLinkSummaryByToken: jest.fn(),
|
||||||
|
listPendingJoinRequests: jest.fn(),
|
||||||
listInviteLinks: jest.fn(),
|
listInviteLinks: jest.fn(),
|
||||||
resolveManagedGroupId: jest.fn(),
|
resolveManagedGroupId: jest.fn(),
|
||||||
revokeInviteLink: jest.fn(),
|
revokeInviteLink: jest.fn(),
|
||||||
@ -28,8 +30,10 @@ const app = require("../app");
|
|||||||
|
|
||||||
describe("group invites routes", () => {
|
describe("group invites routes", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
invitesService.resolveManagedGroupId.mockResolvedValue(1);
|
invitesService.resolveManagedGroupId.mockResolvedValue(1);
|
||||||
invitesService.listInviteLinks.mockResolvedValue([]);
|
invitesService.listInviteLinks.mockResolvedValue([]);
|
||||||
|
invitesService.listPendingJoinRequests.mockResolvedValue([]);
|
||||||
invitesService.createInviteLink.mockResolvedValue({
|
invitesService.createInviteLink.mockResolvedValue({
|
||||||
id: 1,
|
id: 1,
|
||||||
token: "abcd",
|
token: "abcd",
|
||||||
@ -71,4 +75,36 @@ describe("group invites routes", () => {
|
|||||||
expect(response.body.request_id).toBeTruthy();
|
expect(response.body.request_id).toBeTruthy();
|
||||||
expect(response.body.link).toBeTruthy();
|
expect(response.body.link).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("pending join requests can be listed with request_id", async () => {
|
||||||
|
invitesService.listPendingJoinRequests.mockResolvedValue([
|
||||||
|
{ id: 12, user_id: 77, username: "pending-user", status: "PENDING" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response = await request(app).get("/api/groups/join-requests");
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.request_id).toBeTruthy();
|
||||||
|
expect(response.body.requests).toEqual([
|
||||||
|
{ id: 12, user_id: 77, username: "pending-user", status: "PENDING" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("decision route maps service validation errors", async () => {
|
||||||
|
invitesService.decideJoinRequest.mockRejectedValue(
|
||||||
|
new invitesService.InviteServiceError(
|
||||||
|
"JOIN_REQUEST_NOT_FOUND",
|
||||||
|
"Pending join request not found",
|
||||||
|
404
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post("/api/groups/join-requests/decision")
|
||||||
|
.send({ requestId: 99, decision: "APPROVE" });
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body.request_id).toBeTruthy();
|
||||||
|
expect(response.body.error.code).toBe("JOIN_REQUEST_NOT_FOUND");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,12 +10,15 @@ jest.mock("../models/group-invites.model", () => ({
|
|||||||
getInviteLinkById: jest.fn(),
|
getInviteLinkById: jest.fn(),
|
||||||
getInviteLinkSummaryByToken: jest.fn(),
|
getInviteLinkSummaryByToken: jest.fn(),
|
||||||
getManageableGroupsForUser: jest.fn(),
|
getManageableGroupsForUser: jest.fn(),
|
||||||
|
getPendingJoinRequestById: jest.fn(),
|
||||||
getPendingJoinRequest: jest.fn(),
|
getPendingJoinRequest: jest.fn(),
|
||||||
getUserGroupRole: jest.fn(),
|
getUserGroupRole: jest.fn(),
|
||||||
isGroupMember: jest.fn(),
|
isGroupMember: jest.fn(),
|
||||||
|
listPendingJoinRequests: jest.fn(),
|
||||||
listInviteLinks: jest.fn(),
|
listInviteLinks: jest.fn(),
|
||||||
revokeInviteLink: jest.fn(),
|
revokeInviteLink: jest.fn(),
|
||||||
reviveInviteLink: jest.fn(),
|
reviveInviteLink: jest.fn(),
|
||||||
|
updateJoinRequestDecision: jest.fn(),
|
||||||
upsertGroupSettings: jest.fn(),
|
upsertGroupSettings: jest.fn(),
|
||||||
withTransaction: jest.fn(),
|
withTransaction: jest.fn(),
|
||||||
}));
|
}));
|
||||||
@ -41,6 +44,7 @@ function inviteSummary(overrides = {}) {
|
|||||||
|
|
||||||
describe("group invites service", () => {
|
describe("group invites service", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
invitesModel.withTransaction.mockImplementation(async (handler) => handler({}));
|
invitesModel.withTransaction.mockImplementation(async (handler) => handler({}));
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -186,4 +190,120 @@ describe("group invites service", () => {
|
|||||||
expect(result.status).toBe("PENDING");
|
expect(result.status).toBe("PENDING");
|
||||||
expect(invitesModel.addGroupMember).not.toHaveBeenCalled();
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user