openvibers/openvibe.quest Commits
Remove account merge event handling (#266)
Changes
@@@ -34,7 +34,6 @@"notYet": ["Community-made quests (streamers, communities, mods): they need moderation, which this release does not have","Repeatable quests and seasonal resets: every quest completes once per person",-- "Account merges (ADR-029): quest_progress, quest_completions and quest_badges rows are not re-keyed when a Network account is merged into another (deletion is answered, ADR-033; a merge's aliases are erased with the account)","Quest chains, leaderboards and per-community quests; a streamer's getting-started steps still live on OpenVibe.Live"],"capabilitiesRegistered": true,@@@ -36,12 +36,11 @@ const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');const catalog = require('./quests/catalog');const { apply } = require('./quests/apply');const accountDataLib = require('./identity/account-data');--const subjectMerge = require('./identity/subject-merge');const CONSUMER = 'quest';--// The catalog's events plus the account topics (ADR-033) and account merges (ADR-029): one subscription list, so boot++// The catalog's events plus the account topics (ADR-033): one subscription list, so boot// subscribes to all of them.--const TOPICS = Object.freeze([...catalog.EVENT_TYPES, ...accountDataLib.TOPICS, subjectMerge.TOPIC]);++const TOPICS = Object.freeze([...catalog.EVENT_TYPES, ...accountDataLib.TOPICS]);const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;function createEventsConsumer({ db, s, secrets = [], coins = null, items = null, accountData = null, accountSend = null, appQuests = null, daily = null, now = () => Date.now(), log = console }) {@@@ -81,21 +80,6 @@ function createEventsConsumer({ db, s, secrets = [], coins = null, items = null,return res.json({ event_id: event.event_id, outcome: accountOutcome });}-- // An account merge (ADR-029) folds one person's rows into the survivor's, once per merge_id-- // (server/identity/subject-merge.js keeps its own ledger, so it never touches quest_event_inbox either).-- if (event.event_type === subjectMerge.TOPIC) {-- let mergeOutcome;-- try {-- mergeOutcome = await subjectMerge.apply(event, { s, log });-- } catch (err) {-- stats.failed++;-- log.error(`[Events consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);-- return problem(503, 'quest.event_failed', 'processing failed; it will be retried');-- }-- if (String(mergeOutcome).startsWith('ignored:')) stats.ignored++;-- else stats.applied++;-- return res.json({ event_id: event.event_id, outcome: mergeOutcome });-- }let outcome;try {@@@ -583,7 +583,7 @@ streamers do not write quests: a quest sends people to do something.</li><li>Deleting your OpenVibe account deletes your progress, completions and badges here, and an account export includesthem (ADR-033).</li><li>When two of your OpenVibe accounts are merged, everything here moves to the account you keep: each step keeps the--higher count, a quest both finished counts once, and each badge keeps its first award (ADR-029).</li>++higher count, a quest both finished counts once, and each badge keeps its first award.</li></ul><p><a href="/quests">The quests</a> · <a href="/updates">What shipped</a></p>`,}));ddeleted file mode 100644@@@ -1,62 +0,0 @@--'use strict';----/**-- * network.subject.merged → Quest (ADR-029). Two Network accounts became one: `from` is now an alias of `into`, and-- * every quest row of `from` becomes `into`'s, once per merge_id (quest_subject_merges):-- *-- * quest_progress each step keeps the higher count of the two (counts are capped at the step's target)-- * quest_completions a quest only `from` finished moves to `into` with its OpenCoins state (a credit still owed is-- * settled to the survivor); one both finished keeps the survivor's, since each wallet was credited-- * once and Network merges the wallets itself-- * quest_badges the earliest award of each badge is kept-- *-- * Delivered to POST /internal/events beside the account topics; it never passes through quest_event_inbox.-- */--const TOPIC = 'network.subject.merged';--const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;--const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/;----/** One envelope → 'merged' | 'unchanged' | 'ignored:<why>'. */--async function apply(ev, { s, log = console }) {-- if (!ev || ev.event_type !== TOPIC) return 'ignored:type';-- if (ev.source !== 'network') return 'ignored:source';-- const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};-- const mergeId = String(p.merge_id || ''), from = String(p.from || ''), into = String(p.into || '');-- if (!MERGE_RE.test(mergeId) || !SUBJECT_RE.test(from) || !SUBJECT_RE.test(into) || from === into) return 'ignored:payload';-- const r = await s.tx(async () => {-- const db = s.db;-- if (await db.maybe('SELECT 1 FROM quest_subject_merges WHERE merge_id = $1', [mergeId])) return null;-- const at = s.iso();-- const progress = Number(await db.value(`WITH moved AS (-- INSERT INTO quest_progress (subject, quest_id, step, count, updated_at)-- SELECT $2, quest_id, step, count, updated_at FROM quest_progress WHERE subject = $1-- ON CONFLICT (subject, quest_id, step) DO UPDATE SET count = GREATEST(quest_progress.count, excluded.count),-- updated_at = GREATEST(quest_progress.updated_at, excluded.updated_at)-- RETURNING 1) SELECT count(*)::int FROM moved`, [from, into]));-- await db.exec('DELETE FROM quest_progress WHERE subject = $1', [from]);-- const completions = Number(await db.value(`WITH moved AS (-- UPDATE quest_completions SET subject = $2 WHERE subject = $1-- AND quest_id NOT IN (SELECT quest_id FROM quest_completions WHERE subject = $2)-- RETURNING 1) SELECT count(*)::int FROM moved`, [from, into]));-- await db.exec('DELETE FROM quest_completions WHERE subject = $1', [from]);-- const badges = Number(await db.value(`WITH moved AS (-- INSERT INTO quest_badges (subject, badge_id, quest_id, awarded_at)-- SELECT $2, badge_id, quest_id, awarded_at FROM quest_badges WHERE subject = $1-- ON CONFLICT (subject, badge_id) DO UPDATE SET awarded_at = LEAST(quest_badges.awarded_at, excluded.awarded_at)-- RETURNING 1) SELECT count(*)::int FROM moved`, [from, into]));-- await db.exec('DELETE FROM quest_badges WHERE subject = $1', [from]);-- await db.exec('INSERT INTO daily_preferences (subject,time_zone) SELECT $2,time_zone FROM daily_preferences WHERE subject=$1 ON CONFLICT (subject) DO NOTHING', [from, into]);-- await db.exec('DELETE FROM daily_preferences WHERE subject=$1', [from]);-- await db.exec('UPDATE daily_events SET subject=$2 WHERE subject=$1', [from, into]);-- await db.exec('INSERT INTO daily_completions (subject,day,completed_at) SELECT $2,day,completed_at FROM daily_completions WHERE subject=$1 ON CONFLICT (subject,day) DO UPDATE SET completed_at=LEAST(daily_completions.completed_at,excluded.completed_at)', [from, into]);-- await db.exec('DELETE FROM daily_completions WHERE subject=$1', [from]);-- await db.exec('INSERT INTO quest_subject_merges (merge_id, from_subject, into_subject, progress, completions, badges, merged_at) VALUES ($1, $2, $3, $4, $5, $6, $7)',-- [mergeId, from, into, progress, completions, badges, at]);-- return { progress, completions, badges };-- });-- if (!r) return 'unchanged';-- log.log(`[Merge] ${mergeId}: ${r.progress} progress step(s), ${r.completions} completion(s) and ${r.badges} badge(s) now the survivor's`);-- return 'merged';--}----module.exports = { apply, TOPIC };ddeleted file mode 100644@@@ -1,69 +0,0 @@--'use strict';--/**-- * ADR-029: network.subject.merged folds one account's quest rows into the survivor's, through the service's own-- * /internal/events route. Progress keeps the higher count per step, a quest both finished counts once (the-- * survivor's), a quest only the folded account finished moves with its coins, each badge keeps its first award, a-- * redelivery moves nothing twice, and the event never touches quest_event_inbox.-- */--const assert = require('assert');--const { boot, check, done } = require('./helpers/boot');--const { signDeliveryHeaders } = require('openvibe-sdk/events');--const questStore = require('../server/quests/store');----const KEEP = 'usr_01JZ0000000000000000000KEP';--const GONE = 'usr_01JZ0000000000000000000GNE';--const OTHER = 'usr_01JZ0000000000000000000OTH';--const SECRET = `whsec_${'fixture'.repeat(6)}`;--const merged = { event_id: 'evt_01JZ0000000000000000000M01', event_type: 'network.subject.merged', source: 'network', payload: { merge_id: 'mrg_01JZ0000000000000000000MRG', from: GONE, into: KEEP } };--const bodyOf = (ev) => JSON.stringify({ event: ev, seq: 1 });----(async () => {-- await check('a merge moves the folded account\'s quests to the survivor once', async () => {-- const t = await boot({ env: { QUEST_EVENTS_SECRET: SECRET } });-- try {-- const s = t.ctx.s;-- const early = '2026-10-01T00:00:00.000Z', late = '2026-10-10T00:00:00.000Z';-- // Both started "go-live": the folded account is further along.-- await questStore.bumpProgress(s.db, { subject: KEEP, questId: 'go-live', step: 0, target: 3, at: late });-- await questStore.bumpProgress(s.db, { subject: GONE, questId: 'go-live', step: 0, target: 3, at: early });-- await questStore.bumpProgress(s.db, { subject: GONE, questId: 'go-live', step: 0, target: 3, at: early });-- // Both finished "follow-someone"; only the folded account finished "first-paste", with coins still owed.-- await questStore.complete(s.db, { id: s.newId('qcp'), subject: KEEP, questId: 'follow-someone', at: late, coins: 10, coinsState: 'credited' });-- await questStore.complete(s.db, { id: s.newId('qcp'), subject: GONE, questId: 'follow-someone', at: early, coins: 10, coinsState: 'credited' });-- await questStore.complete(s.db, { id: s.newId('qcp'), subject: GONE, questId: 'first-paste', at: early, coins: 5, coinsState: 'pending' });-- await questStore.awardBadge(s.db, { subject: KEEP, badgeId: 'first-follow', questId: 'follow-someone', at: late });-- await questStore.awardBadge(s.db, { subject: GONE, badgeId: 'first-follow', questId: 'follow-someone', at: early });-- await questStore.awardBadge(s.db, { subject: GONE, badgeId: 'first-paste', questId: 'first-paste', at: early });-- await questStore.awardBadge(s.db, { subject: OTHER, badgeId: 'first-paste', questId: 'first-paste', at: early });---- const send = async () => await t.get('/internal/events', { method: 'POST', body: bodyOf(merged), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(merged), SECRET) } });-- const res = await send();-- assert.strictEqual(res.status, 200);-- assert.strictEqual(res.json().outcome, 'merged');---- const count = await s.db.value("SELECT count FROM quest_progress WHERE subject = $1 AND quest_id = 'go-live' AND step = 0", [KEEP]);-- assert.strictEqual(Number(count), 2, 'the step keeps the higher count');-- const comps = await s.db.many('SELECT quest_id, completed_at, coins_state FROM quest_completions WHERE subject = $1 ORDER BY quest_id', [KEEP]);-- assert.deepStrictEqual(comps.map((c) => [c.quest_id, c.coins_state]), [['first-paste', 'pending'], ['follow-someone', 'credited']], 'one completion per quest; an owed credit moves to the survivor');-- assert.strictEqual(comps.find((c) => c.quest_id === 'follow-someone').completed_at, late, "a quest both finished keeps the survivor's");-- const badges = await s.db.many('SELECT badge_id, awarded_at FROM quest_badges WHERE subject = $1 ORDER BY badge_id', [KEEP]);-- assert.deepStrictEqual(badges.map((b) => [b.badge_id, b.awarded_at]), [['first-follow', early], ['first-paste', early]], 'each badge keeps its first award');-- for (const table of ['quest_progress', 'quest_completions', 'quest_badges']) {-- assert.strictEqual(Number(await s.db.value(`SELECT count(*)::int FROM ${table} WHERE subject = $1`, [GONE])), 0, `nothing is left under the folded account in ${table}`);-- }-- assert.strictEqual(Number(await s.db.value('SELECT count(*)::int FROM quest_badges WHERE subject = $1', [OTHER])), 1, 'someone else is untouched');---- const again = await send();-- assert.strictEqual(again.json().outcome, 'unchanged', 'a redelivery moves nothing twice');-- assert.strictEqual(Number(await s.db.value('SELECT count(*)::int FROM quest_event_inbox WHERE event_id = $1', [merged.event_id])), 0, 'the merge is not claimed by quest_event_inbox');-- const bad = { ...merged, event_id: 'evt_01JZ0000000000000000000M02', payload: { ...merged.payload, into: GONE } };-- assert.strictEqual((await t.get('/internal/events', { method: 'POST', body: bodyOf(bad), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(bad), SECRET) } })).json().outcome, 'ignored:payload', 'an account merged into itself is ignored');-- } finally { await t.close(); }-- });---- await check('boot subscribes to account merges', async () => {-- assert.ok(require('../server/events-consumer').TOPICS.includes('network.subject.merged'));-- });---- done();--})();
@@ -34,7 +34,6 @@
@@ -34,7 +34,6 @@
"notYet": [
"notYet": [
"Community-made quests (streamers, communities, mods): they need moderation, which this release does not have",
"Community-made quests (streamers, communities, mods): they need moderation, which this release does not have",
"Repeatable quests and seasonal resets: every quest completes once per person",
"Repeatable quests and seasonal resets: every quest completes once per person",
- "Account merges (ADR-029): quest_progress, quest_completions and quest_badges rows are not re-keyed when a Network account is merged into another (deletion is answered, ADR-033; a merge's aliases are erased with the account)",
"Quest chains, leaderboards and per-community quests; a streamer's getting-started steps still live on OpenVibe.Live"
"Quest chains, leaderboards and per-community quests; a streamer's getting-started steps still live on OpenVibe.Live"
],
],
"capabilitiesRegistered": true,
"capabilitiesRegistered": true,
@@ -36,12 +36,11 @@ const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');
@@ -36,12 +36,11 @@ const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');
const catalog = require('./quests/catalog');
const catalog = require('./quests/catalog');
const { apply } = require('./quests/apply');
const { apply } = require('./quests/apply');
const accountDataLib = require('./identity/account-data');
const accountDataLib = require('./identity/account-data');
-const subjectMerge = require('./identity/subject-merge');
const CONSUMER = 'quest';
const CONSUMER = 'quest';
-// The catalog's events plus the account topics (ADR-033) and account merges (ADR-029): one subscription list, so boot
+// The catalog's events plus the account topics (ADR-033): one subscription list, so boot
// subscribes to all of them.
// subscribes to all of them.
-const TOPICS = Object.freeze([...catalog.EVENT_TYPES, ...accountDataLib.TOPICS, subjectMerge.TOPIC]);
+const TOPICS = Object.freeze([...catalog.EVENT_TYPES, ...accountDataLib.TOPICS]);
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
function createEventsConsumer({ db, s, secrets = [], coins = null, items = null, accountData = null, accountSend = null, appQuests = null, daily = null, now = () => Date.now(), log = console }) {
function createEventsConsumer({ db, s, secrets = [], coins = null, items = null, accountData = null, accountSend = null, appQuests = null, daily = null, now = () => Date.now(), log = console }) {
@@ -81,21 +80,6 @@ function createEventsConsumer({ db, s, secrets = [], coins = null, items = null,
@@ -81,21 +80,6 @@ function createEventsConsumer({ db, s, secrets = [], coins = null, items = null,
return res.json({ event_id: event.event_id, outcome: accountOutcome });
return res.json({ event_id: event.event_id, outcome: accountOutcome });
}
}
- // An account merge (ADR-029) folds one person's rows into the survivor's, once per merge_id
- // (server/identity/subject-merge.js keeps its own ledger, so it never touches quest_event_inbox either).
- if (event.event_type === subjectMerge.TOPIC) {
- let mergeOutcome;
- try {
- mergeOutcome = await subjectMerge.apply(event, { s, log });
- } catch (err) {
- stats.failed++;
- log.error(`[Events consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);
- return problem(503, 'quest.event_failed', 'processing failed; it will be retried');
- }
- if (String(mergeOutcome).startsWith('ignored:')) stats.ignored++;
- else stats.applied++;
- return res.json({ event_id: event.event_id, outcome: mergeOutcome });
- }
let outcome;
let outcome;
try {
try {
@@ -583,7 +583,7 @@ streamers do not write quests: a quest sends people to do something.</li>
@@ -583,7 +583,7 @@ streamers do not write quests: a quest sends people to do something.</li>
<li>Deleting your OpenVibe account deletes your progress, completions and badges here, and an account export includes
<li>Deleting your OpenVibe account deletes your progress, completions and badges here, and an account export includes
them (ADR-033).</li>
them (ADR-033).</li>
<li>When two of your OpenVibe accounts are merged, everything here moves to the account you keep: each step keeps the
<li>When two of your OpenVibe accounts are merged, everything here moves to the account you keep: each step keeps the
-higher count, a quest both finished counts once, and each badge keeps its first award (ADR-029).</li>
+higher count, a quest both finished counts once, and each badge keeps its first award.</li>
</ul>
</ul>
<p><a href="/quests">The quests</a> · <a href="/updates">What shipped</a></p>`,
<p><a href="/quests">The quests</a> · <a href="/updates">What shipped</a></p>`,
}));
}));
deleted file mode 100644
deleted file mode 100644
@@ -1,62 +0,0 @@
@@ -1,62 +0,0 @@
-'use strict';
-
-/**
- * network.subject.merged → Quest (ADR-029). Two Network accounts became one: `from` is now an alias of `into`, and
- * every quest row of `from` becomes `into`'s, once per merge_id (quest_subject_merges):
- *
- * quest_progress each step keeps the higher count of the two (counts are capped at the step's target)
- * quest_completions a quest only `from` finished moves to `into` with its OpenCoins state (a credit still owed is
- * settled to the survivor); one both finished keeps the survivor's, since each wallet was credited
- * once and Network merges the wallets itself
- * quest_badges the earliest award of each badge is kept
- *
- * Delivered to POST /internal/events beside the account topics; it never passes through quest_event_inbox.
- */
-const TOPIC = 'network.subject.merged';
-const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
-const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/;
-
-/** One envelope → 'merged' | 'unchanged' | 'ignored:<why>'. */
-async function apply(ev, { s, log = console }) {
- if (!ev || ev.event_type !== TOPIC) return 'ignored:type';
- if (ev.source !== 'network') return 'ignored:source';
- const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};
- const mergeId = String(p.merge_id || ''), from = String(p.from || ''), into = String(p.into || '');
- if (!MERGE_RE.test(mergeId) || !SUBJECT_RE.test(from) || !SUBJECT_RE.test(into) || from === into) return 'ignored:payload';
- const r = await s.tx(async () => {
- const db = s.db;
- if (await db.maybe('SELECT 1 FROM quest_subject_merges WHERE merge_id = $1', [mergeId])) return null;
- const at = s.iso();
- const progress = Number(await db.value(`WITH moved AS (
- INSERT INTO quest_progress (subject, quest_id, step, count, updated_at)
- SELECT $2, quest_id, step, count, updated_at FROM quest_progress WHERE subject = $1
- ON CONFLICT (subject, quest_id, step) DO UPDATE SET count = GREATEST(quest_progress.count, excluded.count),
- updated_at = GREATEST(quest_progress.updated_at, excluded.updated_at)
- RETURNING 1) SELECT count(*)::int FROM moved`, [from, into]));
- await db.exec('DELETE FROM quest_progress WHERE subject = $1', [from]);
- const completions = Number(await db.value(`WITH moved AS (
- UPDATE quest_completions SET subject = $2 WHERE subject = $1
- AND quest_id NOT IN (SELECT quest_id FROM quest_completions WHERE subject = $2)
- RETURNING 1) SELECT count(*)::int FROM moved`, [from, into]));
- await db.exec('DELETE FROM quest_completions WHERE subject = $1', [from]);
- const badges = Number(await db.value(`WITH moved AS (
- INSERT INTO quest_badges (subject, badge_id, quest_id, awarded_at)
- SELECT $2, badge_id, quest_id, awarded_at FROM quest_badges WHERE subject = $1
- ON CONFLICT (subject, badge_id) DO UPDATE SET awarded_at = LEAST(quest_badges.awarded_at, excluded.awarded_at)
- RETURNING 1) SELECT count(*)::int FROM moved`, [from, into]));
- await db.exec('DELETE FROM quest_badges WHERE subject = $1', [from]);
- await db.exec('INSERT INTO daily_preferences (subject,time_zone) SELECT $2,time_zone FROM daily_preferences WHERE subject=$1 ON CONFLICT (subject) DO NOTHING', [from, into]);
- await db.exec('DELETE FROM daily_preferences WHERE subject=$1', [from]);
- await db.exec('UPDATE daily_events SET subject=$2 WHERE subject=$1', [from, into]);
- await db.exec('INSERT INTO daily_completions (subject,day,completed_at) SELECT $2,day,completed_at FROM daily_completions WHERE subject=$1 ON CONFLICT (subject,day) DO UPDATE SET completed_at=LEAST(daily_completions.completed_at,excluded.completed_at)', [from, into]);
- await db.exec('DELETE FROM daily_completions WHERE subject=$1', [from]);
- await db.exec('INSERT INTO quest_subject_merges (merge_id, from_subject, into_subject, progress, completions, badges, merged_at) VALUES ($1, $2, $3, $4, $5, $6, $7)',
- [mergeId, from, into, progress, completions, badges, at]);
- return { progress, completions, badges };
- });
- if (!r) return 'unchanged';
- log.log(`[Merge] ${mergeId}: ${r.progress} progress step(s), ${r.completions} completion(s) and ${r.badges} badge(s) now the survivor's`);
- return 'merged';
-}
-
-module.exports = { apply, TOPIC };
deleted file mode 100644
deleted file mode 100644
@@ -1,69 +0,0 @@
@@ -1,69 +0,0 @@
-'use strict';
-/**
- * ADR-029: network.subject.merged folds one account's quest rows into the survivor's, through the service's own
- * /internal/events route. Progress keeps the higher count per step, a quest both finished counts once (the
- * survivor's), a quest only the folded account finished moves with its coins, each badge keeps its first award, a
- * redelivery moves nothing twice, and the event never touches quest_event_inbox.
- */
-const assert = require('assert');
-const { boot, check, done } = require('./helpers/boot');
-const { signDeliveryHeaders } = require('openvibe-sdk/events');
-const questStore = require('../server/quests/store');
-
-const KEEP = 'usr_01JZ0000000000000000000KEP';
-const GONE = 'usr_01JZ0000000000000000000GNE';
-const OTHER = 'usr_01JZ0000000000000000000OTH';
-const SECRET = `whsec_${'fixture'.repeat(6)}`;
-const merged = { event_id: 'evt_01JZ0000000000000000000M01', event_type: 'network.subject.merged', source: 'network', payload: { merge_id: 'mrg_01JZ0000000000000000000MRG', from: GONE, into: KEEP } };
-const bodyOf = (ev) => JSON.stringify({ event: ev, seq: 1 });
-
-(async () => {
- await check('a merge moves the folded account\'s quests to the survivor once', async () => {
- const t = await boot({ env: { QUEST_EVENTS_SECRET: SECRET } });
- try {
- const s = t.ctx.s;
- const early = '2026-10-01T00:00:00.000Z', late = '2026-10-10T00:00:00.000Z';
- // Both started "go-live": the folded account is further along.
- await questStore.bumpProgress(s.db, { subject: KEEP, questId: 'go-live', step: 0, target: 3, at: late });
- await questStore.bumpProgress(s.db, { subject: GONE, questId: 'go-live', step: 0, target: 3, at: early });
- await questStore.bumpProgress(s.db, { subject: GONE, questId: 'go-live', step: 0, target: 3, at: early });
- // Both finished "follow-someone"; only the folded account finished "first-paste", with coins still owed.
- await questStore.complete(s.db, { id: s.newId('qcp'), subject: KEEP, questId: 'follow-someone', at: late, coins: 10, coinsState: 'credited' });
- await questStore.complete(s.db, { id: s.newId('qcp'), subject: GONE, questId: 'follow-someone', at: early, coins: 10, coinsState: 'credited' });
- await questStore.complete(s.db, { id: s.newId('qcp'), subject: GONE, questId: 'first-paste', at: early, coins: 5, coinsState: 'pending' });
- await questStore.awardBadge(s.db, { subject: KEEP, badgeId: 'first-follow', questId: 'follow-someone', at: late });
- await questStore.awardBadge(s.db, { subject: GONE, badgeId: 'first-follow', questId: 'follow-someone', at: early });
- await questStore.awardBadge(s.db, { subject: GONE, badgeId: 'first-paste', questId: 'first-paste', at: early });
- await questStore.awardBadge(s.db, { subject: OTHER, badgeId: 'first-paste', questId: 'first-paste', at: early });
-
- const send = async () => await t.get('/internal/events', { method: 'POST', body: bodyOf(merged), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(merged), SECRET) } });
- const res = await send();
- assert.strictEqual(res.status, 200);
- assert.strictEqual(res.json().outcome, 'merged');
-
- const count = await s.db.value("SELECT count FROM quest_progress WHERE subject = $1 AND quest_id = 'go-live' AND step = 0", [KEEP]);
- assert.strictEqual(Number(count), 2, 'the step keeps the higher count');
- const comps = await s.db.many('SELECT quest_id, completed_at, coins_state FROM quest_completions WHERE subject = $1 ORDER BY quest_id', [KEEP]);
- assert.deepStrictEqual(comps.map((c) => [c.quest_id, c.coins_state]), [['first-paste', 'pending'], ['follow-someone', 'credited']], 'one completion per quest; an owed credit moves to the survivor');
- assert.strictEqual(comps.find((c) => c.quest_id === 'follow-someone').completed_at, late, "a quest both finished keeps the survivor's");
- const badges = await s.db.many('SELECT badge_id, awarded_at FROM quest_badges WHERE subject = $1 ORDER BY badge_id', [KEEP]);
- assert.deepStrictEqual(badges.map((b) => [b.badge_id, b.awarded_at]), [['first-follow', early], ['first-paste', early]], 'each badge keeps its first award');
- for (const table of ['quest_progress', 'quest_completions', 'quest_badges']) {
- assert.strictEqual(Number(await s.db.value(`SELECT count(*)::int FROM ${table} WHERE subject = $1`, [GONE])), 0, `nothing is left under the folded account in ${table}`);
- }
- assert.strictEqual(Number(await s.db.value('SELECT count(*)::int FROM quest_badges WHERE subject = $1', [OTHER])), 1, 'someone else is untouched');
-
- const again = await send();
- assert.strictEqual(again.json().outcome, 'unchanged', 'a redelivery moves nothing twice');
- assert.strictEqual(Number(await s.db.value('SELECT count(*)::int FROM quest_event_inbox WHERE event_id = $1', [merged.event_id])), 0, 'the merge is not claimed by quest_event_inbox');
- const bad = { ...merged, event_id: 'evt_01JZ0000000000000000000M02', payload: { ...merged.payload, into: GONE } };
- assert.strictEqual((await t.get('/internal/events', { method: 'POST', body: bodyOf(bad), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(bad), SECRET) } })).json().outcome, 'ignored:payload', 'an account merged into itself is ignored');
- } finally { await t.close(); }
- });
-
- await check('boot subscribes to account merges', async () => {
- assert.ok(require('../server/events-consumer').TOPICS.includes('network.subject.merged'));
- });
-
- done();
-})();