Skip to content
Open
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
83 changes: 83 additions & 0 deletions BackendAcademy/src/social/challenge.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
45 changes: 42 additions & 3 deletions BackendAcademy/src/social/challenge.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ const ALLOWED_TRANSITIONS: Record<ChallengeState, ChallengeState[]> = {
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<string, Challenge>();
Expand All @@ -21,14 +24,29 @@ export class ChallengeService {
private readonly votes = new Map<string, Map<string, string>>();
/** 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<string, Map<string, number>>();
/** challengeId → configured max attempts */
private readonly maxAttemptsConfig = new Map<string, number>();

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,
Expand All @@ -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<string, number>();
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,
Expand Down
8 changes: 7 additions & 1 deletion BackendAcademy/src/social/dto/challenge.dto.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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 {
Expand Down