@chainlesschain/personal-data-hub 0.4.2 → 0.4.4
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.
- package/__tests__/adapters/messaging-whatsapp.test.js +289 -0
- package/__tests__/adapters/qq-pc-direct-read.test.js +36 -0
- package/__tests__/adapters/shopping-base.test.js +179 -0
- package/__tests__/adapters/social-kuaishou-adb-api-client.test.js +64 -0
- package/__tests__/adapters/social-kuaishou-adb-cookies-extension.test.js +11 -0
- package/__tests__/adapters/social-xiaohongshu-adb-api-client.test.js +431 -0
- package/__tests__/adapters/social-xiaohongshu-adb-cookies-extension.test.js +0 -0
- package/__tests__/adapters/social-xiaohongshu-adb-snapshot-builder.test.js +200 -0
- package/__tests__/adapters/travel-12306.test.js +279 -0
- package/__tests__/adapters/travel-amap.test.js +219 -0
- package/__tests__/adapters/travel-baidu-map.test.js +305 -0
- package/__tests__/adapters/travel-base.test.js +205 -0
- package/__tests__/adapters/travel-ctrip.test.js +203 -0
- package/__tests__/adapters/travel-tencent-map.test.js +207 -0
- package/lib/adapter-guide.js +11 -9
- package/lib/adapters/qq-pc/index.js +72 -1
- package/lib/adapters/qq-pc/qqnt-sidecar.js +109 -0
- package/lib/adapters/social-kuaishou/index.js +7 -2
- package/lib/adapters/social-kuaishou-adb/api-client.js +38 -18
- package/lib/adapters/social-kuaishou-adb/cookies-extension.js +16 -15
- package/lib/adapters/social-toutiao/index.js +8 -4
- package/lib/adapters/travel-base/index.js +9 -2
- package/package.json +1 -1
|
@@ -0,0 +1,289 @@
|
|
|
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 {
|
|
10
|
+
WhatsAppAdapter,
|
|
11
|
+
NAME,
|
|
12
|
+
VERSION,
|
|
13
|
+
} = require("../../lib/adapters/messaging-whatsapp");
|
|
14
|
+
|
|
15
|
+
function writeTmpDb() {
|
|
16
|
+
const p = path.join(
|
|
17
|
+
os.tmpdir(),
|
|
18
|
+
`cc-whatsapp-test-${crypto.randomUUID()}.db`,
|
|
19
|
+
);
|
|
20
|
+
fs.writeFileSync(p, "fake");
|
|
21
|
+
return p;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function collect(gen) {
|
|
25
|
+
const out = [];
|
|
26
|
+
for await (const x of gen) out.push(x);
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeFakeDriverFactory(tables, log = {}) {
|
|
31
|
+
return () =>
|
|
32
|
+
class FakeDb {
|
|
33
|
+
constructor(dbPath, opts) {
|
|
34
|
+
log.opened = { dbPath, opts };
|
|
35
|
+
}
|
|
36
|
+
prepare(sql) {
|
|
37
|
+
for (const [needle, rows] of Object.entries(tables)) {
|
|
38
|
+
if (sql.includes(needle)) return { all: () => rows };
|
|
39
|
+
}
|
|
40
|
+
throw new Error(`no such table in: ${sql}`);
|
|
41
|
+
}
|
|
42
|
+
close() {
|
|
43
|
+
log.closed = true;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("constants", () => {
|
|
49
|
+
it("exposes name/version + high sensitivity & legal gate", () => {
|
|
50
|
+
expect(NAME).toBe("messaging-whatsapp");
|
|
51
|
+
expect(VERSION).toBe("0.6.0");
|
|
52
|
+
const a = new WhatsAppAdapter();
|
|
53
|
+
expect(a.dataDisclosure.sensitivity).toBe("high");
|
|
54
|
+
expect(a.dataDisclosure.legalGate).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("authenticate", () => {
|
|
59
|
+
it("fails DB_NOT_PULLED without a real db file", async () => {
|
|
60
|
+
const a = new WhatsAppAdapter();
|
|
61
|
+
const r = await a.authenticate({});
|
|
62
|
+
expect(r.ok).toBe(false);
|
|
63
|
+
expect(r.reason).toBe("DB_NOT_PULLED");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("ok when dbPath exists (inputPath alias too)", async () => {
|
|
67
|
+
const p = writeTmpDb();
|
|
68
|
+
try {
|
|
69
|
+
const a = new WhatsAppAdapter({ account: { phone: "8613800138000" } });
|
|
70
|
+
expect(await a.authenticate({ inputPath: p })).toEqual({
|
|
71
|
+
ok: true,
|
|
72
|
+
account: "8613800138000",
|
|
73
|
+
mode: "snapshot-file",
|
|
74
|
+
});
|
|
75
|
+
} finally {
|
|
76
|
+
fs.unlinkSync(p);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("sync — fake sqlite driver", () => {
|
|
82
|
+
const JID_ROW = {
|
|
83
|
+
_id: 1,
|
|
84
|
+
user: "8613800138000",
|
|
85
|
+
raw_string: "8613800138000@s.whatsapp.net",
|
|
86
|
+
display_name: "Alice",
|
|
87
|
+
};
|
|
88
|
+
const CHAT_ROW = { _id: 2, subject: "Family group" };
|
|
89
|
+
const MSG_ROW = {
|
|
90
|
+
_id: 3,
|
|
91
|
+
key_remote_jid: "8613800138000@s.whatsapp.net",
|
|
92
|
+
from_me: 1,
|
|
93
|
+
text_data: "hello",
|
|
94
|
+
timestamp: 1716383021, // seconds
|
|
95
|
+
};
|
|
96
|
+
const CALL_ROW = {
|
|
97
|
+
_id: 4,
|
|
98
|
+
jid_row_id: 1,
|
|
99
|
+
from_me: 0,
|
|
100
|
+
video_call: 1,
|
|
101
|
+
duration: 65,
|
|
102
|
+
timestamp: 1716383021000,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
it("yields contact/chat/message/call rows with kind tags, closes db", async () => {
|
|
106
|
+
const p = writeTmpDb();
|
|
107
|
+
const log = {};
|
|
108
|
+
try {
|
|
109
|
+
const a = new WhatsAppAdapter({
|
|
110
|
+
dbPath: p,
|
|
111
|
+
dbDriverFactory: makeFakeDriverFactory(
|
|
112
|
+
{
|
|
113
|
+
"FROM jid": [JID_ROW],
|
|
114
|
+
"FROM chat": [CHAT_ROW],
|
|
115
|
+
"FROM message ": [MSG_ROW],
|
|
116
|
+
"FROM call_log": [CALL_ROW],
|
|
117
|
+
},
|
|
118
|
+
log,
|
|
119
|
+
),
|
|
120
|
+
});
|
|
121
|
+
const items = await collect(a.sync({}));
|
|
122
|
+
expect(items.map((i) => i.originalId)).toEqual([
|
|
123
|
+
"jid-1",
|
|
124
|
+
"chat-2",
|
|
125
|
+
"msg-3",
|
|
126
|
+
"call-4",
|
|
127
|
+
]);
|
|
128
|
+
expect(items.map((i) => i.payload.kind)).toEqual([
|
|
129
|
+
"contact",
|
|
130
|
+
"chat",
|
|
131
|
+
"message",
|
|
132
|
+
"call",
|
|
133
|
+
]);
|
|
134
|
+
// message timestamp seconds → ms
|
|
135
|
+
expect(items[2].capturedAt).toBe(1716383021 * 1000);
|
|
136
|
+
expect(log.opened.opts).toEqual({ readonly: true });
|
|
137
|
+
expect(log.closed).toBe(true);
|
|
138
|
+
} finally {
|
|
139
|
+
fs.unlinkSync(p);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("falls back to legacy `messages` table when `message` missing", async () => {
|
|
144
|
+
const p = writeTmpDb();
|
|
145
|
+
try {
|
|
146
|
+
const a = new WhatsAppAdapter({
|
|
147
|
+
dbPath: p,
|
|
148
|
+
dbDriverFactory: makeFakeDriverFactory({
|
|
149
|
+
"FROM jid": [],
|
|
150
|
+
"FROM chat": [],
|
|
151
|
+
"FROM messages ": [MSG_ROW],
|
|
152
|
+
"FROM call_log": [],
|
|
153
|
+
}),
|
|
154
|
+
});
|
|
155
|
+
const items = await collect(a.sync({}));
|
|
156
|
+
expect(items).toHaveLength(1);
|
|
157
|
+
expect(items[0].payload.kind).toBe("message");
|
|
158
|
+
} finally {
|
|
159
|
+
fs.unlinkSync(p);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("returns silently when db file missing", async () => {
|
|
164
|
+
const a = new WhatsAppAdapter({
|
|
165
|
+
dbPath: path.join(os.tmpdir(), "nonexistent-wa.db"),
|
|
166
|
+
dbDriverFactory: makeFakeDriverFactory({}),
|
|
167
|
+
});
|
|
168
|
+
expect(await collect(a.sync({}))).toEqual([]);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("normalize", () => {
|
|
173
|
+
const a = new WhatsAppAdapter();
|
|
174
|
+
|
|
175
|
+
it("personal jid → contact person with digits-only phone identifier", () => {
|
|
176
|
+
const batch = a.normalize({
|
|
177
|
+
originalId: "jid-1",
|
|
178
|
+
payload: {
|
|
179
|
+
kind: "contact",
|
|
180
|
+
row: {
|
|
181
|
+
_id: 1,
|
|
182
|
+
user: "+86 138-0013-8000",
|
|
183
|
+
raw_string: "8613800138000@s.whatsapp.net",
|
|
184
|
+
display_name: "Alice",
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
const person = batch.persons[0];
|
|
189
|
+
expect(person.subtype).toBe("contact");
|
|
190
|
+
expect(person.names).toEqual(["Alice", "+86 138-0013-8000"]);
|
|
191
|
+
expect(person.identifiers).toEqual({ phone: ["8613800138000"] });
|
|
192
|
+
expect(batch.topics).toEqual([]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("group jid (@g.us) → topic, not person", () => {
|
|
196
|
+
const batch = a.normalize({
|
|
197
|
+
originalId: "jid-9",
|
|
198
|
+
payload: {
|
|
199
|
+
kind: "contact",
|
|
200
|
+
row: {
|
|
201
|
+
_id: 9,
|
|
202
|
+
raw_string: "1234-5678@g.us",
|
|
203
|
+
display_name: "Family group",
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
expect(batch.persons).toEqual([]);
|
|
208
|
+
expect(batch.topics[0]).toMatchObject({
|
|
209
|
+
id: "topic-whatsapp-1234-5678@g.us",
|
|
210
|
+
name: "Family group",
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("chat row → topic named by subject", () => {
|
|
215
|
+
const batch = a.normalize({
|
|
216
|
+
originalId: "chat-2",
|
|
217
|
+
payload: { kind: "chat", row: { _id: 2, subject: "Work chat" } },
|
|
218
|
+
});
|
|
219
|
+
expect(batch.topics[0]).toMatchObject({
|
|
220
|
+
id: "topic-whatsapp-chat-2",
|
|
221
|
+
name: "Work chat",
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("outgoing message → message event from person-self, title truncated to 80", () => {
|
|
226
|
+
const longText = "x".repeat(200);
|
|
227
|
+
const batch = a.normalize({
|
|
228
|
+
originalId: "msg-3",
|
|
229
|
+
payload: {
|
|
230
|
+
kind: "message",
|
|
231
|
+
row: {
|
|
232
|
+
_id: 3,
|
|
233
|
+
key_remote_jid: "8613800138000@s.whatsapp.net",
|
|
234
|
+
from_me: 1,
|
|
235
|
+
text_data: longText,
|
|
236
|
+
timestamp: 1716383021000,
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
const ev = batch.events[0];
|
|
241
|
+
expect(ev.subtype).toBe("message");
|
|
242
|
+
expect(ev.actor).toBe("person-self");
|
|
243
|
+
expect(ev.occurredAt).toBe(1716383021000);
|
|
244
|
+
expect(ev.content.title).toHaveLength(80);
|
|
245
|
+
expect(ev.content.text).toHaveLength(200);
|
|
246
|
+
expect(ev.extra.isOutgoing).toBe(true);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("incoming empty message → '(空)' title, actor from jid", () => {
|
|
250
|
+
const batch = a.normalize({
|
|
251
|
+
originalId: "msg-4",
|
|
252
|
+
payload: {
|
|
253
|
+
kind: "message",
|
|
254
|
+
row: {
|
|
255
|
+
_id: 4,
|
|
256
|
+
key_remote_jid: "86139@s.whatsapp.net",
|
|
257
|
+
from_me: 0,
|
|
258
|
+
timestamp: 1716383021000,
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
const ev = batch.events[0];
|
|
263
|
+
expect(ev.content.title).toBe("(空)");
|
|
264
|
+
expect(ev.actor).toBe("person-whatsapp-86139@s.whatsapp.net");
|
|
265
|
+
expect(ev.extra.isOutgoing).toBe(false);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("incoming video call → call event with duration/isVideo extras", () => {
|
|
269
|
+
const batch = a.normalize({
|
|
270
|
+
originalId: "call-5",
|
|
271
|
+
payload: {
|
|
272
|
+
kind: "call",
|
|
273
|
+
row: {
|
|
274
|
+
_id: 5,
|
|
275
|
+
jid_row_id: 1,
|
|
276
|
+
from_me: 0,
|
|
277
|
+
video_call: 1,
|
|
278
|
+
duration: 65,
|
|
279
|
+
timestamp: 1716383021000,
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
const ev = batch.events[0];
|
|
284
|
+
expect(ev.subtype).toBe("call");
|
|
285
|
+
expect(ev.content.title).toBe("WhatsApp call (video)");
|
|
286
|
+
expect(ev.actor).toBe("person-whatsapp-1");
|
|
287
|
+
expect(ev.extra).toMatchObject({ duration: 65, isVideo: true, fromMe: false });
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -189,3 +189,39 @@ describe("QQPcAdapter — edge cases", () => {
|
|
|
189
189
|
expect(() => a.normalize({ kind: "x", payload: { kind: "x" } })).toThrow(/unknown kind/);
|
|
190
190
|
});
|
|
191
191
|
});
|
|
192
|
+
|
|
193
|
+
describe("QQPcAdapter — QQ NT sidecar path (passphrase)", () => {
|
|
194
|
+
const fakeCollector = (result) => async (_opts) => result;
|
|
195
|
+
|
|
196
|
+
it("opts.passphrase routes through the sidecar collector and yields messages", async () => {
|
|
197
|
+
const a = new QQPcAdapter({
|
|
198
|
+
qqCollector: fakeCollector({
|
|
199
|
+
account: "896075341",
|
|
200
|
+
messageCount: 2,
|
|
201
|
+
c2c: 1,
|
|
202
|
+
group: 1,
|
|
203
|
+
messages: [
|
|
204
|
+
{ kind: "group", peer: 88966001, peerUid: "u_x", senderUin: 38181604, senderName: "疯子", type: 0, createTime: 1780941580, text: "保持高贵的沉默。", originalId: "qq-pc:group:88966001:1" },
|
|
205
|
+
{ kind: "c2c", peer: 2747277822, peerUid: "u_y", senderUin: 12345, senderName: "张三", type: 0, createTime: 1780900000, text: "在吗", originalId: "qq-pc:c2c:2747277822:2" },
|
|
206
|
+
],
|
|
207
|
+
}),
|
|
208
|
+
});
|
|
209
|
+
const raws = await collect(a.sync({ passphrase: "5{sww#,6aq=)8=A@" }));
|
|
210
|
+
expect(raws).toHaveLength(2);
|
|
211
|
+
expect(raws[0].payload.text).toBe("保持高贵的沉默。");
|
|
212
|
+
expect(raws[0].payload.isGroup).toBe(true);
|
|
213
|
+
expect(raws[0].payload.senderName).toBe("疯子");
|
|
214
|
+
expect(raws[1].payload.isGroup).toBe(false);
|
|
215
|
+
|
|
216
|
+
const merged = { events: [], persons: [], places: [], items: [], topics: [] };
|
|
217
|
+
for (const r of raws) {
|
|
218
|
+
const n = a.normalize(r);
|
|
219
|
+
for (const k of Object.keys(merged)) if (Array.isArray(n[k])) merged[k].push(...n[k]);
|
|
220
|
+
}
|
|
221
|
+
const { valid } = partitionBatch(merged);
|
|
222
|
+
expect(valid.events.length).toBe(2);
|
|
223
|
+
const texts = valid.events.map((e) => e.content && e.content.text);
|
|
224
|
+
expect(texts).toContain("保持高贵的沉默。");
|
|
225
|
+
expect(valid.events.find((e) => e.content.text === "保持高贵的沉默。").extra.senderName).toBe("疯子");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from "vitest";
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
normalizeOrderRecord,
|
|
7
|
+
mapStatusToSubtype,
|
|
8
|
+
CookieAuth,
|
|
9
|
+
} = require("../../lib/adapters/shopping-base");
|
|
10
|
+
|
|
11
|
+
describe("normalizeOrderRecord", () => {
|
|
12
|
+
const ORDER = {
|
|
13
|
+
vendorId: "jd",
|
|
14
|
+
orderId: "JD123456",
|
|
15
|
+
placedAt: 1716383021000,
|
|
16
|
+
paidAt: 1716383100000,
|
|
17
|
+
status: "delivered",
|
|
18
|
+
merchantName: "京东自营旗舰店",
|
|
19
|
+
totalAmount: { value: 299.9, currency: "CNY" },
|
|
20
|
+
items: [
|
|
21
|
+
{ name: "机械键盘", quantity: 1, unitPrice: 249.9, sku: "SKU1" },
|
|
22
|
+
{ name: "键帽", quantity: 2, unitPrice: 25 },
|
|
23
|
+
],
|
|
24
|
+
recipient: "张三",
|
|
25
|
+
shippingAddress: "上海市幸福路1号",
|
|
26
|
+
trackingNumber: "SF123",
|
|
27
|
+
extras: { shopId: "S1" },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
it("throws on missing rec / orderId / merchantName", () => {
|
|
31
|
+
expect(() => normalizeOrderRecord(null)).toThrow(/rec required/);
|
|
32
|
+
expect(() => normalizeOrderRecord({})).toThrow(/orderId required/);
|
|
33
|
+
expect(() => normalizeOrderRecord({ orderId: "X" })).toThrow(
|
|
34
|
+
/merchantName required/,
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("emits order event with amount out + items text + cross-source link extras", () => {
|
|
39
|
+
const batch = normalizeOrderRecord(ORDER, {
|
|
40
|
+
adapterName: "shopping-jd",
|
|
41
|
+
adapterVersion: "0.5.0",
|
|
42
|
+
});
|
|
43
|
+
expect(batch.events).toHaveLength(1);
|
|
44
|
+
const ev = batch.events[0];
|
|
45
|
+
expect(ev.subtype).toBe("order");
|
|
46
|
+
expect(ev.occurredAt).toBe(1716383021000);
|
|
47
|
+
expect(ev.actor).toBe("person-self");
|
|
48
|
+
expect(ev.content.title).toBe("京东自营旗舰店 订单 JD123456");
|
|
49
|
+
expect(ev.content.amount).toEqual({
|
|
50
|
+
value: 299.9,
|
|
51
|
+
currency: "CNY",
|
|
52
|
+
direction: "out",
|
|
53
|
+
});
|
|
54
|
+
expect(ev.content.text).toBe("机械键盘 x1; 键帽 x2");
|
|
55
|
+
expect(ev.extra).toMatchObject({
|
|
56
|
+
vendorId: "jd",
|
|
57
|
+
orderId: "JD123456",
|
|
58
|
+
merchantOrderNumber: "JD123456", // Email/Alipay cross-source link key
|
|
59
|
+
orderStatus: "delivered",
|
|
60
|
+
itemCount: 2,
|
|
61
|
+
recipient: "张三",
|
|
62
|
+
trackingNumber: "SF123",
|
|
63
|
+
paidAt: 1716383100000,
|
|
64
|
+
vendorExtras: { shopId: "S1" },
|
|
65
|
+
});
|
|
66
|
+
expect(ev.source).toMatchObject({
|
|
67
|
+
adapter: "shopping-jd",
|
|
68
|
+
adapterVersion: "0.5.0",
|
|
69
|
+
originalId: "JD123456",
|
|
70
|
+
capturedBy: "api",
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("emits merchant person + per-SKU item entities inheriting order currency", () => {
|
|
75
|
+
const batch = normalizeOrderRecord(ORDER, { adapterName: "shopping-jd" });
|
|
76
|
+
expect(batch.persons).toHaveLength(1);
|
|
77
|
+
expect(batch.persons[0]).toMatchObject({
|
|
78
|
+
subtype: "merchant",
|
|
79
|
+
names: ["京东自营旗舰店"],
|
|
80
|
+
});
|
|
81
|
+
expect(batch.items).toHaveLength(2);
|
|
82
|
+
expect(batch.items[0]).toMatchObject({
|
|
83
|
+
type: "item",
|
|
84
|
+
subtype: "product",
|
|
85
|
+
name: "机械键盘",
|
|
86
|
+
merchant: batch.persons[0].id,
|
|
87
|
+
price: { value: 249.9, currency: "CNY" },
|
|
88
|
+
});
|
|
89
|
+
expect(batch.items[0].extra).toMatchObject({ quantity: 1, sku: "SKU1" });
|
|
90
|
+
// nameless item entries are dropped
|
|
91
|
+
const withJunk = normalizeOrderRecord(
|
|
92
|
+
{ ...ORDER, items: [{ quantity: 3 }, { name: "ok" }] },
|
|
93
|
+
{},
|
|
94
|
+
);
|
|
95
|
+
expect(withJunk.items).toHaveLength(1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("refund order → refund subtype with money direction IN", () => {
|
|
99
|
+
const batch = normalizeOrderRecord(
|
|
100
|
+
{ ...ORDER, status: "refunded" },
|
|
101
|
+
{ adapterName: "shopping-jd" },
|
|
102
|
+
);
|
|
103
|
+
expect(batch.events[0].subtype).toBe("refund");
|
|
104
|
+
expect(batch.events[0].content.amount.direction).toBe("in");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("omits amount when totalAmount missing; occurredAt falls back to now", () => {
|
|
108
|
+
const before = Date.now();
|
|
109
|
+
const batch = normalizeOrderRecord({
|
|
110
|
+
orderId: "X1",
|
|
111
|
+
merchantName: "店",
|
|
112
|
+
});
|
|
113
|
+
expect(batch.events[0].content.amount).toBeUndefined();
|
|
114
|
+
expect(batch.events[0].occurredAt).toBeGreaterThanOrEqual(before);
|
|
115
|
+
expect(batch.events[0].extra.orderStatus).toBe("placed");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("mapStatusToSubtype", () => {
|
|
120
|
+
it("maps refund variants (en + 中文)", () => {
|
|
121
|
+
expect(mapStatusToSubtype("refunded")).toBe("refund");
|
|
122
|
+
expect(mapStatusToSubtype("REFUND_PENDING")).toBe("refund");
|
|
123
|
+
expect(mapStatusToSubtype("退款中")).toBe("refund");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("maps cancel variants (en + 中文)", () => {
|
|
127
|
+
expect(mapStatusToSubtype("cancelled")).toBe("cancelled");
|
|
128
|
+
expect(mapStatusToSubtype("closed")).toBe("cancelled");
|
|
129
|
+
expect(mapStatusToSubtype("已取消")).toBe("cancelled");
|
|
130
|
+
expect(mapStatusToSubtype("已关闭")).toBe("cancelled");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("everything else (placed/shipped/delivered/blank) → order", () => {
|
|
134
|
+
expect(mapStatusToSubtype("placed")).toBe("order");
|
|
135
|
+
expect(mapStatusToSubtype("shipped")).toBe("order");
|
|
136
|
+
expect(mapStatusToSubtype("delivered")).toBe("order");
|
|
137
|
+
expect(mapStatusToSubtype(undefined)).toBe("order");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("CookieAuth", () => {
|
|
142
|
+
it("requires platform; setCookies type-checks", () => {
|
|
143
|
+
expect(() => new CookieAuth({})).toThrow(/platform required/);
|
|
144
|
+
const c = new CookieAuth({ platform: "jd" });
|
|
145
|
+
expect(() => c.setCookies(42)).toThrow(/string required/);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("toHeader returns raw string or null when empty", () => {
|
|
149
|
+
const c = new CookieAuth({ platform: "jd" });
|
|
150
|
+
expect(c.toHeader()).toBe(null);
|
|
151
|
+
c.setCookies("pt_key=abc; pt_pin=u1");
|
|
152
|
+
expect(c.toHeader()).toBe("pt_key=abc; pt_pin=u1");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("validate: false on empty, true on non-empty, defers to injected validator", async () => {
|
|
156
|
+
const empty = new CookieAuth({ platform: "jd" });
|
|
157
|
+
expect(await empty.validate()).toBe(false);
|
|
158
|
+
const plain = new CookieAuth({ platform: "jd", cookies: "k=v" });
|
|
159
|
+
expect(await plain.validate()).toBe(true);
|
|
160
|
+
const probed = new CookieAuth({
|
|
161
|
+
platform: "jd",
|
|
162
|
+
cookies: "k=v",
|
|
163
|
+
validator: async (raw) => raw.includes("pt_key"),
|
|
164
|
+
});
|
|
165
|
+
expect(await probed.validate()).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("getCookieValue: case-insensitive, URI-decodes, escapes regex metachars", () => {
|
|
169
|
+
const c = new CookieAuth({
|
|
170
|
+
platform: "taobao",
|
|
171
|
+
cookies: "Name=%E5%BC%A0%E4%B8%89; a.b+c=literal; last=v",
|
|
172
|
+
});
|
|
173
|
+
expect(c.getCookieValue("name")).toBe("张三");
|
|
174
|
+
// dot/plus in cookie name must be treated literally, not as regex
|
|
175
|
+
expect(c.getCookieValue("a.b+c")).toBe("literal");
|
|
176
|
+
expect(c.getCookieValue("missing")).toBe(null);
|
|
177
|
+
expect(c.getCookieValue("")).toBe(null);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -430,3 +430,67 @@ describe("normalizeMs", () => {
|
|
|
430
430
|
expect(_internals.normalizeMs(-1)).toBe(0);
|
|
431
431
|
});
|
|
432
432
|
});
|
|
433
|
+
|
|
434
|
+
describe("api_ph base64 fallback (v0.3)", () => {
|
|
435
|
+
const profileJson = JSON.stringify({
|
|
436
|
+
user_id: "424242",
|
|
437
|
+
user_name: "B64User",
|
|
438
|
+
kuaishou_id: "b64_ks",
|
|
439
|
+
});
|
|
440
|
+
const b64 = Buffer.from(profileJson, "utf-8").toString("base64");
|
|
441
|
+
|
|
442
|
+
it("apiPhDecodeCandidates yields base64-decoded JSON as 2nd candidate", () => {
|
|
443
|
+
const cands = _internals.apiPhDecodeCandidates(encodeURIComponent(b64));
|
|
444
|
+
expect(cands).toHaveLength(2);
|
|
445
|
+
expect(cands[1]).toBe(profileJson);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("apiPhDecodeCandidates yields 1 candidate for plain JSON (no double decode)", () => {
|
|
449
|
+
const cands = _internals.apiPhDecodeCandidates(
|
|
450
|
+
encodeURIComponent(profileJson),
|
|
451
|
+
);
|
|
452
|
+
expect(cands).toEqual([profileJson]);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("apiPhDecodeCandidates suppresses garbage base64 (decoded ≠ JSON)", () => {
|
|
456
|
+
expect(_internals.apiPhDecodeCandidates("base64junk")).toEqual([
|
|
457
|
+
"base64junk",
|
|
458
|
+
]);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("apiPhDecodeCandidates handles url-safe base64 (- _ alphabet)", () => {
|
|
462
|
+
const urlSafe = b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
463
|
+
const cands = _internals.apiPhDecodeCandidates(urlSafe);
|
|
464
|
+
expect(cands[1]).toBe(profileJson);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("fetchProfile parses base64-encoded api_ph", async () => {
|
|
468
|
+
const c = new KuaishouApiClient({ fetch: () => {} });
|
|
469
|
+
const p = await c.fetchProfile(
|
|
470
|
+
`kuaishou.web.cp.api_ph=${encodeURIComponent(b64)}`,
|
|
471
|
+
);
|
|
472
|
+
expect(p).toMatchObject({
|
|
473
|
+
uid: "424242",
|
|
474
|
+
nickname: "B64User",
|
|
475
|
+
kuaishouId: "b64_ks",
|
|
476
|
+
});
|
|
477
|
+
expect(c.lastErrorCode).toBe(0);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("fetchProfile still rejects non-JSON non-base64 payload with -9", async () => {
|
|
481
|
+
const c = new KuaishouApiClient({ fetch: () => {} });
|
|
482
|
+
expect(
|
|
483
|
+
await c.fetchProfile(
|
|
484
|
+
`kuaishou.web.cp.api_ph=${encodeURIComponent("%%not-b64%%")}`,
|
|
485
|
+
),
|
|
486
|
+
).toBe(null);
|
|
487
|
+
expect(c.lastErrorCode).toBe(-9);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("extractUid falls back to base64 api_ph nested user_id", () => {
|
|
491
|
+
const c = new KuaishouApiClient({ fetch: () => {} });
|
|
492
|
+
expect(
|
|
493
|
+
c.extractUid(`kuaishou.web.cp.api_ph=${encodeURIComponent(b64)}`),
|
|
494
|
+
).toBe("424242");
|
|
495
|
+
});
|
|
496
|
+
});
|
|
@@ -70,6 +70,17 @@ describe("pickUidFromCookieMap", () => {
|
|
|
70
70
|
const map = make([["did", "anonid"]]);
|
|
71
71
|
expect(_internals.pickUidFromCookieMap(map)).toBe(null);
|
|
72
72
|
});
|
|
73
|
+
|
|
74
|
+
it("falls back to base64-encoded api_ph (v0.3 newer Kuaishou builds)", () => {
|
|
75
|
+
const b64 = Buffer.from(
|
|
76
|
+
JSON.stringify({ user_id: "424242" }),
|
|
77
|
+
"utf-8",
|
|
78
|
+
).toString("base64");
|
|
79
|
+
const map = make([
|
|
80
|
+
["kuaishou.web.cp.api_ph", encodeURIComponent(b64)],
|
|
81
|
+
]);
|
|
82
|
+
expect(_internals.pickUidFromCookieMap(map)).toBe("424242");
|
|
83
|
+
});
|
|
73
84
|
});
|
|
74
85
|
|
|
75
86
|
describe("assembleKuaishouCookieHeader", () => {
|