feat(blog): implement tag filtering and facets endpoint

- Fix tag filtering to correctly match Persian tags by adjusting JSON encoding in the applyTagFilter method.
- Add new endpoint GET /api/v1/blogs/tags to retrieve distinct tag names and their counts for published posts, respecting city scope.
- Update API documentation to reflect changes in tag filtering and the new tags endpoint.
- Create BlogTagFilterTest to ensure correct functionality of tag filtering and facets, including edge cases for Persian tags and city filtering.
This commit is contained in:
hamed
2026-07-29 14:23:36 +03:30
parent 4f4bce9fe2
commit 9b05c6d1ff
6 changed files with 707 additions and 15 deletions
+213
View File
@@ -0,0 +1,213 @@
<?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 tags live in a JSON column that Doctrine writes with plain json_encode, so
* Persian names are stored \uXXXX-escaped. MariaDB's JSON_CONTAINS does not
* normalize those escapes, which silently made every ?tag=<persian> query return
* an empty list. These cases pin both the filter and the tag facet endpoint.
*
* db_test is never reset, so every case tags its fixtures with a random suffix and
* asserts only on its own rows.
*/
class BlogTagFilterTest 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;
}
/** @param string[] $tags */
private function makePost(string $title, array $tags, ?City $city = null, string $status = Blog::STATUS_PUBLISHED): Blog
{
$blog = new Blog($this->createUser(['ROLE_ADMIN']), $title, 'متن آزمایشی مقاله برای تست');
$blog->setStatus($status)->setCity($city)->setTags($tags);
$this->em->persist($blog);
return $blog;
}
/** @return array{titles: string[], total: int} */
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);
return [
'titles' => array_column($payload['data'], 'title'),
'total' => $payload['meta']['totalRecords'],
];
}
/** @return array<string, int> tag name => post count */
private function facets(string $query = ''): array
{
$this->client->request('GET', '/api/v1/blogs/tags' . ($query !== '' ? '?' . $query : ''));
$this->assertSame(200, $this->responseCode());
$payload = json_decode($this->client->getResponse()->getContent(), true);
return array_column($payload['data'], 'count', 'name');
}
public function testPersianTagFilterReturnsMatchingPosts(): void
{
$suffix = bin2hex(random_bytes(4));
$eye = "چشم و گوش-$suffix";
$other = "سلامت عمومی-$suffix";
$this->makePost("هاله رنگی-$suffix", [$eye]);
$this->makePost("خشکی چشم-$suffix", [$eye, $other]);
$this->makePost("تغذیه-$suffix", [$other]);
$this->em->flush();
$result = $this->listBy('tag=' . rawurlencode($eye));
$this->assertSame(2, $result['total'], 'meta.totalRecords must honour the tag filter too');
$this->assertContains("هاله رنگی-$suffix", $result['titles']);
$this->assertContains("خشکی چشم-$suffix", $result['titles']);
$this->assertNotContains("تغذیه-$suffix", $result['titles']);
}
public function testUnknownTagReturnsEmptyListNotAnError(): void
{
$result = $this->listBy('tag=' . rawurlencode('برچسب-ناموجود-' . bin2hex(random_bytes(4))));
$this->assertSame(0, $result['total']);
$this->assertSame([], $result['titles']);
}
public function testTagMatchIsExactNotAPrefix(): void
{
$suffix = bin2hex(random_bytes(4));
$this->makePost("مقاله-$suffix", ["سلامت عمومی-$suffix"]);
$this->em->flush();
$this->assertSame(0, $this->listBy('tag=' . rawurlencode('سلامت'))['total']);
}
public function testTagMatchDoesNotTreatUnderscoreOrPercentAsWildcard(): void
{
$suffix = bin2hex(random_bytes(4));
$this->makePost("مقاله-$suffix", ["axb-$suffix"]);
$this->em->flush();
$this->assertSame(0, $this->listBy('tag=' . rawurlencode("a_b-$suffix"))['total']);
$this->assertSame(0, $this->listBy('tag=' . rawurlencode("%-$suffix"))['total']);
}
public function testDraftPostsAreExcludedFromFilterAndFacets(): void
{
$suffix = bin2hex(random_bytes(4));
$tag = "پیش‌نویس-$suffix";
$this->makePost("منتشرشده-$suffix", [$tag]);
$this->makePost("پیش‌نویس-$suffix", [$tag], null, Blog::STATUS_DRAFT);
$this->em->flush();
$this->assertSame(1, $this->listBy('tag=' . rawurlencode($tag))['total']);
$this->assertSame(1, $this->facets()[$tag] ?? 0);
}
public function testTagAndCityFiltersApplyTogether(): void
{
$suffix = bin2hex(random_bytes(4));
$tag = "چشم و گوش-$suffix";
$yasuj = $this->makeCity('یاسوج');
$tabriz = $this->makeCity('تبریز');
$this->makePost("یاسوجی-$suffix", [$tag], $yasuj);
$this->makePost("تبریزی-$suffix", [$tag], $tabriz);
$this->makePost("سراسری-$suffix", [$tag], null);
$this->em->flush();
$result = $this->listBy('tag=' . rawurlencode($tag) . '&city_id=' . $yasuj->getId());
$this->assertSame(2, $result['total']);
$this->assertContains("یاسوجی-$suffix", $result['titles']);
$this->assertContains("سراسری-$suffix", $result['titles'], 'nationwide posts stay visible on a city domain');
$this->assertNotContains("تبریزی-$suffix", $result['titles']);
}
public function testFacetsCountPublishedPostsPerTag(): void
{
$suffix = bin2hex(random_bytes(4));
$eye = "چشم و گوش-$suffix";
$heart = "قلب و عروق-$suffix";
$this->makePost("اول-$suffix", [$eye]);
$this->makePost("دوم-$suffix", [$eye, $heart]);
$this->em->flush();
$facets = $this->facets();
$this->assertSame(2, $facets[$eye] ?? 0);
$this->assertSame(1, $facets[$heart] ?? 0);
}
public function testFacetsHonourCityScope(): void
{
$suffix = bin2hex(random_bytes(4));
$tag = "چشم و گوش-$suffix";
$yasuj = $this->makeCity('یاسوج');
$tabriz = $this->makeCity('تبریز');
$this->makePost("یاسوجی-$suffix", [$tag], $yasuj);
$this->makePost("تبریزی-$suffix", [$tag], $tabriz);
$this->makePost("سراسری-$suffix", [$tag], null);
$this->em->flush();
$this->assertSame(3, $this->facets()[$tag] ?? 0, 'unscoped facets count every published post');
$this->assertSame(2, $this->facets('city_id=' . $yasuj->getId())[$tag] ?? 0);
}
/**
* Every facet count must equal the totalRecords of filtering by that same tag —
* otherwise a chip in the public site leads to an empty result page.
*/
public function testFacetCountMatchesFilteredTotal(): void
{
$suffix = bin2hex(random_bytes(4));
$tag = "غدد و متابولیسم-$suffix";
$this->makePost("اول-$suffix", [$tag]);
$this->makePost("دوم-$suffix", [$tag]);
$this->makePost("سوم-$suffix", ["دیگر-$suffix"]);
$this->em->flush();
$this->assertSame(
$this->facets()[$tag] ?? 0,
$this->listBy('tag=' . rawurlencode($tag))['total']
);
}
public function testPostsWithoutTagsProduceNoFacetEntry(): void
{
$suffix = bin2hex(random_bytes(4));
$this->makePost("بدون برچسب-$suffix", []);
$this->em->flush();
$facets = $this->facets();
$this->assertArrayNotHasKey('', $facets, 'an empty tag must never become a facet');
$this->assertNotContains(0, $facets, 'no facet may report a zero count');
}
public function testFacetsEndpointIsPublic(): void
{
$this->client->request('GET', '/api/v1/blogs/tags');
$this->assertSame(200, $this->responseCode(), 'the public site calls this without a token');
}
}