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>
167 lines
6.9 KiB
TypeScript
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);
|
|
});
|
|
});
|