Skip to content

Commit cda74a1

Browse files
test(organizations): membership lifecycle HTTP e2e (add/accept/promote/leave/decline) (#3860)
* test(organizations): member-add lifecycle http e2e Covers POST /members, the GET /members/search before-:memberId route ordering, the auth-only accept and decline endpoints, promote via the :memberId param middleware, leave, pending owner_add visibility in the members list, and the 422 duplicate-add guard — all through real routes, passport, CASL, and param middleware. Restores the HTTP coverage lost when the invite-lifecycle e2e was removed. refs #3835 * test(organizations): harden memberAdd e2e against dirty-db + false-green - unique per-run email suffixes (Date.now) eliminate dirty-db re-run flakiness - config set in beforeAll, restored in afterAll; test-2 guards shared state with clear expect() - duplicate-add guard chains .expect(422) so a 500 is no longer swallowed - post-leave / post-decline assertions use identity (no B-row) instead of bare counts - decline DELETE asserts body.message === 'membership invitation declined' refs #3835 * docs(test): add @returns + clarify test-order comment on memberadd e2e Documents cleanupUser's async return and rewrites the misleading --runInBand comment (it schedules files, not test() order). refs #3835
1 parent 12b961c commit cda74a1

1 file changed

Lines changed: 323 additions & 0 deletions

File tree

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
/**
2+
* @desc E2E tests for the owner-add membership lifecycle through real HTTP routes
3+
* (passport + CASL + param middleware): owner adds a member → pending row visible
4+
* in the members list → invited user accepts → owner promotes → member leaves.
5+
* Also covers the invited user's decline path and the duplicate-add guard (422).
6+
*/
7+
import request from 'supertest';
8+
import path from 'path';
9+
10+
import { bootstrap } from '../../../lib/app.js';
11+
import mongooseService from '../../../lib/services/mongoose.js';
12+
import config from '../../../config/index.js';
13+
14+
describe('Organizations member-add E2E tests:', () => {
15+
let UserService;
16+
let OrganizationsRepository;
17+
let MembershipRepository;
18+
let agent;
19+
20+
const password = 'W@os.jsI$Aw3$0m3';
21+
22+
// Store original config
23+
const originalOrganizations = { ...config.organizations };
24+
25+
/**
26+
* @description Reset organizations config to original state.
27+
*/
28+
const resetOrgConfig = () => {
29+
config.organizations = { ...originalOrganizations };
30+
};
31+
32+
/**
33+
* @description Clean up a user and their associated organizations/memberships.
34+
* @param {Object} user - The user object to clean up.
35+
* @returns {Promise<void>}
36+
*/
37+
const cleanupUser = async (user) => {
38+
if (!user) return;
39+
try {
40+
const memberships = await MembershipRepository.list({ userId: user.id || user._id });
41+
for (const m of memberships) {
42+
const orgId = m.organizationId._id || m.organizationId;
43+
await MembershipRepository.deleteMany({ organizationId: orgId });
44+
await OrganizationsRepository.deleteMany({ _id: orgId });
45+
}
46+
await UserService.remove(user);
47+
} catch (_) { /* cleanup — ignore errors */ }
48+
};
49+
50+
beforeAll(async () => {
51+
try {
52+
const init = await bootstrap();
53+
UserService = (await import(path.resolve('./modules/users/services/users.service.js'))).default;
54+
OrganizationsRepository = (await import(path.resolve('./modules/organizations/repositories/organizations.repository.js'))).default;
55+
MembershipRepository = (await import(path.resolve('./modules/organizations/repositories/organizations.membership.repository.js'))).default;
56+
agent = request.agent(init.app);
57+
} catch (err) {
58+
console.log(err);
59+
expect(err).toBeFalsy();
60+
}
61+
});
62+
63+
describe('Full member-add lifecycle', () => {
64+
let userA;
65+
let userB;
66+
let orgA;
67+
let agentA;
68+
let agentB;
69+
70+
// Finding #1: unique-per-run emails to avoid dirty-DB re-run flakiness
71+
const suffix = Date.now();
72+
const emailA = `member-add-a-${suffix}@test.com`;
73+
const emailB = `member-add-b-${suffix}@test.com`;
74+
75+
// Finding #2: move config assignment into beforeAll so it is always set
76+
// before any test in this describe block runs
77+
beforeAll(() => {
78+
config.organizations = { enabled: true, autoCreate: true, domainMatching: false };
79+
});
80+
81+
afterAll(async () => {
82+
resetOrgConfig();
83+
// Clean up org A and its memberships, then B before A
84+
try {
85+
if (orgA) {
86+
await MembershipRepository.deleteMany({ organizationId: orgA._id });
87+
await OrganizationsRepository.deleteMany({ _id: orgA._id });
88+
}
89+
} catch (_) { /* cleanup */ }
90+
await cleanupUser(userB);
91+
await cleanupUser(userA);
92+
});
93+
94+
test('full lifecycle: add → pending in list → accept → promote → leave', async () => {
95+
agentA = request.agent(agent.app);
96+
agentB = request.agent(agent.app);
97+
98+
let membershipId;
99+
100+
// 1. Signup user A (auto-creates org A), then user B
101+
try {
102+
const resultA = await agentA
103+
.post('/api/auth/signup')
104+
.send({
105+
firstName: 'MemberAddA',
106+
lastName: 'User',
107+
email: emailA,
108+
password,
109+
provider: 'local',
110+
})
111+
.expect(200);
112+
userA = resultA.body.user;
113+
orgA = resultA.body.organization;
114+
expect(orgA).toBeDefined();
115+
expect(orgA).not.toBeNull();
116+
} catch (err) {
117+
console.log(err);
118+
expect(err).toBeFalsy();
119+
}
120+
121+
try {
122+
const resultB = await agentB
123+
.post('/api/auth/signup')
124+
.send({
125+
firstName: 'MemberAddB',
126+
lastName: 'User',
127+
email: emailB,
128+
password,
129+
provider: 'local',
130+
})
131+
.expect(200);
132+
userB = resultB.body.user;
133+
} catch (err) {
134+
console.log(err);
135+
expect(err).toBeFalsy();
136+
}
137+
138+
// 2. A looks up B by exact email. Asserts the /members/search route is
139+
// registered BEFORE /members/:memberId — if 'search' were captured as
140+
// :memberId, memberByID would 404 on the invalid ObjectId.
141+
try {
142+
const searchRes = await agentA
143+
.get(`/api/organizations/${orgA._id}/members/search`)
144+
.query({ email: emailB })
145+
.expect(200);
146+
expect(searchRes.body.message).toBe('user lookup');
147+
expect(searchRes.body.data).not.toBeNull();
148+
expect(searchRes.body.data.id).toBe(userB.id);
149+
expect(searchRes.body.data.email).toBe(emailB);
150+
} catch (err) {
151+
console.log(err);
152+
expect(err).toBeFalsy();
153+
}
154+
155+
// 3. A adds B → PENDING owner_add membership (consent not granted yet)
156+
try {
157+
const addRes = await agentA
158+
.post(`/api/organizations/${orgA._id}/members`)
159+
.send({ userId: userB.id, role: 'member' })
160+
.expect(200);
161+
expect(addRes.body.message).toBe('membership invitation created');
162+
membershipId = addRes.body.data._id;
163+
expect(membershipId).toBeDefined();
164+
expect(addRes.body.data.status).toBe('pending');
165+
expect(addRes.body.data.source).toBe('owner_add');
166+
} catch (err) {
167+
console.log(err);
168+
expect(err).toBeFalsy();
169+
}
170+
171+
// 4. The members list shows the pending owner_add row alongside the active
172+
// owner (pending owner_add rows are intentionally visible to the org).
173+
try {
174+
const listRes = await agentA.get(`/api/organizations/${orgA._id}/members`).expect(200);
175+
expect(listRes.body.message).toBe('membership list');
176+
const rowB = listRes.body.data.find((m) => m._id === membershipId);
177+
expect(rowB).toBeDefined();
178+
expect(rowB.status).toBe('pending');
179+
expect(rowB.source).toBe('owner_add');
180+
const rowA = listRes.body.data.find((m) => m.userId?.email === emailA);
181+
expect(rowA).toBeDefined();
182+
expect(rowA.status).toBe('active');
183+
expect(rowA.role).toBe('owner');
184+
} catch (err) {
185+
console.log(err);
186+
expect(err).toBeFalsy();
187+
}
188+
189+
// 5. B sees the invitation on the auth-only mine/pending surface
190+
try {
191+
const mineRes = await agentB.get('/api/membership-requests/mine/pending').expect(200);
192+
expect(mineRes.body.message).toBe('membership invitation list');
193+
const invite = mineRes.body.data.find((m) => m._id === membershipId);
194+
expect(invite).toBeDefined();
195+
expect(invite.status).toBe('pending');
196+
expect(invite.source).toBe('owner_add');
197+
} catch (err) {
198+
console.log(err);
199+
expect(err).toBeFalsy();
200+
}
201+
202+
// 6. B accepts (auth-only route, consent gate in the service) → ACTIVE
203+
try {
204+
const acceptRes = await agentB
205+
.put(`/api/membership-requests/${membershipId}/accept`)
206+
.expect(200);
207+
expect(acceptRes.body.message).toBe('membership invitation accepted');
208+
expect(acceptRes.body.data.status).toBe('active');
209+
} catch (err) {
210+
console.log(err);
211+
expect(err).toBeFalsy();
212+
}
213+
214+
// 7. A promotes B to admin through the :memberId param middleware
215+
try {
216+
const promoteRes = await agentA
217+
.put(`/api/organizations/${orgA._id}/members/${membershipId}`)
218+
.send({ role: 'admin' })
219+
.expect(200);
220+
expect(promoteRes.body.message).toBe('membership updated');
221+
expect(promoteRes.body.data.role).toBe('admin');
222+
} catch (err) {
223+
console.log(err);
224+
expect(err).toBeFalsy();
225+
}
226+
227+
// 8. B leaves → members list back to A only
228+
// Finding #4: assert identity (A's row) rather than a fragile bare count
229+
try {
230+
const leaveRes = await agentB.post(`/api/organizations/${orgA._id}/leave`).expect(200);
231+
expect(leaveRes.body.message).toBe('organization left');
232+
const finalList = await agentA.get(`/api/organizations/${orgA._id}/members`).expect(200);
233+
// precondition: B must not appear in the list after leaving
234+
expect(finalList.body.data.find((m) => m.userId?.email === emailB)).toBeUndefined();
235+
expect(finalList.body.data[0].userId?.email).toBe(emailA);
236+
} catch (err) {
237+
console.log(err);
238+
expect(err).toBeFalsy();
239+
}
240+
});
241+
242+
// Jest runs test() blocks within a file in declaration order, so this runs after
243+
// the lifecycle test above (which leaves A owning org A and B with no membership);
244+
// the precondition assert below guards against that test having aborted.
245+
test('decline path: re-add → duplicate guard 422 → B declines → row gone everywhere', async () => {
246+
// Finding #2: guard against test-1 abort leaving shared state unpopulated
247+
// precondition: the lifecycle test must have populated shared state
248+
expect(orgA && userB && agentA && agentB).toBeTruthy();
249+
250+
let declineId;
251+
252+
// 1. A re-adds B → a fresh PENDING owner_add row
253+
try {
254+
const reAddRes = await agentA
255+
.post(`/api/organizations/${orgA._id}/members`)
256+
.send({ userId: userB.id, role: 'member' })
257+
.expect(200);
258+
declineId = reAddRes.body.data._id;
259+
expect(declineId).toBeDefined();
260+
expect(reAddRes.body.data.status).toBe('pending');
261+
} catch (err) {
262+
console.log(err);
263+
expect(err).toBeFalsy();
264+
}
265+
266+
// 2. Duplicate guard: adding B again while pending is rejected (422)
267+
// Finding #3: chain .expect(422) so a 500 is not swallowed
268+
try {
269+
const dupRes = await agentA
270+
.post(`/api/organizations/${orgA._id}/members`)
271+
.send({ userId: userB.id, role: 'member' })
272+
.expect(422);
273+
expect(dupRes.body.type).toBe('error');
274+
expect(dupRes.body.description).toBe('This user already has a pending membership for this organization.');
275+
} catch (err) {
276+
console.log(err);
277+
expect(err).toBeFalsy();
278+
}
279+
280+
// 3. B sees the invitation, then DECLINES it (DELETE, auth-only)
281+
// Finding #5: assert the decline response body message
282+
try {
283+
const mineRes = await agentB.get('/api/membership-requests/mine/pending').expect(200);
284+
const invite = mineRes.body.data.find((m) => m._id === declineId);
285+
expect(invite).toBeDefined();
286+
287+
const declineRes = await agentB.delete(`/api/membership-requests/${declineId}`).expect(200);
288+
expect(declineRes.body.message).toBe('membership invitation declined');
289+
} catch (err) {
290+
console.log(err);
291+
expect(err).toBeFalsy();
292+
}
293+
294+
// 4. The row is gone everywhere — mine/pending, the members list, the DB
295+
// Finding #4: assert no B-row remains (identity) rather than a bare count
296+
try {
297+
const mineAfter = await agentB.get('/api/membership-requests/mine/pending').expect(200);
298+
expect(mineAfter.body.data.find((m) => m._id === declineId)).toBeUndefined();
299+
300+
const listAfter = await agentA.get(`/api/organizations/${orgA._id}/members`).expect(200);
301+
expect(listAfter.body.data.find((m) => m._id === declineId)).toBeUndefined();
302+
// assert B is not in the list rather than relying on a hard count
303+
expect(listAfter.body.data.find((m) => m.userId?.email === emailB)).toBeUndefined();
304+
305+
const row = await MembershipRepository.findOne({ _id: declineId });
306+
expect(row).toBeNull();
307+
} catch (err) {
308+
console.log(err);
309+
expect(err).toBeFalsy();
310+
}
311+
});
312+
});
313+
314+
// Mongoose disconnect
315+
afterAll(async () => {
316+
try {
317+
await mongooseService.disconnect();
318+
} catch (err) {
319+
console.log(err);
320+
expect(err).toBeFalsy();
321+
}
322+
});
323+
});

0 commit comments

Comments
 (0)