@chainlesschain/personal-data-hub 0.4.29 → 0.4.31

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 (199) hide show
  1. package/lib/forensics/qq-nt-collect.js +190 -0
  2. package/lib/prompt-builder.js +15 -1
  3. package/package.json +8 -3
  4. package/__tests__/adapter-guide.test.js +0 -47
  5. package/__tests__/adapter-spec.test.js +0 -78
  6. package/__tests__/adapters/ai-chat-cookie-capture-spec.test.js +0 -211
  7. package/__tests__/adapters/ai-chat-health-checker.test.js +0 -262
  8. package/__tests__/adapters/ai-chat-history.test.js +0 -396
  9. package/__tests__/adapters/ai-chat-http-client.test.js +0 -242
  10. package/__tests__/adapters/ai-chat-vendors.test.js +0 -874
  11. package/__tests__/adapters/alipay-bill-adapter.test.js +0 -538
  12. package/__tests__/adapters/apple-health.test.js +0 -95
  13. package/__tests__/adapters/bank-family.test.js +0 -125
  14. package/__tests__/adapters/biz-tianyancha.test.js +0 -159
  15. package/__tests__/adapters/browser-history-chrome.test.js +0 -377
  16. package/__tests__/adapters/browser-history-edge.test.js +0 -159
  17. package/__tests__/adapters/car-mercedesme.test.js +0 -74
  18. package/__tests__/adapters/doc-baidu-netdisk.test.js +0 -102
  19. package/__tests__/adapters/doc-camscanner.test.js +0 -147
  20. package/__tests__/adapters/doc-platforms.test.js +0 -177
  21. package/__tests__/adapters/edu-huawei-learning-live.test.js +0 -198
  22. package/__tests__/adapters/edu-zuoyebang-live.test.js +0 -226
  23. package/__tests__/adapters/email-adapter-snapshot.test.js +0 -237
  24. package/__tests__/adapters/email-adapter.test.js +0 -742
  25. package/__tests__/adapters/email-classifier.test.js +0 -347
  26. package/__tests__/adapters/email-imap-session.test.js +0 -334
  27. package/__tests__/adapters/email-parser.test.js +0 -244
  28. package/__tests__/adapters/email-pdf-extractor.test.js +0 -529
  29. package/__tests__/adapters/email-providers.test.js +0 -84
  30. package/__tests__/adapters/email-retry-progress.test.js +0 -294
  31. package/__tests__/adapters/email-templates.test.js +0 -822
  32. package/__tests__/adapters/family-23-collectors-scaffold.test.js +0 -182
  33. package/__tests__/adapters/finance-alipay-live.test.js +0 -258
  34. package/__tests__/adapters/finance-dcep.test.js +0 -74
  35. package/__tests__/adapters/fitness-joyrun.test.js +0 -82
  36. package/__tests__/adapters/game-genshin-live.test.js +0 -238
  37. package/__tests__/adapters/game-genshin-scaffold.test.js +0 -108
  38. package/__tests__/adapters/game-honor-of-kings-live.test.js +0 -230
  39. package/__tests__/adapters/git-activity.test.js +0 -222
  40. package/__tests__/adapters/gov-12123.test.js +0 -103
  41. package/__tests__/adapters/gov-ixiamen.test.js +0 -150
  42. package/__tests__/adapters/gov-tax.test.js +0 -135
  43. package/__tests__/adapters/health-meiyou.test.js +0 -125
  44. package/__tests__/adapters/local-files.test.js +0 -264
  45. package/__tests__/adapters/local-im-pc.test.js +0 -154
  46. package/__tests__/adapters/messaging-whatsapp.test.js +0 -289
  47. package/__tests__/adapters/music-kugou.test.js +0 -187
  48. package/__tests__/adapters/music-qq.test.js +0 -112
  49. package/__tests__/adapters/netease-music-live.test.js +0 -244
  50. package/__tests__/adapters/netease-music.test.js +0 -74
  51. package/__tests__/adapters/pc-local-discovery.test.js +0 -141
  52. package/__tests__/adapters/qq-pc-direct-read.test.js +0 -227
  53. package/__tests__/adapters/reading-family.test.js +0 -108
  54. package/__tests__/adapters/recruit-boss.test.js +0 -180
  55. package/__tests__/adapters/shell-history.test.js +0 -180
  56. package/__tests__/adapters/shopping-base.test.js +0 -179
  57. package/__tests__/adapters/shopping-dianping.test.js +0 -239
  58. package/__tests__/adapters/social-bilibili-adb-api-client.test.js +0 -721
  59. package/__tests__/adapters/social-bilibili-adb-chromium-cookies-reader.test.js +0 -346
  60. package/__tests__/adapters/social-bilibili-adb-collector.test.js +0 -284
  61. package/__tests__/adapters/social-bilibili-adb-cookies-extension.test.js +0 -343
  62. package/__tests__/adapters/social-bilibili-adb-snapshot-builder.test.js +0 -296
  63. package/__tests__/adapters/social-csdn.test.js +0 -175
  64. package/__tests__/adapters/social-dongchedi.test.js +0 -165
  65. package/__tests__/adapters/social-douyin-adb-aweme-detail.test.js +0 -165
  66. package/__tests__/adapters/social-douyin-adb-collector.test.js +0 -254
  67. package/__tests__/adapters/social-douyin-adb-db-extension.test.js +0 -114
  68. package/__tests__/adapters/social-douyin-adb-im-db-parser.test.js +0 -304
  69. package/__tests__/adapters/social-douyin-adb-snapshot-builder.test.js +0 -216
  70. package/__tests__/adapters/social-douyin-adb-usage-profile.test.js +0 -229
  71. package/__tests__/adapters/social-douyin-adb-watch-history.test.js +0 -269
  72. package/__tests__/adapters/social-kuaishou-adb-api-client.test.js +0 -496
  73. package/__tests__/adapters/social-kuaishou-adb-collector.test.js +0 -276
  74. package/__tests__/adapters/social-kuaishou-adb-cookies-extension.test.js +0 -152
  75. package/__tests__/adapters/social-kuaishou-adb-snapshot-builder.test.js +0 -178
  76. package/__tests__/adapters/social-toutiao-adb-account-reader.test.js +0 -135
  77. package/__tests__/adapters/social-toutiao-adb-api-client.test.js +0 -626
  78. package/__tests__/adapters/social-toutiao-adb-article.test.js +0 -155
  79. package/__tests__/adapters/social-toutiao-adb-collector.test.js +0 -378
  80. package/__tests__/adapters/social-toutiao-adb-cookies-extension.test.js +0 -193
  81. package/__tests__/adapters/social-toutiao-adb-snapshot-builder.test.js +0 -196
  82. package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +0 -311
  83. package/__tests__/adapters/social-weibo-adb-api-client.test.js +0 -362
  84. package/__tests__/adapters/social-weibo-adb-collector.test.js +0 -201
  85. package/__tests__/adapters/social-weibo-adb-cookies-extension.test.js +0 -167
  86. package/__tests__/adapters/social-weibo-adb-snapshot-builder.test.js +0 -189
  87. package/__tests__/adapters/social-xiaohongshu-adb-api-client.test.js +0 -431
  88. package/__tests__/adapters/social-xiaohongshu-adb-collector.test.js +0 -207
  89. package/__tests__/adapters/social-xiaohongshu-adb-cookies-extension.test.js +0 -0
  90. package/__tests__/adapters/social-xiaohongshu-adb-sign-provider-injection.test.js +0 -351
  91. package/__tests__/adapters/social-xiaohongshu-adb-sign.test.js +0 -130
  92. package/__tests__/adapters/social-xiaohongshu-adb-snapshot-builder.test.js +0 -200
  93. package/__tests__/adapters/social-zhihu.test.js +0 -246
  94. package/__tests__/adapters/system-data-adapter.test.js +0 -443
  95. package/__tests__/adapters/system-data-android-ingest.test.js +0 -144
  96. package/__tests__/adapters/system-data-android.test.js +0 -519
  97. package/__tests__/adapters/system-data-disclosure.test.js +0 -153
  98. package/__tests__/adapters/travel-12306.test.js +0 -512
  99. package/__tests__/adapters/travel-amap.test.js +0 -219
  100. package/__tests__/adapters/travel-baidu-map.test.js +0 -305
  101. package/__tests__/adapters/travel-base.test.js +0 -205
  102. package/__tests__/adapters/travel-ctrip.test.js +0 -377
  103. package/__tests__/adapters/travel-didi-consumer.test.js +0 -66
  104. package/__tests__/adapters/travel-didi.test.js +0 -204
  105. package/__tests__/adapters/travel-tencent-map.test.js +0 -207
  106. package/__tests__/adapters/travel-tongcheng.test.js +0 -289
  107. package/__tests__/adapters/video-platforms.test.js +0 -152
  108. package/__tests__/adapters/video-xigua.test.js +0 -106
  109. package/__tests__/adapters/vscode.test.js +0 -299
  110. package/__tests__/adapters/wechat-bootstrap.test.js +0 -240
  111. package/__tests__/adapters/wechat-env-probe.test.js +0 -162
  112. package/__tests__/adapters/wechat-frida-agent.test.js +0 -322
  113. package/__tests__/adapters/wechat-frida-integration.test.js +0 -149
  114. package/__tests__/adapters/wechat-frida-key-provider.test.js +0 -188
  115. package/__tests__/adapters/wechat-md5-key-provider.test.js +0 -101
  116. package/__tests__/adapters/wechat-pc-direct-read.test.js +0 -365
  117. package/__tests__/adapters/wechat-pc-group-topic.test.js +0 -63
  118. package/__tests__/adapters/wechat-pc-v4-sidecar.test.js +0 -72
  119. package/__tests__/adapters/weread.test.js +0 -123
  120. package/__tests__/adapters/wework-pc.test.js +0 -124
  121. package/__tests__/adapters/win-recent.test.js +0 -192
  122. package/__tests__/analysis-skills.test.js +0 -754
  123. package/__tests__/analysis.test.js +0 -1845
  124. package/__tests__/audio-ximalaya-snapshot.test.js +0 -279
  125. package/__tests__/batch.test.js +0 -133
  126. package/__tests__/bridges-cc-kg.test.js +0 -231
  127. package/__tests__/bridges-cc-llm.test.js +0 -191
  128. package/__tests__/bridges-cc-rag.test.js +0 -162
  129. package/__tests__/categories.test.js +0 -92
  130. package/__tests__/e2e/ai-chat-cross-source-journey.test.js +0 -213
  131. package/__tests__/e2e/full-user-journey.test.js +0 -188
  132. package/__tests__/e2e/local-data-adapters-cli.e2e.test.js +0 -146
  133. package/__tests__/entity-resolver-ingest-hook.test.js +0 -177
  134. package/__tests__/entity-resolver-stages.test.js +0 -411
  135. package/__tests__/entity-resolver-vault.test.js +0 -249
  136. package/__tests__/entity-resolver.test.js +0 -526
  137. package/__tests__/fitness-keep-snapshot.test.js +0 -224
  138. package/__tests__/fixtures/entity-resolver-200-mock.json +0 -96
  139. package/__tests__/ids.test.js +0 -45
  140. package/__tests__/integration/ai-chat-history-registry.test.js +0 -228
  141. package/__tests__/integration/aichat-wizard-end-to-end.test.js +0 -282
  142. package/__tests__/integration/cross-adapter-pipelines.test.js +0 -396
  143. package/__tests__/integration/local-data-adapters-pipeline.test.js +0 -373
  144. package/__tests__/integration/social-bilibili-pipeline.test.js +0 -261
  145. package/__tests__/integration/wechat-bootstrap-end-to-end.test.js +0 -390
  146. package/__tests__/key-providers.test.js +0 -126
  147. package/__tests__/kg-derive.test.js +0 -219
  148. package/__tests__/llm-client.test.js +0 -122
  149. package/__tests__/longtail-adapters.test.js +0 -281
  150. package/__tests__/messaging-qq-snapshot.test.js +0 -294
  151. package/__tests__/mobile-extractor-encrypted.test.js +0 -460
  152. package/__tests__/mobile-extractor.test.js +0 -288
  153. package/__tests__/mock-adapter.test.js +0 -93
  154. package/__tests__/prompt-builder.test.js +0 -249
  155. package/__tests__/query-parser.test.js +0 -365
  156. package/__tests__/rag-derive.test.js +0 -169
  157. package/__tests__/registry-readiness.test.js +0 -292
  158. package/__tests__/registry.test.js +0 -420
  159. package/__tests__/salvage-ingest.test.js +0 -97
  160. package/__tests__/schemas.test.js +0 -331
  161. package/__tests__/shopping-adapters.test.js +0 -392
  162. package/__tests__/shopping-eleme-snapshot.test.js +0 -454
  163. package/__tests__/shopping-pinduoduo-snapshot.test.js +0 -484
  164. package/__tests__/shopping-snapshot.test.js +0 -438
  165. package/__tests__/shopping-vipshop-snapshot.test.js +0 -425
  166. package/__tests__/shopping-xianyu-snapshot.test.js +0 -451
  167. package/__tests__/sidecar-contacts-cross-validate.test.js +0 -186
  168. package/__tests__/sidecar-supervisor.test.js +0 -128
  169. package/__tests__/sign-providers.test.js +0 -62
  170. package/__tests__/social-adapters.test.js +0 -280
  171. package/__tests__/social-bilibili-snapshot.test.js +0 -278
  172. package/__tests__/social-douban-snapshot.test.js +0 -351
  173. package/__tests__/social-douyin-im-direct-read.test.js +0 -377
  174. package/__tests__/social-douyin-salvage-collector.test.js +0 -98
  175. package/__tests__/social-douyin-salvage-mapper.test.js +0 -90
  176. package/__tests__/social-douyin-snapshot.test.js +0 -256
  177. package/__tests__/social-kuaishou-snapshot.test.js +0 -362
  178. package/__tests__/social-toutiao-snapshot.test.js +0 -366
  179. package/__tests__/social-weibo-snapshot.test.js +0 -234
  180. package/__tests__/social-weibo-sqlite-device.test.js +0 -174
  181. package/__tests__/social-xiaohongshu-snapshot.test.js +0 -232
  182. package/__tests__/sqlite-leaf-salvage.test.js +0 -97
  183. package/__tests__/travel-adapters.test.js +0 -483
  184. package/__tests__/travel-maps-snapshot.test.js +0 -426
  185. package/__tests__/vault-driver-error.test.js +0 -74
  186. package/__tests__/vault-search-helpers.test.js +0 -104
  187. package/__tests__/vault-search.test.js +0 -423
  188. package/__tests__/vault.test.js +0 -767
  189. package/__tests__/wechat-adapter.test.js +0 -594
  190. package/__tests__/whatsapp-adapter.test.js +0 -138
  191. package/scripts/_make-fixture-all.js +0 -126
  192. package/scripts/_make-fixture-contacts.js +0 -84
  193. package/scripts/evaluate-entity-resolver.js +0 -213
  194. package/scripts/run-native-tests-sandbox.sh +0 -55
  195. package/scripts/smoke-phase-5-5.js +0 -196
  196. package/scripts/smoke-phase-5-7.js +0 -181
  197. package/scripts/smoke-system-data-contacts.js +0 -309
  198. package/scripts/smoke-system-data.js +0 -312
  199. package/vitest.config.js +0 -88
@@ -1,529 +0,0 @@
1
- "use strict";
2
-
3
- import { describe, it, expect } from "vitest";
4
-
5
- const {
6
- extractPdfText,
7
- passwordsFromHints,
8
- } = require("../../lib/adapters/email-imap/pdf-extractor");
9
-
10
- const { extractTransactions } = require("../../lib/adapters/email-imap/transactions");
11
-
12
- // ─── pdf-extractor (password trial loop) ─────────────────────────────────
13
-
14
- function makeMockPdfParse(spec = {}) {
15
- // spec: { needsPassword?: string, text?: string, throwsOnPassword?: Function }
16
- const calls = [];
17
- const fn = async (buf, opts = {}) => {
18
- calls.push({ pwd: opts.password, bufLen: buf.length });
19
- if (spec.throwsAlways) {
20
- const err = new Error(spec.throwsAlways);
21
- throw err;
22
- }
23
- if (spec.needsPassword != null && opts.password !== spec.needsPassword) {
24
- const err = new Error("PasswordException: incorrect password");
25
- throw err;
26
- }
27
- return {
28
- text: spec.text || "extracted content",
29
- numpages: spec.numpages || 1,
30
- info: { IsEncrypted: spec.needsPassword != null },
31
- };
32
- };
33
- fn.calls = calls;
34
- return fn;
35
- }
36
-
37
- describe("extractPdfText — password trial loop", () => {
38
- it("returns text when no password is required (empty-password first)", async () => {
39
- const mockParse = makeMockPdfParse({ text: "hello world" });
40
- const r = await extractPdfText(Buffer.from("FAKE PDF"), {
41
- passwords: ["abc", "def"],
42
- pdfParseImpl: mockParse,
43
- });
44
- expect(r.decrypted).toBe(true);
45
- expect(r.text).toBe("hello world");
46
- expect(r.attempted).toBe(1); // empty-string trial succeeds first
47
- expect(r.password).toBeUndefined();
48
- expect(mockParse.calls[0].pwd).toBe("");
49
- });
50
-
51
- it("succeeds on the second password in the list", async () => {
52
- const mockParse = makeMockPdfParse({ needsPassword: "OPENME-mock", text: "decrypted!" });
53
- const r = await extractPdfText(Buffer.from("FAKE"), {
54
- passwords: ["wrong1", "OPENME-mock", "wrong2"],
55
- pdfParseImpl: mockParse,
56
- });
57
- expect(r.decrypted).toBe(true);
58
- expect(r.text).toBe("decrypted!");
59
- expect(r.password).toBe("OPENME-mock");
60
- // Attempts: "" → "wrong1" → "OPENME-mock" = 3
61
- expect(r.attempted).toBe(3);
62
- expect(r.wasEncrypted).toBe(true);
63
- });
64
-
65
- it("returns decrypted:false when no password works", async () => {
66
- const mockParse = makeMockPdfParse({ needsPassword: "nobody-knows" });
67
- const r = await extractPdfText(Buffer.from("FAKE"), {
68
- passwords: ["a", "b", "c"],
69
- pdfParseImpl: mockParse,
70
- });
71
- expect(r.decrypted).toBe(false);
72
- expect(r.text).toBe("");
73
- expect(r.attempted).toBe(4); // "" + 3 user passwords
74
- expect(r.wasEncrypted).toBe(true);
75
- expect(r.error).toMatch(/password/i);
76
- });
77
-
78
- it("deduplicates passwords + always tries empty-string first", async () => {
79
- const mockParse = makeMockPdfParse({ needsPassword: "match" });
80
- const r = await extractPdfText(Buffer.from("FAKE"), {
81
- passwords: ["", "match", "", "match"], // duplicates + empty
82
- pdfParseImpl: mockParse,
83
- });
84
- expect(r.decrypted).toBe(true);
85
- expect(r.attempted).toBe(2); // "" → "match"
86
- });
87
-
88
- it("non-password error short-circuits (no further trials)", async () => {
89
- const mockParse = makeMockPdfParse({ throwsAlways: "corrupt PDF stream" });
90
- const r = await extractPdfText(Buffer.from("FAKE"), {
91
- passwords: ["a", "b", "c"],
92
- pdfParseImpl: mockParse,
93
- });
94
- expect(r.decrypted).toBe(false);
95
- expect(r.attempted).toBe(1); // gave up on first non-password error
96
- expect(r.error).toContain("corrupt PDF stream");
97
- });
98
-
99
- it("rejects non-Buffer input gracefully", async () => {
100
- const r = await extractPdfText("not-a-buffer");
101
- expect(r.decrypted).toBe(false);
102
- expect(r.error).toMatch(/buffer/i);
103
- });
104
-
105
- it("truncates extracted text at maxTextChars", async () => {
106
- const longText = "x".repeat(300_000);
107
- const mockParse = makeMockPdfParse({ text: longText });
108
- const r = await extractPdfText(Buffer.from("F"), {
109
- passwords: [],
110
- maxTextChars: 1000,
111
- pdfParseImpl: mockParse,
112
- });
113
- expect(r.decrypted).toBe(true);
114
- expect(r.text.length).toBeLessThan(longText.length);
115
- expect(r.text).toMatch(/truncated/);
116
- });
117
- });
118
-
119
- describe("passwordsFromHints — hint ordering", () => {
120
- it("returns priority order: idCard → phone → cardLast6 → cardLast4 → dob", () => {
121
- const out = passwordsFromHints({
122
- cardLast4: "1234",
123
- idCardLast6: "987654",
124
- dobYYYYMMDD: "19900101",
125
- phoneLast6: "555000",
126
- cardLast6: "555111",
127
- });
128
- expect(out).toEqual(["987654", "555000", "555111", "1234", "19900101"]);
129
- });
130
-
131
- it("dedups + skips empty / non-string", () => {
132
- const out = passwordsFromHints({
133
- idCardLast6: "111111",
134
- phoneLast6: "111111", // duplicate
135
- cardLast4: "",
136
- cardLast6: undefined,
137
- });
138
- expect(out).toEqual(["111111"]);
139
- });
140
-
141
- it("returns [] for empty input", () => {
142
- expect(passwordsFromHints({})).toEqual([]);
143
- expect(passwordsFromHints()).toEqual([]);
144
- });
145
- });
146
-
147
- // ─── transactions extractor ──────────────────────────────────────────────
148
-
149
- describe("extractTransactions — Chinese bank statements", () => {
150
- it("CMB (招商银行): YYYY-MM-DD whitespace-column format", () => {
151
- const text = [
152
- "招商银行信用卡 11 月对账单",
153
- "账单周期: 2026-10-26 至 2026-11-25",
154
- "",
155
- "2026-10-30 星巴克 上海中山公园店 -39.00 1,234.56",
156
- "2026-11-05 京东商城 -899.00 335.56",
157
- "2026-11-12 退款 淘宝 +50.00 385.56",
158
- "",
159
- "第 1 页 共 2 页",
160
- ].join("\n");
161
- const out = extractTransactions(text);
162
- expect(out).toHaveLength(3);
163
- expect(out[0].amount.value).toBe(39);
164
- expect(out[0].amount.direction).toBe("out");
165
- expect(out[0].balance.value).toBe(1234.56);
166
- expect(out[0].description).toMatch(/星巴克/);
167
- expect(out[2].amount.direction).toBe("in"); // "+" prefix
168
- });
169
-
170
- it("ICBC (工商银行): YYYY/MM/DD with 借/贷 prefix", () => {
171
- const text = [
172
- "工商银行信用卡账单",
173
- "2026/04/15 借 39.00 星巴克 CNY 1234.56",
174
- "2026/04/16 贷 200.00 退款 ABC CNY 1434.56",
175
- ].join("\n");
176
- const out = extractTransactions(text);
177
- expect(out).toHaveLength(2);
178
- expect(out[0].amount.direction).toBe("out"); // 借 → out
179
- expect(out[1].amount.direction).toBe("in"); // 贷 → in
180
- });
181
-
182
- it("中文日期: YYYY年MM月DD日 + 支出/收入 keywords", () => {
183
- const text = [
184
- "2026年04月15日 星巴克 上海中山公园店 支出 39.00 余额 1234.56",
185
- "2026年04月16日 工资到账 公司财务 收入 8000.00 余额 9234.56",
186
- ].join("\n");
187
- const out = extractTransactions(text);
188
- expect(out).toHaveLength(2);
189
- expect(out[0].amount.value).toBe(39);
190
- expect(out[0].amount.direction).toBe("out");
191
- expect(out[1].amount.direction).toBe("in");
192
- expect(out[1].balance.value).toBeCloseTo(9234.56);
193
- });
194
-
195
- it("skips header / footer / legalese lines", () => {
196
- const text = [
197
- "中国银行 月度账单",
198
- "声明: 本邮件由系统自动发送",
199
- "温馨提示: 请按时还款",
200
- "第 1 页",
201
- "===============",
202
- "2026-05-01 测试商户 100.00 1000.00",
203
- ].join("\n");
204
- const out = extractTransactions(text);
205
- expect(out).toHaveLength(1);
206
- expect(out[0].description).toMatch(/测试商户/);
207
- });
208
-
209
- it("returns [] for non-statement text (no dates)", () => {
210
- expect(extractTransactions("This is a marketing email, no statement rows.")).toEqual([]);
211
- expect(extractTransactions("")).toEqual([]);
212
- });
213
-
214
- it("caps at maxRows", () => {
215
- const lines = Array.from(
216
- { length: 50 },
217
- (_, i) => `2026-05-${String((i % 28) + 1).padStart(2, "0")} merchant${i} 10.00 100.00`,
218
- );
219
- const out = extractTransactions(lines.join("\n"), { maxRows: 5 });
220
- expect(out).toHaveLength(5);
221
- });
222
-
223
- it("each transaction includes a unique occurredAtMs", () => {
224
- const text = [
225
- "2026-05-01 a 10.00 100.00",
226
- "2026-05-02 b 20.00 80.00",
227
- "2026-05-03 c 30.00 50.00",
228
- ].join("\n");
229
- const out = extractTransactions(text);
230
- expect(out).toHaveLength(3);
231
- expect(out[0].occurredAtMs).toBeLessThan(out[1].occurredAtMs);
232
- expect(out[1].occurredAtMs).toBeLessThan(out[2].occurredAtMs);
233
- });
234
-
235
- it("description excludes amount/balance/direction-keyword noise", () => {
236
- const text = "2026-05-15 星巴克 支出 39.00 余额 1234.56";
237
- const out = extractTransactions(text);
238
- expect(out[0].description).toBe("星巴克");
239
- });
240
-
241
- it("single-amount line treats it as the transaction amount (no balance)", () => {
242
- const text = "2026-05-15 ATM withdrawal -500.00";
243
- const out = extractTransactions(text);
244
- expect(out).toHaveLength(1);
245
- expect(out[0].amount.value).toBe(500);
246
- expect(out[0].balance).toBeUndefined();
247
- });
248
- });
249
-
250
- // ─── EmailAdapter Phase 5.5 integration ──────────────────────────────────
251
-
252
- const { EmailAdapter } = require("../../lib/adapters/email-imap/email-adapter");
253
-
254
- function makeSession(envelopes) {
255
- return (_opts) => ({
256
- async connect() {},
257
- async openMailbox(_name) {
258
- return { uidValidity: 1, uidNext: 9999, exists: envelopes.length };
259
- },
260
- async *fetchFullSince(sinceUid = 0) {
261
- for (const env of envelopes) {
262
- if (env.uid > sinceUid) yield { ...env, source: env.source || Buffer.alloc(0) };
263
- }
264
- },
265
- async close() {},
266
- });
267
- }
268
-
269
- const billEnv = (uid = 1) => ({
270
- uid,
271
- internalDate: new Date("2026-05-01T10:00:00Z"),
272
- flags: ["\\Seen"],
273
- messageId: `<bill-${uid}@x>`,
274
- subject: "招商银行信用卡 11 月对账单",
275
- from: [{ name: "招商银行", address: "ebank@cmbchina.com" }],
276
- to: [{ address: "me@example.com" }],
277
- cc: [],
278
- date: new Date("2026-05-01T10:00:00Z"),
279
- size: 4096,
280
- source: Buffer.from("RAW", "utf8"),
281
- });
282
-
283
- const PDF_TEXT = [
284
- "招商银行信用卡 11 月对账单",
285
- "尾号 1234 最后还款日 2026-12-05 应还金额 ¥3,000.00",
286
- "",
287
- "2026-11-05 星巴克 上海中山公园店 -39.00 2961.00",
288
- "2026-11-15 京东自营 -899.00 2062.00",
289
- "2026-11-20 退款 淘宝 +50.00 2112.00",
290
- ].join("\n");
291
-
292
- describe("EmailAdapter — Phase 5.5 PDF extraction integration", () => {
293
- it("decrypts bill PDF + extracts transactions into fields.transactions", async () => {
294
- const factory = makeSession([billEnv()]);
295
- const a = new EmailAdapter({
296
- account: { provider: "qq", email: "u@qq.com", authCode: "x", folders: ["INBOX"] },
297
- sessionFactory: factory,
298
- // Force-create a "decrypted" PDF attachment via a custom parser
299
- parser: async () => ({
300
- textBody: "尾号 1234 应还金额 ¥3,000",
301
- attachments: [{
302
- filename: "statement.pdf",
303
- contentType: "application/pdf",
304
- contentDisposition: "attachment",
305
- size: 12345,
306
- sha256: "abc",
307
- isInline: false,
308
- isEncrypted: true,
309
- buffer: Buffer.from("FAKE PDF BYTES"),
310
- }],
311
- }),
312
- pdfExtractor: async (buf, _opts) => ({
313
- decrypted: true,
314
- text: PDF_TEXT,
315
- password: "987654",
316
- attempted: 2,
317
- wasEncrypted: true,
318
- pageCount: 2,
319
- }),
320
- pdfPasswords: ["987654"],
321
- });
322
-
323
- const raws = [];
324
- for await (const r of a.sync()) raws.push(r);
325
- expect(raws).toHaveLength(1);
326
- const ext = raws[0].payload.extraction;
327
- expect(ext.template).toBe("bill");
328
- expect(ext.fields.transactions).toBeDefined();
329
- expect(ext.fields.transactions.length).toBe(3);
330
- expect(ext.fields.transactions[0].amount.value).toBe(39);
331
- expect(ext.fields.transactions[0].amount.direction).toBe("out");
332
- expect(ext.fields.transactions[2].amount.direction).toBe("in");
333
- expect(ext.pdfExtraction).toBeDefined();
334
- expect(ext.pdfExtraction[0].decrypted).toBe(true);
335
- expect(ext.pdfExtraction[0].transactionsExtracted).toBe(3);
336
- // Real password value must NEVER be persisted (only masked)
337
- expect(ext.pdfExtraction[0].passwordUsed).toBe("***");
338
- expect(JSON.stringify(ext.pdfExtraction)).not.toMatch(/987654/);
339
- });
340
-
341
- it("normalize copies transactions into extra.fields.transactions", async () => {
342
- const factory = makeSession([billEnv()]);
343
- const a = new EmailAdapter({
344
- account: { provider: "qq", email: "u@qq.com", authCode: "x", folders: ["INBOX"] },
345
- sessionFactory: factory,
346
- parser: async () => ({
347
- textBody: "尾号 1234",
348
- attachments: [{
349
- filename: "stmt.pdf",
350
- contentType: "application/pdf",
351
- size: 100,
352
- sha256: "x",
353
- buffer: Buffer.from("FAKE"),
354
- }],
355
- }),
356
- pdfExtractor: async () => ({
357
- decrypted: true,
358
- text: "2026-05-01 星巴克 -39.00 1000.00",
359
- attempted: 1,
360
- wasEncrypted: true,
361
- pageCount: 1,
362
- }),
363
- });
364
- const raws = [];
365
- for await (const r of a.sync()) raws.push(r);
366
- const batch = a.normalize(raws[0]);
367
- expect(batch.events).toHaveLength(1);
368
- expect(batch.events[0].extra.fields.transactions).toBeDefined();
369
- expect(batch.events[0].extra.fields.transactions).toHaveLength(1);
370
- expect(batch.events[0].extra.pdfExtraction).toBeDefined();
371
- });
372
-
373
- it("decrypt failure: pdfExtraction.error populated; no transactions", async () => {
374
- const factory = makeSession([billEnv()]);
375
- const a = new EmailAdapter({
376
- account: { provider: "qq", email: "u@qq.com", authCode: "x", folders: ["INBOX"] },
377
- sessionFactory: factory,
378
- parser: async () => ({
379
- textBody: "stmt",
380
- attachments: [{
381
- filename: "x.pdf",
382
- contentType: "application/pdf",
383
- size: 100,
384
- sha256: "h",
385
- buffer: Buffer.from("F"),
386
- }],
387
- }),
388
- pdfExtractor: async () => ({
389
- decrypted: false,
390
- text: "",
391
- attempted: 5,
392
- wasEncrypted: true,
393
- pageCount: 0,
394
- error: "all passwords failed",
395
- }),
396
- });
397
- const raws = [];
398
- for await (const r of a.sync()) raws.push(r);
399
- const ext = raws[0].payload.extraction;
400
- expect(ext.fields.transactions).toBeUndefined();
401
- expect(ext.pdfExtraction[0].decrypted).toBe(false);
402
- expect(ext.pdfExtraction[0].error).toContain("all passwords failed");
403
- });
404
-
405
- it("disablePdfExtraction skips the decryption pass", async () => {
406
- const factory = makeSession([billEnv()]);
407
- let extractorCalled = false;
408
- const a = new EmailAdapter({
409
- account: { provider: "qq", email: "u@qq.com", authCode: "x", folders: ["INBOX"] },
410
- sessionFactory: factory,
411
- parser: async () => ({
412
- textBody: "stmt",
413
- attachments: [{
414
- filename: "x.pdf",
415
- contentType: "application/pdf",
416
- size: 100,
417
- sha256: "h",
418
- buffer: Buffer.from("F"),
419
- }],
420
- }),
421
- pdfExtractor: async () => {
422
- extractorCalled = true;
423
- return { decrypted: true, text: "txt", attempted: 1, wasEncrypted: false, pageCount: 1 };
424
- },
425
- disablePdfExtraction: true,
426
- });
427
- const raws = [];
428
- for await (const r of a.sync()) raws.push(r);
429
- expect(extractorCalled).toBe(false);
430
- expect(raws[0].payload.extraction.pdfExtraction).toBeUndefined();
431
- });
432
-
433
- it("non-bill email does NOT trigger PDF extraction even when PDF attached", async () => {
434
- const factory = makeSession([{
435
- ...billEnv(),
436
- subject: "Welcome",
437
- from: [{ address: "noreply@example.com" }],
438
- }]);
439
- let extractorCalled = false;
440
- const a = new EmailAdapter({
441
- account: { provider: "qq", email: "u@qq.com", authCode: "x", folders: ["INBOX"] },
442
- sessionFactory: factory,
443
- parser: async () => ({
444
- textBody: "Welcome to our service!",
445
- attachments: [{
446
- filename: "brochure.pdf",
447
- contentType: "application/pdf",
448
- size: 100,
449
- sha256: "b",
450
- buffer: Buffer.from("F"),
451
- }],
452
- }),
453
- pdfExtractor: async () => {
454
- extractorCalled = true;
455
- return { decrypted: true, text: "", attempted: 1, wasEncrypted: false, pageCount: 1 };
456
- },
457
- });
458
- const raws = [];
459
- for await (const r of a.sync()) raws.push(r);
460
- expect(extractorCalled).toBe(false);
461
- });
462
-
463
- it("attachment buffers are STRIPPED from the emitted RawEvent payload", async () => {
464
- const factory = makeSession([billEnv()]);
465
- const a = new EmailAdapter({
466
- account: { provider: "qq", email: "u@qq.com", authCode: "x", folders: ["INBOX"] },
467
- sessionFactory: factory,
468
- parser: async () => ({
469
- textBody: "stmt",
470
- attachments: [{
471
- filename: "x.pdf",
472
- contentType: "application/pdf",
473
- size: 100,
474
- sha256: "h",
475
- buffer: Buffer.from("SECRET PDF BYTES"),
476
- }],
477
- }),
478
- pdfExtractor: async () => ({ decrypted: true, text: "", attempted: 1, wasEncrypted: false, pageCount: 1 }),
479
- });
480
- const raws = [];
481
- for await (const r of a.sync()) raws.push(r);
482
- // The buffer must not survive into the serialized payload
483
- const serialized = JSON.stringify(raws[0]);
484
- expect(serialized).not.toMatch(/SECRET PDF BYTES/);
485
- // Attachment metadata still present
486
- expect(raws[0].payload.parsedBody.attachments[0].filename).toBe("x.pdf");
487
- expect(raws[0].payload.parsedBody.attachments[0].buffer).toBeUndefined();
488
- });
489
-
490
- it("capability flag: decrypt:pdf-bills present by default, absent when disabled", () => {
491
- const a = new EmailAdapter({
492
- account: { provider: "qq", email: "u@qq.com", authCode: "x" },
493
- sessionFactory: makeSession([]),
494
- });
495
- expect(a.capabilities).toContain("decrypt:pdf-bills");
496
-
497
- const b = new EmailAdapter({
498
- account: { provider: "qq", email: "u@qq.com", authCode: "x" },
499
- sessionFactory: makeSession([]),
500
- disablePdfExtraction: true,
501
- });
502
- expect(b.capabilities).not.toContain("decrypt:pdf-bills");
503
- });
504
-
505
- it("pdfPasswordHints + pdfPasswords are merged + deduped", () => {
506
- const a = new EmailAdapter({
507
- account: { provider: "qq", email: "u@qq.com", authCode: "x" },
508
- sessionFactory: makeSession([]),
509
- pdfPasswords: ["custom1", "custom2"],
510
- pdfPasswordHints: { idCardLast6: "111111", phoneLast6: "custom1" }, // dup
511
- });
512
- // Internal field — surface via the password list passed to pdfExtractor
513
- let receivedPasswords = null;
514
- a._pdfExtractor = async (_buf, opts) => {
515
- receivedPasswords = opts.passwords;
516
- return { decrypted: false, attempted: 0, text: "", wasEncrypted: false, pageCount: 0 };
517
- };
518
- // No need to run sync; verify the internal list directly:
519
- expect(a._pdfPasswords).toEqual(["custom1", "custom2", "111111"]);
520
- });
521
-
522
- it("version reflects 0.6.0", () => {
523
- const a = new EmailAdapter({
524
- account: { provider: "qq", email: "u@qq.com", authCode: "x" },
525
- sessionFactory: makeSession([]),
526
- });
527
- expect(a.version).toBe("0.7.0");
528
- });
529
- });
@@ -1,84 +0,0 @@
1
- "use strict";
2
-
3
- import { describe, it, expect } from "vitest";
4
-
5
- const {
6
- PROVIDERS,
7
- resolveProvider,
8
- } = require("../../lib/adapters/email-imap/providers");
9
-
10
- describe("EMAIL_PROVIDERS preset table", () => {
11
- it("exposes qq / 189 / 163 / outlook / gmail", () => {
12
- expect(PROVIDERS.qq.host).toBe("imap.qq.com");
13
- expect(PROVIDERS["189"].host).toBe("imap.189.cn");
14
- expect(PROVIDERS["163"].host).toBe("imap.163.com");
15
- expect(PROVIDERS.outlook.host).toBe("outlook.office365.com");
16
- expect(PROVIDERS.gmail.host).toBe("imap.gmail.com");
17
- });
18
-
19
- it("every preset uses port 993 + TLS by default", () => {
20
- for (const p of Object.values(PROVIDERS)) {
21
- expect(p.port).toBe(993);
22
- expect(p.secure).toBe(true);
23
- }
24
- });
25
-
26
- it("each preset advertises an authNote pointing at authorization-code, not password", () => {
27
- for (const p of Object.values(PROVIDERS)) {
28
- expect(p.authNote.length).toBeGreaterThan(10);
29
- }
30
- expect(PROVIDERS.qq.authNote).toMatch(/授权码|auth.*code|app password/i);
31
- });
32
- });
33
-
34
- describe("resolveProvider", () => {
35
- it("returns preset config when no overrides", () => {
36
- const r = resolveProvider({ provider: "qq" });
37
- expect(r.host).toBe("imap.qq.com");
38
- expect(r.port).toBe(993);
39
- expect(r.secure).toBe(true);
40
- expect(r.folders).toEqual(["INBOX", "Sent Messages"]);
41
- expect(r.providerId).toBe("qq");
42
- });
43
-
44
- it("respects user overrides", () => {
45
- const r = resolveProvider({
46
- provider: "qq",
47
- host: "imap.example.com",
48
- port: 143,
49
- secure: false,
50
- folders: ["INBOX", "Drafts", "Custom"],
51
- displayName: "My Mailbox",
52
- });
53
- expect(r.host).toBe("imap.example.com");
54
- expect(r.port).toBe(143);
55
- expect(r.secure).toBe(false);
56
- expect(r.folders).toEqual(["INBOX", "Drafts", "Custom"]);
57
- expect(r.displayName).toBe("My Mailbox");
58
- });
59
-
60
- it("rejects unknown provider", () => {
61
- expect(() => resolveProvider({ provider: "myproto" })).toThrow(/unknown provider/i);
62
- });
63
-
64
- it("custom provider requires host", () => {
65
- expect(() => resolveProvider({ provider: "custom" })).toThrow(/host/);
66
- });
67
-
68
- it("custom provider applies sensible defaults", () => {
69
- const r = resolveProvider({
70
- provider: "custom",
71
- host: "mail.acme.test",
72
- });
73
- expect(r.host).toBe("mail.acme.test");
74
- expect(r.port).toBe(993);
75
- expect(r.secure).toBe(true);
76
- expect(r.folders).toEqual(["INBOX"]);
77
- expect(r.providerId).toBe("custom");
78
- });
79
-
80
- it("rejects null / wrong-type account", () => {
81
- expect(() => resolveProvider()).toThrow();
82
- expect(() => resolveProvider(null)).toThrow();
83
- });
84
- });