Skip to content

Commit 5a2606d

Browse files
authored
feat(client,api): add a per module reset (freeCodeCamp#62547)
1 parent 473d660 commit 5a2606d

29 files changed

Lines changed: 2414 additions & 175 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,4 @@ api/logs/
203203

204204
### Turborepo
205205
.turbo
206+
test-results

api/src/routes/protected/user.test.ts

Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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';
3839
import { getMsTranscriptApiUrl } from './user.js';
3940

4041
const 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

Comments
 (0)