@chainlesschain/personal-data-hub 0.4.23 → 0.4.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/__tests__/adapters/bank-family.test.js +125 -0
  2. package/__tests__/adapters/car-mercedesme.test.js +74 -0
  3. package/__tests__/adapters/finance-dcep.test.js +74 -0
  4. package/__tests__/adapters/fitness-joyrun.test.js +82 -0
  5. package/__tests__/adapters/gov-12123.test.js +103 -0
  6. package/__tests__/adapters/music-qq.test.js +112 -0
  7. package/__tests__/adapters/reading-family.test.js +108 -0
  8. package/__tests__/adapters/travel-didi-consumer.test.js +66 -0
  9. package/__tests__/audio-ximalaya-snapshot.test.js +279 -0
  10. package/__tests__/fitness-keep-snapshot.test.js +224 -0
  11. package/__tests__/shopping-eleme-snapshot.test.js +454 -0
  12. package/__tests__/shopping-vipshop-snapshot.test.js +425 -0
  13. package/__tests__/shopping-xianyu-snapshot.test.js +451 -0
  14. package/__tests__/social-douban-snapshot.test.js +351 -0
  15. package/lib/adapter-guide.js +19 -1
  16. package/lib/adapters/_bank-base.js +405 -0
  17. package/lib/adapters/_reading-base.js +315 -0
  18. package/lib/adapters/audio-ximalaya/index.js +414 -0
  19. package/lib/adapters/bank-bankcomm/index.js +27 -0
  20. package/lib/adapters/bank-boc/index.js +26 -0
  21. package/lib/adapters/bank-cmbc/index.js +26 -0
  22. package/lib/adapters/bank-icbc/index.js +27 -0
  23. package/lib/adapters/car-mercedesme/index.js +225 -0
  24. package/lib/adapters/finance-dcep/index.js +302 -0
  25. package/lib/adapters/fitness-joyrun/index.js +295 -0
  26. package/lib/adapters/fitness-keep/index.js +343 -0
  27. package/lib/adapters/gov-12123/index.js +391 -0
  28. package/lib/adapters/music-qq/index.js +372 -0
  29. package/lib/adapters/reading-fanqie/index.js +61 -0
  30. package/lib/adapters/reading-qimao/index.js +61 -0
  31. package/lib/adapters/shopping-eleme/index.js +441 -0
  32. package/lib/adapters/shopping-vipshop/index.js +429 -0
  33. package/lib/adapters/shopping-xianyu/index.js +454 -0
  34. package/lib/adapters/social-douban/index.js +564 -0
  35. package/lib/adapters/travel-didi-consumer/index.js +148 -0
  36. package/lib/index.js +36 -0
  37. package/package.json +1 -1
@@ -0,0 +1,125 @@
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
+ const crypto = require("node:crypto");
8
+
9
+ const base = require("../../lib/adapters/_bank-base");
10
+ const cmbc = require("../../lib/adapters/bank-cmbc");
11
+ const boc = require("../../lib/adapters/bank-boc");
12
+ const bankcomm = require("../../lib/adapters/bank-bankcomm");
13
+ const icbc = require("../../lib/adapters/bank-icbc");
14
+
15
+ function writeTmp(content) {
16
+ const p = path.join(os.tmpdir(), `cc-bank-${crypto.randomUUID()}.json`);
17
+ fs.writeFileSync(p, content, "utf-8");
18
+ return p;
19
+ }
20
+ async function collect(gen) {
21
+ const out = [];
22
+ for await (const x of gen) out.push(x);
23
+ return out;
24
+ }
25
+ const COOKIES = "MBANK_SSO=abc; sid=xyz";
26
+
27
+ describe("_bank-base helpers", () => {
28
+ it("normDirection: keyword + amount-sign fallback", () => {
29
+ expect(base.normDirection({ direction: "收入" })).toBe("credit");
30
+ expect(base.normDirection({ flag: "支出" })).toBe("debit");
31
+ expect(base.normDirection({ amount: -50 })).toBe("debit");
32
+ expect(base.normDirection({ amount: 50 })).toBe("credit");
33
+ });
34
+ it("mapTransaction / mapCard aliases; no id → null", () => {
35
+ const tx = base.mapTransaction({ serialNo: "T1", tranTime: 1716383000, tranAmount: "-1,234.56", oppName: "星巴克", abstract: "消费", bal: 9999 });
36
+ expect(tx).toMatchObject({ txId: "T1", direction: "debit", counterparty: "星巴克", summary: "消费" });
37
+ expect(tx.amount).toBeCloseTo(1234.56, 2);
38
+ expect(tx.balance).toBe(9999);
39
+ expect(base.mapTransaction({ amount: 1 })).toBe(null);
40
+ const card = base.mapCard({ billId: "B1", billMonth: "2025-03", statementAmount: 3210, minPayment: 321, status: "已出账" });
41
+ expect(card).toMatchObject({ billId: "B1", billMonth: "2025-03", statementAmount: 3210, status: "已出账" });
42
+ expect(base.mapCard({ status: "x" })).toBe(null);
43
+ });
44
+ it("billMonthToMs", () => {
45
+ expect(base.billMonthToMs("2025-03")).toBe(Date.parse("2025-03-01T00:00:00Z"));
46
+ });
47
+ });
48
+
49
+ describe("bank wrappers identity", () => {
50
+ it("each wrapper has its own name + adapter class", () => {
51
+ expect(cmbc.NAME).toBe("bank-cmbc");
52
+ expect(boc.NAME).toBe("bank-boc");
53
+ expect(bankcomm.NAME).toBe("bank-bankcomm");
54
+ expect(icbc.NAME).toBe("bank-icbc");
55
+ expect(new cmbc.CmbcBankAdapter().name).toBe("bank-cmbc");
56
+ expect(new boc.BocBankAdapter().name).toBe("bank-boc");
57
+ expect(new bankcomm.BankcommBankAdapter().name).toBe("bank-bankcomm");
58
+ expect(new icbc.IcbcBankAdapter().name).toBe("bank-icbc");
59
+ });
60
+ it("all gated high sensitivity + legalGate", () => {
61
+ for (const A of [new cmbc.CmbcBankAdapter(), new boc.BocBankAdapter(), new bankcomm.BankcommBankAdapter(), new icbc.IcbcBankAdapter()]) {
62
+ expect(A.dataDisclosure.sensitivity).toBe("high");
63
+ expect(A.dataDisclosure.legalGate).toBe(true);
64
+ }
65
+ });
66
+ });
67
+
68
+ describe("CmbcBankAdapter (via _bank-base)", () => {
69
+ const SNAP = JSON.stringify({
70
+ schemaVersion: 1,
71
+ snapshottedAt: 1716383000000,
72
+ account: { userId: "u1" },
73
+ events: [
74
+ { kind: "transaction", id: "tx-T1", txId: "T1", time: 1716383000, amount: -88.5, direction: "支出", counterparty: "全家", summary: "消费", balance: 1200 },
75
+ { kind: "card", id: "card-C1", billId: "C1", billMonth: "2025-03", statementAmount: 3210, minPayment: 321, status: "已出账" },
76
+ ],
77
+ });
78
+
79
+ it("transaction → PAYMENT event; card → OTHER event", async () => {
80
+ const p = writeTmp(SNAP);
81
+ try {
82
+ const a = new cmbc.CmbcBankAdapter();
83
+ expect((await a.authenticate({ inputPath: p })).mode).toBe("snapshot-file");
84
+ const items = await collect(a.sync({ inputPath: p }));
85
+ expect(items).toHaveLength(2);
86
+ const tx = a.normalize(items[0]);
87
+ expect(tx.events[0].subtype).toBe("payment");
88
+ expect(tx.events[0].content.title).toContain("支出");
89
+ expect(tx.events[0].extra.amount).toBe(88.5);
90
+ expect(tx.events[0].extra.direction).toBe("debit");
91
+ expect(items[0].originalId).toBe("cmbc:transaction:T1");
92
+ const card = a.normalize(items[1]);
93
+ expect(card.events[0].subtype).toBe("other");
94
+ expect(card.events[0].extra.billMonth).toBe("2025-03");
95
+ expect(items[1].originalId).toBe("cmbc:card:C1");
96
+ } finally {
97
+ fs.unlinkSync(p);
98
+ }
99
+ });
100
+
101
+ it("cookie-api: best-effort fetch + unverified + sign seam", async () => {
102
+ let signed = 0;
103
+ const a = new cmbc.CmbcBankAdapter({
104
+ account: { cookies: COOKIES, userId: "u1" },
105
+ signProvider: async () => { signed += 1; return "sig"; },
106
+ fetchFn: async ({ url, query }) => {
107
+ if (query.page > 1) return { list: [] };
108
+ if (url.includes("transactions")) return { list: [{ txId: "T9", time: 1716383000, amount: 500, direction: "收入", summary: "工资" }] };
109
+ return { list: [{ billId: "C9", billMonth: "2025-02", statementAmount: 1000 }] };
110
+ },
111
+ });
112
+ expect(await a.authenticate()).toMatchObject({ ok: true, mode: "cookie", unverified: true });
113
+ const items = await collect(a.sync({}));
114
+ expect(items).toHaveLength(2);
115
+ expect(items.map((i) => i.originalId).sort()).toEqual(["cmbc:card:C9", "cmbc:transaction:T9"]);
116
+ expect(signed).toBeGreaterThan(0);
117
+ });
118
+
119
+ it("default fetch throws; no input throws", async () => {
120
+ const a = new cmbc.CmbcBankAdapter({ account: { cookies: COOKIES } });
121
+ await expect(collect(a.sync({}))).rejects.toThrow(/no fetchFn configured/);
122
+ const b = new cmbc.CmbcBankAdapter();
123
+ await expect(collect(b.sync({}))).rejects.toThrow(/needs opts.inputPath/);
124
+ });
125
+ });
@@ -0,0 +1,74 @@
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
+ const crypto = require("node:crypto");
8
+
9
+ const mb = require("../../lib/adapters/car-mercedesme");
10
+
11
+ function writeTmp(content) {
12
+ const p = path.join(os.tmpdir(), `cc-mb-${crypto.randomUUID()}.json`);
13
+ fs.writeFileSync(p, content, "utf-8");
14
+ return p;
15
+ }
16
+ async function collect(gen) {
17
+ const out = [];
18
+ for await (const x of gen) out.push(x);
19
+ return out;
20
+ }
21
+ const COOKIES = "mb_token=abc";
22
+
23
+ describe("car-mercedesme", () => {
24
+ it("name + tripToRecord (km direct + meters fallback; no id → null)", () => {
25
+ expect(mb.NAME).toBe("car-mercedesme");
26
+ const r = mb.tripToRecord({ tripId: "T1", startTime: 1716383000, endTime: 1716385000, startAddress: "家", endAddress: "公司", distanceKm: 12.4, plate: "京A12345" });
27
+ expect(r).toMatchObject({ vendorId: "mercedesme", recordId: "T1", vehicleType: "car", carrier: "Mercedes me", vehicleNumber: "京A12345" });
28
+ expect(r.from.name).toBe("家");
29
+ expect(r.to.name).toBe("公司");
30
+ expect(r.extras.distanceKm).toBeCloseTo(12.4, 2);
31
+ // meters fallback
32
+ expect(mb.tripToRecord({ tripId: "T2", distanceMeters: 5230 }).extras.distanceKm).toBeCloseTo(5.23, 2);
33
+ expect(mb.tripToRecord({ startAddress: "x" })).toBe(null);
34
+ });
35
+ it("extractTrips tolerant", () => {
36
+ expect(mb.extractTrips({ trips: [{ tripId: 1 }] })).toHaveLength(1);
37
+ expect(mb.extractTrips({ data: { list: [{ tripId: 1 }] } })).toHaveLength(1);
38
+ expect(mb.extractTrips({})).toEqual([]);
39
+ });
40
+
41
+ it("snapshot trip → car travel event", async () => {
42
+ const SNAP = JSON.stringify({ trips: [{ tripId: "T1", startTime: 1716383000, endTime: 1716385000, startAddress: "家", endAddress: "公司", distanceKm: 12.4 }] });
43
+ const p = writeTmp(SNAP);
44
+ try {
45
+ const a = new mb.MercedesMeAdapter();
46
+ const items = await collect(a.sync({ inputPath: p }));
47
+ expect(items).toHaveLength(1);
48
+ expect(items[0].originalId).toBe("T1");
49
+ const b = a.normalize(items[0]);
50
+ expect(b.events[0].source.adapter).toBe("car-mercedesme");
51
+ } finally {
52
+ fs.unlinkSync(p);
53
+ }
54
+ });
55
+
56
+ it("cookie-api: unverified + sign seam", async () => {
57
+ let signed = 0;
58
+ const a = new mb.MercedesMeAdapter({
59
+ account: { cookies: COOKIES, userId: "u1" },
60
+ signProvider: async () => { signed += 1; return "sig"; },
61
+ fetchFn: async ({ query }) => (query.page > 1 ? { trips: [] } : { trips: [{ tripId: "T9", startTime: 1716383000, distanceMeters: 8000 }] }),
62
+ });
63
+ expect(await a.authenticate()).toMatchObject({ ok: true, mode: "cookie", unverified: true });
64
+ const items = await collect(a.sync({}));
65
+ expect(items).toHaveLength(1);
66
+ expect(items[0].originalId).toBe("T9");
67
+ expect(signed).toBeGreaterThan(0);
68
+ });
69
+
70
+ it("medium sensitivity; default fetch throws", async () => {
71
+ expect(new mb.MercedesMeAdapter().dataDisclosure.sensitivity).toBe("medium");
72
+ await expect(collect(new mb.MercedesMeAdapter({ account: { cookies: COOKIES } }).sync({}))).rejects.toThrow(/no fetchFn/);
73
+ });
74
+ });
@@ -0,0 +1,74 @@
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
+ const crypto = require("node:crypto");
8
+
9
+ const dc = require("../../lib/adapters/finance-dcep");
10
+
11
+ function writeTmp(content) {
12
+ const p = path.join(os.tmpdir(), `cc-dcep-${crypto.randomUUID()}.json`);
13
+ fs.writeFileSync(p, content, "utf-8");
14
+ return p;
15
+ }
16
+ async function collect(gen) {
17
+ const out = [];
18
+ for await (const x of gen) out.push(x);
19
+ return out;
20
+ }
21
+ const COOKIES = "DCEP_SSO=abc";
22
+
23
+ describe("finance-dcep", () => {
24
+ it("name/version + mappers", () => {
25
+ expect(dc.NAME).toBe("finance-dcep");
26
+ expect(dc.normDirection({ direction: "收款" })).toBe("receive");
27
+ expect(dc.normDirection({ amount: -5 })).toBe("pay");
28
+ const t = dc.mapTx({ txId: "X1", time: 1716383000, amount: -12.5, merchant: "便利店", subWallet: "中行子钱包" });
29
+ expect(t).toMatchObject({ txId: "X1", direction: "pay", counterparty: "便利店", walletType: "中行子钱包" });
30
+ expect(t.amount).toBe(12.5);
31
+ expect(dc.mapTx({ amount: 1 })).toBe(null);
32
+ });
33
+
34
+ it("snapshot → PAYMENT event; cookie-api unverified + sign", async () => {
35
+ const SNAP = JSON.stringify({
36
+ schemaVersion: 1,
37
+ snapshottedAt: 1716383000000,
38
+ account: { userId: "u1" },
39
+ events: [{ kind: "transaction", id: "tx-X1", txId: "X1", time: 1716383000, amount: 12.5, direction: "pay", counterparty: "便利店" }],
40
+ });
41
+ const p = writeTmp(SNAP);
42
+ try {
43
+ const a = new dc.DcepAdapter();
44
+ const items = await collect(a.sync({ inputPath: p }));
45
+ expect(items).toHaveLength(1);
46
+ const b = a.normalize(items[0]);
47
+ expect(b.events[0].subtype).toBe("payment");
48
+ expect(b.events[0].content.title).toContain("付款");
49
+ expect(b.events[0].extra.amount).toBe(12.5);
50
+ expect(items[0].originalId).toBe("dcep:transaction:X1");
51
+ } finally {
52
+ fs.unlinkSync(p);
53
+ }
54
+
55
+ let signed = 0;
56
+ const a = new dc.DcepAdapter({
57
+ account: { cookies: COOKIES, userId: "u1" },
58
+ signProvider: async () => { signed += 1; return "sig"; },
59
+ fetchFn: async ({ query }) => (query.page > 1 ? { list: [] } : { list: [{ txId: "X9", time: 1716383000, amount: 9.9, direction: "receive" }] }),
60
+ });
61
+ expect(await a.authenticate()).toMatchObject({ ok: true, mode: "cookie", unverified: true });
62
+ const items = await collect(a.sync({}));
63
+ expect(items).toHaveLength(1);
64
+ expect(items[0].originalId).toBe("dcep:transaction:X9");
65
+ expect(signed).toBeGreaterThan(0);
66
+ });
67
+
68
+ it("high sensitivity + legalGate; default fetch / no input throw", async () => {
69
+ expect(new dc.DcepAdapter().dataDisclosure.sensitivity).toBe("high");
70
+ expect(new dc.DcepAdapter().dataDisclosure.legalGate).toBe(true);
71
+ await expect(collect(new dc.DcepAdapter({ account: { cookies: COOKIES } }).sync({}))).rejects.toThrow(/no fetchFn/);
72
+ await expect(collect(new dc.DcepAdapter().sync({}))).rejects.toThrow(/needs opts.inputPath/);
73
+ });
74
+ });
@@ -0,0 +1,82 @@
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
+ const crypto = require("node:crypto");
8
+
9
+ const jr = require("../../lib/adapters/fitness-joyrun");
10
+
11
+ function writeTmp(content) {
12
+ const p = path.join(os.tmpdir(), `cc-jr-${crypto.randomUUID()}.json`);
13
+ fs.writeFileSync(p, content, "utf-8");
14
+ return p;
15
+ }
16
+ async function collect(gen) {
17
+ const out = [];
18
+ for await (const x of gen) out.push(x);
19
+ return out;
20
+ }
21
+ const COOKIES = "joyrun_sid=abc";
22
+
23
+ describe("fitness-joyrun", () => {
24
+ it("name + mapRun (meter wins; km*1000 fallback; no id → null)", () => {
25
+ expect(jr.NAME).toBe("fitness-joyrun");
26
+ const r = jr.mapRun({ fid: "R1", starttime: 1716383000, meter: 5230, second: 1800, pace: 344, dohas: 320, stepcount: 6400 });
27
+ expect(r).toMatchObject({ runId: "R1", distanceMeters: 5230, durationSec: 1800, paceSecPerKm: 344, calories: 320, steps: 6400 });
28
+ expect(r.timeMs).toBe(1716383000000);
29
+ // distance given as km (5.23) with no meter field → *1000
30
+ expect(jr.mapRun({ id: "R2", distance: 5.23 }).distanceMeters).toBeCloseTo(5230, 0);
31
+ expect(jr.mapRun({ meter: 100 })).toBe(null);
32
+ });
33
+ it("extractList tolerant", () => {
34
+ expect(jr.extractList({ data: { runs: [{ fid: 1 }] } })).toHaveLength(1);
35
+ expect(jr.extractList({ list: [{ fid: 1 }] })).toHaveLength(1);
36
+ expect(jr.extractList({})).toEqual([]);
37
+ });
38
+
39
+ it("snapshot → OTHER event with km title + run extras", async () => {
40
+ const SNAP = JSON.stringify({
41
+ schemaVersion: 1,
42
+ snapshottedAt: 1716383000000,
43
+ account: { userId: "u1" },
44
+ events: [{ kind: "run", id: "r-R1", runId: "R1", time: 1716383000, distanceMeters: 5230, durationSec: 1800, paceSecPerKm: 344, calories: 320 }],
45
+ });
46
+ const p = writeTmp(SNAP);
47
+ try {
48
+ const a = new jr.JoyrunAdapter();
49
+ const items = await collect(a.sync({ inputPath: p }));
50
+ expect(items).toHaveLength(1);
51
+ const b = a.normalize(items[0]);
52
+ expect(b.events[0].subtype).toBe("other");
53
+ expect(b.events[0].content.title).toBe("跑步 5.23 km");
54
+ expect(b.events[0].extra.distanceMeters).toBe(5230);
55
+ expect(b.events[0].extra.calories).toBe(320);
56
+ expect(items[0].originalId).toBe("joyrun:run:R1");
57
+ } finally {
58
+ fs.unlinkSync(p);
59
+ }
60
+ });
61
+
62
+ it("cookie-api: unverified flag + sign seam + paginate", async () => {
63
+ let signed = 0;
64
+ const a = new jr.JoyrunAdapter({
65
+ account: { cookies: COOKIES, userId: "u1" },
66
+ signProvider: async () => { signed += 1; return "sig"; },
67
+ fetchFn: async ({ query }) => (query.page > 1 ? { list: [] } : { data: { runs: [{ fid: "R9", starttime: 1716383000, meter: 10000, second: 3600 }] } }),
68
+ });
69
+ expect(await a.authenticate()).toMatchObject({ ok: true, mode: "cookie", unverified: true });
70
+ const items = await collect(a.sync({}));
71
+ expect(items).toHaveLength(1);
72
+ expect(items[0].originalId).toBe("joyrun:run:R9");
73
+ expect(signed).toBeGreaterThan(0);
74
+ });
75
+
76
+ it("medium sensitivity (GPS route); default fetch / no input throw", async () => {
77
+ expect(new jr.JoyrunAdapter().dataDisclosure.sensitivity).toBe("medium");
78
+ expect(new jr.JoyrunAdapter().dataDisclosure.legalGate).toBe(false);
79
+ await expect(collect(new jr.JoyrunAdapter({ account: { cookies: COOKIES } }).sync({}))).rejects.toThrow(/no fetchFn/);
80
+ await expect(collect(new jr.JoyrunAdapter().sync({}))).rejects.toThrow(/needs opts.inputPath/);
81
+ });
82
+ });
@@ -0,0 +1,103 @@
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
+ const crypto = require("node:crypto");
8
+
9
+ const g = require("../../lib/adapters/gov-12123");
10
+
11
+ function writeTmp(content) {
12
+ const p = path.join(os.tmpdir(), `cc-12123-${crypto.randomUUID()}.json`);
13
+ fs.writeFileSync(p, content, "utf-8");
14
+ return p;
15
+ }
16
+ async function collect(gen) {
17
+ const out = [];
18
+ for await (const x of gen) out.push(x);
19
+ return out;
20
+ }
21
+ const COOKIES = "GAB_SSO=abc";
22
+
23
+ describe("gov-12123", () => {
24
+ it("name + mappers (chinese-field aliases)", () => {
25
+ expect(g.NAME).toBe("gov-12123");
26
+ const v = g.mapViolation({ wzbh: "V1", wfsj: 1716383000, wfdz: "环岛路", wfxw: "超速50%以下", fkje: "200", wfjfs: "3" });
27
+ expect(v).toMatchObject({ violationId: "V1", location: "环岛路", reason: "超速50%以下" });
28
+ expect(v.fine).toBe(200);
29
+ expect(v.points).toBe(3);
30
+ expect(g.mapViolation({ wfxw: "x" })).toBe(null);
31
+ const l = g.mapLicense({ dabh: "L1", zt: "正常", ljjf: 3, yxqz: 1900000000 });
32
+ expect(l).toMatchObject({ licenseId: "L1", status: "正常", cumulativePoints: 3 });
33
+ expect(g.mapLicense({ zt: "x" })).toBe(null);
34
+ });
35
+ it("extractLicense wraps single object", () => {
36
+ expect(g.extractLicense({ data: { dabh: "L1" } })).toHaveLength(1);
37
+ expect(g.extractLicense({ licenseId: "L2" })).toHaveLength(1);
38
+ expect(g.extractLicense({ list: [{ dabh: "L3" }] })).toHaveLength(1);
39
+ });
40
+
41
+ it("snapshot → violation + license OTHER events", async () => {
42
+ const SNAP = JSON.stringify({
43
+ schemaVersion: 1,
44
+ snapshottedAt: 1716383000000,
45
+ account: { userId: "u1" },
46
+ events: [
47
+ { kind: "violation", id: "v-V1", violationId: "V1", time: 1716383000, location: "环岛路", reason: "超速", fine: 200, points: 3 },
48
+ { kind: "license", id: "l-L1", licenseId: "L1", status: "正常", cumulativePoints: 3, validUntil: 1900000000 },
49
+ ],
50
+ });
51
+ const p = writeTmp(SNAP);
52
+ try {
53
+ const a = new g.Tmri12123Adapter();
54
+ const items = await collect(a.sync({ inputPath: p }));
55
+ expect(items).toHaveLength(2);
56
+ const v = a.normalize(items[0]);
57
+ expect(v.events[0].subtype).toBe("other");
58
+ expect(v.events[0].content.title).toContain("交通违章");
59
+ expect(v.events[0].extra.fine).toBe(200);
60
+ expect(v.events[0].extra.points).toBe(3);
61
+ expect(items[0].originalId).toBe("12123:violation:V1");
62
+ const l = a.normalize(items[1]);
63
+ expect(l.events[0].content.title).toContain("驾驶证状态");
64
+ expect(l.events[0].extra.cumulativePoints).toBe(3);
65
+ expect(items[1].originalId).toBe("12123:license:L1");
66
+ } finally {
67
+ fs.unlinkSync(p);
68
+ }
69
+ });
70
+
71
+ it("cookie-api: paginated violations + single license + unverified", async () => {
72
+ const a = new g.Tmri12123Adapter({
73
+ account: { cookies: COOKIES, userId: "u1" },
74
+ fetchFn: async ({ url, query }) => {
75
+ if (url.includes("/violation")) return query.page > 1 ? { list: [] } : { list: [{ wzbh: "V9", wfsj: 1716383000, wfxw: "闯红灯", fkje: 200, wfjfs: 6 }] };
76
+ return { data: { dabh: "L9", zt: "正常", ljjf: 6 } };
77
+ },
78
+ });
79
+ expect(await a.authenticate()).toMatchObject({ ok: true, mode: "cookie", unverified: true });
80
+ const items = await collect(a.sync({}));
81
+ expect(items).toHaveLength(2);
82
+ expect(items.map((i) => i.originalId).sort()).toEqual(["12123:license:L9", "12123:violation:V9"]);
83
+ });
84
+
85
+ it("province base host (verified .122.gov.cn/app); override + default", () => {
86
+ // default province bj
87
+ const a = new g.Tmri12123Adapter({ province: "fj" });
88
+ expect(a.province).toBe("fj");
89
+ expect(a._urls.violation).toBe("https://fj.122.gov.cn/app/violation/list");
90
+ expect(a._urls.license).toBe("https://fj.122.gov.cn/app/license/info");
91
+ // bad province → default bj
92
+ expect(new g.Tmri12123Adapter({ province: "XX" })._urls.violation).toBe("https://bj.122.gov.cn/app/violation/list");
93
+ // explicit url override still wins
94
+ expect(new g.Tmri12123Adapter({ violationUrl: "https://x/y" })._urls.violation).toBe("https://x/y");
95
+ });
96
+
97
+ it("high sensitivity + legalGate; default fetch / no input throw", async () => {
98
+ expect(new g.Tmri12123Adapter().dataDisclosure.sensitivity).toBe("high");
99
+ expect(new g.Tmri12123Adapter().dataDisclosure.legalGate).toBe(true);
100
+ await expect(collect(new g.Tmri12123Adapter({ account: { cookies: COOKIES } }).sync({}))).rejects.toThrow(/no fetchFn/);
101
+ await expect(collect(new g.Tmri12123Adapter().sync({}))).rejects.toThrow(/needs opts.inputPath/);
102
+ });
103
+ });
@@ -0,0 +1,112 @@
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
+ const crypto = require("node:crypto");
8
+
9
+ const qm = require("../../lib/adapters/music-qq");
10
+
11
+ function writeTmp(content) {
12
+ const p = path.join(os.tmpdir(), `cc-qqm-${crypto.randomUUID()}.json`);
13
+ fs.writeFileSync(p, content, "utf-8");
14
+ return p;
15
+ }
16
+ async function collect(gen) {
17
+ const out = [];
18
+ for await (const x of gen) out.push(x);
19
+ return out;
20
+ }
21
+ const COOKIES = "qqmusic_key=abc; uin=123";
22
+
23
+ describe("music-qq mappers", () => {
24
+ it("name/version", () => {
25
+ expect(qm.NAME).toBe("music-qq");
26
+ expect(qm.VERSION).toBe("0.1.0");
27
+ });
28
+ it("flattenSinger handles array / string / object", () => {
29
+ expect(qm.flattenSinger([{ name: "周杰伦" }, { name: "费玉清" }])).toBe("周杰伦/费玉清");
30
+ expect(qm.flattenSinger("林俊杰")).toBe("林俊杰");
31
+ expect(qm.flattenSinger({ name: "邓紫棋" })).toBe("邓紫棋");
32
+ expect(qm.flattenSinger(null)).toBe("");
33
+ });
34
+ it("songItemToRecord (QQ fields: songmid, singer array, album.name)", () => {
35
+ const r = qm.songItemToRecord({ songmid: "S1", songname: "晴天", singer: [{ name: "周杰伦" }], album: { name: "叶惠美" }, time: 1716383000 });
36
+ expect(r).toMatchObject({ songId: "S1", song: "晴天", artist: "周杰伦", album: "叶惠美" });
37
+ expect(r.occurredAt).toBe(1716383000000);
38
+ expect(qm.songItemToRecord({ songname: "x" })).toBe(null);
39
+ });
40
+ it("playlistItemToRecord (dissid/dissname/songnum)", () => {
41
+ const r = qm.playlistItemToRecord({ dissid: "P1", dissname: "我喜欢", songnum: 42, creator: { name: "我" } });
42
+ expect(r).toMatchObject({ playlistId: "P1", name: "我喜欢", trackCount: 42, creator: "我" });
43
+ expect(qm.playlistItemToRecord({ dissname: "x" })).toBe(null);
44
+ });
45
+ it("extractList tolerant (list / data.songlist)", () => {
46
+ expect(qm.extractList({ list: [{ songmid: 1 }] })).toHaveLength(1);
47
+ expect(qm.extractList({ data: { songlist: [{ songmid: 1 }] } })).toHaveLength(1);
48
+ expect(qm.extractList({})).toEqual([]);
49
+ });
50
+ });
51
+
52
+ describe("QQMusicAdapter (snapshot + cookie-api)", () => {
53
+ const SNAP = JSON.stringify({
54
+ schemaVersion: 1,
55
+ snapshottedAt: 1716383000000,
56
+ account: { userId: "u1" },
57
+ events: [
58
+ { kind: "play", id: "play-S1", songId: "S1", song: "晴天", artist: "周杰伦", album: "叶惠美" },
59
+ { kind: "favorite", id: "fav-S2", songId: "S2", song: "稻香", artist: "周杰伦" },
60
+ { kind: "playlist", id: "pl-P1", playlistId: "P1", name: "我喜欢", trackCount: 42 },
61
+ ],
62
+ });
63
+
64
+ it("snapshot → play(MEDIA)/favorite(LIKE) events + playlist topic", async () => {
65
+ const p = writeTmp(SNAP);
66
+ try {
67
+ const a = new qm.QQMusicAdapter();
68
+ expect((await a.authenticate({ inputPath: p })).mode).toBe("snapshot-file");
69
+ const items = await collect(a.sync({ inputPath: p }));
70
+ expect(items).toHaveLength(3);
71
+ const play = a.normalize(items[0]);
72
+ expect(play.events[0].subtype).toBe("media");
73
+ expect(play.events[0].content.title).toContain("听了: 晴天");
74
+ expect(play.items[0].subtype).toBe("media");
75
+ expect(play.items[0].extra.platform).toBe("qqmusic");
76
+ expect(items[0].originalId).toBe("qqmusic:play:play-S1"); // snapshot uses ev.id (mirrors kugou)
77
+ const fav = a.normalize(items[1]);
78
+ expect(fav.events[0].subtype).toBe("like");
79
+ const pl = a.normalize(items[2]);
80
+ expect(pl.topics[0].name).toBe("我喜欢");
81
+ expect(pl.topics[0].id).toBe("topic-qqmusic-playlist-P1");
82
+ } finally {
83
+ fs.unlinkSync(p);
84
+ }
85
+ });
86
+
87
+ it("cookie-api: fetch 3 kinds + sign seam", async () => {
88
+ let signed = 0;
89
+ const a = new qm.QQMusicAdapter({
90
+ account: { cookies: COOKIES, userId: "u1" },
91
+ signProvider: async () => { signed += 1; return "sig"; },
92
+ fetchFn: async ({ url, query }) => {
93
+ if (query.page > 1) return { list: [] };
94
+ if (url.includes("/listen")) return { list: [{ songmid: "A", songname: "歌1", singer: [{ name: "歌手" }], time: 1716383000 }] };
95
+ if (url.includes("/favorite")) return { data: { songlist: [{ songmid: "B", songname: "歌2", singer: "歌手2" }] } };
96
+ return { data: { list: [{ dissid: "C", dissname: "歌单", songnum: 5 }] } };
97
+ },
98
+ });
99
+ expect(await a.authenticate()).toEqual({ ok: true, account: "u1", mode: "cookie" });
100
+ const items = await collect(a.sync({}));
101
+ expect(items).toHaveLength(3);
102
+ expect(items.map((i) => i.originalId).sort()).toEqual(["qqmusic:favorite:B", "qqmusic:play:A", "qqmusic:playlist:C"]);
103
+ expect(signed).toBeGreaterThan(0);
104
+ });
105
+
106
+ it("low sensitivity (consumer music); default fetch / no input throw", async () => {
107
+ expect(new qm.QQMusicAdapter().dataDisclosure.sensitivity).toBe("low");
108
+ expect(new qm.QQMusicAdapter().dataDisclosure.legalGate).toBe(false);
109
+ await expect(collect(new qm.QQMusicAdapter({ account: { cookies: COOKIES } }).sync({}))).rejects.toThrow(/no fetchFn/);
110
+ await expect(collect(new qm.QQMusicAdapter().sync({}))).rejects.toThrow(/needs opts.inputPath/);
111
+ });
112
+ });