From 46a7693ea068f3eeeec2f54066b0beb944781be4 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Thu, 8 Oct 2026 20:11:07 +0330 Subject: [PATCH] QA: unit tests for API logic, Playwright smoke tests, pre-push check, manual QA checklist Co-authored-by: Cursor --- .githooks/pre-push | 3 + .gitignore | 6 + apps/api/package.json | 4 +- apps/api/test/dates.test.ts | 104 ++++++++++++ apps/api/test/message-automation.test.ts | 56 +++++++ apps/api/test/record-scope.test.ts | 69 ++++++++ apps/api/test/tahesab-methods.test.ts | 197 +++++++++++++++++++++++ apps/api/test/tsconfig.json | 9 ++ docs/QA_CHECKLIST.md | 80 +++++++++ e2e/.env.example | 11 ++ e2e/auth.setup.ts | 36 +++++ e2e/fixtures.ts | 27 ++++ e2e/smoke.spec.ts | 72 +++++++++ package-lock.json | 48 ++++++ package.json | 8 + playwright.config.ts | 50 ++++++ scripts/check.mjs | 48 ++++++ 17 files changed, 827 insertions(+), 1 deletion(-) create mode 100755 .githooks/pre-push create mode 100644 apps/api/test/dates.test.ts create mode 100644 apps/api/test/message-automation.test.ts create mode 100644 apps/api/test/record-scope.test.ts create mode 100644 apps/api/test/tahesab-methods.test.ts create mode 100644 apps/api/test/tsconfig.json create mode 100644 docs/QA_CHECKLIST.md create mode 100644 e2e/.env.example create mode 100644 e2e/auth.setup.ts create mode 100644 e2e/fixtures.ts create mode 100644 e2e/smoke.spec.ts create mode 100644 playwright.config.ts create mode 100644 scripts/check.mjs diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..738fdd8 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,3 @@ +#!/bin/sh +# فعال‌سازی: npm install (اسکریپت prepare) یا git config core.hooksPath .githooks +npm run check diff --git a/.gitignore b/.gitignore index 39bdb51..18385b7 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,12 @@ docs/~$* apps/stt/.venv/ crm-node_modules.tgz +# Playwright +e2e/.auth/ +e2e/.env +test-results/ +playwright-report/ + # Graphify (local architecture graph — do not commit) graphify-out/ **/graphify-out/ diff --git a/apps/api/package.json b/apps/api/package.json index 6ef704b..3f4f6ec 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,7 +10,9 @@ "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "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": { "@mento/shared": "*", diff --git a/apps/api/test/dates.test.ts b/apps/api/test/dates.test.ts new file mode 100644 index 0000000..445625a --- /dev/null +++ b/apps/api/test/dates.test.ts @@ -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)); + }); +}); diff --git a/apps/api/test/message-automation.test.ts b/apps/api/test/message-automation.test.ts new file mode 100644 index 0000000..5330437 --- /dev/null +++ b/apps/api/test/message-automation.test.ts @@ -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'), []); + }); +}); diff --git a/apps/api/test/record-scope.test.ts b/apps/api/test/record-scope.test.ts new file mode 100644 index 0000000..1a5d535 --- /dev/null +++ b/apps/api/test/record-scope.test.ts @@ -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'); + }); +}); diff --git a/apps/api/test/tahesab-methods.test.ts b/apps/api/test/tahesab-methods.test.ts new file mode 100644 index 0000000..7b5c8e6 --- /dev/null +++ b/apps/api/test/tahesab-methods.test.ts @@ -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 = {}; + 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); + }); +}); diff --git a/apps/api/test/tsconfig.json b/apps/api/test/tsconfig.json new file mode 100644 index 0000000..ae62a2b --- /dev/null +++ b/apps/api/test/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "incremental": false, + "types": ["node"] + }, + "include": ["./**/*.ts"] +} diff --git a/docs/QA_CHECKLIST.md b/docs/QA_CHECKLIST.md new file mode 100644 index 0000000..6a23b3b --- /dev/null +++ b/docs/QA_CHECKLIST.md @@ -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` خطای جدید ندارد + +## ۵. یادداشت تحویل به کارفرما + +با هر تحویل، کوتاه بنویسید: +- چه چیزهایی اضافه یا اصلاح شد +- کجا را ببیند (مسیر صفحه) +- چه چیزی هنوز در دست انجام است diff --git a/e2e/.env.example b/e2e/.env.example new file mode 100644 index 0000000..d68f8cb --- /dev/null +++ b/e2e/.env.example @@ -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= diff --git a/e2e/auth.setup.ts b/e2e/auth.setup.ts new file mode 100644 index 0000000..ed4c26c --- /dev/null +++ b/e2e/auth.setup.ts @@ -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 }); +}); diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..e247dc7 --- /dev/null +++ b/e2e/fixtures.ts @@ -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); +} diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 0000000..2a23b83 --- /dev/null +++ b/e2e/smoke.spec.ts @@ -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(); +}); diff --git a/package-lock.json b/package-lock.json index 31ab121..bf6fd23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,9 @@ "apps/*", "packages/*" ], + "devDependencies": { + "@playwright/test": "^1.64.0" + }, "engines": { "node": ">=20" }, @@ -933,6 +936,22 @@ "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": { "version": "6.19.3", "hasInstallScript": true, @@ -4427,6 +4446,35 @@ "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": { "version": "8.0.0", "dev": true, diff --git a/package.json b/package.json index 463c109..6024a24 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,11 @@ "build": "npm run build --workspaces --if-present", "build:shared": "npm run build -w @mento/shared", "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:migrate": "npm run prisma:migrate -w @mento/api", "db:seed": "npm run prisma:seed -w @mento/api" @@ -25,5 +30,8 @@ }, "optionalDependencies": { "@rollup/rollup-win32-x64-msvc": "^4.63.1" + }, + "devDependencies": { + "@playwright/test": "^1.64.0" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..5bddea0 --- /dev/null +++ b/playwright.config.ts @@ -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, +}); diff --git a/scripts/check.mjs b/scripts/check.mjs new file mode 100644 index 0000000..aa4478d --- /dev/null +++ b/scripts/check.mjs @@ -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); +}