feat: add comprehensive tests for UI components, hooks, and API interactions

- Implement tests for Pagination, StatusBadge, ConfirmDialog, and MobileInput components.
- Add tests for useSubscription, usePaymentConfig, and usePwaInstall hooks.
- Create tests for API requests in the api module, including success and error handling.
- Add utility function tests for formatting and validating Iranian mobile numbers.
- Implement tests for BlogFormPage and BlogsPage to validate form submissions and data fetching.
- Add tests for LoginPage to ensure proper validation and state management.
- Create tests for authStore and uiStore to validate state management and functionality.
- Set up Vitest configuration and testing utilities for consistent testing environment.
This commit is contained in:
hamed
2026-06-28 22:58:15 +03:30
parent 439bb0868e
commit 6084dc5d6b
15 changed files with 2083 additions and 2146 deletions
+133
View File
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { refreshMock, logoutMock } = vi.hoisted(() => ({
refreshMock: vi.fn(),
logoutMock: vi.fn(),
}));
vi.mock('@/stores/authStore', () => ({
useAuthStore: { getState: () => ({ refresh: refreshMock, logout: logoutMock }) },
}));
import { api, ApiError } from '@/lib/api';
const replaceMock = vi.fn();
function jsonRes(body: unknown, ok = true, status = 200): Response {
return { ok, status, json: () => Promise.resolve(body) } as unknown as Response;
}
function setToken(token: string) {
localStorage.setItem('clinicpro-auth', JSON.stringify({ state: { token } }));
}
beforeEach(() => {
localStorage.clear();
refreshMock.mockReset();
logoutMock.mockReset();
replaceMock.mockReset();
vi.stubGlobal('fetch', vi.fn());
Object.defineProperty(window, 'location', {
value: { replace: replaceMock },
writable: true,
configurable: true,
});
});
describe('request — موفق', () => {
it('بدنه JSON را برمی‌گرداند', async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
jsonRes({ success: true, data: { id: 1 } }),
);
const out = await api.get<{ data: { id: number } }>('/api/v1/x');
expect(out).toEqual({ success: true, data: { id: 1 } });
expect(fetch).toHaveBeenCalledTimes(1);
});
it('وقتی توکن هست هدر Authorization می‌فرستد', async () => {
setToken('tok-123');
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(jsonRes({ ok: 1 }));
await api.get('/api/v1/x');
const headers = (fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].headers;
expect(headers['Authorization']).toBe('Bearer tok-123');
});
it('بدون توکن، هدر Authorization نمی‌فرستد', async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(jsonRes({ ok: 1 }));
await api.get('/api/v1/x');
const headers = (fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].headers;
expect(headers['Authorization']).toBeUndefined();
expect(headers['Content-Type']).toBe('application/json');
});
it('post بدنه را JSON.stringify و method=POST می‌فرستد', async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(jsonRes({ ok: 1 }));
await api.post('/api/v1/x', { a: 1 });
const opts = (fetch as ReturnType<typeof vi.fn>).mock.calls[0][1];
expect(opts.method).toBe('POST');
expect(opts.body).toBe(JSON.stringify({ a: 1 }));
});
});
describe('request — خطاهای غیر۴۰۱', () => {
it('۴۲۲ → ApiError با code/message از errors[0]', async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
jsonRes({ errors: [{ code: 'ERR_VALIDATION', message: 'نامعتبر' }] }, false, 422),
);
await expect(api.get('/api/v1/x')).rejects.toMatchObject({
status: 422,
code: 'ERR_VALIDATION',
message: 'نامعتبر',
});
});
it('بدنه بدون errors → ERR_UNKNOWN', async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(jsonRes({}, false, 500));
const err = (await api.get('/api/v1/x').catch((e) => e)) as ApiError;
expect(err).toBeInstanceOf(ApiError);
expect(err.code).toBe('ERR_UNKNOWN');
expect(err.status).toBe(500);
});
});
describe('request — جریان ۴۰۱', () => {
it('refresh موفق → یک‌بار retry و موفقیت', async () => {
refreshMock.mockResolvedValue('new-token');
(fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce(jsonRes({}, false, 401))
.mockResolvedValueOnce(jsonRes({ success: true, data: 'ok' }));
const out = await api.get('/api/v1/x');
expect(out).toEqual({ success: true, data: 'ok' });
expect(refreshMock).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledTimes(2);
expect(logoutMock).not.toHaveBeenCalled();
});
it('refresh ناموفق → logout + redirect + ApiError(401)', async () => {
refreshMock.mockResolvedValue(null);
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(jsonRes({}, false, 401));
const err = (await api.get('/api/v1/x').catch((e) => e)) as ApiError;
expect(err).toBeInstanceOf(ApiError);
expect(err.status).toBe(401);
expect(err.code).toBe('ERR_UNAUTHORIZED');
expect(logoutMock).toHaveBeenCalledTimes(1);
expect(replaceMock).toHaveBeenCalledWith('/admin/login');
});
it('de-dup: دو درخواست همزمان ۴۰۱ فقط یک‌بار refresh می‌زنند', async () => {
refreshMock.mockImplementation(
() => new Promise((r) => setTimeout(() => r('new-token'), 10)),
);
const f = fetch as ReturnType<typeof vi.fn>;
f.mockImplementation(() => {
const n = f.mock.calls.length;
return Promise.resolve(n <= 2 ? jsonRes({}, false, 401) : jsonRes({ ok: 1 }));
});
await Promise.all([api.get('/api/v1/a'), api.get('/api/v1/b')]);
expect(refreshMock).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledTimes(4);
});
});
+170
View File
@@ -0,0 +1,170 @@
import { describe, it, expect } from 'vitest';
import {
formatRial,
formatNumber,
toDate,
formatDate,
formatDateTime,
toGregorianDate,
maskMobile,
cn,
toEnglishDigits,
sanitizeMobileInput,
isValidIranMobile,
IRAN_MOBILE_RE,
iranMobileSchema,
iranMobileOptionalSchema,
} from '@/lib/utils';
const PERSIAN_DIGITS = /[۰-۹]/;
describe('formatRial', () => {
it('عدد را با جداکننده فارسی و پسوند تومان برمی‌گرداند', () => {
const out = formatRial(1000);
expect(out).toContain('تومان');
expect(out).toMatch(PERSIAN_DIGITS);
});
it('صفر را هم فرمت می‌کند', () => {
expect(formatRial(0)).toContain('تومان');
});
});
describe('formatNumber', () => {
it('رقم فارسی برمی‌گرداند', () => {
expect(formatNumber(1234)).toMatch(PERSIAN_DIGITS);
});
});
describe('toDate', () => {
it('null و رشته خالی → null', () => {
expect(toDate(null)).toBeNull();
expect(toDate(undefined)).toBeNull();
expect(toDate('')).toBeNull();
});
it('عدد را به‌عنوان ثانیه (×۱۰۰۰) تفسیر می‌کند', () => {
const d = toDate(1_700_000_000);
expect(d).toBeInstanceOf(Date);
expect(d!.getTime()).toBe(1_700_000_000 * 1000);
});
it('Y-m-d را ظهر محلی می‌گیرد (نه نیمه‌شب UTC)', () => {
const d = toDate('2024-03-21');
expect(d!.getHours()).toBe(12);
expect(d!.getFullYear()).toBe(2024);
});
it('رشته ISO کامل را پارس می‌کند', () => {
const d = toDate('2024-03-21T08:30:00Z');
expect(d).toBeInstanceOf(Date);
expect(isNaN(d!.getTime())).toBe(false);
});
});
describe('formatDate', () => {
it('ورودی نامعتبر → "—"', () => {
expect(formatDate(null)).toBe('—');
expect(formatDate('not-a-date')).toBe('—');
});
it('timestamp ثانیه‌ای معتبر → رشته شمسی با رقم فارسی', () => {
const out = formatDate(1_700_000_000);
expect(out).not.toBe('—');
expect(out).toMatch(PERSIAN_DIGITS);
});
});
describe('formatDateTime', () => {
it('ورودی نامعتبر → "—"', () => {
expect(formatDateTime(undefined)).toBe('—');
});
it('معتبر → شامل ساعت و رقم فارسی', () => {
const out = formatDateTime(1_700_000_000);
expect(out).not.toBe('—');
expect(out).toMatch(PERSIAN_DIGITS);
});
});
describe('toGregorianDate', () => {
it('YYYY-MM-DD با صفر پیش‌رو', () => {
expect(toGregorianDate(new Date(2024, 0, 5))).toBe('2024-01-05');
expect(toGregorianDate(new Date(2024, 11, 31))).toBe('2024-12-31');
});
});
describe('maskMobile', () => {
it('شماره ۱۱ رقمی را ماسک می‌کند', () => {
expect(maskMobile('09123456789')).toBe('0912***789');
});
it('شماره کوتاه‌تر از ۷ رقم بدون تغییر', () => {
expect(maskMobile('123')).toBe('123');
});
});
describe('cn', () => {
it('مقادیر falsy را حذف و بقیه را با فاصله می‌چسباند', () => {
expect(cn('a', false, null, undefined, 'b')).toBe('a b');
expect(cn()).toBe('');
});
});
describe('toEnglishDigits', () => {
it('رقم فارسی به انگلیسی', () => {
expect(toEnglishDigits('۰۹۱۲۳۴۵۶۷۸۹')).toBe('09123456789');
});
it('رقم عربی به انگلیسی', () => {
expect(toEnglishDigits('٠٩١٢')).toBe('0912');
});
it('ورودی خالی → رشته خالی', () => {
expect(toEnglishDigits('')).toBe('');
});
it('کاراکترهای غیررقمی را دست‌نخورده نگه می‌دارد', () => {
expect(toEnglishDigits('شماره ۰۹')).toBe('شماره 09');
});
});
describe('sanitizeMobileInput', () => {
it('غیررقم حذف، رقم فارسی نرمال، حداکثر ۱۱ رقم', () => {
expect(sanitizeMobileInput('۰۹۱۲-۳۴۵ ۶۷۸۹۰۱۲')).toBe('09123456789');
});
it('فقط حروف → خالی', () => {
expect(sanitizeMobileInput('abc')).toBe('');
});
});
describe('isValidIranMobile / IRAN_MOBILE_RE', () => {
it.each([
['09123456789', true],
['۰۹۱۲۳۴۵۶۷۸۹', true],
['9123456789', false],
['08123456789', false],
['0912345678', false],
['091234567890', false],
])('%s → %s', (input, expected) => {
expect(isValidIranMobile(input as string)).toBe(expected);
});
it('regex مستقیم روی ارقام انگلیسی', () => {
expect(IRAN_MOBILE_RE.test('09120000000')).toBe(true);
});
});
describe('iranMobileSchema', () => {
it('رقم فارسی را پذیرفته و نرمال می‌کند', () => {
expect(iranMobileSchema.parse('۰۹۱۲۳۴۵۶۷۸۹')).toBe('09123456789');
});
it('جداکننده‌ها را حذف می‌کند', () => {
expect(iranMobileSchema.parse('0912-345-6789')).toBe('09123456789');
});
it('شماره نامعتبر → ناموفق', () => {
const r = iranMobileSchema.safeParse('12345');
expect(r.success).toBe(false);
});
});
describe('iranMobileOptionalSchema', () => {
it('رشته خالی مجاز است', () => {
expect(iranMobileOptionalSchema.parse('')).toBe('');
});
it('شماره معتبر نرمال می‌شود', () => {
expect(iranMobileOptionalSchema.parse('۰۹۱۲۳۴۵۶۷۸۹')).toBe('09123456789');
});
it('شماره نیمه‌کاره → ناموفق', () => {
expect(iranMobileOptionalSchema.safeParse('0912').success).toBe(false);
});
});