Index: test/moved-lockdown.test.js
===================================================================
--- test/moved-lockdown.test.js	(revision 476672022a20c0df74a19fd6716f8d446cb72fe4)
+++ test/moved-lockdown.test.js	(revision 476672022a20c0df74a19fd6716f8d446cb72fe4)
@@ -0,0 +1,127 @@
+// Een verhuisd account gaat op slot aan de UITGAANDE kant.
+//
+// Het serveerde wel `movedTo`, maar je kon er gewoon op posten, volgen, liken en
+// reageren, en dat federeerde de wereld in. Drie dingen gingen daar mis: nieuwe
+// posts kregen een object-URI op een adres dat je hebt opgezegd, je volgers waren
+// al verhuisd dus je postte in het niets, en een server die je movedTo ziet én
+// verse activiteit van dat adres krijgt, krijgt tegenstrijdige signalen.
+//
+// De poort staat in de SERVICE en niet op de knoppen: een C2S-client praat
+// rechtstreeks met deze functies en zou anders langs een verborgen knop lopen.
+// Deze tests leggen per soort vast wat dicht gaat en wat open blijft, want dat
+// onderscheid is het hele ontwerp.
+import { test, beforeEach } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://oud.example';
+
+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 AP = await import('../src/services/ActivityPubService.js');
+
+const NIEUW = 'https://nieuw.example/ap/users/robo';
+
+function site(moved) {
+  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', 'ik', 'Mijn site', 'u1');
+  db.prepare('UPDATE sites SET moved_to = ? WHERE slug = ?').run(moved || null, 'ik');
+  return db.prepare('SELECT * FROM sites WHERE slug = ?').get('ik');
+}
+
+// Alles hier stil: de poort logt met opzet een waarschuwing per weigering.
+async function stil(fn) {
+  const w = console.warn; const l = console.log;
+  console.warn = () => {}; console.log = () => {};
+  try { return await fn(); } finally { console.warn = w; console.log = l; }
+}
+
+beforeEach(() => { db.prepare('DELETE FROM ap_my_reactions').run(); });
+
+test('movedLock leest de verhuizing', () => {
+  assert.equal(AP.movedLock(site(null)).locked, false);
+  const l = AP.movedLock(site(NIEUW));
+  assert.equal(l.locked, true);
+  assert.equal(l.movedTo, NIEUW);
+});
+
+test('een onzinnige moved_to zet niets op slot', () => {
+  // Alleen een echte http(s)-URI telt; een half ingevuld veld mag je site niet
+  // stilleggen zonder dat er een wegwijzer tegenover staat.
+  assert.equal(AP.movedLock(site('nogniet')).locked, false);
+});
+
+// ── Wat DICHT gaat ────────────────────────────────────────────────
+
+test('volgen wordt geweigerd', async () => {
+  const r = await stil(() => AP.followActor(site(NIEUW), '@iemand@elders.example'));
+  assert.equal(r.error, 'moved');
+  assert.equal(r.movedTo, NIEUW, 'de weigering zegt ook waarheen, zodat de UI dat kan tonen');
+});
+
+test('liken en boosten worden geweigerd', async () => {
+  for (const kind of ['like', 'boost']) {
+    const r = await stil(() => AP.sendInteraction(site(NIEUW), kind, 'https://elders.example/notes/1', 'https://elders.example/users/a'));
+    assert.equal(r.error, 'moved', `${kind} hoort dicht te zijn`);
+  }
+});
+
+test('reageren wordt geweigerd', async () => {
+  const r = await stil(() => AP.deliverReply(site(NIEUW), { postId: 'p1', parent: 'https://elders.example/notes/1', text: 'hoi' }));
+  assert.equal(r.error, 'moved');
+});
+
+test('stemmen in een peiling wordt geweigerd', async () => {
+  const r = await stil(() => AP.voteOnRemotePoll(site(NIEUW), 'https://elders.example/notes/9', [0]));
+  assert.equal(r.error, 'moved');
+});
+
+test('een tweede verhuizing wordt geweigerd', async () => {
+  const r = await stil(() => AP.moveAccount(site(NIEUW), '@nog1@ergens.example'));
+  assert.equal(r.error, 'already_moved',
+    'anders stapel je wegwijzers en weet niemand waar de keten eindigt');
+});
+
+test('de lokale like-vlag wordt OOK niet gezet', async () => {
+  // Anders zie je een like staan die nooit de deur uit is gegaan: de halve
+  // toestand die erger is dan een duidelijke weigering.
+  site(NIEUW);
+  await stil(() => AP.setReaction('ik', 'https://elders.example/notes/1', 'like', true));
+  const n = db.prepare('SELECT count(*) c FROM ap_my_reactions').get().c;
+  assert.equal(n, 0);
+});
+
+// ── Wat OPEN blijft ───────────────────────────────────────────────
+
+test('zonder verhuizing werkt alles gewoon', async () => {
+  const s = site(null);
+  // followActor gaat het netwerk op, dus we kijken alleen dat hij NIET op de
+  // verhuis-poort strandt. Elke andere fout is hier prima.
+  const r = await stil(() => AP.followActor(s, '@iemand@elders.example'));
+  assert.notEqual(r && r.error, 'moved');
+
+  await stil(() => AP.setReaction('ik', 'https://elders.example/notes/1', 'like', true));
+  assert.equal(db.prepare('SELECT count(*) c FROM ap_my_reactions').get().c, 1,
+    'de lokale vlag hoort gewoon gezet te worden als je niet verhuisd bent');
+});
+
+test('ontvolgen blijft mogen, ook na een verhuizing', async () => {
+  // Opruimen mag altijd: het maakt niets nieuws aan en het laat je je oude
+  // account netjes achterlaten.
+  const r = await stil(() => AP.unfollowActor(site(NIEUW), 'https://elders.example/users/a'));
+  assert.notEqual(r && r.error, 'moved');
+});
+
+test('de wegwijzer blijft staan, dat is het hele punt van het domein aanhouden', () => {
+  const doc = AP.buildActor('https://oud.example', site(NIEUW));
+  assert.equal(doc.movedTo, NIEUW,
+    'zonder movedTo weet niemand die de Move miste waar je heen bent');
+  assert.ok(doc.inbox, 'en de inbox blijft, want reacties op oude posts moeten binnen kunnen komen');
+});
