Index: test/gates-family.test.js
===================================================================
--- test/gates-family.test.js	(revision 3b43e4c42e059a39a2ea3ecd827e68d6c1d8a4e6)
+++ test/gates-family.test.js	(revision 3b43e4c42e059a39a2ea3ecd827e68d6c1d8a4e6)
@@ -0,0 +1,105 @@
+// De gate-familie functioneel (shaer-ahy.1, Barts opdracht 8-8): niet alleen
+// een toets in het paneel, maar een poort die echt dichtkan -- bij de
+// AFLEVERING (wat dicht is wordt nooit geserialiseerd) of bij de INNAME (wat
+// de ward niet mag versturen wordt aan de outbox geweigerd).
+//
+// De twee dingen die hier stil kunnen breken: een poort die dicht lijkt maar
+// niets tegenhoudt (een guardian waant het kind beschermd), en een poort die
+// het hulpkanaal mee afsluit (de boei MOET door elke dichte deur heen).
+//
+// In-memory SQLite. Run: npm test
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = await import('../src/services/ActivityPubService.js');
+const Guardianship = await import('../src/services/guardianship/index.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'kind', 'Kind', 'u1');
+const site = () => db.prepare('SELECT * FROM sites WHERE id = ?').get('s1');
+const user = { id: 'u1', username: 'u1' };
+
+test('elke beschikbare gate in de catalogus heeft een kolom', () => {
+  // De bewaker van de bedrading: een catalogusrij zonder kolom is een toets
+  // die nergens op aangesloten is, en dat merkt pas iemand die erop drukt.
+  for (const g of Guardianship.gated.GATE_CATALOGUE.filter((x) => x.available !== false)) {
+    if (g.fixed) continue;   // shaer:follows is een stroom, geen stand
+    assert.ok(Guardianship.gated.featureColumn(g.feature), `${g.feature} mist een kolom`);
+  }
+});
+
+test('de generieke regel: expliciet wint, de automatiek sluit voor een ward', () => {
+  assert.equal(Guardianship.wardGateAllowed(null, false), true);
+  assert.equal(Guardianship.wardGateAllowed(null, true), false);
+  assert.equal(Guardianship.wardGateAllowed(1, true), true);    // guardians openden hem
+  assert.equal(Guardianship.wardGateAllowed(0, false), false);  // expliciete 0 wint ook zonder wardstatus
+});
+
+test('gateAttachments: beeld en muziek dicht laten de rest staan, leeg wordt undefined', () => {
+  const atts = [
+    { type: 'Document', mediaType: 'image/webp', url: 'https://x/p.webp' },
+    { type: 'Audio', mediaType: 'audio/mpeg', url: 'https://x/a.mp3' },
+    { type: 'Document', mediaType: 'video/mp4', url: 'https://x/v.mp4' },
+  ];
+  const zonderBeeld = AP.gateAttachments(atts, { images: false });
+  assert.deepEqual(zonderBeeld.map((a) => a.mediaType), ['audio/mpeg', 'video/mp4']);
+  const zonderMuziek = AP.gateAttachments(atts, { audio: false });
+  assert.deepEqual(zonderMuziek.map((a) => a.mediaType), ['image/webp', 'video/mp4']);
+  // Alles weg is undefined, zoals de serialisatie een leeg veld overal weglaat.
+  assert.equal(AP.gateAttachments([atts[0]], { images: false }), undefined);
+  // En de poort open verandert niets.
+  assert.equal(AP.gateAttachments(atts, {}).length, 3);
+});
+
+test('stripEmojiTags haalt alleen de Emoji-tags weg', () => {
+  const tags = [
+    { type: 'Emoji', name: ':hartje:', icon: { url: 'https://x/h.png' } },
+    { type: 'Mention', href: 'https://x/u', name: '@u' },
+    { type: 'Hashtag', name: '#muziek' },
+  ];
+  const uit = AP.stripEmojiTags(tags);
+  assert.deepEqual(uit.map((t) => t.type), ['Mention', 'Hashtag']);
+  assert.equal(AP.stripEmojiTags([tags[0]]), undefined);
+});
+
+test('compose dicht weigert een eigen post aan de outbox, maar geen antwoord', async () => {
+  db.prepare("UPDATE sites SET gate_compose = 0 WHERE slug = 'kind'").run();
+  const post = { type: 'Create', object: { type: 'Note', content: '<p>hoi</p>', to: ['https://www.w3.org/ns/activitystreams#Public'] } };
+  const uit = await AP.ingestOutboxActivity(site(), user, post);
+  assert.equal(uit.status, 403);
+  assert.equal(uit.error, 'gated_compose');
+  // Een antwoord is meedoen aan een gesprek, geen eigen podium: dat valt
+  // onder messages/het gesprek, niet onder compose.
+  const reply = { type: 'Create', object: { type: 'Note', content: '<p>ja!</p>', inReplyTo: 'https://elders/x', to: ['https://www.w3.org/ns/activitystreams#Public'] } };
+  const uit2 = await AP.ingestOutboxActivity(site(), user, reply);
+  assert.notEqual(uit2.error, 'gated_compose');
+  db.prepare("UPDATE sites SET gate_compose = NULL WHERE slug = 'kind'").run();
+});
+
+test('messages dicht weigert een direct bericht, maar NOOIT de reddingsboei', async () => {
+  db.prepare("UPDATE sites SET gate_messages = 0 WHERE slug = 'kind'").run();
+  const dm = { type: 'Create', object: { type: 'Note', content: '<p>psst</p>', to: ['https://elders/ap/users/vreemde'] } };
+  const uit = await AP.ingestOutboxActivity(site(), user, dm);
+  assert.equal(uit.status, 403);
+  assert.equal(uit.error, 'gated_messages');
+  // De boei gaat door elke dichte deur heen: een poort die het hulpkanaal
+  // afsnijdt beschermt niemand.
+  const hulp = { type: 'Create', object: { type: 'Note', content: '<p>🛟</p>', 'shaer:helpRequest': true, to: ['https://elders/ap/users/oma'] } };
+  const uit2 = await AP.ingestOutboxActivity(site(), user, hulp);
+  assert.notEqual(uit2.error, 'gated_messages');
+  db.prepare("UPDATE sites SET gate_messages = NULL WHERE slug = 'kind'").run();
+});
+
+test('accountMove dicht weigert de verhuizing voordat er iets vertrekt', async () => {
+  db.prepare("UPDATE sites SET gate_account_move = 0 WHERE slug = 'kind'").run();
+  const uit = await AP.moveAccount(site(), '@iemand@elders.example');
+  assert.equal(uit.error, 'guarded_account');
+  db.prepare("UPDATE sites SET gate_account_move = NULL WHERE slug = 'kind'").run();
+});
