source: Klonkt/test/ward-pwa.test.js@ 5218863

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

De Ward PWA: het gesprek over de hemel, en de zwaai-hand

Plak 4. Een tik op een gezicht opent nu echt iets.

HET GESPREK is een overlay OVER de levende hemel, geen aparte pagina:
de hemel eronder blijft dezelfde draaiende instantie, en het paneel is
doorschijnend met blur, zoals de glazen bubbels in de app. Dezelfde
bron als de C2S-lezing die Shaer gebruikt (conversationHistory):
inkomend de ap_mentions, uitgaand de eigen directe notes. Openen is
lezen: markRead neemt het maximum, dus een oud toestel kan nooit iets
weer ongelezen maken. Verversen is pollen om de acht seconden zolang
het gesprek open staat; het ritme van een gesprek is geen milliseconde,
en een socket kan later zonder iets te slopen.

DE ZWAAI-HAND hangt in dezelfde hoek als de boei en ze wisselen elkaar
af met de stand: met de kaarten verdwijnt de boei, op de hemel hangt de
hand. Slepen op een gezicht opent het paneel met de drie antwoorden, en
de tekst die je kiest IS het bericht. De treffer is sky.pick(): exact
dezelfde rekensom als een tik, dus mikken gedraagt zich overal gelijk.

Changed files:
src/routes/ward.js

  • /api/thread: beide kanten, cursor, markRead bij openen
  • /api/message: direct bericht of zwaai (shaer:wave reist mee)

src/assets/js/ward-sky.js

  • pick() als publieke mikfunctie

src/assets/js/ward.js

  • thread-overlay met poll, verstuurvak, Escape sluit
  • de hand: slepen, mikken, paneel, versturen

src/views/pages/ward.ejs

  • thread, hand en zwaai-paneel in de schil

src/assets/css/ward.css

  • doorschijnend gesprek, bubbels, hand, paneel

src/services/i18n.js

  • tien sleutels in nl, en, de

test/ward-pwa.test.js

  • het gesprek draagt beide kanten en de zwaai-vlag overleeft; versturen eist een echte ontvanger en echte tekst

remarks: 897 tests groen. Nog niet geport: de (+) voor een nieuw
gesprek, Friends/Orbit-panelen, de composer met media, de mini-speler
en de themakleur; beads volgen. Slepen met een echte vinger is aan
Robin op dev.

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

  • Property mode set to 100644
File size: 10.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});
Note: See TracBrowser for help on using the repository browser.