Add S3Service with tests and module setup for MinIO integration
This commit is contained in:
10
backend/src/s3/s3.module.ts
Normal file
10
backend/src/s3/s3.module.ts
Normal file
@@ -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 {}
|
||||||
218
backend/src/s3/s3.service.spec.ts
Normal file
218
backend/src/s3/s3.service.spec.ts
Normal file
@@ -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>(S3Service);
|
||||||
|
_configService = module.get<ConfigService>(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
137
backend/src/s3/s3.service.ts
Normal file
137
backend/src/s3/s3.service.ts
Normal file
@@ -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<string>("S3_ENDPOINT", "localhost"),
|
||||||
|
port: Number.parseInt(this.configService.get<string>("S3_PORT", "9000"), 10),
|
||||||
|
useSSL: this.configService.get<string>("S3_USE_SSL") === "true",
|
||||||
|
accessKey: this.configService.get<string>("S3_ACCESS_KEY", "minioadmin"),
|
||||||
|
secretKey: this.configService.get<string>("S3_SECRET_KEY", "minioadmin"),
|
||||||
|
});
|
||||||
|
this.bucketName = this.configService.get<string>(
|
||||||
|
"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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user