Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log.

## [Unreleased]

- [PR-36](https://github.com/itk-dev/event-database-api/pull/36)
Add unit tests pinning the Elasticsearch filters' query DSL and parameter descriptors
- [PR-35](https://github.com/itk-dev/event-database-api/pull/35)
Test against production-parity Elasticsearch mappings (dynamic: strict) so filter tests exercise real field semantics
- [PR-34](https://github.com/itk-dev/event-database-api/pull/34)
Expand Down
53 changes: 53 additions & 0 deletions tests/Unit/Api/Filter/ElasticSearch/BooleanFilterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

namespace App\Tests\Unit\Api\Filter\ElasticSearch;

use App\Api\Filter\ElasticSearch\BooleanFilter;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

/**
* Pins BooleanFilter::apply() query DSL. The value is comma-split and wrapped in
* a single `terms` clause with a boost; an empty selection emits nothing.
*/
class BooleanFilterTest extends TestCase
{
use FilterFactoryMockTrait;

private const RESOURCE = 'App\Api\Dto\Event';

// Goal: apply() wraps the comma-split value in a single `terms` clause with a
// boost; unset/empty is skipped and "0" is treated as a real value.
#[DataProvider('applyProvider')]
public function testApplyEmitsExpectedDsl(array $filters, array $expected): void
{
[$names, $meta, $resolver] = $this->filterDependencies();
$filter = new BooleanFilter($names, $meta, $resolver, null, ['publicAccess' => null]);

self::assertSame($expected, $filter->apply([], self::RESOURCE, null, ['filters' => $filters]));
}

public static function applyProvider(): iterable
{
yield 'no filters → empty' => [[], []];
yield 'empty string → skipped' => [['publicAccess' => ''], []];
yield 'true' => [['publicAccess' => 'true'], ['terms' => ['publicAccess' => ['true'], 'boost' => 1.0]]];
yield 'false' => [['publicAccess' => 'false'], ['terms' => ['publicAccess' => ['false'], 'boost' => 1.0]]];
// "0" is a real value and must reach ES (regression guard for the old empty() drop).
yield "'0' reaches ES" => [['publicAccess' => '0'], ['terms' => ['publicAccess' => ['0'], 'boost' => 1.0]]];
yield 'comma list is split' => [['publicAccess' => 'true,false'], ['terms' => ['publicAccess' => ['true', 'false'], 'boost' => 1.0]]];
}

// Goal: getDescription() advertises the property as an optional boolean parameter.
public function testGetDescriptionShape(): void
{
[$names, $meta, $resolver] = $this->filterDependencies();
$filter = new BooleanFilter($names, $meta, $resolver, null, ['publicAccess' => null]);

$description = $filter->getDescription(self::RESOURCE);

self::assertArrayHasKey('publicAccess', $description);
self::assertSame('bool', $description['publicAccess']['type']);
self::assertFalse($description['publicAccess']['required']);
}
}
139 changes: 139 additions & 0 deletions tests/Unit/Api/Filter/ElasticSearch/DateRangeFilterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php

namespace App\Tests\Unit\Api\Filter\ElasticSearch;

use App\Api\Filter\ElasticSearch\DateRangeFilter;
use App\Model\DateLimit;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

/**
* Pins DateRangeFilter::apply() query DSL and its (currently un-mapped) error
* behaviour. `between` produces exclusive `gt`/`lt` bounds — a consumer-visible
* semantic. The malformed-input and unknown-operator cases document current
* behaviour: both surface as uncaught throwables (they leak as HTTP 500 today).
*/
class DateRangeFilterTest extends TestCase
{
use FilterFactoryMockTrait;

private const RESOURCE = 'App\Api\Dto\Event';

/**
* @param array{limit: DateLimit, throwOnInvalid: bool}[] $config
*/
private function newFilter(array $properties, array $config): DateRangeFilter
{
[$names, $meta, $resolver] = $this->filterDependencies();

return new DateRangeFilter($names, $meta, $resolver, null, $properties, $config);
}

// Goal: apply() maps the fallback and explicit operators to `range` DSL, and
// expands `between` to exclusive gt/lt bounds.
#[DataProvider('applyProvider')]
public function testApplyEmitsExpectedDsl(array $filters, array $expected): void
{
$filter = $this->newFilter(
['updated' => 'gte'],
['gte' => ['limit' => DateLimit::gte, 'throwOnInvalid' => true]],
);

self::assertSame($expected, $filter->apply([], self::RESOURCE, null, ['filters' => $filters]));
}

public static function applyProvider(): iterable
{
yield 'no filters → empty' => [[], []];
yield 'empty string → skipped' => [['updated' => ''], []];

// Bare value → the configured fallback operator (gte).
yield 'fallback operator' => [
['updated' => '2024-01-01T00:00:00+00:00'],
['range' => ['updated' => ['gte' => '2024-01-01T00:00:00+00:00']]],
];

// Explicit operator via `updated[gt]=…`.
yield 'explicit gt' => [
['updated' => ['gt' => '2024-01-01T00:00:00+00:00']],
['range' => ['updated' => ['gt' => '2024-01-01T00:00:00+00:00']]],
];

// `between` expands to EXCLUSIVE gt/lt bounds — a consumer-visible semantic.
yield 'between → exclusive gt/lt' => [
['updated' => ['between' => '2024-01-01T00:00:00+00:00..2024-02-01T00:00:00+00:00']],
['range' => ['updated' => [
'gt' => '2024-01-01T00:00:00+00:00',
'lt' => '2024-02-01T00:00:00+00:00',
]]],
];
}

// Goal: pin that a malformed `between` value is currently an uncaught throwable
// (the error-contract fix will turn this into a 4xx).
public function testMalformedBetweenThrows(): void
{
$filter = $this->newFilter(
['updated' => 'gte'],
['gte' => ['limit' => DateLimit::gte, 'throwOnInvalid' => true]],
);

// No ".." separator → invalid range. Currently an uncaught \InvalidArgumentException
// (leaks as HTTP 500; the error-contract fix will map this to 4xx).
$this->expectException(\InvalidArgumentException::class);
$filter->apply([], self::RESOURCE, null, ['filters' => ['updated' => ['between' => '2024-01-01T00:00:00+00:00']]]);
}

// Goal: pin the second, distinct error leak — an unknown operator hits a native
// \Error — so the error-contract fix addresses both paths.
public function testUnknownOperatorThrowsError(): void
{
$filter = $this->newFilter(
['updated' => 'gte'],
['gte' => ['limit' => DateLimit::gte, 'throwOnInvalid' => true]],
);

// `updated[foo]=…` resolves DateLimit::{foo} → a native \Error (undefined enum
// case). Distinct from the malformed-between path; also leaks as HTTP 500 today.
$this->expectException(\Error::class);
$filter->apply([], self::RESOURCE, null, ['filters' => ['updated' => ['foo' => 'x']]]);
}

// Goal: document that the `throwOnInvalid` config flag is dead — invalid input
// throws whether it is true or false.
#[DataProvider('throwOnInvalidProvider')]
public function testThrowOnInvalidConfigIsNotConsulted(bool $throwOnInvalid): void
{
// `throwOnInvalid` is stored in the config but never read: invalid input throws
// regardless of its value. This pins that the flag is currently dead.
$filter = $this->newFilter(
['updated' => 'gte'],
['gte' => ['limit' => DateLimit::gte, 'throwOnInvalid' => $throwOnInvalid]],
);

$this->expectException(\InvalidArgumentException::class);
$filter->apply([], self::RESOURCE, null, ['filters' => ['updated' => ['between' => 'no-separator']]]);
}

public static function throwOnInvalidProvider(): iterable
{
yield 'throwOnInvalid=true' => [true];
yield 'throwOnInvalid=false' => [false];
}

// Goal: getDescription() advertises the default parameter plus every
// [operator] variant — the surface the OpenAPI spec gate protects.
public function testGetDescriptionExposesEveryOperatorVariant(): void
{
$filter = $this->newFilter(
['updated' => 'gte'],
['gte' => ['limit' => DateLimit::gte, 'throwOnInvalid' => true]],
);

$description = $filter->getDescription(self::RESOURCE);

foreach (['updated', 'updated[between]', 'updated[gt]', 'updated[gte]', 'updated[lt]', 'updated[lte]'] as $key) {
self::assertArrayHasKey($key, $description, $key.' should be described');
}
}
}
33 changes: 33 additions & 0 deletions tests/Unit/Api/Filter/ElasticSearch/FilterFactoryMockTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace App\Tests\Unit\Api\Filter\ElasticSearch;

use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
use ApiPlatform\Metadata\ResourceClassResolverInterface;
use PHPUnit\Framework\MockObject\Stub;

/**
* The custom Elasticsearch filters extend API Platform's AbstractFilter, whose
* constructor requires three metadata factories. When a filter is configured
* with an explicit `properties` map (as every resource here does),
* `AbstractFilter::getProperties()` yields `array_keys($properties)` and never
* touches the factories — so inert test stubs are enough to unit-test
* `apply()`/`getDescription()` without booting the kernel or Elasticsearch.
*/
trait FilterFactoryMockTrait
{
/**
* @return array{PropertyNameCollectionFactoryInterface&Stub, PropertyMetadataFactoryInterface&Stub, ResourceClassResolverInterface&Stub}
*/
private function filterDependencies(): array
{
// Stubs (not mocks) — these collaborators are never called, so they need
// no expectations.
return [
$this->createStub(PropertyNameCollectionFactoryInterface::class),
$this->createStub(PropertyMetadataFactoryInterface::class),
$this->createStub(ResourceClassResolverInterface::class),
];
}
}
68 changes: 68 additions & 0 deletions tests/Unit/Api/Filter/ElasticSearch/IdFilterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

namespace App\Tests\Unit\Api\Filter\ElasticSearch;

use App\Api\Filter\ElasticSearch\IdFilter;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

/**
* Pins IdFilter::apply() query DSL. Unlike Boolean/TagFilter, each property
* yields its own `terms` clause appended to a list, with the boost nested inside
* the clause.
*/
class IdFilterTest extends TestCase
{
use FilterFactoryMockTrait;

private const RESOURCE = 'App\Api\Dto\Event';

// Goal: apply() appends one `terms` clause per property (boost nested inside)
// and comma-splits multi-value ids.
#[DataProvider('applyProvider')]
public function testApplyEmitsExpectedDsl(array $properties, array $filters, array $expected): void
{
[$names, $meta, $resolver] = $this->filterDependencies();
$filter = new IdFilter($names, $meta, $resolver, null, $properties);

self::assertSame($expected, $filter->apply([], self::RESOURCE, null, ['filters' => $filters]));
}

public static function applyProvider(): iterable
{
$props = ['organizer.entityId' => null, 'location.entityId' => null];

yield 'no filters → empty' => [$props, [], []];
yield 'empty string → skipped' => [$props, ['organizer.entityId' => ''], []];
yield 'single id' => [
$props,
['organizer.entityId' => '9'],
[['terms' => ['organizer.entityId' => ['9'], 'boost' => 1.0]]],
];
yield 'comma list is split' => [
$props,
['organizer.entityId' => '9,11'],
[['terms' => ['organizer.entityId' => ['9', '11'], 'boost' => 1.0]]],
];
yield 'two properties → two clauses' => [
$props,
['organizer.entityId' => '9', 'location.entityId' => '4'],
[
['terms' => ['organizer.entityId' => ['9'], 'boost' => 1.0]],
['terms' => ['location.entityId' => ['4'], 'boost' => 1.0]],
],
];
}

// Goal: getDescription() advertises the property as a collection parameter.
public function testGetDescriptionShape(): void
{
[$names, $meta, $resolver] = $this->filterDependencies();
$filter = new IdFilter($names, $meta, $resolver, null, ['organizer.entityId' => null]);

$description = $filter->getDescription(self::RESOURCE);

self::assertArrayHasKey('organizer.entityId', $description);
self::assertTrue($description['organizer.entityId']['is_collection']);
}
}
74 changes: 74 additions & 0 deletions tests/Unit/Api/Filter/ElasticSearch/MatchFilterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

namespace App\Tests\Unit\Api\Filter\ElasticSearch;

use App\Api\Filter\ElasticSearch\MatchFilter;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

/**
* Pins the exact Elasticsearch query DSL emitted by MatchFilter::apply() and the
* shape of getDescription(), independent of Elasticsearch. These lock the
* consumer-visible query contract so a change to the filter (or an API Platform
* upgrade) is caught.
*/
class MatchFilterTest extends TestCase
{
use FilterFactoryMockTrait;

private const RESOURCE = 'App\Api\Dto\Event';

/**
* Goal: apply() emits the exact `match` DSL — a single hit is returned bare,
* multiple hits as a list, and unset/empty values are skipped.
*
* @param array<string, mixed> $filters
*/
#[DataProvider('applyProvider')]
public function testApplyEmitsExpectedDsl(array $properties, array $filters, mixed $expected): void
{
[$names, $meta, $resolver] = $this->filterDependencies();
$filter = new MatchFilter($names, $meta, $resolver, null, $properties);

self::assertSame($expected, $filter->apply([], self::RESOURCE, null, ['filters' => $filters]));
}

public static function applyProvider(): iterable
{
$props = ['title' => null, 'organizer.name' => null];

yield 'no filters → empty' => [$props, [], []];
yield 'unset property → empty' => [$props, ['somethingElse' => 'x'], []];
yield 'empty string → skipped' => [$props, ['title' => ''], []];
yield 'empty array → skipped' => [$props, ['title' => []], []];

// A single match returns the bare clause (not wrapped in a list).
yield 'single match' => [$props, ['title' => 'bicycle'], ['match' => ['title' => 'bicycle']]];

// "0" is a real value and must reach Elasticsearch (regression guard for
// the old empty() drop).
yield "'0' reaches ES" => [$props, ['title' => '0'], ['match' => ['title' => '0']]];

// Two matches are returned as a list.
yield 'two matches → list' => [
$props,
['title' => 'a', 'organizer.name' => 'b'],
[['match' => ['title' => 'a']], ['match' => ['organizer.name' => 'b']]],
];
}

// Goal: getDescription() advertises the property as an optional string parameter
// (the surface the OpenAPI spec gate protects).
public function testGetDescriptionShape(): void
{
[$names, $meta, $resolver] = $this->filterDependencies();
$filter = new MatchFilter($names, $meta, $resolver, null, ['title' => null]);

$description = $filter->getDescription(self::RESOURCE);

self::assertArrayHasKey('title', $description);
self::assertSame('title', $description['title']['property']);
self::assertSame('string', $description['title']['type']);
self::assertFalse($description['title']['required']);
}
}
Loading
Loading