feat(blog): add city_id to blogs for city-specific scoping

- Introduced a new nullable city_id column in the blogs table to allow scoping of blog posts to specific cities.
- Updated Blog entity to include a ManyToOne relationship with the City entity.
- Enhanced BlogController to handle city_id in the request, allowing filtering of posts by city.
- Modified BlogRepository to support querying published posts based on city_id.
- Added tests to ensure correct behavior for city-scoped and nationwide posts, including creation and updating of posts with city associations.
This commit is contained in:
hamed
2026-07-19 08:23:57 +03:30
parent bd66b213c2
commit a4b07c2f80
10 changed files with 535 additions and 178 deletions
+179
View File
@@ -0,0 +1,179 @@
<?php
namespace App\Tests\Blog;
use App\Blog\Entity\Blog;
use App\Location\Entity\City;
use App\Location\Entity\Province;
use App\Tests\ApiTestCase;
/**
* Blog posts carry an optional city so the multi-domain public site can scope
* them. NULL is a permanent, meaningful state — a nationwide post, canonical on
* the main domain but listed on every city domain.
*/
class BlogCityScopeTest 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 makePost(string $title, ?City $city): Blog
{
$blog = new Blog($this->createUser(['ROLE_ADMIN']), $title, 'متن آزمایشی مقاله برای تست');
$blog->setStatus(Blog::STATUS_PUBLISHED)->setCity($city);
$this->em->persist($blog);
return $blog;
}
/** @return array<string, array> published list keyed by title */
private function listBy(string $query = ''): array
{
$this->client->request('GET', '/api/v1/blogs?limit=50' . $query);
$this->assertSame(200, $this->responseCode());
$payload = json_decode($this->client->getResponse()->getContent(), true);
$byTitle = [];
foreach ($payload['data'] as $row) {
$byTitle[$row['title']] = $row;
}
return $byTitle;
}
public function testCityScopedListReturnsCityPostsPlusNationwide(): void
{
$yasuj = $this->makeCity('یاسوج');
$tabriz = $this->makeCity('تبریز');
$tag = bin2hex(random_bytes(4));
$this->makePost("یاسوجی-$tag", $yasuj);
$this->makePost("تبریزی-$tag", $tabriz);
$this->makePost("سراسری-$tag", null);
$this->em->flush();
$scoped = $this->listBy('&city_id=' . $yasuj->getId());
$this->assertArrayHasKey("یاسوجی-$tag", $scoped, 'city post missing');
$this->assertArrayHasKey("سراسری-$tag", $scoped, 'nationwide post must appear on a city domain');
$this->assertArrayNotHasKey("تبریزی-$tag", $scoped, 'another city\'s post leaked into the list');
}
public function testUnscopedListReturnsEveryPublishedPost(): void
{
$yasuj = $this->makeCity('یاسوج');
$tag = bin2hex(random_bytes(4));
$this->makePost("یاسوجی-$tag", $yasuj);
$this->makePost("سراسری-$tag", null);
$this->em->flush();
$all = $this->listBy();
$this->assertArrayHasKey("یاسوجی-$tag", $all);
$this->assertArrayHasKey("سراسری-$tag", $all);
}
public function testCityAppearsInListAndDetailPayload(): void
{
$yasuj = $this->makeCity('یاسوج');
$tag = bin2hex(random_bytes(4));
$cityPost = $this->makePost("یاسوجی-$tag", $yasuj);
$natPost = $this->makePost("سراسری-$tag", null);
$this->em->flush();
$list = $this->listBy();
$this->assertSame(
['id' => (string) $yasuj->getId(), 'name' => 'یاسوج'],
$list["یاسوجی-$tag"]['city']
);
$this->assertNull($list["سراسری-$tag"]['city'], 'nationwide post must report city: null');
$this->client->request('GET', '/api/v1/blog/' . $cityPost->getSlug());
$this->assertSame(200, $this->responseCode());
$detail = json_decode($this->client->getResponse()->getContent(), true)['data']['data'];
$this->assertSame('یاسوج', $detail['city']['name']);
$this->client->request('GET', '/api/v1/blog/' . $natPost->getSlug());
$this->assertSame(200, $this->responseCode());
$natDetail = json_decode($this->client->getResponse()->getContent(), true)['data']['data'];
$this->assertNull($natDetail['city']);
}
public function testAdminCanCreatePostWithAndWithoutCity(): void
{
$admin = $this->createUser(['ROLE_ADMIN']);
$yasuj = $this->makeCity('یاسوج');
$this->em->flush();
$withCity = $this->authJson('POST', '/api/v1/blog', $admin, [
'title' => 'مقاله شهری ' . bin2hex(random_bytes(3)),
'body' => 'متن آزمایشی مقاله برای تست',
'city_id' => $yasuj->getId(),
]);
$this->assertSame(201, $this->responseCode());
$this->assertSame('یاسوج', $withCity['data']['data']['city']['name']);
$nationwide = $this->authJson('POST', '/api/v1/blog', $admin, [
'title' => 'مقاله سراسری ' . bin2hex(random_bytes(3)),
'body' => 'متن آزمایشی مقاله برای تست',
]);
$this->assertSame(201, $this->responseCode());
$this->assertNull($nationwide['data']['data']['city'], 'omitting city_id must mean nationwide');
}
public function testAdminCanMovePostBetweenCityAndNationwide(): void
{
$admin = $this->createUser(['ROLE_ADMIN']);
$yasuj = $this->makeCity('یاسوج');
$post = $this->makePost('مقاله ' . bin2hex(random_bytes(3)), null);
$this->em->flush();
$assigned = $this->authJson('PATCH', '/api/v1/blog/' . $post->getUuid(), $admin, [
'city_id' => $yasuj->getId(),
]);
$this->assertSame(200, $this->responseCode());
$this->assertSame('یاسوج', $assigned['data']['data']['city']['name']);
$cleared = $this->authJson('PATCH', '/api/v1/blog/' . $post->getUuid(), $admin, [
'city_id' => null,
]);
$this->assertSame(200, $this->responseCode());
$this->assertNull($cleared['data']['data']['city'], 'city_id: null must make the post nationwide again');
}
public function testPatchWithoutCityIdLeavesCityUntouched(): void
{
$admin = $this->createUser(['ROLE_ADMIN']);
$yasuj = $this->makeCity('یاسوج');
$post = $this->makePost('مقاله ' . bin2hex(random_bytes(3)), $yasuj);
$this->em->flush();
$updated = $this->authJson('PATCH', '/api/v1/blog/' . $post->getUuid(), $admin, [
'summary' => 'خلاصه جدید',
]);
$this->assertSame(200, $this->responseCode());
$this->assertSame('یاسوج', $updated['data']['data']['city']['name'], 'PATCH must not silently clear the city');
}
public function testUnknownCityIdIsRejected(): void
{
$admin = $this->createUser(['ROLE_ADMIN']);
$this->em->flush();
$this->authJson('POST', '/api/v1/blog', $admin, [
'title' => 'مقاله ' . bin2hex(random_bytes(3)),
'body' => 'متن آزمایشی مقاله برای تست',
'city_id' => 999999,
]);
$this->assertSame(422, $this->responseCode());
}
}