em->persist($province); $city = new City($name, $province); $this->em->persist($city); return $city; } private function makeRepresentation(array $cities): Representation { $user = $this->createUser(['ROLE_REPRESENTATION']); $rep = new Representation($user, 'نمایندهٔ ' . bin2hex(random_bytes(3))); $rep->setCities($cities); $this->em->persist($rep); $this->em->flush(); return $rep; } // ── SEO fields (admin path) ─────────────────────────────────────────────── public function testAdminCreateStoresSeoFields(): void { $admin = $this->createUser(['ROLE_ADMIN']); $res = $this->authJson('POST', '/api/v1/blog', $admin, [ 'title' => 'مقالهٔ سئو ' . bin2hex(random_bytes(3)), 'body' => 'متن آزمایشی مقاله برای تست', 'meta_title' => 'عنوان متا', 'meta_description' => 'توضیح متا برای موتور جستجو', 'primary_keyword' => 'سکته قلبی', 'secondary_keywords' => ['علائم', 'پیشگیری', ''], 'faq' => [['q' => 'سؤال؟', 'a' => 'جواب'], ['q' => '', 'a' => 'بی‌سؤال']], 'internal_links' => ['https://nobat724.com/x'], 'reading_time' => 6, ]); $this->assertSame(201, $this->responseCode()); $b = $res['data']['data']; $this->assertSame('توضیح متا برای موتور جستجو', $b['meta_description']); $this->assertSame('سکته قلبی', $b['primary_keyword']); $this->assertSame(['علائم', 'پیشگیری'], $b['secondary_keywords'], 'empty keyword dropped'); $this->assertCount(1, $b['faq'], 'malformed FAQ entry dropped'); $this->assertSame(6, $b['reading_time']); } // ── Admin list shows drafts (public list does not) ──────────────────────── public function testAdminListReturnsDraftsThatPublicListHides(): void { $admin = $this->createUser(['ROLE_ADMIN']); $tag = bin2hex(random_bytes(4)); $draft = new Blog($admin, "پیش‌نویس-$tag", 'متن آزمایشی مقاله برای تست'); $draft->setStatus(Blog::STATUS_DRAFT); $pub = new Blog($admin, "منتشر-$tag", 'متن آزمایشی مقاله برای تست'); $pub->setStatus(Blog::STATUS_PUBLISHED); $this->em->persist($draft); $this->em->persist($pub); $this->em->flush(); // admin list — sees both $adminList = $this->authJson('GET', '/api/v1/admin/blogs?limit=50', $admin); $titles = array_column($adminList['data'], 'title'); $this->assertContains("پیش‌نویس-$tag", $titles, 'admin list must show drafts'); $this->assertContains("منتشر-$tag", $titles); // status filter $draftsOnly = $this->authJson('GET', '/api/v1/admin/blogs?limit=50&status=draft', $admin); $dTitles = array_column($draftsOnly['data'], 'title'); $this->assertContains("پیش‌نویس-$tag", $dTitles); $this->assertNotContains("منتشر-$tag", $dTitles); // public list — drafts hidden $this->client->request('GET', '/api/v1/blogs?limit=50'); $publicTitles = array_column(json_decode($this->client->getResponse()->getContent(), true)['data'], 'title'); $this->assertNotContains("پیش‌نویس-$tag", $publicTitles, 'public list must never expose drafts'); } public function testAdminListRequiresAdmin(): void { $user = $this->createUser(['ROLE_USER']); $this->authJson('GET', '/api/v1/admin/blogs', $user); $this->assertSame(403, $this->responseCode()); } // ── Representative scope ────────────────────────────────────────────────── public function testRepresentativeCreatesPostInOwnCity(): void { $yasuj = $this->makeCity('یاسوج'); $rep = $this->makeRepresentation([$yasuj]); $res = $this->authJson('POST', '/api/v1/representation/blog', $rep->getUser(), [ 'title' => 'مقالهٔ نماینده ' . bin2hex(random_bytes(3)), 'body' => 'متن آزمایشی مقاله برای تست', 'city_id' => $yasuj->getId(), ]); $this->assertSame(201, $this->responseCode()); $this->assertSame($rep->getUuid(), $res['data']['data']['representation']['uuid']); $this->assertSame('یاسوج', $res['data']['data']['city']['name']); } public function testRepresentativeCannotUseCityOutsideCoverage(): void { $yasuj = $this->makeCity('یاسوج'); $tabriz = $this->makeCity('تبریز'); $rep = $this->makeRepresentation([$yasuj]); // covers Yasuj only $this->authJson('POST', '/api/v1/representation/blog', $rep->getUser(), [ 'title' => 'مقاله', 'body' => 'متن آزمایشی مقاله برای تست', 'city_id' => $tabriz->getId(), ]); $this->assertSame(422, $this->responseCode(), 'city outside coverage must be rejected'); } public function testRepresentativeListShowsOnlyOwnPosts(): void { $yasuj = $this->makeCity('یاسوج'); $repA = $this->makeRepresentation([$yasuj]); $repB = $this->makeRepresentation([$yasuj]); $tag = bin2hex(random_bytes(4)); $this->authJson('POST', '/api/v1/representation/blog', $repA->getUser(), [ 'title' => "مالA-$tag", 'body' => 'متن آزمایشی مقاله برای تست', 'city_id' => $yasuj->getId(), ]); $this->authJson('POST', '/api/v1/representation/blog', $repB->getUser(), [ 'title' => "مالB-$tag", 'body' => 'متن آزمایشی مقاله برای تست', 'city_id' => $yasuj->getId(), ]); $list = $this->authJson('GET', '/api/v1/representation/blogs?limit=50', $repA->getUser()); $titles = array_column($list['data'], 'title'); $this->assertContains("مالA-$tag", $titles); $this->assertNotContains("مالB-$tag", $titles, "another rep's post leaked"); } public function testRepresentativeCanGetOwnPostButNotOthers(): void { $yasuj = $this->makeCity('یاسوج'); $repA = $this->makeRepresentation([$yasuj]); $repB = $this->makeRepresentation([$yasuj]); $created = $this->authJson('POST', '/api/v1/representation/blog', $repA->getUser(), [ 'title' => 'مقالهٔ A', 'body' => 'متن آزمایشی مقاله برای تست', 'city_id' => $yasuj->getId(), ]); $uuid = $created['data']['data']['uuid']; $own = $this->authJson('GET', "/api/v1/representation/blog/$uuid", $repA->getUser()); $this->assertSame(200, $this->responseCode()); $this->assertSame('مقالهٔ A', $own['data']['data']['title']); $this->authJson('GET', "/api/v1/representation/blog/$uuid", $repB->getUser()); $this->assertSame(404, $this->responseCode()); } public function testRepresentativeCannotEditOthersPost(): void { $yasuj = $this->makeCity('یاسوج'); $repA = $this->makeRepresentation([$yasuj]); $repB = $this->makeRepresentation([$yasuj]); $created = $this->authJson('POST', '/api/v1/representation/blog', $repB->getUser(), [ 'title' => 'مقالهٔ B', 'body' => 'متن آزمایشی مقاله برای تست', 'city_id' => $yasuj->getId(), ]); $uuid = $created['data']['data']['uuid']; $this->authJson('PATCH', "/api/v1/representation/blog/$uuid", $repA->getUser(), ['title' => 'هک']); $this->assertSame(404, $this->responseCode(), "must not see another rep's post"); } public function testNonRepresentativeIsForbidden(): void { $user = $this->createUser(['ROLE_USER']); $this->authJson('GET', '/api/v1/representation/blogs', $user); $this->assertSame(403, $this->responseCode()); } // ── Scheduled publish ───────────────────────────────────────────────────── private function makeScheduled(?int $scheduledAt, ?string $reviewStatus): Blog { $blog = new Blog($this->createUser(['ROLE_ADMIN']), 'زمان‌بندی ' . bin2hex(random_bytes(3)), 'متن آزمایشی مقاله برای تست'); $blog->setStatus(Blog::STATUS_DRAFT)->setScheduledAt($scheduledAt)->setReviewStatus($reviewStatus); $this->em->persist($blog); $this->em->flush(); return $blog; } public function testScheduledPublisherPublishesDuePosts(): void { $publisher = static::getContainer()->get(ScheduledBlogPublisher::class); $past = time() - 60; $duePlain = $this->makeScheduled($past, null); // manual, due -> publish $dueApproved = $this->makeScheduled($past, Blog::REVIEW_APPROVED); // approved, due -> publish $duePending = $this->makeScheduled($past, Blog::REVIEW_PENDING); // due but unreviewed -> hold $future = $this->makeScheduled(time() + 3600, null); // not due -> hold $publisher->publishDue(); $this->em->clear(); $reload = fn(Blog $b) => $this->em->getRepository(Blog::class)->find($b->getId()); $this->assertSame(Blog::STATUS_PUBLISHED, $reload($duePlain)->getStatus()); $this->assertSame(Blog::STATUS_PUBLISHED, $reload($dueApproved)->getStatus()); $this->assertSame(Blog::STATUS_DRAFT, $reload($duePending)->getStatus(), 'review gate wins over schedule'); $this->assertSame(Blog::STATUS_DRAFT, $reload($future)->getStatus()); } }