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:
2026-10-08 20:11:07 +03:30
co-authored by Cursor
parent e6755d0c2e
commit 46a7693ea0
17 changed files with 827 additions and 1 deletions
+104
View File
@@ -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));
});
});
+56
View File
@@ -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'), []);
});
});
+69
View File
@@ -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');
});
});
+197
View File
@@ -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);
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"incremental": false,
"types": ["node"]
},
"include": ["./**/*.ts"]
}