From e0034f14c064b1a3503108a9d278a304b8a83d0b Mon Sep 17 00:00:00 2001 From: fedys Date: Mon, 21 Sep 2026 11:20:41 +0200 Subject: [PATCH 1/4] Handling of timezones in datetime values fixed --- CustomFieldType/DateTimeType.php | 3 +- Entity/CustomFieldValueDateTime.php | 10 ++- .../Entity/CustomFieldValueDateTimeTest.php | 66 ++++++++++++++++++- 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/CustomFieldType/DateTimeType.php b/CustomFieldType/DateTimeType.php index f0ccbeb92..797936f7b 100644 --- a/CustomFieldType/DateTimeType.php +++ b/CustomFieldType/DateTimeType.php @@ -53,7 +53,8 @@ public function createValueEntity(CustomField $customField, CustomItem $customIt $value = null; } elseif (is_string($value)) { try { - $value = new \DateTimeImmutable($value); + $value = new \DateTimeImmutable($value, new \DateTimeZone('UTC')); + $value = $value->setTimezone(new \DateTimeZone(date_default_timezone_get())); } catch (\Throwable $e) { throw new InvalidValueException($e->getMessage()); } diff --git a/Entity/CustomFieldValueDateTime.php b/Entity/CustomFieldValueDateTime.php index f696ff2f5..a8d47d0b7 100644 --- a/Entity/CustomFieldValueDateTime.php +++ b/Entity/CustomFieldValueDateTime.php @@ -45,7 +45,15 @@ public function setValue($value = null): void } if (!$value instanceof DateTimeInterface) { - $value = new \DateTimeImmutable($value); + $value = new \DateTimeImmutable($value, new \DateTimeZone('UTC')); + } + + $timezone = new \DateTimeZone(date_default_timezone_get()); + + if ($value instanceof \DateTime) { + $value->setTimezone($timezone); + } elseif ($value instanceof \DateTimeImmutable) { + $value = $value->setTimezone($timezone); } $this->value = $value; diff --git a/Tests/Unit/Entity/CustomFieldValueDateTimeTest.php b/Tests/Unit/Entity/CustomFieldValueDateTimeTest.php index 559011371..39a66a732 100644 --- a/Tests/Unit/Entity/CustomFieldValueDateTimeTest.php +++ b/Tests/Unit/Entity/CustomFieldValueDateTimeTest.php @@ -32,10 +32,74 @@ public function testGettersSetters(): void $optionValue->setValue($value); - $this->assertSame($value, $optionValue->getValue()); + $this->assertEquals($value, $optionValue->getValue()); $optionValue->setValue('2019-06-23 12:43:33'); $this->assertSame('2019-06-23T12:43:33+00:00', $optionValue->getValue()->format(DATE_ATOM)); } + + /** + * @return iterable + */ + public function provideSetValueConvertsTimezoneToLocal(): iterable + { + yield 'UTC string to New York' => [ + 'America/New_York', + '2027-06-07T12:00:00Z', + '2027-06-07T08:00:00-04:00', + ]; + + yield 'UTC DateTimeImmutable to New York' => [ + 'America/New_York', + new \DateTimeImmutable('2027-06-07T12:00:00Z'), + '2027-06-07T08:00:00-04:00', + ]; + + yield 'UTC DateTime to New York' => [ + 'America/New_York', + new \DateTime('2027-06-07T12:00:00Z'), + '2027-06-07T08:00:00-04:00', + ]; + + yield 'UTC string to Asia/Kolkata' => [ + 'Asia/Kolkata', + '2027-06-07T12:00:00Z', + '2027-06-07T17:30:00+05:30', + ]; + + yield 'positive offset string to New York' => [ + 'America/New_York', + '2027-06-07T15:00:00+03:00', + '2027-06-07T08:00:00-04:00', + ]; + + yield 'no timezone string converts UTC to local' => [ + 'America/New_York', + '2027-06-07T12:00:00', + '2027-06-07T08:00:00-04:00', + ]; + } + + /** + * @dataProvider provideSetValueConvertsTimezoneToLocal + * + * @param mixed $value + */ + public function testSetValueConvertsTimezoneToLocal(string $timezone, $value, string $expectedAtom): void + { + $originalTz = date_default_timezone_get(); + date_default_timezone_set($timezone); + + try { + $fieldValue = new CustomFieldValueDateTime(new CustomField(), new CustomItem(new CustomObject())); + $fieldValue->setValue($value); + + $result = $fieldValue->getValue(); + $this->assertSame($timezone, $result->getTimezone()->getName()); + $this->assertSame($expectedAtom, $result->format(DATE_ATOM)); + } finally { + date_default_timezone_set($originalTz); + } + } } From bdcf2abe13b052a518be199e10c71e523632e435 Mon Sep 17 00:00:00 2001 From: fedys Date: Tue, 22 Sep 2026 09:56:09 +0200 Subject: [PATCH 2/4] Functional tests --- ...CustomFieldValueDateTimeFunctionalTest.php | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 Tests/Functional/Entity/CustomFieldValueDateTimeFunctionalTest.php diff --git a/Tests/Functional/Entity/CustomFieldValueDateTimeFunctionalTest.php b/Tests/Functional/Entity/CustomFieldValueDateTimeFunctionalTest.php new file mode 100644 index 000000000..d7eaca0f6 --- /dev/null +++ b/Tests/Functional/Entity/CustomFieldValueDateTimeFunctionalTest.php @@ -0,0 +1,272 @@ +configParams['default_timezone'] = 'America/New_York'; + + parent::setUp(); + + $this->customObject = $this->createCustomObjectWithAllFields(self::$container, 'Event'); + } + + /** + * @return iterable + */ + public function dataApiCreateCustomItemWithDatetime(): iterable + { + yield 'UTC date' => ['2027-06-07T12:00:00Z', '2027-06-07T12:00:00+00:00']; + yield 'Date with offset' => ['2027-06-07T17:00:00+03:00', '2027-06-07T14:00:00+00:00']; + yield 'Date without TZ' => ['2027-06-07T08:00:00', '2027-06-07T08:00:00+00:00']; + } + + /** + * @dataProvider dataApiCreateCustomItemWithDatetime + */ + public function testApiCreateCustomItemWithDatetime(string $inputDate, string $expectedDate): void + { + $contact = [ + 'email' => 'tz-test@api.test', + 'customObjects' => [ + 'data' => [ + [ + 'id' => $this->customObject->getId(), + 'data' => [ + [ + 'name' => 'TZ Test Item', + 'attributes' => [ + 'datetime-test-field' => $inputDate, + ], + ], + ], + ], + ], + ], + ]; + + $this->client->request('POST', '/api/contacts/new?includeCustomObjects=true', $contact); + $this->assertResponseHasExpectedDate($expectedDate); + } + + /** + * @dataProvider dataApiCreateCustomItemWithDatetime + */ + public function testApiUpdateCustomItemWithDatetime(string $inputDate, string $expectedDate): void + { + $contact = [ + 'email' => 'tz-test@api.test', + 'customObjects' => [ + 'data' => [ + [ + 'id' => $this->customObject->getId(), + 'data' => [ + [ + 'name' => 'TZ Test Item', + 'attributes' => [ + 'datetime-test-field' => '2027-06-07T12:00:00Z', + ], + ], + ], + ], + ], + ], + ]; + + $this->client->request('POST', '/api/contacts/new?includeCustomObjects=true', $contact); + $responseData = $this->getResponseDecoded(); + $contactId = $responseData['contact']['id']; + $customItemId = $this->fetchCustomItemId(); + + $updateContact = [ + 'email' => 'tz-test@api.test', + 'customObjects' => [ + 'data' => [ + [ + 'id' => $this->customObject->getId(), + 'data' => [ + [ + 'id' => $customItemId, + 'name' => 'TZ Test Item Updated', + 'attributes' => [ + 'datetime-test-field' => $inputDate, + ], + ], + ], + ], + ], + ], + ]; + + $this->client->request('PATCH', "/api/contacts/$contactId/edit?includeCustomObjects=true", $updateContact); + $this->assertResponseHasExpectedDate($expectedDate); + } + + public function testApiDatetimeDoesNotDriftOnReRead(): void + { + $contact = [ + 'email' => 'tz-test@api.test', + 'customObjects' => [ + 'data' => [ + [ + 'id' => $this->customObject->getId(), + 'data' => [ + [ + 'name' => 'TZ Drift Test', + 'attributes' => [ + 'datetime-test-field' => '2027-06-07T12:00:00-06:00', + ], + ], + ], + ], + ], + ], + ]; + + $this->client->request('POST', '/api/contacts/new?includeCustomObjects=true', $contact); + $this->assertResponseIsSuccessful(); + $responseData = $this->getResponseDecoded(); + $contactId = $responseData['contact']['id']; + $firstRead = $responseData['contact']['customObjects']['data'][0]['data'][0]['attributes']['datetime-test-field']; + + $this->client->request('GET', "/api/contacts/{$contactId}?includeCustomObjects=true"); + $this->assertResponseIsSuccessful(); + $responseData = $this->getResponseDecoded(); + $secondRead = $responseData['contact']['customObjects']['data'][0]['data'][0]['attributes']['datetime-test-field']; + + $this->assertSame( + $firstRead, + $secondRead, + 'Datetime value must not drift between write and subsequent read.' + ); + } + + public function testUiFormSubmitStoresDatetimeCorrectly(): void + { + $inputDate = '2027-06-07 08:00'; + $datetimeField = $this->getDatetimeField($this->customObject); + + $crawler = $this->client->request('GET', sprintf('/s/custom/object/%d/item/new', $this->customObject->getId())); + $this->assertResponseIsSuccessful(); + + $form = $crawler->selectButton('Save')->form(); + + $form['custom_item[name]'] = 'UI TZ Test'; + $form['custom_item[custom_field_values]['.$datetimeField->getId().'][value]'] = $inputDate; + + $this->client->submit($form); + $this->assertResponseIsSuccessful(); + + $customItemId = $this->fetchCustomItemId(); + $storedValue = $this->fetchDateTimeFieldValue($customItemId); + $this->assertNotFalse($storedValue, 'Datetime value must be stored in the database'); + + $storedDt = new DateTimeImmutable($storedValue, new DateTimeZone('UTC')); + $inputDt = new DateTimeImmutable($inputDate, new DateTimeZone('America/New_York')); + + $this->assertSame( + $inputDt->getTimestamp(), + $storedDt->getTimestamp(), + sprintf( + 'UI input "%s" in America/New_York should be stored as UTC equivalent. Stored: %s.', + $inputDate, + $storedValue + ) + ); + + $this->em->clear(); + $crawler = $this->client->request('GET', sprintf('/s/custom/object/%d/item/edit/%d', $this->customObject->getId(), $customItemId)); + $this->assertResponseIsSuccessful(); + $uiDate = $crawler->filterXPath('//input[@name="custom_item[custom_field_values]['.$datetimeField->getId().'][value]"]')->attr('value'); + + $this->assertSame( + $inputDate, + $uiDate, + sprintf( + 'Input date "%s" in America/New_York should be presented as America/New_York in the UI. UI: %s.', + $inputDate, + $uiDate + ) + ); + } + + private function assertStoredUtcValue(string $expectedUtcString, string $customItemId): void + { + $storedValue = $this->fetchDateTimeFieldValue($customItemId); + + $this->assertSame( + (new DateTimeImmutable($expectedUtcString))->format('Y-m-d H:i:s'), + $storedValue, + sprintf('Database should store UTC value "%s" but found "%s"', $expectedUtcString, $storedValue) + ); + } + + /** + * @return false|mixed + */ + private function fetchDateTimeFieldValue(string $customItemId) + { + return $this->connection->fetchOne( + 'SELECT value FROM '.MAUTIC_TABLE_PREFIX.'custom_field_value_datetime WHERE custom_item_id = :id', + ['id' => $customItemId] + ); + } + + private function getDatetimeField(CustomObject $customObject): CustomField + { + foreach ($customObject->getCustomFields() as $field) { + if ('datetime' === $field->getType()) { + return $field; + } + } + + $this->fail('Custom object must have a datetime field'); + } + + private function fetchCustomItemId(): string + { + return $this->em->createQueryBuilder() + ->select('IDENTITY(v.customItem)') + ->from(CustomFieldValueDateTime::class, 'v') + ->getQuery() + ->getSingleResult(AbstractQuery::HYDRATE_SCALAR_COLUMN); + } + + /** + * @return mixed[] + */ + private function getResponseDecoded(): array + { + $content = $this->client->getResponse()->getContent(); + $this->assertJson($content); + + return json_decode($content, true); + } + + private function assertResponseHasExpectedDate(string $expectedDate): void + { + $this->assertResponseIsSuccessful(); + $responseData = $this->getResponseDecoded(); + + $customItem = $responseData['contact']['customObjects']['data'][0]['data'][0]; + $this->assertSame($expectedDate, $customItem['attributes']['datetime-test-field']); + $this->assertStoredUtcValue($expectedDate, (string) $customItem['id']); + } +} From 05a46f6ef3446f1cdcf8f2a3b8b0f75841a71aa0 Mon Sep 17 00:00:00 2001 From: fedys Date: Tue, 22 Sep 2026 10:39:41 +0200 Subject: [PATCH 3/4] Fixed PHPStan issue: Constant MAUTIC_TABLE_PREFIX not found. --- .../Entity/CustomFieldValueDateTimeFunctionalTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Tests/Functional/Entity/CustomFieldValueDateTimeFunctionalTest.php b/Tests/Functional/Entity/CustomFieldValueDateTimeFunctionalTest.php index d7eaca0f6..a48b01617 100644 --- a/Tests/Functional/Entity/CustomFieldValueDateTimeFunctionalTest.php +++ b/Tests/Functional/Entity/CustomFieldValueDateTimeFunctionalTest.php @@ -223,8 +223,10 @@ private function assertStoredUtcValue(string $expectedUtcString, string $customI */ private function fetchDateTimeFieldValue(string $customItemId) { + $prefix = self::$container->getParameter('mautic.db_table_prefix'); + return $this->connection->fetchOne( - 'SELECT value FROM '.MAUTIC_TABLE_PREFIX.'custom_field_value_datetime WHERE custom_item_id = :id', + 'SELECT value FROM '.$prefix.'custom_field_value_datetime WHERE custom_item_id = :id', ['id' => $customItemId] ); } From adc47755416d871c3e565a5d2ee293d4e03d6544 Mon Sep 17 00:00:00 2001 From: fedys Date: Tue, 22 Sep 2026 12:33:02 +0200 Subject: [PATCH 4/4] Functional tests made compatible with the Community version of Mautic --- ...CustomFieldValueDateTimeFunctionalTest.php | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/Tests/Functional/Entity/CustomFieldValueDateTimeFunctionalTest.php b/Tests/Functional/Entity/CustomFieldValueDateTimeFunctionalTest.php index a48b01617..03f98ec2e 100644 --- a/Tests/Functional/Entity/CustomFieldValueDateTimeFunctionalTest.php +++ b/Tests/Functional/Entity/CustomFieldValueDateTimeFunctionalTest.php @@ -21,8 +21,6 @@ class CustomFieldValueDateTimeFunctionalTest extends MauticMysqlTestCase protected function setUp(): void { - $this->configParams['default_timezone'] = 'America/New_York'; - parent::setUp(); $this->customObject = $this->createCustomObjectWithAllFields(self::$container, 'Event'); @@ -160,6 +158,22 @@ public function testApiDatetimeDoesNotDriftOnReRead(): void public function testUiFormSubmitStoresDatetimeCorrectly(): void { + $previousTimeZone = date_default_timezone_get(); + + try { + $this->doTestUiFormSubmitStoresDatetimeCorrectly(); + } finally { + date_default_timezone_set($previousTimeZone); + } + } + + private function doTestUiFormSubmitStoresDatetimeCorrectly(): void + { + $timezone = 'America/New_York'; + $session = self::$container->get('session'); + $session->set('_timezone', $timezone); + date_default_timezone_set($timezone); + $inputDate = '2027-06-07 08:00'; $datetimeField = $this->getDatetimeField($this->customObject); @@ -171,6 +185,8 @@ public function testUiFormSubmitStoresDatetimeCorrectly(): void $form['custom_item[name]'] = 'UI TZ Test'; $form['custom_item[custom_field_values]['.$datetimeField->getId().'][value]'] = $inputDate; + $this->client->restart(); + $this->client->submit($form); $this->assertResponseIsSuccessful(); @@ -179,14 +195,15 @@ public function testUiFormSubmitStoresDatetimeCorrectly(): void $this->assertNotFalse($storedValue, 'Datetime value must be stored in the database'); $storedDt = new DateTimeImmutable($storedValue, new DateTimeZone('UTC')); - $inputDt = new DateTimeImmutable($inputDate, new DateTimeZone('America/New_York')); + $inputDt = new DateTimeImmutable($inputDate, new DateTimeZone($timezone)); $this->assertSame( $inputDt->getTimestamp(), $storedDt->getTimestamp(), sprintf( - 'UI input "%s" in America/New_York should be stored as UTC equivalent. Stored: %s.', + 'UI input "%s" in %s should be stored as UTC equivalent. Stored: %s.', $inputDate, + $timezone, $storedValue ) ); @@ -200,8 +217,9 @@ public function testUiFormSubmitStoresDatetimeCorrectly(): void $inputDate, $uiDate, sprintf( - 'Input date "%s" in America/New_York should be presented as America/New_York in the UI. UI: %s.', + 'Input date "%s" should be presented in %s in the UI. UI date: %s.', $inputDate, + $timezone, $uiDate ) );