@chainlesschain/personal-data-hub 0.3.1 → 0.3.6

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 (60) hide show
  1. package/__tests__/adapters/email-adapter-snapshot.test.js +237 -0
  2. package/__tests__/adapters/email-adapter.test.js +1 -1
  3. package/__tests__/adapters/email-pdf-extractor.test.js +1 -1
  4. package/__tests__/adapters/email-retry-progress.test.js +1 -1
  5. package/__tests__/adapters/email-templates.test.js +1 -1
  6. package/__tests__/adapters/social-bilibili-adb-api-client.test.js +721 -0
  7. package/__tests__/adapters/social-bilibili-adb-chromium-cookies-reader.test.js +346 -0
  8. package/__tests__/adapters/social-bilibili-adb-collector.test.js +284 -0
  9. package/__tests__/adapters/social-bilibili-adb-cookies-extension.test.js +343 -0
  10. package/__tests__/adapters/social-bilibili-adb-snapshot-builder.test.js +296 -0
  11. package/__tests__/adapters/social-douyin-adb-collector.test.js +254 -0
  12. package/__tests__/adapters/social-douyin-adb-im-db-parser.test.js +304 -0
  13. package/__tests__/adapters/social-douyin-adb-snapshot-builder.test.js +216 -0
  14. package/__tests__/adapters/social-weibo-adb-api-client.test.js +362 -0
  15. package/__tests__/adapters/social-weibo-adb-collector.test.js +201 -0
  16. package/__tests__/adapters/social-weibo-adb-snapshot-builder.test.js +189 -0
  17. package/__tests__/adapters/social-xiaohongshu-adb-collector.test.js +207 -0
  18. package/__tests__/adapters/social-xiaohongshu-adb-sign.test.js +130 -0
  19. package/__tests__/adapters/system-data-android.test.js +32 -1
  20. package/__tests__/longtail-adapters.test.js +15 -2
  21. package/__tests__/shopping-adapters.test.js +96 -0
  22. package/__tests__/sign-providers.test.js +62 -0
  23. package/__tests__/travel-adapters.test.js +66 -0
  24. package/__tests__/whatsapp-adapter.test.js +5 -2
  25. package/lib/adapters/browser-history-chrome/chrome-db-reader.js +11 -1
  26. package/lib/adapters/email-imap/email-adapter.js +224 -17
  27. package/lib/adapters/messaging-telegram/index.js +15 -12
  28. package/lib/adapters/messaging-whatsapp/index.js +15 -12
  29. package/lib/adapters/shopping-taobao/index.js +161 -21
  30. package/lib/adapters/social-bilibili-adb/api-client.js +555 -0
  31. package/lib/adapters/social-bilibili-adb/chromium-cookies-reader.js +296 -0
  32. package/lib/adapters/social-bilibili-adb/collector.js +190 -0
  33. package/lib/adapters/social-bilibili-adb/cookies-extension.js +250 -0
  34. package/lib/adapters/social-bilibili-adb/index.js +51 -0
  35. package/lib/adapters/social-bilibili-adb/snapshot-builder.js +197 -0
  36. package/lib/adapters/social-douyin/index.js +4 -0
  37. package/lib/adapters/social-douyin-adb/collector.js +165 -0
  38. package/lib/adapters/social-douyin-adb/db-extension.js +281 -0
  39. package/lib/adapters/social-douyin-adb/im-db-parser.js +287 -0
  40. package/lib/adapters/social-douyin-adb/index.js +57 -0
  41. package/lib/adapters/social-douyin-adb/snapshot-builder.js +174 -0
  42. package/lib/adapters/social-weibo-adb/api-client.js +281 -0
  43. package/lib/adapters/social-weibo-adb/collector.js +169 -0
  44. package/lib/adapters/social-weibo-adb/cookies-extension.js +251 -0
  45. package/lib/adapters/social-weibo-adb/index.js +55 -0
  46. package/lib/adapters/social-weibo-adb/snapshot-builder.js +145 -0
  47. package/lib/adapters/social-xiaohongshu-adb/api-client.js +278 -0
  48. package/lib/adapters/social-xiaohongshu-adb/collector.js +158 -0
  49. package/lib/adapters/social-xiaohongshu-adb/cookies-extension.js +211 -0
  50. package/lib/adapters/social-xiaohongshu-adb/index.js +50 -0
  51. package/lib/adapters/social-xiaohongshu-adb/sign.js +90 -0
  52. package/lib/adapters/social-xiaohongshu-adb/snapshot-builder.js +126 -0
  53. package/lib/adapters/system-data-android/adapter.js +77 -3
  54. package/lib/adapters/travel-amap/index.js +16 -10
  55. package/lib/adapters/travel-ctrip/index.js +25 -9
  56. package/lib/adapters/vscode/vscode-reader.js +7 -1
  57. package/lib/sign-providers/index.js +20 -0
  58. package/lib/sign-providers/interface.js +82 -0
  59. package/lib/sign-providers/null-sign-provider.js +30 -0
  60. package/package.json +6 -1
@@ -0,0 +1,254 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Phase 2a — Douyin C 路径 collector orchestrator unit cover.
5
+ *
6
+ * Same fake-bridge + fake-registry pattern as social-bilibili-adb-collector.
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
10
+ import { existsSync, readFileSync, mkdtempSync, rmSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { tmpdir } from "node:os";
13
+ import Database from "better-sqlite3";
14
+
15
+ const {
16
+ collect,
17
+ collectAndSync,
18
+ } = require("../../lib/adapters/social-douyin-adb/collector");
19
+
20
+ let stagingDir;
21
+ let dbDir;
22
+ let fixtureDbPath;
23
+
24
+ beforeEach(() => {
25
+ stagingDir = mkdtempSync(join(tmpdir(), "cc-douyin-staging-"));
26
+ dbDir = mkdtempSync(join(tmpdir(), "cc-douyin-dbfixture-"));
27
+ fixtureDbPath = join(dbDir, "fixture-1234567890123456789_im.db");
28
+ // Build a small valid IM db so the collector has real data to parse.
29
+ const db = new Database(fixtureDbPath);
30
+ db.exec(`
31
+ CREATE TABLE msg(
32
+ sender INTEGER, created_time INTEGER, content TEXT,
33
+ conversation_id TEXT, read_status INTEGER
34
+ );
35
+ CREATE TABLE SIMPLE_USER(
36
+ UID INTEGER, short_id INTEGER, name TEXT,
37
+ avatar_url TEXT, follow_status INTEGER
38
+ );
39
+ INSERT INTO msg VALUES(123, 1716383021000, '{"text":"hi"}', 'conv-A', 1);
40
+ INSERT INTO msg VALUES(456, 1716383022000, '{"text":"hi back"}', 'conv-A', 0);
41
+ INSERT INTO SIMPLE_USER VALUES(456, 789, 'Friend', 'https://x', 1);
42
+ `);
43
+ db.close();
44
+ });
45
+
46
+ afterEach(() => {
47
+ try {
48
+ rmSync(stagingDir, { recursive: true, force: true });
49
+ rmSync(dbDir, { recursive: true, force: true });
50
+ } catch (_e) {
51
+ // ignore
52
+ }
53
+ });
54
+
55
+ function makeFakeBridge({ pullResult, throwOnInvoke } = {}) {
56
+ return {
57
+ invoke: vi.fn(async (method, _params) => {
58
+ if (throwOnInvoke) throw throwOnInvoke;
59
+ if (method !== "douyin.pull-im-db") {
60
+ throw new Error(`fake bridge: unexpected method ${method}`);
61
+ }
62
+ return pullResult;
63
+ }),
64
+ };
65
+ }
66
+
67
+ function makeCleanupSpy() {
68
+ return vi.fn();
69
+ }
70
+
71
+ // ─── collect() — happy path ─────────────────────────────────────────────
72
+
73
+ describe("collect — happy path", () => {
74
+ it("invokes bridge, parses db, writes snapshot, returns counts", async () => {
75
+ const cleanup = makeCleanupSpy();
76
+ const bridge = makeFakeBridge({
77
+ pullResult: {
78
+ tempPath: fixtureDbPath,
79
+ uid: "1234567890123456789",
80
+ walPath: null,
81
+ shmPath: null,
82
+ extractedAt: 1716383020000,
83
+ cleanup,
84
+ },
85
+ });
86
+ const result = await collect(bridge, { stagingDir });
87
+ expect(bridge.invoke).toHaveBeenCalledWith("douyin.pull-im-db", {
88
+ uid: undefined,
89
+ });
90
+ expect(result.uid).toBe("1234567890123456789");
91
+ expect(result.eventCounts).toEqual({
92
+ message: 2,
93
+ contact: 1,
94
+ total: 3,
95
+ });
96
+ expect(existsSync(result.snapshotPath)).toBe(true);
97
+ const snap = JSON.parse(readFileSync(result.snapshotPath, "utf-8"));
98
+ expect(snap.schemaVersion).toBe(1);
99
+ expect(snap.events).toHaveLength(3);
100
+ expect(result.parserDiagnostic.hadMsgTable).toBe(true);
101
+ expect(result.parserDiagnostic.hadSimpleUserTable).toBe(true);
102
+ // collect() does NOT cleanup db cohort yet — that's caller's
103
+ // responsibility (collectAndSync runs cleanup after syncAdapter).
104
+ expect(cleanup).not.toHaveBeenCalled();
105
+ expect(typeof result._dbCohortCleanup).toBe("function");
106
+ });
107
+
108
+ it("forwards uid filter to extension", async () => {
109
+ const bridge = makeFakeBridge({
110
+ pullResult: {
111
+ tempPath: fixtureDbPath,
112
+ uid: "1234567890123456789",
113
+ cleanup: () => {},
114
+ },
115
+ });
116
+ await collect(bridge, { stagingDir, uid: "1234567890123456789" });
117
+ expect(bridge.invoke).toHaveBeenCalledWith("douyin.pull-im-db", {
118
+ uid: "1234567890123456789",
119
+ });
120
+ });
121
+
122
+ it("forwards limits to parser", async () => {
123
+ const bridge = makeFakeBridge({
124
+ pullResult: {
125
+ tempPath: fixtureDbPath,
126
+ uid: "1234567890123456789",
127
+ cleanup: () => {},
128
+ },
129
+ });
130
+ const result = await collect(bridge, {
131
+ stagingDir,
132
+ limits: { messages: 1, contacts: 0 },
133
+ });
134
+ expect(result.eventCounts.message).toBe(1);
135
+ // contacts: 0 → fallback to default 5000 (parser uses default when
136
+ // limit is 0; that's intentional per im-db-parser.js).
137
+ expect(result.eventCounts.contact).toBeGreaterThanOrEqual(0);
138
+ });
139
+ });
140
+
141
+ // ─── collect() — failure modes ──────────────────────────────────────────
142
+
143
+ describe("collect — failure modes", () => {
144
+ it("propagates bridge.invoke errors verbatim", async () => {
145
+ const bridge = makeFakeBridge({
146
+ throwOnInvoke: new Error("DOUYIN_NO_ROOT: phone isn't rooted"),
147
+ });
148
+ await expect(collect(bridge, { stagingDir })).rejects.toThrow(
149
+ /DOUYIN_NO_ROOT/,
150
+ );
151
+ });
152
+
153
+ it("rejects malformed bridge payload", async () => {
154
+ const bridge = makeFakeBridge({
155
+ pullResult: { uid: null, tempPath: null },
156
+ });
157
+ await expect(collect(bridge, { stagingDir })).rejects.toThrow(
158
+ /malformed payload/,
159
+ );
160
+ });
161
+
162
+ it("rejects bridge missing invoke fn", async () => {
163
+ await expect(collect(null, { stagingDir })).rejects.toThrow(TypeError);
164
+ await expect(collect({}, { stagingDir })).rejects.toThrow(TypeError);
165
+ });
166
+
167
+ it("cleans up db cohort if snapshot building throws", async () => {
168
+ const cleanup = makeCleanupSpy();
169
+ const bridge = makeFakeBridge({
170
+ pullResult: {
171
+ tempPath: "/nonexistent/db/path.db", // parser will throw
172
+ uid: "1234567890123456789",
173
+ cleanup,
174
+ },
175
+ });
176
+ await expect(collect(bridge, { stagingDir })).rejects.toThrow();
177
+ expect(cleanup).toHaveBeenCalledOnce();
178
+ });
179
+ });
180
+
181
+ // ─── collectAndSync() ───────────────────────────────────────────────────
182
+
183
+ describe("collectAndSync — pipes to registry + always cleans up", () => {
184
+ it("calls registry.syncAdapter('social-douyin') + merges report", async () => {
185
+ const cleanup = makeCleanupSpy();
186
+ const bridge = makeFakeBridge({
187
+ pullResult: {
188
+ tempPath: fixtureDbPath,
189
+ uid: "1234567890123456789",
190
+ cleanup,
191
+ },
192
+ });
193
+ let syncedPath = null;
194
+ const registry = {
195
+ syncAdapter: vi.fn(async (name, opts) => {
196
+ if (name !== "social-douyin") throw new Error("wrong name");
197
+ syncedPath = opts.inputPath;
198
+ return {
199
+ adapter: name,
200
+ status: "ok",
201
+ rawCount: 3,
202
+ entityCounts: { events: 3, persons: 0, places: 0, items: 0, topics: 0 },
203
+ };
204
+ }),
205
+ };
206
+ const report = await collectAndSync(bridge, registry, { stagingDir });
207
+ expect(registry.syncAdapter).toHaveBeenCalledWith("social-douyin", {
208
+ inputPath: expect.any(String),
209
+ });
210
+ expect(syncedPath).toBeTruthy();
211
+ expect(report.status).toBe("ok");
212
+ expect(report.douyin.uid).toBe("1234567890123456789");
213
+ expect(report.douyin.eventCounts.total).toBe(3);
214
+ // Both snapshot AND db cohort cleaned up
215
+ expect(existsSync(syncedPath)).toBe(false);
216
+ expect(cleanup).toHaveBeenCalledOnce();
217
+ });
218
+
219
+ it("cleans up both even if syncAdapter throws", async () => {
220
+ const cleanup = makeCleanupSpy();
221
+ const bridge = makeFakeBridge({
222
+ pullResult: {
223
+ tempPath: fixtureDbPath,
224
+ uid: "1234567890123456789",
225
+ cleanup,
226
+ },
227
+ });
228
+ let syncedPath = null;
229
+ const registry = {
230
+ syncAdapter: vi.fn(async (_name, opts) => {
231
+ syncedPath = opts.inputPath;
232
+ throw new Error("registry exploded");
233
+ }),
234
+ };
235
+ await expect(
236
+ collectAndSync(bridge, registry, { stagingDir }),
237
+ ).rejects.toThrow("registry exploded");
238
+ expect(syncedPath).toBeTruthy();
239
+ expect(existsSync(syncedPath)).toBe(false);
240
+ expect(cleanup).toHaveBeenCalledOnce();
241
+ });
242
+
243
+ it("rejects missing registry.syncAdapter", async () => {
244
+ const bridge = makeFakeBridge({
245
+ pullResult: { tempPath: fixtureDbPath, uid: "1", cleanup: () => {} },
246
+ });
247
+ await expect(collectAndSync(bridge, null, { stagingDir })).rejects.toThrow(
248
+ TypeError,
249
+ );
250
+ await expect(collectAndSync(bridge, {}, { stagingDir })).rejects.toThrow(
251
+ TypeError,
252
+ );
253
+ });
254
+ });
@@ -0,0 +1,304 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Phase 2a — IM db parser unit cover.
5
+ *
6
+ * Builds real Douyin-shaped sqlite fixtures via better-sqlite3 (Node ABI
7
+ * 127 test path, same as social-bilibili-adb-chromium-cookies-reader.test.js).
8
+ * Tests cover:
9
+ * - msg table happy path + schema-drift column aliases
10
+ * - SIMPLE_USER table happy path + missing-column tolerance
11
+ * - Empty db / missing table → diagnostic.hadXxxTable=false
12
+ * - Time normalization: seconds / ms / microseconds
13
+ * - Content blob: JSON {text} / nested .content.text / plain string
14
+ * - limitMessages / limitContacts
15
+ */
16
+
17
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
18
+ import { mkdtempSync, rmSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { tmpdir } from "node:os";
21
+ import Database from "better-sqlite3";
22
+
23
+ const {
24
+ parseImDb,
25
+ _internals,
26
+ } = require("../../lib/adapters/social-douyin-adb/im-db-parser");
27
+
28
+ let tmpDir;
29
+ let dbPath;
30
+
31
+ beforeEach(() => {
32
+ tmpDir = mkdtempSync(join(tmpdir(), "cc-douyin-im-test-"));
33
+ dbPath = join(tmpDir, "test_im.db");
34
+ });
35
+
36
+ afterEach(() => {
37
+ try {
38
+ rmSync(tmpDir, { recursive: true, force: true });
39
+ } catch (_e) {
40
+ // ignore
41
+ }
42
+ });
43
+
44
+ function buildMsgFixture(rows, columnOverrides = {}) {
45
+ const senderCol = columnOverrides.senderCol || "sender";
46
+ const timeCol = columnOverrides.timeCol || "created_time";
47
+ const contentCol = columnOverrides.contentCol || "content";
48
+ const convCol = columnOverrides.convCol || "conversation_id";
49
+ const readCol = columnOverrides.readCol || "read_status";
50
+ const db = new Database(dbPath);
51
+ db.exec(
52
+ `CREATE TABLE msg(${senderCol} INTEGER, ${timeCol} INTEGER, ${contentCol} TEXT, ${convCol} TEXT, ${readCol} INTEGER);`,
53
+ );
54
+ const insert = db.prepare(
55
+ `INSERT INTO msg(${senderCol}, ${timeCol}, ${contentCol}, ${convCol}, ${readCol}) VALUES(?, ?, ?, ?, ?)`,
56
+ );
57
+ for (const r of rows) {
58
+ insert.run(
59
+ r.sender || 0,
60
+ r.time || 0,
61
+ r.content || "",
62
+ r.convId || "",
63
+ r.read || 0,
64
+ );
65
+ }
66
+ db.close();
67
+ }
68
+
69
+ function buildSimpleUserFixture(rows) {
70
+ const db = new Database(dbPath, { fileMustExist: false });
71
+ // Open in CREATE mode if not yet
72
+ db.exec(`CREATE TABLE IF NOT EXISTS SIMPLE_USER(
73
+ UID INTEGER, short_id INTEGER, name TEXT, avatar_url TEXT, follow_status INTEGER
74
+ );`);
75
+ const insert = db.prepare(
76
+ "INSERT INTO SIMPLE_USER(UID, short_id, name, avatar_url, follow_status) VALUES(?, ?, ?, ?, ?)",
77
+ );
78
+ for (const r of rows) {
79
+ insert.run(
80
+ r.uid || 0,
81
+ r.shortId || 0,
82
+ r.name || "",
83
+ r.avatar || "",
84
+ r.follow || 0,
85
+ );
86
+ }
87
+ db.close();
88
+ }
89
+
90
+ // ─── internals ──────────────────────────────────────────────────────────
91
+
92
+ describe("_internals.normalizeEpochMs", () => {
93
+ it("treats seconds as seconds (× 1000)", () => {
94
+ expect(_internals.normalizeEpochMs(1716383021)).toBe(1716383021000);
95
+ });
96
+
97
+ it("treats milliseconds verbatim", () => {
98
+ expect(_internals.normalizeEpochMs(1716383021000)).toBe(1716383021000);
99
+ });
100
+
101
+ it("treats microseconds (× 1000 epoch) as µs / 1000", () => {
102
+ expect(_internals.normalizeEpochMs(1716383021000000)).toBe(1716383021000);
103
+ });
104
+
105
+ it("rejects zero / negative / non-number", () => {
106
+ expect(_internals.normalizeEpochMs(0)).toBe(null);
107
+ expect(_internals.normalizeEpochMs(-1)).toBe(null);
108
+ expect(_internals.normalizeEpochMs(NaN)).toBe(null);
109
+ expect(_internals.normalizeEpochMs(null)).toBe(null);
110
+ expect(_internals.normalizeEpochMs("123")).toBe(null);
111
+ });
112
+ });
113
+
114
+ describe("_internals.extractTextFromContent", () => {
115
+ it("parses {text:'...'} JSON", () => {
116
+ expect(_internals.extractTextFromContent('{"text":"hi"}')).toBe("hi");
117
+ });
118
+
119
+ it("parses nested {content:{text:'...'}} JSON", () => {
120
+ expect(
121
+ _internals.extractTextFromContent('{"content":{"text":"nested"}}'),
122
+ ).toBe("nested");
123
+ });
124
+
125
+ it("returns raw string when not valid JSON", () => {
126
+ expect(_internals.extractTextFromContent("legacy plaintext")).toBe(
127
+ "legacy plaintext",
128
+ );
129
+ });
130
+
131
+ it("returns null for empty / non-string", () => {
132
+ expect(_internals.extractTextFromContent("")).toBe(null);
133
+ expect(_internals.extractTextFromContent(null)).toBe(null);
134
+ expect(_internals.extractTextFromContent(undefined)).toBe(null);
135
+ });
136
+
137
+ it("returns null when JSON parses but no text field", () => {
138
+ expect(_internals.extractTextFromContent('{"type":"sticker"}')).toBe(null);
139
+ });
140
+ });
141
+
142
+ describe("_internals.pickCol", () => {
143
+ it("returns first matching column", () => {
144
+ const cols = new Set(["created_time", "sender", "content"]);
145
+ expect(_internals.pickCol(cols, ["create_time", "created_time"])).toBe(
146
+ "created_time",
147
+ );
148
+ });
149
+
150
+ it("returns null when no candidate matches", () => {
151
+ const cols = new Set(["a", "b"]);
152
+ expect(_internals.pickCol(cols, ["c", "d"])).toBe(null);
153
+ });
154
+ });
155
+
156
+ // ─── msg table happy path ───────────────────────────────────────────────
157
+
158
+ describe("parseImDb — msg table", () => {
159
+ it("parses canonical msg rows", () => {
160
+ buildMsgFixture([
161
+ {
162
+ sender: 9007199254740991,
163
+ time: 1716383021000,
164
+ content: '{"text":"hello"}',
165
+ convId: "conv-A",
166
+ read: 1,
167
+ },
168
+ {
169
+ sender: 8007199254740991,
170
+ time: 1716383022000,
171
+ content: '{"text":"hi back"}',
172
+ convId: "conv-A",
173
+ read: 0,
174
+ },
175
+ ]);
176
+ const result = parseImDb(dbPath);
177
+ expect(result.diagnostic.hadMsgTable).toBe(true);
178
+ expect(result.diagnostic.messageCount).toBe(2);
179
+ expect(result.messages).toHaveLength(2);
180
+ // Sorted DESC by time
181
+ expect(result.messages[0].text).toBe("hi back");
182
+ expect(result.messages[1].text).toBe("hello");
183
+ expect(result.messages[0].conversationId).toBe("conv-A");
184
+ expect(result.messages[0].readStatus).toBe(0);
185
+ });
186
+
187
+ it("normalizes time to ms regardless of original unit", () => {
188
+ buildMsgFixture([
189
+ { sender: 1, time: 1716383021, content: '{"text":"seconds"}' },
190
+ ]);
191
+ const result = parseImDb(dbPath);
192
+ expect(result.messages[0].createdTimeMs).toBe(1716383021000);
193
+ });
194
+
195
+ it("preserves contentBlob even when text extracts to null", () => {
196
+ buildMsgFixture([
197
+ { sender: 1, time: 1716383021000, content: '{"type":"sticker"}' },
198
+ ]);
199
+ const result = parseImDb(dbPath);
200
+ expect(result.messages[0].text).toBe(null);
201
+ expect(result.messages[0].contentBlob).toBe('{"type":"sticker"}');
202
+ });
203
+
204
+ it("respects limitMessages", () => {
205
+ const rows = Array.from({ length: 100 }, (_, i) => ({
206
+ sender: i,
207
+ time: 1716383021000 + i,
208
+ content: `{"text":"msg-${i}"}`,
209
+ }));
210
+ buildMsgFixture(rows);
211
+ const result = parseImDb(dbPath, { limitMessages: 10 });
212
+ expect(result.messages).toHaveLength(10);
213
+ });
214
+
215
+ it("handles schema-drift column names (create_time / from_user_id)", () => {
216
+ buildMsgFixture(
217
+ [
218
+ {
219
+ sender: 9007199254740991,
220
+ time: 1716383021,
221
+ content: '{"text":"hi"}',
222
+ convId: "c",
223
+ },
224
+ ],
225
+ {
226
+ senderCol: "from_user_id",
227
+ timeCol: "create_time",
228
+ contentCol: "message_content",
229
+ convCol: "conv_id",
230
+ },
231
+ );
232
+ const result = parseImDb(dbPath);
233
+ expect(result.diagnostic.hadMsgTable).toBe(true);
234
+ expect(result.messages).toHaveLength(1);
235
+ expect(result.messages[0].text).toBe("hi");
236
+ expect(result.messages[0].conversationId).toBe("c");
237
+ });
238
+
239
+ it("returns empty messages array when msg table absent", () => {
240
+ const db = new Database(dbPath);
241
+ db.exec("CREATE TABLE unrelated(x INTEGER);");
242
+ db.close();
243
+ const result = parseImDb(dbPath);
244
+ expect(result.messages).toEqual([]);
245
+ expect(result.diagnostic.hadMsgTable).toBe(false);
246
+ });
247
+ });
248
+
249
+ // ─── SIMPLE_USER table ──────────────────────────────────────────────────
250
+
251
+ describe("parseImDb — SIMPLE_USER table", () => {
252
+ it("parses canonical contact rows", () => {
253
+ buildSimpleUserFixture([
254
+ { uid: 111, shortId: 222, name: "Alice", avatar: "https://a.png", follow: 1 },
255
+ { uid: 333, shortId: 444, name: "Bob", avatar: "https://b.png", follow: 2 },
256
+ ]);
257
+ const result = parseImDb(dbPath);
258
+ expect(result.diagnostic.hadSimpleUserTable).toBe(true);
259
+ expect(result.diagnostic.contactCount).toBe(2);
260
+ expect(result.contacts[0].name).toBe("Alice");
261
+ expect(result.contacts[0].followStatus).toBe(1);
262
+ expect(result.contacts[1].followStatus).toBe(2);
263
+ });
264
+
265
+ it("returns empty when SIMPLE_USER table absent", () => {
266
+ const db = new Database(dbPath);
267
+ db.exec("CREATE TABLE msg(x INTEGER);");
268
+ db.close();
269
+ const result = parseImDb(dbPath);
270
+ expect(result.contacts).toEqual([]);
271
+ expect(result.diagnostic.hadSimpleUserTable).toBe(false);
272
+ });
273
+
274
+ it("respects limitContacts", () => {
275
+ const rows = Array.from({ length: 50 }, (_, i) => ({
276
+ uid: i + 1,
277
+ shortId: i,
278
+ name: `user-${i}`,
279
+ }));
280
+ buildSimpleUserFixture(rows);
281
+ const result = parseImDb(dbPath, { limitContacts: 7 });
282
+ expect(result.contacts).toHaveLength(7);
283
+ });
284
+ });
285
+
286
+ // ─── Combined / empty ───────────────────────────────────────────────────
287
+
288
+ describe("parseImDb — combined diagnostics", () => {
289
+ it("handles both tables present", () => {
290
+ buildMsgFixture([
291
+ { sender: 1, time: 1716383021000, content: '{"text":"hi"}' },
292
+ ]);
293
+ buildSimpleUserFixture([{ uid: 999, name: "x" }]);
294
+ const result = parseImDb(dbPath);
295
+ expect(result.diagnostic.hadMsgTable).toBe(true);
296
+ expect(result.diagnostic.hadSimpleUserTable).toBe(true);
297
+ expect(result.messages.length + result.contacts.length).toBe(2);
298
+ });
299
+
300
+ it("rejects non-string / empty dbPath", () => {
301
+ expect(() => parseImDb("")).toThrow(TypeError);
302
+ expect(() => parseImDb(null)).toThrow(TypeError);
303
+ });
304
+ });