Files
tehran_taj/apps/api/scripts/test-name-trigger.cjs
T
s.esmaeiliandClaude Opus 5 8133a2bdf4 parity(api,admin): split customer name, add making-charge offset, wire SMS
Applies the two approved additive migrations and fixes every consumer.

D0 — Customer.firstName / lastName
The reference has had two columns since 2020
(2020_10_01_120430_customers_table.php:18-19); the port collapsed them, so
the edit form showed the whole name in the "first name" box and saved an
empty last name. Adds both columns, backfills first-token/rest, and keeps
fullName in sync with a customer_sync_full_name trigger. Nothing dropped.

The trigger is SQL, so Prisma cannot see it: writing the parts rebuilds
fullName, writing fullName re-derives the parts, and writing both in one
statement lets THE PARTS WIN. Documented in the migration header, the
Customer model doc comment and DECISIONS.md §D14, because a write can now
store something other than what was passed.

D9 — Customer.makingChargeOffsetPercent
Legacy customers.percent, the per-customer making-charge offset that the
app adds to the variant's making charge (henza_pwa
Mixins/product/index.ts:69-75). toRow() was returning the referring
AGENT's commission rate instead (CUST-02). DB default 0 reproduces
today's behaviour exactly, so no existing customer's price moves; the
reference's application-layer default of 3 for NEW customers
(AdminController.php:338) lives in the create form, and the field is
required on create with the reference's own message (:354).

update() and updateCredit() both accepted percent and silently discarded
it; both now persist it. Sort/filter aliases fname/lname pointed at
fullName and now point at the real columns.

SMS — Trez provider (Sms.php:57-82)
New global SmsModule/SmsService: form-urlencoded POST, 20s timeout, body
trimmed, every message persisted. send() never throws, matching the
reference, which discarded Sms::Send's result so a gateway outage never
aborted the order or approval that triggered it. The OTP text is
byte-identical, including the Android SMS Retriever markers. Credentials
come from env only; production refuses to boot without them rather than
silently locking every customer out. .env.example carries names, no values.

Admin panel: the split(' ') fallback is removed from singleCustomer.vue,
and bulkCustomer.vue defaults percent to 3, requires it, coerces the
Decimal on load so diff-send works, and only re-sends `name` when a name
part changed. ReagentCustomer.vue is untouched — its «درصد کمیسیون» is
AdminUser.wagePercent, a different field.

Tests: 32 jest (incl. 14 new SMS config/template), 9 trigger assertions,
53 auth smoke, and the full D12 round-trip on the customer entity —
«محمد علی» + «احمدی» survives create, edit, a byte-identical no-op save,
and single-field edits. Typecheck clean on api and admin-panel.

Note: migrate dev is interactive-only and could not run here, so the
migrations were applied with migrate deploy after a read-only drift check.
The only drift is the pre-existing _goldbank_migrations table, which
Prisma would DROP; it was NOT dropped (DEAD-07).

[ref: henza_server/app/Models/Sms.php, app/Http/Controllers/Adm/AdminController.php]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 08:08:50 +03:30

110 lines
4.2 KiB
JavaScript

/**
* Integration test for the `customer_sync_full_name` database trigger
* (migration 20260922120000_customer_name_parts).
*
* The trigger is SQL, not Prisma, so jest's unit suite cannot cover it — it
* needs a live database. Run it against a development DB:
*
* node scripts/test-name-trigger.cjs
*
* Exits non-zero on any failure. It creates ONE temporary Customer row and
* deletes it again; the deletion is reported on stdout (CHANGELOG rule 2).
*
* The three directions required by the owner's ruling of 2026-09-22:
* parts only -> fullName is rebuilt
* fullName only -> the parts are re-derived
* both at once -> THE PARTS WIN
*/
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
let failures = 0;
function check(condition, message) {
if (!condition) failures++;
console.log(`${condition ? ' PASS' : ' FAIL'} ${message}`);
}
const read = async (id) =>
(
await prisma.$queryRawUnsafe(
`SELECT "fullName","firstName","lastName","makingChargeOffsetPercent" AS pct
FROM "Customer" WHERE id=$1`,
id,
)
)[0];
(async () => {
const id = 'trigger-test-' + Date.now();
const unique = Date.now();
await prisma.$executeRawUnsafe(
`INSERT INTO "Customer"
(id,"fullName","firstName","lastName",mobile,"nationalCode","passwordHash","updatedAt")
VALUES ($1,'',$2,$3,$4,$5,'x',NOW())`,
id,
'محمد علی',
'احمدی',
'09' + String(unique).slice(-9),
'NC' + unique,
);
// The owner's worked example: a two-token FIRST name must survive intact.
console.log('\nCASE 0 — INSERT with parts only («محمد علی» + «احمدی»)');
let r = await read(id);
console.log(' ', JSON.stringify(r));
check(r.fullName === 'محمد علی احمدی', 'fullName rebuilt from the parts');
check(r.firstName === 'محمد علی', 'a two-token first name is NOT re-split');
check(r.lastName === 'احمدی', 'last name kept separate');
console.log('\nCASE 1 — UPDATE the parts only');
await prisma.$executeRawUnsafe(
`UPDATE "Customer" SET "firstName"='رضا',"lastName"='کریمی' WHERE id=$1`,
id,
);
r = await read(id);
console.log(' ', JSON.stringify(r));
check(r.fullName === 'رضا کریمی', 'fullName rebuilt from the parts');
console.log('\nCASE 2 — UPDATE fullName only');
await prisma.$executeRawUnsafe(`UPDATE "Customer" SET "fullName"='سعید موسوی' WHERE id=$1`, id);
r = await read(id);
console.log(' ', JSON.stringify(r));
check(r.firstName === 'سعید' && r.lastName === 'موسوی', 'parts re-derived from fullName');
console.log('\nCASE 3 — UPDATE BOTH in one statement (the parts must win)');
await prisma.$executeRawUnsafe(
`UPDATE "Customer" SET "fullName"='IGNORED',"firstName"='نرگس',"lastName"='رحیمی' WHERE id=$1`,
id,
);
r = await read(id);
console.log(' ', JSON.stringify(r));
check(r.fullName === 'نرگس رحیمی', 'parts win; the submitted fullName is discarded');
console.log('\nCASE 4 — a multi-token fullName splits first-token / rest');
await prisma.$executeRawUnsafe(`UPDATE "Customer" SET "fullName"='سید محمد حسینی' WHERE id=$1`, id);
r = await read(id);
console.log(' ', JSON.stringify(r));
check(r.firstName === 'سید' && r.lastName === 'محمد حسینی', 'first token / the rest');
console.log('\nCASE 5 — an unrelated update leaves the name untouched');
const before = await read(id);
await prisma.$executeRawUnsafe(`UPDATE "Customer" SET mobile=mobile WHERE id=$1`, id);
const after = await read(id);
check(JSON.stringify(before) === JSON.stringify(after), 'no-op update changes nothing');
console.log('\nCASE 6 — makingChargeOffsetPercent');
check(Number(after.pct) === 0, 'defaults to 0, so no existing price moves');
await prisma.$executeRawUnsafe(`DELETE FROM "Customer" WHERE id=$1`, id);
console.log(`\nCleanup: deleted 1 temporary Customer row (${id}).`);
console.log(failures === 0 ? '\nALL PASSED' : `\n${failures} FAILED`);
await prisma.$disconnect();
process.exit(failures ? 1 : 0);
})().catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});