Files
clinicpro/tests/Blog/BlogReviewGateTest.php
T

199 lines
9.2 KiB
PHP

<?php
namespace App\Tests\Blog;
use App\Blog\Entity\Blog;
use App\Tests\ApiTestCase;
/**
* The medical-review gate: the content pipeline creates drafts as
* review_status=pending_review; a doctor/admin approves (publishes) or rejects
* them. review_status=null stays reserved for manual admin posts outside the gate.
*/
class BlogReviewGateTest extends ApiTestCase
{
private function makePendingPost(string $title, ?string $topicSlug = null): Blog
{
$blog = new Blog($this->createUser(['ROLE_ADMIN']), $title, 'متن آزمایشی مقاله برای تست');
$blog->setStatus(Blog::STATUS_DRAFT)->setReviewStatus(Blog::REVIEW_PENDING);
if ($topicSlug !== null) {
$blog->setTopicSlug($topicSlug);
}
$this->em->persist($blog);
$this->em->flush();
return $blog;
}
// ── review queue ──────────────────────────────────────────────────────────
public function testReviewQueueReturnsOnlyPendingPosts(): void
{
$admin = $this->createUser(['ROLE_ADMIN']);
$tag = bin2hex(random_bytes(4));
$this->makePendingPost("در-انتظار-$tag");
$published = new Blog($admin, "منتشرشده-$tag", 'متن آزمایشی مقاله برای تست');
$published->setStatus(Blog::STATUS_PUBLISHED); // review_status null → outside the gate
$this->em->persist($published);
$this->em->flush();
$payload = $this->authJson('GET', '/api/v1/admin/blog/review-queue?limit=50', $admin);
$titles = array_column($payload['data'], 'title');
$this->assertContains("در-انتظار-$tag", $titles, 'pending post must be in the queue');
$this->assertNotContains("منتشرشده-$tag", $titles, 'non-pending post leaked into the queue');
}
public function testReviewQueueRequiresAdmin(): void
{
$user = $this->createUser(['ROLE_USER']);
$this->authJson('GET', '/api/v1/admin/blog/review-queue', $user);
$this->assertSame(403, $this->responseCode());
}
public function testReviewQueueIsReadableByImporter(): void
{
// پنل کرالر با کاربر سیستمیِ ROLE_IMPORTER لاگین می‌کند؛ بدون این، صف
// همیشه «review-queue failed (403)» می‌داد.
$importer = $this->createUser(['ROLE_IMPORTER']);
$this->authJson('GET', '/api/v1/admin/blog/review-queue', $importer);
$this->assertSame(200, $this->responseCode());
}
// ── who may create a draft ────────────────────────────────────────────────
public function testImporterCreatesDraftForcedIntoTheReviewGate(): void
{
$importer = $this->createUser(['ROLE_IMPORTER']);
$res = $this->authJson('POST', '/api/v1/blog', $importer, [
'title' => 'مقاله خط تولید ' . bin2hex(random_bytes(3)),
'body' => 'متن آزمایشی مقاله برای تست',
'topic_slug' => 'importer-' . bin2hex(random_bytes(4)),
// تلاش برای انتشار مستقیم — باید نادیده گرفته شود
'status' => Blog::STATUS_PUBLISHED,
'review_status' => Blog::REVIEW_APPROVED,
]);
$this->assertSame(201, $this->responseCode());
$this->assertSame(Blog::STATUS_DRAFT, $res['data']['data']['status'],
'importer must never publish directly');
$this->assertSame(Blog::REVIEW_PENDING, $res['data']['data']['review_status'],
'importer draft must enter the doctor review gate');
}
public function testPlainUserCannotCreateBlog(): void
{
$user = $this->createUser(['ROLE_USER']);
$this->authJson('POST', '/api/v1/blog', $user, [
'title' => 'عنوان', 'body' => 'متن آزمایشی مقاله برای تست',
]);
$this->assertSame(403, $this->responseCode());
}
// ── approve ───────────────────────────────────────────────────────────────
public function testApprovePublishesAndRecordsReviewer(): void
{
$doctor = $this->createUser(['ROLE_ADMIN']);
$post = $this->makePendingPost('مقاله ' . bin2hex(random_bytes(3)));
$res = $this->authJson('POST', '/api/v1/admin/blog/' . $post->getUuid() . '/review', $doctor, [
'decision' => 'approved',
]);
$this->assertSame(200, $this->responseCode());
$blog = $res['data']['data'];
$this->assertSame(Blog::REVIEW_APPROVED, $blog['review_status']);
$this->assertSame(Blog::STATUS_PUBLISHED, $blog['status'], 'approve must publish by default');
$this->assertSame($doctor->getUuid(), $blog['reviewer']['uuid'], 'reviewer identity must be stored (E-E-A-T)');
$this->assertNotNull($blog['reviewed_at']);
}
public function testApproveWithPublishFalseKeepsDraft(): void
{
$doctor = $this->createUser(['ROLE_ADMIN']);
$post = $this->makePendingPost('مقاله ' . bin2hex(random_bytes(3)));
$res = $this->authJson('POST', '/api/v1/admin/blog/' . $post->getUuid() . '/review', $doctor, [
'decision' => 'approved',
'publish' => false,
]);
$this->assertSame(200, $this->responseCode());
$this->assertSame(Blog::REVIEW_APPROVED, $res['data']['data']['review_status']);
$this->assertSame(Blog::STATUS_DRAFT, $res['data']['data']['status']);
}
// ── reject ────────────────────────────────────────────────────────────────
public function testRejectRequiresNoteAndKeepsDraft(): void
{
$doctor = $this->createUser(['ROLE_ADMIN']);
$post = $this->makePendingPost('مقاله ' . bin2hex(random_bytes(3)));
// boundary: rejection without a note is refused
$this->authJson('POST', '/api/v1/admin/blog/' . $post->getUuid() . '/review', $doctor, [
'decision' => 'rejected',
]);
$this->assertSame(422, $this->responseCode(), 'rejection must require a reason');
$res = $this->authJson('POST', '/api/v1/admin/blog/' . $post->getUuid() . '/review', $doctor, [
'decision' => 'rejected',
'note' => 'ادعاهای پزشکی بدون منبع کافی',
]);
$this->assertSame(200, $this->responseCode());
$this->assertSame(Blog::REVIEW_REJECTED, $res['data']['data']['review_status']);
$this->assertSame(Blog::STATUS_DRAFT, $res['data']['data']['status'], 'rejected post must not be published');
$this->assertSame('ادعاهای پزشکی بدون منبع کافی', $res['data']['data']['review_note']);
}
public function testInvalidDecisionIsRejected(): void
{
$doctor = $this->createUser(['ROLE_ADMIN']);
$post = $this->makePendingPost('مقاله ' . bin2hex(random_bytes(3)));
$this->authJson('POST', '/api/v1/admin/blog/' . $post->getUuid() . '/review', $doctor, [
'decision' => 'maybe',
]);
$this->assertSame(422, $this->responseCode());
}
public function testReviewUnknownPostReturns404(): void
{
$doctor = $this->createUser(['ROLE_ADMIN']);
$this->authJson('POST', '/api/v1/admin/blog/00000000-0000-0000-0000-000000000000/review', $doctor, [
'decision' => 'approved',
]);
$this->assertSame(404, $this->responseCode());
}
// ── pipeline idempotency ──────────────────────────────────────────────────
public function testCreateIsIdempotentOnTopicSlug(): void
{
$admin = $this->createUser(['ROLE_ADMIN']);
$slug = 'cardiology-chest-pain-' . bin2hex(random_bytes(3));
$first = $this->authJson('POST', '/api/v1/blog', $admin, [
'title' => 'درد قفسه سینه',
'body' => 'متن آزمایشی مقاله برای تست',
'topic_slug' => $slug,
'sources' => [['url' => 'https://mayoclinic.org/a', 'title' => 'Mayo']],
'review_status' => 'pending_review',
]);
$this->assertSame(201, $this->responseCode());
$this->assertSame('pending_review', $first['data']['data']['review_status']);
$firstUuid = $first['data']['data']['uuid'];
// A re-run with the same topic_slug must return the SAME post, not a duplicate.
$second = $this->authJson('POST', '/api/v1/blog', $admin, [
'title' => 'درد قفسه سینه (دوباره)',
'body' => 'متن دیگر',
'topic_slug' => $slug,
]);
$this->assertSame(200, $this->responseCode(), 're-run must be idempotent, not 201');
$this->assertSame($firstUuid, $second['data']['data']['uuid'], 'topic_slug must not create a duplicate');
}
}