@chainlesschain/personal-data-hub 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +58 -16
  2. package/__tests__/adapters/wechat-frida-agent.test.js +132 -1
  3. package/__tests__/integration/social-bilibili-pipeline.test.js +261 -0
  4. package/__tests__/longtail-adapters.test.js +60 -14
  5. package/__tests__/messaging-qq-snapshot.test.js +294 -0
  6. package/__tests__/shopping-pinduoduo-snapshot.test.js +302 -0
  7. package/__tests__/shopping-snapshot.test.js +438 -0
  8. package/__tests__/social-adapters.test.js +91 -17
  9. package/__tests__/social-bilibili-snapshot.test.js +278 -0
  10. package/__tests__/social-douyin-snapshot.test.js +253 -0
  11. package/__tests__/social-kuaishou-snapshot.test.js +309 -0
  12. package/__tests__/social-toutiao-snapshot.test.js +314 -0
  13. package/__tests__/social-weibo-snapshot.test.js +234 -0
  14. package/__tests__/social-xiaohongshu-snapshot.test.js +232 -0
  15. package/__tests__/travel-maps-snapshot.test.js +426 -0
  16. package/__tests__/vault-driver-error.test.js +74 -0
  17. package/__tests__/wechat-adapter.test.js +118 -0
  18. package/lib/adapters/messaging-qq/index.js +498 -92
  19. package/lib/adapters/shopping-jd/index.js +228 -25
  20. package/lib/adapters/shopping-meituan/index.js +222 -26
  21. package/lib/adapters/shopping-pinduoduo/index.js +275 -0
  22. package/lib/adapters/social-bilibili/adapter.js +500 -0
  23. package/lib/adapters/social-bilibili/index.js +21 -169
  24. package/lib/adapters/social-douyin/index.js +454 -63
  25. package/lib/adapters/social-kuaishou/index.js +379 -127
  26. package/lib/adapters/social-toutiao/index.js +400 -130
  27. package/lib/adapters/social-weibo/index.js +393 -95
  28. package/lib/adapters/social-xiaohongshu/index.js +389 -49
  29. package/lib/adapters/travel-baidu-map/index.js +286 -26
  30. package/lib/adapters/travel-tencent-map/index.js +414 -0
  31. package/lib/adapters/wechat/content-parser.js +11 -2
  32. package/lib/adapters/wechat/db-reader.js +88 -10
  33. package/lib/adapters/wechat/frida-agent/loader.js +7 -0
  34. package/lib/adapters/wechat/frida-agent/wechat-key-hook.js +140 -18
  35. package/lib/adapters/wechat/key-providers/frida-key-provider.js +8 -0
  36. package/lib/adapters/wechat/normalize.js +12 -3
  37. package/lib/index.js +5 -1
  38. package/lib/vault.js +60 -8
  39. package/package.json +2 -1
@@ -1,14 +1,18 @@
1
1
  /**
2
- * Phase 13.8+13.9 — Toutiao 今日头条 + Kuaishou 快手 v0.1 scaffold tests.
2
+ * §A8 v0.2 — Toutiao 今日头条 + Kuaishou 快手 sqlite-mode tests.
3
+ *
4
+ * Originally Phase 13.8+13.9 v0.1 scaffold tests; promoted in §A8 v0.2 to
5
+ * cover the dual-mode (snapshot + sqlite) adapter. Snapshot-mode coverage
6
+ * lives in `../social-{toutiao,kuaishou}-snapshot.test.js`; this file
7
+ * focuses on the legacy sqlite/device-pull path that desktop wiring still
8
+ * uses for PCs running AndroidExtractor.
3
9
  *
4
- * Tests are intentionally focused on scaffold-quality guarantees:
5
10
  * - Adapter contract conformance (assertAdapter ok)
6
- * - Account validation (rejects missing uid)
7
11
  * - sync() yields raw rows per `kind` from mocked SQLite driver
8
12
  * - normalize() produces valid UnifiedSchema events with correct subtype
9
- *
10
- * Field-level assertions intentionally avoided schema is待 fixture pin
11
- * in Phase 13.10 (real-device E2E).
13
+ * - Account validation lazy-checked at sync() time (v0.2 changed:
14
+ * account.uid is now OPTIONAL at construction so snapshot-mode-only
15
+ * callers can omit it).
12
16
  */
13
17
 
14
18
  "use strict";
@@ -50,18 +54,37 @@ function withFakeDb(fn) {
50
54
 
51
55
  // ─── ToutiaoAdapter ─────────────────────────────────────────────────────
52
56
 
53
- describe("ToutiaoAdapter — Phase 13.8(+) v0.1 scaffold", () => {
57
+ describe("ToutiaoAdapter — §A8 v0.2 sqlite mode", () => {
54
58
  it("contract conformance + sensitivity high (news reading reveals political/medical interest)", () => {
55
59
  const a = new ToutiaoAdapter({ account: { uid: "u-1" } });
56
60
  expect(assertAdapter(a).ok).toBe(true);
57
61
  expect(a.name).toBe("social-toutiao");
58
62
  expect(a.extractMode).toBe("device-pull");
59
63
  expect(a.dataDisclosure.sensitivity).toBe("high");
64
+ // v0.2 dual-mode capabilities — adapter accepts both snapshot and sqlite.
65
+ expect(a.capabilities).toContain("sync:snapshot");
66
+ expect(a.capabilities).toContain("sync:sqlite");
67
+ });
68
+
69
+ it("v0.2: account OPTIONAL at construction (snapshot mode is stateless)", () => {
70
+ // Used to throw in v0.1 — now legal. Sqlite-mode sync() will lazy-throw.
71
+ expect(() => new ToutiaoAdapter()).not.toThrow();
72
+ expect(() => new ToutiaoAdapter({})).not.toThrow();
73
+ expect(() => new ToutiaoAdapter({ account: {} })).not.toThrow();
60
74
  });
61
75
 
62
- it("rejects missing account.uid", () => {
63
- expect(() => new ToutiaoAdapter({})).toThrow();
64
- expect(() => new ToutiaoAdapter({ account: {} })).toThrow(/uid/);
76
+ it("sqlite mode lazy-throws when account.uid missing at sync time", async () => {
77
+ const a = new ToutiaoAdapter({ dbPath: "/no/such/path.db" });
78
+ let threw = null;
79
+ try {
80
+ for await (const _r of a.sync()) {
81
+ /* drain */
82
+ }
83
+ } catch (err) {
84
+ threw = err;
85
+ }
86
+ expect(threw).toBeTruthy();
87
+ expect(String(threw.message)).toMatch(/account\.uid required/);
65
88
  });
66
89
 
67
90
  it("sync yields read + collection + search raws via mocked driver", async () => {
@@ -130,9 +153,11 @@ describe("ToutiaoAdapter — Phase 13.8(+) v0.1 scaffold", () => {
130
153
  }
131
154
  });
132
155
 
133
- it("normalize throws on missing payload.row (validator-friendly)", () => {
156
+ it("normalize throws on missing payload row + no snapshot fields (validator-friendly)", () => {
134
157
  const a = new ToutiaoAdapter({ account: { uid: "u-1" } });
135
- expect(() => a.normalize({ payload: {} })).toThrow(/row missing/);
158
+ // v0.2: row-missing check moved into per-kind normalizers (snapshot
159
+ // payloads have no `row` but carry fields directly).
160
+ expect(() => a.normalize({ payload: { kind: "read" } })).toThrow(/row missing/);
136
161
  });
137
162
 
138
163
  it("search keyword preserved verbatim in content.title + extra.keyword", () => {
@@ -158,18 +183,35 @@ describe("ToutiaoAdapter — Phase 13.8(+) v0.1 scaffold", () => {
158
183
 
159
184
  // ─── KuaishouAdapter ────────────────────────────────────────────────────
160
185
 
161
- describe("KuaishouAdapter — Phase 13.9(+) v0.1 scaffold", () => {
186
+ describe("KuaishouAdapter — §A8 v0.2 sqlite mode", () => {
162
187
  it("contract conformance + sensitivity medium (entertainment preference)", () => {
163
188
  const a = new KuaishouAdapter({ account: { uid: "u-2" } });
164
189
  expect(assertAdapter(a).ok).toBe(true);
165
190
  expect(a.name).toBe("social-kuaishou");
166
191
  expect(a.extractMode).toBe("device-pull");
167
192
  expect(a.dataDisclosure.sensitivity).toBe("medium");
193
+ expect(a.capabilities).toContain("sync:snapshot");
194
+ expect(a.capabilities).toContain("sync:sqlite");
195
+ });
196
+
197
+ it("v0.2: account OPTIONAL at construction (snapshot mode is stateless)", () => {
198
+ expect(() => new KuaishouAdapter()).not.toThrow();
199
+ expect(() => new KuaishouAdapter({})).not.toThrow();
200
+ expect(() => new KuaishouAdapter({ account: {} })).not.toThrow();
168
201
  });
169
202
 
170
- it("rejects missing account.uid", () => {
171
- expect(() => new KuaishouAdapter({})).toThrow();
172
- expect(() => new KuaishouAdapter({ account: {} })).toThrow(/uid/);
203
+ it("sqlite mode lazy-throws when account.uid missing at sync time", async () => {
204
+ const a = new KuaishouAdapter({ dbPath: "/no/such/path.db" });
205
+ let threw = null;
206
+ try {
207
+ for await (const _r of a.sync()) {
208
+ /* drain */
209
+ }
210
+ } catch (err) {
211
+ threw = err;
212
+ }
213
+ expect(threw).toBeTruthy();
214
+ expect(String(threw.message)).toMatch(/account\.uid required/);
173
215
  });
174
216
 
175
217
  it("sync yields watch + collect + search raws via mocked driver", async () => {
@@ -171,6 +171,134 @@ describe("frida-agent — fallback symbol resolution", () => {
171
171
  });
172
172
  });
173
173
 
174
+ describe("frida-agent — sjqz-audit fixes (sig + format + module case)", () => {
175
+ // Helper extending fakePtr with readCString for ascii-hex tests.
176
+ function fakeAsciiHexPtr(asciiHex) {
177
+ return {
178
+ _v: asciiHex,
179
+ toInt32() { return 0; },
180
+ readByteArray(_len) { return new Uint8Array(0).buffer; },
181
+ };
182
+ }
183
+ function memoryReadCString(ptr, _maxLen) {
184
+ return ptr && typeof ptr._v === "string" ? ptr._v : null;
185
+ }
186
+
187
+ it("attaches when only uppercase libWCDB.so resolves (sjqz canonical name)", () => {
188
+ const send = vi.fn();
189
+ const Interceptor = { attach: vi.fn() };
190
+ const Module = {
191
+ findExportByName(mod, sym) {
192
+ return mod === "libWCDB.so" && sym === "sqlite3_key"
193
+ ? { symbol: sym }
194
+ : null;
195
+ },
196
+ };
197
+ const Process = {
198
+ findModuleByName(mod) {
199
+ return mod === "libWCDB.so" ? { name: mod } : null;
200
+ },
201
+ };
202
+
203
+ runAgentUnderMock({ Module, Process, Interceptor, send });
204
+
205
+ const hooked = send.mock.calls.find((c) => c[0].kind === "hooked");
206
+ expect(hooked).toBeDefined();
207
+ expect(hooked[0].module).toBe("libWCDB.so");
208
+ });
209
+
210
+ it("v2 hook reads key from args[2] and length from args[3] (not args[1]/[2])", () => {
211
+ const send = vi.fn();
212
+ const attached = {};
213
+ const Interceptor = {
214
+ attach(addr, handlers) { attached[addr.symbol] = handlers; },
215
+ };
216
+ const Module = {
217
+ findExportByName(mod, sym) {
218
+ return sym === "sqlite3_key_v2" ? { symbol: sym } : null;
219
+ },
220
+ };
221
+ const Process = { findModuleByName() { return { name: "libwcdb.so" }; } };
222
+
223
+ runAgentUnderMock({ Module, Process, Interceptor, send });
224
+
225
+ // sqlite3_key_v2(sqlite3 *db, const char *zDbName, const void *pKey, int nKey)
226
+ // args[0]=db, args[1]=name, args[2]=keyBytes, args[3]=len
227
+ const dbNamePtr = fakePtr("ffeeffeeffeeffeeffeeffeeffeeffee"); // would be wrong if read as key
228
+ const keyHex = "12345678" + "00".repeat(28); // 32 bytes
229
+ const args = [
230
+ fakePtr(0), // db
231
+ dbNamePtr, // name (NOT the key)
232
+ fakePtr(keyHex), // pKey — correct args[2]
233
+ fakePtr(32), // nKey — correct args[3]
234
+ ];
235
+ attached.sqlite3_key_v2.onEnter(args);
236
+
237
+ const keyEvt = send.mock.calls.find((c) => c[0].kind === "key");
238
+ expect(keyEvt).toBeDefined();
239
+ expect(keyEvt[0].hex).toBe(keyHex); // proves args[2] was read, not args[1]
240
+ expect(keyEvt[0].sig).toBe("v2");
241
+ expect(keyEvt[0].format).toBe("raw-bytes");
242
+ expect(keyEvt[0].length).toBe(32);
243
+ });
244
+
245
+ it("reads ascii-hex key via readCString when length === 64", () => {
246
+ const send = vi.fn();
247
+ const attached = {};
248
+ const Interceptor = {
249
+ attach(addr, handlers) { attached[addr.symbol] = handlers; },
250
+ };
251
+ const Module = {
252
+ findExportByName(mod, sym) {
253
+ return sym === "sqlite3_key" ? { symbol: sym } : null;
254
+ },
255
+ };
256
+ const Process = { findModuleByName() { return { name: "libwcdb.so" }; } };
257
+ const Memory = { readCString: memoryReadCString };
258
+
259
+ runAgentUnderMock({ Module, Process, Interceptor, send, Memory });
260
+
261
+ // 64-char ASCII hex string + len=64 → readCString path (sjqz scenario)
262
+ const asciiHex = "ABCDEF0123456789".repeat(4).toLowerCase();
263
+ const args = [fakePtr(0), fakeAsciiHexPtr(asciiHex), fakePtr(64)];
264
+ attached.sqlite3_key.onEnter(args);
265
+
266
+ const keyEvt = send.mock.calls.find((c) => c[0].kind === "key");
267
+ expect(keyEvt).toBeDefined();
268
+ expect(keyEvt[0].hex).toBe(asciiHex);
269
+ expect(keyEvt[0].format).toBe("ascii-hex");
270
+ expect(keyEvt[0].length).toBe(64);
271
+ });
272
+
273
+ it("emits unsupported-signature error for mangled C++ symbol (no host attempt)", () => {
274
+ const send = vi.fn();
275
+ const attached = {};
276
+ const Interceptor = {
277
+ attach(addr, handlers) { attached[addr.symbol] = handlers; },
278
+ };
279
+ const mangledSymbol =
280
+ "_ZN4WCDB8Database13setCipherKeyERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
281
+ const Module = {
282
+ findExportByName(mod, sym) {
283
+ return sym === mangledSymbol ? { symbol: sym } : null;
284
+ },
285
+ };
286
+ const Process = { findModuleByName() { return { name: "libwcdb.so" }; } };
287
+
288
+ runAgentUnderMock({ Module, Process, Interceptor, send });
289
+
290
+ attached[mangledSymbol].onEnter([fakePtr(0), fakePtr("aabb"), fakePtr(32)]);
291
+
292
+ const errEvt = send.mock.calls.find(
293
+ (c) => c[0].kind === "error" && /unsupported symbol signature/.test(c[0].message),
294
+ );
295
+ expect(errEvt).toBeDefined();
296
+ // And NO key event emitted (host must fall back to MD5 path).
297
+ const keyEvt = send.mock.calls.find((c) => c[0].kind === "key");
298
+ expect(keyEvt).toBeUndefined();
299
+ });
300
+ });
301
+
174
302
  describe("frida-agent — module not yet loaded path", () => {
175
303
  it("emits module-waiting and schedules retry", () => {
176
304
  const send = vi.fn();
@@ -183,7 +311,10 @@ describe("frida-agent — module not yet loaded path", () => {
183
311
 
184
312
  const waiting = send.mock.calls.find((c) => c[0].kind === "module-waiting");
185
313
  expect(waiting).toBeDefined();
186
- expect(waiting[0].module).toBe("libwcdb.so");
314
+ // Post-sjqz audit: agent now tries both libWCDB.so (uppercase, sjqz-verified)
315
+ // and libwcdb.so. The module-waiting event surfaces the join so the
316
+ // host telemetry shows both attempted names.
317
+ expect(waiting[0].module).toBe("libWCDB.so|libwcdb.so");
187
318
  expect(setTimeoutMock).toHaveBeenCalled();
188
319
  // First retry delay 500ms
189
320
  expect(setTimeoutMock.mock.calls[0][1]).toBe(500);
@@ -0,0 +1,261 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Integration test — A8 v0.1 Bilibili snapshot → vault pipeline.
5
+ *
6
+ * Exercises the full chain WITHOUT any real WebView / OkHttp / Android JNI:
7
+ *
8
+ * Synthetic snapshot JSON (4 kinds)
9
+ * ↓
10
+ * AdapterRegistry (real) + LocalVault (real, SQLCipher)
11
+ * ↓
12
+ * adapter.sync({ inputPath }) → _syncViaSnapshot yields raw events
13
+ * ↓
14
+ * registry.putRawEvent → vault.raw_events
15
+ * ↓
16
+ * adapter.normalize(raw) → batch
17
+ * ↓
18
+ * vault.putBatch → events / persons / places / items / topics tables
19
+ *
20
+ * Two scenarios:
21
+ * A. happy path — 4-kind snapshot ingests; vault yields exact counts;
22
+ * KG triples derive; originalId stable across re-sync (idempotency)
23
+ * B. partial snapshot — only history + follow; vault gets correct subset
24
+ *
25
+ * Win note: bs3mc has a known NODE_MODULE_VERSION mismatch on this dev box
26
+ * (Node 22.22.2 ABI v127 vs prebuild ABI v140); test passes on CI Linux
27
+ * which uses the matched prebuild. See memory pdh-plan-a-android-standalone-
28
+ * design §"bs3mc NODE_MODULE_VERSION mismatch".
29
+ */
30
+
31
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
32
+
33
+ const fs = require("node:fs");
34
+ const path = require("node:path");
35
+ const os = require("node:os");
36
+
37
+ const {
38
+ LocalVault,
39
+ generateKeyHex,
40
+ AdapterRegistry,
41
+ } = require("../../lib");
42
+ const {
43
+ BilibiliAdapter,
44
+ SNAPSHOT_SCHEMA_VERSION,
45
+ } = require("../../lib/adapters/social-bilibili");
46
+
47
+ function makeRig() {
48
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bili-int-"));
49
+ const vault = new LocalVault({ path: path.join(dir, "v.db"), key: generateKeyHex() });
50
+ vault.open();
51
+ const registry = new AdapterRegistry({ vault });
52
+ return { vault, registry, dir };
53
+ }
54
+
55
+ function cleanup(rig) {
56
+ if (!rig) return;
57
+ try { rig.vault.close(); } catch (_e) { /* noop */ }
58
+ try { fs.rmSync(rig.dir, { recursive: true, force: true }); } catch (_e) { /* noop */ }
59
+ }
60
+
61
+ function writeSnapshot(dir, snapshot) {
62
+ const p = path.join(dir, "social-bilibili.json");
63
+ fs.writeFileSync(p, JSON.stringify(snapshot), "utf-8");
64
+ return p;
65
+ }
66
+
67
+ function sampleSnapshot(opts = {}) {
68
+ const include = {
69
+ history: true,
70
+ favourite: true,
71
+ dynamic: true,
72
+ follow: true,
73
+ ...opts.include,
74
+ };
75
+ const events = [];
76
+ if (include.history) {
77
+ events.push({
78
+ kind: "history",
79
+ id: "BV1abc",
80
+ capturedAt: 1715000000000,
81
+ title: "Rust 异步学习",
82
+ bvid: "BV1abc",
83
+ avid: 42,
84
+ duration: 600,
85
+ uploader: "技术UP主",
86
+ uploaderMid: 100,
87
+ part: "01 介绍",
88
+ });
89
+ }
90
+ if (include.favourite) {
91
+ events.push({
92
+ kind: "favourite",
93
+ id: "fav-BV2def",
94
+ capturedAt: 1714000000000,
95
+ title: "前端架构",
96
+ bvid: "BV2def",
97
+ folderName: "学习",
98
+ uploader: "码农UP",
99
+ });
100
+ }
101
+ if (include.dynamic) {
102
+ events.push({
103
+ kind: "dynamic",
104
+ id: "dyn-99",
105
+ capturedAt: 1713000000000,
106
+ summary: "今天发了一个新视频",
107
+ dynamicType: "av",
108
+ rid: "99",
109
+ authorMid: 200,
110
+ authorName: "我关注的UP",
111
+ });
112
+ }
113
+ if (include.follow) {
114
+ events.push({
115
+ kind: "follow",
116
+ id: "follow-300",
117
+ capturedAt: 1712000000000,
118
+ mid: 300,
119
+ uname: "美食UP",
120
+ face: "https://i0.hdslb.com/300.jpg",
121
+ sign: "好吃的视频",
122
+ });
123
+ }
124
+ return {
125
+ schemaVersion: SNAPSHOT_SCHEMA_VERSION,
126
+ snapshottedAt: 1716000000000,
127
+ account: { uid: "12345", displayName: "alice" },
128
+ events,
129
+ };
130
+ }
131
+
132
+ describe("Integration — A8 Bilibili snapshot → vault end-to-end", () => {
133
+ let rig;
134
+ afterEach(() => { cleanup(rig); rig = null; });
135
+
136
+ it("4 kinds ingest into vault with exact entity counts", async () => {
137
+ rig = makeRig();
138
+ const adapter = new BilibiliAdapter();
139
+ rig.registry.register(adapter);
140
+
141
+ const snapshotPath = writeSnapshot(rig.dir, sampleSnapshot());
142
+ const report = await rig.registry.syncAdapter("social-bilibili", {
143
+ inputPath: snapshotPath,
144
+ });
145
+
146
+ // 3 events (history + favourite + dynamic) + 1 person (follow) +
147
+ // 2 items (history-video + favourite-video)
148
+ expect(report.status).toBe("ok");
149
+ expect(report.entityCounts.events).toBe(3);
150
+ expect(report.entityCounts.persons).toBe(1);
151
+ expect(report.entityCounts.items).toBe(2);
152
+
153
+ // Vault round-trip
154
+ const events = rig.vault.queryEvents({ limit: 100 });
155
+ expect(events).toHaveLength(3);
156
+ const subtypes = events.map((e) => e.subtype).sort();
157
+ expect(subtypes).toEqual(["browse", "browse", "like"]); // history+dynamic+favourite
158
+
159
+ const persons = rig.vault.queryPersons({ limit: 100 });
160
+ expect(persons).toHaveLength(1);
161
+ expect(persons[0].names[0]).toBe("美食UP");
162
+
163
+ const items = rig.vault.queryItems({ limit: 100 });
164
+ expect(items).toHaveLength(2);
165
+ expect(items.map((i) => i.name).sort()).toEqual(["Rust 异步学习", "前端架构"]);
166
+ });
167
+
168
+ it("re-sync is idempotent — same snapshot twice does NOT double entities", async () => {
169
+ rig = makeRig();
170
+ const adapter = new BilibiliAdapter();
171
+ rig.registry.register(adapter);
172
+ const snapshotPath = writeSnapshot(rig.dir, sampleSnapshot());
173
+
174
+ // First sync
175
+ const report1 = await rig.registry.syncAdapter("social-bilibili", {
176
+ inputPath: snapshotPath,
177
+ });
178
+ expect(report1.status).toBe("ok");
179
+
180
+ // Second sync — same snapshot
181
+ const report2 = await rig.registry.syncAdapter("social-bilibili", {
182
+ inputPath: snapshotPath,
183
+ });
184
+ expect(report2.status).toBe("ok");
185
+
186
+ // Stable originalId means re-sync de-dups at raw_events layer.
187
+ // The person/item entities should remain at 1 / 2 respectively
188
+ // because their IDs derive from bvid / mid (stable). Events can
189
+ // legitimately double-write because each "browse" is a separate
190
+ // occurrence — registry doesn't dedup events.
191
+ const persons = rig.vault.queryPersons({ limit: 100 });
192
+ expect(persons).toHaveLength(1);
193
+
194
+ const items = rig.vault.queryItems({ limit: 100 });
195
+ // Items with same bvid produce same ID, so item table stays at 2
196
+ // (UPSERT semantics via primary-key id).
197
+ expect(items).toHaveLength(2);
198
+ });
199
+
200
+ it("partial snapshot (history + follow only) yields exact subset", async () => {
201
+ rig = makeRig();
202
+ const adapter = new BilibiliAdapter();
203
+ rig.registry.register(adapter);
204
+
205
+ const snapshotPath = writeSnapshot(
206
+ rig.dir,
207
+ sampleSnapshot({ include: { favourite: false, dynamic: false } })
208
+ );
209
+ const report = await rig.registry.syncAdapter("social-bilibili", {
210
+ inputPath: snapshotPath,
211
+ });
212
+ expect(report.status).toBe("ok");
213
+ expect(report.entityCounts.events).toBe(1); // history only
214
+ expect(report.entityCounts.persons).toBe(1); // follow
215
+ expect(report.entityCounts.items).toBe(1); // history video
216
+ });
217
+
218
+ it("empty events array → ok status with 0 entity counts", async () => {
219
+ rig = makeRig();
220
+ const adapter = new BilibiliAdapter();
221
+ rig.registry.register(adapter);
222
+
223
+ const snapshotPath = writeSnapshot(rig.dir, {
224
+ schemaVersion: SNAPSHOT_SCHEMA_VERSION,
225
+ snapshottedAt: Date.now(),
226
+ events: [],
227
+ });
228
+ const report = await rig.registry.syncAdapter("social-bilibili", {
229
+ inputPath: snapshotPath,
230
+ });
231
+ expect(report.status).toBe("ok");
232
+ expect(report.entityCounts.events).toBe(0);
233
+ expect(report.entityCounts.persons).toBe(0);
234
+ expect(report.entityCounts.items).toBe(0);
235
+ });
236
+
237
+ it("schemaVersion mismatch surfaces in SyncReport.error (not silent)", async () => {
238
+ rig = makeRig();
239
+ const adapter = new BilibiliAdapter();
240
+ rig.registry.register(adapter);
241
+
242
+ const snapshotPath = writeSnapshot(rig.dir, {
243
+ schemaVersion: 99, // wrong
244
+ snapshottedAt: Date.now(),
245
+ events: [],
246
+ });
247
+ const report = await rig.registry.syncAdapter("social-bilibili", {
248
+ inputPath: snapshotPath,
249
+ });
250
+ expect(report.status).toBe("error");
251
+ expect(String(report.error)).toMatch(/schemaVersion mismatch/);
252
+ });
253
+
254
+ it("registry queryable by adapter.name after register()", () => {
255
+ rig = makeRig();
256
+ const adapter = new BilibiliAdapter();
257
+ rig.registry.register(adapter);
258
+ expect(rig.registry.has("social-bilibili")).toBe(true);
259
+ expect(rig.registry.list().some((m) => m.name === "social-bilibili")).toBe(true);
260
+ });
261
+ });
@@ -84,10 +84,17 @@ describe("XiaohongshuAdapter", () => {
84
84
  it("contract conformance", () => {
85
85
  const a = new XiaohongshuAdapter({ account: { uid: "u-1" } });
86
86
  expect(assertAdapter(a).ok).toBe(true);
87
+ expect(a.capabilities).toContain("sync:snapshot");
88
+ expect(a.capabilities).toContain("sync:sqlite");
87
89
  });
88
90
 
89
- it("rejects missing account.uid", () => {
90
- expect(() => new XiaohongshuAdapter({ account: {} })).toThrow(/uid/);
91
+ it("snapshot mode constructs without account.uid (stateless)", () => {
92
+ // §A8 v0.2: constructor loosened — snapshot mode pulls account from the
93
+ // snapshot file. Sqlite mode still requires account.uid, checked at sync
94
+ // time not construction.
95
+ const a = new XiaohongshuAdapter({});
96
+ expect(assertAdapter(a).ok).toBe(true);
97
+ expect(a.account).toBeNull();
91
98
  });
92
99
 
93
100
  it("sync yields history + likes + favourites", async () => {
@@ -118,17 +125,39 @@ describe("QQAdapter", () => {
118
125
  const a = new QQAdapter({ account: { qq: "12345" } });
119
126
  expect(assertAdapter(a).ok).toBe(true);
120
127
  expect(a.dataDisclosure.legalGate).toBe(true);
128
+ expect(a.capabilities).toContain("sync:snapshot");
129
+ expect(a.capabilities).toContain("sync:sqlite");
130
+ });
131
+
132
+ it("snapshot mode constructs without account.qq (stateless)", () => {
133
+ // §Phase 13.5 v0.2: constructor loosened — snapshot mode pulls account
134
+ // from the snapshot file. Sqlite mode still requires account.qq, checked
135
+ // at sync time not construction. Mirror of weibo / bilibili A8 pattern.
136
+ const a = new QQAdapter({});
137
+ expect(assertAdapter(a).ok).toBe(true);
138
+ expect(a.account).toBeNull();
121
139
  });
122
140
 
123
- it("rejects missing account.qq", () => {
124
- expect(() => new QQAdapter({ account: {} })).toThrow(/qq/);
141
+ it("sqlite mode throws at sync time when account.qq missing", async () => {
142
+ const { dir, dbPath } = tmpDb();
143
+ try {
144
+ const a = new QQAdapter({ dbPath, keyProvider: { getKey: async () => "k" } });
145
+ let threw = null;
146
+ try {
147
+ for await (const _r of a.sync()) { /* drain */ }
148
+ } catch (err) {
149
+ threw = err;
150
+ }
151
+ expect(threw).toBeTruthy();
152
+ expect(String(threw.message)).toMatch(/account\.qq/);
153
+ } finally { cleanup(dir); }
125
154
  });
126
155
 
127
- it("authenticate fails without DB", async () => {
156
+ it("authenticate({}) without inputPath nor dbPath returns NO_INPUT", async () => {
128
157
  const a = new QQAdapter({ account: { qq: "12345" }, keyProvider: { getKey: async () => "k" } });
129
158
  const r = await a.authenticate();
130
159
  expect(r.ok).toBe(false);
131
- expect(r.reason).toBe("DB_NOT_PULLED");
160
+ expect(r.reason).toBe("NO_INPUT");
132
161
  });
133
162
 
134
163
  it("authenticate fails without keyProvider", async () => {
@@ -143,27 +172,44 @@ describe("QQAdapter", () => {
143
172
  it("sync yields contact + group + message types", async () => {
144
173
  const { dir, dbPath } = tmpDb();
145
174
  try {
175
+ // §Phase 13.5 v0.2 mock — new SQL targets:
176
+ // - Friends / friends / tb_recent_contact (probe order)
177
+ // - TroopInfoV2
178
+ // - sqlite_master LIKE 'mr_friend_%_New'
179
+ // - mr_friend_<MD5(peer).upper()>_New
146
180
  const mockDriver = makeMockDriver([
147
- ["FROM mr_friend", [{ uin: "999", nickname: "好友A", remark: "" }]],
148
- ["FROM mr_troop", [{ troop_uin: "888", troop_name: "测试群" }]],
149
- ["FROM sqlite_master", [{ name: "msgcsr_friend_999" }]],
150
- ["FROM msgcsr_friend_999", [{ msgid: "m1", msg: "你好", time: 1700000000, frienduin: "999", msgtype: 1 }]],
181
+ ["FROM Friends", [{ uin: "999", nickname: "好友A", remark: "" }]],
182
+ ["FROM TroopInfoV2", [{ troop_uin: "888", troop_name: "测试群", member_count: 5, owner_uin: "777" }]],
183
+ ["FROM sqlite_master", [{ name: "mr_friend_ABCDEF1234_New" }]],
184
+ [
185
+ "FROM mr_friend_ABCDEF1234_New",
186
+ [{
187
+ msgId: "m1", msgtype: -1000, senderuin: "999", time: 1700000000,
188
+ // msgData is XOR-encrypted bytes; with imei="123456789012345" key,
189
+ // "hi" encrypts to bytes [0x51, 0x5d] (0x68^0x31=0x59 — actually
190
+ // depends on imei[0] = '1' = 0x31). Use Buffer for cross-platform.
191
+ msgData: Buffer.from([0x68 ^ 0x31, 0x69 ^ 0x32]),
192
+ issend: 0, frienduin: "999", troopuin: null,
193
+ }],
194
+ ],
151
195
  ]);
152
196
  const a = new QQAdapter({
153
197
  account: { qq: "12345" },
154
198
  dbPath,
155
- keyProvider: { getKey: async () => "fakekey" },
199
+ keyProvider: { getKey: async () => "123456789012345" },
156
200
  dbDriverFactory: () => mockDriver,
157
201
  });
158
202
  const raws = [];
159
203
  for await (const r of a.sync()) raws.push(r);
160
204
  expect(raws.length).toBe(3); // contact + group + message
161
- const contact = raws.find((r) => r.payload.kind === "contact");
162
- const group = raws.find((r) => r.payload.kind === "group");
163
- const message = raws.find((r) => r.payload.kind === "message");
205
+ const contact = raws.find((r) => r.kind === "contact");
206
+ const group = raws.find((r) => r.kind === "group");
207
+ const message = raws.find((r) => r.kind === "message");
164
208
  expect(contact).toBeDefined();
165
209
  expect(group).toBeDefined();
166
210
  expect(message).toBeDefined();
211
+ // XOR-decrypt round-trip: bytes(0x68^0x31, 0x69^0x32) XOR "12" = "hi"
212
+ expect(message.payload.text).toBe("hi");
167
213
  for (const r of raws) {
168
214
  expect(validateBatch(a.normalize(r)).valid).toBe(true);
169
215
  }