feat(17-01): add S3 storage service with upload, delete, and presigned URL support
- Create storage.service.ts wrapping @aws-sdk/client-s3 with forcePathStyle for MinIO - Export uploadImage, deleteImage, getImageUrl, withImageUrl, withImageUrls - Add unit tests with mocked S3Client (8 tests passing) - Install @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner
This commit is contained in:
161
tests/services/storage.service.test.ts
Normal file
161
tests/services/storage.service.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
|
||||
// Mock the S3 client send method
|
||||
const mockSend = mock(() => Promise.resolve({}));
|
||||
const mockGetSignedUrl = mock(() =>
|
||||
Promise.resolve("https://minio:9000/gearbox-images/test.jpg?signed=1"),
|
||||
);
|
||||
|
||||
// Mock modules before importing the service
|
||||
mock.module("@aws-sdk/client-s3", () => ({
|
||||
S3Client: class MockS3Client {
|
||||
send = mockSend;
|
||||
},
|
||||
PutObjectCommand: class MockPutObjectCommand {
|
||||
input: Record<string, unknown>;
|
||||
constructor(input: Record<string, unknown>) {
|
||||
this.input = input;
|
||||
}
|
||||
},
|
||||
DeleteObjectCommand: class MockDeleteObjectCommand {
|
||||
input: Record<string, unknown>;
|
||||
constructor(input: Record<string, unknown>) {
|
||||
this.input = input;
|
||||
}
|
||||
},
|
||||
GetObjectCommand: class MockGetObjectCommand {
|
||||
input: Record<string, unknown>;
|
||||
constructor(input: Record<string, unknown>) {
|
||||
this.input = input;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module("@aws-sdk/s3-request-presigner", () => ({
|
||||
getSignedUrl: mockGetSignedUrl,
|
||||
}));
|
||||
|
||||
// Set env vars before importing the service
|
||||
process.env.S3_ENDPOINT = "http://localhost:9000";
|
||||
process.env.S3_ACCESS_KEY = "minioadmin";
|
||||
process.env.S3_SECRET_KEY = "minioadmin";
|
||||
process.env.S3_BUCKET = "gearbox-images";
|
||||
process.env.S3_REGION = "us-east-1";
|
||||
|
||||
// Import after mocking
|
||||
const {
|
||||
uploadImage,
|
||||
deleteImage,
|
||||
getImageUrl,
|
||||
withImageUrl,
|
||||
withImageUrls,
|
||||
} = await import("@/server/services/storage.service");
|
||||
|
||||
describe("storage.service", () => {
|
||||
beforeEach(() => {
|
||||
mockSend.mockClear();
|
||||
mockGetSignedUrl.mockClear();
|
||||
mockGetSignedUrl.mockResolvedValue(
|
||||
"https://minio:9000/gearbox-images/test.jpg?signed=1",
|
||||
);
|
||||
});
|
||||
|
||||
describe("uploadImage", () => {
|
||||
test("calls PutObjectCommand with correct params", async () => {
|
||||
const buffer = Buffer.from("fake-image-data");
|
||||
await uploadImage(buffer, "test-image.jpg", "image/jpeg");
|
||||
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
const command = mockSend.mock.calls[0][0] as { input: Record<string, unknown> };
|
||||
expect(command.input.Bucket).toBe("gearbox-images");
|
||||
expect(command.input.Key).toBe("test-image.jpg");
|
||||
expect(command.input.ContentType).toBe("image/jpeg");
|
||||
expect(Buffer.isBuffer(command.input.Body)).toBe(true);
|
||||
});
|
||||
|
||||
test("handles ArrayBuffer input", async () => {
|
||||
const arrayBuffer = new ArrayBuffer(10);
|
||||
await uploadImage(arrayBuffer, "test.png", "image/png");
|
||||
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
const command = mockSend.mock.calls[0][0] as { input: Record<string, unknown> };
|
||||
expect(Buffer.isBuffer(command.input.Body)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteImage", () => {
|
||||
test("calls DeleteObjectCommand with correct params", async () => {
|
||||
await deleteImage("test-image.jpg");
|
||||
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
const command = mockSend.mock.calls[0][0] as { input: Record<string, unknown> };
|
||||
expect(command.input.Bucket).toBe("gearbox-images");
|
||||
expect(command.input.Key).toBe("test-image.jpg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getImageUrl", () => {
|
||||
test("calls getSignedUrl and returns result", async () => {
|
||||
const url = await getImageUrl("test-image.jpg");
|
||||
|
||||
expect(mockGetSignedUrl).toHaveBeenCalledTimes(1);
|
||||
expect(url).toBe(
|
||||
"https://minio:9000/gearbox-images/test.jpg?signed=1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withImageUrl", () => {
|
||||
test("returns null imageUrl when imageFilename is null", async () => {
|
||||
const record = { id: 1, name: "Test Item", imageFilename: null };
|
||||
const result = await withImageUrl(record);
|
||||
|
||||
expect(result.imageUrl).toBeNull();
|
||||
expect(result.id).toBe(1);
|
||||
expect(result.name).toBe("Test Item");
|
||||
expect(mockGetSignedUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns presigned URL when imageFilename is present", async () => {
|
||||
const record = {
|
||||
id: 1,
|
||||
name: "Test Item",
|
||||
imageFilename: "photo.jpg",
|
||||
};
|
||||
const result = await withImageUrl(record);
|
||||
|
||||
expect(result.imageUrl).toBe(
|
||||
"https://minio:9000/gearbox-images/test.jpg?signed=1",
|
||||
);
|
||||
expect(result.id).toBe(1);
|
||||
expect(mockGetSignedUrl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withImageUrls", () => {
|
||||
test("processes array of records correctly", async () => {
|
||||
const records = [
|
||||
{ id: 1, imageFilename: "a.jpg" },
|
||||
{ id: 2, imageFilename: null },
|
||||
{ id: 3, imageFilename: "b.png" },
|
||||
];
|
||||
const results = await withImageUrls(records);
|
||||
|
||||
expect(results).toHaveLength(3);
|
||||
expect(results[0].imageUrl).toBe(
|
||||
"https://minio:9000/gearbox-images/test.jpg?signed=1",
|
||||
);
|
||||
expect(results[1].imageUrl).toBeNull();
|
||||
expect(results[2].imageUrl).toBe(
|
||||
"https://minio:9000/gearbox-images/test.jpg?signed=1",
|
||||
);
|
||||
// Called twice: for records[0] and records[2]
|
||||
expect(mockGetSignedUrl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("handles empty array", async () => {
|
||||
const results = await withImageUrls([]);
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user