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

309 lines
15 KiB
JavaScript

/**
* "تکمیل پروفایل" — POST customer/me/complete-profile and its uploads.
*
* Ports henza_server Major/CustomerController::complete
* (app/Http/Controllers/Major/CustomerController.php:73-108).
*
* Covers what the owner asked for per page — opening, the empty state, the
* error state and the main flow — plus the boundaries that matter here:
*
* 1. the main flow: three uploads, then complete, then VERIFIED
* 2. empty/validation: each id missing in turn is a 400/422
* 3. error: an id that names no file is refused, and nothing is written
* 4. the customer comes from the TOKEN — no id is accepted from the body
* 5. no token at all is a 401 on both routes
* 6. an expert can complete their own profile too (D21: every customer
* route works for an expert unchanged)
* 7. SEC-14: extra body fields are IGNORED, not mass-assigned. The
* reference did `$customer->update($request->all())`, which let the app
* write gram_level/percent/active onto itself.
*
* 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-complete-profile.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 };
};
/** A one-pixel PNG, so the upload exercises the real MIME whitelist. */
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
const upload = async (token, purpose) => {
const form = new FormData();
form.append('file', new Blob([PNG], { type: 'image/png' }), `${purpose}.png`);
form.append('purpose', purpose);
const res = await fetch(`${API}/customer/me/documents`, {
method: 'POST',
headers: token ? { Authorization: 'Bearer ' + token } : {},
body: form,
});
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 });
/** Everything this run creates, removed at the end. */
const made = { admins: [], customers: [], files: [] };
const uniq = String(Date.now()).slice(-7);
(async () => {
step('SETUP — an unverified customer, and a sales expert');
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: `0917${uniq.slice(0, 3)}${suffix}`,
nationalCode: `nc${uniq}${suffix}`,
passwordHash: '!OTP_ONLY_NO_PASSWORD_LOGIN', kind: 'APP_SHOPKEEPER', isActive: true,
goldCreditLimitGrams: '100',
verificationStep: 'UNVERIFIED',
},
});
made.customers.push(c.id);
return c;
};
const cust = await mkCustomer('11', 'مشتری مدارک');
console.log(` customer: ${cust.fullName} (${cust.mobile}) step=${cust.verificationStep}`);
const expertAdmin = await prisma.adminUser.create({
data: {
fullName: 'کارشناس مدارک', firstName: 'کارشناس', lastName: 'مدارک',
mobile: `0918${uniq.slice(0, 3)}22`,
passwordHash: '!NO_PASSWORD_SET_YET', role: 'SALES_EXPERT', isActive: true, wagePercent: '3',
},
});
await prisma.salesExpertProfile.create({
data: { adminUserId: expertAdmin.id, gramLevel: '120' },
});
made.admins.push(expertAdmin.id);
// ── 5. NO TOKEN ─────────────────────────────────────────────────────────
step('5 — both routes require a session');
const anonComplete = await call('POST', 'customer/me/complete-profile', {
business_license_image_id: 'x', shop_image_id: 'x', ncode_image_id: 'x',
});
chk(anonComplete.status === 401, `complete-profile with no token -> ${anonComplete.status} (expected 401)`);
const anonUpload = await upload(null, 'business-license');
chk(anonUpload.status === 401, `documents with no token -> ${anonUpload.status} (expected 401)`);
// ── SIGN IN ─────────────────────────────────────────────────────────────
step('SIGN IN — the customer, through the ordinary app flow');
await call('POST', 'customer/auth/request-otp', { username: cust.mobile });
const login = await signIn(cust.mobile);
chk(login.ok, `verify-otp -> ${login.status}`);
const token = login.body?.token;
chk(Boolean(token), 'an app token came back');
// The page opens against /customer/me, which is what the router guard reads.
const me = await call('GET', 'customer/me', undefined, token);
chk(me.ok, `GET customer/me -> ${me.status}`);
chk(me.body?.verificationStep === 'UNVERIFIED',
`the page is reachable: verificationStep is ${me.body?.verificationStep}`);
// ── 2. VALIDATION — the empty state ─────────────────────────────────────
step('2 — EMPTY STATE: each document is required');
const ids = { business_license_image_id: 'a', shop_image_id: 'b', ncode_image_id: 'c' };
for (const key of Object.keys(ids)) {
const partial = { ...ids };
delete partial[key];
const r = await call('POST', 'customer/me/complete-profile', partial, token);
chk(r.status === 400 || r.status === 422, `missing ${key} -> ${r.status} (expected 400/422)`);
}
const allEmpty = await call('POST', 'customer/me/complete-profile', {}, token);
chk(allEmpty.status === 400 || allEmpty.status === 422,
`an empty body -> ${allEmpty.status} (expected 400/422)`);
const stillUnverified = await prisma.customer.findUnique({ where: { id: cust.id } });
chk(stillUnverified.verificationStep === 'UNVERIFIED',
'a rejected submission left the verification step untouched');
// ── 3. ERROR STATE — an id that names no file ───────────────────────────
step('3 — ERROR STATE: an unknown document id is refused');
const bogus = await call('POST', 'customer/me/complete-profile', {
business_license_image_id: 'no-such-file-id',
shop_image_id: 'no-such-file-id',
ncode_image_id: 'no-such-file-id',
}, token);
chk(bogus.status === 400, `unknown ids -> ${bogus.status} (expected 400)`);
const afterBogus = await prisma.customer.findUnique({ where: { id: cust.id } });
chk(afterBogus.verificationStep === 'UNVERIFIED',
'and nothing was written: still UNVERIFIED');
chk(afterBogus.businessLicenseUrl === null,
'and no document URL was stored');
// ── 1. MAIN FLOW ────────────────────────────────────────────────────────
step('1 — MAIN FLOW: upload three documents, then complete');
const licence = await upload(token, 'business-license');
chk(licence.ok, `upload the business licence -> ${licence.status}`);
const shop = await upload(token, 'shop-image');
chk(shop.ok, `upload the shop front -> ${shop.status}`);
const ncode = await upload(token, 'national-card');
chk(ncode.ok, `upload the national card -> ${ncode.status}`);
for (const u of [licence, shop, ncode]) if (u.body?.id) made.files.push(u.body.id);
chk(Boolean(licence.body?.id && licence.body?.url),
'each upload answers with an id and a stored url');
chk(licence.body?.uploadedByAdminUserId === null,
'a customer upload is not attributed to an admin user');
const done = await call('POST', 'customer/me/complete-profile', {
business_license_image_id: licence.body.id,
shop_image_id: shop.body.id,
ncode_image_id: ncode.body.id,
}, token);
chk(done.ok, `complete-profile -> ${done.status}`);
const after = await prisma.customer.findUnique({ where: { id: cust.id } });
chk(after.verificationStep === 'VERIFIED',
`the step moved to the reference's live value (VERIFIED), got ${after.verificationStep}`);
chk(after.businessLicenseUrl === licence.body.url, 'the business licence url was stored');
chk(after.shopImageUrl === shop.body.url, 'the shop front url was stored');
chk(after.nationalIdImageUrl === ncode.body.url, 'the national card url was stored');
// The router guard must now keep the page shut.
const meAfter = await call('GET', 'customer/me', undefined, token);
chk(meAfter.body?.verificationStep === 'VERIFIED',
'the app now reads VERIFIED, so the guard redirects the page home');
// ── 7. SEC-14 — extra fields are ignored, not mass-assigned ─────────────
step('7 — SEC-14: extra body fields are IGNORED, not written to the row');
const before = await prisma.customer.findUnique({ where: { id: cust.id } });
const inject = await call('POST', 'customer/me/complete-profile', {
business_license_image_id: licence.body.id,
shop_image_id: shop.body.id,
ncode_image_id: ncode.body.id,
// Every one of these was writable through the reference's $request->all().
goldCreditLimitGrams: '999999',
gram_level: '999999',
goldDebtGrams: '-500',
makingChargeOffsetPercent: '0',
isActive: false,
mobile: '09000000000',
}, token);
chk(inject.status === 400 || inject.ok,
`a body with extra fields -> ${inject.status}`);
const poked = await prisma.customer.findUnique({ where: { id: cust.id } });
chk(String(poked.goldCreditLimitGrams) === String(before.goldCreditLimitGrams),
`the credit limit is unchanged (${poked.goldCreditLimitGrams})`);
chk(String(poked.goldDebtGrams) === String(before.goldDebtGrams),
`the gold debt is unchanged (${poked.goldDebtGrams})`);
chk(String(poked.makingChargeOffsetPercent) === String(before.makingChargeOffsetPercent),
`the making charge is unchanged (${poked.makingChargeOffsetPercent})`);
chk(poked.isActive === before.isActive, 'isActive is unchanged');
chk(poked.mobile === before.mobile, `the mobile is unchanged (${poked.mobile})`);
// ── 4. THE SUBJECT COMES FROM THE TOKEN ─────────────────────────────────
step('4 — the customer comes from the TOKEN, never from the body');
const victim = await mkCustomer('44', 'مشتری دیگر');
const victimBefore = await prisma.customer.findUnique({ where: { id: victim.id } });
chk(victimBefore.verificationStep === 'UNVERIFIED', 'the second customer starts UNVERIFIED');
await call('POST', 'customer/me/complete-profile', {
business_license_image_id: licence.body.id,
shop_image_id: shop.body.id,
ncode_image_id: ncode.body.id,
// Both spellings, in case either were read.
customerId: victim.id,
customer_id: victim.id,
id: victim.id,
}, token);
const victimAfter = await prisma.customer.findUnique({ where: { id: victim.id } });
chk(victimAfter.verificationStep === 'UNVERIFIED',
"naming another customer in the body did NOT touch their row");
chk(victimAfter.businessLicenseUrl === null,
'and stored no document against them');
// ── 6. AN EXPERT CAN COMPLETE THEIR OWN PROFILE ─────────────────────────
step('6 — D21: the same page works for a sales expert');
await call('POST', 'customer/auth/request-otp', { username: expertAdmin.mobile });
const expLogin = await signIn(expertAdmin.mobile);
chk(expLogin.ok, `the expert signs in -> ${expLogin.status}`);
const expToken = expLogin.body?.token;
const prof = await prisma.salesExpertProfile.findUnique({ where: { adminUserId: expertAdmin.id } });
if (prof?.tradingCustomerId) made.customers.push(prof.tradingCustomerId);
const expUpload = await upload(expToken, 'business-license');
chk(expUpload.ok, `the expert uploads a document -> ${expUpload.status}`);
if (expUpload.body?.id) made.files.push(expUpload.body.id);
const expDone = await call('POST', 'customer/me/complete-profile', {
business_license_image_id: expUpload.body.id,
shop_image_id: expUpload.body.id,
ncode_image_id: expUpload.body.id,
}, expToken);
chk(expDone.ok, `the expert completes their profile -> ${expDone.status}`);
const expTrading = prof?.tradingCustomerId
? await prisma.customer.findUnique({ where: { id: prof.tradingCustomerId } })
: null;
chk(expTrading?.verificationStep === 'VERIFIED',
'it landed on the expert\'s own trading account, not on anyone else');
// ── CLEANUP ─────────────────────────────────────────────────────────────
step('CLEANUP');
await prisma.uploadedFile.deleteMany({ where: { id: { in: made.files } } });
await prisma.customerOtp.deleteMany({ where: { customerId: { in: made.customers } } });
await prisma.salesExpertProfile.deleteMany({ where: { adminUserId: { in: made.admins } } });
await prisma.customer.deleteMany({ where: { id: { in: made.customers } } });
await prisma.adminUser.deleteMany({ where: { id: { in: made.admins } } });
console.log(
` deleted ${made.files.length} uploaded-file row(s), ${made.customers.length} customer(s) ` +
`(including the expert trading account this run created) and ${made.admins.length} admin user(s). ` +
'All were created by this run; no pre-existing data was touched. ' +
'The uploaded blobs themselves are left on disk, as every other test does.',
);
console.log(`\n${'='.repeat(60)}`);
console.log(`COMPLETE PROFILE: 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);
});