1
0
Files
breadcrumb-the-shire/tests/Service/CustomField/UserCustomFieldValueServiceTest.php

562 lines
24 KiB
PHP
Raw Normal View History

<?php
namespace MintyPHP\Tests\Service\CustomField;
use MintyPHP\Repository\CustomField\TenantCustomFieldDefinitionRepositoryInterface;
use MintyPHP\Repository\CustomField\TenantCustomFieldOptionRepositoryInterface;
use MintyPHP\Repository\CustomField\UserCustomFieldValueOptionRepositoryInterface;
use MintyPHP\Repository\CustomField\UserCustomFieldValueRepositoryInterface;
use MintyPHP\Service\CustomField\UserCustomFieldValueService;
use MintyPHP\Service\Tenant\TenantScopeService;
use PHPUnit\Framework\TestCase;
final class UserCustomFieldValueServiceTest extends TestCase
{
private const UUID_A = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
private const UUID_B = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
private const UUID_C = 'cccccccc-cccc-cccc-cccc-cccccccccccc';
// --- validateForTenants ------------------------------------------------
public function testValidateForTenantsSkipsWhenNotEditable(): void
{
$service = $this->makeService();
$this->assertSame(['ok' => true, 'errors' => []], $service->validateForTenants([1], [], false));
}
public function testValidateForTenantsSkipsWhenTenantsEmpty(): void
{
$service = $this->makeService();
$this->assertSame(['ok' => true, 'errors' => []], $service->validateForTenants([0, -1], [], true));
}
public function testValidateForTenantsReturnsPreparedErrorsWhenInvalidValue(): void
{
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('listByTenantIds')->willReturn([
['id' => 5, 'tenant_id' => 1, 'label' => 'Score', 'type' => 'date'],
]);
$optionRepo = $this->createMock(TenantCustomFieldOptionRepositoryInterface::class);
$optionRepo->method('listByDefinitionIds')->willReturn([]);
$service = $this->makeService(
definitionRepository: $definitionRepo,
optionRepository: $optionRepo,
);
$result = $service->validateForTenants([1], [
'custom_field_values' => ['5' => 'not-a-date'],
], true);
$this->assertFalse($result['ok']);
$this->assertContains('Invalid value for Score', $result['errors']);
}
public function testValidateForTenantsReturnsOkWhenClean(): void
{
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('listByTenantIds')->willReturn([
['id' => 5, 'tenant_id' => 1, 'label' => 'Nickname', 'type' => 'text'],
]);
$optionRepo = $this->createMock(TenantCustomFieldOptionRepositoryInterface::class);
$optionRepo->method('listByDefinitionIds')->willReturn([]);
$service = $this->makeService(
definitionRepository: $definitionRepo,
optionRepository: $optionRepo,
);
$result = $service->validateForTenants([1], [
'custom_field_values' => ['5' => 'Fred'],
], true);
$this->assertSame(['ok' => true, 'errors' => []], $result);
}
// --- buildDefinitionsByTenant ------------------------------------------
public function testBuildDefinitionsByTenantReturnsEmptyForEmptyTenantIds(): void
{
$service = $this->makeService();
$this->assertSame([], $service->buildDefinitionsByTenant([0, -1]));
}
public function testBuildDefinitionsByTenantReturnsEmptyWhenNoDefinitions(): void
{
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->expects($this->once())
->method('listByTenantIds')
->with([1, 2], true)
->willReturn([]);
$service = $this->makeService(definitionRepository: $definitionRepo);
$this->assertSame([], $service->buildDefinitionsByTenant([1, 2]));
}
public function testBuildDefinitionsByTenantGroupsByTenantAndMergesOptions(): void
{
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('listByTenantIds')->willReturn([
['id' => 10, 'tenant_id' => 1, 'type' => 'select'],
['id' => 11, 'tenant_id' => 2, 'type' => 'text'],
]);
$optionRepo = $this->createMock(TenantCustomFieldOptionRepositoryInterface::class);
$optionRepo->method('listByDefinitionIds')->willReturn([
['definition_id' => 10, 'id' => 100, 'option_key' => 'a', 'label' => 'A'],
]);
$service = $this->makeService(
definitionRepository: $definitionRepo,
optionRepository: $optionRepo,
);
$result = $service->buildDefinitionsByTenant([1, 2]);
$this->assertArrayHasKey(1, $result);
$this->assertArrayHasKey(2, $result);
$this->assertCount(1, $result[1][0]['options']);
$this->assertSame([], $result[2][0]['options']);
}
// --- buildUserValueMap -------------------------------------------------
public function testBuildUserValueMapReturnsEmptyForInvalidUserId(): void
{
$service = $this->makeService();
$this->assertSame([], $service->buildUserValueMap(0, [1]));
}
public function testBuildUserValueMapReturnsEmptyForEmptyDefinitionIds(): void
{
$service = $this->makeService();
$this->assertSame([], $service->buildUserValueMap(7, [0, -1]));
}
public function testBuildUserValueMapReturnsEmptyWhenRepoHasNoRows(): void
{
$valueRepo = $this->createMock(UserCustomFieldValueRepositoryInterface::class);
$valueRepo->method('listByUserAndDefinitionIds')->willReturn([]);
$service = $this->makeService(valueRepository: $valueRepo);
$this->assertSame([], $service->buildUserValueMap(7, [1, 2]));
}
public function testBuildUserValueMapMergesOptionIdsForMultiselectValues(): void
{
$valueRepo = $this->createMock(UserCustomFieldValueRepositoryInterface::class);
$valueRepo->method('listByUserAndDefinitionIds')->willReturn([
['id' => 100, 'definition_id' => 10, 'value_text' => 'Fred', 'value_bool' => null, 'value_date' => null, 'option_id' => null],
['id' => 101, 'definition_id' => 11, 'value_text' => null, 'value_bool' => 1, 'value_date' => null, 'option_id' => null],
]);
$valueOptionRepo = $this->createMock(UserCustomFieldValueOptionRepositoryInterface::class);
$valueOptionRepo->method('listOptionIdsByValueIds')->willReturn([
100 => [200, 201],
]);
$service = $this->makeService(
valueRepository: $valueRepo,
valueOptionRepository: $valueOptionRepo,
);
$result = $service->buildUserValueMap(7, [10, 11]);
$this->assertSame([200, 201], $result[10]['option_ids']);
$this->assertSame('Fred', $result[10]['value_text']);
$this->assertSame([], $result[11]['option_ids']);
$this->assertSame(1, $result[11]['value_bool']);
}
// --- buildPublicValuesByTenant -----------------------------------------
public function testBuildPublicValuesByTenantReturnsEmptyForInvalidUserId(): void
{
$service = $this->makeService();
$this->assertSame([], $service->buildPublicValuesByTenant(0, [['id' => 1]]));
}
public function testBuildPublicValuesByTenantFiltersByTenantScopeId(): void
{
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
// Only tenant 2 is resolved because tenantScopeId=2 filters out tenant 1.
$definitionRepo->expects($this->once())
->method('listByTenantIds')
->with([2], true)
->willReturn([]);
$service = $this->makeService(definitionRepository: $definitionRepo);
$result = $service->buildPublicValuesByTenant(7, [
['id' => 1, 'uuid' => 't1', 'description' => 'T1', 'status' => 'active'],
['id' => 2, 'uuid' => 't2', 'description' => 'T2', 'status' => 'active'],
], 2);
$this->assertSame([], $result);
}
public function testBuildPublicValuesByTenantAssemblesFieldsWithValues(): void
{
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('listByTenantIds')->willReturn([
['id' => 10, 'tenant_id' => 1, 'uuid' => self::UUID_A, 'field_key' => 'nick', 'label' => 'Nickname', 'type' => 'text', 'is_filterable' => 0],
['id' => 11, 'tenant_id' => 1, 'uuid' => self::UUID_B, 'field_key' => 'prio', 'label' => 'Priority', 'type' => 'select', 'is_filterable' => 1],
]);
$optionRepo = $this->createMock(TenantCustomFieldOptionRepositoryInterface::class);
$optionRepo->method('listByDefinitionIds')->willReturn([
['definition_id' => 11, 'id' => 200, 'option_key' => 'low', 'label' => 'Low'],
['definition_id' => 11, 'id' => 201, 'option_key' => 'high', 'label' => 'High'],
]);
$valueRepo = $this->createMock(UserCustomFieldValueRepositoryInterface::class);
$valueRepo->method('listByUserAndDefinitionIds')->willReturn([
['id' => 500, 'definition_id' => 10, 'value_text' => 'Fred', 'value_bool' => null, 'value_date' => null, 'option_id' => null],
['id' => 501, 'definition_id' => 11, 'value_text' => null, 'value_bool' => null, 'value_date' => null, 'option_id' => 201],
]);
$valueOptionRepo = $this->createMock(UserCustomFieldValueOptionRepositoryInterface::class);
$valueOptionRepo->method('listOptionIdsByValueIds')->willReturn([]);
$service = $this->makeService(
definitionRepository: $definitionRepo,
optionRepository: $optionRepo,
valueRepository: $valueRepo,
valueOptionRepository: $valueOptionRepo,
);
$result = $service->buildPublicValuesByTenant(7, [
['id' => 1, 'uuid' => 't1', 'description' => 'Tenant 1', 'status' => 'active'],
]);
$this->assertCount(1, $result);
$this->assertSame('Tenant 1', $result[0]['tenant']['description']);
$this->assertCount(2, $result[0]['fields']);
$this->assertSame('Fred', $result[0]['fields'][0]['value']);
$this->assertSame('high', $result[0]['fields'][1]['value']);
$this->assertCount(2, $result[0]['fields'][1]['options']);
}
// --- syncForUser -------------------------------------------------------
public function testSyncForUserRejectsInvalidUserId(): void
{
$service = $this->makeService();
$result = $service->syncForUser(0, [1], [], true);
$this->assertFalse($result['ok']);
$this->assertSame(['User not found'], $result['errors']);
}
public function testSyncForUserFailsWhenOutsideCleanupFails(): void
{
$valueRepo = $this->createMock(UserCustomFieldValueRepositoryInterface::class);
$valueRepo->method('deleteByUserOutsideTenantIds')->willReturn(false);
$service = $this->makeService(valueRepository: $valueRepo);
$result = $service->syncForUser(7, [1], [], true);
$this->assertFalse($result['ok']);
$this->assertContains('Custom field values can not be saved', $result['errors']);
}
public function testSyncForUserSkipsEditsWhenNotEditable(): void
{
$valueRepo = $this->createMock(UserCustomFieldValueRepositoryInterface::class);
$valueRepo->method('deleteByUserOutsideTenantIds')->willReturn(true);
$valueRepo->expects($this->never())->method('upsertScalarValue');
$service = $this->makeService(valueRepository: $valueRepo);
$result = $service->syncForUser(7, [1], ['custom_field_values' => ['5' => 'Fred']], false);
$this->assertTrue($result['ok']);
}
public function testSyncForUserWritesTextValue(): void
{
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('listByTenantIds')->willReturn([
['id' => 5, 'tenant_id' => 1, 'label' => 'Nickname', 'type' => 'text'],
]);
$optionRepo = $this->createMock(TenantCustomFieldOptionRepositoryInterface::class);
$optionRepo->method('listByDefinitionIds')->willReturn([]);
$valueRepo = $this->createMock(UserCustomFieldValueRepositoryInterface::class);
$valueRepo->method('deleteByUserOutsideTenantIds')->willReturn(true);
$valueRepo->expects($this->once())
->method('upsertScalarValue')
->with(7, 5, $this->callback(static fn (array $data): bool => ($data['value_text'] ?? null) === 'Fred'))
->willReturn(500);
$valueOptionRepo = $this->createMock(UserCustomFieldValueOptionRepositoryInterface::class);
$valueOptionRepo->expects($this->once())
->method('replaceForValueId')
->with(500, [])
->willReturn(true);
$service = $this->makeService(
definitionRepository: $definitionRepo,
optionRepository: $optionRepo,
valueRepository: $valueRepo,
valueOptionRepository: $valueOptionRepo,
);
$result = $service->syncForUser(7, [1], [
'custom_field_values' => ['5' => 'Fred'],
], true);
$this->assertTrue($result['ok']);
}
public function testSyncForUserDeletesEmptyScalarValues(): void
{
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('listByTenantIds')->willReturn([
['id' => 5, 'tenant_id' => 1, 'label' => 'Nickname', 'type' => 'text'],
]);
$optionRepo = $this->createMock(TenantCustomFieldOptionRepositoryInterface::class);
$optionRepo->method('listByDefinitionIds')->willReturn([]);
$valueRepo = $this->createMock(UserCustomFieldValueRepositoryInterface::class);
$valueRepo->method('deleteByUserOutsideTenantIds')->willReturn(true);
$valueRepo->expects($this->once())
->method('deleteByUserAndDefinitionIds')
->with(7, [5])
->willReturn(true);
$valueRepo->expects($this->never())->method('upsertScalarValue');
$service = $this->makeService(
definitionRepository: $definitionRepo,
optionRepository: $optionRepo,
valueRepository: $valueRepo,
);
$result = $service->syncForUser(7, [1], [
'custom_field_values' => ['5' => ' '],
], true);
$this->assertTrue($result['ok']);
}
public function testSyncForUserRejectsInvalidDateValue(): void
{
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('listByTenantIds')->willReturn([
['id' => 5, 'tenant_id' => 1, 'label' => 'Due', 'type' => 'date'],
]);
$optionRepo = $this->createMock(TenantCustomFieldOptionRepositoryInterface::class);
$optionRepo->method('listByDefinitionIds')->willReturn([]);
$valueRepo = $this->createMock(UserCustomFieldValueRepositoryInterface::class);
$valueRepo->method('deleteByUserOutsideTenantIds')->willReturn(true);
$service = $this->makeService(
definitionRepository: $definitionRepo,
optionRepository: $optionRepo,
valueRepository: $valueRepo,
);
$result = $service->syncForUser(7, [1], [
'custom_field_values' => ['5' => 'not-a-date'],
], true);
$this->assertFalse($result['ok']);
$this->assertContains('Invalid value for Due', $result['errors']);
}
public function testSyncForUserWritesMultiselectOptionIds(): void
{
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('listByTenantIds')->willReturn([
['id' => 5, 'tenant_id' => 1, 'label' => 'Tags', 'type' => 'multiselect'],
]);
$optionRepo = $this->createMock(TenantCustomFieldOptionRepositoryInterface::class);
$optionRepo->method('listByDefinitionIds')->willReturn([
['definition_id' => 5, 'id' => 200, 'active' => 1, 'option_key' => 'x', 'label' => 'X'],
['definition_id' => 5, 'id' => 201, 'active' => 1, 'option_key' => 'y', 'label' => 'Y'],
]);
$valueRepo = $this->createMock(UserCustomFieldValueRepositoryInterface::class);
$valueRepo->method('deleteByUserOutsideTenantIds')->willReturn(true);
$valueRepo->expects($this->once())
->method('upsertScalarValue')
->with(7, 5, $this->callback(static fn (array $d): bool => ($d['option_id'] ?? null) === null && ($d['value_text'] ?? null) === null))
->willReturn(500);
$valueOptionRepo = $this->createMock(UserCustomFieldValueOptionRepositoryInterface::class);
$valueOptionRepo->expects($this->once())
->method('replaceForValueId')
->with(500, [200, 201])
->willReturn(true);
$service = $this->makeService(
definitionRepository: $definitionRepo,
optionRepository: $optionRepo,
valueRepository: $valueRepo,
valueOptionRepository: $valueOptionRepo,
);
$result = $service->syncForUser(7, [1], [
'custom_field_values_multi' => ['5' => [200, 201]],
], true);
$this->assertTrue($result['ok']);
}
public function testSyncForUserRejectsMultiselectValueNotInActiveOptions(): void
{
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('listByTenantIds')->willReturn([
['id' => 5, 'tenant_id' => 1, 'label' => 'Tags', 'type' => 'multiselect'],
]);
$optionRepo = $this->createMock(TenantCustomFieldOptionRepositoryInterface::class);
$optionRepo->method('listByDefinitionIds')->willReturn([
['definition_id' => 5, 'id' => 200, 'active' => 1],
]);
$valueRepo = $this->createMock(UserCustomFieldValueRepositoryInterface::class);
$valueRepo->method('deleteByUserOutsideTenantIds')->willReturn(true);
$service = $this->makeService(
definitionRepository: $definitionRepo,
optionRepository: $optionRepo,
valueRepository: $valueRepo,
);
$result = $service->syncForUser(7, [1], [
'custom_field_values_multi' => ['5' => [200, 999]],
], true);
$this->assertFalse($result['ok']);
$this->assertContains('Invalid value for Tags', $result['errors']);
}
// --- extractCustomFieldFilterSpec -------------------------------------
public function testExtractCustomFieldFilterSpecReturnsEmptyWhenNoFilterableDefinitions(): void
{
$scope = $this->createMock(TenantScopeService::class);
$scope->method('getUserTenantIds')->willReturn([1]);
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('listFilterableByTenantIds')->willReturn([]);
$service = $this->makeService(
userScopeGateway: $scope,
definitionRepository: $definitionRepo,
);
$this->assertSame(
['definitions' => [], 'filters' => [], 'query' => []],
$service->extractCustomFieldFilterSpec(['cf_' . self::UUID_A => '123'], 7)
);
}
public function testExtractCustomFieldFilterSpecParsesSelectAndBooleanFilters(): void
{
$scope = $this->createMock(TenantScopeService::class);
$scope->method('getUserTenantIds')->willReturn([1]);
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('listFilterableByTenantIds')->willReturn([
['id' => 10, 'uuid' => self::UUID_A, 'type' => 'select'],
['id' => 11, 'uuid' => self::UUID_B, 'type' => 'boolean'],
]);
$optionRepo = $this->createMock(TenantCustomFieldOptionRepositoryInterface::class);
$optionRepo->method('listByDefinitionIds')->willReturn([
['definition_id' => 10, 'id' => 200],
]);
$service = $this->makeService(
userScopeGateway: $scope,
definitionRepository: $definitionRepo,
optionRepository: $optionRepo,
);
$result = $service->extractCustomFieldFilterSpec([
'cf_' . self::UUID_A => '200',
'cf_' . self::UUID_B => 'yes',
'cf_' . self::UUID_C => 'ignored', // UUID_C not among definitions
], 7);
$this->assertSame(['10' => 200], $this->keyedAsStringInt($result['filters']['select']));
$this->assertSame(['11' => 1], $this->keyedAsStringInt($result['filters']['boolean']));
$this->assertArrayNotHasKey('cf_' . self::UUID_C, $result['query']);
}
public function testExtractCustomFieldFilterSpecParsesMultiselectAndDateRange(): void
{
$scope = $this->createMock(TenantScopeService::class);
$scope->method('getUserTenantIds')->willReturn([1]);
$definitionRepo = $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class);
$definitionRepo->method('listFilterableByTenantIds')->willReturn([
['id' => 10, 'uuid' => self::UUID_A, 'type' => 'multiselect'],
['id' => 11, 'uuid' => self::UUID_B, 'type' => 'date'],
]);
$optionRepo = $this->createMock(TenantCustomFieldOptionRepositoryInterface::class);
$optionRepo->method('listByDefinitionIds')->willReturn([
['definition_id' => 10, 'id' => 200],
['definition_id' => 10, 'id' => 201],
]);
$service = $this->makeService(
userScopeGateway: $scope,
definitionRepository: $definitionRepo,
optionRepository: $optionRepo,
);
$result = $service->extractCustomFieldFilterSpec([
'cfm_' . self::UUID_A => '200,201,999', // 999 not allowed — should be filtered
'cfd_' . self::UUID_B . '_from' => '2026-01-01',
'cfd_' . self::UUID_B . '_to' => 'not-a-date', // invalid — ignored
], 7);
$this->assertSame([200, 201], $result['filters']['multiselect'][10]);
$this->assertSame('2026-01-01', $result['filters']['date'][11]['from']);
$this->assertNull($result['filters']['date'][11]['to']);
}
// --- helpers -----------------------------------------------------------
/** @param array<int, int> $map @return array<string, int> */
private function keyedAsStringInt(array $map): array
{
$out = [];
foreach ($map as $k => $v) {
$out[(string) $k] = (int) $v;
}
return $out;
}
private function makeService(
?TenantScopeService $userScopeGateway = null,
?TenantCustomFieldDefinitionRepositoryInterface $definitionRepository = null,
?TenantCustomFieldOptionRepositoryInterface $optionRepository = null,
?UserCustomFieldValueRepositoryInterface $valueRepository = null,
?UserCustomFieldValueOptionRepositoryInterface $valueOptionRepository = null,
): UserCustomFieldValueService {
return new UserCustomFieldValueService(
$userScopeGateway ?? $this->createMock(TenantScopeService::class),
$definitionRepository ?? $this->createMock(TenantCustomFieldDefinitionRepositoryInterface::class),
$optionRepository ?? $this->createMock(TenantCustomFieldOptionRepositoryInterface::class),
$valueRepository ?? $this->createMock(UserCustomFieldValueRepositoryInterface::class),
$valueOptionRepository ?? $this->createMock(UserCustomFieldValueOptionRepositoryInterface::class),
);
}
}