QA: unit tests for API logic, Playwright smoke tests, pre-push check, manual QA checklist
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# فعالسازی: npm install (اسکریپت prepare) یا git config core.hooksPath .githooks
|
||||||
|
npm run check
|
||||||
@@ -22,6 +22,12 @@ docs/~$*
|
|||||||
apps/stt/.venv/
|
apps/stt/.venv/
|
||||||
crm-node_modules.tgz
|
crm-node_modules.tgz
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
e2e/.auth/
|
||||||
|
e2e/.env
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|
||||||
# Graphify (local architecture graph — do not commit)
|
# Graphify (local architecture graph — do not commit)
|
||||||
graphify-out/
|
graphify-out/
|
||||||
**/graphify-out/
|
**/graphify-out/
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:seed": "tsx prisma/seed.ts",
|
"prisma:seed": "tsx prisma/seed.ts",
|
||||||
"lint": "echo \"api lint placeholder\""
|
"lint": "echo \"api lint placeholder\"",
|
||||||
|
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json",
|
||||||
|
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/message-automation.test.ts test/record-scope.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mento/shared": "*",
|
"@mento/shared": "*",
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
addJalaliMonths,
|
||||||
|
endOfJalaliMonth,
|
||||||
|
jalaliMonthName,
|
||||||
|
toGregorian,
|
||||||
|
toJalali,
|
||||||
|
} from '../src/common/jalali';
|
||||||
|
import {
|
||||||
|
addDays,
|
||||||
|
dayDiff,
|
||||||
|
eachDay,
|
||||||
|
isDayKey,
|
||||||
|
isTimeOfDay,
|
||||||
|
jalaliDayOfMonth,
|
||||||
|
persianWeekday,
|
||||||
|
tehranDateTime,
|
||||||
|
tehranDayKey,
|
||||||
|
tehranHour,
|
||||||
|
weekStartKey,
|
||||||
|
} from '../src/routines/routine-dates';
|
||||||
|
import {
|
||||||
|
formatTehranJalaliNumeric,
|
||||||
|
isDueInDays,
|
||||||
|
} from '../src/finance/cheque-reminder.util';
|
||||||
|
|
||||||
|
describe('تبدیل تاریخ شمسی', () => {
|
||||||
|
const pairs: Array<[[number, number, number], [number, number, number]]> = [
|
||||||
|
[[2026, 10, 8], [1405, 7, 16]],
|
||||||
|
[[2025, 3, 21], [1404, 1, 1]],
|
||||||
|
[[2024, 3, 20], [1403, 1, 1]],
|
||||||
|
[[2025, 3, 20], [1403, 12, 30]],
|
||||||
|
[[2000, 1, 1], [1378, 10, 11]],
|
||||||
|
];
|
||||||
|
for (const [[gy, gm, gd], [jy, jm, jd]] of pairs) {
|
||||||
|
it(`${gy}-${gm}-${gd} ⇄ ${jy}/${jm}/${jd}`, () => {
|
||||||
|
assert.deepEqual(toJalali(gy, gm, gd), { jy, jm, jd });
|
||||||
|
assert.deepEqual(toGregorian(jy, jm, jd), { gy, gm, gd });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('رفتوبرگشت برای هر روز دو سال', () => {
|
||||||
|
const d = new Date(Date.UTC(2024, 0, 1));
|
||||||
|
for (let i = 0; i < 731; i += 1) {
|
||||||
|
const gy = d.getUTCFullYear();
|
||||||
|
const gm = d.getUTCMonth() + 1;
|
||||||
|
const gd = d.getUTCDate();
|
||||||
|
const j = toJalali(gy, gm, gd);
|
||||||
|
assert.deepEqual(toGregorian(j.jy, j.jm, j.jd), { gy, gm, gd });
|
||||||
|
d.setUTCDate(gd + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ماهها', () => {
|
||||||
|
assert.deepEqual(addJalaliMonths(1405, 12, 1), { jy: 1406, jm: 1 });
|
||||||
|
assert.deepEqual(addJalaliMonths(1405, 1, -1), { jy: 1404, jm: 12 });
|
||||||
|
assert.equal(jalaliMonthName(7), 'مهر');
|
||||||
|
assert.equal(jalaliMonthName(13), '');
|
||||||
|
const end = endOfJalaliMonth(1405, 6);
|
||||||
|
const j = toJalali(end.getFullYear(), end.getMonth() + 1, end.getDate());
|
||||||
|
assert.deepEqual(j, { jy: 1405, jm: 6, jd: 31 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('روزهای روتین (وقت تهران)', () => {
|
||||||
|
it('کلید روز با منطقهٔ زمانی تهران', () => {
|
||||||
|
// ۰۰:۳۰ بامداد تهران = ۲۱:۰۰ UTC روز قبل
|
||||||
|
assert.equal(tehranDayKey(new Date('2026-10-07T21:00:00Z')), '2026-10-08');
|
||||||
|
assert.equal(tehranDayKey(new Date('2026-10-07T20:29:00Z')), '2026-10-07');
|
||||||
|
assert.equal(tehranHour(new Date('2026-10-07T21:00:00Z')), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('اعتبارسنجی', () => {
|
||||||
|
assert.ok(isDayKey('2026-10-08'));
|
||||||
|
assert.ok(!isDayKey('1405/07/16'));
|
||||||
|
assert.ok(isTimeOfDay('23:59'));
|
||||||
|
assert.ok(!isTimeOfDay('24:00'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('محاسبات روز و هفته', () => {
|
||||||
|
assert.equal(addDays('2026-12-31', 1), '2027-01-01');
|
||||||
|
assert.equal(dayDiff('2026-10-01', '2026-10-08'), 7);
|
||||||
|
assert.equal(persianWeekday('2026-10-08'), 5); // پنجشنبه
|
||||||
|
assert.equal(persianWeekday('2026-10-03'), 0); // شنبه
|
||||||
|
assert.equal(weekStartKey('2026-10-08'), '2026-10-03');
|
||||||
|
assert.equal(jalaliDayOfMonth('2026-10-08'), 16);
|
||||||
|
assert.deepEqual(eachDay('2026-10-01', '2026-10-03'), ['2026-10-01', '2026-10-02', '2026-10-03']);
|
||||||
|
assert.equal(tehranDateTime('2026-10-08', '09:00').toISOString(), '2026-10-08T05:30:00.000Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('یادآور چک', () => {
|
||||||
|
it('تاریخ شمسی به وقت تهران', () => {
|
||||||
|
assert.equal(formatTehranJalaliNumeric(new Date('2026-10-08T10:00:00Z')), '۱۴۰۵/۰۷/۱۶');
|
||||||
|
assert.equal(formatTehranJalaliNumeric(new Date('2026-10-07T21:00:00Z')), '۱۴۰۵/۰۷/۱۶');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('سررسید سه روز بعد', () => {
|
||||||
|
const now = new Date('2026-10-08T06:00:00Z');
|
||||||
|
assert.ok(isDueInDays(new Date('2026-10-11T12:00:00Z'), 3, now));
|
||||||
|
assert.ok(!isDueInDays(new Date('2026-10-12T12:00:00Z'), 3, now));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
MAX_MESSAGE_REPEAT,
|
||||||
|
MAX_MESSAGE_SMS,
|
||||||
|
addDelay,
|
||||||
|
applyMessageTemplate,
|
||||||
|
buildSendAts,
|
||||||
|
cancelTriggersFor,
|
||||||
|
clipSms,
|
||||||
|
delayMs,
|
||||||
|
} from '../src/message-automations/message-automation.util';
|
||||||
|
|
||||||
|
describe('اتوماسیون پیام', () => {
|
||||||
|
it('تأخیرها', () => {
|
||||||
|
assert.equal(delayMs(5, 'minutes'), 300_000);
|
||||||
|
assert.equal(delayMs(2, 'hours'), 7_200_000);
|
||||||
|
assert.equal(delayMs(-3, 'days'), 0);
|
||||||
|
assert.equal(delayMs(1, 'weeks'), 7 * 86_400_000);
|
||||||
|
const d = addDelay(new Date(2026, 0, 31), 1, 'months');
|
||||||
|
assert.equal(d.getMonth(), 2); // ۳۱ ژانویه + ۱ ماه در جاوااسکریپت = ۳ مارس
|
||||||
|
});
|
||||||
|
|
||||||
|
it('زمانهای ارسال تکراری', () => {
|
||||||
|
const now = new Date('2026-10-08T00:00:00Z');
|
||||||
|
const times = buildSendAts(now, 1, 'hours', 3, 1, 'days');
|
||||||
|
assert.deepEqual(
|
||||||
|
times.map((t) => t.toISOString()),
|
||||||
|
['2026-10-08T01:00:00.000Z', '2026-10-09T01:00:00.000Z', '2026-10-10T01:00:00.000Z'],
|
||||||
|
);
|
||||||
|
assert.equal(buildSendAts(now, 0, 'minutes', 999, 1, 'days').length, MAX_MESSAGE_REPEAT);
|
||||||
|
assert.equal(buildSendAts(now, 0, 'minutes', 0, 1, 'days').length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('جایگذاری متغیرهای پیام', () => {
|
||||||
|
assert.equal(
|
||||||
|
applyMessageTemplate('سلام {نام} از {شرکت} - {unknown}', { name: 'علی', company: 'منتکت' }),
|
||||||
|
'سلام علی از منتکت - {unknown}',
|
||||||
|
);
|
||||||
|
assert.equal(applyMessageTemplate('{ name }', { name: 'x' }), 'x');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('کوتاه کردن پیامک', () => {
|
||||||
|
const long = 'ا'.repeat(200);
|
||||||
|
const clipped = clipSms(long);
|
||||||
|
assert.equal(clipped.length, MAX_MESSAGE_SMS);
|
||||||
|
assert.ok(clipped.endsWith('…'));
|
||||||
|
assert.equal(clipSms(' کوتاه '), 'کوتاه');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('لغو پیامهای قبلی', () => {
|
||||||
|
assert.deepEqual(cancelTriggersFor('invoice_paid'), ['invoice_issued']);
|
||||||
|
assert.deepEqual(cancelTriggersFor('deal_won'), ['deal_created', 'deal_stage_changed']);
|
||||||
|
assert.deepEqual(cancelTriggersFor('person_created'), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
noteAccessWhere,
|
||||||
|
normalizeDataVisibility,
|
||||||
|
ownerWhere,
|
||||||
|
parseVisibilityScopes,
|
||||||
|
resolveOwnerId,
|
||||||
|
scopedOwnerIds,
|
||||||
|
seesAllRecords,
|
||||||
|
} from '../src/common/record-scope';
|
||||||
|
|
||||||
|
const sales = { userId: 'u1', role: 'sales' };
|
||||||
|
|
||||||
|
describe('سطح دسترسی به پروندهها', () => {
|
||||||
|
it('مدیر و مالک همه را میبینند', () => {
|
||||||
|
assert.ok(seesAllRecords({ role: 'owner' }));
|
||||||
|
assert.ok(seesAllRecords({ role: 'admin' }));
|
||||||
|
assert.deepEqual(ownerWhere({ userId: 'a', role: 'admin' }), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('کارشناس فروش فقط پروندههای خودش را میبیند', () => {
|
||||||
|
assert.ok(!seesAllRecords(sales));
|
||||||
|
assert.deepEqual(scopedOwnerIds(sales), ['u1']);
|
||||||
|
assert.deepEqual(ownerWhere(sales), { ownerId: 'u1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('دید تیمی', () => {
|
||||||
|
const actor = { ...sales, dataVisibility: 'team' as const, visibleUserIds: ['u1', 'u2', 'u2'] };
|
||||||
|
assert.deepEqual(ownerWhere(actor), { ownerId: { in: ['u1', 'u2'] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('دید سازمانی یا visibleUserIds=null یعنی همه', () => {
|
||||||
|
assert.deepEqual(ownerWhere({ ...sales, dataVisibility: 'org' as const }), {});
|
||||||
|
assert.deepEqual(ownerWhere({ ...sales, visibleUserIds: null }), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('سطح دید تفکیکی هر بخش بر پیشفرض اولویت دارد', () => {
|
||||||
|
const actor = {
|
||||||
|
...sales,
|
||||||
|
dataVisibility: 'org' as const,
|
||||||
|
scopes: { finance: { dataVisibility: 'own' as const, visibleUserIds: ['u1'] } },
|
||||||
|
};
|
||||||
|
assert.deepEqual(ownerWhere(actor, 'contacts'), {});
|
||||||
|
assert.deepEqual(ownerWhere(actor, 'finance'), { ownerId: 'u1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('یادداشت شخصی فقط برای نویسنده', () => {
|
||||||
|
const where = noteAccessWhere({ userId: 'a', role: 'admin' });
|
||||||
|
assert.deepEqual(where, {
|
||||||
|
OR: [
|
||||||
|
{ visibility: { not: 'personal' } },
|
||||||
|
{ visibility: 'personal', authorId: 'a' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('فقط مدیر میتواند مالک را عوض کند', () => {
|
||||||
|
assert.equal(resolveOwnerId(sales, 'u9'), 'u1');
|
||||||
|
assert.equal(resolveOwnerId({ userId: 'a', role: 'admin' }, ' u9 '), 'u9');
|
||||||
|
assert.equal(resolveOwnerId({ userId: 'a', role: 'admin' }, ''), 'a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('پارس تنظیمات دید', () => {
|
||||||
|
assert.deepEqual(parseVisibilityScopes({ contacts: 'team', finance: 'bad' }), { contacts: 'team' });
|
||||||
|
assert.deepEqual(parseVisibilityScopes(null), {});
|
||||||
|
assert.equal(normalizeDataVisibility('weird'), 'own');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
FACTOR_CODE_MIN_LEN,
|
||||||
|
SanadBuilders,
|
||||||
|
assertFactorCode,
|
||||||
|
buildFactorCode,
|
||||||
|
extractMandehRaw,
|
||||||
|
normalizeIranMobile,
|
||||||
|
normalizeMandehResponse,
|
||||||
|
} from '../src/integrations/tahesab/tahesab-methods';
|
||||||
|
|
||||||
|
const FACTOR = 'crm-test-factor-code-0001';
|
||||||
|
|
||||||
|
/** ترتیب پارامترها از docs/integrations/tahesab-openapi.json؛ اگر این تست شکست، سند با پارامتر غلط به تهحساب میرود */
|
||||||
|
const DOC_PARAMS = {
|
||||||
|
buySaleGold: [
|
||||||
|
'Sabte_Kol_Or_Movaghat_1_0', 'Moshtari_Code', 'Factor_Number', 'Radif_Number',
|
||||||
|
'Shamsi_Year', 'Shamsi_Month', 'Shamsi_Day', 'Vazn', 'Ayar', 'ثابت', 'ثابت',
|
||||||
|
'BuyOrSale_0_1', 'Mazaneh', 'MazanehIsMesghalOrGeram_0_1', 'IsMotefaregheOrAbshode_0_1',
|
||||||
|
'MablaghKol', 'Sharh', 'Factor_Code', 'HavalehBe_Mcode', 'MultiRadif', 'Jens_Felez',
|
||||||
|
'Zaman_Tasvie',
|
||||||
|
],
|
||||||
|
buySaleSekeh: [
|
||||||
|
'Sabte_Kol_Or_Movaghat_1_0', 'Moshtari_Code', 'Factor_Number', 'Radif_Number',
|
||||||
|
'Shamsi_Year', 'Shamsi_Month', 'Shamsi_Day', 'Vazn', 'Ayar', 'Count', 'Name_Sekeh',
|
||||||
|
'BuyOrSale_0_1', 'Mazaneh', 'MablaghKol', 'Sharh', 'Factor_Code', 'ثابت', 'MultiRadif',
|
||||||
|
'Jens_Felez', 'ArzName',
|
||||||
|
],
|
||||||
|
vkhGold: [
|
||||||
|
'Sabte_Kol_Or_Movaghat_1_0', 'Moshtari_Code', 'Factor_Number', 'Radif_Number',
|
||||||
|
'Shamsi_Year', 'Shamsi_Month', 'Shamsi_Day', 'Vazn', 'Ayar', 'Ang_Number', 'Name_az',
|
||||||
|
'IsVoroodOrKhorooj_0_1', 'IsMotefaregheOrAbshode_0_1', 'Sharh', 'Factor_Code',
|
||||||
|
'HavalehBe_Mcode', 'MultiRadif', 'Jens_Felez', 'IsSharti',
|
||||||
|
],
|
||||||
|
newMoshtari: [
|
||||||
|
'Moshtari_Name', 'GroupName', 'Tel', 'Address', 'CodeMeli', 'Tarikh_Tavalod', 'Moaref',
|
||||||
|
'Code_Moaref', 'Moshtari_Code', 'Jens_Felez',
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function byName(names: readonly string[], args: unknown[]) {
|
||||||
|
assert.equal(args.length, names.length, 'تعداد پارامترها با مستندات تهحساب یکی نیست');
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
names.forEach((n, i) => {
|
||||||
|
if (n !== 'ثابت') out[n] = args[i];
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('normalizeIranMobile', () => {
|
||||||
|
it('حذف پیششمارهها و کاراکترهای اضافه', () => {
|
||||||
|
assert.equal(normalizeIranMobile('09121234567'), '9121234567');
|
||||||
|
assert.equal(normalizeIranMobile('+98 912 123 4567'), '9121234567');
|
||||||
|
assert.equal(normalizeIranMobile('989121234567'), '9121234567');
|
||||||
|
assert.equal(normalizeIranMobile('9121234567'), '9121234567');
|
||||||
|
});
|
||||||
|
it('ورودی نامعتبر', () => {
|
||||||
|
assert.equal(normalizeIranMobile(''), null);
|
||||||
|
assert.equal(normalizeIranMobile(null), null);
|
||||||
|
assert.equal(normalizeIranMobile('12345'), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Factor_Code', () => {
|
||||||
|
it('کد ساختهشده حداقل طول را دارد و یکتاست', () => {
|
||||||
|
const a = buildFactorCode();
|
||||||
|
const b = buildFactorCode();
|
||||||
|
assert.ok(a.length >= FACTOR_CODE_MIN_LEN);
|
||||||
|
assert.notEqual(a, b);
|
||||||
|
});
|
||||||
|
it('کد کوتاه رد میشود', () => {
|
||||||
|
assert.throws(() => assertFactorCode('short'));
|
||||||
|
assert.throws(() => assertFactorCode(''));
|
||||||
|
assert.doesNotThrow(() => assertFactorCode(FACTOR));
|
||||||
|
});
|
||||||
|
it('سازندههای سند بدون Factor_Code معتبر خطا میدهند', () => {
|
||||||
|
assert.throws(() =>
|
||||||
|
SanadBuilders.vkhGold({
|
||||||
|
moshtariCode: 1, year: 1405, month: 7, day: 16, vazn: 1, ayar: 750,
|
||||||
|
angNumber: 1, nameAz: 'az', isVoroodOrKhorooj: 1, isAbshode: 1, sharh: '',
|
||||||
|
factorCode: 'x',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SanadBuilders مطابق مستندات تهحساب', () => {
|
||||||
|
it('خرید و فروش آبشده', () => {
|
||||||
|
const p = byName(
|
||||||
|
DOC_PARAMS.buySaleGold,
|
||||||
|
SanadBuilders.buySaleGold({
|
||||||
|
moshtariCode: 1355, year: 1405, month: 7, day: 16, vazn: 1.25, ayar: 750,
|
||||||
|
buyOrSale: 1, mazaneh: 4_600_000, mazanehIsGeram: 0, isAbshode: 1,
|
||||||
|
mablaghKol: 10_000_000, sharh: 'شرح', factorCode: FACTOR, havalehBeMcode: 2,
|
||||||
|
jensFelez: 2, zamanTasvie: '1405-07-20',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.deepEqual(p, {
|
||||||
|
Sabte_Kol_Or_Movaghat_1_0: 1, Moshtari_Code: 1355, Factor_Number: -1, Radif_Number: 1,
|
||||||
|
Shamsi_Year: 1405, Shamsi_Month: 7, Shamsi_Day: 16, Vazn: 1.25, Ayar: 750,
|
||||||
|
BuyOrSale_0_1: 1, Mazaneh: 4_600_000, MazanehIsMesghalOrGeram_0_1: 0,
|
||||||
|
IsMotefaregheOrAbshode_0_1: 1, MablaghKol: 10_000_000, Sharh: 'شرح',
|
||||||
|
Factor_Code: FACTOR, HavalehBe_Mcode: 2, MultiRadif: -1, Jens_Felez: 2,
|
||||||
|
Zaman_Tasvie: '1405-07-20',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('خرید و فروش سکه', () => {
|
||||||
|
const p = byName(
|
||||||
|
DOC_PARAMS.buySaleSekeh,
|
||||||
|
SanadBuilders.buySaleSekeh({
|
||||||
|
moshtariCode: 1355, year: 1405, month: 7, day: 16, vazn: 8.13, ayar: 900, count: 2,
|
||||||
|
nameSekeh: 'تمام', buyOrSale: 0, mazaneh: 1, mablaghKol: 500, sharh: 's',
|
||||||
|
factorCode: FACTOR,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.equal(p.Count, 2);
|
||||||
|
assert.equal(p.Name_Sekeh, 'تمام');
|
||||||
|
assert.equal(p.BuyOrSale_0_1, 0);
|
||||||
|
assert.equal(p.MablaghKol, 500);
|
||||||
|
assert.equal(p.Factor_Code, FACTOR);
|
||||||
|
assert.equal(p.Jens_Felez, 0);
|
||||||
|
assert.equal(p.ArzName, 'IRR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ورود و خروج آبشده', () => {
|
||||||
|
const p = byName(
|
||||||
|
DOC_PARAMS.vkhGold,
|
||||||
|
SanadBuilders.vkhGold({
|
||||||
|
moshtariCode: 7, year: 1405, month: 1, day: 2, vazn: 3.5, ayar: 750, angNumber: 12,
|
||||||
|
nameAz: 'آزمایشگاه', isVoroodOrKhorooj: 0, isAbshode: 1, sharh: 's',
|
||||||
|
factorCode: FACTOR, isSharti: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.equal(p.Vazn, 3.5);
|
||||||
|
assert.equal(p.Ang_Number, 12);
|
||||||
|
assert.equal(p.Name_az, 'آزمایشگاه');
|
||||||
|
assert.equal(p.IsVoroodOrKhorooj_0_1, 0);
|
||||||
|
assert.equal(p.Factor_Code, FACTOR);
|
||||||
|
assert.equal(p.IsSharti, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('مشتری جدید', () => {
|
||||||
|
const p = byName(
|
||||||
|
DOC_PARAMS.newMoshtari,
|
||||||
|
SanadBuilders.newMoshtari({ name: 'علی', tel: '09121234567', jensFelez: 2 }),
|
||||||
|
);
|
||||||
|
assert.deepEqual(p, {
|
||||||
|
Moshtari_Name: 'علی', GroupName: 'CRM', Tel: '09121234567', Address: '', CodeMeli: '',
|
||||||
|
Tarikh_Tavalod: '', Moaref: '', Code_Moaref: '-1', Moshtari_Code: -1, Jens_Felez: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('پاسخ مانده حساب', () => {
|
||||||
|
const raw = {
|
||||||
|
MandeHesab: [
|
||||||
|
{
|
||||||
|
Code: 1355,
|
||||||
|
MandeyeMali: '135040598',
|
||||||
|
MandeyeVazni: -1.5,
|
||||||
|
MandeyeArz_USD: null,
|
||||||
|
details: [
|
||||||
|
{ Name: 'سکه تمام', Value: '2', Value1: null, Value2: 2, Value3: null, Value4: 'null' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('extractMandehRaw هیچ مقداری را تغییر نمیدهد', () => {
|
||||||
|
const { fields, details } = extractMandehRaw(raw);
|
||||||
|
assert.deepEqual(fields, [
|
||||||
|
{ key: 'Code', value: '1355' },
|
||||||
|
{ key: 'MandeyeMali', value: '135040598' },
|
||||||
|
{ key: 'MandeyeVazni', value: '-1.5' },
|
||||||
|
{ key: 'MandeyeArz_USD', value: null },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(details, [
|
||||||
|
{ Name: 'سکه تمام', Value: '2', Value1: null, Value2: '2', Value3: null, Value4: null },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extractMandehRaw با پاسخ خالی یا خراب', () => {
|
||||||
|
assert.deepEqual(extractMandehRaw(null), { fields: [], details: [] });
|
||||||
|
assert.deepEqual(extractMandehRaw({}), { fields: [], details: [] });
|
||||||
|
assert.deepEqual(extractMandehRaw({ MandeHesab: [] }), { fields: [], details: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizeMandehResponse', () => {
|
||||||
|
const n = normalizeMandehResponse(raw);
|
||||||
|
assert.equal(n.rialBalance, 135040598);
|
||||||
|
assert.equal(n.goldBalance, -1.5);
|
||||||
|
assert.equal(n.mandehDetail.length, 1);
|
||||||
|
assert.equal(normalizeMandehResponse({}).goldBalance, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"incremental": false,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["./**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# چکلیست تست قبل از تحویل
|
||||||
|
|
||||||
|
هدف: هیچ باگی را کارفرما پیدا نکند. قبل از هر تحویل یا دیپلوی این مراحل را به ترتیب انجام دهید.
|
||||||
|
|
||||||
|
## ۱. بررسی خودکار (اجباری)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
- بیلد `@mento/shared`، بررسی تایپ API و وب، و تستهای واحد API را اجرا میکند (حدود ۲ دقیقه).
|
||||||
|
- قبل از هر `git push` خودکار اجرا میشود و اگر خطا داشته باشد جلوی پوش را میگیرد.
|
||||||
|
- فقط در شرایط اضطراری: `git push --no-verify`.
|
||||||
|
|
||||||
|
## ۲. تست مرورگری خودکار (اجباری برای تغییرات ظاهری)
|
||||||
|
|
||||||
|
یک بار: فایل `e2e/.env.example` را به `e2e/.env` کپی کنید و نام کاربری و رمز را بنویسید.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
- با Edge، همهٔ صفحات اصلی را باز میکند و اگر خطای جاوااسکریپت یا خطای 5xx از سرور ببیند، رد میشود.
|
||||||
|
- فقط خواندنی است؛ چیزی ثبت یا حذف نمیکند.
|
||||||
|
- گزارش خطاها با عکس: `npm run test:e2e:report`.
|
||||||
|
|
||||||
|
## ۳. تست دستی (۱۰ تا ۱۵ دقیقه)
|
||||||
|
|
||||||
|
فقط بخشهایی را که در این تحویل تغییر کردهاند **کامل** تست کنید؛ بقیه را یک نگاه سریع.
|
||||||
|
|
||||||
|
### ورود و دسترسی
|
||||||
|
- [ ] ورود با رمز و خروج
|
||||||
|
- [ ] ورود با کد یکبارمصرف
|
||||||
|
- [ ] با کاربر «فروش»: فقط پروندههای خودش را میبیند و به تنظیمات دسترسی ندارد
|
||||||
|
|
||||||
|
### مشتریان
|
||||||
|
- [ ] ساخت شخص جدید، ویرایش، و دیدن در لیست
|
||||||
|
- [ ] جستجو با نام و موبایل
|
||||||
|
- [ ] پروفایل مشتری: همهٔ تبها باز میشوند
|
||||||
|
- [ ] ورود از اکسل با یک فایل کوچک
|
||||||
|
|
||||||
|
### لید و فرم عمومی
|
||||||
|
- [ ] ساخت/ویرایش فرم در فرمساز؛ پیشنمایش با صفحهٔ عمومی یکی است
|
||||||
|
- [ ] باز کردن لینک عمومی فرم در پنجرهٔ ناشناس و ارسال آن
|
||||||
|
- [ ] لید ثبتشده در لیست لیدها دیده میشود
|
||||||
|
- [ ] کاور فرم با عکس بزرگ/کوچک درست نمایش داده میشود
|
||||||
|
|
||||||
|
### کارها و روتینها
|
||||||
|
- [ ] ساخت کار با نوع و فیلدهای سفارشی، تغییر وضعیت در کانبان
|
||||||
|
- [ ] تقویم کارها
|
||||||
|
- [ ] روتین: ثبت انجام کار امروز
|
||||||
|
|
||||||
|
### فروش و مالی
|
||||||
|
- [ ] ساخت معامله و جابهجایی مرحله در پایپلاین
|
||||||
|
- [ ] صدور پیشفاکتور و فاکتور، ثبت پرداخت
|
||||||
|
- [ ] ثبت چک و دیدن سررسیدها
|
||||||
|
|
||||||
|
### تهحساب
|
||||||
|
- [ ] تنظیمات تهحساب: تست اتصال موفق
|
||||||
|
- [ ] داراییهای یک مشتری متصل: مانده حساب نمایش داده میشود
|
||||||
|
- [ ] جزئیات حساب: هر دو حالت «هر سند یکجا» و «ردیفبهردیف»
|
||||||
|
- [ ] ثبت سند فقط روی حساب آزمایشی (هرگز روی مشتری واقعی)
|
||||||
|
|
||||||
|
### رابط کاربری عمومی
|
||||||
|
- [ ] مودالها: با کلیک بیرون بسته **نمیشوند**؛ با Esc، ضربدر و انصراف بسته میشوند
|
||||||
|
- [ ] نمای موبایل (عرض ۳۹۰ پیکسل) برای صفحاتی که تغییر کردهاند
|
||||||
|
- [ ] اعداد و تاریخها فارسی و درست هستند
|
||||||
|
|
||||||
|
## ۴. بعد از دیپلوی (۲ دقیقه)
|
||||||
|
|
||||||
|
- [ ] سایت اصلی را باز کنید، یک بار Ctrl+F5 بزنید
|
||||||
|
- [ ] همان قابلیت جدید را یک بار روی سرور اصلی ببینید
|
||||||
|
- [ ] `pm2 logs mento-crm-api --lines 50` خطای جدید ندارد
|
||||||
|
|
||||||
|
## ۵. یادداشت تحویل به کارفرما
|
||||||
|
|
||||||
|
با هر تحویل، کوتاه بنویسید:
|
||||||
|
- چه چیزهایی اضافه یا اصلاح شد
|
||||||
|
- کجا را ببیند (مسیر صفحه)
|
||||||
|
- چه چیزی هنوز در دست انجام است
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# کپی کنید به e2e/.env (در git ثبت نمیشود) — اینجا رمز ننویسید
|
||||||
|
E2E_USER=
|
||||||
|
E2E_PASSWORD=
|
||||||
|
# اگر کاربر عضو چند سازمان است، نام سازمان
|
||||||
|
E2E_ORG=
|
||||||
|
# پیشفرض: سرور لوکال (npm run dev:web که خودش به سرور اصلی وصل است)
|
||||||
|
E2E_BASE_URL=http://127.0.0.1:5173
|
||||||
|
# اختیاری: مشتری دارای حساب تهحساب برای تست اسناد
|
||||||
|
E2E_PERSON_ID=
|
||||||
|
# اختیاری: توکن یک فرم عمومی لید
|
||||||
|
E2E_FORM_TOKEN=
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { test as setup, expect } from '@playwright/test';
|
||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
|
|
||||||
|
const STATE = 'e2e/.auth/user.json';
|
||||||
|
|
||||||
|
setup('ورود به پنل', async ({ page }) => {
|
||||||
|
const user = process.env.E2E_USER;
|
||||||
|
const password = process.env.E2E_PASSWORD;
|
||||||
|
if (!user || !password) {
|
||||||
|
throw new Error('E2E_USER و E2E_PASSWORD را در e2e/.env تنظیم کنید (نمونه: e2e/.env.example).');
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.locator('#identifier').fill(user);
|
||||||
|
await page.locator('#password').fill(password);
|
||||||
|
await page.getByRole('button', { name: 'ورود', exact: true }).click();
|
||||||
|
|
||||||
|
const orgCards = page.locator('.org-card');
|
||||||
|
await expect(async () => {
|
||||||
|
const onOrgPicker = await orgCards.first().isVisible();
|
||||||
|
const leftLogin = !page.url().includes('/login');
|
||||||
|
expect(onOrgPicker || leftLogin).toBe(true);
|
||||||
|
}).toPass({ timeout: 30_000 });
|
||||||
|
|
||||||
|
if (await orgCards.first().isVisible()) {
|
||||||
|
const orgName = process.env.E2E_ORG;
|
||||||
|
const card = orgName ? orgCards.filter({ hasText: orgName }).first() : orgCards.first();
|
||||||
|
await card.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page).not.toHaveURL(/\/login/, { timeout: 30_000 });
|
||||||
|
await expect(page.locator('main.content')).toBeVisible();
|
||||||
|
|
||||||
|
mkdirSync('e2e/.auth', { recursive: true });
|
||||||
|
await page.context().storageState({ path: STATE });
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { test as base, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
type Problems = { list: string[] };
|
||||||
|
|
||||||
|
/** هر خطای جاوااسکریپت یا پاسخ 5xx از API در طول تست، تست را رد میکند */
|
||||||
|
export const test = base.extend<{ problems: Problems }>({
|
||||||
|
problems: [
|
||||||
|
async ({ page }, use) => {
|
||||||
|
const problems: Problems = { list: [] };
|
||||||
|
page.on('pageerror', (err) => problems.list.push(`JS error: ${err.message}`));
|
||||||
|
page.on('response', (res) => {
|
||||||
|
if (res.url().includes('/api/') && res.status() >= 500) {
|
||||||
|
problems.list.push(`API ${res.status()}: ${res.request().method()} ${res.url()}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await use(problems);
|
||||||
|
expect(problems.list, 'خطا در صفحه').toEqual([]);
|
||||||
|
},
|
||||||
|
{ auto: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export { expect };
|
||||||
|
|
||||||
|
export async function waitForIdle(page: Page) {
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => undefined);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { test, expect, waitForIdle } from './fixtures';
|
||||||
|
|
||||||
|
/** فقط خواندنی: هیچ رکوردی ساخته، ویرایش یا حذف نمیشود؛ اجرا روی سرور اصلی بیخطر است */
|
||||||
|
const PAGES: Array<[string, string]> = [
|
||||||
|
['/', 'داشبورد'],
|
||||||
|
['/contacts', 'مشتریان'],
|
||||||
|
['/leads', 'لیدها'],
|
||||||
|
['/pipeline', 'فروش'],
|
||||||
|
['/activities', 'کارها'],
|
||||||
|
['/activities/calendar', 'تقویم کارها'],
|
||||||
|
['/routines', 'کارهای روتین'],
|
||||||
|
['/conversations', 'گفتگو'],
|
||||||
|
['/notes', 'یادداشتها'],
|
||||||
|
['/tickets', 'پشتیبانی'],
|
||||||
|
['/loyalty', 'باشگاه مشتریان'],
|
||||||
|
['/reports', 'گزارشها'],
|
||||||
|
['/invoices', 'فاکتورها'],
|
||||||
|
['/quotes', 'پیشفاکتورها'],
|
||||||
|
['/cheques', 'چکها'],
|
||||||
|
['/projects', 'پروژهها'],
|
||||||
|
['/profile', 'پروفایل'],
|
||||||
|
['/settings/team', 'تنظیمات تیم'],
|
||||||
|
['/settings/lead-forms', 'فرمهای لید'],
|
||||||
|
['/settings/message-automations', 'اتوماسیون پیام'],
|
||||||
|
['/settings/tahesab', 'تنظیمات تهحساب'],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [path, title] of PAGES) {
|
||||||
|
test(`صفحهٔ ${title} بدون خطا باز میشود (${path})`, async ({ page, problems }) => {
|
||||||
|
await page.goto(path);
|
||||||
|
await expect(page).not.toHaveURL(/\/login/);
|
||||||
|
await expect(page.locator('main.content')).toBeVisible();
|
||||||
|
await waitForIdle(page);
|
||||||
|
expect(problems.list).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('جستجوی سراسری باز میشود و نتیجه میدهد', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const search = page.getByPlaceholder(/جستجو در مشتریان/);
|
||||||
|
await expect(search).toBeVisible();
|
||||||
|
await search.fill('a');
|
||||||
|
await waitForIdle(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('داراییها و اسناد تهحساب مشتری', async ({ page }) => {
|
||||||
|
const personId = process.env.E2E_PERSON_ID;
|
||||||
|
test.skip(!personId, 'E2E_PERSON_ID تنظیم نشده');
|
||||||
|
await page.goto(`/contacts/people/${personId}?tab=assets`);
|
||||||
|
await expect(page.getByText('مانده حساب')).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByRole('button', { name: 'جزئیات حساب', exact: true }).click();
|
||||||
|
await expect(page.locator('.invoice, .asnad-grid .sanad-card').first().or(page.getByText('در این بازه سندی یافت نشد'))).toBeVisible({ timeout: 60_000 });
|
||||||
|
const invoice = page.locator('.invoice').first();
|
||||||
|
if (await invoice.isVisible()) {
|
||||||
|
await expect(invoice.locator('.inv-head')).toContainText('سند شماره');
|
||||||
|
await expect(invoice.locator('.inv-table tbody tr').first()).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('فرم عمومی لید بدون لاگین باز میشود', async ({ browser }) => {
|
||||||
|
const token = process.env.E2E_FORM_TOKEN;
|
||||||
|
test.skip(!token, 'E2E_FORM_TOKEN تنظیم نشده');
|
||||||
|
const ctx = await browser.newContext({ storageState: undefined });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on('pageerror', (e) => errors.push(e.message));
|
||||||
|
await page.goto(`/f/${token}`);
|
||||||
|
await expect(page.locator('form')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /ارسال|ثبت/ })).toBeVisible();
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
Generated
+48
@@ -11,6 +11,9 @@
|
|||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.64.0"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
@@ -933,6 +936,22 @@
|
|||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.64.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.64.0.tgz",
|
||||||
|
"integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.64.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@prisma/client": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "6.19.3",
|
"version": "6.19.3",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
@@ -4427,6 +4446,35 @@
|
|||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.64.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.64.0.tgz",
|
||||||
|
"integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.64.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.64.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.64.0.tgz",
|
||||||
|
"integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pluralize": {
|
"node_modules/pluralize": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
@@ -16,6 +16,11 @@
|
|||||||
"build": "npm run build --workspaces --if-present",
|
"build": "npm run build --workspaces --if-present",
|
||||||
"build:shared": "npm run build -w @mento/shared",
|
"build:shared": "npm run build -w @mento/shared",
|
||||||
"lint": "npm run lint --workspaces --if-present",
|
"lint": "npm run lint --workspaces --if-present",
|
||||||
|
"test": "npm run build -w @mento/shared && npm test -w @mento/api",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:report": "playwright show-report",
|
||||||
|
"check": "node scripts/check.mjs",
|
||||||
|
"prepare": "node -e \"try{require('child_process').execSync('git config core.hooksPath .githooks',{stdio:'ignore'})}catch{}\"",
|
||||||
"db:generate": "npm run prisma:generate -w @mento/api",
|
"db:generate": "npm run prisma:generate -w @mento/api",
|
||||||
"db:migrate": "npm run prisma:migrate -w @mento/api",
|
"db:migrate": "npm run prisma:migrate -w @mento/api",
|
||||||
"db:seed": "npm run prisma:seed -w @mento/api"
|
"db:seed": "npm run prisma:seed -w @mento/api"
|
||||||
@@ -25,5 +30,8 @@
|
|||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-win32-x64-msvc": "^4.63.1"
|
"@rollup/rollup-win32-x64-msvc": "^4.63.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.64.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { defineConfig } from '@playwright/test';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
/** e2e/.env (gitignored): E2E_USER, E2E_PASSWORD, ... */
|
||||||
|
const envFile = 'e2e/.env';
|
||||||
|
if (existsSync(envFile)) {
|
||||||
|
for (const line of readFileSync(envFile, 'utf8').split(/\r?\n/)) {
|
||||||
|
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/);
|
||||||
|
if (m && process.env[m[1]!] === undefined) process.env[m[1]!] = m[2]!.replace(/^["']|["']$/g, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseURL = process.env.E2E_BASE_URL || 'http://127.0.0.1:5173';
|
||||||
|
const isLocal = /^https?:\/\/(127\.0\.0\.1|localhost)/.test(baseURL);
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: 'e2e',
|
||||||
|
timeout: 60_000,
|
||||||
|
expect: { timeout: 15_000 },
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
retries: 0,
|
||||||
|
reporter: [['list'], ['html', { open: 'never' }]],
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
channel: process.env.E2E_CHANNEL || (process.platform === 'win32' ? 'msedge' : undefined),
|
||||||
|
locale: 'fa-IR',
|
||||||
|
timezoneId: 'Asia/Tehran',
|
||||||
|
viewport: { width: 1366, height: 860 },
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
|
||||||
|
{
|
||||||
|
name: 'smoke',
|
||||||
|
testMatch: /.*\.spec\.ts/,
|
||||||
|
dependencies: ['setup'],
|
||||||
|
use: { storageState: 'e2e/.auth/user.json' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: isLocal
|
||||||
|
? {
|
||||||
|
command: 'npm run dev:web',
|
||||||
|
url: baseURL,
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 120_000,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* بررسی قبل از پوش: بیلد shared، تایپ API و تستها، تایپ وب.
|
||||||
|
* npm run check — بررسی کامل
|
||||||
|
* npm run check -- --e2e — بهعلاوهٔ تستهای مرورگری (نیاز به e2e/.env)
|
||||||
|
*/
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
|
||||||
|
const withE2e = process.argv.includes('--e2e');
|
||||||
|
|
||||||
|
function run(label, cmd, cwd = '.') {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const started = Date.now();
|
||||||
|
const child = spawn(cmd, { cwd, shell: true, env: process.env });
|
||||||
|
let out = '';
|
||||||
|
child.stdout.on('data', (d) => (out += d));
|
||||||
|
child.stderr.on('data', (d) => (out += d));
|
||||||
|
child.on('close', (code) => {
|
||||||
|
const sec = ((Date.now() - started) / 1000).toFixed(0);
|
||||||
|
if (code === 0) console.log(` ✔ ${label} (${sec}s)`);
|
||||||
|
else console.log(` ✖ ${label} (${sec}s)\n${out.trim().split('\n').slice(-40).join('\n')}\n`);
|
||||||
|
resolve(code === 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('بررسی پروژه…');
|
||||||
|
const steps = [];
|
||||||
|
if (!(await run('بیلد @mento/shared', 'npm run build -w @mento/shared'))) process.exit(1);
|
||||||
|
|
||||||
|
steps.push(
|
||||||
|
run('تایپ API', 'npx tsc --noEmit -p tsconfig.json', 'apps/api'),
|
||||||
|
run('تایپ تستهای API', 'npx tsc --noEmit -p test/tsconfig.json', 'apps/api'),
|
||||||
|
run('تستهای واحد API', 'npm test -w @mento/api'),
|
||||||
|
run('تایپ وب (vue-tsc)', 'npx vue-tsc -b', 'apps/web'),
|
||||||
|
);
|
||||||
|
const results = await Promise.all(steps);
|
||||||
|
|
||||||
|
if (withE2e && results.every(Boolean)) {
|
||||||
|
results.push(await run('تستهای مرورگری (Playwright)', 'npx playwright test'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.every(Boolean)) {
|
||||||
|
console.log('همهچیز سالم است.');
|
||||||
|
} else {
|
||||||
|
console.log('بررسی ناموفق بود؛ قبل از پوش مشکلها را رفع کنید. (اضطراری: git push --no-verify)');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user