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