implement file duplication check in upload process and enhance test coverage
This commit is contained in:
@@ -24,6 +24,12 @@ export class RestStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userId = req.payload.uuid;
|
const userId = req.payload.uuid;
|
||||||
|
|
||||||
|
const isDuplicate = await this.s3Service.isFileDuplicate(req.file, userId);
|
||||||
|
if (isDuplicate) {
|
||||||
|
return badRequest(res, "File was already uploaded.");
|
||||||
|
}
|
||||||
|
|
||||||
const objectKey = await this.s3Service.uploadFile(req.file, userId);
|
const objectKey = await this.s3Service.uploadFile(req.file, userId);
|
||||||
|
|
||||||
return created(res, { message: "File uploaded successfully", objectKey });
|
return created(res, { message: "File uploaded successfully", objectKey });
|
||||||
|
|||||||
@@ -65,21 +65,23 @@ export class S3Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async uploadFile(file: Express.Multer.File, userId: string): Promise<string> {
|
async uploadFile(file: Express.Multer.File, userId: string): Promise<string> {
|
||||||
const fileExtension = file.originalname.split(".").pop();
|
const objectKey = `user-${userId}/${randomUUID()}_${file.originalname}`;
|
||||||
const objectKey = `user-${userId}/${randomUUID()}.${fileExtension}`;
|
|
||||||
|
|
||||||
const command = new PutObjectCommand({
|
const command = new PutObjectCommand({
|
||||||
Bucket: this.bucketName,
|
Bucket: this.bucketName,
|
||||||
Key: objectKey,
|
Key: objectKey,
|
||||||
Body: file.buffer,
|
Body: file.buffer,
|
||||||
ContentType: file.mimetype,
|
ContentType: file.mimetype,
|
||||||
|
Metadata: {
|
||||||
|
originalname: encodeURIComponent(file.originalname),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.client.send(command);
|
await this.client.send(command);
|
||||||
return objectKey;
|
return objectKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listFilesForUser(userId: string): Promise<{ key: string; lastModified: Date }[]> {
|
async listFilesForUser(userId: string): Promise<{ key: string; lastModified: Date; originalName?: string }[]> {
|
||||||
const command = new ListObjectsV2Command({
|
const command = new ListObjectsV2Command({
|
||||||
Bucket: this.bucketName,
|
Bucket: this.bucketName,
|
||||||
Prefix: `user-${userId}/`,
|
Prefix: `user-${userId}/`,
|
||||||
@@ -91,10 +93,38 @@ export class S3Service {
|
|||||||
response.Contents?.map((item) => ({
|
response.Contents?.map((item) => ({
|
||||||
key: item.Key!,
|
key: item.Key!,
|
||||||
lastModified: item.LastModified!,
|
lastModified: item.LastModified!,
|
||||||
|
originalName: this.extractOriginalNameFromKey(item.Key!),
|
||||||
})) || []
|
})) || []
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async isFileDuplicate(file: Express.Multer.File, userId: string): Promise<boolean> {
|
||||||
|
const existingFiles = await this.listFilesForUser(userId);
|
||||||
|
const fileName = file.originalname.toLowerCase();
|
||||||
|
|
||||||
|
// Prüfen, ob eine Datei mit demselben Namen bereits existiert
|
||||||
|
for (const existingFile of existingFiles) {
|
||||||
|
const existingFileName = this.extractOriginalNameFromKey(existingFile.key);
|
||||||
|
if (existingFileName && existingFileName.toLowerCase() === fileName) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractOriginalNameFromKey(key: string): string | undefined {
|
||||||
|
// Extrahiere den Dateinamen aus dem Objektschlüssel
|
||||||
|
// Format: user-{userId}/{uuid}_{originalname}
|
||||||
|
const parts = key.split("/");
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const filename = parts[parts.length - 1];
|
||||||
|
const filenameMatch = filename.match(/[^_]+_(.+)$/);
|
||||||
|
return filenameMatch ? filenameMatch[1] : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
async deleteFile(objectKey: string): Promise<void> {
|
async deleteFile(objectKey: string): Promise<void> {
|
||||||
const command = new DeleteObjectCommand({
|
const command = new DeleteObjectCommand({
|
||||||
Bucket: this.bucketName,
|
Bucket: this.bucketName,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { PasswordUtils } from "../../src/utils/passwordUtils";
|
|||||||
export const defaultMockPayload = {
|
export const defaultMockPayload = {
|
||||||
uuid: "test-user-uuid",
|
uuid: "test-user-uuid",
|
||||||
username: "testuser",
|
username: "testuser",
|
||||||
id: "test-user-id"
|
id: "test-user-id",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -121,9 +121,9 @@ export const createMockSpotifyApiService = () => ({
|
|||||||
getCurrentlyPlaying: vi.fn(),
|
getCurrentlyPlaying: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createMockSpotifyPollingService = () => ({
|
export const createMockSpotifyPollingService = () => ({
|
||||||
startPollingForUser: vi.fn(),
|
startPollingForUser: vi.fn(),
|
||||||
stopPollingForUser: vi.fn()
|
stopPollingForUser: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createMockS3Service = () => ({
|
export const createMockS3Service = () => ({
|
||||||
@@ -132,4 +132,5 @@ export const createMockS3Service = () => ({
|
|||||||
listFilesForUser: vi.fn(),
|
listFilesForUser: vi.fn(),
|
||||||
deleteFile: vi.fn(),
|
deleteFile: vi.fn(),
|
||||||
getSignedDownloadUrl: vi.fn(),
|
getSignedDownloadUrl: vi.fn(),
|
||||||
});
|
isFileDuplicate: vi.fn(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ describe("RestStorage", () => {
|
|||||||
|
|
||||||
describe("POST /upload", () => {
|
describe("POST /upload", () => {
|
||||||
it("should upload a file and return a 201 Created response", async () => {
|
it("should upload a file and return a 201 Created response", async () => {
|
||||||
|
mockS3Service.isFileDuplicate.mockResolvedValue(false);
|
||||||
|
|
||||||
const objectKey = `user-${requestingUserUUID}/generated-uuid.jpg`;
|
const objectKey = `user-${requestingUserUUID}/generated-uuid.jpg`;
|
||||||
mockS3Service.uploadFile.mockResolvedValue(objectKey);
|
mockS3Service.uploadFile.mockResolvedValue(objectKey);
|
||||||
|
|
||||||
@@ -48,6 +50,14 @@ describe("RestStorage", () => {
|
|||||||
objectKey: objectKey,
|
objectKey: objectKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(mockS3Service.isFileDuplicate).toHaveBeenCalledOnce();
|
||||||
|
expect(mockS3Service.isFileDuplicate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
originalname: "test.jpg",
|
||||||
|
}),
|
||||||
|
requestingUserUUID
|
||||||
|
);
|
||||||
|
|
||||||
expect(mockS3Service.uploadFile).toHaveBeenCalledOnce();
|
expect(mockS3Service.uploadFile).toHaveBeenCalledOnce();
|
||||||
expect(mockS3Service.uploadFile).toHaveBeenCalledWith(
|
expect(mockS3Service.uploadFile).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -63,6 +73,19 @@ describe("RestStorage", () => {
|
|||||||
expect(response.body.data.message).toBe("No file provided.");
|
expect(response.body.data.message).toBe("No file provided.");
|
||||||
expect(mockS3Service.uploadFile).not.toHaveBeenCalled();
|
expect(mockS3Service.uploadFile).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should return a 400 Bad Request if the file is a duplicate", async () => {
|
||||||
|
mockS3Service.isFileDuplicate.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post("/storage/upload")
|
||||||
|
.attach("image", Buffer.from("duplicate image data"), "duplicate.jpg")
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body.data.message).toBe("File was already uploaded.");
|
||||||
|
expect(mockS3Service.isFileDuplicate).toHaveBeenCalledOnce();
|
||||||
|
expect(mockS3Service.uploadFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("GET /files", () => {
|
describe("GET /files", () => {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
S3Client,
|
S3Client,
|
||||||
CreateBucketCommand,
|
CreateBucketCommand,
|
||||||
PutObjectCommand,
|
PutObjectCommand,
|
||||||
GetObjectCommand,
|
|
||||||
ListObjectsV2Command,
|
ListObjectsV2Command,
|
||||||
DeleteObjectCommand,
|
DeleteObjectCommand,
|
||||||
} from "@aws-sdk/client-s3";
|
} from "@aws-sdk/client-s3";
|
||||||
@@ -44,6 +43,13 @@ describe("S3Service", () => {
|
|||||||
() =>
|
() =>
|
||||||
({
|
({
|
||||||
send: mockSend,
|
send: mockSend,
|
||||||
|
config: {
|
||||||
|
region: "mock-region",
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: "mock-access-key",
|
||||||
|
secretAccessKey: "mock-secret-key",
|
||||||
|
},
|
||||||
|
},
|
||||||
}) as never
|
}) as never
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -90,7 +96,7 @@ describe("S3Service", () => {
|
|||||||
|
|
||||||
const objectKey = await s3Service.uploadFile(mockFile as never, userId);
|
const objectKey = await s3Service.uploadFile(mockFile as never, userId);
|
||||||
|
|
||||||
expect(objectKey).toMatch(/^user-user-123\/[a-f0-9-]+\.jpg$/);
|
expect(objectKey).toMatch(/^user-user-123\/[a-f0-9-]+_test-image\.jpg$/);
|
||||||
|
|
||||||
expect(mockSend).toHaveBeenCalledOnce();
|
expect(mockSend).toHaveBeenCalledOnce();
|
||||||
expect(mockSend).toHaveBeenCalledWith(expect.any(PutObjectCommand));
|
expect(mockSend).toHaveBeenCalledWith(expect.any(PutObjectCommand));
|
||||||
@@ -109,8 +115,14 @@ describe("S3Service", () => {
|
|||||||
it("should return a correctly formatted list of files for a user", async () => {
|
it("should return a correctly formatted list of files for a user", async () => {
|
||||||
const mockS3Response = {
|
const mockS3Response = {
|
||||||
Contents: [
|
Contents: [
|
||||||
{ Key: `user-${userId}/file1.txt`, LastModified: new Date("2023-01-01") },
|
{
|
||||||
{ Key: `user-${userId}/image.jpg`, LastModified: new Date("2023-01-02") },
|
Key: `user-${userId}/uuid1_file1.txt`,
|
||||||
|
LastModified: new Date("2023-01-01"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: `user-${userId}/uuid2_image.jpg`,
|
||||||
|
LastModified: new Date("2023-01-02"),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
mockSend.mockResolvedValue(mockS3Response);
|
mockSend.mockResolvedValue(mockS3Response);
|
||||||
@@ -125,10 +137,16 @@ describe("S3Service", () => {
|
|||||||
expect(sentCommand.Prefix).toBe(`user-${userId}/`);
|
expect(sentCommand.Prefix).toBe(`user-${userId}/`);
|
||||||
|
|
||||||
expect(files).toHaveLength(2);
|
expect(files).toHaveLength(2);
|
||||||
expect(files).toEqual([
|
expect(files).toContainEqual({
|
||||||
{ key: `user-${userId}/file1.txt`, lastModified: new Date("2023-01-01") },
|
key: `user-${userId}/uuid1_file1.txt`,
|
||||||
{ key: `user-${userId}/image.jpg`, lastModified: new Date("2023-01-02") },
|
lastModified: new Date("2023-01-01"),
|
||||||
]);
|
originalName: "file1.txt",
|
||||||
|
});
|
||||||
|
expect(files).toContainEqual({
|
||||||
|
key: `user-${userId}/uuid2_image.jpg`,
|
||||||
|
lastModified: new Date("2023-01-02"),
|
||||||
|
originalName: "image.jpg",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return an empty array if the user has no files", async () => {
|
it("should return an empty array if the user has no files", async () => {
|
||||||
@@ -178,7 +196,106 @@ describe("S3Service", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ignore test for now.
|
describe("isFileDuplicate", () => {
|
||||||
|
it("should correctly identify duplicate files", async () => {
|
||||||
|
const userId = "user-123";
|
||||||
|
const mockFile = {
|
||||||
|
originalname: "duplicate-image.jpg",
|
||||||
|
buffer: Buffer.from("test-data"),
|
||||||
|
mimetype: "image/jpeg",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockFiles = [
|
||||||
|
{
|
||||||
|
key: `user-${userId}/file1_original-file.txt`,
|
||||||
|
lastModified: new Date("2023-01-01"),
|
||||||
|
originalName: "original-file.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: `user-${userId}/file2_duplicate-image.jpg`,
|
||||||
|
lastModified: new Date("2023-01-02"),
|
||||||
|
originalName: "duplicate-image.jpg",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockSend.mockResolvedValueOnce({
|
||||||
|
Contents: mockFiles.map((file) => ({
|
||||||
|
Key: file.key,
|
||||||
|
LastModified: file.lastModified,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isDuplicate = await s3Service.isFileDuplicate(mockFile as never, userId);
|
||||||
|
|
||||||
|
expect(isDuplicate).toBe(true);
|
||||||
|
expect(mockSend).toHaveBeenCalledWith(expect.any(ListObjectsV2Command));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should correctly identify non-duplicate files", async () => {
|
||||||
|
const userId = "user-123";
|
||||||
|
const mockFile = {
|
||||||
|
originalname: "new-image.jpg",
|
||||||
|
buffer: Buffer.from("test-data"),
|
||||||
|
mimetype: "image/jpeg",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockFiles = [
|
||||||
|
{
|
||||||
|
key: `user-${userId}/file1_existing-file.txt`,
|
||||||
|
lastModified: new Date("2023-01-01"),
|
||||||
|
originalName: "existing-file.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: `user-${userId}/file2_another-image.jpg`,
|
||||||
|
lastModified: new Date("2023-01-02"),
|
||||||
|
originalName: "another-image.jpg",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockSend.mockResolvedValueOnce({
|
||||||
|
Contents: mockFiles.map((file) => ({
|
||||||
|
Key: file.key,
|
||||||
|
LastModified: file.lastModified,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isDuplicate = await s3Service.isFileDuplicate(mockFile as never, userId);
|
||||||
|
|
||||||
|
expect(isDuplicate).toBe(false);
|
||||||
|
expect(mockSend).toHaveBeenCalledWith(expect.any(ListObjectsV2Command));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty file lists correctly", async () => {
|
||||||
|
const userId = "user-123";
|
||||||
|
const mockFile = {
|
||||||
|
originalname: "test-image.jpg",
|
||||||
|
buffer: Buffer.from("test-data"),
|
||||||
|
mimetype: "image/jpeg",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockSend.mockResolvedValueOnce({
|
||||||
|
Contents: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const isDuplicate = await s3Service.isFileDuplicate(mockFile as never, userId);
|
||||||
|
|
||||||
|
expect(isDuplicate).toBe(false);
|
||||||
|
expect(mockSend).toHaveBeenCalledWith(expect.any(ListObjectsV2Command));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractOriginalNameFromKey", () => {
|
||||||
|
it("should correctly extract the original filename from an object key", () => {
|
||||||
|
const originalName = (s3Service as any).extractOriginalNameFromKey("user-123/abc123_original-file.jpg");
|
||||||
|
expect(originalName).toBe("original-file.jpg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return undefined for invalid object keys", () => {
|
||||||
|
const originalName = (s3Service as any).extractOriginalNameFromKey("invalid-key");
|
||||||
|
expect(originalName).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("getSignedDownloadUrl", () => {
|
describe("getSignedDownloadUrl", () => {
|
||||||
it("should generate a signed URL for a given object key", async () => {
|
it("should generate a signed URL for a given object key", async () => {
|
||||||
const objectKey = "user-123/image.png";
|
const objectKey = "user-123/image.png";
|
||||||
@@ -190,14 +307,18 @@ describe("S3Service", () => {
|
|||||||
|
|
||||||
expect(signedUrl).toBe(fakeSignedUrl);
|
expect(signedUrl).toBe(fakeSignedUrl);
|
||||||
|
|
||||||
|
// Prüfung, dass getSignedUrl korrekt aufgerufen wurde
|
||||||
expect(mockGetSignedUrl).toHaveBeenCalledOnce();
|
expect(mockGetSignedUrl).toHaveBeenCalledOnce();
|
||||||
expect(mockGetSignedUrl).toHaveBeenCalledWith(expect.any(Object), expect.any(GetObjectCommand), {
|
expect(mockGetSignedUrl).toHaveBeenCalledWith(
|
||||||
expiresIn: 300,
|
expect.anything(),
|
||||||
});
|
expect.objectContaining({
|
||||||
|
input: {
|
||||||
const passedCommand = (mockGetSignedUrl.mock.calls[0][1] as GetObjectCommand).input;
|
Bucket: "test-bucket",
|
||||||
expect(passedCommand.Bucket).toBe("test-bucket");
|
Key: objectKey,
|
||||||
expect(passedCommand.Key).toBe(objectKey);
|
},
|
||||||
|
}),
|
||||||
|
{ expiresIn: 300 }
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user