feat: port نوبت‌ها (appointments) tab from tauri to patient detail page

Replace the placeholder row list on the patient detail «نوبت‌ها» tab with the
card-grid design ported pixel-for-pixel from clinic-pro-tauri TurnsSection:

- New AppointmentTurnCard mirrors tauri TurnsCard (success icon, title, date/time
  chips, personnel, status). Status uses the live AppointmentStatusDropdown
  instead of the tauri mock.
- New AppointmentsTab in PatientDetailPage: sort/filter toolbar (client-side) +
  reserve/new buttons linking to existing /admin/appointments pages + card grid.
- Add CalendarD/ClockP/UserD/StatusGlobe icons (verbatim from tauri).
- Backend: expose version on GET /patient/{uuid}/appointments so the status
  dropdown can optimistic-lock. No new endpoint.
- Tests: PatientAppointmentsTest (shape/version/order/empty/ownership) +
  AppointmentTurnCard + tab data/empty cases. docs/api/patient.md updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-16 11:30:12 +03:30
co-authored by Claude Opus 4.8
parent 7b87fda8f9
commit 1a783971c9
8 changed files with 379 additions and 4 deletions
@@ -0,0 +1,38 @@
import { describe, it, expect, vi } from 'vitest';
import { screen } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import AppointmentTurnCard, { type AppointmentCardData } from './AppointmentTurnCard';
const base: AppointmentCardData = {
uuid: 'a1', starts_at: 1754000000, status: 'confirmed', version: 1,
doctor_name: 'دکتر ژیلا فتحی', service_name: null,
};
describe('AppointmentTurnCard', () => {
it('renders title, date/time labels, doctor and the live status label', () => {
renderWithProviders(<AppointmentTurnCard appointment={base} queryKey={['x']} />);
expect(screen.getByText('نوبت')).toBeInTheDocument(); // no service → generic title
expect(screen.getByText('تاریخ:')).toBeInTheDocument();
expect(screen.getByText('ساعت:')).toBeInTheDocument();
expect(screen.getByText('پرسنل:')).toBeInTheDocument();
expect(screen.getByText('دکتر ژیلا فتحی')).toBeInTheDocument();
expect(screen.getByText('قطعی شده')).toBeInTheDocument(); // confirmed via STATUS_META
});
it('falls back to «—» when the doctor is missing', () => {
renderWithProviders(<AppointmentTurnCard appointment={{ ...base, doctor_name: null }} queryKey={['x']} />);
expect(screen.getByText('—')).toBeInTheDocument();
});
it('prefers the service name as the title when present', () => {
renderWithProviders(<AppointmentTurnCard appointment={{ ...base, service_name: 'لیزر' }} queryKey={['x']} />);
expect(screen.getByText('لیزر')).toBeInTheDocument();
});
});
@@ -0,0 +1,93 @@
import type { CSSProperties, ReactNode } from 'react';
import { formatDate, formatTime } from '../lib/utils';
import {
FilesServiceSuccess, FilesServiceMore, CalendarD, ClockP, UserD, StatusGlobe,
} from './icons/FilesServiceIcons';
import AppointmentStatusDropdown from './ui/AppointmentStatusDropdown';
export interface AppointmentCardData {
uuid: string;
starts_at: number;
status: string;
version: number;
doctor_name?: string | null;
service_name?: string | null;
}
/**
* label/value row inside the turn card — mirrors tauri ServiceInfoRow
* (separatedValues variant): icon+label on one side, value (optionally chip) on
* the other.
*/
function InfoRow({ icon, label, value, chip = false, valueStyle }: {
icon: ReactNode; label: string; value: ReactNode; chip?: boolean; valueStyle?: CSSProperties;
}) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{icon}
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 12, color: '#616161' }}>{label}:</span>
</div>
<span
className={chip ? 'dark:bg-[#404040] dark:text-[#D7D8ED]' : 'dark:text-[#D7D8ED]'}
style={{
fontSize: 12, color: '#525252', textAlign: 'left',
...(chip ? { background: '#efefef', borderRadius: 8, padding: '2px 8px', color: '#2f2f2f' } : {}),
...valueStyle,
}}
>
{value}
</span>
</div>
);
}
/**
* A patient «نوبت» card — ported pixel-for-pixel from tauri
* files/services/TurnsCard. The status uses the admin's live
* AppointmentStatusDropdown (backed by PATCH /appointment/{uuid}/status)
* instead of the tauri mock.
*/
export default function AppointmentTurnCard({ appointment, queryKey }: {
appointment: AppointmentCardData;
queryKey: unknown[];
}) {
const title = appointment.service_name || 'نوبت';
return (
<div
className="bg-white dark:bg-[#222433] border border-[#EDEDED] dark:border-[#35343D]"
style={{ width: 277, maxWidth: '100%', minHeight: 249, borderRadius: 12, padding: 14, display: 'flex', flexDirection: 'column', gap: 10, boxShadow: '0 1px 6px rgba(15,23,42,0.06)' }}
>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 2 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<FilesServiceSuccess />
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 3, minWidth: 0 }}>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 600, color: '#525252', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 180 }}>{title}</span>
</div>
</div>
<FilesServiceMore style={{ color: '#9CA3AF', flexShrink: 0 }} />
</div>
<div className="dark:border-[#35343D]" style={{ borderTop: '1px solid #F1F1F1' }} />
{/* Middle: date & time chips */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<InfoRow icon={<CalendarD />} label="تاریخ" chip value={formatDate(appointment.starts_at)} />
<InfoRow icon={<ClockP />} label="ساعت" chip value={formatTime(appointment.starts_at)} />
</div>
<div className="dark:border-[#35343D]" style={{ borderTop: '1px solid #F1F1F1' }} />
{/* Bottom: personnel & status */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<InfoRow icon={<UserD size={19} />} label="پرسنل" value={appointment.doctor_name || '—'} valueStyle={{ fontWeight: 500 }} />
<InfoRow
icon={<StatusGlobe size={19} />}
label="وضعیت"
value={<AppointmentStatusDropdown uuid={appointment.uuid} currentStatus={appointment.status} version={appointment.version} queryKey={queryKey} />}
/>
</div>
</div>
);
}
@@ -192,3 +192,50 @@ export function TabBody({ color = '#616161', style }: IconProps) {
</svg>
);
}
/* ── Turn card info-row icons (tauri CalendarD / ClockP / UserD / status) ───── */
export function CalendarD({ color = '#616161', size = 20, style }: IconProps & { size?: number }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" style={style}>
<path d="M8 2V5" stroke={color} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M16 2V5" stroke={color} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M3.5 9.08984H20.5" stroke={color} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M21 8.5V17C21 20 19.5 22 16 22H8C4.5 22 3 20 3 17V8.5C3 5.5 4.5 3.5 8 3.5H16C19.5 3.5 21 5.5 21 8.5Z" stroke={color} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M15.6947 13.7002H15.7037" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M15.6947 16.7002H15.7037" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M11.9955 13.7002H12.0045" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M11.9955 16.7002H12.0045" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8.29431 13.7002H8.30329" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8.29431 16.7002H8.30329" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function ClockP({ color = '#525252', size = 20, style }: IconProps & { size?: number }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 20 20" fill="none" style={style}>
<path d="M18.3337 10.0003C18.3337 14.6003 14.6003 18.3337 10.0003 18.3337C5.40033 18.3337 1.66699 14.6003 1.66699 10.0003C1.66699 5.40033 5.40033 1.66699 10.0003 1.66699C14.6003 1.66699 18.3337 5.40033 18.3337 10.0003Z" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M13.0914 12.6505L10.5081 11.1088C10.0581 10.8421 9.69141 10.2005 9.69141 9.67546V6.25879" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function UserD({ color = '#616161', size = 20, style }: IconProps & { size?: number }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" style={style}>
<path d="M12 12C14.7614 12 17 9.76142 17 7C17 4.23858 14.7614 2 12 2C9.23858 2 7 4.23858 7 7C7 9.76142 9.23858 12 12 12Z" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M20.5899 22C20.5899 18.13 16.7399 15 11.9999 15C7.25991 15 3.40991 18.13 3.40991 22" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function StatusGlobe({ color = '#616161', size = 20, style }: IconProps & { size?: number }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 20 20" fill="none" style={style}>
<path d="M2.04166 12.4751C2.93332 15.3418 5.33333 17.5501 8.31666 18.1584" stroke={color} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M1.70834 9.14984C2.13334 4.9415 5.68334 1.6665 10 1.6665C14.3167 1.6665 17.8667 4.94984 18.2917 9.14984" stroke={color} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M11.675 18.1666C14.65 17.5583 17.0417 15.3749 17.95 12.5166" stroke={color} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
@@ -169,4 +169,34 @@ describe('PatientDetailPage (پرونده تب‌دار)', () => {
expect(await screen.findByPlaceholderText('متن پیام...')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'ارسال' })).toBeInTheDocument();
});
it('renders appointment turn cards + toolbar on the نوبت‌ها tab', async () => {
get.mockImplementation((url: string) => {
if (url === '/api/v1/patient/r1') return Promise.resolve({ success: true, data: {
uuid: 'r1', user_name: 'ساغر صابری', record_number: 'P-1001', created_at: 1700000000, profile: null,
} });
if (url === '/api/v1/patient/r1/appointments') return Promise.resolve({ success: true, data: [
{ uuid: 'a1', starts_at: 1754000000, ends_at: 1754001800, status: 'confirmed', version: 1, doctor_name: 'دکتر راد', service_name: null },
] });
return Promise.resolve({ success: true, data: [] });
});
renderDetail();
await loaded();
fireEvent.click(screen.getByText('نوبت‌ها'));
// card: generic title + doctor + live status label (confirmed → قطعی شده)
expect(await screen.findByText('دکتر راد')).toBeInTheDocument();
expect(screen.getByText('قطعی شده')).toBeInTheDocument();
expect(screen.getByText('تاریخ:')).toBeInTheDocument();
expect(screen.getByText('ساعت:')).toBeInTheDocument();
// toolbar buttons link to the existing appointment pages
expect(screen.getByRole('link', { name: /نوبت رزرو/ })).toHaveAttribute('href', '/admin/appointments/reserve');
expect(screen.getByRole('link', { name: /نوبت جدید/ })).toHaveAttribute('href', '/admin/appointments/new');
});
it('shows the empty state on the نوبت‌ها tab when there are no appointments', async () => {
renderDetail();
await loaded();
fireEvent.click(screen.getByText('نوبت‌ها'));
expect(await screen.findByText('نوبتی ثبت نشده است')).toBeInTheDocument();
});
});
+71 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useSearchParams, Link } from 'react-router-dom';
import {
@@ -20,7 +20,9 @@ import PersianDateInput from '../components/ui/PersianDateInput';
import PriceInput from '../components/ui/PriceInput';
import PatientCaseBanner, { Breadcrumb } from '../components/PatientCaseBanner';
import SessionServiceCard, { type SessionCardData } from '../components/SessionServiceCard';
import AppointmentTurnCard, { type AppointmentCardData } from '../components/AppointmentTurnCard';
import InvoiceSummaryModal from '../components/InvoiceSummaryModal';
import SearchableSelect from '../components/ui/SearchableSelect';
import { TurnsFilter, AddTurn } from '../components/icons/FilesToolbarIcons';
import {
TabServices, TabInfo, TabCalendar, TabCard, TabWallet, TabSMS, TabCall, TabAttach, TabBody,
@@ -231,8 +233,7 @@ export default function PatientDetailPage() {
)}
</div>
) : tab === 'appointments' ? (
<TabList q={appointmentsQ} emptyLabel="نوبتی ثبت نشده است"
row={(a) => ({ title: a.service_name || a.doctor_name || 'نوبت', meta: a.date ? formatDate(a.date) : (a.starts_at ? formatDate(a.starts_at) : ''), badge: a.status_label || a.status })} />
<AppointmentsTab uuid={uuid!} q={appointmentsQ} />
) : tab === 'payments' ? (
<TabList q={paymentsQ} emptyLabel="پرداختی ثبت نشده است"
row={(p) => ({ title: formatRial(p.amount_rials), meta: [p.created_at ? formatDate(p.created_at) : '', p.gateway].filter(Boolean).join(' · '), badge: PAYMENT_STATUS[p.status] || p.status })} />
@@ -688,6 +689,73 @@ function WalletTab({ uuid }: { uuid: string }) {
);
}
const APPT_SORT_OPTS = [
{ value: 'newest', label: 'جدیدترین' },
{ value: 'oldest', label: 'قدیمی‌ترین' },
{ value: 'reserved', label: 'رزرو شده' },
{ value: 'done', label: 'انجام شده' },
{ value: 'cancelled', label: 'لغو شده' },
];
const CANCELLED_STATUSES = ['cancelled_by_doctor', 'cancelled_by_user', 'no_show', 'expired'];
/**
* نوبت‌ها — the appointments tab, ported from tauri TurnsSection: a sort/filter
* toolbar + reserve/new buttons, over a grid of AppointmentTurnCard. Sorting and
* filtering are client-side over the already-fetched list (tauri leaves them inert).
*/
function AppointmentsTab({ uuid, q }: {
uuid: string;
q: { data?: ApiResponse<AppointmentCardData[]>; isLoading: boolean };
}) {
const [sort, setSort] = useState('newest');
const items = q.data?.data ?? [];
const queryKey = ['patient-appointments', uuid];
const shown = useMemo(() => {
let list = [...items];
if (sort === 'reserved') list = list.filter((a) => a.status !== 'completed' && !CANCELLED_STATUSES.includes(a.status));
else if (sort === 'done') list = list.filter((a) => a.status === 'completed');
else if (sort === 'cancelled') list = list.filter((a) => CANCELLED_STATUSES.includes(a.status));
list.sort((a, b) => (sort === 'oldest' ? a.starts_at - b.starts_at : b.starts_at - a.starts_at));
return list;
}, [items, sort]);
return (
<div>
{/* toolbar: sort + filter (right of RTL) · reserve/new buttons (left) */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 320, maxWidth: '100%' }}>
<SearchableSelect options={APPT_SORT_OPTS} value={sort} onChange={(v) => setSort(String(v ?? 'newest'))} height={48} />
</div>
<button type="button" aria-label="فیلتر" className="flex items-center justify-center rounded-[4px] cursor-pointer" style={{ width: 62, height: 48, border: '1px solid #5559ce', background: 'transparent' }}>
<TurnsFilter color="#5559ce" />
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Link to="/admin/appointments/reserve" className="flex items-center justify-center gap-2 rounded-[8px]" style={{ height: 48, minWidth: 137, border: '1px solid #5559ce', color: '#5559ce', textDecoration: 'none', padding: '0 16px', fontSize: 14 }}>
<AddTurn color="#5559ce" /> نوبت رزرو
</Link>
<Link to="/admin/appointments/new" className="flex items-center justify-center gap-2 rounded-[8px]" style={{ height: 48, minWidth: 137, background: '#5559ce', color: '#fff', textDecoration: 'none', padding: '0 16px', fontSize: 14 }}>
<AddTurn color="#fff" /> نوبت جدید
</Link>
</div>
</div>
{q.isLoading ? (
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
) : shown.length === 0 ? (
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>نوبتی ثبت نشده است</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', columnGap: 6, rowGap: 10, alignItems: 'stretch' }}>
{shown.map((a) => <AppointmentTurnCard key={a.uuid} appointment={a} queryKey={queryKey} />)}
</div>
)}
</div>
);
}
function TabList({ q, emptyLabel, row }: {
q: { data?: ApiResponse<any[]>; isLoading: boolean };
emptyLabel: string;
+2 -1
View File
@@ -371,6 +371,7 @@ GET /api/v1/patient/{uuid}/appointments
"starts_at": 1754000000,
"ends_at": 1754001800,
"status": "confirmed",
"version": 1,
"doctor_name": "دکتر ژیلا فتحی",
"service_name": null,
"price_rials": null,
@@ -380,7 +381,7 @@ GET /api/v1/patient/{uuid}/appointments
}
```
`status` یکی از: `pending`، `confirmed`، `completed`، `cancelled_by_doctor`، `cancelled_by_user`، `no_show`، `expired`. فیلدهای `service_name`/`price_rials` فعلاً همیشه `null` هستند (نوبت خدمت/قیمت مستقل ندارد).
`status` یکی از: `pending`، `confirmed`، `completed`، `cancelled_by_doctor`، `cancelled_by_user`، `no_show`، `expired`. فیلدهای `service_name`/`price_rials` فعلاً همیشه `null` هستند (نوبت خدمت/قیمت مستقل ندارد). `version` نسخهٔ خوش‌بینانهٔ (optimistic-lock) نوبت است و برای فراخوانی `PATCH /api/v1/appointment/{uuid}/status` لازم است.
**Errors:**
@@ -830,6 +830,7 @@ class PatientController extends BaseController
'starts_at' => $a->getSlotStart(),
'ends_at' => $a->getSlotEnd(),
'status' => $a->getStatus(),
'version' => $a->getVersion(),
'doctor_name' => $a->getDoctor()->getName(),
'service_name' => null,
'price_rials' => null,
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace App\Tests\Patient;
use App\Appointment\Entity\Appointment;
use App\Doctor\Entity\Doctor;
use App\Patient\Entity\PatientRecord;
use App\Tests\ApiTestCase;
/**
* List Patient Appointments (تب نوبت‌ها): shape (incl. version), ordering,
* empty boundary, and ownership scoping.
*/
class PatientAppointmentsTest extends ApiTestCase
{
/** @return array{0: \App\Auth\Entity\User, 1: Doctor, 2: PatientRecord} */
private function recordFor(): array
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر ژیلا فتحی');
$this->em->persist($doctor);
$this->em->flush();
$patient = $this->createUser(['ROLE_USER']);
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
$this->em->persist($record);
$this->em->flush();
return [$owner, $doctor, $record];
}
private function appointment(Doctor $doctor, PatientRecord $record, int $slotStart): Appointment
{
$appt = new Appointment($doctor, $record->getUser(), $slotStart, $slotStart + 1800);
$this->em->persist($appt);
$this->em->flush();
return $appt;
}
public function testListReturnsShapeWithVersion(): void
{
[$owner, $doctor, $record] = $this->recordFor();
$this->appointment($doctor, $record, 1_754_000_000);
$list = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/appointments', $owner);
self::assertSame(200, $this->responseCode());
self::assertCount(1, $list['data']);
$row = $list['data'][0];
foreach (['uuid', 'starts_at', 'ends_at', 'status', 'version', 'doctor_name'] as $key) {
self::assertArrayHasKey($key, $row);
}
self::assertSame(1, $row['version']);
self::assertSame('pending', $row['status']);
self::assertSame('دکتر ژیلا فتحی', $row['doctor_name']);
self::assertSame(1_754_000_000, $row['starts_at']);
}
public function testSortedByStartDescending(): void
{
[$owner, $doctor, $record] = $this->recordFor();
$this->appointment($doctor, $record, 1_754_000_000);
$this->appointment($doctor, $record, 1_755_000_000);
$list = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/appointments', $owner);
self::assertCount(2, $list['data']);
self::assertSame(1_755_000_000, $list['data'][0]['starts_at']);
self::assertSame(1_754_000_000, $list['data'][1]['starts_at']);
}
public function testEmptyWhenNoAppointments(): void
{
[$owner, , $record] = $this->recordFor();
$list = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/appointments', $owner);
self::assertSame(200, $this->responseCode());
self::assertCount(0, $list['data']);
}
public function testNotFoundForUnknownRecord(): void
{
[$owner] = $this->recordFor();
$this->authJson('GET', '/api/v1/patient/00000000-0000-0000-0000-000000000000/appointments', $owner);
self::assertSame(404, $this->responseCode());
}
public function testOwnershipScoped(): void
{
[, $doctor, $record] = $this->recordFor();
$this->appointment($doctor, $record, 1_754_000_000);
[$other] = $this->recordFor();
$this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/appointments', $other);
self::assertSame(404, $this->responseCode());
}
}