| … | … | @@ -0,0 +1,253 @@ |
| 1 | import test from 'brittle' |
| 2 | import Corestore from 'corestore' |
| 3 | import Hyperdrive from 'hyperdrive' |
| 4 | import path from 'bare-path' |
| 5 | import fs from 'bare-fs' |
| 6 | import os from 'bare-os' |
| 7 | import { MediaStore } from '../../lib/media-store.js' |
| 8 | |
| 9 | function tmpdir () { |
| 10 | const dir = path.join(os.tmpdir(), 'hn-mig-test-' + Date.now() + '-' + Math.random().toString(36).slice(2)) |
| 11 | fs.mkdirSync(dir, { recursive: true }) |
| 12 | return dir |
| 13 | } |
| 14 | |
| 15 | function rmrf (dir) { |
| 16 | try { |
| 17 | for (const f of fs.readdirSync(dir)) { |
| 18 | const fp = path.join(dir, f) |
| 19 | const stat = fs.statSync(fp) |
| 20 | if (stat.isDirectory()) rmrf(fp) |
| 21 | else fs.unlinkSync(fp) |
| 22 | } |
| 23 | fs.rmdirSync(dir) |
| 24 | } catch {} |
| 25 | } |
| 26 | |
| 27 | test('migration - deduplicates identical files across notebooks', async (t) => { |
| 28 | const dir = tmpdir() |
| 29 | try { |
| 30 | const store = new Corestore(dir) |
| 31 | const media = new MediaStore(store) |
| 32 | await media.ready() |
| 33 | |
| 34 | // Simulate two drives with overlapping media |
| 35 | const driveA = new Hyperdrive(store.namespace('notebook-alpha')) |
| 36 | await driveA.ready() |
| 37 | const driveB = new Hyperdrive(store.namespace('notebook-beta')) |
| 38 | await driveB.ready() |
| 39 | |
| 40 | const imgBuf = Buffer.from('fake image data for dedup test') |
| 41 | const vidBuf = Buffer.from('fake video data unique') |
| 42 | |
| 43 | await driveA.put('/media/photo1.jpg', imgBuf) |
| 44 | await driveA.put('/media/clip.mp4', vidBuf) |
| 45 | await driveB.put('/media/same-photo.jpg', imgBuf) // same content |
| 46 | |
| 47 | // Simulate migration logic |
| 48 | const drives = [ |
| 49 | { slug: 'alpha', drive: driveA }, |
| 50 | { slug: 'beta', drive: driveB } |
| 51 | ] |
| 52 | |
| 53 | let migrated = 0 |
| 54 | let deduped = 0 |
| 55 | const mimeTypes = { |
| 56 | png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', |
| 57 | gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', |
| 58 | mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', |
| 59 | mp3: 'audio/mpeg', ogg: 'audio/ogg' |
| 60 | } |
| 61 | |
| 62 | for (const { slug, drive } of drives) { |
| 63 | const files = [] |
| 64 | for await (const f of drive.readdir('/media/')) files.push(f) |
| 65 | for (const filename of files) { |
| 66 | const buf = await drive.get('/media/' + filename) |
| 67 | if (!buf) continue |
| 68 | const ext = filename.split('.').pop().toLowerCase() |
| 69 | const mimeType = mimeTypes[ext] || 'application/octet-stream' |
| 70 | const hash = media._hash(buf) |
| 71 | const existed = await media.has(hash) |
| 72 | await media.put(buf, mimeType) |
| 73 | if (existed) deduped++ |
| 74 | else migrated++ |
| 75 | |
| 76 | // Store legacy mapping |
| 77 | await media._index.put('legacy:' + filename, { |
| 78 | hash, |
| 79 | slug, |
| 80 | migratedAt: Date.now() |
| 81 | }) |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | t.is(migrated, 2, 'should migrate 2 unique files') |
| 86 | t.is(deduped, 1, 'should detect 1 duplicate') |
| 87 | |
| 88 | const all = await media.listAll() |
| 89 | t.is(all.length, 2, 'MediaStore should have 2 unique blobs') |
| 90 | |
| 91 | await store.close() |
| 92 | } finally { |
| 93 | rmrf(dir) |
| 94 | } |
| 95 | }) |
| 96 | |
| 97 | test('migration - legacy filename mapping resolves to correct hash', async (t) => { |
| 98 | const dir = tmpdir() |
| 99 | try { |
| 100 | const store = new Corestore(dir) |
| 101 | const media = new MediaStore(store) |
| 102 | await media.ready() |
| 103 | |
| 104 | const buf = Buffer.from('legacy image content') |
| 105 | const hash = await media.put(buf, 'image/png') |
| 106 | |
| 107 | // Store legacy mapping (as migration would) |
| 108 | await media._index.put('legacy:old-photo.png', { |
| 109 | hash, |
| 110 | slug: 'general', |
| 111 | migratedAt: Date.now() |
| 112 | }) |
| 113 | |
| 114 | // Look up by legacy filename |
| 115 | const legacyEntry = await media._index.get('legacy:old-photo.png') |
| 116 | t.ok(legacyEntry, 'legacy entry should exist') |
| 117 | t.is(legacyEntry.value.hash, hash, 'hash should match') |
| 118 | |
| 119 | // Verify we can retrieve the blob via the mapped hash |
| 120 | const info = await media.getInfo(legacyEntry.value.hash) |
| 121 | t.ok(info, 'should find info via mapped hash') |
| 122 | t.is(info.mimeType, 'image/png') |
| 123 | t.is(info.size, buf.length) |
| 124 | |
| 125 | const retrieved = await media.get(legacyEntry.value.hash) |
| 126 | t.alike(retrieved, buf, 'should retrieve original content via legacy mapping') |
| 127 | |
| 128 | await store.close() |
| 129 | } finally { |
| 130 | rmrf(dir) |
| 131 | } |
| 132 | }) |
| 133 | |
| 134 | test('migration - preserves correct mime types from file extensions', async (t) => { |
| 135 | const dir = tmpdir() |
| 136 | try { |
| 137 | const store = new Corestore(dir) |
| 138 | const media = new MediaStore(store) |
| 139 | await media.ready() |
| 140 | |
| 141 | const drive = new Hyperdrive(store.namespace('notebook-test')) |
| 142 | await drive.ready() |
| 143 | |
| 144 | await drive.put('/media/photo.jpg', Buffer.from('jpeg data')) |
| 145 | await drive.put('/media/animation.gif', Buffer.from('gif data')) |
| 146 | await drive.put('/media/video.mp4', Buffer.from('mp4 data')) |
| 147 | await drive.put('/media/unknown.xyz', Buffer.from('unknown data')) |
| 148 | |
| 149 | const mimeTypes = { |
| 150 | png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', |
| 151 | gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', |
| 152 | mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', |
| 153 | mp3: 'audio/mpeg', ogg: 'audio/ogg' |
| 154 | } |
| 155 | |
| 156 | const results = {} |
| 157 | const files = [] |
| 158 | for await (const f of drive.readdir('/media/')) files.push(f) |
| 159 | |
| 160 | for (const filename of files) { |
| 161 | const buf = await drive.get('/media/' + filename) |
| 162 | const ext = filename.split('.').pop().toLowerCase() |
| 163 | const mimeType = mimeTypes[ext] || 'application/octet-stream' |
| 164 | const hash = await media.put(buf, mimeType) |
| 165 | results[filename] = { hash, mimeType } |
| 166 | } |
| 167 | |
| 168 | // Verify mime types |
| 169 | const jpgInfo = await media.getInfo(results['photo.jpg'].hash) |
| 170 | t.is(jpgInfo.mimeType, 'image/jpeg', 'jpg should map to image/jpeg') |
| 171 | |
| 172 | const gifInfo = await media.getInfo(results['animation.gif'].hash) |
| 173 | t.is(gifInfo.mimeType, 'image/gif', 'gif should map to image/gif') |
| 174 | |
| 175 | const mp4Info = await media.getInfo(results['video.mp4'].hash) |
| 176 | t.is(mp4Info.mimeType, 'video/mp4', 'mp4 should map to video/mp4') |
| 177 | |
| 178 | const unknownInfo = await media.getInfo(results['unknown.xyz'].hash) |
| 179 | t.is(unknownInfo.mimeType, 'application/octet-stream', 'unknown ext should fallback to octet-stream') |
| 180 | |
| 181 | await store.close() |
| 182 | } finally { |
| 183 | rmrf(dir) |
| 184 | } |
| 185 | }) |
| 186 | |
| 187 | test('migration - skips empty files gracefully', async (t) => { |
| 188 | const dir = tmpdir() |
| 189 | try { |
| 190 | const store = new Corestore(dir) |
| 191 | const media = new MediaStore(store) |
| 192 | await media.ready() |
| 193 | |
| 194 | const drive = new Hyperdrive(store.namespace('notebook-empty')) |
| 195 | await drive.ready() |
| 196 | |
| 197 | // Write a real file and an empty file |
| 198 | await drive.put('/media/real.png', Buffer.from('real image data')) |
| 199 | await drive.put('/media/empty.png', Buffer.alloc(0)) |
| 200 | |
| 201 | let migrated = 0 |
| 202 | const files = [] |
| 203 | for await (const f of drive.readdir('/media/')) files.push(f) |
| 204 | |
| 205 | for (const filename of files) { |
| 206 | const buf = await drive.get('/media/' + filename) |
| 207 | if (!buf || buf.length === 0) continue |
| 208 | await media.put(buf, 'image/png') |
| 209 | migrated++ |
| 210 | } |
| 211 | |
| 212 | t.is(migrated, 1, 'should only migrate non-empty files') |
| 213 | |
| 214 | const all = await media.listAll() |
| 215 | t.is(all.length, 1, 'MediaStore should have 1 blob') |
| 216 | |
| 217 | await store.close() |
| 218 | } finally { |
| 219 | rmrf(dir) |
| 220 | } |
| 221 | }) |
| 222 | |
| 223 | test('migration - handles drives with no /media/ directory', async (t) => { |
| 224 | const dir = tmpdir() |
| 225 | try { |
| 226 | const store = new Corestore(dir) |
| 227 | const media = new MediaStore(store) |
| 228 | await media.ready() |
| 229 | |
| 230 | const drive = new Hyperdrive(store.namespace('notebook-nomedia')) |
| 231 | await drive.ready() |
| 232 | |
| 233 | // Only write notes, no media |
| 234 | await drive.put('/notes/test.json', Buffer.from('{}')) |
| 235 | |
| 236 | let files = [] |
| 237 | try { |
| 238 | const iter = drive.readdir('/media/') |
| 239 | for await (const f of iter) files.push(f) |
| 240 | } catch { |
| 241 | // no /media/ dir — expected |
| 242 | } |
| 243 | |
| 244 | t.is(files.length, 0, 'should find no media files') |
| 245 | |
| 246 | const all = await media.listAll() |
| 247 | t.is(all.length, 0, 'MediaStore should be empty') |
| 248 | |
| 249 | await store.close() |
| 250 | } finally { |
| 251 | rmrf(dir) |
| 252 | } |
| 253 | }) |