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 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']); } /** * The detail endpoint must scope exactly like the list: a post owned by * another city does not exist on this domain. Without this, a post filtered * out of /api/v1/blogs?city_id=… was still served with a direct URL, so the * same article appeared on every city domain. */ public function testDetailIsNotFoundOnAnotherCityDomain(): void { $yasuj = $this->makeCity('یاسوج'); $tabriz = $this->makeCity('تبریز'); $post = $this->makePost('یاسوجی ' . bin2hex(random_bytes(3)), $yasuj); $this->em->flush(); $this->client->request('GET', '/api/v1/blog/' . $post->getSlug() . '?city_id=' . $tabriz->getId()); $this->assertSame(404, $this->responseCode(), 'another city\'s post must not be readable'); } public function testDetailIsFoundOnItsOwnCityDomain(): void { $yasuj = $this->makeCity('یاسوج'); $post = $this->makePost('یاسوجی ' . bin2hex(random_bytes(3)), $yasuj); $this->em->flush(); $this->client->request('GET', '/api/v1/blog/' . $post->getUuid() . '?city_id=' . $yasuj->getId()); $this->assertSame(200, $this->responseCode()); } public function testNationwideDetailIsReadableFromEveryCityDomain(): void { $tabriz = $this->makeCity('تبریز'); $post = $this->makePost('سراسری ' . bin2hex(random_bytes(3)), null); $this->em->flush(); $this->client->request('GET', '/api/v1/blog/' . $post->getSlug() . '?city_id=' . $tabriz->getId()); $this->assertSame(200, $this->responseCode(), 'nationwide posts stay visible on city domains'); } public function testDetailWithoutCityIdStaysUnscoped(): void { $yasuj = $this->makeCity('یاسوج'); $post = $this->makePost('یاسوجی ' . bin2hex(random_bytes(3)), $yasuj); $this->em->flush(); // The main domain (and any API consumer) omits city_id and sees everything. $this->client->request('GET', '/api/v1/blog/' . $post->getSlug()); $this->assertSame(200, $this->responseCode()); $this->client->request('GET', '/api/v1/blog/' . $post->getSlug() . '?city_id='); $this->assertSame(200, $this->responseCode(), 'an empty city_id must not scope the request'); } 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()); } }