Files
tehran_taj/apps/api/scripts/test-report.cjs
T
2026-10-07 20:20:27 +03:30

276 lines
14 KiB
JavaScript

/**
* "گزارش" — the account statement (D24).
*
* The reference proxied an external accounting system
* (henza_server Customer/TahesabController). The owner chose option الف: build
* the same UI and flow over the target's OWN ledger, with no external call.
*
* Covers what the owner asked for per page — opening, the empty state, the
* error state and the main flow — plus what makes a statement trustworthy:
*
* 1. empty state: a customer with no entries gets an empty result, not a 500
* 2. main flow: entries appear with the right in/out columns
* 3. the RUNNING BALANCE is correct, and its last value agrees with the
* cached Customer.goldDebtGrams / fiatDebtRial the rest of the app shows
* 4. UNCONFIRMED entries are excluded, exactly as they are from the cached
* balance
* 5. the detail list and the single-document read
* 6. SEC-16: another customer's document id returns NOTHING. The reference's
* detail() took the factor code from the URL with no ownership check at
* all, so any signed-in customer could read anyone's document.
* 7. no token is a 401 on every route
* 8. balances endpoint agrees with the customer row
*
* Every Persian string is a literal here and travels as UTF-8 JSON (D15).
*
* Requires the API running WITH OTP_MASTER_CODE set.
* Usage: node scripts/test-report.cjs [apiBaseUrl] [masterCode]
*/
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
const API = (process.argv[2] || 'http://localhost:3000/api').replace(/\/$/, '');
const MASTER = process.argv[3] || '1379';
let pass = 0, fail = 0;
const failures = [];
const chk = (c, m) => {
if (c) { pass++; console.log(' PASS ' + m); }
else { fail++; failures.push(m); console.log(' FAIL ' + m); }
};
const step = (t) => console.log('\n' + t);
const call = async (method, p, body, token) => {
const res = await fetch(`${API}/${p}`, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: 'Bearer ' + token } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
let payload = null;
try { payload = JSON.parse(text); } catch { /* empty */ }
return { status: res.status, ok: res.ok, body: payload, raw: text };
};
const signIn = (mobile) =>
call('POST', 'customer/auth/verify-otp', { username: mobile, password: MASTER });
const made = { customers: [], entries: [] };
const uniq = String(Date.now()).slice(-7);
(async () => {
step('SETUP — two customers; one with a ledger, one with none');
const mkCustomer = async (suffix, name) => {
const c = await prisma.customer.create({
data: {
fullName: name,
firstName: name.split(' ')[0],
lastName: name.split(' ').slice(1).join(' '),
mobile: `0919${uniq.slice(0, 3)}${suffix}`,
nationalCode: `nc${uniq}${suffix}`,
passwordHash: '!OTP_ONLY_NO_PASSWORD_LOGIN', kind: 'APP_SHOPKEEPER', isActive: true,
goldCreditLimitGrams: '500',
},
});
made.customers.push(c.id);
return c;
};
const owner = await mkCustomer('11', 'مشتری گزارش');
const other = await mkCustomer('22', 'مشتری دیگر');
const empty = await mkCustomer('33', 'مشتری بدون سند');
console.log(` owner: ${owner.fullName} · other: ${other.fullName} · empty: ${empty.fullName}`);
// ── 7. NO TOKEN ─────────────────────────────────────────────────────────
step('7 — every route requires a session');
for (const path of ['customer/me/report', 'customer/me/report/detail', 'customer/me/report/balances']) {
const r = await call('GET', path);
chk(r.status === 401, `GET ${path} with no token -> ${r.status} (expected 401)`);
}
// ── 1. EMPTY STATE ──────────────────────────────────────────────────────
step('1 — EMPTY STATE: a customer with no ledger entries');
await call('POST', 'customer/auth/request-otp', { username: empty.mobile });
const emptyLogin = await signIn(empty.mobile);
chk(emptyLogin.ok, `the empty customer signs in -> ${emptyLogin.status}`);
const emptyToken = emptyLogin.body?.token;
const emptyReport = await call('GET', 'customer/me/report', undefined, emptyToken);
chk(emptyReport.ok, `GET report -> ${emptyReport.status}`);
chk(Array.isArray(emptyReport.body?.result) && emptyReport.body.result.length === 0,
'the statement is an empty list, not an error');
chk(emptyReport.body?.count === 0, `count is 0 (got ${emptyReport.body?.count})`);
const emptyDetail = await call('GET', 'customer/me/report/detail', undefined, emptyToken);
chk(emptyDetail.ok && emptyDetail.body?.result?.length === 0,
`the detail tab is empty too -> ${emptyDetail.status}`);
// ── BUILD A LEDGER ──────────────────────────────────────────────────────
step('SETUP — a ledger for the owner: gold out, gold in, rial in, plus one UNCONFIRMED');
const mkEntry = async (data) => {
const e = await prisma.ledgerEntry.create({ data: { customerId: owner.id, ...data } });
made.entries.push(e.id);
return e;
};
// +12.5 gold owed
await mkEntry({
currency: 'GOLD_GRAM', direction: 'DEBIT', sourceType: 'ORDER_STOCK',
amount: '12.5', confirmed: true, confirmedAt: new Date(),
note: 'سفارش اول',
});
// -4.25 gold (a melted-gold repayment, with the document metadata)
await mkEntry({
currency: 'GOLD_GRAM', direction: 'CREDIT', sourceType: 'REPAYMENT_MELTED_GOLD',
amount: '4.25', confirmed: true, confirmedAt: new Date(),
karatPerMille: 750, originalValue: '4.3', labName: 'آزمایشگاه مرکزی', hallmark: 'A-1001',
note: 'طلای آبشده',
});
// +1,000,000 rial owed
await mkEntry({
currency: 'RIAL', direction: 'DEBIT', sourceType: 'ADJUSTMENT',
amount: '1000000', confirmed: true, confirmedAt: new Date(),
note: 'اصلاحیه ریالی',
});
// An UNCONFIRMED entry that must NOT appear anywhere.
const pending = await mkEntry({
currency: 'GOLD_GRAM', direction: 'DEBIT', sourceType: 'ORDER_STOCK',
amount: '99.999', confirmed: false,
note: 'سند تاییدنشده',
});
// The cached balances the rest of the app shows.
await prisma.customer.update({
where: { id: owner.id },
data: { goldDebtGrams: '8.25', fiatDebtRial: '1000000' },
});
// One document for the OTHER customer, for the cross-read test.
const foreign = await prisma.ledgerEntry.create({
data: {
customerId: other.id, currency: 'GOLD_GRAM', direction: 'DEBIT',
sourceType: 'ORDER_STOCK', amount: '7', confirmed: true, confirmedAt: new Date(),
note: 'سند مشتری دیگر',
},
});
made.entries.push(foreign.id);
// ── 2/3/4. MAIN FLOW ────────────────────────────────────────────────────
step('2 — MAIN FLOW: the statement');
await call('POST', 'customer/auth/request-otp', { username: owner.mobile });
const login = await signIn(owner.mobile);
chk(login.ok, `the owner signs in -> ${login.status}`);
const token = login.body?.token;
const rep = await call('GET', 'customer/me/report', undefined, token);
chk(rep.ok, `GET report -> ${rep.status}`);
const rows = rep.body?.result ?? [];
chk(rows.length === 3, `three CONFIRMED entries came back (got ${rows.length})`);
// Newest first, as the reference displayed it.
chk(rows[0]?.note === 'اصلاحیه ریالی', `newest first: top row is "${rows[0]?.note}"`);
const gold = rows.find((r) => r.note === 'سفارش اول');
chk(gold?.geram_kh === '12.500', `a gold DEBIT lands in geram_kh (${gold?.geram_kh})`);
chk(gold?.geram_v === '0.000', 'and not in geram_v');
const back = rows.find((r) => r.note === 'طلای آبشده');
chk(back?.geram_v === '4.250', `a gold CREDIT lands in geram_v (${back?.geram_v})`);
const rialRow = rows.find((r) => r.note === 'اصلاحیه ریالی');
chk(rialRow?.rial_kh === '1000000.00', `a rial DEBIT lands in rial_kh (${rialRow?.rial_kh})`);
step('4 — UNCONFIRMED entries are excluded');
chk(!rows.some((r) => r.note === 'سند تاییدنشده'),
'the unconfirmed 99.999g entry does NOT appear in the statement');
step('3 — the RUNNING BALANCE');
// Oldest first: +12.5, then -4.25 => 8.25. Rows come back newest-first.
chk(gold?.tahesab_T === '12.500', `after the first entry the gold balance is 12.500 (got ${gold?.tahesab_T})`);
chk(back?.tahesab_T === '8.250', `after the repayment it is 8.250 (got ${back?.tahesab_T})`);
const cached = await prisma.customer.findUnique({ where: { id: owner.id } });
chk(String(Number(back.tahesab_T)) === String(Number(cached.goldDebtGrams)),
`and it agrees with the cached Customer.goldDebtGrams (${cached.goldDebtGrams})`);
chk(rialRow?.tahesab_P === '1000000.00',
`the rial balance is 1000000.00 (got ${rialRow?.tahesab_P})`);
chk(String(Number(rialRow.tahesab_P)) === String(Number(cached.fiatDebtRial)),
`and it agrees with the cached Customer.fiatDebtRial (${cached.fiatDebtRial})`);
step('8 — the balances endpoint agrees with the customer row');
const bal = await call('GET', 'customer/me/report/balances', undefined, token);
chk(bal.ok, `GET balances -> ${bal.status}`);
const goldBal = bal.body?.result?.find((r) => r.arz === 'gold');
const rialBal = bal.body?.result?.find((r) => r.arz === 'IRR');
chk(goldBal?.mandeh === '8.250', `gold balance ${goldBal?.mandeh}`);
chk(rialBal?.mandeh === '1000000.00', `rial balance ${rialBal?.mandeh}`);
// ── 5. DETAIL ───────────────────────────────────────────────────────────
step('5 — the detail tab and one document');
const det = await call('GET', 'customer/me/report/detail', undefined, token);
chk(det.ok, `GET detail -> ${det.status}`);
chk(det.body?.result?.length === 3, `three documents (got ${det.body?.result?.length})`);
chk(!det.body?.result?.some((d) => d.note === 'سند تاییدنشده'),
'the unconfirmed entry is excluded here too');
const melted = det.body?.result?.find((d) => d.note === 'طلای آبشده');
chk(melted?.ayar === 750, `the melted-gold document carries its عیار (${melted?.ayar})`);
chk(melted?.name_az === 'آزمایشگاه مرکزی', `and its lab name (${melted?.name_az})`);
chk(melted?.sh_sharti === 'A-1001', `and its hallmark (${melted?.sh_sharti})`);
chk(melted?.vazn === '4.300',
`وزن خالص is the PRE-normalization weight, as a paper document states (${melted?.vazn})`);
chk(melted?.nO_kar === null && melted?.jens_felez === null,
'fields the target cannot know are null, never invented (REP-01)');
const one = await call('GET', `customer/me/report/${back.factor_Code}`, undefined, token);
chk(one.ok, `GET one document -> ${one.status}`);
chk(one.body?.result?.length === 1, `exactly one row came back (got ${one.body?.result?.length})`);
chk(one.body?.result?.[0]?.factor_Code === back.factor_Code, 'and it is the one asked for');
// ── 6. SEC-16 ───────────────────────────────────────────────────────────
step("6 — SEC-16: another customer's document is NOT readable");
const stolen = await call('GET', `customer/me/report/${foreign.id}`, undefined, token);
chk(stolen.ok, `the request itself succeeds -> ${stolen.status}`);
chk(stolen.body?.result?.length === 0,
"but it returns NOTHING — the id is only a filter on top of the token's scope");
// And the reverse direction, so the scope is not accidentally one-way.
await call('POST', 'customer/auth/request-otp', { username: other.mobile });
const otherLogin = await signIn(other.mobile);
const otherToken = otherLogin.body?.token;
const reverse = await call('GET', `customer/me/report/${back.factor_Code}`, undefined, otherToken);
chk(reverse.body?.result?.length === 0,
"and the other customer cannot read the owner's document either");
const otherRep = await call('GET', 'customer/me/report', undefined, otherToken);
chk(otherRep.body?.result?.length === 1,
`the other customer sees only their OWN one entry (got ${otherRep.body?.result?.length})`);
// ── CLEANUP ─────────────────────────────────────────────────────────────
step('CLEANUP');
await prisma.ledgerEntry.deleteMany({ where: { id: { in: made.entries } } });
await prisma.customerOtp.deleteMany({ where: { customerId: { in: made.customers } } });
await prisma.customer.deleteMany({ where: { id: { in: made.customers } } });
console.log(
` deleted ${made.entries.length} ledger entr(ies) and ${made.customers.length} customer(s). ` +
'All were created by this run; no pre-existing data was touched.',
);
void pending;
console.log(`\n${'='.repeat(60)}`);
console.log(`REPORT: passed ${pass} failed ${fail}`);
if (fail) { console.log('\nFailures:'); failures.forEach((f) => console.log(' - ' + f)); }
console.log('='.repeat(60));
await prisma.$disconnect();
process.exit(fail ? 1 : 0);
})().catch(async (e) => {
console.error('\nABORTED:', e);
await prisma.$disconnect();
process.exit(1);
});