source: Klonkt/test/archive-audio.test.js@ 861c228

main
Last change on this file since 861c228 was 861c228, checked in by Robin <roboburr@…>, 4 weeks ago

Gebakken links wijzen voortaan naar hier, in beide routes

Robin: de gebakken tracklinks moeten de nieuwe links worden. Terecht, en ik had
ze te makkelijk afgedaan als "geen plaatje, dus niet mijn probleem". Het zijn
tijdbommen: buildNote zet https://oud/<slug>#track-<id> en ?fc=2 in de content,
en zodra dat domein opgezegd wordt staan er dode links in je eigen berichten.

Ze kunnen nu ook echt om, en dat is te danken aan "altijd behouden" van
vanmiddag: slug en track-id zijn hier hetzelfde, dus alleen de host hoeft eraf
en het fragment klopt vanzelf.

DE REGEL: alleen ombuigen voor een slug die hier ECHT bestaat. Wijst een link
naar iets dat we niet hebben, dan blijft hij naar de oude site wijzen. Een
verwijzing naar een pagina die er nog is, is beter dan een 404 op de nieuwe.

Pas aan het EIND van de ronde, want dan pas weten we welke slugs binnen zijn.
Bij een verse ingest zou de helft van de links nog niet te beoordelen zijn.

EN IN BEIDE ROUTES ("het moet wel gebeuren bij migratie direct ook"). Een
zip-import is net zo goed een verhuizing. Daar draait het binnen dezelfde
transactie: half bijgetrokken is erger dan niet.

Changed files:
src/services/MigrationService.js

  • postLinksBijtrekken(), geexporteerd zodat de zip hem ook kan draaien
  • aangeroepen zodra alle berichten binnen zijn

src/services/ArchiveImportService.js

  • dezelfde stap aan het eind van de import, in de transactie
  • rapportveld linksBijgetrokken

src/views/pages/admin-migrate.ejs

  • in het import- en het ophaalverslag

src/services/i18n.js

  • mig.r_links_fixed in nl/en/de

test/fep1580-migration.test.js

  • fragment en querystring blijven heel, een onbekende slug blijft met rust

test/archive-audio.test.js

  • dezelfde eis voor de zip-route

remarks: /media, /audio en /ap worden overgeslagen; die lopen via hun eigen weg.
Suite 1000 groen, tegenproef op allebei de routes.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 12.7 KB
Line 
1// Audio overleeft een verhuizing. Formaat v2.
2//
3// Dit bestand bestaat door wat er op soundfabrics.nl gebeurde: 36 berichten
4// kwamen goed over, 14 van de 140 nummers stonden er, en 13 daarvan speelden
5// niet af. Drie oorzaken, alle drie hier vastgelegd:
6//
7// 1. Gehoste audio leeft BUITEN MEDIA_ROOT (eigen gated route). Het archief
8// droeg alleen bestanden onder media/, dus de exporter rekende er een
9// /media/../audio/x.mp3 van en de importer weigerde dat pad. Terecht: het
10// wijst buiten de mediamap. Gevolg was wel dat audio nooit kon aankomen.
11// 2. De importer maakte een audio_tracks-rij aan ZONDER te kijken of het
12// bestand er was. Een nummer dat in de lijst staat en 404't bij play is
13// erger dan een nummer dat ontbreekt: het ziet eruit alsof het gelukt is,
14// dus je gooit je oude instantie weg.
15// 3. Alleen audio die met [[track:]] in een bericht stond ging mee. Losse
16// nummers en playlists bleven achter (126 respectievelijk 11 stuks).
17//
18// Een verhuizing die je bibliotheek achterlaat is geen verhuizing.
19
20import { test } from 'node:test';
21import assert from 'node:assert/strict';
22import fs from 'fs';
23import os from 'os';
24import path from 'path';
25
26const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-audio-'));
27const MEDIA = path.join(TMP, 'media');
28const AUDIO = path.join(TMP, 'audio');
29fs.mkdirSync(MEDIA, { recursive: true });
30fs.mkdirSync(AUDIO, { recursive: true });
31
32process.env.DATABASE_PATH = ':memory:';
33process.env.PUBLIC_BASE_URL = 'https://oud.test';
34process.env.MEDIA_PATH = MEDIA;
35process.env.AUDIO_PATH = AUDIO; // MOET voor de import van paths.js staan
36
37const dbMod = await import('../src/config/database.js');
38const db = dbMod.default;
39{ const stil = console.log; console.log = () => {}; try { dbMod.initializeDatabase(); } finally { console.log = stil; } }
40const AX = await import('../src/services/ArchiveExportService.js');
41const AI = await import('../src/services/ArchiveImportService.js');
42const { AUDIO_ROOT } = await import('../src/config/paths.js');
43
44db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
45 .run('u1', 'u1', 'u1@test', 'x', 'god');
46db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Mijn site', 'u1');
47
48const BYTES = (s) => Buffer.from(`RIFF${s}`.padEnd(64, '.'));
49
50/** Schone lei: tabellen leeg en de audiomap leeg. Tests lekken anders in elkaar. */
51function leeg() {
52 for (const t of ['playlist_tracks', 'playlists', 'audio_tracks', 'media']) {
53 try { db.prepare(`DELETE FROM ${t}`).run(); } catch { /* tabel bestaat niet */ }
54 }
55 for (const f of fs.readdirSync(AUDIO)) { try { fs.unlinkSync(path.join(AUDIO, f)); } catch { /* niets */ } }
56}
57
58/** Een nummer met zijn bestand op de plek waar de SPELER het zoekt. */
59function track(id, titel, { padInDb = null, schrijf = true } = {}) {
60 const naam = `${id}.mp3`;
61 const echt = path.join(AUDIO, naam);
62 if (schrijf) fs.writeFileSync(echt, BYTES(id));
63 db.prepare('INSERT OR REPLACE INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
64 .run(`m-${id}`, 's1', naam, 'audio/mpeg', 1, padInDb || echt);
65 db.prepare('INSERT OR REPLACE INTO audio_tracks (id, site_id, title, artist, media_id) VALUES (?,?,?,?,?)')
66 .run(id, 's1', titel, 'Robin', `m-${id}`);
67 return echt;
68}
69
70test('een nummer met een VEROUDERD pad in de database gaat gewoon mee', () => {
71 leeg();
72 // Dit is de stille moordenaar. Op sound-fabrics.com wees storage_path voor
73 // 124 van de 139 nummers nog naar /srv/prutfolio/storage/audio, van voor een
74 // dataverhuizing. De speler merkte niets (die zoekt op bestandsnaam in
75 // AUDIO_ROOT), de exporter vond niets en liet ze weg. Zonder klacht.
76 track('oudpad', 'Verouderd pad', { padInDb: '/srv/ergens/anders/oudpad.mp3' });
77 const uit = AX.buildArchive('me');
78 const rij = JSON.parse(uit.files.get('tracks.json').toString('utf8')).orderedItems.find((x) => x.id === 'oudpad');
79 assert.equal(rij['shaer:availability'], 'included',
80 'de exporter hoort te zoeken zoals de speler zoekt, niet zoals de database onthoudt');
81 assert.deepEqual(uit.files.get(rij['shaer:file']), BYTES('oudpad'));
82});
83
84test('de hele rondgang: exporteren, importeren, en het geluid staat er', () => {
85 leeg();
86 track('a', 'Nummer A');
87 track('b', 'Nummer B');
88 const uit = AX.buildArchive('me');
89 assert.equal(uit.counts.tracks, 2);
90
91 // Een LEGE tweede installatie, met zijn eigen mappen.
92 const TMP2 = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-audio2-'));
93 const doelAudio = path.join(TMP2, 'audio');
94 fs.mkdirSync(doelAudio, { recursive: true });
95
96 // AUDIO_ROOT ligt vast bij import, dus we importeren in DEZELFDE map en
97 // controleren daarna dat het bestand er staat onder de naam uit het archief.
98 leeg();
99 assert.equal(fs.readdirSync(AUDIO).length, 0, 'schone lei');
100
101 const rapport = AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test' });
102 assert.equal(rapport.tracks, 2, 'beide nummers komen terug');
103 assert.equal(rapport.tracksMissing, 0);
104
105 const rijen = db.prepare('SELECT t.id, t.title, m.storage_path, m.size FROM audio_tracks t JOIN media m ON m.id = t.media_id ORDER BY t.id').all();
106 assert.equal(rijen.length, 2);
107 for (const r of rijen) {
108 assert.ok(fs.existsSync(r.storage_path), `${r.title}: het bestand hoort er ECHT te staan`);
109 assert.ok(r.size > 0, 'en de grootte klopt, geen 0 zoals bij de kapotte import');
110 assert.ok(r.storage_path.startsWith(path.resolve(AUDIO_ROOT) + path.sep),
111 'audio hoort in AUDIO_ROOT, niet onder de publieke mediamap');
112 }
113 fs.rmSync(TMP2, { recursive: true, force: true });
114});
115
116test('een nummer zonder bestand wordt NIET aangemaakt', () => {
117 // De kern van de klacht: 13 nummers die bestonden en niet speelden. Liever
118 // eerlijk weg dan zichtbaar kapot.
119 leeg();
120 track('zoek', 'Zoekgeraakt', { padInDb: '/bestaat/echt/niet.mp3', schrijf: false });
121 const uit = AX.buildArchive('me');
122 assert.equal(uit.counts.audioMissing, 1);
123
124 leeg();
125 const rapport = AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test' });
126 assert.equal(rapport.tracks, 0);
127 assert.equal(rapport.tracksMissing, 1);
128 assert.equal(db.prepare('SELECT COUNT(*) n FROM audio_tracks').get().n, 0,
129 'geen rij zonder geluid: dat is precies de val die soundfabrics.nl opleverde');
130 assert.ok(rapport.waarschuwingen.some((w) => /Zoekgeraakt/.test(w)),
131 'en de gebruiker hoort te weten welk nummer er niet is');
132});
133
134test('playlists komen mee, met hun volgorde', () => {
135 leeg();
136
137 track('p1', 'Eerste');
138 track('p2', 'Tweede');
139 track('p3', 'Derde');
140 db.prepare("INSERT INTO playlists (id, site_id, title, artist, kind) VALUES ('pl1','s1','Mijn plaat','Robin','album')").run();
141 // Bewust NIET op alfabet: de volgorde is de hele playlist.
142 db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('pl1','p3',0),('pl1','p1',1),('pl1','p2',2)").run();
143
144 const uit = AX.buildArchive('me');
145 assert.equal(uit.counts.playlists, 1);
146
147 leeg();
148
149 const rapport = AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test' });
150 assert.equal(rapport.playlists, 1);
151 const pl = db.prepare("SELECT * FROM playlists WHERE id = 'pl1'").get();
152 assert.equal(pl.title, 'Mijn plaat');
153 assert.equal(pl.kind, 'album');
154 const volgorde = db.prepare("SELECT track_id FROM playlist_tracks WHERE playlist_id = 'pl1' ORDER BY position").all().map((r) => r.track_id);
155 assert.deepEqual(volgorde, ['p3', 'p1', 'p2'], 'de volgorde IS de playlist');
156});
157
158test('een playlist verwijst nooit naar een nummer dat niet aankwam', () => {
159 // Anders staat er een plaat met gaten erin die je niet kunt afspelen, en dat
160 // is weer dezelfde soort halve waarheid.
161 leeg();
162
163 track('heel', 'Heel');
164 track('stuk', 'Stuk', { padInDb: '/weg/stuk.mp3', schrijf: false });
165 db.prepare("INSERT INTO playlists (id, site_id, title) VALUES ('pl2','s1','Half')").run();
166 db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('pl2','heel',0),('pl2','stuk',1)").run();
167
168 const uit = AX.buildArchive('me');
169 leeg();
170
171 const rapport = AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test' });
172 const items = db.prepare("SELECT track_id FROM playlist_tracks WHERE playlist_id = 'pl2'").all().map((r) => r.track_id);
173 assert.deepEqual(items, ['heel']);
174 assert.ok(rapport.waarschuwingen.some((w) => /Half/.test(w)), 'en het wordt gemeld');
175});
176
177test('een droogloop telt de nummers maar schrijft niets', () => {
178 leeg();
179 track('d1', 'Droog');
180 const uit = AX.buildArchive('me');
181
182 leeg();
183
184 const droog = AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test', dryRun: true });
185 assert.equal(droog.tracks, 1, 'het verslag hoort te zeggen hoeveel nummers er zouden komen');
186 assert.equal(db.prepare('SELECT COUNT(*) n FROM audio_tracks').get().n, 0, 'maar er staat niets');
187 assert.equal(fs.readdirSync(AUDIO).length, 0, 'en er is geen bestand geschreven');
188});
189
190
191test('de hoes van een nummer reist mee als BESTAND, niet als losse verwijzing', () => {
192 // Robin na de tweede ronde: "de audio tracks hadden images, die zijn niet
193 // meegegaan". cover_url ging wel mee als string en het bestand niet, dus kwam
194 // een nummer aan met een verwijzing naar een plaatje dat er niet was.
195 // Dezelfde fout als bij de audio zelf, een laag hoger.
196 leeg();
197 const hoesDir = path.join(MEDIA, 'hoes');
198 fs.mkdirSync(hoesDir, { recursive: true });
199 const PNG = Buffer.from('nep-png-bytes');
200 fs.writeFileSync(path.join(hoesDir, 'a.png'), PNG);
201 track('h1', 'Met hoes');
202 db.prepare("UPDATE audio_tracks SET cover_url = '/media/hoes/a.png' WHERE id = 'h1'").run();
203 db.prepare("INSERT INTO playlists (id, site_id, title, cover_url) VALUES ('plh','s1','Plaat','/media/hoes/a.png')").run();
204 db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('plh','h1',0)").run();
205
206 const uit = AX.buildArchive('me');
207 const rij = JSON.parse(uit.files.get('tracks.json').toString('utf8')).orderedItems[0];
208 assert.ok(rij['shaer:coverFile'], 'de hoes hoort een plek in het archief te hebben');
209 assert.deepEqual(uit.files.get(rij['shaer:coverFile']), PNG, 'met de echte bytes erin');
210 const pl = JSON.parse(uit.files.get('playlists.json').toString('utf8')).orderedItems[0];
211 assert.ok(pl['shaer:coverFile'], 'en de hoes van de plaat ook');
212
213 leeg();
214 AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test' });
215 const t = db.prepare("SELECT cover_url FROM audio_tracks WHERE id = 'h1'").get();
216 assert.ok(t.cover_url, 'na de import wijst het nummer naar een hoes');
217 const opSchijf = path.join(MEDIA, t.cover_url.replace(/^\/media\//, ''));
218 assert.deepEqual(fs.readFileSync(opSchijf), PNG, 'en die staat er ook echt');
219});
220
221test('een hoes die niet in het archief zit levert GEEN kapotte verwijzing op', () => {
222 // Liever geen hoes dan een <img> die 404't. Dezelfde regel als bij de tracks.
223 leeg();
224 track('h2', 'Hoes zoek');
225 db.prepare("UPDATE audio_tracks SET cover_url = '/media/bestaat/niet.png' WHERE id = 'h2'").run();
226 const uit = AX.buildArchive('me');
227 const rij = JSON.parse(uit.files.get('tracks.json').toString('utf8')).orderedItems[0];
228 assert.equal(rij['shaer:coverFile'], undefined);
229
230 leeg();
231 AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test' });
232 assert.equal(db.prepare("SELECT cover_url FROM audio_tracks WHERE id = 'h2'").get().cover_url, null,
233 'geen verwijzing naar een plaatje dat er niet is');
234});
235
236test('ook een zip-import buigt links naar de bronpost om', () => {
237 // Robin: "het moet wel gebeuren bij migratie direct ook". Een zip-import is
238 // net zo goed een verhuizing, dus dezelfde regel.
239 leeg();
240 try { db.prepare('DELETE FROM posts').run(); } catch { /* leeg */ }
241 db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at)
242 VALUES ('pl1','s1','u1','tiktik','TikTik','<p><a href="https://oud.test/tiktik?fc=2">luister</a></p>','published','2026-01-01T10:00:00Z')`).run();
243 const uit = AX.buildArchive('me');
244
245 db.prepare('DELETE FROM posts').run();
246 // De import doet alsof het archief van oud.test komt; deze site is oud.test
247 // niet, dus de links moeten om.
248 const files = new Map(uit.files);
249 const man = JSON.parse(files.get('manifest.json').toString('utf8'));
250 man.origin = 'https://oud.test';
251 files.set('manifest.json', Buffer.from(JSON.stringify(man)));
252
253 const r = AI.importArchive(files, { slug: 'me', origin: 'https://nieuw.test' });
254 const c = db.prepare("SELECT content FROM posts WHERE slug = 'tiktik'").get().content;
255 assert.ok(c.includes('href="/tiktik?fc=2"'), `omgebogen naar hier, kreeg: ${c}`);
256 assert.ok(!c.includes('oud.test'), 'niets wijst meer naar de bron');
257 assert.equal(r.linksBijgetrokken, 1, 'en het verslag zegt het');
258});
259
260test.after(() => { try { fs.rmSync(TMP, { recursive: true, force: true }); } catch { /* niets */ } });
Note: See TracBrowser for help on using the repository browser.