openvibers/openvibe.live Commits
Live: comment threads are addressed by access id; resolve takes a thread with no numeric id (C-14 step 1) (#96)
Changes
nnew file mode 100644@@@ -0,0 +1,6 @@++-- phase: expand++-- Compatibility register C-14: Live addresses OpenVibe.Community threads by their access id (cth_…, already stored in++-- comment_thread_refs.access_id), and Community stops handing services the numeric thread id. A thread resolved from++-- now on may have no numeric id, so thread_id becomes nullable; rows made before keep theirs, used only to recognise a++-- parent comment during the switch. A later contract migration drops the column.++ALTER TABLE comment_thread_refs ALTER COLUMN thread_id DROP NOT NULL;@@@ -78,18 +78,34 @@ async function call(method, path, { body, act = {}, ip = null, timeoutMs = 5000,return out;}--/** The Community thread of a VOD/clip (resolved once, then remembered). → { id, access_id } */++/**++ * The Community thread of a VOD/clip (resolved once, then remembered). → { id, access_id, ids }++ * `id` is the thread's access id (cth_…), the one id Live sends Community (compatibility register C-14: services++ * address threads as browsers do). `ids` is every id a Community answer may name this thread by: the access id, and++ * the numeric id Community handed services before C-14 (kept in comment_thread_refs.thread_id for rows made then).++ */async function threadFor(type, id, label, { timeoutMs, ip } = {}) {if (!TYPES.includes(type)) throw new CommentsError(400, 'Invalid content type');const known = await db.getDb().prepare('SELECT thread_id, access_id FROM comment_thread_refs WHERE content_type = ? AND content_id = ?').get(type, Number(id));-- if (known) return { id: known.thread_id, access_id: known.access_id };++ if (known) return threadOf(known.access_id, known.thread_id);const ref = { service: 'live', type, id: String(id) };if (label) ref.label = String(label).slice(0, 200);const out = await call('POST', '/threads/resolve', { body: { ref }, timeoutMs, ip });const t = out && out.thread;-- if (!t || !Number.isInteger(t.id) || !t.access_id) throw unavailable('resolve');-- await db.getDb().prepare('INSERT INTO comment_thread_refs (content_type, content_id, thread_id, access_id) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING').run(type, Number(id), t.id, t.access_id);-- return { id: t.id, access_id: t.access_id };++ if (!t || !ACCESS_ID_RE.test(String(t.access_id || ''))) throw unavailable('resolve');++ const numeric = Number.isInteger(t.id) ? t.id : null;++ await db.getDb().prepare('INSERT INTO comment_thread_refs (content_type, content_id, thread_id, access_id) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING').run(type, Number(id), numeric, t.access_id);++ return threadOf(t.access_id, numeric);++}++++const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;++function threadOf(accessId, numeric) {++ return { id: accessId, access_id: accessId, ids: numeric != null ? [accessId, String(numeric)] : [accessId] };++}++++/** Does a Community answer's thread id (`comment.thread.id`) name this thread? */++function sameThread(thread, otherId) {++ return otherId != null && thread.ids.includes(String(otherId));}/** Read a thread page (as the viewer, if signed in). q: { sort, after, limit, parent } */@@@ -129,7 +145,7 @@ function hideThreadOf(type, id) {_hideQueue = _hideQueue.then(async () => {try {const t = await threadFor(type, id);-- await call('PUT', `/threads/${t.id}/visibility`, { body: { visibility: 'hidden' } });++ await call('PUT', `/threads/${encodeURIComponent(t.id)}/visibility`, { body: { visibility: 'hidden' } });} catch (err) {console.warn(`[Comments] could not hide the comment thread of ${type} ${id}: ${err.message}`);}@@@ -150,6 +166,7 @@ async function liveUserForSubject(subject) {const threadUrl = (accessId) => `${COMMUNITY_PUBLIC_URL()}/c/${encodeURIComponent(accessId)}`;module.exports = {++ sameThread,CommentsError, TYPES, threadFor, readThread, addComment, getComment, editComment, deleteComment,hideThreadOf, liveUserForSubject, subjectForLiveUser, threadUrl,};@@@ -228,7 +228,7 @@ router.post('/:type/:id', requireAuth, async (req, res) => {let parent = null;if (parentId) {parent = await comments.getComment(parentId, subject, { ip: req.ip });-- if (!parent || !parent.thread || parent.thread.id !== thread.id) {++ if (!parent || !parent.thread || !comments.sameThread(thread, parent.thread.id)) {return res.status(400).json({ error: 'Invalid parent comment' });}}@@@ -75,6 +75,7 @@ async function check(name, fn) {100: { id: 100, user_id: 3, title: 'Public VOD', visibility: 'public', is_public: 1, status: 'ready' },101: { id: 101, user_id: 3, title: 'Private VOD', visibility: 'private', is_public: 0, status: 'ready' },102: { id: 102, user_id: 3, title: 'Unlisted VOD', visibility: 'unlisted', is_public: 0, status: 'ready' },++ 103: { id: 103, user_id: 3, title: 'Later VOD', visibility: 'public', is_public: 1, status: 'ready' },};const CLIPS = { 200: { id: 200, user_id: 5, stream_id: streamA, title: 'Public clip', visibility: 'public', is_public: 1, status: 'ready' } };const missing = (what) => new media.MediaApiError(`${what} not found`, 404, { error: `${what} not found` });@@@ -174,7 +175,7 @@ async function check(name, fn) {const asOwner = (await call('GET', '/api/comments/vod/100', 3)).json;assert.ok(asOwner.comments.every((c) => c.can_delete), 'the VOD\'s owner may delete any comment on it');assert.ok(!asOwner.comments[0].can_edit, 'but edit only their own');-- assert.strictEqual(last('GET', /^\/threads\/\d+$/).subject, SUB('B'), 'read as the viewer');++ assert.strictEqual(last('GET', /^\/threads\/cth_[^/]+$/).subject, SUB('B'), 'read as the viewer, by the thread\'s access id (C-14)');const page = (await call('GET', '/api/comments/vod/100?limit=1&offset=1')).json;assert.deepStrictEqual(page.comments.map((c) => c.message), ['great stream']);assert.ok(!r.text.includes('legacy local row'), 'Live\'s own table is not read');@@@ -255,6 +256,34 @@ async function check(name, fn) {stub.down = false;});++ await check('once Community names threads by access id for services (C-14), resolve, comment, reply and hide still work', async () => {++ // A parent on a thread resolved before the switch (VOD 100's, remembered with its numeric id).++ const before = await call('POST', '/api/comments/vod/100', 3, { message: 'said before the switch' });++ assert.strictEqual(before.status, 201, before.text);++ stub.serviceIds = 'access';++ try {++ // A thread resolved now has no numeric id: comment_thread_refs keeps the access id only.++ const fresh = await call('POST', '/api/comments/vod/103', 3, { message: 'first comment on a later VOD' });++ assert.strictEqual(fresh.status, 201, fresh.text);++ const row = await raw.prepare("SELECT thread_id, access_id FROM comment_thread_refs WHERE content_type = 'vod' AND content_id = 103").get();++ assert.strictEqual(row.thread_id, null, 'no numeric id to keep');++ assert.ok(/^cth_/.test(row.access_id));++ const reply = await call('POST', '/api/comments/vod/103', 5, { message: 'reply under the access id', parent_id: fresh.json.comment.id });++ assert.strictEqual(reply.status, 201, `reply on the new thread: ${reply.text}`);++ // A thread resolved before the switch: Community now names it by access id, and the reply still matches.++ const old = await call('POST', '/api/comments/vod/100', 5, { message: 'reply on an older thread', parent_id: before.json.comment.id });++ assert.strictEqual(old.status, 201, `reply on the older thread: ${old.text}`);++ // A parent in another thread is still refused.++ const cross = await call('POST', '/api/comments/vod/103', 5, { message: 'cross', parent_id: before.json.comment.id });++ assert.strictEqual(cross.status, 400, cross.text);++ // Every call Live made addresses the thread by its access id.++ const sent = stub.calls.filter((c) => /^\/threads\/[^/]+\/comments$/.test(c.path)).slice(-3).map((c) => c.path.split('/')[2]);++ assert.ok(sent.every((id) => /^cth_/.test(decodeURIComponent(id))), sent.join(','));++ } finally {++ stub.serviceIds = 'numeric';++ }++ });++await check('deleting a VOD hides its Community thread', async () => {const r = await call('DELETE', '/api/vods/100', 3);assert.strictEqual(r.status, 200, r.text);@@@ -9,6 +9,7 @@* const stub = await startCommunityStub({ people: { usr_…: { username, display_name } } });* process.env.OV_COMMUNITY_INTERNAL_URL = stub.url;* stub.down = true; // every request fails (connection reset) until set back++ * stub.serviceIds = 'access'; // name threads by their access id (cth_…), as Community does for services since C-14* stub.calls // [{ method, path, subject, staff, body }]*/const http = require('http');@@@ -18,18 +19,19 @@ function startCommunityStub({ people = {} } = {}) {const threads = []; // { id, access_id, ref, visibility, comment_count }const comments = []; // { id, thread_id, parent_id, author, message, created_at, edited_at, deleted_at }const calls = [];-- const state = { down: false };++ const state = { down: false, serviceIds: 'numeric' };let clock = Date.parse('2026-09-01T00:00:00Z');const now = () => new Date(clock += 1000).toISOString();const personOf = (subject) => (subject ? { subject, username: (people[subject] || {}).username || null, display_name: (people[subject] || {}).display_name || null, avatar_url: null, profile_color: null } : null);const threadBy = (id) => threads.find((t) => String(t.id) === String(id) || t.access_id === id) || null;const replyCount = (c) => comments.filter((r) => r.parent_id === c.id && !r.deleted_at).length;-- const shapeThread = (t) => ({ id: t.id, access_id: t.access_id, ref: t.ref, visibility: t.visibility, comment_count: comments.filter((c) => c.thread_id === t.id && !c.deleted_at).length });++ const tid = (t) => (state.serviceIds === 'access' ? t.access_id : t.id);++ const shapeThread = (t) => ({ id: tid(t), access_id: t.access_id, ref: t.ref, visibility: t.visibility, comment_count: comments.filter((c) => c.thread_id === t.id && !c.deleted_at).length });function shapeComment(c, subject, withReplies) {const deleted = !!c.deleted_at;const out = {-- id: c.id, thread_id: c.thread_id, parent_id: c.parent_id, origin: 'user',++ id: c.id, thread_id: tid(threads.find((t) => t.id === c.thread_id)), parent_id: c.parent_id, origin: 'user',author: deleted ? null : personOf(c.author), anon_name: null,display_name: deleted ? null : ((people[c.author] || {}).display_name || null),message: deleted ? null : c.message, deleted, score: 0, upvotes: 0, downvotes: 0, my_vote: 0,@@@ -129,6 +131,8 @@ function startCommunityStub({ people = {} } = {}) {threads, comments, calls,get down() { return state.down; },set down(v) { state.down = !!v; },++ get serviceIds() { return state.serviceIds; },++ set serviceIds(v) { state.serviceIds = v === 'access' ? 'access' : 'numeric'; },close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(r); }),})));}@@@ -14,12 +14,15 @@ const mk = async (u, key) => {return (await await db.get('SELECT id FROM users WHERE username = ?', [u])).id;};const a = await mk('alice', 'a'.repeat(32)), b = await mk('bob', 'b'.repeat(32));--await db.run("UPDATE users SET created_at = datetime('now', '-3 days') WHERE id = ?", [a]);// Bob: yesterday, and inside a 2-day window (which starts 24 h ago and moves on while the test runs): halfway// between 24 h ago and the end of yesterday (UTC), never on the window's moving edge.const sql = (ms) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ');const startOfToday = Date.parse(`${new Date().toISOString().slice(0, 10)}T00:00:00Z`);await db.run('UPDATE users SET created_at = ? WHERE id = ?', [sql(((Date.now() - 86400000) + (startOfToday - 1000)) / 2), b]);++// Alice: 3 days ago, and inside the 2-day window before that one (it starts 3 days back and moves on too): halfway++// between 3 days ago and the end of that calendar day, never on the moving edge (exactly 3 days ago fell out of++// prev_total whenever a second ticked between this line and the query).++await db.run('UPDATE users SET created_at = ? WHERE id = ?', [sql(((Date.now() - 3 * 86400000) + (startOfToday - 2 * 86400000 - 1000)) / 2), a]);// ── series: registry, zero-filled days, totals ──assert.ok(db.HOME_SERIES_KEYS.includes('users') && db.HOME_SERIES_KEYS.includes('vibes'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+-- phase: expand
+-- Compatibility register C-14: Live addresses OpenVibe.Community threads by their access id (cth_…, already stored in
+-- comment_thread_refs.access_id), and Community stops handing services the numeric thread id. A thread resolved from
+-- now on may have no numeric id, so thread_id becomes nullable; rows made before keep theirs, used only to recognise a
+-- parent comment during the switch. A later contract migration drops the column.
+ALTER TABLE comment_thread_refs ALTER COLUMN thread_id DROP NOT NULL;
@@ -78,18 +78,34 @@ async function call(method, path, { body, act = {}, ip = null, timeoutMs = 5000,
@@ -78,18 +78,34 @@ async function call(method, path, { body, act = {}, ip = null, timeoutMs = 5000,
return out;
return out;
}
}
-/** The Community thread of a VOD/clip (resolved once, then remembered). → { id, access_id } */
+/**
+ * The Community thread of a VOD/clip (resolved once, then remembered). → { id, access_id, ids }
+ * `id` is the thread's access id (cth_…), the one id Live sends Community (compatibility register C-14: services
+ * address threads as browsers do). `ids` is every id a Community answer may name this thread by: the access id, and
+ * the numeric id Community handed services before C-14 (kept in comment_thread_refs.thread_id for rows made then).
+ */
async function threadFor(type, id, label, { timeoutMs, ip } = {}) {
async function threadFor(type, id, label, { timeoutMs, ip } = {}) {
if (!TYPES.includes(type)) throw new CommentsError(400, 'Invalid content type');
if (!TYPES.includes(type)) throw new CommentsError(400, 'Invalid content type');
const known = await db.getDb().prepare('SELECT thread_id, access_id FROM comment_thread_refs WHERE content_type = ? AND content_id = ?').get(type, Number(id));
const known = await db.getDb().prepare('SELECT thread_id, access_id FROM comment_thread_refs WHERE content_type = ? AND content_id = ?').get(type, Number(id));
- if (known) return { id: known.thread_id, access_id: known.access_id };
+ if (known) return threadOf(known.access_id, known.thread_id);
const ref = { service: 'live', type, id: String(id) };
const ref = { service: 'live', type, id: String(id) };
if (label) ref.label = String(label).slice(0, 200);
if (label) ref.label = String(label).slice(0, 200);
const out = await call('POST', '/threads/resolve', { body: { ref }, timeoutMs, ip });
const out = await call('POST', '/threads/resolve', { body: { ref }, timeoutMs, ip });
const t = out && out.thread;
const t = out && out.thread;
- if (!t || !Number.isInteger(t.id) || !t.access_id) throw unavailable('resolve');
- await db.getDb().prepare('INSERT INTO comment_thread_refs (content_type, content_id, thread_id, access_id) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING').run(type, Number(id), t.id, t.access_id);
- return { id: t.id, access_id: t.access_id };
+ if (!t || !ACCESS_ID_RE.test(String(t.access_id || ''))) throw unavailable('resolve');
+ const numeric = Number.isInteger(t.id) ? t.id : null;
+ await db.getDb().prepare('INSERT INTO comment_thread_refs (content_type, content_id, thread_id, access_id) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING').run(type, Number(id), numeric, t.access_id);
+ return threadOf(t.access_id, numeric);
+}
+
+const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;
+function threadOf(accessId, numeric) {
+ return { id: accessId, access_id: accessId, ids: numeric != null ? [accessId, String(numeric)] : [accessId] };
+}
+
+/** Does a Community answer's thread id (`comment.thread.id`) name this thread? */
+function sameThread(thread, otherId) {
+ return otherId != null && thread.ids.includes(String(otherId));
}
}
/** Read a thread page (as the viewer, if signed in). q: { sort, after, limit, parent } */
/** Read a thread page (as the viewer, if signed in). q: { sort, after, limit, parent } */
@@ -129,7 +145,7 @@ function hideThreadOf(type, id) {
@@ -129,7 +145,7 @@ function hideThreadOf(type, id) {
_hideQueue = _hideQueue.then(async () => {
_hideQueue = _hideQueue.then(async () => {
try {
try {
const t = await threadFor(type, id);
const t = await threadFor(type, id);
- await call('PUT', `/threads/${t.id}/visibility`, { body: { visibility: 'hidden' } });
+ await call('PUT', `/threads/${encodeURIComponent(t.id)}/visibility`, { body: { visibility: 'hidden' } });
} catch (err) {
} catch (err) {
console.warn(`[Comments] could not hide the comment thread of ${type} ${id}: ${err.message}`);
console.warn(`[Comments] could not hide the comment thread of ${type} ${id}: ${err.message}`);
}
}
@@ -150,6 +166,7 @@ async function liveUserForSubject(subject) {
@@ -150,6 +166,7 @@ async function liveUserForSubject(subject) {
const threadUrl = (accessId) => `${COMMUNITY_PUBLIC_URL()}/c/${encodeURIComponent(accessId)}`;
const threadUrl = (accessId) => `${COMMUNITY_PUBLIC_URL()}/c/${encodeURIComponent(accessId)}`;
module.exports = {
module.exports = {
+ sameThread,
CommentsError, TYPES, threadFor, readThread, addComment, getComment, editComment, deleteComment,
CommentsError, TYPES, threadFor, readThread, addComment, getComment, editComment, deleteComment,
hideThreadOf, liveUserForSubject, subjectForLiveUser, threadUrl,
hideThreadOf, liveUserForSubject, subjectForLiveUser, threadUrl,
};
};
@@ -228,7 +228,7 @@ router.post('/:type/:id', requireAuth, async (req, res) => {
@@ -228,7 +228,7 @@ router.post('/:type/:id', requireAuth, async (req, res) => {
let parent = null;
let parent = null;
if (parentId) {
if (parentId) {
parent = await comments.getComment(parentId, subject, { ip: req.ip });
parent = await comments.getComment(parentId, subject, { ip: req.ip });
- if (!parent || !parent.thread || parent.thread.id !== thread.id) {
+ if (!parent || !parent.thread || !comments.sameThread(thread, parent.thread.id)) {
return res.status(400).json({ error: 'Invalid parent comment' });
return res.status(400).json({ error: 'Invalid parent comment' });
}
}
}
}
@@ -75,6 +75,7 @@ async function check(name, fn) {
@@ -75,6 +75,7 @@ async function check(name, fn) {
100: { id: 100, user_id: 3, title: 'Public VOD', visibility: 'public', is_public: 1, status: 'ready' },
100: { id: 100, user_id: 3, title: 'Public VOD', visibility: 'public', is_public: 1, status: 'ready' },
101: { id: 101, user_id: 3, title: 'Private VOD', visibility: 'private', is_public: 0, status: 'ready' },
101: { id: 101, user_id: 3, title: 'Private VOD', visibility: 'private', is_public: 0, status: 'ready' },
102: { id: 102, user_id: 3, title: 'Unlisted VOD', visibility: 'unlisted', is_public: 0, status: 'ready' },
102: { id: 102, user_id: 3, title: 'Unlisted VOD', visibility: 'unlisted', is_public: 0, status: 'ready' },
+ 103: { id: 103, user_id: 3, title: 'Later VOD', visibility: 'public', is_public: 1, status: 'ready' },
};
};
const CLIPS = { 200: { id: 200, user_id: 5, stream_id: streamA, title: 'Public clip', visibility: 'public', is_public: 1, status: 'ready' } };
const CLIPS = { 200: { id: 200, user_id: 5, stream_id: streamA, title: 'Public clip', visibility: 'public', is_public: 1, status: 'ready' } };
const missing = (what) => new media.MediaApiError(`${what} not found`, 404, { error: `${what} not found` });
const missing = (what) => new media.MediaApiError(`${what} not found`, 404, { error: `${what} not found` });
@@ -174,7 +175,7 @@ async function check(name, fn) {
@@ -174,7 +175,7 @@ async function check(name, fn) {
const asOwner = (await call('GET', '/api/comments/vod/100', 3)).json;
const asOwner = (await call('GET', '/api/comments/vod/100', 3)).json;
assert.ok(asOwner.comments.every((c) => c.can_delete), 'the VOD\'s owner may delete any comment on it');
assert.ok(asOwner.comments.every((c) => c.can_delete), 'the VOD\'s owner may delete any comment on it');
assert.ok(!asOwner.comments[0].can_edit, 'but edit only their own');
assert.ok(!asOwner.comments[0].can_edit, 'but edit only their own');
- assert.strictEqual(last('GET', /^\/threads\/\d+$/).subject, SUB('B'), 'read as the viewer');
+ assert.strictEqual(last('GET', /^\/threads\/cth_[^/]+$/).subject, SUB('B'), 'read as the viewer, by the thread\'s access id (C-14)');
const page = (await call('GET', '/api/comments/vod/100?limit=1&offset=1')).json;
const page = (await call('GET', '/api/comments/vod/100?limit=1&offset=1')).json;
assert.deepStrictEqual(page.comments.map((c) => c.message), ['great stream']);
assert.deepStrictEqual(page.comments.map((c) => c.message), ['great stream']);
assert.ok(!r.text.includes('legacy local row'), 'Live\'s own table is not read');
assert.ok(!r.text.includes('legacy local row'), 'Live\'s own table is not read');
@@ -255,6 +256,34 @@ async function check(name, fn) {
@@ -255,6 +256,34 @@ async function check(name, fn) {
stub.down = false;
stub.down = false;
});
});
+ await check('once Community names threads by access id for services (C-14), resolve, comment, reply and hide still work', async () => {
+ // A parent on a thread resolved before the switch (VOD 100's, remembered with its numeric id).
+ const before = await call('POST', '/api/comments/vod/100', 3, { message: 'said before the switch' });
+ assert.strictEqual(before.status, 201, before.text);
+ stub.serviceIds = 'access';
+ try {
+ // A thread resolved now has no numeric id: comment_thread_refs keeps the access id only.
+ const fresh = await call('POST', '/api/comments/vod/103', 3, { message: 'first comment on a later VOD' });
+ assert.strictEqual(fresh.status, 201, fresh.text);
+ const row = await raw.prepare("SELECT thread_id, access_id FROM comment_thread_refs WHERE content_type = 'vod' AND content_id = 103").get();
+ assert.strictEqual(row.thread_id, null, 'no numeric id to keep');
+ assert.ok(/^cth_/.test(row.access_id));
+ const reply = await call('POST', '/api/comments/vod/103', 5, { message: 'reply under the access id', parent_id: fresh.json.comment.id });
+ assert.strictEqual(reply.status, 201, `reply on the new thread: ${reply.text}`);
+ // A thread resolved before the switch: Community now names it by access id, and the reply still matches.
+ const old = await call('POST', '/api/comments/vod/100', 5, { message: 'reply on an older thread', parent_id: before.json.comment.id });
+ assert.strictEqual(old.status, 201, `reply on the older thread: ${old.text}`);
+ // A parent in another thread is still refused.
+ const cross = await call('POST', '/api/comments/vod/103', 5, { message: 'cross', parent_id: before.json.comment.id });
+ assert.strictEqual(cross.status, 400, cross.text);
+ // Every call Live made addresses the thread by its access id.
+ const sent = stub.calls.filter((c) => /^\/threads\/[^/]+\/comments$/.test(c.path)).slice(-3).map((c) => c.path.split('/')[2]);
+ assert.ok(sent.every((id) => /^cth_/.test(decodeURIComponent(id))), sent.join(','));
+ } finally {
+ stub.serviceIds = 'numeric';
+ }
+ });
+
await check('deleting a VOD hides its Community thread', async () => {
await check('deleting a VOD hides its Community thread', async () => {
const r = await call('DELETE', '/api/vods/100', 3);
const r = await call('DELETE', '/api/vods/100', 3);
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
* const stub = await startCommunityStub({ people: { usr_…: { username, display_name } } });
* const stub = await startCommunityStub({ people: { usr_…: { username, display_name } } });
* process.env.OV_COMMUNITY_INTERNAL_URL = stub.url;
* process.env.OV_COMMUNITY_INTERNAL_URL = stub.url;
* stub.down = true; // every request fails (connection reset) until set back
* stub.down = true; // every request fails (connection reset) until set back
+ * stub.serviceIds = 'access'; // name threads by their access id (cth_…), as Community does for services since C-14
* stub.calls // [{ method, path, subject, staff, body }]
* stub.calls // [{ method, path, subject, staff, body }]
*/
*/
const http = require('http');
const http = require('http');
@@ -18,18 +19,19 @@ function startCommunityStub({ people = {} } = {}) {
@@ -18,18 +19,19 @@ function startCommunityStub({ people = {} } = {}) {
const threads = []; // { id, access_id, ref, visibility, comment_count }
const threads = []; // { id, access_id, ref, visibility, comment_count }
const comments = []; // { id, thread_id, parent_id, author, message, created_at, edited_at, deleted_at }
const comments = []; // { id, thread_id, parent_id, author, message, created_at, edited_at, deleted_at }
const calls = [];
const calls = [];
- const state = { down: false };
+ const state = { down: false, serviceIds: 'numeric' };
let clock = Date.parse('2026-09-01T00:00:00Z');
let clock = Date.parse('2026-09-01T00:00:00Z');
const now = () => new Date(clock += 1000).toISOString();
const now = () => new Date(clock += 1000).toISOString();
const personOf = (subject) => (subject ? { subject, username: (people[subject] || {}).username || null, display_name: (people[subject] || {}).display_name || null, avatar_url: null, profile_color: null } : null);
const personOf = (subject) => (subject ? { subject, username: (people[subject] || {}).username || null, display_name: (people[subject] || {}).display_name || null, avatar_url: null, profile_color: null } : null);
const threadBy = (id) => threads.find((t) => String(t.id) === String(id) || t.access_id === id) || null;
const threadBy = (id) => threads.find((t) => String(t.id) === String(id) || t.access_id === id) || null;
const replyCount = (c) => comments.filter((r) => r.parent_id === c.id && !r.deleted_at).length;
const replyCount = (c) => comments.filter((r) => r.parent_id === c.id && !r.deleted_at).length;
- const shapeThread = (t) => ({ id: t.id, access_id: t.access_id, ref: t.ref, visibility: t.visibility, comment_count: comments.filter((c) => c.thread_id === t.id && !c.deleted_at).length });
+ const tid = (t) => (state.serviceIds === 'access' ? t.access_id : t.id);
+ const shapeThread = (t) => ({ id: tid(t), access_id: t.access_id, ref: t.ref, visibility: t.visibility, comment_count: comments.filter((c) => c.thread_id === t.id && !c.deleted_at).length });
function shapeComment(c, subject, withReplies) {
function shapeComment(c, subject, withReplies) {
const deleted = !!c.deleted_at;
const deleted = !!c.deleted_at;
const out = {
const out = {
- id: c.id, thread_id: c.thread_id, parent_id: c.parent_id, origin: 'user',
+ id: c.id, thread_id: tid(threads.find((t) => t.id === c.thread_id)), parent_id: c.parent_id, origin: 'user',
author: deleted ? null : personOf(c.author), anon_name: null,
author: deleted ? null : personOf(c.author), anon_name: null,
display_name: deleted ? null : ((people[c.author] || {}).display_name || null),
display_name: deleted ? null : ((people[c.author] || {}).display_name || null),
message: deleted ? null : c.message, deleted, score: 0, upvotes: 0, downvotes: 0, my_vote: 0,
message: deleted ? null : c.message, deleted, score: 0, upvotes: 0, downvotes: 0, my_vote: 0,
@@ -129,6 +131,8 @@ function startCommunityStub({ people = {} } = {}) {
@@ -129,6 +131,8 @@ function startCommunityStub({ people = {} } = {}) {
threads, comments, calls,
threads, comments, calls,
get down() { return state.down; },
get down() { return state.down; },
set down(v) { state.down = !!v; },
set down(v) { state.down = !!v; },
+ get serviceIds() { return state.serviceIds; },
+ set serviceIds(v) { state.serviceIds = v === 'access' ? 'access' : 'numeric'; },
close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(r); }),
close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(r); }),
})));
})));
}
}
@@ -14,12 +14,15 @@ const mk = async (u, key) => {
@@ -14,12 +14,15 @@ const mk = async (u, key) => {
return (await await db.get('SELECT id FROM users WHERE username = ?', [u])).id;
return (await await db.get('SELECT id FROM users WHERE username = ?', [u])).id;
};
};
const a = await mk('alice', 'a'.repeat(32)), b = await mk('bob', 'b'.repeat(32));
const a = await mk('alice', 'a'.repeat(32)), b = await mk('bob', 'b'.repeat(32));
-await db.run("UPDATE users SET created_at = datetime('now', '-3 days') WHERE id = ?", [a]);
// Bob: yesterday, and inside a 2-day window (which starts 24 h ago and moves on while the test runs): halfway
// Bob: yesterday, and inside a 2-day window (which starts 24 h ago and moves on while the test runs): halfway
// between 24 h ago and the end of yesterday (UTC), never on the window's moving edge.
// between 24 h ago and the end of yesterday (UTC), never on the window's moving edge.
const sql = (ms) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ');
const sql = (ms) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ');
const startOfToday = Date.parse(`${new Date().toISOString().slice(0, 10)}T00:00:00Z`);
const startOfToday = Date.parse(`${new Date().toISOString().slice(0, 10)}T00:00:00Z`);
await db.run('UPDATE users SET created_at = ? WHERE id = ?', [sql(((Date.now() - 86400000) + (startOfToday - 1000)) / 2), b]);
await db.run('UPDATE users SET created_at = ? WHERE id = ?', [sql(((Date.now() - 86400000) + (startOfToday - 1000)) / 2), b]);
+// Alice: 3 days ago, and inside the 2-day window before that one (it starts 3 days back and moves on too): halfway
+// between 3 days ago and the end of that calendar day, never on the moving edge (exactly 3 days ago fell out of
+// prev_total whenever a second ticked between this line and the query).
+await db.run('UPDATE users SET created_at = ? WHERE id = ?', [sql(((Date.now() - 3 * 86400000) + (startOfToday - 2 * 86400000 - 1000)) / 2), a]);
// ── series: registry, zero-filled days, totals ──
// ── series: registry, zero-filled days, totals ──
assert.ok(db.HOME_SERIES_KEYS.includes('users') && db.HOME_SERIES_KEYS.includes('vibes'));
assert.ok(db.HOME_SERIES_KEYS.includes('users') && db.HOME_SERIES_KEYS.includes('vibes'));