diff --git a/.husky/pre-push b/.husky/pre-push index 6ca46a7..d11c1e8 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1 +1 @@ -pnpm run lint +pnpm run lint \ No newline at end of file diff --git a/app/controllers/events_controller.ts b/app/controllers/events_controller.ts index 9fe9197..a28a9ed 100644 --- a/app/controllers/events_controller.ts +++ b/app/controllers/events_controller.ts @@ -47,6 +47,10 @@ export default class EventsController { const payload = await request.validateUsing(createEventValidator) + if (payload.minTeamSize > payload.maxTeamSize) + return response.unprocessableEntity({ message: 'minTeamSize cannot be greater than maxTeamSize.' }) + + const event = await db.transaction(async (trx) => { const newEvent = await Event.create(payload, { client: trx }) @@ -180,6 +184,11 @@ export default class EventsController { if (!admin) return response.notFound({ message: 'User is not an administrator of this event.' }) + const admins = await EventAdministrator.query().where('event_id', event.id) + + if (admins.length <= 1) + return response.unprocessableEntity({ message: 'Cannot remove the last administrator from the event.' }) + await admin.delete() return response.noContent() } diff --git a/tests/bootstrap.ts b/tests/bootstrap.ts index 46b58f7..95a4f78 100644 --- a/tests/bootstrap.ts +++ b/tests/bootstrap.ts @@ -29,6 +29,7 @@ import { pluginAdonisJS } from '@japa/plugin-adonisjs' import testUtils from '@adonisjs/core/services/test_utils' import { authApiClient } from '@adonisjs/auth/plugins/api_client' import { sessionApiClient } from '@adonisjs/session/plugins/api_client' +import logger from '@adonisjs/core/services/logger' /** * This file is imported by the "bin/test.ts" entrypoint file @@ -54,7 +55,18 @@ export const plugins: Config['plugins'] = [ * The teardown functions are executed after all the tests */ export const runnerHooks: Required> = { - setup: [() => testUtils.db().migrate()], + setup: [() => testUtils.db().migrate(), () => { + // Suppress database migration and truncate logs to keep test output clean + logger.level = 'error' + // eslint-disable-next-line no-console + const originalLog = console.log + // eslint-disable-next-line no-console + console.log = (...args: unknown[]) => { + const message = args.join(' ') + if (!message.includes('completed') && !message.includes('successfully') || (!message.includes('database') && !message.includes('seeders') && !message.includes('Truncated'))) + originalLog(...args) + } + }], teardown: [], } diff --git a/tests/functional/events.spec.ts b/tests/functional/events.spec.ts new file mode 100644 index 0000000..dd71933 --- /dev/null +++ b/tests/functional/events.spec.ts @@ -0,0 +1,294 @@ +/* + * ______ __ __ + * _ __/ ____/___ ____ / /____ _____/ /_ + * | |/_/ / / __ \/ __ \/ __/ _ \/ ___/ __/ + * _> { + group.each.setup(() => testUtils.db().seed()) + group.each.teardown(() => testUtils.db().truncate()) + + test('Lists all events', async ({ client }) => { + const response = await client.get('/events') + + response.assertOk() + response.assertBodyContains([ + { slug: 'no-tasks' }, + { slug: 'hackathon-tasks' }, + ]) + }) + + test('Creates an event', async ({ client }) => { + const admin = await User.findByOrFail('nickname', 'admin') + + const response = await client.post('/events').json({ + slug: 'new-event', + title: 'New Test Event', + description: 'A brand new event for testing.', + status: 'DRAFT', + minTeamSize: 1, + maxTeamSize: 5, + }).loginAs(admin) + + response.assertCreated() + response.assertBodyContains({ + slug: 'new-event', + title: 'New Test Event', + }) + }) + + test('Shows event details', async ({ client }) => { + const response = await client.get('/events/no-tasks') + + response.assertOk() + response.assertBodyContains({ slug: 'no-tasks' }) + }) + + test('Updates an event', async ({ client }) => { + const admin = await User.findByOrFail('nickname', 'admin') + + const response = await client.put('/events/no-tasks').json({ + title: 'Updated Event Title', + }).loginAs(admin) + + response.assertOk() + response.assertBodyContains({ title: 'Updated Event Title' }) + }) + + test('Deletes an event', async ({ client }) => { + const admin = await User.findByOrFail('nickname', 'admin') + + const response = await client.delete('/events/no-tasks').json({ + confirmation: 'no-tasks', + }).loginAs(admin) + + response.assertNoContent() + }) + + test('Lists event administrators', async ({ client }) => { + const admin = await User.findByOrFail('nickname', 'admin') + + const response = await client.get('/events/no-tasks/administrators').loginAs(admin) + + response.assertOk() + response.assertBodyContains([ + { userId: admin.id }, + ]) + }) + + test('Adds an event administrator', async ({ client }) => { + const admin = await User.findByOrFail('nickname', 'admin') + const user = await User.findByOrFail('nickname', 'user') + + const response = await client.post('/events/no-tasks/administrators').json({ + userId: user.id, + permissions: 0, + }).loginAs(admin) + + response.assertCreated() + response.assertBodyContains({ userId: user.id }) + }) + + test('Updates an event administrator', async ({ client }) => { + const admin = await User.findByOrFail('nickname', 'admin') + const user = await User.findByOrFail('nickname', 'user') + + await client.post('/events/no-tasks/administrators').json({ + userId: user.id, + permissions: 0, + }).loginAs(admin) + + const response = await client.put(`/events/no-tasks/administrators/${user.id}`).json({ + permissions: 1, + }).loginAs(admin) + + response.assertOk() + response.assertBodyContains({ permissions: 1 }) + }) + + test('Deletes an event administrator', async ({ client }) => { + const admin = await User.findByOrFail('nickname', 'admin') + const user = await User.findByOrFail('nickname', 'user') + + await client.post('/events/no-tasks/administrators').json({ + userId: user.id, + permissions: 0, + }).loginAs(admin) + + const response = await client.delete(`/events/no-tasks/administrators/${user.id}`).loginAs(admin) + + response.assertNoContent() + }) + + test('Does not expose draft events in listing', async ({ client, assert }) => { + const response = await client.get('/events') + + response.assertOk() + const slugs = response.body().map((e: any) => e.slug) + assert.notInclude(slugs, 'not-visible') + }) + + test('Guest cannot view draft event details', async ({ client }) => { + const response = await client.get('/events/not-visible') + + response.assertForbidden() + }) + + test('Admin can view draft event details', async ({ client }) => { + const admin = await User.findByOrFail('nickname', 'admin') + + const response = await client.get('/events/not-visible').loginAs(admin) + + response.assertOk() + response.assertBodyContains({ slug: 'not-visible' }) + }) + + test('Fails to create event without authentication', async ({ client }) => { + const response = await client.post('/events').json({ + slug: 'unauth-event', + title: 'Unauth Event', + description: 'Should fail.', + status: 'DRAFT', + minTeamSize: 1, + maxTeamSize: 3, + }) + + response.assertUnauthorized() + }) + + test('Regular user without permission cannot create event', async ({ client }) => { + const user = await User.findByOrFail('nickname', 'user') + + const response = await client.post('/events').json({ + slug: 'user-event', + title: 'User Event', + description: 'Should fail.', + status: 'DRAFT', + minTeamSize: 1, + maxTeamSize: 3, + }).loginAs(user) + + response.assertForbidden() + }) + + test('Fails to create event with duplicate slug', async ({ client }) => { + const admin = await User.findByOrFail('nickname', 'admin') + + const response = await client.post('/events').json({ + slug: 'no-tasks', + title: 'Duplicate Slug Event', + description: 'Should fail due to duplicate slug.', + status: 'DRAFT', + minTeamSize: 1, + maxTeamSize: 3, + }).loginAs(admin) + + response.assertUnprocessableEntity() + }) + + test('Fails to create event with missing required fields', async ({ client }) => { + const admin = await User.findByOrFail('nickname', 'admin') + + const response = await client.post('/events').json({ + slug: 'incomplete-event', + }).loginAs(admin) + + response.assertUnprocessableEntity() + }) + + test('Fails to update event without authentication', async ({ client }) => { + const response = await client.put('/events/no-tasks').json({ + title: 'Updated Without Auth', + }) + + response.assertUnauthorized() + }) + + test('Fails to delete event without authentication', async ({ client }) => { + const response = await client.delete('/events/no-tasks').json({ + confirmation: 'no-tasks', + }) + + response.assertUnauthorized() + }) + + test('Fails to delete event with wrong confirmation', async ({ client }) => { + const admin = await User.findByOrFail('nickname', 'admin') + + const response = await client.delete('/events/no-tasks').json({ + confirmation: 'wrong-slug', + }).loginAs(admin) + + response.assertUnprocessableEntity() + }) + + test('Fails to access event administrators without authentication', async ({ client }) => { + const response = await client.get('/events/no-tasks/administrators') + + response.assertUnauthorized() + }) + + test('Non-admin user cannot manage event administrators', async ({ client }) => { + const user = await User.findByOrFail('nickname', 'user') + + const response = await client.get('/events/no-tasks/administrators').loginAs(user) + + response.assertForbidden() + }) + + test('Fails to add duplicate event administrator', async ({ client }) => { + const admin = await User.findByOrFail('nickname', 'admin') + + // Admin is already an administrator of all seeded events + const response = await client.post('/events/no-tasks/administrators').json({ + userId: admin.id, + permissions: 0, + }).loginAs(admin) + + response.assertStatus(409) + }) + + test('Fails when minTeamSize is greater than maxTeamSize', async ({ client }) => { + const admin = await User.findByOrFail('nickname', 'admin') + + const response = await client.post('/events').json({ + slug: 'invalid-team-size', + title: 'Invalid Team Size Event', + description: 'Should fail due to invalid team size.', + status: 'DRAFT', + minTeamSize: 5, + maxTeamSize: 3, + }).loginAs(admin) + + response.assertUnprocessableEntity() + }) + + test('Event cannot contain no administrators', async ({ client }) => { + const admin = await User.findByOrFail('nickname', 'admin') + + const response = await client.delete(`/events/no-tasks/administrators/${admin.id}`).loginAs(admin) + + response.assertUnprocessableEntity() + }) +}) \ No newline at end of file diff --git a/tests/functional/tasks.spec.ts b/tests/functional/tasks.spec.ts new file mode 100644 index 0000000..07ea7dc --- /dev/null +++ b/tests/functional/tasks.spec.ts @@ -0,0 +1,194 @@ +/* + * ______ __ __ + * _ __/ ____/___ ____ / /____ _____/ /_ + * | |/_/ / / __ \/ __ \/ __/ _ \/ ___/ __/ + * _> { + 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') + + response.assertOk() + response.assertBodyContains([ + { task: { slug: 'visible-task' } }, + { task: { slug: 'visible-task-2' } }, + ]) + }) + + test('Creates a task', async ({ client }) => { + const admin = await User.findByOrFail('nickname', 'admin') + + const response = await client.post('/event/hackathon-tasks/task').json({ + slug: 'new-task', + title: 'New Hackathon Task', + description: 'A new task for testing.', + taskType: 'HACKATHON', + requirementsDocumentUrl: 'http://local.host/requirements/new-task', + }).loginAs(admin) + + response.assertCreated() + response.assertBodyContains({ + slug: 'new-task', + title: 'New Hackathon Task', + }) + }) + + test('Shows task details', async ({ client }) => { + const response = await client.get('/tasks/visible-task') + + response.assertOk() + response.assertBodyContains({ task: { slug: 'visible-task' } }) + }) + + test('Updates a task', async ({ client }) => { + const admin = await User.findByOrFail('nickname', 'admin') + + const response = await client.put('/tasks/visible-task').json({ + title: 'Updated Task Title', + }).loginAs(admin) + + response.assertOk() + response.assertBodyContains({ title: 'Updated Task Title' }) + }) + + test('Deletes a task', async ({ client }) => { + const admin = await User.findByOrFail('nickname', 'admin') + + const response = await client.delete('/tasks/visible-task').loginAs(admin) + + response.assertNoContent() + }) + + test('Admin can see draft tasks in listing', async ({ client }) => { + const admin = await User.findByOrFail('nickname', 'admin') + + const response = await client.get('/event/hackathon-tasks/tasks').loginAs(admin) + + response.assertOk() + response.assertBodyContains([ + { task: { slug: 'hidden-task' } }, + ]) + }) + + test('Regular user cannot see draft tasks in listing', async ({ client, assert }) => { + const user = await User.findByOrFail('nickname', 'user') + + const response = await client.get('/event/hackathon-tasks/tasks').loginAs(user) + + response.assertOk() + const slugs = response.body().map((t: any) => t.task?.slug) + assert.notInclude(slugs, 'hidden-task') + }) + + test('Listing tasks for a draft event is forbidden to guests', async ({ client }) => { + const response = await client.get('/event/not-visible/tasks') + + response.assertForbidden() + }) + + test('Guest cannot view draft task details', async ({ client }) => { + const response = await client.get('/tasks/hidden-task') + + response.assertForbidden() + }) + + test('Admin can view draft task details', async ({ client }) => { + const admin = await User.findByOrFail('nickname', 'admin') + + const response = await client.get('/tasks/hidden-task').loginAs(admin) + + response.assertOk() + response.assertBodyContains({ task: { slug: 'hidden-task' } }) + }) + + test('Fails to create task without authentication', async ({ client }) => { + const response = await client.post('/event/hackathon-tasks/task').json({ + slug: 'unauth-task', + title: 'Unauth Task', + description: 'Should fail.', + taskType: 'HACKATHON', + requirementsDocumentUrl: 'http://localhost/requirements/unauth-task', + }) + + response.assertUnauthorized() + }) + + test('Regular user without permission cannot create task', async ({ client }) => { + const user = await User.findByOrFail('nickname', 'user') + + const response = await client.post('/event/hackathon-tasks/task').json({ + slug: 'user-task', + title: 'User Task', + description: 'Should fail.', + taskType: 'HACKATHON', + requirementsDocumentUrl: 'http://localhost/requirements/user-task', + }).loginAs(user) + + response.assertForbidden() + }) + + test('Fails to create HACKATHON task without requirementsDocumentUrl', async ({ client }) => { + const admin = await User.findByOrFail('nickname', 'admin') + + const response = await client.post('/event/hackathon-tasks/task').json({ + slug: 'missing-requirements-task', + title: 'Missing Requirements Task', + description: 'Should fail.', + taskType: 'HACKATHON', + }).loginAs(admin) + + response.assertUnprocessableEntity() + }) + + test('Fails to create task with duplicate slug', async ({ client }) => { + const admin = await User.findByOrFail('nickname', 'admin') + + const response = await client.post('/event/hackathon-tasks/task').json({ + slug: 'visible-task', + title: 'Duplicate Slug Task', + description: 'Should fail due to duplicate slug.', + taskType: 'HACKATHON', + requirementsDocumentUrl: 'http://localhost/requirements/visible-task', + }).loginAs(admin) + + response.assertUnprocessableEntity() + }) + + test('Fails to update task without authentication', async ({ client }) => { + const response = await client.put('/tasks/visible-task').json({ + title: 'Updated Without Auth', + }) + + response.assertUnauthorized() + }) + + test('Fails to delete task without authentication', async ({ client }) => { + const response = await client.delete('/tasks/visible-task') + + response.assertUnauthorized() + }) +}) \ No newline at end of file