@chainlesschain/personal-data-hub 0.1.0 → 0.2.1

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 (154) hide show
  1. package/__tests__/adapters/ai-chat-cookie-capture-spec.test.js +211 -0
  2. package/__tests__/adapters/ai-chat-health-checker.test.js +262 -0
  3. package/__tests__/adapters/ai-chat-history.test.js +396 -0
  4. package/__tests__/adapters/ai-chat-http-client.test.js +242 -0
  5. package/__tests__/adapters/ai-chat-vendors.test.js +874 -0
  6. package/__tests__/adapters/alipay-bill-adapter.test.js +538 -0
  7. package/__tests__/adapters/email-adapter.test.js +138 -1
  8. package/__tests__/adapters/email-classifier.test.js +347 -0
  9. package/__tests__/adapters/email-pdf-extractor.test.js +529 -0
  10. package/__tests__/adapters/email-retry-progress.test.js +294 -0
  11. package/__tests__/adapters/email-templates.test.js +699 -0
  12. package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +269 -0
  13. package/__tests__/adapters/system-data-adapter.test.js +440 -0
  14. package/__tests__/adapters/system-data-android-ingest.test.js +144 -0
  15. package/__tests__/adapters/system-data-android.test.js +387 -0
  16. package/__tests__/adapters/system-data-disclosure.test.js +153 -0
  17. package/__tests__/adapters/wechat-bootstrap.test.js +240 -0
  18. package/__tests__/adapters/wechat-env-probe.test.js +162 -0
  19. package/__tests__/adapters/wechat-frida-agent.test.js +191 -0
  20. package/__tests__/adapters/wechat-frida-integration.test.js +149 -0
  21. package/__tests__/adapters/wechat-frida-key-provider.test.js +188 -0
  22. package/__tests__/adapters/wechat-md5-key-provider.test.js +101 -0
  23. package/__tests__/analysis-skills.test.js +556 -0
  24. package/__tests__/analysis.test.js +329 -1
  25. package/__tests__/e2e/ai-chat-cross-source-journey.test.js +213 -0
  26. package/__tests__/e2e/full-user-journey.test.js +188 -0
  27. package/__tests__/entity-resolver-ingest-hook.test.js +177 -0
  28. package/__tests__/entity-resolver-stages.test.js +411 -0
  29. package/__tests__/entity-resolver-vault.test.js +246 -0
  30. package/__tests__/entity-resolver.test.js +526 -0
  31. package/__tests__/fixtures/entity-resolver-200-mock.json +96 -0
  32. package/__tests__/integration/ai-chat-history-registry.test.js +228 -0
  33. package/__tests__/integration/aichat-wizard-end-to-end.test.js +282 -0
  34. package/__tests__/integration/cross-adapter-pipelines.test.js +396 -0
  35. package/__tests__/integration/wechat-bootstrap-end-to-end.test.js +390 -0
  36. package/__tests__/longtail-adapters.test.js +217 -0
  37. package/__tests__/mobile-extractor.test.js +288 -0
  38. package/__tests__/registry.test.js +4 -2
  39. package/__tests__/shopping-adapters.test.js +296 -0
  40. package/__tests__/sidecar-contacts-cross-validate.test.js +163 -0
  41. package/__tests__/sidecar-supervisor.test.js +120 -0
  42. package/__tests__/social-adapters.test.js +206 -0
  43. package/__tests__/travel-adapters.test.js +325 -0
  44. package/__tests__/vault.test.js +3 -3
  45. package/__tests__/wechat-adapter.test.js +476 -0
  46. package/__tests__/whatsapp-adapter.test.js +135 -0
  47. package/lib/adapter-spec.js +12 -0
  48. package/lib/adapters/_python-sidecar-base.js +207 -0
  49. package/lib/adapters/ai-chat-history/ai-chat-adapter.js +374 -0
  50. package/lib/adapters/ai-chat-history/cookie-auth.js +109 -0
  51. package/lib/adapters/ai-chat-history/cookie-capture-spec.js +331 -0
  52. package/lib/adapters/ai-chat-history/health-checker.js +210 -0
  53. package/lib/adapters/ai-chat-history/http-client.js +211 -0
  54. package/lib/adapters/ai-chat-history/index.js +28 -0
  55. package/lib/adapters/ai-chat-history/schema-map.js +258 -0
  56. package/lib/adapters/ai-chat-history/vendor-spec.js +86 -0
  57. package/lib/adapters/ai-chat-history/vendors/coze.js +179 -0
  58. package/lib/adapters/ai-chat-history/vendors/deepseek.js +199 -0
  59. package/lib/adapters/ai-chat-history/vendors/doubao.js +255 -0
  60. package/lib/adapters/ai-chat-history/vendors/dreamina.js +174 -0
  61. package/lib/adapters/ai-chat-history/vendors/hunyuan.js +176 -0
  62. package/lib/adapters/ai-chat-history/vendors/kimi.js +182 -0
  63. package/lib/adapters/ai-chat-history/vendors/qianfan.js +160 -0
  64. package/lib/adapters/ai-chat-history/vendors/tongyi.js +193 -0
  65. package/lib/adapters/ai-chat-history/vendors/zhipu.js +202 -0
  66. package/lib/adapters/ai-chat-history/wizard-controller.js +473 -0
  67. package/lib/adapters/alipay-bill/alipay-bill-adapter.js +311 -0
  68. package/lib/adapters/alipay-bill/counterparty.js +129 -0
  69. package/lib/adapters/alipay-bill/csv-parser.js +217 -0
  70. package/lib/adapters/alipay-bill/index.js +41 -0
  71. package/lib/adapters/alipay-bill/zip-decryptor.js +111 -0
  72. package/lib/adapters/email-imap/classifier.js +495 -0
  73. package/lib/adapters/email-imap/email-adapter.js +419 -8
  74. package/lib/adapters/email-imap/index.js +42 -0
  75. package/lib/adapters/email-imap/pdf-extractor.js +192 -0
  76. package/lib/adapters/email-imap/templates/bill.js +232 -0
  77. package/lib/adapters/email-imap/templates/government.js +120 -0
  78. package/lib/adapters/email-imap/templates/index.js +78 -0
  79. package/lib/adapters/email-imap/templates/order.js +186 -0
  80. package/lib/adapters/email-imap/templates/other.js +114 -0
  81. package/lib/adapters/email-imap/templates/register.js +113 -0
  82. package/lib/adapters/email-imap/templates/travel.js +157 -0
  83. package/lib/adapters/email-imap/templates/utils.js +275 -0
  84. package/lib/adapters/email-imap/transactions.js +234 -0
  85. package/lib/adapters/messaging-qq/index.js +158 -0
  86. package/lib/adapters/messaging-telegram/index.js +142 -0
  87. package/lib/adapters/messaging-whatsapp/index.js +189 -0
  88. package/lib/adapters/shopping-base/index.js +208 -0
  89. package/lib/adapters/shopping-jd/index.js +150 -0
  90. package/lib/adapters/shopping-meituan/index.js +154 -0
  91. package/lib/adapters/shopping-taobao/index.js +176 -0
  92. package/lib/adapters/social-bilibili/index.js +171 -0
  93. package/lib/adapters/social-douyin/index.js +116 -0
  94. package/lib/adapters/social-kuaishou/index.js +237 -0
  95. package/lib/adapters/social-toutiao/index.js +236 -0
  96. package/lib/adapters/social-weibo/index.js +164 -0
  97. package/lib/adapters/social-xiaohongshu/index.js +96 -0
  98. package/lib/adapters/system-data/disclosure.js +166 -0
  99. package/lib/adapters/system-data/index.js +34 -0
  100. package/lib/adapters/system-data/system-data-adapter.js +344 -0
  101. package/lib/adapters/system-data-android/adapter.js +348 -0
  102. package/lib/adapters/system-data-android/index.js +76 -0
  103. package/lib/adapters/travel-12306/index.js +151 -0
  104. package/lib/adapters/travel-amap/index.js +164 -0
  105. package/lib/adapters/travel-baidu-map/index.js +162 -0
  106. package/lib/adapters/travel-base/index.js +240 -0
  107. package/lib/adapters/travel-ctrip/index.js +151 -0
  108. package/lib/adapters/wechat/bootstrap.js +146 -0
  109. package/lib/adapters/wechat/content-parser.js +326 -0
  110. package/lib/adapters/wechat/db-reader.js +209 -0
  111. package/lib/adapters/wechat/env-probe.js +218 -0
  112. package/lib/adapters/wechat/frida-agent/loader.js +67 -0
  113. package/lib/adapters/wechat/frida-agent/wechat-key-hook.js +126 -0
  114. package/lib/adapters/wechat/index.js +37 -0
  115. package/lib/adapters/wechat/key-extractor.js +158 -0
  116. package/lib/adapters/wechat/key-providers/frida-key-provider.js +244 -0
  117. package/lib/adapters/wechat/key-providers/index.js +22 -0
  118. package/lib/adapters/wechat/key-providers/key-provider-base.js +44 -0
  119. package/lib/adapters/wechat/key-providers/md5-key-provider.js +81 -0
  120. package/lib/adapters/wechat/normalize.js +220 -0
  121. package/lib/adapters/wechat/wechat-adapter.js +205 -0
  122. package/lib/analysis-skills/base.js +113 -0
  123. package/lib/analysis-skills/footprint.js +167 -0
  124. package/lib/analysis-skills/index.js +58 -0
  125. package/lib/analysis-skills/interests.js +161 -0
  126. package/lib/analysis-skills/relations.js +226 -0
  127. package/lib/analysis-skills/spending.js +219 -0
  128. package/lib/analysis-skills/timeline.js +167 -0
  129. package/lib/analysis.js +191 -2
  130. package/lib/entity-resolver/embedding-stage.js +198 -0
  131. package/lib/entity-resolver/entity-resolver.js +384 -0
  132. package/lib/entity-resolver/index.js +42 -0
  133. package/lib/entity-resolver/llm-stage.js +191 -0
  134. package/lib/entity-resolver/rule-stage.js +208 -0
  135. package/lib/entity-resolver/worker.js +149 -0
  136. package/lib/index.js +131 -0
  137. package/lib/migrations.js +73 -0
  138. package/lib/mobile-extractor/android.js +193 -0
  139. package/lib/mobile-extractor/index.js +9 -0
  140. package/lib/mobile-extractor/ios.js +223 -0
  141. package/lib/prompt-builder.js +11 -1
  142. package/lib/query-parser.js +7 -1
  143. package/lib/registry.js +42 -0
  144. package/lib/sidecar/index.js +15 -0
  145. package/lib/sidecar/supervisor.js +359 -0
  146. package/lib/vault.js +343 -0
  147. package/package.json +36 -3
  148. package/scripts/_make-fixture-all.js +126 -0
  149. package/scripts/_make-fixture-contacts.js +84 -0
  150. package/scripts/evaluate-entity-resolver.js +213 -0
  151. package/scripts/smoke-phase-5-5.js +196 -0
  152. package/scripts/smoke-phase-5-7.js +181 -0
  153. package/scripts/smoke-system-data-contacts.js +309 -0
  154. package/scripts/smoke-system-data.js +312 -0
@@ -0,0 +1,288 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
4
+
5
+ const fs = require("node:fs");
6
+ const path = require("node:path");
7
+ const os = require("node:os");
8
+ const { AndroidExtractor, iOSBackupReader } = require("../lib/mobile-extractor");
9
+
10
+ // ─── AndroidExtractor — mocked execFn ────────────────────────────────────
11
+
12
+ function mockExec(scriptMap) {
13
+ // scriptMap: { "args joined by space": { stdout: "...", stderr: "..." }
14
+ // | () => Promise<{stdout, stderr}> }
15
+ return async (cmd, args) => {
16
+ const key = args.join(" ");
17
+ if (key in scriptMap) {
18
+ const v = scriptMap[key];
19
+ return typeof v === "function" ? await v() : v;
20
+ }
21
+ // Allow prefix matches for convenience
22
+ for (const k of Object.keys(scriptMap)) {
23
+ if (key.startsWith(k)) {
24
+ const v = scriptMap[k];
25
+ return typeof v === "function" ? await v() : v;
26
+ }
27
+ }
28
+ throw new Error(`mockExec: no script for: ${cmd} ${key}`);
29
+ };
30
+ }
31
+
32
+ describe("AndroidExtractor", () => {
33
+ it("listDevices parses adb output", async () => {
34
+ const ext = new AndroidExtractor({
35
+ execFn: mockExec({
36
+ "devices -l": {
37
+ stdout:
38
+ "List of devices attached\n" +
39
+ "ABCDEF123 device product:redmi model:Redmi_24115RA8EC device:redmi\n" +
40
+ "OFFLINE99 offline\n",
41
+ stderr: "",
42
+ },
43
+ }),
44
+ });
45
+ const devices = await ext.listDevices();
46
+ expect(devices).toHaveLength(2);
47
+ expect(devices[0].serial).toBe("ABCDEF123");
48
+ expect(devices[0].state).toBe("device");
49
+ expect(devices[0].model).toBe("Redmi_24115RA8EC");
50
+ expect(devices[1].state).toBe("offline");
51
+ });
52
+
53
+ it("isDeviceReady checks state", async () => {
54
+ const ext = new AndroidExtractor({
55
+ execFn: mockExec({
56
+ "devices -l": {
57
+ stdout: "List of devices attached\nABCDEF123 device product:p\n",
58
+ stderr: "",
59
+ },
60
+ }),
61
+ });
62
+ expect(await ext.isDeviceReady("ABCDEF123")).toBe(true);
63
+ expect(await ext.isDeviceReady("BAD-SERIAL")).toBe(false);
64
+ });
65
+
66
+ it("probeRoot detects Magisk + selinux", async () => {
67
+ const ext = new AndroidExtractor({
68
+ execFn: mockExec({
69
+ "-s ABC shell which su": { stdout: "/system/bin/magisk\n" },
70
+ "-s ABC shell which magisk": { stdout: "/system/bin/magisk\n" },
71
+ "-s ABC shell getenforce": { stdout: "Enforcing\n" },
72
+ }),
73
+ });
74
+ const probe = await ext.probeRoot("ABC");
75
+ expect(probe.rooted).toBe(true);
76
+ expect(probe.su).toBe("magisk-su");
77
+ expect(probe.magiskInstalled).toBe(true);
78
+ expect(probe.selinux).toBe("enforcing");
79
+ });
80
+
81
+ it("probeRoot non-rooted device", async () => {
82
+ const ext = new AndroidExtractor({
83
+ execFn: mockExec({
84
+ "-s ABC shell which su": { stdout: "" },
85
+ "-s ABC shell which magisk": { stdout: "" },
86
+ "-s ABC shell getenforce": { stdout: "Enforcing\n" },
87
+ }),
88
+ });
89
+ const probe = await ext.probeRoot("ABC");
90
+ expect(probe.rooted).toBe(false);
91
+ expect(probe.su).toBeNull();
92
+ });
93
+
94
+ it("listPackages filters user-installed by default", async () => {
95
+ const ext = new AndroidExtractor({
96
+ execFn: mockExec({
97
+ "-s ABC shell pm list packages -3": {
98
+ stdout: "package:com.tencent.mm\npackage:com.taobao.taobao\n",
99
+ },
100
+ }),
101
+ });
102
+ const pkgs = await ext.listPackages("ABC");
103
+ expect(pkgs).toEqual(["com.tencent.mm", "com.taobao.taobao"]);
104
+ });
105
+
106
+ it("pull creates dest dir + invokes adb pull", async () => {
107
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ax-"));
108
+ let pulled = false;
109
+ const ext = new AndroidExtractor({
110
+ execFn: async (cmd, args) => {
111
+ if (args[2] === "pull") {
112
+ pulled = true;
113
+ // Simulate adb pull writing to dest
114
+ fs.writeFileSync(args[4], "fake-pulled-content");
115
+ return { stdout: "1 file pulled", stderr: "" };
116
+ }
117
+ throw new Error("unexpected adb call");
118
+ },
119
+ });
120
+ const dest = path.join(dir, "sub/file.bin");
121
+ await ext.pull("ABC", "/sdcard/x.bin", dest);
122
+ expect(pulled).toBe(true);
123
+ expect(fs.existsSync(dest)).toBe(true);
124
+ fs.rmSync(dir, { recursive: true, force: true });
125
+ });
126
+
127
+ it("pullFromAppPrivate uses su cat + temp + cleanup", async () => {
128
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ax-"));
129
+ const calls = [];
130
+ const ext = new AndroidExtractor({
131
+ execFn: async (cmd, args) => {
132
+ calls.push(args.join(" "));
133
+ if (args[2] === "shell" && args[3].startsWith("su -c 'cat")) {
134
+ return { stdout: "", stderr: "" };
135
+ }
136
+ if (args[2] === "pull") {
137
+ fs.writeFileSync(args[4], "decrypted-db-bytes");
138
+ return { stdout: "", stderr: "" };
139
+ }
140
+ if (args[2] === "shell" && args[3].startsWith("rm -f")) {
141
+ return { stdout: "", stderr: "" };
142
+ }
143
+ throw new Error("unexpected adb call: " + args.join(" "));
144
+ },
145
+ });
146
+ const dest = path.join(dir, "EnMicroMsg.db");
147
+ await ext.pullFromAppPrivate("ABC", "com.tencent.mm", "/data/data/com.tencent.mm/MicroMsg/x/EnMicroMsg.db", dest);
148
+ expect(fs.existsSync(dest)).toBe(true);
149
+ // Verify su cat happened + cleanup rm happened
150
+ expect(calls.some((c) => c.includes("su -c 'cat"))).toBe(true);
151
+ expect(calls.some((c) => c.includes("rm -f"))).toBe(true);
152
+ fs.rmSync(dir, { recursive: true, force: true });
153
+ });
154
+
155
+ it("lsAppPrivate parses ls -1 output", async () => {
156
+ const ext = new AndroidExtractor({
157
+ execFn: mockExec({
158
+ "-s ABC shell su -c 'ls -1 \"/data/data/com.tencent.mm\"'": {
159
+ stdout: "MicroMsg\nshared_prefs\nfiles\ncache\n",
160
+ },
161
+ }),
162
+ });
163
+ const ls = await ext.lsAppPrivate("ABC", "/data/data/com.tencent.mm");
164
+ expect(ls).toContain("MicroMsg");
165
+ expect(ls).toContain("shared_prefs");
166
+ });
167
+ });
168
+
169
+ // ─── iOSBackupReader — fixture-driven ────────────────────────────────────
170
+
171
+ describe("iOSBackupReader", () => {
172
+ let dir;
173
+
174
+ function makeBackup({ encrypted = false } = {}) {
175
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "ios-bk-"));
176
+ fs.writeFileSync(
177
+ path.join(dir, "Manifest.plist"),
178
+ `<?xml version="1.0"?><plist version="1.0"><dict>
179
+ <key>IsEncrypted</key>${encrypted ? "<true/>" : "<false/>"}
180
+ </dict></plist>`,
181
+ );
182
+ fs.writeFileSync(
183
+ path.join(dir, "Info.plist"),
184
+ `<?xml version="1.0"?><plist version="1.0"><dict>
185
+ <key>Device Name</key><string>Test iPhone</string>
186
+ <key>Product Type</key><string>iPhone15,2</string>
187
+ <key>Product Version</key><string>18.0</string>
188
+ </dict></plist>`,
189
+ );
190
+ // Empty SQLite Manifest.db — mock driver below skips it
191
+ fs.writeFileSync(path.join(dir, "Manifest.db"), Buffer.from("SQLite format 3\0"));
192
+ return dir;
193
+ }
194
+
195
+ afterEach(() => {
196
+ if (dir) {
197
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_e) {}
198
+ }
199
+ dir = null;
200
+ });
201
+
202
+ it("constructor rejects missing dir", () => {
203
+ expect(() => new iOSBackupReader({ backupDir: "/nonexistent/path/xx" })).toThrow();
204
+ expect(() => new iOSBackupReader({})).toThrow(/backupDir/);
205
+ });
206
+
207
+ it("encrypted backup throws (Phase 7.5b TODO)", async () => {
208
+ const backupDir = makeBackup({ encrypted: true });
209
+ const reader = new iOSBackupReader({
210
+ backupDir,
211
+ dbDriverFn: () => {
212
+ throw new Error("driver should not be called when encrypted");
213
+ },
214
+ });
215
+ await expect(reader.open()).rejects.toThrow(/encrypted/);
216
+ });
217
+
218
+ it("open() reads Info.plist + opens Manifest.db", async () => {
219
+ const backupDir = makeBackup({ encrypted: false });
220
+ // Mock SQLite driver
221
+ const mockDb = {
222
+ prepare: () => ({ all: () => [], get: () => undefined }),
223
+ close: () => {},
224
+ };
225
+ const mockDriver = () => mockDb;
226
+ const reader = new iOSBackupReader({ backupDir, dbDriverFn: mockDriver });
227
+ const r = await reader.open();
228
+ expect(r.encrypted).toBe(false);
229
+ expect(r.info["Device Name"]).toBe("Test iPhone");
230
+ expect(r.info["Product Version"]).toBe("18.0");
231
+ reader.close();
232
+ });
233
+
234
+ it("listFiles passes WHERE clauses based on opts", async () => {
235
+ const backupDir = makeBackup({ encrypted: false });
236
+ let lastSql, lastParams;
237
+ const mockDriver = () => ({
238
+ prepare: (sql) => ({
239
+ all: (...params) => {
240
+ lastSql = sql;
241
+ lastParams = params;
242
+ return [{ fileID: "abc", domain: "HomeDomain", relativePath: "Library/Notes/notes.sqlite", flags: 1 }];
243
+ },
244
+ }),
245
+ close: () => {},
246
+ });
247
+ const reader = new iOSBackupReader({ backupDir, dbDriverFn: mockDriver });
248
+ await reader.open();
249
+ const files = reader.listFiles({ domain: "HomeDomain", limit: 10 });
250
+ expect(files).toHaveLength(1);
251
+ expect(lastSql).toContain("WHERE domain = ?");
252
+ expect(lastParams).toContain("HomeDomain");
253
+ reader.close();
254
+ });
255
+
256
+ it("resolveFileOnDisk shards by first 2 chars", async () => {
257
+ const backupDir = makeBackup({ encrypted: false });
258
+ const mockDriver = () => ({
259
+ prepare: () => ({ all: () => [], get: () => undefined }),
260
+ close: () => {},
261
+ });
262
+ const reader = new iOSBackupReader({ backupDir, dbDriverFn: mockDriver });
263
+ await reader.open();
264
+ const p = reader.resolveFileOnDisk("abcd1234567890");
265
+ expect(p).toBe(path.join(backupDir, "ab", "abcd1234567890"));
266
+ reader.close();
267
+ });
268
+
269
+ it("copyOut copies the sharded file to localPath", async () => {
270
+ const backupDir = makeBackup({ encrypted: false });
271
+ // Create a fake sharded file
272
+ const fileID = "abc1234";
273
+ const shardedDir = path.join(backupDir, "ab");
274
+ fs.mkdirSync(shardedDir, { recursive: true });
275
+ fs.writeFileSync(path.join(shardedDir, fileID), "test-content");
276
+
277
+ const mockDriver = () => ({
278
+ prepare: () => ({ all: () => [], get: () => undefined }),
279
+ close: () => {},
280
+ });
281
+ const reader = new iOSBackupReader({ backupDir, dbDriverFn: mockDriver });
282
+ await reader.open();
283
+ const local = path.join(backupDir, "out", "extracted.bin");
284
+ reader.copyOut(fileID, local);
285
+ expect(fs.readFileSync(local, "utf-8")).toBe("test-content");
286
+ reader.close();
287
+ });
288
+ });
@@ -178,7 +178,9 @@ describe("AdapterRegistry.syncAdapter", () => {
178
178
  it("refuses concurrent sync of two adapters in one registry", async () => {
179
179
  freshVault();
180
180
  const reg = new AdapterRegistry({ vault });
181
- reg.register(new MockAdapter({ name: "a", count: 5000 })); // long enough to be in-flight
181
+ // Use 500 events (not 5000) still big enough to be mid-flight when
182
+ // the second syncAdapter() lands, but fits comfortably in 10s.
183
+ reg.register(new MockAdapter({ name: "a", count: 500 }));
182
184
  reg.register(new MockAdapter({ name: "b", count: 5 }));
183
185
 
184
186
  const p1 = reg.syncAdapter("a");
@@ -194,7 +196,7 @@ describe("AdapterRegistry.syncAdapter", () => {
194
196
  // assert no double-sync corruption. The active-sync invariant is
195
197
  // additionally enforced by the activeSync flag.
196
198
  expect(racedReject == null || /already syncing/.test(racedReject.message)).toBe(true);
197
- });
199
+ }, 30_000);
198
200
  });
199
201
 
200
202
  // ─── KG + RAG sinks ──────────────────────────────────────────────────────
@@ -0,0 +1,296 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect } from "vitest";
4
+
5
+ const {
6
+ normalizeOrderRecord,
7
+ CookieAuth,
8
+ TaobaoAdapter,
9
+ JdAdapter,
10
+ MeituanAdapter,
11
+ } = require("../lib");
12
+ const { orderToRecord: taobaoOrderToRecord, parseTaobaoTime } = require("../lib/adapters/shopping-taobao");
13
+ const { orderToRecord: jdOrderToRecord } = require("../lib/adapters/shopping-jd");
14
+ const { orderToRecord: meituanOrderToRecord } = require("../lib/adapters/shopping-meituan");
15
+ const { assertAdapter } = require("../lib/adapter-spec");
16
+ const { validateBatch } = require("../lib/batch");
17
+
18
+ // ─── normalizeOrderRecord ───────────────────────────────────────────────
19
+
20
+ describe("normalizeOrderRecord", () => {
21
+ it("produces Event + merchant Person + Item entities", () => {
22
+ const rec = {
23
+ vendorId: "taobao",
24
+ orderId: "ORD-1",
25
+ placedAt: 1700000000000,
26
+ paidAt: 1700000010000,
27
+ status: "delivered",
28
+ merchantName: "Apple官方旗舰店",
29
+ totalAmount: { value: 9999, currency: "CNY" },
30
+ items: [
31
+ { name: "iPhone 17 Pro 256GB", quantity: 1, unitPrice: 9999 },
32
+ ],
33
+ recipient: "张三",
34
+ shippingAddress: "上海市某区某路",
35
+ trackingNumber: "SF1234567",
36
+ };
37
+ const b = normalizeOrderRecord(rec, { adapterName: "shopping-taobao", adapterVersion: "0.5.0" });
38
+ expect(b.events).toHaveLength(1);
39
+ expect(b.events[0].subtype).toBe("order");
40
+ expect(b.events[0].content.amount.value).toBe(9999);
41
+ expect(b.events[0].content.amount.direction).toBe("out");
42
+ expect(b.events[0].extra.merchantOrderNumber).toBe("ORD-1"); // cross-source link
43
+ expect(b.events[0].extra.orderStatus).toBe("delivered");
44
+ expect(b.events[0].extra.trackingNumber).toBe("SF1234567");
45
+ expect(b.persons).toHaveLength(1);
46
+ expect(b.persons[0].subtype).toBe("merchant");
47
+ expect(b.items).toHaveLength(1);
48
+ expect(b.items[0].name).toBe("iPhone 17 Pro 256GB");
49
+ const v = validateBatch(b);
50
+ expect(v.valid).toBe(true);
51
+ });
52
+
53
+ it("refund status maps to refund subtype + amount in", () => {
54
+ const rec = {
55
+ vendorId: "taobao", orderId: "X", placedAt: Date.now(),
56
+ status: "refunded", merchantName: "Test",
57
+ totalAmount: { value: 100, currency: "CNY" },
58
+ };
59
+ const b = normalizeOrderRecord(rec, { adapterName: "shopping-taobao" });
60
+ expect(b.events[0].subtype).toBe("refund");
61
+ expect(b.events[0].content.amount.direction).toBe("in");
62
+ });
63
+
64
+ it("cancelled status maps to cancelled subtype", () => {
65
+ const rec = {
66
+ vendorId: "jd", orderId: "X", placedAt: Date.now(),
67
+ status: "已取消", merchantName: "Test",
68
+ };
69
+ const b = normalizeOrderRecord(rec, { adapterName: "shopping-jd" });
70
+ expect(b.events[0].subtype).toBe("cancelled");
71
+ });
72
+
73
+ it("requires orderId + merchantName", () => {
74
+ expect(() => normalizeOrderRecord({})).toThrow();
75
+ expect(() => normalizeOrderRecord({ orderId: "x" })).toThrow();
76
+ });
77
+ });
78
+
79
+ // ─── CookieAuth ──────────────────────────────────────────────────────────
80
+
81
+ describe("CookieAuth", () => {
82
+ it("constructor requires platform", () => {
83
+ expect(() => new CookieAuth({})).toThrow(/platform/);
84
+ });
85
+
86
+ it("toHeader returns null for empty", () => {
87
+ const ca = new CookieAuth({ platform: "taobao" });
88
+ expect(ca.toHeader()).toBeNull();
89
+ });
90
+
91
+ it("toHeader returns the cookie string when set", () => {
92
+ const ca = new CookieAuth({ platform: "taobao", cookies: "k1=v1; k2=v2" });
93
+ expect(ca.toHeader()).toBe("k1=v1; k2=v2");
94
+ });
95
+
96
+ it("getCookieValue reads single cookie", () => {
97
+ const ca = new CookieAuth({ platform: "taobao", cookies: "k1=v1; k2=v%20space" });
98
+ expect(ca.getCookieValue("k1")).toBe("v1");
99
+ expect(ca.getCookieValue("k2")).toBe("v space"); // decoded
100
+ expect(ca.getCookieValue("missing")).toBeNull();
101
+ });
102
+
103
+ it("validate returns false for empty", async () => {
104
+ const ca = new CookieAuth({ platform: "taobao" });
105
+ expect(await ca.validate()).toBe(false);
106
+ });
107
+
108
+ it("validate uses injected validator", async () => {
109
+ const ca = new CookieAuth({
110
+ platform: "taobao",
111
+ cookies: "k=v",
112
+ validator: async (c) => c.includes("good"),
113
+ });
114
+ expect(await ca.validate()).toBe(false);
115
+ ca.setCookies("good=ok");
116
+ expect(await ca.validate()).toBe(true);
117
+ });
118
+ });
119
+
120
+ // ─── TaobaoAdapter ──────────────────────────────────────────────────────
121
+
122
+ describe("TaobaoAdapter", () => {
123
+ it("contract conformance", () => {
124
+ const a = new TaobaoAdapter({ account: { userId: "u-1", cookies: "k=v" } });
125
+ expect(assertAdapter(a).ok).toBe(true);
126
+ expect(a.extractMode).toBe("web-api");
127
+ });
128
+
129
+ it("orderToRecord maps Taobao fields", () => {
130
+ const o = {
131
+ bizOrderId: "TB-1",
132
+ sellerNick: "Apple官方旗舰店",
133
+ createTime: 1700000000,
134
+ payTime: 1700000010,
135
+ statusText: "已发货",
136
+ actualFee: "9999.00",
137
+ subOrders: [
138
+ { itemTitle: "iPhone 17", buyCount: 1, itemPrice: "9999" },
139
+ ],
140
+ receiverName: "张三",
141
+ fullAddress: "上海...",
142
+ };
143
+ const rec = taobaoOrderToRecord(o);
144
+ expect(rec.orderId).toBe("TB-1");
145
+ expect(rec.merchantName).toBe("Apple官方旗舰店");
146
+ expect(rec.status).toBe("shipped");
147
+ expect(rec.totalAmount.value).toBe(9999);
148
+ expect(rec.items).toHaveLength(1);
149
+ expect(rec.placedAt).toBeGreaterThan(0);
150
+ });
151
+
152
+ it("parseTaobaoTime upgrades seconds → ms", () => {
153
+ expect(parseTaobaoTime(1700000000)).toBe(1700000000000);
154
+ expect(parseTaobaoTime(1700000000000)).toBe(1700000000000);
155
+ expect(parseTaobaoTime("2026-04-15T10:00:00Z")).toBeGreaterThan(0);
156
+ });
157
+
158
+ it("sync yields raw events from fetchFn fixture", async () => {
159
+ const fetchFn = async () => ({
160
+ orders: [
161
+ {
162
+ bizOrderId: "TB-2", sellerNick: "Test",
163
+ createTime: 1700000000, payTime: 1700000010,
164
+ statusText: "已签收", actualFee: "100",
165
+ subOrders: [{ itemTitle: "Item A", buyCount: 1, itemPrice: "100" }],
166
+ },
167
+ ],
168
+ });
169
+ const a = new TaobaoAdapter({
170
+ account: { userId: "u-1", cookies: "valid=cookie" },
171
+ fetchFn,
172
+ });
173
+ const raws = [];
174
+ for await (const r of a.sync({ pageSize: 20, sinceWatermark: 0 })) raws.push(r);
175
+ expect(raws).toHaveLength(1);
176
+ const batch = a.normalize(raws[0]);
177
+ expect(validateBatch(batch).valid).toBe(true);
178
+ });
179
+
180
+ it("sync no-ops on invalid cookies", async () => {
181
+ const a = new TaobaoAdapter({ account: { userId: "u-1" } }); // no cookies
182
+ const raws = [];
183
+ for await (const r of a.sync()) raws.push(r);
184
+ expect(raws).toHaveLength(0);
185
+ });
186
+ });
187
+
188
+ // ─── JdAdapter ───────────────────────────────────────────────────────────
189
+
190
+ describe("JdAdapter", () => {
191
+ it("contract conformance", () => {
192
+ const a = new JdAdapter({ account: { pin: "p1", cookies: "k=v" } });
193
+ expect(assertAdapter(a).ok).toBe(true);
194
+ });
195
+
196
+ it("orderToRecord maps JD fields", () => {
197
+ const o = {
198
+ orderId: "JD-1",
199
+ orderTotalPrice: "1999.00",
200
+ orderStartTime: "2026-04-15 10:00:00",
201
+ orderStatusText: "已收货",
202
+ venderName: "京东自营",
203
+ productList: [
204
+ { productName: "Kindle", productPrice: "999", productQuantity: 2 },
205
+ ],
206
+ consigneeName: "张三",
207
+ };
208
+ const rec = jdOrderToRecord(o);
209
+ expect(rec.orderId).toBe("JD-1");
210
+ expect(rec.merchantName).toBe("京东自营");
211
+ expect(rec.status).toBe("delivered");
212
+ expect(rec.totalAmount.value).toBe(1999);
213
+ expect(rec.items[0].name).toBe("Kindle");
214
+ expect(rec.items[0].quantity).toBe(2);
215
+ });
216
+
217
+ it("sync + normalize end-to-end", async () => {
218
+ const fetchFn = async () => ({
219
+ orders: [
220
+ {
221
+ orderId: "JD-2", orderTotalPrice: "299",
222
+ orderStartTime: "2026-04-15 10:00:00",
223
+ orderStatusText: "已发货",
224
+ venderName: "京东",
225
+ productList: [{ productName: "鼠标", productPrice: "299", productQuantity: 1 }],
226
+ },
227
+ ],
228
+ });
229
+ const a = new JdAdapter({
230
+ account: { pin: "p1", cookies: "v=ok" },
231
+ fetchFn,
232
+ });
233
+ const raws = [];
234
+ for await (const r of a.sync({ sinceWatermark: 0 })) raws.push(r);
235
+ expect(raws).toHaveLength(1);
236
+ expect(validateBatch(a.normalize(raws[0])).valid).toBe(true);
237
+ });
238
+ });
239
+
240
+ // ─── MeituanAdapter ──────────────────────────────────────────────────────
241
+
242
+ describe("MeituanAdapter", () => {
243
+ it("contract conformance", () => {
244
+ const a = new MeituanAdapter({ account: { userId: "u-1", cookies: "k=v" } });
245
+ expect(assertAdapter(a).ok).toBe(true);
246
+ });
247
+
248
+ it("orderToRecord maps Meituan 外卖 fields", () => {
249
+ const o = {
250
+ orderId: "MT-1",
251
+ poiName: "麦当劳(中山公园店)",
252
+ orderTime: 1700000000,
253
+ payTime: 1700000010,
254
+ statusDesc: "已送达",
255
+ totalPrice: "45.50",
256
+ dishes: [
257
+ { name: "巨无霸套餐", quantity: 1, price: "45.5" },
258
+ ],
259
+ recipientAddress: "上海...",
260
+ };
261
+ const rec = meituanOrderToRecord(o, "waimai");
262
+ expect(rec.orderId).toBe("MT-1");
263
+ expect(rec.merchantName).toBe("麦当劳(中山公园店)");
264
+ expect(rec.status).toBe("delivered");
265
+ expect(rec.totalAmount.value).toBe(45.5);
266
+ expect(rec.extras.platform).toBe("waimai");
267
+ });
268
+
269
+ it("sync iterates multiple platforms", async () => {
270
+ const seen = [];
271
+ const fetchFn = async (opts) => {
272
+ seen.push(opts.query.platform);
273
+ return {
274
+ orders: [
275
+ {
276
+ orderId: `MT-${opts.query.platform}`,
277
+ poiName: "Test",
278
+ orderTime: 1700000000,
279
+ statusDesc: "已完成",
280
+ totalPrice: "10",
281
+ dishes: [{ name: "x", quantity: 1, price: "10" }],
282
+ },
283
+ ],
284
+ };
285
+ };
286
+ const a = new MeituanAdapter({
287
+ account: { userId: "u-1", cookies: "v=ok" },
288
+ fetchFn,
289
+ });
290
+ const raws = [];
291
+ for await (const r of a.sync({ sinceWatermark: 0, platforms: ["waimai", "groupbuy"] })) raws.push(r);
292
+ expect(seen).toContain("waimai");
293
+ expect(seen).toContain("groupbuy");
294
+ expect(raws.length).toBeGreaterThan(0);
295
+ });
296
+ });