Files
tehran_taj/apps/api/scripts/test-sales-expert-roundtrip.cjs
T
s.esmaeiliandClaude Opus 5 53436ec855 chore(release): prepare release/delivery + fix docker env passthrough
DEPLOY-01: docker-compose.yml passed only DATABASE_URL and PORT to the api
service, so JWT_SECRET, JWT_CUSTOMER_SECRET, OTP_MASTER_CODE and every SMS_TREZ_*
variable were silently dropped in Docker. A deployed build would have used the
development JWT fallback and would not have had the delivery master code at all,
so the demo login would have failed with no obvious cause. All are now forwarded,
defaulting to empty (which the code treats as unset); verified with
`docker compose config`.

TEST-01: test-sales-expert-roundtrip.cjs had localhost:3077 hardcoded and could
only connect by accident. Now defaults to the repo's API_PORT. 33/33.

DELIVERY.md documents env vars (names only), run/deploy commands, how migrations
run inside the container, the verification results and the known issues.

release/delivery contains both parity branches with no conflicts (fast-forward).
build 5/5, jest 45/45, admin smoke 43/43, apps smoke 43/43, B2B scan 34/34,
B2B writes 25/25, auth-smoke 32/32, typecheck clean.

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

173 lines
9.1 KiB
JavaScript

/**
* D12 rule E0 round-trip test for the SALES EXPERT entity, plus the two grid
* filters D17 required.
*
* All Persian input is written here as literals and sent as UTF-8 JSON, never
* through a shell argument (D15).
*/
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
// Port 3000 is API_PORT in the repo-root .env; overridable for another setup.
// (Was hardcoded to 3077, a port from an earlier dev session, so the script
// could only ever connect by accident.)
const API = (process.argv[2] || 'http://localhost:3000/api').replace(/\/$/, '');
let pass = 0, fail = 0;
const chk = (c, m) => { c ? pass++ : fail++; console.log((c ? ' PASS ' : ' FAIL ') + m); };
const FIRST = 'محمد علی';
const LAST = 'احمدی';
async function login() {
const r = await fetch(API + '/admin/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: '09128081392', password: '09128081392' }),
});
return (await r.json()).api_token;
}
const snap = async (id) => {
const u = await prisma.adminUser.findUnique({
where: { id }, include: { salesExpertProfile: true },
});
const { updatedAt, ...profile } = u.salesExpertProfile ?? {};
return JSON.stringify({
fullName: u.fullName, firstName: u.firstName, lastName: u.lastName,
mobile: u.mobile, role: u.role, wagePercent: String(u.wagePercent),
address: u.address, storeName: u.storeName,
profile: { ...profile, gramLevel: String(profile.gramLevel) },
});
};
(async () => {
const token = await login();
const H = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token };
const city = await prisma.city.findFirst({ select: { id: true, name: true } });
const city2 = await prisma.city.findFirst({ where: { id: { not: city.id } }, select: { id: true, name: true } });
const mobile = '0912' + String(Date.now()).slice(-7);
console.log('STEP 1 — create a sales expert with EVERY field populated');
const createBody = {
fname: FIRST, lname: LAST, mobile, password: 'Test12345',
role: 'SALES_EXPERT', percent: 4.5,
city_id: city.id, gram_level: 250.5, phone: '02188887777', ncode: '1234567890',
address: 'تهران، خیابان ولیعصر', store_name: 'طلای محمد', pasazh_name: 'پاساژ بزرگ', mcode: 'SE-001',
};
const cr = await fetch(API + '/admin/users', { method: 'POST', headers: H, body: JSON.stringify(createBody) });
const created = await cr.json();
if (!created.id) { console.log(' create failed:', JSON.stringify(created).slice(0, 300)); process.exit(1); }
const id = created.id;
console.log(' id:', id);
const row = await prisma.adminUser.findUnique({ where: { id }, include: { salesExpertProfile: true } });
chk(row.firstName === FIRST, `firstName stored as «${FIRST}» — a two-token first name is NOT re-split`);
chk(row.lastName === LAST, `lastName stored as «${LAST}»`);
chk(row.fullName === `${FIRST} ${LAST}`, 'fullName rebuilt by the trigger');
chk(!!row.salesExpertProfile, 'SalesExpertProfile auto-created on create');
chk(row.salesExpertProfile?.cityId === city.id, 'profile.cityId stored');
chk(String(row.salesExpertProfile?.gramLevel) === '250.5', 'profile.gramLevel stored (250.5)');
chk(row.salesExpertProfile?.phone === '02188887777', 'profile.phone stored');
chk(row.salesExpertProfile?.nationalCode === '1234567890', 'profile.nationalCode stored');
console.log('\nSTEP 2 — open the edit form: every field populated, separately');
const g = await (await fetch(API + `/admin/users/${id}`, { headers: H })).json();
chk(g.fname === FIRST, `fname === «${FIRST}» (NOT the whole name)`);
chk(g.lname === LAST, `lname === «${LAST}» (NOT empty)`);
chk(Number(g.percent) === 4.5, 'percent === 4.5');
const listed = await (await fetch(API + '/admin/customers-omde/reagent/list', {
method: 'POST', headers: H, body: JSON.stringify({ page: 1, limit: 200 }),
})).json();
const mine = (listed.rows ?? []).find((x) => x.id === id);
chk(!!mine, 'the new expert appears in the reagent grid');
chk(mine?.fname === FIRST && mine?.lname === LAST, 'grid shows the real name parts');
chk(mine?.city_id === city.id, 'grid returns a REAL city_id (was hardcoded null)');
chk(mine?.city?.title === city.name, 'grid returns the city object for the column');
chk(String(mine?.gram_level) === '250.5', 'grid returns a REAL gram_level (was hardcoded null)');
chk(mine?.phone === '02188887777', 'grid returns phone');
chk(mine?.ncode === '1234567890', 'grid returns ncode');
console.log('\nSTEP 3 — save with NO changes: the row must be byte-identical');
const before = await snap(id);
await fetch(API + `/admin/users/${id}`, {
method: 'PUT', headers: H,
body: JSON.stringify({
fname: g.fname, lname: g.lname, mobile: g.mobile, role: 'SALES_EXPERT',
percent: Number(g.percent), city_id: mine.city_id, gram_level: Number(mine.gram_level),
phone: mine.phone, ncode: mine.ncode, address: g.address,
store_name: g.store_name, pasazh_name: g.pasazh_name, mcode: g.mcode,
}),
});
const after = await snap(id);
chk(before === after, 'no-op save changed nothing');
if (before !== after) console.log(' before: ' + before + '\n after : ' + after);
console.log('\nSTEP 4a — change ONLY lastName');
await fetch(API + `/admin/users/${id}`, {
method: 'PUT', headers: H, body: JSON.stringify({ lname: 'محمدی' }),
});
const s4 = JSON.parse(await snap(id));
const b4 = JSON.parse(after);
chk(s4.lastName === 'محمدی' && s4.firstName === FIRST, `only lastName changed; firstName still «${FIRST}»`);
chk(s4.fullName === `${FIRST} محمدی`, 'fullName rebuilt by the trigger');
chk(JSON.stringify(s4.profile) === JSON.stringify(b4.profile), 'the profile was untouched');
console.log('\nSTEP 4b — change ONLY gramLevel');
const b5 = JSON.parse(await snap(id));
await fetch(API + `/admin/users/${id}`, {
method: 'PUT', headers: H, body: JSON.stringify({ gram_level: 999.25 }),
});
const s5 = JSON.parse(await snap(id));
const diff = Object.keys(s5.profile).filter((k) => String(s5.profile[k]) !== String(b5.profile[k]));
chk(diff.length === 1 && diff[0] === 'gramLevel', 'only gramLevel changed: ' + diff.join(','));
chk(s5.firstName === b5.firstName && s5.lastName === b5.lastName, 'the name was untouched');
console.log('\nSTEP 5 — the two grid FILTERS');
const byCity = await (await fetch(API + '/admin/customers-omde/reagent/list', {
method: 'POST', headers: H,
body: JSON.stringify({ page: 1, limit: 200, filters: [{ field: 'city_id', op: '=', value: city.id }] }),
})).json();
chk((byCity.rows ?? []).some((x) => x.id === id), `city filter (${city.name}) returns the expert`);
chk((byCity.rows ?? []).every((x) => x.city_id === city.id), 'city filter returns ONLY that city');
if (city2) {
const otherCity = await (await fetch(API + '/admin/customers-omde/reagent/list', {
method: 'POST', headers: H,
body: JSON.stringify({ page: 1, limit: 200, filters: [{ field: 'city_id', op: '=', value: city2.id }] }),
})).json();
chk(!(otherCity.rows ?? []).some((x) => x.id === id), `filtering by a DIFFERENT city (${city2.name}) excludes it`);
}
const byGram = await (await fetch(API + '/admin/customers-omde/reagent/list', {
method: 'POST', headers: H,
body: JSON.stringify({ page: 1, limit: 200, filters: [{ field: 'gram_level', op: '=', value: 999.25 }] }),
})).json();
chk((byGram.rows ?? []).some((x) => x.id === id), 'gram_level filter returns the expert');
chk((byGram.rows ?? []).every((x) => String(x.gram_level) === '999.25'), 'gram_level filter returns ONLY matches');
console.log('\nSTEP 6 — an expert with NO profile still lists (left join)');
const orphan = await prisma.adminUser.create({
data: { fullName: 'بدون پروفایل', firstName: 'بدون', lastName: 'پروفایل',
mobile: '0913' + String(Date.now()).slice(-7), passwordHash: 'x', role: 'SALES_EXPERT' },
});
const l2 = await (await fetch(API + '/admin/customers-omde/reagent/list', {
method: 'POST', headers: H, body: JSON.stringify({ page: 1, limit: 200 }),
})).json();
const o = (l2.rows ?? []).find((x) => x.id === orphan.id);
chk(!!o, 'an expert without a profile still appears in the grid');
chk(o?.city_id === null && o?.gram_level === null, 'its city/gram cells are null, not an error');
console.log('\nSTEP 7 — the referrer dropdown lists every active expert');
const opts = await (await fetch(API + '/admin/customers-omde/query/reagent', { headers: H })).json();
chk(opts.some((x) => x.id === id), 'the new expert is selectable as a referrer');
chk(opts.some((x) => x.id === orphan.id), 'the profile-less expert is selectable too');
await prisma.salesExpertProfile.deleteMany({ where: { adminUserId: { in: [id, orphan.id] } } });
await prisma.adminUser.deleteMany({ where: { id: { in: [id, orphan.id] } } });
console.log(`\nCleanup: deleted 2 test AdminUser rows (${id}, ${orphan.id}) and their profiles.`);
console.log(`\npassed: ${pass} failed: ${fail}`);
await prisma.$disconnect();
process.exit(fail ? 1 : 0);
})().catch(async (e) => { console.error(e); await prisma.$disconnect(); process.exit(1); });