199 lines
9.2 KiB
PHP
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');
|
|
}
|
|
}
|