Files
clinicpro/assets/admin/hooks/tour.test.tsx
T
hamed 20c8eaaad9 feat: add TourProgressController and related entities for user tour progress tracking
- Implemented TourProgressController to handle API endpoints for tracking guided tours seen by users.
- Created UserTourProgress entity to store the highest version of tours seen by each user.
- Developed UserTourProgressRepository for database interactions related to user tour progress.
- Introduced TourProgressService to manage business logic for marking tours as seen and retrieving seen maps.
- Added comprehensive tests for API endpoints and entity behavior to ensure functionality and data integrity.
2026-08-10 09:34:14 +03:30

201 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { waitFor } from '@testing-library/react';
import { renderHookWithClient } from '@/test/utils';
vi.mock('@/lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
const drive = vi.fn();
const destroyHandlers: Array<() => void> = [];
vi.mock('driver.js', () => ({
driver: vi.fn((config: { onDestroyed?: () => void }) => {
if (config.onDestroyed) destroyHandlers.push(config.onDestroyed);
return { drive };
}),
}));
vi.mock('driver.js/dist/driver.css', () => ({}));
import { driver } from 'driver.js';
import { api } from '@/lib/api';
import { useTour } from '@/hooks/useTour';
import { useTourProgress } from '@/hooks/useTourProgress';
import { appointmentsTour } from '@/lib/tour/tours/appointments';
const get = api.get as ReturnType<typeof vi.fn>;
const post = api.post as ReturnType<typeof vi.fn>;
const driverMock = driver as unknown as ReturnType<typeof vi.fn>;
function mountAppointmentsAnchors() {
document.body.innerHTML = appointmentsTour.steps
.map((s) => `<div data-tour="${s.anchor}"></div>`)
.join('');
}
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
document.body.innerHTML = '';
destroyHandlers.length = 0;
get.mockReset();
post.mockReset();
drive.mockReset();
driverMock.mockClear();
});
describe('useTourProgress', () => {
it('نقشهٔ دیده‌شده‌ها را از سرور می‌خواند', async () => {
get.mockResolvedValue({ data: { seen: { appointments: 2 } } });
const { result } = renderHookWithClient(() => useTourProgress());
await waitFor(() => expect(result.current.isReady).toBe(true));
expect(result.current.isSeen('appointments', 2)).toBe(true);
expect(result.current.isSeen('appointments', 3)).toBe(false);
expect(result.current.isSeen('patients', 1)).toBe(false);
});
it('ثبت دیده‌شدن، کش را بدون درخواست دوباره به‌روز می‌کند', async () => {
get.mockResolvedValue({ data: { seen: {} } });
post.mockResolvedValue({ data: { tourId: 'appointments', version: 1 } });
const { result } = renderHookWithClient(() => useTourProgress());
await waitFor(() => expect(result.current.isReady).toBe(true));
result.current.markSeen('appointments', 1);
await waitFor(() => expect(result.current.isSeen('appointments', 1)).toBe(true));
expect(post).toHaveBeenCalledWith('/api/v1/my/tours/appointments/seen', { version: 1 });
expect(get).toHaveBeenCalledTimes(1);
});
});
describe('useTour', () => {
it('تور دیده‌نشده را بعد از آماده شدن صفحه خودکار اجرا می‌کند', async () => {
get.mockResolvedValue({ data: { seen: {} } });
mountAppointmentsAnchors();
renderHookWithClient(() => useTour('appointments', { ready: true }));
await waitFor(() => expect(driverMock).toHaveBeenCalled());
await vi.advanceTimersByTimeAsync(400);
expect(drive).toHaveBeenCalledTimes(1);
expect(driverMock.mock.calls[0][0].steps).toHaveLength(appointmentsTour.steps.length);
});
it('شمارندهٔ استپ‌ها با ارقام فارسی است', async () => {
get.mockResolvedValue({ data: { seen: {} } });
mountAppointmentsAnchors();
renderHookWithClient(() => useTour('appointments', { ready: true }));
await vi.advanceTimersByTimeAsync(400);
await waitFor(() => expect(drive).toHaveBeenCalled());
const steps = driverMock.mock.calls[0][0].steps;
expect(steps[0].popover.progressText).toBe('۱ از ۸');
expect(steps[7].popover.progressText).toBe('۸ از ۸');
});
it('رندرهای پیاپی قبل از شلیک تایمر، اجرای خودکار را لغو نمی‌کنند', async () => {
get.mockResolvedValue({ data: { seen: {} } });
mountAppointmentsAnchors();
const { rerender } = renderHookWithClient(() => useTour('appointments', { ready: true }));
// هر رندر تابع start تازه‌ای می‌سازد؛ اگر وابستگیِ effect باشد، cleanup
// تایمر را پاک می‌کند و تور هرگز اجرا نمی‌شود.
for (let i = 0; i < 5; i++) rerender();
await vi.advanceTimersByTimeAsync(400);
await waitFor(() => expect(drive).toHaveBeenCalledTimes(1));
});
it('استپِ بدون المان را حذف می‌کند', async () => {
get.mockResolvedValue({ data: { seen: {} } });
document.body.innerHTML = '<div data-tour="appointments-stats"></div>';
renderHookWithClient(() => useTour('appointments', { ready: true }));
await vi.advanceTimersByTimeAsync(400);
await waitFor(() => expect(drive).toHaveBeenCalled());
expect(driverMock.mock.calls[0][0].steps).toHaveLength(1);
});
it('تور دیده‌شده خودکار اجرا نمی‌شود', async () => {
get.mockResolvedValue({ data: { seen: { appointments: appointmentsTour.version } } });
mountAppointmentsAnchors();
const { result } = renderHookWithClient(() => useTour('appointments', { ready: true }));
await vi.advanceTimersByTimeAsync(400);
expect(drive).not.toHaveBeenCalled();
// ولی دکمهٔ راهنما همچنان کار می‌کند
result.current.start();
expect(drive).toHaveBeenCalledTimes(1);
});
it('تا وقتی وضعیت از سرور نیامده اجرا نمی‌شود', async () => {
get.mockRejectedValue(new Error('network down'));
mountAppointmentsAnchors();
renderHookWithClient(() => useTour('appointments', { ready: true }));
await vi.advanceTimersByTimeAsync(400);
expect(drive).not.toHaveBeenCalled();
});
it('صفحه‌ای که هنوز آماده نیست تور نمی‌گیرد', async () => {
get.mockResolvedValue({ data: { seen: {} } });
mountAppointmentsAnchors();
renderHookWithClient(() => useTour('appointments', { ready: false }));
await vi.advanceTimersByTimeAsync(400);
expect(drive).not.toHaveBeenCalled();
});
it('شناسهٔ ناشناس نه دکمه دارد نه اجرا', async () => {
get.mockResolvedValue({ data: { seen: {} } });
const { result } = renderHookWithClient(() => useTour('nope', { ready: true }));
await vi.advanceTimersByTimeAsync(400);
expect(result.current.available).toBe(false);
result.current.start();
expect(drive).not.toHaveBeenCalled();
});
it('وقتی هیچ المانی روی صفحه نیست، اجرا نمی‌شود', async () => {
get.mockResolvedValue({ data: { seen: {} } });
const { result } = renderHookWithClient(() => useTour('appointments', { ready: true }));
await waitFor(() => expect(result.current.available).toBe(true));
result.current.start();
expect(drive).not.toHaveBeenCalled();
});
it('بستن تور، دیده‌شدن را ثبت می‌کند', async () => {
get.mockResolvedValue({ data: { seen: {} } });
post.mockResolvedValue({ data: { tourId: 'appointments', version: appointmentsTour.version } });
mountAppointmentsAnchors();
renderHookWithClient(() => useTour('appointments', { ready: true }));
await vi.advanceTimersByTimeAsync(400);
await waitFor(() => expect(destroyHandlers).toHaveLength(1));
destroyHandlers[0]();
await waitFor(() =>
expect(post).toHaveBeenCalledWith('/api/v1/my/tours/appointments/seen', {
version: appointmentsTour.version,
}),
);
});
});