@chainlesschain/personal-data-hub 0.4.18 → 0.4.24

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 (53) hide show
  1. package/__tests__/adapters/bank-family.test.js +125 -0
  2. package/__tests__/adapters/biz-tianyancha.test.js +159 -0
  3. package/__tests__/adapters/car-mercedesme.test.js +74 -0
  4. package/__tests__/adapters/doc-camscanner.test.js +147 -0
  5. package/__tests__/adapters/finance-dcep.test.js +74 -0
  6. package/__tests__/adapters/fitness-joyrun.test.js +82 -0
  7. package/__tests__/adapters/gov-12123.test.js +103 -0
  8. package/__tests__/adapters/gov-ixiamen.test.js +150 -0
  9. package/__tests__/adapters/gov-tax.test.js +135 -0
  10. package/__tests__/adapters/health-meiyou.test.js +125 -0
  11. package/__tests__/adapters/music-qq.test.js +112 -0
  12. package/__tests__/adapters/reading-family.test.js +108 -0
  13. package/__tests__/adapters/social-dongchedi.test.js +165 -0
  14. package/__tests__/adapters/travel-didi-consumer.test.js +66 -0
  15. package/__tests__/adapters/video-xigua.test.js +106 -0
  16. package/__tests__/adapters/wework-pc.test.js +124 -0
  17. package/__tests__/audio-ximalaya-snapshot.test.js +279 -0
  18. package/__tests__/fitness-keep-snapshot.test.js +224 -0
  19. package/__tests__/shopping-eleme-snapshot.test.js +454 -0
  20. package/__tests__/shopping-vipshop-snapshot.test.js +425 -0
  21. package/__tests__/shopping-xianyu-snapshot.test.js +451 -0
  22. package/__tests__/social-douban-snapshot.test.js +351 -0
  23. package/lib/adapter-guide.js +31 -3
  24. package/lib/adapters/_bank-base.js +405 -0
  25. package/lib/adapters/_reading-base.js +315 -0
  26. package/lib/adapters/audio-ximalaya/index.js +414 -0
  27. package/lib/adapters/bank-bankcomm/index.js +27 -0
  28. package/lib/adapters/bank-boc/index.js +26 -0
  29. package/lib/adapters/bank-cmbc/index.js +26 -0
  30. package/lib/adapters/bank-icbc/index.js +27 -0
  31. package/lib/adapters/biz-tianyancha/index.js +348 -0
  32. package/lib/adapters/car-mercedesme/index.js +225 -0
  33. package/lib/adapters/doc-camscanner/index.js +102 -0
  34. package/lib/adapters/finance-dcep/index.js +302 -0
  35. package/lib/adapters/fitness-joyrun/index.js +295 -0
  36. package/lib/adapters/fitness-keep/index.js +343 -0
  37. package/lib/adapters/gov-12123/index.js +391 -0
  38. package/lib/adapters/gov-ixiamen/index.js +380 -0
  39. package/lib/adapters/gov-tax/index.js +451 -0
  40. package/lib/adapters/health-meiyou/index.js +393 -0
  41. package/lib/adapters/music-qq/index.js +372 -0
  42. package/lib/adapters/reading-fanqie/index.js +61 -0
  43. package/lib/adapters/reading-qimao/index.js +61 -0
  44. package/lib/adapters/shopping-eleme/index.js +441 -0
  45. package/lib/adapters/shopping-vipshop/index.js +429 -0
  46. package/lib/adapters/shopping-xianyu/index.js +454 -0
  47. package/lib/adapters/social-dongchedi/index.js +360 -0
  48. package/lib/adapters/social-douban/index.js +564 -0
  49. package/lib/adapters/travel-didi-consumer/index.js +148 -0
  50. package/lib/adapters/video-xigua/index.js +68 -0
  51. package/lib/adapters/wework-pc/index.js +31 -0
  52. package/lib/index.js +52 -0
  53. package/package.json +1 -1
@@ -0,0 +1,279 @@
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
+ XimalayaAdapter,
11
+ SNAPSHOT_SCHEMA_VERSION,
12
+ VALID_KINDS,
13
+ extractList,
14
+ trackItemToRecord,
15
+ albumItemToRecord,
16
+ } = require("../lib/adapters/audio-ximalaya");
17
+ const { assertAdapter } = require("../lib/adapter-spec");
18
+ const { validateBatch } = require("../lib/batch");
19
+
20
+ // 喜马拉雅 (Ximalaya) — 听书/播客; mirrors music-kugou's play/favorite/subscribe
21
+ // shape. signing injected (signProvider). pure-Node.
22
+
23
+ function writeSnapshot(dir, snapshot) {
24
+ const p = path.join(dir, "audio-ximalaya.json");
25
+ fs.writeFileSync(p, JSON.stringify(snapshot), "utf-8");
26
+ return p;
27
+ }
28
+
29
+ describe("XimalayaAdapter snapshot mode", () => {
30
+ let tmpDir;
31
+ beforeEach(() => {
32
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "xmly-snap-"));
33
+ });
34
+
35
+ it("exports schema constants", () => {
36
+ expect(SNAPSHOT_SCHEMA_VERSION).toBe(1);
37
+ expect(VALID_KINDS).toEqual(["play", "favorite", "subscribe"]);
38
+ });
39
+
40
+ it("authenticate(inputPath) ok when readable", async () => {
41
+ const p = writeSnapshot(tmpDir, { schemaVersion: 1, snapshottedAt: Date.now(), events: [] });
42
+ const a = new XimalayaAdapter();
43
+ const res = await a.authenticate({ inputPath: p });
44
+ expect(res.ok).toBe(true);
45
+ expect(res.mode).toBe("snapshot-file");
46
+ });
47
+
48
+ it("authenticate() no input → NO_INPUT", async () => {
49
+ const a = new XimalayaAdapter();
50
+ const res = await a.authenticate({});
51
+ expect(res.ok).toBe(false);
52
+ expect(res.reason).toBe("NO_INPUT");
53
+ });
54
+
55
+ it("sync() without input throws with signProvider hint", async () => {
56
+ const a = new XimalayaAdapter();
57
+ let threw = null;
58
+ try {
59
+ for await (const _r of a.sync({})) { /* drain */ }
60
+ } catch (err) {
61
+ threw = err;
62
+ }
63
+ expect(String(threw.message)).toMatch(/signProvider/);
64
+ });
65
+
66
+ it("rejects schemaVersion mismatch", async () => {
67
+ const p = writeSnapshot(tmpDir, { schemaVersion: 9, snapshottedAt: Date.now(), events: [] });
68
+ const a = new XimalayaAdapter();
69
+ let threw = null;
70
+ try {
71
+ for await (const _r of a.sync({ inputPath: p })) { /* drain */ }
72
+ } catch (err) {
73
+ threw = err;
74
+ }
75
+ expect(String(threw.message)).toMatch(/schemaVersion mismatch/);
76
+ });
77
+
78
+ it("play (收听节目) → MEDIA event + MEDIA item", async () => {
79
+ const now = Date.now();
80
+ const p = writeSnapshot(tmpDir, {
81
+ schemaVersion: 1, snapshottedAt: now,
82
+ account: { userId: "u1", name: "alice" },
83
+ events: [
84
+ { kind: "play", id: "t1", trackId: "98765", title: "第1集 三体广播剧",
85
+ anchor: "729声工场", album: "三体", durationSec: 1800, capturedAt: now - 1000 },
86
+ ],
87
+ });
88
+ const a = new XimalayaAdapter();
89
+ const raws = [];
90
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
91
+ expect(raws.length).toBe(1);
92
+ expect(raws[0].originalId).toBe("ximalaya:play:t1");
93
+ const batch = a.normalize(raws[0]);
94
+ expect(validateBatch(batch).valid).toBe(true);
95
+ expect(batch.events[0].subtype).toBe("media");
96
+ expect(batch.events[0].content.title).toContain("收听: 第1集 三体广播剧 - 729声工场");
97
+ expect(batch.items[0].subtype).toBe("media");
98
+ expect(batch.items[0].name).toBe("第1集 三体广播剧 - 729声工场");
99
+ expect(batch.events[0].extra.album).toBe("三体");
100
+ });
101
+
102
+ it("favorite → LIKE event", async () => {
103
+ const now = Date.now();
104
+ const p = writeSnapshot(tmpDir, {
105
+ schemaVersion: 1, snapshottedAt: now,
106
+ events: [{ kind: "favorite", id: "f1", trackId: "555", title: "收藏的播客", anchor: "主播X" }],
107
+ });
108
+ const a = new XimalayaAdapter();
109
+ const raws = [];
110
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
111
+ const batch = a.normalize(raws[0]);
112
+ expect(validateBatch(batch).valid).toBe(true);
113
+ expect(batch.events[0].subtype).toBe("like");
114
+ expect(batch.events[0].content.title).toContain("收藏: 收藏的播客");
115
+ });
116
+
117
+ it("subscribe → TOPIC (album)", async () => {
118
+ const now = Date.now();
119
+ const p = writeSnapshot(tmpDir, {
120
+ schemaVersion: 1, snapshottedAt: now,
121
+ events: [{ kind: "subscribe", id: "s1", albumId: "30001", album: "得到·精英日课",
122
+ trackCount: 365, anchor: "罗振宇" }],
123
+ });
124
+ const a = new XimalayaAdapter();
125
+ const raws = [];
126
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
127
+ const batch = a.normalize(raws[0]);
128
+ expect(validateBatch(batch).valid).toBe(true);
129
+ expect(batch.events.length).toBe(0);
130
+ expect(batch.topics.length).toBe(1);
131
+ expect(batch.topics[0].id).toBe("topic-ximalaya-album-30001");
132
+ expect(batch.topics[0].name).toBe("得到·精英日课");
133
+ expect(batch.topics[0].extra.trackCount).toBe(365);
134
+ });
135
+
136
+ it("respects include opt-out + limit", async () => {
137
+ const now = Date.now();
138
+ const events = [
139
+ { kind: "play", id: "p1", trackId: "1", title: "a", capturedAt: now },
140
+ { kind: "favorite", id: "f1", trackId: "2", title: "b" },
141
+ { kind: "subscribe", id: "s1", albumId: "3", album: "c" },
142
+ ];
143
+ const p = writeSnapshot(tmpDir, { schemaVersion: 1, snapshottedAt: now, events });
144
+ const a = new XimalayaAdapter();
145
+ const raws = [];
146
+ for await (const r of a.sync({ inputPath: p, include: { favorite: false, subscribe: false } })) raws.push(r);
147
+ expect(raws.length).toBe(1);
148
+ expect(raws[0].kind).toBe("play");
149
+ });
150
+
151
+ it("filters unknown kinds", async () => {
152
+ const now = Date.now();
153
+ const p = writeSnapshot(tmpDir, {
154
+ schemaVersion: 1, snapshottedAt: now,
155
+ events: [
156
+ { kind: "play", id: "p1", trackId: "1", title: "a", capturedAt: now },
157
+ { kind: "comment", id: "c1" },
158
+ ],
159
+ });
160
+ const a = new XimalayaAdapter();
161
+ const raws = [];
162
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
163
+ expect(raws.length).toBe(1);
164
+ });
165
+
166
+ it("advertises capabilities + passes assertAdapter", () => {
167
+ const a = new XimalayaAdapter();
168
+ expect(a.capabilities).toContain("sync:snapshot");
169
+ expect(a.capabilities).toContain("sync:cookie-api");
170
+ expect(assertAdapter(a).ok).toBe(true);
171
+ });
172
+ });
173
+
174
+ describe("XimalayaAdapter cookie-api mode", () => {
175
+ it("authenticate(cookie) ok with cookies (userId optional)", async () => {
176
+ const a = new XimalayaAdapter({ account: { cookies: "1&_token=ok" } });
177
+ const res = await a.authenticate();
178
+ expect(res.ok).toBe(true);
179
+ expect(res.mode).toBe("cookie");
180
+ });
181
+
182
+ it("fetches plays/favorites/subscribes and normalizes", async () => {
183
+ const byUrl = (url) => {
184
+ if (url.includes("/history")) {
185
+ return { data: { list: [{ trackId: 98765, title: "第1集", nickname: "声工场", albumTitle: "三体", duration: 1800, startedAt: 1700000000000 }] } };
186
+ }
187
+ if (url.includes("/favorite")) {
188
+ return { data: { tracks: [{ track_id: 555, track_title: "播客A", anchor_name: "主播X" }] } };
189
+ }
190
+ if (url.includes("/subscribe")) {
191
+ return { data: { albums: [{ albumId: 30001, albumTitle: "精英日课", includeTrackCount: 365, nickname: "罗振宇" }] } };
192
+ }
193
+ return {};
194
+ };
195
+ const fetchFn = async (opts) => byUrl(opts.url);
196
+ const a = new XimalayaAdapter({ account: { userId: "u1", cookies: "1&_token=ok" }, fetchFn });
197
+ const raws = [];
198
+ for await (const r of a.sync({})) raws.push(r);
199
+ expect(raws.map((r) => r.kind).sort()).toEqual(["favorite", "play", "subscribe"]);
200
+
201
+ const play = raws.find((r) => r.kind === "play");
202
+ const pb = a.normalize(play);
203
+ expect(validateBatch(pb).valid).toBe(true);
204
+ expect(pb.events[0].content.title).toContain("收听: 第1集 - 声工场");
205
+ expect(pb.items[0].name).toBe("第1集 - 声工场");
206
+
207
+ const sub = raws.find((r) => r.kind === "subscribe");
208
+ const sb = a.normalize(sub);
209
+ expect(sb.topics[0].name).toBe("精英日课");
210
+ expect(sb.topics[0].extra.trackCount).toBe(365);
211
+ });
212
+
213
+ it("invokes signProvider, passes sign to fetchFn", async () => {
214
+ let seen = null;
215
+ const signProvider = async () => "SIG";
216
+ const fetchFn = async (opts) => {
217
+ seen = opts.sign;
218
+ return {};
219
+ };
220
+ const a = new XimalayaAdapter({ account: { cookies: "x=1" }, fetchFn, signProvider });
221
+ for await (const _r of a.sync({ include: { favorite: false, subscribe: false } })) { /* drain */ }
222
+ expect(seen).toBe("SIG");
223
+ });
224
+
225
+ it("paginates plays until short page", async () => {
226
+ const all = Array.from({ length: 45 }, (_, i) => ({ trackId: i + 1, title: `t${i}`, startedAt: 1700000000000 }));
227
+ const seenPages = [];
228
+ const fetchFn = async (opts) => {
229
+ if (!opts.url.includes("/history")) return {};
230
+ const page = opts.query.page;
231
+ seenPages.push(page);
232
+ return { list: all.slice((page - 1) * 30, page * 30) };
233
+ };
234
+ const a = new XimalayaAdapter({ account: { cookies: "x=1" }, fetchFn });
235
+ const raws = [];
236
+ for await (const r of a.sync({ include: { favorite: false, subscribe: false } })) raws.push(r);
237
+ expect(raws.length).toBe(45);
238
+ expect(seenPages).toEqual([1, 2]);
239
+ });
240
+
241
+ it("extractList + item mappers tolerate shapes", () => {
242
+ expect(extractList({ list: [1] })).toEqual([1]);
243
+ expect(extractList({ data: { tracks: [2] } })).toEqual([2]);
244
+ expect(extractList({ data: { albums: [3] } })).toEqual([3]);
245
+ expect(extractList(null)).toEqual([]);
246
+ expect(trackItemToRecord({ trackId: 7, title: "x" }).trackId).toBe("7");
247
+ expect(trackItemToRecord({})).toBe(null);
248
+ expect(albumItemToRecord({ albumId: 9, albumTitle: "A" }).album).toBe("A");
249
+ expect(albumItemToRecord({})).toBe(null);
250
+ });
251
+
252
+ it("uses opts.*Url overrides", async () => {
253
+ const seen = [];
254
+ const fetchFn = async (opts) => {
255
+ seen.push(opts.url);
256
+ return {};
257
+ };
258
+ const a = new XimalayaAdapter({
259
+ account: { cookies: "x=1" },
260
+ fetchFn,
261
+ playsUrl: "https://x/p",
262
+ favoritesUrl: "https://x/f",
263
+ subscribesUrl: "https://x/s",
264
+ });
265
+ for await (const _r of a.sync({})) { /* drain */ }
266
+ expect(seen).toEqual(["https://x/p", "https://x/f", "https://x/s"]);
267
+ });
268
+
269
+ it("default fetchFn throws legible error", async () => {
270
+ const a = new XimalayaAdapter({ account: { cookies: "x=1" } });
271
+ let threw = null;
272
+ try {
273
+ for await (const _r of a.sync({})) { /* drain */ }
274
+ } catch (err) {
275
+ threw = err;
276
+ }
277
+ expect(String(threw.message)).toMatch(/no fetchFn configured/);
278
+ });
279
+ });
@@ -0,0 +1,224 @@
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
+ KeepAdapter,
11
+ SNAPSHOT_SCHEMA_VERSION,
12
+ VALID_SNAPSHOT_KINDS,
13
+ mapWorkout,
14
+ extractList,
15
+ typeLabel,
16
+ } = require("../lib/adapters/fitness-keep");
17
+ const { assertAdapter } = require("../lib/adapter-spec");
18
+ const { validateBatch } = require("../lib/batch");
19
+
20
+ // Keep (健身) — mirrors fitness-joyrun but multi-type (workoutType). best-effort
21
+ // scaffold; signing injected. pure-Node.
22
+
23
+ function writeSnapshot(dir, snapshot) {
24
+ const p = path.join(dir, "fitness-keep.json");
25
+ fs.writeFileSync(p, JSON.stringify(snapshot), "utf-8");
26
+ return p;
27
+ }
28
+
29
+ describe("KeepAdapter snapshot mode", () => {
30
+ let tmpDir;
31
+ beforeEach(() => {
32
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "keep-snap-"));
33
+ });
34
+
35
+ it("exports schema constants", () => {
36
+ expect(SNAPSHOT_SCHEMA_VERSION).toBe(1);
37
+ expect(VALID_SNAPSHOT_KINDS).toEqual(["workout"]);
38
+ });
39
+
40
+ it("authenticate(inputPath) ok; authenticate() no input → NO_INPUT", async () => {
41
+ const p = writeSnapshot(tmpDir, { schemaVersion: 1, snapshottedAt: Date.now(), events: [] });
42
+ const a = new KeepAdapter();
43
+ expect((await a.authenticate({ inputPath: p })).ok).toBe(true);
44
+ const r = await a.authenticate({});
45
+ expect(r.ok).toBe(false);
46
+ expect(r.reason).toBe("NO_INPUT");
47
+ });
48
+
49
+ it("rejects schemaVersion mismatch", async () => {
50
+ const p = writeSnapshot(tmpDir, { schemaVersion: 9, snapshottedAt: Date.now(), events: [] });
51
+ const a = new KeepAdapter();
52
+ let threw = null;
53
+ try {
54
+ for await (const _r of a.sync({ inputPath: p })) { /* drain */ }
55
+ } catch (err) {
56
+ threw = err;
57
+ }
58
+ expect(String(threw.message)).toMatch(/schemaVersion mismatch/);
59
+ });
60
+
61
+ it("running workout → OTHER event '运动: 跑步 X km' + GPS-bearing extra", async () => {
62
+ const now = Date.now();
63
+ const p = writeSnapshot(tmpDir, {
64
+ schemaVersion: 1, snapshottedAt: now,
65
+ account: { userId: "u1" },
66
+ events: [
67
+ { kind: "workout", id: "w1", workoutId: "9001", type: "running", name: "晨跑",
68
+ time: 1700000000, distanceMeters: 5230, durationSec: 1800, calories: 320, steps: 6400 },
69
+ ],
70
+ });
71
+ const a = new KeepAdapter();
72
+ const raws = [];
73
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
74
+ expect(raws.length).toBe(1);
75
+ expect(raws[0].originalId).toBe("keep:workout:9001");
76
+ const batch = a.normalize(raws[0]);
77
+ expect(validateBatch(batch).valid).toBe(true);
78
+ expect(batch.events[0].subtype).toBe("other");
79
+ expect(batch.events[0].content.title).toBe("运动: 跑步 5.23 km");
80
+ expect(batch.events[0].extra.workoutType).toBe("running");
81
+ expect(batch.events[0].extra.calories).toBe(320);
82
+ });
83
+
84
+ it("non-distance workout (yoga) → '运动: 瑜伽 N 分钟'", async () => {
85
+ const now = Date.now();
86
+ const p = writeSnapshot(tmpDir, {
87
+ schemaVersion: 1, snapshottedAt: now,
88
+ events: [
89
+ { kind: "workout", id: "w2", workoutId: "9002", type: "yoga", name: "晚间瑜伽",
90
+ time: now, durationSec: 1500, calories: 120 },
91
+ ],
92
+ });
93
+ const a = new KeepAdapter();
94
+ const raws = [];
95
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
96
+ const batch = a.normalize(raws[0]);
97
+ expect(validateBatch(batch).valid).toBe(true);
98
+ expect(batch.events[0].content.title).toBe("运动: 瑜伽 25 分钟");
99
+ });
100
+
101
+ it("unknown type falls back to raw token; no metrics → bare '运动: <type>'", async () => {
102
+ const now = Date.now();
103
+ const p = writeSnapshot(tmpDir, {
104
+ schemaVersion: 1, snapshottedAt: now,
105
+ events: [{ kind: "workout", id: "w3", workoutId: "9003", type: "parkour", time: now }],
106
+ });
107
+ const a = new KeepAdapter();
108
+ const raws = [];
109
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
110
+ const batch = a.normalize(raws[0]);
111
+ expect(batch.events[0].content.title).toBe("运动: parkour");
112
+ });
113
+
114
+ it("respects include opt-out + limit", async () => {
115
+ const now = Date.now();
116
+ const events = Array.from({ length: 4 }, (_, i) => ({
117
+ kind: "workout", id: `w${i}`, workoutId: String(100 + i), type: "running",
118
+ time: now - i * 1000, distanceMeters: 3000,
119
+ }));
120
+ const p = writeSnapshot(tmpDir, { schemaVersion: 1, snapshottedAt: now, events });
121
+ const a = new KeepAdapter();
122
+ let raws = [];
123
+ for await (const r of a.sync({ inputPath: p, limit: 2 })) raws.push(r);
124
+ expect(raws.length).toBe(2);
125
+ raws = [];
126
+ for await (const r of a.sync({ inputPath: p, include: { workout: false } })) raws.push(r);
127
+ expect(raws.length).toBe(0);
128
+ });
129
+
130
+ it("advertises capabilities + passes assertAdapter", () => {
131
+ const a = new KeepAdapter();
132
+ expect(a.capabilities).toContain("sync:snapshot");
133
+ expect(a.capabilities).toContain("sync:cookie-api");
134
+ expect(assertAdapter(a).ok).toBe(true);
135
+ });
136
+ });
137
+
138
+ describe("KeepAdapter cookie-api mode", () => {
139
+ it("authenticate(cookie) returns unverified:true (best-effort scaffold)", async () => {
140
+ const a = new KeepAdapter({ account: { cookies: "token=ok" } });
141
+ const res = await a.authenticate();
142
+ expect(res.ok).toBe(true);
143
+ expect(res.mode).toBe("cookie");
144
+ expect(res.unverified).toBe(true);
145
+ });
146
+
147
+ it("fetches workouts via fetchFn and normalizes (km vs meters heuristic)", async () => {
148
+ const fetchFn = async () => ({
149
+ data: {
150
+ records: [
151
+ { workoutId: 555, type: "cycling", name: "骑行", doneDate: 1700000000, distance: 12.5, duration: 2400, kcal: 410 },
152
+ ],
153
+ },
154
+ });
155
+ const a = new KeepAdapter({ account: { userId: "u1", cookies: "token=ok" }, fetchFn });
156
+ const raws = [];
157
+ for await (const r of a.sync({})) raws.push(r);
158
+ expect(raws.length).toBe(1);
159
+ const batch = a.normalize(raws[0]);
160
+ expect(validateBatch(batch).valid).toBe(true);
161
+ // distance 12.5 (looks like km, no meter field) → 12500 m → 12.50 km
162
+ expect(batch.events[0].content.title).toBe("运动: 骑行 12.50 km");
163
+ expect(batch.events[0].extra.calories).toBe(410);
164
+ });
165
+
166
+ it("invokes signProvider, passes sign to fetchFn", async () => {
167
+ let seen = null;
168
+ const signProvider = async () => "SIG";
169
+ const fetchFn = async (opts) => {
170
+ seen = opts.sign;
171
+ return { list: [] };
172
+ };
173
+ const a = new KeepAdapter({ account: { cookies: "x=1" }, fetchFn, signProvider });
174
+ for await (const _r of a.sync({})) { /* drain */ }
175
+ expect(seen).toBe("SIG");
176
+ });
177
+
178
+ it("paginates until short page", async () => {
179
+ const all = Array.from({ length: 45 }, (_, i) => ({ workoutId: i + 1, type: "running", time: 1700000000, distanceMeters: 3000 }));
180
+ const seenPages = [];
181
+ const fetchFn = async (opts) => {
182
+ const page = opts.query.page;
183
+ seenPages.push(page);
184
+ return { list: all.slice((page - 1) * 30, page * 30) };
185
+ };
186
+ const a = new KeepAdapter({ account: { cookies: "x=1" }, fetchFn });
187
+ const raws = [];
188
+ for await (const r of a.sync({})) raws.push(r);
189
+ expect(raws.length).toBe(45);
190
+ expect(seenPages).toEqual([1, 2]);
191
+ });
192
+
193
+ it("mapWorkout / extractList / typeLabel helpers", () => {
194
+ expect(mapWorkout({ workoutId: 7, type: "running", distanceMeters: 5000 }).distanceMeters).toBe(5000);
195
+ expect(mapWorkout({})).toBe(null);
196
+ expect(extractList({ data: { logs: [1] } })).toEqual([1]);
197
+ expect(extractList(null)).toEqual([]);
198
+ expect(typeLabel("yoga")).toBe("瑜伽");
199
+ expect(typeLabel("xyz")).toBe("xyz");
200
+ expect(typeLabel(null)).toBe("运动");
201
+ });
202
+
203
+ it("uses opts.listUrl override", async () => {
204
+ let seenUrl = null;
205
+ const fetchFn = async (opts) => {
206
+ seenUrl = opts.url;
207
+ return { list: [] };
208
+ };
209
+ const a = new KeepAdapter({ account: { cookies: "x=1" }, fetchFn, listUrl: "https://x/w" });
210
+ for await (const _r of a.sync({})) { /* drain */ }
211
+ expect(seenUrl).toBe("https://x/w");
212
+ });
213
+
214
+ it("default fetchFn throws legible error", async () => {
215
+ const a = new KeepAdapter({ account: { cookies: "x=1" } });
216
+ let threw = null;
217
+ try {
218
+ for await (const _r of a.sync({})) { /* drain */ }
219
+ } catch (err) {
220
+ threw = err;
221
+ }
222
+ expect(String(threw.message)).toMatch(/no fetchFn configured/);
223
+ });
224
+ });