@chainlesschain/personal-data-hub 0.3.0 → 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 (61) 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 +163 -5
  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-12306/index.js +215 -29
  55. package/lib/adapters/travel-amap/index.js +16 -10
  56. package/lib/adapters/travel-ctrip/index.js +25 -9
  57. package/lib/adapters/vscode/vscode-reader.js +7 -1
  58. package/lib/sign-providers/index.js +20 -0
  59. package/lib/sign-providers/interface.js +82 -0
  60. package/lib/sign-providers/null-sign-provider.js +30 -0
  61. package/package.json +6 -1
@@ -0,0 +1,201 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
4
+ import { existsSync, readFileSync, mkdtempSync, rmSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+
8
+ const {
9
+ collect,
10
+ collectAndSync,
11
+ } = require("../../lib/adapters/social-weibo-adb/collector");
12
+
13
+ let stagingDir;
14
+
15
+ beforeEach(() => {
16
+ stagingDir = mkdtempSync(join(tmpdir(), "cc-weibo-test-"));
17
+ });
18
+
19
+ afterEach(() => {
20
+ try {
21
+ rmSync(stagingDir, { recursive: true, force: true });
22
+ } catch (_e) {
23
+ // ignore
24
+ }
25
+ });
26
+
27
+ function makeFakeBridge({ cookieResult, throwOnInvoke } = {}) {
28
+ return {
29
+ invoke: vi.fn(async (_method) => {
30
+ if (throwOnInvoke) throw throwOnInvoke;
31
+ return cookieResult;
32
+ }),
33
+ };
34
+ }
35
+
36
+ function makeFakeApiClient({
37
+ uid = 1234567890,
38
+ posts = [],
39
+ favourites = [],
40
+ follows = [],
41
+ lastErrorCode = 0,
42
+ lastErrorMessage = null,
43
+ } = {}) {
44
+ return {
45
+ fetchUid: vi.fn(async () => uid),
46
+ fetchPosts: vi.fn(async () => posts),
47
+ fetchFavourites: vi.fn(async () => favourites),
48
+ fetchFollows: vi.fn(async () => follows),
49
+ lastErrorCode,
50
+ lastErrorMessage,
51
+ };
52
+ }
53
+
54
+ describe("collect — happy path", () => {
55
+ it("invokes cookies, fetchUid, 3 endpoints, writes snapshot", async () => {
56
+ const bridge = makeFakeBridge({
57
+ cookieResult: {
58
+ cookie: "SUB=abc",
59
+ diagnostic: { cookieCount: 5, hasSub: true },
60
+ },
61
+ });
62
+ const apiClient = makeFakeApiClient({
63
+ uid: 1234567890,
64
+ posts: [{ mid: "P1", text: "p", createdAt: 1 }],
65
+ favourites: [{ mid: "F1", text: "f", favAt: 2 }],
66
+ follows: [{ uid: 99, screenName: "x", followedAt: 0 }],
67
+ });
68
+ const result = await collect(bridge, { apiClient, stagingDir });
69
+
70
+ expect(bridge.invoke).toHaveBeenCalledWith("weibo.cookies");
71
+ expect(apiClient.fetchUid).toHaveBeenCalledWith("SUB=abc");
72
+ expect(apiClient.fetchPosts).toHaveBeenCalledWith(
73
+ "SUB=abc",
74
+ 1234567890,
75
+ expect.any(Object),
76
+ );
77
+ expect(result.uid).toBe(1234567890);
78
+ expect(result.eventCounts).toEqual({
79
+ post: 1,
80
+ favourite: 1,
81
+ follow: 1,
82
+ total: 3,
83
+ });
84
+ expect(existsSync(result.snapshotPath)).toBe(true);
85
+ const snap = JSON.parse(readFileSync(result.snapshotPath, "utf-8"));
86
+ expect(snap.schemaVersion).toBe(1);
87
+ expect(snap.events).toHaveLength(3);
88
+ expect(result.uidFetchFailed).toBe(false);
89
+ });
90
+ });
91
+
92
+ describe("collect — uid fetch failure (cookie expired)", () => {
93
+ it("returns empty-event snapshot + uidFetchFailed=true", async () => {
94
+ const bridge = makeFakeBridge({
95
+ cookieResult: { cookie: "SUB=expired", diagnostic: {} },
96
+ });
97
+ const apiClient = makeFakeApiClient({
98
+ uid: null,
99
+ lastErrorCode: -4,
100
+ lastErrorMessage: "non-json (cookie expired?)",
101
+ });
102
+ const result = await collect(bridge, { apiClient, stagingDir });
103
+ expect(result.uid).toBe(null);
104
+ expect(result.uidFetchFailed).toBe(true);
105
+ expect(result.eventCounts.total).toBe(0);
106
+ expect(result.lastErrorCode).toBe(-4);
107
+ // fetchPosts/Favourites/Follows must NOT be called when uid failed
108
+ expect(apiClient.fetchPosts).not.toHaveBeenCalled();
109
+ // Snapshot file still written so downstream caller doesn't crash on
110
+ // missing inputPath
111
+ expect(existsSync(result.snapshotPath)).toBe(true);
112
+ });
113
+ });
114
+
115
+ describe("collect — failure modes", () => {
116
+ it("propagates bridge.invoke errors", async () => {
117
+ const bridge = makeFakeBridge({
118
+ throwOnInvoke: new Error("WEIBO_NO_ROOT: phone isn't rooted"),
119
+ });
120
+ await expect(collect(bridge, { stagingDir })).rejects.toThrow(/WEIBO_NO_ROOT/);
121
+ });
122
+
123
+ it("throws TypeError when bridge missing invoke", async () => {
124
+ await expect(collect(null, { stagingDir })).rejects.toThrow(TypeError);
125
+ await expect(collect({}, { stagingDir })).rejects.toThrow(TypeError);
126
+ });
127
+
128
+ it("throws on malformed cookieResult", async () => {
129
+ const bridge = makeFakeBridge({ cookieResult: { cookie: null } });
130
+ await expect(collect(bridge, { stagingDir })).rejects.toThrow(/malformed payload/);
131
+ });
132
+ });
133
+
134
+ describe("collect — partial result tolerance", () => {
135
+ it("0 events when uid OK but all 3 endpoints empty", async () => {
136
+ const bridge = makeFakeBridge({ cookieResult: { cookie: "SUB=a" } });
137
+ const apiClient = makeFakeApiClient();
138
+ const result = await collect(bridge, { apiClient, stagingDir });
139
+ expect(result.eventCounts.total).toBe(0);
140
+ expect(existsSync(result.snapshotPath)).toBe(true);
141
+ });
142
+
143
+ it("partial event set when one endpoint fails", async () => {
144
+ const bridge = makeFakeBridge({ cookieResult: { cookie: "SUB=a" } });
145
+ const apiClient = makeFakeApiClient({
146
+ posts: [{ mid: "P", text: "x", createdAt: 1 }],
147
+ favourites: [], // simulated failure
148
+ follows: [{ uid: 1, screenName: "x" }],
149
+ });
150
+ const result = await collect(bridge, { apiClient, stagingDir });
151
+ expect(result.eventCounts).toEqual({ post: 1, favourite: 0, follow: 1, total: 2 });
152
+ });
153
+ });
154
+
155
+ describe("collectAndSync", () => {
156
+ it("calls registry.syncAdapter + cleans up on success", async () => {
157
+ const bridge = makeFakeBridge({ cookieResult: { cookie: "SUB=a" } });
158
+ const apiClient = makeFakeApiClient({
159
+ posts: [{ mid: "P", createdAt: 1 }],
160
+ });
161
+ let syncedPath = null;
162
+ const registry = {
163
+ syncAdapter: vi.fn(async (name, opts) => {
164
+ if (name !== "social-weibo") throw new Error("wrong name");
165
+ syncedPath = opts.inputPath;
166
+ return { adapter: name, status: "ok", rawCount: 1 };
167
+ }),
168
+ };
169
+ const report = await collectAndSync(bridge, registry, {
170
+ apiClient,
171
+ stagingDir,
172
+ });
173
+ expect(report.status).toBe("ok");
174
+ expect(report.weibo.uid).toBe(1234567890);
175
+ expect(report.weibo.eventCounts.post).toBe(1);
176
+ expect(existsSync(syncedPath)).toBe(false);
177
+ });
178
+
179
+ it("cleanup even on syncAdapter throw", async () => {
180
+ const bridge = makeFakeBridge({ cookieResult: { cookie: "SUB=a" } });
181
+ const apiClient = makeFakeApiClient();
182
+ let syncedPath = null;
183
+ const registry = {
184
+ syncAdapter: vi.fn(async (_n, opts) => {
185
+ syncedPath = opts.inputPath;
186
+ throw new Error("registry exploded");
187
+ }),
188
+ };
189
+ await expect(
190
+ collectAndSync(bridge, registry, { apiClient, stagingDir }),
191
+ ).rejects.toThrow("registry exploded");
192
+ expect(existsSync(syncedPath)).toBe(false);
193
+ });
194
+
195
+ it("rejects missing syncAdapter", async () => {
196
+ const bridge = makeFakeBridge({ cookieResult: { cookie: "SUB=a" } });
197
+ await expect(
198
+ collectAndSync(bridge, null, { apiClient: makeFakeApiClient() }),
199
+ ).rejects.toThrow(TypeError);
200
+ });
201
+ });
@@ -0,0 +1,189 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
4
+ import { existsSync, readFileSync, mkdtempSync, rmSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+
8
+ const {
9
+ buildSnapshot,
10
+ writeSnapshotJson,
11
+ cleanupSnapshotJson,
12
+ SNAPSHOT_SCHEMA_VERSION,
13
+ } = require("../../lib/adapters/social-weibo-adb/snapshot-builder");
14
+
15
+ let tmpDir;
16
+ beforeEach(() => {
17
+ tmpDir = mkdtempSync(join(tmpdir(), "cc-weibo-snap-test-"));
18
+ });
19
+ afterEach(() => {
20
+ try {
21
+ rmSync(tmpDir, { recursive: true, force: true });
22
+ } catch (_e) {
23
+ // ignore
24
+ }
25
+ });
26
+
27
+ describe("buildSnapshot — schema", () => {
28
+ it("returns documented shape", () => {
29
+ const s = buildSnapshot({
30
+ uid: 1234567890,
31
+ displayName: "alice",
32
+ snapshottedAt: 1716383021000,
33
+ });
34
+ expect(s.schemaVersion).toBe(1);
35
+ expect(s.schemaVersion).toBe(SNAPSHOT_SCHEMA_VERSION);
36
+ expect(s.snapshottedAt).toBe(1716383021000);
37
+ expect(s.account).toEqual({ uid: "1234567890", displayName: "alice" });
38
+ expect(s.events).toEqual([]);
39
+ });
40
+
41
+ it("rejects non-positive uid", () => {
42
+ expect(() => buildSnapshot({ uid: 0 })).toThrow(TypeError);
43
+ expect(() => buildSnapshot({ uid: -1 })).toThrow(TypeError);
44
+ expect(() => buildSnapshot({})).toThrow(TypeError);
45
+ });
46
+
47
+ it("defaults displayName + snapshottedAt", () => {
48
+ const before = Date.now();
49
+ const s = buildSnapshot({ uid: 1 });
50
+ const after = Date.now();
51
+ expect(s.account.displayName).toBe("");
52
+ expect(s.snapshottedAt).toBeGreaterThanOrEqual(before);
53
+ expect(s.snapshottedAt).toBeLessThanOrEqual(after);
54
+ });
55
+ });
56
+
57
+ describe("buildSnapshot — events", () => {
58
+ it("maps posts → kind=post", () => {
59
+ const s = buildSnapshot({
60
+ uid: 1,
61
+ posts: [
62
+ {
63
+ mid: "M1",
64
+ text: "hi",
65
+ createdAt: 1716383021000,
66
+ source: "iPhone",
67
+ repostsCount: 5,
68
+ commentsCount: 10,
69
+ likesCount: 100,
70
+ picCount: 2,
71
+ },
72
+ ],
73
+ });
74
+ expect(s.events).toHaveLength(1);
75
+ expect(s.events[0]).toMatchObject({
76
+ kind: "post",
77
+ id: "post-M1",
78
+ capturedAt: 1716383021000,
79
+ text: "hi",
80
+ mid: "M1",
81
+ source: "iPhone",
82
+ repostsCount: 5,
83
+ commentsCount: 10,
84
+ likesCount: 100,
85
+ picCount: 2,
86
+ });
87
+ });
88
+
89
+ it("maps favourites → kind=favourite", () => {
90
+ const s = buildSnapshot({
91
+ uid: 1,
92
+ favourites: [
93
+ { mid: "F1", text: "fav text", favAt: 1716383022000, authorScreenName: "x" },
94
+ ],
95
+ });
96
+ expect(s.events[0]).toMatchObject({
97
+ kind: "favourite",
98
+ id: "fav-F1",
99
+ capturedAt: 1716383022000,
100
+ mid: "F1",
101
+ authorScreenName: "x",
102
+ });
103
+ });
104
+
105
+ it("maps follows → kind=follow with snapshottedAt fallback", () => {
106
+ const s = buildSnapshot({
107
+ uid: 1,
108
+ snapshottedAt: 999,
109
+ follows: [
110
+ {
111
+ uid: 42,
112
+ screenName: "Friend",
113
+ description: "hi",
114
+ avatarUrl: "https://x.png",
115
+ followedAt: 0, // /api/friendships/friends doesn't return follow time
116
+ },
117
+ ],
118
+ });
119
+ expect(s.events[0]).toMatchObject({
120
+ kind: "follow",
121
+ id: "follow-42",
122
+ uid: 42,
123
+ screenName: "Friend",
124
+ capturedAt: 999, // fallback to snapshottedAt
125
+ });
126
+ });
127
+
128
+ it("uses index fallback id when mid/uid missing", () => {
129
+ const s = buildSnapshot({
130
+ uid: 1,
131
+ posts: [{ text: "no mid" }],
132
+ favourites: [{ text: "no mid" }],
133
+ follows: [{ screenName: "no uid" }],
134
+ });
135
+ expect(s.events.map((e) => e.id)).toEqual(["post-0", "fav-0", "follow-0"]);
136
+ });
137
+
138
+ it("preserves order post → favourite → follow", () => {
139
+ const s = buildSnapshot({
140
+ uid: 1,
141
+ posts: [{ mid: "P", createdAt: 1 }],
142
+ favourites: [{ mid: "F", favAt: 2 }],
143
+ follows: [{ uid: 1, followedAt: 3 }],
144
+ });
145
+ expect(s.events.map((e) => e.kind)).toEqual(["post", "favourite", "follow"]);
146
+ });
147
+
148
+ it("handles non-array fields", () => {
149
+ const s = buildSnapshot({
150
+ uid: 1,
151
+ posts: "not array",
152
+ favourites: null,
153
+ follows: {},
154
+ });
155
+ expect(s.events).toEqual([]);
156
+ });
157
+
158
+ it("skips null/non-object items", () => {
159
+ const s = buildSnapshot({
160
+ uid: 1,
161
+ posts: [null, { mid: "P", createdAt: 1 }, "junk"],
162
+ });
163
+ expect(s.events).toHaveLength(1);
164
+ });
165
+ });
166
+
167
+ describe("writeSnapshotJson + cleanupSnapshotJson", () => {
168
+ it("writes to default tmpdir", () => {
169
+ const s = buildSnapshot({ uid: 1 });
170
+ const p = writeSnapshotJson(s);
171
+ expect(existsSync(p)).toBe(true);
172
+ expect(p).toContain("cc-weibo-snapshot-");
173
+ const parsed = JSON.parse(readFileSync(p, "utf-8"));
174
+ expect(parsed.schemaVersion).toBe(1);
175
+ cleanupSnapshotJson(p);
176
+ expect(existsSync(p)).toBe(false);
177
+ });
178
+
179
+ it("rejects path separators in fileName", () => {
180
+ expect(() => writeSnapshotJson({ schemaVersion: 1 }, { fileName: "../evil.json" })).toThrow(
181
+ /must be a basename/,
182
+ );
183
+ });
184
+
185
+ it("cleanupSnapshotJson tolerates missing / null", () => {
186
+ cleanupSnapshotJson(null);
187
+ cleanupSnapshotJson(join(tmpDir, "nonexistent.json"));
188
+ });
189
+ });
@@ -0,0 +1,207 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
4
+ import { existsSync, readFileSync, mkdtempSync, rmSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+
8
+ const {
9
+ collect,
10
+ collectAndSync,
11
+ } = require("../../lib/adapters/social-xiaohongshu-adb/collector");
12
+
13
+ let stagingDir;
14
+
15
+ beforeEach(() => {
16
+ stagingDir = mkdtempSync(join(tmpdir(), "cc-xhs-test-"));
17
+ });
18
+
19
+ afterEach(() => {
20
+ try {
21
+ rmSync(stagingDir, { recursive: true, force: true });
22
+ } catch (_e) {
23
+ // ignore
24
+ }
25
+ });
26
+
27
+ function makeFakeBridge({ cookieResult, throwOnInvoke } = {}) {
28
+ return {
29
+ invoke: vi.fn(async (_method) => {
30
+ if (throwOnInvoke) throw throwOnInvoke;
31
+ return cookieResult;
32
+ }),
33
+ };
34
+ }
35
+
36
+ function makeFakeApiClient({
37
+ me = { userId: "5e8c8f7e1234abcdef", nickname: "Alice" },
38
+ notes = [],
39
+ liked = [],
40
+ follows = [],
41
+ lastErrorCode = 0,
42
+ lastErrorMessage = null,
43
+ } = {}) {
44
+ return {
45
+ fetchMe: vi.fn(async () => me),
46
+ fetchNotes: vi.fn(async () => notes),
47
+ fetchLiked: vi.fn(async () => liked),
48
+ fetchFollows: vi.fn(async () => follows),
49
+ lastErrorCode,
50
+ lastErrorMessage,
51
+ };
52
+ }
53
+
54
+ describe("collect — happy path", () => {
55
+ it("invokes cookies, fetchMe, 3 endpoints, writes snapshot", async () => {
56
+ const bridge = makeFakeBridge({
57
+ cookieResult: {
58
+ cookie: "a1=fp; web_session=tok",
59
+ a1: "fp",
60
+ diagnostic: { cookieCount: 5 },
61
+ },
62
+ });
63
+ const apiClient = makeFakeApiClient({
64
+ notes: [{ noteId: "N1", title: "note", createdAt: 1716383021000 }],
65
+ liked: [{ noteId: "L1", title: "liked" }],
66
+ follows: [{ userId: "U1", nickname: "Friend" }],
67
+ });
68
+ const result = await collect(bridge, { apiClient, stagingDir });
69
+
70
+ expect(bridge.invoke).toHaveBeenCalledWith("xhs.cookies");
71
+ expect(apiClient.fetchMe).toHaveBeenCalledWith("a1=fp; web_session=tok");
72
+ expect(apiClient.fetchNotes).toHaveBeenCalledWith(
73
+ "a1=fp; web_session=tok",
74
+ "fp",
75
+ "5e8c8f7e1234abcdef",
76
+ expect.any(Object),
77
+ );
78
+ expect(result.userId).toBe("5e8c8f7e1234abcdef");
79
+ expect(result.nickname).toBe("Alice");
80
+ expect(result.eventCounts).toEqual({
81
+ note: 1,
82
+ liked: 1,
83
+ follow: 1,
84
+ total: 3,
85
+ });
86
+ expect(existsSync(result.snapshotPath)).toBe(true);
87
+ const snap = JSON.parse(readFileSync(result.snapshotPath, "utf-8"));
88
+ expect(snap.schemaVersion).toBe(1);
89
+ expect(snap.events).toHaveLength(3);
90
+ expect(result.meFetchFailed).toBe(false);
91
+ });
92
+ });
93
+
94
+ describe("collect — meFetchFailed (cookie expired)", () => {
95
+ it("returns empty-event snapshot + meFetchFailed=true", async () => {
96
+ const bridge = makeFakeBridge({
97
+ cookieResult: { cookie: "a1=fp; web_session=expired", a1: "fp" },
98
+ });
99
+ const apiClient = makeFakeApiClient({
100
+ me: null,
101
+ lastErrorCode: -7,
102
+ lastErrorMessage: "/user/me user_id blank",
103
+ });
104
+ const result = await collect(bridge, { apiClient, stagingDir });
105
+ expect(result.userId).toBe(null);
106
+ expect(result.meFetchFailed).toBe(true);
107
+ expect(result.eventCounts.total).toBe(0);
108
+ expect(apiClient.fetchNotes).not.toHaveBeenCalled();
109
+ expect(existsSync(result.snapshotPath)).toBe(true);
110
+ });
111
+ });
112
+
113
+ describe("collect — failure modes", () => {
114
+ it("propagates bridge.invoke errors", async () => {
115
+ const bridge = makeFakeBridge({
116
+ throwOnInvoke: new Error("XHS_NO_ROOT: phone isn't rooted"),
117
+ });
118
+ await expect(collect(bridge, { stagingDir })).rejects.toThrow(/XHS_NO_ROOT/);
119
+ });
120
+
121
+ it("throws TypeError when bridge missing invoke", async () => {
122
+ await expect(collect(null, { stagingDir })).rejects.toThrow(TypeError);
123
+ await expect(collect({}, { stagingDir })).rejects.toThrow(TypeError);
124
+ });
125
+
126
+ it("throws on malformed cookieResult (missing a1)", async () => {
127
+ const bridge = makeFakeBridge({
128
+ cookieResult: { cookie: "web_session=s", a1: null },
129
+ });
130
+ await expect(collect(bridge, { stagingDir })).rejects.toThrow(/malformed payload/);
131
+ });
132
+ });
133
+
134
+ describe("collect — partial result (X-S signing best-effort)", () => {
135
+ it("0 events when X-S sign rejected on all 3 endpoints", async () => {
136
+ const bridge = makeFakeBridge({
137
+ cookieResult: { cookie: "a1=fp; web_session=tok", a1: "fp" },
138
+ });
139
+ const apiClient = makeFakeApiClient({
140
+ // me OK (no X-S) but all 3 X-S endpoints return [] (461 rejection)
141
+ lastErrorCode: 461,
142
+ lastErrorMessage: "X-S validation failed",
143
+ });
144
+ const result = await collect(bridge, { apiClient, stagingDir });
145
+ expect(result.eventCounts.total).toBe(0);
146
+ expect(result.lastErrorCode).toBe(461);
147
+ expect(result.meFetchFailed).toBe(false);
148
+ });
149
+
150
+ it("partial event set when one endpoint X-S signed OK", async () => {
151
+ const bridge = makeFakeBridge({
152
+ cookieResult: { cookie: "a1=fp; web_session=tok", a1: "fp" },
153
+ });
154
+ const apiClient = makeFakeApiClient({
155
+ notes: [{ noteId: "N1", title: "x" }],
156
+ liked: [], // 461
157
+ follows: [], // 461
158
+ });
159
+ const result = await collect(bridge, { apiClient, stagingDir });
160
+ expect(result.eventCounts).toEqual({ note: 1, liked: 0, follow: 0, total: 1 });
161
+ });
162
+ });
163
+
164
+ describe("collectAndSync", () => {
165
+ it("calls registry.syncAdapter + cleans up on success", async () => {
166
+ const bridge = makeFakeBridge({
167
+ cookieResult: { cookie: "a1=fp; web_session=tok", a1: "fp" },
168
+ });
169
+ const apiClient = makeFakeApiClient({
170
+ notes: [{ noteId: "N1" }],
171
+ });
172
+ let syncedPath = null;
173
+ const registry = {
174
+ syncAdapter: vi.fn(async (name, opts) => {
175
+ if (name !== "social-xiaohongshu") throw new Error("wrong name");
176
+ syncedPath = opts.inputPath;
177
+ return { adapter: name, status: "ok", rawCount: 1 };
178
+ }),
179
+ };
180
+ const report = await collectAndSync(bridge, registry, {
181
+ apiClient,
182
+ stagingDir,
183
+ });
184
+ expect(report.status).toBe("ok");
185
+ expect(report.xhs.userId).toBe("5e8c8f7e1234abcdef");
186
+ expect(report.xhs.eventCounts.note).toBe(1);
187
+ expect(existsSync(syncedPath)).toBe(false);
188
+ });
189
+
190
+ it("cleanup even on syncAdapter throw", async () => {
191
+ const bridge = makeFakeBridge({
192
+ cookieResult: { cookie: "a1=fp; web_session=tok", a1: "fp" },
193
+ });
194
+ const apiClient = makeFakeApiClient();
195
+ let syncedPath = null;
196
+ const registry = {
197
+ syncAdapter: vi.fn(async (_n, opts) => {
198
+ syncedPath = opts.inputPath;
199
+ throw new Error("registry exploded");
200
+ }),
201
+ };
202
+ await expect(
203
+ collectAndSync(bridge, registry, { apiClient, stagingDir }),
204
+ ).rejects.toThrow("registry exploded");
205
+ expect(existsSync(syncedPath)).toBe(false);
206
+ });
207
+ });