@chainlesschain/personal-data-hub 0.2.0 → 0.2.2

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 (59) hide show
  1. package/__tests__/adapters/ai-chat-cookie-capture-spec.test.js +211 -0
  2. package/__tests__/adapters/ai-chat-health-checker.test.js +262 -0
  3. package/__tests__/adapters/ai-chat-history.test.js +8 -7
  4. package/__tests__/adapters/ai-chat-vendors.test.js +149 -8
  5. package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +269 -0
  6. package/__tests__/adapters/system-data-android-ingest.test.js +144 -0
  7. package/__tests__/adapters/system-data-android.test.js +387 -0
  8. package/__tests__/adapters/wechat-bootstrap.test.js +240 -0
  9. package/__tests__/adapters/wechat-env-probe.test.js +162 -0
  10. package/__tests__/adapters/wechat-frida-agent.test.js +322 -0
  11. package/__tests__/adapters/wechat-frida-integration.test.js +149 -0
  12. package/__tests__/adapters/wechat-frida-key-provider.test.js +188 -0
  13. package/__tests__/adapters/wechat-md5-key-provider.test.js +101 -0
  14. package/__tests__/analysis-skills.test.js +147 -0
  15. package/__tests__/analysis.test.js +329 -1
  16. package/__tests__/e2e/ai-chat-cross-source-journey.test.js +213 -0
  17. package/__tests__/e2e/full-user-journey.test.js +188 -0
  18. package/__tests__/integration/ai-chat-history-registry.test.js +228 -0
  19. package/__tests__/integration/aichat-wizard-end-to-end.test.js +282 -0
  20. package/__tests__/integration/cross-adapter-pipelines.test.js +396 -0
  21. package/__tests__/integration/social-bilibili-pipeline.test.js +261 -0
  22. package/__tests__/integration/wechat-bootstrap-end-to-end.test.js +390 -0
  23. package/__tests__/registry.test.js +4 -2
  24. package/__tests__/social-adapters.test.js +63 -14
  25. package/__tests__/social-bilibili-snapshot.test.js +278 -0
  26. package/__tests__/wechat-adapter.test.js +118 -0
  27. package/lib/adapters/ai-chat-history/ai-chat-adapter.js +55 -16
  28. package/lib/adapters/ai-chat-history/cookie-capture-spec.js +331 -0
  29. package/lib/adapters/ai-chat-history/health-checker.js +210 -0
  30. package/lib/adapters/ai-chat-history/schema-map.js +42 -5
  31. package/lib/adapters/ai-chat-history/vendor-spec.js +1 -0
  32. package/lib/adapters/ai-chat-history/vendors/doubao.js +255 -0
  33. package/lib/adapters/ai-chat-history/wizard-controller.js +473 -0
  34. package/lib/adapters/alipay-bill/alipay-bill-adapter.js +4 -0
  35. package/lib/adapters/social-bilibili/adapter.js +500 -0
  36. package/lib/adapters/social-bilibili/index.js +21 -169
  37. package/lib/adapters/social-kuaishou/index.js +237 -0
  38. package/lib/adapters/social-toutiao/index.js +236 -0
  39. package/lib/adapters/system-data-android/adapter.js +348 -0
  40. package/lib/adapters/system-data-android/index.js +76 -0
  41. package/lib/adapters/wechat/bootstrap.js +146 -0
  42. package/lib/adapters/wechat/content-parser.js +11 -2
  43. package/lib/adapters/wechat/db-reader.js +88 -10
  44. package/lib/adapters/wechat/env-probe.js +218 -0
  45. package/lib/adapters/wechat/frida-agent/loader.js +74 -0
  46. package/lib/adapters/wechat/frida-agent/wechat-key-hook.js +248 -0
  47. package/lib/adapters/wechat/index.js +9 -0
  48. package/lib/adapters/wechat/key-providers/frida-key-provider.js +252 -0
  49. package/lib/adapters/wechat/key-providers/index.js +22 -0
  50. package/lib/adapters/wechat/key-providers/key-provider-base.js +44 -0
  51. package/lib/adapters/wechat/key-providers/md5-key-provider.js +81 -0
  52. package/lib/adapters/wechat/normalize.js +12 -3
  53. package/lib/analysis-skills/spending.js +4 -1
  54. package/lib/analysis.js +191 -2
  55. package/lib/index.js +16 -0
  56. package/lib/prompt-builder.js +11 -1
  57. package/lib/query-parser.js +7 -1
  58. package/lib/vault.js +77 -0
  59. package/package.json +8 -1
@@ -16,6 +16,7 @@ const hunyuanModule = require("../../lib/adapters/ai-chat-history/vendors/hunyua
16
16
  const qianfanModule = require("../../lib/adapters/ai-chat-history/vendors/qianfan");
17
17
  const cozeModule = require("../../lib/adapters/ai-chat-history/vendors/coze");
18
18
  const dreaminaModule = require("../../lib/adapters/ai-chat-history/vendors/dreamina");
19
+ const doubaoModule = require("../../lib/adapters/ai-chat-history/vendors/doubao");
19
20
 
20
21
  // ─── helpers ─────────────────────────────────────────────────────────────
21
22
 
@@ -296,11 +297,11 @@ describe("AIChatHistoryAdapter.sync — wired DeepSeek path E2E", () => {
296
297
  const out = [];
297
298
  for await (const ev of a.sync({ vendors: ["deepseek"] })) out.push(ev);
298
299
  expect(out.length).toBe(3); // 1 conv + 2 msgs
299
- expect(out[0].kind).toBe("conversation");
300
- expect(out[0].conversation.title).toBe("wired test");
301
- expect(out[1].kind).toBe("message");
302
- expect(out[1].message.role).toBe("user");
303
- expect(out[2].message.role).toBe("assistant");
300
+ expect(out[0].payload.kind).toBe("conversation");
301
+ expect(out[0].payload.conversation.title).toBe("wired test");
302
+ expect(out[1].payload.kind).toBe("message");
303
+ expect(out[1].payload.message.role).toBe("user");
304
+ expect(out[2].payload.message.role).toBe("assistant");
304
305
 
305
306
  // normalize → events / topics
306
307
  const batches = out.map((r) => a.normalize(r));
@@ -319,8 +320,8 @@ describe("AIChatHistoryAdapter.sync — wired DeepSeek path E2E", () => {
319
320
  const out = [];
320
321
  for await (const ev of a.sync({ vendors: ["deepseek"] })) out.push(ev);
321
322
  expect(out.length).toBe(1);
322
- expect(out[0].kind).toBe("vendor-cookie-expired");
323
- expect(out[0].vendor).toBe("deepseek");
323
+ expect(out[0].payload.kind).toBe("vendor-cookie-expired");
324
+ expect(out[0].payload.vendor).toBe("deepseek");
324
325
  });
325
326
 
326
327
  it("healthCheck reports per-vendor wired result", async () => {
@@ -712,9 +713,148 @@ describe("Dreamina vendor — Phase 10.2 wiring", () => {
712
713
  });
713
714
  });
714
715
 
716
+ // ─── Doubao 豆包 ────────────────────────────────────────────────────────
717
+
718
+ function doubaoFixtureClient() {
719
+ const fixture = makeRoutedFetch([
720
+ [
721
+ "/samantha/user/info",
722
+ makeResponse({ body: { code: 0, data: { user_id: "db-u1" } } }),
723
+ ],
724
+ [
725
+ "/samantha/conversation/list",
726
+ makeResponse({
727
+ body: {
728
+ data: {
729
+ conversation_list: [
730
+ {
731
+ conversation_id: "conv-1",
732
+ name: "聊聊 Rust",
733
+ bot_id: "bot-default",
734
+ bot_name: "豆包",
735
+ create_time: 1700000000,
736
+ last_message_time: 1700001000,
737
+ message_count: 4,
738
+ },
739
+ ],
740
+ has_more: false,
741
+ cursor: "",
742
+ },
743
+ },
744
+ }),
745
+ ],
746
+ [
747
+ /samantha\/conversation\/conv-1\/message\/list/,
748
+ makeResponse({
749
+ body: {
750
+ data: {
751
+ message_list: [
752
+ {
753
+ id: "m-2",
754
+ sender_type: 2,
755
+ content: "Rust 的核心是所有权…",
756
+ create_time: 1700000060,
757
+ bot_id: "bot-default",
758
+ },
759
+ {
760
+ id: "m-1",
761
+ sender_type: 1,
762
+ content: "讲讲 Rust 的特点",
763
+ create_time: 1700000050,
764
+ },
765
+ ],
766
+ has_more: false,
767
+ cursor: "",
768
+ },
769
+ },
770
+ }),
771
+ ],
772
+ ]);
773
+ const clk = makeClock();
774
+ const httpClient = new HttpClient({
775
+ vendor: "doubao",
776
+ rateLimits: { perMinute: 0, minIntervalMs: 0 },
777
+ fetch: fixture.fetch,
778
+ sleep: clk.sleep,
779
+ now: clk.now,
780
+ });
781
+ return { httpClient, fixture };
782
+ }
783
+
784
+ describe("Doubao vendor — Phase 10.2(+) v0.1 scaffold", () => {
785
+ it("validateCookie returns userId from /samantha/user/info", async () => {
786
+ const { httpClient } = doubaoFixtureClient();
787
+ const session = new CookieAuthSession({ vendor: "doubao", cookies: [] });
788
+ const r = await doubaoModule.SPEC.validateCookie({
789
+ httpClient,
790
+ session,
791
+ vendor: "doubao",
792
+ });
793
+ expect(r.ok).toBe(true);
794
+ expect(r.userId).toBe("db-u1");
795
+ });
796
+
797
+ it("listConversations yields RawConversation with bot_name as modelName", async () => {
798
+ const { httpClient } = doubaoFixtureClient();
799
+ const session = new CookieAuthSession({ vendor: "doubao", cookies: [] });
800
+ const out = [];
801
+ for await (const c of doubaoModule.SPEC.listConversations({
802
+ httpClient,
803
+ session,
804
+ vendor: "doubao",
805
+ })) {
806
+ out.push(c);
807
+ }
808
+ expect(out.length).toBe(1);
809
+ expect(out[0].originalId).toBe("conv-1");
810
+ expect(out[0].title).toBe("聊聊 Rust");
811
+ expect(out[0].modelName).toBe("豆包");
812
+ expect(out[0].extra.botId).toBe("bot-default");
813
+ });
814
+
815
+ it("listMessages sorts messages chronologically + maps numeric sender_type", async () => {
816
+ const { httpClient } = doubaoFixtureClient();
817
+ const session = new CookieAuthSession({ vendor: "doubao", cookies: [] });
818
+ const out = [];
819
+ for await (const m of doubaoModule.SPEC.listMessages(
820
+ { httpClient, session, vendor: "doubao" },
821
+ "conv-1",
822
+ )) {
823
+ out.push(m);
824
+ }
825
+ expect(out.length).toBe(2);
826
+ // Re-sorted to chronological even though API returned reverse.
827
+ expect(out[0].originalId).toBe("m-1");
828
+ expect(out[0].role).toBe("user");
829
+ expect(out[0].content.text).toBe("讲讲 Rust 的特点");
830
+ expect(out[1].originalId).toBe("m-2");
831
+ expect(out[1].role).toBe("assistant");
832
+ });
833
+
834
+ it("_normalizeRole handles numeric + string + uppercase sender_type", () => {
835
+ const { _normalizeRole } = doubaoModule._internal;
836
+ expect(_normalizeRole(1)).toBe("user");
837
+ expect(_normalizeRole("2")).toBe("assistant");
838
+ expect(_normalizeRole("SYSTEM")).toBe("system");
839
+ expect(_normalizeRole("assistant")).toBe("assistant");
840
+ });
841
+
842
+ it("_extractConvList absorbs alternate response field names", () => {
843
+ const { _extractConvList, _extractMsgList } = doubaoModule._internal;
844
+ expect(_extractConvList({ data: { conversations: [{ id: 1 }] } })).toEqual([
845
+ { id: 1 },
846
+ ]);
847
+ expect(_extractConvList({ data: { list: [{ id: 2 }] } })).toEqual([{ id: 2 }]);
848
+ expect(_extractMsgList({ data: { messages: [{ id: "x" }] } })).toEqual([
849
+ { id: "x" },
850
+ ]);
851
+ expect(_extractMsgList({})).toEqual([]);
852
+ });
853
+ });
854
+
715
855
  // ─── Spec contract still valid after wiring ──────────────────────────────
716
856
 
717
- describe("vendor spec post-wire smoke (8/8 vendors live)", () => {
857
+ describe("vendor spec post-wire smoke (9 vendors total — 8 live + doubao scaffold)", () => {
718
858
  it.each([
719
859
  "deepseek",
720
860
  "kimi",
@@ -724,6 +864,7 @@ describe("vendor spec post-wire smoke (8/8 vendors live)", () => {
724
864
  "qianfan",
725
865
  "coze",
726
866
  "dreamina",
867
+ "doubao",
727
868
  ])("%s spec still has correct shape", (v) => {
728
869
  expect(DEFAULT_VENDOR_SPECS[v].name).toBe(v);
729
870
  expect(typeof DEFAULT_VENDOR_SPECS[v].listConversations).toBe("function");
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Phase 13.8+13.9 — Toutiao 今日头条 + Kuaishou 快手 v0.1 scaffold tests.
3
+ *
4
+ * Tests are intentionally focused on scaffold-quality guarantees:
5
+ * - Adapter contract conformance (assertAdapter ok)
6
+ * - Account validation (rejects missing uid)
7
+ * - sync() yields raw rows per `kind` from mocked SQLite driver
8
+ * - normalize() produces valid UnifiedSchema events with correct subtype
9
+ *
10
+ * Field-level assertions intentionally avoided — schema is待 fixture pin
11
+ * in Phase 13.10 (real-device E2E).
12
+ */
13
+
14
+ "use strict";
15
+
16
+ import { describe, it, expect } from "vitest";
17
+
18
+ const fs = require("node:fs");
19
+ const path = require("node:path");
20
+ const os = require("node:os");
21
+
22
+ const { ToutiaoAdapter, KuaishouAdapter } = require("../../lib");
23
+ const { assertAdapter } = require("../../lib/adapter-spec");
24
+ const { validateBatch } = require("../../lib/batch");
25
+
26
+ function makeMockDriver(scriptedRows) {
27
+ return function () {
28
+ return {
29
+ prepare(sql) {
30
+ return {
31
+ all() {
32
+ for (const [matchSubstr, rows] of scriptedRows) {
33
+ if (sql.includes(matchSubstr)) return rows;
34
+ }
35
+ throw new Error("no such table");
36
+ },
37
+ };
38
+ },
39
+ close() {},
40
+ };
41
+ };
42
+ }
43
+
44
+ function withFakeDb(fn) {
45
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pdh-scaffold-"));
46
+ const dbPath = path.join(dir, "fake.db");
47
+ fs.writeFileSync(dbPath, "fake");
48
+ return fn(dbPath);
49
+ }
50
+
51
+ // ─── ToutiaoAdapter ─────────────────────────────────────────────────────
52
+
53
+ describe("ToutiaoAdapter — Phase 13.8(+) v0.1 scaffold", () => {
54
+ it("contract conformance + sensitivity high (news reading reveals political/medical interest)", () => {
55
+ const a = new ToutiaoAdapter({ account: { uid: "u-1" } });
56
+ expect(assertAdapter(a).ok).toBe(true);
57
+ expect(a.name).toBe("social-toutiao");
58
+ expect(a.extractMode).toBe("device-pull");
59
+ expect(a.dataDisclosure.sensitivity).toBe("high");
60
+ });
61
+
62
+ it("rejects missing account.uid", () => {
63
+ expect(() => new ToutiaoAdapter({})).toThrow();
64
+ expect(() => new ToutiaoAdapter({ account: {} })).toThrow(/uid/);
65
+ });
66
+
67
+ it("sync yields read + collection + search raws via mocked driver", async () => {
68
+ await withFakeDb(async (dbPath) => {
69
+ const driver = makeMockDriver([
70
+ [
71
+ "FROM read_history",
72
+ [
73
+ { id: 1, item_id: "i-1", title: "新闻 A", read_time: 1700000000, category: "tech" },
74
+ { id: 2, item_id: "i-2", title: "新闻 B", read_time: 1700000010, category: "finance" },
75
+ ],
76
+ ],
77
+ [
78
+ "FROM collection_article",
79
+ [{ id: 1, item_id: "i-3", article_title: "深度长文", save_time: 1700001000 }],
80
+ ],
81
+ [
82
+ "FROM search_history",
83
+ [{ id: 1, keyword: "Rust 语言", search_time: 1700002000 }],
84
+ ],
85
+ ]);
86
+ const a = new ToutiaoAdapter({
87
+ account: { uid: "u-1" },
88
+ dbPath,
89
+ dbDriverFactory: () => driver,
90
+ });
91
+ const raws = [];
92
+ for await (const r of a.sync()) raws.push(r);
93
+ expect(raws.length).toBe(4);
94
+ expect(raws.filter((r) => r.payload.kind === "read")).toHaveLength(2);
95
+ expect(raws.filter((r) => r.payload.kind === "collection")).toHaveLength(1);
96
+ expect(raws.filter((r) => r.payload.kind === "search")).toHaveLength(1);
97
+ });
98
+ });
99
+
100
+ it("normalize maps read → browse / collection → like / search → post (all subtypes valid)", async () => {
101
+ const a = new ToutiaoAdapter({ account: { uid: "u-1" } });
102
+ const samples = [
103
+ {
104
+ kind: "read",
105
+ row: { id: 1, item_id: "i-1", title: "T1", read_time: 1700000000, category: "tech" },
106
+ expectedSubtype: "browse",
107
+ },
108
+ {
109
+ kind: "collection",
110
+ row: { id: 1, item_id: "i-2", article_title: "T2", save_time: 1700001000 },
111
+ expectedSubtype: "like",
112
+ },
113
+ {
114
+ kind: "search",
115
+ row: { id: 1, keyword: "Rust", search_time: 1700002000 },
116
+ expectedSubtype: "post",
117
+ },
118
+ ];
119
+ for (const s of samples) {
120
+ const batch = a.normalize({
121
+ adapter: "social-toutiao",
122
+ originalId: `${s.kind}-${s.row.id}`,
123
+ capturedAt: Date.now(),
124
+ payload: { row: s.row, kind: s.kind },
125
+ });
126
+ const v = validateBatch(batch);
127
+ expect(v.valid).toBe(true);
128
+ expect(batch.events[0].subtype).toBe(s.expectedSubtype);
129
+ expect(batch.events[0].source.adapter).toBe("social-toutiao");
130
+ }
131
+ });
132
+
133
+ it("normalize throws on missing payload.row (validator-friendly)", () => {
134
+ const a = new ToutiaoAdapter({ account: { uid: "u-1" } });
135
+ expect(() => a.normalize({ payload: {} })).toThrow(/row missing/);
136
+ });
137
+
138
+ it("search keyword preserved verbatim in content.title + extra.keyword", () => {
139
+ const a = new ToutiaoAdapter({ account: { uid: "u-1" } });
140
+ const batch = a.normalize({
141
+ adapter: "social-toutiao",
142
+ originalId: "search-1",
143
+ capturedAt: 1700002000_000,
144
+ payload: { row: { id: 1, keyword: "新冠 后遗症", search_time: 1700002000 }, kind: "search" },
145
+ });
146
+ expect(batch.events[0].content.title).toBe("新冠 后遗症");
147
+ expect(batch.events[0].extra.kind).toBe("search");
148
+ expect(batch.events[0].extra.keyword).toBe("新冠 后遗症");
149
+ });
150
+
151
+ it("sync gracefully exits when dbPath missing", async () => {
152
+ const a = new ToutiaoAdapter({ account: { uid: "u-1" }, dbPath: "/no/such/path.db" });
153
+ const raws = [];
154
+ for await (const r of a.sync()) raws.push(r);
155
+ expect(raws).toEqual([]);
156
+ });
157
+ });
158
+
159
+ // ─── KuaishouAdapter ────────────────────────────────────────────────────
160
+
161
+ describe("KuaishouAdapter — Phase 13.9(+) v0.1 scaffold", () => {
162
+ it("contract conformance + sensitivity medium (entertainment preference)", () => {
163
+ const a = new KuaishouAdapter({ account: { uid: "u-2" } });
164
+ expect(assertAdapter(a).ok).toBe(true);
165
+ expect(a.name).toBe("social-kuaishou");
166
+ expect(a.extractMode).toBe("device-pull");
167
+ expect(a.dataDisclosure.sensitivity).toBe("medium");
168
+ });
169
+
170
+ it("rejects missing account.uid", () => {
171
+ expect(() => new KuaishouAdapter({})).toThrow();
172
+ expect(() => new KuaishouAdapter({ account: {} })).toThrow(/uid/);
173
+ });
174
+
175
+ it("sync yields watch + collect + search raws via mocked driver", async () => {
176
+ await withFakeDb(async (dbPath) => {
177
+ const driver = makeMockDriver([
178
+ [
179
+ "FROM photo_history",
180
+ [
181
+ {
182
+ id: 1,
183
+ photo_id: "p-1",
184
+ caption: "搞笑视频",
185
+ view_time: 1700000000,
186
+ duration: 30,
187
+ author_name: "UpA",
188
+ },
189
+ ],
190
+ ],
191
+ [
192
+ "FROM user_collect",
193
+ [{ id: 1, photo_id: "p-2", caption: "美食 vlog", collect_time: 1700001000 }],
194
+ ],
195
+ [
196
+ "FROM search_record",
197
+ [{ id: 1, keyword: "广场舞", search_time: 1700002000 }],
198
+ ],
199
+ ]);
200
+ const a = new KuaishouAdapter({
201
+ account: { uid: "u-2" },
202
+ dbPath,
203
+ dbDriverFactory: () => driver,
204
+ });
205
+ const raws = [];
206
+ for await (const r of a.sync()) raws.push(r);
207
+ expect(raws.length).toBe(3);
208
+ expect(raws.filter((r) => r.payload.kind === "watch")).toHaveLength(1);
209
+ expect(raws.filter((r) => r.payload.kind === "collect")).toHaveLength(1);
210
+ expect(raws.filter((r) => r.payload.kind === "search")).toHaveLength(1);
211
+ });
212
+ });
213
+
214
+ it("normalize maps watch → browse / collect → like / search → post (all subtypes valid)", () => {
215
+ const a = new KuaishouAdapter({ account: { uid: "u-2" } });
216
+ const samples = [
217
+ {
218
+ kind: "watch",
219
+ row: { id: 1, photo_id: "p-1", caption: "C1", view_time: 1700000000, duration: 30 },
220
+ expectedSubtype: "browse",
221
+ },
222
+ {
223
+ kind: "collect",
224
+ row: { id: 1, photo_id: "p-2", caption: "C2", collect_time: 1700001000 },
225
+ expectedSubtype: "like",
226
+ },
227
+ {
228
+ kind: "search",
229
+ row: { id: 1, keyword: "广场舞", search_time: 1700002000 },
230
+ expectedSubtype: "post",
231
+ },
232
+ ];
233
+ for (const s of samples) {
234
+ const batch = a.normalize({
235
+ adapter: "social-kuaishou",
236
+ originalId: `${s.kind}-${s.row.id}`,
237
+ capturedAt: Date.now(),
238
+ payload: { row: s.row, kind: s.kind },
239
+ });
240
+ const v = validateBatch(batch);
241
+ expect(v.valid).toBe(true);
242
+ expect(batch.events[0].subtype).toBe(s.expectedSubtype);
243
+ expect(batch.events[0].source.adapter).toBe("social-kuaishou");
244
+ }
245
+ });
246
+
247
+ it("watch event extra carries photoId + duration + authorName", () => {
248
+ const a = new KuaishouAdapter({ account: { uid: "u-2" } });
249
+ const batch = a.normalize({
250
+ adapter: "social-kuaishou",
251
+ originalId: "watch-1",
252
+ capturedAt: 1700000000_000,
253
+ payload: {
254
+ row: {
255
+ id: 1,
256
+ photo_id: "p-1",
257
+ caption: "美食",
258
+ view_time: 1700000000,
259
+ duration: 60,
260
+ author_name: "FoodVlogger",
261
+ },
262
+ kind: "watch",
263
+ },
264
+ });
265
+ expect(batch.events[0].extra.photoId).toBe("p-1");
266
+ expect(batch.events[0].extra.duration).toBe(60);
267
+ expect(batch.events[0].extra.authorName).toBe("FoodVlogger");
268
+ });
269
+ });
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
4
+ import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+
8
+ const {
9
+ SystemDataAndroidAdapter,
10
+ ingestSystemDataAndroidSnapshot,
11
+ SNAPSHOT_SCHEMA_VERSION,
12
+ } = require("../../lib/adapters/system-data-android");
13
+
14
+ /**
15
+ * Tests for the Path C ingest helper — staging-file write + syncAdapter call
16
+ * + cleanup. Uses a fake hub.registry that proxies to a real
17
+ * SystemDataAndroidAdapter so we exercise both halves of the pipeline (the
18
+ * adapter's _syncViaSnapshot does fs.readFileSync of the staging path we
19
+ * wrote, then the helper unlinks it after).
20
+ */
21
+
22
+ let tmpHubDir;
23
+ let hub;
24
+
25
+ beforeEach(() => {
26
+ tmpHubDir = mkdtempSync(join(tmpdir(), "pdh-ingest-"));
27
+ const adapter = new SystemDataAndroidAdapter();
28
+ hub = {
29
+ hubDir: tmpHubDir,
30
+ registry: {
31
+ syncAdapter: vi.fn(async (name, opts) => {
32
+ // Mimic what the real hub does: run the adapter's sync against the
33
+ // staging path the helper wrote.
34
+ const out = { adapter: name, ingested: 0, partitions: {} };
35
+ for await (const raw of adapter.sync(opts)) {
36
+ out.ingested += 1;
37
+ if (raw.kind === "contact")
38
+ out.partitions.contacts = { ingested: (out.partitions.contacts?.ingested || 0) + 1 };
39
+ if (raw.kind === "app")
40
+ out.partitions.apps = { ingested: (out.partitions.apps?.ingested || 0) + 1 };
41
+ }
42
+ return out;
43
+ }),
44
+ },
45
+ };
46
+ });
47
+
48
+ afterEach(() => {
49
+ rmSync(tmpHubDir, { recursive: true, force: true });
50
+ });
51
+
52
+ describe("ingestSystemDataAndroidSnapshot", () => {
53
+ it("writes staging file, runs syncAdapter, cleans up", async () => {
54
+ const snapshot = {
55
+ schemaVersion: SNAPSHOT_SCHEMA_VERSION,
56
+ snapshottedAt: 1_700_000_000_000,
57
+ contacts: [
58
+ { lookupKey: "ck-1", displayName: "妈妈", phones: ["13800000001"] },
59
+ ],
60
+ apps: [{ packageName: "com.tencent.mm", label: "微信" }],
61
+ };
62
+
63
+ const report = await ingestSystemDataAndroidSnapshot(hub, snapshot);
64
+
65
+ expect(report.adapter).toBe("system-data-android");
66
+ expect(report.ingested).toBe(2); // 1 contact + 1 app
67
+
68
+ // syncAdapter was called with an inputPath under <hubDir>/staging/
69
+ expect(hub.registry.syncAdapter).toHaveBeenCalledTimes(1);
70
+ const [name, opts] = hub.registry.syncAdapter.mock.calls[0];
71
+ expect(name).toBe("system-data-android");
72
+ expect(opts.inputPath).toContain(join(tmpHubDir, "staging"));
73
+ expect(opts.inputPath).toMatch(/system-data-android-\d+.+\.json$/);
74
+
75
+ // Staging file was cleaned up afterwards
76
+ expect(existsSync(opts.inputPath)).toBe(false);
77
+ });
78
+
79
+ it("rejects snapshot with mismatched schemaVersion", async () => {
80
+ await expect(
81
+ ingestSystemDataAndroidSnapshot(hub, {
82
+ schemaVersion: 99,
83
+ snapshottedAt: 0,
84
+ contacts: [],
85
+ apps: [],
86
+ }),
87
+ ).rejects.toThrow(/schemaVersion 99/);
88
+ });
89
+
90
+ it("rejects missing hub.hubDir", async () => {
91
+ await expect(
92
+ ingestSystemDataAndroidSnapshot(
93
+ { registry: hub.registry },
94
+ { schemaVersion: SNAPSHOT_SCHEMA_VERSION, snapshottedAt: 0, contacts: [], apps: [] },
95
+ ),
96
+ ).rejects.toThrow(/hubDir/);
97
+ });
98
+
99
+ it("rejects missing snapshot payload", async () => {
100
+ await expect(
101
+ ingestSystemDataAndroidSnapshot(hub, null),
102
+ ).rejects.toThrow(/snapshot payload required/);
103
+ });
104
+
105
+ it("cleans up staging file even when syncAdapter throws", async () => {
106
+ hub.registry.syncAdapter = vi.fn(async () => {
107
+ throw new Error("simulated sync failure");
108
+ });
109
+ let leaked = null;
110
+ try {
111
+ await ingestSystemDataAndroidSnapshot(hub, {
112
+ schemaVersion: SNAPSHOT_SCHEMA_VERSION,
113
+ snapshottedAt: 0,
114
+ contacts: [{ lookupKey: "x", displayName: "X" }],
115
+ apps: [],
116
+ });
117
+ } catch (_e) {
118
+ // expected
119
+ }
120
+ leaked = hub.registry.syncAdapter.mock.calls[0]?.[1]?.inputPath;
121
+ expect(leaked).toBeTruthy();
122
+ expect(existsSync(leaked)).toBe(false);
123
+ });
124
+
125
+ it("staging file contains the snapshot JSON we passed in", async () => {
126
+ // Capture path before syncAdapter unlinks it
127
+ let captured = null;
128
+ hub.registry.syncAdapter = vi.fn(async (_name, opts) => {
129
+ captured = readFileSync(opts.inputPath, "utf-8");
130
+ return { adapter: "system-data-android", ingested: 0 };
131
+ });
132
+ const snapshot = {
133
+ schemaVersion: SNAPSHOT_SCHEMA_VERSION,
134
+ snapshottedAt: 1_700_000_000_000,
135
+ contacts: [{ lookupKey: "ck-test", displayName: "Test" }],
136
+ apps: [],
137
+ };
138
+ await ingestSystemDataAndroidSnapshot(hub, snapshot);
139
+ expect(captured).toBeTruthy();
140
+ const parsed = JSON.parse(captured);
141
+ expect(parsed.schemaVersion).toBe(SNAPSHOT_SCHEMA_VERSION);
142
+ expect(parsed.contacts[0].lookupKey).toBe("ck-test");
143
+ });
144
+ });