@alepha/bucket-s3 0.14.1 → 0.14.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts
CHANGED
|
@@ -460,6 +460,7 @@ declare class EventManager {
|
|
|
460
460
|
protected events: Record<string, Array<Hook>>;
|
|
461
461
|
constructor(logFn?: () => LoggerInterface | undefined);
|
|
462
462
|
protected get log(): LoggerInterface | undefined;
|
|
463
|
+
clear(): void;
|
|
463
464
|
/**
|
|
464
465
|
* Registers a hook for the specified event.
|
|
465
466
|
*/
|
|
@@ -992,6 +993,34 @@ interface State {
|
|
|
992
993
|
"alepha.logger"?: LoggerInterface;
|
|
993
994
|
/**
|
|
994
995
|
* If defined, the Alepha container will only register this service and its dependencies.
|
|
996
|
+
*
|
|
997
|
+
* @example
|
|
998
|
+
* ```ts
|
|
999
|
+
* class MigrateCmd {
|
|
1000
|
+
* db = $inject(DatabaseProvider);
|
|
1001
|
+
* alepha = $inject(Alepha);
|
|
1002
|
+
* env = $env(
|
|
1003
|
+
* t.object({
|
|
1004
|
+
* MIGRATE: t.optional(t.boolean()),
|
|
1005
|
+
* }),
|
|
1006
|
+
* );
|
|
1007
|
+
*
|
|
1008
|
+
* constructor() {
|
|
1009
|
+
* if (this.env.MIGRATE) {
|
|
1010
|
+
* this.alepha.set("alepha.target", MigrateCmd);
|
|
1011
|
+
* }
|
|
1012
|
+
* }
|
|
1013
|
+
*
|
|
1014
|
+
* ready = $hook({
|
|
1015
|
+
* on: "ready",
|
|
1016
|
+
* handler: async () => {
|
|
1017
|
+
* if (this.env.MIGRATE) {
|
|
1018
|
+
* await this.db.migrate();
|
|
1019
|
+
* }
|
|
1020
|
+
* },
|
|
1021
|
+
* });
|
|
1022
|
+
* }
|
|
1023
|
+
* ```
|
|
995
1024
|
*/
|
|
996
1025
|
"alepha.target"?: Service;
|
|
997
1026
|
/**
|
|
@@ -1539,6 +1568,13 @@ interface LsOptions {
|
|
|
1539
1568
|
* FileSystem interface providing utilities for working with files.
|
|
1540
1569
|
*/
|
|
1541
1570
|
declare abstract class FileSystemProvider {
|
|
1571
|
+
/**
|
|
1572
|
+
* Joins multiple path segments into a single path.
|
|
1573
|
+
*
|
|
1574
|
+
* @param paths - The path segments to join
|
|
1575
|
+
* @returns The joined path
|
|
1576
|
+
*/
|
|
1577
|
+
abstract join(...paths: string[]): string;
|
|
1542
1578
|
/**
|
|
1543
1579
|
* Creates a FileLike object from various sources.
|
|
1544
1580
|
*
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":["chunks: Buffer[]"],"sources":["../src/providers/S3FileStorageProvider.ts","../src/index.ts"],"sourcesContent":["import type { Readable } from \"node:stream\";\nimport {\n CreateBucketCommand,\n DeleteObjectCommand,\n GetObjectCommand,\n HeadBucketCommand,\n HeadObjectCommand,\n PutObjectCommand,\n S3Client,\n type S3ServiceException,\n} from \"@aws-sdk/client-s3\";\nimport {\n $env,\n $hook,\n $inject,\n Alepha,\n AlephaError,\n type FileLike,\n type Static,\n t,\n} from \"alepha\";\nimport {\n $bucket,\n FileNotFoundError,\n type FileStorageProvider,\n} from \"alepha/bucket\";\nimport { FileDetector, FileSystemProvider } from \"alepha/file\";\nimport { $logger } from \"alepha/logger\";\n\nconst envSchema = t.object({\n /**\n * Custom S3 endpoint URL for S3-compatible services.\n *\n * Examples:\n * - Cloudflare R2: https://<account-id>.r2.cloudflarestorage.com\n * - MinIO: http://localhost:9000\n * - DigitalOcean Spaces: https://<region>.digitaloceanspaces.com\n *\n * Leave empty for AWS S3.\n */\n S3_ENDPOINT: t.optional(t.string()),\n\n /**\n * AWS region or \"auto\" for R2.\n *\n * @default \"us-east-1\"\n */\n S3_REGION: t.optional(t.string()),\n\n /**\n * Access key ID for S3 authentication.\n */\n S3_ACCESS_KEY_ID: t.string(),\n\n /**\n * Secret access key for S3 authentication.\n */\n S3_SECRET_ACCESS_KEY: t.string(),\n\n /**\n * Force path-style URLs (required for MinIO and some S3-compatible services).\n * Set to \"true\" to enable.\n */\n S3_FORCE_PATH_STYLE: t.optional(t.string()),\n});\n\ndeclare module \"alepha\" {\n interface Env extends Partial<Static<typeof envSchema>> {}\n}\n\n/**\n * S3-compatible storage implementation of File Storage Provider.\n *\n * Works with AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces, and other S3-compatible services.\n */\nexport class S3FileStorageProvider implements FileStorageProvider {\n protected readonly log = $logger();\n protected readonly env = $env(envSchema);\n protected readonly alepha = $inject(Alepha);\n protected readonly fileSystem = $inject(FileSystemProvider);\n protected readonly fileDetector = $inject(FileDetector);\n protected readonly buckets: Set<string> = new Set();\n\n protected readonly client: S3Client;\n\n constructor() {\n this.client = new S3Client({\n endpoint: this.env.S3_ENDPOINT || undefined,\n region: this.env.S3_REGION || \"us-east-1\",\n credentials: {\n accessKeyId: this.env.S3_ACCESS_KEY_ID,\n secretAccessKey: this.env.S3_SECRET_ACCESS_KEY,\n },\n forcePathStyle: this.env.S3_FORCE_PATH_STYLE === \"true\",\n });\n }\n\n protected readonly onStart = $hook({\n on: \"start\",\n handler: async () => {\n for (const bucket of this.alepha.primitives($bucket)) {\n if (bucket.provider !== this) {\n continue;\n }\n\n const bucketName = this.convertName(bucket.name);\n\n this.log.debug(`Preparing S3 bucket '${bucketName}'...`);\n\n // Check if bucket exists, create if not\n try {\n await this.client.send(new HeadBucketCommand({ Bucket: bucketName }));\n } catch (error) {\n if (this.isNotFoundError(error)) {\n this.log.debug(`Creating S3 bucket '${bucketName}'...`);\n await this.client.send(\n new CreateBucketCommand({ Bucket: bucketName }),\n );\n } else {\n throw error;\n }\n }\n\n this.buckets.add(bucketName);\n this.log.info(`S3 bucket '${bucket.name}' OK`);\n }\n },\n });\n\n /**\n * Convert bucket name to S3-compatible format.\n * S3 bucket names must be lowercase, 3-63 characters, no underscores.\n */\n public convertName(name: string): string {\n return name.replaceAll(\"/\", \"-\").replaceAll(\"_\", \"-\").toLowerCase();\n }\n\n protected createId(mimeType: string): string {\n const ext = this.fileDetector.getExtensionFromMimeType(mimeType);\n return `${crypto.randomUUID()}.${ext}`;\n }\n\n public async upload(\n bucketName: string,\n file: FileLike,\n fileId?: string,\n ): Promise<string> {\n fileId ??= this.createId(file.type);\n\n this.log.trace(\n `Uploading file '${file.name}' to bucket '${bucketName}' with id '${fileId}'...`,\n );\n\n const bucket = this.convertName(bucketName);\n\n try {\n const body = Buffer.from(await file.arrayBuffer());\n\n await this.client.send(\n new PutObjectCommand({\n Bucket: bucket,\n Key: fileId,\n Body: body,\n ContentType: file.type || \"application/octet-stream\",\n ContentLength: file.size,\n Metadata: {\n name: encodeURIComponent(file.name),\n },\n }),\n );\n\n this.log.trace(`File uploaded successfully: ${fileId}`);\n return fileId;\n } catch (error) {\n this.log.error(`Failed to upload file: ${error}`);\n if (error instanceof Error) {\n throw new AlephaError(`Upload failed: ${error.message}`, {\n cause: error,\n });\n }\n throw error;\n }\n }\n\n public async download(bucketName: string, fileId: string): Promise<FileLike> {\n this.log.trace(\n `Downloading file '${fileId}' from bucket '${bucketName}'...`,\n );\n\n const bucket = this.convertName(bucketName);\n\n try {\n const response = await this.client.send(\n new GetObjectCommand({\n Bucket: bucket,\n Key: fileId,\n }),\n );\n\n if (!response.Body) {\n throw new FileNotFoundError(\"File not found - empty response body\");\n }\n\n // Convert the stream to a buffer\n const stream = response.Body as Readable;\n const chunks: Buffer[] = [];\n for await (const chunk of stream) {\n chunks.push(Buffer.from(chunk));\n }\n const buffer = Buffer.concat(chunks);\n\n const mimeType =\n response.ContentType || this.fileDetector.getContentType(fileId);\n\n const name = response.Metadata?.name\n ? decodeURIComponent(response.Metadata.name)\n : fileId;\n\n return this.fileSystem.createFile({\n buffer,\n name,\n type: mimeType,\n size: response.ContentLength,\n });\n } catch (error) {\n if (this.isNotFoundError(error)) {\n throw new FileNotFoundError(\n `File '${fileId}' not found in bucket '${bucketName}'`,\n );\n }\n\n this.log.error(`Failed to download file: ${error}`);\n if (error instanceof Error) {\n throw new FileNotFoundError(\"Error downloading file\", { cause: error });\n }\n throw error;\n }\n }\n\n public async exists(bucketName: string, fileId: string): Promise<boolean> {\n this.log.trace(\n `Checking existence of file '${fileId}' in bucket '${bucketName}'...`,\n );\n\n const bucket = this.convertName(bucketName);\n\n try {\n await this.client.send(\n new HeadObjectCommand({\n Bucket: bucket,\n Key: fileId,\n }),\n );\n return true;\n } catch (error) {\n if (this.isNotFoundError(error)) {\n return false;\n }\n throw error;\n }\n }\n\n public async delete(bucketName: string, fileId: string): Promise<void> {\n this.log.trace(`Deleting file '${fileId}' from bucket '${bucketName}'...`);\n\n const bucket = this.convertName(bucketName);\n\n try {\n await this.client.send(\n new DeleteObjectCommand({\n Bucket: bucket,\n Key: fileId,\n }),\n );\n } catch (error) {\n this.log.error(`Failed to delete file: ${error}`);\n if (error instanceof Error) {\n throw new FileNotFoundError(\"Error deleting file\", { cause: error });\n }\n throw error;\n }\n }\n\n protected isNotFoundError(error: unknown): boolean {\n if (error instanceof Error) {\n const name = error.name;\n // Check error name for S3 not found errors\n if (\n name === \"NotFound\" ||\n name === \"NoSuchKey\" ||\n name === \"NoSuchBucket\"\n ) {\n return true;\n }\n // Check HTTP status code for 404\n const metadata = (error as S3ServiceException).$metadata;\n if (metadata?.httpStatusCode === 404) {\n return true;\n }\n }\n return false;\n }\n}\n","import { $module } from \"alepha\";\nimport { AlephaBucket, FileStorageProvider } from \"alepha/bucket\";\nimport { S3FileStorageProvider } from \"./providers/S3FileStorageProvider.ts\";\n\nexport * from \"./providers/S3FileStorageProvider.ts\";\n\n// ---------------------------------------------------------------------------------------------------------------------\n\n/**\n * Plugin for Alepha Bucket that provides S3-compatible storage capabilities.\n *\n * Works with AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces, and other S3-compatible services.\n *\n * @see {@link S3FileStorageProvider}\n * @module alepha.bucket.s3\n */\nexport const AlephaBucketS3 = $module({\n name: \"alepha.bucket.s3\",\n services: [S3FileStorageProvider],\n register: (alepha) =>\n alepha\n .with({\n optional: true,\n provide: FileStorageProvider,\n use: S3FileStorageProvider,\n })\n .with(AlephaBucket),\n});\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/providers/S3FileStorageProvider.ts","../src/index.ts"],"sourcesContent":["import type { Readable } from \"node:stream\";\nimport {\n CreateBucketCommand,\n DeleteObjectCommand,\n GetObjectCommand,\n HeadBucketCommand,\n HeadObjectCommand,\n PutObjectCommand,\n S3Client,\n type S3ServiceException,\n} from \"@aws-sdk/client-s3\";\nimport {\n $env,\n $hook,\n $inject,\n Alepha,\n AlephaError,\n type FileLike,\n type Static,\n t,\n} from \"alepha\";\nimport {\n $bucket,\n FileNotFoundError,\n type FileStorageProvider,\n} from \"alepha/bucket\";\nimport { FileDetector, FileSystemProvider } from \"alepha/file\";\nimport { $logger } from \"alepha/logger\";\n\nconst envSchema = t.object({\n /**\n * Custom S3 endpoint URL for S3-compatible services.\n *\n * Examples:\n * - Cloudflare R2: https://<account-id>.r2.cloudflarestorage.com\n * - MinIO: http://localhost:9000\n * - DigitalOcean Spaces: https://<region>.digitaloceanspaces.com\n *\n * Leave empty for AWS S3.\n */\n S3_ENDPOINT: t.optional(t.string()),\n\n /**\n * AWS region or \"auto\" for R2.\n *\n * @default \"us-east-1\"\n */\n S3_REGION: t.optional(t.string()),\n\n /**\n * Access key ID for S3 authentication.\n */\n S3_ACCESS_KEY_ID: t.string(),\n\n /**\n * Secret access key for S3 authentication.\n */\n S3_SECRET_ACCESS_KEY: t.string(),\n\n /**\n * Force path-style URLs (required for MinIO and some S3-compatible services).\n * Set to \"true\" to enable.\n */\n S3_FORCE_PATH_STYLE: t.optional(t.string()),\n});\n\ndeclare module \"alepha\" {\n interface Env extends Partial<Static<typeof envSchema>> {}\n}\n\n/**\n * S3-compatible storage implementation of File Storage Provider.\n *\n * Works with AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces, and other S3-compatible services.\n */\nexport class S3FileStorageProvider implements FileStorageProvider {\n protected readonly log = $logger();\n protected readonly env = $env(envSchema);\n protected readonly alepha = $inject(Alepha);\n protected readonly fileSystem = $inject(FileSystemProvider);\n protected readonly fileDetector = $inject(FileDetector);\n protected readonly buckets: Set<string> = new Set();\n\n protected readonly client: S3Client;\n\n constructor() {\n this.client = new S3Client({\n endpoint: this.env.S3_ENDPOINT || undefined,\n region: this.env.S3_REGION || \"us-east-1\",\n credentials: {\n accessKeyId: this.env.S3_ACCESS_KEY_ID,\n secretAccessKey: this.env.S3_SECRET_ACCESS_KEY,\n },\n forcePathStyle: this.env.S3_FORCE_PATH_STYLE === \"true\",\n });\n }\n\n protected readonly onStart = $hook({\n on: \"start\",\n handler: async () => {\n for (const bucket of this.alepha.primitives($bucket)) {\n if (bucket.provider !== this) {\n continue;\n }\n\n const bucketName = this.convertName(bucket.name);\n\n this.log.debug(`Preparing S3 bucket '${bucketName}'...`);\n\n // Check if bucket exists, create if not\n try {\n await this.client.send(new HeadBucketCommand({ Bucket: bucketName }));\n } catch (error) {\n if (this.isNotFoundError(error)) {\n this.log.debug(`Creating S3 bucket '${bucketName}'...`);\n await this.client.send(\n new CreateBucketCommand({ Bucket: bucketName }),\n );\n } else {\n throw error;\n }\n }\n\n this.buckets.add(bucketName);\n this.log.info(`S3 bucket '${bucket.name}' OK`);\n }\n },\n });\n\n /**\n * Convert bucket name to S3-compatible format.\n * S3 bucket names must be lowercase, 3-63 characters, no underscores.\n */\n public convertName(name: string): string {\n return name.replaceAll(\"/\", \"-\").replaceAll(\"_\", \"-\").toLowerCase();\n }\n\n protected createId(mimeType: string): string {\n const ext = this.fileDetector.getExtensionFromMimeType(mimeType);\n return `${crypto.randomUUID()}.${ext}`;\n }\n\n public async upload(\n bucketName: string,\n file: FileLike,\n fileId?: string,\n ): Promise<string> {\n fileId ??= this.createId(file.type);\n\n this.log.trace(\n `Uploading file '${file.name}' to bucket '${bucketName}' with id '${fileId}'...`,\n );\n\n const bucket = this.convertName(bucketName);\n\n try {\n const body = Buffer.from(await file.arrayBuffer());\n\n await this.client.send(\n new PutObjectCommand({\n Bucket: bucket,\n Key: fileId,\n Body: body,\n ContentType: file.type || \"application/octet-stream\",\n ContentLength: file.size,\n Metadata: {\n name: encodeURIComponent(file.name),\n },\n }),\n );\n\n this.log.trace(`File uploaded successfully: ${fileId}`);\n return fileId;\n } catch (error) {\n this.log.error(`Failed to upload file: ${error}`);\n if (error instanceof Error) {\n throw new AlephaError(`Upload failed: ${error.message}`, {\n cause: error,\n });\n }\n throw error;\n }\n }\n\n public async download(bucketName: string, fileId: string): Promise<FileLike> {\n this.log.trace(\n `Downloading file '${fileId}' from bucket '${bucketName}'...`,\n );\n\n const bucket = this.convertName(bucketName);\n\n try {\n const response = await this.client.send(\n new GetObjectCommand({\n Bucket: bucket,\n Key: fileId,\n }),\n );\n\n if (!response.Body) {\n throw new FileNotFoundError(\"File not found - empty response body\");\n }\n\n // Convert the stream to a buffer\n const stream = response.Body as Readable;\n const chunks: Buffer[] = [];\n for await (const chunk of stream) {\n chunks.push(Buffer.from(chunk));\n }\n const buffer = Buffer.concat(chunks);\n\n const mimeType =\n response.ContentType || this.fileDetector.getContentType(fileId);\n\n const name = response.Metadata?.name\n ? decodeURIComponent(response.Metadata.name)\n : fileId;\n\n return this.fileSystem.createFile({\n buffer,\n name,\n type: mimeType,\n size: response.ContentLength,\n });\n } catch (error) {\n if (this.isNotFoundError(error)) {\n throw new FileNotFoundError(\n `File '${fileId}' not found in bucket '${bucketName}'`,\n );\n }\n\n this.log.error(`Failed to download file: ${error}`);\n if (error instanceof Error) {\n throw new FileNotFoundError(\"Error downloading file\", { cause: error });\n }\n throw error;\n }\n }\n\n public async exists(bucketName: string, fileId: string): Promise<boolean> {\n this.log.trace(\n `Checking existence of file '${fileId}' in bucket '${bucketName}'...`,\n );\n\n const bucket = this.convertName(bucketName);\n\n try {\n await this.client.send(\n new HeadObjectCommand({\n Bucket: bucket,\n Key: fileId,\n }),\n );\n return true;\n } catch (error) {\n if (this.isNotFoundError(error)) {\n return false;\n }\n throw error;\n }\n }\n\n public async delete(bucketName: string, fileId: string): Promise<void> {\n this.log.trace(`Deleting file '${fileId}' from bucket '${bucketName}'...`);\n\n const bucket = this.convertName(bucketName);\n\n try {\n await this.client.send(\n new DeleteObjectCommand({\n Bucket: bucket,\n Key: fileId,\n }),\n );\n } catch (error) {\n this.log.error(`Failed to delete file: ${error}`);\n if (error instanceof Error) {\n throw new FileNotFoundError(\"Error deleting file\", { cause: error });\n }\n throw error;\n }\n }\n\n protected isNotFoundError(error: unknown): boolean {\n if (error instanceof Error) {\n const name = error.name;\n // Check error name for S3 not found errors\n if (\n name === \"NotFound\" ||\n name === \"NoSuchKey\" ||\n name === \"NoSuchBucket\"\n ) {\n return true;\n }\n // Check HTTP status code for 404\n const metadata = (error as S3ServiceException).$metadata;\n if (metadata?.httpStatusCode === 404) {\n return true;\n }\n }\n return false;\n }\n}\n","import { $module } from \"alepha\";\nimport { AlephaBucket, FileStorageProvider } from \"alepha/bucket\";\nimport { S3FileStorageProvider } from \"./providers/S3FileStorageProvider.ts\";\n\nexport * from \"./providers/S3FileStorageProvider.ts\";\n\n// ---------------------------------------------------------------------------------------------------------------------\n\n/**\n * Plugin for Alepha Bucket that provides S3-compatible storage capabilities.\n *\n * Works with AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces, and other S3-compatible services.\n *\n * @see {@link S3FileStorageProvider}\n * @module alepha.bucket.s3\n */\nexport const AlephaBucketS3 = $module({\n name: \"alepha.bucket.s3\",\n services: [S3FileStorageProvider],\n register: (alepha) =>\n alepha\n .with({\n optional: true,\n provide: FileStorageProvider,\n use: S3FileStorageProvider,\n })\n .with(AlephaBucket),\n});\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"minio"
|
|
14
14
|
],
|
|
15
15
|
"author": "Nicolas Foures",
|
|
16
|
-
"version": "0.14.
|
|
16
|
+
"version": "0.14.3",
|
|
17
17
|
"type": "module",
|
|
18
18
|
"engines": {
|
|
19
19
|
"node": ">=22.0.0"
|
|
@@ -26,17 +26,17 @@
|
|
|
26
26
|
"src"
|
|
27
27
|
],
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@aws-sdk/client-s3": "^3.
|
|
29
|
+
"@aws-sdk/client-s3": "^3.965.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@biomejs/biome": "^2.3.
|
|
33
|
-
"alepha": "0.14.
|
|
34
|
-
"tsdown": "^0.
|
|
32
|
+
"@biomejs/biome": "^2.3.11",
|
|
33
|
+
"alepha": "0.14.3",
|
|
34
|
+
"tsdown": "^0.19.0-beta.5",
|
|
35
35
|
"typescript": "^5.9.3",
|
|
36
36
|
"vitest": "^4.0.16"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
|
-
"alepha": "0.14.
|
|
39
|
+
"alepha": "0.14.3"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
42
|
"lint": "alepha lint",
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Alepha } from "alepha";
|
|
2
|
+
import { describe, test } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
TestApp,
|
|
5
|
+
testCustomFileId,
|
|
6
|
+
testDeleteFile,
|
|
7
|
+
testDeleteNonExistentFile,
|
|
8
|
+
testDownloadAndMetadata,
|
|
9
|
+
testEmptyFiles,
|
|
10
|
+
testFileExistence,
|
|
11
|
+
testFileStream,
|
|
12
|
+
testNonExistentFile,
|
|
13
|
+
testNonExistentFileError,
|
|
14
|
+
testUploadAndExistence,
|
|
15
|
+
testUploadIntoBuckets,
|
|
16
|
+
} from "../../../alepha/src/bucket/__tests__/shared.ts";
|
|
17
|
+
import { AlephaBucketS3, S3FileStorageProvider } from "../index.ts";
|
|
18
|
+
|
|
19
|
+
const alepha = Alepha.create().with(AlephaBucketS3).with(TestApp);
|
|
20
|
+
|
|
21
|
+
const provider = alepha.inject(S3FileStorageProvider);
|
|
22
|
+
|
|
23
|
+
describe("S3FileStorageProvider", () => {
|
|
24
|
+
test("should upload a file and return a fileId", async () => {
|
|
25
|
+
await testUploadAndExistence(provider);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("should download a file and restore its metadata", async () => {
|
|
29
|
+
await testDownloadAndMetadata(provider);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("exists() should return false for a non-existent file", async () => {
|
|
33
|
+
await testNonExistentFile(provider);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("exists() should return true for an existing file", async () => {
|
|
37
|
+
await testFileExistence(provider);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("should delete a file", async () => {
|
|
41
|
+
await testDeleteFile(provider);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("delete() should not throw for a non-existent file", async () => {
|
|
45
|
+
await testDeleteNonExistentFile(provider);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("download() should throw FileNotFoundError for a non-existent file", async () => {
|
|
49
|
+
await testNonExistentFileError(provider);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("should handle uploading to different buckets", async () => {
|
|
53
|
+
await testUploadIntoBuckets(provider);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("should handle empty files correctly", async () => {
|
|
57
|
+
await testEmptyFiles(provider);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("should be able to upload with a specific fileId", async () => {
|
|
61
|
+
await testCustomFileId(provider);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("should be able to upload, stream with metadata", async () => {
|
|
65
|
+
await testFileStream(provider);
|
|
66
|
+
});
|
|
67
|
+
});
|