diff --git a/img/screenshots/email-field-admin-proof.png b/img/screenshots/email-field-admin-proof.png new file mode 100644 index 0000000..7dc6520 Binary files /dev/null and b/img/screenshots/email-field-admin-proof.png differ diff --git a/lib/Enum/FieldType.php b/lib/Enum/FieldType.php index 9dd2a8b..cadc5f1 100644 --- a/lib/Enum/FieldType.php +++ b/lib/Enum/FieldType.php @@ -15,6 +15,7 @@ enum FieldType: string { case BOOLEAN = 'boolean'; case DATE = 'date'; case URL = 'url'; + case EMAIL = 'email'; case SELECT = 'select'; case MULTISELECT = 'multiselect'; @@ -28,6 +29,7 @@ public static function values(): array { self::BOOLEAN->value, self::DATE->value, self::URL->value, + self::EMAIL->value, self::SELECT->value, self::MULTISELECT->value, ]; diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 89699bc..3cb6c10 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -10,7 +10,7 @@ namespace OCA\ProfileFields; /** - * @psalm-type ProfileFieldsType = 'text'|'number'|'boolean'|'date'|'url'|'select'|'multiselect' + * @psalm-type ProfileFieldsType = 'text'|'number'|'boolean'|'date'|'url'|'email'|'select'|'multiselect' * @psalm-type ProfileFieldsVisibility = 'private'|'users'|'public' * @psalm-type ProfileFieldsEditPolicy = 'admins'|'users' * @psalm-type ProfileFieldsExposurePolicy = 'hidden'|'private'|'users'|'public' diff --git a/lib/Service/DataImportService.php b/lib/Service/DataImportService.php index e7cd8a2..d71d88a 100644 --- a/lib/Service/DataImportService.php +++ b/lib/Service/DataImportService.php @@ -69,7 +69,7 @@ public function import(array $payload, bool $dryRun = false): array { * @param list $this->normalizeBooleanValue($rawValue), FieldType::DATE => $this->normalizeDateValue($rawValue), FieldType::URL => $this->normalizeUrlValue($rawValue), + FieldType::EMAIL => $this->normalizeEmailValue($rawValue), FieldType::SELECT => $this->normalizeSelectValue($rawValue, $definition), FieldType::MULTISELECT => $this->normalizeMultiSelectValue($rawValue, $definition), }; @@ -367,6 +368,23 @@ private function normalizeUrlValue(array|string|int|float|bool $rawValue): array return ['value' => $value]; } + /** + * @param array|scalar $rawValue + * @return array{value: string} + */ + private function normalizeEmailValue(array|string|int|float|bool $rawValue): array { + if (!is_string($rawValue)) { + throw new InvalidArgumentException($this->l10n->t('Email fields require a valid email address.')); + } + + $value = trim($rawValue); + if (filter_var($value, FILTER_VALIDATE_EMAIL) === false) { + throw new InvalidArgumentException($this->l10n->t('Email fields require a valid email address.')); + } + + return ['value' => $value]; + } + /** * @param array $value */ @@ -429,8 +447,8 @@ private function normalizeSearchValue(FieldDefinition $definition, string $opera return $this->normalizeValue($definition, $rawValue); } - if (FieldType::from($definition->getType()) !== FieldType::TEXT) { - throw new InvalidArgumentException($this->l10n->t('The "contains" operator is only available for text fields.')); + if (!in_array(FieldType::from($definition->getType()), [FieldType::TEXT, FieldType::EMAIL], true)) { + throw new InvalidArgumentException($this->l10n->t('The "contains" operator is only available for text and email fields.')); } $normalized = $this->normalizeValue($definition, $rawValue); @@ -451,7 +469,7 @@ private function matchesSearchOperator(FieldType $fieldType, array $candidateVal return ($candidateValue['value'] ?? null) === ($searchValue['value'] ?? null); } - if ($fieldType !== FieldType::TEXT) { + if (!in_array($fieldType, [FieldType::TEXT, FieldType::EMAIL], true)) { return false; } diff --git a/lib/Service/ImportPayloadValidator.php b/lib/Service/ImportPayloadValidator.php index 0186433..6365db0 100644 --- a/lib/Service/ImportPayloadValidator.php +++ b/lib/Service/ImportPayloadValidator.php @@ -32,7 +32,7 @@ public function __construct( * definitions: listgetType()); if ($fieldType === FieldType::MULTISELECT) { $this->normalizeExpectedMultiSelectOperand($definition, $config['value']); - } elseif ($fieldType === FieldType::URL && ((string)$operator === 'contains' || (string)$operator === '!contains')) { - // URL contains search terms are plain substrings — no URL validation needed. + } elseif ( + ($fieldType === FieldType::URL || $fieldType === FieldType::EMAIL) + && ((string)$operator === 'contains' || (string)$operator === '!contains') + ) { + // URL/email contains search terms are plain substrings — no strict value validation needed. } else { $this->fieldValueService->normalizeValue($definition, $config['value']); } @@ -196,6 +207,7 @@ private function isOperatorSupported(FieldDefinition $definition, string $operat FieldType::BOOLEAN => self::BOOLEAN_OPERATORS, FieldType::DATE => self::DATE_OPERATORS, FieldType::URL => self::URL_OPERATORS, + FieldType::EMAIL => self::EMAIL_OPERATORS, FieldType::SELECT => self::SELECT_OPERATORS, FieldType::MULTISELECT => self::SELECT_OPERATORS, }; @@ -261,7 +273,7 @@ private function evaluate(FieldDefinition $definition, string $operator, string| return $this->evaluateMultiSelectOperator($operator, $expectedValue, $actualValue); } - if ($fieldType === FieldType::URL && ($operator === 'contains' || $operator === '!contains')) { + if (($fieldType === FieldType::URL || $fieldType === FieldType::EMAIL) && ($operator === 'contains' || $operator === '!contains')) { return $this->evaluateTextOperator($operator, trim((string)$expectedRawValue), (string)$actualValue); } @@ -271,6 +283,7 @@ private function evaluate(FieldDefinition $definition, string $operator, string| return match ($fieldType) { FieldType::TEXT, FieldType::URL, + FieldType::EMAIL, FieldType::SELECT => $this->evaluateTextOperator($operator, (string)$expectedValue, (string)$actualValue), FieldType::BOOLEAN => $this->evaluateBooleanOperator( $operator, diff --git a/openapi-administration.json b/openapi-administration.json index 39c6287..6c52dc4 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -201,6 +201,7 @@ "boolean", "date", "url", + "email", "select", "multiselect" ] diff --git a/openapi-full.json b/openapi-full.json index d7246b7..551804d 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -276,6 +276,7 @@ "boolean", "date", "url", + "email", "select", "multiselect" ] diff --git a/openapi.json b/openapi.json index 6686e13..d5e81eb 100644 --- a/openapi.json +++ b/openapi.json @@ -147,6 +147,7 @@ "boolean", "date", "url", + "email", "select", "multiselect" ] diff --git a/src/components/AdminUserFieldsDialog.vue b/src/components/AdminUserFieldsDialog.vue index e4c9be6..b8aec8e 100644 --- a/src/components/AdminUserFieldsDialog.vue +++ b/src/components/AdminUserFieldsDialog.vue @@ -212,6 +212,7 @@ export default defineComponent({ boolean: t('profile_fields', 'Choose either true or false.'), date: t('profile_fields', 'Use a valid date in YYYY-MM-DD format.'), url: t('profile_fields', 'Enter a valid URL (e.g. https://example.com).'), + email: t('profile_fields', 'Enter a valid email address (e.g. alice@example.com).'), select: t('profile_fields', 'Choose one of the predefined options.'), multiselect: t('profile_fields', 'Choose one or more predefined options.'), } as Record)[type] @@ -222,6 +223,7 @@ export default defineComponent({ boolean: t('profile_fields', 'Select true or false'), date: t('profile_fields', 'Select a date'), url: t('profile_fields', 'Enter a URL'), + email: t('profile_fields', 'Enter an email address'), select: t('profile_fields', 'Select an option'), multiselect: t('profile_fields', 'Select one or more options'), } as Record)[type] @@ -235,6 +237,7 @@ export default defineComponent({ boolean: 'text', date: 'numeric', url: 'url', + email: 'email', select: 'text', multiselect: 'text', } as Record)[type] @@ -245,6 +248,7 @@ export default defineComponent({ boolean: 'text', date: 'date', url: 'url', + email: 'email', select: 'text', multiselect: 'text', } as Record)[type] @@ -337,6 +341,13 @@ export default defineComponent({ } } + if (field.definition.type === 'email') { + const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailPattern.test(rawValue)) { + return t('profile_fields', '{fieldLabel} must be a valid email address.', { fieldLabel: field.definition.label }) + } + } + if (field.definition.type === 'select') { const options = field.definition.options ?? [] if (!options.includes(rawValue)) { @@ -368,7 +379,7 @@ export default defineComponent({ const hasInvalidFields = computed(() => invalidFields.value.length > 0) const helperTextForField = (field: AdminEditableField) => { - return field.definition.type === 'number' || field.definition.type === 'date' || field.definition.type === 'boolean' || field.definition.type === 'url' + return field.definition.type === 'number' || field.definition.type === 'date' || field.definition.type === 'boolean' || field.definition.type === 'url' || field.definition.type === 'email' ? descriptionForType(field.definition.type) : '' } @@ -444,6 +455,7 @@ export default defineComponent({ 'Boolean fields require true or false values.': t('profile_fields', '{fieldLabel} must be either true or false.', { fieldLabel: field.definition.label }), 'Date fields require a valid ISO-8601 date in YYYY-MM-DD format.': t('profile_fields', '{fieldLabel} must be a valid date in YYYY-MM-DD format.', { fieldLabel: field.definition.label }), 'URL fields require a valid URL.': t('profile_fields', '{fieldLabel} must be a valid URL.', { fieldLabel: field.definition.label }), + 'Email fields require a valid email address.': t('profile_fields', '{fieldLabel} must be a valid email address.', { fieldLabel: field.definition.label }), 'current_visibility is not supported': t('profile_fields', 'The selected visibility is not supported.'), }[message] ?? (message.includes('is not a valid option') ? t('profile_fields', '{fieldLabel}: invalid option selected.', { fieldLabel: field.definition.label }) diff --git a/src/tests/components/AdminSettings.spec.ts b/src/tests/components/AdminSettings.spec.ts index 5a5c1b9..5e888da 100644 --- a/src/tests/components/AdminSettings.spec.ts +++ b/src/tests/components/AdminSettings.spec.ts @@ -120,4 +120,19 @@ describe('AdminSettings', () => { expect(wrapper.text()).toContain('tr:URL') }) + + it('offers the Email field type in the editor', async() => { + const wrapper = mount(AdminSettings, { + global: { + stubs: { + Draggable: defineComponent({ template: '
' }), + }, + }, + }) + + await flushPromises() + await wrapper.get('button').trigger('click') + + expect(wrapper.text()).toContain('tr:Email') + }) }) \ No newline at end of file diff --git a/src/tests/components/AdminUserFieldsDialog.spec.ts b/src/tests/components/AdminUserFieldsDialog.spec.ts index 384ef0a..010ebad 100644 --- a/src/tests/components/AdminUserFieldsDialog.spec.ts +++ b/src/tests/components/AdminUserFieldsDialog.spec.ts @@ -59,6 +59,17 @@ vi.mock('../../api', () => ({ active: true, options: null, }, + { + id: 6, + field_key: 'work_email', + label: 'Work email', + type: 'email', + edit_policy: 'users', + exposure_policy: 'private', + sort_order: 3, + active: true, + options: null, + }, ]), listAdminUserValues: vi.fn().mockResolvedValue([ { @@ -178,4 +189,36 @@ describe('AdminUserFieldsDialog', () => { // helper-text is bound as an attribute through v-bind="$attrs" expect(urlInput.attributes('helper-text')).toBeTruthy() }) + + it('renders email fields with type=email input', async() => { + const wrapper = mount(AdminUserFieldsDialog, { + props: { + open: true, + userUid: 'alice', + userDisplayName: 'Alice', + }, + }) + + await flushPromises() + + const emailInput = wrapper.find('#profile-fields-user-dialog-value-6') + expect(emailInput.exists()).toBe(true) + expect(emailInput.attributes('type')).toBe('email') + }) + + it('shows email helper text for email fields', async() => { + const wrapper = mount(AdminUserFieldsDialog, { + props: { + open: true, + userUid: 'alice', + userDisplayName: 'Alice', + }, + }) + + await flushPromises() + + const emailInput = wrapper.find('#profile-fields-user-dialog-value-6') + expect(emailInput.exists()).toBe(true) + expect(emailInput.attributes('helper-text')).toBeTruthy() + }) }) \ No newline at end of file diff --git a/src/tests/components/PersonalSettings.spec.ts b/src/tests/components/PersonalSettings.spec.ts index 364c6d3..a917242 100644 --- a/src/tests/components/PersonalSettings.spec.ts +++ b/src/tests/components/PersonalSettings.spec.ts @@ -87,6 +87,29 @@ vi.mock('../../api', () => ({ }, can_edit: true, }, + { + definition: { + id: 6, + field_key: 'work_email', + label: 'Work email', + type: 'email', + edit_policy: 'users', + exposure_policy: 'private', + sort_order: 3, + active: true, + options: null, + }, + value: { + id: 13, + field_definition_id: 6, + user_uid: 'alice', + value: { value: 'alice@example.com' }, + current_visibility: 'private', + updated_by_uid: 'alice', + updated_at: '2026-03-20T12:00:00+00:00', + }, + can_edit: true, + }, ]), upsertOwnValue: vi.fn(), })) @@ -173,4 +196,20 @@ describe('PersonalSettings', () => { expect(input.exists()).toBe(true) expect(input.attributes('type')).toBe('url') }) + + it('renders email fields with type=email input', async() => { + const wrapper = mount(PersonalSettings, { + global: { + stubs: { + Teleport: true, + }, + }, + }) + + await flushPromises() + + const input = wrapper.find('[data-testid="profile-fields-personal-input-work_email"]') + expect(input.exists()).toBe(true) + expect(input.attributes('type')).toBe('email') + }) }) \ No newline at end of file diff --git a/src/tests/utils/workflowProfileFieldCheck.spec.ts b/src/tests/utils/workflowProfileFieldCheck.spec.ts index 691b460..ba1db16 100644 --- a/src/tests/utils/workflowProfileFieldCheck.spec.ts +++ b/src/tests/utils/workflowProfileFieldCheck.spec.ts @@ -16,6 +16,7 @@ const definitions = [ { field_key: 'start_date', label: 'Start date', type: 'date', active: true }, { field_key: 'is_manager', label: 'Is manager', type: 'boolean', active: true }, { field_key: 'website', label: 'Website', type: 'url', active: true }, + { field_key: 'work_email', label: 'Work email', type: 'email', active: true }, ] as const describe('workflowProfileFieldCheck', () => { @@ -90,4 +91,19 @@ describe('workflowProfileFieldCheck', () => { it('accepts contains operator for url field', () => { expect(isWorkflowOperatorSupported('contains', serializeWorkflowCheckValue({ field_key: 'website', value: 'example.com' }), definitions)).toBe(true) }) + + it('returns text-style operators for email definitions', () => { + expect(getWorkflowOperatorKeys(serializeWorkflowCheckValue({ field_key: 'work_email', value: 'alice@example.com' }), definitions)).toEqual([ + 'is-set', + '!is-set', + 'is', + '!is', + 'contains', + '!contains', + ]) + }) + + it('accepts contains operator for email field', () => { + expect(isWorkflowOperatorSupported('contains', serializeWorkflowCheckValue({ field_key: 'work_email', value: '@example.com' }), definitions)).toBe(true) + }) }) diff --git a/src/types/index.ts b/src/types/index.ts index a7ce545..7e32fa1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -19,7 +19,7 @@ type ApiOperationRequestBody = TOperation extends { type ApiRequestJsonBody = ApiJsonBody> -export type FieldType = ApiComponents['schemas']['Type'] | 'multiselect' | 'date' | 'boolean' | 'url' +export type FieldType = ApiComponents['schemas']['Type'] | 'multiselect' | 'date' | 'boolean' | 'url' | 'email' export type FieldVisibility = ApiComponents['schemas']['Visibility'] export type FieldEditPolicy = 'admins' | 'users' export type FieldExposurePolicy = 'hidden' | FieldVisibility diff --git a/src/types/openapi/openapi-administration.ts b/src/types/openapi/openapi-administration.ts index 3093aa6..32f8891 100644 --- a/src/types/openapi/openapi-administration.ts +++ b/src/types/openapi/openapi-administration.ts @@ -193,7 +193,7 @@ export type components = { }; }; /** @enum {string} */ - Type: "text" | "number" | "boolean" | "date" | "url" | "select" | "multiselect"; + Type: "text" | "number" | "boolean" | "date" | "url" | "email" | "select" | "multiselect"; ValuePayload: { value: Record; }; diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 41baa38..d28c30f 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -276,7 +276,7 @@ export type components = { }; }; /** @enum {string} */ - Type: "text" | "number" | "boolean" | "date" | "url" | "select" | "multiselect"; + Type: "text" | "number" | "boolean" | "date" | "url" | "email" | "select" | "multiselect"; ValuePayload: { value: Record; }; diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 10e19d1..13f7e32 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -100,7 +100,7 @@ export type components = { itemsperpage?: string; }; /** @enum {string} */ - Type: "text" | "number" | "boolean" | "date" | "url" | "select" | "multiselect"; + Type: "text" | "number" | "boolean" | "date" | "url" | "email" | "select" | "multiselect"; ValuePayload: { value: Record; }; diff --git a/src/utils/workflowProfileFieldCheck.ts b/src/utils/workflowProfileFieldCheck.ts index 9e6c0ed..d9a9b2e 100644 --- a/src/utils/workflowProfileFieldCheck.ts +++ b/src/utils/workflowProfileFieldCheck.ts @@ -68,7 +68,7 @@ export const getWorkflowOperatorKeys = (rawValue: string | null | undefined, def ? [...numberOperatorKeys] : definition.type === 'boolean' ? [...booleanOperatorKeys] - : definition.type === 'url' + : definition.type === 'url' || definition.type === 'email' ? [...textOperatorKeys] : [...textOperatorKeys] } diff --git a/src/views/AdminSettings.vue b/src/views/AdminSettings.vue index 78193a2..5cb9738 100644 --- a/src/views/AdminSettings.vue +++ b/src/views/AdminSettings.vue @@ -290,6 +290,7 @@ const fieldTypeOptions: Array<{ value: FieldType, label: string }> = [ { value: 'boolean', label: t('profile_fields', 'Boolean') }, { value: 'date', label: t('profile_fields', 'Date') }, { value: 'url', label: t('profile_fields', 'URL') }, + { value: 'email', label: t('profile_fields', 'Email') }, { value: 'select', label: t('profile_fields', 'Select') }, { value: 'multiselect', label: t('profile_fields', 'Multiselect') }, ] diff --git a/src/views/PersonalSettings.vue b/src/views/PersonalSettings.vue index 9194c12..327ec7b 100644 --- a/src/views/PersonalSettings.vue +++ b/src/views/PersonalSettings.vue @@ -249,16 +249,17 @@ const embeddedVisibilityAnchorReady = ref(false) const draftValues = reactive>({}) const draftVisibilities = reactive>({}) -const inputModesByType: Record = { +const inputModesByType: Record = { text: 'text', number: 'decimal', boolean: 'text', date: 'numeric', + email: 'email', select: 'text', multiselect: 'text', } -const inputModeForType = (type: FieldType): 'text' | 'decimal' | 'numeric' => { +const inputModeForType = (type: FieldType): 'text' | 'decimal' | 'numeric' | 'email' => { return inputModesByType[type] } @@ -268,17 +269,18 @@ const managedByAdminAriaLabel = (fieldLabel: string) => t('profile_fields', '{fi const fieldInputId = (fieldId: number) => `profile-fields-personal-value-${fieldId}` -const componentInputTypesByType: Record = { +const componentInputTypesByType: Record = { text: 'text', number: 'number', boolean: 'text', date: 'date', url: 'url', + email: 'email', select: 'text', multiselect: 'text', } -const componentInputTypeForType = (type: FieldType): 'text' | 'number' | 'date' | 'url' => { +const componentInputTypeForType = (type: FieldType): 'text' | 'number' | 'date' | 'url' | 'email' => { return componentInputTypesByType[type] } @@ -454,7 +456,7 @@ const canAutosaveField = (field: EditableField) => { return field.definition.type === 'multiselect' } - if (field.definition.type === 'text' || field.definition.type === 'select' || field.definition.type === 'date' || field.definition.type === 'boolean' || field.definition.type === 'url') { + if (field.definition.type === 'text' || field.definition.type === 'select' || field.definition.type === 'date' || field.definition.type === 'boolean' || field.definition.type === 'url' || field.definition.type === 'email') { return true } diff --git a/tests/php/Unit/Service/FieldDefinitionValidatorTest.php b/tests/php/Unit/Service/FieldDefinitionValidatorTest.php index 867e4d6..4e8b326 100644 --- a/tests/php/Unit/Service/FieldDefinitionValidatorTest.php +++ b/tests/php/Unit/Service/FieldDefinitionValidatorTest.php @@ -141,6 +141,17 @@ public function testValidateUrlFieldDefinition(): void { $this->assertNull($validated['options']); } + public function testValidateEmailFieldDefinition(): void { + $validated = $this->validator->validate([ + 'field_key' => 'work_email', + 'label' => 'Work email', + 'type' => FieldType::EMAIL->value, + ]); + + $this->assertSame(FieldType::EMAIL->value, $validated['type']); + $this->assertNull($validated['options']); + } + public function testRejectMultiSelectWithNoOptions(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('multiselect fields require at least one option'); diff --git a/tests/php/Unit/Service/FieldValueServiceTest.php b/tests/php/Unit/Service/FieldValueServiceTest.php index 481c53b..326ec61 100644 --- a/tests/php/Unit/Service/FieldValueServiceTest.php +++ b/tests/php/Unit/Service/FieldValueServiceTest.php @@ -115,6 +115,40 @@ public function testNormalizeUrlValueRejectsArray(): void { $this->service->normalizeValue($definition, ['https://example.com']); } + public function testNormalizeEmailValueAcceptsValidEmail(): void { + $definition = $this->buildDefinition(FieldType::EMAIL->value); + + $normalized = $this->service->normalizeValue($definition, 'alice@example.com'); + + $this->assertSame(['value' => 'alice@example.com'], $normalized); + } + + public function testNormalizeEmailValueTrimsWhitespace(): void { + $definition = $this->buildDefinition(FieldType::EMAIL->value); + + $normalized = $this->service->normalizeValue($definition, ' alice@example.com '); + + $this->assertSame(['value' => 'alice@example.com'], $normalized); + } + + public function testNormalizeEmailValueRejectsInvalidEmail(): void { + $definition = $this->buildDefinition(FieldType::EMAIL->value); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Email fields require a valid email address.'); + + $this->service->normalizeValue($definition, 'not-an-email'); + } + + public function testNormalizeEmailValueRejectsArray(): void { + $definition = $this->buildDefinition(FieldType::EMAIL->value); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Email fields require a valid email address.'); + + $this->service->normalizeValue($definition, ['alice@example.com']); + } + public function testNormalizeMissingValueAsNull(): void { $definition = $this->buildDefinition(FieldType::TEXT->value); diff --git a/tests/php/Unit/Workflow/UserProfileFieldCheckTest.php b/tests/php/Unit/Workflow/UserProfileFieldCheckTest.php index e1867f1..f35dabc 100644 --- a/tests/php/Unit/Workflow/UserProfileFieldCheckTest.php +++ b/tests/php/Unit/Workflow/UserProfileFieldCheckTest.php @@ -222,6 +222,35 @@ public function testValidateCheckAcceptsContainsForUrlField(): void { $this->addToAssertionCount(1); } + public function testExecuteCheckMatchesEmailContains(): void { + $definition = $this->buildDefinition(9, 'work_email', FieldType::EMAIL->value); + $value = $this->buildStoredValue(9, 'alice', '{"value":"alice@example.com"}'); + + $this->fieldDefinitionService->expects($this->once()) + ->method('findByFieldKey') + ->with('work_email') + ->willReturn($definition); + $this->fieldValueMapper->expects($this->once()) + ->method('findByFieldDefinitionIdAndUserUid') + ->with(9, 'alice') + ->willReturn($value); + + $this->userSession->method('getUser')->willReturn($this->buildUser('alice')); + + $this->assertTrue($this->check->executeCheck('contains', $this->encodeConfig('work_email', '@example.com'))); + } + + public function testValidateCheckAcceptsContainsForEmailField(): void { + $this->fieldDefinitionService->expects($this->once()) + ->method('findByFieldKey') + ->with('work_email') + ->willReturn($this->buildDefinition(9, 'work_email', FieldType::EMAIL->value)); + + // Should not throw + $this->check->validateCheck('contains', $this->encodeConfig('work_email', '@example.com')); + $this->addToAssertionCount(1); + } + public function testExecuteCheckTreatsMissingValueAsNotSet(): void { $definition = $this->buildDefinition(7, 'region', FieldType::TEXT->value);