feat(blog): implement medical review gate for blog posts
- Added new fields to the Blog entity: sources, review_status, reviewer, reviewed_at, review_note, and topic_slug.
- Created API endpoints for reviewing blog posts: GET /api/v1/admin/blog/review-queue and POST /api/v1/admin/blog/{uuid}/review.
- Updated BlogController to handle review logic, including approval and rejection of posts.
- Introduced BlogReviewPage component for admin interface to manage blog reviews.
- Added migration to update the database schema for new fields.
- Implemented tests for review queue functionality and review decision handling.
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
<?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());
|
||||
}
|
||||
|
||||
// ── 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user