@chainlesschain/personal-data-hub 0.3.6 → 0.3.7

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 (22) hide show
  1. package/__tests__/adapters/social-kuaishou-adb-api-client.test.js +432 -0
  2. package/__tests__/adapters/social-kuaishou-adb-collector.test.js +276 -0
  3. package/__tests__/adapters/social-kuaishou-adb-cookies-extension.test.js +141 -0
  4. package/__tests__/adapters/social-kuaishou-adb-snapshot-builder.test.js +178 -0
  5. package/__tests__/adapters/social-toutiao-adb-api-client.test.js +537 -0
  6. package/__tests__/adapters/social-toutiao-adb-collector.test.js +285 -0
  7. package/__tests__/adapters/social-toutiao-adb-cookies-extension.test.js +163 -0
  8. package/__tests__/adapters/social-toutiao-adb-snapshot-builder.test.js +196 -0
  9. package/__tests__/adapters/social-xiaohongshu-adb-sign-provider-injection.test.js +351 -0
  10. package/lib/adapters/social-kuaishou-adb/api-client.js +397 -0
  11. package/lib/adapters/social-kuaishou-adb/collector.js +196 -0
  12. package/lib/adapters/social-kuaishou-adb/cookies-extension.js +261 -0
  13. package/lib/adapters/social-kuaishou-adb/index.js +53 -0
  14. package/lib/adapters/social-kuaishou-adb/snapshot-builder.js +145 -0
  15. package/lib/adapters/social-toutiao-adb/api-client.js +377 -0
  16. package/lib/adapters/social-toutiao-adb/collector.js +200 -0
  17. package/lib/adapters/social-toutiao-adb/cookies-extension.js +266 -0
  18. package/lib/adapters/social-toutiao-adb/index.js +52 -0
  19. package/lib/adapters/social-toutiao-adb/snapshot-builder.js +148 -0
  20. package/lib/adapters/social-xiaohongshu-adb/api-client.js +36 -5
  21. package/lib/adapters/social-xiaohongshu-adb/collector.js +102 -51
  22. package/package.json +5 -1
@@ -0,0 +1,285 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect, vi } from "vitest";
4
+ const os = require("node:os");
5
+
6
+ const {
7
+ collect,
8
+ collectAndSync,
9
+ } = require("../../lib/adapters/social-toutiao-adb/collector");
10
+ const {
11
+ ToutiaoApiClient,
12
+ } = require("../../lib/adapters/social-toutiao-adb/api-client");
13
+
14
+ function makeFakeFetch(responses) {
15
+ const calls = [];
16
+ const fakeFetch = async (urlStr, opts) => {
17
+ calls.push({ url: urlStr, opts });
18
+ for (const [pattern, payload] of responses) {
19
+ if (urlStr.includes(pattern)) {
20
+ const resolved =
21
+ typeof payload === "function" ? await payload(urlStr, opts) : payload;
22
+ return {
23
+ ok: resolved.status == null || resolved.status === 200,
24
+ status: resolved.status || 200,
25
+ text: async () => resolved.body,
26
+ };
27
+ }
28
+ }
29
+ throw new Error("fake fetch: no response for " + urlStr);
30
+ };
31
+ return { fakeFetch, calls };
32
+ }
33
+
34
+ const HAPPY_RESPONSES = [
35
+ [
36
+ "passport/account/info/v2",
37
+ {
38
+ body: JSON.stringify({
39
+ status_code: 0,
40
+ data: {
41
+ user_id: "12345",
42
+ screen_name: "Alice",
43
+ avatar_url: "https://a/x.jpg",
44
+ },
45
+ }),
46
+ },
47
+ ],
48
+ [
49
+ "api/news/feed/v90",
50
+ {
51
+ body: JSON.stringify({
52
+ data: [{ group_id: "G1", title: "T1", behot_time: 1700000000 }],
53
+ }),
54
+ },
55
+ ],
56
+ [
57
+ "article/v2/tab_comments",
58
+ {
59
+ body: JSON.stringify({
60
+ data: [{ group_id: "C1", title: "Saved", behot_time: 1700001000 }],
61
+ }),
62
+ },
63
+ ],
64
+ [
65
+ "api/search/content",
66
+ {
67
+ body: JSON.stringify({
68
+ data: { user_search_history: [{ keyword: "kw", time: 1700002000 }] },
69
+ }),
70
+ },
71
+ ],
72
+ ];
73
+
74
+ function makeBridge(invokeResult) {
75
+ return {
76
+ invoke: vi.fn(async (method, params) => invokeResult),
77
+ };
78
+ }
79
+
80
+ const COOKIE_PAYLOAD = {
81
+ cookie: "sessionid=abc; passport_uid=12345",
82
+ uid: "12345",
83
+ diagnostic: { cookieCount: 2, hadEncrypted: false, cookieNames: ["sessionid", "passport_uid"] },
84
+ };
85
+
86
+ describe("collect — happy path with signProvider", () => {
87
+ it("warmUp → signed endpoints → shutdown", async () => {
88
+ const { fakeFetch } = makeFakeFetch(HAPPY_RESPONSES);
89
+ const calls = [];
90
+ const sign = {
91
+ warmUp: vi.fn(async (c) => calls.push({ warmUp: c })),
92
+ signUrl: vi.fn(async (url) => {
93
+ const u = new URL(String(url));
94
+ u.searchParams.set("_signature", "BRIDGE_SIG");
95
+ return u;
96
+ }),
97
+ shutdown: vi.fn(async () => calls.push("shutdown")),
98
+ };
99
+ const client = new ToutiaoApiClient({
100
+ fetch: fakeFetch,
101
+ signProvider: sign,
102
+ });
103
+ const r = await collect(makeBridge(COOKIE_PAYLOAD), {
104
+ apiClient: client,
105
+ signProvider: sign,
106
+ stagingDir: os.tmpdir(),
107
+ });
108
+ expect(sign.warmUp).toHaveBeenCalledWith(COOKIE_PAYLOAD.cookie);
109
+ expect(sign.shutdown).toHaveBeenCalledOnce();
110
+ expect(r.uid).toBe("12345");
111
+ expect(r.nickname).toBe("Alice");
112
+ expect(r.profileFetchFailed).toBe(false);
113
+ expect(r.eventCounts.feed).toBe(1);
114
+ expect(r.eventCounts.collection).toBe(1);
115
+ expect(r.eventCounts.search).toBe(1);
116
+ expect(r.eventCounts.profile).toBe(1);
117
+ expect(r.signProviderHits).toBe(3); // 3 signed endpoints
118
+ expect(r.signProviderFallbacks).toBe(0);
119
+ });
120
+ });
121
+
122
+ describe("collect — fallback path (no signProvider)", () => {
123
+ it("3 signed endpoints short-circuit; profile still emitted", async () => {
124
+ const { fakeFetch } = makeFakeFetch(HAPPY_RESPONSES);
125
+ const client = new ToutiaoApiClient({ fetch: fakeFetch });
126
+ const r = await collect(makeBridge(COOKIE_PAYLOAD), {
127
+ apiClient: client,
128
+ stagingDir: os.tmpdir(),
129
+ });
130
+ expect(r.uid).toBe("12345");
131
+ expect(r.profileFetchFailed).toBe(false);
132
+ expect(r.eventCounts.profile).toBe(1);
133
+ expect(r.eventCounts.feed).toBe(0); // short-circuit
134
+ expect(r.eventCounts.collection).toBe(0);
135
+ expect(r.eventCounts.search).toBe(0);
136
+ expect(r.signProviderUsed).toBe("none");
137
+ expect(r.signProviderHits).toBe(0);
138
+ expect(r.signProviderFallbacks).toBe(3);
139
+ });
140
+ });
141
+
142
+ describe("collect — profile fetch fails", () => {
143
+ it("emits empty snapshot with cookie-derived uid + profileFetchFailed=true", async () => {
144
+ const { fakeFetch } = makeFakeFetch([
145
+ [
146
+ "passport/account/info/v2",
147
+ {
148
+ body: JSON.stringify({
149
+ status_code: 1,
150
+ status_msg: "token expired",
151
+ }),
152
+ },
153
+ ],
154
+ ]);
155
+ const client = new ToutiaoApiClient({ fetch: fakeFetch });
156
+ const r = await collect(makeBridge(COOKIE_PAYLOAD), {
157
+ apiClient: client,
158
+ stagingDir: os.tmpdir(),
159
+ });
160
+ expect(r.profileFetchFailed).toBe(true);
161
+ expect(r.uid).toBe("12345"); // from cookie pre-extract
162
+ expect(r.eventCounts.total).toBe(0);
163
+ expect(r.lastErrorCode).toBe(1);
164
+ });
165
+
166
+ it("falls back to 'unknown-user' uid when cookie pre-extract also empty", async () => {
167
+ const { fakeFetch } = makeFakeFetch([
168
+ [
169
+ "passport/account/info/v2",
170
+ { body: JSON.stringify({ status_code: 1, status_msg: "expired" }) },
171
+ ],
172
+ ]);
173
+ const client = new ToutiaoApiClient({ fetch: fakeFetch });
174
+ const r = await collect(
175
+ makeBridge({
176
+ cookie: "sessionid=abc",
177
+ uid: null,
178
+ diagnostic: {},
179
+ }),
180
+ { apiClient: client, stagingDir: os.tmpdir() },
181
+ );
182
+ expect(r.uid).toBe(null);
183
+ expect(r.profileFetchFailed).toBe(true);
184
+ });
185
+ });
186
+
187
+ describe("collect — bridge warmUp failure", () => {
188
+ it("tolerates warmUp throw (falls through to fallback path)", async () => {
189
+ const { fakeFetch } = makeFakeFetch(HAPPY_RESPONSES);
190
+ const sign = {
191
+ warmUp: vi.fn(async () => {
192
+ throw new Error("toutiao.com 403 — anti-bot blocked");
193
+ }),
194
+ signUrl: vi.fn(async () => null),
195
+ shutdown: vi.fn(async () => {}),
196
+ };
197
+ const client = new ToutiaoApiClient({
198
+ fetch: fakeFetch,
199
+ signProvider: sign,
200
+ });
201
+ const r = await collect(makeBridge(COOKIE_PAYLOAD), {
202
+ apiClient: client,
203
+ signProvider: sign,
204
+ stagingDir: os.tmpdir(),
205
+ });
206
+ expect(r.profileFetchFailed).toBe(false); // profile uses no _sig
207
+ expect(r.eventCounts.feed).toBe(0); // signed endpoints fall through
208
+ expect(client._fallbackHits).toBe(3);
209
+ expect(sign.shutdown).toHaveBeenCalledOnce();
210
+ });
211
+ });
212
+
213
+ describe("collect — malformed bridge payload", () => {
214
+ it("throws when bridge.invoke returns no cookie", async () => {
215
+ const bridge = { invoke: vi.fn(async () => ({ uid: "1" })) };
216
+ await expect(
217
+ collect(bridge, { stagingDir: os.tmpdir() }),
218
+ ).rejects.toThrow(/malformed payload/);
219
+ });
220
+
221
+ it("throws when bridge missing invoke", async () => {
222
+ await expect(collect({}, {})).rejects.toThrow(
223
+ /bridge must expose invoke/,
224
+ );
225
+ });
226
+ });
227
+
228
+ describe("collect — signProviderUsed diagnostic", () => {
229
+ it("reports class name when bridge present", async () => {
230
+ const { fakeFetch } = makeFakeFetch(HAPPY_RESPONSES);
231
+ class ToutiaoSignBridge {
232
+ constructor() {
233
+ this.warmUp = vi.fn(async () => {});
234
+ this.signUrl = vi.fn(async (url) => {
235
+ const u = new URL(String(url));
236
+ u.searchParams.set("_signature", "X");
237
+ return u;
238
+ });
239
+ this.shutdown = vi.fn(async () => {});
240
+ }
241
+ }
242
+ const sign = new ToutiaoSignBridge();
243
+ const client = new ToutiaoApiClient({
244
+ fetch: fakeFetch,
245
+ signProvider: sign,
246
+ });
247
+ const r = await collect(makeBridge(COOKIE_PAYLOAD), {
248
+ apiClient: client,
249
+ signProvider: sign,
250
+ stagingDir: os.tmpdir(),
251
+ });
252
+ expect(r.signProviderUsed).toBe("ToutiaoSignBridge");
253
+ });
254
+
255
+ it("reports 'none' when no bridge", async () => {
256
+ const { fakeFetch } = makeFakeFetch(HAPPY_RESPONSES);
257
+ const client = new ToutiaoApiClient({ fetch: fakeFetch });
258
+ const r = await collect(makeBridge(COOKIE_PAYLOAD), {
259
+ apiClient: client,
260
+ stagingDir: os.tmpdir(),
261
+ });
262
+ expect(r.signProviderUsed).toBe("none");
263
+ });
264
+ });
265
+
266
+ describe("collectAndSync", () => {
267
+ it("orchestrates collect + registry.syncAdapter + cleanup", async () => {
268
+ const { fakeFetch } = makeFakeFetch(HAPPY_RESPONSES);
269
+ const client = new ToutiaoApiClient({ fetch: fakeFetch });
270
+ const registry = {
271
+ syncAdapter: vi.fn(async (name) => ({ adapter: name, status: "ok" })),
272
+ };
273
+ const r = await collectAndSync(makeBridge(COOKIE_PAYLOAD), registry, {
274
+ apiClient: client,
275
+ stagingDir: os.tmpdir(),
276
+ });
277
+ expect(registry.syncAdapter).toHaveBeenCalledWith(
278
+ "social-toutiao",
279
+ expect.objectContaining({ inputPath: expect.stringContaining(".json") }),
280
+ );
281
+ expect(r.adapter).toBe("social-toutiao");
282
+ expect(r.toutiao.uid).toBe("12345");
283
+ expect(r.toutiao.eventCounts.profile).toBe(1);
284
+ });
285
+ });
@@ -0,0 +1,163 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect, vi } from "vitest";
4
+
5
+ const {
6
+ createToutiaoCookiesExtension,
7
+ TOUTIAO_COOKIES_REMOTE_PATH,
8
+ TOUTIAO_COOKIE_HOST_DOMAIN,
9
+ TOUTIAO_SESSION_COOKIES,
10
+ TOUTIAO_UID_COOKIES,
11
+ assembleToutiaoCookieHeader,
12
+ _internals,
13
+ } = require("../../lib/adapters/social-toutiao-adb/cookies-extension");
14
+
15
+ describe("constants", () => {
16
+ it("path points to com.ss.android.article.news WebView cookies", () => {
17
+ expect(TOUTIAO_COOKIES_REMOTE_PATH).toBe(
18
+ "/data/data/com.ss.android.article.news/app_webview/Default/Cookies",
19
+ );
20
+ });
21
+
22
+ it("host domain is toutiao.com", () => {
23
+ expect(TOUTIAO_COOKIE_HOST_DOMAIN).toBe("toutiao.com");
24
+ });
25
+
26
+ it("session cookies enumerated (lenient — sessionid OR sessionid_ss)", () => {
27
+ expect([...TOUTIAO_SESSION_COOKIES]).toEqual(["sessionid", "sessionid_ss"]);
28
+ });
29
+
30
+ it("uid cookie candidates in priority order", () => {
31
+ expect([...TOUTIAO_UID_COOKIES]).toEqual([
32
+ "passport_uid",
33
+ "multi_sids",
34
+ "__ac_uid",
35
+ "tt_uid",
36
+ ]);
37
+ });
38
+ });
39
+
40
+ describe("pickUidFromCookieMap", () => {
41
+ const make = (entries) => new Map(entries.map(([k, v]) => [k, { value: v }]));
42
+
43
+ it("prefers passport_uid", () => {
44
+ const map = make([
45
+ ["passport_uid", "12345"],
46
+ ["__ac_uid", "99999"],
47
+ ]);
48
+ expect(_internals.pickUidFromCookieMap(map)).toBe("12345");
49
+ });
50
+
51
+ it("falls back to multi_sids first segment", () => {
52
+ const map = make([["multi_sids", "67890:abcd;11111:efgh"]]);
53
+ expect(_internals.pickUidFromCookieMap(map)).toBe("67890");
54
+ });
55
+
56
+ it("falls back to __ac_uid when passport + multi missing", () => {
57
+ const map = make([["__ac_uid", "555"]]);
58
+ expect(_internals.pickUidFromCookieMap(map)).toBe("555");
59
+ });
60
+
61
+ it("falls back to tt_uid (legacy) when above missing", () => {
62
+ const map = make([["tt_uid", "777"]]);
63
+ expect(_internals.pickUidFromCookieMap(map)).toBe("777");
64
+ });
65
+
66
+ it("returns null for '0' values (guest sentinel)", () => {
67
+ const map = make([
68
+ ["passport_uid", "0"],
69
+ ["__ac_uid", "0"],
70
+ ]);
71
+ expect(_internals.pickUidFromCookieMap(map)).toBe(null);
72
+ });
73
+
74
+ it("returns null for non-numeric values", () => {
75
+ const map = make([["passport_uid", "notnumeric"]]);
76
+ expect(_internals.pickUidFromCookieMap(map)).toBe(null);
77
+ });
78
+
79
+ it("returns null when none of the uid candidates present", () => {
80
+ const map = make([["sessionid", "abc"]]);
81
+ expect(_internals.pickUidFromCookieMap(map)).toBe(null);
82
+ });
83
+ });
84
+
85
+ describe("assembleToutiaoCookieHeader", () => {
86
+ const mkCookie = (name, value, hostKey = ".toutiao.com") => ({
87
+ name,
88
+ value,
89
+ hostKey,
90
+ });
91
+
92
+ it("builds header from a session + uid cookie", () => {
93
+ const cookies = [
94
+ mkCookie("sessionid", "sessabc"),
95
+ mkCookie("passport_uid", "12345"),
96
+ ];
97
+ const r = assembleToutiaoCookieHeader(cookies);
98
+ expect(r.header).toContain("sessionid=sessabc");
99
+ expect(r.header).toContain("passport_uid=12345");
100
+ expect(r.uid).toBe("12345");
101
+ expect(r.missing).toEqual([]);
102
+ });
103
+
104
+ it("succeeds with only sessionid_ss (no sessionid)", () => {
105
+ const cookies = [
106
+ mkCookie("sessionid_ss", "altsess"),
107
+ mkCookie("passport_uid", "999"),
108
+ ];
109
+ const r = assembleToutiaoCookieHeader(cookies);
110
+ expect(r.header).toContain("sessionid_ss=altsess");
111
+ expect(r.uid).toBe("999");
112
+ expect(r.missing).toEqual([]);
113
+ });
114
+
115
+ it("returns null header when no session cookie present", () => {
116
+ const cookies = [mkCookie("passport_uid", "12345")];
117
+ const r = assembleToutiaoCookieHeader(cookies);
118
+ expect(r.header).toBe(null);
119
+ expect(r.uid).toBe(null);
120
+ expect(r.missing).toEqual(["sessionid", "sessionid_ss"]);
121
+ });
122
+
123
+ it("succeeds without any uid cookie (uid=null, fetchProfile fills later)", () => {
124
+ const cookies = [
125
+ mkCookie("sessionid", "sess"),
126
+ mkCookie("ttwid", "anonid"),
127
+ ];
128
+ const r = assembleToutiaoCookieHeader(cookies);
129
+ expect(r.header).toContain("sessionid=sess");
130
+ expect(r.uid).toBe(null);
131
+ expect(r.missing).toEqual([]);
132
+ });
133
+
134
+ it("dedupes by longest hostKey when same name appears twice", () => {
135
+ const cookies = [
136
+ mkCookie("sessionid", "subdomain-val", "www.toutiao.com"),
137
+ mkCookie("sessionid", "wildcard-val", ".toutiao.com"),
138
+ mkCookie("passport_uid", "1"),
139
+ ];
140
+ const r = assembleToutiaoCookieHeader(cookies);
141
+ // www.toutiao.com (16 chars) > .toutiao.com (12 chars) — longest wins
142
+ expect(r.header).toContain("sessionid=subdomain-val");
143
+ });
144
+
145
+ it("throws TypeError on non-array input", () => {
146
+ expect(() => assembleToutiaoCookieHeader(null)).toThrow(TypeError);
147
+ expect(() => assembleToutiaoCookieHeader("string")).toThrow(TypeError);
148
+ });
149
+ });
150
+
151
+ describe("createToutiaoCookiesExtension", () => {
152
+ it("rejects when ctx missing required functions", async () => {
153
+ const ext = createToutiaoCookiesExtension();
154
+ await expect(ext({}, {})).rejects.toThrow(/ctx must provide/);
155
+ });
156
+
157
+ it("rejects when ctx.adb is not a function", async () => {
158
+ const ext = createToutiaoCookiesExtension();
159
+ await expect(
160
+ ext({}, { adb: null, pickDevice: () => "serial" }),
161
+ ).rejects.toThrow(/ctx must provide/);
162
+ });
163
+ });
@@ -0,0 +1,196 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect } from "vitest";
4
+ const fs = require("node:fs");
5
+ const path = require("node:path");
6
+ const os = require("node:os");
7
+
8
+ const {
9
+ buildSnapshot,
10
+ writeSnapshotJson,
11
+ cleanupSnapshotJson,
12
+ SNAPSHOT_SCHEMA_VERSION,
13
+ } = require("../../lib/adapters/social-toutiao-adb/snapshot-builder");
14
+
15
+ describe("SNAPSHOT_SCHEMA_VERSION", () => {
16
+ it("is 1 (matches existing social-toutiao adapter)", () => {
17
+ expect(SNAPSHOT_SCHEMA_VERSION).toBe(1);
18
+ });
19
+ });
20
+
21
+ describe("buildSnapshot", () => {
22
+ it("throws on missing uid", () => {
23
+ expect(() => buildSnapshot({})).toThrow(/uid must be a non-empty string/);
24
+ });
25
+
26
+ it("emits profile event when input.profile given", () => {
27
+ const snap = buildSnapshot({
28
+ uid: "12345",
29
+ profile: {
30
+ nickname: "Alice",
31
+ avatarUrl: "https://a/x.jpg",
32
+ mobile: "138****",
33
+ followingCount: 10,
34
+ followerCount: 99,
35
+ mediaId: "5678",
36
+ },
37
+ snapshottedAt: 1716383021000,
38
+ });
39
+ expect(snap.schemaVersion).toBe(1);
40
+ expect(snap.account).toEqual({ uid: "12345", displayName: "" });
41
+ const profileEvents = snap.events.filter((e) => e.kind === "profile");
42
+ expect(profileEvents).toHaveLength(1);
43
+ expect(profileEvents[0]).toMatchObject({
44
+ kind: "profile",
45
+ id: "profile-12345",
46
+ uid: "12345",
47
+ nickname: "Alice",
48
+ followingCount: 10,
49
+ followerCount: 99,
50
+ mediaId: "5678",
51
+ });
52
+ });
53
+
54
+ it("skips profile event when input.profile missing", () => {
55
+ const snap = buildSnapshot({ uid: "12345" });
56
+ expect(snap.events.filter((e) => e.kind === "profile")).toHaveLength(0);
57
+ });
58
+
59
+ it("emits read events from feed input", () => {
60
+ const snap = buildSnapshot({
61
+ uid: "12345",
62
+ feed: [
63
+ {
64
+ itemId: "G1",
65
+ title: "Read 1",
66
+ category: "tech",
67
+ author: "AuthorA",
68
+ publishedAt: 1716000000000,
69
+ readDuration: 60,
70
+ source: "source-a",
71
+ },
72
+ ],
73
+ });
74
+ const reads = snap.events.filter((e) => e.kind === "read");
75
+ expect(reads).toHaveLength(1);
76
+ expect(reads[0]).toMatchObject({
77
+ kind: "read",
78
+ id: "read-G1",
79
+ capturedAt: 1716000000000,
80
+ itemId: "G1",
81
+ title: "Read 1",
82
+ category: "tech",
83
+ author: "AuthorA",
84
+ readDuration: 60,
85
+ source: "source-a",
86
+ });
87
+ });
88
+
89
+ it("emits collection events", () => {
90
+ const snap = buildSnapshot({
91
+ uid: "12345",
92
+ collection: [
93
+ {
94
+ itemId: "C1",
95
+ title: "Saved",
96
+ category: "news",
97
+ author: "AuthorB",
98
+ savedAt: 1716100000000,
99
+ },
100
+ ],
101
+ });
102
+ const cols = snap.events.filter((e) => e.kind === "collection");
103
+ expect(cols).toHaveLength(1);
104
+ expect(cols[0]).toMatchObject({
105
+ kind: "collection",
106
+ id: "collect-C1",
107
+ capturedAt: 1716100000000,
108
+ itemId: "C1",
109
+ title: "Saved",
110
+ category: "news",
111
+ author: "AuthorB",
112
+ });
113
+ });
114
+
115
+ it("emits search events with keyword as id base", () => {
116
+ const snap = buildSnapshot({
117
+ uid: "12345",
118
+ search: [{ keyword: "AI", searchedAt: 1716200000000 }],
119
+ });
120
+ const searches = snap.events.filter((e) => e.kind === "search");
121
+ expect(searches).toHaveLength(1);
122
+ expect(searches[0]).toMatchObject({
123
+ kind: "search",
124
+ id: "search-AI:1716200000000",
125
+ capturedAt: 1716200000000,
126
+ keyword: "AI",
127
+ searchAt: 1716200000000,
128
+ });
129
+ });
130
+
131
+ it("ignores search entries with empty keyword", () => {
132
+ const snap = buildSnapshot({
133
+ uid: "1",
134
+ search: [{ keyword: "", searchedAt: 1 }, { keyword: "ok", searchedAt: 2 }],
135
+ });
136
+ const searches = snap.events.filter((e) => e.kind === "search");
137
+ expect(searches).toHaveLength(1);
138
+ expect(searches[0].keyword).toBe("ok");
139
+ });
140
+
141
+ it("falls back to snapshottedAt when event lacks timestamp", () => {
142
+ const snap = buildSnapshot({
143
+ uid: "1",
144
+ feed: [{ itemId: "G", title: "T" }],
145
+ snapshottedAt: 1716000000000,
146
+ });
147
+ expect(snap.events[0].capturedAt).toBe(1716000000000);
148
+ });
149
+
150
+ it("emits multiple kinds at once with stable id formats", () => {
151
+ const snap = buildSnapshot({
152
+ uid: "12345",
153
+ profile: { nickname: "Alice" },
154
+ feed: [{ itemId: "G1", title: "T1" }],
155
+ collection: [{ itemId: "C1", title: "T2" }],
156
+ search: [{ keyword: "kw1", searchedAt: 1 }],
157
+ });
158
+ expect(snap.events).toHaveLength(4);
159
+ const ids = snap.events.map((e) => e.id);
160
+ expect(ids).toContain("profile-12345");
161
+ expect(ids).toContain("read-G1");
162
+ expect(ids).toContain("collect-C1");
163
+ expect(ids).toContain("search-kw1:1");
164
+ });
165
+ });
166
+
167
+ describe("writeSnapshotJson + cleanupSnapshotJson", () => {
168
+ it("writes valid JSON to staging dir", () => {
169
+ const snap = buildSnapshot({ uid: "1", profile: { nickname: "A" } });
170
+ const fullPath = writeSnapshotJson(snap);
171
+ try {
172
+ expect(fs.existsSync(fullPath)).toBe(true);
173
+ const round = JSON.parse(fs.readFileSync(fullPath, "utf-8"));
174
+ expect(round.account.uid).toBe("1");
175
+ } finally {
176
+ cleanupSnapshotJson(fullPath);
177
+ }
178
+ });
179
+
180
+ it("rejects fileName with path separator", () => {
181
+ const snap = buildSnapshot({ uid: "1" });
182
+ expect(() =>
183
+ writeSnapshotJson(snap, { fileName: "../escape.json" }),
184
+ ).toThrow(/basename, not a path/);
185
+ expect(() =>
186
+ writeSnapshotJson(snap, { fileName: "sub/dir.json" }),
187
+ ).toThrow(/basename, not a path/);
188
+ });
189
+
190
+ it("cleanupSnapshotJson tolerates missing file", () => {
191
+ expect(() => cleanupSnapshotJson(null)).not.toThrow();
192
+ expect(() =>
193
+ cleanupSnapshotJson(path.join(os.tmpdir(), "nonexistent-x-y-z.json")),
194
+ ).not.toThrow();
195
+ });
196
+ });