Files
daily_console_web/scripts/contract.mjs
2026-08-26 18:37:28 +05:30

435 lines
15 KiB
JavaScript

/**
* The contract check.
*
* Signs in once, calls every endpoint the console reads, and reports what came
* back: the HTTP status, the envelope, whether `details` is an array or an
* object or null, how many rows, and — the part that matters — the keys on the
* first row against the keys `src/api/types.ts` says to expect.
*
* This exists because field-name drift is invisible until a real payload
* arrives, and it is the single most likely way connecting to the backend goes
* wrong. It has already happened once from fixtures alone: POS health returns
* `terminal_id` while the sales split returns `terminalid`, and an index
* signature on the type let the wrong one typecheck in silence.
*
* READ-ONLY. Nothing here writes. Every create, update and import is left to a
* person on a scratch tenant, because several of them are unscoped and one of
* them moves stock.
*
* Run:
* npm run contract (prompts for the password, hidden)
*
* Or, for CI, set NEARLE_EMAIL and NEARLE_PASSWORD in the environment. Neither
* is ever written into this file — see the note beside EMAIL below.
*
* Plain JavaScript on purpose: it runs with the node you already have, with no
* install step and no TypeScript loader in the way.
*
* The credentials are read from the environment and never printed, logged or
* written to a file. Put them in front of the command rather than in a script,
* and they stay out of your shell history if your shell is configured for it.
*/
import { createInterface } from 'node:readline';
const BASE = process.env['NEARLE_API'] ?? 'https://fiesta.nearle.app';
/**
* `/web/pos`, not `/pos`.
*
* The `/v1/pos` group sits behind the terminal's session guard; the console's
* copies of the same reads are registered under `/v1/web/pos`. Sweeping the
* wrong one would report a surface the console never calls.
*/
const WEB = '/live/api/v1/web';
const POS = '/live/api/v1/web/pos';
const MOB = '/live/api/v1/mob';
/**
* The account to sweep with.
*
* The email defaults because it is not a secret. The PASSWORD is never
* defaulted and never written into this file: a password in source is
* committed, synced to every machine that clones the repo, and survives in the
* history after it is changed. It is read from the environment if set, and
* otherwise typed at the prompt below, where it is not echoed and does not
* reach the shell history.
*/
const EMAIL = process.env['NEARLE_EMAIL'] ?? 'care@nearle.in';
/** Reads a line without echoing it. */
function askHidden(question) {
return new Promise((resolve) => {
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
const onData = (char) => {
// Stop echoing everything except the newline that ends the answer.
if (char.toString() !== '\n' && char.toString() !== '\r' && char.toString() !== '\u0004') {
process.stdout.write('\u001b[2K\u001b[200D' + question + '*'.repeat(rl.line.length));
}
};
process.stdin.on('data', onData);
rl.question(question, (answer) => {
process.stdin.off('data', onData);
rl.close();
process.stdout.write('\n');
resolve(answer);
});
});
}
const PASSWORD =
process.env['NEARLE_PASSWORD'] ?? (await askHidden(`Password for ${EMAIL}: `));
if (!PASSWORD) {
console.error('No password given — nothing to sign in with.');
process.exit(1);
}
async function call(path, init = {}) {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(init.params ?? {})) {
if (value === undefined || value === null || value === '') continue;
search.set(key, String(value));
}
const query = search.toString();
const response = await fetch(`${BASE}${path}${query ? `?${query}` : ''}`, {
method: init.method ?? 'GET',
headers: init.body
? { Accept: 'application/json', 'Content-Type': 'application/json' }
: { Accept: 'application/json' },
...(init.body ? { body: JSON.stringify(init.body) } : {}),
});
let envelope = {};
try {
envelope = await response.json();
} catch {
envelope = { message: 'not JSON' };
}
return { http: response.status, envelope };
}
/**
* A call that reports a dead host rather than crashing the run.
*
* A wrong `NEARLE_API`, a VPN that is not up, or one endpoint timing out should
* leave the other twenty-four results on screen — a stack trace at check four
* tells you nothing about checks five to twenty-five.
*/
async function attempt(path, init = {}) {
try {
return await call(path, init);
} catch (cause) {
return {
http: 0,
envelope: { status: false, message: `could not reach the server (${String(cause)})` },
};
}
}
/* ── Sign in ─────────────────────────────────────────────────────────────── */
const login = await attempt(`${WEB}/users/applogin`, {
method: 'POST',
// `configid` is not optional: the lookup is `WHERE authname = ? AND configid = ?`.
body: { authname: EMAIL, password: PASSWORD, configid: 1 },
});
if (login.envelope.status !== true || !login.envelope.details) {
console.error(
`Sign-in failed — HTTP ${login.http}, code ${login.envelope.code}: ${login.envelope.message}`,
);
process.exit(1);
}
const me = login.envelope.details;
const tenantid = Number(me['tenantid'] ?? 0);
const locationid = Number(me['locationid'] ?? 0);
const issuperadmin = me['issuperadmin'] === true;
console.log('── signed in ─────────────────────────────────────────────');
console.log(`userid ${me['userid']} · roleid ${me['roleid']} · issuperadmin ${issuperadmin}`);
console.log(`tenantid ${tenantid} · locationid ${locationid} · ${me['locationname'] ?? '—'}`);
console.log('login keys:', Object.keys(me).sort().join(', '));
console.log('');
/**
* A tenant and a branch to probe the scoped endpoints with.
*
* A super admin has neither of their own, so one is borrowed from the platform
* list. Override with NEARLE_TENANT / NEARLE_LOCATION to aim at a specific one.
*/
let probeTenant = Number(process.env['NEARLE_TENANT'] ?? 0) || tenantid;
let probeLocation = Number(process.env['NEARLE_LOCATION'] ?? 0) || locationid;
if (!probeTenant) {
const tenants = await attempt(`${WEB}/tenants/getalltenants`, {
params: { pageno: 1, pagesize: 1 },
});
probeTenant = Number(tenants.envelope.details?.[0]?.['tenantid'] ?? 0);
}
if (probeTenant && !probeLocation) {
const locations = await attempt(`${WEB}/tenants/gettenantlocations`, {
params: { tenantid: probeTenant },
});
probeLocation = Number(locations.envelope.details?.[0]?.['locationid'] ?? 0);
}
console.log(`probing with tenantid ${probeTenant} · locationid ${probeLocation}\n`);
/* ── What we expect ──────────────────────────────────────────────────────── */
/**
* The keys each row should carry, taken from `src/api/types.ts`.
*
* Only the ones the console actually reads are listed — a backend that returns
* MORE than this is fine and normal, and is reported as extras rather than as a
* failure. What matters is anything missing.
*/
const CHECKS = [
{
name: 'tenants/getalltenants',
path: `${WEB}/tenants/getalltenants`,
params: { pageno: 1, pagesize: 5 },
expect: ['tenantid', 'tenantname', 'locationid', 'locationname', 'status'],
},
{
name: 'tenants/search?pending',
path: `${WEB}/tenants/search`,
params: { status: 'pending' },
expect: ['tenantid', 'tenantname'],
},
{
name: 'tenants/gettenantlocations',
path: `${WEB}/tenants/gettenantlocations`,
params: { tenantid: probeTenant },
needs: 'tenant',
expect: ['locationid', 'tenantid', 'locationname', 'status'],
},
{
name: 'utils/getappcategories',
path: `${WEB}/utils/getappcategories`,
expect: ['categoryid', 'categoryname'],
},
{
name: 'orders/getlocationsummary',
path: `${WEB}/orders/getlocationsummary`,
params: { tenantid: probeTenant },
needs: 'tenant',
expect: ['locationid', 'locationname', 'total', 'delivered', 'cancelled'],
},
{
name: 'orders/getordersummary',
path: `${WEB}/orders/getordersummary`,
params: { tenantid: probeTenant },
needs: 'tenant',
expect: ['total', 'delivered', 'cancelled'],
},
{
name: 'catalogue/getbrands',
path: `${WEB}/catalogue/getbrands`,
expect: ['brand', 'product_count'],
},
{
name: 'catalogue/getproducts',
path: `${WEB}/catalogue/getproducts`,
params: { pageno: 1, pagesize: 5 },
expect: ['id', 'brand', 'product_name'],
},
{
name: 'products/getimportedcatalogueproducts',
path: `${WEB}/products/getimportedcatalogueproducts`,
params: { tenantid: probeTenant },
needs: 'tenant',
expect: ['brand', 'catalogueid'],
},
{
name: 'products/getproductcategories',
path: `${WEB}/products/getproductcategories`,
params: { tenantid: probeTenant },
needs: 'tenant',
expect: ['categoryid', 'categoryname'],
},
{
name: 'products/gettenantcategories',
path: `${WEB}/products/gettenantcategories`,
params: { tenantid: probeTenant },
needs: 'tenant',
expect: ['categoryid', 'categoryname'],
},
{
name: 'products/getlocationproducts',
path: `${WEB}/products/getlocationproducts`,
params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 },
needs: 'location',
expect: ['productid', 'productname', 'price', 'publishedat', 'status'],
},
{
name: 'products/getallproducts',
path: `${WEB}/products/getallproducts`,
params: { tenantid: probeTenant, pageno: 1, pagesize: 5 },
needs: 'tenant',
expect: ['productid', 'productname'],
},
{
name: 'products/getstockstatement',
path: `${WEB}/products/getstockstatement`,
params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 },
needs: 'location',
expect: ['productid', 'opening', 'credit', 'debit', 'closing'],
},
{
name: 'products/getstockrequests',
path: `${WEB}/products/getstockrequests`,
params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 },
needs: 'tenant',
expect: ['requestid', 'productid', 'qty', 'status'],
},
{
name: 'products/getsaletemplate',
path: `${WEB}/products/getsaletemplate`,
params: { tenantid: probeTenant, locationid: probeLocation },
needs: 'tenant',
expect: ['tenantid', 'locations', 'products'],
},
{
name: 'customers/gettenantcustomers',
path: `${WEB}/customers/gettenantcustomers`,
params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 },
needs: 'tenant',
expect: ['customerid', 'firstname', 'contactno'],
},
{
name: 'orders/getorders',
path: `${WEB}/orders/tenant/getorders`,
params: {
tenantid: probeTenant,
locationid: probeLocation,
fromdate: isoDaysAgo(30),
todate: isoDaysAgo(0),
pageno: 1,
pagesize: 5,
},
needs: 'tenant',
expect: ['orderheaderid', 'orderstatus'],
},
{
name: 'deliveries/getdeliveries',
path: `${WEB}/deliveries/getdeliveries`,
params: {
tenantid: probeTenant,
locationid: probeLocation,
fromdate: isoDaysAgo(30),
todate: isoDaysAgo(0),
pageno: 1,
pagesize: 5,
},
needs: 'tenant',
expect: ['orderheaderid', 'orderstatus'],
},
{
name: 'pos/sales/summary',
path: `${POS}/sales/summary`,
params: { locationid: probeLocation, fromdate: isoDaysAgo(7), todate: isoDaysAgo(0) },
needs: 'location',
expect: ['billcount', 'grosssales', 'taxcollected'],
},
{
name: 'pos/sales',
path: `${POS}/sales`,
params: { locationid: probeLocation, pageno: 0, pagesize: 5 },
needs: 'location',
expect: ['bills', 'total'],
},
{
name: 'pos/health/location',
path: `${POS}/health/location`,
params: { location_id: probeLocation },
needs: 'location',
expect: ['total', 'online', 'terminals'],
},
{
name: 'tenants/getposusers',
path: `${WEB}/tenants/getposusers`,
params: { tenantid: probeTenant, locationid: probeLocation },
needs: 'location',
expect: ['users', 'location_id'],
},
{
name: 'tenants/getstaffs (MOB)',
path: `${MOB}/tenants/getstaffs`,
params: { tenantid: probeTenant },
needs: 'tenant',
expect: ['userid', 'rolename', 'firstname'],
},
{ name: 'tenants/posroles', path: `${WEB}/tenants/posroles`, expect: ['role_id', 'role'] },
{
name: 'tenants/getstaffshifts',
path: `${WEB}/tenants/getstaffshifts`,
params: { tenantid: probeTenant, locationid: probeLocation },
needs: 'location',
expect: ['shifts', 'location_id'],
},
];
function isoDaysAgo(days) {
const date = new Date();
date.setDate(date.getDate() - days);
return date.toISOString().slice(0, 10);
}
/* ── Run ─────────────────────────────────────────────────────────────────── */
let mismatches = 0;
let unreachable = 0;
for (const check of CHECKS) {
if (check.needs === 'tenant' && !probeTenant) {
console.log(`SKIP ${check.name} — no tenant to probe with`);
continue;
}
if (check.needs === 'location' && !probeLocation) {
console.log(`SKIP ${check.name} — no location to probe with`);
continue;
}
const { http, envelope } = await attempt(check.path, { params: check.params });
const payload = envelope.details ?? envelope.data;
const shape = Array.isArray(payload)
? `array(${payload.length})`
: payload === null || payload === undefined
? 'null'
: typeof payload;
// The row to inspect: the first element of a list, or the object itself.
const row = Array.isArray(payload) ? payload[0] : payload;
const keys = row && typeof row === 'object' ? Object.keys(row) : [];
const missing = check.expect.filter((key) => !keys.includes(key));
const ok = envelope.status !== false && http < 400 && missing.length === 0;
if (!ok) mismatches += 1;
if (http >= 400 || envelope.status === false) unreachable += 1;
console.log(
`${ok ? 'OK ' : 'CHECK'} ${check.name.padEnd(38)} http ${http} · code ${envelope.code ?? '—'} · ${shape}`,
);
if (envelope.status === false || http >= 400) {
console.log(` message: ${envelope.message ?? '(none)'}`);
}
if (missing.length > 0 && keys.length > 0) {
console.log(` MISSING: ${missing.join(', ')}`);
console.log(` got: ${keys.sort().join(', ')}`);
}
if (keys.length === 0 && shape !== 'null' && !Array.isArray(payload)) {
console.log(` payload: ${JSON.stringify(payload).slice(0, 160)}`);
}
}
console.log('');
console.log(`${CHECKS.length} checked · ${mismatches} to look at · ${unreachable} refused`);
console.log(
mismatches === 0
? 'Every endpoint answered in the shape the console expects.'
: 'Anything marked CHECK either refused the call or is missing a key the console reads.',
);