- 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.
201 lines
7.8 KiB
TypeScript
201 lines
7.8 KiB
TypeScript
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,
|
||
}),
|
||
);
|
||
});
|
||
});
|