@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.
- package/__tests__/adapters/ai-chat-history.test.js +395 -0
- package/__tests__/adapters/ai-chat-http-client.test.js +242 -0
- package/__tests__/adapters/ai-chat-vendors.test.js +733 -0
- package/__tests__/adapters/alipay-bill-adapter.test.js +538 -0
- package/__tests__/adapters/email-adapter.test.js +138 -1
- package/__tests__/adapters/email-classifier.test.js +347 -0
- package/__tests__/adapters/email-pdf-extractor.test.js +529 -0
- package/__tests__/adapters/email-retry-progress.test.js +294 -0
- package/__tests__/adapters/email-templates.test.js +699 -0
- package/__tests__/adapters/system-data-adapter.test.js +440 -0
- package/__tests__/adapters/system-data-disclosure.test.js +153 -0
- package/__tests__/analysis-skills.test.js +409 -0
- package/__tests__/entity-resolver-ingest-hook.test.js +177 -0
- package/__tests__/entity-resolver-stages.test.js +411 -0
- package/__tests__/entity-resolver-vault.test.js +246 -0
- package/__tests__/entity-resolver.test.js +526 -0
- package/__tests__/fixtures/entity-resolver-200-mock.json +96 -0
- package/__tests__/longtail-adapters.test.js +217 -0
- package/__tests__/mobile-extractor.test.js +288 -0
- package/__tests__/shopping-adapters.test.js +296 -0
- package/__tests__/sidecar-contacts-cross-validate.test.js +163 -0
- package/__tests__/sidecar-supervisor.test.js +120 -0
- package/__tests__/social-adapters.test.js +206 -0
- package/__tests__/travel-adapters.test.js +325 -0
- package/__tests__/vault.test.js +3 -3
- package/__tests__/wechat-adapter.test.js +476 -0
- package/__tests__/whatsapp-adapter.test.js +135 -0
- package/lib/adapter-spec.js +12 -0
- package/lib/adapters/_python-sidecar-base.js +207 -0
- package/lib/adapters/ai-chat-history/ai-chat-adapter.js +335 -0
- package/lib/adapters/ai-chat-history/cookie-auth.js +109 -0
- package/lib/adapters/ai-chat-history/http-client.js +211 -0
- package/lib/adapters/ai-chat-history/index.js +28 -0
- package/lib/adapters/ai-chat-history/schema-map.js +221 -0
- package/lib/adapters/ai-chat-history/vendor-spec.js +85 -0
- package/lib/adapters/ai-chat-history/vendors/coze.js +179 -0
- package/lib/adapters/ai-chat-history/vendors/deepseek.js +199 -0
- package/lib/adapters/ai-chat-history/vendors/dreamina.js +174 -0
- package/lib/adapters/ai-chat-history/vendors/hunyuan.js +176 -0
- package/lib/adapters/ai-chat-history/vendors/kimi.js +182 -0
- package/lib/adapters/ai-chat-history/vendors/qianfan.js +160 -0
- package/lib/adapters/ai-chat-history/vendors/tongyi.js +193 -0
- package/lib/adapters/ai-chat-history/vendors/zhipu.js +202 -0
- package/lib/adapters/alipay-bill/alipay-bill-adapter.js +307 -0
- package/lib/adapters/alipay-bill/counterparty.js +129 -0
- package/lib/adapters/alipay-bill/csv-parser.js +217 -0
- package/lib/adapters/alipay-bill/index.js +41 -0
- package/lib/adapters/alipay-bill/zip-decryptor.js +111 -0
- package/lib/adapters/email-imap/classifier.js +495 -0
- package/lib/adapters/email-imap/email-adapter.js +419 -8
- package/lib/adapters/email-imap/index.js +42 -0
- package/lib/adapters/email-imap/pdf-extractor.js +192 -0
- package/lib/adapters/email-imap/templates/bill.js +232 -0
- package/lib/adapters/email-imap/templates/government.js +120 -0
- package/lib/adapters/email-imap/templates/index.js +78 -0
- package/lib/adapters/email-imap/templates/order.js +186 -0
- package/lib/adapters/email-imap/templates/other.js +114 -0
- package/lib/adapters/email-imap/templates/register.js +113 -0
- package/lib/adapters/email-imap/templates/travel.js +157 -0
- package/lib/adapters/email-imap/templates/utils.js +275 -0
- package/lib/adapters/email-imap/transactions.js +234 -0
- package/lib/adapters/messaging-qq/index.js +158 -0
- package/lib/adapters/messaging-telegram/index.js +142 -0
- package/lib/adapters/messaging-whatsapp/index.js +189 -0
- package/lib/adapters/shopping-base/index.js +208 -0
- package/lib/adapters/shopping-jd/index.js +150 -0
- package/lib/adapters/shopping-meituan/index.js +154 -0
- package/lib/adapters/shopping-taobao/index.js +176 -0
- package/lib/adapters/social-bilibili/index.js +171 -0
- package/lib/adapters/social-douyin/index.js +116 -0
- package/lib/adapters/social-weibo/index.js +164 -0
- package/lib/adapters/social-xiaohongshu/index.js +96 -0
- package/lib/adapters/system-data/disclosure.js +166 -0
- package/lib/adapters/system-data/index.js +34 -0
- package/lib/adapters/system-data/system-data-adapter.js +344 -0
- package/lib/adapters/travel-12306/index.js +151 -0
- package/lib/adapters/travel-amap/index.js +164 -0
- package/lib/adapters/travel-baidu-map/index.js +162 -0
- package/lib/adapters/travel-base/index.js +240 -0
- package/lib/adapters/travel-ctrip/index.js +151 -0
- package/lib/adapters/wechat/content-parser.js +326 -0
- package/lib/adapters/wechat/db-reader.js +209 -0
- package/lib/adapters/wechat/index.js +28 -0
- package/lib/adapters/wechat/key-extractor.js +158 -0
- package/lib/adapters/wechat/normalize.js +220 -0
- package/lib/adapters/wechat/wechat-adapter.js +205 -0
- package/lib/analysis-skills/base.js +113 -0
- package/lib/analysis-skills/footprint.js +167 -0
- package/lib/analysis-skills/index.js +58 -0
- package/lib/analysis-skills/interests.js +161 -0
- package/lib/analysis-skills/relations.js +226 -0
- package/lib/analysis-skills/spending.js +216 -0
- package/lib/analysis-skills/timeline.js +167 -0
- package/lib/entity-resolver/embedding-stage.js +198 -0
- package/lib/entity-resolver/entity-resolver.js +384 -0
- package/lib/entity-resolver/index.js +42 -0
- package/lib/entity-resolver/llm-stage.js +191 -0
- package/lib/entity-resolver/rule-stage.js +208 -0
- package/lib/entity-resolver/worker.js +149 -0
- package/lib/index.js +115 -0
- package/lib/migrations.js +73 -0
- package/lib/mobile-extractor/android.js +193 -0
- package/lib/mobile-extractor/index.js +9 -0
- package/lib/mobile-extractor/ios.js +223 -0
- package/lib/registry.js +42 -0
- package/lib/sidecar/index.js +15 -0
- package/lib/sidecar/supervisor.js +359 -0
- package/lib/vault.js +266 -0
- package/package.json +29 -3
- package/scripts/_make-fixture-all.js +126 -0
- package/scripts/_make-fixture-contacts.js +84 -0
- package/scripts/evaluate-entity-resolver.js +213 -0
- package/scripts/smoke-phase-5-5.js +196 -0
- package/scripts/smoke-phase-5-7.js +181 -0
- package/scripts/smoke-system-data-contacts.js +309 -0
- 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
|
+
});
|