From db75df1b6ddccd6a69982c18dc1ebdaadd26c428 Mon Sep 17 00:00:00 2001 From: InfoTCube Date: Fri, 27 Feb 2026 11:34:05 +0100 Subject: [PATCH 1/5] feat: hackathon submissions API --- app/controllers/hackathons_controller.ts | 144 ++++++++++++++++++ .../hackathon/hackathon_task_submission.ts | 12 +- app/policies/team_policy.ts | 32 ++++ app/utils/permissions.ts | 5 +- app/validators/hackathon_submission.ts | 61 ++++++++ ...create_hackathon_task_submissions_table.ts | 1 + start/routes.ts | 8 + 7 files changed, 259 insertions(+), 4 deletions(-) create mode 100644 app/validators/hackathon_submission.ts diff --git a/app/controllers/hackathons_controller.ts b/app/controllers/hackathons_controller.ts index 74c5f67..a0f42e6 100644 --- a/app/controllers/hackathons_controller.ts +++ b/app/controllers/hackathons_controller.ts @@ -32,6 +32,14 @@ import { createJuryMemberValidator, updateJuryMemberValidator } from '#validator import { updateHackathonTaskValidator } from '#validators/task' import type { HttpContext } from '@adonisjs/core/http' import { ApiOperation, ApiRequest, ApiResponse } from '#openapi/decorators' +import { createHackathonSubmissionValidator, updateHackathonSubmissionValidator } from '#validators/hackathon_submission' +import HackathonTaskSubmission from '#models/hackathon/hackathon_task_submission' +import TaskRegistration from '#models/task/task_registration' +import TeamPolicy from '#policies/team_policy' +import { DateTime } from 'luxon' +import { randomUUID } from 'node:crypto' +import Media from '#models/media' +import drive from '@adonisjs/drive/services/main' export default class HackathonsController { @ApiOperation({ description: 'Get a specific hackathon task by its ID or slug' }) @@ -172,4 +180,140 @@ export default class HackathonsController { await juryMember.delete() return response.noContent() } + + @ApiOperation({ description: 'Create a new submission for a hackathon task' }) + @ApiRequest({ validator: createHackathonSubmissionValidator, withResponse: true }) + @ApiResponse(201, { description: 'The newly created hackathon submission', data: HackathonTaskSubmission }) + @ApiResponse(400, { description: 'Submissions are closed or invalid request' }) + @ApiResponse(403, { description: 'Missing permission to submit for this task' }) + @ApiResponse(404, { description: 'Task registration not found' }) + async storeHackathonSubmission({ bouncer, request, response }: HttpContext) { + const payload = await request.validateUsing(createHackathonSubmissionValidator) + const taskRegistration = await TaskRegistration.query() + .where('id', payload.taskRegistrationId) + .preload('team', (q) => q.preload('members')) + .firstOrFail() + + await bouncer.with(TeamPolicy).authorize('manageSubmissions', taskRegistration.team) + + const task = await Task.findByUuidOrSlug(taskRegistration.taskId) + + const now = DateTime.now() + + if (task.submissionsEndAt && task.submissionsEndAt < now) + return response.badRequest({ message: 'Submissions are closed for this task' }) + + if (task.submissionsStartAt && task.submissionsStartAt > now) + return response.badRequest({ message: 'Submissions are not open yet for this task' }) + + const submission = await HackathonTaskSubmission.create({ + taskRegistrationId: taskRegistration.id, + description: payload.description, + repositoryUrl: payload.repositoryUrl, + demoUrl: payload.demoUrl, + status: payload.status, + }) + + if (payload.media && payload.media.length > 0) + for (const [index, mediaItem] of payload.media.entries()) { + let mediaUrl: string = '' + + if (mediaItem.file) { + if (mediaItem.mediaType === 'VIDEO') + return response.badRequest({ message: 'You cannot upload video files, please use a URL instead' }) + + const filename = `${randomUUID()}.${mediaItem.file.extname}` + const path = `hackathon-submissions/${submission.id}/${filename}` + await mediaItem.file.moveToDisk(path, { disk: 's3' } ) + mediaUrl = await drive.use('s3').getUrl(path) + } else if (mediaItem.url) + mediaUrl = mediaItem.url + + await Media.create({ + relatedId: submission.id, + description: mediaItem.description ?? '', + mediaType: mediaItem.mediaType ?? 'IMAGE', + url: mediaUrl, + galleryIndex: index, + }) + } + + await submission.load('media') + + return response.created(submission) + } + + @ApiOperation({ description: 'Get a list of submissions for a hackathon task registration' }) + @ApiResponse(200, { description: 'A list of hackathon submissions', data: [HackathonTaskSubmission] }) + @ApiResponse(403, { description: 'Missing permission to view submissions for this task registration' }) + @ApiResponse(404, { description: 'Task registration not found' }) + async indexHackathonSubmissions({ bouncer, params }: HttpContext) { + const taskRegistration = await TaskRegistration.query() + .where('id', params.taskRegistrationId) + .preload('team', (q) => q.preload('members')) + .firstOrFail() + + await bouncer.with(TeamPolicy).authorize('viewSubmissions', taskRegistration.team) + + return taskRegistration.related('hackathonSubmissions').query().preload('media') + } + + @ApiOperation({ description: 'Get a specific hackathon submission by ID' }) + @ApiResponse(200, { description: 'The requested hackathon submission', data: HackathonTaskSubmission }) + @ApiResponse(403, { description: 'Missing permission to view this submission' }) + @ApiResponse(404, { description: 'Hackathon submission not found' }) + async showHackathonSubmission({ bouncer, params }: HttpContext) { + const submission = await HackathonTaskSubmission.query() + .where('id', params.id) + .preload('taskRegistration', (q) => q.preload('team', (r) => r.preload('members'))) + .preload('media') + .firstOrFail() + + await bouncer.with(TeamPolicy).authorize('viewSubmissions', submission.taskRegistration.team) + + return submission + } + + @ApiOperation({ description: 'Update a hackathon submission by ID' }) + @ApiRequest({ validator: updateHackathonSubmissionValidator, withResponse: true }) + @ApiResponse(200, { description: 'The updated hackathon submission', data: HackathonTaskSubmission }) + @ApiResponse(400, { description: 'Invalid request or submission status transition not allowed' }) + @ApiResponse(403, { description: 'Missing permission to manage this submission' }) + @ApiResponse(404, { description: 'Hackathon submission not found' }) + async updateHackathonSubmission({ bouncer, response, params, request }: HttpContext) { + const submission = await HackathonTaskSubmission.query() + .where('id', params.id) + .preload('taskRegistration', (q) => q.preload('team', (r) => r.preload('members'))) + .firstOrFail() + + await bouncer.with(TeamPolicy).authorize('manageSubmissions', submission.taskRegistration.team) + + const payload = await request.validateUsing(updateHackathonSubmissionValidator) + + if (submission.status === 'ARCHIVED' && payload.status !== 'ARCHIVED') + return response.badRequest({ message: 'Cannot update an archived submission' }) + + if (submission.status === 'ACTIVE' && payload.status === 'DRAFT') + return response.badRequest({ message: 'Cannot change status of an active submission to draft' }) + + submission.merge(payload) + await submission.save() + return submission + } + + @ApiOperation({ description: 'Delete a hackathon submission by ID' }) + @ApiResponse(204, { description: 'Hackathon submission deleted successfully' }) + @ApiResponse(403, { description: 'Missing permission to manage this submission' }) + @ApiResponse(404, { description: 'Hackathon submission not found' }) + async destroyHackathonSubmission({ bouncer, response, params }: HttpContext) { + const submission = await HackathonTaskSubmission.query() + .where('id', params.id) + .preload('taskRegistration', (q) => q.preload('team', (r) => r.preload('members'))) + .firstOrFail() + + await bouncer.with(TeamPolicy).authorize('manageSubmissions', submission.taskRegistration.team) + + await submission.delete() + return response.noContent() + } } diff --git a/app/models/hackathon/hackathon_task_submission.ts b/app/models/hackathon/hackathon_task_submission.ts index 24bba0d..9c67f9d 100644 --- a/app/models/hackathon/hackathon_task_submission.ts +++ b/app/models/hackathon/hackathon_task_submission.ts @@ -23,11 +23,12 @@ import { randomUUID } from 'node:crypto' import { DateTime } from 'luxon' -import { BaseModel, beforeCreate, belongsTo, column, hasOne } from '@adonisjs/lucid/orm' -import type { BelongsTo, HasOne } from '@adonisjs/lucid/types/relations' +import { BaseModel, beforeCreate, belongsTo, column, hasMany, hasOne } from '@adonisjs/lucid/orm' +import type { BelongsTo, HasMany, HasOne } from '@adonisjs/lucid/types/relations' import TaskRegistration from '#models/task/task_registration' import HackathonSubmissionResult from '#models/hackathon/hackathon_submission_result' import { ApiColumn } from '#openapi/decorators' +import Media from '#models/media' export default class HackathonTaskSubmission extends BaseModel { @column({ isPrimary: true }) @@ -46,6 +47,10 @@ export default class HackathonTaskSubmission extends BaseModel { @ApiColumn(String, { required: false, example: 'https://github.com/user/repo' }) declare repositoryUrl: string | null + @column() + @ApiColumn(String, { required: false, example: 'https://example.com/demo' }) + declare demoUrl: string | null + @column() @ApiColumn(String, { example: 'ACTIVE' }) declare status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED' @@ -58,6 +63,9 @@ export default class HackathonTaskSubmission extends BaseModel { @ApiColumn(String, { format: 'date-time' }) declare updatedAt: DateTime | null + @hasMany(() => Media, { foreignKey: 'relatedId' }) + declare media: HasMany + @belongsTo(() => TaskRegistration) declare taskRegistration: BelongsTo diff --git a/app/policies/team_policy.ts b/app/policies/team_policy.ts index 7aba904..c7b902b 100644 --- a/app/policies/team_policy.ts +++ b/app/policies/team_policy.ts @@ -68,6 +68,38 @@ export default class TeamPolicy extends BasePolicy { return TeamMemberGuard.can(member, 'REGISTER_TASK') } + async manageSubmissions(user: User, team: Team): Promise { + if (UserGuard.can(user, 'MANAGE_ALL_TASKS')) + return true + + const member = await team + .related('members') + .query() + .where('user_id', user.id) + .first() + + if (!member) + return false + + return TeamMemberGuard.can(member, 'MANAGE_TASK_SUBMISSIONS') + } + + async viewSubmissions(user: User, team: Team): Promise { + if (UserGuard.can(user, 'MANAGE_ALL_TASKS')) + return true + + const member = await team + .related('members') + .query() + .where('user_id', user.id) + .first() + + if (!member) + return false + + return TeamMemberGuard.can(member, 'VIEW_TASK_SUBMISSIONS') + } + async kick(user: User, team: Team, targetMemberId: string): Promise { const member = await TeamMember.findOrFail(targetMemberId) if (TeamMemberGuard.can(member, 'IS_OWNER')) diff --git a/app/utils/permissions.ts b/app/utils/permissions.ts index d559900..035a3e8 100644 --- a/app/utils/permissions.ts +++ b/app/utils/permissions.ts @@ -62,8 +62,9 @@ export const UserGuard = PermissionsGuard(UserPermissions) export enum TeamMemberPermissions { MANAGE_MEMBERS = 1 << 0, // Whether a member can invite, remove and change other member permissions REGISTER_TASK = 1 << 1, // Whether a member can register the team for tasks - SUBMIT_TASK = 1 << 2, // Whether member can submit tasks - MANAGE_TEAM = 1 << 3, // Whether a user can edit and/or delete the team + MANAGE_TASK_SUBMISSIONS = 1 << 2, // Whether a member can view and manage task submissions (submit, update, delete) + VIEW_TASK_SUBMISSIONS = 1 << 3, // Whether a member can view task submissions + MANAGE_TEAM = 1 << 4, // Whether a user can edit and/or delete the team IS_OWNER = 1 << 30, // Whether a user is a team owner (leftmost bit for signed 32-bit integer) } diff --git a/app/validators/hackathon_submission.ts b/app/validators/hackathon_submission.ts new file mode 100644 index 0000000..6d1f05f --- /dev/null +++ b/app/validators/hackathon_submission.ts @@ -0,0 +1,61 @@ +/* + * ______ __ __ + * _ __/ ____/___ ____ / /____ _____/ /_ + * | |/_/ / / __ \/ __ \/ __/ _ \/ ___/ __/ + * _> { router.patch('/:id', [HackathonController, 'updateTask']).as('hackathon.update_task_patch') }).prefix('hackathon/tasks').use(middleware.auth()) +router.group(() => { + router.post('/', [HackathonController, 'storeHackathonSubmission']) + router.get('/by-registrations/:taskRegistrationId', [HackathonController, 'indexHackathonSubmissions']) + router.get('/:id', [HackathonController, 'showHackathonSubmission']) + router.put('/:id', [HackathonController, 'updateHackathonSubmission']) + router.patch('/:id', [HackathonController, 'updateHackathonSubmission']) + router.delete('/:id', [HackathonController, 'destroyHackathonSubmission']) +}).prefix('hackathon/submissions').use(middleware.auth()) router.group(() => { router.get('/', [HackathonController, 'indexJuryMembers']) From f14392920e775abb90334a0e536a75f68c33cfbd Mon Sep 17 00:00:00 2001 From: InfoTCube Date: Sat, 28 Feb 2026 13:25:08 +0100 Subject: [PATCH 2/5] feat: submissions seeder and post test with files --- app/controllers/hackathons_controller.ts | 9 +- .../seeders/4_task_registration_seeder.ts | 18 +++- .../5_hackathon_task_submission_seeder.ts | 68 +++++++++++++++ tests/fixtures/test_image.png | Bin 0 -> 129 bytes tests/functional/events.spec.ts | 2 + .../functional/hackathon_submissions.spec.ts | 79 ++++++++++++++++++ tests/functional/registrations.spec.ts | 2 + tests/functional/tasks.spec.ts | 2 + 8 files changed, 175 insertions(+), 5 deletions(-) create mode 100644 database/seeders/5_hackathon_task_submission_seeder.ts create mode 100644 tests/fixtures/test_image.png create mode 100644 tests/functional/hackathon_submissions.spec.ts diff --git a/app/controllers/hackathons_controller.ts b/app/controllers/hackathons_controller.ts index a0f42e6..153d41f 100644 --- a/app/controllers/hackathons_controller.ts +++ b/app/controllers/hackathons_controller.ts @@ -184,7 +184,7 @@ export default class HackathonsController { @ApiOperation({ description: 'Create a new submission for a hackathon task' }) @ApiRequest({ validator: createHackathonSubmissionValidator, withResponse: true }) @ApiResponse(201, { description: 'The newly created hackathon submission', data: HackathonTaskSubmission }) - @ApiResponse(400, { description: 'Submissions are closed or invalid request' }) + @ApiResponse(400, { description: 'Submissions are closed or invalid request or already exists' }) @ApiResponse(403, { description: 'Missing permission to submit for this task' }) @ApiResponse(404, { description: 'Task registration not found' }) async storeHackathonSubmission({ bouncer, request, response }: HttpContext) { @@ -206,6 +206,13 @@ export default class HackathonsController { if (task.submissionsStartAt && task.submissionsStartAt > now) return response.badRequest({ message: 'Submissions are not open yet for this task' }) + const existingSubmission = await HackathonTaskSubmission.query() + .where('task_registration_id', taskRegistration.id) + .first() + + if (existingSubmission) + return response.badRequest({ message: 'A submission already exists for this task registration' }) + const submission = await HackathonTaskSubmission.create({ taskRegistrationId: taskRegistration.id, description: payload.description, diff --git a/database/seeders/4_task_registration_seeder.ts b/database/seeders/4_task_registration_seeder.ts index d7692fb..6d8c9c9 100644 --- a/database/seeders/4_task_registration_seeder.ts +++ b/database/seeders/4_task_registration_seeder.ts @@ -31,6 +31,10 @@ export default class extends BaseSeeder { const visibleTask = await Task.findBy('slug', 'visible-task') if (!visibleTask) throw new Error('Visible task not found. Please run TaskSeeder first.') + + const visibleTask2 = await Task.findBy('slug', 'visible-task-2') + if (!visibleTask2) + throw new Error('Visible task 2 not found. Please run TaskSeeder first.') const team = await Team.query().where('name', "User's team").first() @@ -38,9 +42,15 @@ export default class extends BaseSeeder { throw new Error('Team not found. Please run TeamSeeder first.') - await TaskRegistration.create({ - taskId: visibleTask.id, - teamId: team.id, - }) + await TaskRegistration.createMany([ + { + taskId: visibleTask.id, + teamId: team.id, + }, + { + taskId: visibleTask2.id, + teamId: team.id, + }, + ]) } } \ No newline at end of file diff --git a/database/seeders/5_hackathon_task_submission_seeder.ts b/database/seeders/5_hackathon_task_submission_seeder.ts new file mode 100644 index 0000000..594f322 --- /dev/null +++ b/database/seeders/5_hackathon_task_submission_seeder.ts @@ -0,0 +1,68 @@ +/* + * ______ __ __ + * _ __/ ____/___ ____ / /____ _____/ /_ + * | |/_/ / / __ \/ __ \/ __/ _ \/ ___/ __/ + * _> YWW#!QkoY=d#Wzp$Py%MIcxJ literal 0 HcmV?d00001 diff --git a/tests/functional/events.spec.ts b/tests/functional/events.spec.ts index dd71933..e5aee62 100644 --- a/tests/functional/events.spec.ts +++ b/tests/functional/events.spec.ts @@ -29,6 +29,7 @@ test.group('Events', (group) => { group.each.setup(() => testUtils.db().seed()) group.each.teardown(() => testUtils.db().truncate()) + /* test('Lists all events', async ({ client }) => { const response = await client.get('/events') @@ -291,4 +292,5 @@ test.group('Events', (group) => { response.assertUnprocessableEntity() }) + */ }) \ No newline at end of file diff --git a/tests/functional/hackathon_submissions.spec.ts b/tests/functional/hackathon_submissions.spec.ts new file mode 100644 index 0000000..f51f998 --- /dev/null +++ b/tests/functional/hackathon_submissions.spec.ts @@ -0,0 +1,79 @@ +/* + * ______ __ __ + * _ __/ ____/___ ____ / /____ _____/ /_ + * | |/_/ / / __ \/ __ \/ __/ _ \/ ___/ __/ + * _> { + group.each.setup(() => testUtils.db().seed()) + group.each.teardown(() => testUtils.db().truncate()) + + test('Make a successful hackathon task submission with file', async ({ client }) => { + const user = await User.findByOrFail('nickname', 'user') + const task = await Task.findByUuidOrSlug('visible-task-2') + const taskRegistration = await TaskRegistration.query() + .where('taskId', task.id) + .firstOrFail() + + const testImagePath = './tests/fixtures/test_image.png' + + const response = await client.post('/hackathon/submissions') + .loginAs(user) + .field('taskRegistrationId', taskRegistration.id.toString()) + .field('description', 'This is our submission description') + .field('repositoryUrl', 'https://github.com/xContest/xContest') + .field('demoUrl', 'https://xcontest.org') + .field('status', 'ACTIVE') + .field('media[0][mediaType]', 'IMAGE') + .field('media[0][description]', 'Test image') + .field('media[0][galleryIndex]', '0') + .file('media[0][file]', testImagePath) + .field('media[1][mediaType]', 'VIDEO') + .field('media[1][description]', 'Some video') + .field('media[1][galleryIndex]', '1') + .field('media[1][url]', 'https://example.com/video2.png') + + response.assertCreated() + response.assertBodyContains({ + description: 'This is our submission description', + repositoryUrl: 'https://github.com/xContest/xContest', + demoUrl: 'https://xcontest.org', + status: 'ACTIVE', + media: [ + { + mediaType: 'IMAGE', + description: 'Test image', + galleryIndex: 0, + }, + { + mediaType: 'VIDEO', + description: 'Some video', + galleryIndex: 1, + }, + ], + }) + }) +}) \ No newline at end of file diff --git a/tests/functional/registrations.spec.ts b/tests/functional/registrations.spec.ts index 91967a2..806c122 100644 --- a/tests/functional/registrations.spec.ts +++ b/tests/functional/registrations.spec.ts @@ -33,6 +33,7 @@ import TaskRegistration from '#models/task/task_registration' test.group('Registrations', (group) => { group.each.setup(() => testUtils.db().seed()) group.each.teardown(() => testUtils.db().truncate()) + /* test('Registers a team to a task successfully', async ({ client }) => { const event = await Event.findByUuidOrSlug('hackathon-tasks') @@ -246,4 +247,5 @@ test.group('Registrations', (group) => { response.assertUnauthorized() }) + */ }) \ No newline at end of file diff --git a/tests/functional/tasks.spec.ts b/tests/functional/tasks.spec.ts index 07ea7dc..0dadbb6 100644 --- a/tests/functional/tasks.spec.ts +++ b/tests/functional/tasks.spec.ts @@ -28,6 +28,7 @@ import User from '#models/user' test.group('Tasks', (group) => { group.each.setup(() => testUtils.db().seed()) group.each.teardown(() => testUtils.db().truncate()) + /* test('Lists all tasks', async ({ client }) => { const response = await client.get('/event/hackathon-tasks/tasks') @@ -191,4 +192,5 @@ test.group('Tasks', (group) => { response.assertUnauthorized() }) + */ }) \ No newline at end of file From 77197054da3ee7e8bfc2790e03f8d5573756c064 Mon Sep 17 00:00:00 2001 From: InfoTCube Date: Sun, 1 Mar 2026 23:49:45 +0100 Subject: [PATCH 3/5] test: add tests for hackathon submissions --- app/controllers/hackathons_controller.ts | 67 ++- app/policies/event_policy.ts | 11 + app/utils/permissions.ts | 1 + database/seeders/3_task_seeder.ts | 3 + .../seeders/4_task_registration_seeder.ts | 8 + .../5_hackathon_task_submission_seeder.ts | 2 +- start/routes.ts | 4 +- tests/functional/events.spec.ts | 2 - .../functional/hackathon_submissions.spec.ts | 440 ++++++++++++++++++ tests/functional/registrations.spec.ts | 2 - tests/functional/tasks.spec.ts | 4 +- 11 files changed, 523 insertions(+), 21 deletions(-) diff --git a/app/controllers/hackathons_controller.ts b/app/controllers/hackathons_controller.ts index 153d41f..2fdc042 100644 --- a/app/controllers/hackathons_controller.ts +++ b/app/controllers/hackathons_controller.ts @@ -40,6 +40,7 @@ import { DateTime } from 'luxon' import { randomUUID } from 'node:crypto' import Media from '#models/media' import drive from '@adonisjs/drive/services/main' +import Team from '#models/team/team' export default class HackathonsController { @ApiOperation({ description: 'Get a specific hackathon task by its ID or slug' }) @@ -203,7 +204,7 @@ export default class HackathonsController { if (task.submissionsEndAt && task.submissionsEndAt < now) return response.badRequest({ message: 'Submissions are closed for this task' }) - if (task.submissionsStartAt && task.submissionsStartAt > now) + if (!task.submissionsStartAt || task.submissionsStartAt > now) return response.badRequest({ message: 'Submissions are not open yet for this task' }) const existingSubmission = await HackathonTaskSubmission.query() @@ -250,19 +251,57 @@ export default class HackathonsController { return response.created(submission) } - @ApiOperation({ description: 'Get a list of submissions for a hackathon task registration' }) + @ApiOperation({ description: 'Get a list of submissions for a hackathon event' }) @ApiResponse(200, { description: 'A list of hackathon submissions', data: [HackathonTaskSubmission] }) - @ApiResponse(403, { description: 'Missing permission to view submissions for this task registration' }) - @ApiResponse(404, { description: 'Task registration not found' }) - async indexHackathonSubmissions({ bouncer, params }: HttpContext) { - const taskRegistration = await TaskRegistration.query() - .where('id', params.taskRegistrationId) - .preload('team', (q) => q.preload('members')) - .firstOrFail() + @ApiResponse(401, { description: 'Authentication required' }) + async indexHackathonSubmissionsByUser({ auth }: HttpContext) { + const user = await auth.getUserOrFail() + + return HackathonTaskSubmission.query() + .whereHas('taskRegistration', (q) => { + q.whereHas('team', (r) => { + r.whereHas('members', (s) => { + s.where('user_id', user.id) + }) + }) + }) + .preload('taskRegistration', (q) => q.preload('team').preload('task')) + .preload('media') + } - await bouncer.with(TeamPolicy).authorize('viewSubmissions', taskRegistration.team) + @ApiOperation({ description: 'Get a list of submissions for a hackathon event' }) + @ApiResponse(200, { description: 'A list of hackathon submissions', data: [HackathonTaskSubmission] }) + @ApiResponse(403, { description: 'Missing permission to view submissions for this team' }) + @ApiResponse(404, { description: 'Team not found' }) + async indexHackathonSubmissionsByTeam({ bouncer, params }: HttpContext) { + const team = await Team.findOrFail(params.teamId) + + await bouncer.with(TeamPolicy).authorize('viewSubmissions', team) + + return HackathonTaskSubmission.query() + .whereHas('taskRegistration', (q) => { + q.where('team_id', team.id) + }) + .preload('taskRegistration', (q) => q.preload('team').preload('task')) + .preload('media') + } + + @ApiOperation({ description: 'Get a list of submissions for a hackathon event' }) + @ApiResponse(200, { description: 'A list of hackathon submissions', data: [HackathonTaskSubmission] }) + @ApiResponse(403, { description: 'Missing permission to view submissions for this task' }) + @ApiResponse(404, { description: 'Task not found' }) + async indexHackathonSubmissionsByTask({ bouncer, params }: HttpContext) { + const task = await Task.findByUuidOrSlug(params.taskId) + const event = await task.related('event').query().firstOrFail() + + await bouncer.with(EventPolicy).authorize('manageSubmissions', event) - return taskRegistration.related('hackathonSubmissions').query().preload('media') + return HackathonTaskSubmission.query() + .whereHas('taskRegistration', (q) => { + q.where('task_id', task.id) + }) + .preload('taskRegistration', (q) => q.preload('team').preload('task')) + .preload('media') } @ApiOperation({ description: 'Get a specific hackathon submission by ID' }) @@ -297,7 +336,7 @@ export default class HackathonsController { const payload = await request.validateUsing(updateHackathonSubmissionValidator) - if (submission.status === 'ARCHIVED' && payload.status !== 'ARCHIVED') + if (submission.status === 'ARCHIVED') return response.badRequest({ message: 'Cannot update an archived submission' }) if (submission.status === 'ACTIVE' && payload.status === 'DRAFT') @@ -310,6 +349,7 @@ export default class HackathonsController { @ApiOperation({ description: 'Delete a hackathon submission by ID' }) @ApiResponse(204, { description: 'Hackathon submission deleted successfully' }) + @ApiResponse(400, { description: 'Cannot delete an archived submission' }) @ApiResponse(403, { description: 'Missing permission to manage this submission' }) @ApiResponse(404, { description: 'Hackathon submission not found' }) async destroyHackathonSubmission({ bouncer, response, params }: HttpContext) { @@ -320,6 +360,9 @@ export default class HackathonsController { await bouncer.with(TeamPolicy).authorize('manageSubmissions', submission.taskRegistration.team) + if (submission.status === 'ARCHIVED') + return response.badRequest({ message: 'Cannot delete an archived submission' }) + await submission.delete() return response.noContent() } diff --git a/app/policies/event_policy.ts b/app/policies/event_policy.ts index 6406178..4f1aaf8 100644 --- a/app/policies/event_policy.ts +++ b/app/policies/event_policy.ts @@ -114,4 +114,15 @@ export default class EventPolicy extends BasePolicy { return EventAdminGuard.can(eventAdmin, 'MANAGE_JURY_MEMBERS') } + + async manageSubmissions(user: User, event: Event): Promise { + if (UserGuard.can(user, 'MANAGE_ALL_EVENTS')) + return true + + const eventAdmin = await event.related('administrators').query().where('user_id', user.id).first() + if (!eventAdmin) + return false + + return EventAdminGuard.can(eventAdmin, 'MANAGE_SUBMISSIONS') + } } diff --git a/app/utils/permissions.ts b/app/utils/permissions.ts index 035a3e8..42d9666 100644 --- a/app/utils/permissions.ts +++ b/app/utils/permissions.ts @@ -80,5 +80,6 @@ export enum EventAdminPermissions { MANAGE_SPONSORS = 1 << 6, // Whether a user can manage sponsors for the event MANAGE_JURY_MEMBERS = 1 << 7, // Whether a user can manage jury members for the events tasks EDIT_TASK = 1 << 8, // Whether a user can edit tasks (except for those with MANAGE_ALL_TASKS) + MANAGE_SUBMISSIONS = 1 << 9, // Whether a user can manage (view/update/delete) all submissions for the event's tasks } export const EventAdminGuard = PermissionsGuard(EventAdminPermissions) diff --git a/database/seeders/3_task_seeder.ts b/database/seeders/3_task_seeder.ts index 0bf7c6e..0792bf0 100644 --- a/database/seeders/3_task_seeder.ts +++ b/database/seeders/3_task_seeder.ts @@ -70,6 +70,7 @@ export default class extends BaseSeeder { detailsRevealAt: DateTime.now().minus({ days: 1 }), // Details were revealed 1 day ago registrationStartAt: DateTime.now(), registrationEndAt: DateTime.now().plus({ days: 7 }), // 1 week from now + submissionsStartAt: DateTime.now(), }, { eventId: hackathonEvent.id, @@ -104,6 +105,8 @@ export default class extends BaseSeeder { registrationStartAt: DateTime.now().plus({ days: 1 }), // Registration will start in 1 day registrationEndAt: DateTime.now().plus({ days: 7 }), // Registration will end in 7 days autoregister: true, + submissionsStartAt: DateTime.now().plus({ days: 2 }), // Submissions will start in 2 days + submissionsEndAt: DateTime.now().plus({ days: 9 }), // Submissions will end in 9 days }, { eventId: teamSizeEvent.id, diff --git a/database/seeders/4_task_registration_seeder.ts b/database/seeders/4_task_registration_seeder.ts index 6d8c9c9..487c4e0 100644 --- a/database/seeders/4_task_registration_seeder.ts +++ b/database/seeders/4_task_registration_seeder.ts @@ -35,6 +35,10 @@ export default class extends BaseSeeder { const visibleTask2 = await Task.findBy('slug', 'visible-task-2') if (!visibleTask2) throw new Error('Visible task 2 not found. Please run TaskSeeder first.') + + const autoregisterTask = await Task.findBy('slug', 'autoregister-task') + if (!autoregisterTask) + throw new Error('Autoregister task not found. Please run TaskSeeder first.') const team = await Team.query().where('name', "User's team").first() @@ -51,6 +55,10 @@ export default class extends BaseSeeder { taskId: visibleTask2.id, teamId: team.id, }, + { + taskId: autoregisterTask.id, + teamId: team.id, + }, ]) } } \ No newline at end of file diff --git a/database/seeders/5_hackathon_task_submission_seeder.ts b/database/seeders/5_hackathon_task_submission_seeder.ts index 594f322..ae3585b 100644 --- a/database/seeders/5_hackathon_task_submission_seeder.ts +++ b/database/seeders/5_hackathon_task_submission_seeder.ts @@ -59,7 +59,7 @@ export default class extends BaseSeeder { { relatedId: submission.id, mediaType: 'VIDEO', - url: 'https://example.com/video2.png', + url: 'https://example.com/video2.mp4', description: 'Video Screenshot 2', galleryIndex: 2, }, diff --git a/start/routes.ts b/start/routes.ts index 955d8cd..332c8ea 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -147,7 +147,9 @@ router.group(() => { router.group(() => { router.post('/', [HackathonController, 'storeHackathonSubmission']) - router.get('/by-registrations/:taskRegistrationId', [HackathonController, 'indexHackathonSubmissions']) + router.get('/user', [HackathonController, 'indexHackathonSubmissionsByUser']) + router.get('/team/:teamId', [HackathonController, 'indexHackathonSubmissionsByTeam']) + router.get('/task/:taskId', [HackathonController, 'indexHackathonSubmissionsByTask']) router.get('/:id', [HackathonController, 'showHackathonSubmission']) router.put('/:id', [HackathonController, 'updateHackathonSubmission']) router.patch('/:id', [HackathonController, 'updateHackathonSubmission']) diff --git a/tests/functional/events.spec.ts b/tests/functional/events.spec.ts index e5aee62..dd71933 100644 --- a/tests/functional/events.spec.ts +++ b/tests/functional/events.spec.ts @@ -29,7 +29,6 @@ test.group('Events', (group) => { group.each.setup(() => testUtils.db().seed()) group.each.teardown(() => testUtils.db().truncate()) - /* test('Lists all events', async ({ client }) => { const response = await client.get('/events') @@ -292,5 +291,4 @@ test.group('Events', (group) => { response.assertUnprocessableEntity() }) - */ }) \ No newline at end of file diff --git a/tests/functional/hackathon_submissions.spec.ts b/tests/functional/hackathon_submissions.spec.ts index f51f998..60b69b4 100644 --- a/tests/functional/hackathon_submissions.spec.ts +++ b/tests/functional/hackathon_submissions.spec.ts @@ -21,8 +21,11 @@ * */ +import { UserFactory } from '#database/factories/user_factory' +import HackathonTaskSubmission from '#models/hackathon/hackathon_task_submission' import Task from '#models/task/task' import TaskRegistration from '#models/task/task_registration' +import Team from '#models/team/team' import User from '#models/user' import testUtils from '@adonisjs/core/services/test_utils' import { test } from '@japa/runner' @@ -31,6 +34,193 @@ test.group('Hackathon submissions', (group) => { group.each.setup(() => testUtils.db().seed()) group.each.teardown(() => testUtils.db().truncate()) + test('Success to list current user\'s submissions', async ({ client }) => { + const user = await User.findByOrFail('nickname', 'user') + + const response = await client.get('/hackathon/submissions/user') + .loginAs(user) + + response.assertOk() + response.assertBodyContains([ + { + description: 'This is our submission description', + repositoryUrl: 'https://github.com/xContest/xContest', + demoUrl: 'https://xcontest.org', + status: 'ACTIVE', + media: [ + { + mediaType: 'IMAGE', + url: 'https://example.com/image1.png', + description: 'Screenshot 1', + galleryIndex: 1, + }, + { + mediaType: 'VIDEO', + url: 'https://example.com/video2.mp4', + description: 'Video Screenshot 2', + galleryIndex: 2, + }, + ], + }, + ]) + }) + + test('Fail to list submissions if user is not authenticated', async ({ client }) => { + const response = await client.get('/hackathon/submissions/user') + + response.assertUnauthorized() + }) + + test('Success to list submissions for a team', async ({ client }) => { + const user = await User.findByOrFail('nickname', 'user') + const team = await Team.findByOrFail('name', 'User\'s team') + + const response = await client.get(`/hackathon/submissions/team/${team.id}`) + .loginAs(user) + + response.assertOk() + response.assertBodyContains([ + { + description: 'This is our submission description', + repositoryUrl: 'https://github.com/xContest/xContest', + demoUrl: 'https://xcontest.org', + status: 'ACTIVE', + media: [ + { + mediaType: 'IMAGE', + url: 'https://example.com/image1.png', + description: 'Screenshot 1', + galleryIndex: 1, + }, + { + mediaType: 'VIDEO', + url: 'https://example.com/video2.mp4', + description: 'Video Screenshot 2', + galleryIndex: 2, + }, + ], + }, + ]) + }) + + test('Fail to list submissions for a team if not a team member', async ({ client }) => { + const newUser = await UserFactory.create() + const team = await Team.findByOrFail('name', 'User\'s team') + + const response = await client.get(`/hackathon/submissions/team/${team.id}`) + .loginAs(newUser) + + response.assertForbidden() + }) + + test('Success to list submissions for a task', async ({ client }) => { + const admin = await User.findByOrFail('nickname', 'admin') + const task = await Task.findByUuidOrSlug('visible-task') + + const response = await client.get(`/hackathon/submissions/task/${task.id}`) + .loginAs(admin) + + response.assertOk() + response.assertBodyContains([ + { + description: 'This is our submission description', + repositoryUrl: 'https://github.com/xContest/xContest', + demoUrl: 'https://xcontest.org', + status: 'ACTIVE', + media: [ + { + mediaType: 'IMAGE', + url: 'https://example.com/image1.png', + description: 'Screenshot 1', + galleryIndex: 1, + }, + { + mediaType: 'VIDEO', + url: 'https://example.com/video2.mp4', + description: 'Video Screenshot 2', + galleryIndex: 2, + }, + ], + }, + ]) + }) + + test('Fail to list submissions for a task if user does not have permission', async ({ client }) => { + const user = await User.findByOrFail('nickname', 'user') + const task = await Task.findByUuidOrSlug('visible-task') + + const response = await client.get(`/hackathon/submissions/task/${task.id}`) + .loginAs(user) + + response.assertForbidden() + }) + + test('Success to get a single submission', async ({ client }) => { + const user = await User.findByOrFail('nickname', 'user') + const task = await Task.findByUuidOrSlug('visible-task') + const taskRegistration = await TaskRegistration.query() + .where('taskId', task.id) + .firstOrFail() + const submission = await HackathonTaskSubmission.query() + .where('task_registration_id', taskRegistration.id) + .firstOrFail() + + const response = await client.get(`/hackathon/submissions/${submission.id}`) + .loginAs(user) + + response.assertOk() + response.assertBodyContains({ + description: 'This is our submission description', + repositoryUrl: 'https://github.com/xContest/xContest', + demoUrl: 'https://xcontest.org', + status: 'ACTIVE', + media: [ + { + mediaType: 'IMAGE', + url: 'https://example.com/image1.png', + description: 'Screenshot 1', + galleryIndex: 1, + }, + { + mediaType: 'VIDEO', + url: 'https://example.com/video2.mp4', + description: 'Video Screenshot 2', + galleryIndex: 2, + }, + ], + }) + }) + + test('Fail to get a single submission if user does not have permission', async ({ client }) => { + const user = await UserFactory.create() + const task = await Task.findByUuidOrSlug('visible-task') + const taskRegistration = await TaskRegistration.query() + .where('taskId', task.id) + .firstOrFail() + const submission = await HackathonTaskSubmission.query() + .where('task_registration_id', taskRegistration.id) + .firstOrFail() + + const response = await client.get(`/hackathon/submissions/${submission.id}`) + .loginAs(user) + + response.assertForbidden() + }) + + test('Fail to get a single submission if not authenticated', async ({ client }) => { + const task = await Task.findByUuidOrSlug('visible-task') + const taskRegistration = await TaskRegistration.query() + .where('taskId', task.id) + .firstOrFail() + const submission = await HackathonTaskSubmission.query() + .where('task_registration_id', taskRegistration.id) + .firstOrFail() + + const response = await client.get(`/hackathon/submissions/${submission.id}`) + + response.assertUnauthorized() + }) + test('Make a successful hackathon task submission with file', async ({ client }) => { const user = await User.findByOrFail('nickname', 'user') const task = await Task.findByUuidOrSlug('visible-task-2') @@ -76,4 +266,254 @@ test.group('Hackathon submissions', (group) => { ], }) }) + + test('Fail when submissions are closed', async ({ client }) => { + const user = await User.findByOrFail('nickname', 'user') + const task = await Task.findByUuidOrSlug('autoregister-task') + const taskRegistration = await TaskRegistration.query() + .where('taskId', task.id) + .firstOrFail() + + const response = await client.post('/hackathon/submissions') + .loginAs(user) + .field('taskRegistrationId', taskRegistration.id) + .field('description', 'This is our submission description') + .field('repositoryUrl', 'https://github.com/xContest/xContest') + .field('demoUrl', 'https://xcontest.org') + .field('status', 'ACTIVE') + + response.assertBadRequest() + }) + + test('Fail when user does not have permission to submit', async ({ client }) => { + const user = await User.findByOrFail('nickname', 'user2') + const task = await Task.findByUuidOrSlug('visible-task') + const taskRegistration = await TaskRegistration.query() + .where('taskId', task.id) + .firstOrFail() + + const response = await client.post('/hackathon/submissions') + .loginAs(user) + .field('taskRegistrationId', taskRegistration.id) + .field('description', 'This is our submission description') + .field('repositoryUrl', 'https://github.com/xContest/xContest') + .field('demoUrl', 'https://xcontest.org') + .field('status', 'ACTIVE') + + response.assertForbidden() + }) + + test('Fail when user is not authenticated', async ({ client }) => { + const task = await Task.findByUuidOrSlug('autoregister-task') + const taskRegistration = await TaskRegistration.query() + .where('taskId', task.id) + .firstOrFail() + + const response = await client.post('/hackathon/submissions') + .field('taskRegistrationId', taskRegistration.id) + .field('description', 'This is our submission description') + .field('repositoryUrl', 'https://github.com/xContest/xContest') + .field('demoUrl', 'https://xcontest.org') + .field('status', 'ACTIVE') + + response.assertUnauthorized() + }) + + test('Fail when team already has a submission for the task registration', async ({ client }) => { + const user = await User.findByOrFail('nickname', 'user') + const task = await Task.findByUuidOrSlug('visible-task') + const taskRegistration = await TaskRegistration.query() + .where('taskId', task.id) + .firstOrFail() + + const response = await client.post('/hackathon/submissions') + .loginAs(user) + .field('taskRegistrationId', taskRegistration.id) + .field('description', 'This is our submission description') + .field('repositoryUrl', 'https://github.com/xContest/xContest') + .field('demoUrl', 'https://xcontest.org') + .field('status', 'ACTIVE') + + response.assertBadRequest() + }) + + test('Reject if users tries to submit video file instead of URL', async ({ client }) => { + const user = await User.findByOrFail('nickname', 'user') + const task = await Task.findByUuidOrSlug('autoregister-task') + const taskRegistration = await TaskRegistration.query() + .where('taskId', task.id) + .firstOrFail() + + const testImagePath = './tests/fixtures/test_image.png' + + const response = await client.post('/hackathon/submissions') + .loginAs(user) + .field('taskRegistrationId', taskRegistration.id) + .field('description', 'This is our submission description') + .field('repositoryUrl', 'https://github.com/xContest/xContest') + .field('demoUrl', 'https://xcontest.org') + .field('status', 'ACTIVE') + .field('media[0][mediaType]', 'VIDEO') + .field('media[0][description]', 'Test video') + .field('media[0][galleryIndex]', '0') + .file('media[0][file]', testImagePath) + + response.assertBadRequest() + }) + + test('Fail to update when changing from active to draft status', async ({ client }) => { + const user = await User.findByOrFail('nickname', 'user') + const task = await Task.findByUuidOrSlug('visible-task') + const taskRegistration = await TaskRegistration.query() + .where('taskId', task.id) + .firstOrFail() + const submission = await HackathonTaskSubmission.query() + .where('task_registration_id', taskRegistration.id) + .firstOrFail() + + const response = await client.put(`/hackathon/submissions/${submission.id}`) + .loginAs(user) + .field('status', 'DRAFT') + + response.assertBadRequest() + }) + + test('Fail to update when user does not have permission', async ({ client }) => { + const user = await User.findByOrFail('nickname', 'user2') + const task = await Task.findByUuidOrSlug('visible-task') + const taskRegistration = await TaskRegistration.query() + .where('taskId', task.id) + .firstOrFail() + const submission = await HackathonTaskSubmission.query() + .where('task_registration_id', taskRegistration.id) + .firstOrFail() + + const response = await client.put(`/hackathon/submissions/${submission.id}`) + .loginAs(user) + .field('description', 'Updated description') + .field('status', 'ARCHIVED') + + response.assertForbidden() + }) + + test('Fail to update when not authenticated', async ({ client }) => { + const task = await Task.findByUuidOrSlug('visible-task') + const taskRegistration = await TaskRegistration.query() + .where('taskId', task.id) + .firstOrFail() + const submission = await HackathonTaskSubmission.query() + .where('task_registration_id', taskRegistration.id) + .firstOrFail() + + const response = await client.put(`/hackathon/submissions/${submission.id}`) + .field('description', 'Updated description') + .field('status', 'ARCHIVED') + + response.assertUnauthorized() + }) + + test('Update submission successfully', async ({ client }) => { + const user = await User.findByOrFail('nickname', 'user') + const task = await Task.findByUuidOrSlug('visible-task') + const taskRegistration = await TaskRegistration.query() + .where('taskId', task.id) + .firstOrFail() + const submission = await HackathonTaskSubmission.query() + .where('task_registration_id', taskRegistration.id) + .firstOrFail() + + const response = await client.put(`/hackathon/submissions/${submission.id}`) + .loginAs(user) + .field('description', 'Updated description') + .field('status', 'ARCHIVED') + + response.assertOk() + response.assertBodyContains({ status: 'ARCHIVED', description: 'Updated description' }) + }) + + test('Fail to update if submission is archived', async ({ client }) => { + const user = await User.findByOrFail('nickname', 'user') + const task = await Task.findByUuidOrSlug('visible-task') + const taskRegistration = await TaskRegistration.query() + .where('taskId', task.id) + .firstOrFail() + const submission = await HackathonTaskSubmission.query() + .where('task_registration_id', taskRegistration.id) + .firstOrFail() + + submission.status = 'ARCHIVED' + await submission.save() + + const response = await client.put(`/hackathon/submissions/${submission.id}`) + .loginAs(user) + .field('description', 'Trying to update description') + .field('status', 'ARCHIVED') + + response.assertBadRequest() + }) + + test('Fail to delete if submission is archived', async ({ client }) => { + const user = await User.findByOrFail('nickname', 'user') + const task = await Task.findByUuidOrSlug('visible-task') + const taskRegistration = await TaskRegistration.query() + .where('taskId', task.id) + .firstOrFail() + const submission = await HackathonTaskSubmission.query() + .where('task_registration_id', taskRegistration.id) + .firstOrFail() + + submission.status = 'ARCHIVED' + await submission.save() + + const response = await client.delete(`/hackathon/submissions/${submission.id}`) + .loginAs(user) + + response.assertBadRequest() + }) + + test('Fail to delete when user does not have permission', async ({ client }) => { + const user = await User.findByOrFail('nickname', 'user2') + const task = await Task.findByUuidOrSlug('visible-task') + const taskRegistration = await TaskRegistration.query() + .where('taskId', task.id) + .firstOrFail() + const submission = await HackathonTaskSubmission.query() + .where('task_registration_id', taskRegistration.id) + .firstOrFail() + + const response = await client.delete(`/hackathon/submissions/${submission.id}`) + .loginAs(user) + + response.assertForbidden() + }) + + test('Fail to delete when not authenticated', async ({ client }) => { + const task = await Task.findByUuidOrSlug('visible-task') + const taskRegistration = await TaskRegistration.query() + .where('taskId', task.id) + .firstOrFail() + const submission = await HackathonTaskSubmission.query() + .where('task_registration_id', taskRegistration.id) + .firstOrFail() + + const response = await client.delete(`/hackathon/submissions/${submission.id}`) + + response.assertUnauthorized() + }) + + test('Delete submission successfully', async ({ client }) => { + const user = await User.findByOrFail('nickname', 'user') + const task = await Task.findByUuidOrSlug('visible-task') + const taskRegistration = await TaskRegistration.query() + .where('taskId', task.id) + .firstOrFail() + const submission = await HackathonTaskSubmission.query() + .where('task_registration_id', taskRegistration.id) + .firstOrFail() + + const response = await client.delete(`/hackathon/submissions/${submission.id}`) + .loginAs(user) + + response.assertNoContent() + }) }) \ No newline at end of file diff --git a/tests/functional/registrations.spec.ts b/tests/functional/registrations.spec.ts index 806c122..91967a2 100644 --- a/tests/functional/registrations.spec.ts +++ b/tests/functional/registrations.spec.ts @@ -33,7 +33,6 @@ import TaskRegistration from '#models/task/task_registration' test.group('Registrations', (group) => { group.each.setup(() => testUtils.db().seed()) group.each.teardown(() => testUtils.db().truncate()) - /* test('Registers a team to a task successfully', async ({ client }) => { const event = await Event.findByUuidOrSlug('hackathon-tasks') @@ -247,5 +246,4 @@ test.group('Registrations', (group) => { response.assertUnauthorized() }) - */ }) \ No newline at end of file diff --git a/tests/functional/tasks.spec.ts b/tests/functional/tasks.spec.ts index 0dadbb6..5005b97 100644 --- a/tests/functional/tasks.spec.ts +++ b/tests/functional/tasks.spec.ts @@ -27,8 +27,7 @@ import User from '#models/user' test.group('Tasks', (group) => { group.each.setup(() => testUtils.db().seed()) - group.each.teardown(() => testUtils.db().truncate()) - /* + group.each.teardown(() => testUtils.db().truncate()) test('Lists all tasks', async ({ client }) => { const response = await client.get('/event/hackathon-tasks/tasks') @@ -192,5 +191,4 @@ test.group('Tasks', (group) => { response.assertUnauthorized() }) - */ }) \ No newline at end of file From 35c60591c8523dbc48dc014670e3d8308ab59042 Mon Sep 17 00:00:00 2001 From: InfoTCube Date: Mon, 2 Mar 2026 22:07:18 +0100 Subject: [PATCH 4/5] feat: configure local storage for tests --- .gitignore | 3 +++ app/controllers/hackathons_controller.ts | 4 ++-- config/drive.ts | 13 ++++++++++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 5be1fa9..08b369d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ node_modules build tmp +# Test storage (used for local drive in tests) +tests/storage + # Secrets .env .env.local diff --git a/app/controllers/hackathons_controller.ts b/app/controllers/hackathons_controller.ts index 2fdc042..95559ab 100644 --- a/app/controllers/hackathons_controller.ts +++ b/app/controllers/hackathons_controller.ts @@ -232,8 +232,8 @@ export default class HackathonsController { const filename = `${randomUUID()}.${mediaItem.file.extname}` const path = `hackathon-submissions/${submission.id}/${filename}` - await mediaItem.file.moveToDisk(path, { disk: 's3' } ) - mediaUrl = await drive.use('s3').getUrl(path) + await mediaItem.file.moveToDisk(path) + mediaUrl = await drive.use().getUrl(path) } else if (mediaItem.url) mediaUrl = mediaItem.url diff --git a/config/drive.ts b/config/drive.ts index 0dcf684..5d53c10 100644 --- a/config/drive.ts +++ b/config/drive.ts @@ -22,16 +22,27 @@ */ import env from '#start/env' +import app from '@adonisjs/core/services/app' import { defineConfig, services } from '@adonisjs/drive' const driveConfig = defineConfig({ - default: env.get('DRIVE_DISK'), + default: app.inTest ? 'local' : env.get('DRIVE_DISK'), /** * The services object can be used to configure multiple file system * services each using the same or a different driver. */ services: { + ...(app.inTest && { + local: services.fs({ + location: './tests/storage', + serveFiles: true, + routeBasePath: '/uploads', + visibility: 'public', + appUrl: 'http://localhost:3333/uploads', + }), + }), + s3: services.s3({ credentials: { accessKeyId: env.get('S3_KEY_ID'), From 486dce305b5b4f54a970b7dbd9d643dd7e393ff2 Mon Sep 17 00:00:00 2001 From: InfoTCube Date: Mon, 2 Mar 2026 22:18:54 +0100 Subject: [PATCH 5/5] fix: put and patch hackathon submission conflict --- start/routes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/start/routes.ts b/start/routes.ts index 332c8ea..c8b76a7 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -151,8 +151,8 @@ router.group(() => { router.get('/team/:teamId', [HackathonController, 'indexHackathonSubmissionsByTeam']) router.get('/task/:taskId', [HackathonController, 'indexHackathonSubmissionsByTask']) router.get('/:id', [HackathonController, 'showHackathonSubmission']) - router.put('/:id', [HackathonController, 'updateHackathonSubmission']) - router.patch('/:id', [HackathonController, 'updateHackathonSubmission']) + router.put('/:id', [HackathonController, 'updateHackathonSubmission']).as('hackathon.update_submission_put') + router.patch('/:id', [HackathonController, 'updateHackathonSubmission']).as('hackathon.update_submission_patch') router.delete('/:id', [HackathonController, 'destroyHackathonSubmission']) }).prefix('hackathon/submissions').use(middleware.auth())