diff --git a/api/validators/EventControllerRequests.ts b/api/validators/EventControllerRequests.ts index 9be8c4ce..f0bddf9c 100644 --- a/api/validators/EventControllerRequests.ts +++ b/api/validators/EventControllerRequests.ts @@ -58,30 +58,30 @@ export class Event extends OptionalEventProperties implements IEvent { pointValue: number; } -export class EventPatches extends OptionalEventProperties implements IEvent { +export class EventPatches extends OptionalEventProperties implements Partial { @IsNotEmpty() - cover: string; + cover?: string; @IsNotEmpty() - title: string; + title?: string; @IsNotEmpty() - description: string; + description?: string; @IsNotEmpty() - location: string; + location?: string; @IsDateString() - start: Date; + start?: Date; @IsDateString() - end: Date; + end?: Date; @IsNotEmpty() - attendanceCode: string; + attendanceCode?: string; @Allow() - pointValue: number; + pointValue?: number; } export class EventSearchOptions implements IEventSearchOptions { diff --git a/api/validators/FeedbackControllerRequests.ts b/api/validators/FeedbackControllerRequests.ts index c699b84c..f34eb5c0 100644 --- a/api/validators/FeedbackControllerRequests.ts +++ b/api/validators/FeedbackControllerRequests.ts @@ -2,7 +2,6 @@ import { Type } from 'class-transformer'; import { IsDefined, IsNotEmpty, MinLength, ValidateNested } from 'class-validator'; import { IsValidFeedbackType, IsValidFeedbackStatus } from '../decorators/Validators'; import { - SubmitEventFeedbackRequest as ISubmitEventFeedbackRequest, SubmitFeedbackRequest as ISubmitFeedbackRequest, UpdateFeedbackStatusRequest as IUpdateFeedbackStatusRequest, Feedback as IFeedback, @@ -24,11 +23,6 @@ export class Feedback implements IFeedback { type: FeedbackType; } -export class SubmitEventFeedbackRequest implements ISubmitEventFeedbackRequest { - @IsDefined() - feedback: string[]; -} - export class SubmitFeedbackRequest implements ISubmitFeedbackRequest { @Type(() => Feedback) @ValidateNested() diff --git a/package-lock.json b/package-lock.json index e66aac12..eb047d30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,7 +62,8 @@ "jest": "^26.6.3", "nodemon": "^2.0.7", "rfdc": "^1.3.0", - "ts-jest": "^26.5.6" + "ts-jest": "^26.5.6", + "ts-mockito": "^2.6.1" }, "engines": { "node": "^14.0.0", @@ -8081,9 +8082,9 @@ } }, "node_modules/normalize-url": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.0.tgz", - "integrity": "sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.1.tgz", + "integrity": "sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==", "dev": true, "engines": { "node": ">=8" @@ -11221,6 +11222,15 @@ "node": ">=10" } }, + "node_modules/ts-mockito": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/ts-mockito/-/ts-mockito-2.6.1.tgz", + "integrity": "sha512-qU9m/oEBQrKq5hwfbJ7MgmVN5Gu6lFnIGWvpxSjrqq6YYEVv+RwVFWySbZMBgazsWqv6ctAyVBpo9TmAxnOEKw==", + "dev": true, + "dependencies": { + "lodash": "^4.17.5" + } + }, "node_modules/ts-node": { "version": "9.1.1", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-9.1.1.tgz", @@ -12292,9 +12302,9 @@ } }, "node_modules/ws": { - "version": "7.4.5", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.5.tgz", - "integrity": "sha512-xzyu3hFvomRfXKH8vOFMU3OguG6oOvhXMo3xsGy3xWExqaM2dxBbVxuD99O7m3ZUFMvvscsZDqxfgMaRr/Nr1g==", + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.3.tgz", + "integrity": "sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg==", "dev": true, "engines": { "node": ">=8.3.0" @@ -18956,9 +18966,9 @@ "dev": true }, "normalize-url": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.0.tgz", - "integrity": "sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.1.tgz", + "integrity": "sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==", "dev": true }, "npm-run-path": { @@ -21387,6 +21397,15 @@ } } }, + "ts-mockito": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/ts-mockito/-/ts-mockito-2.6.1.tgz", + "integrity": "sha512-qU9m/oEBQrKq5hwfbJ7MgmVN5Gu6lFnIGWvpxSjrqq6YYEVv+RwVFWySbZMBgazsWqv6ctAyVBpo9TmAxnOEKw==", + "dev": true, + "requires": { + "lodash": "^4.17.5" + } + }, "ts-node": { "version": "9.1.1", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-9.1.1.tgz", @@ -22226,9 +22245,9 @@ } }, "ws": { - "version": "7.4.5", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.5.tgz", - "integrity": "sha512-xzyu3hFvomRfXKH8vOFMU3OguG6oOvhXMo3xsGy3xWExqaM2dxBbVxuD99O7m3ZUFMvvscsZDqxfgMaRr/Nr1g==", + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.3.tgz", + "integrity": "sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg==", "dev": true, "requires": {} }, diff --git a/package.json b/package.json index 9576d52e..c13f134d 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "jest": "^26.6.3", "nodemon": "^2.0.7", "rfdc": "^1.3.0", - "ts-jest": "^26.5.6" + "ts-jest": "^26.5.6", + "ts-mockito": "^2.6.1" } } diff --git a/services/EventService.ts b/services/EventService.ts index ca1a9c58..435e4ebb 100644 --- a/services/EventService.ts +++ b/services/EventService.ts @@ -18,7 +18,7 @@ export default class EventService { public async create(event: Event) { const eventCreated = await this.transactions.readWrite(async (txn) => { const eventRepository = Repositories.event(txn); - const isUnusedAttendanceCode = eventRepository.isUnusedAttendanceCode(event.attendanceCode); + const isUnusedAttendanceCode = await eventRepository.isUnusedAttendanceCode(event.attendanceCode); if (!isUnusedAttendanceCode) throw new UserError('Attendance code has already been used'); return eventRepository.upsertEvent(EventModel.create(event)); }); @@ -61,7 +61,7 @@ export default class EventService { const currentEvent = await eventRepository.findByUuid(uuid); if (!currentEvent) throw new NotFoundError('Event not found'); if (changes.attendanceCode !== currentEvent.attendanceCode) { - const isUnusedAttendanceCode = eventRepository.isUnusedAttendanceCode(changes.attendanceCode); + const isUnusedAttendanceCode = await eventRepository.isUnusedAttendanceCode(changes.attendanceCode); if (!isUnusedAttendanceCode) throw new UserError('Attendance code has already been used'); } return eventRepository.upsertEvent(currentEvent, changes); diff --git a/services/StorageService.ts b/services/StorageService.ts index 8008334a..ac1954b1 100644 --- a/services/StorageService.ts +++ b/services/StorageService.ts @@ -4,9 +4,8 @@ import * as path from 'path'; import * as multer from 'multer'; import { InternalServerError } from 'routing-controllers'; import { Config } from '../config'; -import { MediaType } from '../types'; +import { MediaType, File } from '../types'; -type File = Express.Multer.File; type FileOptions = multer.Options; interface MediaTypeConfig { diff --git a/tests/ControllerFactory.ts b/tests/ControllerFactory.ts new file mode 100644 index 00000000..b5a4b22d --- /dev/null +++ b/tests/ControllerFactory.ts @@ -0,0 +1,78 @@ +import { Connection } from 'typeorm'; +import FeedbackService from '../services/FeedbackService'; +import { FeedbackController } from '../api/controllers/FeedbackController'; +import { UserController } from '../api/controllers/UserController'; +import UserAccountService from '../services/UserAccountService'; +import StorageService from '../services/StorageService'; +import { AdminController } from '../api/controllers/AdminController'; +import AttendanceService from '../services/AttendanceService'; +import { AttendanceController } from '../api/controllers/AttendanceController'; +import { AuthController } from '../api/controllers/AuthController'; +import { EventController } from '../api/controllers/EventController'; +import { LeaderboardController } from '../api/controllers/LeaderboardController'; +import { MerchStoreController } from '../api/controllers/MerchStoreController'; +import UserAuthService from '../services/UserAuthService'; +import EmailService from '../services/EmailService'; +import EventService from '../services/EventService'; +import MerchStoreService from '../services/MerchStoreService'; + +export class ControllerFactory { + public static user( + conn: Connection, + userAccountService = new UserAccountService(conn.manager), + storageService = new StorageService(), + ): UserController { + return new UserController(userAccountService, storageService); + } + + public static feedback( + conn: Connection, + feedbackService = new FeedbackService(conn.manager), + ): FeedbackController { + return new FeedbackController(feedbackService); + } + + public static admin( + conn: Connection, + storageService = new StorageService(), + userAccountService = new UserAccountService(conn.manager), + attendanceService = new AttendanceService(conn.manager), + ): AdminController { + return new AdminController(storageService, userAccountService, attendanceService); + } + + public static attendance( + conn: Connection, + attendanceService = new AttendanceService(conn.manager), + ): AttendanceController { + return new AttendanceController(attendanceService); + } + + public static auth( + conn: Connection, + userAccountService = new UserAccountService(conn.manager), + userAuthService = new UserAuthService(conn.manager), + emailService = new EmailService(), + ): AuthController { + return new AuthController(userAccountService, userAuthService, emailService); + } + + public static event( + conn: Connection, + eventService = new EventService(conn.manager), + storageService = new StorageService(), + attendanceService = new AttendanceService(conn.manager), + ): EventController { + return new EventController(eventService, storageService, attendanceService); + } + + public static leaderboard(conn: Connection, + userAccountService = new UserAccountService(conn.manager)): LeaderboardController { + return new LeaderboardController(userAccountService); + } + + public static merchStore(conn: Connection, + merchStoreService = new MerchStoreService(conn.manager)): MerchStoreController { + return new MerchStoreController(merchStoreService); + } +} diff --git a/tests/admin.test.ts b/tests/admin.test.ts index 6c53f572..07897e4b 100644 --- a/tests/admin.test.ts +++ b/tests/admin.test.ts @@ -1,5 +1,5 @@ import { ActivityScope, ActivityType, SubmitAttendanceForUsersRequest, UserAccessType } from '../types'; -import { ControllerFactory } from './controllers'; +import { ControllerFactory } from './ControllerFactory'; import { DatabaseConnection, EventFactory, UserFactory, PortalState } from './data'; beforeAll(async () => { @@ -75,11 +75,12 @@ describe('retroactive attendance submission', () => { ); const userResponse = await userController.getUser({ uuid: user.uuid }, proxyUser); - const attendanceResponse = await attendanceController.getAttendancesForCurrentUser(user); - const activityResponse = await userController.getCurrentUserActivityStream(user); - expect(userResponse.user.points).toEqual(user.points); + + const attendanceResponse = await attendanceController.getAttendancesForCurrentUser(user); expect(attendanceResponse.attendances).toHaveLength(1); + + const activityResponse = await userController.getCurrentUserActivityStream(user); expect(activityResponse.activity).toHaveLength(2); expect(activityResponse.activity[1].description).toBeNull(); }); @@ -107,13 +108,15 @@ describe('retroactive attendance submission', () => { await adminController.submitAttendanceForUsers(request, proxyUser); const userResponse = await userController.getUser({ uuid: user.uuid }, proxyUser); - const staffUserResponse = await userController.getUser({ uuid: staffUser.uuid }, proxyUser); - const activityResponse = await userController.getCurrentUserActivityStream(user); - const staffActivityResponse = await userController.getCurrentUserActivityStream(staffUser); - expect(userResponse.user.points).toEqual(event.pointValue); + + const staffUserResponse = await userController.getUser({ uuid: staffUser.uuid }, proxyUser); expect(staffUserResponse.user.points).toEqual(event.pointValue + event.staffPointBonus); + + const activityResponse = await userController.getCurrentUserActivityStream(user); expect(activityResponse.activity[1].type).toEqual(ActivityType.ATTEND_EVENT); + + const staffActivityResponse = await userController.getCurrentUserActivityStream(staffUser); expect(staffActivityResponse.activity[1].type).toEqual(ActivityType.ATTEND_EVENT_AS_STAFF); }); }); diff --git a/tests/controllers/ControllerFactory.ts b/tests/controllers/ControllerFactory.ts deleted file mode 100644 index 6e285377..00000000 --- a/tests/controllers/ControllerFactory.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Connection } from 'typeorm'; -import FeedbackService from '../../services/FeedbackService'; -import { FeedbackController } from '../../api/controllers/FeedbackController'; -import { UserController } from '../../api/controllers/UserController'; -import UserAccountService from '../../services/UserAccountService'; -import StorageService from '../../services/StorageService'; -import { AdminController } from '../../api/controllers/AdminController'; -import AttendanceService from '../../services/AttendanceService'; -import { AttendanceController } from '../../api/controllers/AttendanceController'; -import { AuthController } from '../../api/controllers/AuthController'; -import { EventController } from '../../api/controllers/EventController'; -import { LeaderboardController } from '../../api/controllers/LeaderboardController'; -import { MerchStoreController } from '../../api/controllers/MerchStoreController'; -import UserAuthService from '../../services/UserAuthService'; -import EmailService from '../../services/EmailService'; -import EventService from '../../services/EventService'; -import MerchStoreService from '../../services/MerchStoreService'; - -export class ControllerFactory { - private static userController: UserController = null; - - private static feedbackController: FeedbackController = null; - - private static adminController: AdminController = null; - - private static attendanceController: AttendanceController = null; - - private static authController: AuthController = null; - - private static eventController: EventController = null; - - private static leaderboardController: LeaderboardController = null; - - private static merchStoreController: MerchStoreController = null; - - public static user(conn: Connection): UserController { - if (!ControllerFactory.userController) { - const userAccountService = new UserAccountService(conn.manager); - const storageService = new StorageService(); - ControllerFactory.userController = new UserController(userAccountService, storageService); - } - return ControllerFactory.userController; - } - - public static feedback(conn: Connection): FeedbackController { - if (!ControllerFactory.feedbackController) { - const feedbackService = new FeedbackService(conn.manager); - ControllerFactory.feedbackController = new FeedbackController(feedbackService); - } - return ControllerFactory.feedbackController; - } - - public static admin(conn: Connection): AdminController { - if (!ControllerFactory.adminController) { - const userAccountService = new UserAccountService(conn.manager); - const storageService = new StorageService(); - const attendanceService = new AttendanceService(conn.manager); - ControllerFactory.adminController = new AdminController(storageService, userAccountService, attendanceService); - } - return ControllerFactory.adminController; - } - - public static attendance(conn: Connection): AttendanceController { - if (!ControllerFactory.attendanceController) { - const attendanceService = new AttendanceService(conn.manager); - ControllerFactory.attendanceController = new AttendanceController(attendanceService); - } - return ControllerFactory.attendanceController; - } - - public static auth(conn: Connection): AuthController { - if (!ControllerFactory.authController) { - const userAccountService = new UserAccountService(conn.manager); - const userAuthService = new UserAuthService(conn.manager); - const emailService = new EmailService(); - ControllerFactory.authController = new AuthController(userAccountService, userAuthService, emailService); - } - return ControllerFactory.authController; - } - - public static event(conn: Connection): EventController { - if (!ControllerFactory.eventController) { - const eventService = new EventService(conn.manager); - const storageService = new StorageService(); - const attendanceService = new AttendanceService(conn.manager); - ControllerFactory.eventController = new EventController(eventService, storageService, attendanceService); - } - return ControllerFactory.eventController; - } - - public static leaderboard(conn: Connection): LeaderboardController { - if (!ControllerFactory.leaderboardController) { - const userAccountService = new UserAccountService(conn.manager); - ControllerFactory.leaderboardController = new LeaderboardController(userAccountService); - } - return ControllerFactory.leaderboardController; - } - - public static merchStore(conn: Connection): MerchStoreController { - if (!ControllerFactory.merchStoreController) { - const merchStoreService = new MerchStoreService(conn.manager); - ControllerFactory.merchStoreController = new MerchStoreController(merchStoreService); - } - return ControllerFactory.merchStoreController; - } -} diff --git a/tests/controllers/index.ts b/tests/controllers/index.ts deleted file mode 100644 index 9957d439..00000000 --- a/tests/controllers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './ControllerFactory'; diff --git a/tests/data/EventFactory.ts b/tests/data/EventFactory.ts index d17a4c59..64ac0593 100644 --- a/tests/data/EventFactory.ts +++ b/tests/data/EventFactory.ts @@ -40,16 +40,58 @@ export class EventFactory { return EventModel.merge(fake, substitute); } + public static fakePastEvent(daysAgo = 1): EventModel { + const [start, end] = EventFactory.randomPastTime(daysAgo); + return EventFactory.with({ start, end })[0]; + } + + public static fakeOngoingEvent(): EventModel { + const [start, end] = EventFactory.randomOngoingTime(); + return EventFactory.with({ start, end })[0]; + } + + public static fakeFutureEvent(daysAhead = 1): EventModel { + const [start, end] = EventFactory.randomFutureTime(daysAhead); + return EventFactory.with({ start, end })[0]; + } + + public static createEventFeedback(n: number): string[] { + return new Array(n).fill(faker.random.word()); + } + private static randomTime(): [Date, Date] { - // between last and next week - const days = FactoryUtils.getRandomNumber(-7, 7); - // between 8 AM and 6 PM - const hour = FactoryUtils.getRandomNumber(9, 19); - // between 0.5 and 2.5 hours long, rounded to the half hour - const duration = FactoryUtils.getRandomNumber(30, 150, 30); - const start = moment().subtract(days, 'days').hour(hour); + // random day between last and next week + const day = FactoryUtils.getRandomNumber(-7, 7); + return EventFactory.randomIntervalInDay(day); + } + + private static randomPastTime(daysAgo: number): [Date, Date] { + // random day between daysAgo and a week before daysAgo + const day = FactoryUtils.getRandomNumber(-daysAgo - 7, -daysAgo); + return EventFactory.randomIntervalInDay(day); + } + + private static randomOngoingTime(): [Date, Date] { + // 0 or 30 mins before now + const currentHour = moment().hour(); + const hour = FactoryUtils.getRandomNumber(currentHour - 0.5, currentHour, 0.5); + return EventFactory.randomIntervalInDay(0, hour); + } + + private static randomFutureTime(daysAhead: number): [Date, Date] { + // random day between daysAhead and a week after daysAhead + const day = FactoryUtils.getRandomNumber(daysAhead, daysAhead + 7); + return EventFactory.randomIntervalInDay(day); + } + + private static randomIntervalInDay(day: number, hour?: number): [Date, Date] { + // default between 8 AM and 6 PM + if (!hour) hour = FactoryUtils.getRandomNumber(9, 19); + // between 1 and 2.5 hours long, rounded to the half hour + const duration = FactoryUtils.getRandomNumber(60, 150, 30); + const start = moment().add(day, 'days').hour(hour); const end = moment(start.valueOf()).add(duration, 'minutes'); - return [new Date(start.valueOf()), new Date(end.valueOf())]; + return [start.toDate(), end.toDate()]; } private static randomPointValue(): number { diff --git a/tests/data/FileFactory.ts b/tests/data/FileFactory.ts new file mode 100644 index 00000000..5ee0d8f7 --- /dev/null +++ b/tests/data/FileFactory.ts @@ -0,0 +1,24 @@ +import { File } from '../../types'; + +const ONE_BYTE_FILLER = 'a'; + +// Factory for mocking in-memory files for testing with multer +export class FileFactory { + public static image(size: number): File { + const imageContents = ONE_BYTE_FILLER.repeat(size); + const buffer = Buffer.from(imageContents); + return { + fieldname: 'image', + originalname: 'image.jpg', + filename: 'image.jpg', + mimetype: 'image/jpg', + buffer, + encoding: '7bit', + size, + // the below fields are unused for in-memory multer storage but are still required by type def + stream: null, + destination: null, + path: null, + }; + } +} diff --git a/tests/data/index.ts b/tests/data/index.ts index e760ea5a..d39e24f3 100644 --- a/tests/data/index.ts +++ b/tests/data/index.ts @@ -3,5 +3,7 @@ export * from './DatabaseConnection'; export * from './UserFactory'; export * from './EventFactory'; export * from './MerchFactory'; +export * from './FeedbackFactory'; +export * from './FileFactory'; export * from './PortalState'; diff --git a/tests/event.test.ts b/tests/event.test.ts new file mode 100644 index 00000000..3085af58 --- /dev/null +++ b/tests/event.test.ts @@ -0,0 +1,252 @@ +import { validate } from 'class-validator'; +import { plainToClass } from 'class-transformer'; +import { DatabaseConnection, EventFactory, FileFactory, PortalState, UserFactory } from './data'; +import { ControllerFactory } from './ControllerFactory'; +import { UserAccessType } from '../types'; +import { Config } from '../config'; +import { SubmitEventFeedbackRequest } from '../api/validators/EventControllerRequests'; +import Mocks from './mocks'; + +beforeAll(async () => { + await DatabaseConnection.connect(); +}); + +beforeEach(async () => { + await DatabaseConnection.clear(); +}); + +afterAll(async () => { + await DatabaseConnection.clear(); + await DatabaseConnection.close(); +}); + +describe('event CRUD operations', () => { + test('events from the past, future, and all time can be pulled', async () => { + const conn = await DatabaseConnection.get(); + const pastEvent = EventFactory.fakePastEvent(); + const ongoingEvent = EventFactory.fakeOngoingEvent(); + const futureEvent = EventFactory.fakeFutureEvent(); + const user = UserFactory.fake(); + + await new PortalState() + .createEvents([pastEvent, ongoingEvent, futureEvent]) + .createUsers([user]) + .write(); + + const eventController = ControllerFactory.event(conn); + + const pastEventsResponse = await eventController.getPastEvents({}, user); + expect(pastEventsResponse.events).toEqual( + expect.arrayContaining([pastEvent.getPublicEvent()]), + ); + + const futureEventsResponse = await eventController.getFutureEvents({}, user); + expect(futureEventsResponse.events).toEqual( + expect.arrayContaining([ + ongoingEvent.getPublicEvent(), + futureEvent.getPublicEvent(), + ]), + ); + + const allEventsResponse = await eventController.getAllEvents({}, user); + expect(allEventsResponse.events).toEqual( + expect.arrayContaining([ + pastEvent.getPublicEvent(), + ongoingEvent.getPublicEvent(), + futureEvent.getPublicEvent(), + ]), + ); + }); + + test('events can be created with unused attendance codes', async () => { + const conn = await DatabaseConnection.get(); + const [event1] = EventFactory.with({ attendanceCode: 'code' }); + const [event2] = EventFactory.with({ attendanceCode: 'different-code' }); + const [admin] = UserFactory.with({ accessType: UserAccessType.ADMIN }); + + await new PortalState() + .createEvents([event1]) + .createUsers([admin]) + .write(); + + const createEventResponse = await ControllerFactory.event(conn).createEvent({ event: event2 }, admin); + + expect(createEventResponse.error).toBeNull(); + }); + + test('events cannot be created with duplicate attendance codes', async () => { + const conn = await DatabaseConnection.get(); + const [event1] = EventFactory.with({ attendanceCode: 'code' }); + const [event2] = EventFactory.with({ attendanceCode: 'code' }); + const [admin] = UserFactory.with({ accessType: UserAccessType.ADMIN }); + + await new PortalState() + .createEvents([event1]) + .createUsers([admin]) + .write(); + + const eventController = ControllerFactory.event(conn); + + await expect( + eventController.createEvent({ event: event2 }, admin), + ).rejects.toThrow('Attendance code has already been used'); + + const eventsResponse = await eventController.getAllEvents({}, admin); + + expect(eventsResponse.events).toHaveLength(1); + expect(eventsResponse.events[0]).toStrictEqual(event1.getPublicEvent(true)); + }); + + test('events can be updated by an admin', async () => { + const conn = await DatabaseConnection.get(); + const [event] = EventFactory.with({ attendanceCode: 'code' }); + const [admin] = UserFactory.with({ accessType: UserAccessType.ADMIN }); + + await new PortalState() + .createEvents([event]) + .createUsers([admin]) + .write(); + + const eventChanges = { + title: 'new title', + description: 'new description', + }; + + const updateEventResponse = await ControllerFactory + .event(conn) + .updateEvent({ uuid: event.uuid }, { event: eventChanges }, admin); + + expect(updateEventResponse.event).toMatchObject({ ...event.getPublicEvent(), ...eventChanges }); + }); + + test('event attendance code cannot be updated to a duplicate one', async () => { + const conn = await DatabaseConnection.get(); + const [event1] = EventFactory.with({ attendanceCode: 'code' }); + const [event2] = EventFactory.with({ attendanceCode: 'another-code' }); + const [admin] = UserFactory.with({ accessType: UserAccessType.ADMIN }); + + await new PortalState() + .createEvents([event1, event2]) + .createUsers([admin]) + .write(); + + event1.attendanceCode = event2.attendanceCode; + + await expect( + ControllerFactory.event(conn).updateEvent({ uuid: event1.uuid }, { event: event1 }, admin), + ).rejects.toThrow('Attendance code has already been used'); + }); +}); + +describe('event covers', () => { + test('properly updates cover photo in database', async () => { + const conn = await DatabaseConnection.get(); + const [event] = EventFactory.create(1); + const [admin] = UserFactory.with({ accessType: UserAccessType.ADMIN }); + const cover = FileFactory.image(Config.file.MAX_EVENT_COVER_FILE_SIZE / 2); + const fileLocation = 'fake location'; + + await new PortalState() + .createUsers([admin]) + .createEvents([event]) + .write(); + + await ControllerFactory + .event(conn, undefined, Mocks.storage(fileLocation)) + .updateEventCover(cover, { uuid: event.uuid }, admin); + + const eventResponse = await ControllerFactory.event(conn).getOneEvent({ uuid: event.uuid }, admin); + expect(eventResponse.event.cover).toEqual(fileLocation); + }); + + test('rejects upload if file size too large', async () => { + // TODO: implement once API wrappers exist (since multer validation can't be verified with function calls) + }); +}); + +describe('event feedback', () => { + test('can be persisted and rewarded points when submitted for an event already attended', async () => { + const conn = await DatabaseConnection.get(); + const event = EventFactory.fakeOngoingEvent(); + const user = UserFactory.fake(); + const feedback = EventFactory.createEventFeedback(3); + + await new PortalState() + .createUsers([user]) + .createEvents([event]) + .attendEvents([user], [event], false) + .write(); + + await ControllerFactory.event(conn).submitEventFeedback({ uuid: event.uuid }, { feedback }, user); + + const attendanceResponse = await ControllerFactory.attendance(conn).getAttendancesForCurrentUser(user); + + expect(attendanceResponse.attendances[0].feedback).toEqual(feedback); + expect(user.points).toEqual(event.pointValue + Config.pointReward.EVENT_FEEDBACK_POINT_REWARD); + }); + + test('is rejected on submission to an event not attended', async () => { + const conn = await DatabaseConnection.get(); + const event = EventFactory.fakeOngoingEvent(); + const user = UserFactory.fake(); + const feedback = EventFactory.createEventFeedback(3); + + await new PortalState() + .createUsers([user]) + .createEvents([event]) + .write(); + + await expect( + ControllerFactory.event(conn).submitEventFeedback({ uuid: event.uuid }, { feedback }, user), + ).rejects.toThrow('You must attend this event before submiting feedback'); + }); + + test('is rejected if submitted to an event multiple times', async () => { + const conn = await DatabaseConnection.get(); + const event = EventFactory.fakeOngoingEvent(); + const user = UserFactory.fake(); + const feedback = EventFactory.createEventFeedback(3); + + await new PortalState() + .createUsers([user]) + .createEvents([event]) + .attendEvents([user], [event], false) + .write(); + + const eventController = ControllerFactory.event(conn); + await eventController.submitEventFeedback({ uuid: event.uuid }, { feedback }, user); + + await expect( + eventController.submitEventFeedback({ uuid: event.uuid }, { feedback }, user), + ).rejects.toThrow('You cannot submit feedback for this event more than once'); + }); + + test('is rejected if sent after 2 days of event completion', async () => { + const conn = await DatabaseConnection.get(); + const event = EventFactory.fakePastEvent(3); + const user = UserFactory.fake(); + const feedback = EventFactory.createEventFeedback(3); + + await new PortalState() + .createUsers([user]) + .createEvents([event]) + .attendEvents([user], [event], false) + .write(); + + await expect( + ControllerFactory.event(conn).submitEventFeedback({ uuid: event.uuid }, { feedback }, user), + ).rejects.toThrow('You must submit feedback within 2 days of the event ending'); + }); + + test('is rejected if more than 3 feedback is provided', async () => { + const feedback = EventFactory.createEventFeedback(4); + const errors = await validate(plainToClass(SubmitEventFeedbackRequest, { feedback })); + + expect(errors).toBeDefined(); + expect(errors).toHaveLength(1); + expect(errors[0].property).toEqual('feedback'); + expect(errors[0].constraints.EventFeedbackValidator).toEqual( + 'No more than 3 event feedback comments can be submitted', + ); + }); +}); diff --git a/tests/feedback.test.ts b/tests/feedback.test.ts index adbba11c..d39a6f24 100644 --- a/tests/feedback.test.ts +++ b/tests/feedback.test.ts @@ -5,7 +5,7 @@ import { FeedbackFactory } from './data/FeedbackFactory'; import { ActivityScope, ActivityType, FeedbackStatus, UserAccessType } from '../types'; import { Feedback } from '../api/validators/FeedbackControllerRequests'; import { Config } from '../config'; -import { ControllerFactory } from './controllers'; +import { ControllerFactory } from './ControllerFactory'; beforeAll(async () => { await DatabaseConnection.connect(); @@ -118,8 +118,8 @@ describe('feedback submission', () => { const submittedFeedbackResponse = await feedbackController.submitFeedback({ feedback }, user); const status = FeedbackStatus.ACKNOWLEDGED; - const uuid = submittedFeedbackResponse.feedback; - const acknowledgedFeedback = await feedbackController.updateFeedbackStatus(uuid, { status }, admin); + const { uuid } = submittedFeedbackResponse.feedback; + const acknowledgedFeedback = await feedbackController.updateFeedbackStatus({ uuid }, { status }, admin); const persistedUserResponse = await userController.getUser({ uuid: user.uuid }, admin); @@ -142,12 +142,12 @@ describe('feedback submission', () => { const submittedFeedbackResponse = await feedbackController.submitFeedback({ feedback }, user); const status = FeedbackStatus.IGNORED; - const uuid = submittedFeedbackResponse.feedback; - const ignoredFeedbackResponse = await feedbackController.updateFeedbackStatus(uuid, { status }, admin); - - const persistedUserResponse = await userController.getUser({ uuid: user.uuid }, admin); + const { uuid } = submittedFeedbackResponse.feedback; + const ignoredFeedbackResponse = await feedbackController.updateFeedbackStatus({ uuid }, { status }, admin); expect(ignoredFeedbackResponse.feedback.status).toEqual(FeedbackStatus.IGNORED); + + const persistedUserResponse = await userController.getUser({ uuid: user.uuid }, admin); expect(persistedUserResponse.user.points).toEqual(user.points); }); diff --git a/tests/mocks/index.ts b/tests/mocks/index.ts new file mode 100644 index 00000000..c09eddd5 --- /dev/null +++ b/tests/mocks/index.ts @@ -0,0 +1,10 @@ +import { anything, instance, mock, when } from 'ts-mockito'; +import StorageService from '../../services/StorageService'; + +export default class Mocks { + public static storage(fileLocation: string) { + const storageMock = mock(StorageService); + when(storageMock.upload(anything(), anything(), anything())).thenResolve(fileLocation); + return instance(storageMock); + } +}