Files
clinicpro/tests/Blog/BlogV2FieldsTest.php
T
hamed 62b2f28c4f feat(blog): add SEO fields, scheduling, and representative ownership to blog posts
- Introduced new SEO fields (meta_title, meta_description, primary_keyword, secondary_keywords, faq, internal_links, external_links, reading_time, canonical_url, og_image) to the Blog entity.
- Added scheduling capability with a scheduled_at field to manage automatic publishing of blog posts.
- Implemented representative ownership through a foreign key representation_id in the Blog entity, allowing representatives to manage their own posts.
- Updated BlogController and RepresentationBlogController to handle new fields and ensure proper data handling for SEO and scheduling.
- Created BlogWriter service to encapsulate the logic for applying SEO and scheduling fields to blog entities.
- Added PublishScheduledBlogsMessage and its handler to manage the publishing of scheduled blogs.
- Implemented ScheduledBlogPublisher service to publish drafts whose scheduled_at has arrived, respecting review status.
- Created migration to update the database schema with new fields and constraints.
- Added tests to ensure the correct functionality of new features, including SEO fields, representative scope, and scheduled publishing.
2026-07-23 22:05:23 +03:30

165 lines
7.6 KiB
PHP

<?php
namespace App\Tests\Blog;
use App\Blog\Entity\Blog;
use App\Blog\Service\ScheduledBlogPublisher;
use App\Location\Entity\City;
use App\Location\Entity\Province;
use App\Representation\Entity\Representation;
use App\Tests\ApiTestCase;
/**
* v2 backend: SEO fields, representative-scoped ownership, and scheduled publish.
*/
class BlogV2FieldsTest extends ApiTestCase
{
private function makeCity(string $name): City
{
$province = new Province($name);
$this->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']);
}
// ── 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 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());
}
}