@chainlesschain/personal-data-hub 0.2.2 → 0.2.4

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 (28) hide show
  1. package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +58 -16
  2. package/__tests__/analysis.test.js +1 -1
  3. package/__tests__/longtail-adapters.test.js +67 -16
  4. package/__tests__/messaging-qq-snapshot.test.js +294 -0
  5. package/__tests__/shopping-pinduoduo-snapshot.test.js +302 -0
  6. package/__tests__/shopping-snapshot.test.js +438 -0
  7. package/__tests__/social-adapters.test.js +28 -3
  8. package/__tests__/social-douyin-snapshot.test.js +253 -0
  9. package/__tests__/social-kuaishou-snapshot.test.js +309 -0
  10. package/__tests__/social-toutiao-snapshot.test.js +314 -0
  11. package/__tests__/social-weibo-snapshot.test.js +234 -0
  12. package/__tests__/social-xiaohongshu-snapshot.test.js +232 -0
  13. package/__tests__/travel-maps-snapshot.test.js +426 -0
  14. package/__tests__/vault-driver-error.test.js +74 -0
  15. package/lib/adapters/messaging-qq/index.js +498 -92
  16. package/lib/adapters/shopping-jd/index.js +228 -25
  17. package/lib/adapters/shopping-meituan/index.js +222 -26
  18. package/lib/adapters/shopping-pinduoduo/index.js +275 -0
  19. package/lib/adapters/social-douyin/index.js +454 -63
  20. package/lib/adapters/social-kuaishou/index.js +379 -127
  21. package/lib/adapters/social-toutiao/index.js +400 -130
  22. package/lib/adapters/social-weibo/index.js +393 -95
  23. package/lib/adapters/social-xiaohongshu/index.js +389 -49
  24. package/lib/adapters/travel-baidu-map/index.js +286 -26
  25. package/lib/adapters/travel-tencent-map/index.js +414 -0
  26. package/lib/index.js +5 -1
  27. package/lib/vault.js +60 -8
  28. 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 () => {
@@ -320,7 +320,7 @@ describe("AnalysisEngine emits TOTALS preamble", () => {
320
320
  expect(userMsg).toContain('"persons": 512');
321
321
  expect(userMsg).toContain('"items": 89');
322
322
  // System prompt tells LLM to trust TOTALS for counts.
323
- expect(chatCalls[0][0].content).toMatch(/TOTALS.*authoritative/);
323
+ expect(chatCalls[0][0].content).toMatch(/TOTALS.*authoritative/i);
324
324
  });
325
325
 
326
326
  it("intent=count for '几个联系人' and '几个 app' and '多少个 X'", () => {
@@ -53,8 +53,13 @@ describe("DouyinAdapter", () => {
53
53
  expect(assertAdapter(a).ok).toBe(true);
54
54
  });
55
55
 
56
- it("rejects missing account.uid", () => {
57
- expect(() => new DouyinAdapter({ account: {} })).toThrow(/uid/);
56
+ it("snapshot mode constructs without account.uid (stateless)", () => {
57
+ // §A8 v0.2: constructor loosened — snapshot mode pulls account from the
58
+ // snapshot file. Sqlite mode still requires account.uid, checked at sync
59
+ // time not construction (see _syncViaSqlite throw at runtime).
60
+ const a = new DouyinAdapter({});
61
+ expect(assertAdapter(a).ok).toBe(true);
62
+ expect(a.account).toBeNull();
58
63
  });
59
64
 
60
65
  it("sync yields history + favourite + search", async () => {
@@ -84,10 +89,17 @@ describe("XiaohongshuAdapter", () => {
84
89
  it("contract conformance", () => {
85
90
  const a = new XiaohongshuAdapter({ account: { uid: "u-1" } });
86
91
  expect(assertAdapter(a).ok).toBe(true);
92
+ expect(a.capabilities).toContain("sync:snapshot");
93
+ expect(a.capabilities).toContain("sync:sqlite");
87
94
  });
88
95
 
89
- it("rejects missing account.uid", () => {
90
- expect(() => new XiaohongshuAdapter({ account: {} })).toThrow(/uid/);
96
+ it("snapshot mode constructs without account.uid (stateless)", () => {
97
+ // §A8 v0.2: constructor loosened — snapshot mode pulls account from the
98
+ // snapshot file. Sqlite mode still requires account.uid, checked at sync
99
+ // time not construction.
100
+ const a = new XiaohongshuAdapter({});
101
+ expect(assertAdapter(a).ok).toBe(true);
102
+ expect(a.account).toBeNull();
91
103
  });
92
104
 
93
105
  it("sync yields history + likes + favourites", async () => {
@@ -118,17 +130,39 @@ describe("QQAdapter", () => {
118
130
  const a = new QQAdapter({ account: { qq: "12345" } });
119
131
  expect(assertAdapter(a).ok).toBe(true);
120
132
  expect(a.dataDisclosure.legalGate).toBe(true);
133
+ expect(a.capabilities).toContain("sync:snapshot");
134
+ expect(a.capabilities).toContain("sync:sqlite");
135
+ });
136
+
137
+ it("snapshot mode constructs without account.qq (stateless)", () => {
138
+ // §Phase 13.5 v0.2: constructor loosened — snapshot mode pulls account
139
+ // from the snapshot file. Sqlite mode still requires account.qq, checked
140
+ // at sync time not construction. Mirror of weibo / bilibili A8 pattern.
141
+ const a = new QQAdapter({});
142
+ expect(assertAdapter(a).ok).toBe(true);
143
+ expect(a.account).toBeNull();
121
144
  });
122
145
 
123
- it("rejects missing account.qq", () => {
124
- expect(() => new QQAdapter({ account: {} })).toThrow(/qq/);
146
+ it("sqlite mode throws at sync time when account.qq missing", async () => {
147
+ const { dir, dbPath } = tmpDb();
148
+ try {
149
+ const a = new QQAdapter({ dbPath, keyProvider: { getKey: async () => "k" } });
150
+ let threw = null;
151
+ try {
152
+ for await (const _r of a.sync()) { /* drain */ }
153
+ } catch (err) {
154
+ threw = err;
155
+ }
156
+ expect(threw).toBeTruthy();
157
+ expect(String(threw.message)).toMatch(/account\.qq/);
158
+ } finally { cleanup(dir); }
125
159
  });
126
160
 
127
- it("authenticate fails without DB", async () => {
161
+ it("authenticate({}) without inputPath nor dbPath returns NO_INPUT", async () => {
128
162
  const a = new QQAdapter({ account: { qq: "12345" }, keyProvider: { getKey: async () => "k" } });
129
163
  const r = await a.authenticate();
130
164
  expect(r.ok).toBe(false);
131
- expect(r.reason).toBe("DB_NOT_PULLED");
165
+ expect(r.reason).toBe("NO_INPUT");
132
166
  });
133
167
 
134
168
  it("authenticate fails without keyProvider", async () => {
@@ -143,27 +177,44 @@ describe("QQAdapter", () => {
143
177
  it("sync yields contact + group + message types", async () => {
144
178
  const { dir, dbPath } = tmpDb();
145
179
  try {
180
+ // §Phase 13.5 v0.2 mock — new SQL targets:
181
+ // - Friends / friends / tb_recent_contact (probe order)
182
+ // - TroopInfoV2
183
+ // - sqlite_master LIKE 'mr_friend_%_New'
184
+ // - mr_friend_<MD5(peer).upper()>_New
146
185
  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 }]],
186
+ ["FROM Friends", [{ uin: "999", nickname: "好友A", remark: "" }]],
187
+ ["FROM TroopInfoV2", [{ troop_uin: "888", troop_name: "测试群", member_count: 5, owner_uin: "777" }]],
188
+ ["FROM sqlite_master", [{ name: "mr_friend_ABCDEF1234_New" }]],
189
+ [
190
+ "FROM mr_friend_ABCDEF1234_New",
191
+ [{
192
+ msgId: "m1", msgtype: -1000, senderuin: "999", time: 1700000000,
193
+ // msgData is XOR-encrypted bytes; with imei="123456789012345" key,
194
+ // "hi" encrypts to bytes [0x51, 0x5d] (0x68^0x31=0x59 — actually
195
+ // depends on imei[0] = '1' = 0x31). Use Buffer for cross-platform.
196
+ msgData: Buffer.from([0x68 ^ 0x31, 0x69 ^ 0x32]),
197
+ issend: 0, frienduin: "999", troopuin: null,
198
+ }],
199
+ ],
151
200
  ]);
152
201
  const a = new QQAdapter({
153
202
  account: { qq: "12345" },
154
203
  dbPath,
155
- keyProvider: { getKey: async () => "fakekey" },
204
+ keyProvider: { getKey: async () => "123456789012345" },
156
205
  dbDriverFactory: () => mockDriver,
157
206
  });
158
207
  const raws = [];
159
208
  for await (const r of a.sync()) raws.push(r);
160
209
  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");
210
+ const contact = raws.find((r) => r.kind === "contact");
211
+ const group = raws.find((r) => r.kind === "group");
212
+ const message = raws.find((r) => r.kind === "message");
164
213
  expect(contact).toBeDefined();
165
214
  expect(group).toBeDefined();
166
215
  expect(message).toBeDefined();
216
+ // XOR-decrypt round-trip: bytes(0x68^0x31, 0x69^0x32) XOR "12" = "hi"
217
+ expect(message.payload.text).toBe("hi");
167
218
  for (const r of raws) {
168
219
  expect(validateBatch(a.normalize(r)).valid).toBe(true);
169
220
  }
@@ -0,0 +1,294 @@
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
+ QQAdapter,
11
+ SNAPSHOT_SCHEMA_VERSION,
12
+ VALID_SNAPSHOT_KINDS,
13
+ xorDecrypt,
14
+ } = require("../lib/adapters/messaging-qq");
15
+ const { validateBatch } = require("../lib/batch");
16
+
17
+ // §Phase 13.5 v0.2 (2026-05-22) — snapshot-mode tests, mirror of
18
+ // social-weibo-snapshot.test.js.
19
+ //
20
+ // Snapshot mode is in-APK Android cc reading JSON written by QQLocalCollector
21
+ // (su cp /data/data/com.tencent.mobileqq/databases/<uin>.db + plain SQLite
22
+ // open + per-row XOR-with-IMEI decrypt of msgData). Sqlite/device-pull tests
23
+ // stay in longtail-adapters.test.js. Both paths share normalize().
24
+
25
+ function writeSnapshot(dir, snapshot) {
26
+ const p = path.join(dir, "messaging-qq.json");
27
+ fs.writeFileSync(p, JSON.stringify(snapshot), "utf-8");
28
+ return p;
29
+ }
30
+
31
+ describe("QQAdapter snapshot mode", () => {
32
+ let tmpDir;
33
+ beforeEach(() => {
34
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "qq-snap-"));
35
+ });
36
+
37
+ it("exports SNAPSHOT_SCHEMA_VERSION = 1 + 3 VALID_SNAPSHOT_KINDS", () => {
38
+ expect(SNAPSHOT_SCHEMA_VERSION).toBe(1);
39
+ expect(VALID_SNAPSHOT_KINDS).toEqual(["contact", "group", "message"]);
40
+ });
41
+
42
+ it("authenticate(inputPath) ok when readable", async () => {
43
+ const p = writeSnapshot(tmpDir, {
44
+ schemaVersion: 1,
45
+ snapshottedAt: Date.now(),
46
+ events: [],
47
+ });
48
+ const a = new QQAdapter();
49
+ const res = await a.authenticate({ inputPath: p });
50
+ expect(res.ok).toBe(true);
51
+ expect(res.mode).toBe("snapshot-file");
52
+ });
53
+
54
+ it("authenticate(inputPath) fails when path unreadable", async () => {
55
+ const a = new QQAdapter();
56
+ const res = await a.authenticate({ inputPath: path.join(tmpDir, "missing.json") });
57
+ expect(res.ok).toBe(false);
58
+ expect(res.reason).toBe("INPUT_PATH_UNREADABLE");
59
+ });
60
+
61
+ it("authenticate() with neither inputPath nor dbPath returns NO_INPUT", async () => {
62
+ const a = new QQAdapter();
63
+ const res = await a.authenticate({});
64
+ expect(res.ok).toBe(false);
65
+ expect(res.reason).toBe("NO_INPUT");
66
+ });
67
+
68
+ it("rejects schemaVersion mismatch", async () => {
69
+ const p = writeSnapshot(tmpDir, {
70
+ schemaVersion: 99,
71
+ snapshottedAt: Date.now(),
72
+ events: [],
73
+ });
74
+ const a = new QQAdapter();
75
+ let threw = null;
76
+ try {
77
+ for await (const _r of a.sync({ inputPath: p })) { /* drain */ }
78
+ } catch (err) {
79
+ threw = err;
80
+ }
81
+ expect(threw).toBeTruthy();
82
+ expect(String(threw.message)).toMatch(/schemaVersion mismatch/);
83
+ });
84
+
85
+ it("empty events array yields nothing (no crash)", async () => {
86
+ const p = writeSnapshot(tmpDir, {
87
+ schemaVersion: 1,
88
+ snapshottedAt: Date.now(),
89
+ events: [],
90
+ });
91
+ const a = new QQAdapter();
92
+ const raws = [];
93
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
94
+ expect(raws.length).toBe(0);
95
+ });
96
+
97
+ it("contact + group + message round-trip normalize cleanly", async () => {
98
+ const now = Date.now();
99
+ const p = writeSnapshot(tmpDir, {
100
+ schemaVersion: 1,
101
+ snapshottedAt: now,
102
+ account: { qq: "12345", displayName: "alice" },
103
+ events: [
104
+ {
105
+ kind: "contact",
106
+ id: "contact-99999",
107
+ capturedAt: now - 1000,
108
+ uin: "99999",
109
+ nickname: "好友A",
110
+ remark: "工作组",
111
+ },
112
+ {
113
+ kind: "group",
114
+ id: "group-88888",
115
+ capturedAt: now - 2000,
116
+ troopUin: "88888",
117
+ troopName: "测试群",
118
+ memberCount: 30,
119
+ ownerUin: "77777",
120
+ },
121
+ {
122
+ kind: "message",
123
+ id: "msg-m1",
124
+ capturedAt: now - 3000,
125
+ msgId: "m1",
126
+ msgType: -1000,
127
+ senderUin: "99999",
128
+ peerUin: "12345",
129
+ isGroup: false,
130
+ isSend: false,
131
+ text: "你好",
132
+ },
133
+ ],
134
+ });
135
+ const a = new QQAdapter();
136
+ const raws = [];
137
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
138
+ expect(raws.length).toBe(3);
139
+
140
+ const kinds = raws.map((r) => r.kind);
141
+ expect(kinds).toEqual(["contact", "group", "message"]);
142
+
143
+ // Each originalId namespaced under qq:<kind>:<id>
144
+ expect(raws[0].originalId).toMatch(/^qq:contact:/);
145
+ expect(raws[1].originalId).toMatch(/^qq:group:/);
146
+ expect(raws[2].originalId).toMatch(/^qq:message:/);
147
+
148
+ // Normalize each + validate
149
+ for (const raw of raws) {
150
+ const batch = a.normalize(raw);
151
+ expect(validateBatch(batch).valid).toBe(true);
152
+ }
153
+
154
+ const contactBatch = a.normalize(raws[0]);
155
+ expect(contactBatch.events.length).toBe(0);
156
+ expect(contactBatch.persons.length).toBe(1);
157
+ // remark + nickname + uin all surface as names (priority order)
158
+ expect(contactBatch.persons[0].names).toEqual(["工作组", "好友A", "99999"]);
159
+ expect(contactBatch.persons[0].identifiers["qq-uin"]).toEqual(["99999"]);
160
+
161
+ const groupBatch = a.normalize(raws[1]);
162
+ expect(groupBatch.events.length).toBe(0);
163
+ expect(groupBatch.topics.length).toBe(1);
164
+ expect(groupBatch.topics[0].name).toBe("测试群");
165
+ expect(groupBatch.topics[0].extra.troopUin).toBe("88888");
166
+ expect(groupBatch.topics[0].extra.memberCount).toBe(30);
167
+
168
+ const msgBatch = a.normalize(raws[2]);
169
+ expect(msgBatch.events.length).toBe(1);
170
+ expect(msgBatch.events[0].subtype).toBe("message");
171
+ expect(msgBatch.events[0].extra.peerUin).toBe("12345");
172
+ expect(msgBatch.events[0].extra.senderUin).toBe("99999");
173
+ expect(msgBatch.events[0].extra.isGroup).toBe(false);
174
+ expect(msgBatch.events[0].extra.isSend).toBe(false);
175
+ expect(msgBatch.events[0].content.text).toBe("你好");
176
+ });
177
+
178
+ it("respects per-kind include opt-out", async () => {
179
+ const now = Date.now();
180
+ const p = writeSnapshot(tmpDir, {
181
+ schemaVersion: 1,
182
+ snapshottedAt: now,
183
+ events: [
184
+ { kind: "contact", id: "c1", capturedAt: now, uin: "100", nickname: "A" },
185
+ { kind: "group", id: "g1", capturedAt: now, troopUin: "200", troopName: "G" },
186
+ { kind: "message", id: "m1", capturedAt: now, msgId: "m1", text: "hi" },
187
+ ],
188
+ });
189
+ const a = new QQAdapter();
190
+ const raws = [];
191
+ for await (const r of a.sync({ inputPath: p, include: { message: false } })) {
192
+ raws.push(r);
193
+ }
194
+ const kinds = raws.map((r) => r.kind);
195
+ expect(kinds).toEqual(["contact", "group"]);
196
+ });
197
+
198
+ it("respects opts.limit", async () => {
199
+ const now = Date.now();
200
+ const events = Array.from({ length: 5 }, (_, i) => ({
201
+ kind: "message",
202
+ id: `m${i}`,
203
+ capturedAt: now - i * 100,
204
+ msgId: `m${i}`,
205
+ text: `t${i}`,
206
+ }));
207
+ const p = writeSnapshot(tmpDir, { schemaVersion: 1, snapshottedAt: now, events });
208
+ const a = new QQAdapter();
209
+ const raws = [];
210
+ for await (const r of a.sync({ inputPath: p, limit: 2 })) raws.push(r);
211
+ expect(raws.length).toBe(2);
212
+ });
213
+
214
+ it("filters out unknown kinds (forward compat)", async () => {
215
+ const now = Date.now();
216
+ const p = writeSnapshot(tmpDir, {
217
+ schemaVersion: 1,
218
+ snapshottedAt: now,
219
+ events: [
220
+ { kind: "contact", id: "c1", capturedAt: now, uin: "100", nickname: "A" },
221
+ { kind: "future-kind", id: "x", capturedAt: now },
222
+ { kind: "search", id: "s", capturedAt: now }, // not a QQ snapshot kind
223
+ ],
224
+ });
225
+ const a = new QQAdapter();
226
+ const raws = [];
227
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
228
+ expect(raws.length).toBe(1);
229
+ expect(raws[0].kind).toBe("contact");
230
+ });
231
+
232
+ it("snapshottedAt fallback when event capturedAt missing", async () => {
233
+ const ts = 1700000000000;
234
+ const p = writeSnapshot(tmpDir, {
235
+ schemaVersion: 1,
236
+ snapshottedAt: ts,
237
+ events: [
238
+ { kind: "contact", id: "c1", uin: "100", nickname: "A" },
239
+ ],
240
+ });
241
+ const a = new QQAdapter();
242
+ const raws = [];
243
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
244
+ expect(raws[0].capturedAt).toBe(ts);
245
+ });
246
+
247
+ it("normalize handles missing identifiers gracefully (forward compat)", () => {
248
+ const a = new QQAdapter();
249
+ // Contact with empty payload — uin/nickname/remark all absent
250
+ const raw = {
251
+ adapter: "messaging-qq",
252
+ kind: "contact",
253
+ originalId: "qq:contact:unknown",
254
+ capturedAt: Date.now(),
255
+ payload: { kind: "contact" },
256
+ };
257
+ const batch = a.normalize(raw);
258
+ expect(batch.persons.length).toBe(1);
259
+ expect(batch.persons[0].names).toEqual(["(unnamed)"]);
260
+ expect(validateBatch(batch).valid).toBe(true);
261
+ });
262
+
263
+ it("xorDecrypt: empty input or empty key returns empty string", () => {
264
+ expect(xorDecrypt(null, Buffer.from("123"))).toBe("");
265
+ expect(xorDecrypt(Buffer.from(""), Buffer.from("123"))).toBe("");
266
+ expect(xorDecrypt(Buffer.from("hi"), Buffer.from(""))).toBe("");
267
+ });
268
+
269
+ it("xorDecrypt: ASCII round-trip via IMEI XOR (sjqz qq.py parity)", () => {
270
+ // Algorithm: out[i] = data[i] XOR imeiBytes[i % imeiLen]. Encryption is
271
+ // its own inverse: XOR-decrypt the encrypted form ⇒ original plaintext.
272
+ const imei = "123456789012345";
273
+ const plaintext = "hello";
274
+ const imeiBytes = Buffer.from(imei, "utf-8");
275
+ const ptBytes = Buffer.from(plaintext, "utf-8");
276
+ const encrypted = Buffer.alloc(ptBytes.length);
277
+ for (let i = 0; i < ptBytes.length; i++) {
278
+ encrypted[i] = ptBytes[i] ^ imeiBytes[i % imeiBytes.length];
279
+ }
280
+ expect(xorDecrypt(encrypted, imeiBytes)).toBe(plaintext);
281
+ });
282
+
283
+ it("xorDecrypt: UTF-8 multibyte (Chinese) round-trip", () => {
284
+ const imei = "999000111222333";
285
+ const plaintext = "你好世界";
286
+ const imeiBytes = Buffer.from(imei, "utf-8");
287
+ const ptBytes = Buffer.from(plaintext, "utf-8");
288
+ const encrypted = Buffer.alloc(ptBytes.length);
289
+ for (let i = 0; i < ptBytes.length; i++) {
290
+ encrypted[i] = ptBytes[i] ^ imeiBytes[i % imeiBytes.length];
291
+ }
292
+ expect(xorDecrypt(encrypted, imeiBytes)).toBe(plaintext);
293
+ });
294
+ });