@chainlesschain/personal-data-hub 0.4.4 → 0.4.5

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 (41) hide show
  1. package/__tests__/adapters/edu-huawei-learning-live.test.js +198 -0
  2. package/__tests__/adapters/edu-zuoyebang-live.test.js +226 -0
  3. package/__tests__/adapters/family-23-collectors-scaffold.test.js +5 -1
  4. package/__tests__/adapters/finance-alipay-live.test.js +258 -0
  5. package/__tests__/adapters/game-genshin-live.test.js +238 -0
  6. package/__tests__/adapters/game-genshin-scaffold.test.js +4 -3
  7. package/__tests__/adapters/game-honor-of-kings-live.test.js +230 -0
  8. package/__tests__/adapters/netease-music-live.test.js +244 -0
  9. package/__tests__/adapters/social-douyin-adb-aweme-detail.test.js +165 -0
  10. package/__tests__/adapters/social-douyin-adb-watch-history.test.js +192 -0
  11. package/__tests__/adapters/social-toutiao-adb-account-reader.test.js +135 -0
  12. package/__tests__/adapters/social-toutiao-adb-api-client.test.js +89 -0
  13. package/__tests__/adapters/social-toutiao-adb-collector.test.js +95 -2
  14. package/__tests__/adapters/social-toutiao-adb-cookies-extension.test.js +30 -0
  15. package/__tests__/adapters/social-xiaohongshu-adb-cookies-extension.test.js +0 -0
  16. package/lib/adapters/_live-json-helpers.js +50 -0
  17. package/lib/adapters/edu-huawei-learning/api-client.js +178 -5
  18. package/lib/adapters/edu-huawei-learning/index.js +83 -9
  19. package/lib/adapters/edu-zuoyebang/api-client.js +181 -6
  20. package/lib/adapters/edu-zuoyebang/index.js +83 -9
  21. package/lib/adapters/finance-alipay/api-client.js +268 -6
  22. package/lib/adapters/finance-alipay/index.js +85 -9
  23. package/lib/adapters/game-genshin/api-client.js +207 -6
  24. package/lib/adapters/game-genshin/index.js +90 -9
  25. package/lib/adapters/game-honor-of-kings/api-client.js +235 -12
  26. package/lib/adapters/game-honor-of-kings/index.js +80 -9
  27. package/lib/adapters/netease-music/api-client.js +284 -0
  28. package/lib/adapters/netease-music/index.js +85 -9
  29. package/lib/adapters/social-douyin/index.js +2 -0
  30. package/lib/adapters/social-douyin-adb/aweme-detail-client.js +119 -0
  31. package/lib/adapters/social-douyin-adb/collector.js +114 -0
  32. package/lib/adapters/social-douyin-adb/index.js +18 -1
  33. package/lib/adapters/social-douyin-adb/watch-history-reader.js +188 -0
  34. package/lib/adapters/social-toutiao-adb/account-reader.js +179 -0
  35. package/lib/adapters/social-toutiao-adb/api-client.js +41 -17
  36. package/lib/adapters/social-toutiao-adb/collector.js +55 -19
  37. package/lib/adapters/social-toutiao-adb/cookies-extension.js +21 -1
  38. package/lib/adapters/social-toutiao-adb/index.js +6 -0
  39. package/lib/adapters/social-xiaohongshu-adb/cookies-extension.js +19 -1
  40. package/lib/index.js +1 -1
  41. package/package.json +1 -1
@@ -0,0 +1,230 @@
1
+ /**
2
+ * FAMILY-23 v0.2 — game-honor-of-kings LIVE 营地 (Camp) fetcher tests.
3
+ * credential (accessToken+openid+role) → profile + battle-list → snapshot-shaped
4
+ * events + adapter live sync + defensive field extraction + error mapping.
5
+ * All network via injected fetch. Endpoint shapes are best-effort (see api-client
6
+ * header) — these tests pin the REQUEST CONSTRUCTION + PARSING contract, not the
7
+ * live server, which can't be verified here.
8
+ */
9
+ "use strict";
10
+
11
+ import { describe, it, expect } from "vitest";
12
+
13
+ const { HonorOfKingsAdapter } = require("../../lib");
14
+ const {
15
+ HonorOfKingsApiClient,
16
+ pick,
17
+ toDurationMs,
18
+ toEpochMs,
19
+ } = require("../../lib/adapters/game-honor-of-kings/api-client");
20
+ const { validateBatch } = require("../../lib/batch");
21
+
22
+ const CRED = {
23
+ accessToken: "tok-abc",
24
+ openid: "OPENID12345678",
25
+ acctype: "qc",
26
+ areaId: "1",
27
+ roleId: "900000001",
28
+ };
29
+
30
+ function makeFetch(routes, calls) {
31
+ return async (url, init) => {
32
+ let parsedBody = null;
33
+ try {
34
+ parsedBody = init && init.body ? JSON.parse(init.body) : null;
35
+ } catch (_e) {
36
+ parsedBody = init && init.body;
37
+ }
38
+ calls.push({ url, body: parsedBody, headers: (init && init.headers) || {} });
39
+ const route = routes.find((r) => url.includes(r.match));
40
+ if (!route) return { ok: false, status: 404, text: async () => "not mapped" };
41
+ return {
42
+ ok: route.status ? route.status >= 200 && route.status < 300 : true,
43
+ status: route.status || 200,
44
+ text: async () =>
45
+ typeof route.body === "string" ? route.body : JSON.stringify(route.body),
46
+ };
47
+ };
48
+ }
49
+
50
+ async function collect(iter) {
51
+ const out = [];
52
+ for await (const r of iter) out.push(r);
53
+ return out;
54
+ }
55
+
56
+ describe("honor-of-kings field-extraction helpers", () => {
57
+ it("pick returns first present non-empty value", () => {
58
+ expect(pick({ a: "", b: "x" }, ["a", "b"])).toBe("x");
59
+ expect(pick({ a: 0 }, ["a"])).toBe(0); // 0 is present
60
+ expect(pick({}, ["a"], "fb")).toBe("fb");
61
+ });
62
+
63
+ it("toDurationMs treats <1e7 as seconds, else ms; coerces numeric strings", () => {
64
+ expect(toDurationMs(1800)).toBe(1800000); // 30 min in seconds
65
+ expect(toDurationMs(1800000)).toBe(1800000); // already ms
66
+ expect(toDurationMs("900")).toBe(900000);
67
+ expect(toDurationMs(0)).toBe(0);
68
+ expect(toDurationMs("nope")).toBe(0);
69
+ });
70
+
71
+ it("toEpochMs coerces seconds/ms/date-string", () => {
72
+ expect(toEpochMs(1700000000)).toBe(1700000000000);
73
+ expect(toEpochMs(1700000000000)).toBe(1700000000000);
74
+ expect(toEpochMs("2023-11-14T22:13:20Z")).toBe(1700000000000);
75
+ expect(toEpochMs("bogus")).toBeNull();
76
+ });
77
+ });
78
+
79
+ describe("HonorOfKingsApiClient.fetchSnapshot — live (mocked fetch)", () => {
80
+ it("merges auth fields into each request + parses profile/battles defensively", async () => {
81
+ const calls = [];
82
+ const fetch = makeFetch(
83
+ [
84
+ {
85
+ match: "/play/profildetail",
86
+ body: {
87
+ returnCode: 0,
88
+ data: { role: { roleId: "900000001", roleName: "小明", level: 30, rankName: "钻石", logo: "u.png" } },
89
+ },
90
+ },
91
+ {
92
+ match: "/game/getbattlelist",
93
+ body: {
94
+ returnCode: 0,
95
+ data: {
96
+ list: [
97
+ { gameSeq: "g1", startTime: 1700000000, gametime: 900, mapName: "排位赛" },
98
+ { gameSeq: "g2", startTime: "1700003600", usedTime: 1200, modeName: "巅峰赛" },
99
+ ],
100
+ },
101
+ },
102
+ },
103
+ ],
104
+ calls,
105
+ );
106
+ const client = new HonorOfKingsApiClient({ fetch });
107
+ const result = await client.fetchSnapshot(CRED);
108
+ expect(result.account).toEqual({ uid: "900000001", displayName: "小明" });
109
+ const profile = result.events.find((e) => e.kind === "profile");
110
+ expect(profile).toMatchObject({ uid: "900000001", nickname: "小明", level: 30, rank: "钻石", avatarUrl: "u.png" });
111
+ const plays = result.events.filter((e) => e.kind === "play");
112
+ expect(plays).toHaveLength(2);
113
+ expect(plays[0]).toMatchObject({ durationMs: 900000, mode: "排位赛", startAt: 1700000000000 });
114
+ expect(plays[1]).toMatchObject({ durationMs: 1200000, mode: "巅峰赛", startAt: 1700003600000 });
115
+ // every request carried the OAuth auth fields.
116
+ for (const c of calls) {
117
+ expect(c.body.accessToken).toBe("tok-abc");
118
+ expect(c.body.openid).toBe("OPENID12345678");
119
+ expect(c.body.acctype).toBe("qc");
120
+ expect(c.headers["Content-Type"]).toContain("application/json");
121
+ }
122
+ });
123
+
124
+ it("handles alternate envelope/field names (result + roleInfo + battleList)", async () => {
125
+ const calls = [];
126
+ const fetch = makeFetch(
127
+ [
128
+ {
129
+ match: "/play/profildetail",
130
+ body: { result: 0, data: { roleInfo: { uid: "777", nickName: "阿强", roleLevel: 15, gradeName: "黄金" } } },
131
+ },
132
+ {
133
+ match: "/game/getbattlelist",
134
+ body: { result: 0, data: { battleList: [{ gameId: "b9", battleTime: 1700000000, gameDuration: 600, gameName: "5v5" }] } },
135
+ },
136
+ ],
137
+ calls,
138
+ );
139
+ const client = new HonorOfKingsApiClient({ fetch });
140
+ const result = await client.fetchSnapshot(CRED);
141
+ expect(result.account.displayName).toBe("阿强");
142
+ const profile = result.events.find((e) => e.kind === "profile");
143
+ expect(profile).toMatchObject({ uid: "777", level: 15, rank: "黄金" });
144
+ const play = result.events.find((e) => e.kind === "play");
145
+ expect(play).toMatchObject({ durationMs: 600000, mode: "5v5" });
146
+ });
147
+
148
+ it("include.play:false skips the battle-list call", async () => {
149
+ const calls = [];
150
+ const fetch = makeFetch(
151
+ [{ match: "/play/profildetail", body: { returnCode: 0, data: { role: { roleId: "1", roleName: "x" } } } }],
152
+ calls,
153
+ );
154
+ const client = new HonorOfKingsApiClient({ fetch });
155
+ const result = await client.fetchSnapshot(CRED, { include: { play: false } });
156
+ expect(calls.some((c) => c.url.includes("getbattlelist"))).toBe(false);
157
+ expect(result.events.every((e) => e.kind !== "play")).toBe(true);
158
+ });
159
+
160
+ it("maps non-zero returnCode to null + lastError (e.g. token expired)", async () => {
161
+ const calls = [];
162
+ const fetch = makeFetch([{ match: "/play/profildetail", body: { returnCode: 1001, returnMsg: "登录态失效" } }], calls);
163
+ const client = new HonorOfKingsApiClient({ fetch });
164
+ expect(await client.fetchSnapshot(CRED)).toBeNull();
165
+ expect(client.lastError.code).toBe(1001);
166
+ expect(client.lastError.message).toContain("登录态失效");
167
+ });
168
+
169
+ it("missing accessToken/openid → null + lastError -1 (no network)", async () => {
170
+ const calls = [];
171
+ const client = new HonorOfKingsApiClient({ fetch: makeFetch([], calls) });
172
+ expect(await client.fetchSnapshot({ openid: "x" })).toBeNull();
173
+ expect(client.lastError.code).toBe(-1);
174
+ expect(calls).toHaveLength(0);
175
+ });
176
+
177
+ it("HTTP non-2xx → null + lastError with status", async () => {
178
+ const calls = [];
179
+ const fetch = makeFetch([{ match: "/play/profildetail", status: 500, body: "err" }], calls);
180
+ const client = new HonorOfKingsApiClient({ fetch });
181
+ expect(await client.fetchSnapshot(CRED)).toBeNull();
182
+ expect(client.lastError.code).toBe(500);
183
+ });
184
+ });
185
+
186
+ describe("HonorOfKingsAdapter — credential (live) sync mode", () => {
187
+ it("authenticate accepts a credential bundle; rejects incomplete", async () => {
188
+ const a = new HonorOfKingsAdapter();
189
+ expect((await a.authenticate({ credential: CRED })).mode).toBe("camp-token");
190
+ expect((await a.authenticate({ credential: { openid: "x" } })).ok).toBe(false);
191
+ });
192
+
193
+ it("version/capabilities reflect v0.2 live mode", () => {
194
+ const a = new HonorOfKingsAdapter();
195
+ expect(a.version).toBe("0.2.0");
196
+ expect(a.capabilities).toContain("sync:camp-token");
197
+ });
198
+
199
+ it("sync via credential yields profile+play raws → valid normalized batch", async () => {
200
+ const calls = [];
201
+ const fetch = makeFetch(
202
+ [
203
+ { match: "/play/profildetail", body: { returnCode: 0, data: { role: { roleId: "900000001", roleName: "小明", level: 30, rankName: "钻石" } } } },
204
+ { match: "/game/getbattlelist", body: { returnCode: 0, data: { list: [{ gameSeq: "g1", startTime: 1700000000, gametime: 1800, mapName: "排位赛" }] } } },
205
+ ],
206
+ calls,
207
+ );
208
+ const a = new HonorOfKingsAdapter();
209
+ const raws = await collect(a.sync({ credential: CRED, fetch }));
210
+ expect(raws.map((r) => r.kind).sort()).toEqual(["play", "profile"]);
211
+ const profileRaw = raws.find((r) => r.kind === "profile");
212
+ expect(profileRaw.originalId).toBe("hok:profile:profile-900000001");
213
+
214
+ for (const raw of raws) {
215
+ expect(validateBatch(a.normalize(raw)).valid).toBe(true);
216
+ }
217
+ const profileBatch = a.normalize(profileRaw);
218
+ expect(profileBatch.persons[0].identifiers["hok-uid"]).toEqual(["900000001"]);
219
+ expect(profileBatch.persons[0].extra.rank).toBe("钻石");
220
+ const playBatch = a.normalize(raws.find((r) => r.kind === "play"));
221
+ expect(playBatch.events[0].extra.durationMs).toBe(1800000);
222
+ });
223
+
224
+ it("sync via credential throws (mapped lastError) on API error", async () => {
225
+ const calls = [];
226
+ const fetch = makeFetch([{ match: "/play/profildetail", body: { returnCode: 1001, returnMsg: "登录态失效" } }], calls);
227
+ const a = new HonorOfKingsAdapter();
228
+ await expect(collect(a.sync({ credential: CRED, fetch }))).rejects.toThrow(/登录态失效|code 1001/);
229
+ });
230
+ });
@@ -0,0 +1,244 @@
1
+ /**
2
+ * 网易云音乐 LIVE cookie web-API (weapi) fetcher tests.
3
+ * weapi crypto determinism (fixed secKey) + account/play/playlist parsing +
4
+ * adapter cookie-mode sync + error mapping. All network via injected fetch.
5
+ */
6
+ "use strict";
7
+
8
+ import { describe, it, expect } from "vitest";
9
+
10
+ const { NeteaseMusicAdapter } = require("../../lib/adapters/netease-music");
11
+ const {
12
+ NeteaseMusicApiClient,
13
+ weapiEncrypt,
14
+ aesEncrypt,
15
+ rsaEncrypt,
16
+ modpow,
17
+ } = require("../../lib/adapters/netease-music/api-client");
18
+ const { partitionBatch } = require("../../lib/batch");
19
+
20
+ const FIXED_SECKEY = "0123456789abcdef"; // 16 chars → deterministic weapi
21
+ const COOKIE = "MUSIC_U=abcdef0123456789; __csrf=zzz";
22
+
23
+ /** Map endpoint path substrings → JSON bodies; record posted form bodies. */
24
+ function makeFetch(routes, calls) {
25
+ return async (url, init) => {
26
+ calls.push({ url, body: init && init.body, headers: (init && init.headers) || {} });
27
+ const route = routes.find((r) => url.includes(r.match));
28
+ if (!route) return { ok: false, status: 404, text: async () => "not mapped" };
29
+ return {
30
+ ok: route.status ? route.status >= 200 && route.status < 300 : true,
31
+ status: route.status || 200,
32
+ text: async () =>
33
+ typeof route.body === "string" ? route.body : JSON.stringify(route.body),
34
+ };
35
+ };
36
+ }
37
+
38
+ async function collect(iter) {
39
+ const out = [];
40
+ for await (const r of iter) out.push(r);
41
+ return out;
42
+ }
43
+
44
+ describe("weapi crypto primitives", () => {
45
+ it("modpow matches BigInt ** % for small values", () => {
46
+ expect(modpow(4n, 13n, 497n)).toBe(445n); // textbook RSA example
47
+ expect(modpow(7n, 0n, 13n)).toBe(1n);
48
+ });
49
+
50
+ it("aesEncrypt is deterministic (fixed key + IV) and base64", () => {
51
+ const a = aesEncrypt("hello", "0CoJUm6Qyw8W8jud");
52
+ const b = aesEncrypt("hello", "0CoJUm6Qyw8W8jud");
53
+ expect(a).toBe(b);
54
+ expect(a).toMatch(/^[A-Za-z0-9+/=]+$/);
55
+ });
56
+
57
+ it("rsaEncrypt yields zero-padded 256-hex", () => {
58
+ const enc = rsaEncrypt("test", "010001", "e0b5");
59
+ expect(enc).toHaveLength(256);
60
+ expect(enc).toMatch(/^[0-9a-f]{256}$/);
61
+ });
62
+
63
+ it("weapiEncrypt produces stable { params, encSecKey } for a fixed secKey", () => {
64
+ const a = weapiEncrypt({ uid: 1 }, FIXED_SECKEY);
65
+ const b = weapiEncrypt({ uid: 1 }, FIXED_SECKEY);
66
+ expect(a).toEqual(b);
67
+ expect(a.encSecKey).toHaveLength(256);
68
+ expect(a.params).toMatch(/^[A-Za-z0-9+/=]+$/);
69
+ });
70
+ });
71
+
72
+ describe("NeteaseMusicApiClient.fetchSnapshot — live (mocked fetch)", () => {
73
+ it("resolves account, play record, playlists into snapshot-shaped events", async () => {
74
+ const calls = [];
75
+ const fetch = makeFetch(
76
+ [
77
+ {
78
+ match: "/weapi/w/nuser/account/get",
79
+ body: { code: 200, profile: { userId: 42, nickname: "听歌的人" }, account: { id: 42 } },
80
+ },
81
+ {
82
+ match: "/weapi/v1/play/record",
83
+ body: {
84
+ code: 200,
85
+ weekData: [
86
+ { playCount: 50, song: { id: 186016, name: "晴天", ar: [{ name: "周杰伦" }], al: { name: "叶惠美" } } },
87
+ ],
88
+ },
89
+ },
90
+ {
91
+ match: "/weapi/user/playlist",
92
+ body: {
93
+ code: 200,
94
+ playlist: [{ id: 999, name: "我喜欢的音乐", trackCount: 200, creator: { nickname: "听歌的人" } }],
95
+ },
96
+ },
97
+ ],
98
+ calls,
99
+ );
100
+ const client = new NeteaseMusicApiClient({ fetch, secKey: FIXED_SECKEY });
101
+ const result = await client.fetchSnapshot(COOKIE);
102
+ expect(result.account).toEqual({ uid: "42", nickname: "听歌的人" });
103
+ expect(result.events).toHaveLength(2);
104
+ const play = result.events.find((e) => e.kind === "play");
105
+ expect(play).toMatchObject({ songId: "186016", song: "晴天", artist: "周杰伦", album: "叶惠美", playCount: 50 });
106
+ const pl = result.events.find((e) => e.kind === "playlist");
107
+ expect(pl).toMatchObject({ playlistId: "999", name: "我喜欢的音乐", trackCount: 200, creator: "听歌的人" });
108
+ // every POST is form-encoded weapi (params + encSecKey) with the cookie.
109
+ for (const c of calls) {
110
+ expect(c.body).toMatch(/^params=.+&encSecKey=.+$/);
111
+ expect(c.headers.Cookie).toBe(COOKIE);
112
+ expect(c.headers["Content-Type"]).toContain("x-www-form-urlencoded");
113
+ }
114
+ });
115
+
116
+ it("multi-artist joined with ' / '; falls back to allData when weekData empty", async () => {
117
+ const calls = [];
118
+ const fetch = makeFetch(
119
+ [
120
+ { match: "account/get", body: { code: 200, profile: { userId: 7 } } },
121
+ {
122
+ match: "play/record",
123
+ body: {
124
+ code: 200,
125
+ weekData: [],
126
+ allData: [{ playCount: 3, song: { id: 1, name: "S", ar: [{ name: "A" }, { name: "B" }], al: { name: "Al" } } }],
127
+ },
128
+ },
129
+ { match: "user/playlist", body: { code: 200, playlist: [] } },
130
+ ],
131
+ calls,
132
+ );
133
+ const client = new NeteaseMusicApiClient({ fetch, secKey: FIXED_SECKEY });
134
+ const result = await client.fetchSnapshot(COOKIE);
135
+ const play = result.events.find((e) => e.kind === "play");
136
+ expect(play.artist).toBe("A / B");
137
+ });
138
+
139
+ it("include flags skip endpoints", async () => {
140
+ const calls = [];
141
+ const fetch = makeFetch(
142
+ [
143
+ { match: "account/get", body: { code: 200, profile: { userId: 7 } } },
144
+ { match: "user/playlist", body: { code: 200, playlist: [] } },
145
+ ],
146
+ calls,
147
+ );
148
+ const client = new NeteaseMusicApiClient({ fetch, secKey: FIXED_SECKEY });
149
+ const result = await client.fetchSnapshot(COOKIE, { include: { play: false } });
150
+ expect(calls.some((c) => c.url.includes("play/record"))).toBe(false);
151
+ expect(result.events.every((e) => e.kind !== "play")).toBe(true);
152
+ });
153
+
154
+ it("maps API code != 200 to null + lastError (e.g. -460 risk-control)", async () => {
155
+ const calls = [];
156
+ const fetch = makeFetch(
157
+ [{ match: "account/get", body: { code: -460, message: "Cheating" } }],
158
+ calls,
159
+ );
160
+ const client = new NeteaseMusicApiClient({ fetch, secKey: FIXED_SECKEY });
161
+ expect(await client.fetchSnapshot(COOKIE)).toBeNull();
162
+ expect(client.lastError.code).toBe(-460);
163
+ expect(client.lastError.message).toContain("Cheating");
164
+ });
165
+
166
+ it("account with no userId → null + lastError -7", async () => {
167
+ const calls = [];
168
+ const fetch = makeFetch([{ match: "account/get", body: { code: 200, profile: null, account: null } }], calls);
169
+ const client = new NeteaseMusicApiClient({ fetch, secKey: FIXED_SECKEY });
170
+ expect(await client.fetchSnapshot(COOKIE)).toBeNull();
171
+ expect(client.lastError.code).toBe(-7);
172
+ });
173
+
174
+ it("empty cookie → null + lastError -1 (no network)", async () => {
175
+ const calls = [];
176
+ const client = new NeteaseMusicApiClient({ fetch: makeFetch([], calls), secKey: FIXED_SECKEY });
177
+ expect(await client.fetchSnapshot("")).toBeNull();
178
+ expect(client.lastError.code).toBe(-1);
179
+ expect(calls).toHaveLength(0);
180
+ });
181
+
182
+ it("HTTP non-2xx → null + lastError with status", async () => {
183
+ const calls = [];
184
+ const fetch = makeFetch([{ match: "account/get", status: 502, body: "bad gw" }], calls);
185
+ const client = new NeteaseMusicApiClient({ fetch, secKey: FIXED_SECKEY });
186
+ expect(await client.fetchSnapshot(COOKIE)).toBeNull();
187
+ expect(client.lastError.code).toBe(502);
188
+ });
189
+ });
190
+
191
+ describe("NeteaseMusicAdapter — cookie (live) sync mode", () => {
192
+ it("authenticate accepts a cookie with MUSIC_U; rejects without it", async () => {
193
+ const a = new NeteaseMusicAdapter();
194
+ expect((await a.authenticate({ cookie: COOKIE })).mode).toBe("cookie");
195
+ const bad = await a.authenticate({ cookie: "foo=bar" });
196
+ expect(bad.ok).toBe(false);
197
+ expect(bad.reason).toBe("INVALID_COOKIE");
198
+ });
199
+
200
+ it("capabilities/version reflect v0.2 live mode", () => {
201
+ const a = new NeteaseMusicAdapter();
202
+ expect(a.version).toBe("0.2.0");
203
+ expect(a.capabilities).toContain("sync:cookie");
204
+ });
205
+
206
+ it("sync via cookie yields play+playlist raws → valid normalized batch", async () => {
207
+ const calls = [];
208
+ const fetch = makeFetch(
209
+ [
210
+ { match: "account/get", body: { code: 200, profile: { userId: 42, nickname: "me" } } },
211
+ {
212
+ match: "play/record",
213
+ body: { code: 200, weekData: [{ playCount: 9, song: { id: 5, name: "夜曲", ar: [{ name: "周杰伦" }], al: { name: "11月的萧邦" } } }] },
214
+ },
215
+ { match: "user/playlist", body: { code: 200, playlist: [{ id: 8, name: "练歌", trackCount: 12, creator: { nickname: "me" } }] } },
216
+ ],
217
+ calls,
218
+ );
219
+ const a = new NeteaseMusicAdapter();
220
+ const raws = await collect(a.sync({ cookie: COOKIE, fetch, secKey: FIXED_SECKEY }));
221
+ expect(raws.map((r) => r.kind).sort()).toEqual(["play", "playlist"]);
222
+ const playRaw = raws.find((r) => r.kind === "play");
223
+ expect(playRaw.originalId).toBe("netease-music:play:play-5");
224
+
225
+ // normalize → vault-valid batch for both kinds.
226
+ for (const raw of raws) {
227
+ const batch = a.normalize(raw);
228
+ const { invalidReasons } = partitionBatch(batch);
229
+ expect(invalidReasons).toHaveLength(0);
230
+ }
231
+ const playBatch = a.normalize(playRaw);
232
+ expect(playBatch.items[0].extra.songId).toBe("5");
233
+ expect(playBatch.events[0].content.title).toContain("夜曲");
234
+ });
235
+
236
+ it("sync via cookie throws (mapped lastError) on risk-control", async () => {
237
+ const calls = [];
238
+ const fetch = makeFetch([{ match: "account/get", body: { code: -460, message: "Cheating" } }], calls);
239
+ const a = new NeteaseMusicAdapter();
240
+ await expect(collect(a.sync({ cookie: COOKIE, fetch, secKey: FIXED_SECKEY }))).rejects.toThrow(
241
+ /Cheating|code -460/,
242
+ );
243
+ });
244
+ });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Douyin aweme-detail title resolver tests (real-device-driven 2026-06-11:
3
+ * web detail endpoint returns aweme_detail{desc,author,duration} with no signing).
4
+ * fetch injected — no network.
5
+ */
6
+ "use strict";
7
+
8
+ import { describe, it, expect, vi } from "vitest";
9
+
10
+ const {
11
+ AwemeDetailClient,
12
+ } = require("../../lib/adapters/social-douyin-adb/aweme-detail-client");
13
+ const {
14
+ collectWatchHistory,
15
+ } = require("../../lib/adapters/social-douyin-adb/collector");
16
+ const { DouyinAdapter } = require("../../lib/adapters/social-douyin");
17
+ const { partitionBatch } = require("../../lib/batch");
18
+
19
+ function makeFetch(byAid) {
20
+ return async (url) => {
21
+ const m = /aweme_id=(\d+)/.exec(url);
22
+ const aid = m && m[1];
23
+ const payload = byAid[aid];
24
+ if (payload === undefined) return { ok: false, status: 404, text: async () => "nf" };
25
+ return {
26
+ ok: payload.status ? payload.status >= 200 && payload.status < 300 : true,
27
+ status: payload.status || 200,
28
+ text: async () => (typeof payload.body === "string" ? payload.body : JSON.stringify(payload.body)),
29
+ };
30
+ };
31
+ }
32
+ const noSleep = () => Promise.resolve();
33
+
34
+ describe("AwemeDetailClient.fetchDetail", () => {
35
+ it("parses aweme_detail → {desc, author, durationMs, createTime}; sends aweme_id + webapp params", async () => {
36
+ let seenUrl;
37
+ const fetch = async (url) => {
38
+ seenUrl = url;
39
+ return { ok: true, status: 200, text: async () => JSON.stringify({
40
+ status_code: 0,
41
+ aweme_detail: { desc: "洋气婆婆和她的土狗儿媳", author: { nickname: "任集" }, duration: 9200, create_time: 1780112750 },
42
+ }) };
43
+ };
44
+ const c = new AwemeDetailClient({ fetch, sleep: noSleep });
45
+ const d = await c.fetchDetail("7645526043227334246");
46
+ expect(d).toEqual({ awemeId: "7645526043227334246", desc: "洋气婆婆和她的土狗儿媳", author: "任集", durationMs: 9200, createTime: 1780112750 });
47
+ expect(seenUrl).toContain("aweme_id=7645526043227334246");
48
+ expect(seenUrl).toContain("device_platform=webapp");
49
+ expect(seenUrl).toContain("aid=6383");
50
+ });
51
+
52
+ it("status_code != 0 → null + lastError", async () => {
53
+ const fetch = makeFetch({ 1: { body: { status_code: 8, status_msg: "risk" } } });
54
+ const c = new AwemeDetailClient({ fetch, sleep: noSleep });
55
+ expect(await c.fetchDetail("1")).toBe(null);
56
+ expect(c.lastErrorCode).toBe(8);
57
+ });
58
+
59
+ it("missing aweme_detail (deleted/private) → null -5", async () => {
60
+ const fetch = makeFetch({ 1: { body: { status_code: 0 } } });
61
+ const c = new AwemeDetailClient({ fetch, sleep: noSleep });
62
+ expect(await c.fetchDetail("1")).toBe(null);
63
+ expect(c.lastErrorCode).toBe(-5);
64
+ });
65
+
66
+ it("HTTP non-2xx → null with status", async () => {
67
+ const fetch = makeFetch({ 1: { status: 444, body: "blocked" } });
68
+ const c = new AwemeDetailClient({ fetch, sleep: noSleep });
69
+ expect(await c.fetchDetail("1")).toBe(null);
70
+ expect(c.lastErrorCode).toBe(444);
71
+ });
72
+ });
73
+
74
+ describe("AwemeDetailClient.resolveMany", () => {
75
+ it("dedups ids, caps at limit, skips per-id failures", async () => {
76
+ const calls = [];
77
+ const fetch = async (url) => {
78
+ const aid = /aweme_id=(\d+)/.exec(url)[1];
79
+ calls.push(aid);
80
+ const ok = { 11: true, 22: true }[aid];
81
+ return { ok: true, status: 200, text: async () => JSON.stringify(
82
+ ok ? { status_code: 0, aweme_detail: { desc: "d" + aid, author: { nickname: "a" } } }
83
+ : { status_code: 0 }, // 33 → no aweme_detail → skipped
84
+ ) };
85
+ };
86
+ const c = new AwemeDetailClient({ fetch, sleep: noSleep });
87
+ const map = await c.resolveMany(["11", "11", "22", "33"], { limit: 10 });
88
+ expect(calls).toEqual(["11", "22", "33"]); // deduped (one 11)
89
+ expect([...map.keys()].sort()).toEqual(["11", "22"]); // 33 skipped
90
+ expect(map.get("11").desc).toBe("d11");
91
+ });
92
+
93
+ it("respects limit (stops early)", async () => {
94
+ const calls = [];
95
+ const fetch = async (url) => {
96
+ calls.push(/aweme_id=(\d+)/.exec(url)[1]);
97
+ return { ok: true, status: 200, text: async () => JSON.stringify({ status_code: 0, aweme_detail: { desc: "x" } }) };
98
+ };
99
+ const c = new AwemeDetailClient({ fetch, sleep: noSleep });
100
+ await c.resolveMany(["1", "2", "3", "4", "5"], { limit: 2 });
101
+ expect(calls).toEqual(["1", "2"]);
102
+ });
103
+ });
104
+
105
+ describe("collectWatchHistory --resolve-titles integration", () => {
106
+ it("attaches title/author/duration → normalizeHistory shows real content", async () => {
107
+ const fs = require("node:fs");
108
+ const os = require("node:os");
109
+ const bridge = {
110
+ invoke: vi.fn(async (m) =>
111
+ m === "douyin.watch-history"
112
+ ? {
113
+ uid: "92585448288",
114
+ records: [
115
+ { awemeId: "7645526043227334246", capturedAt: 1780112750000, enterFrom: "homepage_hot" },
116
+ ],
117
+ }
118
+ : (() => { throw new Error("unknown " + m); })(),
119
+ ),
120
+ };
121
+ const detailClient = new AwemeDetailClient({
122
+ sleep: () => Promise.resolve(),
123
+ fetch: makeFetch({
124
+ "7645526043227334246": { body: { status_code: 0, aweme_detail: { desc: "洋气婆婆和她的土狗儿媳", author: { nickname: "任集" }, duration: 9200 } } },
125
+ }),
126
+ });
127
+ const r = await collectWatchHistory(bridge, {
128
+ stagingDir: os.tmpdir(),
129
+ now: () => 1781000000000,
130
+ resolveTitles: true,
131
+ _detailClient: detailClient,
132
+ });
133
+ expect(r.titlesResolved).toBe(1);
134
+ const snap = JSON.parse(fs.readFileSync(r.snapshotPath, "utf-8"));
135
+ try {
136
+ expect(snap.events[0].title).toBe("洋气婆婆和她的土狗儿媳");
137
+ const a = new DouyinAdapter();
138
+ const batch = a.normalize({
139
+ adapter: "social-douyin",
140
+ kind: "history",
141
+ originalId: "douyin:history:1",
142
+ capturedAt: 1780112750000,
143
+ payload: { ...snap.events[0], account: snap.account },
144
+ });
145
+ expect(partitionBatch(batch).invalidReasons).toHaveLength(0);
146
+ expect(batch.events[0].content.title).toBe("洋气婆婆和她的土狗儿媳");
147
+ expect(batch.events[0].extra.author).toBe("任集");
148
+ expect(batch.events[0].extra.duration).toBe(9200);
149
+ expect(batch.events[0].extra.enterFrom).toBe("homepage_hot");
150
+ } finally {
151
+ fs.unlinkSync(r.snapshotPath);
152
+ }
153
+ });
154
+
155
+ it("without resolveTitles, no network + title stays unresolved", async () => {
156
+ const os = require("node:os");
157
+ const fs = require("node:fs");
158
+ const bridge = { invoke: vi.fn(async () => ({ uid: "1", records: [{ awemeId: "999", capturedAt: 1, enterFrom: "x" }] })) };
159
+ const r = await collectWatchHistory(bridge, { stagingDir: os.tmpdir(), now: () => 1, resolveTitles: false });
160
+ expect(r.titlesResolved).toBe(0);
161
+ const snap = JSON.parse(fs.readFileSync(r.snapshotPath, "utf-8"));
162
+ fs.unlinkSync(r.snapshotPath);
163
+ expect(snap.events[0].title).toBeUndefined();
164
+ });
165
+ });