mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
Device-owner tier substrate + silent install, end-to-end QR provisioning (Android 12+ compliance, APK-derived checksum, URL pre-seed, zero-touch onboarding, guided a11y screen), content-expiry (#157) with player no-restart deferral, and the device-enhancement batch (#10/#12/#13/#14). Backward-compatible with 1.9.3 clients; all autonomous behaviors opt-in. QA + security review green. Closes #161, #157, #159.
97 lines
5.2 KiB
JavaScript
97 lines
5.2 KiB
JavaScript
'use strict';
|
|
|
|
// #157 auto-deactivate expired content. Exercises the two hard parts against a real DB:
|
|
// 1. buildSnapshotItems (via publishPlaylist) drops expired/inactive content but keeps
|
|
// live content, future-expiry content, and non-content (widget/content_id-NULL) items.
|
|
// 2. sweepExpiredContent flips is_active=0 for newly-expired items and republishes ONLY
|
|
// the published playlists that carried them — once, idempotently, blast-radius-limited.
|
|
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const crypto = require('node:crypto');
|
|
process.env.DATA_DIR = path.join(os.tmpdir(), 'st-expiry-' + crypto.randomBytes(4).toString('hex'));
|
|
process.env.SELF_HOSTED = 'true';
|
|
process.env.NODE_ENV = 'test';
|
|
|
|
const { test, before } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { db } = require('../db/database');
|
|
const { publishPlaylist } = require('../routes/playlists');
|
|
const { sweepExpiredContent } = require('../services/content-expiry');
|
|
|
|
const now = () => Math.floor(Date.now() / 1000);
|
|
const uid = (p) => p + '-' + crypto.randomBytes(4).toString('hex');
|
|
|
|
function mkContent(id, { expires_at = null, is_active = 1 } = {}) {
|
|
db.prepare("INSERT INTO content (id, filename, mime_type, expires_at, is_active) VALUES (?, ?, 'video/mp4', ?, ?)")
|
|
.run(id, id + '.mp4', expires_at, is_active);
|
|
}
|
|
function snapshotIds(playlistId) {
|
|
const row = db.prepare('SELECT published_snapshot FROM playlists WHERE id = ?').get(playlistId);
|
|
return JSON.parse(row.published_snapshot || '[]').map(i => i.content_id || '(widget)');
|
|
}
|
|
|
|
let USER;
|
|
before(() => {
|
|
USER = uid('u');
|
|
db.prepare("INSERT INTO users (id, email, password_hash) VALUES (?, ?, 'x')").run(USER, USER + '@t.local');
|
|
});
|
|
|
|
test('publish snapshot: keeps live/future/widget items, drops expired + inactive', () => {
|
|
const live = uid('live'), future = uid('future'), pastC = uid('past'), inactive = uid('inact');
|
|
mkContent(live);
|
|
mkContent(future, { expires_at: now() + 3600 });
|
|
mkContent(pastC, { expires_at: now() - 3600 }); // expired but is_active still 1
|
|
mkContent(inactive, { is_active: 0 }); // already deactivated
|
|
|
|
const pl = uid('pl');
|
|
db.prepare("INSERT INTO playlists (id, user_id, name, status) VALUES (?, ?, 'P', 'draft')").run(pl, USER);
|
|
let so = 0;
|
|
for (const cid of [live, future, pastC, inactive]) {
|
|
db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order) VALUES (?, ?, ?)').run(pl, cid, so++);
|
|
}
|
|
// A content_id-NULL item (widget-style) must always survive the filter.
|
|
db.prepare("INSERT INTO playlist_items (playlist_id, content_id, widget_id, sort_order) VALUES (?, NULL, NULL, ?)")
|
|
.run(pl, so++);
|
|
|
|
publishPlaylist(pl, null);
|
|
const ids = snapshotIds(pl);
|
|
assert.deepEqual(ids.sort(), [live, future, '(widget)'].sort(),
|
|
'snapshot should contain only live + future-expiry content + the widget row');
|
|
assert.ok(!ids.includes(pastC) && !ids.includes(inactive), 'expired + inactive dropped');
|
|
});
|
|
|
|
test('sweep: deactivates newly-expired, republishes only affected PUBLISHED playlists, idempotent', () => {
|
|
const expC = uid('sweepexp'), liveC = uid('sweeplive');
|
|
mkContent(expC, { expires_at: now() + 3600 }); // published while VALID (future expiry)...
|
|
mkContent(liveC);
|
|
|
|
// Published playlist that carries the item -> should be republished once it expires.
|
|
const plPub = uid('plpub');
|
|
db.prepare("INSERT INTO playlists (id, user_id, name, status) VALUES (?, ?, 'pub', 'draft')").run(plPub, USER);
|
|
db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order) VALUES (?, ?, 0)').run(plPub, expC);
|
|
db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order) VALUES (?, ?, 1)').run(plPub, liveC);
|
|
publishPlaylist(plPub, null); // status -> published, snapshot has BOTH
|
|
assert.ok(snapshotIds(plPub).includes(expC), 'precondition: item is in the published snapshot while valid');
|
|
|
|
// ...now it expires (models real life: valid at publish, expires later).
|
|
db.prepare('UPDATE content SET expires_at = ? WHERE id = ?').run(now() - 10, expC);
|
|
|
|
// Draft playlist with the same item -> must NOT be republished (drafts don't serve).
|
|
const plDraft = uid('pldraft');
|
|
db.prepare("INSERT INTO playlists (id, user_id, name, status) VALUES (?, ?, 'draft', 'draft')").run(plDraft, USER);
|
|
db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order) VALUES (?, ?, 0)').run(plDraft, expC);
|
|
|
|
const res = sweepExpiredContent(null);
|
|
assert.ok(res.expired.includes(expC), 'sweep reports the expired content id');
|
|
assert.equal(db.prepare('SELECT is_active FROM content WHERE id = ?').get(expC).is_active, 0, 'is_active flipped to 0');
|
|
assert.ok(res.republished.includes(plPub), 'published playlist republished');
|
|
assert.ok(!res.republished.includes(plDraft), 'draft playlist NOT republished');
|
|
assert.ok(!snapshotIds(plPub).includes(expC), 'republished snapshot no longer carries the expired item');
|
|
assert.ok(snapshotIds(plPub).includes(liveC), 'the live sibling item survives');
|
|
|
|
// Second sweep: nothing new (already-processed marker prevents a re-republish).
|
|
const res2 = sweepExpiredContent(null);
|
|
assert.ok(!res2.expired.includes(expC), 'idempotent: expired item not reprocessed');
|
|
});
|