add no project, no task, no client, no task, no tag support to the API

This commit is contained in:
Gregor Vostrak
2026-02-02 01:16:28 +01:00
parent bc562bf76f
commit dd75a80df7
7 changed files with 550 additions and 69 deletions

View File

@@ -20,6 +20,7 @@ use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Models\User;
use App\Service\TimeEntryFilter;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Log;
@@ -4033,4 +4034,202 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
'member_id' => $ownTimeEntry->member_id,
]);
}
public function test_index_endpoint_with_none_project_filter_returns_entries_without_project(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$timeEntryWithProject = TimeEntry::factory()
->forOrganization($data->organization)
->forProject($project)
->forMember($data->member)
->create([
'start' => Carbon::now()->subHour(),
]);
$timeEntryWithoutProject = TimeEntry::factory()
->forOrganization($data->organization)
->forMember($data->member)
->create([
'project_id' => null,
'start' => Carbon::now()->subHour(),
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'project_ids' => [TimeEntryFilter::NONE_VALUE],
'start' => Carbon::now()->subDay()->toIso8601ZuluString(),
'end' => Carbon::now()->addDay()->toIso8601ZuluString(),
]));
// Assert
$this->assertResponseCode($response, 200);
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('data.0.id', $timeEntryWithoutProject->getKey());
}
public function test_index_endpoint_with_none_and_id_project_filter_returns_both(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$otherProject = Project::factory()->forOrganization($data->organization)->create();
$timeEntryWithProject = TimeEntry::factory()
->forOrganization($data->organization)
->forProject($project)
->forMember($data->member)
->create([
'start' => Carbon::now()->subHour(),
]);
$timeEntryWithoutProject = TimeEntry::factory()
->forOrganization($data->organization)
->forMember($data->member)
->create([
'project_id' => null,
'start' => Carbon::now()->subHour(),
]);
$timeEntryWithOtherProject = TimeEntry::factory()
->forOrganization($data->organization)
->forProject($otherProject)
->forMember($data->member)
->create([
'start' => Carbon::now()->subHour(),
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'project_ids' => [TimeEntryFilter::NONE_VALUE, $project->getKey()],
'start' => Carbon::now()->subDay()->toIso8601ZuluString(),
'end' => Carbon::now()->addDay()->toIso8601ZuluString(),
]));
// Assert
$this->assertResponseCode($response, 200);
$response->assertJsonCount(2, 'data');
$ids = collect($response->json('data'))->pluck('id')->toArray();
$this->assertContains($timeEntryWithProject->getKey(), $ids);
$this->assertContains($timeEntryWithoutProject->getKey(), $ids);
$this->assertNotContains($timeEntryWithOtherProject->getKey(), $ids);
}
public function test_index_endpoint_with_none_task_filter_returns_entries_without_task(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
$timeEntryWithTask = TimeEntry::factory()
->forOrganization($data->organization)
->forProject($project)
->forTask($task)
->forMember($data->member)
->create([
'start' => Carbon::now()->subHour(),
]);
$timeEntryWithoutTask = TimeEntry::factory()
->forOrganization($data->organization)
->forMember($data->member)
->create([
'task_id' => null,
'start' => Carbon::now()->subHour(),
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'task_ids' => [TimeEntryFilter::NONE_VALUE],
'start' => Carbon::now()->subDay()->toIso8601ZuluString(),
'end' => Carbon::now()->addDay()->toIso8601ZuluString(),
]));
// Assert
$this->assertResponseCode($response, 200);
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('data.0.id', $timeEntryWithoutTask->getKey());
}
public function test_index_endpoint_with_none_client_filter_returns_entries_without_client(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
$client = Client::factory()->forOrganization($data->organization)->create();
$timeEntryWithClient = TimeEntry::factory()
->forOrganization($data->organization)
->forMember($data->member)
->create([
'client_id' => $client->getKey(),
'start' => Carbon::now()->subHour(),
]);
$timeEntryWithoutClient = TimeEntry::factory()
->forOrganization($data->organization)
->forMember($data->member)
->create([
'client_id' => null,
'start' => Carbon::now()->subHour(),
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'client_ids' => [TimeEntryFilter::NONE_VALUE],
'start' => Carbon::now()->subDay()->toIso8601ZuluString(),
'end' => Carbon::now()->addDay()->toIso8601ZuluString(),
]));
// Assert
$this->assertResponseCode($response, 200);
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('data.0.id', $timeEntryWithoutClient->getKey());
}
public function test_index_endpoint_with_none_tag_filter_returns_entries_without_tags(): 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()],
]);
$timeEntryWithoutTag = 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' => [TimeEntryFilter::NONE_VALUE],
'start' => Carbon::now()->subDay()->toIso8601ZuluString(),
'end' => Carbon::now()->addDay()->toIso8601ZuluString(),
]));
// Assert
$this->assertResponseCode($response, 200);
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('data.0.id', $timeEntryWithoutTag->getKey());
}
}

View File

@@ -4,7 +4,10 @@ declare(strict_types=1);
namespace Tests\Unit\Service;
use App\Models\Client;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Service\TimeEntryFilter;
use PHPUnit\Framework\Attributes\CoversClass;
@@ -39,4 +42,167 @@ class TimeEntryFilterTest extends TestCaseWithDatabase
$this->assertCount(3, $timeEntries);
}
public function test_add_project_ids_filter_with_none_returns_entries_without_project(): void
{
// Arrange
$project = Project::factory()->create();
$timeEntryWithProject = TimeEntry::factory()->create([
'project_id' => $project->getKey(),
'organization_id' => $project->organization_id,
]);
$timeEntryWithoutProject = TimeEntry::factory()->create([
'project_id' => null,
]);
$builder = TimeEntry::query();
$filter = new TimeEntryFilter($builder);
// Act
$filter->addProjectIdsFilter([TimeEntryFilter::NONE_VALUE]);
// Assert
$timeEntries = $builder->get();
$this->assertCount(1, $timeEntries);
$this->assertTrue($timeEntries->contains($timeEntryWithoutProject));
$this->assertFalse($timeEntries->contains($timeEntryWithProject));
}
public function test_add_project_ids_filter_with_none_and_ids_returns_both(): void
{
// Arrange
$project = Project::factory()->create();
$timeEntryWithProject = TimeEntry::factory()->create([
'project_id' => $project->getKey(),
'organization_id' => $project->organization_id,
]);
$timeEntryWithoutProject = TimeEntry::factory()->create([
'project_id' => null,
]);
$otherProject = Project::factory()->create();
$timeEntryWithOtherProject = TimeEntry::factory()->create([
'project_id' => $otherProject->getKey(),
'organization_id' => $otherProject->organization_id,
]);
$builder = TimeEntry::query();
$filter = new TimeEntryFilter($builder);
// Act
$filter->addProjectIdsFilter([$project->getKey(), TimeEntryFilter::NONE_VALUE]);
// Assert
$timeEntries = $builder->get();
$this->assertCount(2, $timeEntries);
$this->assertTrue($timeEntries->contains($timeEntryWithProject));
$this->assertTrue($timeEntries->contains($timeEntryWithoutProject));
$this->assertFalse($timeEntries->contains($timeEntryWithOtherProject));
}
public function test_add_task_ids_filter_with_none_returns_entries_without_task(): void
{
// Arrange
$task = Task::factory()->create();
$timeEntryWithTask = TimeEntry::factory()->create([
'task_id' => $task->getKey(),
'organization_id' => $task->organization_id,
]);
$timeEntryWithoutTask = TimeEntry::factory()->create([
'task_id' => null,
]);
$builder = TimeEntry::query();
$filter = new TimeEntryFilter($builder);
// Act
$filter->addTaskIdsFilter([TimeEntryFilter::NONE_VALUE]);
// Assert
$timeEntries = $builder->get();
$this->assertCount(1, $timeEntries);
$this->assertTrue($timeEntries->contains($timeEntryWithoutTask));
$this->assertFalse($timeEntries->contains($timeEntryWithTask));
}
public function test_add_client_ids_filter_with_none_returns_entries_without_client(): void
{
// Arrange
$client = Client::factory()->create();
$timeEntryWithClient = TimeEntry::factory()->create([
'client_id' => $client->getKey(),
'organization_id' => $client->organization_id,
]);
$timeEntryWithoutClient = TimeEntry::factory()->create([
'client_id' => null,
]);
$builder = TimeEntry::query();
$filter = new TimeEntryFilter($builder);
// Act
$filter->addClientIdsFilter([TimeEntryFilter::NONE_VALUE]);
// Assert
$timeEntries = $builder->get();
$this->assertCount(1, $timeEntries);
$this->assertTrue($timeEntries->contains($timeEntryWithoutClient));
$this->assertFalse($timeEntries->contains($timeEntryWithClient));
}
public function test_add_tag_ids_filter_with_none_returns_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]);
// Assert
$timeEntries = $builder->get();
$this->assertCount(2, $timeEntries);
$this->assertTrue($timeEntries->contains($timeEntryWithEmptyTags));
$this->assertTrue($timeEntries->contains($timeEntryWithNullTags));
$this->assertFalse($timeEntries->contains($timeEntryWithTag));
}
public function test_add_tag_ids_filter_with_none_and_ids_returns_both(): void
{
// Arrange
$tag1 = Tag::factory()->create();
$tag2 = Tag::factory()->create();
$timeEntryWithTag1 = TimeEntry::factory()->create([
'tags' => [$tag1->getKey()],
]);
$timeEntryWithTag2 = TimeEntry::factory()->create([
'tags' => [$tag2->getKey()],
]);
$timeEntryWithNoTags = TimeEntry::factory()->create([
'tags' => [],
]);
$builder = TimeEntry::query();
$filter = new TimeEntryFilter($builder);
// Act
$filter->addTagIdsFilter([$tag1->getKey(), TimeEntryFilter::NONE_VALUE]);
// Assert
$timeEntries = $builder->get();
$this->assertCount(2, $timeEntries);
$this->assertTrue($timeEntries->contains($timeEntryWithTag1));
$this->assertTrue($timeEntries->contains($timeEntryWithNoTags));
$this->assertFalse($timeEntries->contains($timeEntryWithTag2));
}
}