feat(patients): phase B2 — attachments (ضمیمه)
Add patient file attachments: a new PatientAttachment entity (record-scoped,
CASCADE) + repository, and endpoints GET /patient/{uuid}/attachments,
POST /patient/{uuid}/attachment (raw-body upload) and DELETE
/patient/attachment/{uuid} (owner-scoped). Factor the shared raw-body upload
logic into FileUploadService. Wire the "ضمیمه" tab in PatientDetailPage
(upload + list + delete). PHPUnit covers list/delete/ownership; Vitest covers
the tab. API docs updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -58,4 +58,12 @@ describe('PatientDetailPage (پرونده تبدار)', () => {
|
||||
fireEvent.click(screen.getByText('پیامها'));
|
||||
expect(screen.getByText(/بهزودی تکمیل میشود/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the attachments tab with an upload button', async () => {
|
||||
renderDetail();
|
||||
await screen.findByText('ساغر صابری');
|
||||
fireEvent.click(screen.getByText('ضمیمه'));
|
||||
expect(await screen.findByRole('button', { name: /آپلود فایل جدید/ })).toBeInTheDocument();
|
||||
expect(await screen.findByText('هنوز فایلی ضمیمه نشده است.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import {
|
||||
ChevronRightIcon, PencilIcon, ClipboardDocumentCheckIcon, DocumentTextIcon,
|
||||
CalendarDaysIcon, CreditCardIcon, BanknotesIcon, ChatBubbleLeftRightIcon,
|
||||
PhoneArrowUpRightIcon, PaperClipIcon, ClipboardDocumentListIcon,
|
||||
ArrowUpTrayIcon, TrashIcon, DocumentIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { PatientRecord } from '../types';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { formatDate } from '../lib/utils';
|
||||
|
||||
type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'messages' | 'callcenter' | 'attach' | 'records';
|
||||
@@ -120,6 +123,8 @@ export default function PatientDetailPage() {
|
||||
) : 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 })} />
|
||||
) : tab === 'attach' ? (
|
||||
<AttachmentsTab uuid={uuid!} />
|
||||
) : (
|
||||
<Placeholder label={TABS.find((t) => t.key === tab)!.label} />
|
||||
)}
|
||||
@@ -127,6 +132,88 @@ export default function PatientDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
interface Attachment { uuid: string; name: string; url: string; mime?: string | null; size?: number | null }
|
||||
|
||||
const formatBytes = (n?: number | null) => {
|
||||
if (!n) return '';
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
/** ضمیمه — patient attachments: upload (raw body), list, delete. */
|
||||
function AttachmentsTab({ uuid }: { uuid: string }) {
|
||||
const qc = useQueryClient();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery<ApiResponse<Attachment[]>>({
|
||||
queryKey: ['patient-attachments', uuid],
|
||||
queryFn: () => api.get(`/api/v1/patient/${uuid}/attachments`),
|
||||
});
|
||||
const items = data?.data ?? [];
|
||||
|
||||
const del = useMutation({
|
||||
mutationFn: (attUuid: string) => api.delete(`/api/v1/patient/attachment/${attUuid}`),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['patient-attachments', uuid] }); toast.success('ضمیمه حذف شد'); },
|
||||
onError: (e: any) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const onFile = async (file: File) => {
|
||||
setUploading(true);
|
||||
try {
|
||||
const token = useAuthStore.getState().token;
|
||||
const res = await fetch(`/api/v1/patient/${uuid}/attachment?name=${encodeURIComponent(file.name)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${file.name}"`,
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: file,
|
||||
});
|
||||
if (!res.ok) throw new Error('خطا در آپلود فایل');
|
||||
qc.invalidateQueries({ queryKey: ['patient-attachments', uuid] });
|
||||
toast.success('فایل آپلود شد');
|
||||
} catch (e: any) {
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<input ref={fileRef} type="file" hidden onChange={(e) => e.target.files?.[0] && onFile(e.target.files[0])} />
|
||||
<button className="btn primary" disabled={uploading} onClick={() => fileRef.current?.click()}>
|
||||
<ArrowUpTrayIcon style={{ width: 16 }} /> {uploading ? 'در حال آپلود...' : 'آپلود فایل جدید'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
||||
) : items.length === 0 ? (
|
||||
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>هنوز فایلی ضمیمه نشده است.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map((a) => (
|
||||
<div key={a.uuid} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<DocumentIcon style={{ width: 22, color: 'var(--primary)', flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<a href={a.url} target="_blank" rel="noreferrer" style={{ fontWeight: 600, fontSize: 14, color: 'var(--text)', textDecoration: 'none', wordBreak: 'break-all' }}>{a.name}</a>
|
||||
{a.size ? <div style={{ fontSize: 12, color: 'var(--text-3)' }}>{formatBytes(a.size)}</div> : null}
|
||||
</div>
|
||||
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)' }} onClick={() => del.mutate(a.uuid)}><TrashIcon style={{ width: 16 }} /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabList({ q, emptyLabel, row }: {
|
||||
q: { data?: ApiResponse<any[]>; isLoading: boolean };
|
||||
emptyLabel: string;
|
||||
|
||||
@@ -28,6 +28,10 @@ services:
|
||||
arguments:
|
||||
$environment: '%kernel.environment%'
|
||||
|
||||
App\Shared\Service\FileUploadService:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
App\Doctor\Controller\DoctorController:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
@@ -483,3 +483,26 @@ When an appointment's status changes to `confirmed` via `PATCH /api/v1/appointme
|
||||
**انتساب پزشک:** هر `PatientSession` در پاسخ، `doctor_uuid` و `doctor_name` را از روی نوبتِ متناظر برمیگرداند؛ پس در پروندهی کلینیک مشخص است هر مراجعه برای کدام پزشک بوده است.
|
||||
|
||||
**آدرس نوبت:** هنگام رزرو، `address_id` خودکار از `location_id` همان session برنامهی هفتگی ست میشود (در همهی مسیرهای رزرو). ثبت `location_id` برای هر شیفت فعال در برنامهی هفتگی الزامی است (`POST/PATCH /api/v1/appointment-settings/weekly-schedule`)؛ در غیر این صورت `422`.
|
||||
|
||||
---
|
||||
|
||||
## ضمیمههای بیمار (Attachments)
|
||||
|
||||
فایلهای پیوستِ یک پرونده. همه scope به رکورد و tenant صاحب رکورد.
|
||||
|
||||
**Permission:** `IS_AUTHENTICATED_FULLY` (doctor/clinic/secretary مالک رکورد)
|
||||
|
||||
### GET `/api/v1/patient/{uuid}/attachments`
|
||||
لیست ضمیمهها. Response: `{ success, data: [{ uuid, name, url, mime, size, created_at }] }`
|
||||
|
||||
### POST `/api/v1/patient/{uuid}/attachment`
|
||||
آپلود فایل بهصورت **raw body** (مثل سایر `/file/upload/...`): بدنه = بایتهای فایل، هدر `Content-Disposition: attachment; filename="..."`. نام نمایشی اختیاری از query `?name=`. فایل زیر `public/uploads/patients/attachments/YYYY-MM/` ذخیره میشود. Response `201`: attachment object.
|
||||
|
||||
### DELETE `/api/v1/patient/attachment/{uuid}`
|
||||
حذف ضمیمه. فقط مالک رکورد؛ در غیر این صورت `404`.
|
||||
|
||||
### Errors
|
||||
| HTTP | Code | Description |
|
||||
|------|------|-------------|
|
||||
| 404 | `ERR_PATIENT_001` / `ERR_NOT_FOUND_001` | رکورد/ضمیمه یافت نشد یا متعلق به tenant دیگر |
|
||||
| 422 | `ERR_VALIDATION_001` | فایل نامعتبر |
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260713114523 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE patient_attachments (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(200) NOT NULL, url VARCHAR(500) NOT NULL, mime VARCHAR(100) DEFAULT NULL, size INT DEFAULT NULL, created_at INT NOT NULL, record_id INT NOT NULL, UNIQUE INDEX UNIQ_77C7C599D17F50A6 (uuid), INDEX idx_patient_attachments_record (record_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
$this->addSql('ALTER TABLE patient_attachments ADD CONSTRAINT FK_77C7C5994DFD750C FOREIGN KEY (record_id) REFERENCES patient_records (id) ON DELETE CASCADE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE patient_attachments DROP FOREIGN KEY FK_77C7C5994DFD750C');
|
||||
$this->addSql('DROP TABLE patient_attachments');
|
||||
}
|
||||
}
|
||||
@@ -50,9 +50,64 @@ class PatientController extends BaseController
|
||||
private readonly \App\Appointment\Repository\AppointmentRepository $appointmentRepo,
|
||||
private readonly \App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository $invitationRepo,
|
||||
private readonly \App\Tag\Repository\TenantTagRepository $tenantTagRepo,
|
||||
private readonly \App\Patient\Repository\PatientAttachmentRepository $attachmentRepo,
|
||||
private readonly \App\Shared\Service\FileUploadService $fileUpload,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
// ── Attachments (ضمیمه) ───────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/patient/{uuid}/attachments', methods: ['GET'])]
|
||||
public function listAttachments(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$record = $this->recordRepo->findByUuid($uuid);
|
||||
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
|
||||
}
|
||||
|
||||
return $this->success(array_map(
|
||||
fn(\App\Patient\Entity\PatientAttachment $a) => $a->toArray(),
|
||||
$this->attachmentRepo->findByRecord($record)
|
||||
));
|
||||
}
|
||||
|
||||
#[Route('/api/v1/patient/{uuid}/attachment', methods: ['POST'])]
|
||||
public function uploadAttachment(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$record = $this->recordRepo->findByUuid($uuid);
|
||||
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
|
||||
}
|
||||
|
||||
try {
|
||||
$stored = $this->fileUpload->storeFromRequest($request, 'patients/attachments');
|
||||
} catch (\RuntimeException $e) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, $e->getMessage(), 422);
|
||||
}
|
||||
|
||||
$name = trim((string) $request->query->get('name', '')) ?: $stored['filename'];
|
||||
$attachment = new \App\Patient\Entity\PatientAttachment($record, $name, $stored['url'], $stored['filemime'], $stored['size']);
|
||||
$this->attachmentRepo->save($attachment);
|
||||
|
||||
return $this->success($attachment->toArray(), 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/patient/attachment/{uuid}', methods: ['DELETE'])]
|
||||
public function deleteAttachment(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$attachment = $this->attachmentRepo->findByUuid($uuid);
|
||||
if ($attachment === null || !$this->ownsRecord($attachment->getRecord(), $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'ضمیمه یافت نشد', 404);
|
||||
}
|
||||
|
||||
$this->attachmentRepo->remove($attachment);
|
||||
|
||||
return $this->success(['message' => 'ضمیمه حذف شد']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign record labels from the payload (`tags` = array of TenantTag uuids),
|
||||
* scoped to the caller's entity. Returns a 422 response on a foreign tag,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Entity;
|
||||
|
||||
use App\Patient\Repository\PatientAttachmentRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/** A file attached to a patient record (the «ضمیمه» tab). */
|
||||
#[ORM\Entity(repositoryClass: PatientAttachmentRepository::class)]
|
||||
#[ORM\Table(name: 'patient_attachments')]
|
||||
#[ORM\Index(columns: ['record_id'], name: 'idx_patient_attachments_record')]
|
||||
class PatientAttachment
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: PatientRecord::class)]
|
||||
#[ORM\JoinColumn(name: 'record_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private PatientRecord $record;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 200)]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 500)]
|
||||
private string $url;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 100, nullable: true)]
|
||||
private ?string $mime = null;
|
||||
|
||||
#[ORM\Column(type: 'integer', nullable: true)]
|
||||
private ?int $size = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
public function __construct(PatientRecord $record, string $name, string $url, ?string $mime = null, ?int $size = null)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->record = $record;
|
||||
$this->name = $name;
|
||||
$this->url = $url;
|
||||
$this->mime = $mime;
|
||||
$this->size = $size;
|
||||
$this->createdAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getRecord(): PatientRecord { return $this->record; }
|
||||
public function getUrl(): string { return $this->url; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'name' => $this->name,
|
||||
'url' => $this->url,
|
||||
'mime' => $this->mime,
|
||||
'size' => $this->size,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Repository;
|
||||
|
||||
use App\Patient\Entity\PatientAttachment;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class PatientAttachmentRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, PatientAttachment::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?PatientAttachment
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
/** @return PatientAttachment[] */
|
||||
public function findByRecord(PatientRecord $record): array
|
||||
{
|
||||
return $this->createQueryBuilder('a')
|
||||
->where('a.record = :record')
|
||||
->setParameter('record', $record)
|
||||
->orderBy('a.id', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function save(PatientAttachment $a): void
|
||||
{
|
||||
$this->getEntityManager()->persist($a);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function remove(PatientAttachment $a): void
|
||||
{
|
||||
$this->getEntityManager()->remove($a);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Service;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Stores a raw-body file upload (the `/file/upload/...` convention: the file is
|
||||
* sent as the request body with a Content-Disposition filename) under
|
||||
* public/uploads/<subDir>/<year>-<month>/ and returns its public URL + metadata.
|
||||
*/
|
||||
class FileUploadService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FileValidatorService $fileValidator,
|
||||
private readonly string $projectDir,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{url: string, filename: string, filemime: string, size: int}
|
||||
* @throws \RuntimeException on an invalid/oversized file
|
||||
*/
|
||||
public function storeFromRequest(Request $request, string $subDir): array
|
||||
{
|
||||
$content = $request->getContent();
|
||||
preg_match('/filename=["\']?([^"\';\s]+)["\']?/i', $request->headers->get('Content-Disposition', ''), $m);
|
||||
$filename = $m[1] ?? 'file';
|
||||
|
||||
$tmpPath = sys_get_temp_dir() . '/' . uniqid('upload_', true);
|
||||
file_put_contents($tmpPath, $content);
|
||||
|
||||
try {
|
||||
$safeFilename = $this->fileValidator->sanitizeFilename($filename);
|
||||
$mime = $this->fileValidator->detectMimeType($tmpPath);
|
||||
|
||||
$year = date('Y');
|
||||
$month = date('m');
|
||||
$dir = $this->projectDir . '/public/uploads/' . $subDir . '/' . $year . '-' . $month;
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
$storedName = uniqid('', true) . '_' . $safeFilename;
|
||||
rename($tmpPath, $dir . '/' . $storedName);
|
||||
|
||||
return [
|
||||
'url' => '/uploads/' . $subDir . '/' . $year . '-' . $month . '/' . $storedName,
|
||||
'filename' => $safeFilename,
|
||||
'filemime' => $mime,
|
||||
'size' => strlen($content),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
if (file_exists($tmpPath)) {
|
||||
unlink($tmpPath);
|
||||
}
|
||||
throw new \RuntimeException($e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientAttachment;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Patient attachments: list, delete, and tenant ownership scoping.
|
||||
*/
|
||||
class PatientAttachmentTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: \App\Auth\Entity\User, 1: 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, $record];
|
||||
}
|
||||
|
||||
public function testListAndDelete(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$att = new PatientAttachment($record, 'آزمایش.pdf', '/uploads/patients/attachments/x.pdf', 'application/pdf', 1234);
|
||||
$this->em->persist($att);
|
||||
$this->em->flush();
|
||||
$attUuid = $att->getUuid();
|
||||
|
||||
$list = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/attachments', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertCount(1, $list['data']);
|
||||
self::assertSame('آزمایش.pdf', $list['data'][0]['name']);
|
||||
|
||||
$this->authJson('DELETE', '/api/v1/patient/attachment/' . $attUuid, $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$after = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/attachments', $owner);
|
||||
self::assertCount(0, $after['data']);
|
||||
}
|
||||
|
||||
public function testCannotDeleteAnotherTenantsAttachment(): void
|
||||
{
|
||||
[, $record] = $this->recordFor();
|
||||
$att = new PatientAttachment($record, 'x.pdf', '/uploads/x.pdf');
|
||||
$this->em->persist($att);
|
||||
$this->em->flush();
|
||||
|
||||
[$otherOwner] = $this->recordFor();
|
||||
$this->authJson('DELETE', '/api/v1/patient/attachment/' . $att->getUuid(), $otherOwner);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user