@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.
- package/__tests__/adapters/ai-chat-cookie-capture-spec.test.js +211 -0
- package/__tests__/adapters/ai-chat-health-checker.test.js +262 -0
- package/__tests__/adapters/ai-chat-history.test.js +8 -7
- package/__tests__/adapters/ai-chat-vendors.test.js +149 -8
- package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +269 -0
- package/__tests__/adapters/system-data-android-ingest.test.js +144 -0
- package/__tests__/adapters/system-data-android.test.js +387 -0
- package/__tests__/adapters/wechat-bootstrap.test.js +240 -0
- package/__tests__/adapters/wechat-env-probe.test.js +162 -0
- package/__tests__/adapters/wechat-frida-agent.test.js +322 -0
- package/__tests__/adapters/wechat-frida-integration.test.js +149 -0
- package/__tests__/adapters/wechat-frida-key-provider.test.js +188 -0
- package/__tests__/adapters/wechat-md5-key-provider.test.js +101 -0
- package/__tests__/analysis-skills.test.js +147 -0
- package/__tests__/analysis.test.js +329 -1
- package/__tests__/e2e/ai-chat-cross-source-journey.test.js +213 -0
- package/__tests__/e2e/full-user-journey.test.js +188 -0
- package/__tests__/integration/ai-chat-history-registry.test.js +228 -0
- package/__tests__/integration/aichat-wizard-end-to-end.test.js +282 -0
- package/__tests__/integration/cross-adapter-pipelines.test.js +396 -0
- package/__tests__/integration/social-bilibili-pipeline.test.js +261 -0
- package/__tests__/integration/wechat-bootstrap-end-to-end.test.js +390 -0
- package/__tests__/registry.test.js +4 -2
- package/__tests__/social-adapters.test.js +63 -14
- package/__tests__/social-bilibili-snapshot.test.js +278 -0
- package/__tests__/wechat-adapter.test.js +118 -0
- package/lib/adapters/ai-chat-history/ai-chat-adapter.js +55 -16
- package/lib/adapters/ai-chat-history/cookie-capture-spec.js +331 -0
- package/lib/adapters/ai-chat-history/health-checker.js +210 -0
- package/lib/adapters/ai-chat-history/schema-map.js +42 -5
- package/lib/adapters/ai-chat-history/vendor-spec.js +1 -0
- package/lib/adapters/ai-chat-history/vendors/doubao.js +255 -0
- package/lib/adapters/ai-chat-history/wizard-controller.js +473 -0
- package/lib/adapters/alipay-bill/alipay-bill-adapter.js +4 -0
- package/lib/adapters/social-bilibili/adapter.js +500 -0
- package/lib/adapters/social-bilibili/index.js +21 -169
- package/lib/adapters/social-kuaishou/index.js +237 -0
- package/lib/adapters/social-toutiao/index.js +236 -0
- package/lib/adapters/system-data-android/adapter.js +348 -0
- package/lib/adapters/system-data-android/index.js +76 -0
- package/lib/adapters/wechat/bootstrap.js +146 -0
- package/lib/adapters/wechat/content-parser.js +11 -2
- package/lib/adapters/wechat/db-reader.js +88 -10
- package/lib/adapters/wechat/env-probe.js +218 -0
- package/lib/adapters/wechat/frida-agent/loader.js +74 -0
- package/lib/adapters/wechat/frida-agent/wechat-key-hook.js +248 -0
- package/lib/adapters/wechat/index.js +9 -0
- package/lib/adapters/wechat/key-providers/frida-key-provider.js +252 -0
- package/lib/adapters/wechat/key-providers/index.js +22 -0
- package/lib/adapters/wechat/key-providers/key-provider-base.js +44 -0
- package/lib/adapters/wechat/key-providers/md5-key-provider.js +81 -0
- package/lib/adapters/wechat/normalize.js +12 -3
- package/lib/analysis-skills/spending.js +4 -1
- package/lib/analysis.js +191 -2
- package/lib/index.js +16 -0
- package/lib/prompt-builder.js +11 -1
- package/lib/query-parser.js +7 -1
- package/lib/vault.js +77 -0
- 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 (
|
|
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
|
+
});
|