Skip to content

Commit e82bf96

Browse files
committed
PM-5231: Queue validation scorer submissions
What was broken Challenge managers and copilots had no Marathon Match API endpoint to upload a synthetic submission and run scorer validation before challenge launch. Root cause The existing rerun endpoints only reused latest real submissions or existing system reviews, and they required active/open challenge state. What was changed Added a multipart validation upload endpoint that creates a clean Review API validation submission and launches one ECS scorer task against a saved phase config. Expanded the challenge resource guard to allow Copilot and Manager resource assignments for scorer operations, and added request/response DTOs. Any added/updated tests Added service coverage for validation upload dispatch and tester-not-ready rejection. Added guard and route metadata coverage for manager/copilot access.
1 parent af07d25 commit e82bf96

7 files changed

Lines changed: 662 additions & 34 deletions

src/api/marathon-match-config/marathon-match-config.controller.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,18 @@ import {
1010
Put,
1111
Query,
1212
Res,
13+
UploadedFile,
1314
UseGuards,
1415
UseInterceptors,
1516
} from '@nestjs/common';
17+
import { FileInterceptor } from '@nestjs/platform-express';
18+
import { PhaseConfigType } from '@prisma/client';
1619
import { Response } from 'express';
20+
import { memoryStorage } from 'multer';
1721
import {
1822
ApiBearerAuth,
1923
ApiBody,
24+
ApiConsumes,
2025
ApiOperation,
2126
ApiParam,
2227
ApiQuery,
@@ -31,6 +36,8 @@ import {
3136
RerunResponseDto,
3237
SearchMarathonMatchConfigQueryDto,
3338
SystemRerunResponseDto,
39+
TestSubmissionResponseDto,
40+
TestSubmissionUploadDto,
3441
UpdateMarathonMatchConfigDto,
3542
} from 'src/dto/marathon-match-config.dto';
3643
import { PaginationHeaderInterceptor } from 'src/interceptors/PaginationHeaderInterceptor';
@@ -193,6 +200,93 @@ export class MarathonMatchConfigController {
193200
);
194201
}
195202

203+
/**
204+
* Uploads a validation submission and queues scorer execution through ECS.
205+
* @param challengeId Challenge ID.
206+
* @param body Multipart form fields for validation scoring.
207+
* @param file Uploaded submission ZIP.
208+
* @param user Authenticated user for authorization and member fallback.
209+
* @returns Created submission and ECS task launch details.
210+
*/
211+
@Post('/:challengeId/test-submission')
212+
@Roles(
213+
UserRole.Admin,
214+
UserRole.Copilot,
215+
UserRole.ProjectManager,
216+
UserRole.User,
217+
)
218+
@Scopes(Scope.UpdateMarathonMatch)
219+
@UseGuards(ChallengeCopilotResourceGuard)
220+
@HttpCode(HttpStatus.ACCEPTED)
221+
@ApiOperation({
222+
summary: 'Upload and score a Marathon Match validation submission',
223+
description:
224+
'Roles: Admin or challenge Copilot/Manager resource | Scopes: update:marathon-match',
225+
})
226+
@ApiParam({
227+
name: 'challengeId',
228+
description:
229+
'The challenge ID for the marathon match validation submission request',
230+
example: '30000123',
231+
})
232+
@ApiConsumes('multipart/form-data')
233+
@UseInterceptors(
234+
FileInterceptor('file', {
235+
storage: memoryStorage(),
236+
}),
237+
)
238+
@ApiBody({
239+
required: true,
240+
type: 'multipart/form-data',
241+
schema: {
242+
type: 'object',
243+
properties: {
244+
file: {
245+
type: 'string',
246+
format: 'binary',
247+
},
248+
configType: {
249+
type: 'string',
250+
enum: Object.values(PhaseConfigType),
251+
default: PhaseConfigType.PROVISIONAL,
252+
},
253+
memberId: {
254+
type: 'string',
255+
},
256+
fileName: {
257+
type: 'string',
258+
},
259+
},
260+
required: ['file'],
261+
},
262+
})
263+
@ApiResponse({
264+
status: 202,
265+
description:
266+
'Validation submission created and queued for asynchronous scoring.',
267+
type: TestSubmissionResponseDto,
268+
})
269+
@ApiResponse({
270+
status: 400,
271+
description:
272+
'Missing file, tester is not compiled successfully, or requested phase config is missing.',
273+
})
274+
@ApiResponse({ status: 403, description: 'Forbidden.' })
275+
@ApiResponse({ status: 404, description: 'Marathon match config not found.' })
276+
async uploadTestSubmission(
277+
@Param('challengeId') challengeId: string,
278+
@Body() body: TestSubmissionUploadDto,
279+
@UploadedFile() file: Express.Multer.File,
280+
@User() user: JwtUser,
281+
): Promise<TestSubmissionResponseDto> {
282+
return await this.marathonMatchConfigService.uploadTestSubmission(
283+
challengeId,
284+
body,
285+
file,
286+
user,
287+
);
288+
}
289+
196290
/**
197291
* Lists marathon match configurations with optional filters and pagination.
198292
* @param query Search and pagination query parameters.

src/api/marathon-match-config/marathon-match-config.service.spec.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ describe('MarathonMatchConfigService', () => {
2727
const createService = () => {
2828
const httpService = {
2929
get: jest.fn(),
30+
post: jest.fn(),
3031
};
3132
const ecsService = {
3233
launchScorerTask: jest.fn(),
@@ -426,6 +427,150 @@ describe('MarathonMatchConfigService', () => {
426427
);
427428
});
428429

430+
it('uploads a validation submission and queues scorer execution', async () => {
431+
const { service, ecsService, httpService, m2mService, prisma } =
432+
createService();
433+
const user = {
434+
isMachine: false,
435+
userId: '40051399',
436+
} as never;
437+
438+
prisma.marathonMatchConfig.findUnique.mockResolvedValue({
439+
id: 'config-1',
440+
challengeId: '30000123',
441+
active: true,
442+
submissionApiUrl: 'https://submissions.example.com/v6',
443+
testerId: 'tester-1',
444+
taskDefinitionName: 'mm-runner',
445+
taskDefinitionVersion: '7',
446+
tester: {
447+
compilationStatus: CompilationStatus.SUCCESS,
448+
},
449+
phaseConfigs: [
450+
{
451+
id: 'phase-provisional',
452+
configType: PhaseConfigType.PROVISIONAL,
453+
phaseId: 'provisional-phase',
454+
startSeed: BigInt(100),
455+
numberOfTests: 50,
456+
},
457+
],
458+
});
459+
m2mService.getM2MToken.mockResolvedValue('m2m-token');
460+
httpService.post.mockReturnValue(
461+
of({
462+
data: {
463+
id: 'validation-submission-1',
464+
},
465+
}),
466+
);
467+
ecsService.launchScorerTask.mockResolvedValue({
468+
taskArn: 'arn:aws:ecs:task/task-1',
469+
taskId: 'task-1',
470+
cluster: 'cluster-1',
471+
containerName: 'runner',
472+
taskDefinition: 'mm-runner:7',
473+
cloudWatchLogsConsoleUrl: 'https://logs.example.com/task-1',
474+
});
475+
476+
const result = await service.uploadTestSubmission(
477+
'30000123',
478+
{
479+
configType: PhaseConfigType.PROVISIONAL,
480+
},
481+
{
482+
buffer: Buffer.from('zip'),
483+
mimetype: 'application/zip',
484+
originalname: 'solution.zip',
485+
size: 3,
486+
} as Express.Multer.File,
487+
user,
488+
);
489+
490+
const postedForm = httpService.post.mock.calls[0][1] as FormData;
491+
expect(result).toEqual({
492+
challengeId: '30000123',
493+
submissionId: 'validation-submission-1',
494+
configType: PhaseConfigType.PROVISIONAL,
495+
taskArn: 'arn:aws:ecs:task/task-1',
496+
taskId: 'task-1',
497+
cloudWatchLogsConsoleUrl: 'https://logs.example.com/task-1',
498+
});
499+
expect(httpService.post).toHaveBeenCalledWith(
500+
'https://submissions.example.com/v6/submissions/validation-upload',
501+
expect.any(FormData),
502+
{
503+
headers: {
504+
Authorization: 'Bearer m2m-token',
505+
},
506+
},
507+
);
508+
expect(postedForm.get('challengeId')).toBe('30000123');
509+
expect(postedForm.get('memberId')).toBe('40051399');
510+
expect(postedForm.get('type')).toBe('CONTEST_SUBMISSION');
511+
expect(postedForm.get('submissionPhaseId')).toBe('provisional-phase');
512+
expect(ecsService.launchScorerTask).toHaveBeenCalledWith(
513+
'30000123',
514+
'validation-submission-1',
515+
{
516+
taskDefinitionName: 'mm-runner',
517+
taskDefinitionVersion: '7',
518+
},
519+
{
520+
configType: PhaseConfigType.PROVISIONAL,
521+
startSeed: BigInt(100),
522+
numberOfTests: 50,
523+
},
524+
undefined,
525+
{
526+
memberId: '40051399',
527+
},
528+
);
529+
});
530+
531+
it('rejects validation submissions when the tester is not compiled', async () => {
532+
const { service, ecsService, httpService, prisma } = createService();
533+
534+
prisma.marathonMatchConfig.findUnique.mockResolvedValue({
535+
id: 'config-1',
536+
challengeId: '30000123',
537+
testerId: 'tester-1',
538+
tester: {
539+
compilationStatus: CompilationStatus.FAILED,
540+
compilationError: 'javac failed',
541+
},
542+
phaseConfigs: [
543+
{
544+
id: 'phase-provisional',
545+
configType: PhaseConfigType.PROVISIONAL,
546+
phaseId: 'provisional-phase',
547+
startSeed: BigInt(100),
548+
numberOfTests: 50,
549+
},
550+
],
551+
});
552+
553+
await expect(
554+
service.uploadTestSubmission(
555+
'30000123',
556+
{},
557+
{
558+
buffer: Buffer.from('zip'),
559+
mimetype: 'application/zip',
560+
originalname: 'solution.zip',
561+
size: 3,
562+
} as Express.Multer.File,
563+
{
564+
isMachine: false,
565+
userId: '40051399',
566+
} as never,
567+
),
568+
).rejects.toThrow(BadRequestException);
569+
570+
expect(httpService.post).not.toHaveBeenCalled();
571+
expect(ecsService.launchScorerTask).not.toHaveBeenCalled();
572+
});
573+
429574
it('rate limits rerun scorer task launches in batches', async () => {
430575
jest.useFakeTimers();
431576

0 commit comments

Comments
 (0)