Allow NONE filter value to shared reports and add shared-report tests

This commit is contained in:
Gregor Vostrak
2026-02-02 20:42:07 +01:00
parent 18989a9a8e
commit 09c3205680
7 changed files with 651 additions and 10 deletions

View File

@@ -16,6 +16,7 @@ use App\Models\Task;
use App\Models\TimeEntry;
use App\Service\CurrencyService;
use App\Service\Dto\ReportPropertiesDto;
use App\Service\TimeEntryFilter;
use Illuminate\Support\Str;
use Tests\Unit\Endpoint\Api\V1\ApiEndpointTestAbstract;
@@ -423,4 +424,244 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
],
]);
}
public function test_show_returns_only_entries_without_project_when_none_project_filter_is_set(): void
{
// Arrange
$organization = Organization::factory()->create();
$project = Project::factory()->forOrganization($organization)->create();
// Entry with project (should be excluded)
TimeEntry::factory()->forOrganization($organization)
->forProject($project)
->startWithDuration(now()->subDay(), 100)
->create();
// Entry without project (should be included)
TimeEntry::factory()->forOrganization($organization)
->startWithDuration(now()->subDay(), 200)
->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->setProjectIds([TimeEntryFilter::NONE_VALUE]);
$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
$response->assertOk();
$response->assertJson([
'data' => [
'seconds' => 200,
'cost' => 0,
'grouped_type' => TimeEntryAggregationType::Project->value,
],
]);
}
public function test_show_returns_entries_with_and_without_project_when_none_and_real_id_combined(): void
{
// Arrange
$organization = Organization::factory()->create();
$projectA = Project::factory()->forOrganization($organization)->create();
$projectB = Project::factory()->forOrganization($organization)->create();
// Entry with project A (should be included)
TimeEntry::factory()->forOrganization($organization)
->forProject($projectA)
->startWithDuration(now()->subDay(), 100)
->create();
// Entry with project B (should be excluded)
TimeEntry::factory()->forOrganization($organization)
->forProject($projectB)
->startWithDuration(now()->subDay(), 100)
->create();
// Entry without project (should be included)
TimeEntry::factory()->forOrganization($organization)
->startWithDuration(now()->subDay(), 200)
->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->setProjectIds([$projectA->getKey(), TimeEntryFilter::NONE_VALUE]);
$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
$response->assertOk();
$response->assertJson([
'data' => [
'seconds' => 300,
'cost' => 0,
'grouped_type' => TimeEntryAggregationType::Project->value,
],
]);
}
public function test_show_returns_only_entries_without_task_when_none_task_filter_is_set(): void
{
// Arrange
$organization = Organization::factory()->create();
$project = Project::factory()->forOrganization($organization)->create();
$task = Task::factory()->forOrganization($organization)->forProject($project)->create();
// Entry with task (should be excluded)
TimeEntry::factory()->forOrganization($organization)
->forTask($task)
->startWithDuration(now()->subDay(), 100)
->create();
// Entry without task (should be included)
TimeEntry::factory()->forOrganization($organization)
->forProject($project)
->startWithDuration(now()->subDay(), 200)
->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->setTaskIds([TimeEntryFilter::NONE_VALUE]);
$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
$response->assertOk();
$response->assertJson([
'data' => [
'seconds' => 200,
'cost' => 0,
'grouped_type' => TimeEntryAggregationType::Project->value,
],
]);
}
public function test_show_returns_only_entries_without_client_when_none_client_filter_is_set(): void
{
// Arrange
$organization = Organization::factory()->create();
$client = Client::factory()->forOrganization($organization)->create();
$projectWithClient = Project::factory()->forClient($client)->forOrganization($organization)->create();
// Entry with client (should be excluded)
TimeEntry::factory()->forOrganization($organization)
->forProject($projectWithClient)
->startWithDuration(now()->subDay(), 100)
->create();
// Entry without client (should be included)
TimeEntry::factory()->forOrganization($organization)
->startWithDuration(now()->subDay(), 200)
->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->setClientIds([TimeEntryFilter::NONE_VALUE]);
$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
$response->assertOk();
$response->assertJson([
'data' => [
'seconds' => 200,
'cost' => 0,
'grouped_type' => TimeEntryAggregationType::Project->value,
],
]);
}
public function test_show_returns_only_entries_without_tags_when_none_tag_filter_is_set(): void
{
// Arrange
$organization = Organization::factory()->create();
$tag = Tag::factory()->forOrganization($organization)->create();
// Entry with tag (should be excluded)
TimeEntry::factory()->forOrganization($organization)
->startWithDuration(now()->subDay(), 100)
->create([
'tags' => [$tag->getKey()],
]);
// Entry without tags (should be included)
TimeEntry::factory()->forOrganization($organization)
->startWithDuration(now()->subDay(), 200)
->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([TimeEntryFilter::NONE_VALUE]);
$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
$response->assertOk();
$response->assertJson([
'data' => [
'seconds' => 200,
'cost' => 0,
'grouped_type' => TimeEntryAggregationType::Project->value,
],
]);
}
}

View File

@@ -8,7 +8,12 @@ use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday;
use App\Http\Controllers\Api\V1\ReportController;
use App\Models\Client;
use App\Models\Project;
use App\Models\Report;
use App\Models\Tag;
use App\Models\Task;
use App\Service\TimeEntryFilter;
use Illuminate\Support\Carbon;
use Illuminate\Testing\Fluent\AssertableJson;
use Laravel\Passport\Passport;
@@ -490,6 +495,84 @@ class ReportEndpointTest extends ApiEndpointTestAbstract
);
}
public function test_store_endpoint_creates_report_with_none_filter_values(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:create',
]);
Passport::actingAs($data->user);
// Act
$response = $this->withoutExceptionHandling()->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [
'name' => 'Test Report with None Filters',
'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,
'project_ids' => [TimeEntryFilter::NONE_VALUE],
'client_ids' => [TimeEntryFilter::NONE_VALUE],
'tag_ids' => [TimeEntryFilter::NONE_VALUE],
'task_ids' => [TimeEntryFilter::NONE_VALUE],
],
]);
// Assert
$response->assertStatus(201);
/** @var Report $report */
$report = Report::query()->findOrFail($response->json('data.id'));
$this->assertTrue($report->properties->projectIds->contains(TimeEntryFilter::NONE_VALUE));
$this->assertTrue($report->properties->clientIds->contains(TimeEntryFilter::NONE_VALUE));
$this->assertTrue($report->properties->tagIds->contains(TimeEntryFilter::NONE_VALUE));
$this->assertTrue($report->properties->taskIds->contains(TimeEntryFilter::NONE_VALUE));
}
public function test_store_endpoint_creates_report_with_none_combined_with_real_ids(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:create',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$client = Client::factory()->forOrganization($data->organization)->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->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' => 'Test Report with Combined Filters',
'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,
'project_ids' => [$project->getKey(), TimeEntryFilter::NONE_VALUE],
'client_ids' => [$client->getKey(), TimeEntryFilter::NONE_VALUE],
'tag_ids' => [$tag->getKey(), TimeEntryFilter::NONE_VALUE],
'task_ids' => [$task->getKey(), TimeEntryFilter::NONE_VALUE],
],
]);
// Assert
$response->assertStatus(201);
/** @var Report $report */
$report = Report::query()->findOrFail($response->json('data.id'));
$this->assertTrue($report->properties->projectIds->contains($project->getKey()));
$this->assertTrue($report->properties->projectIds->contains(TimeEntryFilter::NONE_VALUE));
$this->assertTrue($report->properties->clientIds->contains($client->getKey()));
$this->assertTrue($report->properties->clientIds->contains(TimeEntryFilter::NONE_VALUE));
$this->assertTrue($report->properties->tagIds->contains($tag->getKey()));
$this->assertTrue($report->properties->tagIds->contains(TimeEntryFilter::NONE_VALUE));
$this->assertTrue($report->properties->taskIds->contains($task->getKey()));
$this->assertTrue($report->properties->taskIds->contains(TimeEntryFilter::NONE_VALUE));
}
public function test_destroy_endpoint_fails_if_user_has_no_permission_to_delete_report(): void
{
// Arrange