fix(formation): add working /name-search worker route + e2e harness
Two latent bugs the e2e harness caught:
1. api entities.ts GET /states/:code/name-search calls WORKER_URL/name-search,
but job_server had NO such route -> 404 -> silently fell back to stale
entity_cache on every live name check. Added a synchronous /name-search route
returning {available,exact_match,similar_names,state}.
2. both the new route AND the existing async handle_name_search imported a
nonexistent search_name_sync(); fixed to drive the real async search_name()
via an event loop (same pattern as /entity-status).
scripts/e2e-formation-order.mjs: replays the real formation order flow (live
name search -> formation_orders insert -> ERPNext customer + Sales Order with
BUSINESS-FORMATION + STATE-FILING-FEE line items -> verify SO total + DB linkage
-> cleanup) without a real Stripe charge or state filing. Run in the api container.
Also created the missing ERPNext Items (BUSINESS-FORMATION, STATE-FILING-FEE,
FOREIGN-QUAL-SINGLE/MULTI) that the formation SO references.
This commit is contained in:
parent
c0344769a0
commit
4c0decd175
2 changed files with 244 additions and 2 deletions
184
scripts/e2e-formation-order.mjs
Normal file
184
scripts/e2e-formation-order.mjs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* e2e-formation-order.mjs - end-to-end check of the corporate formation order flow.
|
||||
*
|
||||
* Verifies the real plumbing WITHOUT taking a real Stripe payment or making a real
|
||||
* state filing:
|
||||
* 1. Name search against the live state portal (NV + TX) via the worker.
|
||||
* 2. Insert a test formation_orders row (the same columns the intake route writes).
|
||||
* 3. Replay the EXACT ERPNext Sales Order creation logic from checkout.ts
|
||||
* (BUSINESS-FORMATION + STATE-FILING-FEE line items) against the real ERPNext.
|
||||
* 4. Verify: the SO exists, has the right items, and formation_orders.erpnext_sales_order
|
||||
* was set. Then CLEAN UP (cancel+delete the test SO, delete the test order row)
|
||||
* unless --keep is passed.
|
||||
*
|
||||
* Run inside the api container on prod (it has node + the compiled erpnext client +
|
||||
* DATABASE_URL + WORKER_URL):
|
||||
* docker exec performancewest-api-1 node /app/scripts/e2e-formation-order.mjs [--keep] [--no-namesearch]
|
||||
*
|
||||
* Exit code 0 = all checks passed; non-zero = a check failed (CI-friendly).
|
||||
*/
|
||||
import pg from "pg";
|
||||
|
||||
const KEEP = process.argv.includes("--keep");
|
||||
const SKIP_NAMESEARCH = process.argv.includes("--no-namesearch");
|
||||
const WORKER_URL = process.env.WORKER_URL || "http://workers:8090";
|
||||
|
||||
// Load the app's own ERPNext client (compiled).
|
||||
const erp = await import("/app/dist/erpnext-client.js");
|
||||
const { createResource, getResource, callMethod, searchLink, deleteResource } = erp;
|
||||
|
||||
// Mirror checkout.ts findOrCreateCustomer using the exported client primitives
|
||||
// (the route-level helper is not exported, so reproduce its essentials here).
|
||||
async function findOrCreateCustomer(email, name) {
|
||||
try {
|
||||
const existing = await searchLink("Customer", email, { searchfield: "email_id" });
|
||||
if (existing && existing.length && existing[0].value) return { customerName: existing[0].value };
|
||||
} catch { /* fall through to create */ }
|
||||
const c = await createResource("Customer", {
|
||||
customer_name: name || email,
|
||||
customer_type: "Company",
|
||||
customer_group: "All Customer Groups",
|
||||
territory: "All Territories",
|
||||
email_id: email,
|
||||
});
|
||||
return { customerName: c.name };
|
||||
}
|
||||
|
||||
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
|
||||
|
||||
let failures = 0;
|
||||
const ok = (m) => console.log(` [PASS] ${m}`);
|
||||
const bad = (m) => { console.log(` [FAIL] ${m}`); failures++; };
|
||||
const info = (m) => console.log(m);
|
||||
|
||||
const toDollars = (c) => Math.round(c) / 100;
|
||||
|
||||
// Test cases: NV LLC and TX corp (the two DEXIT destinations).
|
||||
const CASES = [
|
||||
{ state: "NV", entity_type: "llc", name: "PW E2E Test Holdings", service: 17900, stateFee: 7500 },
|
||||
{ state: "TX", entity_type: "corporation", name: "PW E2E Test Industries", service: 17900, stateFee: 30000 },
|
||||
];
|
||||
|
||||
async function nameSearch(state, name) {
|
||||
if (SKIP_NAMESEARCH) { info(` (skipped name search for ${state})`); return null; }
|
||||
try {
|
||||
const r = await fetch(`${WORKER_URL}/name-search`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ state_code: state, name }),
|
||||
signal: AbortSignal.timeout(45000),
|
||||
});
|
||||
if (!r.ok) { bad(`name-search ${state} HTTP ${r.status}`); return null; }
|
||||
const j = await r.json();
|
||||
ok(`name search ${state} reachable (available=${j.available ?? "?"})`);
|
||||
return j;
|
||||
} catch (e) {
|
||||
bad(`name-search ${state} failed: ${e.message} (worker reachable from this container?)`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function runCase(tc) {
|
||||
info(`\n=== ${tc.state} ${tc.entity_type} formation ===`);
|
||||
const year = new Date().getFullYear();
|
||||
const orderNumber = `PW-E2E-${tc.state}-${Date.now().toString().slice(-7)}`;
|
||||
const email = `e2e-formation+${tc.state.toLowerCase()}@performancewest.net`;
|
||||
|
||||
await nameSearch(tc.state, tc.name);
|
||||
|
||||
// 1) insert the test order (mirror the intake route columns)
|
||||
let orderRow;
|
||||
try {
|
||||
const r = await pool.query(
|
||||
`INSERT INTO formation_orders (
|
||||
order_number, customer_name, customer_email,
|
||||
state_code, entity_type, entity_name,
|
||||
include_ra_service, include_ein, expedited,
|
||||
state_fee_cents, service_fee_cents, total_cents, status
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,TRUE,FALSE,FALSE,$7,$8,$9,'received')
|
||||
RETURNING id, order_number`,
|
||||
[orderNumber, "PW E2E Test", email, tc.state, tc.entity_type, tc.name,
|
||||
tc.stateFee, tc.service, tc.service + tc.stateFee],
|
||||
);
|
||||
orderRow = r.rows[0];
|
||||
ok(`inserted formation_orders row ${orderRow.order_number}`);
|
||||
} catch (e) {
|
||||
bad(`formation_orders insert failed: ${e.message}`);
|
||||
return { orderNumber, soName: null };
|
||||
}
|
||||
|
||||
// 2) ERPNext customer
|
||||
let customer;
|
||||
try {
|
||||
const { customerName } = await findOrCreateCustomer(email, "PW E2E Test");
|
||||
customer = customerName;
|
||||
ok(`ERPNext customer: ${customer}`);
|
||||
} catch (e) {
|
||||
bad(`findOrCreateCustomer failed: ${e.message}`);
|
||||
}
|
||||
|
||||
// 3) replay the EXACT SO creation from checkout.ts (formation branch)
|
||||
let soName = null;
|
||||
if (customer) {
|
||||
try {
|
||||
const so = await createResource("Sales Order", {
|
||||
customer,
|
||||
delivery_date: new Date(Date.now() + 30 * 86400000).toISOString().split("T")[0],
|
||||
custom_external_order_id: orderNumber,
|
||||
custom_order_type: "formation",
|
||||
workflow_state: "Received",
|
||||
items: [
|
||||
{ item_code: "BUSINESS-FORMATION", description: `${tc.entity_type} Formation — ${tc.state}`, qty: 1, rate: toDollars(tc.service) },
|
||||
{ item_code: "STATE-FILING-FEE", description: `${tc.state} state filing fee (government fee)`, qty: 1, rate: toDollars(tc.stateFee) },
|
||||
],
|
||||
});
|
||||
soName = so.name;
|
||||
ok(`created Sales Order ${soName}`);
|
||||
await pool.query(`UPDATE formation_orders SET erpnext_sales_order = $1 WHERE order_number = $2`, [soName, orderNumber]);
|
||||
} catch (e) {
|
||||
bad(`Sales Order creation failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) verify the SO + line items + DB linkage
|
||||
if (soName) {
|
||||
try {
|
||||
const so = await getResource("Sales Order", soName);
|
||||
const codes = (so.items || []).map((i) => i.item_code);
|
||||
const total = Number(so.total || 0);
|
||||
if (codes.includes("BUSINESS-FORMATION") && codes.includes("STATE-FILING-FEE")) ok(`SO line items correct: ${codes.join(", ")}`);
|
||||
else bad(`SO line items wrong: ${codes.join(", ")}`);
|
||||
const expected = toDollars(tc.service + tc.stateFee);
|
||||
if (Math.abs(total - expected) < 0.01) ok(`SO total = $${total} (expected $${expected})`);
|
||||
else bad(`SO total $${total} != expected $${expected}`);
|
||||
|
||||
const dbCheck = await pool.query(`SELECT erpnext_sales_order FROM formation_orders WHERE order_number=$1`, [orderNumber]);
|
||||
if (dbCheck.rows[0]?.erpnext_sales_order === soName) ok(`formation_orders.erpnext_sales_order linked`);
|
||||
else bad(`DB linkage missing (got ${dbCheck.rows[0]?.erpnext_sales_order})`);
|
||||
} catch (e) {
|
||||
bad(`SO verification fetch failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 5) cleanup
|
||||
if (!KEEP) {
|
||||
if (soName) {
|
||||
try { await callMethod("frappe.client.cancel", { doctype: "Sales Order", name: soName }); } catch {}
|
||||
try { await deleteResource("Sales Order", soName); } catch {}
|
||||
info(` cleaned up SO ${soName}`);
|
||||
}
|
||||
try { await pool.query(`DELETE FROM formation_orders WHERE order_number=$1`, [orderNumber]); info(` cleaned up order row`); } catch {}
|
||||
} else {
|
||||
info(` --keep: left ${orderNumber} / ${soName} in place`);
|
||||
}
|
||||
return { orderNumber, soName };
|
||||
}
|
||||
|
||||
info("=== formation order e2e harness ===");
|
||||
info(`worker: ${WORKER_URL} | namesearch: ${!SKIP_NAMESEARCH} | keep: ${KEEP}\n`);
|
||||
for (const tc of CASES) await runCase(tc);
|
||||
|
||||
await pool.end();
|
||||
info(`\n=== ${failures === 0 ? "ALL CHECKS PASSED" : failures + " CHECK(S) FAILED"} ===`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
|
|
@ -224,7 +224,8 @@ def _check_standard_delay(order_name: str, checkpoint: str) -> dict | None:
|
|||
|
||||
def handle_name_search(payload: dict) -> dict:
|
||||
"""Search for business name availability in a state portal."""
|
||||
from scripts.formation.name_search import search_name_sync
|
||||
import asyncio
|
||||
from scripts.formation.name_search import search_name
|
||||
order_name = payload["order_name"]
|
||||
state_code = payload["state_code"]
|
||||
entity_name = payload["entity_name"]
|
||||
|
|
@ -235,7 +236,12 @@ def handle_name_search(payload: dict) -> dict:
|
|||
return {"action": "name_search", **defer}
|
||||
|
||||
LOG.info("Name search: %s in %s", entity_name, state_code)
|
||||
result = search_name_sync(entity_name, state_code)
|
||||
_loop = asyncio.new_event_loop()
|
||||
try:
|
||||
_nsr = _loop.run_until_complete(search_name(entity_name, state_code))
|
||||
finally:
|
||||
_loop.close()
|
||||
result = vars(_nsr) if hasattr(_nsr, "__dict__") else dict(_nsr)
|
||||
|
||||
# Report back to ERPNext
|
||||
from scripts.workers.erpnext_client import ERPNextClient
|
||||
|
|
@ -2014,6 +2020,16 @@ class JobHandler(BaseHTTPRequestHandler):
|
|||
self._handle_rmd_preview()
|
||||
return
|
||||
|
||||
if self.path == "/name-search":
|
||||
# Synchronous name-availability check used by the API
|
||||
# (GET /api/v1/states/:code/name-search). Returns the shape
|
||||
# entities.ts expects: {available, state, exact_match, similar_names}.
|
||||
# This is distinct from /entity-status (which frames the same
|
||||
# search as an entity-status lookup) and from the order-gated
|
||||
# async name_search job (which writes back to ERPNext).
|
||||
self._handle_name_search_sync()
|
||||
return
|
||||
|
||||
if self.path == "/jobs/presign":
|
||||
# Synchronous presign — used by API routes
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
|
|
@ -2091,6 +2107,48 @@ class JobHandler(BaseHTTPRequestHandler):
|
|||
|
||||
self._respond(202, {"job_id": job_id, "action": action, "status": "queued"})
|
||||
|
||||
def _handle_name_search_sync(self):
|
||||
"""Synchronous name-availability check used by the API.
|
||||
POST /name-search {state_code, name}
|
||||
Returns {available, state, exact_match, similar_names} (the shape
|
||||
api/src/routes/entities.ts expects). Calls the live state portal
|
||||
adapter via search_name_sync; the API caches the result for 24h.
|
||||
"""
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_length)
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
self._respond(400, {"error": "Invalid JSON"})
|
||||
return
|
||||
|
||||
state_code = payload.get("state_code", "").strip().upper()
|
||||
name = (payload.get("name") or payload.get("entity_name") or "").strip()
|
||||
if not state_code or not name:
|
||||
self._respond(400, {"error": "state_code and name required"})
|
||||
return
|
||||
|
||||
try:
|
||||
import asyncio
|
||||
from scripts.formation.name_search import search_name
|
||||
loop = asyncio.new_event_loop()
|
||||
result = loop.run_until_complete(search_name(name, state_code))
|
||||
loop.close()
|
||||
if hasattr(result, "__dict__"):
|
||||
result = vars(result)
|
||||
elif hasattr(result, "_asdict"):
|
||||
result = result._asdict()
|
||||
self._respond(200, {
|
||||
"available": result.get("available"),
|
||||
"exact_match": result.get("exact_match"),
|
||||
"similar_names": result.get("similar_names", []),
|
||||
"state": state_code,
|
||||
"source": "live_search",
|
||||
})
|
||||
except Exception as exc:
|
||||
LOG.exception("name-search sync failed for %s/%s", state_code, name)
|
||||
self._respond(500, {"error": str(exc)})
|
||||
|
||||
def _handle_entity_status(self):
|
||||
"""Synchronous entity status lookup via Playwright state adapter.
|
||||
POST /entity-status {entity_name, state_code}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue