@chainlesschain/personal-data-hub 0.2.2 → 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.
@@ -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 () => {
@@ -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
  }
@@ -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
+ });