@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
@@ -0,0 +1,211 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect } from "vitest";
4
+
5
+ const {
6
+ COOKIE_SPEC_VERSION,
7
+ KNOWN_VENDORS,
8
+ COOKIE_CAPTURE_SPECS,
9
+ getSpec,
10
+ listVendors,
11
+ classifyProbedCookies,
12
+ validateCookieCaptureSpec,
13
+ _internal,
14
+ } = require("../../lib/adapters/ai-chat-history/cookie-capture-spec");
15
+
16
+ describe("cookie-capture-spec — Phase 10.3.1 matrix", () => {
17
+ it("exposes a positive integer COOKIE_SPEC_VERSION", () => {
18
+ expect(Number.isInteger(COOKIE_SPEC_VERSION)).toBe(true);
19
+ expect(COOKIE_SPEC_VERSION).toBeGreaterThanOrEqual(1);
20
+ });
21
+
22
+ it("KNOWN_VENDORS contains exactly the 9 wired vendors", () => {
23
+ expect(KNOWN_VENDORS).toEqual([
24
+ "deepseek",
25
+ "kimi",
26
+ "tongyi",
27
+ "zhipu",
28
+ "hunyuan",
29
+ "qianfan",
30
+ "coze",
31
+ "dreamina",
32
+ "doubao",
33
+ ]);
34
+ // Defensive — frozen so contributors don't accidentally mutate at runtime.
35
+ expect(Object.isFrozen(KNOWN_VENDORS)).toBe(true);
36
+ });
37
+
38
+ it("ships 9 specs, one per KNOWN_VENDORS entry, no duplicates", () => {
39
+ expect(COOKIE_CAPTURE_SPECS.length).toBe(KNOWN_VENDORS.length);
40
+ const seen = new Set();
41
+ for (const s of COOKIE_CAPTURE_SPECS) {
42
+ expect(KNOWN_VENDORS).toContain(s.vendor);
43
+ expect(seen.has(s.vendor)).toBe(false);
44
+ seen.add(s.vendor);
45
+ }
46
+ });
47
+
48
+ it("validates the shipped spec set without errors", () => {
49
+ const r = validateCookieCaptureSpec(undefined, { throwOnError: false });
50
+ expect(r.errors).toEqual([]);
51
+ expect(r.ok).toBe(true);
52
+ });
53
+
54
+ it("each loginUrl host matches at least one cookieDomains entry", () => {
55
+ for (const s of COOKIE_CAPTURE_SPECS) {
56
+ const host = new URL(s.loginUrl).host;
57
+ const matched = s.cookieDomains.some((d) =>
58
+ d.startsWith(".") ? host.endsWith(d.slice(1)) : host === d,
59
+ );
60
+ expect({ vendor: s.vendor, host, matched }).toEqual({ vendor: s.vendor, host, matched: true });
61
+ }
62
+ });
63
+
64
+ it("each spec has non-empty requiredCookies + postLoginPathHints + positive maxAge", () => {
65
+ for (const s of COOKIE_CAPTURE_SPECS) {
66
+ expect(Array.isArray(s.requiredCookies)).toBe(true);
67
+ expect(s.requiredCookies.length).toBeGreaterThan(0);
68
+ expect(Array.isArray(s.postLoginPathHints)).toBe(true);
69
+ expect(s.postLoginPathHints.length).toBeGreaterThan(0);
70
+ expect(Number.isInteger(s.cookieMaxAgeHintDays)).toBe(true);
71
+ expect(s.cookieMaxAgeHintDays).toBeGreaterThan(0);
72
+ expect(typeof s.notes).toBe("string");
73
+ expect(s.notes.length).toBeGreaterThan(0);
74
+ }
75
+ });
76
+
77
+ it("getSpec returns the right spec for a known vendor and null for unknown", () => {
78
+ const ds = getSpec("deepseek");
79
+ expect(ds).toBeTruthy();
80
+ expect(ds.vendor).toBe("deepseek");
81
+ expect(getSpec("notarealvendor")).toBeNull();
82
+ expect(getSpec("")).toBeNull();
83
+ expect(getSpec(undefined)).toBeNull();
84
+ expect(getSpec(null)).toBeNull();
85
+ });
86
+
87
+ it("listVendors returns a copy (mutation does not affect KNOWN_VENDORS)", () => {
88
+ const arr = listVendors();
89
+ expect(arr).toEqual([...KNOWN_VENDORS]);
90
+ arr.push("hacked");
91
+ expect(KNOWN_VENDORS.includes("hacked")).toBe(false);
92
+ });
93
+ });
94
+
95
+ describe("classifyProbedCookies — required vs optional vs missing", () => {
96
+ it("returns ok=true when all required cookies present (object input)", () => {
97
+ const r = classifyProbedCookies("deepseek", {
98
+ userToken: "abc",
99
+ "intercom-session-deepseek": "xyz",
100
+ });
101
+ expect(r.ok).toBe(true);
102
+ expect(r.foundRequired).toEqual(["userToken"]);
103
+ expect(r.missingRequired).toEqual([]);
104
+ expect(r.foundOptional).toEqual(["intercom-session-deepseek"]);
105
+ });
106
+
107
+ it("returns ok=false when a required cookie is missing", () => {
108
+ const r = classifyProbedCookies("kimi", { refresh_token: "rt", session_id: "sid" });
109
+ expect(r.ok).toBe(false);
110
+ expect(r.missingRequired).toEqual(["access_token"]);
111
+ expect(r.foundOptional.sort()).toEqual(["refresh_token", "session_id"]);
112
+ });
113
+
114
+ it("accepts Electron Cookie[] shape (array of { name, value })", () => {
115
+ const r = classifyProbedCookies("zhipu", [
116
+ { name: "chatglm_token", value: "tok" },
117
+ { name: "cgsessionid", value: "sid" },
118
+ { name: "unrelated", value: "x" },
119
+ ]);
120
+ expect(r.ok).toBe(true);
121
+ expect(r.foundRequired).toEqual(["chatglm_token"]);
122
+ expect(r.foundOptional).toContain("cgsessionid");
123
+ });
124
+
125
+ it("accepts raw 'k=v; k=v' string (web-shell paste fallback)", () => {
126
+ const raw = "sessionid=abc; sid_guard=xyz; passport_csrf_token=csrf; ;junk";
127
+ const r = classifyProbedCookies("doubao", raw);
128
+ expect(r.ok).toBe(true);
129
+ expect(r.foundRequired).toEqual(["sessionid"]);
130
+ expect(r.foundOptional.sort()).toEqual(["passport_csrf_token", "sid_guard"]);
131
+ });
132
+
133
+ it("string parser tolerates values containing '=' (e.g. base64)", () => {
134
+ // sessionid is base64 with '=' padding; only the FIRST '=' is the delimiter.
135
+ const raw = "sessionid=YWJjZGVmZ2g=; sid_guard=v1=";
136
+ const r = classifyProbedCookies("coze", raw);
137
+ expect(r.ok).toBe(true);
138
+ expect(r.foundRequired).toEqual(["sessionid"]);
139
+ // raw cookie value must be preserved verbatim including the trailing '='
140
+ const jar = _internal._normalizeCookieJar(raw);
141
+ expect(jar.sessionid).toBe("YWJjZGVmZ2g=");
142
+ });
143
+
144
+ it("empty string / null / undefined / wrong type all produce ok=false", () => {
145
+ for (const input of ["", null, undefined, 42, true]) {
146
+ const r = classifyProbedCookies("doubao", input);
147
+ expect(r.ok).toBe(false);
148
+ expect(r.missingRequired).toEqual(["sessionid"]);
149
+ }
150
+ });
151
+
152
+ it("returns UNKNOWN_VENDOR reason for an unregistered vendor name", () => {
153
+ const r = classifyProbedCookies("notarealvendor", { anything: "x" });
154
+ expect(r.ok).toBe(false);
155
+ expect(r.reason).toBe("UNKNOWN_VENDOR");
156
+ });
157
+
158
+ it("treats empty-string cookie value as missing (not present)", () => {
159
+ const r = classifyProbedCookies("deepseek", { userToken: "" });
160
+ expect(r.ok).toBe(false);
161
+ expect(r.foundRequired).toEqual([]);
162
+ expect(r.missingRequired).toEqual(["userToken"]);
163
+ });
164
+ });
165
+
166
+ describe("validateCookieCaptureSpec — defensive guard catches malformed specs", () => {
167
+ it("flags unknown vendor", () => {
168
+ const { ok, errors } = validateCookieCaptureSpec(
169
+ [{ ...COOKIE_CAPTURE_SPECS[0], vendor: "ghostvendor" }],
170
+ { throwOnError: false },
171
+ );
172
+ expect(ok).toBe(false);
173
+ expect(errors.join(" ")).toMatch(/unknown vendor/);
174
+ });
175
+
176
+ it("flags loginUrl host not matching any cookieDomain", () => {
177
+ const broken = {
178
+ ...COOKIE_CAPTURE_SPECS[0],
179
+ loginUrl: "https://malicious.example.com/",
180
+ };
181
+ const { ok, errors } = validateCookieCaptureSpec([broken], { throwOnError: false });
182
+ expect(ok).toBe(false);
183
+ expect(errors.join(" ")).toMatch(/does not match any cookieDomain/);
184
+ });
185
+
186
+ it("flags empty requiredCookies / postLoginPathHints / invalid maxAge", () => {
187
+ const broken = {
188
+ ...COOKIE_CAPTURE_SPECS[0],
189
+ requiredCookies: [],
190
+ postLoginPathHints: [],
191
+ cookieMaxAgeHintDays: 0,
192
+ };
193
+ const { ok, errors } = validateCookieCaptureSpec([broken], { throwOnError: false });
194
+ expect(ok).toBe(false);
195
+ const joined = errors.join(" ");
196
+ expect(joined).toMatch(/requiredCookies/);
197
+ expect(joined).toMatch(/postLoginPathHints/);
198
+ expect(joined).toMatch(/cookieMaxAgeHintDays/);
199
+ });
200
+
201
+ it("flags duplicate vendor entries", () => {
202
+ const dup = [COOKIE_CAPTURE_SPECS[0], { ...COOKIE_CAPTURE_SPECS[0] }];
203
+ const { ok, errors } = validateCookieCaptureSpec(dup, { throwOnError: false });
204
+ expect(ok).toBe(false);
205
+ expect(errors.join(" ")).toMatch(/duplicate vendor/);
206
+ });
207
+
208
+ it("throws by default when malformed (no opts)", () => {
209
+ expect(() => validateCookieCaptureSpec([{ vendor: "ghost" }])).toThrow(/Invalid cookie capture spec/);
210
+ });
211
+ });
@@ -0,0 +1,262 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect, beforeEach, vi } from "vitest";
4
+
5
+ const {
6
+ createAIChatHealthChecker,
7
+ DEFAULT_INTERVAL_MS,
8
+ DEFAULT_FIRST_RUN_DELAY_MS,
9
+ } = require("../../lib/adapters/ai-chat-history/health-checker");
10
+
11
+ // ─── fakes ────────────────────────────────────────────────────────────────
12
+
13
+ function makeFakeAccountsStore({ initial = {} } = {}) {
14
+ const store = new Map(Object.entries(initial));
15
+ return {
16
+ get: vi.fn(async (v) => store.get(v) || null),
17
+ put: vi.fn(async (v, e) => store.set(v, e)),
18
+ delete: vi.fn(async (v) => store.delete(v)),
19
+ list: vi.fn(async () => Array.from(store.values())),
20
+ _store: store,
21
+ };
22
+ }
23
+
24
+ function makeFakeAdapter({ behaviors = {} } = {}) {
25
+ // behaviors = { vendor: ({ok, reason?, throw?: msg}) }
26
+ return {
27
+ registerVendor: vi.fn(async (vendor, _cookies) => {
28
+ const b = behaviors[vendor];
29
+ if (!b) return { ok: true, userId: "u_" + vendor };
30
+ if (b.throw) throw new Error(b.throw);
31
+ return b;
32
+ }),
33
+ };
34
+ }
35
+
36
+ function makeFakeTimers() {
37
+ // Deterministic timer dispatcher we can step through. We track scheduled
38
+ // callbacks + their delays in a queue and let tests `flush` them in order.
39
+ const scheduled = [];
40
+ let _now = 0;
41
+ let _id = 0;
42
+ const setTimeout = (fn, ms) => {
43
+ const id = ++_id;
44
+ scheduled.push({ id, type: "timeout", fireAt: _now + ms, fn });
45
+ return id;
46
+ };
47
+ const setInterval = (fn, ms) => {
48
+ const id = ++_id;
49
+ scheduled.push({ id, type: "interval", fireAt: _now + ms, ms, fn });
50
+ return id;
51
+ };
52
+ const clearTimeout = (id) => {
53
+ const i = scheduled.findIndex((s) => s.id === id);
54
+ if (i >= 0) scheduled.splice(i, 1);
55
+ };
56
+ const clearInterval = clearTimeout;
57
+ async function advance(ms) {
58
+ _now += ms;
59
+ while (true) {
60
+ const due = scheduled
61
+ .filter((s) => s.fireAt <= _now)
62
+ .sort((a, b) => a.fireAt - b.fireAt);
63
+ if (due.length === 0) break;
64
+ const next = due[0];
65
+ const idx = scheduled.indexOf(next);
66
+ scheduled.splice(idx, 1);
67
+ if (next.type === "interval") {
68
+ scheduled.push({ ...next, fireAt: next.fireAt + next.ms });
69
+ }
70
+ await next.fn();
71
+ // Flush any fire-and-forget microtasks the callback kicked off
72
+ // (runOnce returns a promise; the callback doesn't await it).
73
+ await new Promise((r) => setImmediate(r));
74
+ }
75
+ }
76
+ return {
77
+ setTimeout, setInterval, clearTimeout, clearInterval,
78
+ clock: () => _now,
79
+ advance,
80
+ _scheduled: scheduled,
81
+ };
82
+ }
83
+
84
+ // ─── construction guards ─────────────────────────────────────────────────
85
+
86
+ describe("createAIChatHealthChecker — guards", () => {
87
+ it("throws when accountsStore.list missing", () => {
88
+ expect(() => createAIChatHealthChecker({ accountsStore: {}, vendorAdapter: { registerVendor: () => {} } })).toThrow(/accountsStore.list/);
89
+ });
90
+ it("throws when accountsStore.put missing", () => {
91
+ expect(() => createAIChatHealthChecker({ accountsStore: { list: () => [] }, vendorAdapter: { registerVendor: () => {} } })).toThrow(/accountsStore.put/);
92
+ });
93
+ it("throws when vendorAdapter.registerVendor missing", () => {
94
+ expect(() => createAIChatHealthChecker({ accountsStore: makeFakeAccountsStore() })).toThrow(/vendorAdapter.registerVendor/);
95
+ });
96
+ });
97
+
98
+ // ─── runOnce behavior ────────────────────────────────────────────────────
99
+
100
+ describe("runOnce — health classification", () => {
101
+ let timers;
102
+ beforeEach(() => {
103
+ timers = makeFakeTimers();
104
+ });
105
+
106
+ it("marks vendors ok when adapter returns ok=true", async () => {
107
+ const store = makeFakeAccountsStore({
108
+ initial: {
109
+ deepseek: { vendor: "deepseek", cookies: { userToken: "x" }, cookieSpecVersion: 1 },
110
+ kimi: { vendor: "kimi", cookies: { access_token: "y" }, cookieSpecVersion: 1 },
111
+ },
112
+ });
113
+ const adapter = makeFakeAdapter();
114
+ const hc = createAIChatHealthChecker({
115
+ accountsStore: store, vendorAdapter: adapter, _deps: timers,
116
+ });
117
+ const r = await hc.runOnce();
118
+ expect(r).toMatchObject({ checked: 2, ok: 2, failed: 0, mismatch: 0 });
119
+ expect(store._store.get("deepseek").lastHealth.ok).toBe(true);
120
+ expect(store._store.get("kimi").lastHealth.ok).toBe(true);
121
+ });
122
+
123
+ it("marks vendors failed when adapter returns ok=false (with reason)", async () => {
124
+ const store = makeFakeAccountsStore({
125
+ initial: { deepseek: { vendor: "deepseek", cookies: {}, cookieSpecVersion: 1 } },
126
+ });
127
+ const adapter = makeFakeAdapter({
128
+ behaviors: { deepseek: { ok: false, reason: "COOKIE_EXPIRED" } },
129
+ });
130
+ const hc = createAIChatHealthChecker({
131
+ accountsStore: store, vendorAdapter: adapter, _deps: timers,
132
+ });
133
+ const r = await hc.runOnce();
134
+ expect(r).toMatchObject({ checked: 1, failed: 1, ok: 0 });
135
+ expect(store._store.get("deepseek").lastHealth).toMatchObject({
136
+ ok: false, reason: "COOKIE_EXPIRED",
137
+ });
138
+ });
139
+
140
+ it("marks SPEC_VERSION_MISMATCH when entry.cookieSpecVersion < specVersion", async () => {
141
+ const store = makeFakeAccountsStore({
142
+ initial: { deepseek: { vendor: "deepseek", cookies: { x: "y" }, cookieSpecVersion: 0 } },
143
+ });
144
+ const adapter = makeFakeAdapter();
145
+ const hc = createAIChatHealthChecker({
146
+ accountsStore: store, vendorAdapter: adapter, specVersion: 2, _deps: timers,
147
+ });
148
+ const r = await hc.runOnce();
149
+ expect(r).toMatchObject({ checked: 1, mismatch: 1, ok: 0, failed: 0 });
150
+ expect(store._store.get("deepseek").lastHealth).toMatchObject({
151
+ ok: false, reason: "SPEC_VERSION_MISMATCH",
152
+ });
153
+ // Adapter was NOT called because the version gate fired first
154
+ expect(adapter.registerVendor).not.toHaveBeenCalled();
155
+ });
156
+
157
+ it("captures ADAPTER_THREW when registerVendor rejects", async () => {
158
+ const store = makeFakeAccountsStore({
159
+ initial: { deepseek: { vendor: "deepseek", cookies: { x: "y" }, cookieSpecVersion: 1 } },
160
+ });
161
+ const adapter = makeFakeAdapter({ behaviors: { deepseek: { throw: "net down" } } });
162
+ const hc = createAIChatHealthChecker({
163
+ accountsStore: store, vendorAdapter: adapter, _deps: timers,
164
+ });
165
+ const r = await hc.runOnce();
166
+ expect(r).toMatchObject({ checked: 1, failed: 1 });
167
+ expect(store._store.get("deepseek").lastHealth).toMatchObject({
168
+ ok: false, reason: "ADAPTER_THREW",
169
+ });
170
+ });
171
+
172
+ it("skips a run if previous one is still in flight", async () => {
173
+ const store = makeFakeAccountsStore({
174
+ initial: { deepseek: { vendor: "deepseek", cookies: {}, cookieSpecVersion: 1 } },
175
+ });
176
+ let release;
177
+ const adapter = {
178
+ registerVendor: vi.fn(() => new Promise((resolve) => {
179
+ release = () => resolve({ ok: true, userId: "u" });
180
+ })),
181
+ };
182
+ const hc = createAIChatHealthChecker({
183
+ accountsStore: store, vendorAdapter: adapter, _deps: timers,
184
+ });
185
+ const p1 = hc.runOnce();
186
+ const r2 = await hc.runOnce();
187
+ expect(r2.skipped).toBe(true);
188
+ release();
189
+ await p1;
190
+ });
191
+
192
+ it("returns 0 counts on empty accounts list", async () => {
193
+ const hc = createAIChatHealthChecker({
194
+ accountsStore: makeFakeAccountsStore(),
195
+ vendorAdapter: makeFakeAdapter(),
196
+ _deps: timers,
197
+ });
198
+ const r = await hc.runOnce();
199
+ expect(r).toMatchObject({ checked: 0, ok: 0, failed: 0, mismatch: 0 });
200
+ });
201
+ });
202
+
203
+ // ─── start / stop / interval scheduling ──────────────────────────────────
204
+
205
+ describe("start / stop / interval", () => {
206
+ let timers, store, adapter;
207
+ beforeEach(() => {
208
+ timers = makeFakeTimers();
209
+ store = makeFakeAccountsStore({
210
+ initial: { deepseek: { vendor: "deepseek", cookies: { userToken: "x" }, cookieSpecVersion: 1 } },
211
+ });
212
+ adapter = makeFakeAdapter();
213
+ });
214
+
215
+ it("first run happens after firstRunDelayMs (30s default), then interval (6h)", async () => {
216
+ const hc = createAIChatHealthChecker({
217
+ accountsStore: store, vendorAdapter: adapter,
218
+ intervalMs: 6 * 3600_000, firstRunDelayMs: 30_000,
219
+ _deps: timers,
220
+ });
221
+ hc.start();
222
+ expect(hc.status().started).toBe(true);
223
+ // 10s in — nothing has fired yet
224
+ await timers.advance(10_000);
225
+ expect(adapter.registerVendor).not.toHaveBeenCalled();
226
+ // 30s mark — first run fires
227
+ await timers.advance(20_000);
228
+ expect(adapter.registerVendor).toHaveBeenCalledTimes(1);
229
+ // Another 6h — interval run #1
230
+ await timers.advance(6 * 3600_000);
231
+ expect(adapter.registerVendor).toHaveBeenCalledTimes(2);
232
+ });
233
+
234
+ it("start is idempotent — second call returns false", () => {
235
+ const hc = createAIChatHealthChecker({
236
+ accountsStore: store, vendorAdapter: adapter, _deps: timers,
237
+ });
238
+ expect(hc.start()).toBe(true);
239
+ expect(hc.start()).toBe(false);
240
+ });
241
+
242
+ it("stop clears pending first-run + interval", async () => {
243
+ const hc = createAIChatHealthChecker({
244
+ accountsStore: store, vendorAdapter: adapter, firstRunDelayMs: 30_000, _deps: timers,
245
+ });
246
+ hc.start();
247
+ hc.stop();
248
+ expect(hc.status().started).toBe(false);
249
+ // 60s in — nothing fires since we stopped
250
+ await timers.advance(60_000);
251
+ expect(adapter.registerVendor).not.toHaveBeenCalled();
252
+ });
253
+
254
+ it("uses default 6h interval + 30s delay when not specified", () => {
255
+ const hc = createAIChatHealthChecker({
256
+ accountsStore: store, vendorAdapter: adapter, _deps: timers,
257
+ });
258
+ const s = hc.status();
259
+ expect(s.intervalMs).toBe(DEFAULT_INTERVAL_MS);
260
+ expect(s.firstRunDelayMs).toBe(DEFAULT_FIRST_RUN_DELAY_MS);
261
+ });
262
+ });
@@ -21,7 +21,7 @@ const {
21
21
  // ─── vendor-spec assertion ──────────────────────────────────────────────
22
22
 
23
23
  describe("assertVendorSpec — SUPPORTED_VENDORS", () => {
24
- it("8 vendors are declared", () => {
24
+ it("9 vendors are declared (Phase 10.2 +doubao scaffold)", () => {
25
25
  expect(SUPPORTED_VENDORS).toEqual([
26
26
  "deepseek",
27
27
  "kimi",
@@ -31,6 +31,7 @@ describe("assertVendorSpec — SUPPORTED_VENDORS", () => {
31
31
  "qianfan",
32
32
  "coze",
33
33
  "dreamina",
34
+ "doubao",
34
35
  ]);
35
36
  });
36
37
 
@@ -184,9 +185,9 @@ describe("AIChatHistoryAdapter.sync — skeleton path", () => {
184
185
  const out = [];
185
186
  for await (const ev of a.sync({ vendors: ["deepseek"] })) out.push(ev);
186
187
  expect(out.length).toBe(1);
187
- expect(out[0].kind).toBe("vendor-not-wired");
188
- expect(out[0].vendor).toBe("deepseek");
189
- expect(out[0].error).toBe("VENDOR_NOT_WIRED");
188
+ expect(out[0].payload.kind).toBe("vendor-not-wired");
189
+ expect(out[0].payload.vendor).toBe("deepseek");
190
+ expect(out[0].payload.error).toBe("VENDOR_NOT_WIRED");
190
191
  });
191
192
 
192
193
  it("can be driven end-to-end with a mock vendor spec", async () => {
@@ -237,9 +238,9 @@ describe("AIChatHistoryAdapter.sync — skeleton path", () => {
237
238
  const out = [];
238
239
  for await (const ev of a.sync({ vendors: ["deepseek"] })) out.push(ev);
239
240
  expect(out.length).toBe(3); // 1 conv + 2 msgs
240
- expect(out[0].kind).toBe("conversation");
241
- expect(out[1].kind).toBe("message");
242
- expect(out[2].kind).toBe("message");
241
+ expect(out[0].payload.kind).toBe("conversation");
242
+ expect(out[1].payload.kind).toBe("message");
243
+ expect(out[2].payload.kind).toBe("message");
243
244
 
244
245
  // Drive normalize() over each
245
246
  const batches = out.map((r) => a.normalize(r));