diff --git a/BackendAcademy/src/social/challenge.service.spec.ts b/BackendAcademy/src/social/challenge.service.spec.ts index 475dd6a1a1..da6481c222 100644 --- a/BackendAcademy/src/social/challenge.service.spec.ts +++ b/BackendAcademy/src/social/challenge.service.spec.ts @@ -199,4 +199,87 @@ describe('ChallengeService', () => { expect(pending[0].challengeId).toBe('wk-1'); }); }); + + // ── BA-057: Bound challenge submission attempts ────────────────────────── + + describe('BA-057 — Bound challenge submission attempts', () => { + it('enforces default retry limit: tests before, at, and beyond the limit', () => { + // Default limit is 3 attempts + expect(service.getMaxAttempts('wk-1')).toBe(3); + + // 1. Before the limit (attempts 1 and 2) + const sub1 = service.submit('wk-1', { userId: 'alice', submissionId: 'sub-1' }); + expect(sub1.submissionId).toBe('sub-1'); + expect(service.getAttempts('wk-1', 'alice')).toBe(1); + + const sub2 = service.submit('wk-1', { userId: 'alice', submissionId: 'sub-2' }); + expect(sub2.submissionId).toBe('sub-2'); + expect(service.getAttempts('wk-1', 'alice')).toBe(2); + + // 2. Exactly at the limit (attempt 3) + const sub3 = service.submit('wk-1', { userId: 'alice', submissionId: 'sub-3' }); + expect(sub3.submissionId).toBe('sub-3'); + expect(service.getAttempts('wk-1', 'alice')).toBe(3); + + // 3. Beyond the limit (attempt 4) + expect(() => + service.submit('wk-1', { userId: 'alice', submissionId: 'sub-4' }), + ).toThrow(BadRequestException); + + // Verify attempts counter remains at 3 + expect(service.getAttempts('wk-1', 'alice')).toBe(3); + }); + + it('enforces custom configured retry limit: tests before, at, and beyond limit', () => { + // Create a challenge with maxAttempts = 2 + service.create({ + challengeId: 'custom-bound', + title: 'Strict Retry Challenge', + potStroops: POT, + maxAttempts: 2, + }); + + expect(service.getMaxAttempts('custom-bound')).toBe(2); + + // Before limit (attempt 1) + service.submit('custom-bound', { userId: 'coder', submissionId: 'c-sub-1' }); + expect(service.getAttempts('custom-bound', 'coder')).toBe(1); + + // At limit (attempt 2) + service.submit('custom-bound', { userId: 'coder', submissionId: 'c-sub-2' }); + expect(service.getAttempts('custom-bound', 'coder')).toBe(2); + + // Beyond limit (attempt 3) -> rejected + expect(() => + service.submit('custom-bound', { userId: 'coder', submissionId: 'c-sub-3' }), + ).toThrow(BadRequestException); + }); + + it('does not impact other users attempts', () => { + service.submit('wk-1', { userId: 'u1', submissionId: 'u1-1' }); + service.submit('wk-1', { userId: 'u1', submissionId: 'u1-2' }); + service.submit('wk-1', { userId: 'u1', submissionId: 'u1-3' }); + + // u1 is exhausted + expect(() => service.submit('wk-1', { userId: 'u1', submissionId: 'u1-4' })).toThrow( + BadRequestException, + ); + + // u2 can still submit + expect(service.submit('wk-1', { userId: 'u2', submissionId: 'u2-1' }).submissionId).toBe( + 'u2-1', + ); + }); + + it('rejects creation with maxAttempts less than 1', () => { + expect(() => + service.create({ + challengeId: 'bad-limit', + title: 'Zero Limit', + potStroops: POT, + maxAttempts: 0, + }), + ).toThrow(BadRequestException); + }); + }); }); diff --git a/BackendAcademy/src/social/challenge.service.ts b/BackendAcademy/src/social/challenge.service.ts index 373c31bfdb..a617b2573b 100644 --- a/BackendAcademy/src/social/challenge.service.ts +++ b/BackendAcademy/src/social/challenge.service.ts @@ -13,6 +13,9 @@ const ALLOWED_TRANSITIONS: Record = { closed: [], }; +/** Default submission attempt retry limit per user per challenge (BA-057). */ +export const DEFAULT_MAX_CHALLENGE_ATTEMPTS = 3; + @Injectable() export class ChallengeService { private readonly challenges = new Map(); @@ -21,14 +24,29 @@ export class ChallengeService { private readonly votes = new Map>(); /** Payout requests awaiting the BE-053 job. */ private readonly payoutQueue: ChallengePayout[] = []; - - create(input: { challengeId: string; title: string; potStroops: bigint }): Challenge { + /** challengeId → (userId → attemptCount) */ + private readonly attempts = new Map>(); + /** challengeId → configured max attempts */ + private readonly maxAttemptsConfig = new Map(); + + create(input: { + challengeId: string; + title: string; + potStroops: bigint; + maxAttempts?: number; + }): Challenge { if (this.challenges.has(input.challengeId)) { throw new BadRequestException(`Challenge ${input.challengeId} already exists`); } if (input.potStroops < 0n) { throw new BadRequestException('potStroops cannot be negative'); } + if (input.maxAttempts !== undefined && input.maxAttempts < 1) { + throw new BadRequestException('maxAttempts must be at least 1'); + } + + const maxAttempts = input.maxAttempts ?? DEFAULT_MAX_CHALLENGE_ATTEMPTS; + this.maxAttemptsConfig.set(input.challengeId, maxAttempts); const challenge: Challenge = { challengeId: input.challengeId, @@ -47,15 +65,36 @@ export class ChallengeService { return challenge; } - /** Entries arrive from the task pipeline; accepted only while `open`. */ + getMaxAttempts(challengeId: string): number { + return this.maxAttemptsConfig.get(challengeId) ?? DEFAULT_MAX_CHALLENGE_ATTEMPTS; + } + + getAttempts(challengeId: string, userId: string): number { + return this.attempts.get(challengeId)?.get(userId) ?? 0; + } + + /** Entries arrive from the task pipeline; accepted only while `open` and within retry limit. */ submit(challengeId: string, input: { userId: string; submissionId: string }): ChallengeSubmission { this.assertState(challengeId, 'open', 'accept submissions'); + const maxLimit = this.maxAttemptsConfig.get(challengeId) ?? DEFAULT_MAX_CHALLENGE_ATTEMPTS; + const challengeAttempts = this.attempts.get(challengeId) ?? new Map(); + const currentAttempts = challengeAttempts.get(input.userId) ?? 0; + + if (currentAttempts >= maxLimit) { + throw new BadRequestException( + `User ${input.userId} exceeded maximum allowed submission attempts (${maxLimit}) for challenge ${challengeId}`, + ); + } + const existing = this.submissions.get(challengeId) ?? []; if (existing.some((s) => s.submissionId === input.submissionId)) { throw new BadRequestException(`Submission ${input.submissionId} already entered`); } + challengeAttempts.set(input.userId, currentAttempts + 1); + this.attempts.set(challengeId, challengeAttempts); + const submission: ChallengeSubmission = { ...input, challengeId, diff --git a/BackendAcademy/src/social/dto/challenge.dto.ts b/BackendAcademy/src/social/dto/challenge.dto.ts index b868b0f193..7670b392a9 100644 --- a/BackendAcademy/src/social/dto/challenge.dto.ts +++ b/BackendAcademy/src/social/dto/challenge.dto.ts @@ -1,4 +1,4 @@ -import { IsInt, IsNotEmpty, IsString, MaxLength, Min } from 'class-validator'; +import { IsInt, IsNotEmpty, IsOptional, IsString, MaxLength, Min } from 'class-validator'; export class CreateChallengeDto { @IsString() @@ -14,6 +14,12 @@ export class CreateChallengeDto { @IsInt() @Min(0) potStroops: number; + + /** Maximum allowed submission attempts per user (defaults to 3). */ + @IsOptional() + @IsInt() + @Min(1) + maxAttempts?: number; } export class SubmitChallengeDto {