@@ -35,6 +35,7 @@ import {
3535 seedEnvExamAttempt ,
3636 seedExamEnvExamAuthToken
3737} from '../../../__fixtures__/exam-environment-exam.js' ;
38+ import * as getChallengesModule from '../../utils/get-challenges.js' ;
3839import { getMsTranscriptApiUrl } from './user.js' ;
3940
4041const mockedFetch = vi . fn ( ) ;
@@ -771,6 +772,345 @@ describe('userRoutes', () => {
771772 test . todo ( 'POST resets the user to the default state' ) ;
772773 } ) ;
773774
775+ describe ( '/account/reset-module' , ( ) => {
776+ const testChallengesBlockOne = [
777+ {
778+ id : 'block-one-challenge-1' ,
779+ completedDate : 1520002973119 ,
780+ solution : null ,
781+ challengeType : 5 ,
782+ files : [ ]
783+ } ,
784+ {
785+ id : 'block-one-challenge-2' ,
786+ completedDate : 1520002973120 ,
787+ solution : null ,
788+ challengeType : 5 ,
789+ files : [ ]
790+ }
791+ ] ;
792+
793+ const testChallengesBlockTwo = [
794+ {
795+ id : 'block-two-challenge-1' ,
796+ completedDate : 1520002973121 ,
797+ solution : null ,
798+ challengeType : 5 ,
799+ files : [ ]
800+ } ,
801+ {
802+ id : 'block-two-challenge-2' ,
803+ completedDate : 1520002973122 ,
804+ solution : null ,
805+ challengeType : 5 ,
806+ files : [ ]
807+ }
808+ ] ;
809+
810+ const savedChallengesBlockOne = [
811+ {
812+ id : 'block-one-challenge-1' ,
813+ lastSavedDate : 123 ,
814+ files : [
815+ {
816+ contents : 'test-contents' ,
817+ ext : 'js' ,
818+ history : [ 'indexjs' ] ,
819+ key : 'indexjs' ,
820+ name : 'test-name'
821+ }
822+ ]
823+ }
824+ ] ;
825+
826+ const partiallyCompletedChallengesBlockOne = [
827+ {
828+ id : 'block-one-challenge-1' ,
829+ completedDate : 1520002973119
830+ } ,
831+ {
832+ id : 'block-one-challenge-2' ,
833+ completedDate : 1520002973120
834+ }
835+ ] ;
836+
837+ let getChallengeIdsByBlockSpy : MockInstance ;
838+
839+ beforeEach ( async ( ) => {
840+ // Mock getChallengeIdsByBlock to return test challenge IDs
841+ getChallengeIdsByBlockSpy = vi
842+ . spyOn ( getChallengesModule , 'getChallengeIdsByBlock' )
843+ . mockImplementation ( ( blockId : string ) => {
844+ if ( blockId === 'block-one' ) {
845+ return [ 'block-one-challenge-1' , 'block-one-challenge-2' ] ;
846+ }
847+ if ( blockId === 'block-two' ) {
848+ return [ 'block-two-challenge-1' , 'block-two-challenge-2' ] ;
849+ }
850+ return [ ] ;
851+ } ) ;
852+
853+ await fastifyTestInstance . prisma . user . updateMany ( {
854+ where : { email : testUserData . email } ,
855+ data : {
856+ completedChallenges : [
857+ ...testChallengesBlockOne ,
858+ ...testChallengesBlockTwo
859+ ] ,
860+ savedChallenges : savedChallengesBlockOne ,
861+ partiallyCompletedChallenges : partiallyCompletedChallengesBlockOne ,
862+ isRespWebDesignCert : true
863+ }
864+ } ) ;
865+ } ) ;
866+
867+ afterEach ( ( ) => {
868+ getChallengeIdsByBlockSpy . mockRestore ( ) ;
869+ } ) ;
870+
871+ test ( 'DELETE returns 400 for missing blockIds' , async ( ) => {
872+ const response = await superDelete ( '/account/reset-module' ) . send ( { } ) ;
873+
874+ expect ( response . status ) . toBe ( 400 ) ;
875+ } ) ;
876+
877+ test ( 'DELETE returns 400 for empty blockIds array' , async ( ) => {
878+ const response = await superDelete ( '/account/reset-module' ) . send ( {
879+ blockIds : [ ]
880+ } ) ;
881+
882+ expect ( response . status ) . toBe ( 400 ) ;
883+ } ) ;
884+
885+ test ( 'DELETE returns 400 for blockIds containing an empty string' , async ( ) => {
886+ const response = await superDelete ( '/account/reset-module' ) . send ( {
887+ blockIds : [ '' ]
888+ } ) ;
889+
890+ expect ( response . status ) . toBe ( 400 ) ;
891+ } ) ;
892+
893+ test ( 'DELETE returns 400 when blockIds exceeds maxItems' , async ( ) => {
894+ const tooMany = Array . from ( { length : 501 } , ( _ , i ) => `block-${ i } ` ) ;
895+ const response = await superDelete ( '/account/reset-module' ) . send ( {
896+ blockIds : tooMany
897+ } ) ;
898+
899+ expect ( response . status ) . toBe ( 400 ) ;
900+ } ) ;
901+
902+ test ( 'DELETE returns 200 with removedChallengeIds' , async ( ) => {
903+ const response = await superDelete ( '/account/reset-module' ) . send ( {
904+ blockIds : [ 'block-one' ]
905+ } ) ;
906+
907+ expect ( response . status ) . toBe ( 200 ) ;
908+ expect ( response . body ) . toStrictEqual ( {
909+ removedChallengeIds : expect . arrayContaining ( [
910+ 'block-one-challenge-1' ,
911+ 'block-one-challenge-2'
912+ ] )
913+ } ) ;
914+ } ) ;
915+
916+ test ( 'DELETE removes only challenges from the specified block' , async ( ) => {
917+ await superDelete ( '/account/reset-module' ) . send ( {
918+ blockIds : [ 'block-one' ]
919+ } ) ;
920+
921+ const user = await fastifyTestInstance . prisma . user . findFirst ( {
922+ where : { email : testUserData . email }
923+ } ) ;
924+
925+ expect ( user ?. completedChallenges ) . toHaveLength ( 2 ) ;
926+ const challengeIds = (
927+ user ?. completedChallenges as { id : string } [ ]
928+ ) . map ( c => c . id ) ;
929+ expect ( challengeIds ) . toContain ( 'block-two-challenge-1' ) ;
930+ expect ( challengeIds ) . toContain ( 'block-two-challenge-2' ) ;
931+ expect ( challengeIds ) . not . toContain ( 'block-one-challenge-1' ) ;
932+ expect ( challengeIds ) . not . toContain ( 'block-one-challenge-2' ) ;
933+ } ) ;
934+
935+ test ( 'DELETE removes saved challenges from the specified block' , async ( ) => {
936+ await superDelete ( '/account/reset-module' ) . send ( {
937+ blockIds : [ 'block-one' ]
938+ } ) ;
939+
940+ const user = await fastifyTestInstance . prisma . user . findFirst ( {
941+ where : { email : testUserData . email }
942+ } ) ;
943+
944+ expect ( user ?. savedChallenges ) . toHaveLength ( 0 ) ;
945+ } ) ;
946+
947+ test ( 'DELETE removes partially completed challenges from the specified block' , async ( ) => {
948+ await superDelete ( '/account/reset-module' ) . send ( {
949+ blockIds : [ 'block-one' ]
950+ } ) ;
951+
952+ const user = await fastifyTestInstance . prisma . user . findFirst ( {
953+ where : { email : testUserData . email }
954+ } ) ;
955+
956+ expect ( user ?. partiallyCompletedChallenges ) . toHaveLength ( 0 ) ;
957+ } ) ;
958+
959+ test ( 'DELETE keeps certifications intact' , async ( ) => {
960+ await superDelete ( '/account/reset-module' ) . send ( {
961+ blockIds : [ 'block-one' ]
962+ } ) ;
963+
964+ const user = await fastifyTestInstance . prisma . user . findFirst ( {
965+ where : { email : testUserData . email }
966+ } ) ;
967+
968+ expect ( user ?. isRespWebDesignCert ) . toBe ( true ) ;
969+ } ) ;
970+
971+ test ( 'DELETE keeps progress timestamps intact' , async ( ) => {
972+ const userBefore = await fastifyTestInstance . prisma . user . findFirst ( {
973+ where : { email : testUserData . email }
974+ } ) ;
975+
976+ await superDelete ( '/account/reset-module' ) . send ( {
977+ blockIds : [ 'block-one' ]
978+ } ) ;
979+
980+ const userAfter = await fastifyTestInstance . prisma . user . findFirst ( {
981+ where : { email : testUserData . email }
982+ } ) ;
983+
984+ expect ( userAfter ?. progressTimestamps ) . toEqual (
985+ userBefore ?. progressTimestamps
986+ ) ;
987+ } ) ;
988+
989+ test ( 'DELETE does not delete userTokens' , async ( ) => {
990+ await fastifyTestInstance . prisma . userToken . create ( {
991+ data : {
992+ created : new Date ( ) ,
993+ id : '123' ,
994+ ttl : 1000 ,
995+ userId : defaultUserId
996+ }
997+ } ) ;
998+
999+ await superDelete ( '/account/reset-module' ) . send ( {
1000+ blockIds : [ 'block-one' ]
1001+ } ) ;
1002+
1003+ expect ( await fastifyTestInstance . prisma . userToken . count ( ) ) . toBe ( 1 ) ;
1004+
1005+ await fastifyTestInstance . prisma . userToken . deleteMany ( {
1006+ where : { userId : defaultUserId }
1007+ } ) ;
1008+ } ) ;
1009+
1010+ test ( 'DELETE does not delete surveys' , async ( ) => {
1011+ await fastifyTestInstance . prisma . survey . create ( {
1012+ data : {
1013+ userId : defaultUserId ,
1014+ title : 'Test Survey' ,
1015+ responses : [ ]
1016+ }
1017+ } ) ;
1018+
1019+ await superDelete ( '/account/reset-module' ) . send ( {
1020+ blockIds : [ 'block-one' ]
1021+ } ) ;
1022+
1023+ expect ( await fastifyTestInstance . prisma . survey . count ( ) ) . toBe ( 1 ) ;
1024+
1025+ await fastifyTestInstance . prisma . survey . deleteMany ( {
1026+ where : { userId : defaultUserId }
1027+ } ) ;
1028+ } ) ;
1029+
1030+ test ( 'DELETE handles multiple blocks in a single call' , async ( ) => {
1031+ const response = await superDelete ( '/account/reset-module' ) . send ( {
1032+ blockIds : [ 'block-one' , 'block-two' ]
1033+ } ) ;
1034+
1035+ expect ( response . status ) . toBe ( 200 ) ;
1036+ expect ( response . body . removedChallengeIds ) . toEqual (
1037+ expect . arrayContaining ( [
1038+ 'block-one-challenge-1' ,
1039+ 'block-one-challenge-2' ,
1040+ 'block-two-challenge-1' ,
1041+ 'block-two-challenge-2'
1042+ ] )
1043+ ) ;
1044+
1045+ const user = await fastifyTestInstance . prisma . user . findFirst ( {
1046+ where : { email : testUserData . email }
1047+ } ) ;
1048+
1049+ expect ( user ?. completedChallenges ) . toHaveLength ( 0 ) ;
1050+ } ) ;
1051+
1052+ test ( 'DELETE dedupes overlapping blockIds' , async ( ) => {
1053+ const response = await superDelete ( '/account/reset-module' ) . send ( {
1054+ blockIds : [ 'block-one' , 'block-one' ]
1055+ } ) ;
1056+
1057+ expect ( response . status ) . toBe ( 200 ) ;
1058+ expect ( response . body . removedChallengeIds ) . toHaveLength ( 2 ) ;
1059+ } ) ;
1060+
1061+ test ( 'DELETE proceeds when only some blockIds are valid' , async ( ) => {
1062+ const response = await superDelete ( '/account/reset-module' ) . send ( {
1063+ blockIds : [ 'block-one' , 'non-existent-block' ]
1064+ } ) ;
1065+
1066+ expect ( response . status ) . toBe ( 200 ) ;
1067+ expect ( response . body . removedChallengeIds ) . toEqual (
1068+ expect . arrayContaining ( [
1069+ 'block-one-challenge-1' ,
1070+ 'block-one-challenge-2'
1071+ ] )
1072+ ) ;
1073+ } ) ;
1074+
1075+ test ( 'DELETE only affects the authenticated user' , async ( ) => {
1076+ await fastifyTestInstance . prisma . user . create ( {
1077+ data : {
1078+ ...testUserData ,
1079+ email : 'another@user.com' ,
1080+ completedChallenges : testChallengesBlockOne
1081+ }
1082+ } ) ;
1083+
1084+ await superDelete ( '/account/reset-module' ) . send ( {
1085+ blockIds : [ 'block-one' ]
1086+ } ) ;
1087+
1088+ const otherUser = await fastifyTestInstance . prisma . user . findFirst ( {
1089+ where : { email : 'another@user.com' }
1090+ } ) ;
1091+
1092+ expect ( otherUser ?. completedChallenges ) . toHaveLength ( 2 ) ;
1093+
1094+ await fastifyTestInstance . prisma . user . deleteMany ( {
1095+ where : { email : 'another@user.com' }
1096+ } ) ;
1097+ } ) ;
1098+
1099+ test ( 'DELETE returns 400 for non-existent blockId' , async ( ) => {
1100+ const response = await superDelete ( '/account/reset-module' ) . send ( {
1101+ blockIds : [ 'non-existent-block' ]
1102+ } ) ;
1103+
1104+ expect ( response . status ) . toBe ( 400 ) ;
1105+
1106+ const user = await fastifyTestInstance . prisma . user . findFirst ( {
1107+ where : { email : testUserData . email }
1108+ } ) ;
1109+
1110+ expect ( user ?. completedChallenges ) . toHaveLength ( 4 ) ;
1111+ } ) ;
1112+ } ) ;
1113+
7741114 describe ( '/user/user-token' , ( ) => {
7751115 beforeEach ( async ( ) => {
7761116 await fastifyTestInstance . prisma . userToken . create ( {
@@ -1611,6 +1951,7 @@ Thanks and regards,
16111951 { path : `/users/${ otherUserId } ` , method : 'DELETE' } ,
16121952 { path : '/account/delete' , method : 'POST' } ,
16131953 { path : '/account/reset-progress' , method : 'POST' } ,
1954+ { path : '/account/reset-module' , method : 'DELETE' } ,
16141955 { path : '/user/user-token' , method : 'DELETE' } ,
16151956 { path : '/user/user-token' , method : 'POST' } ,
16161957 { path : '/user/ms-username' , method : 'DELETE' } ,
0 commit comments