source: Klonkt/test/ward-pwa.test.js@ 5412cbb

Last change on this file since 5412cbb was 5412cbb, checked in by Robin <roboburr@…>, 3 months ago

Ward-PWA: de (+) voor een nieuw gesprek

De hemel toont alleen met wie je al sprak; de (+) is de deur naar de
rest. Hij zit op de OORSPRONG van het veld en pant en zoomt met de
gezichten mee, zodat hij altijd het midden van de vliegende avatars is,
waar de hemel ook heen drijft. Precies waar de plaatsing bewust ruimte
vrijhoudt: elk gezicht hangt minstens 1.4 cel naar buiten.

De tik kiest eerst een gezicht en pas daarna de (+), met een raakzone
die meeschaalt met de zoom. De kiezer erachter toont iedereen met een
volgrelatie, welke kant op dan ook: dezelfde bron als de Connect-pagina
(listConnections), want de vraag is dezelfde. Kies iemand en het lege
gesprek opent gewoon; het eerste bericht maakt het echt.

Changed files:
src/routes/ward.js

  • /api/friends: following + followers samengevoegd, met naam en icoon

src/assets/js/ward-sky.js

  • de (+) getekend op de veldoorsprong; onNew naast onPick

src/assets/js/ward.js

  • de vriendenkiezer, die doorvalt in openThread

src/views/pages/ward.ejs + src/assets/css/ward.css

  • de kiezer als dialoog

src/services/i18n.js

  • twee sleutels in nl, en, de

test/ward-pwa.test.js

  • beide richtingen zijn een deur, en elke deur heeft een naam

remarks: 899 tests groen.

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

  • Property mode set to 100644
File size: 12.0 KB
RevLine 
[303bfc4]1// De Ward PWA (/ward): de ward-kant van hetzelfde vangnet, in de browser.
2//
3// Wat hier bewaakt wordt is niet de opmaak maar de POORT en de INHOUD: een
4// vreemde mag deze pagina niet zien, en wat er wel doorheen komt moet de drie
5// dingen dragen waarvoor het scherm bestaat (wie waakt over mij, wat wacht er
6// op hen, en de §4-uitkomst die zegt of dat vangnet heel is).
7import { test } from 'node:test';
8import assert from 'node:assert/strict';
9
10process.env.DATABASE_PATH = ':memory:';
11process.env.PUBLIC_BASE_URL = 'https://kind.example';
12
13const dbMod = await import('../src/config/database.js');
14const db = dbMod.default;
15dbMod.initializeDatabase();
16const express = (await import('express')).default;
17const wardRoutes = (await import('../src/routes/ward.js')).default;
18
19db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
20 .run('u1', 'mee', 'mee@t', 'x', 'user');
21db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,1)')
22 .run('s1', 'mee', 'Mee', 'u1');
23
24// Twee guardians: oma is in orde, en een tweede die zelf een ward is. Die
25// laatste is het geval uit §4: een guardian met eigen guardians is er geen.
26const OMA = 'https://elders.example/ap/users/oma';
27const KAPOT = 'https://elders.example/ap/users/kapot';
28for (const [uri, handle] of [[OMA, '@oma@elders.example'], [KAPOT, '@kapot@elders.example']]) {
29 db.prepare(`INSERT INTO ap_guardianships (slug, role, other_uri, other_handle, status, created_at)
30 VALUES (?, 'ward', ?, ?, 'accepted', datetime('now'))`).run('mee', uri, handle);
31}
32
33/// Een sessie die zegt dat je ingelogd bent als deze gebruiker.
34function appWith(session) {
35 const app = express();
36 // Zelfde view-opzet als de echte server, anders kan express het sjabloon
37 // niet vinden en faalt de render met "no default engine".
38 app.set('view engine', 'ejs');
39 app.set('views', new URL('../src/views', import.meta.url).pathname);
40 app.use((req, res, next) => { req.session = session; res.locals = {}; next(); });
41 app.use('/ward', wardRoutes);
42 return app;
43}
44
45async function listen(app) {
46 const server = app.listen(0);
47 await new Promise((r) => server.once('listening', r));
48 return { server, port: server.address().port };
49}
50
51test('een vreemde komt er niet in', async () => {
52 // Geen sessie: requireAuth hoort dit te weren. Zonder deze poort zou het
53 // vangnet van een kind voor iedereen te lezen zijn.
54 const { server, port } = await listen(appWith({}));
55 try {
56 const r = await fetch(`http://127.0.0.1:${port}/ward/api/state`, { redirect: 'manual' });
57 assert.notEqual(r.status, 200, 'een niet-ingelogde lezer kreeg de ward-toestand');
58 } finally { server.close(); }
59});
60
61test('de toestand draagt de guardians en wat er op hen wacht', async () => {
62 // Een follow die de guardians nog moeten goedkeuren (§5.3 uitgaand).
63 const G = await import('../src/services/guardianship/outgoing.js');
64 // De velden heten zoals recordPending ze leest: id, target, inbox, handle.
65 G.recordPending('mee', {
66 id: 'of-test-1',
67 target: 'https://elders.example/ap/users/tante',
68 handle: '@tante@elders.example',
69 inbox: 'https://elders.example/inbox',
70 quorum: 'any',
71 });
72
73 const { server, port } = await listen(appWith({ user: { id: 'u1' } }));
74 try {
75 const r = await fetch(`http://127.0.0.1:${port}/ward/api/state`);
76 assert.equal(r.status, 200);
77 const s = await r.json();
78
79 assert.equal(s.slug, 'mee');
80 assert.deepEqual(s.guardians.map((g) => g.uri).sort(), [OMA, KAPOT].sort());
81 // Beschikbaarheid hoort erbij: een guardian die weg is verandert de echte
82 // maat van je vangnet.
83 assert.ok(s.guardians.every((g) => typeof g.availability === 'string'));
84
85 assert.equal(s.waitingFollows.length, 1);
86 assert.equal(s.waitingFollows[0].handle, '@tante@elders.example');
87 // Niet 'sent' en niet mislukt: dit is de derde uitkomst.
88 assert.equal(s.waitingFollows[0].status, 'pending');
89 } finally { server.close(); }
90});
91
92test('de pagina serveert zichzelf compleet, zonder inline script', async () => {
93 const { server, port } = await listen(appWith({ user: { id: 'u1' } }));
94 try {
95 const r = await fetch(`http://127.0.0.1:${port}/ward/`);
96 assert.equal(r.status, 200);
97 const html = await r.text();
98 // De begintoestand reist mee, zodat het scherm niet leeg begint.
99 assert.match(html, /id="ward-state"/);
100 assert.match(html, /application\/json/);
101 // CSP: alle logica komt uit een bestand, nooit uit de pagina zelf.
102 assert.doesNotMatch(html, /<script(?![^>]*(src=|type="application\/json"))/);
103 assert.match(html, /\/ward\/app\.js/);
104 // En het is een installeerbare PWA, geen losse pagina.
105 assert.match(html, /rel="manifest" href="\/ward\/manifest\.webmanifest"/);
106 } finally { server.close(); }
107});
108
109test('het manifest wijst naar zijn eigen scope', async () => {
110 const { server, port } = await listen(appWith({ user: { id: 'u1' } }));
111 try {
112 const m = await (await fetch(`http://127.0.0.1:${port}/ward/manifest.webmanifest`)).json();
113 assert.equal(m.scope, '/ward/');
114 assert.equal(m.display, 'standalone');
115 // Eigen scope EN eigen start_url: anders opent de geïnstalleerde app in
116 // het guardian-dashboard, en dat is de andere kant van deze relatie.
117 assert.match(m.start_url, /^\/ward/);
118 } finally { server.close(); }
119});
[a5a6249]120
121test('de feed levert afgemaakte kaarten, niet kale content', async () => {
122 // Een post in de tijdlijn van dit kind.
123 db.prepare(`INSERT INTO ap_timeline (slug, id, author_uri, author_handle, author_name, content, url, published, created_at)
124 VALUES ('mee', 'p-1', ?, '@vriend@elders.example', 'Vriend', '<p>hallo</p>', 'https://elders.example/n/1', datetime('now'), datetime('now'))`)
125 .run('https://elders.example/ap/users/vriend');
126
127 const { server, port } = await listen(appWith({ user: { id: 'u1' } }));
128 try {
129 const r = await fetch(`http://127.0.0.1:${port}/ward/api/feed`);
130 assert.equal(r.status, 200);
131 const { items } = await r.json();
132 assert.equal(items.length, 1);
133 // De body komt AF van de server: dezelfde partial als de Krant. Alleen
134 // `content` doorgeven zou media en quote-kaarten laten verdwijnen.
135 assert.ok(items[0].bodyHtml, 'de kaart kwam zonder gerenderde body');
136 assert.ok(items[0].whenText, 'geen leesbare tijd');
137 // De id is waar de boei op mikt.
138 assert.equal(items[0].id, 'p-1');
139 } finally { server.close(); }
140});
141
142test('de boei weigert als er niemand is om te vragen', async () => {
143 // Een kind ZONDER guardians. Dan is er niets om naartoe te sturen, en dat
144 // moet het zeggen in plaats van te doen alsof er hulp onderweg is.
145 db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
146 .run('u2', 'alleen', 'a@t', 'x', 'user');
147 db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)')
148 .run('s2', 'alleen', 'Alleen', 'u2');
149
150 const { server, port } = await listen(appWith({ user: { id: 'u2' } }));
151 try {
152 const r = await fetch(`http://127.0.0.1:${port}/ward/api/help`, {
153 method: 'POST',
154 headers: { 'Content-Type': 'application/json' },
155 body: JSON.stringify({ words: 'help' }),
156 });
157 assert.equal(r.status, 409);
158 assert.equal((await r.json()).error, 'no_guardians');
159 } finally { server.close(); }
160});
161
162test('een vreemde kan de boei niet afvuren', async () => {
163 // Zonder deze poort zou iedereen namens een kind zijn guardians kunnen
164 // alarmeren.
165 const { server, port } = await listen(appWith({}));
166 try {
167 const r = await fetch(`http://127.0.0.1:${port}/ward/api/help`, {
168 method: 'POST', headers: { 'Content-Type': 'application/json' },
169 body: JSON.stringify({ words: 'x' }), redirect: 'manual',
170 });
171 assert.notEqual(r.status, 200);
172 } finally { server.close(); }
173});
[5218863]174
175
176test('het gesprek draagt beide kanten en een zwaai heet een zwaai', async () => {
177 // Eén binnengekomen bericht (ap_mentions) en één eigen antwoord (ap_outbox,
178 // direct). Het gesprek moet ze allebei dragen, elk met zijn richting.
179 const VRIEND = 'https://elders.example/ap/users/vriend';
180 db.prepare(`INSERT INTO ap_mentions (slug, object_uri, actor_uri, actor_name, actor_handle, content, wave, published, created_at)
181 VALUES ('mee', 'https://elders.example/n/w1', ?, 'Vriend', '@vriend@elders.example', '<p>👋</p>', 1, datetime('now'), datetime('now'))`)
182 .run(VRIEND);
183 // post_id is NOT NULL (een reply hangt normaal onder een post); een direct
184 // bericht draagt daar een leeg anker, precies zoals deliverDirectNote doet.
185 db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, in_reply_to, content, visibility, to_actors, created_at)
186 VALUES ('out-1', 'mee', '', NULL, '<p>hoi terug</p>', 'direct', ?, datetime('now', '+1 minute'))`)
187 .run(JSON.stringify([VRIEND]));
188
189 const { server, port } = await listen(appWith({ user: { id: 'u1' } }));
190 try {
191 const r = await fetch(`http://127.0.0.1:${port}/ward/api/thread?with=${encodeURIComponent(VRIEND)}`);
192 assert.equal(r.status, 200);
193 const { items } = await r.json();
194 const dirs = items.map((i) => i.dir).sort();
195 assert.deepEqual(dirs, ['in', 'out'], 'een kant van het gesprek ontbreekt');
196 // De zwaai-vlag overleeft de reis: de client toont hem groter, en dat kan
197 // alleen als de server hem niet als kale tekst plat slaat.
198 assert.equal(items.find((i) => i.dir === 'in').wave, true);
199 } finally { server.close(); }
200});
201
202test('een bericht sturen eist een echte ontvanger en echte tekst', async () => {
203 const { server, port } = await listen(appWith({ user: { id: 'u1' } }));
204 try {
205 const post = (body) => fetch(`http://127.0.0.1:${port}/ward/api/message`, {
206 method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
207 });
208 // Geen uri: weigeren, want "aan niemand" bestaat niet.
209 assert.equal((await post({ to: 'vriend', text: 'hoi' })).status, 400);
210 // Lege tekst: weigeren, een leeg bericht versturen kan niet.
211 assert.equal((await post({ to: 'https://elders.example/ap/users/vriend', text: ' ' })).status, 400);
212 } finally { server.close(); }
213});
[4bb93aa]214
215test('hidden wint van elke display-regel in de stylesheet', async () => {
216 // De gespreks-overlay heeft display:flex; zonder een [hidden]-regel met
217 // !important verslaat die de browser-default en staat een "verborgen"
218 // overlay permanent over de pagina (Robins schermafdruk, 12-8). De CSS is
219 // hier de waarheid, dus die bewaken we, niet de DOM.
220 const fs = await import('node:fs');
221 const css = fs.readFileSync(new URL('../src/assets/css/ward.css', import.meta.url), 'utf8');
222 assert.match(css, /\[hidden\]\s*\{\s*display:\s*none\s*!important/, 'de [hidden]-regel ontbreekt');
223 // En hij moet VOOR de #thread-regel staan is niet nodig (important wint),
224 // maar de flex-regel moet er wel zijn, anders bewaakt deze test niets.
225 assert.match(css, /#thread\s*\{[^}]*display:\s*flex/);
226});
[5412cbb]227
228test('de vrienden achter de (+) komen uit beide richtingen', async () => {
229 // Iemand die JIJ volgt en iemand die JOU volgt: allebei een deur. De hemel
230 // toont alleen met wie je al sprak; de kiezer moet ruimer zijn, anders kun
231 // je nooit een EERSTE bericht sturen.
232 db.prepare(`INSERT INTO ap_following (slug, actor_uri, handle, name, icon, follow_id, status)
233 VALUES ('mee', 'https://elders.example/ap/users/tante', '@tante@elders.example', 'Tante', NULL, 'f-1', 'accepted')`).run();
234 db.prepare(`INSERT INTO ap_followers (slug, actor_uri, handle, name)
235 VALUES ('mee', 'https://elders.example/ap/users/neef', '@neef@elders.example', 'Neef')`).run();
236
237 const { server, port } = await listen(appWith({ user: { id: 'u1' } }));
238 try {
239 const r = await fetch(`http://127.0.0.1:${port}/ward/api/friends`);
240 assert.equal(r.status, 200);
241 const { items } = await r.json();
242 const uris = items.map((f) => f.uri);
243 assert.ok(uris.includes('https://elders.example/ap/users/tante'), 'wie je volgt ontbreekt');
244 assert.ok(uris.includes('https://elders.example/ap/users/neef'), 'wie jou volgt ontbreekt');
245 // Elke deur heeft een leesbare naam, ook zonder display name.
246 assert.ok(items.every((f) => f.name && f.handle));
247 } finally { server.close(); }
248});
Note: See TracBrowser for help on using the repository browser.