add tests for tag match type (backend + e2e)

This commit is contained in:
Gregor Vostrak
2026-06-26 14:16:42 +02:00
parent ccf3cec8c0
commit 981bcbe091
6 changed files with 521 additions and 6 deletions

View File

@@ -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);
}
}