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'); } }