224 lines
10 KiB
PHP
224 lines
10 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']);
|
|
}
|
|
|
|
// ── 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());
|
|
}
|
|
}
|