// Parent controls for children: per-area and weekend limits, the activity log and alerts to parents, requests, bonus screen time from rewards, the weekly summary. const assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), { Readable } = require('stream'); process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-kidctl-')); process.env.HUB_TZ = 'UTC'; const users = require('../users'), kid = require('../kidlimits'), activity = require('../activity'), inbox = require('../inbox'), requests = require('../requests'), chores = require('../chores'); const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), kai = users.create({ name: 'Kai', role: 'user', tabs: ['chat', 'chores', 'music'], handle: 'Yg' }); users.update(kai.id, { kid: { on: true, limitMin: 120, weekendMin: 240, areaLimits: { video: 15, music: 30, bogus: 5 } } }); const call = (who, url, method = 'GET', body, mod = requests) => new Promise(ok => { const q = Readable.from(body ? [Buffer.from(JSON.stringify(body))] : []); q.url = url; q.method = method; q.headers = {}; users.runAs(who, () => mod.handle(q, { writeHead(c) { this.c = c; }, end(d) { ok({ status: this.c, json: JSON.parse(d) }); } }, who)); }); (async () => { const k = users.get(kai.id).kid; assert.deepEqual(k.areaLimits, { video: 15, music: 30 }, 'only real areas, at least 15 minutes'); assert.equal(k.weekendMin, 240); // per-area time: pings add to the area; the area closes by itself while the rest stays open let st = kid.status(users.get(kai.id)); assert.deepEqual(st.lockedAreas, []); assert.equal(st.areas.music.limit, 30); const realNow = Date.now; let off = 0; Date.now = () => realNow() + off; for (let i = 0; i < 40; i++) { off += 60e3; kid.ping(users.get(kai.id), 'music'); } Date.now = realNow; // (a minute passes between pings) st = kid.status(users.get(kai.id)); assert.ok(st.areas.music.used >= 30 && st.lockedAreas.includes('music'), 'music time used up'); assert.equal(st.locked, null, 'but the app as a whole is open'); assert.equal(kid.lockedFor(users.get(kai.id), '/api/music/x').area, 'music', 'music requests are refused'); assert.equal(kid.lockedFor(users.get(kai.id), '/api/notes/'), null, 'other things still work'); assert.equal(kid.lockedFor(users.get(kai.id), '/api/read/list'), null); assert.ok(inbox.load(ann.id).some(i => /Music time/.test(i.body)), 'the parent is told'); assert.equal(kid.usageByDay(kai.id, 7)[0].areas.music >= 30, true, 'the parents see minutes per area'); // activity: logged, de-duplicated, only for children, alerts rate-limited activity.log(kai.id, 'play', 'Song A'); activity.log(kai.id, 'play', 'Song A'); activity.log(kai.id, 'search', 'dinosaurs'); activity.log(ann.id, 'play', 'Not a child'); assert.equal(activity.recent(kai.id).filter(e => e.title === 'Song A').length, 1, 'the same thing twice counts once'); assert.equal(activity.recent(ann.id).length, 0, 'adults are not logged'); activity.alertParents(users.get(kai.id), 'blocked', 'tried to change the Plex profile'); const n0 = inbox.load(ann.id).length; activity.alertParents(users.get(kai.id), 'blocked', 'tried to change the Plex profile'); assert.equal(inbox.load(ann.id).length, n0, 'the same alert is not repeated'); assert.equal((await call(ann, '/api/kid/activity?user=' + kai.id, 'GET', null, kid)).json.recent.length >= 2, true); assert.equal((await call(kai, '/api/kid/activity?user=' + kai.id, 'GET', null, kid)).status, 403, 'a child cannot read the log'); assert.equal((await call(kai, '/api/kid/activity', 'POST', { kind: 'watch', title: 'A movie' }, kid)).status, 200); assert.ok(activity.recent(kai.id).some(e => e.title === 'A movie' && e.kind === 'watch'), 'the app reports what a child plays'); // requests: ask, parent answers, a yes opens a tab and gives minutes, a no just tells them assert.equal((await call(ann, '/api/kid/requests', 'POST', { text: 'x' })).status, 400, 'only children ask'); let r = (await call(kai, '/api/kid/requests', 'POST', { text: 'Can I watch movies?', tab: 'video', minutes: 20 })).json; assert.equal(r.status, 'open'); assert.ok(inbox.load(ann.id).some(i => /Kai is asking/.test(i.title))); assert.equal((await call(kai, '/api/kid/requests/' + r.id, 'POST', { approve: true })).status, 403, 'a child cannot approve their own'); const before = kid.status(users.get(kai.id)).limit; assert.equal((await call(ann, '/api/kid/requests/' + r.id, 'POST', { approve: true, note: 'Weekend only' })).json.status, 'approved'); assert.ok(users.can(users.get(kai.id), 'video'), 'a yes opens the part of the hub'); assert.equal(kid.status(users.get(kai.id)).limit, before + 20, 'and adds the minutes'); assert.ok(inbox.load(kai.id).some(i => /^Yes: /.test(i.title)), 'the child is told'); r = (await call(kai, '/api/kid/requests', 'POST', { text: 'A new game' })).json; assert.equal((await call(ann, '/api/kid/requests/' + r.id, 'POST', { approve: false, note: 'Not now' })).json.status, 'denied'); assert.equal((await call(ann, '/api/kid/requests/' + r.id, 'POST', { approve: true })).status, 400, 'answered once'); // asking for time from the lock screen makes one request the parent can answer with minutes const t1 = requests.askTime(users.get(kai.id), 'Can I have more time?', 30), t2 = requests.askTime(users.get(kai.id), 'again', 30); assert.equal(t1.id, t2.id, 'asking twice is one request'); const lim1 = kid.status(users.get(kai.id)).limit; assert.equal((await call(ann, '/api/kid/requests/' + t1.id, 'POST', { approve: true, minutes: 15 })).json.minutes, 15, 'the parent chooses how much'); assert.equal(kid.status(users.get(kai.id)).limit, lim1 + 15); // a reward with bonus screen time adds it when the parent marks it given users.update(kai.id, { tabs: [...users.get(kai.id).tabs, 'chores'] }); await call(ann, '/api/chores', 'POST', { title: 'Dishes', repeat: 'daily', assign: 'anyone', points: 10 }, chores); const today = new Date().toISOString().slice(0, 10), ch = (await call(ann, '/api/chores?date=' + today, 'GET', null, chores)).json.chores[0]; await call(kai, '/api/chores/done', 'POST', { chore: ch.id, date: today }, chores); const rw = (await call(ann, '/api/chores/reward', 'POST', { title: 'Extra time', points: 5, minutes: 30 }, chores)).json; assert.equal(rw.minutes, 30); const lim0 = kid.status(users.get(kai.id)).limit; await call(ann, '/api/chores/give', 'POST', { user: kai.id, reward: rw.id }, chores); assert.equal(kid.status(users.get(kai.id)).limit, lim0 + 30, 'the bonus minutes are added'); // the weekly summary goes to the parents once, on Monday morning const mon = new Date('2026-10-26T09:00:00Z'); assert.equal(activity.weeklyDigest(new Date('2026-10-27T09:00:00Z')), false, 'only on Monday'); assert.equal(activity.weeklyDigest(mon), true); assert.ok(inbox.load(ann.id).some(i => /Kai: last week/.test(i.title))); assert.equal(activity.weeklyDigest(mon), false, 'once a week'); console.log('kidcontrols ok'); process.exit(0); })().catch(e => { console.error(e); process.exit(1); });