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 74c5f67..95559ab 100644 --- a/app/controllers/hackathons_controller.ts +++ b/app/controllers/hackathons_controller.ts @@ -32,6 +32,15 @@ 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' +import Team from '#models/team/team' export default class HackathonsController { @ApiOperation({ description: 'Get a specific hackathon task by its ID or slug' }) @@ -172,4 +181,189 @@ 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 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) { + 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 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, + 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) + mediaUrl = await drive.use().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 event' }) + @ApiResponse(200, { description: 'A list of hackathon submissions', data: [HackathonTaskSubmission] }) + @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') + } + + @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 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' }) + @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') + 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(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) { + 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) + + if (submission.status === 'ARCHIVED') + return response.badRequest({ message: 'Cannot delete an archived submission' }) + + 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/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/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..42d9666 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) } @@ -79,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/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('/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']).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()) router.group(() => { router.get('/', [HackathonController, 'indexJuryMembers']) diff --git a/tests/fixtures/test_image.png b/tests/fixtures/test_image.png new file mode 100644 index 0000000..62f0795 Binary files /dev/null and b/tests/fixtures/test_image.png differ diff --git a/tests/functional/hackathon_submissions.spec.ts b/tests/functional/hackathon_submissions.spec.ts new file mode 100644 index 0000000..60b69b4 --- /dev/null +++ b/tests/functional/hackathon_submissions.spec.ts @@ -0,0 +1,519 @@ +/* + * ______ __ __ + * _ __/ ____/___ ____ / /____ _____/ /_ + * | |/_/ / / __ \/ __ \/ __/ _ \/ ___/ __/ + * _> { + 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') + 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, + }, + ], + }) + }) + + 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/tasks.spec.ts b/tests/functional/tasks.spec.ts index 07ea7dc..5005b97 100644 --- a/tests/functional/tasks.spec.ts +++ b/tests/functional/tasks.spec.ts @@ -27,7 +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')