Skip to content

Commit d52c3a1

Browse files
refactor(invitations): extract platform-invitation module + invert auth eligibility hook (P2) (#3820)
* refactor(invitations): extract platform-invite module + eligibility hook (#3810) Phase 2 of the invitations↔org decouple epic. Move #3715's platform-invite code out of modules/auth into a glob-discovered optional modules/invitations, and invert the dependency so auth no longer imports invitation code. - New modules/invitations: controller/service/repository/model/schema/policy/ doc moved from auth (renamed auth.invitation.* → invitations.*), Mongoose model name kept "Invitation" → collection "invitations" (live Trawl prod docs; collection NOT renamed). Fields + indexes unchanged. - New auth.eligibility.js: generic signup-eligibility hook registry (registerSignupEligibility / assertSignupEligible / _reset). invitations plugs in at boot via invitations.init.js (invitations → auth; auth never imports invitations). init also wires the events 'error' listener (mirrors billing.init.js crash guard). - auth.controller signup + OAuth gates now call the generic hook; the checker resolves the invite and stashes it (+ a bound single-use consume closure) on the ctx carrier so the controller can pin the account email + consume without importing invitation code. Cap logic unchanged (P3). - Routes: canonical /api/invitations mount in modules/invitations; the legacy /api/auth/invitations* alias stays in auth.routes.js (before the greedy /api/auth/:strategy wildcard) pointing at the moved controller. invitationId param registered once (app-global) by the canonical routes — no double DB hit. - Policy registers BOTH /api/invitations and /api/auth/invitations → Invitation. - config: inviteExpiresInDays moved to invitations config (deep-merged into config.sign); audit.routeTypeMap.invitations → Invitation. Behavior-preserving refactor + deprecation alias only. No P3 hardening (two-phase claim / email index / cap unify / soft-revoke). Signup behavior identical. Tests: moved + split into per-layer unit tests, added init/ controller/repository unit + e2e + alias/OAuth-wildcard integration tests. Full suite green (169 suites, 2246 tests); lint clean. * refactor(invitations): return-value eligibility seam (drop req stash) Code-review fix-up on the Phase-2 eligibility hook: - Replace the req._signupInvite / req._consumeSignupInvite stash + synthetic oauthCtx={} carrier with a return-value seam. assertSignupEligible now runs every check and returns the first non-null result (opaque to auth); the invitations checker returns { invite, consume } instead of stashing. Deletes both req._-prefixed props. auth.controller stays import-free of invitation code (only comments + the generic Eligibility import). - Fix the misleading body.inviteToken comment: HTTP signup strips unknown body keys via the model middleware, so query is the effective source; body fallback is for non-HTTP/direct callers. Rename the init test accordingly. - Inline findValidByEmail into assertInvitedByEmail (drop the pure pass-through; no other caller); update its service unit test. - Retitle the init 'registers exactly one checker' test to what it verifies (one init() => one checker) and document that init() is not idempotent. Lint clean. 34 touched unit tests + 17 invitation integration/e2e tests green; full suite 2246/2246 (one pre-existing socket-hang-up flake in auth lockout, unrelated, passes on re-run). * docs(auth): note throw-aborts-chain + loose-null seam semantics for P5/P8 checkers * fix(invitations): address CodeRabbit + Copilot review findings - auth.routes.js: add TODO(P6, #4279) with explicit rationale for the intentional core→optional import (deprecation alias until P6) - auth.eligibility.js: guard registerSignupEligibility against non-function input with TypeError for earlier error surfacing - auth.eligibility.unit.tests.js: add TypeError test for the new guard - invitations.controller.unit.tests.js: add JSDoc to makeRes per coding guidelines for named test helpers - invitations.init.unit.tests.js: fix misleading comment that said init() must not duplicate — clarify it is not idempotent by design, boots exactly once at startup - invitations.repository.unit.tests.js: add jest.clearAllMocks() in beforeEach to isolate mock call state across test cases
1 parent 6c46add commit d52c3a1

30 files changed

Lines changed: 1014 additions & 149 deletions

lib/services/tests/analytics.identify.unit.tests.js

Lines changed: 15 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,12 @@ describe('Analytics identify on auth events:', () => {
4747
},
4848
}));
4949

50-
jest.unstable_mockModule('../../../modules/auth/services/auth.invitation.service.js', () => ({
50+
// auth.controller imports the generic eligibility registry (not invitation code).
51+
jest.unstable_mockModule('../../../modules/auth/services/auth.eligibility.js', () => ({
5152
default: {
52-
findValid: jest.fn().mockResolvedValue(null),
53-
findValidByEmail: jest.fn().mockResolvedValue(null),
54-
consume: jest.fn().mockResolvedValue(null),
55-
create: jest.fn(),
56-
list: jest.fn(),
57-
get: jest.fn(),
58-
revoke: jest.fn(),
53+
registerSignupEligibility: jest.fn(),
54+
assertSignupEligible: jest.fn().mockResolvedValue(undefined),
55+
_reset: jest.fn(),
5956
},
6057
}));
6158

@@ -243,15 +240,12 @@ describe('Analytics identify on auth events:', () => {
243240
default: { getBrut: jest.fn(), update: jest.fn(), count: jest.fn().mockResolvedValue(0) },
244241
}));
245242

246-
jest.unstable_mockModule('../../../modules/auth/services/auth.invitation.service.js', () => ({
243+
// auth.controller imports the generic eligibility registry (not invitation code).
244+
jest.unstable_mockModule('../../../modules/auth/services/auth.eligibility.js', () => ({
247245
default: {
248-
findValid: jest.fn().mockResolvedValue(null),
249-
findValidByEmail: jest.fn().mockResolvedValue(null),
250-
consume: jest.fn().mockResolvedValue(null),
251-
create: jest.fn(),
252-
list: jest.fn(),
253-
get: jest.fn(),
254-
revoke: jest.fn(),
246+
registerSignupEligibility: jest.fn(),
247+
assertSignupEligible: jest.fn().mockResolvedValue(undefined),
248+
_reset: jest.fn(),
255249
},
256250
}));
257251

@@ -359,15 +353,12 @@ describe('Analytics identify on auth events:', () => {
359353
default: { getBrut: jest.fn(), update: jest.fn(), count: jest.fn().mockResolvedValue(0) },
360354
}));
361355

362-
jest.unstable_mockModule('../../../modules/auth/services/auth.invitation.service.js', () => ({
356+
// auth.controller imports the generic eligibility registry (not invitation code).
357+
jest.unstable_mockModule('../../../modules/auth/services/auth.eligibility.js', () => ({
363358
default: {
364-
findValid: jest.fn().mockResolvedValue(null),
365-
findValidByEmail: jest.fn().mockResolvedValue(null),
366-
consume: jest.fn().mockResolvedValue(null),
367-
create: jest.fn(),
368-
list: jest.fn(),
369-
get: jest.fn(),
370-
revoke: jest.fn(),
359+
registerSignupEligibility: jest.fn(),
360+
assertSignupEligible: jest.fn().mockResolvedValue(undefined),
361+
_reset: jest.fn(),
371362
},
372363
}));
373364

modules/auth/config/auth.development.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const config = {
1414
in: true, // disable signin
1515
up: true, // disable signup
1616
cap: null, // null = unlimited; integer = hard ceiling on TOTAL accounts (invited included)
17-
inviteExpiresInDays: 14, // signup invite link validity
17+
// inviteExpiresInDays moved to modules/invitations/config (deep-merged into config.sign)
1818
},
1919
// jwt is for token authentication
2020
jwt: {

modules/auth/controllers/auth.controller.js

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import passport from 'passport';
66
import jwt from 'jsonwebtoken';
77

88
import UserService from '../../users/services/users.service.js';
9-
import InvitationService from '../services/auth.invitation.service.js';
9+
import Eligibility from '../services/auth.eligibility.js';
1010
import { computeSignupCapacity } from '../services/auth.signupCapacity.js';
1111
import config from '../../../config/index.js';
1212
import model from '../../../lib/middlewares/model.js';
@@ -68,19 +68,18 @@ const signup = async (req, res) => {
6868
try {
6969
// Two AND-ed gates: (1) capacity — a hard ceiling on total accounts,
7070
// invited users included; (2) eligibility — public signup open OR a valid
71-
// invite token (read from query: model.isValid strips unknown body keys).
71+
// invite token. The eligibility check is supplied by optional modules via the
72+
// generic registry (auth never imports invitation code). The invitations
73+
// checker resolves the email-pinned invite and RETURNS `{ invite, consume }`,
74+
// which auth relays back here verbatim (opaque result) so this controller can
75+
// canonicalize the account email + consume it below.
7276
// Short-circuit count() when cap is not set (null = unlimited) to avoid a
7377
// collection-wide count on every signup request for uncapped deployments.
7478
const cap = config.sign.cap != null ? Number(config.sign.cap) : null;
7579
const capReached = cap != null && Number.isFinite(cap) && (await UserService.count()) >= cap;
76-
let invite = null;
77-
if (req.query?.inviteToken) {
78-
invite = await InvitationService.findValid(req.query.inviteToken, req.body.email);
79-
// An invite is bound to its email; never honor it for a signup that supplies
80-
// no email to match. findValid stays lenient on a falsy email because the
81-
// public verify endpoint reuses it, so enforce the pin here.
82-
if (invite && invite.email && !req.body.email) invite = null;
83-
}
80+
const eligibility = await Eligibility.assertSignupEligible({ email: req.body.email, body: req.body, req });
81+
// null when no optional module opened the gate (registry empty or no valid invite).
82+
const invite = eligibility?.invite || null;
8483
if (capReached || (!config.sign.up && !invite)) {
8584
return responses.error(res, 404, 'Signup error', 'Registration is currently deactivated')();
8685
}
@@ -149,7 +148,9 @@ const signup = async (req, res) => {
149148
// Single-use: consume only when the invite actually opened the gate (signup
150149
// was closed, so the invite was required). When signup is open, a token can
151150
// be presented but is not required — consuming it would silently burn it.
152-
if (invite && !config.sign.up) await InvitationService.consume(invite.id);
151+
// Consume runs through the closure returned by the eligibility checker
152+
// (invitations module owns consume; auth never imports invitation code).
153+
if (invite && !config.sign.up) await eligibility?.consume?.();
153154

154155
const token = jwt.sign({ userId: user.id }, config.jwt.secret, {
155156
expiresIn: config.jwt.expiresIn,
@@ -443,7 +444,20 @@ const checkOAuthUserProfile = async (profil, key, provider) => {
443444
// provider returning an arbitrary unverified email could open the gate on an
444445
// invite meant for someone else.
445446
const hasVerifiedProviderEmail = !!(profil.email && profil.emailVerifiedByProvider);
446-
const oauthInvite = config.sign.up || !hasVerifiedProviderEmail ? null : await InvitationService.findValidByEmail(profil.email);
447+
// Eligibility via the generic hook (auth never imports invitation code). OAuth
448+
// carries no token through the redirect, so the invite is matched on the
449+
// provider-verified email — the checker honors it only when emailVerifiedByProvider.
450+
// Skip the hook entirely when signup is open: a token/invite is presented but not
451+
// required, and resolving one would silently burn it (preserves prior short-circuit).
452+
// The checker returns `{ invite, consume }` (opaque to auth); no synthetic req carrier.
453+
let oauthEligibility = null;
454+
if (!config.sign.up) {
455+
oauthEligibility = await Eligibility.assertSignupEligible({
456+
email: profil.email,
457+
oauth: { emailVerifiedByProvider: hasVerifiedProviderEmail },
458+
});
459+
}
460+
const oauthInvite = oauthEligibility?.invite || null;
447461
if (capReached || (!config.sign.up && !oauthInvite)) {
448462
// Mirror the local signup endpoint's error shape so clients see the same
449463
// `message`/`description` regardless of signup method (see `signup` above).
@@ -468,7 +482,8 @@ const checkOAuthUserProfile = async (profil, key, provider) => {
468482
// else return req.body with the data after Zod validation
469483
if (oauthInvite) result.value.email = oauthInvite.email;
470484
const createdUser = await UserService.create(result.value);
471-
if (oauthInvite) await InvitationService.consume(oauthInvite.id);
485+
// Consume through the returned closure (invitations owns consume; auth stays import-free).
486+
if (oauthInvite) await oauthEligibility?.consume?.();
472487
return createdUser;
473488
} catch (err) {
474489
if (err instanceof AppError) throw err;

modules/auth/routes/auth.routes.js

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,16 @@ import policy from '../../../lib/middlewares/policy.js';
99
import UsersSchema from '../../users/models/users.schema.js';
1010
import auth from '../controllers/auth.controller.js';
1111
import authPassword from '../controllers/auth.password.controller.js';
12-
import invitations from '../controllers/auth.invitation.controller.js';
13-
import InvitationSchema from '../models/auth.invitation.schema.js';
12+
// TODO(P6, pierreb-devkit/Node#4279): Remove these two imports and the three routes
13+
// below once Vue migrates to the canonical /api/invitations mount.
14+
// Deprecation alias only — the invitations feature lives in modules/invitations.
15+
// This block MUST stay here (before the greedy `/api/auth/:strategy` wildcard) so
16+
// the legacy `/api/auth/invitations*` paths the Vue admin store still calls keep
17+
// working; it points at the MOVED controller. Canonical mount: /api/invitations
18+
// (modules/invitations/routes/invitations.routes.js).
19+
// INTENTIONAL core→optional import: temporary coupling until P6 removes these routes.
20+
import invitations from '../../invitations/controllers/invitations.controller.js';
21+
import InvitationSchema from '../../invitations/models/invitations.schema.js';
1422

1523
/**
1624
* Register authentication routes on the Express application.
@@ -20,9 +28,13 @@ import InvitationSchema from '../models/auth.invitation.schema.js';
2028
export default (app) => {
2129
const authLimiter = limiters.auth;
2230

23-
// Signup invitations — public verify + admin CRUD. MUST be declared before the
24-
// greedy `/api/auth/:strategy` wildcard below, which would otherwise capture
25-
// `/api/auth/invitations`.
31+
// Signup invitations — DEPRECATION ALIAS for the canonical /api/invitations mount
32+
// (modules/invitations). MUST be declared before the greedy `/api/auth/:strategy`
33+
// wildcard below, which would otherwise capture `/api/auth/invitations`. A separate
34+
// glob-loaded module cannot guarantee it loads before that wildcard, so the alias
35+
// stays here, pointing at the MOVED controller. The `invitationId` param callback is
36+
// registered once (app-global) by the canonical invitations.routes.js, so it resolves
37+
// for this alias route too — no need (and harmful: double DB lookup) to re-register it.
2638
app.route('/api/auth/invitations/verify/:token').get(authLimiter, invitations.verify);
2739
app
2840
.route('/api/auth/invitations')
@@ -33,7 +45,6 @@ export default (app) => {
3345
.route('/api/auth/invitations/:invitationId')
3446
.all(passport.authenticate('jwt', { session: false }), policy.isAllowed)
3547
.delete(invitations.remove);
36-
app.param('invitationId', invitations.invitationByID);
3748

3849
// Auth config — optional JWT: public fields for everyone, org details for authenticated users
3950
/**
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* Generic signup-eligibility hook registry.
3+
*
4+
* Inverts the auth → invitations dependency: auth never imports invitation code.
5+
* Optional modules (e.g. invitations) register a check at boot via
6+
* registerSignupEligibility; auth runs them all in the signup / OAuth paths.
7+
*
8+
* Each check (a) may THROW an AppError to block signup, and (b) may RETURN a
9+
* result object that auth hands back verbatim to whoever cares. The result is
10+
* OPAQUE to auth — auth never reads its shape, it just relays the first non-null
11+
* one. This is the return-value seam that replaces stashing carrier props on req.
12+
*/
13+
14+
const checks = [];
15+
16+
/**
17+
* Register a signup eligibility check. Each fn(ctx) may throw an AppError to block
18+
* signup, and/or return a result object (opaque to auth) — or undefined.
19+
* @param {(ctx: { email?: string, body?: Object, req?: Object, oauth?: Object }) => (any|Promise<any>)} fn
20+
* @returns {void}
21+
*/
22+
export const registerSignupEligibility = (fn) => {
23+
if (typeof fn !== 'function') {
24+
throw new TypeError('registerSignupEligibility: fn must be a function');
25+
}
26+
checks.push(fn);
27+
};
28+
29+
/**
30+
* Run EVERY registered eligibility check with the same ctx. A check may throw
31+
* (to block signup — propagated to the caller) and/or return a result object.
32+
* Returns the FIRST non-null result collected across all checks (in registration
33+
* order), or null when no check returned one. The result is opaque to auth: it is
34+
* handed straight back to the caller (e.g. the invitations checker returns
35+
* `{ invite, consume }`, which auth relays without importing any invitation code).
36+
*
37+
* NOTE for future check authors (P5/P8 will register more): a THROW from ANY
38+
* check aborts the whole chain and blocks signup — even a check that runs AFTER
39+
* an earlier one already resolved a non-null result. Throwing is "block signup",
40+
* not "I have no opinion"; return `undefined` for the latter. Order matters.
41+
* @param {{ email?: string, body?: Object, req?: Object, oauth?: Object }} ctx
42+
* @returns {Promise<any|null>} the first non-null check result, or null
43+
*/
44+
export const assertSignupEligible = async (ctx) => {
45+
let result = null;
46+
for (const check of checks) {
47+
const r = await check(ctx);
48+
// Loose `== null`: a check returning either undefined or null counts as "no
49+
// result" (a check can `return;` to mean "no opinion"). First non-null wins.
50+
if (result == null && r != null) result = r;
51+
}
52+
return result;
53+
};
54+
55+
/**
56+
* Test helper — clears the registry (mirrors discoverPolicies registry clearing).
57+
* @returns {void}
58+
*/
59+
export const _reset = () => {
60+
checks.length = 0;
61+
};
62+
63+
export default { registerSignupEligibility, assertSignupEligible, _reset };

modules/auth/tests/auth.config.controller.unit.tests.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,13 @@ describe('auth.controller getConfig:', () => {
3737
jest.unstable_mockModule('../../../modules/users/services/users.service.js', () => ({
3838
default: { create: jest.fn(), getBrut: jest.fn(), update: jest.fn(), remove: jest.fn(), search: jest.fn(), count: jest.fn().mockResolvedValue(0) },
3939
}));
40-
jest.unstable_mockModule('../../../modules/auth/services/auth.invitation.service.js', () => ({
41-
default: { findValid: jest.fn().mockResolvedValue(null), consume: jest.fn().mockResolvedValue(null) },
40+
// auth.controller imports the generic eligibility registry (not invitation code).
41+
jest.unstable_mockModule('../../../modules/auth/services/auth.eligibility.js', () => ({
42+
default: {
43+
registerSignupEligibility: jest.fn(),
44+
assertSignupEligible: jest.fn().mockResolvedValue(undefined),
45+
_reset: jest.fn(),
46+
},
4247
}));
4348
jest.unstable_mockModule('../../../modules/users/repositories/users.repository.js', () => ({
4449
default: { update: jest.fn() },
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { jest } from '@jest/globals';
2+
3+
const Eligibility = (await import('../services/auth.eligibility.js')).default;
4+
const { registerSignupEligibility, assertSignupEligible, _reset } = await import('../services/auth.eligibility.js');
5+
6+
beforeEach(() => {
7+
_reset();
8+
});
9+
10+
describe('auth.eligibility registry', () => {
11+
test('assertSignupEligible returns null when no checks are registered', async () => {
12+
await expect(assertSignupEligible({ email: 'a@b.co' })).resolves.toBeNull();
13+
});
14+
15+
test('returns null when every registered check returns nothing', async () => {
16+
registerSignupEligibility(() => {});
17+
registerSignupEligibility(async () => undefined);
18+
await expect(assertSignupEligible({ email: 'a@b.co' })).resolves.toBeNull();
19+
});
20+
21+
test('runs every registered check, in order, with the same ctx', async () => {
22+
const order = [];
23+
const ctx = { email: 'a@b.co', body: { x: 1 }, req: {} };
24+
registerSignupEligibility((c) => { order.push(['a', c]); });
25+
registerSignupEligibility(async (c) => { order.push(['b', c]); });
26+
await assertSignupEligible(ctx);
27+
expect(order.map((o) => o[0])).toEqual(['a', 'b']);
28+
expect(order[0][1]).toBe(ctx);
29+
expect(order[1][1]).toBe(ctx);
30+
});
31+
32+
test('returns the first non-null check result and still runs every check', async () => {
33+
const r1 = { invite: { id: 'a' } };
34+
const r2 = { invite: { id: 'b' } };
35+
const third = jest.fn();
36+
registerSignupEligibility(() => undefined); // contributes no result
37+
registerSignupEligibility(() => r1); // first non-null → this one wins
38+
registerSignupEligibility(() => r2); // later non-null is ignored
39+
registerSignupEligibility(third); // but EVERY check still runs (side-effects fire)
40+
const result = await assertSignupEligible({});
41+
expect(result).toBe(r1);
42+
expect(third).toHaveBeenCalledTimes(1);
43+
});
44+
45+
test('registerSignupEligibility throws TypeError when fn is not a function', () => {
46+
expect(() => registerSignupEligibility('not-a-function')).toThrow(TypeError);
47+
expect(() => registerSignupEligibility(null)).toThrow(TypeError);
48+
expect(() => registerSignupEligibility(42)).toThrow(TypeError);
49+
});
50+
51+
test('a throwing check aborts the chain (blocks signup) and propagates', async () => {
52+
const second = jest.fn();
53+
registerSignupEligibility(() => { throw new Error('blocked'); });
54+
registerSignupEligibility(second);
55+
await expect(assertSignupEligible({})).rejects.toThrow('blocked');
56+
expect(second).not.toHaveBeenCalled();
57+
});
58+
59+
test('a rejecting async check propagates', async () => {
60+
registerSignupEligibility(async () => { throw new Error('async-blocked'); });
61+
await expect(assertSignupEligible({})).rejects.toThrow('async-blocked');
62+
});
63+
64+
test('_reset clears the registry', async () => {
65+
const check = jest.fn();
66+
registerSignupEligibility(check);
67+
_reset();
68+
await assertSignupEligible({});
69+
expect(check).not.toHaveBeenCalled();
70+
});
71+
72+
test('default export exposes the same functions', () => {
73+
expect(typeof Eligibility.registerSignupEligibility).toBe('function');
74+
expect(typeof Eligibility.assertSignupEligible).toBe('function');
75+
expect(typeof Eligibility._reset).toBe('function');
76+
});
77+
});

modules/auth/tests/auth.signout.controller.unit.tests.js

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,12 @@ describe('auth.controller signout:', () => {
2222
jest.unstable_mockModule('../../../modules/users/services/users.service.js', () => ({
2323
default: { create: jest.fn(), getBrut: jest.fn(), update: jest.fn(), remove: jest.fn(), search: jest.fn(), count: jest.fn().mockResolvedValue(0) },
2424
}));
25-
jest.unstable_mockModule('../../../modules/auth/services/auth.invitation.service.js', () => ({
25+
// auth.controller imports the generic eligibility registry (not invitation code).
26+
jest.unstable_mockModule('../../../modules/auth/services/auth.eligibility.js', () => ({
2627
default: {
27-
findValid: jest.fn().mockResolvedValue(null),
28-
findValidByEmail: jest.fn().mockResolvedValue(null),
29-
consume: jest.fn().mockResolvedValue(null),
30-
create: jest.fn(),
31-
list: jest.fn(),
32-
get: jest.fn(),
33-
revoke: jest.fn(),
28+
registerSignupEligibility: jest.fn(),
29+
assertSignupEligible: jest.fn().mockResolvedValue(undefined),
30+
_reset: jest.fn(),
3431
},
3532
}));
3633
jest.unstable_mockModule('../../../modules/users/repositories/users.repository.js', () => ({

modules/auth/tests/auth.silent.catch.unit.tests.js

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,13 @@ describe('auth.controller silent-catch error logging:', () => {
3939
},
4040
}));
4141

42-
jest.unstable_mockModule('../../../modules/auth/services/auth.invitation.service.js', () => ({
42+
// auth.controller no longer imports invitation code — it runs the generic
43+
// eligibility registry. Mock it inert (no checks registered ⇒ no invite stashed).
44+
jest.unstable_mockModule('../../../modules/auth/services/auth.eligibility.js', () => ({
4345
default: {
44-
findValid: jest.fn().mockResolvedValue(null),
45-
findValidByEmail: jest.fn().mockResolvedValue(null),
46-
consume: jest.fn().mockResolvedValue(null),
47-
create: jest.fn(),
48-
list: jest.fn(),
49-
get: jest.fn(),
50-
revoke: jest.fn(),
46+
registerSignupEligibility: jest.fn(),
47+
assertSignupEligible: jest.fn().mockResolvedValue(undefined),
48+
_reset: jest.fn(),
5149
},
5250
}));
5351

0 commit comments

Comments
 (0)