270 lines
8.5 KiB
JavaScript
270 lines
8.5 KiB
JavaScript
import { createHmac } from "node:crypto";
|
|
|
|
const BASE = process.env.BASE_URL || `http://127.0.0.1:${process.env.PORT || "3000"}`;
|
|
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || "admin-dev-token";
|
|
const DEVICE_TOKEN = process.env.DEVICE_TOKEN || "device-dev-token";
|
|
const SECRET = process.env.INTEGRATION_WEBHOOK_SECRET || "dev-callback-secret";
|
|
const STATIC_SN = process.env.QF100_STATIC_SN || `QF100-STATIC-${Date.now()}`;
|
|
const DYNAMIC_SN = process.env.QF100_DYNAMIC_SN || `QF100-DYNAMIC-${Date.now()}`;
|
|
|
|
function short(data) {
|
|
const text = typeof data === "string" ? data : JSON.stringify(data || {});
|
|
return text.length > 220 ? `${text.slice(0, 220)}...` : text;
|
|
}
|
|
|
|
async function req(path, options = {}) {
|
|
const response = await fetch(`${BASE}${path}`, {
|
|
method: options.method || "GET",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(options.headers || {})
|
|
},
|
|
body: Object.prototype.hasOwnProperty.call(options, "body") ? JSON.stringify(options.body) : undefined
|
|
});
|
|
|
|
const text = await response.text();
|
|
let body = null;
|
|
try {
|
|
body = text ? JSON.parse(text) : null;
|
|
} catch {
|
|
body = text;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`${options._label || path} failed: ${response.status} ${short(body)}`);
|
|
}
|
|
|
|
console.log(`${options._label || `${options.method || "GET"} ${path}`} => ${response.status} ${short(body)}`);
|
|
return body?.data !== undefined ? body.data : body;
|
|
}
|
|
|
|
function reqAdmin(path, options = {}) {
|
|
return req(path, {
|
|
...options,
|
|
headers: {
|
|
...(options.headers || {}),
|
|
Authorization: `Bearer ${ADMIN_TOKEN}`
|
|
}
|
|
});
|
|
}
|
|
|
|
function reqDevice(path, options = {}) {
|
|
return req(path, {
|
|
...options,
|
|
headers: {
|
|
...(options.headers || {}),
|
|
Authorization: `Bearer ${DEVICE_TOKEN}`
|
|
}
|
|
});
|
|
}
|
|
|
|
function assert(condition, message) {
|
|
if (!condition) {
|
|
throw new Error(message);
|
|
}
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function createBundle({ ts, suffix, serialNumber, terminalMode, capability }) {
|
|
const merchant = await reqAdmin("/admin/merchants", {
|
|
method: "POST",
|
|
body: {
|
|
legal_name: `QF100 Smoke Merchant ${suffix} ${ts}`,
|
|
brand_name: `QF100-${suffix}-${ts}`,
|
|
settlement_account_reference: `bank:qf100:${suffix}:${ts}`,
|
|
settlement_account_type: "merchant_bank_account",
|
|
payout_mode: "merchant_direct"
|
|
},
|
|
_label: `POST /admin/merchants ${suffix}`
|
|
});
|
|
|
|
const outlet = await reqAdmin(`/admin/merchants/${merchant.id}/outlets`, {
|
|
method: "POST",
|
|
body: { name: `QF100 Smoke Outlet ${suffix} ${ts}` },
|
|
_label: `POST /admin/merchants/:id/outlets ${suffix}`
|
|
});
|
|
|
|
const terminal = await reqAdmin(`/admin/outlets/${outlet.id}/terminals`, {
|
|
method: "POST",
|
|
body: { terminal_code: `QF100-${suffix}-TERM-${ts}`, qr_mode: terminalMode },
|
|
_label: `POST /admin/outlets/:id/terminals ${suffix}`
|
|
});
|
|
|
|
const device = await reqAdmin("/admin/devices", {
|
|
method: "POST",
|
|
body: {
|
|
device_code: `QF100-${suffix}-DEV-${ts}`,
|
|
serial_number: serialNumber,
|
|
vendor: "QF",
|
|
model: "QF100",
|
|
communication_mode: "mqtt",
|
|
status: "active",
|
|
capability_profile_json: {
|
|
mqtt_payload_profile: "qf100",
|
|
...(capability || {})
|
|
}
|
|
},
|
|
_label: `POST /admin/devices ${suffix}`
|
|
});
|
|
|
|
await reqAdmin(`/admin/devices/${device.id}/bind`, {
|
|
method: "POST",
|
|
body: {
|
|
merchant_id: merchant.id,
|
|
outlet_id: outlet.id,
|
|
terminal_id: terminal.id
|
|
},
|
|
_label: `POST /admin/devices/:id/bind ${suffix}`
|
|
});
|
|
|
|
return { merchant, outlet, terminal, device };
|
|
}
|
|
|
|
async function pullQf100Config(serialNumber, label) {
|
|
const query = new URLSearchParams({
|
|
"dev-model": "QF100",
|
|
"item-number": "00",
|
|
"dev-sn": serialNumber,
|
|
"fw-version": "1.0.0",
|
|
"fw-build": "1",
|
|
"app-config-version": "1",
|
|
imei: `${label}-imei`,
|
|
imsi: `${label}-imsi`,
|
|
iccid: `${label}-iccid`
|
|
});
|
|
const config = await req(`/speaker/dev-config?${query.toString()}`, {
|
|
_label: `GET /speaker/dev-config ${label}`
|
|
});
|
|
|
|
assert(config["error-code"] === 0, `${label} config error-code must be 0`);
|
|
assert(config.mqtt?.["broker-ip"], `${label} config must include mqtt.broker-ip`);
|
|
assert(config.mqtt?.["broker-port"], `${label} config must include mqtt.broker-port`);
|
|
assert(config.mqtt?.["subscribe-topic"]?.includes("/downlink/qf100"), `${label} must subscribe qf100 topic`);
|
|
return config;
|
|
}
|
|
|
|
async function waitForQf100PaymentMessage(deviceId) {
|
|
for (let i = 0; i < 20; i += 1) {
|
|
const data = await reqAdmin(`/admin/devices/${deviceId}/mqtt-messages?direction=downlink&message_type=payment_success&limit=10`, {
|
|
_label: `GET /admin/devices/:id/mqtt-messages attempt ${i + 1}`
|
|
});
|
|
const found = data.messages?.find((message) => message.topic === `devices/${deviceId}/downlink/qf100`);
|
|
if (found) {
|
|
return found;
|
|
}
|
|
await sleep(250);
|
|
}
|
|
throw new Error("QF100 payment downlink message not found");
|
|
}
|
|
|
|
async function triggerStaticPayment({ bundle, ts }) {
|
|
const partnerReference = `QF100-PR-${ts}`;
|
|
await reqAdmin("/admin/transactions", {
|
|
method: "POST",
|
|
body: {
|
|
partner_reference: partnerReference,
|
|
merchant_id: bundle.merchant.id,
|
|
outlet_id: bundle.outlet.id,
|
|
terminal_id: bundle.terminal.id,
|
|
device_id: bundle.device.id,
|
|
amount: 15000,
|
|
currency: "IDR",
|
|
qr_mode: "static",
|
|
initiation_mode: "static",
|
|
status: "initiated"
|
|
},
|
|
_label: "POST /admin/transactions static"
|
|
});
|
|
|
|
const callback = {
|
|
partner_reference: partnerReference,
|
|
partner_txn_id: `QF100-PTX-${ts}`,
|
|
amount: 15000,
|
|
currency: "IDR",
|
|
payment_status: "paid",
|
|
status: "paid",
|
|
paid_at: new Date().toISOString()
|
|
};
|
|
const signature = createHmac("sha256", SECRET).update(JSON.stringify(callback)).digest("hex");
|
|
await req("/integrations/qris/callback", {
|
|
method: "POST",
|
|
headers: { "X-Partner-Signature": signature },
|
|
body: { ...callback, signature },
|
|
_label: "POST /integrations/qris/callback static paid"
|
|
});
|
|
|
|
const message = await waitForQf100PaymentMessage(bundle.device.id);
|
|
assert(message.payload_json?.header?.category === 1, "QF100 payment payload header.category must be 1");
|
|
assert(message.payload_json?.data?.["pay-amount"] === 15000, "QF100 payment payload pay-amount must match");
|
|
return message;
|
|
}
|
|
|
|
async function triggerDynamicMqttQr({ bundle, ts }) {
|
|
const requestId = `QF100-DYN-${ts}`;
|
|
const response = await reqDevice("/device/mqtt/uplink/dynamic-qr/request", {
|
|
method: "POST",
|
|
body: {
|
|
message_type: "dynamic_qr_request",
|
|
request_id: requestId,
|
|
device_id: bundle.device.id,
|
|
terminal_id: bundle.terminal.id,
|
|
amount: 27500,
|
|
currency: "IDR",
|
|
created_at: new Date().toISOString()
|
|
},
|
|
_label: "POST /device/mqtt/uplink/dynamic-qr/request"
|
|
});
|
|
|
|
assert(response.status === "success", "dynamic MQTT QR response must be success");
|
|
assert(response.qr_payload, "dynamic MQTT QR response must include qr_payload");
|
|
return response;
|
|
}
|
|
|
|
async function main() {
|
|
await req("/health", { _label: "GET /health" });
|
|
const ts = Date.now();
|
|
|
|
const staticBundle = await createBundle({
|
|
ts,
|
|
suffix: "STATIC",
|
|
serialNumber: STATIC_SN,
|
|
terminalMode: "static",
|
|
capability: {}
|
|
});
|
|
const dynamicBundle = await createBundle({
|
|
ts,
|
|
suffix: "DYNAMIC",
|
|
serialNumber: DYNAMIC_SN,
|
|
terminalMode: "dynamic_mqtt",
|
|
capability: {
|
|
dynamic_qr: {
|
|
mqtt: true
|
|
}
|
|
}
|
|
});
|
|
|
|
const staticConfig = await pullQf100Config(STATIC_SN, "static");
|
|
const dynamicConfig = await pullQf100Config(DYNAMIC_SN, "dynamic");
|
|
assert(staticConfig.mqtt["client-id"] === staticBundle.device.id, "static client-id must match device id");
|
|
assert(dynamicConfig.mqtt["client-id"] === dynamicBundle.device.id, "dynamic client-id must match device id");
|
|
|
|
const staticPaymentMessage = await triggerStaticPayment({ bundle: staticBundle, ts });
|
|
const dynamicQr = await triggerDynamicMqttQr({ bundle: dynamicBundle, ts });
|
|
|
|
console.log("\nQF100 adapter smoke passed");
|
|
console.log(`static_sn=${STATIC_SN}`);
|
|
console.log(`static_device_id=${staticBundle.device.id}`);
|
|
console.log(`static_payment_topic=${staticPaymentMessage.topic}`);
|
|
console.log(`dynamic_sn=${DYNAMIC_SN}`);
|
|
console.log(`dynamic_device_id=${dynamicBundle.device.id}`);
|
|
console.log(`dynamic_transaction_id=${dynamicQr.transaction_id}`);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|