From 33a3d2df454d92f57ba9482497038c70e478a566 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 09:14:27 +0200 Subject: [PATCH] test: add unit tests for the Elasticsearch filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the custom filters' apply() query DSL and getDescription() descriptors directly, without Elasticsearch or the kernel — the filters are constructed with inert stubs for the AbstractFilter metadata factories (never called once `properties` is set). - MatchFilter/BooleanFilter/TagFilter/IdFilter: exact term/match clauses, boost shape, comma-splitting, single-vs-list return; "0" reaches ES (guarding the strict-guard behaviour). - DateRangeFilter: fallback/explicit operators, `between` → exclusive gt/lt, and the two current error leaks (malformed `between` → \InvalidArgumentException, unknown operator → native \Error) plus the dead `throwOnInvalid` flag, pinned as current behaviour. --- CHANGELOG.md | 2 + .../ElasticSearch/BooleanFilterTest.php | 53 +++++++ .../ElasticSearch/DateRangeFilterTest.php | 139 ++++++++++++++++++ .../ElasticSearch/FilterFactoryMockTrait.php | 33 +++++ .../Api/Filter/ElasticSearch/IdFilterTest.php | 68 +++++++++ .../Filter/ElasticSearch/MatchFilterTest.php | 74 ++++++++++ .../Filter/ElasticSearch/TagFilterTest.php | 52 +++++++ 7 files changed, 421 insertions(+) create mode 100644 tests/Unit/Api/Filter/ElasticSearch/BooleanFilterTest.php create mode 100644 tests/Unit/Api/Filter/ElasticSearch/DateRangeFilterTest.php create mode 100644 tests/Unit/Api/Filter/ElasticSearch/FilterFactoryMockTrait.php create mode 100644 tests/Unit/Api/Filter/ElasticSearch/IdFilterTest.php create mode 100644 tests/Unit/Api/Filter/ElasticSearch/MatchFilterTest.php create mode 100644 tests/Unit/Api/Filter/ElasticSearch/TagFilterTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bbee56..2fb63a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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-33](https://github.com/itk-dev/event-database-api/pull/33) Mature the API test suite ahead of the API Platform upgrade (contract, filter, pagination and error tests) - [PR-32](https://github.com/itk-dev/event-database-api/pull/32) diff --git a/tests/Unit/Api/Filter/ElasticSearch/BooleanFilterTest.php b/tests/Unit/Api/Filter/ElasticSearch/BooleanFilterTest.php new file mode 100644 index 0000000..6f5307f --- /dev/null +++ b/tests/Unit/Api/Filter/ElasticSearch/BooleanFilterTest.php @@ -0,0 +1,53 @@ +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']); + } +} diff --git a/tests/Unit/Api/Filter/ElasticSearch/DateRangeFilterTest.php b/tests/Unit/Api/Filter/ElasticSearch/DateRangeFilterTest.php new file mode 100644 index 0000000..fb8cfff --- /dev/null +++ b/tests/Unit/Api/Filter/ElasticSearch/DateRangeFilterTest.php @@ -0,0 +1,139 @@ +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'); + } + } +} diff --git a/tests/Unit/Api/Filter/ElasticSearch/FilterFactoryMockTrait.php b/tests/Unit/Api/Filter/ElasticSearch/FilterFactoryMockTrait.php new file mode 100644 index 0000000..ff86fe1 --- /dev/null +++ b/tests/Unit/Api/Filter/ElasticSearch/FilterFactoryMockTrait.php @@ -0,0 +1,33 @@ +createStub(PropertyNameCollectionFactoryInterface::class), + $this->createStub(PropertyMetadataFactoryInterface::class), + $this->createStub(ResourceClassResolverInterface::class), + ]; + } +} diff --git a/tests/Unit/Api/Filter/ElasticSearch/IdFilterTest.php b/tests/Unit/Api/Filter/ElasticSearch/IdFilterTest.php new file mode 100644 index 0000000..0664ffa --- /dev/null +++ b/tests/Unit/Api/Filter/ElasticSearch/IdFilterTest.php @@ -0,0 +1,68 @@ +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']); + } +} diff --git a/tests/Unit/Api/Filter/ElasticSearch/MatchFilterTest.php b/tests/Unit/Api/Filter/ElasticSearch/MatchFilterTest.php new file mode 100644 index 0000000..ce5afd7 --- /dev/null +++ b/tests/Unit/Api/Filter/ElasticSearch/MatchFilterTest.php @@ -0,0 +1,74 @@ + $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']); + } +} diff --git a/tests/Unit/Api/Filter/ElasticSearch/TagFilterTest.php b/tests/Unit/Api/Filter/ElasticSearch/TagFilterTest.php new file mode 100644 index 0000000..6db4173 --- /dev/null +++ b/tests/Unit/Api/Filter/ElasticSearch/TagFilterTest.php @@ -0,0 +1,52 @@ +filterDependencies(); + $filter = new TagFilter($names, $meta, $resolver, null, ['tags' => null]); + + self::assertSame($expected, $filter->apply([], self::RESOURCE, null, ['filters' => $filters])); + } + + public static function applyProvider(): iterable + { + yield 'no filters → empty' => [[], []]; + yield 'empty string → skipped' => [['tags' => ''], []]; + yield 'single tag, verbatim casing' => [['tags' => 'ITKDev'], ['terms' => ['tags' => ['ITKDev'], 'boost' => 1.0]]]; + yield 'comma list is split' => [['tags' => 'aros,ITKDev'], ['terms' => ['tags' => ['aros', 'ITKDev'], 'boost' => 1.0]]]; + // A hyphenated tag is a single whole value, not split. + yield 'hyphenated tag is one value' => [['tags' => 'for-boern'], ['terms' => ['tags' => ['for-boern'], 'boost' => 1.0]]]; + } + + // Goal: getDescription() advertises the property as a collection parameter. + public function testGetDescriptionShape(): void + { + [$names, $meta, $resolver] = $this->filterDependencies(); + $filter = new TagFilter($names, $meta, $resolver, null, ['tags' => null]); + + $description = $filter->getDescription(self::RESOURCE); + + self::assertArrayHasKey('tags', $description); + self::assertTrue($description['tags']['is_collection']); + } +}