485 lines
16 KiB
JavaScript
485 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* rr-test.js — end-to-end exerciser for Reynolds "Rome"/STAR actions.
|
|
*
|
|
* Key improvements vs prior version:
|
|
* - Prints FULL XML responses (no truncation).
|
|
* - CombinedSearch now sends at least one search criterion by default.
|
|
* - InsertCustomer sets required IBFlag + LastName/FirstName.
|
|
* - Orchestrates dependent steps (CombinedSearch → InsertCustomer → InsertVehicle → Create/Update RO).
|
|
*
|
|
* Usage examples:
|
|
* node rr-test.js --ping
|
|
* node rr-test.js --all
|
|
* node rr-test.js --combined --last "SMITH" --phone 9375550001 --vin 1FTFW1E50JFA00000
|
|
*
|
|
* Common flags:
|
|
* --first, --last, --phone, --email, --vin, --plate, --advisor, --max N
|
|
*
|
|
* Env fallbacks:
|
|
* RR_TEST_LAST, RR_TEST_FIRST, RR_TEST_PHONE, RR_TEST_EMAIL, RR_TEST_VIN, RR_TEST_PLATE, RR_TEST_PARTDESC
|
|
*
|
|
* Dealer creds/env (used only in this test harness; runtime pulls from DB):
|
|
* RR_DEALER_NUMBER, RR_STORE_NUMBER, RR_AREA_NUMBER, RR_USERNAME, RR_PASSWORD, RR_ENDPOINT
|
|
*/
|
|
|
|
const path = require("path");
|
|
const fs = require("fs");
|
|
const fsp = require("fs/promises");
|
|
const minimist = require("minimist");
|
|
const dayjs = require("dayjs");
|
|
const { XMLParser } = require("fast-xml-parser");
|
|
|
|
// Load dev env if present
|
|
const envPath = path.resolve(__dirname, "../../.env.development");
|
|
if (fs.existsSync(envPath)) {
|
|
require("dotenv").config({ path: envPath });
|
|
console.log(envPath);
|
|
console.log(
|
|
`[dotenv@${require("dotenv/package.json").version}] injecting env (${Object.keys(process.env).length}) from ../../.env.development`
|
|
);
|
|
}
|
|
|
|
const { MakeRRCall, parseRRResponse } = require("./rr-helpers");
|
|
const RRLogger = require("./rr-logger");
|
|
|
|
// CLI flags
|
|
const argv = minimist(process.argv.slice(2));
|
|
const FLAG = (k, d) => (argv[k] !== undefined ? argv[k] : d);
|
|
|
|
// For templates that expect STAR NS in {{STAR_NS}}
|
|
const STAR_NS = "http://www.starstandards.org/STAR";
|
|
|
|
// Basic XML parser for optional extractions
|
|
const parser = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true });
|
|
|
|
// Ensure templates exist
|
|
async function verifyTemplates() {
|
|
const required = [
|
|
"CombinedSearch",
|
|
"GetAdvisors",
|
|
"GetParts",
|
|
"InsertCustomer",
|
|
"UpdateCustomer",
|
|
"InsertServiceVehicle",
|
|
"CreateRepairOrder",
|
|
"UpdateRepairOrder"
|
|
];
|
|
for (const t of required) {
|
|
const p = path.join(__dirname, "xml-templates", `${t}.xml`);
|
|
await fsp.readFile(p, "utf8");
|
|
}
|
|
console.log("✅ Templates verified.");
|
|
}
|
|
|
|
// Format AppArea per action (Sender.Task/ReferenceId)
|
|
function appAreaFor(action, cfg) {
|
|
const pad2 = (x) => String(x ?? "").padStart(2, "0");
|
|
const base = {
|
|
CreationDateTime: new Date().toISOString(),
|
|
BODId: `BOD-${Date.now()}`,
|
|
Sender: { Component: "Rome", CreatorNameCode: "RCI", SenderNameCode: "RCI" },
|
|
Destination: {
|
|
DealerNumber: cfg.dealerNumber,
|
|
StoreNumber: pad2(cfg.storeNumber),
|
|
AreaNumber: pad2(cfg.branchNumber || "01")
|
|
}
|
|
};
|
|
switch (action) {
|
|
case "CreateRepairOrder":
|
|
return { ...base, Sender: { ...base.Sender, Task: "BSMRO", ReferenceId: "Insert" } };
|
|
case "UpdateRepairOrder":
|
|
return { ...base, Sender: { ...base.Sender, Task: "BSMRO", ReferenceId: "Update" } };
|
|
case "InsertCustomer":
|
|
return { ...base, Sender: { ...base.Sender, Task: "CU", ReferenceId: "Insert" } };
|
|
case "UpdateCustomer":
|
|
return { ...base, Sender: { ...base.Sender, Task: "CU", ReferenceId: "Update" } };
|
|
case "InsertServiceVehicle":
|
|
return { ...base, Sender: { ...base.Sender, Task: "SV", ReferenceId: "Insert" } };
|
|
case "CombinedSearch":
|
|
return { ...base, Sender: { ...base.Sender, Task: "SV", ReferenceId: "Query" } };
|
|
case "GetAdvisors":
|
|
return { ...base, Sender: { ...base.Sender, Task: "RCI", ReferenceId: "Lookup" } };
|
|
case "GetParts":
|
|
return { ...base, Sender: { ...base.Sender, Task: "RCI", ReferenceId: "Lookup" } };
|
|
default:
|
|
return base;
|
|
}
|
|
}
|
|
|
|
// Build cfg for test harness (env fallbacks allowed here)
|
|
function cfgFromEnv() {
|
|
return {
|
|
baseUrl:
|
|
process.env.RR_ENDPOINT || process.env.RR_BASE_URL || "https://b2b-test.reyrey.com/Sync/RCI/Rome/Receive.ashx",
|
|
username: process.env.RR_USERNAME || "Rome",
|
|
password: process.env.RR_PASSWORD || "",
|
|
timeout: Number(process.env.RR_TIMEOUT || 30000),
|
|
dealerNumber: process.env.RR_DEALER_NUMBER || "PPERASV02000000",
|
|
storeNumber: process.env.RR_STORE_NUMBER || "05",
|
|
branchNumber: process.env.RR_AREA_NUMBER || "03"
|
|
};
|
|
}
|
|
|
|
// Utility: get a value from parsed JSON or raw XML (element or attribute)
|
|
function findFirstId(parsed, rawXml, keyRegex) {
|
|
// search parsed object
|
|
if (parsed && typeof parsed === "object") {
|
|
const stack = [parsed];
|
|
while (stack.length) {
|
|
const cur = stack.pop();
|
|
if (cur && typeof cur === "object") {
|
|
for (const [k, v] of Object.entries(cur)) {
|
|
if (keyRegex.test(k) && (typeof v === "string" || typeof v === "number")) {
|
|
const s = String(v).trim();
|
|
if (s) return s;
|
|
}
|
|
if (v && typeof v === "object") stack.push(v);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// search raw XML: <Key>value</Key> or Key="value"
|
|
if (rawXml) {
|
|
const re1 = new RegExp(`<([A-Za-z0-9:_-]*${keyRegex.source}[A-Za-z0-9:_-]*)>([^<]+)</\\1>`, "i");
|
|
const m1 = re1.exec(rawXml);
|
|
if (m1 && m1[2]) return m1[2].trim();
|
|
const re2 = new RegExp(`${keyRegex.source}="([^"]+)"`, "i");
|
|
const m2 = re2.exec(rawXml);
|
|
if (m2 && m2[1]) return m2[1].trim();
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
// Call wrapper: prints FULL XML
|
|
async function callRR(action, dataObj, cfg) {
|
|
const respXml = await MakeRRCall({
|
|
action,
|
|
dealerConfig: cfg,
|
|
body: { data: { STAR_NS, ...dataObj }, appArea: appAreaFor(action, cfg) }
|
|
});
|
|
const parsed = parseRRResponse(respXml);
|
|
return { respXml, parsed };
|
|
}
|
|
|
|
// --------- Individual Steps (full XML printed) ---------
|
|
|
|
async function stepGetAdvisors(ctx) {
|
|
const cfg = ctx.cfg;
|
|
const data = {
|
|
SearchCriteria: {
|
|
Department: FLAG("dept", "B"), // critical to avoid 201
|
|
Status: FLAG("status", "ACTIVE"),
|
|
MaxResults: Number(FLAG("max", 5)) || 5
|
|
}
|
|
};
|
|
const { respXml, parsed } = await callRR("GetAdvisors", data, cfg);
|
|
|
|
console.log("\n[GetAdvisors] RESPONSE (FULL):\n");
|
|
console.log(respXml);
|
|
|
|
const advId =
|
|
findFirstId(parsed.parsed, respXml, /(Advisor(ID|No|Number)?|AdvNo)/i) ||
|
|
findFirstId(parsed.parsed, respXml, /(Employee(ID|No|Number)?)/i);
|
|
if (advId) ctx.advisorId = advId;
|
|
|
|
return { ok: true, code: parsed.code, advisorId: advId || null };
|
|
}
|
|
|
|
async function stepCombinedSearch(ctx) {
|
|
const cfg = ctx.cfg;
|
|
|
|
// Provide at least one criterion by default to avoid 201
|
|
const defaultLast = FLAG("last", process.env.RR_TEST_LAST || "SMITH");
|
|
const defaultPhone = FLAG("phone", process.env.RR_TEST_PHONE || "");
|
|
const defaultVin = FLAG("vin", process.env.RR_TEST_VIN || "");
|
|
|
|
const data = {
|
|
MaxResults: Number(FLAG("max", 5)) || 5,
|
|
Customer: {},
|
|
Vehicle: {}
|
|
};
|
|
if (defaultLast) data.Customer.LastName = defaultLast;
|
|
if (defaultPhone) data.Customer.PhoneNumber = defaultPhone;
|
|
if (defaultVin) data.Vehicle.VIN = defaultVin;
|
|
|
|
const { respXml, parsed } = await callRR("CombinedSearch", data, cfg);
|
|
|
|
console.log("\n[CombinedSearch] RESPONSE (FULL):\n");
|
|
console.log(respXml);
|
|
|
|
const custId = findFirstId(parsed.parsed, respXml, /(Cust(omer)?(No|Id|ID|Number|Key)|NameRecId)/i);
|
|
const vehId = findFirstId(parsed.parsed, respXml, /(Veh(icle)?(No|Id|ID|Number|Key))/i);
|
|
const vin = findFirstId(parsed.parsed, respXml, /VIN/i);
|
|
|
|
if (custId) ctx.customerId = custId;
|
|
if (vehId) ctx.vehicleId = vehId;
|
|
if (vin) ctx.vin = vin;
|
|
|
|
return { ok: true, code: parsed.code, customerId: custId || null, vehicleId: vehId || null, vin: vin || null };
|
|
}
|
|
|
|
async function stepInsertCustomer(ctx) {
|
|
const cfg = ctx.cfg;
|
|
|
|
// RR requires IBFlag and LastName for individual customers
|
|
const ts = dayjs().format("YYYYMMDD-HHmmss");
|
|
const firstName = FLAG("first", process.env.RR_TEST_FIRST || "QA");
|
|
const lastName = FLAG("last", process.env.RR_TEST_LAST || "Test");
|
|
const email = FLAG("email", process.env.RR_TEST_EMAIL || `qa.${ts}@example.com`);
|
|
const phone = FLAG(
|
|
"phone",
|
|
process.env.RR_TEST_PHONE || `937555${String(Math.floor(Math.random() * 10000)).padStart(4, "0")}`
|
|
);
|
|
|
|
const data = {
|
|
IBFlag: "I", // <== REQUIRED (I=Individual, B=Business)
|
|
FirstName: firstName, // <== REQUIRED with IBFlag=I
|
|
LastName: lastName, // <== REQUIRED with IBFlag=I
|
|
Active: "Y",
|
|
Phones: [{ Type: "Mobile", CountryCode: "1", Number: phone, Preferred: "Y" }],
|
|
Emails: [{ Type: "Personal", Address: email, Preferred: "Y" }],
|
|
Addresses: [
|
|
{
|
|
Type: "Home",
|
|
Line1: "123 Test St",
|
|
City: "Dayton",
|
|
State: "OH",
|
|
PostalCode: "45402",
|
|
Country: "US",
|
|
IsPrimary: "Y"
|
|
}
|
|
]
|
|
};
|
|
|
|
const { respXml, parsed } = await callRR("InsertCustomer", data, cfg);
|
|
|
|
console.log("\n[InsertCustomer] RESPONSE (FULL):\n");
|
|
console.log(respXml);
|
|
|
|
// RR often returns the new customer key as DMSRecKey attribute in TransStatus
|
|
const custId = findFirstId(parsed.parsed, respXml, /(DMSRecKey|Cust(omer)?(No|Id|ID|Number|Key)|NameRecId)/i);
|
|
|
|
if (custId) ctx.customerId = custId;
|
|
return { ok: !!custId, code: parsed.code, customerId: custId || null };
|
|
}
|
|
|
|
async function stepUpdateCustomer(ctx) {
|
|
const cfg = ctx.cfg;
|
|
if (!ctx.customerId) return { ok: false, code: "NO_CONTEXT", message: "No customerId" };
|
|
|
|
const data = {
|
|
CustomerId: ctx.customerId,
|
|
Emails: [{ Type: "Personal", Address: `qa.${dayjs().format("HHmmss")}@example.com`, Preferred: "Y" }]
|
|
};
|
|
|
|
const { respXml, parsed } = await callRR("UpdateCustomer", data, cfg);
|
|
|
|
console.log("\n[UpdateCustomer] RESPONSE (FULL):\n");
|
|
console.log(respXml);
|
|
|
|
return { ok: parsed.success, code: parsed.code };
|
|
}
|
|
|
|
async function stepInsertServiceVehicle(ctx) {
|
|
const cfg = ctx.cfg;
|
|
if (!ctx.customerId) return { ok: false, code: "NO_CONTEXT", message: "No customerId" };
|
|
|
|
const vin =
|
|
FLAG("vin", process.env.RR_TEST_VIN) ||
|
|
`1FTFW1E50JFA${String(Math.floor(Math.random() * 1000000)).padStart(6, "0")}`;
|
|
|
|
const data = {
|
|
CustomerId: ctx.customerId,
|
|
VIN: vin,
|
|
Year: "2018",
|
|
Make: "FORD",
|
|
Model: "F-150",
|
|
Color: "WHITE",
|
|
LicensePlate:
|
|
FLAG("plate", process.env.RR_TEST_PLATE) || `QA${String(Math.floor(Math.random() * 100000)).padStart(5, "0")}`,
|
|
LicenseState: "OH",
|
|
Odometer: "123456",
|
|
OdometerUnits: "MI"
|
|
};
|
|
|
|
const { respXml, parsed } = await callRR("InsertServiceVehicle", data, cfg);
|
|
|
|
console.log("\n[InsertServiceVehicle] RESPONSE (FULL):\n");
|
|
console.log(respXml);
|
|
|
|
const vehId = findFirstId(parsed.parsed, respXml, /(Veh(icle)?(No|Id|ID|Number|Key))/i);
|
|
if (vehId) ctx.vehicleId = vehId;
|
|
ctx.vin = vin;
|
|
|
|
return { ok: parsed.success || !!vehId, code: parsed.code, vehicleId: vehId || null, vin };
|
|
}
|
|
|
|
async function stepCreateRepairOrder(ctx) {
|
|
const cfg = ctx.cfg;
|
|
if (!ctx.customerId) return { ok: false, code: "NO_CONTEXT", message: "No customerId" };
|
|
|
|
const roNumber = `BSM-${dayjs().format("MMDD-HHmmss")}-${String(Math.floor(Math.random() * 1000)).padStart(3, "0")}`;
|
|
|
|
const data = {
|
|
RepairOrderNumber: roNumber,
|
|
Department: "B",
|
|
ROType: "INS",
|
|
Status: "OPEN",
|
|
IsBodyShop: "Y",
|
|
ServiceAdvisorId: ctx.advisorId || FLAG("advisor", process.env.RR_TEST_ADVISOR) || undefined,
|
|
Customer: {
|
|
CustomerId: ctx.customerId,
|
|
FirstName: FLAG("first", process.env.RR_TEST_FIRST || "QA"),
|
|
LastName: FLAG("last", process.env.RR_TEST_LAST || "Test"),
|
|
PhoneNumber: FLAG("phone", process.env.RR_TEST_PHONE || "9375550000"),
|
|
EmailAddress: FLAG("email", process.env.RR_TEST_EMAIL || "qa@example.com")
|
|
},
|
|
ServiceVehicle: {
|
|
VIN: ctx.vin || FLAG("vin", process.env.RR_TEST_VIN) || undefined,
|
|
Odometer: "4321"
|
|
},
|
|
Totals: {
|
|
Currency: "USD",
|
|
LaborTotal: "0",
|
|
PartsTotal: "0",
|
|
MiscTotal: "0",
|
|
DiscountTotal: "0",
|
|
TaxTotal: "0",
|
|
GrandTotal: "0"
|
|
}
|
|
};
|
|
|
|
const respXml = await MakeRRCall({
|
|
action: "CreateRepairOrder",
|
|
dealerConfig: cfg,
|
|
body: { data: { STAR_NS, ...data }, appArea: appAreaFor("CreateRepairOrder", cfg) }
|
|
});
|
|
|
|
const parsed = parseRRResponse(respXml);
|
|
|
|
console.log("\n[CreateRepairOrder] RESPONSE (FULL):\n");
|
|
console.log(respXml);
|
|
|
|
const roNo = findFirstId(parsed.parsed, respXml, /(DMSRoNo|RO?No|OutsdRoNo|RepairOrder(Id|Number))/i) || roNumber;
|
|
|
|
ctx.roNumber = roNo;
|
|
|
|
return { ok: parsed.success, code: parsed.code, roNumber: roNo };
|
|
}
|
|
|
|
async function stepUpdateRepairOrder(ctx) {
|
|
const cfg = ctx.cfg;
|
|
if (!ctx.roNumber) return { ok: false, code: "NO_CONTEXT", message: "No roNumber" };
|
|
|
|
const data = {
|
|
RepairOrderNumber: ctx.roNumber,
|
|
Department: "B",
|
|
Status: "OPEN",
|
|
Totals: { LaborTotal: "0", PartsTotal: "0", MiscTotal: "0", TaxTotal: "0", GrandTotal: "0" }
|
|
};
|
|
|
|
const { respXml, parsed } = await callRR("UpdateRepairOrder", data, cfg);
|
|
|
|
console.log("\n[UpdateRepairOrder] RESPONSE (FULL):\n");
|
|
console.log(respXml);
|
|
|
|
return { ok: parsed.success, code: parsed.code };
|
|
}
|
|
|
|
async function stepGetParts(ctx) {
|
|
const cfg = ctx.cfg;
|
|
|
|
const data = {
|
|
MaxResults: Number(FLAG("max", 5)) || 5,
|
|
SearchMode: "Description",
|
|
Description: FLAG("partdesc", process.env.RR_TEST_PARTDESC || "clip")
|
|
};
|
|
|
|
const { respXml, parsed } = await callRR("GetParts", data, cfg);
|
|
|
|
console.log("\n[GetParts] RESPONSE (FULL):\n");
|
|
console.log(respXml);
|
|
|
|
return { ok: parsed.success, code: parsed.code };
|
|
}
|
|
|
|
// --------- Runner ---------
|
|
|
|
async function runAll() {
|
|
await verifyTemplates();
|
|
|
|
const cfg = cfgFromEnv();
|
|
const ctx = { cfg };
|
|
|
|
const results = [];
|
|
|
|
async function runStep(name, fn) {
|
|
const t0 = Date.now();
|
|
try {
|
|
const res = await fn(ctx);
|
|
results.push({ step: name, ok: !!res.ok, code: res.code || "OK", ms: Date.now() - t0 });
|
|
console.log(`\n✔ ${name} done in ${Date.now() - t0}ms`);
|
|
return res;
|
|
} catch (err) {
|
|
results.push({ step: name, ok: false, code: err.code || "ERR", ms: Date.now() - t0, error: err.message });
|
|
console.error(`\n✖ ${name} failed: ${err.message}`);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// Sequence (dependencies respected)
|
|
await runStep("GetAdvisors", stepGetAdvisors);
|
|
await runStep("CombinedSearch", stepCombinedSearch);
|
|
|
|
if (!ctx.customerId) await runStep("InsertCustomer", stepInsertCustomer);
|
|
if (!ctx.vehicleId) await runStep("InsertServiceVehicle", stepInsertServiceVehicle);
|
|
|
|
await runStep("CreateRepairOrder", stepCreateRepairOrder);
|
|
await runStep("UpdateRepairOrder", stepUpdateRepairOrder);
|
|
await runStep("GetParts", stepGetParts);
|
|
|
|
// Summary
|
|
console.log("\n=== SUMMARY ===");
|
|
console.table(results.map((r) => ({ step: r.step, ok: r.ok ? "✅" : "❌", code: r.code, ms: r.ms })));
|
|
const failed = results.some((r) => !r.ok);
|
|
console.log(`Total: ${results.reduce((a, r) => a + r.ms, 0)}ms | Status: ${failed ? "FAIL" : "PASS"}`);
|
|
if (failed) process.exitCode = 1;
|
|
}
|
|
|
|
// Entrypoint
|
|
(async () => {
|
|
if (FLAG("ping", false)) {
|
|
await verifyTemplates();
|
|
const cfg = cfgFromEnv();
|
|
console.log("\n▶ Calling Rome action: GetAdvisors");
|
|
const r = await stepGetAdvisors({ cfg });
|
|
if (r.ok) console.log("\n✅ RR call completed.\n");
|
|
return;
|
|
}
|
|
|
|
if (FLAG("all", true)) {
|
|
await runAll();
|
|
return;
|
|
}
|
|
|
|
// Individual flags (optional)
|
|
await verifyTemplates();
|
|
const cfg = cfgFromEnv();
|
|
const ctx = { cfg };
|
|
const want = [];
|
|
if (FLAG("get-advisors", false)) want.push(stepGetAdvisors);
|
|
if (FLAG("combined", false)) want.push(stepCombinedSearch);
|
|
if (FLAG("insert-customer", false)) want.push(stepInsertCustomer);
|
|
if (FLAG("update-customer", false)) want.push(stepUpdateCustomer);
|
|
if (FLAG("insert-vehicle", false)) want.push(stepInsertServiceVehicle);
|
|
if (FLAG("create-ro", false)) want.push(stepCreateRepairOrder);
|
|
if (FLAG("update-ro", false)) want.push(stepUpdateRepairOrder);
|
|
if (FLAG("get-parts", false)) want.push(stepGetParts);
|
|
|
|
for (const fn of want) {
|
|
const res = await fn(ctx).catch((e) => ({ ok: false, code: "ERR", error: e.message }));
|
|
console.log(res);
|
|
}
|
|
})();
|