@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.
@@ -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
+ }