From 4d95b7ab0e037ddd72f70c953065c8853d096631 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Fri, 26 Jun 2026 14:16:42 +0200 Subject: [PATCH] add tests for tag match type (backend + e2e) --- e2e/reporting-tag-match.spec.ts | 72 +++++++ .../V1/Public/PublicReportEndpointTest.php | 55 ++++++ .../Endpoint/Api/V1/ReportEndpointTest.php | 61 ++++++ .../Endpoint/Api/V1/TimeEntryEndpointTest.php | 150 ++++++++++++++ tests/Unit/Service/TimeEntryFilterTest.php | 185 ++++++++++++++++++ 5 files changed, 523 insertions(+) create mode 100644 e2e/reporting-tag-match.spec.ts diff --git a/e2e/reporting-tag-match.spec.ts b/e2e/reporting-tag-match.spec.ts new file mode 100644 index 00000000..8c8a32ec --- /dev/null +++ b/e2e/reporting-tag-match.spec.ts @@ -0,0 +1,72 @@ +import { expect } from '@playwright/test'; +import { test } from '../playwright/fixtures'; +import { + goToReportingDetailed, + waitForDetailedReportingUpdate, +} from './utils/reporting'; +import { createTimeEntryWithTagViaApi } from './utils/api'; + +// Each test registers a new user and creates test data via the API +test.describe.configure({ timeout: 30000 }); + +test('detailed reporting: "Does Not Contain" excludes entries with the selected tag', async ({ + page, + ctx, +}) => { + const tagA = 'MatchTagA ' + Math.floor(Math.random() * 10000); + const tagB = 'MatchTagB ' + Math.floor(Math.random() * 10000); + await createTimeEntryWithTagViaApi(ctx, tagA, '1h'); + await createTimeEntryWithTagViaApi(ctx, tagB, '2h'); + + await goToReportingDetailed(page); + await expect(page.getByText(`Entry with tag ${tagA}`).first()).toBeVisible(); + await expect(page.getByText(`Entry with tag ${tagB}`).first()).toBeVisible(); + + // Open the Tags dropdown, select tagA, then switch the match mode to "Does Not Contain" + await page.getByRole('button', { name: 'Tags' }).click(); + await Promise.all([ + waitForDetailedReportingUpdate(page), + page.getByRole('option').filter({ hasText: tagA }).click(), + ]); + await Promise.all([ + waitForDetailedReportingUpdate(page), + page.getByRole('radio', { name: 'Does Not Contain', exact: true }).click(), + ]); + await page.keyboard.press('Escape'); + + // The entry with tagA is excluded; the entry with tagB remains + await expect(page.getByText(`Entry with tag ${tagA}`)).toHaveCount(0); + await expect(page.getByText(`Entry with tag ${tagB}`).first()).toBeVisible(); +}); + +test('detailed reporting: toggling between "Contains" and "Does Not Contain" flips the result', async ({ + page, + ctx, +}) => { + const tagA = 'ToggleTagA ' + Math.floor(Math.random() * 10000); + const tagB = 'ToggleTagB ' + Math.floor(Math.random() * 10000); + await createTimeEntryWithTagViaApi(ctx, tagA, '1h'); + await createTimeEntryWithTagViaApi(ctx, tagB, '2h'); + + await goToReportingDetailed(page); + await page.getByRole('button', { name: 'Tags' }).click(); + await Promise.all([ + waitForDetailedReportingUpdate(page), + page.getByRole('option').filter({ hasText: tagA }).click(), + ]); + + // "Contains" tagA -> only the tagA entry is listed + await page.keyboard.press('Escape'); + await expect(page.getByText(`Entry with tag ${tagA}`).first()).toBeVisible(); + await expect(page.getByText(`Entry with tag ${tagB}`)).toHaveCount(0); + + // "Does Not Contain" tagA -> flips to the tagB entry + await page.getByRole('button', { name: 'Tags' }).click(); + await Promise.all([ + waitForDetailedReportingUpdate(page), + page.getByRole('radio', { name: 'Does Not Contain', exact: true }).click(), + ]); + await page.keyboard.press('Escape'); + await expect(page.getByText(`Entry with tag ${tagB}`).first()).toBeVisible(); + await expect(page.getByText(`Entry with tag ${tagA}`)).toHaveCount(0); +}); diff --git a/tests/Unit/Endpoint/Api/V1/Public/PublicReportEndpointTest.php b/tests/Unit/Endpoint/Api/V1/Public/PublicReportEndpointTest.php index 84c8e55c..610c80da 100644 --- a/tests/Unit/Endpoint/Api/V1/Public/PublicReportEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/Public/PublicReportEndpointTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tests\Unit\Endpoint\Api\V1\Public; +use App\Enums\TagMatchType; use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\Weekday; @@ -667,4 +668,58 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract ], ]); } + + public function test_show_applies_not_contains_tag_match_type(): void + { + // Arrange + $organization = Organization::factory()->create(); + $tagA = Tag::factory()->forOrganization($organization)->create(); + $tagB = Tag::factory()->forOrganization($organization)->create(); + + // Entry with tagA (should be excluded by "does not contain tagA") + TimeEntry::factory()->forOrganization($organization) + ->startWithDuration(now()->subDay(), 100) + ->create([ + 'tags' => [$tagA->getKey()], + ]); + // Entry with a different tag (should be included) + TimeEntry::factory()->forOrganization($organization) + ->startWithDuration(now()->subDay(), 200) + ->create([ + 'tags' => [$tagB->getKey()], + ]); + // Entry without tags (should be included) + TimeEntry::factory()->forOrganization($organization) + ->startWithDuration(now()->subDay(), 50) + ->create(); + + $reportDto = new ReportPropertiesDto; + $reportDto->start = now()->subDays(2); + $reportDto->end = now(); + $reportDto->group = TimeEntryAggregationType::Project; + $reportDto->subGroup = TimeEntryAggregationType::Task; + $reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day; + $reportDto->weekStart = Weekday::Monday; + $reportDto->timezone = 'Europe/Vienna'; + $reportDto->setTagIds([$tagA->getKey()]); + $reportDto->setTagMatchType(TagMatchType::NotContains); + $report = Report::factory()->forOrganization($organization)->public()->create([ + 'public_until' => null, + 'properties' => $reportDto, + ]); + + // Act + $response = $this->getJson(route('api.v1.public.reports.show'), [ + 'X-Api-Key' => $report->share_secret, + ]); + + // Assert: tagA entry (100s) excluded; tagB (200s) + untagged (50s) included + $response->assertOk(); + $response->assertJson([ + 'data' => [ + 'seconds' => 250, + 'grouped_type' => TimeEntryAggregationType::Project->value, + ], + ]); + } } diff --git a/tests/Unit/Endpoint/Api/V1/ReportEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ReportEndpointTest.php index de8eb90b..9b298f98 100644 --- a/tests/Unit/Endpoint/Api/V1/ReportEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ReportEndpointTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tests\Unit\Endpoint\Api\V1; +use App\Enums\TagMatchType; use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryRoundingType; use App\Enums\Weekday; @@ -685,4 +686,64 @@ class ReportEndpointTest extends ApiEndpointTestAbstract 'id' => $report->getKey(), ]); } + + public function test_store_endpoint_persists_tag_match_type(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'reports:create', + ]); + $tag = Tag::factory()->forOrganization($data->organization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->withoutExceptionHandling()->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [ + 'name' => 'Report with tag match type', + 'is_public' => false, + 'properties' => [ + 'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(), + 'end' => Carbon::now()->toIso8601ZuluString(), + 'group' => TimeEntryAggregationType::Project->value, + 'sub_group' => TimeEntryAggregationType::Task->value, + 'history_group' => TimeEntryAggregationType::Day->value, + 'tag_ids' => [$tag->getKey()], + 'tag_match_type' => TagMatchType::NotContains->value, + ], + ]); + + // Assert + $response->assertStatus(201); + /** @var Report $report */ + $report = Report::query()->findOrFail($response->json('data.id')); + $this->assertSame(TagMatchType::NotContains, $report->properties->tagMatchType); + // DetailedReportResource exposes the match type in the response + $response->assertJsonPath('data.properties.tag_match_type', TagMatchType::NotContains->value); + } + + public function test_store_endpoint_rejects_invalid_tag_match_type(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'reports:create', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [ + 'name' => 'Report with invalid tag match type', + 'is_public' => false, + 'properties' => [ + 'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(), + 'end' => Carbon::now()->toIso8601ZuluString(), + 'group' => TimeEntryAggregationType::Project->value, + 'sub_group' => TimeEntryAggregationType::Task->value, + 'history_group' => TimeEntryAggregationType::Day->value, + 'tag_match_type' => 'invalid_value', + ], + ]); + + // Assert + $response->assertStatus(422); + $response->assertInvalid(['properties.tag_match_type']); + } } diff --git a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php index aed71833..0661f7ab 100644 --- a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php @@ -6,6 +6,7 @@ namespace Tests\Unit\Endpoint\Api\V1; use App\Enums\ExportFormat; use App\Enums\Role; +use App\Enums\TagMatchType; use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryRoundingType; @@ -4351,4 +4352,153 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract $response->assertJsonCount(1, 'data'); $response->assertJsonPath('data.0.id', $timeEntryWithoutTag->getKey()); } + + public function test_index_endpoint_with_not_contains_tag_match_type_excludes_entries_with_tag(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + $tag = Tag::factory()->forOrganization($data->organization)->create(); + $timeEntryWithTag = TimeEntry::factory() + ->forOrganization($data->organization) + ->forMember($data->member) + ->create([ + 'start' => Carbon::now()->subHour(), + 'tags' => [$tag->getKey()], + ]); + $timeEntryWithEmptyTags = TimeEntry::factory() + ->forOrganization($data->organization) + ->forMember($data->member) + ->create([ + 'start' => Carbon::now()->subHour(), + 'tags' => [], + ]); + $timeEntryWithNullTags = TimeEntry::factory() + ->forOrganization($data->organization) + ->forMember($data->member) + ->create([ + 'start' => Carbon::now()->subHour(), + 'tags' => null, + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.index', [ + $data->organization->getKey(), + 'tag_ids' => [$tag->getKey()], + 'tag_match_type' => TagMatchType::NotContains->value, + 'start' => Carbon::now()->subDay()->toIso8601ZuluString(), + 'end' => Carbon::now()->addDay()->toIso8601ZuluString(), + ])); + + // Assert: the tagged entry is excluded; the untagged (empty + null) entries remain + $response->assertValid(); + $this->assertResponseCode($response, 200); + $response->assertJsonCount(2, 'data'); + $returnedIds = collect($response->json('data'))->pluck('id'); + $this->assertTrue($returnedIds->contains($timeEntryWithEmptyTags->getKey())); + $this->assertTrue($returnedIds->contains($timeEntryWithNullTags->getKey())); + $this->assertFalse($returnedIds->contains($timeEntryWithTag->getKey())); + } + + public function test_index_endpoint_with_contains_tag_match_type_returns_only_entries_with_tag(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + $tag = Tag::factory()->forOrganization($data->organization)->create(); + $timeEntryWithTag = TimeEntry::factory() + ->forOrganization($data->organization) + ->forMember($data->member) + ->create([ + 'start' => Carbon::now()->subHour(), + 'tags' => [$tag->getKey()], + ]); + TimeEntry::factory() + ->forOrganization($data->organization) + ->forMember($data->member) + ->create([ + 'start' => Carbon::now()->subHour(), + 'tags' => [], + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.index', [ + $data->organization->getKey(), + 'tag_ids' => [$tag->getKey()], + 'tag_match_type' => TagMatchType::Contains->value, + 'start' => Carbon::now()->subDay()->toIso8601ZuluString(), + 'end' => Carbon::now()->addDay()->toIso8601ZuluString(), + ])); + + // Assert: only the entry that has the tag + $response->assertValid(); + $this->assertResponseCode($response, 200); + $response->assertJsonCount(1, 'data'); + $response->assertJsonPath('data.0.id', $timeEntryWithTag->getKey()); + } + + public function test_index_endpoint_rejects_invalid_tag_match_type(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + $tag = Tag::factory()->forOrganization($data->organization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.index', [ + $data->organization->getKey(), + 'tag_ids' => [$tag->getKey()], + 'tag_match_type' => 'invalid_value', + 'start' => Carbon::now()->subDay()->toIso8601ZuluString(), + 'end' => Carbon::now()->addDay()->toIso8601ZuluString(), + ])); + + // Assert + $this->assertResponseCode($response, 422); + $response->assertInvalid(['tag_match_type']); + } + + public function test_aggregate_endpoint_with_not_contains_tag_match_type_excludes_entries_with_tag(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + $tag = Tag::factory()->forOrganization($data->organization)->create(); + TimeEntry::factory() + ->forOrganization($data->organization) + ->forMember($data->member) + ->startWithDuration(Carbon::now()->subHour(), 100) + ->create([ + 'tags' => [$tag->getKey()], + ]); + TimeEntry::factory() + ->forOrganization($data->organization) + ->forMember($data->member) + ->startWithDuration(Carbon::now()->subHour(), 200) + ->create([ + 'tags' => [], + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.aggregate', [ + $data->organization->getKey(), + 'tag_ids' => [$tag->getKey()], + 'tag_match_type' => TagMatchType::NotContains->value, + 'start' => Carbon::now()->subDay()->toIso8601ZuluString(), + 'end' => Carbon::now()->addDay()->toIso8601ZuluString(), + ])); + + // Assert: only the untagged entry (200s) is aggregated + $response->assertValid(); + $this->assertResponseCode($response, 200); + $response->assertJsonPath('data.seconds', 200); + } } diff --git a/tests/Unit/Service/TimeEntryFilterTest.php b/tests/Unit/Service/TimeEntryFilterTest.php index 0613ecdb..74e58416 100644 --- a/tests/Unit/Service/TimeEntryFilterTest.php +++ b/tests/Unit/Service/TimeEntryFilterTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tests\Unit\Service; +use App\Enums\TagMatchType; use App\Models\Client; use App\Models\Project; use App\Models\Tag; @@ -250,4 +251,188 @@ class TimeEntryFilterTest extends TestCaseWithDatabase $this->assertTrue($timeEntries->contains($timeEntryWithNoTags)); $this->assertFalse($timeEntries->contains($timeEntryWithTag2)); } + + public function test_add_tag_ids_filter_not_contains_includes_entries_without_matching_tag(): void + { + // Arrange + $tag1 = Tag::factory()->create(); + $tag2 = Tag::factory()->create(); + $timeEntryWithTag1 = TimeEntry::factory()->create([ + 'tags' => [$tag1->getKey()], + ]); + $timeEntryWithTag2 = TimeEntry::factory()->create([ + 'tags' => [$tag2->getKey()], + ]); + $timeEntryWithAllTags = TimeEntry::factory()->create([ + 'tags' => [$tag1->getKey(), $tag2->getKey()], + ]); + $timeEntryWithEmptyTags = TimeEntry::factory()->create([ + 'tags' => [], + ]); + $timeEntryWithNullTags = TimeEntry::factory()->create([ + 'tags' => null, + ]); + + $builder = TimeEntry::query(); + $filter = new TimeEntryFilter($builder); + + // Act + $filter->addTagIdsFilter([$tag1->getKey()], TagMatchType::NotContains); + + // Assert + $timeEntries = $builder->get(); + $this->assertCount(3, $timeEntries); + $this->assertFalse($timeEntries->contains($timeEntryWithTag1)); + $this->assertTrue($timeEntries->contains($timeEntryWithTag2)); + $this->assertFalse($timeEntries->contains($timeEntryWithAllTags)); + $this->assertTrue($timeEntries->contains($timeEntryWithEmptyTags)); + $this->assertTrue($timeEntries->contains($timeEntryWithNullTags)); + } + + public function test_add_tag_ids_filter_not_contains_with_none_excludes_entries_without_tags(): void + { + // Arrange + $tag = Tag::factory()->create(); + $timeEntryWithTag = TimeEntry::factory()->create([ + 'tags' => [$tag->getKey()], + ]); + $timeEntryWithEmptyTags = TimeEntry::factory()->create([ + 'tags' => [], + ]); + $timeEntryWithNullTags = TimeEntry::factory()->create([ + 'tags' => null, + ]); + + $builder = TimeEntry::query(); + $filter = new TimeEntryFilter($builder); + + // Act + $filter->addTagIdsFilter([TimeEntryFilter::NONE_VALUE], TagMatchType::NotContains); + + // Assert + $timeEntries = $builder->get(); + $this->assertCount(1, $timeEntries); + $this->assertTrue($timeEntries->contains($timeEntryWithTag)); + $this->assertFalse($timeEntries->contains($timeEntryWithEmptyTags)); + $this->assertFalse($timeEntries->contains($timeEntryWithNullTags)); + } + + public function test_add_tag_ids_filter_not_contains_with_multiple_tags_excludes_entries_with_any_of_them(): void + { + // Arrange + $tag1 = Tag::factory()->create(); + $tag2 = Tag::factory()->create(); + $tag3 = Tag::factory()->create(); + $timeEntryWithTag1 = TimeEntry::factory()->create(['tags' => [$tag1->getKey()]]); + $timeEntryWithTag2 = TimeEntry::factory()->create(['tags' => [$tag2->getKey()]]); + $timeEntryWithTag3 = TimeEntry::factory()->create(['tags' => [$tag3->getKey()]]); + // a filtered tag (tag1) mixed with an unrelated one (tag3): still excluded + $timeEntryWithTag1AndTag3 = TimeEntry::factory()->create(['tags' => [$tag1->getKey(), $tag3->getKey()]]); + $timeEntryWithoutTags = TimeEntry::factory()->create(['tags' => null]); + + $builder = TimeEntry::query(); + $filter = new TimeEntryFilter($builder); + + // Act: "does not contain tag1 or tag2" (NOT (has tag1 OR has tag2)) + $filter->addTagIdsFilter([$tag1->getKey(), $tag2->getKey()], TagMatchType::NotContains); + + // Assert: only entries that have neither tag1 nor tag2 remain + $timeEntries = $builder->get(); + $this->assertCount(2, $timeEntries); + $this->assertFalse($timeEntries->contains($timeEntryWithTag1)); + $this->assertFalse($timeEntries->contains($timeEntryWithTag2)); + $this->assertTrue($timeEntries->contains($timeEntryWithTag3)); + $this->assertFalse($timeEntries->contains($timeEntryWithTag1AndTag3)); + $this->assertTrue($timeEntries->contains($timeEntryWithoutTags)); + } + + public function test_add_tag_ids_filter_contains_mode_returns_only_entries_with_tag(): void + { + // Arrange + $tag1 = Tag::factory()->create(); + $tag2 = Tag::factory()->create(); + $timeEntryWithTag1 = TimeEntry::factory()->create(['tags' => [$tag1->getKey()]]); + $timeEntryWithTag2 = TimeEntry::factory()->create(['tags' => [$tag2->getKey()]]); + $timeEntryWithEmptyTags = TimeEntry::factory()->create(['tags' => []]); + $timeEntryWithNullTags = TimeEntry::factory()->create(['tags' => null]); + + $builder = TimeEntry::query(); + $filter = new TimeEntryFilter($builder); + + // Act: explicit contains mode + $filter->addTagIdsFilter([$tag1->getKey()], TagMatchType::Contains); + + // Assert: only the entry that has tag1 + $timeEntries = $builder->get(); + $this->assertCount(1, $timeEntries); + $this->assertTrue($timeEntries->contains($timeEntryWithTag1)); + $this->assertFalse($timeEntries->contains($timeEntryWithTag2)); + $this->assertFalse($timeEntries->contains($timeEntryWithEmptyTags)); + $this->assertFalse($timeEntries->contains($timeEntryWithNullTags)); + } + + public function test_add_tag_ids_filter_not_contains_with_none_and_tag_excludes_tagged_and_untagged(): void + { + // Arrange + $tag1 = Tag::factory()->create(); + $tag2 = Tag::factory()->create(); + $timeEntryWithTag1 = TimeEntry::factory()->create(['tags' => [$tag1->getKey()]]); + $timeEntryWithTag2 = TimeEntry::factory()->create(['tags' => [$tag2->getKey()]]); + $timeEntryWithBothTags = TimeEntry::factory()->create(['tags' => [$tag1->getKey(), $tag2->getKey()]]); + $timeEntryWithEmptyTags = TimeEntry::factory()->create(['tags' => []]); + $timeEntryWithNullTags = TimeEntry::factory()->create(['tags' => null]); + + $builder = TimeEntry::query(); + $filter = new TimeEntryFilter($builder); + + // Act: NOT (has tag1 OR has no tags) => has at least one tag and not tag1 + $filter->addTagIdsFilter([$tag1->getKey(), TimeEntryFilter::NONE_VALUE], TagMatchType::NotContains); + + // Assert + $timeEntries = $builder->get(); + $this->assertCount(1, $timeEntries); + $this->assertFalse($timeEntries->contains($timeEntryWithTag1)); + $this->assertTrue($timeEntries->contains($timeEntryWithTag2)); + $this->assertFalse($timeEntries->contains($timeEntryWithBothTags)); + $this->assertFalse($timeEntries->contains($timeEntryWithEmptyTags)); + $this->assertFalse($timeEntries->contains($timeEntryWithNullTags)); + } + + public function test_add_tag_ids_filter_with_empty_array_applies_no_filter(): void + { + // Arrange + $tag = Tag::factory()->create(); + TimeEntry::factory()->create(['tags' => [$tag->getKey()]]); + TimeEntry::factory()->create(['tags' => []]); + TimeEntry::factory()->create(['tags' => null]); + + // Act + Assert: an empty selection is no constraint in either mode + $builderNotContains = TimeEntry::query(); + (new TimeEntryFilter($builderNotContains))->addTagIdsFilter([], TagMatchType::NotContains); + $this->assertCount(3, $builderNotContains->get()); + + $builderContains = TimeEntry::query(); + (new TimeEntryFilter($builderContains))->addTagIdsFilter([], TagMatchType::Contains); + $this->assertCount(3, $builderContains->get()); + } + + public function test_add_tag_ids_filter_with_null_match_type_defaults_to_contains(): void + { + // Arrange + $tag = Tag::factory()->create(); + $timeEntryWithTag = TimeEntry::factory()->create(['tags' => [$tag->getKey()]]); + $timeEntryWithoutTag = TimeEntry::factory()->create(['tags' => null]); + + $builder = TimeEntry::query(); + $filter = new TimeEntryFilter($builder); + + // Act: a null match type falls back to "contains" + $filter->addTagIdsFilter([$tag->getKey()], null); + + // Assert + $timeEntries = $builder->get(); + $this->assertCount(1, $timeEntries); + $this->assertTrue($timeEntries->contains($timeEntryWithTag)); + $this->assertFalse($timeEntries->contains($timeEntryWithoutTag)); + } }