@chainlesschain/personal-data-hub 0.2.2 → 0.2.3

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.
@@ -0,0 +1,438 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect, beforeEach } from "vitest";
4
+
5
+ const fs = require("node:fs");
6
+ const path = require("node:path");
7
+ const os = require("node:os");
8
+
9
+ const {
10
+ JdAdapter,
11
+ SNAPSHOT_SCHEMA_VERSION: JD_VERSION,
12
+ } = require("../lib/adapters/shopping-jd");
13
+ const {
14
+ MeituanAdapter,
15
+ SNAPSHOT_SCHEMA_VERSION: MT_VERSION,
16
+ } = require("../lib/adapters/shopping-meituan");
17
+ const { validateBatch } = require("../lib/batch");
18
+
19
+ // §2.4b 购物双联 v0.2 — snapshot-mode tests, mirror of social-weibo-snapshot
20
+ // & travel-maps-snapshot patterns.
21
+ //
22
+ // Snapshot mode is in-APK Android cc reading JSON written by Jd/Meituan
23
+ // LocalCollector (WebView cookie scrape + OkHttp fetch order list). Cookie-
24
+ // mode tests stay in legacy shopping-adapters tests.
25
+
26
+ function writeSnapshot(dir, fileName, snapshot) {
27
+ const p = path.join(dir, fileName);
28
+ fs.writeFileSync(p, JSON.stringify(snapshot), "utf-8");
29
+ return p;
30
+ }
31
+
32
+ describe("JdAdapter snapshot mode", () => {
33
+ let tmpDir;
34
+ beforeEach(() => {
35
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "jd-snap-"));
36
+ });
37
+
38
+ it("exports SNAPSHOT_SCHEMA_VERSION = 1", () => {
39
+ expect(JD_VERSION).toBe(1);
40
+ });
41
+
42
+ it("constructor allows missing account (stateless snapshot mode)", () => {
43
+ expect(() => new JdAdapter()).not.toThrow();
44
+ expect(() => new JdAdapter({})).not.toThrow();
45
+ });
46
+
47
+ it("authenticate(inputPath) ok + mode=snapshot-file", async () => {
48
+ const p = writeSnapshot(tmpDir, "shopping-jd.json", {
49
+ schemaVersion: 1,
50
+ snapshottedAt: Date.now(),
51
+ events: [],
52
+ });
53
+ const a = new JdAdapter();
54
+ const res = await a.authenticate({ inputPath: p });
55
+ expect(res.ok).toBe(true);
56
+ expect(res.mode).toBe("snapshot-file");
57
+ });
58
+
59
+ it("authenticate(inputPath) fails when path unreadable", async () => {
60
+ const a = new JdAdapter();
61
+ const res = await a.authenticate({ inputPath: path.join(tmpDir, "missing.json") });
62
+ expect(res.ok).toBe(false);
63
+ expect(res.reason).toBe("INPUT_PATH_UNREADABLE");
64
+ });
65
+
66
+ it("authenticate() with neither inputPath nor account returns NO_INPUT", async () => {
67
+ const a = new JdAdapter();
68
+ const res = await a.authenticate({});
69
+ expect(res.ok).toBe(false);
70
+ expect(res.reason).toBe("NO_INPUT");
71
+ });
72
+
73
+ it("rejects schemaVersion mismatch", async () => {
74
+ const p = writeSnapshot(tmpDir, "shopping-jd.json", {
75
+ schemaVersion: 99,
76
+ snapshottedAt: Date.now(),
77
+ events: [],
78
+ });
79
+ const a = new JdAdapter();
80
+ let threw = null;
81
+ try {
82
+ for await (const _r of a.sync({ inputPath: p })) { /* drain */ }
83
+ } catch (err) {
84
+ threw = err;
85
+ }
86
+ expect(threw).toBeTruthy();
87
+ expect(String(threw.message)).toMatch(/schemaVersion mismatch/);
88
+ });
89
+
90
+ it("rejects non-JSON snapshot (v0.3 will add HTML parsing)", async () => {
91
+ const p = path.join(tmpDir, "shopping-jd.html");
92
+ fs.writeFileSync(p, "<!DOCTYPE html><html><body>not json</body></html>", "utf-8");
93
+ const a = new JdAdapter();
94
+ let threw = null;
95
+ try {
96
+ for await (const _r of a.sync({ inputPath: p })) { /* drain */ }
97
+ } catch (err) {
98
+ threw = err;
99
+ }
100
+ expect(threw).toBeTruthy();
101
+ expect(String(threw.message)).toMatch(/must be JSON.*v0\.3.*HTML/);
102
+ });
103
+
104
+ it("empty events array yields nothing", async () => {
105
+ const p = writeSnapshot(tmpDir, "shopping-jd.json", {
106
+ schemaVersion: 1,
107
+ snapshottedAt: Date.now(),
108
+ events: [],
109
+ });
110
+ const a = new JdAdapter();
111
+ const raws = [];
112
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
113
+ expect(raws.length).toBe(0);
114
+ });
115
+
116
+ it("order kind round-trip normalize cleanly + namespaced originalId", async () => {
117
+ const now = Date.now();
118
+ const p = writeSnapshot(tmpDir, "shopping-jd.json", {
119
+ schemaVersion: 1,
120
+ snapshottedAt: now,
121
+ vendor: "jd",
122
+ account: { pin: "alice_pin", displayName: "alice" },
123
+ events: [
124
+ {
125
+ kind: "order",
126
+ id: "order-J1",
127
+ capturedAt: now - 1000,
128
+ orderId: "JD200001",
129
+ merchantName: "京东自营",
130
+ items: [
131
+ { name: "AirPods Pro 2", quantity: 1, unitPrice: 1899, sku: "100012345" },
132
+ ],
133
+ placedAt: now - 86400_000,
134
+ paidAt: now - 86300_000,
135
+ status: "delivered",
136
+ totalAmount: { value: 1899, currency: "CNY" },
137
+ recipient: "alice",
138
+ shippingAddress: "厦门市象屿路 93 号",
139
+ },
140
+ ],
141
+ });
142
+ const a = new JdAdapter();
143
+ const raws = [];
144
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
145
+ expect(raws.length).toBe(1);
146
+ expect(raws[0].kind).toBe("order");
147
+ expect(raws[0].originalId).toMatch(/^jd:order:/);
148
+
149
+ const batch = a.normalize(raws[0]);
150
+ expect(validateBatch(batch).valid).toBe(true);
151
+ // OrderRecord → events[0] = "purchase" subtype with merchant 京东自营 / amount 1899
152
+ expect(batch.events.length).toBe(1);
153
+ const merchantPerson = batch.persons.find((p) => p.names && p.names.includes("京东自营"));
154
+ expect(merchantPerson).toBeTruthy();
155
+ });
156
+
157
+ it("respects opts.limit", async () => {
158
+ const now = Date.now();
159
+ const events = Array.from({ length: 5 }, (_, i) => ({
160
+ kind: "order",
161
+ id: `order-J${i}`,
162
+ capturedAt: now - i * 100,
163
+ orderId: `JD${i}`,
164
+ merchantName: "京东自营",
165
+ items: [],
166
+ placedAt: now - i * 100,
167
+ status: "delivered",
168
+ totalAmount: { value: 100, currency: "CNY" },
169
+ }));
170
+ const p = writeSnapshot(tmpDir, "shopping-jd.json", {
171
+ schemaVersion: 1,
172
+ snapshottedAt: now,
173
+ events,
174
+ });
175
+ const a = new JdAdapter();
176
+ const raws = [];
177
+ for await (const r of a.sync({ inputPath: p, limit: 2 })) raws.push(r);
178
+ expect(raws.length).toBe(2);
179
+ });
180
+
181
+ it("filters unknown kinds (forward compat)", async () => {
182
+ const now = Date.now();
183
+ const p = writeSnapshot(tmpDir, "shopping-jd.json", {
184
+ schemaVersion: 1,
185
+ snapshottedAt: now,
186
+ events: [
187
+ {
188
+ kind: "order",
189
+ id: "order-J1",
190
+ capturedAt: now,
191
+ orderId: "JD1",
192
+ merchantName: "京东",
193
+ items: [],
194
+ totalAmount: { value: 0, currency: "CNY" },
195
+ },
196
+ { kind: "future-kind", id: "x", capturedAt: now },
197
+ { kind: "review", id: "rev1", capturedAt: now }, // hypothetical future
198
+ ],
199
+ });
200
+ const a = new JdAdapter();
201
+ const raws = [];
202
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
203
+ expect(raws.length).toBe(1);
204
+ expect(raws[0].kind).toBe("order");
205
+ });
206
+
207
+ it("snapshottedAt fallback when event capturedAt+placedAt+paidAt missing", async () => {
208
+ const ts = 1700000000000;
209
+ const p = writeSnapshot(tmpDir, "shopping-jd.json", {
210
+ schemaVersion: 1,
211
+ snapshottedAt: ts,
212
+ events: [
213
+ {
214
+ kind: "order",
215
+ id: "order-J1",
216
+ orderId: "JD1",
217
+ merchantName: "京东",
218
+ items: [],
219
+ status: "placed",
220
+ totalAmount: { value: 0, currency: "CNY" },
221
+ },
222
+ ],
223
+ });
224
+ const a = new JdAdapter();
225
+ const raws = [];
226
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
227
+ expect(raws[0].capturedAt).toBe(ts);
228
+ });
229
+
230
+ it("sync() without inputPath OR cookies throws", async () => {
231
+ const a = new JdAdapter();
232
+ let threw = null;
233
+ try {
234
+ for await (const _r of a.sync({})) { /* drain */ }
235
+ } catch (err) {
236
+ threw = err;
237
+ }
238
+ expect(threw).toBeTruthy();
239
+ expect(String(threw.message)).toMatch(/needs opts\.inputPath/);
240
+ });
241
+
242
+ it("legacy cookie mode still works (regression guard)", async () => {
243
+ const a = new JdAdapter({
244
+ account: { pin: "alice", cookies: "pt_key=abc; pt_pin=alice" },
245
+ fetchFn: async () => ({
246
+ orders: [
247
+ {
248
+ orderId: "JD-LEGACY-1",
249
+ venderName: "京东自营",
250
+ orderStartTime: "2026-05-22 10:00:00",
251
+ orderTotalPrice: 99,
252
+ productList: [
253
+ { productName: "test", productQuantity: 1, productPrice: 99 },
254
+ ],
255
+ orderStatusText: "已完成",
256
+ },
257
+ ],
258
+ }),
259
+ });
260
+ const raws = [];
261
+ for await (const r of a.sync({})) raws.push(r);
262
+ expect(raws.length).toBeGreaterThanOrEqual(1);
263
+ expect(raws[0].originalId).toBe("JD-LEGACY-1");
264
+ const batch = a.normalize(raws[0]);
265
+ expect(validateBatch(batch).valid).toBe(true);
266
+ });
267
+ });
268
+
269
+ describe("MeituanAdapter snapshot mode", () => {
270
+ let tmpDir;
271
+ beforeEach(() => {
272
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "meituan-snap-"));
273
+ });
274
+
275
+ it("exports SNAPSHOT_SCHEMA_VERSION = 1", () => {
276
+ expect(MT_VERSION).toBe(1);
277
+ });
278
+
279
+ it("constructor allows missing account (stateless snapshot mode)", () => {
280
+ expect(() => new MeituanAdapter()).not.toThrow();
281
+ expect(() => new MeituanAdapter({})).not.toThrow();
282
+ });
283
+
284
+ it("authenticate(inputPath) ok", async () => {
285
+ const p = writeSnapshot(tmpDir, "shopping-meituan.json", {
286
+ schemaVersion: 1,
287
+ snapshottedAt: Date.now(),
288
+ events: [],
289
+ });
290
+ const a = new MeituanAdapter();
291
+ const res = await a.authenticate({ inputPath: p });
292
+ expect(res.ok).toBe(true);
293
+ expect(res.mode).toBe("snapshot-file");
294
+ });
295
+
296
+ it("rejects schemaVersion mismatch", async () => {
297
+ const p = writeSnapshot(tmpDir, "shopping-meituan.json", {
298
+ schemaVersion: 99,
299
+ snapshottedAt: Date.now(),
300
+ events: [],
301
+ });
302
+ const a = new MeituanAdapter();
303
+ let threw = null;
304
+ try {
305
+ for await (const _r of a.sync({ inputPath: p })) { /* drain */ }
306
+ } catch (err) {
307
+ threw = err;
308
+ }
309
+ expect(threw).toBeTruthy();
310
+ expect(String(threw.message)).toMatch(/schemaVersion mismatch/);
311
+ });
312
+
313
+ it("rejects non-JSON snapshot (HTML defer to v0.3)", async () => {
314
+ const p = path.join(tmpDir, "shopping-meituan.html");
315
+ fs.writeFileSync(p, "<html>not json</html>", "utf-8");
316
+ const a = new MeituanAdapter();
317
+ let threw = null;
318
+ try {
319
+ for await (const _r of a.sync({ inputPath: p })) { /* drain */ }
320
+ } catch (err) {
321
+ threw = err;
322
+ }
323
+ expect(threw).toBeTruthy();
324
+ expect(String(threw.message)).toMatch(/must be JSON.*v0\.3.*HTML/);
325
+ });
326
+
327
+ it("order round-trip normalize with carrier=美团 (waimai platform)", async () => {
328
+ const now = Date.now();
329
+ const p = writeSnapshot(tmpDir, "shopping-meituan.json", {
330
+ schemaVersion: 1,
331
+ snapshottedAt: now,
332
+ vendor: "meituan",
333
+ account: { userId: "alice_uid", displayName: "alice" },
334
+ events: [
335
+ {
336
+ kind: "order",
337
+ id: "order-MT1",
338
+ capturedAt: now - 1000,
339
+ orderId: "MT200001",
340
+ merchantName: "肯德基(厦门集美店)",
341
+ platform: "waimai",
342
+ items: [
343
+ { name: "黄金鸡块 5 块", quantity: 1, unitPrice: 15.5 },
344
+ { name: "雪顶咖啡", quantity: 1, unitPrice: 16 },
345
+ ],
346
+ placedAt: now - 86400_000,
347
+ paidAt: now - 86300_000,
348
+ status: "delivered",
349
+ totalAmount: { value: 31.5, currency: "CNY" },
350
+ recipient: "alice",
351
+ shippingAddress: "厦门市集美区某街道",
352
+ },
353
+ ],
354
+ });
355
+ const a = new MeituanAdapter();
356
+ const raws = [];
357
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
358
+ expect(raws.length).toBe(1);
359
+ expect(raws[0].originalId).toMatch(/^meituan:order:/);
360
+
361
+ const batch = a.normalize(raws[0]);
362
+ expect(validateBatch(batch).valid).toBe(true);
363
+ // Merchant is per-POI in waimai (not generic "美团")
364
+ const merchant = batch.persons.find((p) => p.names && p.names.includes("肯德基(厦门集美店)"));
365
+ expect(merchant).toBeTruthy();
366
+ });
367
+
368
+ it("filters unknown kinds (forward compat)", async () => {
369
+ const now = Date.now();
370
+ const p = writeSnapshot(tmpDir, "shopping-meituan.json", {
371
+ schemaVersion: 1,
372
+ snapshottedAt: now,
373
+ events: [
374
+ {
375
+ kind: "order",
376
+ id: "order-MT1",
377
+ capturedAt: now,
378
+ orderId: "MT1",
379
+ merchantName: "测试餐厅",
380
+ items: [],
381
+ status: "placed",
382
+ totalAmount: { value: 0, currency: "CNY" },
383
+ },
384
+ { kind: "speculative-coupon", id: "c1", capturedAt: now },
385
+ ],
386
+ });
387
+ const a = new MeituanAdapter();
388
+ const raws = [];
389
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
390
+ expect(raws.length).toBe(1);
391
+ expect(raws[0].kind).toBe("order");
392
+ });
393
+
394
+ it("snapshottedAt fallback when no timestamps in event", async () => {
395
+ const ts = 1700000000000;
396
+ const p = writeSnapshot(tmpDir, "shopping-meituan.json", {
397
+ schemaVersion: 1,
398
+ snapshottedAt: ts,
399
+ events: [
400
+ {
401
+ kind: "order",
402
+ id: "order-MT1",
403
+ orderId: "MT1",
404
+ merchantName: "test",
405
+ items: [],
406
+ status: "placed",
407
+ totalAmount: { value: 0, currency: "CNY" },
408
+ },
409
+ ],
410
+ });
411
+ const a = new MeituanAdapter();
412
+ const raws = [];
413
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
414
+ expect(raws[0].capturedAt).toBe(ts);
415
+ });
416
+
417
+ it("legacy cookie mode still works (regression guard)", async () => {
418
+ const a = new MeituanAdapter({
419
+ account: { userId: "alice", cookies: "iuuid=abc" },
420
+ fetchFn: async () => ({
421
+ orders: [
422
+ {
423
+ orderId: "MT-LEGACY-1",
424
+ poiName: "测试餐厅",
425
+ orderTime: "2026-05-22 12:00:00",
426
+ totalPrice: 50,
427
+ dishes: [{ name: "测试菜", quantity: 1, price: 50 }],
428
+ statusDesc: "已完成",
429
+ },
430
+ ],
431
+ }),
432
+ });
433
+ const raws = [];
434
+ for await (const r of a.sync({})) raws.push(r);
435
+ expect(raws.length).toBeGreaterThanOrEqual(1);
436
+ expect(raws[0].originalId).toBe("MT-LEGACY-1");
437
+ });
438
+ });
@@ -169,11 +169,36 @@ describe("WeiboAdapter", () => {
169
169
  const a = new WeiboAdapter({ account: { uid: "1234" } });
170
170
  expect(assertAdapter(a).ok).toBe(true);
171
171
  expect(a.extractMode).toBe("device-pull");
172
+ expect(a.capabilities).toContain("sync:snapshot");
173
+ expect(a.capabilities).toContain("sync:sqlite");
172
174
  });
173
175
 
174
- it("rejects missing account.uid", () => {
175
- expect(() => new WeiboAdapter({})).toThrow();
176
- expect(() => new WeiboAdapter({ account: {} })).toThrow(/uid/);
176
+ it("snapshot mode constructs without account.uid (stateless)", () => {
177
+ // §A8 v0.2: constructor loosened — snapshot mode pulls account from the
178
+ // snapshot file. Sqlite mode still requires account.uid, checked at sync
179
+ // time not construction.
180
+ const a = new WeiboAdapter({});
181
+ expect(assertAdapter(a).ok).toBe(true);
182
+ expect(a.account).toBeNull();
183
+ });
184
+
185
+ it("sqlite mode throws at sync time when account.uid missing", async () => {
186
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "weibo-"));
187
+ const dbPath = path.join(dir, "weibo.db");
188
+ fs.writeFileSync(dbPath, "fake");
189
+ try {
190
+ const a = new WeiboAdapter({});
191
+ let threw = null;
192
+ try {
193
+ for await (const _r of a.sync({ dbPath })) { /* drain */ }
194
+ } catch (e) {
195
+ threw = e;
196
+ }
197
+ expect(threw).not.toBeNull();
198
+ expect(threw.message).toMatch(/account\.uid/);
199
+ } finally {
200
+ fs.rmSync(dir, { recursive: true, force: true });
201
+ }
177
202
  });
178
203
 
179
204
  it("sync yields posts + search records via mocked driver", async () => {