diff --git a/README.md b/README.md index 9e9445bdc..640242ad8 100644 --- a/README.md +++ b/README.md @@ -134,10 +134,14 @@ After these have been set up, set the environment variables according to the tab |BULK_UPLOAD_MAX_NUM|No|Maximum number of links that can be bulk uploaded at once. Defaults to 1000| |BULK_UPLOAD_RANDOM_STR_LENGTH|No|String length of randomly generated shortUrl in bulk upload. Defaults to 8| |BULK_QR_CODE_BATCH_SIZE|No|Maximum batch size of QR codes to generate in a single Lambda run. Defaults to 1000| +|BULK_QR_CODE_BUCKET_URL|No|Link to download QR codes from| +|ACTIVATE_BULK_QR_CODE_GENERATION|No|Whether to start Lambda for bulk QR code generation or not. Defaults to false| |REPLICA_URI|Yes|The postgres connection string, e.g. `postgres://postgres:postgres@postgres:5432/postgres`| |SQS_BULK_QRCODE_GENERATE_START_URL|No|The SQS queue for starting QR code bulk generation Lambda| |SQS_TIMEOUT|No|Duration of time in ms for sending to SQS queue before timeout. Defaults to 10000ms (10s)| |SQS_REGION|No|AWS Region of SQS queue for starting QR code bulk generation Lambda| +|JOB_POLL_ATTEMPTS|No|Number of attempts for long polling of job status before timeout of 408 is returned. Defaults to 12| +|JOB_POLL_INTERVAL|No|Interval of time between attempts for long polling of job status in ms. Defaults to 5000ms (5s)| #### Serverless functions for link migration diff --git a/docker-compose.yml b/docker-compose.yml index 9666a8664..58c1331a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,6 +57,10 @@ services: - SQS_REGION= - BULK_QR_CODE_BATCH_SIZE=1000 - LAMBDA_HASH_SECRET= + - ACTIVATE_BULK_QR_CODE_GENERATION=false + - JOB_POLL_ATTEMPTS=12 + - JOB_POLL_INTERVAL=5000 + - BULK_QR_CODE_BUCKET_URL= volumes: - ./public:/usr/src/gogovsg/public diff --git a/src/server/api/user/index.ts b/src/server/api/user/index.ts index 607cfb5a3..7df5a4fda 100644 --- a/src/server/api/user/index.ts +++ b/src/server/api/user/index.ts @@ -10,6 +10,7 @@ import { } from '../../../shared/constants' import { ownershipTransferSchema, + pollJobInformationSchema, tagRetrievalSchema, urlBulkSchema, urlEditSchema, @@ -163,6 +164,14 @@ router.get( userController.getTagsWithConditions, ) +router.get( + '/job/status', + validator.query(pollJobInformationSchema), + jobController.pollJobStatusUpdate, +) + +router.get('/job/latest', jobController.getLatestJob) + router.get('/message', userController.getUserMessage) router.get('/announcement', userController.getUserAnnouncement) diff --git a/src/server/api/user/validators.ts b/src/server/api/user/validators.ts index cc3a57bee..e799e94e0 100644 --- a/src/server/api/user/validators.ts +++ b/src/server/api/user/validators.ts @@ -142,3 +142,7 @@ export const ownershipTransferSchema = Joi.object({ shortUrl: Joi.string().required(), newUserEmail: Joi.string().required(), }) + +export const pollJobInformationSchema = Joi.object({ + jobId: Joi.number().required(), +}) diff --git a/src/server/config.ts b/src/server/config.ts index e53925a00..46fdaf64b 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -251,4 +251,11 @@ export const bulkUploadRandomStrLength: number = Number(process.env.BULK_UPLOAD_RANDOM_STR_LENGTH) || 8 export const qrCodeJobBatchSize: number = Number(process.env.BULK_QR_CODE_BATCH_SIZE) || 1000 +export const qrCodeBucketUrl: string = process.env.BULK_QR_CODE_BUCKET_URL || '' +export const shouldGenerateQRCodes: boolean = + process.env.ACTIVATE_BULK_QR_CODE_GENERATION === 'true' export const lambdaHashSecret: string = process.env.LAMBDA_HASH_SECRET as string +export const jobPollInterval: number = + Number(process.env.JOB_POLL_INTERVAL) || 5000 // in ms +export const jobPollAttempts: number = + Number(process.env.JOB_POLL_ATTEMPTS) || 12 diff --git a/src/server/inversify.config.ts b/src/server/inversify.config.ts index 71efc8121..3e68d5efb 100644 --- a/src/server/inversify.config.ts +++ b/src/server/inversify.config.ts @@ -76,7 +76,7 @@ import { FileCheckController, UrlCheckController } from './modules/threat' import { QrCodeService } from './modules/qr/services' import { QrCodeController } from './modules/qr' import TagManagementService from './modules/user/services/TagManagementService' -import JobManagementService from './modules/job/services/JobManagementService' +import { JobManagementService } from './modules/job/services' import { BulkService } from './modules/bulk/services' import { BulkController } from './modules/bulk' import { SQSService } from './services/sqs' diff --git a/src/server/modules/bulk/BulkController.ts b/src/server/modules/bulk/BulkController.ts index 55aa6bded..029745d8d 100644 --- a/src/server/modules/bulk/BulkController.ts +++ b/src/server/modules/bulk/BulkController.ts @@ -6,6 +6,7 @@ import { DependencyIds } from '../../constants' import { BulkService } from './interfaces' import { UrlManagementService } from '../user/interfaces' import dogstatsd from '../../util/dogstatsd' +import { logger, shouldGenerateQRCodes } from '../../config' @injectable() export class BulkController { @@ -62,12 +63,18 @@ export class BulkController { return } - // put jobParamsList on the req body so that it can be used by JobController - req.body.jobParamsList = urlMappings - dogstatsd.increment('bulk.hash.success', 1, 1) - res.ok(jsonMessage(`${urlMappings.length} links created`)) - next() + if (shouldGenerateQRCodes) { + logger.info('shouldGenerateQRCodes true, triggering QR code generation') + // put jobParamsList on the req body so that it can be used by JobController + req.body.jobParamsList = urlMappings + next() + } else { + logger.info( + 'shouldGenerateQRCodes false, not triggering QR code generation', + ) + res.ok({ count: urlMappings.length }) + } } } diff --git a/src/server/modules/bulk/__tests__/BulkController.test.ts b/src/server/modules/bulk/__tests__/BulkController.test.ts index 01cb599f3..63bf244b2 100644 --- a/src/server/modules/bulk/__tests__/BulkController.test.ts +++ b/src/server/modules/bulk/__tests__/BulkController.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable global-require */ import httpMocks from 'node-mocks-http' import express from 'express' @@ -77,7 +78,101 @@ describe('BulkController unit test', () => { }) }) - describe('bulkCreate tests', () => { + describe('bulkCreate config tests', () => { + const ok = jest.fn() + + beforeEach(() => { + ok.mockClear() + }) + + it('bulkCreate with shouldGenerateQRCodes true should call next', async () => { + jest.resetModules() + const logger = { + info: jest.fn(), + } + jest.mock('../../../config', () => ({ + shouldGenerateQRCodes: true, + logger, + })) + + const { BulkController } = require('..') + + const controller = new BulkController( + mockBulkService, + mockUrlManagementService, + ) + + const userId = 1 + const longUrl = 'https://google.com' + const urlMappings = [ + { + shortUrl: 'n2io3n12', + longUrl, + }, + ] + + const req = httpMocks.createRequest({ + body: { userId, longUrls: [longUrl] }, + }) + const res = httpMocks.createResponse() as any + res.ok = ok + const next = jest.fn() as unknown as express.NextFunction + + mockBulkService.generateUrlMappings.mockResolvedValue(urlMappings) + mockUrlManagementService.bulkCreate.mockResolvedValue({}) + + await controller.bulkCreate(req, res, next) + expect(req.body).toHaveProperty('jobParamsList') + expect(next).toHaveBeenCalled() + expect(res.ok).not.toHaveBeenCalled() + expect(logger.info).toHaveBeenCalled() + }) + + it('bulkCreate with shouldGenerateQRCodes false should call res.ok', async () => { + jest.resetModules() + const logger = { + info: jest.fn(), + } + jest.mock('../../../config', () => ({ + shouldGenerateQRCodes: false, + logger, + })) + + const { BulkController } = require('..') + + const controller = new BulkController( + mockBulkService, + mockUrlManagementService, + ) + + const userId = 1 + const longUrl = 'https://google.com' + const urlMappings = [ + { + shortUrl: 'n2io3n12', + longUrl, + }, + ] + + const req = httpMocks.createRequest({ + body: { userId, longUrls: [longUrl] }, + }) + const res = httpMocks.createResponse() as any + res.ok = ok + const next = jest.fn() as unknown as express.NextFunction + + mockBulkService.generateUrlMappings.mockResolvedValue(urlMappings) + mockUrlManagementService.bulkCreate.mockResolvedValue({}) + + await controller.bulkCreate(req, res, next) + + expect(next).not.toHaveBeenCalled() + expect(req.body).not.toHaveProperty('jobParamsList') + expect(res.ok).toHaveBeenCalled() + }) + }) + + describe('bulkCreate main tests', () => { const badRequest = jest.fn() const ok = jest.fn() @@ -116,8 +211,6 @@ describe('BulkController unit test', () => { undefined, ) expect(res.badRequest).not.toHaveBeenCalled() - expect(res.ok).toHaveBeenCalled() - expect(next).toHaveBeenCalled() }) it('bulkCreate without tags responds with error if urls are not created', async () => { @@ -148,8 +241,6 @@ describe('BulkController unit test', () => { undefined, ) expect(res.badRequest).toHaveBeenCalled() - expect(res.ok).not.toHaveBeenCalled() - expect(next).not.toHaveBeenCalled() }) it('bulkCreate with tags should return success if urls are created', async () => { @@ -183,8 +274,6 @@ describe('BulkController unit test', () => { tags, ) expect(res.badRequest).not.toHaveBeenCalled() - expect(res.ok).toHaveBeenCalled() - expect(next).toHaveBeenCalled() }) it('bulkCreate with tags responds with error if urls are not created', async () => { @@ -216,8 +305,6 @@ describe('BulkController unit test', () => { tags, ) expect(res.badRequest).toHaveBeenCalled() - expect(res.ok).not.toHaveBeenCalled() - expect(next).not.toHaveBeenCalled() }) }) }) diff --git a/src/server/modules/job/JobController.ts b/src/server/modules/job/JobController.ts index e8ff6ff3e..236b27639 100644 --- a/src/server/modules/job/JobController.ts +++ b/src/server/modules/job/JobController.ts @@ -7,6 +7,7 @@ import { SQSServiceInterface } from '../../services/sqs' import { JobManagementService } from './interfaces' import { logger, qrCodeJobBatchSize } from '../../config' import jsonMessage from '../../util/json' +import { NotFoundError } from '../../util/error' @injectable() export class JobController { @@ -24,37 +25,42 @@ export class JobController { this.jobManagementService = jobManagementService } - public createAndStartJob: (req: Request) => Promise = async (req) => { - const { userId, jobParamsList } = req.body - if (!jobParamsList || jobParamsList.length === 0) { - return - } + public createAndStartJob: (req: Request, res: Response) => Promise = + async (req, res) => { + const { userId, jobParamsList } = req.body + if (!jobParamsList || jobParamsList.length === 0) { + return + } - const jobBatches = _.chunk(jobParamsList, qrCodeJobBatchSize) - try { - const job = await this.jobManagementService.createJob(userId) + const jobBatches = _.chunk(jobParamsList, qrCodeJobBatchSize) + try { + const job = await this.jobManagementService.createJob(userId) - await Promise.all( - jobBatches.map(async (jobBatch, idx) => { - const messageParams = { - jobItemId: `${job.uuid}/${idx}`, - mappings: jobBatch, - } - await this.jobManagementService.createJobItem({ - params: (messageParams), - jobId: job.id, - jobItemId: `${job.uuid}/${idx}`, - }) - await this.sqsService.sendMessage(messageParams) - return - }), - ) - dogstatsd.increment('job.start.success', 1, 1) - } catch (error) { - logger.error(`error creating and starting job: ${error}`) - dogstatsd.increment('job.start.failure', 1, 1) + await Promise.all( + jobBatches.map(async (jobBatch, idx) => { + const messageParams = { + jobItemId: `${job.uuid}/${idx}`, + mappings: jobBatch, + } + await this.jobManagementService.createJobItem({ + params: (messageParams), + jobId: job.id, + jobItemId: `${job.uuid}/${idx}`, + }) + await this.sqsService.sendMessage(messageParams) + return + }), + ) + dogstatsd.increment('job.start.success', 1, 1) + res.ok({ count: jobParamsList.length, job }) + } catch (error) { + logger.error(`error creating and starting job: ${error}`) + dogstatsd.increment('job.start.failure', 1, 1) + // created links but failed to create and start job + res.serverError({ count: jobParamsList.length }) + } + return } - } public updateJobItem: ( req: Request, @@ -91,6 +97,50 @@ export class JobController { } return } + + public getLatestJob: (req: Request, res: Response) => Promise = async ( + req, + res, + ) => { + const { userId } = req.body + try { + const jobInformation = + await this.jobManagementService.getLatestJobForUser(userId) + res.ok(jobInformation) + } catch (error) { + if (error instanceof NotFoundError) { + res.ok(jsonMessage('User has no jobs')) + return + } + res.serverError(jsonMessage(error.message)) + } + return + } + + public pollJobStatusUpdate: (req: Request, res: Response) => Promise = + async (req, res) => { + const { jobId } = req.query + const user = req.session?.user + if (!user) { + res.status(401).send(jsonMessage('User session does not exist')) + return + } + try { + const jobInformation = + await this.jobManagementService.pollJobStatusUpdate( + user.id, + parseInt(jobId as string, 10), + ) + res.ok(jobInformation) + } catch (error) { + if (error instanceof NotFoundError) { + res.notFound(jsonMessage(error.message)) + return + } + res.status(408).send(jsonMessage('Request timed out, please try again')) + } + return + } } export default JobController diff --git a/src/server/modules/job/__tests__/JobController.test.ts b/src/server/modules/job/__tests__/JobController.test.ts index 751a7f5d9..306260bcd 100644 --- a/src/server/modules/job/__tests__/JobController.test.ts +++ b/src/server/modules/job/__tests__/JobController.test.ts @@ -3,8 +3,10 @@ import httpMocks from 'node-mocks-http' import _ from 'lodash' import express from 'express' -import { JobItemStatusEnum } from '../../../repositories/enums' +import { JobItemStatusEnum, JobStatusEnum } from '../../../repositories/enums' import { NotFoundError } from '../../../util/error' +import { JobInformation } from '../interfaces' +import { UserType } from '../../../models/user' const jobManagementService = { createJob: jest.fn(), @@ -12,6 +14,8 @@ const jobManagementService = { updateJobItemStatus: jest.fn(), computeJobStatus: jest.fn(), updateJobStatus: jest.fn(), + getLatestJobForUser: jest.fn(), + pollJobStatusUpdate: jest.fn(), } const sqsService = { @@ -29,6 +33,10 @@ const getMockMessageParams = ( } } +const { JobController } = require('..') + +const controller = new JobController(jobManagementService, sqsService) + describe('JobController unit test', () => { afterAll(jest.resetModules) @@ -49,11 +57,13 @@ describe('JobController unit test', () => { longUrl, }, ] + const serverError = jest.fn() beforeEach(() => { jobManagementService.createJob.mockReset() jobManagementService.createJobItem.mockReset() sqsService.sendMessage.mockReset() + serverError.mockClear() }) it('createAndStartJob works with 1 batch', async () => { @@ -82,12 +92,14 @@ describe('JobController unit test', () => { const req = httpMocks.createRequest({ body: { userId, jobParamsList }, }) + const res = httpMocks.createResponse() as any + res.serverError = serverError jobManagementService.createJob.mockResolvedValue(mockJob) jobManagementService.createJobItem.mockResolvedValue({}) sqsService.sendMessage.mockResolvedValue({}) - await controller.createAndStartJob(req) + await controller.createAndStartJob(req, res) expect(jobManagementService.createJob).toHaveBeenCalledWith(userId) expect(jobManagementService.createJobItem).toHaveBeenCalledWith({ @@ -138,12 +150,14 @@ describe('JobController unit test', () => { const req = httpMocks.createRequest({ body: { userId, jobParamsList }, }) + const res = httpMocks.createResponse() as any + res.serverError = serverError jobManagementService.createJob.mockResolvedValue(mockJob) jobManagementService.createJobItem.mockResolvedValue({}) sqsService.sendMessage.mockResolvedValue({}) - await controller.createAndStartJob(req) + await controller.createAndStartJob(req, res) expect(jobManagementService.createJob).toHaveBeenCalledWith(userId) expect(jobManagementService.createJobItem).toHaveBeenCalledTimes( @@ -162,15 +176,13 @@ describe('JobController unit test', () => { }) it('createAndStartJob does not start job if jobParamsList is empty', async () => { - const { JobController } = require('..') - - const controller = new JobController(jobManagementService, sqsService) - const req = httpMocks.createRequest({ body: { userId, jobParamsList: [] }, }) - await controller.createAndStartJob(req) + const res = httpMocks.createResponse() as any + res.serverError = serverError + await controller.createAndStartJob(req, res) expect(jobManagementService.createJob).not.toHaveBeenCalled() expect(jobManagementService.createJobItem).not.toHaveBeenCalled() @@ -178,15 +190,13 @@ describe('JobController unit test', () => { }) it('createAndStartJob does not start job if no jobParamsList', async () => { - const { JobController } = require('..') - - const controller = new JobController(jobManagementService, sqsService) - const req = httpMocks.createRequest({ body: { userId }, }) - await controller.createAndStartJob(req) + const res = httpMocks.createResponse() as any + res.serverError = serverError + await controller.createAndStartJob(req, res) expect(jobManagementService.createJob).not.toHaveBeenCalled() expect(jobManagementService.createJobItem).not.toHaveBeenCalled() @@ -204,10 +214,6 @@ describe('JobController unit test', () => { }) it('updateJobItem adds jobItem to req body if update is successful', async () => { - const { JobController } = require('..') - - const controller = new JobController(jobManagementService, sqsService) - const req = httpMocks.createRequest({ body: { jobItemId: 1, status: { isSuccess: true, errorMessage: ' ' } }, }) @@ -238,10 +244,6 @@ describe('JobController unit test', () => { }) it('updateJobItem does not add jobItem to req body if update is not successful', async () => { - const { JobController } = require('..') - - const controller = new JobController(jobManagementService, sqsService) - const req = httpMocks.createRequest({ body: { jobItemId: 24, status: { isSuccess: true, errorMessage: ' ' } }, }) @@ -263,4 +265,203 @@ describe('JobController unit test', () => { expect(responseSpy).toBeCalledWith(404) }) }) + + describe('updateJob', () => { + it('should succeed if jobManagementService.updateJobStatus succeeds', async () => { + const req = httpMocks.createRequest({ + body: { jobId: 1 }, + }) + const updatedJob = { + id: 1, + uuid: 'abc', + status: JobStatusEnum.Success, + userId: 1, + } + jobManagementService.updateJobStatus.mockResolvedValue(updatedJob) + + await controller.updateJob(req) + + expect(jobManagementService.updateJobStatus).toHaveBeenCalledWith(1) + }) + + it('should fail and log error if jobManagementService.updateJobStatus fails', async () => { + const req = httpMocks.createRequest({ + body: { jobId: 1 }, + }) + jobManagementService.updateJobStatus.mockRejectedValue( + new NotFoundError('Job not found'), + ) + + await controller.updateJob(req) + + expect(jobManagementService.updateJobStatus).toHaveBeenCalledWith(1) + }) + + describe('getLatestJob', () => { + const ok = jest.fn() + const serverError = jest.fn() + + beforeEach(() => { + ok.mockClear() + serverError.mockClear() + }) + + it('should succeed and respond with jobInformation if jobManagementService.getLatestJob returns information', async () => { + const userId = 2 + const req = httpMocks.createRequest({ + body: { userId }, + }) + const res = httpMocks.createResponse() as any + res.ok = ok + res.serverError = serverError + + const mockJobInformation = { + job: { + id: 1, + uuid: 'abc', + status: JobStatusEnum.Success, + userId: 2, + }, + jobItemUrls: ['https://bucket.com/abc/0', 'https://bucket.com/abc/1'], + } as unknown as JobInformation + jobManagementService.getLatestJobForUser.mockResolvedValue( + mockJobInformation, + ) + + await controller.getLatestJob(req, res) + expect(jobManagementService.getLatestJobForUser).toBeCalledWith(userId) + expect(res.ok).toBeCalledWith(mockJobInformation) + expect(res.serverError).not.toBeCalled() + }) + + it('should respond with res.ok if jobManagementService.getLatestJob is unable to find a job for user', async () => { + const userId = 2 + const req = httpMocks.createRequest({ + body: { userId }, + }) + const res = httpMocks.createResponse() as any + res.ok = ok + res.serverError = serverError + + jobManagementService.getLatestJobForUser.mockRejectedValue( + new NotFoundError('No jobs found'), + ) + + await controller.getLatestJob(req, res) + expect(jobManagementService.getLatestJobForUser).toBeCalledWith(userId) + expect(res.ok).toBeCalled() + expect(res.serverError).not.toBeCalled() + }) + + it('should respond with badRequest if jobManagementService.getLatestJob fails', async () => { + const controller = new JobController(jobManagementService, sqsService) + const userId = 2 + const req = httpMocks.createRequest({ + body: { userId }, + }) + const res = httpMocks.createResponse() as any + res.ok = ok + res.serverError = serverError + + jobManagementService.getLatestJobForUser.mockRejectedValue(new Error()) + + await controller.getLatestJob(req, res) + expect(jobManagementService.getLatestJobForUser).toBeCalledWith(userId) + expect(res.ok).not.toBeCalled() + expect(res.serverError).toBeCalled() + }) + }) + + describe('pollJobStatusUpdate', () => { + const ok = jest.fn() + const notFound = jest.fn() + const userCredentials = { + id: 2, + email: 'hello@open.gov.sg', + } as UserType + + beforeEach(() => { + ok.mockClear() + notFound.mockClear() + }) + + it('should respond with jobInformation if jobManagementService.pollJobStatusUpdate succeeds', async () => { + const jobId = 4 + const req = httpMocks.createRequest({ + query: { jobId }, + session: { user: userCredentials }, + }) + const res = httpMocks.createResponse() as any + res.ok = ok + res.notFound = notFound + + const mockJobInformation = { + job: { + id: 1, + uuid: 'abc', + status: JobStatusEnum.Success, + userId: 2, + }, + jobItemUrls: ['https://bucket.com/abc/0', 'https://bucket.com/abc/1'], + } as unknown as JobInformation + + jobManagementService.pollJobStatusUpdate.mockResolvedValue( + mockJobInformation, + ) + + await controller.pollJobStatusUpdate(req, res) + expect(jobManagementService.pollJobStatusUpdate).toBeCalledWith( + userCredentials.id, + jobId, + ) + expect(res.ok).toBeCalledWith(mockJobInformation) + expect(res.notFound).not.toBeCalled() + }) + + it('should respond with notFound if jobManagementService.pollJobStatusUpdate is unable to find job', async () => { + const jobId = 4 + const req = httpMocks.createRequest({ + query: { jobId }, + session: { user: userCredentials }, + }) + const res = httpMocks.createResponse() as any + res.ok = ok + res.notFound = notFound + + jobManagementService.pollJobStatusUpdate.mockRejectedValue( + new NotFoundError(''), + ) + + await controller.pollJobStatusUpdate(req, res) + expect(res.notFound).toBeCalled() + expect(res.ok).not.toBeCalled() + }) + + it('should throw 408 if jobManagementService.pollJobStatusUpdate exceeds long polling timeout', async () => { + const jobId = 4 + const req = httpMocks.createRequest({ + query: { jobId }, + session: { user: userCredentials }, + }) + const res = httpMocks.createResponse() as any + res.ok = ok + res.send = jest.fn() + res.status = jest.fn(() => res) + res.notFound = notFound + + jobManagementService.pollJobStatusUpdate.mockRejectedValue( + new Error('Exceeded max attempts'), + ) + + await controller.pollJobStatusUpdate(req, res) + expect(jobManagementService.pollJobStatusUpdate).toBeCalledWith( + userCredentials.id, + jobId, + ) + expect(res.status).toBeCalledWith(408) + expect(res.ok).not.toBeCalled() + expect(res.notFound).not.toBeCalled() + }) + }) + }) }) diff --git a/src/server/modules/job/interfaces/JobManagementService.ts b/src/server/modules/job/interfaces/JobManagementService.ts index 1be2968eb..ebfbfbd1d 100644 --- a/src/server/modules/job/interfaces/JobManagementService.ts +++ b/src/server/modules/job/interfaces/JobManagementService.ts @@ -6,6 +6,11 @@ export interface JobItemCallbackStatus { errorMessage?: string } +export interface JobInformation { + job: JobType + jobItemUrls: string[] +} + export interface JobManagementService { createJob(userId: number): Promise createJobItem: (properties: { @@ -19,4 +24,7 @@ export interface JobManagementService { ): Promise computeJobStatus(jobItems: JobItemType[]): JobStatusEnum updateJobStatus(jobId: number): Promise + getJobInformation(jobId: number): Promise + getLatestJobForUser(userId: number): Promise + pollJobStatusUpdate(userId: number, jobId: number): Promise } diff --git a/src/server/modules/job/interfaces/JobRepository.ts b/src/server/modules/job/interfaces/JobRepository.ts index 77d469b30..47532a65e 100644 --- a/src/server/modules/job/interfaces/JobRepository.ts +++ b/src/server/modules/job/interfaces/JobRepository.ts @@ -4,4 +4,6 @@ export interface JobRepository { findById(id: number): Promise create(userId: number): Promise update(job: JobType, changes: Partial): Promise + findJobForUser(userId: number, jobId: number): Promise + findLatestJobForUser(userId: number): Promise } diff --git a/src/server/modules/job/interfaces/index.ts b/src/server/modules/job/interfaces/index.ts index 25f0fa2bd..95d29a971 100644 --- a/src/server/modules/job/interfaces/index.ts +++ b/src/server/modules/job/interfaces/index.ts @@ -2,5 +2,6 @@ export { JobItemRepository } from './JobItemRepository' export { JobRepository } from './JobRepository' export { JobItemCallbackStatus, + JobInformation, JobManagementService, } from './JobManagementService' diff --git a/src/server/modules/job/repositories/JobRepository.ts b/src/server/modules/job/repositories/JobRepository.ts index 4959134b2..6487f37e4 100644 --- a/src/server/modules/job/repositories/JobRepository.ts +++ b/src/server/modules/job/repositories/JobRepository.ts @@ -28,6 +28,27 @@ export class JobRepository implements interfaces.JobRepository { if (!updatedJob) throw new Error('Newly-updated job is null') return updatedJob } + + findLatestJobForUser: (userId: number) => Promise = async ( + userId, + ) => { + return Job.findOne({ + where: { + userId, + }, + order: [['createdAt', 'DESC']], + }) + } + + findJobForUser: (userId: number, jobId: number) => Promise = + async (userId, jobId) => { + return Job.findOne({ + where: { + userId, + id: jobId, + }, + }) + } } export default JobRepository diff --git a/src/server/modules/job/services/JobManagementService.ts b/src/server/modules/job/services/JobManagementService.ts index 7d945b48d..4309a3510 100644 --- a/src/server/modules/job/services/JobManagementService.ts +++ b/src/server/modules/job/services/JobManagementService.ts @@ -5,9 +5,14 @@ import { DependencyIds } from '../../../constants' import { JobItemStatusEnum, JobStatusEnum } from '../../../repositories/enums' import { UserRepositoryInterface } from '../../../repositories/interfaces/UserRepositoryInterface' import { JobItemType, JobType } from '../../../models/job' +import { + jobPollAttempts, + jobPollInterval, + qrCodeBucketUrl, +} from '../../../config' @injectable() -class JobManagementService implements interfaces.JobManagementService { +export class JobManagementService implements interfaces.JobManagementService { private jobRepository: interfaces.JobRepository private jobItemRepository: interfaces.JobItemRepository @@ -73,7 +78,7 @@ class JobManagementService implements interfaces.JobManagementService { return this.jobItemRepository.update(currJobItem, changes) } - // 'Failed' if any job item fails + // 'Failure' if any job item fails // 'Success' if all job items succeed // 'InProgress' if no failure and any job item is still in progress computeJobStatus: (jobItems: JobItemType[]) => JobStatusEnum = (jobItems) => { @@ -106,6 +111,64 @@ class JobManagementService implements interfaces.JobManagementService { const currJobStatus = this.computeJobStatus(jobItems) return this.jobRepository.update(job, { status: currJobStatus }) } + + getJobInformation: (jobId: number) => Promise = + async (jobId) => { + const job = await this.jobRepository.findById(jobId) + if (!job) { + throw new NotFoundError('Job not found') + } + + const jobItems = await this.jobItemRepository.findJobItemsByJobId(jobId) + if (jobItems.length === 0) { + throw new Error('Job does not have any job items') + } + + return { + job, + jobItemUrls: jobItems.map( + (jobItem) => `${qrCodeBucketUrl}/${jobItem.jobItemId}`, + ), + } + } + + getLatestJobForUser: (userId: number) => Promise = + async (userId) => { + const job = await this.jobRepository.findLatestJobForUser(userId) + if (!job) { + throw new NotFoundError('No jobs found') + } + const jobInformation = await this.getJobInformation(job.id) + return jobInformation + } + + pollJobStatusUpdate: ( + userId: number, + jobId: number, + ) => Promise = async (userId, jobId) => { + const job = await this.jobRepository.findJobForUser(userId, jobId) + if (!job) { + throw new NotFoundError('Job not found') + } + + let attempts = 0 + // eslint-disable-next-line consistent-return + const executePoll = async (resolve: any, reject: any) => { + if (attempts === jobPollAttempts) { + return reject(new Error('Exceeded max attempts')) + } + const { job, jobItemUrls } = await this.getJobInformation(jobId) + // if job status has changed, return updated job status + if (job.status !== JobStatusEnum.InProgress) { + return resolve({ job, jobItemUrls }) + } + // continue polling + setTimeout(executePoll, jobPollInterval, resolve, reject) + + attempts += 1 + } + return new Promise(executePoll) + } } export default JobManagementService diff --git a/src/server/modules/job/services/__tests__/JobManagementService.test.ts b/src/server/modules/job/services/__tests__/JobManagementService.test.ts index 78fa1201f..326231f17 100644 --- a/src/server/modules/job/services/__tests__/JobManagementService.test.ts +++ b/src/server/modules/job/services/__tests__/JobManagementService.test.ts @@ -1,16 +1,19 @@ +/* eslint-disable global-require */ import { JobItemStatusEnum, JobStatusEnum, } from '../../../../repositories/enums' import { NotFoundError } from '../../../../util/error' -import JobManagementService from '../JobManagementService' -import { JobItemCallbackStatus } from '../../interfaces' -import { JobItemType } from '../../../../models/job' +import { JobManagementService } from '..' +import { JobInformation, JobItemCallbackStatus } from '../../interfaces' +import { JobItemType, JobType } from '../../../../models/job' const mockJobRepository = { findById: jest.fn(), create: jest.fn(), update: jest.fn(), + findLatestJobForUser: jest.fn(), + findJobForUser: jest.fn(), } const mockJobItemRepository = { @@ -201,7 +204,7 @@ describe('JobManagementService tests', () => { }) describe('computeJobStatus', () => { - it('should return JobStatus.Failed if any job item fails', async () => { + it('should return JobStatusEnum.Failure if any job item fails', async () => { const mockJobItems = [ { status: JobItemStatusEnum.Success, @@ -302,9 +305,17 @@ describe('JobManagementService tests', () => { }) describe('updateJobStatus', () => { + const spy = jest.spyOn(service, 'computeJobStatus') + + beforeAll(() => { + mockJobItemRepository.findByJobItemId.mockClear() + mockJobRepository.findById.mockClear() + spy.mockClear() + }) + it('should throw error if job does not exist', async () => { mockJobRepository.findById.mockResolvedValue(null) - const spy = jest.spyOn(service, 'computeJobStatus') + await expect(service.updateJobStatus(2)).rejects.toThrow( new NotFoundError('Job not found'), ) @@ -322,7 +333,7 @@ describe('JobManagementService tests', () => { } mockJobRepository.findById.mockResolvedValue(mockJob) mockJobItemRepository.findJobItemsByJobId.mockResolvedValue([]) - const spy = jest.spyOn(service, 'computeJobStatus') + await expect(service.updateJobStatus(2)).rejects.toThrow( new Error('Job does not have any job items'), ) @@ -356,7 +367,6 @@ describe('JobManagementService tests', () => { mockJobRepository.findById.mockResolvedValue(mockJob) mockJobItemRepository.findJobItemsByJobId.mockResolvedValue(mockJobItems) mockJobRepository.update.mockResolvedValue(updatedMockJob) - const spy = jest.spyOn(service, 'computeJobStatus') await expect(service.updateJobStatus(2)).resolves.toStrictEqual( updatedMockJob, @@ -368,4 +378,140 @@ describe('JobManagementService tests', () => { }) }) }) + + describe('getJobInformation', () => { + beforeEach(() => { + mockJobItemRepository.findJobItemsByJobId.mockClear() + mockJobRepository.findById.mockClear() + }) + + it('should throw error if job does not exist', async () => { + mockJobRepository.findById.mockResolvedValue(null) + await expect(service.getJobInformation(2)).rejects.toThrow( + new NotFoundError('Job not found'), + ) + expect(mockJobItemRepository.findJobItemsByJobId).not.toBeCalled() + }) + + it('should throw error if job has no job items', async () => { + const mockUserId = 1 + const mockJob = { + uuid: 'abc', + userId: mockUserId, + id: 2, + } + mockJobRepository.findById.mockResolvedValue(mockJob) + mockJobItemRepository.findJobItemsByJobId.mockResolvedValue([]) + await expect(service.getJobInformation(2)).rejects.toThrow( + new Error('Job does not have any job items'), + ) + }) + + it('should return job and jobItemUrls if successfully retrieved', async () => { + jest.resetModules() + jest.mock('../../../../config', () => ({ + qrCodeBucketUrl: 'https://bucket.com', + })) + + const { JobManagementService } = require('..') + + const service = new JobManagementService( + mockJobRepository, + mockJobItemRepository, + mockUserRepository, + ) + + const mockUserId = 1 + const mockJob = { + uuid: 'abc', + userId: mockUserId, + id: 2, + status: JobStatusEnum.Success, + } + const mockJobItems = [ + { + status: JobItemStatusEnum.Success, + message: '', + params: ({ testParams: 'hello' }), + jobId: 2, + jobItemId: 'abc/0', + id: 1, + }, + ] + mockJobRepository.findById.mockResolvedValue(mockJob) + mockJobItemRepository.findJobItemsByJobId.mockResolvedValue(mockJobItems) + await expect(service.getJobInformation(2)).resolves.toStrictEqual({ + job: mockJob, + jobItemUrls: ['https://bucket.com/abc/0'], + }) + }) + }) + + describe('getLatestJobForUser', () => { + const spy = jest.spyOn(service, 'getJobInformation') + + beforeEach(() => { + spy.mockClear() + }) + + it('should throw error if user has no jobs', async () => { + mockJobRepository.findLatestJobForUser.mockResolvedValue(null) + + await expect(service.getLatestJobForUser(2)).rejects.toThrow( + new NotFoundError('No jobs found'), + ) + expect(spy).not.toBeCalled() + }) + + it('should retrieve job information and return successfully', async () => { + const mockJob = { + uuid: 'abc', + userId: 2, + id: 4, + status: JobStatusEnum.Success, + } as unknown as JobType + const mockJobInformation = { + job: mockJob, + jobItemUrls: ['https://bucket.com/abc/0'], + } as JobInformation + mockJobRepository.findLatestJobForUser.mockResolvedValue(mockJob) + spy.mockImplementation(() => Promise.resolve(mockJobInformation)) + + await expect(service.getLatestJobForUser(2)).resolves.toStrictEqual( + mockJobInformation, + ) + expect(spy).toBeCalledWith(4) + }) + }) + + describe('pollJobStatusUpdate', () => { + it('should throw error if user has no jobs', async () => { + mockJobRepository.findLatestJobForUser.mockResolvedValue(null) + + await expect(service.getLatestJobForUser(2)).rejects.toThrow( + new NotFoundError('No jobs found'), + ) + }) + + it('should retrieve job information and return successfully', async () => { + const mockJob = { + uuid: 'abc', + userId: 2, + id: 4, + status: JobStatusEnum.Success, + } as unknown as JobType + const mockJobInformation = { + job: mockJob, + jobItemUrls: ['https://bucket.com/abc/0'], + } as JobInformation + mockJobRepository.findLatestJobForUser.mockResolvedValue(mockJob) + const spy = jest + .spyOn(service, 'getJobInformation') + .mockImplementation(() => Promise.resolve(mockJobInformation)) + await expect(service.getLatestJobForUser(2)).resolves.toStrictEqual( + mockJobInformation, + ) + expect(spy).toBeCalledWith(4) + }) + }) }) diff --git a/src/server/modules/job/services/index.ts b/src/server/modules/job/services/index.ts new file mode 100644 index 000000000..23b901984 --- /dev/null +++ b/src/server/modules/job/services/index.ts @@ -0,0 +1,4 @@ +export { + JobManagementService, + JobManagementService as default, +} from './JobManagementService'