Files
clinicpro/assets/admin/pages/BranchWorkingHoursPage.test.tsx
T
hamedandClaude Opus 5 d813843fcd feat(branch): admin UI for branch working hours and rooms, plus real API docs
Three pages, all on the existing design system: BranchesPage lists the current
environment's booking locations with their working-hours and active-room counts,
and two subpages edit the week and the rooms. The list page deliberately does not
create or rename a branch — clinic and doctor detail pages already do that, and
duplicating it would give one physical place two edit surfaces. Route permission
reuses `appointment_settings` rather than inventing a new one.

Two real bugs fell out of exercising this end to end:

`days` was serialising as a JSON *array*, not an object keyed "0".."6" — keys 0..6
are sequential so json_encode collapses them to a list. The client reads days["0"]
either way, so nothing looked broken, but the response shape was unstable: one
missing day would flip the same field to an object. The controller now casts to
stdClass and WorkingHoursTest::testDaysIsAJsonObjectNotAnArray pins it. Found by
curling the endpoint for the docs, not by any test.

`<input type="time">` caps at 23:59, so it can neither display nor produce the
legal end value 1440. An all-day range would have vanished from the form and been
corrupted by the first save. Ranges now carry an explicit end-of-day flag, with a
round-trip test proving 1440 survives.

docs/api/branch.md documents all eight endpoints with responses captured from real
curl runs against ddev, including the 422 and 404 bodies. doctor.md records that
active/timezone now appear on all nine existing address endpoints (additive), and
tenancy.md gains the two lessons this task taught: an aggregate child whose root is
itself declared global inherits no environment and needs a real pair, and
TenantFilter is not a substitute for an explicit ownership check because hard
isolation only applies to a *chosen* context.

Verified: phpunit 1067 tests / 2974 assertions green; slot-mode frozen contract
green; phpstan 14 errors before and after, none in touched files; tsc clean;
vitest 87 files / 612 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 16:48:49 +03:30

167 lines
6.9 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { renderWithProviders } 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 {},
}));
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { Routes, Route } from 'react-router-dom';
import { api } from '../lib/api';
import BranchWorkingHoursPage from './BranchWorkingHoursPage';
const get = api.get as ReturnType<typeof vi.fn>;
const put = api.put as ReturnType<typeof vi.fn>;
const branch = {
id: '1', uuid: 'b1', type: 'clinic', clinic_id: 3, clinic_name: 'کلینیک ما',
name: 'شعبهٔ مرکزی', map: { latitude: null, longitude: null },
address: 'خیابان اول', telephone: '03511111111', active: true,
timezone: 'Asia/Tehran', city: null, province: null,
working_hours_defined: true, rooms_count: 0,
};
function emptyDays(): Record<string, unknown[]> {
return Object.fromEntries(Array.from({ length: 7 }, (_, d) => [String(d), []]));
}
function mockApi(days: Record<string, unknown[]>) {
get.mockImplementation((path: string) => {
if (path === '/api/v1/branches') return Promise.resolve({ success: true, data: [branch] });
if (path.endsWith('/working-hours')) {
return Promise.resolve({
success: true,
data: { branch_uuid: 'b1', timezone: 'Asia/Tehran', defined: true, days },
});
}
return Promise.resolve({ success: true, data: null });
});
put.mockResolvedValue({ success: true, data: { branch_uuid: 'b1', timezone: 'Asia/Tehran', defined: true, days } });
}
function renderPage() {
return renderWithProviders(
<Routes>
<Route path="/admin/branches/:branchUuid/working-hours" element={<BranchWorkingHoursPage />} />
</Routes>,
{ route: '/admin/branches/b1/working-hours' },
);
}
describe('BranchWorkingHoursPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders all seven days and marks the empty ones closed', async () => {
mockApi(emptyDays());
renderPage();
await waitFor(() => expect(screen.getByText('شنبه')).toBeInTheDocument());
expect(screen.getByText('جمعه')).toBeInTheDocument();
expect(screen.getAllByText('بسته')).toHaveLength(7);
});
it('shows stored ranges as times, converting minutes from midnight', async () => {
const days = emptyDays();
days['0'] = [{ sequence: 0, start_minute: 540, end_minute: 780, start_time: '09:00', end_time: '13:00', active: true }];
mockApi(days);
renderPage();
await waitFor(() => expect(screen.getByDisplayValue('09:00')).toBeInTheDocument());
expect(screen.getByDisplayValue('13:00')).toBeInTheDocument();
});
/**
* `<input type="time">` سقفش ۲۳:۵۹ است، پس ۱۴۴۰ با پرچم «تا پایان روز» نمایش داده
* می‌شود و همان ۱۴۴۰ برمی‌گردد — وگرنه اولین ذخیره بازهٔ شبانه‌روزی را خراب می‌کرد.
*/
it('keeps an all-day range at 1440 through a round trip', async () => {
const days = emptyDays();
days['3'] = [{ sequence: 0, start_minute: 0, end_minute: 1440, start_time: '00:00', end_time: '24:00', active: true }];
mockApi(days);
renderPage();
await waitFor(() => expect(screen.getByText('۲۴:۰۰')).toBeInTheDocument());
expect((screen.getByLabelText('تا پایان روز') as HTMLInputElement).checked).toBe(true);
fireEvent.click(screen.getByText('ذخیرهٔ هفته'));
await waitFor(() => expect(put).toHaveBeenCalled());
expect(put.mock.calls[0][1].days['3']).toEqual([{ start_minute: 0, end_minute: 1440 }]);
});
it('turns a normal range into an all-day one when the flag is checked', async () => {
const days = emptyDays();
days['6'] = [{ sequence: 0, start_minute: 540, end_minute: 660, start_time: '09:00', end_time: '11:00', active: true }];
mockApi(days);
renderPage();
await waitFor(() => expect(screen.getByDisplayValue('11:00')).toBeInTheDocument());
fireEvent.click(screen.getByLabelText('تا پایان روز'));
fireEvent.click(screen.getByText('ذخیرهٔ هفته'));
await waitFor(() => expect(put).toHaveBeenCalled());
expect(put.mock.calls[0][1].days['6']).toEqual([{ start_minute: 540, end_minute: 1440 }]);
});
it('sends minutes, not time strings, on save', async () => {
const days = emptyDays();
days['1'] = [{ sequence: 0, start_minute: 600, end_minute: 720, start_time: '10:00', end_time: '12:00', active: true }];
mockApi(days);
renderPage();
await waitFor(() => expect(screen.getByDisplayValue('10:00')).toBeInTheDocument());
fireEvent.click(screen.getByText('ذخیرهٔ هفته'));
await waitFor(() => expect(put).toHaveBeenCalled());
const [path, body] = put.mock.calls[0];
expect(path).toBe('/api/v1/branch/b1/working-hours');
expect(body.days['1']).toEqual([{ start_minute: 600, end_minute: 720 }]);
// هر هفت روز فرستاده می‌شود، چون PUT جایگزینی کامل است نه merge تفاضلی.
expect(Object.keys(body.days)).toHaveLength(7);
});
it('blocks a save whose end is not after its start, without calling the API', async () => {
const days = emptyDays();
days['2'] = [{ sequence: 0, start_minute: 600, end_minute: 720, start_time: '10:00', end_time: '12:00', active: true }];
mockApi(days);
renderPage();
await waitFor(() => expect(screen.getByDisplayValue('12:00')).toBeInTheDocument());
fireEvent.change(screen.getByDisplayValue('12:00'), { target: { value: '09:00' } });
fireEvent.click(screen.getByText('ذخیرهٔ هفته'));
await waitFor(() => expect(screen.getByText(/پایان بازه باید بعد از شروع/)).toBeInTheDocument());
expect(put).not.toHaveBeenCalled();
});
it('copies one day onto the whole week', async () => {
const days = emptyDays();
days['0'] = [{ sequence: 0, start_minute: 480, end_minute: 600, start_time: '08:00', end_time: '10:00', active: true }];
mockApi(days);
renderPage();
await waitFor(() => expect(screen.getByDisplayValue('08:00')).toBeInTheDocument());
fireEvent.click(screen.getByText('اعمال روی همهٔ روزها'));
expect(screen.getAllByDisplayValue('08:00')).toHaveLength(7);
expect(screen.queryByText('بسته')).not.toBeInTheDocument();
});
it('removes a range so the day becomes closed', async () => {
const days = emptyDays();
days['4'] = [{ sequence: 0, start_minute: 540, end_minute: 660, start_time: '09:00', end_time: '11:00', active: true }];
mockApi(days);
renderPage();
await waitFor(() => expect(screen.getByDisplayValue('11:00')).toBeInTheDocument());
fireEvent.click(screen.getByLabelText('حذف بازه'));
expect(screen.getAllByText('بسته')).toHaveLength(7);
});
});