mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
add tests for tag match type (backend + e2e)
This commit is contained in:
69
e2e/reporting-tag-match.spec.ts
Normal file
69
e2e/reporting-tag-match.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
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);
|
||||
});
|
||||
@@ -2,12 +2,7 @@
|
||||
import { CheckCircleIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
||||
import { FolderIcon } from '@heroicons/vue/16/solid';
|
||||
import { Check } from '@lucide/vue';
|
||||
import {
|
||||
RadioGroupIndicator,
|
||||
RadioGroupItem,
|
||||
RadioGroupRoot,
|
||||
type AcceptableValue,
|
||||
} from 'reka-ui';
|
||||
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot, type AcceptableValue } from 'reka-ui';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
|
||||
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user