@chainlesschain/personal-data-hub 0.1.0 → 0.2.0

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 (116) hide show
  1. package/__tests__/adapters/ai-chat-history.test.js +395 -0
  2. package/__tests__/adapters/ai-chat-http-client.test.js +242 -0
  3. package/__tests__/adapters/ai-chat-vendors.test.js +733 -0
  4. package/__tests__/adapters/alipay-bill-adapter.test.js +538 -0
  5. package/__tests__/adapters/email-adapter.test.js +138 -1
  6. package/__tests__/adapters/email-classifier.test.js +347 -0
  7. package/__tests__/adapters/email-pdf-extractor.test.js +529 -0
  8. package/__tests__/adapters/email-retry-progress.test.js +294 -0
  9. package/__tests__/adapters/email-templates.test.js +699 -0
  10. package/__tests__/adapters/system-data-adapter.test.js +440 -0
  11. package/__tests__/adapters/system-data-disclosure.test.js +153 -0
  12. package/__tests__/analysis-skills.test.js +409 -0
  13. package/__tests__/entity-resolver-ingest-hook.test.js +177 -0
  14. package/__tests__/entity-resolver-stages.test.js +411 -0
  15. package/__tests__/entity-resolver-vault.test.js +246 -0
  16. package/__tests__/entity-resolver.test.js +526 -0
  17. package/__tests__/fixtures/entity-resolver-200-mock.json +96 -0
  18. package/__tests__/longtail-adapters.test.js +217 -0
  19. package/__tests__/mobile-extractor.test.js +288 -0
  20. package/__tests__/shopping-adapters.test.js +296 -0
  21. package/__tests__/sidecar-contacts-cross-validate.test.js +163 -0
  22. package/__tests__/sidecar-supervisor.test.js +120 -0
  23. package/__tests__/social-adapters.test.js +206 -0
  24. package/__tests__/travel-adapters.test.js +325 -0
  25. package/__tests__/vault.test.js +3 -3
  26. package/__tests__/wechat-adapter.test.js +476 -0
  27. package/__tests__/whatsapp-adapter.test.js +135 -0
  28. package/lib/adapter-spec.js +12 -0
  29. package/lib/adapters/_python-sidecar-base.js +207 -0
  30. package/lib/adapters/ai-chat-history/ai-chat-adapter.js +335 -0
  31. package/lib/adapters/ai-chat-history/cookie-auth.js +109 -0
  32. package/lib/adapters/ai-chat-history/http-client.js +211 -0
  33. package/lib/adapters/ai-chat-history/index.js +28 -0
  34. package/lib/adapters/ai-chat-history/schema-map.js +221 -0
  35. package/lib/adapters/ai-chat-history/vendor-spec.js +85 -0
  36. package/lib/adapters/ai-chat-history/vendors/coze.js +179 -0
  37. package/lib/adapters/ai-chat-history/vendors/deepseek.js +199 -0
  38. package/lib/adapters/ai-chat-history/vendors/dreamina.js +174 -0
  39. package/lib/adapters/ai-chat-history/vendors/hunyuan.js +176 -0
  40. package/lib/adapters/ai-chat-history/vendors/kimi.js +182 -0
  41. package/lib/adapters/ai-chat-history/vendors/qianfan.js +160 -0
  42. package/lib/adapters/ai-chat-history/vendors/tongyi.js +193 -0
  43. package/lib/adapters/ai-chat-history/vendors/zhipu.js +202 -0
  44. package/lib/adapters/alipay-bill/alipay-bill-adapter.js +307 -0
  45. package/lib/adapters/alipay-bill/counterparty.js +129 -0
  46. package/lib/adapters/alipay-bill/csv-parser.js +217 -0
  47. package/lib/adapters/alipay-bill/index.js +41 -0
  48. package/lib/adapters/alipay-bill/zip-decryptor.js +111 -0
  49. package/lib/adapters/email-imap/classifier.js +495 -0
  50. package/lib/adapters/email-imap/email-adapter.js +419 -8
  51. package/lib/adapters/email-imap/index.js +42 -0
  52. package/lib/adapters/email-imap/pdf-extractor.js +192 -0
  53. package/lib/adapters/email-imap/templates/bill.js +232 -0
  54. package/lib/adapters/email-imap/templates/government.js +120 -0
  55. package/lib/adapters/email-imap/templates/index.js +78 -0
  56. package/lib/adapters/email-imap/templates/order.js +186 -0
  57. package/lib/adapters/email-imap/templates/other.js +114 -0
  58. package/lib/adapters/email-imap/templates/register.js +113 -0
  59. package/lib/adapters/email-imap/templates/travel.js +157 -0
  60. package/lib/adapters/email-imap/templates/utils.js +275 -0
  61. package/lib/adapters/email-imap/transactions.js +234 -0
  62. package/lib/adapters/messaging-qq/index.js +158 -0
  63. package/lib/adapters/messaging-telegram/index.js +142 -0
  64. package/lib/adapters/messaging-whatsapp/index.js +189 -0
  65. package/lib/adapters/shopping-base/index.js +208 -0
  66. package/lib/adapters/shopping-jd/index.js +150 -0
  67. package/lib/adapters/shopping-meituan/index.js +154 -0
  68. package/lib/adapters/shopping-taobao/index.js +176 -0
  69. package/lib/adapters/social-bilibili/index.js +171 -0
  70. package/lib/adapters/social-douyin/index.js +116 -0
  71. package/lib/adapters/social-weibo/index.js +164 -0
  72. package/lib/adapters/social-xiaohongshu/index.js +96 -0
  73. package/lib/adapters/system-data/disclosure.js +166 -0
  74. package/lib/adapters/system-data/index.js +34 -0
  75. package/lib/adapters/system-data/system-data-adapter.js +344 -0
  76. package/lib/adapters/travel-12306/index.js +151 -0
  77. package/lib/adapters/travel-amap/index.js +164 -0
  78. package/lib/adapters/travel-baidu-map/index.js +162 -0
  79. package/lib/adapters/travel-base/index.js +240 -0
  80. package/lib/adapters/travel-ctrip/index.js +151 -0
  81. package/lib/adapters/wechat/content-parser.js +326 -0
  82. package/lib/adapters/wechat/db-reader.js +209 -0
  83. package/lib/adapters/wechat/index.js +28 -0
  84. package/lib/adapters/wechat/key-extractor.js +158 -0
  85. package/lib/adapters/wechat/normalize.js +220 -0
  86. package/lib/adapters/wechat/wechat-adapter.js +205 -0
  87. package/lib/analysis-skills/base.js +113 -0
  88. package/lib/analysis-skills/footprint.js +167 -0
  89. package/lib/analysis-skills/index.js +58 -0
  90. package/lib/analysis-skills/interests.js +161 -0
  91. package/lib/analysis-skills/relations.js +226 -0
  92. package/lib/analysis-skills/spending.js +216 -0
  93. package/lib/analysis-skills/timeline.js +167 -0
  94. package/lib/entity-resolver/embedding-stage.js +198 -0
  95. package/lib/entity-resolver/entity-resolver.js +384 -0
  96. package/lib/entity-resolver/index.js +42 -0
  97. package/lib/entity-resolver/llm-stage.js +191 -0
  98. package/lib/entity-resolver/rule-stage.js +208 -0
  99. package/lib/entity-resolver/worker.js +149 -0
  100. package/lib/index.js +115 -0
  101. package/lib/migrations.js +73 -0
  102. package/lib/mobile-extractor/android.js +193 -0
  103. package/lib/mobile-extractor/index.js +9 -0
  104. package/lib/mobile-extractor/ios.js +223 -0
  105. package/lib/registry.js +42 -0
  106. package/lib/sidecar/index.js +15 -0
  107. package/lib/sidecar/supervisor.js +359 -0
  108. package/lib/vault.js +266 -0
  109. package/package.json +29 -3
  110. package/scripts/_make-fixture-all.js +126 -0
  111. package/scripts/_make-fixture-contacts.js +84 -0
  112. package/scripts/evaluate-entity-resolver.js +213 -0
  113. package/scripts/smoke-phase-5-5.js +196 -0
  114. package/scripts/smoke-phase-5-7.js +181 -0
  115. package/scripts/smoke-system-data-contacts.js +309 -0
  116. package/scripts/smoke-system-data.js +312 -0
@@ -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
+ });
@@ -0,0 +1,163 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Cross-validate that Persons produced by the Python sidecar's
5
+ * `system.parse_contacts` method pass the hub-side UnifiedSchema validator.
6
+ *
7
+ * Without this test, sidecar and hub can drift silently: sidecar emits
8
+ * fields the schema rejects, or skips required ones. Run against a real
9
+ * sidecar subprocess + synthesized contacts2.db.
10
+ */
11
+
12
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
13
+ import path from "node:path";
14
+ import fs from "node:fs";
15
+ import os from "node:os";
16
+ import { spawnSync } from "node:child_process";
17
+
18
+ const { SidecarSupervisor } = require("../lib/sidecar");
19
+ const { validatePerson } = require("../lib/schemas");
20
+ const Database = require("better-sqlite3-multiple-ciphers");
21
+
22
+ const SIDECAR_ROOT = path.resolve(
23
+ __dirname,
24
+ "..",
25
+ "..",
26
+ "personal-data-hub-bridge",
27
+ );
28
+
29
+ let pythonAvailable = true;
30
+ try {
31
+ const probe = spawnSync(
32
+ process.env.FORENSICS_BRIDGE_PYTHON || "python",
33
+ ["--version"],
34
+ { stdio: "ignore" },
35
+ );
36
+ if (probe.status !== 0) pythonAvailable = false;
37
+ } catch (_err) {
38
+ pythonAvailable = false;
39
+ }
40
+
41
+ const describePy = pythonAvailable ? describe : describe.skip;
42
+
43
+ function seedFixtureContactsDb(dbPath) {
44
+ const db = new Database(dbPath);
45
+ try {
46
+ db.exec(`
47
+ CREATE TABLE raw_contacts (
48
+ _id INTEGER PRIMARY KEY,
49
+ display_name TEXT,
50
+ starred INTEGER DEFAULT 0,
51
+ deleted INTEGER DEFAULT 0
52
+ );
53
+ CREATE TABLE mimetypes (
54
+ _id INTEGER PRIMARY KEY,
55
+ mimetype TEXT NOT NULL UNIQUE
56
+ );
57
+ CREATE TABLE data (
58
+ _id INTEGER PRIMARY KEY,
59
+ raw_contact_id INTEGER NOT NULL,
60
+ mimetype_id INTEGER NOT NULL,
61
+ data1 TEXT
62
+ );
63
+ `);
64
+ const mimetypes = {
65
+ "vnd.android.cursor.item/phone_v2": 5,
66
+ "vnd.android.cursor.item/email_v2": 1,
67
+ "vnd.android.cursor.item/organization": 4,
68
+ "vnd.android.cursor.item/note": 10,
69
+ };
70
+ const insertMime = db.prepare(
71
+ "INSERT INTO mimetypes (_id, mimetype) VALUES (?, ?)",
72
+ );
73
+ for (const [mt, mid] of Object.entries(mimetypes)) insertMime.run(mid, mt);
74
+
75
+ const insertContact = db.prepare(
76
+ "INSERT INTO raw_contacts (_id, display_name, starred, deleted) VALUES (?, ?, ?, 0)",
77
+ );
78
+ insertContact.run(1, "妈妈", 1);
79
+ insertContact.run(2, "张三", 0);
80
+
81
+ const insertData = db.prepare(
82
+ "INSERT INTO data (raw_contact_id, mimetype_id, data1) VALUES (?, ?, ?)",
83
+ );
84
+ insertData.run(1, mimetypes["vnd.android.cursor.item/phone_v2"], "13800001111");
85
+ insertData.run(1, mimetypes["vnd.android.cursor.item/phone_v2"], "13900002222");
86
+ insertData.run(1, mimetypes["vnd.android.cursor.item/email_v2"], "mom@example.com");
87
+ insertData.run(2, mimetypes["vnd.android.cursor.item/phone_v2"], "13711112222");
88
+ } finally {
89
+ db.close();
90
+ }
91
+ }
92
+
93
+ describePy("sidecar.system.parse_contacts × hub.validatePerson", () => {
94
+ let supervisor;
95
+ let tmpDir;
96
+ let dbPath;
97
+
98
+ beforeAll(async () => {
99
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "phdb-sidecar-contacts-"));
100
+ dbPath = path.join(tmpDir, "contacts2.db");
101
+ seedFixtureContactsDb(dbPath);
102
+
103
+ supervisor = new SidecarSupervisor({
104
+ command: process.env.FORENSICS_BRIDGE_PYTHON || "python",
105
+ args: ["-u", "-m", "forensics_bridge.ipc_server"],
106
+ cwd: SIDECAR_ROOT,
107
+ healthCheckIntervalMs: 0,
108
+ env: { PYTHONPATH: SIDECAR_ROOT },
109
+ });
110
+ await supervisor.start({ readyTimeoutMs: 8_000 });
111
+ }, 15_000);
112
+
113
+ afterAll(async () => {
114
+ if (supervisor) await supervisor.stop({ graceMs: 1500 });
115
+ if (tmpDir) {
116
+ try {
117
+ fs.rmSync(tmpDir, { recursive: true, force: true });
118
+ } catch (_err) {
119
+ /* best effort */
120
+ }
121
+ }
122
+ });
123
+
124
+ it("emits Persons that pass hub UnifiedSchema validation", async () => {
125
+ const persons = [];
126
+ const result = await supervisor.invoke(
127
+ "system.parse_contacts",
128
+ { data_path: dbPath, device_serial: "24115RA8EC-test" },
129
+ {
130
+ timeoutMs: 10_000,
131
+ onChunk: (batch) => {
132
+ for (const p of batch.persons || []) persons.push(p);
133
+ },
134
+ },
135
+ );
136
+
137
+ expect(result.status).toBe("ok");
138
+ expect(result.totalPersons).toBe(2);
139
+ expect(persons).toHaveLength(2);
140
+
141
+ for (const person of persons) {
142
+ const validation = validatePerson(person);
143
+ if (!validation.valid) {
144
+ // Dump the offender so CI failures are debuggable without re-running.
145
+ console.error(
146
+ "validatePerson failed for",
147
+ JSON.stringify(person, null, 2),
148
+ "errors:",
149
+ validation.errors,
150
+ );
151
+ }
152
+ expect(validation.valid).toBe(true);
153
+ expect(validation.errors).toEqual([]);
154
+ }
155
+
156
+ // Spot-check shape — mom has phones+email+starred+notes? (notes none in this fixture)
157
+ const mom = persons.find((p) => p.names[0] === "妈妈");
158
+ expect(mom.identifiers.phone).toEqual(["13800001111", "13900002222"]);
159
+ expect(mom.identifiers.email).toEqual(["mom@example.com"]);
160
+ expect(mom.extra.starred).toBe(true);
161
+ expect(mom.extra.deviceSerial).toBe("24115RA8EC-test");
162
+ });
163
+ });
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
4
+ import path from "node:path";
5
+
6
+ const {
7
+ SidecarSupervisor,
8
+ SidecarTimeoutError,
9
+ SidecarMethodError,
10
+ SidecarNotRunningError,
11
+ } = require("../lib/sidecar");
12
+
13
+ const SIDECAR_ROOT = path.resolve(
14
+ __dirname,
15
+ "..",
16
+ "..",
17
+ "personal-data-hub-bridge",
18
+ );
19
+
20
+ /**
21
+ * Spawn the forensics-bridge sidecar from this repo's sibling package.
22
+ * Tests are skipped if Python is unavailable on PATH (CI matrix coverage
23
+ * is the source of truth; local dev without Python should not be blocked).
24
+ */
25
+ function makeSupervisor() {
26
+ return new SidecarSupervisor({
27
+ command: process.env.FORENSICS_BRIDGE_PYTHON || "python",
28
+ args: ["-u", "-m", "forensics_bridge.ipc_server"],
29
+ cwd: SIDECAR_ROOT,
30
+ healthCheckIntervalMs: 0, // disable for tests — manual control only
31
+ env: { PYTHONPATH: SIDECAR_ROOT },
32
+ });
33
+ }
34
+
35
+ let pythonAvailable = true;
36
+ try {
37
+ // Cheap synchronous probe — spawn fails if python is not on PATH.
38
+ const { spawnSync } = require("node:child_process");
39
+ const probe = spawnSync(
40
+ process.env.FORENSICS_BRIDGE_PYTHON || "python",
41
+ ["--version"],
42
+ { stdio: "ignore" },
43
+ );
44
+ if (probe.status !== 0) pythonAvailable = false;
45
+ } catch (_err) {
46
+ pythonAvailable = false;
47
+ }
48
+
49
+ const itPy = pythonAvailable ? it : it.skip;
50
+
51
+ describe("SidecarSupervisor (forensics-bridge integration)", () => {
52
+ let supervisor;
53
+
54
+ beforeEach(() => {
55
+ supervisor = makeSupervisor();
56
+ });
57
+
58
+ afterEach(async () => {
59
+ if (supervisor) await supervisor.stop({ graceMs: 1500 });
60
+ });
61
+
62
+ itPy("starts the sidecar and round-trips sidecar.ping", async () => {
63
+ await supervisor.start({ readyTimeoutMs: 8_000 });
64
+ expect(supervisor.isRunning()).toBe(true);
65
+
66
+ const ping = await supervisor.invoke("sidecar.ping", {}, { timeoutMs: 3_000 });
67
+ expect(ping.version).toMatch(/^\d+\.\d+\.\d+/);
68
+ expect(ping.pythonVersion).toMatch(/^3\./);
69
+ });
70
+
71
+ itPy("sidecar.capabilities exposes registered methods", async () => {
72
+ await supervisor.start({ readyTimeoutMs: 8_000 });
73
+
74
+ const caps = await supervisor.invoke("sidecar.capabilities");
75
+ expect(caps.methods).toContain("sidecar.ping");
76
+ expect(caps.methods).toContain("sidecar.capabilities");
77
+ // Namespace registry grows as parsers/extractors land; Phase 4.5.2 brings
78
+ // the system parser online.
79
+ expect(caps.parsers).toContain("system");
80
+ expect(caps.extractors).toContain("android");
81
+ });
82
+
83
+ itPy("METHOD_NOT_FOUND surfaces as a typed SidecarMethodError", async () => {
84
+ await supervisor.start({ readyTimeoutMs: 8_000 });
85
+
86
+ await expect(
87
+ supervisor.invoke("definitely.not.a.real.method"),
88
+ ).rejects.toMatchObject({
89
+ name: "SidecarMethodError",
90
+ code: "METHOD_NOT_FOUND",
91
+ retryable: false,
92
+ });
93
+ });
94
+
95
+ itPy("invoke rejects with SidecarNotRunningError when sidecar is stopped", async () => {
96
+ await supervisor.start({ readyTimeoutMs: 8_000 });
97
+ await supervisor.stop({ graceMs: 1500 });
98
+ expect(supervisor.isRunning()).toBe(false);
99
+
100
+ await expect(supervisor.invoke("sidecar.ping")).rejects.toMatchObject({
101
+ name: "SidecarNotRunningError",
102
+ code: "SIDECAR_NOT_RUNNING",
103
+ });
104
+ });
105
+
106
+ itPy("two sequential invokes share one sidecar process", async () => {
107
+ await supervisor.start({ readyTimeoutMs: 8_000 });
108
+ const first = await supervisor.invoke("sidecar.ping");
109
+ const second = await supervisor.invoke("sidecar.capabilities");
110
+ expect(first.version).toBeDefined();
111
+ expect(second.methods).toContain("sidecar.ping");
112
+ });
113
+
114
+ itPy("error class hierarchy is exported", () => {
115
+ expect(typeof SidecarSupervisor).toBe("function");
116
+ expect(typeof SidecarTimeoutError).toBe("function");
117
+ expect(typeof SidecarMethodError).toBe("function");
118
+ expect(typeof SidecarNotRunningError).toBe("function");
119
+ });
120
+ });