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:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user