From 06d2a6556731e28ce15ec632069a9aae413ab496 Mon Sep 17 00:00:00 2001 From: Avnyr Date: Wed, 7 Jan 2026 21:09:33 +0100 Subject: [PATCH] Add `S3Service` with tests and module setup for MinIO integration --- backend/src/s3/s3.module.ts | 10 ++ backend/src/s3/s3.service.spec.ts | 218 ++++++++++++++++++++++++++++++ backend/src/s3/s3.service.ts | 137 +++++++++++++++++++ 3 files changed, 365 insertions(+) create mode 100644 backend/src/s3/s3.module.ts create mode 100644 backend/src/s3/s3.service.spec.ts create mode 100644 backend/src/s3/s3.service.ts diff --git a/backend/src/s3/s3.module.ts b/backend/src/s3/s3.module.ts new file mode 100644 index 0000000..d7ad7cd --- /dev/null +++ b/backend/src/s3/s3.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { S3Service } from "./s3.service"; + +@Module({ + imports: [ConfigModule], + providers: [S3Service], + exports: [S3Service], +}) +export class S3Module {} diff --git a/backend/src/s3/s3.service.spec.ts b/backend/src/s3/s3.service.spec.ts new file mode 100644 index 0000000..7fa12d0 --- /dev/null +++ b/backend/src/s3/s3.service.spec.ts @@ -0,0 +1,218 @@ +import { ConfigService } from "@nestjs/config"; +import { Test, TestingModule } from "@nestjs/testing"; +import * as Minio from "minio"; +import { S3Service } from "./s3.service"; + +jest.mock("minio"); + +describe("S3Service", () => { + let service: S3Service; + let _configService: ConfigService; + let minioClient: any; + + beforeEach(async () => { + minioClient = { + bucketExists: jest.fn().mockResolvedValue(true), + makeBucket: jest.fn().mockResolvedValue(undefined), + putObject: jest.fn().mockResolvedValue(undefined), + getObject: jest.fn().mockResolvedValue({}), + presignedUrl: jest.fn().mockResolvedValue("http://localhost/url"), + removeObject: jest.fn().mockResolvedValue(undefined), + statObject: jest.fn().mockResolvedValue({ size: 100, etag: "123" }), + copyObject: jest.fn().mockResolvedValue(undefined), + }; + + (Minio.Client as jest.Mock).mockImplementation(() => minioClient); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + S3Service, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string, defaultValue?: string) => { + if (key === "S3_PORT") return "9000"; + if (key === "S3_USE_SSL") return "false"; + return defaultValue || key; + }), + }, + }, + ], + }).compile(); + + service = module.get(S3Service); + _configService = module.get(ConfigService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("onModuleInit", () => { + it("should create bucket if it does not exist", async () => { + minioClient.bucketExists.mockResolvedValue(false); + await service.onModuleInit(); + expect(minioClient.makeBucket).toHaveBeenCalledWith("memegoat"); + }); + + it("should not create bucket if it exists", async () => { + minioClient.bucketExists.mockResolvedValue(true); + await service.onModuleInit(); + expect(minioClient.makeBucket).not.toHaveBeenCalled(); + }); + }); + + describe("uploadFile", () => { + it("should upload a file to default bucket", async () => { + const fileName = "test.txt"; + const file = Buffer.from("test content"); + const mimeType = "text/plain"; + + const result = await service.uploadFile(fileName, file, mimeType); + + expect(minioClient.putObject).toHaveBeenCalledWith( + "memegoat", + fileName, + file, + file.length, + { "Content-Type": mimeType }, + ); + expect(result).toBe(fileName); + }); + + it("should upload a file to specific bucket", async () => { + const fileName = "test.txt"; + const file = Buffer.from("test content"); + const mimeType = "text/plain"; + const bucketName = "other-bucket"; + + await service.uploadFile(fileName, file, mimeType, {}, bucketName); + + expect(minioClient.putObject).toHaveBeenCalledWith( + bucketName, + fileName, + file, + file.length, + { "Content-Type": mimeType }, + ); + }); + }); + + describe("getFile", () => { + it("should get a file from default bucket", async () => { + const fileName = "test.txt"; + await service.getFile(fileName); + expect(minioClient.getObject).toHaveBeenCalledWith("memegoat", fileName); + }); + + it("should get a file from specific bucket", async () => { + const fileName = "test.txt"; + const bucketName = "other-bucket"; + await service.getFile(fileName, bucketName); + expect(minioClient.getObject).toHaveBeenCalledWith(bucketName, fileName); + }); + }); + + describe("getFileUrl", () => { + it("should get a presigned URL from default bucket", async () => { + const fileName = "test.txt"; + const url = await service.getFileUrl(fileName); + expect(minioClient.presignedUrl).toHaveBeenCalledWith( + "GET", + "memegoat", + fileName, + 3600, + ); + expect(url).toBe("http://localhost/url"); + }); + + it("should get a presigned URL from specific bucket", async () => { + const fileName = "test.txt"; + const bucketName = "other-bucket"; + await service.getFileUrl(fileName, 3600, bucketName); + expect(minioClient.presignedUrl).toHaveBeenCalledWith( + "GET", + bucketName, + fileName, + 3600, + ); + }); + }); + + describe("deleteFile", () => { + it("should delete a file from default bucket", async () => { + const fileName = "test.txt"; + await service.deleteFile(fileName); + expect(minioClient.removeObject).toHaveBeenCalledWith("memegoat", fileName); + }); + + it("should delete a file from specific bucket", async () => { + const fileName = "test.txt"; + const bucketName = "other-bucket"; + await service.deleteFile(fileName, bucketName); + expect(minioClient.removeObject).toHaveBeenCalledWith(bucketName, fileName); + }); + }); + + describe("ensureBucketExists", () => { + it("should create bucket if it does not exist", async () => { + minioClient.bucketExists.mockResolvedValue(false); + await service.ensureBucketExists("new-bucket"); + expect(minioClient.makeBucket).toHaveBeenCalledWith("new-bucket"); + }); + + it("should not create bucket if it exists", async () => { + minioClient.bucketExists.mockResolvedValue(true); + await service.ensureBucketExists("existing-bucket"); + expect(minioClient.makeBucket).not.toHaveBeenCalled(); + }); + }); + + describe("getFileInfo", () => { + it("should return file info from default bucket", async () => { + const fileName = "test.txt"; + const info = await service.getFileInfo(fileName); + expect(minioClient.statObject).toHaveBeenCalledWith("memegoat", fileName); + expect(info).toEqual({ size: 100, etag: "123" }); + }); + + it("should return file info from specific bucket", async () => { + const fileName = "test.txt"; + const bucketName = "other-bucket"; + await service.getFileInfo(fileName, bucketName); + expect(minioClient.statObject).toHaveBeenCalledWith(bucketName, fileName); + }); + }); + + describe("moveFile", () => { + it("should move file within default bucket", async () => { + const source = "source.txt"; + const dest = "dest.txt"; + await service.moveFile(source, dest); + + expect(minioClient.copyObject).toHaveBeenCalledWith( + "memegoat", + dest, + "/memegoat/source.txt", + expect.any(Minio.CopyConditions), + ); + expect(minioClient.removeObject).toHaveBeenCalledWith("memegoat", source); + }); + + it("should move file between different buckets", async () => { + const source = "source.txt"; + const dest = "dest.txt"; + const sBucket = "source-bucket"; + const dBucket = "dest-bucket"; + await service.moveFile(source, dest, sBucket, dBucket); + + expect(minioClient.copyObject).toHaveBeenCalledWith( + dBucket, + dest, + `/${sBucket}/${source}`, + expect.any(Minio.CopyConditions), + ); + expect(minioClient.removeObject).toHaveBeenCalledWith(sBucket, source); + }); + }); +}); diff --git a/backend/src/s3/s3.service.ts b/backend/src/s3/s3.service.ts new file mode 100644 index 0000000..adb8495 --- /dev/null +++ b/backend/src/s3/s3.service.ts @@ -0,0 +1,137 @@ +import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import * as Minio from "minio"; + +@Injectable() +export class S3Service implements OnModuleInit { + private readonly logger = new Logger(S3Service.name); + private minioClient: Minio.Client; + private readonly bucketName: string; + + constructor(private readonly configService: ConfigService) { + this.minioClient = new Minio.Client({ + endPoint: this.configService.get("S3_ENDPOINT", "localhost"), + port: Number.parseInt(this.configService.get("S3_PORT", "9000"), 10), + useSSL: this.configService.get("S3_USE_SSL") === "true", + accessKey: this.configService.get("S3_ACCESS_KEY", "minioadmin"), + secretKey: this.configService.get("S3_SECRET_KEY", "minioadmin"), + }); + this.bucketName = this.configService.get( + "S3_BUCKET_NAME", + "memegoat", + ); + } + + async onModuleInit() { + await this.ensureBucketExists(this.bucketName); + } + + async ensureBucketExists(bucketName: string) { + try { + const exists = await this.minioClient.bucketExists(bucketName); + if (!exists) { + await this.minioClient.makeBucket(bucketName); + this.logger.log(`Bucket "${bucketName}" created successfully.`); + } + } catch (error) { + this.logger.error( + `Error checking/creating bucket "${bucketName}": ${error.message}`, + ); + throw error; + } + } + + async uploadFile( + fileName: string, + file: Buffer, + mimeType: string, + metaData: Minio.ItemBucketMetadata = {}, + bucketName: string = this.bucketName, + ) { + try { + await this.minioClient.putObject(bucketName, fileName, file, file.length, { + ...metaData, + "Content-Type": mimeType, + }); + return fileName; + } catch (error) { + this.logger.error(`Error uploading file to ${bucketName}: ${error.message}`); + throw error; + } + } + + async getFile(fileName: string, bucketName: string = this.bucketName) { + try { + return await this.minioClient.getObject(bucketName, fileName); + } catch (error) { + this.logger.error(`Error getting file from ${bucketName}: ${error.message}`); + throw error; + } + } + + async getFileUrl( + fileName: string, + expiry = 3600, + bucketName: string = this.bucketName, + ) { + try { + return await this.minioClient.presignedUrl( + "GET", + bucketName, + fileName, + expiry, + ); + } catch (error) { + this.logger.error( + `Error getting file URL from ${bucketName}: ${error.message}`, + ); + throw error; + } + } + + async deleteFile(fileName: string, bucketName: string = this.bucketName) { + try { + await this.minioClient.removeObject(bucketName, fileName); + } catch (error) { + this.logger.error( + `Error deleting file from ${bucketName}: ${error.message}`, + ); + throw error; + } + } + + async getFileInfo(fileName: string, bucketName: string = this.bucketName) { + try { + return await this.minioClient.statObject(bucketName, fileName); + } catch (error) { + this.logger.error( + `Error getting file info from ${bucketName}: ${error.message}`, + ); + throw error; + } + } + + async moveFile( + sourceFileName: string, + destinationFileName: string, + sourceBucketName: string = this.bucketName, + destinationBucketName: string = this.bucketName, + ) { + try { + const conds = new Minio.CopyConditions(); + await this.minioClient.copyObject( + destinationBucketName, + destinationFileName, + `/${sourceBucketName}/${sourceFileName}`, + conds, + ); + await this.minioClient.removeObject(sourceBucketName, sourceFileName); + return destinationFileName; + } catch (error) { + this.logger.error( + `Error moving file from ${sourceBucketName}/${sourceFileName} to ${destinationBucketName}/${destinationFileName}: ${error.message}`, + ); + throw error; + } + } +}