@@ -329,6 +329,276 @@ describe('auth.controller signup mass-assignment strip:', () => {
329329 } ) ;
330330} ) ;
331331
332+ describe ( 'auth.controller signup analytics: invite/referral attribution (#3945):' , ( ) => {
333+ beforeEach ( ( ) => {
334+ jest . resetModules ( ) ;
335+
336+ jest . unstable_mockModule ( '../../../lib/services/logger.js' , ( ) => ( {
337+ default : { warn : jest . fn ( ) , error : jest . fn ( ) , info : jest . fn ( ) } ,
338+ } ) ) ;
339+ } ) ;
340+
341+ test ( 'user_signed_up carries invited:true + invitationId + invitedBy when the eligibility registry resolved an invite' , async ( ) => {
342+ const mockCreate = jest . fn ( ) . mockResolvedValue ( {
343+ id : 'u1' , email : 'invitee@y.com' , firstName : 'A' , lastName : 'B' , provider : 'local' ,
344+ } ) ;
345+
346+ jest . unstable_mockModule ( '../../../modules/users/services/users.service.js' , ( ) => ( {
347+ default : {
348+ create : mockCreate ,
349+ getBrut : jest . fn ( ) . mockResolvedValue ( { id : 'u1' } ) ,
350+ update : jest . fn ( ) . mockResolvedValue ( { } ) ,
351+ remove : jest . fn ( ) ,
352+ count : jest . fn ( ) . mockResolvedValue ( 0 ) ,
353+ } ,
354+ } ) ) ;
355+
356+ // Closed-signup, invite-gated path: the eligibility registry resolves + claims
357+ // the invite and returns { invite, finalize, release } — auth relays it verbatim.
358+ jest . unstable_mockModule ( '../../../modules/auth/services/auth.eligibility.js' , ( ) => ( {
359+ default : {
360+ registerSignupEligibility : jest . fn ( ) ,
361+ assertSignupEligible : jest . fn ( ) . mockResolvedValue ( {
362+ invite : { id : 'inv1' , email : 'invitee@y.com' , invitedBy : 'inviter1' } ,
363+ finalize : jest . fn ( ) . mockResolvedValue ( { id : 'inv1' , status : 'accepted' } ) ,
364+ release : jest . fn ( ) ,
365+ } ) ,
366+ _reset : jest . fn ( ) ,
367+ } ,
368+ } ) ) ;
369+
370+ jest . unstable_mockModule ( '../../../modules/organizations/services/organizations.service.js' , ( ) => ( {
371+ default : {
372+ handleSignupOrganization : jest . fn ( ) . mockResolvedValue ( {
373+ organization : null , joined : false , pendingJoin : false ,
374+ abilities : [ ] , organizationSetupRequired : false ,
375+ emailVerificationRequired : false , suggestedOrganization : null ,
376+ } ) ,
377+ } ,
378+ } ) ) ;
379+
380+ jest . unstable_mockModule ( '../../../modules/organizations/services/organizations.crud.service.js' , ( ) => ( {
381+ default : { autoSetCurrentOrganization : jest . fn ( ) } ,
382+ } ) ) ;
383+
384+ jest . unstable_mockModule ( '../../../modules/organizations/services/organizations.membership.service.js' , ( ) => ( {
385+ default : { findByUserAndOrganization : jest . fn ( ) , listPendingByUser : jest . fn ( ) . mockResolvedValue ( [ ] ) } ,
386+ } ) ) ;
387+
388+ jest . unstable_mockModule ( '../../../config/index.js' , ( ) => ( {
389+ default : {
390+ sign : { up : false , in : true } , // closed signup — invite is required to open the gate
391+ jwt : { secret : 'test-secret' , expiresIn : 3600 } ,
392+ cookie : { secure : false , sameSite : 'lax' } ,
393+ organizations : { enabled : false } ,
394+ app : { title : 'Test' , contact : 'test@test.com' } ,
395+ } ,
396+ } ) ) ;
397+
398+ jest . unstable_mockModule ( '../../../lib/middlewares/model.js' , ( ) => ( {
399+ default : { getResultFromZod : jest . fn ( ) , checkError : jest . fn ( ) } ,
400+ } ) ) ;
401+
402+ jest . unstable_mockModule ( '../../../lib/helpers/mailer/index.js' , ( ) => ( {
403+ default : { isConfigured : jest . fn ( ) . mockReturnValue ( false ) , sendMail : jest . fn ( ) } ,
404+ } ) ) ;
405+
406+ jest . unstable_mockModule ( '../../../lib/helpers/responses.js' , ( ) => ( {
407+ default : {
408+ success : jest . fn ( ) . mockReturnValue ( jest . fn ( ) ) ,
409+ error : jest . fn ( ) . mockReturnValue ( jest . fn ( ) ) ,
410+ } ,
411+ } ) ) ;
412+
413+ jest . unstable_mockModule ( '../../../lib/helpers/errors.js' , ( ) => ( {
414+ default : { getMessage : jest . fn ( ) . mockReturnValue ( 'error' ) } ,
415+ } ) ) ;
416+
417+ jest . unstable_mockModule ( '../../../lib/helpers/AppError.js' , ( ) => ( {
418+ default : class AppError extends Error {
419+ constructor ( msg , opts ) {
420+ super ( msg ) ;
421+ this . status = opts ?. status ;
422+ this . code = opts ?. code ;
423+ this . details = opts ?. details ;
424+ }
425+ } ,
426+ } ) ) ;
427+
428+ jest . unstable_mockModule ( '../../../modules/users/models/users.schema.js' , ( ) => ( {
429+ default : { User : { } , SignupUser : { } } ,
430+ } ) ) ;
431+
432+ jest . unstable_mockModule ( '../../../lib/middlewares/policy.js' , ( ) => ( {
433+ default : { defineAbilityFor : jest . fn ( ) . mockResolvedValue ( { } ) } ,
434+ } ) ) ;
435+
436+ jest . unstable_mockModule ( '../../../lib/helpers/abilities.js' , ( ) => ( {
437+ default : jest . fn ( ) . mockReturnValue ( [ ] ) ,
438+ } ) ) ;
439+
440+ jest . unstable_mockModule ( '../../../lib/helpers/getBaseUrl.js' , ( ) => ( {
441+ default : jest . fn ( ) . mockReturnValue ( 'http://localhost:3000' ) ,
442+ } ) ) ;
443+
444+ const mockCapture = jest . fn ( ) ;
445+ jest . unstable_mockModule ( '../../../lib/services/analytics.js' , ( ) => ( {
446+ default : { identify : jest . fn ( ) , groupIdentify : jest . fn ( ) , capture : mockCapture } ,
447+ } ) ) ;
448+
449+ const { default : AuthController } = await import ( '../../../modules/auth/controllers/auth.controller.js' ) ;
450+
451+ const req = {
452+ body : { email : 'invitee@y.com' , firstName : 'A' , lastName : 'B' , password : 'P@ss1234!' } ,
453+ query : { inviteToken : 'tok' } ,
454+ } ;
455+ const res = {
456+ status : jest . fn ( ) . mockReturnThis ( ) ,
457+ cookie : jest . fn ( ) . mockReturnThis ( ) ,
458+ json : jest . fn ( ) . mockReturnThis ( ) ,
459+ } ;
460+
461+ await AuthController . signup ( req , res ) ;
462+
463+ expect ( mockCapture ) . toHaveBeenCalledWith ( expect . objectContaining ( {
464+ distinctId : 'u1' ,
465+ event : 'user_signed_up' ,
466+ properties : expect . objectContaining ( {
467+ invited : true ,
468+ invitationId : 'inv1' ,
469+ invitedBy : 'inviter1' ,
470+ } ) ,
471+ } ) ) ;
472+ } ) ;
473+
474+ test ( 'user_signed_up carries invited:false + null invitationId/invitedBy on a non-invited (open) signup' , async ( ) => {
475+ const mockCreate = jest . fn ( ) . mockResolvedValue ( {
476+ id : 'u2' , email : 'self@y.com' , firstName : 'C' , lastName : 'D' , provider : 'local' ,
477+ } ) ;
478+
479+ jest . unstable_mockModule ( '../../../modules/users/services/users.service.js' , ( ) => ( {
480+ default : {
481+ create : mockCreate ,
482+ getBrut : jest . fn ( ) . mockResolvedValue ( { id : 'u2' } ) ,
483+ update : jest . fn ( ) . mockResolvedValue ( { } ) ,
484+ remove : jest . fn ( ) ,
485+ count : jest . fn ( ) . mockResolvedValue ( 0 ) ,
486+ } ,
487+ } ) ) ;
488+
489+ jest . unstable_mockModule ( '../../../modules/auth/services/auth.eligibility.js' , ( ) => ( {
490+ default : {
491+ registerSignupEligibility : jest . fn ( ) ,
492+ assertSignupEligible : jest . fn ( ) . mockResolvedValue ( undefined ) , // no invite opened the gate
493+ _reset : jest . fn ( ) ,
494+ } ,
495+ } ) ) ;
496+
497+ jest . unstable_mockModule ( '../../../modules/organizations/services/organizations.service.js' , ( ) => ( {
498+ default : {
499+ handleSignupOrganization : jest . fn ( ) . mockResolvedValue ( {
500+ organization : null , joined : false , pendingJoin : false ,
501+ abilities : [ ] , organizationSetupRequired : false ,
502+ emailVerificationRequired : false , suggestedOrganization : null ,
503+ } ) ,
504+ } ,
505+ } ) ) ;
506+
507+ jest . unstable_mockModule ( '../../../modules/organizations/services/organizations.crud.service.js' , ( ) => ( {
508+ default : { autoSetCurrentOrganization : jest . fn ( ) } ,
509+ } ) ) ;
510+
511+ jest . unstable_mockModule ( '../../../modules/organizations/services/organizations.membership.service.js' , ( ) => ( {
512+ default : { findByUserAndOrganization : jest . fn ( ) , listPendingByUser : jest . fn ( ) . mockResolvedValue ( [ ] ) } ,
513+ } ) ) ;
514+
515+ jest . unstable_mockModule ( '../../../config/index.js' , ( ) => ( {
516+ default : {
517+ sign : { up : true , in : true } , // open signup — no invite required
518+ jwt : { secret : 'test-secret' , expiresIn : 3600 } ,
519+ cookie : { secure : false , sameSite : 'lax' } ,
520+ organizations : { enabled : false } ,
521+ app : { title : 'Test' , contact : 'test@test.com' } ,
522+ } ,
523+ } ) ) ;
524+
525+ jest . unstable_mockModule ( '../../../lib/middlewares/model.js' , ( ) => ( {
526+ default : { getResultFromZod : jest . fn ( ) , checkError : jest . fn ( ) } ,
527+ } ) ) ;
528+
529+ jest . unstable_mockModule ( '../../../lib/helpers/mailer/index.js' , ( ) => ( {
530+ default : { isConfigured : jest . fn ( ) . mockReturnValue ( false ) , sendMail : jest . fn ( ) } ,
531+ } ) ) ;
532+
533+ jest . unstable_mockModule ( '../../../lib/helpers/responses.js' , ( ) => ( {
534+ default : {
535+ success : jest . fn ( ) . mockReturnValue ( jest . fn ( ) ) ,
536+ error : jest . fn ( ) . mockReturnValue ( jest . fn ( ) ) ,
537+ } ,
538+ } ) ) ;
539+
540+ jest . unstable_mockModule ( '../../../lib/helpers/errors.js' , ( ) => ( {
541+ default : { getMessage : jest . fn ( ) . mockReturnValue ( 'error' ) } ,
542+ } ) ) ;
543+
544+ jest . unstable_mockModule ( '../../../lib/helpers/AppError.js' , ( ) => ( {
545+ default : class AppError extends Error {
546+ constructor ( msg , opts ) {
547+ super ( msg ) ;
548+ this . status = opts ?. status ;
549+ this . code = opts ?. code ;
550+ this . details = opts ?. details ;
551+ }
552+ } ,
553+ } ) ) ;
554+
555+ jest . unstable_mockModule ( '../../../modules/users/models/users.schema.js' , ( ) => ( {
556+ default : { User : { } , SignupUser : { } } ,
557+ } ) ) ;
558+
559+ jest . unstable_mockModule ( '../../../lib/middlewares/policy.js' , ( ) => ( {
560+ default : { defineAbilityFor : jest . fn ( ) . mockResolvedValue ( { } ) } ,
561+ } ) ) ;
562+
563+ jest . unstable_mockModule ( '../../../lib/helpers/abilities.js' , ( ) => ( {
564+ default : jest . fn ( ) . mockReturnValue ( [ ] ) ,
565+ } ) ) ;
566+
567+ jest . unstable_mockModule ( '../../../lib/helpers/getBaseUrl.js' , ( ) => ( {
568+ default : jest . fn ( ) . mockReturnValue ( 'http://localhost:3000' ) ,
569+ } ) ) ;
570+
571+ const mockCapture = jest . fn ( ) ;
572+ jest . unstable_mockModule ( '../../../lib/services/analytics.js' , ( ) => ( {
573+ default : { identify : jest . fn ( ) , groupIdentify : jest . fn ( ) , capture : mockCapture } ,
574+ } ) ) ;
575+
576+ const { default : AuthController } = await import ( '../../../modules/auth/controllers/auth.controller.js' ) ;
577+
578+ const req = {
579+ body : { email : 'self@y.com' , firstName : 'C' , lastName : 'D' , password : 'P@ss1234!' } ,
580+ query : { } ,
581+ } ;
582+ const res = {
583+ status : jest . fn ( ) . mockReturnThis ( ) ,
584+ cookie : jest . fn ( ) . mockReturnThis ( ) ,
585+ json : jest . fn ( ) . mockReturnThis ( ) ,
586+ } ;
587+
588+ await AuthController . signup ( req , res ) ;
589+
590+ expect ( mockCapture ) . toHaveBeenCalledWith ( expect . objectContaining ( {
591+ distinctId : 'u2' ,
592+ event : 'user_signed_up' ,
593+ properties : expect . objectContaining ( {
594+ invited : false ,
595+ invitationId : null ,
596+ invitedBy : null ,
597+ } ) ,
598+ } ) ) ;
599+ } ) ;
600+ } ) ;
601+
332602describe ( 'auth.password.controller silent-catch error logging:' , ( ) => {
333603 let mockWarn ;
334604 let mockError ;
0 commit comments