diff --git a/backend/jest.config.ts b/backend/jest.config.ts index f02b8c374..534fb90db 100644 --- a/backend/jest.config.ts +++ b/backend/jest.config.ts @@ -3,6 +3,8 @@ * https://jestjs.io/docs/configuration */ +import { MOCK_FIREBASE_STORAGE_DEFAULT_BUCKET } from "./testUtils/imageUpload"; + export default { // All imported modules in your tests should be mocked automatically // automock: false, @@ -204,3 +206,7 @@ export default { // Increases timeout to let tests run testTimeout: 1000000, }; + +process.env = Object.assign(process.env, { + FIREBASE_STORAGE_DEFAULT_BUCKET: MOCK_FIREBASE_STORAGE_DEFAULT_BUCKET, +}); diff --git a/backend/models/imageCount.model.ts b/backend/models/imageCount.model.ts new file mode 100644 index 000000000..8f6776201 --- /dev/null +++ b/backend/models/imageCount.model.ts @@ -0,0 +1,27 @@ +import type { Document } from "mongoose"; +import mongoose, { Schema } from "mongoose"; + +/** + * This document contains information about a single image. + */ +export interface ImageCount extends Document { + /** the unique identifier for the image */ + id: string; + /** the unique file path of the image */ + filePath: string; + /** the number of tests that reference this image */ + referenceCount: number; +} + +const ImageCountSchema: Schema = new Schema({ + filePath: { + type: String, + required: true, + }, + referenceCount: { + type: Number, + required: true, + }, +}); + +export default mongoose.model("ImageCount", ImageCountSchema); diff --git a/backend/services/implementations/__tests__/imageCountService.test.ts b/backend/services/implementations/__tests__/imageCountService.test.ts new file mode 100644 index 000000000..377adce58 --- /dev/null +++ b/backend/services/implementations/__tests__/imageCountService.test.ts @@ -0,0 +1,43 @@ +import db from "../../../testUtils/testDb"; +import ImageCountService from "../imageCountService"; + +describe("mongo imageCountService", (): void => { + let imageCountService: ImageCountService; + + beforeAll(async () => { + await db.connect(); + }); + + afterAll(async () => { + await db.disconnect(); + }); + + beforeEach(async () => { + imageCountService = new ImageCountService(); + }); + + afterEach(async () => { + await db.clear(); + }); + + it("initialize count", async () => { + const referenceCount = await imageCountService.initializeCount("test path"); + expect(referenceCount).toEqual(1); + }); + + it("increment count", async () => { + await imageCountService.initializeCount("test path"); + const referenceCount = await imageCountService.incrementCount("test path"); + expect(referenceCount).toEqual(2); + }); + + it("decrement count", async () => { + await imageCountService.initializeCount("test path"); + const referenceCount = await imageCountService.decrementCount("test path"); + expect(referenceCount).toEqual(0); + + await expect(async () => { + await imageCountService.decrementCount("test path"); + }).rejects.toThrowError(`Image test path not found`); + }); +}); diff --git a/backend/services/implementations/__tests__/imageUploadService.test.ts b/backend/services/implementations/__tests__/imageUploadService.test.ts index 629c16fe2..21781d674 100644 --- a/backend/services/implementations/__tests__/imageUploadService.test.ts +++ b/backend/services/implementations/__tests__/imageUploadService.test.ts @@ -9,6 +9,9 @@ import { } from "../../../testUtils/imageUpload"; import type IImageUploadService from "../../interfaces/imageUploadService"; import ImageUploadService from "../imageUploadService"; +import ImageCountService from "../imageCountService"; +import type { FileUpload } from "../../../lib/graphql-upload"; +import MgImageCount from "../../../models/imageCount.model"; jest.mock("firebase-admin", () => { const storage = jest.fn().mockReturnValue({ @@ -24,7 +27,7 @@ jest.mock("firebase-admin", () => { getSignedUrl: jest .fn() .mockReturnValue([ - "https://storage.googleapis.com/jump-math-98edf.appspot.com/test-bucket/test.png", + "https://storage.googleapis.com/test-url/test-bucket/test.png", ]), delete: jest.fn().mockReturnValue({}), }), @@ -36,6 +39,7 @@ jest.mock("firebase-admin", () => { describe("mongo imageUploadService", (): void => { let imageUploadService: IImageUploadService; + let imageCountService: ImageCountService; beforeAll(async () => { await db.connect(); @@ -46,7 +50,8 @@ describe("mongo imageUploadService", (): void => { }); beforeEach(async () => { - imageUploadService = new ImageUploadService(uploadDir); + imageCountService = new ImageCountService(); + imageUploadService = new ImageUploadService(uploadDir, imageCountService); }); afterEach(async () => { @@ -54,6 +59,8 @@ describe("mongo imageUploadService", (): void => { }); it("deleteImage - invalid filePath", async () => { + await imageCountService.initializeCount(imageMetadata.filePath); + await expect(async () => { await imageUploadService.deleteImage(imageMetadata); }).rejects.toThrowError( @@ -65,8 +72,38 @@ describe("mongo imageUploadService", (): void => { const uploadedImage = await imageUploadService.uploadImage(imageUpload); assertResponseMatchesExpected(uploadedImage); - const res = await imageUploadService.deleteImage(uploadedImage); + // check that the reference count is 1 + let referenceCount = await MgImageCount.findOne({ + filePath: uploadedImage.filePath, + }); + expect(referenceCount?.referenceCount).toEqual(1); + + // upload same image and check that the reference count is 2 + const id = uploadedImage.filePath.split("_")[1]; + await imageUploadService.uploadImage({ + file: undefined as unknown as Promise, + previewUrl: `${uploadedImage.url}_${id}?GoogleAccessId=fi&Expires=1698622126&Signature=gV`, + }); + referenceCount = await MgImageCount.findOne({ + filePath: uploadedImage.filePath, + }); + expect(referenceCount?.referenceCount).toEqual(2); + + // delete image and check that the reference count is 1 + let res = await imageUploadService.deleteImage(uploadedImage); + assertResponseMatchesExpected(res); + referenceCount = await MgImageCount.findOne({ + filePath: uploadedImage.filePath, + }); + expect(referenceCount?.referenceCount).toEqual(1); + + // delete same image and check that the image is deleted + res = await imageUploadService.deleteImage(uploadedImage); assertResponseMatchesExpected(res); + referenceCount = await MgImageCount.findOne({ + filePath: uploadedImage.filePath, + }); + expect(referenceCount).toBeNull(); }); it("uploadImage - invalid image type", async () => { diff --git a/backend/services/implementations/__tests__/testService.test.ts b/backend/services/implementations/__tests__/testService.test.ts index ffdb9f6c0..2d9e6701a 100644 --- a/backend/services/implementations/__tests__/testService.test.ts +++ b/backend/services/implementations/__tests__/testService.test.ts @@ -38,6 +38,9 @@ describe("mongo testService", (): void => { testService.imageUploadService.uploadImage = jest .fn() .mockReturnValue(imageMetadata); + testService.imageUploadService.incrementImageCount = jest + .fn() + .mockReturnValue(imageMetadata); testService.imageUploadService.getImage = jest .fn() .mockReturnValue(imageMetadata); @@ -117,6 +120,13 @@ describe("mongo testService", (): void => { expect(test.id).not.toEqual(duplicateTest.id); expect(`${test.name} [COPY]`).toEqual(duplicateTest.name); + expect( + testService.imageUploadService.incrementImageCount, + ).toHaveBeenCalledWith(imageMetadata); + expect( + testService.imageUploadService.incrementImageCount, + ).toHaveBeenCalledTimes(1); + const originalTest = await testService.getTestById(test.id); assertResponseMatchesExpected(mockPublishedTest, originalTest); expect(test.id).toEqual(originalTest.id); @@ -131,6 +141,13 @@ describe("mongo testService", (): void => { expect(test.id).not.toEqual(unarchivedTest.id); expect(`${test.name} [COPY]`).toEqual(unarchivedTest.name); + expect( + testService.imageUploadService.incrementImageCount, + ).toHaveBeenCalledWith(imageMetadata); + expect( + testService.imageUploadService.incrementImageCount, + ).toHaveBeenCalledTimes(1); + const originalTest = await MgTest.findById(test.id); expect(originalTest?.status).toBe(AssessmentStatus.DELETED); }); diff --git a/backend/services/implementations/imageCountService.ts b/backend/services/implementations/imageCountService.ts new file mode 100644 index 000000000..bbe6725a4 --- /dev/null +++ b/backend/services/implementations/imageCountService.ts @@ -0,0 +1,70 @@ +import MgImage from "../../models/imageCount.model"; +import { getErrorMessage } from "../../utilities/errorUtils"; +import logger from "../../utilities/logger"; +import type IImageCountService from "../interfaces/imageCountService"; + +const Logger = logger(__filename); + +class ImageCountService implements IImageCountService { + /* eslint-disable class-methods-use-this */ + async initializeCount(filePath: string): Promise { + try { + const image = new MgImage({ + filePath, + referenceCount: 1, + }); + await image.save(); + return image.referenceCount; + } catch (error: unknown) { + Logger.error( + `Failed to increment count. Reason = ${getErrorMessage(error)}`, + ); + throw error; + } + } + + async incrementCount(filePath: string): Promise { + try { + const image = await MgImage.findOneAndUpdate( + { filePath }, + { $inc: { referenceCount: 1 } }, + { new: true }, + ); + if (!image) { + throw new Error(`Image ${filePath} not found`); + } + return image.referenceCount; + } catch (error: unknown) { + Logger.error( + `Failed to increment count. Reason = ${getErrorMessage(error)}`, + ); + throw error; + } + } + + async decrementCount(filePath: string): Promise { + try { + const image = await MgImage.findOneAndUpdate( + { filePath, referenceCount: { $gt: 0 } }, + { $inc: { referenceCount: -1 } }, + { new: true }, + ); + if (!image) { + throw new Error(`Image ${filePath} not found`); + } + + if (image.referenceCount === 0) { + await MgImage.deleteOne({ filePath }); + } + + return image.referenceCount; + } catch (error: unknown) { + Logger.error( + `Failed to decrement count. Reason = ${getErrorMessage(error)}`, + ); + throw error; + } + } +} + +export default ImageCountService; diff --git a/backend/services/implementations/imageUploadService.ts b/backend/services/implementations/imageUploadService.ts index 04a8765b2..c07277fdb 100644 --- a/backend/services/implementations/imageUploadService.ts +++ b/backend/services/implementations/imageUploadService.ts @@ -15,6 +15,7 @@ import type { ImageMetadata, ImageMetadataRequest, } from "../../types/questionMetadataTypes"; +import type IImageService from "../interfaces/imageCountService"; const Logger = logger(__filename); @@ -32,16 +33,19 @@ const writeFile = (readStream: ReadStream, filePath: string): Promise => { class ImageUploadService implements IImageUploadService { uploadDir: string; + imageService: IImageService; + storageService: IFileStorageService; googleStorageUploadUrl: string; - constructor(uploadDir: string) { + constructor(uploadDir: string, imageService: IImageService) { this.uploadDir = escapeRegExp(uploadDir); const defaultBucket = process.env.FIREBASE_STORAGE_DEFAULT_BUCKET || ""; const storageService = new FileStorageService(defaultBucket); this.storageService = storageService; + this.imageService = imageService; this.googleStorageUploadUrl = escapeRegExp( `https://storage.googleapis.com/${defaultBucket}`, @@ -53,11 +57,13 @@ class ImageUploadService implements IImageUploadService { let filePath = this.getFilePath(image); try { - if (filePath) + if (filePath) { + await this.incrementImageCount({ filePath, url: "" }); return await this.hydrateImage({ filePath, url: image.previewUrl, }); + } const { createReadStream, mimetype, filename } = await image.file; if (!fs.existsSync(this.uploadDir)) { @@ -78,6 +84,20 @@ class ImageUploadService implements IImageUploadService { } } + async incrementImageCount(image: ImageMetadata): Promise { + try { + await this.imageService.incrementCount(image.filePath); + return image; + } catch (error: unknown) { + Logger.error( + `Failed to increment image count for filePath: ${ + image.filePath + }. Reason = ${getErrorMessage(error)}`, + ); + throw error; + } + } + async hydrateImage(image: ImageMetadata): Promise { if (this.getExpirationDate(image) > Date.now()) { return image; @@ -112,7 +132,13 @@ class ImageUploadService implements IImageUploadService { async deleteImage(image: ImageMetadata): Promise { try { - await this.storageService.deleteFile(image.filePath); + const decrementCount = await this.imageService.decrementCount( + image.filePath, + ); + if (decrementCount === 0) { + await this.storageService.deleteFile(image.filePath); + } + return image; } catch (error: unknown) { Logger.error( @@ -130,6 +156,7 @@ class ImageUploadService implements IImageUploadService { ): Promise { try { await this.storageService.createFile(filePath, filePath, fileContentType); + await this.imageService.initializeCount(filePath); return await this.getImage(filePath); } catch (error: unknown) { Logger.error( diff --git a/backend/services/implementations/testService.ts b/backend/services/implementations/testService.ts index 12ac1e222..990c1c722 100644 --- a/backend/services/implementations/testService.ts +++ b/backend/services/implementations/testService.ts @@ -21,6 +21,7 @@ import type { ImageMetadataRequest, ImageMetadataTypes, } from "../../types/questionMetadataTypes"; +import ImageService from "./imageCountService"; const Logger = logger(__filename); @@ -28,7 +29,11 @@ class TestService implements ITestService { imageUploadService: IImageUploadService; constructor() { - this.imageUploadService = new ImageUploadService("assessment-images"); + const imageService = new ImageService(); + this.imageUploadService = new ImageUploadService( + "assessment-images", + imageService, + ); } /* eslint-disable class-methods-use-this */ @@ -266,12 +271,15 @@ class TestService implements ITestService { if (!test) { throw new Error(`Test ID ${id} not found`); } + + await this.incrementImageCounts(test.questions); + // eslint-disable-next-line no-underscore-dangle test._id = new mongoose.Types.ObjectId(); test.name += " [COPY]"; test.isNew = true; test.status = AssessmentStatus.DRAFT; - test.save(); + await test.save(); } catch (error: unknown) { Logger.error( `Failed to duplicate test with ID ${id}. Reason = ${getErrorMessage( @@ -400,6 +408,15 @@ class TestService implements ITestService { ); } + private async incrementImageCounts( + questions: QuestionComponent[][], + ): Promise { + await this.processImages( + questions, + this.imageUploadService.incrementImageCount.bind(this.imageUploadService), + ); + } + private async deleteImages( questions: QuestionComponent[][], ): Promise { diff --git a/backend/services/interfaces/imageCountService.ts b/backend/services/interfaces/imageCountService.ts new file mode 100644 index 000000000..228ed7b06 --- /dev/null +++ b/backend/services/interfaces/imageCountService.ts @@ -0,0 +1,24 @@ +interface IImageCountService { + /** + * Initializes the reference count of an image + * @param filePath file path of the image + * @returns a reference count + */ + initializeCount(filePath: string): Promise; + + /** + * Increments the reference count of an image + * @param filePath file path of the image + * @returns a reference count + */ + incrementCount(filePath: string): Promise; + + /** + * Decrements the reference count of an image + * @param filePath file path of the image + * @returns a reference count + */ + decrementCount(filePath: string): Promise; +} + +export default IImageCountService; diff --git a/backend/services/interfaces/imageUploadService.ts b/backend/services/interfaces/imageUploadService.ts index 71f6fba43..de1d84ba3 100644 --- a/backend/services/interfaces/imageUploadService.ts +++ b/backend/services/interfaces/imageUploadService.ts @@ -11,6 +11,13 @@ interface IImageUploadService { */ uploadImage(image: ImageMetadataRequest): Promise; + /** + * Increment the reference count of an image + * @param image the image to increment + * @returns a url and file path for the requested image + */ + incrementImageCount(image: ImageMetadata): Promise; + /** * Get an image stored in Firebase * @param file the file path to get diff --git a/backend/testUtils/imageUpload.ts b/backend/testUtils/imageUpload.ts index a385de0b4..5368a7846 100644 --- a/backend/testUtils/imageUpload.ts +++ b/backend/testUtils/imageUpload.ts @@ -7,9 +7,10 @@ import type { ImageMetadataRequest, } from "../types/questionMetadataTypes"; +export const MOCK_FIREBASE_STORAGE_DEFAULT_BUCKET = "test-url"; export const filename = "test.png"; export const uploadDir = "test-bucket"; -export const signedUrl = `https://storage.googleapis.com/jump-math-98edf.appspot.com/${uploadDir}/${filename}`; +export const signedUrl = `https://storage.googleapis.com/${MOCK_FIREBASE_STORAGE_DEFAULT_BUCKET}/${uploadDir}/${filename}`; export const invalidImageType = "text/plain"; const createReadStream = (): ReadStream =>