Index: test/following-csv.test.js
===================================================================
--- test/following-csv.test.js	(revision 096f099d60e7e389151f34725f1774d271426dcb)
+++ test/following-csv.test.js	(revision 096f099d60e7e389151f34725f1774d271426dcb)
@@ -0,0 +1,141 @@
+// Wie je volgt gaat mee bij een verhuizing.
+//
+// Dit ontbrak: de exporter dekte posts, tracks en antwoorden, maar niet je
+// relaties. De Move vertelt je VOLGERS waar je heen ging; niets vertelde JOU wie
+// jij volgde. Die lijst stond alleen in de database die je achterlaat.
+//
+// Kolomvorm van Mastodon, met een vijfde kolom van ons erachter, zodat de lijst
+// beide kanten op werkt. Een uitwisselformaat dat alleen met zichzelf praat is
+// geen uitwisselformaat.
+import { test, beforeEach } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+{
+  const stil = console.log;
+  console.log = () => {};
+  try { dbMod.initializeDatabase(); } finally { console.log = stil; }
+}
+
+const { followingCsv, parseFollowingCsv } = await import('../src/services/ArchiveExportService.js');
+const { importFollowing } = await import('../src/services/ArchiveImportService.js');
+
+const volg = db.prepare(`INSERT INTO ap_following (slug, actor_uri, handle, status, auto_boost, highlighted)
+                         VALUES (?,?,?,?,?,?)`);
+
+beforeEach(() => {
+  db.prepare('DELETE FROM ap_following').run();
+});
+
+test('de CSV heeft de Mastodon-koppen, met Featured erachter', () => {
+  volg.run('me', 'https://a.example/ap/users/jason', '@jason@a.example', 'accepted', 1, 1);
+  const csv = followingCsv('me');
+  const [kop, eerste] = csv.trim().split('\n');
+  assert.equal(kop, 'Account address,Show boosts,Notify on new posts,Languages,Featured');
+  // Zonder de leidende @, want zo schrijft Mastodon het adres.
+  assert.equal(eerste, 'jason@a.example,true,false,,true');
+});
+
+test('een openstaand verzoek gaat NIET mee', () => {
+  volg.run('me', 'https://a.example/ap/users/wacht', '@wacht@a.example', 'pending', 0, 0);
+  volg.run('me', 'https://a.example/ap/users/ja', '@ja@a.example', 'accepted', 0, 0);
+  const regels = followingCsv('me').trim().split('\n').slice(1);
+  assert.equal(regels.length, 1, 'een verzoek dat iemand bewust liet liggen mag niet opnieuw verstuurd worden');
+  assert.match(regels[0], /^ja@a\.example/);
+});
+
+test('zonder handle valt hij terug op de actor-URI', () => {
+  volg.run('me', 'https://a.example/ap/users/naamloos', null, 'accepted', 0, 0);
+  assert.match(followingCsv('me'), /https:\/\/a\.example\/ap\/users\/naamloos/);
+});
+
+test('een lege lijst levert geen bestand op', () => {
+  assert.equal(followingCsv('me'), null);
+});
+
+test('een export leest zichzelf terug', () => {
+  volg.run('me', 'https://a.example/ap/users/a', '@a@a.example', 'accepted', 1, 0);
+  volg.run('me', 'https://b.example/ap/users/b', '@b@b.example', 'accepted', 0, 1);
+  const terug = parseFollowingCsv(followingCsv('me'));
+  assert.deepEqual(terug, [
+    { address: 'a@a.example', autoBoost: true, highlighted: false },
+    { address: 'b@b.example', autoBoost: false, highlighted: true },
+  ]);
+});
+
+test('een bestand uit Mastodon werkt, ook zonder onze vijfde kolom', () => {
+  const uit = parseFollowingCsv(
+    'Account address,Show boosts,Notify on new posts,Languages\n'
+    + 'iemand@mastodon.social,true,false,\n',
+  );
+  assert.deepEqual(uit, [{ address: 'iemand@mastodon.social', autoBoost: true, highlighted: false }]);
+});
+
+test('een kale lijst adressen werkt ook, zonder kopregel', () => {
+  const uit = parseFollowingCsv('@een@a.example\ntwee@b.example\n');
+  assert.deepEqual(uit.map((r) => r.address), ['een@a.example', 'twee@b.example']);
+});
+
+test('een geciteerd veld met een komma erin blijft heel', () => {
+  const uit = parseFollowingCsv('Account address,Show boosts\n"raar,naam@a.example",true\n');
+  assert.equal(uit[0].address, 'raar,naam@a.example');
+  assert.equal(uit[0].autoBoost, true);
+});
+
+test('importFollowing volgt elke regel, met de boost-stand mee', async () => {
+  const gezien = [];
+  const r = await importFollowing({ slug: 'me' },
+    'Account address,Show boosts,Notify on new posts,Languages,Featured\n'
+    + 'a@a.example,true,false,,false\n'
+    + 'b@b.example,false,false,,true\n',
+    { followFn: async (site, adres, boost) => { gezien.push([adres, boost]); return true; } });
+
+  assert.deepEqual(gezien, [['a@a.example', true], ['b@b.example', false]]);
+  assert.equal(r.gevolgd, 2);
+  assert.equal(r.mislukt.length, 0);
+});
+
+test('jezelf volgen wordt overgeslagen', async () => {
+  const r = await importFollowing({ slug: 'me' },
+    'Account address\nme@eigen.example\nander@a.example\n',
+    { followFn: async () => true });
+  assert.equal(r.overgeslagen, 1);
+  assert.equal(r.gevolgd, 1);
+});
+
+test('een mislukte follow stopt de rest niet, en wordt gemeld', async () => {
+  const r = await importFollowing({ slug: 'me' },
+    'Account address\neen@a.example\nkapot@b.example\ndrie@c.example\n',
+    { followFn: async (s, adres) => { if (adres.startsWith('kapot')) throw new Error('onbereikbaar'); return true; } });
+
+  assert.equal(r.gevolgd, 2, 'de derde moet nog geprobeerd zijn na de tweede');
+  assert.deepEqual(r.mislukt, [{ adres: 'kapot@b.example', reden: 'onbereikbaar' }]);
+});
+
+test('zonder followFn gebeurt er niets, en dat zegt hij ook', async () => {
+  const r = await importFollowing({ slug: 'me' }, 'Account address\na@a.example\n', {});
+  assert.equal(r.error, 'no_follow_fn');
+  assert.equal(r.gevolgd, 0);
+});
+
+// De koppeling zelf. De losse functies hierboven kunnen prima werken terwijl het
+// archief de CSV nooit ziet, en dat is precies het soort stil gat waar een
+// verhuizing op strandt: alles groen, en je volglijst blijft achter.
+test('buildArchive stopt following.csv er echt in, met hash in het manifest', async () => {
+  db.prepare('INSERT OR IGNORE INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+    .run('u1', 'u1', 'u1@test', 'x', 'god');
+  db.prepare('INSERT OR IGNORE INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)')
+    .run('s1', 'me', 'Mijn site', 'u1');
+  volg.run('me', 'https://a.example/ap/users/jason', '@jason@a.example', 'accepted', 1, 1);
+
+  const { buildArchive } = await import('../src/services/ArchiveExportService.js');
+  const r = buildArchive('me', { origin: 'https://oud.example' });
+
+  assert.ok(r.files.has('following.csv'), 'het archief moet de volglijst dragen');
+  assert.equal(r.counts.following, 1);
+  assert.ok(r.manifest.files['following.csv'], 'en hij hoort in het manifest, anders telt hij niet mee bij de controle');
+  assert.match(String(r.files.get('following.csv')), /jason@a\.example,true,false,,true/);
+});
