@chainlesschain/personal-data-hub 0.3.1 → 0.3.6

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 (60) hide show
  1. package/__tests__/adapters/email-adapter-snapshot.test.js +237 -0
  2. package/__tests__/adapters/email-adapter.test.js +1 -1
  3. package/__tests__/adapters/email-pdf-extractor.test.js +1 -1
  4. package/__tests__/adapters/email-retry-progress.test.js +1 -1
  5. package/__tests__/adapters/email-templates.test.js +1 -1
  6. package/__tests__/adapters/social-bilibili-adb-api-client.test.js +721 -0
  7. package/__tests__/adapters/social-bilibili-adb-chromium-cookies-reader.test.js +346 -0
  8. package/__tests__/adapters/social-bilibili-adb-collector.test.js +284 -0
  9. package/__tests__/adapters/social-bilibili-adb-cookies-extension.test.js +343 -0
  10. package/__tests__/adapters/social-bilibili-adb-snapshot-builder.test.js +296 -0
  11. package/__tests__/adapters/social-douyin-adb-collector.test.js +254 -0
  12. package/__tests__/adapters/social-douyin-adb-im-db-parser.test.js +304 -0
  13. package/__tests__/adapters/social-douyin-adb-snapshot-builder.test.js +216 -0
  14. package/__tests__/adapters/social-weibo-adb-api-client.test.js +362 -0
  15. package/__tests__/adapters/social-weibo-adb-collector.test.js +201 -0
  16. package/__tests__/adapters/social-weibo-adb-snapshot-builder.test.js +189 -0
  17. package/__tests__/adapters/social-xiaohongshu-adb-collector.test.js +207 -0
  18. package/__tests__/adapters/social-xiaohongshu-adb-sign.test.js +130 -0
  19. package/__tests__/adapters/system-data-android.test.js +32 -1
  20. package/__tests__/longtail-adapters.test.js +15 -2
  21. package/__tests__/shopping-adapters.test.js +96 -0
  22. package/__tests__/sign-providers.test.js +62 -0
  23. package/__tests__/travel-adapters.test.js +66 -0
  24. package/__tests__/whatsapp-adapter.test.js +5 -2
  25. package/lib/adapters/browser-history-chrome/chrome-db-reader.js +11 -1
  26. package/lib/adapters/email-imap/email-adapter.js +224 -17
  27. package/lib/adapters/messaging-telegram/index.js +15 -12
  28. package/lib/adapters/messaging-whatsapp/index.js +15 -12
  29. package/lib/adapters/shopping-taobao/index.js +161 -21
  30. package/lib/adapters/social-bilibili-adb/api-client.js +555 -0
  31. package/lib/adapters/social-bilibili-adb/chromium-cookies-reader.js +296 -0
  32. package/lib/adapters/social-bilibili-adb/collector.js +190 -0
  33. package/lib/adapters/social-bilibili-adb/cookies-extension.js +250 -0
  34. package/lib/adapters/social-bilibili-adb/index.js +51 -0
  35. package/lib/adapters/social-bilibili-adb/snapshot-builder.js +197 -0
  36. package/lib/adapters/social-douyin/index.js +4 -0
  37. package/lib/adapters/social-douyin-adb/collector.js +165 -0
  38. package/lib/adapters/social-douyin-adb/db-extension.js +281 -0
  39. package/lib/adapters/social-douyin-adb/im-db-parser.js +287 -0
  40. package/lib/adapters/social-douyin-adb/index.js +57 -0
  41. package/lib/adapters/social-douyin-adb/snapshot-builder.js +174 -0
  42. package/lib/adapters/social-weibo-adb/api-client.js +281 -0
  43. package/lib/adapters/social-weibo-adb/collector.js +169 -0
  44. package/lib/adapters/social-weibo-adb/cookies-extension.js +251 -0
  45. package/lib/adapters/social-weibo-adb/index.js +55 -0
  46. package/lib/adapters/social-weibo-adb/snapshot-builder.js +145 -0
  47. package/lib/adapters/social-xiaohongshu-adb/api-client.js +278 -0
  48. package/lib/adapters/social-xiaohongshu-adb/collector.js +158 -0
  49. package/lib/adapters/social-xiaohongshu-adb/cookies-extension.js +211 -0
  50. package/lib/adapters/social-xiaohongshu-adb/index.js +50 -0
  51. package/lib/adapters/social-xiaohongshu-adb/sign.js +90 -0
  52. package/lib/adapters/social-xiaohongshu-adb/snapshot-builder.js +126 -0
  53. package/lib/adapters/system-data-android/adapter.js +77 -3
  54. package/lib/adapters/travel-amap/index.js +16 -10
  55. package/lib/adapters/travel-ctrip/index.js +25 -9
  56. package/lib/adapters/vscode/vscode-reader.js +7 -1
  57. package/lib/sign-providers/index.js +20 -0
  58. package/lib/sign-providers/interface.js +82 -0
  59. package/lib/sign-providers/null-sign-provider.js +30 -0
  60. package/package.json +6 -1
@@ -0,0 +1,216 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Phase 2a — Douyin snapshot-builder unit cover.
5
+ *
6
+ * Mirrors social-bilibili-adb-snapshot-builder.test.js but with the
7
+ * Douyin event shapes (kind=message / kind=contact).
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
11
+ import { existsSync, readFileSync, mkdtempSync, rmSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { tmpdir } from "node:os";
14
+
15
+ const {
16
+ buildSnapshot,
17
+ writeSnapshotJson,
18
+ cleanupSnapshotJson,
19
+ SNAPSHOT_SCHEMA_VERSION,
20
+ } = require("../../lib/adapters/social-douyin-adb/snapshot-builder");
21
+
22
+ let tmpDir;
23
+
24
+ beforeEach(() => {
25
+ tmpDir = mkdtempSync(join(tmpdir(), "cc-douyin-snap-test-"));
26
+ });
27
+
28
+ afterEach(() => {
29
+ try {
30
+ rmSync(tmpDir, { recursive: true, force: true });
31
+ } catch (_e) {
32
+ // ignore
33
+ }
34
+ });
35
+
36
+ describe("buildSnapshot — schema contract", () => {
37
+ it("returns documented schema shape", () => {
38
+ const snap = buildSnapshot({
39
+ uid: "1234567890123456789",
40
+ displayName: "alice",
41
+ messages: [],
42
+ contacts: [],
43
+ snapshottedAt: 1716383021000,
44
+ });
45
+ expect(snap.schemaVersion).toBe(SNAPSHOT_SCHEMA_VERSION);
46
+ expect(snap.schemaVersion).toBe(1);
47
+ expect(snap.snapshottedAt).toBe(1716383021000);
48
+ expect(snap.account).toEqual({
49
+ secUid: null, // unknown via pure-db extraction
50
+ shortId: "1234567890123456789",
51
+ displayName: "alice",
52
+ });
53
+ expect(Array.isArray(snap.events)).toBe(true);
54
+ });
55
+
56
+ it("rejects non-string / empty uid", () => {
57
+ expect(() => buildSnapshot({ uid: 123 })).toThrow(TypeError);
58
+ expect(() => buildSnapshot({ uid: "" })).toThrow(TypeError);
59
+ expect(() => buildSnapshot({})).toThrow(TypeError);
60
+ });
61
+
62
+ it("defaults snapshottedAt to Date.now() when omitted", () => {
63
+ const before = Date.now();
64
+ const snap = buildSnapshot({ uid: "1" });
65
+ const after = Date.now();
66
+ expect(snap.snapshottedAt).toBeGreaterThanOrEqual(before);
67
+ expect(snap.snapshottedAt).toBeLessThanOrEqual(after);
68
+ });
69
+
70
+ it("defaults displayName to empty string", () => {
71
+ const snap = buildSnapshot({ uid: "1" });
72
+ expect(snap.account.displayName).toBe("");
73
+ });
74
+ });
75
+
76
+ describe("buildSnapshot — events", () => {
77
+ it("maps messages → kind=message events with composite id", () => {
78
+ const snap = buildSnapshot({
79
+ uid: "1",
80
+ messages: [
81
+ {
82
+ senderUid: "10001",
83
+ conversationId: "conv-A",
84
+ createdTimeMs: 1716383021000,
85
+ text: "hi",
86
+ readStatus: 1,
87
+ contentBlob: '{"text":"hi"}',
88
+ },
89
+ ],
90
+ });
91
+ expect(snap.events).toHaveLength(1);
92
+ expect(snap.events[0]).toMatchObject({
93
+ kind: "message",
94
+ id: "msg-conv-A-1716383021000",
95
+ capturedAt: 1716383021000,
96
+ senderUid: "10001",
97
+ conversationId: "conv-A",
98
+ text: "hi",
99
+ readStatus: 1,
100
+ contentBlob: '{"text":"hi"}',
101
+ });
102
+ });
103
+
104
+ it("falls back to senderUid+time when conversationId absent", () => {
105
+ const snap = buildSnapshot({
106
+ uid: "1",
107
+ messages: [
108
+ {
109
+ senderUid: "10001",
110
+ conversationId: null,
111
+ createdTimeMs: 1716383021000,
112
+ text: "hi",
113
+ },
114
+ ],
115
+ });
116
+ expect(snap.events[0].id).toBe("msg-10001-1716383021000");
117
+ });
118
+
119
+ it("falls back to index when both conversationId + senderUid absent", () => {
120
+ const snap = buildSnapshot({
121
+ uid: "1",
122
+ messages: [{ text: "stray" }],
123
+ });
124
+ expect(snap.events[0].id).toBe("msg-msg-0");
125
+ });
126
+
127
+ it("maps contacts → kind=contact events with uid-based id", () => {
128
+ const snap = buildSnapshot({
129
+ uid: "1",
130
+ contacts: [
131
+ { uid: "111", shortId: "222", name: "Alice", avatarUrl: "https://a.png", followStatus: 1 },
132
+ ],
133
+ });
134
+ expect(snap.events[0]).toMatchObject({
135
+ kind: "contact",
136
+ id: "contact-111",
137
+ uid: "111",
138
+ shortId: "222",
139
+ name: "Alice",
140
+ followStatus: 1,
141
+ });
142
+ });
143
+
144
+ it("preserves message → contact ordering", () => {
145
+ const snap = buildSnapshot({
146
+ uid: "1",
147
+ messages: [{ senderUid: "a", createdTimeMs: 1 }],
148
+ contacts: [{ uid: "b" }],
149
+ });
150
+ expect(snap.events.map((e) => e.kind)).toEqual(["message", "contact"]);
151
+ });
152
+
153
+ it("uses snapshottedAt fallback when message has no createdTimeMs", () => {
154
+ const snap = buildSnapshot({
155
+ uid: "1",
156
+ snapshottedAt: 999,
157
+ messages: [{ senderUid: "x", text: "no time" }],
158
+ });
159
+ expect(snap.events[0].capturedAt).toBe(999);
160
+ });
161
+
162
+ it("skips null / non-object items", () => {
163
+ const snap = buildSnapshot({
164
+ uid: "1",
165
+ messages: [null, { senderUid: "a", createdTimeMs: 1 }, "junk"],
166
+ contacts: [undefined, { uid: "b" }],
167
+ });
168
+ expect(snap.events).toHaveLength(2);
169
+ });
170
+
171
+ it("handles non-array fields gracefully", () => {
172
+ const snap = buildSnapshot({
173
+ uid: "1",
174
+ messages: "not an array",
175
+ contacts: null,
176
+ });
177
+ expect(snap.events).toEqual([]);
178
+ });
179
+ });
180
+
181
+ describe("writeSnapshotJson + cleanupSnapshotJson", () => {
182
+ it("writes JSON to default tmpdir", () => {
183
+ const snap = buildSnapshot({ uid: "1" });
184
+ const filePath = writeSnapshotJson(snap);
185
+ expect(existsSync(filePath)).toBe(true);
186
+ expect(filePath).toContain("cc-douyin-snapshot-");
187
+ expect(filePath).toMatch(/\.json$/);
188
+ const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
189
+ expect(parsed.schemaVersion).toBe(1);
190
+ cleanupSnapshotJson(filePath);
191
+ expect(existsSync(filePath)).toBe(false);
192
+ });
193
+
194
+ it("respects custom dir + fileName", () => {
195
+ const snap = buildSnapshot({ uid: "1" });
196
+ const filePath = writeSnapshotJson(snap, {
197
+ dir: tmpDir,
198
+ fileName: "custom.json",
199
+ });
200
+ expect(filePath).toBe(join(tmpDir, "custom.json"));
201
+ });
202
+
203
+ it("rejects path separators in fileName", () => {
204
+ const snap = buildSnapshot({ uid: "1" });
205
+ expect(() => writeSnapshotJson(snap, { fileName: "../evil.json" })).toThrow(
206
+ /must be a basename/,
207
+ );
208
+ });
209
+
210
+ it("cleanupSnapshotJson tolerates missing / null", () => {
211
+ cleanupSnapshotJson(null);
212
+ cleanupSnapshotJson(undefined);
213
+ cleanupSnapshotJson(join(tmpDir, "nonexistent.json"));
214
+ // does not throw
215
+ });
216
+ });
@@ -0,0 +1,362 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Phase 3a — Weibo Node API client unit cover.
5
+ *
6
+ * Same fake-fetch pattern as social-bilibili-adb-api-client.test.js.
7
+ * Byte-parity check against WeiboApiClient.kt (Kotlin) is in
8
+ * `android-app/.../WeiboApiClient*Test.kt`.
9
+ */
10
+
11
+ import { describe, it, expect, vi } from "vitest";
12
+
13
+ const { WeiboApiClient, _internals } = require(
14
+ "../../lib/adapters/social-weibo-adb/api-client",
15
+ );
16
+
17
+ function makeClient(responses) {
18
+ const calls = [];
19
+ const fakeFetch = async (urlStr, opts) => {
20
+ calls.push({ url: urlStr, opts });
21
+ for (const [pattern, payload] of responses) {
22
+ if (urlStr.includes(pattern)) {
23
+ const resolved =
24
+ typeof payload === "function" ? await payload(urlStr, opts) : payload;
25
+ return {
26
+ ok: resolved.status == null || resolved.status === 200,
27
+ status: resolved.status || 200,
28
+ text: async () => resolved.body,
29
+ json: async () => JSON.parse(resolved.body),
30
+ };
31
+ }
32
+ }
33
+ throw new Error("fake fetch: no scripted response for " + urlStr);
34
+ };
35
+ return { client: new WeiboApiClient({ fetch: fakeFetch }), calls };
36
+ }
37
+
38
+ const FAKE_COOKIE = "SUB=abc; SUBP=def; _T_WM=tw1; MLOGIN=1";
39
+
40
+ // ─── _internals.parseWeiboTime ──────────────────────────────────────────
41
+
42
+ describe("parseWeiboTime", () => {
43
+ it("parses 'EEE MMM dd HH:mm:ss Z yyyy' format", () => {
44
+ const t = _internals.parseWeiboTime("Sun Jan 12 13:45:00 +0800 2026");
45
+ // Sunday 2026-01-12 13:45:00 +0800 = 2026-01-12 05:45:00 UTC
46
+ expect(t).toBeGreaterThan(0);
47
+ const d = new Date(t);
48
+ expect(d.getUTCFullYear()).toBe(2026);
49
+ expect(d.getUTCMonth()).toBe(0);
50
+ expect(d.getUTCDate()).toBe(12);
51
+ expect(d.getUTCHours()).toBe(5);
52
+ expect(d.getUTCMinutes()).toBe(45);
53
+ });
54
+
55
+ it("digits-only treated as unix-seconds (< 1e12)", () => {
56
+ expect(_internals.parseWeiboTime("1716383021")).toBe(1716383021000);
57
+ });
58
+
59
+ it("digits-only treated as ms (> 1e12)", () => {
60
+ expect(_internals.parseWeiboTime("1716383021000")).toBe(1716383021000);
61
+ });
62
+
63
+ it("returns 0 for empty / null / unparseable", () => {
64
+ expect(_internals.parseWeiboTime("")).toBe(0);
65
+ expect(_internals.parseWeiboTime(null)).toBe(0);
66
+ expect(_internals.parseWeiboTime(undefined)).toBe(0);
67
+ expect(_internals.parseWeiboTime("not a date")).toBe(0);
68
+ });
69
+ });
70
+
71
+ // ─── _internals.stripHtml ───────────────────────────────────────────────
72
+
73
+ describe("stripHtml", () => {
74
+ it("strips <a> / <span> tags", () => {
75
+ expect(
76
+ _internals.stripHtml(
77
+ '<a href="x">hello</a> <span class="y">world</span>',
78
+ ),
79
+ ).toBe("hello world");
80
+ });
81
+
82
+ it("decodes &nbsp; / &amp; / &lt; / &gt; / &quot;", () => {
83
+ expect(_internals.stripHtml("a&nbsp;b&amp;c&lt;d&gt;e&quot;f")).toBe(
84
+ 'a b&c<d>e"f',
85
+ );
86
+ });
87
+
88
+ it("handles Chinese + emoji", () => {
89
+ expect(_internals.stripHtml("<p>你好 👋</p>")).toBe("你好 👋");
90
+ });
91
+
92
+ it("returns empty for null / empty", () => {
93
+ expect(_internals.stripHtml("")).toBe("");
94
+ expect(_internals.stripHtml(null)).toBe("");
95
+ });
96
+ });
97
+
98
+ // ─── fetchUid ──────────────────────────────────────────────────────────
99
+
100
+ describe("WeiboApiClient.fetchUid", () => {
101
+ it("returns numeric UID when login=true", async () => {
102
+ const { client } = makeClient([
103
+ [
104
+ "api/config",
105
+ { body: JSON.stringify({ ok: 1, data: { login: true, uid: "1234567890" } }) },
106
+ ],
107
+ ]);
108
+ const uid = await client.fetchUid(FAKE_COOKIE);
109
+ expect(uid).toBe(1234567890);
110
+ });
111
+
112
+ it("returns null when login=false", async () => {
113
+ const { client } = makeClient([
114
+ [
115
+ "api/config",
116
+ { body: JSON.stringify({ ok: 1, data: { login: false } }) },
117
+ ],
118
+ ]);
119
+ expect(await client.fetchUid(FAKE_COOKIE)).toBe(null);
120
+ });
121
+
122
+ it("returns null on non-JSON (cookie expired login redirect)", async () => {
123
+ const { client } = makeClient([
124
+ ["api/config", { body: "<html>login redirect</html>" }],
125
+ ]);
126
+ expect(await client.fetchUid(FAKE_COOKIE)).toBe(null);
127
+ expect(client.lastErrorCode).toBe(-4);
128
+ });
129
+
130
+ it("returns null on HTTP error", async () => {
131
+ const { client } = makeClient([
132
+ ["api/config", { status: 503, body: "Service Unavailable" }],
133
+ ]);
134
+ expect(await client.fetchUid(FAKE_COOKIE)).toBe(null);
135
+ expect(client.lastErrorCode).toBe(503);
136
+ });
137
+
138
+ it("sends required browser headers", async () => {
139
+ const { client, calls } = makeClient([
140
+ [
141
+ "api/config",
142
+ { body: JSON.stringify({ ok: 1, data: { login: true, uid: "1" } }) },
143
+ ],
144
+ ]);
145
+ await client.fetchUid(FAKE_COOKIE);
146
+ const h = calls[0].opts.headers;
147
+ expect(h["User-Agent"]).toContain("Mozilla");
148
+ expect(h.Referer).toBe("https://m.weibo.cn/");
149
+ expect(h["X-Requested-With"]).toBe("XMLHttpRequest");
150
+ expect(h["MWeibo-Pwa"]).toBe("1");
151
+ expect(h.Cookie).toBe(FAKE_COOKIE);
152
+ });
153
+ });
154
+
155
+ // ─── fetchPosts ────────────────────────────────────────────────────────
156
+
157
+ describe("WeiboApiClient.fetchPosts", () => {
158
+ it("parses card_type=9 mblog cards", async () => {
159
+ const { client } = makeClient([
160
+ [
161
+ "api/container/getIndex",
162
+ {
163
+ body: JSON.stringify({
164
+ ok: 1,
165
+ data: {
166
+ cards: [
167
+ {
168
+ card_type: 9,
169
+ mblog: {
170
+ mid: "MBLOG_001",
171
+ text: '<a href="/user/x">@friend</a> hello 你好',
172
+ created_at: "Sun Jan 12 13:45:00 +0800 2026",
173
+ source: '<a href="x">iPhone 16</a>',
174
+ reposts_count: 5,
175
+ comments_count: 10,
176
+ attitudes_count: 100,
177
+ pic_num: 2,
178
+ },
179
+ },
180
+ { card_type: 11 /* banner — should skip */ },
181
+ {
182
+ card_type: 9,
183
+ mblog: {
184
+ id: 12345, // alt id field
185
+ text: "second post",
186
+ created_at: "1716383021",
187
+ },
188
+ },
189
+ ],
190
+ },
191
+ }),
192
+ },
193
+ ],
194
+ ]);
195
+ const posts = await client.fetchPosts(FAKE_COOKIE, 1234567890);
196
+ expect(posts).toHaveLength(2);
197
+ expect(posts[0]).toMatchObject({
198
+ mid: "MBLOG_001",
199
+ text: "@friend hello 你好",
200
+ // source field preserves raw HTML (Kotlin byte-parity — WeiboApiClient.kt
201
+ // doesn't strip it; UI can strip if it wants)
202
+ source: '<a href="x">iPhone 16</a>',
203
+ repostsCount: 5,
204
+ commentsCount: 10,
205
+ likesCount: 100,
206
+ picCount: 2,
207
+ });
208
+ expect(posts[1].mid).toBe("12345");
209
+ expect(posts[1].text).toBe("second post");
210
+ });
211
+
212
+ it("respects limit", async () => {
213
+ const items = Array.from({ length: 100 }, (_, i) => ({
214
+ card_type: 9,
215
+ mblog: { mid: `M${i}`, text: `t${i}`, created_at: "1716383021" },
216
+ }));
217
+ const { client } = makeClient([
218
+ [
219
+ "api/container/getIndex",
220
+ { body: JSON.stringify({ ok: 1, data: { cards: items } }) },
221
+ ],
222
+ ]);
223
+ const posts = await client.fetchPosts(FAKE_COOKIE, 1, { limit: 5 });
224
+ expect(posts).toHaveLength(5);
225
+ });
226
+
227
+ it("uses containerid=107603<uid>", async () => {
228
+ const { client, calls } = makeClient([
229
+ [
230
+ "api/container/getIndex",
231
+ { body: JSON.stringify({ ok: 1, data: { cards: [] } }) },
232
+ ],
233
+ ]);
234
+ await client.fetchPosts(FAKE_COOKIE, 99);
235
+ expect(calls[0].url).toContain("containerid=10760399");
236
+ });
237
+ });
238
+
239
+ // ─── fetchFavourites ───────────────────────────────────────────────────
240
+
241
+ describe("WeiboApiClient.fetchFavourites", () => {
242
+ it("parses status nested rows", async () => {
243
+ const { client } = makeClient([
244
+ [
245
+ "api/favorites",
246
+ {
247
+ body: JSON.stringify({
248
+ ok: 1,
249
+ data: {
250
+ favorites: [
251
+ {
252
+ favorited_time: "Sun Jan 12 14:00:00 +0800 2026",
253
+ status: {
254
+ mid: "FAV_001",
255
+ text: "interesting <em>thing</em>",
256
+ user: { screen_name: "@famous" },
257
+ },
258
+ },
259
+ ],
260
+ },
261
+ }),
262
+ },
263
+ ],
264
+ ]);
265
+ const favs = await client.fetchFavourites(FAKE_COOKIE);
266
+ expect(favs).toHaveLength(1);
267
+ expect(favs[0]).toMatchObject({
268
+ mid: "FAV_001",
269
+ text: "interesting thing",
270
+ authorScreenName: "@famous",
271
+ });
272
+ expect(favs[0].favAt).toBeGreaterThan(0);
273
+ });
274
+
275
+ it("falls back to status.created_at when favorited_time absent", async () => {
276
+ const { client } = makeClient([
277
+ [
278
+ "api/favorites",
279
+ {
280
+ body: JSON.stringify({
281
+ ok: 1,
282
+ data: {
283
+ favorites: [
284
+ {
285
+ status: {
286
+ mid: "X",
287
+ text: "y",
288
+ created_at: "Sun Jan 12 13:45:00 +0800 2026",
289
+ },
290
+ },
291
+ ],
292
+ },
293
+ }),
294
+ },
295
+ ],
296
+ ]);
297
+ const favs = await client.fetchFavourites(FAKE_COOKIE);
298
+ expect(favs[0].favAt).toBeGreaterThan(0);
299
+ });
300
+ });
301
+
302
+ // ─── fetchFollows ──────────────────────────────────────────────────────
303
+
304
+ describe("WeiboApiClient.fetchFollows", () => {
305
+ it("parses users list", async () => {
306
+ const { client } = makeClient([
307
+ [
308
+ "api/friendships/friends",
309
+ {
310
+ body: JSON.stringify({
311
+ ok: 1,
312
+ data: {
313
+ users: [
314
+ {
315
+ id: 42,
316
+ screen_name: "Friend1",
317
+ description: "hi",
318
+ profile_image_url: "https://x.png",
319
+ },
320
+ { id: 0, screen_name: "Ghost" }, // skip
321
+ { id: 99, screen_name: "Friend2" },
322
+ ],
323
+ },
324
+ }),
325
+ },
326
+ ],
327
+ ]);
328
+ const fols = await client.fetchFollows(FAKE_COOKIE, 1234);
329
+ expect(fols).toHaveLength(2);
330
+ expect(fols[0].uid).toBe(42);
331
+ expect(fols[1].uid).toBe(99);
332
+ expect(fols[0].screenName).toBe("Friend1");
333
+ });
334
+
335
+ it("uid sent as vmid query param", async () => {
336
+ const { client, calls } = makeClient([
337
+ [
338
+ "api/friendships/friends",
339
+ { body: JSON.stringify({ ok: 1, data: { users: [] } }) },
340
+ ],
341
+ ]);
342
+ await client.fetchFollows(FAKE_COOKIE, 9999);
343
+ expect(calls[0].url).toContain("uid=9999");
344
+ });
345
+ });
346
+
347
+ // ─── error code propagation ─────────────────────────────────────────────
348
+
349
+ describe("WeiboApiClient — error propagation", () => {
350
+ it("ok != 1 → returns [] + sets lastError", async () => {
351
+ const { client } = makeClient([
352
+ [
353
+ "api/container/getIndex",
354
+ { body: JSON.stringify({ ok: -100, msg: "anti-bot" }) },
355
+ ],
356
+ ]);
357
+ const r = await client.fetchPosts(FAKE_COOKIE, 1);
358
+ expect(r).toEqual([]);
359
+ expect(client.lastErrorCode).toBe(-100);
360
+ expect(client.lastErrorMessage).toBe("anti-bot");
361
+ });
362
+ });