@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.
- package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +58 -16
- package/__tests__/longtail-adapters.test.js +60 -14
- package/__tests__/messaging-qq-snapshot.test.js +294 -0
- package/__tests__/shopping-pinduoduo-snapshot.test.js +302 -0
- package/__tests__/shopping-snapshot.test.js +438 -0
- package/__tests__/social-adapters.test.js +28 -3
- package/__tests__/social-douyin-snapshot.test.js +253 -0
- package/__tests__/social-kuaishou-snapshot.test.js +309 -0
- package/__tests__/social-toutiao-snapshot.test.js +314 -0
- package/__tests__/social-weibo-snapshot.test.js +234 -0
- package/__tests__/social-xiaohongshu-snapshot.test.js +232 -0
- package/__tests__/travel-maps-snapshot.test.js +426 -0
- package/__tests__/vault-driver-error.test.js +74 -0
- package/lib/adapters/messaging-qq/index.js +498 -92
- package/lib/adapters/shopping-jd/index.js +228 -25
- package/lib/adapters/shopping-meituan/index.js +222 -26
- package/lib/adapters/shopping-pinduoduo/index.js +275 -0
- package/lib/adapters/social-douyin/index.js +454 -63
- package/lib/adapters/social-kuaishou/index.js +379 -127
- package/lib/adapters/social-toutiao/index.js +400 -130
- package/lib/adapters/social-weibo/index.js +393 -95
- package/lib/adapters/social-xiaohongshu/index.js +389 -49
- package/lib/adapters/travel-baidu-map/index.js +286 -26
- package/lib/adapters/travel-tencent-map/index.js +414 -0
- package/lib/index.js +5 -1
- package/lib/vault.js +60 -8
- package/package.json +2 -1
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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 —
|
|
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("
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
156
|
+
it("normalize throws on missing payload row + no snapshot fields (validator-friendly)", () => {
|
|
134
157
|
const a = new ToutiaoAdapter({ account: { uid: "u-1" } });
|
|
135
|
-
|
|
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 —
|
|
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("
|
|
171
|
-
|
|
172
|
-
|
|
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("
|
|
90
|
-
|
|
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("
|
|
124
|
-
|
|
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
|
|
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("
|
|
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
|
|
148
|
-
["FROM
|
|
149
|
-
["FROM sqlite_master", [{ name: "
|
|
150
|
-
[
|
|
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 () => "
|
|
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.
|
|
162
|
-
const group = raws.find((r) => r.
|
|
163
|
-
const message = raws.find((r) => r.
|
|
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
|
+
});
|