@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
@@ -0,0 +1,438 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect, beforeEach } from "vitest";
4
+
5
+ const fs = require("node:fs");
6
+ const path = require("node:path");
7
+ const os = require("node:os");
8
+
9
+ const {
10
+ JdAdapter,
11
+ SNAPSHOT_SCHEMA_VERSION: JD_VERSION,
12
+ } = require("../lib/adapters/shopping-jd");
13
+ const {
14
+ MeituanAdapter,
15
+ SNAPSHOT_SCHEMA_VERSION: MT_VERSION,
16
+ } = require("../lib/adapters/shopping-meituan");
17
+ const { validateBatch } = require("../lib/batch");
18
+
19
+ // §2.4b 购物双联 v0.2 — snapshot-mode tests, mirror of social-weibo-snapshot
20
+ // & travel-maps-snapshot patterns.
21
+ //
22
+ // Snapshot mode is in-APK Android cc reading JSON written by Jd/Meituan
23
+ // LocalCollector (WebView cookie scrape + OkHttp fetch order list). Cookie-
24
+ // mode tests stay in legacy shopping-adapters tests.
25
+
26
+ function writeSnapshot(dir, fileName, snapshot) {
27
+ const p = path.join(dir, fileName);
28
+ fs.writeFileSync(p, JSON.stringify(snapshot), "utf-8");
29
+ return p;
30
+ }
31
+
32
+ describe("JdAdapter snapshot mode", () => {
33
+ let tmpDir;
34
+ beforeEach(() => {
35
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "jd-snap-"));
36
+ });
37
+
38
+ it("exports SNAPSHOT_SCHEMA_VERSION = 1", () => {
39
+ expect(JD_VERSION).toBe(1);
40
+ });
41
+
42
+ it("constructor allows missing account (stateless snapshot mode)", () => {
43
+ expect(() => new JdAdapter()).not.toThrow();
44
+ expect(() => new JdAdapter({})).not.toThrow();
45
+ });
46
+
47
+ it("authenticate(inputPath) ok + mode=snapshot-file", async () => {
48
+ const p = writeSnapshot(tmpDir, "shopping-jd.json", {
49
+ schemaVersion: 1,
50
+ snapshottedAt: Date.now(),
51
+ events: [],
52
+ });
53
+ const a = new JdAdapter();
54
+ const res = await a.authenticate({ inputPath: p });
55
+ expect(res.ok).toBe(true);
56
+ expect(res.mode).toBe("snapshot-file");
57
+ });
58
+
59
+ it("authenticate(inputPath) fails when path unreadable", async () => {
60
+ const a = new JdAdapter();
61
+ const res = await a.authenticate({ inputPath: path.join(tmpDir, "missing.json") });
62
+ expect(res.ok).toBe(false);
63
+ expect(res.reason).toBe("INPUT_PATH_UNREADABLE");
64
+ });
65
+
66
+ it("authenticate() with neither inputPath nor account returns NO_INPUT", async () => {
67
+ const a = new JdAdapter();
68
+ const res = await a.authenticate({});
69
+ expect(res.ok).toBe(false);
70
+ expect(res.reason).toBe("NO_INPUT");
71
+ });
72
+
73
+ it("rejects schemaVersion mismatch", async () => {
74
+ const p = writeSnapshot(tmpDir, "shopping-jd.json", {
75
+ schemaVersion: 99,
76
+ snapshottedAt: Date.now(),
77
+ events: [],
78
+ });
79
+ const a = new JdAdapter();
80
+ let threw = null;
81
+ try {
82
+ for await (const _r of a.sync({ inputPath: p })) { /* drain */ }
83
+ } catch (err) {
84
+ threw = err;
85
+ }
86
+ expect(threw).toBeTruthy();
87
+ expect(String(threw.message)).toMatch(/schemaVersion mismatch/);
88
+ });
89
+
90
+ it("rejects non-JSON snapshot (v0.3 will add HTML parsing)", async () => {
91
+ const p = path.join(tmpDir, "shopping-jd.html");
92
+ fs.writeFileSync(p, "<!DOCTYPE html><html><body>not json</body></html>", "utf-8");
93
+ const a = new JdAdapter();
94
+ let threw = null;
95
+ try {
96
+ for await (const _r of a.sync({ inputPath: p })) { /* drain */ }
97
+ } catch (err) {
98
+ threw = err;
99
+ }
100
+ expect(threw).toBeTruthy();
101
+ expect(String(threw.message)).toMatch(/must be JSON.*v0\.3.*HTML/);
102
+ });
103
+
104
+ it("empty events array yields nothing", async () => {
105
+ const p = writeSnapshot(tmpDir, "shopping-jd.json", {
106
+ schemaVersion: 1,
107
+ snapshottedAt: Date.now(),
108
+ events: [],
109
+ });
110
+ const a = new JdAdapter();
111
+ const raws = [];
112
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
113
+ expect(raws.length).toBe(0);
114
+ });
115
+
116
+ it("order kind round-trip normalize cleanly + namespaced originalId", async () => {
117
+ const now = Date.now();
118
+ const p = writeSnapshot(tmpDir, "shopping-jd.json", {
119
+ schemaVersion: 1,
120
+ snapshottedAt: now,
121
+ vendor: "jd",
122
+ account: { pin: "alice_pin", displayName: "alice" },
123
+ events: [
124
+ {
125
+ kind: "order",
126
+ id: "order-J1",
127
+ capturedAt: now - 1000,
128
+ orderId: "JD200001",
129
+ merchantName: "京东自营",
130
+ items: [
131
+ { name: "AirPods Pro 2", quantity: 1, unitPrice: 1899, sku: "100012345" },
132
+ ],
133
+ placedAt: now - 86400_000,
134
+ paidAt: now - 86300_000,
135
+ status: "delivered",
136
+ totalAmount: { value: 1899, currency: "CNY" },
137
+ recipient: "alice",
138
+ shippingAddress: "厦门市象屿路 93 号",
139
+ },
140
+ ],
141
+ });
142
+ const a = new JdAdapter();
143
+ const raws = [];
144
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
145
+ expect(raws.length).toBe(1);
146
+ expect(raws[0].kind).toBe("order");
147
+ expect(raws[0].originalId).toMatch(/^jd:order:/);
148
+
149
+ const batch = a.normalize(raws[0]);
150
+ expect(validateBatch(batch).valid).toBe(true);
151
+ // OrderRecord → events[0] = "purchase" subtype with merchant 京东自营 / amount 1899
152
+ expect(batch.events.length).toBe(1);
153
+ const merchantPerson = batch.persons.find((p) => p.names && p.names.includes("京东自营"));
154
+ expect(merchantPerson).toBeTruthy();
155
+ });
156
+
157
+ it("respects opts.limit", async () => {
158
+ const now = Date.now();
159
+ const events = Array.from({ length: 5 }, (_, i) => ({
160
+ kind: "order",
161
+ id: `order-J${i}`,
162
+ capturedAt: now - i * 100,
163
+ orderId: `JD${i}`,
164
+ merchantName: "京东自营",
165
+ items: [],
166
+ placedAt: now - i * 100,
167
+ status: "delivered",
168
+ totalAmount: { value: 100, currency: "CNY" },
169
+ }));
170
+ const p = writeSnapshot(tmpDir, "shopping-jd.json", {
171
+ schemaVersion: 1,
172
+ snapshottedAt: now,
173
+ events,
174
+ });
175
+ const a = new JdAdapter();
176
+ const raws = [];
177
+ for await (const r of a.sync({ inputPath: p, limit: 2 })) raws.push(r);
178
+ expect(raws.length).toBe(2);
179
+ });
180
+
181
+ it("filters unknown kinds (forward compat)", async () => {
182
+ const now = Date.now();
183
+ const p = writeSnapshot(tmpDir, "shopping-jd.json", {
184
+ schemaVersion: 1,
185
+ snapshottedAt: now,
186
+ events: [
187
+ {
188
+ kind: "order",
189
+ id: "order-J1",
190
+ capturedAt: now,
191
+ orderId: "JD1",
192
+ merchantName: "京东",
193
+ items: [],
194
+ totalAmount: { value: 0, currency: "CNY" },
195
+ },
196
+ { kind: "future-kind", id: "x", capturedAt: now },
197
+ { kind: "review", id: "rev1", capturedAt: now }, // hypothetical future
198
+ ],
199
+ });
200
+ const a = new JdAdapter();
201
+ const raws = [];
202
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
203
+ expect(raws.length).toBe(1);
204
+ expect(raws[0].kind).toBe("order");
205
+ });
206
+
207
+ it("snapshottedAt fallback when event capturedAt+placedAt+paidAt missing", async () => {
208
+ const ts = 1700000000000;
209
+ const p = writeSnapshot(tmpDir, "shopping-jd.json", {
210
+ schemaVersion: 1,
211
+ snapshottedAt: ts,
212
+ events: [
213
+ {
214
+ kind: "order",
215
+ id: "order-J1",
216
+ orderId: "JD1",
217
+ merchantName: "京东",
218
+ items: [],
219
+ status: "placed",
220
+ totalAmount: { value: 0, currency: "CNY" },
221
+ },
222
+ ],
223
+ });
224
+ const a = new JdAdapter();
225
+ const raws = [];
226
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
227
+ expect(raws[0].capturedAt).toBe(ts);
228
+ });
229
+
230
+ it("sync() without inputPath OR cookies throws", async () => {
231
+ const a = new JdAdapter();
232
+ let threw = null;
233
+ try {
234
+ for await (const _r of a.sync({})) { /* drain */ }
235
+ } catch (err) {
236
+ threw = err;
237
+ }
238
+ expect(threw).toBeTruthy();
239
+ expect(String(threw.message)).toMatch(/needs opts\.inputPath/);
240
+ });
241
+
242
+ it("legacy cookie mode still works (regression guard)", async () => {
243
+ const a = new JdAdapter({
244
+ account: { pin: "alice", cookies: "pt_key=abc; pt_pin=alice" },
245
+ fetchFn: async () => ({
246
+ orders: [
247
+ {
248
+ orderId: "JD-LEGACY-1",
249
+ venderName: "京东自营",
250
+ orderStartTime: "2026-05-22 10:00:00",
251
+ orderTotalPrice: 99,
252
+ productList: [
253
+ { productName: "test", productQuantity: 1, productPrice: 99 },
254
+ ],
255
+ orderStatusText: "已完成",
256
+ },
257
+ ],
258
+ }),
259
+ });
260
+ const raws = [];
261
+ for await (const r of a.sync({})) raws.push(r);
262
+ expect(raws.length).toBeGreaterThanOrEqual(1);
263
+ expect(raws[0].originalId).toBe("JD-LEGACY-1");
264
+ const batch = a.normalize(raws[0]);
265
+ expect(validateBatch(batch).valid).toBe(true);
266
+ });
267
+ });
268
+
269
+ describe("MeituanAdapter snapshot mode", () => {
270
+ let tmpDir;
271
+ beforeEach(() => {
272
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "meituan-snap-"));
273
+ });
274
+
275
+ it("exports SNAPSHOT_SCHEMA_VERSION = 1", () => {
276
+ expect(MT_VERSION).toBe(1);
277
+ });
278
+
279
+ it("constructor allows missing account (stateless snapshot mode)", () => {
280
+ expect(() => new MeituanAdapter()).not.toThrow();
281
+ expect(() => new MeituanAdapter({})).not.toThrow();
282
+ });
283
+
284
+ it("authenticate(inputPath) ok", async () => {
285
+ const p = writeSnapshot(tmpDir, "shopping-meituan.json", {
286
+ schemaVersion: 1,
287
+ snapshottedAt: Date.now(),
288
+ events: [],
289
+ });
290
+ const a = new MeituanAdapter();
291
+ const res = await a.authenticate({ inputPath: p });
292
+ expect(res.ok).toBe(true);
293
+ expect(res.mode).toBe("snapshot-file");
294
+ });
295
+
296
+ it("rejects schemaVersion mismatch", async () => {
297
+ const p = writeSnapshot(tmpDir, "shopping-meituan.json", {
298
+ schemaVersion: 99,
299
+ snapshottedAt: Date.now(),
300
+ events: [],
301
+ });
302
+ const a = new MeituanAdapter();
303
+ let threw = null;
304
+ try {
305
+ for await (const _r of a.sync({ inputPath: p })) { /* drain */ }
306
+ } catch (err) {
307
+ threw = err;
308
+ }
309
+ expect(threw).toBeTruthy();
310
+ expect(String(threw.message)).toMatch(/schemaVersion mismatch/);
311
+ });
312
+
313
+ it("rejects non-JSON snapshot (HTML defer to v0.3)", async () => {
314
+ const p = path.join(tmpDir, "shopping-meituan.html");
315
+ fs.writeFileSync(p, "<html>not json</html>", "utf-8");
316
+ const a = new MeituanAdapter();
317
+ let threw = null;
318
+ try {
319
+ for await (const _r of a.sync({ inputPath: p })) { /* drain */ }
320
+ } catch (err) {
321
+ threw = err;
322
+ }
323
+ expect(threw).toBeTruthy();
324
+ expect(String(threw.message)).toMatch(/must be JSON.*v0\.3.*HTML/);
325
+ });
326
+
327
+ it("order round-trip normalize with carrier=美团 (waimai platform)", async () => {
328
+ const now = Date.now();
329
+ const p = writeSnapshot(tmpDir, "shopping-meituan.json", {
330
+ schemaVersion: 1,
331
+ snapshottedAt: now,
332
+ vendor: "meituan",
333
+ account: { userId: "alice_uid", displayName: "alice" },
334
+ events: [
335
+ {
336
+ kind: "order",
337
+ id: "order-MT1",
338
+ capturedAt: now - 1000,
339
+ orderId: "MT200001",
340
+ merchantName: "肯德基(厦门集美店)",
341
+ platform: "waimai",
342
+ items: [
343
+ { name: "黄金鸡块 5 块", quantity: 1, unitPrice: 15.5 },
344
+ { name: "雪顶咖啡", quantity: 1, unitPrice: 16 },
345
+ ],
346
+ placedAt: now - 86400_000,
347
+ paidAt: now - 86300_000,
348
+ status: "delivered",
349
+ totalAmount: { value: 31.5, currency: "CNY" },
350
+ recipient: "alice",
351
+ shippingAddress: "厦门市集美区某街道",
352
+ },
353
+ ],
354
+ });
355
+ const a = new MeituanAdapter();
356
+ const raws = [];
357
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
358
+ expect(raws.length).toBe(1);
359
+ expect(raws[0].originalId).toMatch(/^meituan:order:/);
360
+
361
+ const batch = a.normalize(raws[0]);
362
+ expect(validateBatch(batch).valid).toBe(true);
363
+ // Merchant is per-POI in waimai (not generic "美团")
364
+ const merchant = batch.persons.find((p) => p.names && p.names.includes("肯德基(厦门集美店)"));
365
+ expect(merchant).toBeTruthy();
366
+ });
367
+
368
+ it("filters unknown kinds (forward compat)", async () => {
369
+ const now = Date.now();
370
+ const p = writeSnapshot(tmpDir, "shopping-meituan.json", {
371
+ schemaVersion: 1,
372
+ snapshottedAt: now,
373
+ events: [
374
+ {
375
+ kind: "order",
376
+ id: "order-MT1",
377
+ capturedAt: now,
378
+ orderId: "MT1",
379
+ merchantName: "测试餐厅",
380
+ items: [],
381
+ status: "placed",
382
+ totalAmount: { value: 0, currency: "CNY" },
383
+ },
384
+ { kind: "speculative-coupon", id: "c1", capturedAt: now },
385
+ ],
386
+ });
387
+ const a = new MeituanAdapter();
388
+ const raws = [];
389
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
390
+ expect(raws.length).toBe(1);
391
+ expect(raws[0].kind).toBe("order");
392
+ });
393
+
394
+ it("snapshottedAt fallback when no timestamps in event", async () => {
395
+ const ts = 1700000000000;
396
+ const p = writeSnapshot(tmpDir, "shopping-meituan.json", {
397
+ schemaVersion: 1,
398
+ snapshottedAt: ts,
399
+ events: [
400
+ {
401
+ kind: "order",
402
+ id: "order-MT1",
403
+ orderId: "MT1",
404
+ merchantName: "test",
405
+ items: [],
406
+ status: "placed",
407
+ totalAmount: { value: 0, currency: "CNY" },
408
+ },
409
+ ],
410
+ });
411
+ const a = new MeituanAdapter();
412
+ const raws = [];
413
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
414
+ expect(raws[0].capturedAt).toBe(ts);
415
+ });
416
+
417
+ it("legacy cookie mode still works (regression guard)", async () => {
418
+ const a = new MeituanAdapter({
419
+ account: { userId: "alice", cookies: "iuuid=abc" },
420
+ fetchFn: async () => ({
421
+ orders: [
422
+ {
423
+ orderId: "MT-LEGACY-1",
424
+ poiName: "测试餐厅",
425
+ orderTime: "2026-05-22 12:00:00",
426
+ totalPrice: 50,
427
+ dishes: [{ name: "测试菜", quantity: 1, price: 50 }],
428
+ statusDesc: "已完成",
429
+ },
430
+ ],
431
+ }),
432
+ });
433
+ const raws = [];
434
+ for await (const r of a.sync({})) raws.push(r);
435
+ expect(raws.length).toBeGreaterThanOrEqual(1);
436
+ expect(raws[0].originalId).toBe("MT-LEGACY-1");
437
+ });
438
+ });
@@ -37,9 +37,47 @@ describe("BilibiliAdapter", () => {
37
37
  expect(a.extractMode).toBe("device-pull");
38
38
  });
39
39
 
40
- it("rejects missing account.uid", () => {
41
- expect(() => new BilibiliAdapter({})).toThrow();
42
- expect(() => new BilibiliAdapter({ account: {} })).toThrow(/uid/);
40
+ it("accepts stateless construction (snapshot mode added in A8)", () => {
41
+ // Before A8: constructor required opts.account.uid. After A8 the adapter
42
+ // is stateless when running snapshot mode (in-APK Android cc reads a JSON
43
+ // produced by the phone). Sqlite mode still needs account.uid but the
44
+ // check moved into _syncViaSqlite where it actually matters.
45
+ expect(() => new BilibiliAdapter({})).not.toThrow();
46
+ expect(() => new BilibiliAdapter({ account: {} })).not.toThrow();
47
+ expect(() => new BilibiliAdapter()).not.toThrow();
48
+ });
49
+
50
+ it("sqlite mode rejects missing account.uid at sync time", async () => {
51
+ const a = new BilibiliAdapter({ dbPath: "/tmp/bili.db" });
52
+ // Path-existence check happens before account.uid validation, so we
53
+ // exercise the guard via dbPath=null + account=null which falls to
54
+ // "sync needs inputPath OR dbPath" first. Use a real-looking dbPath
55
+ // with no account to surface the account.uid throw deterministically.
56
+ const fs = require("node:fs");
57
+ const path = require("node:path");
58
+ const os = require("node:os");
59
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bili-no-acct-"));
60
+ const dbPath = path.join(dir, "bili.db");
61
+ fs.writeFileSync(dbPath, "fake");
62
+ try {
63
+ const b = new BilibiliAdapter({
64
+ dbPath,
65
+ dbDriverFactory: () => () => ({
66
+ prepare: () => ({ all: () => [] }),
67
+ close() {},
68
+ }),
69
+ });
70
+ let threw = null;
71
+ try {
72
+ for await (const _r of b.sync()) { /* drain */ }
73
+ } catch (err) {
74
+ threw = err;
75
+ }
76
+ expect(threw).toBeTruthy();
77
+ expect(String(threw.message)).toMatch(/account\.uid/);
78
+ } finally {
79
+ fs.rmSync(dir, { recursive: true, force: true });
80
+ }
43
81
  });
44
82
 
45
83
  it("sync yields history + favourite records via mocked driver", async () => {
@@ -83,26 +121,34 @@ describe("BilibiliAdapter", () => {
83
121
  }
84
122
  });
85
123
 
86
- it("idle when DB path missing", async () => {
124
+ it("throws when neither inputPath nor dbPath provided (A8: surface config errors)", async () => {
125
+ // Before A8: sync silently yielded 0 if dbPath missing — masked typos and
126
+ // misconfigured callers. After A8 we throw so callers see the problem.
87
127
  const a = new BilibiliAdapter({ account: { uid: "1234" } });
88
- const raws = [];
89
- for await (const r of a.sync()) raws.push(r);
90
- expect(raws).toHaveLength(0);
128
+ let threw = null;
129
+ try {
130
+ for await (const _r of a.sync()) { /* drain */ }
131
+ } catch (err) {
132
+ threw = err;
133
+ }
134
+ expect(threw).toBeTruthy();
135
+ expect(String(threw.message)).toMatch(/inputPath|dbPath/);
91
136
  });
92
137
 
93
- it("normalize captures bvid/avid/uploader into extra", async () => {
138
+ it("normalize captures bvid/avid/uploader into extra (flat payload, A8 shape)", async () => {
94
139
  const a = new BilibiliAdapter({ account: { uid: "1234" } });
95
140
  const raw = {
96
141
  adapter: "social-bilibili",
97
- originalId: "history-1",
142
+ kind: "history",
143
+ originalId: "bilibili:history:BV1abc",
98
144
  capturedAt: 1700000000000,
99
145
  payload: {
100
146
  kind: "history",
101
- row: {
102
- id: 1, bvid: "BV1abc", avid: "1234",
103
- title: "Test", view_at: 1700000000,
104
- uploader: "UpA", duration: 300,
105
- },
147
+ title: "Test",
148
+ bvid: "BV1abc",
149
+ avid: "1234",
150
+ uploader: "UpA",
151
+ duration: 300,
106
152
  },
107
153
  };
108
154
  const batch = a.normalize(raw);
@@ -110,6 +156,9 @@ describe("BilibiliAdapter", () => {
110
156
  expect(batch.events[0].extra.avid).toBe("1234");
111
157
  expect(batch.events[0].extra.uploader).toBe("UpA");
112
158
  expect(batch.events[0].extra.duration).toBe(300);
159
+ // A8: history also yields an item entity (video) for KG linkage
160
+ expect(batch.items).toHaveLength(1);
161
+ expect(batch.items[0].extra.bvid).toBe("BV1abc");
113
162
  });
114
163
  });
115
164
 
@@ -120,11 +169,36 @@ describe("WeiboAdapter", () => {
120
169
  const a = new WeiboAdapter({ account: { uid: "1234" } });
121
170
  expect(assertAdapter(a).ok).toBe(true);
122
171
  expect(a.extractMode).toBe("device-pull");
172
+ expect(a.capabilities).toContain("sync:snapshot");
173
+ expect(a.capabilities).toContain("sync:sqlite");
123
174
  });
124
175
 
125
- it("rejects missing account.uid", () => {
126
- expect(() => new WeiboAdapter({})).toThrow();
127
- expect(() => new WeiboAdapter({ account: {} })).toThrow(/uid/);
176
+ it("snapshot mode constructs without account.uid (stateless)", () => {
177
+ // §A8 v0.2: constructor loosened — snapshot mode pulls account from the
178
+ // snapshot file. Sqlite mode still requires account.uid, checked at sync
179
+ // time not construction.
180
+ const a = new WeiboAdapter({});
181
+ expect(assertAdapter(a).ok).toBe(true);
182
+ expect(a.account).toBeNull();
183
+ });
184
+
185
+ it("sqlite mode throws at sync time when account.uid missing", async () => {
186
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "weibo-"));
187
+ const dbPath = path.join(dir, "weibo.db");
188
+ fs.writeFileSync(dbPath, "fake");
189
+ try {
190
+ const a = new WeiboAdapter({});
191
+ let threw = null;
192
+ try {
193
+ for await (const _r of a.sync({ dbPath })) { /* drain */ }
194
+ } catch (e) {
195
+ threw = e;
196
+ }
197
+ expect(threw).not.toBeNull();
198
+ expect(threw.message).toMatch(/account\.uid/);
199
+ } finally {
200
+ fs.rmSync(dir, { recursive: true, force: true });
201
+ }
128
202
  });
129
203
 
130
204
  it("sync yields posts + search records via mocked driver", async () => {