adaptServiceSlots labelled its single session "زمانهای خالی" and set end_time to
the last slot's *start* time, so the range shown was shorter than reality. It now
uses the last slot's end_time (falling back to start + total_duration_minutes) and
labels the session with the actual range.
Shift separation was requested but is not implementable from this payload: with a
flat start_times list, a gap between shifts is indistinguishable from a gap left
by a booked appointment. The normal step is duration + buffer, so any threshold
that splits shifts either splits every slot of a long service (step above the
threshold) or invents tabs around booked appointments. A fabricated tab claims a
shift that does not exist, which is worse than one correct tab. Real grouping
belongs to the server-side endpoint task 06 adds.
Also repairs three tests that had been red since adaptSlots changed shape: they
still asserted the old { morning, evening } contract while the function returns an
array of sessions. Suite goes from 4 failures to 1 (an unrelated pre-existing
getStateInfo network timeout).
Task: clinicpro/docs/new_feture/taskes/task-00b-nobat724-service-mode/
Slot-mode contract: adaptSlots untouched
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
212 lines
8.9 KiB
JavaScript
212 lines
8.9 KiB
JavaScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { adaptSlots, adaptServiceSlots, hasAvailable } from '@/lib/appointmentSlots';
|
|
import { getAccessToken, setAccessToken, clearAccessToken } from '@/lib/tokenStore';
|
|
import { sanitizeHtml, safeJsonParse } from '@/lib/sanitize';
|
|
import { defineAbilitiesFor } from '@/lib/ability';
|
|
import { setRefreshCookie, clearRefreshCookie, COOKIE_NAME } from '@/lib/refreshCookie';
|
|
import { buildPatientUser } from '@/lib/representationAdapters';
|
|
|
|
describe('appointmentSlots', () => {
|
|
// مرزِ شیفت را بکاند میدهد؛ فرانت تقسیم صبح/عصر نمیسازد. سه تست قبلی روی
|
|
// قرارداد قدیمیِ `{ morning, evening }` نوشته شده بودند و از زمانی که خروجی به
|
|
// آرایهٔ session تغییر کرد قرمز مانده بودند.
|
|
const resp = {
|
|
sessions: [
|
|
{
|
|
start_time: '09:00',
|
|
end_time: '12:00',
|
|
slots: [
|
|
{ start_time: '09:00', is_available: true },
|
|
{ start_time: '11:30', is_available: false },
|
|
],
|
|
},
|
|
{
|
|
start_time: '16:00',
|
|
end_time: '20:00',
|
|
slots: [{ start_time: '16:00', is_available: true }],
|
|
},
|
|
],
|
|
};
|
|
|
|
it('adaptSlots هر شیفتِ بکاند را یک session با برچسب بازه میکند', () => {
|
|
const sessions = adaptSlots(resp);
|
|
expect(sessions).toHaveLength(2);
|
|
expect(sessions[0].label).toBe('09:00 - 12:00');
|
|
expect(sessions[1].label).toBe('16:00 - 20:00');
|
|
expect(sessions[0].slots).toHaveLength(2);
|
|
});
|
|
|
|
it('adaptSlots ساختار data.sessions را هم میپذیرد', () => {
|
|
expect(adaptSlots({ data: resp })).toHaveLength(2);
|
|
});
|
|
|
|
it('adaptSlots شیفتِ بدون اسلات را حذف میکند', () => {
|
|
const withEmpty = { sessions: [...resp.sessions, { start_time: '21:00', end_time: '22:00', slots: [] }] };
|
|
expect(adaptSlots(withEmpty)).toHaveLength(2);
|
|
});
|
|
|
|
it('adaptSlots ورودی خالی → آرایهٔ خالی', () => {
|
|
expect(adaptSlots(null)).toEqual([]);
|
|
});
|
|
|
|
it('hasAvailable', () => {
|
|
expect(hasAvailable([{ is_available: false }, { is_available: true }])).toBe(true);
|
|
expect(hasAvailable([{ is_available: false }])).toBe(false);
|
|
expect(hasAvailable([])).toBe(false);
|
|
expect(hasAvailable(null)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('adaptServiceSlots', () => {
|
|
// پاسخ واقعی: شیفت ۰۹:۰۰–۱۲:۰۰، دو سرویس ۲۰+۱۵، بافر ۱۰ ⇒ گام ۴۵ دقیقه.
|
|
const serviceResp = {
|
|
data: {
|
|
total_duration_minutes: 35,
|
|
buffer_minutes: 10,
|
|
start_times: [
|
|
{ start: 1785562200, end: 1785564300, start_time: '09:00', end_time: '09:35' },
|
|
{ start: 1785564900, end: 1785567000, start_time: '09:45', end_time: '10:20' },
|
|
{ start: 1785567600, end: 1785569700, start_time: '10:30', end_time: '11:05' },
|
|
{ start: 1785570300, end: 1785572400, start_time: '11:15', end_time: '11:50' },
|
|
],
|
|
},
|
|
};
|
|
|
|
it('یک session با بازهٔ واقعی میسازد', () => {
|
|
const [session] = adaptServiceSlots(serviceResp);
|
|
expect(session.start_time).toBe('09:00');
|
|
// پایانِ آخرین نوبت، نه زمانِ شروعش.
|
|
expect(session.end_time).toBe('11:50');
|
|
expect(session.label).toBe('09:00 - 11:50');
|
|
expect(session.slots).toHaveLength(4);
|
|
});
|
|
|
|
it('همهٔ زمانهای پیشنهادی available علامت میخورند', () => {
|
|
const [session] = adaptServiceSlots(serviceResp);
|
|
expect(session.slots.every((s) => s.is_available)).toBe(true);
|
|
expect(hasAvailable(session.slots)).toBe(true);
|
|
});
|
|
|
|
it('ساختار بدون data را هم میپذیرد', () => {
|
|
expect(adaptServiceSlots(serviceResp.data)).toHaveLength(1);
|
|
});
|
|
|
|
it('نبودِ end_time در پاسخ → از total_duration_minutes ساخته میشود', () => {
|
|
const withoutEnd = {
|
|
data: {
|
|
total_duration_minutes: 35,
|
|
start_times: [{ start_time: '09:00' }],
|
|
},
|
|
};
|
|
expect(adaptServiceSlots(withoutEnd)[0].end_time).toBe('09:35');
|
|
});
|
|
|
|
it('start_times خالی → آرایهٔ خالی', () => {
|
|
expect(adaptServiceSlots({ data: { start_times: [] } })).toEqual([]);
|
|
expect(adaptServiceSlots(null)).toEqual([]);
|
|
});
|
|
|
|
it('یک اسلات تنها → بازهٔ همان اسلات', () => {
|
|
const single = { data: { start_times: [{ start_time: '15:00', end_time: '15:30' }] } };
|
|
expect(adaptServiceSlots(single)[0].label).toBe('15:00 - 15:30');
|
|
});
|
|
});
|
|
|
|
describe('tokenStore', () => {
|
|
beforeEach(() => clearAccessToken());
|
|
it('set/get/clear', () => {
|
|
expect(getAccessToken()).toBeNull();
|
|
setAccessToken('tok');
|
|
expect(getAccessToken()).toBe('tok');
|
|
clearAccessToken();
|
|
expect(getAccessToken()).toBeNull();
|
|
});
|
|
it('set با مقدار falsy → null', () => {
|
|
setAccessToken('');
|
|
expect(getAccessToken()).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('sanitize', () => {
|
|
it('غیررشته → رشته خالی', () => {
|
|
expect(sanitizeHtml(null)).toBe('');
|
|
expect(sanitizeHtml(123)).toBe('');
|
|
});
|
|
it('تگ مجاز حفظ، اسکریپت حذف', () => {
|
|
const out = sanitizeHtml('<p>سلام</p><script>alert(1)</script>');
|
|
expect(out).toContain('<p>سلام</p>');
|
|
expect(out).not.toContain('<script>');
|
|
});
|
|
it('safeJsonParse معتبر → object', () => {
|
|
expect(safeJsonParse('{"a":1}')).toEqual({ a: 1 });
|
|
});
|
|
it('safeJsonParse نامعتبر → fallback', () => {
|
|
expect(safeJsonParse('{bad', { x: 1 })).toEqual({ x: 1 });
|
|
expect(safeJsonParse('')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('ability (CASL)', () => {
|
|
it('کاربر لاگین → دسترسی Dashboard، نه Login', () => {
|
|
const a = defineAbilitiesFor({ id: 1 });
|
|
expect(a.can('access', 'Dashboard')).toBe(true);
|
|
expect(a.can('access', 'Login')).toBe(false);
|
|
});
|
|
it('بدون کاربر → دسترسی Login، نه Dashboard', () => {
|
|
const a = defineAbilitiesFor(null);
|
|
expect(a.can('access', 'Login')).toBe(true);
|
|
expect(a.can('access', 'Dashboard')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('refreshCookie (cookieDomain از طریق set)', () => {
|
|
let response;
|
|
beforeEach(() => {
|
|
response = { cookies: { set: vi.fn() } };
|
|
});
|
|
it('دامنهی واقعی → "." + دو لیبل آخر', () => {
|
|
setRefreshCookie(response, 'rt', 'arak-nobat.ir');
|
|
const opts = response.cookies.set.mock.calls[0][2];
|
|
expect(response.cookies.set.mock.calls[0][0]).toBe(COOKIE_NAME);
|
|
expect(opts.domain).toBe('.arak-nobat.ir');
|
|
expect(opts.httpOnly).toBe(true);
|
|
});
|
|
it('host سهلیبلی → فقط دو لیبل آخر', () => {
|
|
setRefreshCookie(response, 'rt', 'www.arak-nobat.ir');
|
|
expect(response.cookies.set.mock.calls[0][2].domain).toBe('.arak-nobat.ir');
|
|
});
|
|
it('localhost و IP → domain undefined', () => {
|
|
setRefreshCookie(response, 'rt', 'localhost:3000');
|
|
expect(response.cookies.set.mock.calls[0][2].domain).toBeUndefined();
|
|
response.cookies.set.mockClear();
|
|
setRefreshCookie(response, 'rt', '127.0.0.1');
|
|
expect(response.cookies.set.mock.calls[0][2].domain).toBeUndefined();
|
|
});
|
|
it('clearRefreshCookie با maxAge=0', () => {
|
|
clearRefreshCookie(response, 'arak-nobat.ir');
|
|
expect(response.cookies.set.mock.calls[0][2].maxAge).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('representationAdapters.buildPatientUser', () => {
|
|
it('ساختار double-nested را flatten میکند', () => {
|
|
const out = buildPatientUser({
|
|
data: { data: { label: 'علی', family: 'رضایی', phone: '09120000000', national_code: '0013542419' } },
|
|
});
|
|
expect(out.name).toBe('علی رضایی');
|
|
expect(out.phone).toBe('09120000000');
|
|
expect(out.national_code).toBe('0013542419');
|
|
});
|
|
it('ورودی خالی → مقادیر پیشفرض', () => {
|
|
const out = buildPatientUser(null);
|
|
expect(out.name).toBe('');
|
|
expect(out.profile).toBe('/assets/images/profile-user.png');
|
|
expect(out.turns).toEqual({ current: [], done: [] });
|
|
});
|
|
it('extras بهعنوان fallback استفاده میشود', () => {
|
|
const out = buildPatientUser({}, { realName: 'مهمان', mobile: '0935' });
|
|
expect(out.name).toBe('مهمان');
|
|
expect(out.phone).toBe('0935');
|
|
});
|
|
});
|