Skip to content

Model: Handle dates in UTC and keep column fields out of message meta - #627

Open
huzaifaalmesbah wants to merge 2 commits into
WordPress:developfrom
huzaifaalmesbah:fix/614-serde-utc-meta-keys
Open

huzaifaalmesbah wants to merge 2 commits into
WordPress:developfrom
huzaifaalmesbah:fix/614-serde-utc-meta-keys

Conversation

@huzaifaalmesbah

Copy link
Copy Markdown
Member

What?

Closes #614. Part of #302.

Makes Helper\Serde read and write dates in UTC, removes column fields from Message::$meta_keys, and makes Factory\Message deserialize its dates the same way the other factories do.

Why?

  • Serde depended on the PHP default timezone. maybe_deserialize_mysql_date() called DateTime::createFromFormat() without a timezone. WordPress sets the default to UTC, but anything that changes it shifts every date read from the database. It also accepted dates like 2024-02-30, which PHP silently rolls over to March.
  • Column fields leaked into meta. Message::$meta_keys listed expires_at, severity and channel_title, so encode_meta() wrote them into the meta JSON (expires_at as {"date":…,"timezone_type":3,"timezone":"UTC"}). jsonSerialize() overwrote them afterwards, which hid the problem.
  • Factory\Message could not load a message from the database. It passed created_at / expires_at straight to the model, whose properties are typed ?DateTime, so a MySQL date string threw TypeError: Cannot assign string to property … of type ?DateTime. Implement querying the database for notifications #307 would hit this first.

How?

includes/helper/class-serde.php

  • maybe_deserialize_mysql_date() parses with !Y-m-d H:i:s in UTC (! also resets microseconds).
  • Dates PHP would roll over (e.g. 2024-02-30, month 13, the MySQL zero date) and non-string values now return null.
  • A DateTime passed in is converted to UTC without modifying the original.
  • maybe_serialize_json_date() always outputs UTC (+00:00).

includes/model/class-message.php

  • Removed expires_at, severity and channel_title from $meta_keys.
  • jsonSerialize() still includes channel_title and severity, so the REST response has the same fields (only the key order changes).
  • Removed the unused validate_message(). Its 255-byte limit would not match the TEXT column planned in Database schema v2 #615.

includes/factory/class-message.php

  • created_at and expires_at go through Serde::maybe_deserialize_mysql_date(), matching the Notification and Subscription factories.

Tests

  • New tests/phpunit/tests/test-serde.php (14 tests). It sets the PHP default timezone to America/New_York to prove Serde doesn't depend on it, and covers the round trip, DateTime conversion, no mutation of the input, and invalid input.
  • test-model-message.php: updated the pinned JSON order and added two encode_meta() tests.
  • test-factory-message.php: added a test that makes a message from MySQL date strings.

With the old code, 11 of the new tests fail (10 assertion failures, 1 TypeError).

Testing Instructions

  1. npx wp-env start
  2. Run the suite:
    npx wp-env run tests-cli --env-cwd=wp-content/plugins/wp-feature-notifications vendor/bin/phpunit
  3. Run it as multisite:
    npx wp-env run tests-cli --env-cwd=wp-content/plugins/wp-feature-notifications env WP_MULTISITE=1 vendor/bin/phpunit
  4. Run only the affected tests:
    npx wp-env run tests-cli --env-cwd=wp-content/plugins/wp-feature-notifications vendor/bin/phpunit --filter "Test_Serde|Test_Model_Message|Test_Factory_Message"

Local results: 79 tests, 0 failures on single site and multisite (the 24 skipped are the existing REST controller placeholder tests). PHPCS is clean.

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5.5
Used for: Drafting the fix and unit tests. I chose the approach from the issue, decided to include the Factory\Message fix in this PR, reviewed every change, and verified it locally with PHPUnit (single site and multisite) and PHPCS.

Serde read MySQL datetime strings in the PHP default timezone and
accepted out of range dates, which PHP silently rolls over. It now
reads and writes dates in UTC and rejects invalid dates.

`Message::$meta_keys` listed `expires_at`, `severity` and
`channel_title`, so `encode_meta()` wrote them into the `meta` JSON.
They are removed from `$meta_keys` and kept in `jsonSerialize()`.

`Factory\Message` now deserializes `created_at` and `expires_at` with
Serde, like the Notification and Subscription factories, so a message
loaded from the database no longer throws a `TypeError`.

Also removes the unused `Message::validate_message()`.

Fixes WordPress#614.
@jonathanbossenger jonathanbossenger mentioned this pull request Oct 16, 2026
2 of 22 tasks
'ISO 8601 format' => array( '2024-01-02T03:04:05+00:00' ),
'date only' => array( '2024-01-02' ),
'out of range day' => array( '2024-02-30 00:00:00' ),
'out of range month' => array( '2024-13-01 00:00:00' ),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: The PR description says the MySQL zero date (0000-00-00 00:00:00) now returns null. I tested this locally and it works. What do you think about adding it to this list too? Old MySQL setups sometimes store this value, so a test would protect it in the future.

'mysql zero date'    => array( '0000-00-00 00:00:00' ),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for testing it! Good idea, I added the MySQL zero date case to the data provider in 5ae27f6.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fix Serde UTC deserialisation and remove column fields from Message::$meta_keys

2 participants