@positronic/cloudflare 0.0.2

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,671 @@
1
+ import {
2
+ env,
3
+ createExecutionContext,
4
+ waitOnExecutionContext,
5
+ } from 'cloudflare:test';
6
+
7
+ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
8
+ import worker from '../src/index';
9
+
10
+ interface TestEnv {
11
+ RESOURCES_BUCKET: R2Bucket;
12
+ NODE_ENV?: string;
13
+ }
14
+
15
+ describe('Resources API Tests', () => {
16
+ const testEnv = env as TestEnv;
17
+
18
+ beforeAll(async () => {
19
+ // Clean up any existing test resources
20
+ try {
21
+ const listed = await testEnv.RESOURCES_BUCKET.list();
22
+ for (const obj of listed.objects) {
23
+ await testEnv.RESOURCES_BUCKET.delete(obj.key);
24
+ }
25
+ } catch (e) {
26
+ // Ignore errors if bucket is empty
27
+ }
28
+ });
29
+
30
+ afterAll(async () => {
31
+ // Clean up after all tests
32
+ try {
33
+ const listed = await testEnv.RESOURCES_BUCKET.list();
34
+ for (const obj of listed.objects) {
35
+ await testEnv.RESOURCES_BUCKET.delete(obj.key);
36
+ }
37
+ } catch (e) {
38
+ // Ignore errors
39
+ }
40
+ });
41
+
42
+ it('POST /resources with path only', async () => {
43
+ const formData = new FormData();
44
+ formData.append(
45
+ 'file',
46
+ new Blob(['Hello from test file'], { type: 'text/plain' }),
47
+ 'test.txt'
48
+ );
49
+ formData.append('type', 'text');
50
+ formData.append('path', 'resources/test-files/test.txt');
51
+ formData.append('local', 'false');
52
+
53
+ const request = new Request('http://example.com/resources', {
54
+ method: 'POST',
55
+ body: formData,
56
+ });
57
+ const context = createExecutionContext();
58
+ const response = await worker.fetch(request, testEnv, context);
59
+ await waitOnExecutionContext(context);
60
+
61
+ expect(response.status).toBe(201);
62
+ const responseBody = await response.json<{
63
+ type: string;
64
+ path: string;
65
+ key: string;
66
+ size: number;
67
+ lastModified: string;
68
+ local: boolean;
69
+ }>();
70
+ expect(responseBody).toEqual({
71
+ type: 'text',
72
+ path: 'resources/test-files/test.txt',
73
+ key: 'resources/test-files/test.txt',
74
+ size: expect.any(Number),
75
+ lastModified: expect.any(String),
76
+ local: false,
77
+ });
78
+
79
+ // Verify the resource was actually stored in R2
80
+ const storedObject = await testEnv.RESOURCES_BUCKET.get(
81
+ 'resources/test-files/test.txt'
82
+ );
83
+ expect(storedObject).not.toBeNull();
84
+ const storedText = await storedObject!.text();
85
+ expect(storedText).toBe('Hello from test file');
86
+ expect(storedObject!.customMetadata).toEqual({
87
+ type: 'text',
88
+ path: 'resources/test-files/test.txt',
89
+ local: 'false',
90
+ });
91
+ });
92
+
93
+ it('POST /resources with key only (no path)', async () => {
94
+ const formData = new FormData();
95
+ const videoContent = new Uint8Array([
96
+ 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70,
97
+ ]); // Mock video header
98
+ formData.append(
99
+ 'file',
100
+ new Blob([videoContent], { type: 'video/mp4' }),
101
+ 'video.mp4'
102
+ );
103
+ formData.append('type', 'binary');
104
+ formData.append('key', 'videos/large-video.mp4');
105
+ formData.append('local', 'false');
106
+
107
+ const request = new Request('http://example.com/resources', {
108
+ method: 'POST',
109
+ body: formData,
110
+ });
111
+ const context = createExecutionContext();
112
+ const response = await worker.fetch(request, testEnv, context);
113
+ await waitOnExecutionContext(context);
114
+
115
+ expect(response.status).toBe(201);
116
+ const responseBody = await response.json<{
117
+ type: string;
118
+ path?: string;
119
+ key: string;
120
+ size: number;
121
+ lastModified: string;
122
+ local: boolean;
123
+ }>();
124
+ expect(responseBody).toEqual({
125
+ type: 'binary',
126
+ key: 'videos/large-video.mp4',
127
+ size: expect.any(Number),
128
+ lastModified: expect.any(String),
129
+ local: false,
130
+ });
131
+ // Should not have path since we didn't provide one
132
+ expect(responseBody.path).toBeUndefined();
133
+
134
+ // Verify the resource was stored without path metadata
135
+ const storedObject = await testEnv.RESOURCES_BUCKET.get(
136
+ 'videos/large-video.mp4'
137
+ );
138
+ expect(storedObject).not.toBeNull();
139
+ // IMPORTANT: Consume the response body to avoid isolated storage issues
140
+ await storedObject!.arrayBuffer();
141
+ expect(storedObject!.customMetadata).toEqual({
142
+ type: 'binary',
143
+ local: 'false',
144
+ });
145
+ });
146
+
147
+ it('POST /resources with both key and path (key takes precedence)', async () => {
148
+ const formData = new FormData();
149
+ formData.append(
150
+ 'file',
151
+ new Blob(['Image data'], { type: 'image/png' }),
152
+ 'logo.png'
153
+ );
154
+ formData.append('type', 'binary');
155
+ formData.append('path', 'resources/images/logo.png');
156
+ formData.append('key', 'assets/branding/logo.png');
157
+ formData.append('local', 'true'); // Testing with local=true
158
+
159
+ const request = new Request('http://example.com/resources', {
160
+ method: 'POST',
161
+ body: formData,
162
+ });
163
+ const context = createExecutionContext();
164
+ const response = await worker.fetch(request, testEnv, context);
165
+ await waitOnExecutionContext(context);
166
+
167
+ expect(response.status).toBe(201);
168
+ const responseBody = await response.json<{
169
+ type: string;
170
+ path: string;
171
+ key: string;
172
+ size: number;
173
+ lastModified: string;
174
+ local: boolean;
175
+ }>();
176
+ expect(responseBody).toEqual({
177
+ type: 'binary',
178
+ path: 'resources/images/logo.png',
179
+ key: 'assets/branding/logo.png',
180
+ size: expect.any(Number),
181
+ lastModified: expect.any(String),
182
+ local: true,
183
+ });
184
+
185
+ // Verify stored at key location, not path location
186
+ const storedAtKey = await testEnv.RESOURCES_BUCKET.get(
187
+ 'assets/branding/logo.png'
188
+ );
189
+ expect(storedAtKey).not.toBeNull();
190
+ // IMPORTANT: Consume the response body
191
+ await storedAtKey!.arrayBuffer();
192
+
193
+ const storedAtPath = await testEnv.RESOURCES_BUCKET.get(
194
+ 'resources/images/logo.png'
195
+ );
196
+ expect(storedAtPath).toBeNull();
197
+ });
198
+
199
+ it('GET /resources lists all resources correctly', async () => {
200
+ // First create a resource to list
201
+ const formData = new FormData();
202
+ formData.append(
203
+ 'file',
204
+ new Blob(['Test content'], { type: 'text/plain' }),
205
+ 'test.txt'
206
+ );
207
+ formData.append('type', 'text');
208
+ formData.append('path', 'resources/list-test.txt');
209
+ formData.append('local', 'true');
210
+
211
+ const createRequest = new Request('http://example.com/resources', {
212
+ method: 'POST',
213
+ body: formData,
214
+ });
215
+ const createContext = createExecutionContext();
216
+ await worker.fetch(createRequest, testEnv, createContext);
217
+ await waitOnExecutionContext(createContext);
218
+
219
+ // Now list resources
220
+ const request = new Request('http://example.com/resources');
221
+ const context = createExecutionContext();
222
+ const response = await worker.fetch(request, testEnv, context);
223
+ await waitOnExecutionContext(context);
224
+
225
+ expect(response.status).toBe(200);
226
+ const responseBody = await response.json<{
227
+ resources: Array<{
228
+ type: string;
229
+ path?: string;
230
+ key: string;
231
+ size: number;
232
+ lastModified: string;
233
+ local: boolean;
234
+ }>;
235
+ truncated: boolean;
236
+ count: number;
237
+ }>();
238
+ expect(responseBody.truncated).toBe(false);
239
+ // Should have 1 resource (the one we just created)
240
+ expect(responseBody.count).toBe(1);
241
+ expect(responseBody.resources).toHaveLength(1);
242
+
243
+ // Check that the resource has the correct structure
244
+ const resource = responseBody.resources[0];
245
+ expect(resource).toEqual({
246
+ type: 'text',
247
+ path: 'resources/list-test.txt',
248
+ key: 'resources/list-test.txt',
249
+ size: expect.any(Number),
250
+ lastModified: expect.any(String),
251
+ local: true,
252
+ });
253
+ });
254
+
255
+ describe('Error cases', () => {
256
+ it('POST /resources without file should return 400 error', async () => {
257
+ const formData = new FormData();
258
+ formData.append('type', 'text');
259
+ formData.append('path', 'resources/test.txt');
260
+
261
+ const request = new Request('http://example.com/resources', {
262
+ method: 'POST',
263
+ body: formData,
264
+ });
265
+ const context = createExecutionContext();
266
+
267
+ const response = await worker.fetch(request, testEnv, context);
268
+ expect(response.status).toBe(400);
269
+
270
+ await waitOnExecutionContext(context);
271
+ });
272
+
273
+ it('POST /resources without type should return 400 error', async () => {
274
+ const formData = new FormData();
275
+ formData.append('file', new Blob(['content']), 'test.txt');
276
+ formData.append('path', 'resources/test.txt');
277
+
278
+ const request = new Request('http://example.com/resources', {
279
+ method: 'POST',
280
+ body: formData,
281
+ });
282
+ const context = createExecutionContext();
283
+
284
+ const response = await worker.fetch(request, testEnv, context);
285
+ expect(response.status).toBe(400);
286
+
287
+ await waitOnExecutionContext(context);
288
+ });
289
+
290
+ it('POST /resources with invalid type should return 400 error', async () => {
291
+ const formData = new FormData();
292
+ formData.append('file', new Blob(['content']), 'test.txt');
293
+ formData.append('type', 'invalid');
294
+ formData.append('path', 'resources/test.txt');
295
+
296
+ const request = new Request('http://example.com/resources', {
297
+ method: 'POST',
298
+ body: formData,
299
+ });
300
+ const context = createExecutionContext();
301
+
302
+ const response = await worker.fetch(request, testEnv, context);
303
+ expect(response.status).toBe(400);
304
+
305
+ await waitOnExecutionContext(context);
306
+ });
307
+
308
+ it('POST /resources without key or path should return 400 error', async () => {
309
+ const formData = new FormData();
310
+ formData.append('file', new Blob(['content']), 'test.txt');
311
+ formData.append('type', 'text');
312
+
313
+ const request = new Request('http://example.com/resources', {
314
+ method: 'POST',
315
+ body: formData,
316
+ });
317
+ const context = createExecutionContext();
318
+
319
+ const response = await worker.fetch(request, testEnv, context);
320
+ expect(response.status).toBe(400);
321
+
322
+ await waitOnExecutionContext(context);
323
+ });
324
+
325
+ it('GET /resources with missing type metadata should return 500 error', async () => {
326
+ // Manually create a resource without type metadata
327
+ await testEnv.RESOURCES_BUCKET.put('bad-resource.txt', 'content', {
328
+ customMetadata: {
329
+ path: 'bad-resource.txt',
330
+ // Missing type
331
+ },
332
+ });
333
+
334
+ const request = new Request('http://example.com/resources');
335
+ const context = createExecutionContext();
336
+
337
+ const response = await worker.fetch(request, testEnv, context);
338
+ expect(response.status).toBe(500);
339
+
340
+ await waitOnExecutionContext(context);
341
+
342
+ // Clean up
343
+ await testEnv.RESOURCES_BUCKET.delete('bad-resource.txt');
344
+ });
345
+ });
346
+
347
+ describe('DELETE /resources/:key', () => {
348
+ it('should delete an existing resource', async () => {
349
+ // First create a resource
350
+ const formData = new FormData();
351
+ formData.append(
352
+ 'file',
353
+ new Blob(['Content to delete'], { type: 'text/plain' }),
354
+ 'delete-test.txt'
355
+ );
356
+ formData.append('type', 'text');
357
+ formData.append('key', 'resources/delete-test.txt');
358
+
359
+ const createRequest = new Request('http://example.com/resources', {
360
+ method: 'POST',
361
+ body: formData,
362
+ });
363
+ const createContext = createExecutionContext();
364
+ const createResponse = await worker.fetch(
365
+ createRequest,
366
+ testEnv,
367
+ createContext
368
+ );
369
+ await waitOnExecutionContext(createContext);
370
+ expect(createResponse.status).toBe(201);
371
+
372
+ // Now delete it
373
+ const deleteRequest = new Request(
374
+ 'http://example.com/resources/' +
375
+ encodeURIComponent('resources/delete-test.txt'),
376
+ { method: 'DELETE' }
377
+ );
378
+ const deleteContext = createExecutionContext();
379
+ const deleteResponse = await worker.fetch(
380
+ deleteRequest,
381
+ testEnv,
382
+ deleteContext
383
+ );
384
+ await waitOnExecutionContext(deleteContext);
385
+
386
+ expect(deleteResponse.status).toBe(204);
387
+
388
+ // Verify it's deleted
389
+ const deletedObject = await testEnv.RESOURCES_BUCKET.get(
390
+ 'resources/delete-test.txt'
391
+ );
392
+ expect(deletedObject).toBeNull();
393
+ });
394
+
395
+ it('should handle URL encoded keys with slashes', async () => {
396
+ // Create a resource with a path containing subdirectories
397
+ const formData = new FormData();
398
+ formData.append(
399
+ 'file',
400
+ new Blob(['Nested content'], { type: 'text/plain' }),
401
+ 'nested.txt'
402
+ );
403
+ formData.append('type', 'text');
404
+ formData.append('key', 'resources/subfolder/nested.txt');
405
+
406
+ const createRequest = new Request('http://example.com/resources', {
407
+ method: 'POST',
408
+ body: formData,
409
+ });
410
+ const createContext = createExecutionContext();
411
+ await worker.fetch(createRequest, testEnv, createContext);
412
+ await waitOnExecutionContext(createContext);
413
+
414
+ // Delete with URL encoded key
415
+ const deleteRequest = new Request(
416
+ 'http://example.com/resources/' +
417
+ encodeURIComponent('resources/subfolder/nested.txt'),
418
+ { method: 'DELETE' }
419
+ );
420
+ const deleteContext = createExecutionContext();
421
+ const deleteResponse = await worker.fetch(
422
+ deleteRequest,
423
+ testEnv,
424
+ deleteContext
425
+ );
426
+ await waitOnExecutionContext(deleteContext);
427
+
428
+ expect(deleteResponse.status).toBe(204);
429
+
430
+ // Verify it's deleted
431
+ const deletedObject = await testEnv.RESOURCES_BUCKET.get(
432
+ 'resources/subfolder/nested.txt'
433
+ );
434
+ expect(deletedObject).toBeNull();
435
+ });
436
+
437
+ it('should return 204 even for non-existent resources (idempotent delete)', async () => {
438
+ const deleteRequest = new Request(
439
+ 'http://example.com/resources/' +
440
+ encodeURIComponent('non-existent.txt'),
441
+ { method: 'DELETE' }
442
+ );
443
+ const deleteContext = createExecutionContext();
444
+ const deleteResponse = await worker.fetch(
445
+ deleteRequest,
446
+ testEnv,
447
+ deleteContext
448
+ );
449
+ await waitOnExecutionContext(deleteContext);
450
+
451
+ // Should return 204 No Content even if resource doesn't exist (idempotent)
452
+ expect(deleteResponse.status).toBe(204);
453
+ });
454
+ });
455
+
456
+ describe('DELETE /resources (bulk delete)', () => {
457
+ beforeEach(async () => {
458
+ // Create some test resources
459
+ const resources = ['file1.txt', 'file2.txt', 'subfolder/file3.txt'];
460
+
461
+ for (const resource of resources) {
462
+ const formData = new FormData();
463
+ formData.append(
464
+ 'file',
465
+ new Blob([`Content of ${resource}`], { type: 'text/plain' }),
466
+ resource
467
+ );
468
+ formData.append('type', 'text');
469
+ formData.append('key', `resources/${resource}`);
470
+
471
+ const request = new Request('http://example.com/resources', {
472
+ method: 'POST',
473
+ body: formData,
474
+ });
475
+ const context = createExecutionContext();
476
+ await worker.fetch(request, testEnv, context);
477
+ await waitOnExecutionContext(context);
478
+ }
479
+ });
480
+
481
+ it('should delete all resources when in development mode', async () => {
482
+ // Explicitly set environment to development mode
483
+ const devEnv = {
484
+ ...testEnv,
485
+ NODE_ENV: 'development',
486
+ };
487
+
488
+ const deleteRequest = new Request('http://example.com/resources', {
489
+ method: 'DELETE',
490
+ });
491
+ const deleteContext = createExecutionContext();
492
+ const deleteResponse = await worker.fetch(
493
+ deleteRequest,
494
+ devEnv,
495
+ deleteContext
496
+ );
497
+ await waitOnExecutionContext(deleteContext);
498
+
499
+ expect(deleteResponse.status).toBe(200);
500
+ const responseBody = await deleteResponse.json<{
501
+ deletedCount: number;
502
+ }>();
503
+ expect(responseBody.deletedCount).toBeGreaterThanOrEqual(3);
504
+
505
+ // Verify all resources are deleted
506
+ const listed = await testEnv.RESOURCES_BUCKET.list();
507
+ expect(listed.objects.length).toBe(0);
508
+ });
509
+
510
+ it('should return 403 when not in development mode', async () => {
511
+ // Create an environment without NODE_ENV
512
+ const envWithoutNodeEnv = {
513
+ RESOURCES_BUCKET: testEnv.RESOURCES_BUCKET,
514
+ // Explicitly omit NODE_ENV
515
+ };
516
+
517
+ const deleteRequest = new Request('http://example.com/resources', {
518
+ method: 'DELETE',
519
+ });
520
+ const deleteContext = createExecutionContext();
521
+ const deleteResponse = await worker.fetch(
522
+ deleteRequest,
523
+ envWithoutNodeEnv,
524
+ deleteContext
525
+ );
526
+ await waitOnExecutionContext(deleteContext);
527
+
528
+ expect(deleteResponse.status).toBe(403);
529
+ const errorBody = await deleteResponse.json<{ error: string }>();
530
+ expect(errorBody.error).toBe(
531
+ 'Bulk delete is only available in development mode'
532
+ );
533
+
534
+ // Verify resources are not deleted
535
+ const listed = await testEnv.RESOURCES_BUCKET.list();
536
+ expect(listed.objects.length).toBeGreaterThanOrEqual(3);
537
+ });
538
+
539
+ it('should return 403 when NODE_ENV is production', async () => {
540
+ const prodEnv = {
541
+ ...testEnv,
542
+ NODE_ENV: 'production',
543
+ };
544
+
545
+ const deleteRequest = new Request('http://example.com/resources', {
546
+ method: 'DELETE',
547
+ });
548
+ const deleteContext = createExecutionContext();
549
+ const deleteResponse = await worker.fetch(
550
+ deleteRequest,
551
+ prodEnv,
552
+ deleteContext
553
+ );
554
+ await waitOnExecutionContext(deleteContext);
555
+
556
+ expect(deleteResponse.status).toBe(403);
557
+ const errorBody = await deleteResponse.json<{ error: string }>();
558
+ expect(errorBody.error).toBe(
559
+ 'Bulk delete is only available in development mode'
560
+ );
561
+ });
562
+ });
563
+
564
+ describe('POST /resources/presigned-link', () => {
565
+ it('should return 400 when R2 credentials are not configured', async () => {
566
+ // This test verifies behavior when the backend doesn't have credentials configured
567
+ const request = new Request(
568
+ 'http://example.com/resources/presigned-link',
569
+ {
570
+ method: 'POST',
571
+ headers: {
572
+ 'Content-Type': 'application/json',
573
+ },
574
+ body: JSON.stringify({
575
+ key: 'test-files/large-video.mp4',
576
+ type: 'binary',
577
+ size: 150 * 1024 * 1024, // 150MB
578
+ }),
579
+ }
580
+ );
581
+ const context = createExecutionContext();
582
+ const response = await worker.fetch(request, testEnv, context);
583
+ await waitOnExecutionContext(context);
584
+
585
+ expect(response.status).toBe(400);
586
+ const responseBody = await response.json<{ error: string }>();
587
+ expect(responseBody.error).toBeDefined();
588
+ expect(typeof responseBody.error).toBe('string');
589
+ });
590
+
591
+ it('should validate required fields in request body', async () => {
592
+ // Missing key
593
+ let request = new Request('http://example.com/resources/presigned-link', {
594
+ method: 'POST',
595
+ headers: {
596
+ 'Content-Type': 'application/json',
597
+ },
598
+ body: JSON.stringify({
599
+ type: 'binary',
600
+ size: 1024,
601
+ }),
602
+ });
603
+ let context = createExecutionContext();
604
+ let response = await worker.fetch(request, testEnv, context);
605
+ await waitOnExecutionContext(context);
606
+ expect(response.status).toBe(400);
607
+
608
+ // Missing type
609
+ request = new Request('http://example.com/resources/presigned-link', {
610
+ method: 'POST',
611
+ headers: {
612
+ 'Content-Type': 'application/json',
613
+ },
614
+ body: JSON.stringify({
615
+ key: 'test.txt',
616
+ size: 1024,
617
+ }),
618
+ });
619
+ context = createExecutionContext();
620
+ response = await worker.fetch(request, testEnv, context);
621
+ await waitOnExecutionContext(context);
622
+ expect(response.status).toBe(400);
623
+
624
+ // Missing size
625
+ request = new Request('http://example.com/resources/presigned-link', {
626
+ method: 'POST',
627
+ headers: {
628
+ 'Content-Type': 'application/json',
629
+ },
630
+ body: JSON.stringify({
631
+ key: 'test.txt',
632
+ type: 'text',
633
+ }),
634
+ });
635
+ context = createExecutionContext();
636
+ response = await worker.fetch(request, testEnv, context);
637
+ await waitOnExecutionContext(context);
638
+ expect(response.status).toBe(400);
639
+ });
640
+
641
+ it('should validate type field is text or binary', async () => {
642
+ const request = new Request(
643
+ 'http://example.com/resources/presigned-link',
644
+ {
645
+ method: 'POST',
646
+ headers: {
647
+ 'Content-Type': 'application/json',
648
+ },
649
+ body: JSON.stringify({
650
+ key: 'test.txt',
651
+ type: 'invalid',
652
+ size: 1024,
653
+ }),
654
+ }
655
+ );
656
+ const context = createExecutionContext();
657
+ const response = await worker.fetch(request, testEnv, context);
658
+ await waitOnExecutionContext(context);
659
+
660
+ expect(response.status).toBe(400);
661
+ const responseBody = await response.json<{ error: string }>();
662
+ expect(responseBody.error).toContain(
663
+ 'type must be either "text" or "binary"'
664
+ );
665
+ });
666
+
667
+ // Note: We intentionally do not test successful presigned URL generation
668
+ // as it would require real cloud storage credentials and internet connectivity.
669
+ // The spec test validates the API contract when credentials are configured.
670
+ });
671
+ });