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