From 6b2b71884beb1bc5284f4b939744da34732e1680 Mon Sep 17 00:00:00 2001 From: ut5tw Date: Sun, 10 Nov 2024 17:50:00 +0200 Subject: [PATCH 1/5] Implement attachments download --- src/controllers/attachment.js | 9 ++++++++- src/routes/attachment.js | 1 + src/services/attachment.js | 13 +++++++++++++ src/services/upload.js | 14 ++++++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/controllers/attachment.js b/src/controllers/attachment.js index 12613334..9e27d2bb 100644 --- a/src/controllers/attachment.js +++ b/src/controllers/attachment.js @@ -56,9 +56,16 @@ const deleteAttachment = async (req, res) => { res.status(204).end() } +const downloadAttachment = async (req, res) => { + const { id } = req.params + const attachment = await attachmentService.downloadAttachment(id, res) + res.status(201).json(attachment) +} + module.exports = { getAttachments, createAttachments, updateAttachment, - deleteAttachment + deleteAttachment, + downloadAttachment } diff --git a/src/routes/attachment.js b/src/routes/attachment.js index 686df7cf..df559a4b 100644 --- a/src/routes/attachment.js +++ b/src/routes/attachment.js @@ -17,6 +17,7 @@ router.use(restrictTo(TUTOR)) router.param('id', idValidation) router.get('/', asyncWrapper(attachmentController.getAttachments)) +router.get('/:id', asyncWrapper(attachmentController.downloadAttachment)) router.post('/', upload.array('files'), asyncWrapper(attachmentController.createAttachments)) router.patch('/:id', isEntityValid({ params }), asyncWrapper(attachmentController.updateAttachment)) router.delete('/:id', isEntityValid({ params }), asyncWrapper(attachmentController.deleteAttachment)) diff --git a/src/services/attachment.js b/src/services/attachment.js index 2b9f4d33..d91f2684 100644 --- a/src/services/attachment.js +++ b/src/services/attachment.js @@ -77,6 +77,19 @@ const attachmentService = { } await Attachment.findByIdAndRemove(id).exec() + }, + + downloadAttachment: async (id, res) => { + const attachment = await Attachment.findById(id).exec() + + if (!attachment) { + throw createError(404, DOCUMENT_NOT_FOUND('Attachment')) + } + + res.setHeader('Content-Disposition', `attachment; filename="${attachment.fileName}"`) + res.setHeader('Content-Type', 'application/octet-stream') + + return await uploadService.downloadFile(attachment.link, ATTACHMENT, res) } } diff --git a/src/services/upload.js b/src/services/upload.js index 97d35cd8..bb890078 100644 --- a/src/services/upload.js +++ b/src/services/upload.js @@ -67,6 +67,20 @@ const uploadService = { resolve(res) }) ).catch((e) => e.message) + }, + + downloadFile: (fileName, containerName, res) => { + blobService = azureStorage.createBlobService(STORAGE_ACCOUNT, ACCESS_KEY, AZURE_HOST) + + return new Promise((resolve, reject) => { + blobService.getBlobToStream(containerName, fileName, res, (err) => { + if (err) { + reject(err) + } else { + resolve() + } + }) + }) } } From b931ea21ceda8ad08e662e975149a42ccd4bcd7d Mon Sep 17 00:00:00 2001 From: ut5tw Date: Mon, 11 Nov 2024 17:37:49 +0200 Subject: [PATCH 2/5] Increase coverage --- .../controllers/attachments.spec.js | 61 +++++++++++++++++++ src/test/unit/services/upload.spec.js | 50 ++++++++++++++- 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/src/test/integration/controllers/attachments.spec.js b/src/test/integration/controllers/attachments.spec.js index bcf38fe3..5a05de8a 100644 --- a/src/test/integration/controllers/attachments.spec.js +++ b/src/test/integration/controllers/attachments.spec.js @@ -5,6 +5,7 @@ const { UNAUTHORIZED, FORBIDDEN, DOCUMENT_NOT_FOUND } = require('~/consts/errors const TokenService = require('~/services/token') const Attachment = require('~/models/attachment') const uploadService = require('~/services/upload') +const attachmentService = require('~/services/attachment') const { enums: { RESOURCES_TYPES_ENUM } @@ -298,3 +299,63 @@ describe('Attachments controller', () => { }) }) }) + +describe('downloadAttachment', () => { + jest.mock('~/models/attachment') + jest.mock('~/services/upload') + const ATTACHMENT = 'attachment' + + afterEach(() => { + jest.clearAllMocks() + }) + + beforeAll(() => { + uploadService.downloadFile = jest.fn() + }) + + it('should download the attachment successfully when it exists', async () => { + const attachmentId = 'testId' + const attachment = { + _id: attachmentId, + fileName: 'testFile.txt', + link: 'https://example.com/testFile.txt' + } + + const responseMock = { + setHeader: jest.fn() + } + + jest.spyOn(Attachment, 'findById').mockReturnValue({ + exec: jest.fn().mockResolvedValue(attachment) + }) + uploadService.downloadFile.mockResolvedValue() + + await expect(attachmentService.downloadAttachment(attachmentId, responseMock)).resolves.toBeUndefined() + + expect(Attachment.findById).toHaveBeenCalledWith(attachmentId) + expect(responseMock.setHeader).toHaveBeenCalledWith( + 'Content-Disposition', + `attachment; filename="${attachment.fileName}"` + ) + expect(responseMock.setHeader).toHaveBeenCalledWith('Content-Type', 'application/octet-stream') + expect(uploadService.downloadFile).toHaveBeenCalledWith(attachment.link, ATTACHMENT, responseMock) + }) + + it('should throw a 404 error if the attachment is not found', async () => { + const attachmentId = 'nonExistentId' + const responseMock = { + setHeader: jest.fn() + } + + jest.spyOn(Attachment, 'findById').mockReturnValue({ + exec: jest.fn().mockResolvedValue(null) + }) + + await expect(attachmentService.downloadAttachment(attachmentId, responseMock)).rejects.toThrow( + 'Attachment with the specified IDs were not found.' + ) + expect(Attachment.findById).toHaveBeenCalledWith(attachmentId) + expect(responseMock.setHeader).not.toHaveBeenCalled() + expect(uploadService.downloadFile).not.toHaveBeenCalled() + }) +}) diff --git a/src/test/unit/services/upload.spec.js b/src/test/unit/services/upload.spec.js index b28528f3..c70d38bd 100644 --- a/src/test/unit/services/upload.spec.js +++ b/src/test/unit/services/upload.spec.js @@ -28,7 +28,7 @@ const getBlobPropertiesStatusWithError = (container, blobName, cb) => { } const file = { - buffer: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAgAAAQABAAD...', + buffer: 'data:image/jpegbase64,/9j/4AAQSkZJRgABAgAAAQABAAD...', name: 'example.jpg', newName: 'exampleName.jpg' } @@ -122,3 +122,51 @@ describe('uploadService', () => { } }) }) + +describe('downloadFile', () => { + jest.mock('azure-storage', () => ({ + createBlobService: jest.fn() + })) + + let blobServiceMock + + beforeEach(() => { + blobServiceMock = { + getBlobToStream: jest.fn() + } + azureStorage.createBlobService.mockReturnValue(blobServiceMock) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should resolve if the blob is downloaded successfully', async () => { + const fileName = 'testFile.txt' + const containerName = 'test-container' + const responseMock = jest.fn() + + blobServiceMock.getBlobToStream.mockImplementation((container, file, res, callback) => { + callback(null) + }) + + await expect(uploadService.downloadFile(fileName, containerName, responseMock)).resolves.toBeUndefined() + expect(azureStorage.createBlobService).toHaveBeenCalled() + expect(blobServiceMock.getBlobToStream).toHaveBeenCalled() + }) + + it('should reject if there is an error downloading the blob', async () => { + const fileName = 'testFile.txt' + const containerName = 'test-container' + const responseMock = jest.fn() + const error = new Error('Download error') + + blobServiceMock.getBlobToStream.mockImplementation((container, file, res, callback) => { + callback(error) + }) + + await expect(uploadService.downloadFile(fileName, containerName, responseMock)).rejects.toThrow('Download error') + expect(azureStorage.createBlobService).toHaveBeenCalled() + expect(blobServiceMock.getBlobToStream).toHaveBeenCalled() + }) +}) From aee8b89558689449d702e2a31a897db1a1e71eae Mon Sep 17 00:00:00 2001 From: ut5tw Date: Mon, 11 Nov 2024 17:47:01 +0200 Subject: [PATCH 3/5] Fix sonar issue --- src/services/upload.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/upload.js b/src/services/upload.js index bb890078..ac0d9939 100644 --- a/src/services/upload.js +++ b/src/services/upload.js @@ -75,7 +75,7 @@ const uploadService = { return new Promise((resolve, reject) => { blobService.getBlobToStream(containerName, fileName, res, (err) => { if (err) { - reject(err) + reject(new Error('Download error')) } else { resolve() } From d584faab717d74f74a1a14e1c06a74e0720d20bc Mon Sep 17 00:00:00 2001 From: ut5tw Date: Wed, 13 Nov 2024 17:03:25 +0200 Subject: [PATCH 4/5] Allow students to download attachments --- src/routes/attachment.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/attachment.js b/src/routes/attachment.js index df559a4b..c8277285 100644 --- a/src/routes/attachment.js +++ b/src/routes/attachment.js @@ -13,11 +13,11 @@ const { const params = [{ model: Attachment, idName: 'id' }] router.use(authMiddleware) +router.get('/:id', asyncWrapper(attachmentController.downloadAttachment)) router.use(restrictTo(TUTOR)) router.param('id', idValidation) router.get('/', asyncWrapper(attachmentController.getAttachments)) -router.get('/:id', asyncWrapper(attachmentController.downloadAttachment)) router.post('/', upload.array('files'), asyncWrapper(attachmentController.createAttachments)) router.patch('/:id', isEntityValid({ params }), asyncWrapper(attachmentController.updateAttachment)) router.delete('/:id', isEntityValid({ params }), asyncWrapper(attachmentController.deleteAttachment)) From e189221d65e05060aebc30b22852904327c7ca34 Mon Sep 17 00:00:00 2001 From: ut5tw Date: Wed, 13 Nov 2024 17:20:04 +0200 Subject: [PATCH 5/5] Add swagger docs --- docs/attachments/attachment.yaml | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/attachments/attachment.yaml b/docs/attachments/attachment.yaml index 81709d7f..7dbc4bc7 100644 --- a/docs/attachments/attachment.yaml +++ b/docs/attachments/attachment.yaml @@ -35,7 +35,7 @@ paths: schema: type: boolean required: false - description: If true will include in response attachments that have isDuplicate set to true. + description: If true will include in response attachments that have isDuplicate set to true. responses: 200: description: OK @@ -134,6 +134,33 @@ paths: code: UNAUTHORIZED message: The requested URL requires user authorization. /attachments/{id}: + get: + security: + - cookieAuth: [] + tags: + - Attachments + summary: Get attachment blob by ID + description: Creates a blob for attachment with the specified ID. + produces: + - application/octet-stream + parameters: + - name: id + in: path + required: true + description: ID of the attachment + type: string + responses: + '200': + description: Successfully downloaded the blob file. + content: + application/octet-stream: + schema: + type: string + format: binary + '404': + description: Blob not found for the given ID. + '500': + description: Error occurred during blob download. patch: security: - cookieAuth: []