Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/src/challenge-attempt/challenge-attempt.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { UsersModule } from '../users/users.module';
import { ChallengeAttemptService } from './providers/challenge-attempt.service';
import { ChallengeAttemptController } from './controllers/challenge-attempt.controller';
import { ChallengeValidationService } from './providers/challenge-validation.service';
import { IdempotencyModule } from '../common/idempotency/idempotency.module';

@Module({
imports: [
Expand All @@ -24,6 +25,7 @@ import { ChallengeValidationService } from './providers/challenge-validation.ser
// ChallengeAttempt entity).
TypeOrmModule.forFeature([ChallengeAttempt, Puzzle, GameSession]),
UsersModule, // for XpLevelService
IdempotencyModule,
],
controllers: [ChallengeAttemptController],
providers: [ChallengeAttemptService, ChallengeValidationService],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Body,
Controller,
Get,
Headers,
HttpCode,
HttpStatus,
Param,
Expand All @@ -14,6 +15,7 @@ import {
import { AuthGuard } from '@nestjs/passport';
import {
ApiBearerAuth,
ApiHeader,
ApiOperation,
ApiParam,
ApiResponse,
Expand Down Expand Up @@ -87,7 +89,14 @@ export class ChallengeAttemptController {
'the attempt, records progress, calculates score and XP, selects the ' +
'next challenge, and detects session completion. Idempotent: a repeat ' +
'submit on an already-graded attempt returns the original cached result ' +
'(isDuplicateReplay: true) instead of re-running the pipeline.',
'(isDuplicateReplay: true) instead of re-running the pipeline. ' +
'Supports Redis-based idempotency via the Idempotency-Key header or the idempotencyKey body field.',
})
@ApiHeader({
name: 'Idempotency-Key',
required: false,
description:
'Client-generated idempotency key (UUID v4 recommended). Prevents duplicate XP awards, session advances, and reward eligibility on retries.',
})
@ApiResponse({
status: 200,
Expand All @@ -102,7 +111,12 @@ export class ChallengeAttemptController {
async submitAttempt(
@ActiveUser() user: ActiveUserData,
@Body() dto: SubmitAttemptDto,
@Headers('Idempotency-Key') idempotencyKey?: string,
): Promise<SubmitAttemptResponseDto> {
// Header takes precedence over body field for idempotency key.
if (idempotencyKey && !dto.idempotencyKey) {
dto.idempotencyKey = idempotencyKey;
}
return this.challengeAttemptService.submitAttempt(
dto,
this.requireUserId(user),
Expand Down
20 changes: 19 additions & 1 deletion backend/src/challenge-attempt/dtos/submit-attempt.dto.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import { IsInt, IsNotEmpty, IsString, IsUUID, Min } from 'class-validator';
import { IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Min } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';

/**
* DTO for submitting an answer to an existing attempt.
*
* timeSpent is in seconds, measured client-side from when the player
* opened the challenge to when they pressed submit.
*
* idempotencyKey is optional but strongly recommended. When provided,
* the backend guarantees the same submission cannot award XP, advance
* a session, or create duplicate rewards even if the request is sent
* multiple times (double-click, network retry, browser refresh).
*
* If omitted, a deterministic key is derived from the attemptId,
* answer, and timeSpent — but a client-generated UUID v4 is preferred
* for true idempotency across retries with the same logical submission.
*/
export class SubmitAttemptDto {
@IsUUID('4', { message: 'attemptId must be a valid UUID v4' })
Expand All @@ -17,4 +27,12 @@ export class SubmitAttemptDto {
@IsInt()
@Min(0, { message: 'timeSpent must be a non-negative integer (seconds)' })
timeSpent: number;

@ApiPropertyOptional({
description:
'Client-generated idempotency key (UUID v4 recommended). Prevents duplicate XP awards, session advances, and reward eligibility on retries.',
})
@IsOptional()
@IsString()
idempotencyKey?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { CreateChallengeAttemptDto } from '../dtos/create-challenge-attempt.dto'
import { SubmitAttemptDto } from '../dtos/submit-attempt.dto';
import { RevealSolutionDto } from '../dtos/reveal-solution.dto';
import { UseHintDto } from '../dtos/use-hint.dto';
import { SubmitAttemptResponseDto } from '../dtos/submit-attempt-response.dto';
import { IdempotencyService } from '../../common/idempotency/idempotency.service';
import {
afterEach,
beforeEach,
Expand Down Expand Up @@ -140,6 +142,7 @@ describe('ChallengeAttemptService', () => {
let service: ChallengeAttemptService;
let attemptRepo: jest.Mocked<Repository<ChallengeAttempt>>;
let puzzleRepo: jest.Mocked<Repository<Puzzle>>;
let idempotencyService: { execute: jest.Mock<any> };
let xpLevelService: jest.Mocked<Pick<XpLevelService, 'addXp'>>;
let dataSource: { transaction: jest.Mock };

Expand All @@ -162,6 +165,9 @@ describe('ChallengeAttemptService', () => {
findOneBy: jest.fn(),
};

const mockIdempotencyService = {
execute: jest.fn(),
};
xpLevelService = { addXp: jest.fn() };
dataSource = { transaction: jest.fn() };

Expand All @@ -179,6 +185,10 @@ describe('ChallengeAttemptService', () => {
provide: getRepositoryToken(Puzzle),
useValue: mockPuzzleRepo,
},
{
provide: IdempotencyService,
useValue: mockIdempotencyService,
},
{
// Read-only lookup in resolveSessionTarget(); no test currently
// exercises a real GameSession row, so a bare jest.fn() (always
Expand All @@ -196,6 +206,7 @@ describe('ChallengeAttemptService', () => {
service = module.get<ChallengeAttemptService>(ChallengeAttemptService);
attemptRepo = module.get(getRepositoryToken(ChallengeAttempt));
puzzleRepo = module.get(getRepositoryToken(Puzzle));
idempotencyService = module.get(IdempotencyService);
});

afterEach(() => {
Expand Down Expand Up @@ -282,6 +293,37 @@ describe('ChallengeAttemptService', () => {
};
const userId = 'user-1';

/** Sets up idempotencyService.execute to simply call the inner fn (first request, not a duplicate). */
beforeEach(() => {
idempotencyService.execute!.mockImplementation(
async (key: string, fn: () => Promise<any>) => {
const data = await fn();
return { duplicate: false, data };
},
);
});

/** Helper: sets up idempotencyService.execute to call the fn (first request). */
function mockFirstRequest(attempt: ChallengeAttempt, puzzle: Puzzle, savedAttempt: ChallengeAttempt) {
idempotencyService.execute!.mockImplementation(
async (key: string, fn: () => Promise<ChallengeAttempt>) => {
attemptRepo.findOneBy!.mockResolvedValue(attempt);
puzzleRepo.findOneBy!.mockResolvedValue(puzzle);
attemptRepo.save!.mockResolvedValue(savedAttempt);
const data = await fn();
return { duplicate: false, data };
},
);
}

/** Helper: sets up idempotencyService.execute to return a cached result (duplicate). */
function mockDuplicateRequest(cachedAttempt: ChallengeAttempt) {
idempotencyService.execute!.mockResolvedValue({
duplicate: true,
data: cachedAttempt,
});
}

it('should mark attempt CORRECT, award score, XP, and record progress for a correct answer', async () => {
const attempt = makeAttempt({ sessionId: undefined });
const puzzle = makePuzzle();
Expand Down Expand Up @@ -454,6 +496,16 @@ describe('ChallengeAttemptService', () => {
};
const userId = 'user-1';

/** Sets up idempotencyService.execute to simply call the inner fn (first request, not a duplicate). */
beforeEach(() => {
idempotencyService.execute!.mockImplementation(
async (key: string, fn: () => Promise<any>) => {
const data = await fn();
return { duplicate: false, data };
},
);
});

it('excludes already-attempted-in-session and the current challenge from next-challenge selection', async () => {
const attempt = makeAttempt({ sessionId: 'sess-1' });
const puzzle = makePuzzle();
Expand Down Expand Up @@ -634,6 +686,201 @@ describe('ChallengeAttemptService', () => {
);
expect(progressSaves).toHaveLength(1);
});

// ─────────────────────────────────────────────────────────────────────────
// Idempotency tests
// ─────────────────────────────────────────────────────────────────────────

describe('idempotency', () => {
it('should return cached result for a duplicate submission with the same idempotencyKey', async () => {
const cachedResult: SubmitAttemptResponseDto = {
attempt: makeAttempt({
status: AttemptStatus.CORRECT,
score: 125,
answer: '4',
submittedAt: new Date(),
}),
isCorrect: true,
feedback: 'Correct!',
xp: { awarded: 125, levelUp: false, currentLevel: 1, currentXp: 0 },
progress: { attemptsInSession: 1, sessionTarget: 5, sessionCompleted: false },
nextChallenge: null,
isDuplicateReplay: false,
};

idempotencyService.execute!.mockResolvedValue({
duplicate: true,
data: cachedResult,
});

const result = await service.submitAttempt({
...dto,
idempotencyKey: 'idempotency-key-abc',
}, userId);

expect(result).toBe(cachedResult);
expect(result.isCorrect).toBe(true);
expect(result.attempt.score).toBe(125);
// The inner function should NOT have touched the repos
expect(dataSource.transaction).not.toHaveBeenCalled();
});

it('should derive a deterministic key when idempotencyKey is not provided', async () => {
const attempt = makeAttempt({ sessionId: undefined });
const puzzle = makePuzzle();
const manager = makeMockManager({ attempt, puzzle, nextPuzzleCandidates: [] });
dataSource.transaction.mockImplementation((cb: any) => cb(manager));
xpLevelService.addXp.mockResolvedValue({
levelUp: false,
currentLevel: 1,
currentXp: 100,
previousLevel: 1,
});

// Mock idempotencyService.execute to call the inner function
idempotencyService.execute!.mockImplementation(
async (key: string, fn: () => Promise<SubmitAttemptResponseDto>) => {
const data = await fn();
return { duplicate: false, data };
},
);

await service.submitAttempt(dto, userId);

// Verify idempotencyService.execute was called with a derived key
expect(idempotencyService.execute).toHaveBeenCalledWith(
expect.stringMatching(/^attempt-submit:[a-f0-9]{32}$/),
expect.any(Function),
);
});

it('should use the client-provided idempotencyKey as the Redis key', async () => {
const attempt = makeAttempt({ sessionId: undefined });
const puzzle = makePuzzle();
const manager = makeMockManager({ attempt, puzzle, nextPuzzleCandidates: [] });
dataSource.transaction.mockImplementation((cb: any) => cb(manager));
xpLevelService.addXp.mockResolvedValue({
levelUp: false,
currentLevel: 1,
currentXp: 100,
previousLevel: 1,
});

idempotencyService.execute!.mockImplementation(
async (key: string, fn: () => Promise<SubmitAttemptResponseDto>) => {
const data = await fn();
return { duplicate: false, data };
},
);

const customKey = 'my-custom-idempotency-key';
await service.submitAttempt({
...dto,
idempotencyKey: customKey,
}, userId);

expect(idempotencyService.execute).toHaveBeenCalledWith(
`attempt-submit:${customKey}`,
expect.any(Function),
);
});

it('should prevent double XP awards on duplicate submissions', async () => {
const cachedResult: SubmitAttemptResponseDto = {
attempt: makeAttempt({
status: AttemptStatus.CORRECT,
score: 200,
answer: '4',
submittedAt: new Date(),
}),
isCorrect: true,
feedback: 'Correct!',
xp: { awarded: 200, levelUp: false, currentLevel: 1, currentXp: 0 },
progress: { attemptsInSession: 1, sessionTarget: 5, sessionCompleted: false },
nextChallenge: null,
isDuplicateReplay: false,
};

// First submission
idempotencyService.execute!.mockResolvedValueOnce({
duplicate: false,
data: cachedResult,
});
const result1 = await service.submitAttempt({
...dto,
idempotencyKey: 'duplicate-xp-test',
}, userId);
expect(result1.isCorrect).toBe(true);
expect(result1.attempt.score).toBe(200);

// Second submission with the same key — should return cached, no re-grading
idempotencyService.execute!.mockResolvedValueOnce({
duplicate: true,
data: cachedResult,
});
const result2 = await service.submitAttempt({
...dto,
idempotencyKey: 'duplicate-xp-test',
}, userId);
expect(result2).toBe(cachedResult);
expect(result2.attempt.score).toBe(200);

// The transaction should NOT have been called for the duplicate
expect(dataSource.transaction).not.toHaveBeenCalled();
});

it('should allow different idempotencyKeys for different submissions', async () => {
const attempt1 = makeAttempt({ sessionId: undefined });
const attempt2 = makeAttempt(); // fresh mutable attempt
const puzzle = makePuzzle();

const manager1 = makeMockManager({ attempt: attempt1, puzzle, nextPuzzleCandidates: [] });
const manager2 = makeMockManager({ attempt: attempt2, puzzle, nextPuzzleCandidates: [] });

// First call with key-1
dataSource.transaction.mockImplementationOnce((cb: any) => cb(manager1));
xpLevelService.addXp.mockResolvedValue({
levelUp: false,
currentLevel: 1,
currentXp: 100,
previousLevel: 1,
});
idempotencyService.execute!.mockImplementationOnce(
async (key: string, fn: () => Promise<SubmitAttemptResponseDto>) => {
const data = await fn();
return { duplicate: false, data };
},
);

const result1 = await service.submitAttempt({
...dto,
idempotencyKey: 'key-1',
}, userId);
expect(result1.isCorrect).toBe(true);

// Second call with key-2 — different idempotency key
dataSource.transaction.mockImplementationOnce((cb: any) => cb(manager2));
xpLevelService.addXp.mockResolvedValue({
levelUp: false,
currentLevel: 1,
currentXp: 100,
previousLevel: 1,
});
idempotencyService.execute!.mockImplementationOnce(
async (key: string, fn: () => Promise<SubmitAttemptResponseDto>) => {
const data = await fn();
return { duplicate: false, data };
},
);

const result2 = await service.submitAttempt({
...dto,
answer: 'wrong',
idempotencyKey: 'key-2',
}, userId);
expect(result2.isCorrect).toBe(false);
});
});
});

// ─────────────────────────────────────────────────────────────────────────────
Expand Down
Loading
Loading