1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268 | /**
* Characterization net for FriendStore.merge — the cross-device fold.
*
* It is the hardest function in the kit (197 lines before the idiomatic
* rewrite, the longest in the suite outside chat) and it had NO coverage of any
* kind: the golden vectors pin sanitizeFriend, the friend links and the
* profile-sync fold, and stop there. Everything below is a black-box drive of
* the committed dist through the public API — new FriendStore(ns), upsert,
* block, socBlob, merge — with no reach into internals, so it holds across a
* rewrite by construction.
*
* PROVEN AGAINST THE PRE-REWRITE IMPLEMENTATION: every assertion here was run
* against the transliterated friends.cljs (git 1732571) before the rewrite
* landed, and passes identically on both. That is what makes it a
* characterization test rather than a description of the new code.
*
* These are not golden vectors and carry none of their authority — no byte of
* this file crossed over from the retired TypeScript. They pin behaviour that
* was previously pinned by nothing at all.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { installFakeDom } from "./helpers/fake-dom.mjs";
installFakeDom();
const { FriendStore } = await import("../dist/index.js");
const TS = 1735689600000;
let seq = 0;
const store = () => new FriendStore(`t-merge-${seq++}`);
/** A 43-char b64url key built from a short tag, so records are readable. */
const key = (tag) => (tag + "_".repeat(43)).slice(0, 43);
/** A cert that satisfies sanitizeFriend's shape check (20..120 b64url). */
const cert = (tag) => (tag + "c".repeat(30)).slice(0, 30);
const rec = (tag, over = {}) => ({
pub: key(tag),
x: key(`x${tag}`),
xs: cert(tag),
pet: "",
name: "",
state: "friend",
addedTs: TS,
petTs: 0,
lastSeenTs: 0,
lastApp: "",
...over,
});
const blob = (friends, blocked = []) => ({ v: 1, friends, blocked, ts: TS });
const byPub = (s) => Object.fromEntries(s.list().map((f) => [f.pub, f]));
// ---- the union ---------------------------------------------------------------
test("an unknown remote record is added and counted", () => {
const s = store();
assert.equal(s.merge(blob([rec("a")])), 1);
assert.deepEqual(s.list().map((f) => f.pub), [key("a")]);
});
test("merging the same blob again changes nothing — the fold is idempotent", () => {
const s = store();
const b = blob([rec("a"), rec("b")]);
assert.equal(s.merge(b), 2);
assert.equal(s.merge(b), 0);
assert.equal(s.list().length, 2);
});
test("a malformed blob is refused outright", () => {
const s = store();
s.upsert(rec("a"));
for (const bad of [null, undefined, {}, { v: 2, friends: [] }, { v: 1, friends: "nope" }]) {
assert.equal(s.merge(bad), 0, JSON.stringify(bad ?? null));
}
assert.equal(s.list().length, 1, "a refused blob must not touch the list");
});
test("records that fail sanitizeFriend are skipped, valid siblings still land", () => {
const s = store();
assert.equal(s.merge(blob([rec("a", { pub: "short" }), rec("b", { state: "enemy" }), rec("c")])), 1);
assert.deepEqual(s.list().map((f) => f.pub), [key("c")]);
});
// ---- field-level rules -------------------------------------------------------
test("a petname wins on a newer petTs and loses on an older one", () => {
const s = store();
s.upsert(rec("a", { pet: "mine", petTs: TS + 100 }));
assert.equal(s.merge(blob([rec("a", { pet: "theirs", petTs: TS })])), 0, "older petTs: no change");
assert.equal(byPub(s)[key("a")].pet, "mine");
assert.equal(s.merge(blob([rec("a", { pet: "theirs", petTs: TS + 200 })])), 1, "newer petTs wins");
assert.equal(byPub(s)[key("a")].pet, "theirs");
});
test("out meeting in settles the friendship; agreement on a non-friend state does not", () => {
const crossed = store();
crossed.upsert(rec("a", { state: "out" }));
assert.equal(crossed.merge(blob([rec("a", { state: "in" })])), 1);
assert.equal(byPub(crossed)[key("a")].state, "friend");
const agreed = store();
agreed.upsert(rec("a", { state: "out" }));
assert.equal(agreed.merge(blob([rec("a", { state: "out" })])), 0);
assert.equal(byPub(agreed)[key("a")].state, "out");
});
test("a remote friend upgrades us; a remote non-friend never downgrades us", () => {
const up = store();
up.upsert(rec("a", { state: "in" }));
assert.equal(up.merge(blob([rec("a", { state: "friend" })])), 1);
assert.equal(byPub(up)[key("a")].state, "friend");
const down = store();
down.upsert(rec("a", { state: "friend" }));
assert.equal(down.merge(blob([rec("a", { state: "in" })])), 0);
assert.equal(byPub(down)[key("a")].state, "friend");
});
test("a non-empty remote name wins; an empty one never clobbers", () => {
const s = store();
s.upsert(rec("a", { name: "Ala" }));
assert.equal(s.merge(blob([rec("a", { name: "" })])), 0, "empty name is not news");
assert.equal(byPub(s)[key("a")].name, "Ala");
assert.equal(s.merge(blob([rec("a", { name: "Bala" })])), 1);
assert.equal(byPub(s)[key("a")].name, "Bala");
});
test("lastSeen/lastApp advance but are NOT reported as a change", () => {
// presence rides in with every beacon; counting it would make each one look
// like real news and re-arm a self-sync round
const s = store();
s.upsert(rec("a"));
assert.equal(s.merge(blob([rec("a", { lastSeenTs: TS + 5000, lastApp: "lampion" })])), 0);
const f = byPub(s)[key("a")];
assert.equal(f.lastSeenTs, TS + 5000);
assert.equal(f.lastApp, "lampion");
});
test("addedTs only ever advances, and does not count as a change on its own", () => {
const s = store();
s.upsert(rec("a", { addedTs: TS }));
assert.equal(s.merge(blob([rec("a", { addedTs: TS + 9000 })])), 0);
assert.equal(byPub(s)[key("a")].addedTs, TS + 9000);
assert.equal(s.merge(blob([rec("a", { addedTs: TS })])), 0);
assert.equal(byPub(s)[key("a")].addedTs, TS + 9000, "an older addedTs must not win");
});
// ---- the tombstone race ------------------------------------------------------
test("a tombstone at least as new as the record kills it", () => {
const s = store();
s.upsert(rec("a", { addedTs: TS }));
assert.equal(s.merge(blob([], [{ pub: key("a"), ts: TS + 1 }])), 1, "removal is counted");
assert.deepEqual(s.list().map((f) => f.pub), []);
assert.deepEqual(s.blocked().map((b) => b.pub), [key("a")]);
assert.equal(s.isBlocked(key("a")), true);
});
test("the two halves of the race break an exact tie in OPPOSITE directions", () => {
// Not a tidy rule, but it is the deployed one, and it is the kind of thing
// that changes silently under a rewrite. Both halves compare a tombstone ts
// against a record addedTs; they disagree only at equality.
//
// incoming record vs tombstone we already hold -> tombstone wins (>=)
// incoming tombstone vs record we already hold -> record wins (<=)
//
// So an exactly-simultaneous block and record converge on "whoever arrived
// second loses", which at least makes the outcome independent of which
// device is doing the folding.
const incomingRecord = store();
incomingRecord.merge(blob([], [{ pub: key("a"), ts: TS + 1000 }])); // hold a tombstone at T
const t = incomingRecord.blocked()[0].ts;
assert.equal(incomingRecord.merge(blob([rec("a", { addedTs: t })])), 0, "tombstone wins the tie");
assert.deepEqual(incomingRecord.list(), []);
const incomingTombstone = store();
incomingTombstone.upsert(rec("a", { addedTs: TS })); // hold a record at T
assert.equal(incomingTombstone.merge(blob([], [{ pub: key("a"), ts: TS }])), 0, "record wins the tie");
assert.deepEqual(incomingTombstone.list().map((f) => f.pub), [key("a")]);
assert.deepEqual(incomingTombstone.blocked(), [], "and the tie-losing tombstone is not kept");
});
test("a record newer than the tombstone survives and clears it", () => {
const s = store();
s.block(key("a")); // tombstone at Date.now()
const tomb = s.blocked()[0].ts;
assert.equal(s.merge(blob([rec("a", { addedTs: tomb + 1000 })])), 1, "the record lands");
assert.deepEqual(s.list().map((f) => f.pub), [key("a")]);
assert.deepEqual(s.blocked(), [], "and the tombstone is gone");
});
test("a record older than the tombstone stays out and the tombstone stays", () => {
const s = store();
s.block(key("a"));
const tomb = s.blocked()[0].ts;
assert.equal(s.merge(blob([rec("a", { addedTs: tomb - 1000 })])), 0);
assert.deepEqual(s.list(), []);
assert.deepEqual(s.blocked().map((b) => b.pub), [key("a")]);
});
test("a remote tombstone older than a live record is dropped on the floor", () => {
const s = store();
s.upsert(rec("a", { addedTs: TS + 5000 }));
assert.equal(s.merge(blob([], [{ pub: key("a"), ts: TS }])), 0);
assert.deepEqual(s.list().map((f) => f.pub), [key("a")]);
assert.deepEqual(s.blocked(), [], "and it does not become a tombstone either");
});
test("the newer of two tombstones for the same pub is the one kept", () => {
const s = store();
assert.equal(s.merge(blob([], [{ pub: key("a"), ts: TS }, { pub: key("a"), ts: TS + 1000 }])), 0);
assert.deepEqual(s.blocked().map((b) => b.ts), [TS + 1000]);
assert.equal(s.merge(blob([], [{ pub: key("a"), ts: TS - 5000 }])), 0);
assert.deepEqual(s.blocked().map((b) => b.ts), [TS + 1000], "an older one does not replace it");
});
test("within one blob, friends are folded before tombstones", () => {
// the same pub arriving as both a record and a newer tombstone: the
// tombstone is applied second and wins
const s = store();
assert.equal(s.merge(blob([rec("a", { addedTs: TS })], [{ pub: key("a"), ts: TS + 1 }])), 2,
"one add, then one removal");
assert.deepEqual(s.list(), []);
assert.deepEqual(s.blocked().map((b) => b.pub), [key("a")]);
});
test("a malformed tombstone is ignored without disturbing the rest", () => {
const s = store();
s.upsert(rec("a"));
assert.equal(s.merge(blob([], [null, {}, { pub: "short", ts: TS }, { pub: 7, ts: TS }])), 0);
assert.deepEqual(s.list().map((f) => f.pub), [key("a")]);
assert.deepEqual(s.blocked(), []);
});
test("a non-array blocked section is tolerated, friends still fold", () => {
const s = store();
assert.equal(s.merge({ v: 1, friends: [rec("a")], blocked: "nope", ts: TS }), 1);
assert.deepEqual(s.list().map((f) => f.pub), [key("a")]);
});
// ---- round trip --------------------------------------------------------------
test("socBlob feeds merge: a second device converges to the same list", () => {
const a = store();
a.upsert(rec("a", { name: "Ala", state: "friend" }));
a.upsert(rec("b", { state: "out" }));
a.block(key("c"));
const b = store();
assert.equal(b.merge(a.socBlob()), 2);
assert.deepEqual(b.list().map((f) => f.pub).sort(), a.list().map((f) => f.pub).sort());
assert.deepEqual(b.blocked().map((x) => x.pub), a.blocked().map((x) => x.pub));
assert.equal(b.merge(a.socBlob()), 0, "and stays converged");
});
test("onChange fires once per merge, including a merge that changed nothing", () => {
const s = store();
let n = 0;
s.onChange = () => { n += 1; };
s.merge(blob([rec("a")]));
assert.equal(n, 1);
s.merge(blob([rec("a")]));
assert.equal(n, 2, "persist runs unconditionally — the fold does not gate on `changed`");
s.merge(null);
assert.equal(n, 2, "but a refused blob never reaches persist");
});
|