@@ -42,6 +42,8 @@ describe('AuthService', () => {
4242 checkUsernameExists : jest . fn ( ) ,
4343 uploadAvatar : jest . fn ( ) ,
4444 createProfile : jest . fn ( ) ,
45+ deleteAvatar : jest . fn ( ) ,
46+ deleteUserById : jest . fn ( ) ,
4547 } ;
4648
4749 const mockAuditService = {
@@ -458,6 +460,8 @@ describe('AuthService', () => {
458460 mockUsersRepository . checkUsernameExists . mockResolvedValue ( false ) ;
459461 mockUsersRepository . createProfile . mockResolvedValue ( mockUser ) ;
460462 mockUsersRepository . uploadAvatar . mockResolvedValue ( 'https://example.com/avatar.png' ) ;
463+ mockUsersRepository . deleteAvatar . mockResolvedValue ( undefined ) ;
464+ mockUsersRepository . deleteUserById . mockResolvedValue ( undefined ) ;
461465
462466 // Mock findOrCreateUser internal behavior via Supabase mock
463467 mockFrom . mockImplementation ( ( table : string ) => {
@@ -492,8 +496,6 @@ describe('AuthService', () => {
492496 it ( 'should register a new user successfully without image' , async ( ) => {
493497 const result = await service . register ( registerDto ) ;
494498
495- expect ( mockUsersRepository . findByWallet ) . toHaveBeenCalledWith ( validWallet ) ;
496- expect ( mockUsersRepository . checkUsernameExists ) . toHaveBeenCalledWith ( 'testuser' ) ;
497499 expect ( mockUsersRepository . createProfile ) . toHaveBeenCalledWith ( {
498500 wallet : validWallet ,
499501 username : 'testuser' ,
@@ -517,23 +519,139 @@ describe('AuthService', () => {
517519 expect ( result . user . avatarUrl ) . toBe ( 'https://example.com/avatar.png' ) ;
518520 } ) ;
519521
520- it ( 'should throw ConflictException if wallet already exists' , async ( ) => {
521- mockUsersRepository . findByWallet . mockResolvedValue ( { id : 'existing' } ) ;
522+ it ( 'should throw ConflictException (AUTH_WALLET_EXISTS) if DB unique constraint on wallet is violated' , async ( ) => {
523+ mockUsersRepository . createProfile . mockRejectedValueOnce (
524+ new ConflictException ( { code : 'AUTH_WALLET_EXISTS' , message : 'Wallet address is already registered.' } ) ,
525+ ) ;
522526
523- await expect ( service . register ( registerDto ) ) . rejects . toThrow ( ConflictException ) ;
524527 await expect ( service . register ( registerDto ) ) . rejects . toMatchObject ( {
525528 response : { code : 'AUTH_WALLET_EXISTS' } ,
526529 } ) ;
527530 } ) ;
528531
529- it ( 'should throw ConflictException if username is taken' , async ( ) => {
530- mockUsersRepository . checkUsernameExists . mockResolvedValue ( true ) ;
532+ it ( 'should throw ConflictException (AUTH_USERNAME_TAKEN) if DB unique constraint on username is violated' , async ( ) => {
533+ mockUsersRepository . createProfile . mockRejectedValueOnce (
534+ new ConflictException ( { code : 'AUTH_USERNAME_TAKEN' , message : 'Username is already taken.' } ) ,
535+ ) ;
531536
532- await expect ( service . register ( registerDto ) ) . rejects . toThrow ( ConflictException ) ;
533537 await expect ( service . register ( registerDto ) ) . rejects . toMatchObject ( {
534538 response : { code : 'AUTH_USERNAME_TAKEN' } ,
535539 } ) ;
536540 } ) ;
541+
542+ it ( 'should handle parallel duplicate-wallet registrations yielding exactly one success and one 409 AUTH_WALLET_EXISTS' , async ( ) => {
543+ mockUsersRepository . createProfile
544+ . mockResolvedValueOnce ( mockUser )
545+ . mockRejectedValueOnce (
546+ new ConflictException ( { code : 'AUTH_WALLET_EXISTS' , message : 'Wallet address is already registered.' } ) ,
547+ ) ;
548+
549+ const [ res1 , res2 ] = await Promise . allSettled ( [
550+ service . register ( registerDto ) ,
551+ service . register ( registerDto ) ,
552+ ] ) ;
553+
554+ const fulfilled = [ res1 , res2 ] . filter ( ( r ) => r . status === 'fulfilled' ) ;
555+ const rejected = [ res1 , res2 ] . filter ( ( r ) => r . status === 'rejected' ) ;
556+
557+ expect ( fulfilled ) . toHaveLength ( 1 ) ;
558+ expect ( rejected ) . toHaveLength ( 1 ) ;
559+ if ( rejected [ 0 ] . status === 'rejected' ) {
560+ expect ( rejected [ 0 ] . reason ) . toBeInstanceOf ( ConflictException ) ;
561+ expect ( ( rejected [ 0 ] . reason as ConflictException ) . getResponse ( ) ) . toEqual ( {
562+ code : 'AUTH_WALLET_EXISTS' ,
563+ message : 'Wallet address is already registered.' ,
564+ } ) ;
565+ }
566+ } ) ;
567+
568+ it ( 'should handle parallel duplicate-username registrations yielding exactly one success and one 409 AUTH_USERNAME_TAKEN' , async ( ) => {
569+ const dto2 = { ...registerDto , walletAddress : 'GDIFFERENTWALLETHDHSKDHFKSHDFKSHDFKSHDFKSHDFKSH' } ;
570+
571+ mockUsersRepository . createProfile
572+ . mockResolvedValueOnce ( mockUser )
573+ . mockRejectedValueOnce (
574+ new ConflictException ( { code : 'AUTH_USERNAME_TAKEN' , message : 'Username is already taken.' } ) ,
575+ ) ;
576+
577+ const [ res1 , res2 ] = await Promise . allSettled ( [
578+ service . register ( registerDto ) ,
579+ service . register ( dto2 ) ,
580+ ] ) ;
581+
582+ const fulfilled = [ res1 , res2 ] . filter ( ( r ) => r . status === 'fulfilled' ) ;
583+ const rejected = [ res1 , res2 ] . filter ( ( r ) => r . status === 'rejected' ) ;
584+
585+ expect ( fulfilled ) . toHaveLength ( 1 ) ;
586+ expect ( rejected ) . toHaveLength ( 1 ) ;
587+ if ( rejected [ 0 ] . status === 'rejected' ) {
588+ expect ( rejected [ 0 ] . reason ) . toBeInstanceOf ( ConflictException ) ;
589+ expect ( ( rejected [ 0 ] . reason as ConflictException ) . getResponse ( ) ) . toEqual ( {
590+ code : 'AUTH_USERNAME_TAKEN' ,
591+ message : 'Username is already taken.' ,
592+ } ) ;
593+ }
594+ } ) ;
595+
596+ it ( 'should return same structured 409 AUTH_WALLET_EXISTS on sequential re-registration' , async ( ) => {
597+ // First registration succeeds
598+ await service . register ( registerDto ) ;
599+
600+ // Second registration fails on unique constraint
601+ mockUsersRepository . createProfile . mockRejectedValueOnce (
602+ new ConflictException ( { code : 'AUTH_WALLET_EXISTS' , message : 'Wallet address is already registered.' } ) ,
603+ ) ;
604+
605+ await expect ( service . register ( registerDto ) ) . rejects . toMatchObject ( {
606+ response : { code : 'AUTH_WALLET_EXISTS' } ,
607+ } ) ;
608+ } ) ;
609+
610+ it ( 'should clean up avatar from storage when registration fails after avatar upload' , async ( ) => {
611+ const mockFile = { originalname : 'avatar.png' , buffer : Buffer . from ( 'test' ) , mimetype : 'image/png' } ;
612+ mockUsersRepository . uploadAvatar . mockResolvedValue ( 'https://example.com/avatar.png' ) ;
613+ mockUsersRepository . createProfile . mockRejectedValueOnce (
614+ new ConflictException ( { code : 'AUTH_WALLET_EXISTS' , message : 'Wallet address is already registered.' } ) ,
615+ ) ;
616+
617+ await expect ( service . register ( registerDto , mockFile ) ) . rejects . toThrow ( ConflictException ) ;
618+
619+ expect ( mockUsersRepository . deleteAvatar ) . toHaveBeenCalledWith ( 'https://example.com/avatar.png' ) ;
620+ } ) ;
621+
622+ it ( 'should clean up both avatar and created user if downstream token issuance fails' , async ( ) => {
623+ const mockFile = { originalname : 'avatar.png' , buffer : Buffer . from ( 'test' ) , mimetype : 'image/png' } ;
624+ mockUsersRepository . uploadAvatar . mockResolvedValue ( 'https://example.com/avatar.png' ) ;
625+ mockUsersRepository . createProfile . mockResolvedValue ( mockUser ) ;
626+
627+ // Mock session creation failure during generateTokens
628+ mockFrom . mockImplementation ( ( table : string ) => {
629+ if ( table === 'users' ) {
630+ return {
631+ upsert : jest . fn ( ) . mockReturnThis ( ) ,
632+ select : jest . fn ( ) . mockReturnThis ( ) ,
633+ single : jest . fn ( ) . mockResolvedValue ( { data : { id : 'user-uuid' , status : 'active' } , error : null } ) ,
634+ } ;
635+ }
636+ if ( table === 'learner_profiles' ) {
637+ return {
638+ select : jest . fn ( ) . mockReturnThis ( ) ,
639+ eq : jest . fn ( ) . mockReturnThis ( ) ,
640+ maybeSingle : jest . fn ( ) . mockResolvedValue ( { data : null , error : null } ) ,
641+ insert : jest . fn ( ) . mockResolvedValue ( { error : null } ) ,
642+ } ;
643+ }
644+ if ( table === 'sessions' ) {
645+ return { insert : jest . fn ( ) . mockResolvedValue ( { error : { message : 'Session failed' } } ) } ;
646+ }
647+ return { insert : mockInsert } ;
648+ } ) ;
649+
650+ await expect ( service . register ( registerDto , mockFile ) ) . rejects . toThrow ( InternalServerErrorException ) ;
651+
652+ expect ( mockUsersRepository . deleteAvatar ) . toHaveBeenCalledWith ( 'https://example.com/avatar.png' ) ;
653+ expect ( mockUsersRepository . deleteUserById ) . toHaveBeenCalledWith ( 'user-uuid' ) ;
654+ } ) ;
537655 } ) ;
538656
539657 // ---------------------------------------------------------------------------
0 commit comments