Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
1import { describe, test, expect } from 'bun:test'
2import {
3 shouldCompressFile,
4 compressFile,
5 processUploadedFiles,
6 createManifest,
7 updateFileBlobs,
8 computeCID,
9 extractBlobMap,
10 type UploadedFile,
11 type FileUploadResult,
12} from './wisp-utils'
13import type { Directory } from '../lexicons/types/place/wisp/fs'
14import { gunzipSync } from 'zlib'
15import { BlobRef } from '@atproto/api'
16import { CID } from 'multiformats/cid'
17
18// Helper function to create a valid CID for testing
19// Using a real valid CID from actual AT Protocol usage
20const TEST_CID_STRING = 'bafkreid7ybejd5s2vv2j7d4aajjlmdgazguemcnuliiyfn6coxpwp2mi6y'
21
22function createMockBlobRef(mimeType: string, size: number): BlobRef {
23 // Create a properly formatted CID
24 const cid = CID.parse(TEST_CID_STRING)
25 return new BlobRef(cid as any, mimeType, size)
26}
27
28describe('shouldCompressFile', () => {
29 test('should compress HTML files', () => {
30 expect(shouldCompressFile('text/html')).toBe(true)
31 expect(shouldCompressFile('text/html; charset=utf-8')).toBe(true)
32 })
33
34 test('should compress CSS files', () => {
35 expect(shouldCompressFile('text/css')).toBe(true)
36 })
37
38 test('should compress JavaScript files', () => {
39 expect(shouldCompressFile('text/javascript')).toBe(true)
40 expect(shouldCompressFile('application/javascript')).toBe(true)
41 expect(shouldCompressFile('application/x-javascript')).toBe(true)
42 })
43
44 test('should compress JSON files', () => {
45 expect(shouldCompressFile('application/json')).toBe(true)
46 })
47
48 test('should compress SVG files', () => {
49 expect(shouldCompressFile('image/svg+xml')).toBe(true)
50 })
51
52 test('should compress XML files', () => {
53 expect(shouldCompressFile('text/xml')).toBe(true)
54 expect(shouldCompressFile('application/xml')).toBe(true)
55 })
56
57 test('should compress plain text files', () => {
58 expect(shouldCompressFile('text/plain')).toBe(true)
59 })
60
61 test('should NOT compress _redirects file', () => {
62 expect(shouldCompressFile('text/plain', '_redirects')).toBe(false)
63 expect(shouldCompressFile('text/plain', 'folder/_redirects')).toBe(false)
64 expect(shouldCompressFile('application/octet-stream', '_redirects')).toBe(false)
65 })
66
67 test('should NOT compress images', () => {
68 expect(shouldCompressFile('image/png')).toBe(false)
69 expect(shouldCompressFile('image/jpeg')).toBe(false)
70 expect(shouldCompressFile('image/jpg')).toBe(false)
71 expect(shouldCompressFile('image/gif')).toBe(false)
72 expect(shouldCompressFile('image/webp')).toBe(false)
73 })
74
75 test('should NOT compress videos', () => {
76 expect(shouldCompressFile('video/mp4')).toBe(false)
77 expect(shouldCompressFile('video/webm')).toBe(false)
78 })
79
80 test('should NOT compress already compressed formats', () => {
81 expect(shouldCompressFile('application/zip')).toBe(false)
82 expect(shouldCompressFile('application/gzip')).toBe(false)
83 expect(shouldCompressFile('application/pdf')).toBe(false)
84 })
85
86 test('should NOT compress fonts', () => {
87 expect(shouldCompressFile('font/woff')).toBe(false)
88 expect(shouldCompressFile('font/woff2')).toBe(false)
89 expect(shouldCompressFile('font/ttf')).toBe(false)
90 })
91})
92
93describe('compressFile', () => {
94 test('should compress text content', () => {
95 const content = Buffer.from('Hello, World! '.repeat(100))
96 const compressed = compressFile(content)
97
98 expect(compressed.length).toBeLessThan(content.length)
99
100 // Verify we can decompress it back
101 const decompressed = gunzipSync(compressed)
102 expect(decompressed.toString()).toBe(content.toString())
103 })
104
105 test('should compress HTML content significantly', () => {
106 const html = `
107 <!DOCTYPE html>
108 <html>
109 <head><title>Test</title></head>
110 <body>
111 ${'<p>Hello World!</p>\n'.repeat(50)}
112 </body>
113 </html>
114 `
115 const content = Buffer.from(html)
116 const compressed = compressFile(content)
117
118 expect(compressed.length).toBeLessThan(content.length)
119
120 // Verify decompression
121 const decompressed = gunzipSync(compressed)
122 expect(decompressed.toString()).toBe(html)
123 })
124
125 test('should handle empty content', () => {
126 const content = Buffer.from('')
127 const compressed = compressFile(content)
128 const decompressed = gunzipSync(compressed)
129 expect(decompressed.toString()).toBe('')
130 })
131
132 test('should produce deterministic compression', () => {
133 const content = Buffer.from('Test content')
134 const compressed1 = compressFile(content)
135 const compressed2 = compressFile(content)
136
137 expect(compressed1.toString('base64')).toBe(compressed2.toString('base64'))
138 })
139})
140
141describe('processUploadedFiles', () => {
142 test('should process single root-level file', () => {
143 const files: UploadedFile[] = [
144 {
145 name: 'index.html',
146 content: Buffer.from('<html></html>'),
147 mimeType: 'text/html',
148 size: 13,
149 },
150 ]
151
152 const result = processUploadedFiles(files)
153
154 expect(result.fileCount).toBe(1)
155 expect(result.directory.type).toBe('directory')
156 expect(result.directory.entries).toHaveLength(1)
157 expect(result.directory.entries[0].name).toBe('index.html')
158
159 const node = result.directory.entries[0].node
160 expect('blob' in node).toBe(true) // It's a file node
161 })
162
163 test('should process multiple root-level files', () => {
164 const files: UploadedFile[] = [
165 {
166 name: 'index.html',
167 content: Buffer.from('<html></html>'),
168 mimeType: 'text/html',
169 size: 13,
170 },
171 {
172 name: 'styles.css',
173 content: Buffer.from('body {}'),
174 mimeType: 'text/css',
175 size: 7,
176 },
177 {
178 name: 'script.js',
179 content: Buffer.from('console.log("hi")'),
180 mimeType: 'application/javascript',
181 size: 17,
182 },
183 ]
184
185 const result = processUploadedFiles(files)
186
187 expect(result.fileCount).toBe(3)
188 expect(result.directory.entries).toHaveLength(3)
189
190 const names = result.directory.entries.map(e => e.name)
191 expect(names).toContain('index.html')
192 expect(names).toContain('styles.css')
193 expect(names).toContain('script.js')
194 })
195
196 test('should process files with subdirectories', () => {
197 const files: UploadedFile[] = [
198 {
199 name: 'dist/index.html',
200 content: Buffer.from('<html></html>'),
201 mimeType: 'text/html',
202 size: 13,
203 },
204 {
205 name: 'dist/css/styles.css',
206 content: Buffer.from('body {}'),
207 mimeType: 'text/css',
208 size: 7,
209 },
210 {
211 name: 'dist/js/app.js',
212 content: Buffer.from('console.log()'),
213 mimeType: 'application/javascript',
214 size: 13,
215 },
216 ]
217
218 const result = processUploadedFiles(files)
219
220 expect(result.fileCount).toBe(3)
221 expect(result.directory.entries).toHaveLength(3) // index.html, css/, js/
222
223 // Check root has index.html (after base folder removal)
224 const indexEntry = result.directory.entries.find(e => e.name === 'index.html')
225 expect(indexEntry).toBeDefined()
226
227 // Check css directory exists
228 const cssDir = result.directory.entries.find(e => e.name === 'css')
229 expect(cssDir).toBeDefined()
230 expect('entries' in cssDir!.node).toBe(true)
231
232 if ('entries' in cssDir!.node) {
233 expect(cssDir!.node.entries).toHaveLength(1)
234 expect(cssDir!.node.entries[0].name).toBe('styles.css')
235 }
236
237 // Check js directory exists
238 const jsDir = result.directory.entries.find(e => e.name === 'js')
239 expect(jsDir).toBeDefined()
240 expect('entries' in jsDir!.node).toBe(true)
241 })
242
243 test('should handle deeply nested subdirectories', () => {
244 const files: UploadedFile[] = [
245 {
246 name: 'dist/deep/nested/folder/file.txt',
247 content: Buffer.from('content'),
248 mimeType: 'text/plain',
249 size: 7,
250 },
251 ]
252
253 const result = processUploadedFiles(files)
254
255 expect(result.fileCount).toBe(1)
256
257 // Navigate through the directory structure (base folder removed)
258 const deepDir = result.directory.entries.find(e => e.name === 'deep')
259 expect(deepDir).toBeDefined()
260 expect('entries' in deepDir!.node).toBe(true)
261
262 if ('entries' in deepDir!.node) {
263 const nestedDir = deepDir!.node.entries.find(e => e.name === 'nested')
264 expect(nestedDir).toBeDefined()
265
266 if (nestedDir && 'entries' in nestedDir.node) {
267 const folderDir = nestedDir.node.entries.find(e => e.name === 'folder')
268 expect(folderDir).toBeDefined()
269
270 if (folderDir && 'entries' in folderDir.node) {
271 expect(folderDir.node.entries).toHaveLength(1)
272 expect(folderDir.node.entries[0].name).toBe('file.txt')
273 }
274 }
275 }
276 })
277
278 test('should remove base folder name from paths', () => {
279 const files: UploadedFile[] = [
280 {
281 name: 'dist/index.html',
282 content: Buffer.from('<html></html>'),
283 mimeType: 'text/html',
284 size: 13,
285 },
286 {
287 name: 'dist/css/styles.css',
288 content: Buffer.from('body {}'),
289 mimeType: 'text/css',
290 size: 7,
291 },
292 ]
293
294 const result = processUploadedFiles(files)
295
296 // After removing 'dist/', we should have index.html and css/ at root
297 expect(result.directory.entries.find(e => e.name === 'index.html')).toBeDefined()
298 expect(result.directory.entries.find(e => e.name === 'css')).toBeDefined()
299 expect(result.directory.entries.find(e => e.name === 'dist')).toBeUndefined()
300 })
301
302 test('should handle empty file list', () => {
303 const files: UploadedFile[] = []
304 const result = processUploadedFiles(files)
305
306 expect(result.fileCount).toBe(0)
307 expect(result.directory.entries).toHaveLength(0)
308 })
309
310 test('should handle multiple files in same subdirectory', () => {
311 const files: UploadedFile[] = [
312 {
313 name: 'dist/assets/image1.png',
314 content: Buffer.from('png1'),
315 mimeType: 'image/png',
316 size: 4,
317 },
318 {
319 name: 'dist/assets/image2.png',
320 content: Buffer.from('png2'),
321 mimeType: 'image/png',
322 size: 4,
323 },
324 ]
325
326 const result = processUploadedFiles(files)
327
328 expect(result.fileCount).toBe(2)
329
330 const assetsDir = result.directory.entries.find(e => e.name === 'assets')
331 expect(assetsDir).toBeDefined()
332
333 if ('entries' in assetsDir!.node) {
334 expect(assetsDir!.node.entries).toHaveLength(2)
335 const names = assetsDir!.node.entries.map(e => e.name)
336 expect(names).toContain('image1.png')
337 expect(names).toContain('image2.png')
338 }
339 })
340})
341
342describe('createManifest', () => {
343 test('should create valid manifest', () => {
344 const root: Directory = {
345 $type: 'place.wisp.fs#directory',
346 type: 'directory',
347 entries: [],
348 }
349
350 const manifest = createManifest('example.com', root, 0)
351
352 expect(manifest.$type).toBe('place.wisp.fs')
353 expect(manifest.site).toBe('example.com')
354 expect(manifest.root).toBe(root)
355 expect(manifest.fileCount).toBe(0)
356 expect(manifest.createdAt).toBeDefined()
357
358 // Verify it's a valid ISO date string
359 const date = new Date(manifest.createdAt)
360 expect(date.toISOString()).toBe(manifest.createdAt)
361 })
362
363 test('should create manifest with file count', () => {
364 const root: Directory = {
365 $type: 'place.wisp.fs#directory',
366 type: 'directory',
367 entries: [],
368 }
369
370 const manifest = createManifest('test-site', root, 42)
371
372 expect(manifest.fileCount).toBe(42)
373 expect(manifest.site).toBe('test-site')
374 })
375
376 test('should create manifest with populated directory', () => {
377 const mockBlob = createMockBlobRef('text/html', 100)
378
379 const root: Directory = {
380 $type: 'place.wisp.fs#directory',
381 type: 'directory',
382 entries: [
383 {
384 name: 'index.html',
385 node: {
386 $type: 'place.wisp.fs#file',
387 type: 'file',
388 blob: mockBlob,
389 },
390 },
391 ],
392 }
393
394 const manifest = createManifest('populated-site', root, 1)
395
396 expect(manifest).toBeDefined()
397 expect(manifest.site).toBe('populated-site')
398 expect(manifest.root.entries).toHaveLength(1)
399 })
400})
401
402describe('updateFileBlobs', () => {
403 test('should update single file blob at root', () => {
404 const directory: Directory = {
405 $type: 'place.wisp.fs#directory',
406 type: 'directory',
407 entries: [
408 {
409 name: 'index.html',
410 node: {
411 $type: 'place.wisp.fs#file',
412 type: 'file',
413 blob: undefined as any,
414 },
415 },
416 ],
417 }
418
419 const mockBlob = createMockBlobRef('text/html', 100)
420 const uploadResults: FileUploadResult[] = [
421 {
422 hash: TEST_CID_STRING,
423 blobRef: mockBlob,
424 mimeType: 'text/html',
425 },
426 ]
427
428 const filePaths = ['index.html']
429
430 const updated = updateFileBlobs(directory, uploadResults, filePaths)
431
432 expect(updated.entries).toHaveLength(1)
433 const fileNode = updated.entries[0].node
434
435 if ('blob' in fileNode) {
436 expect(fileNode.blob).toBeDefined()
437 expect(fileNode.blob.mimeType).toBe('text/html')
438 expect(fileNode.blob.size).toBe(100)
439 } else {
440 throw new Error('Expected file node')
441 }
442 })
443
444 test('should update files in nested directories', () => {
445 const directory: Directory = {
446 $type: 'place.wisp.fs#directory',
447 type: 'directory',
448 entries: [
449 {
450 name: 'css',
451 node: {
452 $type: 'place.wisp.fs#directory',
453 type: 'directory',
454 entries: [
455 {
456 name: 'styles.css',
457 node: {
458 $type: 'place.wisp.fs#file',
459 type: 'file',
460 blob: undefined as any,
461 },
462 },
463 ],
464 },
465 },
466 ],
467 }
468
469 const mockBlob = createMockBlobRef('text/css', 50)
470 const uploadResults: FileUploadResult[] = [
471 {
472 hash: TEST_CID_STRING,
473 blobRef: mockBlob,
474 mimeType: 'text/css',
475 encoding: 'gzip',
476 },
477 ]
478
479 const filePaths = ['css/styles.css']
480
481 const updated = updateFileBlobs(directory, uploadResults, filePaths)
482
483 const cssDir = updated.entries[0]
484 expect(cssDir.name).toBe('css')
485
486 if ('entries' in cssDir.node) {
487 const cssFile = cssDir.node.entries[0]
488 expect(cssFile.name).toBe('styles.css')
489
490 if ('blob' in cssFile.node) {
491 expect(cssFile.node.blob.mimeType).toBe('text/css')
492 if ('encoding' in cssFile.node) {
493 expect(cssFile.node.encoding).toBe('gzip')
494 }
495 } else {
496 throw new Error('Expected file node')
497 }
498 } else {
499 throw new Error('Expected directory node')
500 }
501 })
502
503 test('should handle normalized paths with base folder removed', () => {
504 const directory: Directory = {
505 $type: 'place.wisp.fs#directory',
506 type: 'directory',
507 entries: [
508 {
509 name: 'index.html',
510 node: {
511 $type: 'place.wisp.fs#file',
512 type: 'file',
513 blob: undefined as any,
514 },
515 },
516 ],
517 }
518
519 const mockBlob = createMockBlobRef('text/html', 100)
520 const uploadResults: FileUploadResult[] = [
521 {
522 hash: TEST_CID_STRING,
523 blobRef: mockBlob,
524 },
525 ]
526
527 // Path includes base folder that should be normalized
528 const filePaths = ['dist/index.html']
529
530 const updated = updateFileBlobs(directory, uploadResults, filePaths)
531
532 const fileNode = updated.entries[0].node
533 if ('blob' in fileNode) {
534 expect(fileNode.blob).toBeDefined()
535 } else {
536 throw new Error('Expected file node')
537 }
538 })
539
540 test('should preserve file metadata (encoding, mimeType, base64)', () => {
541 const directory: Directory = {
542 $type: 'place.wisp.fs#directory',
543 type: 'directory',
544 entries: [
545 {
546 name: 'data.json',
547 node: {
548 $type: 'place.wisp.fs#file',
549 type: 'file',
550 blob: undefined as any,
551 },
552 },
553 ],
554 }
555
556 const mockBlob = createMockBlobRef('application/json', 200)
557 const uploadResults: FileUploadResult[] = [
558 {
559 hash: TEST_CID_STRING,
560 blobRef: mockBlob,
561 mimeType: 'application/json',
562 encoding: 'gzip',
563 base64: true,
564 },
565 ]
566
567 const filePaths = ['data.json']
568
569 const updated = updateFileBlobs(directory, uploadResults, filePaths)
570
571 const fileNode = updated.entries[0].node
572 if ('blob' in fileNode && 'mimeType' in fileNode && 'encoding' in fileNode && 'base64' in fileNode) {
573 expect(fileNode.mimeType).toBe('application/json')
574 expect(fileNode.encoding).toBe('gzip')
575 expect(fileNode.base64).toBe(true)
576 } else {
577 throw new Error('Expected file node with metadata')
578 }
579 })
580
581 test('should handle multiple files at different directory levels', () => {
582 const directory: Directory = {
583 $type: 'place.wisp.fs#directory',
584 type: 'directory',
585 entries: [
586 {
587 name: 'index.html',
588 node: {
589 $type: 'place.wisp.fs#file',
590 type: 'file',
591 blob: undefined as any,
592 },
593 },
594 {
595 name: 'assets',
596 node: {
597 $type: 'place.wisp.fs#directory',
598 type: 'directory',
599 entries: [
600 {
601 name: 'logo.svg',
602 node: {
603 $type: 'place.wisp.fs#file',
604 type: 'file',
605 blob: undefined as any,
606 },
607 },
608 ],
609 },
610 },
611 ],
612 }
613
614 const htmlBlob = createMockBlobRef('text/html', 100)
615 const svgBlob = createMockBlobRef('image/svg+xml', 500)
616
617 const uploadResults: FileUploadResult[] = [
618 {
619 hash: TEST_CID_STRING,
620 blobRef: htmlBlob,
621 },
622 {
623 hash: TEST_CID_STRING,
624 blobRef: svgBlob,
625 },
626 ]
627
628 const filePaths = ['index.html', 'assets/logo.svg']
629
630 const updated = updateFileBlobs(directory, uploadResults, filePaths)
631
632 // Check root file
633 const indexNode = updated.entries[0].node
634 if ('blob' in indexNode) {
635 expect(indexNode.blob.mimeType).toBe('text/html')
636 }
637
638 // Check nested file
639 const assetsDir = updated.entries[1]
640 if ('entries' in assetsDir.node) {
641 const logoNode = assetsDir.node.entries[0].node
642 if ('blob' in logoNode) {
643 expect(logoNode.blob.mimeType).toBe('image/svg+xml')
644 }
645 }
646 })
647})
648
649describe('computeCID', () => {
650 test('should compute CID for gzipped+base64 encoded content', () => {
651 // This simulates the actual flow: gzip -> base64 -> compute CID
652 const originalContent = Buffer.from('Hello, World!')
653 const gzipped = compressFile(originalContent)
654 const base64Content = Buffer.from(gzipped.toString('base64'), 'binary')
655
656 const cid = computeCID(base64Content)
657
658 // CID should be a valid CIDv1 string starting with 'bafkrei'
659 expect(cid).toMatch(/^bafkrei[a-z0-9]+$/)
660 expect(cid.length).toBeGreaterThan(10)
661 })
662
663 test('should compute deterministic CIDs for identical content', () => {
664 const content = Buffer.from('Test content for CID calculation')
665 const gzipped = compressFile(content)
666 const base64Content = Buffer.from(gzipped.toString('base64'), 'binary')
667
668 const cid1 = computeCID(base64Content)
669 const cid2 = computeCID(base64Content)
670
671 expect(cid1).toBe(cid2)
672 })
673
674 test('should compute different CIDs for different content', () => {
675 const content1 = Buffer.from('Content A')
676 const content2 = Buffer.from('Content B')
677
678 const gzipped1 = compressFile(content1)
679 const gzipped2 = compressFile(content2)
680
681 const base64Content1 = Buffer.from(gzipped1.toString('base64'), 'binary')
682 const base64Content2 = Buffer.from(gzipped2.toString('base64'), 'binary')
683
684 const cid1 = computeCID(base64Content1)
685 const cid2 = computeCID(base64Content2)
686
687 expect(cid1).not.toBe(cid2)
688 })
689
690 test('should handle empty content', () => {
691 const emptyContent = Buffer.from('')
692 const gzipped = compressFile(emptyContent)
693 const base64Content = Buffer.from(gzipped.toString('base64'), 'binary')
694
695 const cid = computeCID(base64Content)
696
697 expect(cid).toMatch(/^bafkrei[a-z0-9]+$/)
698 })
699
700 test('should compute same CID as PDS for base64-encoded content', () => {
701 // Test that binary encoding produces correct bytes for CID calculation
702 const testContent = Buffer.from('<!DOCTYPE html><html><body>Hello</body></html>')
703 const gzipped = compressFile(testContent)
704 const base64Content = Buffer.from(gzipped.toString('base64'), 'binary')
705
706 // Compute CID twice to ensure consistency
707 const cid1 = computeCID(base64Content)
708 const cid2 = computeCID(base64Content)
709
710 expect(cid1).toBe(cid2)
711 expect(cid1).toMatch(/^bafkrei/)
712 })
713
714 test('should use binary encoding for base64 strings', () => {
715 // This test verifies we're using the correct encoding method
716 // For base64 strings, 'binary' encoding ensures each character becomes exactly one byte
717 const content = Buffer.from('Test content')
718 const gzipped = compressFile(content)
719 const base64String = gzipped.toString('base64')
720
721 // Using binary encoding (what we use in production)
722 const base64Content = Buffer.from(base64String, 'binary')
723
724 // Verify the length matches the base64 string length
725 expect(base64Content.length).toBe(base64String.length)
726
727 // Verify CID is computed correctly
728 const cid = computeCID(base64Content)
729 expect(cid).toMatch(/^bafkrei/)
730 })
731})
732
733describe('extractBlobMap', () => {
734 test('should extract blob map from flat directory structure', () => {
735 const mockCid = CID.parse(TEST_CID_STRING)
736 const mockBlob = new BlobRef(mockCid as any, 'text/html', 100)
737
738 const directory: Directory = {
739 $type: 'place.wisp.fs#directory',
740 type: 'directory',
741 entries: [
742 {
743 name: 'index.html',
744 node: {
745 $type: 'place.wisp.fs#file',
746 type: 'file',
747 blob: mockBlob,
748 },
749 },
750 ],
751 }
752
753 const blobMap = extractBlobMap(directory)
754
755 expect(blobMap.size).toBe(1)
756 expect(blobMap.has('index.html')).toBe(true)
757
758 const entry = blobMap.get('index.html')
759 expect(entry?.cid).toBe(TEST_CID_STRING)
760 expect(entry?.blobRef).toBe(mockBlob)
761 })
762
763 test('should extract blob map from nested directory structure', () => {
764 const mockCid1 = CID.parse(TEST_CID_STRING)
765 const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi')
766
767 const mockBlob1 = new BlobRef(mockCid1 as any, 'text/html', 100)
768 const mockBlob2 = new BlobRef(mockCid2 as any, 'text/css', 50)
769
770 const directory: Directory = {
771 $type: 'place.wisp.fs#directory',
772 type: 'directory',
773 entries: [
774 {
775 name: 'index.html',
776 node: {
777 $type: 'place.wisp.fs#file',
778 type: 'file',
779 blob: mockBlob1,
780 },
781 },
782 {
783 name: 'assets',
784 node: {
785 $type: 'place.wisp.fs#directory',
786 type: 'directory',
787 entries: [
788 {
789 name: 'styles.css',
790 node: {
791 $type: 'place.wisp.fs#file',
792 type: 'file',
793 blob: mockBlob2,
794 },
795 },
796 ],
797 },
798 },
799 ],
800 }
801
802 const blobMap = extractBlobMap(directory)
803
804 expect(blobMap.size).toBe(2)
805 expect(blobMap.has('index.html')).toBe(true)
806 expect(blobMap.has('assets/styles.css')).toBe(true)
807
808 expect(blobMap.get('index.html')?.cid).toBe(TEST_CID_STRING)
809 expect(blobMap.get('assets/styles.css')?.cid).toBe('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi')
810 })
811
812 test('should handle deeply nested directory structures', () => {
813 const mockCid = CID.parse(TEST_CID_STRING)
814 const mockBlob = new BlobRef(mockCid as any, 'text/javascript', 200)
815
816 const directory: Directory = {
817 $type: 'place.wisp.fs#directory',
818 type: 'directory',
819 entries: [
820 {
821 name: 'src',
822 node: {
823 $type: 'place.wisp.fs#directory',
824 type: 'directory',
825 entries: [
826 {
827 name: 'lib',
828 node: {
829 $type: 'place.wisp.fs#directory',
830 type: 'directory',
831 entries: [
832 {
833 name: 'utils.js',
834 node: {
835 $type: 'place.wisp.fs#file',
836 type: 'file',
837 blob: mockBlob,
838 },
839 },
840 ],
841 },
842 },
843 ],
844 },
845 },
846 ],
847 }
848
849 const blobMap = extractBlobMap(directory)
850
851 expect(blobMap.size).toBe(1)
852 expect(blobMap.has('src/lib/utils.js')).toBe(true)
853 expect(blobMap.get('src/lib/utils.js')?.cid).toBe(TEST_CID_STRING)
854 })
855
856 test('should handle empty directory', () => {
857 const directory: Directory = {
858 $type: 'place.wisp.fs#directory',
859 type: 'directory',
860 entries: [],
861 }
862
863 const blobMap = extractBlobMap(directory)
864
865 expect(blobMap.size).toBe(0)
866 })
867
868 test('should correctly extract CID from BlobRef instances (not plain objects)', () => {
869 // This test verifies the fix: AT Protocol SDK returns BlobRef instances,
870 // not plain objects with $type and $link properties
871 const mockCid = CID.parse(TEST_CID_STRING)
872 const mockBlob = new BlobRef(mockCid as any, 'application/octet-stream', 500)
873
874 const directory: Directory = {
875 $type: 'place.wisp.fs#directory',
876 type: 'directory',
877 entries: [
878 {
879 name: 'test.bin',
880 node: {
881 $type: 'place.wisp.fs#file',
882 type: 'file',
883 blob: mockBlob,
884 },
885 },
886 ],
887 }
888
889 const blobMap = extractBlobMap(directory)
890
891 // The fix: we call .toString() on the CID instance instead of accessing $link
892 expect(blobMap.get('test.bin')?.cid).toBe(TEST_CID_STRING)
893 expect(blobMap.get('test.bin')?.blobRef.ref.toString()).toBe(TEST_CID_STRING)
894 })
895
896 test('should handle multiple files in same directory', () => {
897 const mockCid1 = CID.parse(TEST_CID_STRING)
898 const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi')
899 const mockCid3 = CID.parse('bafkreieb3ixgchss44kw7xiavnkns47emdfsqbhcdfluo3p6n3o53fl3vq')
900
901 const mockBlob1 = new BlobRef(mockCid1 as any, 'image/png', 1000)
902 const mockBlob2 = new BlobRef(mockCid2 as any, 'image/png', 2000)
903 const mockBlob3 = new BlobRef(mockCid3 as any, 'image/png', 3000)
904
905 const directory: Directory = {
906 $type: 'place.wisp.fs#directory',
907 type: 'directory',
908 entries: [
909 {
910 name: 'images',
911 node: {
912 $type: 'place.wisp.fs#directory',
913 type: 'directory',
914 entries: [
915 {
916 name: 'logo.png',
917 node: {
918 $type: 'place.wisp.fs#file',
919 type: 'file',
920 blob: mockBlob1,
921 },
922 },
923 {
924 name: 'banner.png',
925 node: {
926 $type: 'place.wisp.fs#file',
927 type: 'file',
928 blob: mockBlob2,
929 },
930 },
931 {
932 name: 'icon.png',
933 node: {
934 $type: 'place.wisp.fs#file',
935 type: 'file',
936 blob: mockBlob3,
937 },
938 },
939 ],
940 },
941 },
942 ],
943 }
944
945 const blobMap = extractBlobMap(directory)
946
947 expect(blobMap.size).toBe(3)
948 expect(blobMap.has('images/logo.png')).toBe(true)
949 expect(blobMap.has('images/banner.png')).toBe(true)
950 expect(blobMap.has('images/icon.png')).toBe(true)
951 })
952
953 test('should handle mixed directory and file structure', () => {
954 const mockCid1 = CID.parse(TEST_CID_STRING)
955 const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi')
956 const mockCid3 = CID.parse('bafkreieb3ixgchss44kw7xiavnkns47emdfsqbhcdfluo3p6n3o53fl3vq')
957
958 const directory: Directory = {
959 $type: 'place.wisp.fs#directory',
960 type: 'directory',
961 entries: [
962 {
963 name: 'index.html',
964 node: {
965 $type: 'place.wisp.fs#file',
966 type: 'file',
967 blob: new BlobRef(mockCid1 as any, 'text/html', 100),
968 },
969 },
970 {
971 name: 'assets',
972 node: {
973 $type: 'place.wisp.fs#directory',
974 type: 'directory',
975 entries: [
976 {
977 name: 'styles.css',
978 node: {
979 $type: 'place.wisp.fs#file',
980 type: 'file',
981 blob: new BlobRef(mockCid2 as any, 'text/css', 50),
982 },
983 },
984 ],
985 },
986 },
987 {
988 name: 'README.md',
989 node: {
990 $type: 'place.wisp.fs#file',
991 type: 'file',
992 blob: new BlobRef(mockCid3 as any, 'text/markdown', 200),
993 },
994 },
995 ],
996 }
997
998 const blobMap = extractBlobMap(directory)
999
1000 expect(blobMap.size).toBe(3)
1001 expect(blobMap.has('index.html')).toBe(true)
1002 expect(blobMap.has('assets/styles.css')).toBe(true)
1003 expect(blobMap.has('README.md')).toBe(true)
1004 })
1005})