@veloxts/storage 0.6.91 → 0.6.92
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/drivers/local.js +31 -2
- package/dist/drivers/s3.js +79 -26
- package/dist/errors.d.ts +13 -0
- package/dist/errors.js +18 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/manager.d.ts +53 -1
- package/dist/manager.js +3 -0
- package/dist/plugin.js +2 -0
- package/dist/providers/index.d.ts +30 -0
- package/dist/providers/index.js +32 -0
- package/dist/providers/minio.d.ts +74 -0
- package/dist/providers/minio.js +68 -0
- package/dist/providers/r2.d.ts +75 -0
- package/dist/providers/r2.js +73 -0
- package/dist/testing/index.d.ts +20 -0
- package/dist/testing/index.js +20 -0
- package/dist/testing/provider-compliance.d.ts +72 -0
- package/dist/testing/provider-compliance.js +365 -0
- package/dist/types.d.ts +45 -2
- package/package.json +28 -3
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare R2 Storage Provider
|
|
3
|
+
*
|
|
4
|
+
* Factory function that creates an S3-compatible storage store configured
|
|
5
|
+
* for Cloudflare R2.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import type { StorageStore } from '../types.js';
|
|
9
|
+
/**
|
|
10
|
+
* Cloudflare R2 configuration schema.
|
|
11
|
+
*/
|
|
12
|
+
declare const R2ConfigSchema: z.ZodObject<{
|
|
13
|
+
/** R2 bucket name */
|
|
14
|
+
bucket: z.ZodString;
|
|
15
|
+
/** Cloudflare account ID */
|
|
16
|
+
accountId: z.ZodString;
|
|
17
|
+
/** R2 API token access key ID */
|
|
18
|
+
accessKeyId: z.ZodString;
|
|
19
|
+
/** R2 API token secret access key */
|
|
20
|
+
secretAccessKey: z.ZodString;
|
|
21
|
+
/** Custom domain / CDN URL for public objects */
|
|
22
|
+
publicUrl: z.ZodOptional<z.ZodString>;
|
|
23
|
+
/** Regional jurisdiction (affects endpoint URL) */
|
|
24
|
+
jurisdiction: z.ZodOptional<z.ZodEnum<["eu", "fedramp"]>>;
|
|
25
|
+
/** Key prefix for all operations */
|
|
26
|
+
prefix: z.ZodOptional<z.ZodString>;
|
|
27
|
+
}, "strip", z.ZodTypeAny, {
|
|
28
|
+
bucket: string;
|
|
29
|
+
accessKeyId: string;
|
|
30
|
+
secretAccessKey: string;
|
|
31
|
+
accountId: string;
|
|
32
|
+
prefix?: string | undefined;
|
|
33
|
+
publicUrl?: string | undefined;
|
|
34
|
+
jurisdiction?: "eu" | "fedramp" | undefined;
|
|
35
|
+
}, {
|
|
36
|
+
bucket: string;
|
|
37
|
+
accessKeyId: string;
|
|
38
|
+
secretAccessKey: string;
|
|
39
|
+
accountId: string;
|
|
40
|
+
prefix?: string | undefined;
|
|
41
|
+
publicUrl?: string | undefined;
|
|
42
|
+
jurisdiction?: "eu" | "fedramp" | undefined;
|
|
43
|
+
}>;
|
|
44
|
+
/**
|
|
45
|
+
* Cloudflare R2 configuration options.
|
|
46
|
+
*/
|
|
47
|
+
export type R2Config = z.infer<typeof R2ConfigSchema>;
|
|
48
|
+
/**
|
|
49
|
+
* Create a Cloudflare R2 storage store.
|
|
50
|
+
*
|
|
51
|
+
* @param config - R2 configuration
|
|
52
|
+
* @returns Storage store implementation
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```typescript
|
|
56
|
+
* import { r2 } from '@veloxts/storage/providers/r2';
|
|
57
|
+
*
|
|
58
|
+
* const storage = await r2({
|
|
59
|
+
* bucket: 'my-bucket',
|
|
60
|
+
* accountId: process.env.CF_ACCOUNT_ID!,
|
|
61
|
+
* accessKeyId: process.env.R2_ACCESS_KEY_ID!,
|
|
62
|
+
* secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
|
|
63
|
+
* publicUrl: 'https://cdn.example.com',
|
|
64
|
+
* });
|
|
65
|
+
*
|
|
66
|
+
* await storage.init();
|
|
67
|
+
* await storage.put('uploads/file.txt', 'Hello World');
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export declare function r2(config: R2Config): Promise<StorageStore>;
|
|
71
|
+
/**
|
|
72
|
+
* R2 provider name identifier.
|
|
73
|
+
*/
|
|
74
|
+
export declare const PROVIDER_NAME: "r2";
|
|
75
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare R2 Storage Provider
|
|
3
|
+
*
|
|
4
|
+
* Factory function that creates an S3-compatible storage store configured
|
|
5
|
+
* for Cloudflare R2.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { createS3Store } from '../drivers/s3.js';
|
|
9
|
+
/**
|
|
10
|
+
* Cloudflare R2 configuration schema.
|
|
11
|
+
*/
|
|
12
|
+
const R2ConfigSchema = z.object({
|
|
13
|
+
/** R2 bucket name */
|
|
14
|
+
bucket: z.string().min(1, 'Bucket name is required'),
|
|
15
|
+
/** Cloudflare account ID */
|
|
16
|
+
accountId: z.string().min(1, 'Account ID is required'),
|
|
17
|
+
/** R2 API token access key ID */
|
|
18
|
+
accessKeyId: z.string().min(1, 'Access key ID is required'),
|
|
19
|
+
/** R2 API token secret access key */
|
|
20
|
+
secretAccessKey: z.string().min(1, 'Secret access key is required'),
|
|
21
|
+
/** Custom domain / CDN URL for public objects */
|
|
22
|
+
publicUrl: z.string().url().optional(),
|
|
23
|
+
/** Regional jurisdiction (affects endpoint URL) */
|
|
24
|
+
jurisdiction: z.enum(['eu', 'fedramp']).optional(),
|
|
25
|
+
/** Key prefix for all operations */
|
|
26
|
+
prefix: z.string().optional(),
|
|
27
|
+
});
|
|
28
|
+
/**
|
|
29
|
+
* Create a Cloudflare R2 storage store.
|
|
30
|
+
*
|
|
31
|
+
* @param config - R2 configuration
|
|
32
|
+
* @returns Storage store implementation
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```typescript
|
|
36
|
+
* import { r2 } from '@veloxts/storage/providers/r2';
|
|
37
|
+
*
|
|
38
|
+
* const storage = await r2({
|
|
39
|
+
* bucket: 'my-bucket',
|
|
40
|
+
* accountId: process.env.CF_ACCOUNT_ID!,
|
|
41
|
+
* accessKeyId: process.env.R2_ACCESS_KEY_ID!,
|
|
42
|
+
* secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
|
|
43
|
+
* publicUrl: 'https://cdn.example.com',
|
|
44
|
+
* });
|
|
45
|
+
*
|
|
46
|
+
* await storage.init();
|
|
47
|
+
* await storage.put('uploads/file.txt', 'Hello World');
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export async function r2(config) {
|
|
51
|
+
// Validate configuration with Zod
|
|
52
|
+
const validated = R2ConfigSchema.parse(config);
|
|
53
|
+
// Build R2 endpoint URL
|
|
54
|
+
// Format: https://{accountId}[.{jurisdiction}].r2.cloudflarestorage.com
|
|
55
|
+
const jurisdictionPart = validated.jurisdiction ? `.${validated.jurisdiction}` : '';
|
|
56
|
+
const endpoint = `https://${validated.accountId}${jurisdictionPart}.r2.cloudflarestorage.com`;
|
|
57
|
+
// Create S3-compatible store with R2-specific configuration
|
|
58
|
+
return createS3Store({
|
|
59
|
+
driver: 's3',
|
|
60
|
+
bucket: validated.bucket,
|
|
61
|
+
region: 'auto', // R2 always uses 'auto' region
|
|
62
|
+
endpoint,
|
|
63
|
+
accessKeyId: validated.accessKeyId,
|
|
64
|
+
secretAccessKey: validated.secretAccessKey,
|
|
65
|
+
forcePathStyle: false, // R2 uses virtual-hosted style URLs
|
|
66
|
+
prefix: validated.prefix,
|
|
67
|
+
publicUrl: validated.publicUrl,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* R2 provider name identifier.
|
|
72
|
+
*/
|
|
73
|
+
export const PROVIDER_NAME = 'r2';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @veloxts/storage Testing Utilities
|
|
3
|
+
*
|
|
4
|
+
* This module exports testing utilities for storage providers.
|
|
5
|
+
* Use these to test custom provider implementations or verify
|
|
6
|
+
* third-party providers conform to the StorageStore interface.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { runProviderTests } from '@veloxts/storage/testing';
|
|
11
|
+
* import { createLocalStore } from '@veloxts/storage/drivers/local';
|
|
12
|
+
*
|
|
13
|
+
* runProviderTests('Local', () =>
|
|
14
|
+
* createLocalStore({ driver: 'local', root: '/tmp/test-storage' })
|
|
15
|
+
* );
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* @packageDocumentation
|
|
19
|
+
*/
|
|
20
|
+
export { type ProviderTestOptions, runProviderTests } from './provider-compliance.js';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @veloxts/storage Testing Utilities
|
|
3
|
+
*
|
|
4
|
+
* This module exports testing utilities for storage providers.
|
|
5
|
+
* Use these to test custom provider implementations or verify
|
|
6
|
+
* third-party providers conform to the StorageStore interface.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { runProviderTests } from '@veloxts/storage/testing';
|
|
11
|
+
* import { createLocalStore } from '@veloxts/storage/drivers/local';
|
|
12
|
+
*
|
|
13
|
+
* runProviderTests('Local', () =>
|
|
14
|
+
* createLocalStore({ driver: 'local', root: '/tmp/test-storage' })
|
|
15
|
+
* );
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* @packageDocumentation
|
|
19
|
+
*/
|
|
20
|
+
export { runProviderTests } from './provider-compliance.js';
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage Provider Compliance Test Suite
|
|
3
|
+
*
|
|
4
|
+
* This module exports a test helper that verifies a StorageStore implementation
|
|
5
|
+
* conforms to the expected interface contract. Use this to test custom providers
|
|
6
|
+
* or verify third-party provider implementations.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { runProviderTests } from '@veloxts/storage/testing';
|
|
11
|
+
* import { createLocalStore } from '@veloxts/storage/drivers/local';
|
|
12
|
+
*
|
|
13
|
+
* runProviderTests('Local', () =>
|
|
14
|
+
* createLocalStore({ driver: 'local', root: '/tmp/test-storage' })
|
|
15
|
+
* );
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
import type { StorageStore } from '../types.js';
|
|
19
|
+
/**
|
|
20
|
+
* Options for the provider compliance test suite.
|
|
21
|
+
*/
|
|
22
|
+
export interface ProviderTestOptions {
|
|
23
|
+
/**
|
|
24
|
+
* Skip presigned URL tests (for providers that don't support them).
|
|
25
|
+
* Default: false
|
|
26
|
+
*/
|
|
27
|
+
skipPresignedUrls?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Skip init/destroy lifecycle tests.
|
|
30
|
+
* Default: false
|
|
31
|
+
*/
|
|
32
|
+
skipLifecycle?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Timeout for async operations in milliseconds.
|
|
35
|
+
* Default: 5000
|
|
36
|
+
*/
|
|
37
|
+
timeout?: number;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Run the provider compliance test suite against a StorageStore implementation.
|
|
41
|
+
*
|
|
42
|
+
* This function runs a comprehensive set of tests to verify that a storage provider
|
|
43
|
+
* correctly implements the StorageStore interface. It covers:
|
|
44
|
+
*
|
|
45
|
+
* - Basic CRUD operations (put, get, delete, deleteMany)
|
|
46
|
+
* - Metadata operations (metadata, head, exists)
|
|
47
|
+
* - Listing files with pagination
|
|
48
|
+
* - Presigned URLs (download and upload)
|
|
49
|
+
* - Copy and move operations
|
|
50
|
+
* - Lifecycle hooks (init, close)
|
|
51
|
+
*
|
|
52
|
+
* @param name - Name of the provider being tested (for test output)
|
|
53
|
+
* @param createProvider - Factory function that creates a new provider instance
|
|
54
|
+
* @param options - Optional test configuration
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```typescript
|
|
58
|
+
* // Test the local driver
|
|
59
|
+
* runProviderTests('Local', () =>
|
|
60
|
+
* createLocalStore({ driver: 'local', root: '/tmp/test-storage' })
|
|
61
|
+
* );
|
|
62
|
+
*
|
|
63
|
+
* // Test with options
|
|
64
|
+
* runProviderTests('MinIO', async () => minio({
|
|
65
|
+
* bucket: 'test',
|
|
66
|
+
* endpoint: 'http://localhost:9000',
|
|
67
|
+
* accessKeyId: 'minioadmin',
|
|
68
|
+
* secretAccessKey: 'minioadmin',
|
|
69
|
+
* }), { timeout: 10000 });
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export declare function runProviderTests(name: string, createProvider: () => StorageStore | Promise<StorageStore>, options?: ProviderTestOptions): void;
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage Provider Compliance Test Suite
|
|
3
|
+
*
|
|
4
|
+
* This module exports a test helper that verifies a StorageStore implementation
|
|
5
|
+
* conforms to the expected interface contract. Use this to test custom providers
|
|
6
|
+
* or verify third-party provider implementations.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { runProviderTests } from '@veloxts/storage/testing';
|
|
11
|
+
* import { createLocalStore } from '@veloxts/storage/drivers/local';
|
|
12
|
+
*
|
|
13
|
+
* runProviderTests('Local', () =>
|
|
14
|
+
* createLocalStore({ driver: 'local', root: '/tmp/test-storage' })
|
|
15
|
+
* );
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
19
|
+
import { StorageObjectNotFoundError } from '../errors.js';
|
|
20
|
+
/**
|
|
21
|
+
* Run the provider compliance test suite against a StorageStore implementation.
|
|
22
|
+
*
|
|
23
|
+
* This function runs a comprehensive set of tests to verify that a storage provider
|
|
24
|
+
* correctly implements the StorageStore interface. It covers:
|
|
25
|
+
*
|
|
26
|
+
* - Basic CRUD operations (put, get, delete, deleteMany)
|
|
27
|
+
* - Metadata operations (metadata, head, exists)
|
|
28
|
+
* - Listing files with pagination
|
|
29
|
+
* - Presigned URLs (download and upload)
|
|
30
|
+
* - Copy and move operations
|
|
31
|
+
* - Lifecycle hooks (init, close)
|
|
32
|
+
*
|
|
33
|
+
* @param name - Name of the provider being tested (for test output)
|
|
34
|
+
* @param createProvider - Factory function that creates a new provider instance
|
|
35
|
+
* @param options - Optional test configuration
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```typescript
|
|
39
|
+
* // Test the local driver
|
|
40
|
+
* runProviderTests('Local', () =>
|
|
41
|
+
* createLocalStore({ driver: 'local', root: '/tmp/test-storage' })
|
|
42
|
+
* );
|
|
43
|
+
*
|
|
44
|
+
* // Test with options
|
|
45
|
+
* runProviderTests('MinIO', async () => minio({
|
|
46
|
+
* bucket: 'test',
|
|
47
|
+
* endpoint: 'http://localhost:9000',
|
|
48
|
+
* accessKeyId: 'minioadmin',
|
|
49
|
+
* secretAccessKey: 'minioadmin',
|
|
50
|
+
* }), { timeout: 10000 });
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function runProviderTests(name, createProvider, options = {}) {
|
|
54
|
+
const { skipPresignedUrls = false, skipLifecycle = false, timeout = 5000 } = options;
|
|
55
|
+
describe(`StorageProvider compliance: ${name}`, { timeout }, () => {
|
|
56
|
+
let provider;
|
|
57
|
+
const testPrefix = `compliance-test-${Date.now()}`;
|
|
58
|
+
// Helper to generate unique test keys
|
|
59
|
+
const testKey = (suffix) => `${testPrefix}/${suffix}`;
|
|
60
|
+
// Helper to clean up test files
|
|
61
|
+
const cleanup = async () => {
|
|
62
|
+
try {
|
|
63
|
+
const result = await provider.list(testPrefix, { recursive: true });
|
|
64
|
+
if (result.files.length > 0) {
|
|
65
|
+
await provider.deleteMany(result.files.map((f) => f.path));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Ignore cleanup errors
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
beforeAll(async () => {
|
|
73
|
+
provider = await createProvider();
|
|
74
|
+
if (!skipLifecycle && provider.init) {
|
|
75
|
+
await provider.init();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
afterAll(async () => {
|
|
79
|
+
await cleanup();
|
|
80
|
+
await provider.close();
|
|
81
|
+
});
|
|
82
|
+
beforeEach(async () => {
|
|
83
|
+
// Clean state before each test
|
|
84
|
+
});
|
|
85
|
+
afterEach(async () => {
|
|
86
|
+
// Clean up any test files created during the test
|
|
87
|
+
});
|
|
88
|
+
// =========================================================================
|
|
89
|
+
// Basic Operations
|
|
90
|
+
// =========================================================================
|
|
91
|
+
describe('put/get operations', () => {
|
|
92
|
+
it('should put and get a buffer', async () => {
|
|
93
|
+
const key = testKey('buffer-test.txt');
|
|
94
|
+
const content = Buffer.from('Hello, World!');
|
|
95
|
+
const path = await provider.put(key, content, { contentType: 'text/plain' });
|
|
96
|
+
expect(path).toBe(key);
|
|
97
|
+
const result = await provider.get(key);
|
|
98
|
+
expect(result).not.toBeNull();
|
|
99
|
+
expect(result?.toString()).toBe('Hello, World!');
|
|
100
|
+
});
|
|
101
|
+
it('should put and get a string', async () => {
|
|
102
|
+
const key = testKey('string-test.txt');
|
|
103
|
+
const content = 'String content here';
|
|
104
|
+
await provider.put(key, content, { contentType: 'text/plain' });
|
|
105
|
+
const result = await provider.get(key);
|
|
106
|
+
expect(result).not.toBeNull();
|
|
107
|
+
expect(result?.toString()).toBe('String content here');
|
|
108
|
+
});
|
|
109
|
+
it('should return null for non-existent key', async () => {
|
|
110
|
+
const result = await provider.get(testKey('does-not-exist.txt'));
|
|
111
|
+
expect(result).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
it('should overwrite existing file', async () => {
|
|
114
|
+
const key = testKey('overwrite-test.txt');
|
|
115
|
+
await provider.put(key, 'First content');
|
|
116
|
+
await provider.put(key, 'Second content');
|
|
117
|
+
const result = await provider.get(key);
|
|
118
|
+
expect(result?.toString()).toBe('Second content');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
// =========================================================================
|
|
122
|
+
// Metadata Operations
|
|
123
|
+
// =========================================================================
|
|
124
|
+
describe('metadata operations', () => {
|
|
125
|
+
it('should return metadata for existing file', async () => {
|
|
126
|
+
const key = testKey('metadata-test.txt');
|
|
127
|
+
const content = 'Test content for metadata';
|
|
128
|
+
await provider.put(key, content, { contentType: 'text/plain' });
|
|
129
|
+
const meta = await provider.metadata(key);
|
|
130
|
+
expect(meta).not.toBeNull();
|
|
131
|
+
expect(meta?.path).toBe(key);
|
|
132
|
+
expect(meta?.size).toBe(content.length);
|
|
133
|
+
expect(meta?.lastModified).toBeInstanceOf(Date);
|
|
134
|
+
});
|
|
135
|
+
it('should return null for non-existent file', async () => {
|
|
136
|
+
const meta = await provider.metadata(testKey('metadata-not-found.txt'));
|
|
137
|
+
expect(meta).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
describe('head() method', () => {
|
|
141
|
+
it('should return metadata for existing file', async () => {
|
|
142
|
+
const key = testKey('head-test.txt');
|
|
143
|
+
const content = 'Test content for head';
|
|
144
|
+
await provider.put(key, content, { contentType: 'text/plain' });
|
|
145
|
+
const meta = await provider.head(key);
|
|
146
|
+
expect(meta.path).toBe(key);
|
|
147
|
+
expect(meta.size).toBe(content.length);
|
|
148
|
+
expect(meta.lastModified).toBeInstanceOf(Date);
|
|
149
|
+
});
|
|
150
|
+
it('should throw StorageObjectNotFoundError for non-existent file', async () => {
|
|
151
|
+
const key = testKey('head-not-found.txt');
|
|
152
|
+
await expect(provider.head(key)).rejects.toThrow(StorageObjectNotFoundError);
|
|
153
|
+
await expect(provider.head(key)).rejects.toMatchObject({ key });
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
describe('exists() method', () => {
|
|
157
|
+
it('should return true for existing file', async () => {
|
|
158
|
+
const key = testKey('exists-test.txt');
|
|
159
|
+
await provider.put(key, 'Test content');
|
|
160
|
+
const exists = await provider.exists(key);
|
|
161
|
+
expect(exists).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
it('should return false for non-existent file', async () => {
|
|
164
|
+
const exists = await provider.exists(testKey('exists-not-found.txt'));
|
|
165
|
+
expect(exists).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
// =========================================================================
|
|
169
|
+
// Delete Operations
|
|
170
|
+
// =========================================================================
|
|
171
|
+
describe('delete operations', () => {
|
|
172
|
+
it('should delete an existing file', async () => {
|
|
173
|
+
const key = testKey('delete-test.txt');
|
|
174
|
+
await provider.put(key, 'Delete me');
|
|
175
|
+
const deleted = await provider.delete(key);
|
|
176
|
+
expect(deleted).toBe(true);
|
|
177
|
+
const exists = await provider.exists(key);
|
|
178
|
+
expect(exists).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
it('should return false when deleting non-existent file', async () => {
|
|
181
|
+
const deleted = await provider.delete(testKey('delete-not-found.txt'));
|
|
182
|
+
expect(deleted).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
it('should batch delete multiple files with deleteMany', async () => {
|
|
185
|
+
const keys = [
|
|
186
|
+
testKey('batch-delete-1.txt'),
|
|
187
|
+
testKey('batch-delete-2.txt'),
|
|
188
|
+
testKey('batch-delete-3.txt'),
|
|
189
|
+
];
|
|
190
|
+
// Create files
|
|
191
|
+
await Promise.all(keys.map((key) => provider.put(key, `Content for ${key}`)));
|
|
192
|
+
// Delete them
|
|
193
|
+
const count = await provider.deleteMany(keys);
|
|
194
|
+
expect(count).toBe(3);
|
|
195
|
+
// Verify they're gone
|
|
196
|
+
for (const key of keys) {
|
|
197
|
+
const exists = await provider.exists(key);
|
|
198
|
+
expect(exists).toBe(false);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
it('should handle deleteMany with empty array (no-op)', async () => {
|
|
202
|
+
const count = await provider.deleteMany([]);
|
|
203
|
+
expect(count).toBe(0);
|
|
204
|
+
});
|
|
205
|
+
it('should handle deleteMany with mix of existing and non-existing files', async () => {
|
|
206
|
+
const key = testKey('batch-mixed-1.txt');
|
|
207
|
+
await provider.put(key, 'Content');
|
|
208
|
+
const count = await provider.deleteMany([key, testKey('batch-mixed-not-found.txt')]);
|
|
209
|
+
// Should delete at least the existing file
|
|
210
|
+
expect(count).toBeGreaterThanOrEqual(1);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
// =========================================================================
|
|
214
|
+
// List Operations
|
|
215
|
+
// =========================================================================
|
|
216
|
+
describe('list operations', () => {
|
|
217
|
+
const listPrefix = `${testPrefix}/list-test`;
|
|
218
|
+
beforeAll(async () => {
|
|
219
|
+
// Create some test files for listing
|
|
220
|
+
await provider.put(`${listPrefix}/file1.txt`, 'File 1');
|
|
221
|
+
await provider.put(`${listPrefix}/file2.txt`, 'File 2');
|
|
222
|
+
await provider.put(`${listPrefix}/subdir/file3.txt`, 'File 3');
|
|
223
|
+
});
|
|
224
|
+
it('should list files with prefix', async () => {
|
|
225
|
+
const result = await provider.list(listPrefix);
|
|
226
|
+
expect(result.files.length).toBeGreaterThanOrEqual(2);
|
|
227
|
+
expect(result.hasMore).toBeDefined();
|
|
228
|
+
});
|
|
229
|
+
it('should list files recursively', async () => {
|
|
230
|
+
const result = await provider.list(listPrefix, { recursive: true });
|
|
231
|
+
expect(result.files.length).toBeGreaterThanOrEqual(3);
|
|
232
|
+
});
|
|
233
|
+
it('should respect limit option', async () => {
|
|
234
|
+
const result = await provider.list(listPrefix, { recursive: true, limit: 1 });
|
|
235
|
+
expect(result.files.length).toBe(1);
|
|
236
|
+
});
|
|
237
|
+
it('should return file metadata in list results', async () => {
|
|
238
|
+
const result = await provider.list(listPrefix, { recursive: true });
|
|
239
|
+
for (const file of result.files) {
|
|
240
|
+
expect(file.path).toBeDefined();
|
|
241
|
+
expect(file.size).toBeDefined();
|
|
242
|
+
expect(typeof file.size).toBe('number');
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
// =========================================================================
|
|
247
|
+
// Copy/Move Operations
|
|
248
|
+
// =========================================================================
|
|
249
|
+
describe('copy/move operations', () => {
|
|
250
|
+
it('should copy a file', async () => {
|
|
251
|
+
const source = testKey('copy-source.txt');
|
|
252
|
+
const dest = testKey('copy-dest.txt');
|
|
253
|
+
await provider.put(source, 'Copy me');
|
|
254
|
+
const resultPath = await provider.copy(source, dest);
|
|
255
|
+
expect(resultPath).toBe(dest);
|
|
256
|
+
// Both should exist
|
|
257
|
+
expect(await provider.exists(source)).toBe(true);
|
|
258
|
+
expect(await provider.exists(dest)).toBe(true);
|
|
259
|
+
// Content should match
|
|
260
|
+
const content = await provider.get(dest);
|
|
261
|
+
expect(content?.toString()).toBe('Copy me');
|
|
262
|
+
});
|
|
263
|
+
it('should move a file', async () => {
|
|
264
|
+
const source = testKey('move-source.txt');
|
|
265
|
+
const dest = testKey('move-dest.txt');
|
|
266
|
+
await provider.put(source, 'Move me');
|
|
267
|
+
const resultPath = await provider.move(source, dest);
|
|
268
|
+
expect(resultPath).toBe(dest);
|
|
269
|
+
// Source should be gone, dest should exist
|
|
270
|
+
expect(await provider.exists(source)).toBe(false);
|
|
271
|
+
expect(await provider.exists(dest)).toBe(true);
|
|
272
|
+
// Content should match
|
|
273
|
+
const content = await provider.get(dest);
|
|
274
|
+
expect(content?.toString()).toBe('Move me');
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
// =========================================================================
|
|
278
|
+
// URL Operations
|
|
279
|
+
// =========================================================================
|
|
280
|
+
describe('URL operations', () => {
|
|
281
|
+
it('should generate public URL', async () => {
|
|
282
|
+
const key = testKey('url-test.txt');
|
|
283
|
+
await provider.put(key, 'URL test content');
|
|
284
|
+
const url = await provider.url(key);
|
|
285
|
+
expect(typeof url).toBe('string');
|
|
286
|
+
expect(url.length).toBeGreaterThan(0);
|
|
287
|
+
});
|
|
288
|
+
if (!skipPresignedUrls) {
|
|
289
|
+
it('should generate presigned download URL', async () => {
|
|
290
|
+
const key = testKey('signed-download.txt');
|
|
291
|
+
await provider.put(key, 'Signed download content');
|
|
292
|
+
const signedUrl = await provider.signedUrl(key, { expiresIn: 300 });
|
|
293
|
+
expect(typeof signedUrl).toBe('string');
|
|
294
|
+
expect(signedUrl.length).toBeGreaterThan(0);
|
|
295
|
+
});
|
|
296
|
+
it('should generate presigned upload URL', async () => {
|
|
297
|
+
const key = testKey('signed-upload.txt');
|
|
298
|
+
const uploadUrl = await provider.signedUploadUrl({
|
|
299
|
+
key,
|
|
300
|
+
contentType: 'text/plain',
|
|
301
|
+
expiresIn: 300,
|
|
302
|
+
});
|
|
303
|
+
expect(typeof uploadUrl).toBe('string');
|
|
304
|
+
expect(uploadUrl.length).toBeGreaterThan(0);
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
// =========================================================================
|
|
309
|
+
// Stream Operations
|
|
310
|
+
// =========================================================================
|
|
311
|
+
describe('stream operations', () => {
|
|
312
|
+
it('should return readable stream for existing file', async () => {
|
|
313
|
+
const key = testKey('stream-test.txt');
|
|
314
|
+
const content = 'Streamed content';
|
|
315
|
+
await provider.put(key, content);
|
|
316
|
+
const stream = await provider.stream(key);
|
|
317
|
+
expect(stream).not.toBeNull();
|
|
318
|
+
// Read stream to verify content
|
|
319
|
+
if (stream) {
|
|
320
|
+
const chunks = [];
|
|
321
|
+
for await (const chunk of stream) {
|
|
322
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
323
|
+
}
|
|
324
|
+
const result = Buffer.concat(chunks).toString();
|
|
325
|
+
expect(result).toBe(content);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
it('should return null for non-existent file', async () => {
|
|
329
|
+
const stream = await provider.stream(testKey('stream-not-found.txt'));
|
|
330
|
+
expect(stream).toBeNull();
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
// =========================================================================
|
|
334
|
+
// Visibility Operations
|
|
335
|
+
// =========================================================================
|
|
336
|
+
describe('visibility operations', () => {
|
|
337
|
+
it('should set and get visibility', async () => {
|
|
338
|
+
const key = testKey('visibility-test.txt');
|
|
339
|
+
await provider.put(key, 'Visibility test');
|
|
340
|
+
await provider.setVisibility(key, 'public');
|
|
341
|
+
// Note: getVisibility may return null for some providers (like S3)
|
|
342
|
+
// that don't easily expose ACL info
|
|
343
|
+
const visibility = await provider.getVisibility(key);
|
|
344
|
+
// Only check if visibility is returned
|
|
345
|
+
if (visibility !== null) {
|
|
346
|
+
expect(visibility).toBe('public');
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
// =========================================================================
|
|
351
|
+
// Lifecycle
|
|
352
|
+
// =========================================================================
|
|
353
|
+
if (!skipLifecycle) {
|
|
354
|
+
describe('lifecycle', () => {
|
|
355
|
+
it('should have init method (optional)', () => {
|
|
356
|
+
// init is optional, just verify it exists or doesn't
|
|
357
|
+
expect(typeof provider.init === 'function' || provider.init === undefined).toBe(true);
|
|
358
|
+
});
|
|
359
|
+
it('should have close method', () => {
|
|
360
|
+
expect(typeof provider.close).toBe('function');
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
}
|