fix: adds deleteFilesByPrefix service

This commit is contained in:
pandeymangg
2025-08-25 12:42:18 +05:30
parent df63f2e5d9
commit 0155c41593
6 changed files with 358 additions and 92 deletions

View File

@@ -96,7 +96,6 @@ describe("client.ts", () => {
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("s3_credentials_error");
expect(result.error.message).toBe("S3 credentials are not set");
}
});
@@ -114,7 +113,6 @@ describe("client.ts", () => {
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("s3_credentials_error");
expect(result.error.message).toBe("S3 credentials are not set");
}
});
@@ -133,7 +131,6 @@ describe("client.ts", () => {
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("s3_credentials_error");
expect(result.error.message).toBe("S3 credentials are not set");
}
});
@@ -152,7 +149,6 @@ describe("client.ts", () => {
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("s3_credentials_error");
expect(result.error.message).toBe("S3 credentials are not set");
}
});
@@ -171,7 +167,6 @@ describe("client.ts", () => {
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("s3_credentials_error");
expect(result.error.message).toBe("S3 credentials are not set");
}
});

View File

@@ -1,6 +1,6 @@
import { S3Client } from "@aws-sdk/client-s3";
import { logger } from "@formbricks/logger";
import { ErrorCode, type Result, type S3CredentialsError, type UnknownError, err, ok } from "../types/error";
import { ErrorCode, type Result, type StorageError, err, ok } from "../types/error";
import {
S3_ACCESS_KEY,
S3_BUCKET_NAME,
@@ -14,13 +14,12 @@ import {
* Create an S3 client from environment variables
* @returns A Result containing the S3 client or an error: S3CredentialsError | UnknownError
*/
export const createS3ClientFromEnv = (): Result<S3Client, S3CredentialsError | UnknownError> => {
export const createS3ClientFromEnv = (): Result<S3Client, StorageError> => {
try {
if (!S3_ACCESS_KEY || !S3_SECRET_KEY || !S3_BUCKET_NAME || !S3_REGION) {
logger.error("S3 Client: S3 credentials are not set");
return err({
code: ErrorCode.S3CredentialsError,
message: "S3 credentials are not set",
});
}
@@ -33,10 +32,9 @@ export const createS3ClientFromEnv = (): Result<S3Client, S3CredentialsError | U
return ok(s3ClientInstance);
} catch (error) {
logger.error("Error creating S3 client from environment variables", { error });
logger.error({ error }, "Error creating S3 client from environment variables");
return err({
code: ErrorCode.Unknown,
message: "Error creating S3 client from environment variables",
});
}
};

View File

@@ -1 +1 @@
export { deleteFile, getSignedDownloadUrl, getSignedUploadUrl } from "./service";
export { deleteFile, getSignedDownloadUrl, getSignedUploadUrl, deleteFilesByPrefix } from "./service";

View File

@@ -1,4 +1,10 @@
import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand } from "@aws-sdk/client-s3";
import {
DeleteObjectCommand,
DeleteObjectsCommand,
GetObjectCommand,
HeadObjectCommand,
ListObjectsCommand,
} from "@aws-sdk/client-s3";
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { beforeEach, describe, expect, test, vi } from "vitest";
@@ -6,8 +12,10 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
// Mock AWS SDK modules
vi.mock("@aws-sdk/client-s3", () => ({
DeleteObjectCommand: vi.fn(),
DeleteObjectsCommand: vi.fn(),
GetObjectCommand: vi.fn(),
HeadObjectCommand: vi.fn(),
ListObjectsCommand: vi.fn(),
}));
vi.mock("@aws-sdk/s3-presigned-post", () => ({
@@ -26,8 +34,10 @@ vi.mock("./client", () => ({
}));
const mockDeleteObjectCommand = vi.mocked(DeleteObjectCommand);
const mockDeleteObjectsCommand = vi.mocked(DeleteObjectsCommand);
const mockGetObjectCommand = vi.mocked(GetObjectCommand);
const mockHeadObjectCommand = vi.mocked(HeadObjectCommand);
const mockListObjectsCommand = vi.mocked(ListObjectsCommand);
const mockCreatePresignedPost = vi.mocked(createPresignedPost);
const mockGetSignedUrl = vi.mocked(getSignedUrl);
@@ -95,7 +105,6 @@ describe("service.ts", () => {
if (!result.ok) {
expect(result.error.code).toBe("s3_credentials_error");
expect(result.error.message).toBe("S3 bucket name is not set");
}
});
@@ -113,7 +122,6 @@ describe("service.ts", () => {
if (!result.ok) {
expect(result.error.code).toBe("s3_client_error");
expect(result.error.message).toBe("S3 client is not set");
}
});
@@ -135,7 +143,6 @@ describe("service.ts", () => {
if (!result.ok) {
expect(result.error.code).toBe("unknown");
expect(result.error.message).toBe("Failed to get signed upload URL");
}
});
@@ -200,7 +207,6 @@ describe("service.ts", () => {
if (!result.ok) {
expect(result.error.code).toBe("s3_credentials_error");
expect(result.error.message).toBe("S3 bucket name is not set");
}
});
@@ -297,7 +303,6 @@ describe("service.ts", () => {
if (!result.ok) {
expect(result.error.code).toBe("s3_client_error");
expect(result.error.message).toBe("S3 client is not set");
}
});
@@ -325,7 +330,6 @@ describe("service.ts", () => {
if (!result.ok) {
expect(result.error.code).toBe("unknown");
expect(result.error.message).toBe("Failed to get signed download URL");
}
});
@@ -356,7 +360,6 @@ describe("service.ts", () => {
if (!result.ok) {
expect(result.error.code).toBe("file_not_found_error");
expect(result.error.message).toBe("File not found: non-existent-file.pdf");
}
});
@@ -383,7 +386,6 @@ describe("service.ts", () => {
if (!result.ok) {
expect(result.error.code).toBe("file_not_found_error");
expect(result.error.message).toBe("File not found: another-non-existent-file.pdf");
}
});
});
@@ -408,7 +410,6 @@ describe("service.ts", () => {
if (!result.ok) {
expect(result.error.code).toBe("s3_credentials_error");
expect(result.error.message).toBe("S3 bucket name is not set");
}
});
@@ -512,7 +513,6 @@ describe("service.ts", () => {
if (!result.ok) {
expect(result.error.code).toBe("s3_client_error");
expect(result.error.message).toBe("S3 client is not set");
}
});
@@ -535,7 +535,266 @@ describe("service.ts", () => {
if (!result.ok) {
expect(result.error.code).toBe("unknown");
expect(result.error.message).toBe("Failed to delete file");
}
});
});
describe("deleteFilesByPrefix", () => {
test("should return error if bucket name is not set", async () => {
vi.doMock("./constants", () => ({
...mockConstants,
S3_BUCKET_NAME: undefined,
}));
vi.doMock("./client", () => ({
createS3Client: vi.fn(() => ({
send: vi.fn(),
})),
}));
const { deleteFilesByPrefix } = await import("./service");
const result = await deleteFilesByPrefix("uploads/images/");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("s3_credentials_error");
}
});
test("should return error if s3Client is null", async () => {
vi.doMock("./constants", () => mockConstants);
vi.doMock("./client", () => ({
createS3Client: vi.fn(() => undefined),
}));
const { deleteFilesByPrefix } = await import("./service");
const result = await deleteFilesByPrefix("uploads/images/");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("s3_client_error");
}
});
test("should delete multiple files with given prefix", async () => {
vi.doMock("./constants", () => ({
...mockConstants,
}));
const mockS3Client = {
send: vi
.fn()
.mockResolvedValueOnce({
Contents: [
{ Key: "uploads/images/file1.jpg" },
{ Key: "uploads/images/file2.png" },
{ Key: "uploads/images/subfolder/file3.gif" },
],
})
.mockResolvedValueOnce({}), // DeleteObjectsCommand response
};
vi.doMock("./client", () => ({
createS3Client: vi.fn(() => mockS3Client),
}));
const { deleteFilesByPrefix } = await import("./service");
const result = await deleteFilesByPrefix("uploads/images/");
expect(mockListObjectsCommand).toHaveBeenCalledWith({
Bucket: mockConstants.S3_BUCKET_NAME,
Prefix: "uploads/images/",
});
expect(mockDeleteObjectsCommand).toHaveBeenCalledWith({
Bucket: mockConstants.S3_BUCKET_NAME,
Delete: {
Objects: [
{ Key: "uploads/images/file1.jpg" },
{ Key: "uploads/images/file2.png" },
{ Key: "uploads/images/subfolder/file3.gif" },
],
},
});
expect(mockS3Client.send).toHaveBeenCalledTimes(2);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toBeUndefined();
}
});
test("should handle empty result list (no files found)", async () => {
vi.doMock("./constants", () => ({
...mockConstants,
}));
const mockS3Client = {
send: vi.fn().mockResolvedValueOnce({
Contents: undefined, // No files found
}),
};
vi.doMock("./client", () => ({
createS3Client: vi.fn(() => mockS3Client),
}));
const { deleteFilesByPrefix } = await import("./service");
const result = await deleteFilesByPrefix("uploads/non-existent/");
expect(mockListObjectsCommand).toHaveBeenCalledWith({
Bucket: mockConstants.S3_BUCKET_NAME,
Prefix: "uploads/non-existent/",
});
// Should not call DeleteObjectsCommand when no files found
expect(mockDeleteObjectsCommand).not.toHaveBeenCalled();
expect(mockS3Client.send).toHaveBeenCalledTimes(1);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toBeUndefined();
}
});
test("should handle empty Contents array", async () => {
vi.doMock("./constants", () => ({
...mockConstants,
}));
const mockS3Client = {
send: vi.fn().mockResolvedValueOnce({
Contents: [], // Empty array
}),
};
vi.doMock("./client", () => ({
createS3Client: vi.fn(() => mockS3Client),
}));
const { deleteFilesByPrefix } = await import("./service");
const result = await deleteFilesByPrefix("uploads/empty/");
expect(mockListObjectsCommand).toHaveBeenCalledWith({
Bucket: mockConstants.S3_BUCKET_NAME,
Prefix: "uploads/empty/",
});
// Should not call DeleteObjectsCommand when Contents is empty
expect(mockDeleteObjectsCommand).not.toHaveBeenCalled();
expect(mockS3Client.send).toHaveBeenCalledTimes(1);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toBeUndefined();
}
});
test("should handle different prefix patterns", async () => {
vi.doMock("./constants", () => ({
...mockConstants,
}));
const mockS3Client = {
send: vi
.fn()
.mockResolvedValueOnce({
Contents: [{ Key: "surveys/123/responses/response1.json" }],
})
.mockResolvedValueOnce({}),
};
vi.doMock("./client", () => ({
createS3Client: vi.fn(() => mockS3Client),
}));
const { deleteFilesByPrefix } = await import("./service");
const result = await deleteFilesByPrefix("surveys/123/responses/");
expect(mockListObjectsCommand).toHaveBeenCalledWith({
Bucket: mockConstants.S3_BUCKET_NAME,
Prefix: "surveys/123/responses/",
});
expect(mockDeleteObjectsCommand).toHaveBeenCalledWith({
Bucket: mockConstants.S3_BUCKET_NAME,
Delete: {
Objects: [{ Key: "surveys/123/responses/response1.json" }],
},
});
expect(result.ok).toBe(true);
});
test("should handle ListObjectsCommand throwing an error", async () => {
vi.doMock("./constants", () => mockConstants);
const mockS3Client = {
send: vi.fn().mockRejectedValueOnce(new Error("AWS ListObjects Error")),
};
vi.doMock("./client", () => ({
createS3Client: vi.fn(() => mockS3Client),
}));
const { deleteFilesByPrefix } = await import("./service");
const result = await deleteFilesByPrefix("uploads/test/");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("unknown");
}
});
test("should handle DeleteObjectsCommand throwing an error", async () => {
vi.doMock("./constants", () => mockConstants);
const mockS3Client = {
send: vi
.fn()
.mockResolvedValueOnce({
Contents: [{ Key: "test-file.txt" }],
})
.mockRejectedValueOnce(new Error("AWS Delete Error")), // DeleteObjectsCommand fails
};
vi.doMock("./client", () => ({
createS3Client: vi.fn(() => mockS3Client),
}));
const { deleteFilesByPrefix } = await import("./service");
const result = await deleteFilesByPrefix("uploads/test/");
expect(mockListObjectsCommand).toHaveBeenCalledWith({
Bucket: mockConstants.S3_BUCKET_NAME,
Prefix: "uploads/test/",
});
expect(mockDeleteObjectsCommand).toHaveBeenCalledWith({
Bucket: mockConstants.S3_BUCKET_NAME,
Delete: {
Objects: [{ Key: "test-file.txt" }],
},
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("unknown");
}
});
});

View File

@@ -1,4 +1,10 @@
import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand } from "@aws-sdk/client-s3";
import {
DeleteObjectCommand,
DeleteObjectsCommand,
GetObjectCommand,
HeadObjectCommand,
ListObjectsCommand,
} from "@aws-sdk/client-s3";
import {
type PresignedPost,
type PresignedPostOptions,
@@ -6,16 +12,7 @@ import {
} from "@aws-sdk/s3-presigned-post";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { logger } from "@formbricks/logger";
import {
ErrorCode,
type FileNotFoundError,
type Result,
type S3ClientError,
type S3CredentialsError,
type UnknownError,
err,
ok,
} from "../types/error";
import { ErrorCode, type Result, type StorageError, err, ok } from "../types/error";
import { createS3Client } from "./client";
import { S3_BUCKET_NAME } from "./constants";
@@ -27,7 +24,7 @@ const s3Client = createS3Client();
* @param contentType - The content type of the file
* @param filePath - The path to the file in S3
* @param maxSize - The maximum size of the file to upload or undefined if no limit is desired
* @returns A Result containing the signed URL and presigned fields or an error: UnknownError | S3CredentialsError | S3ClientError
* @returns A Result containing the signed URL and presigned fields or an error: StorageError
*/
export const getSignedUploadUrl = async (
fileName: string,
@@ -40,7 +37,7 @@ export const getSignedUploadUrl = async (
signedUrl: string;
presignedFields: PresignedPost["fields"];
},
UnknownError | S3CredentialsError | S3ClientError
StorageError
>
> => {
try {
@@ -48,7 +45,6 @@ export const getSignedUploadUrl = async (
logger.error("Failed to get signed upload URL: S3 client is not set");
return err({
code: ErrorCode.S3ClientError,
message: "S3 client is not set",
});
}
@@ -60,7 +56,6 @@ export const getSignedUploadUrl = async (
logger.error("Failed to get signed upload URL: S3 bucket name is not set");
return err({
code: ErrorCode.S3CredentialsError,
message: "S3 bucket name is not set",
});
}
@@ -80,36 +75,30 @@ export const getSignedUploadUrl = async (
presignedFields: fields,
});
} catch (error) {
logger.error("Failed to get signed upload URL", { error });
const unknownError: UnknownError = {
code: ErrorCode.Unknown,
message: "Failed to get signed upload URL",
};
logger.error({ error }, "Failed to get signed upload URL");
return err(unknownError);
return err({
code: ErrorCode.Unknown,
});
}
};
/**
* Get a signed URL for a file in S3
* @param fileKey - The key of the file in S3
* @returns A Result containing the signed URL or an error: S3CredentialsError | S3ClientError | FileNotFoundError | UnknownError
* @returns A Result containing the signed URL or an error: StorageError
*/
export const getSignedDownloadUrl = async (
fileKey: string
): Promise<Result<string, S3CredentialsError | S3ClientError | FileNotFoundError | UnknownError>> => {
export const getSignedDownloadUrl = async (fileKey: string): Promise<Result<string, StorageError>> => {
try {
if (!s3Client) {
return err({
code: ErrorCode.S3ClientError,
message: "S3 client is not set",
});
}
if (!S3_BUCKET_NAME) {
return err({
code: ErrorCode.S3CredentialsError,
message: "S3 bucket name is not set",
});
}
@@ -121,19 +110,18 @@ export const getSignedDownloadUrl = async (
try {
await s3Client.send(headObjectCommand);
} catch (headError: unknown) {
logger.error("Failed to check if file exists", headError);
} catch (error: unknown) {
logger.error({ error }, "Failed to check if file exists");
if (
(headError as Error).name === "NotFound" ||
(headError as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode === 404
(error as Error).name === "NotFound" ||
(error as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode === 404
) {
return err({
code: ErrorCode.FileNotFoundError,
message: `File not found: ${fileKey}`,
});
}
logger.warn("HeadObject check failed; proceeding to sign download URL", { error: headError, fileKey });
logger.warn({ error, fileKey }, "HeadObject check failed; proceeding to sign download URL");
}
const getObjectCommand = new GetObjectCommand({
@@ -143,36 +131,29 @@ export const getSignedDownloadUrl = async (
return ok(await getSignedUrl(s3Client, getObjectCommand, { expiresIn: 60 * 30 }));
} catch (error) {
logger.error("Failed to get signed download URL", { error });
const unknownError: UnknownError = {
logger.error({ error }, "Failed to get signed download URL");
return err({
code: ErrorCode.Unknown,
message: "Failed to get signed download URL",
};
return err(unknownError);
});
}
};
/**
* Delete a file from S3
* @param fileKey - The key of the file in S3 (e.g. "surveys/123/responses/456/file.pdf")
* @returns A Result containing the void or an error: S3CredentialsError | S3ClientError | UnknownError
* @returns A Result containing the void or an error: StorageError
*/
export const deleteFile = async (
fileKey: string
): Promise<Result<void, S3CredentialsError | S3ClientError | UnknownError>> => {
export const deleteFile = async (fileKey: string): Promise<Result<void, StorageError>> => {
try {
if (!s3Client) {
return err({
code: ErrorCode.S3ClientError,
message: "S3 client is not set",
});
}
if (!S3_BUCKET_NAME) {
return err({
code: ErrorCode.S3CredentialsError,
message: "S3 bucket name is not set",
});
}
@@ -185,13 +166,62 @@ export const deleteFile = async (
return ok(undefined);
} catch (error) {
logger.error("Failed to delete file", { error });
logger.error({ error }, "Failed to delete file");
const unknownError: UnknownError = {
return err({
code: ErrorCode.Unknown,
message: "Failed to delete file",
};
return err(unknownError);
});
}
};
export const deleteFilesByPrefix = async (prefix: string): Promise<Result<void, StorageError>> => {
try {
if (!s3Client) {
return err({
code: ErrorCode.S3ClientError,
});
}
if (!S3_BUCKET_NAME) {
return err({
code: ErrorCode.S3CredentialsError,
});
}
const listObjectsCommand = new ListObjectsCommand({
Bucket: S3_BUCKET_NAME,
Prefix: prefix,
});
const listObjectsOutput = await s3Client.send(listObjectsCommand);
if (!listObjectsOutput.Contents) {
return ok(undefined);
}
const objectsToDelete = listObjectsOutput.Contents.map((obj) => {
return { Key: obj.Key };
});
if (!objectsToDelete.length) {
return ok(undefined);
}
const deleteObjectsCommand = new DeleteObjectsCommand({
Bucket: S3_BUCKET_NAME,
Delete: {
Objects: objectsToDelete,
},
});
await s3Client.send(deleteObjectsCommand);
return ok(undefined);
} catch (error) {
logger.error({ error }, "Failed to delete files by prefix");
return err({
code: ErrorCode.Unknown,
});
}
};

View File

@@ -26,22 +26,6 @@ export enum ErrorCode {
FileNotFoundError = "file_not_found_error",
}
export interface UnknownError {
code: ErrorCode.Unknown;
message: string;
}
export interface S3CredentialsError {
code: ErrorCode.S3CredentialsError;
message: string;
}
export interface S3ClientError {
code: ErrorCode.S3ClientError;
message: string;
}
export interface FileNotFoundError {
code: ErrorCode.FileNotFoundError;
message: string;
export interface StorageError {
code: ErrorCode;
}