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

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

De Ward PWA: de feed en de reddingsboei

Plak 2. Nu valt er iets te doen op /ward in plaats van alleen te kijken.

DE FEED. Dezelfde bron als de Krant (getTimeline) en dezelfde partial
voor de body (renderNoteBody), zodat een post er overal hetzelfde uitziet:
opmaak, media, quote-kaart, embed. Alleen kale content doorgeven zou een
lege regel opleveren waar een foto hoort, precies de fout die het
guardian-dashboard ooit had.

DE BOEI, en die is hier BETER dan in de app. Shaer maakt een
schermafdruk van de kaart, met al het gedoe van buiten beeld renderen.
Op de server weten we wélke note het is, dus de hulpvraag draagt de note
zelf mee. Een guardian ziet daardoor de echte post en niet een plaatje
dat er ooit op leek.

Slepen met pointer-events, want dat is het enige dat op een telefoon en
met een muis hetzelfde doet. touch-action: none op de boei houdt de
pagina stil tijdens het slepen; zonder dat vecht het met scrollen en
verlies je hem halverwege. De kaart eronder licht op, want mikken met een
vinger zonder terugkoppeling is gokken. Een tik zonder slepen vraagt hulp
zonder dat er een post bij hoort, en de grote knop bovenaan doet
hetzelfde.

Changed files:
src/routes/ward.js

  • /api/feed met afgemaakte body per kaart
  • /api/help: direct bericht aan ALLE guardians, met de note erbij; 409 als er geen guardians zijn, want dan is er niets te vragen

src/views/pages/ward.ejs

  • feed-sectie, zwevende boei, hulpdialoog

src/assets/js/ward.js

  • kaarten tekenen, slepen, mikken, versturen

src/assets/css/ward.css

  • kaarten, boei, dialoog, sr-only

src/services/i18n.js

  • acht nieuwe sleutels in nl, en, de

test/ward-pwa.test.js

  • de feed levert een gerenderde body; de boei weigert zonder guardians; een vreemde kan hem niet afvuren

remarks: 889 tests groen. Slepen is alleen in code getest, niet met een
echte vinger op een echt scherm; dat is aan Robin op dev.

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

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