Problem/Motivation

Before releasing a stable version, we should add more tests.

Here's a plan by Claude Code:

 Test plan: firebase_php module

 Context

 web/modules/orig/firebase_php is a Drupal contrib module (maintained by the
 user, released on Drupal.org) that wraps kreait/firebase-php to send push
 notifications. Today its only test is FirebasePhpInstallTest
 (install/uninstall
 smoke test). The module's real logic — building Firebase CloudMessages,
 mapping send failures to typed exceptions, validating tokens, and validating
 the credentials config — is completely untested. This plan adds focused
 PHPUnit coverage of the most essential features so regressions are caught
 in the GitLab CI pipeline (.gitlab-ci.yml already runs the test stage for
 contrib modules automatically).

 Decisions confirmed with the user:
 - Cover three areas: (1) the messaging API logic, (2) the config-form
 credentials validation, (3) the service constructor's credentials
 resolution branches. Skip functional/route tests.
 - Use native PHPUnit createMock for the Kreait\Firebase\Contract\Messaging
 test double (no Prophecy).
 - Tests must follow Drupal contrib conventions: @group firebase_php,
 namespace Drupal\Tests\firebase_php\..., placed under tests/src/.

 Testability challenge & approach

 FirebasePhpMessagingService::__construct() does heavy work (reads config,
 builds a real Kreait\Firebase\Factory, calls createMessaging()), so the
 messaging API cannot be unit-tested by normal instantiation. Key levers:

 - FirebasePhpMessagingService::$messaging is public and
 FirebasePhpMessagingService::$config is protected.
 - All public API methods funnel through $this->messaging->send() /
 ->sendMulticast() / ->validateRegistrationTokens().

 So for the API unit test: build the subject with
 (new \ReflectionClass(FirebasePhpMessagingApi::class))->newInstanceWithoutCon
 structor(),
 then inject a mocked Messaging into the public $messaging property and a
 mocked ImmutableConfig into the protected $config (via reflection). Drive
 the public methods and assert on what is passed to the mock. Because the
 private helpers (createCloudMessage, setBadgeCount, etc.) run as part of the
 public calls, capturing the CloudMessage argument to send() and inspecting
 ->jsonSerialize() verifies the build logic end-to-end.

 Files to add

 All under web/modules/orig/firebase_php/tests/src/.

 1. Unit/Service/FirebasePhpMessagingApiTest.php (the essential core)

 Drupal\Tests\firebase_php\Unit\Service\FirebasePhpMessagingApiTest extends
 Drupal\Tests\UnitTestCase, @group firebase_php,
 @coversDefaultClass \Drupal\firebase_php\Service\FirebasePhpMessagingApi.

 Helper makeApi(Messaging $messaging, ?ImmutableConfig $config = null):
 FirebasePhpMessagingApi
 that uses newInstanceWithoutConstructor() and reflection to set
 $messaging (public) and $config (protected). Mock the Kreait contract with
 $this->createMock(\Kreait\Firebase\Contract\Messaging::class).

 Cases:
 - Single device — sendMessageSingleDevice() calls ->send() once; capture
 the CloudMessage and assert jsonSerialize() contains token, the
 notification (title/body/and notification_url→link), and that a default
 sound is set (APNS payload present) when silent is falsey.
 - Topic — sendMessageTopic() produces a topic key (not token).
 - Data payload — passing $data surfaces a data key; omitting it does not.
 - Badge count — badge_count > 0 sets the APNS badge and an Android
 notification_count; badge_count = 0 sets APNS badge but no Android count
 (covers the > 0 branch in setBadgeCount()).
 - Silent — silent = TRUE does not add a default sound.
 - Empty destination — empty/whitespace token (and empty topic) throws
 FirebasePhpInvalidArgumentException (covers createMessageArray() guard).
 - Multiple devices — sendMessageMultipleDevices() calls
 ->sendMulticast() once with the re-indexed token list and returns the
 MulticastSendReport the mock returns; also assert string-keyed input is
 re-indexed (uses array_values, builds the base message from [0]).
 - InvalidMessage → 400 — mock ->send() to throw
 (new InvalidMessage('bad'))->withErrors(['error' => ['code' => 400]]);
 assert sendMessageSingleDevice() rethrows
 FirebasePhpInvalidTokenException. (InvalidMessage is final; construct
 with withErrors() — confirmed available in vendor.)
 - InvalidMessage → other code — errors code != 400 rethrows
 FirebasePhpException.
 - validateToken — empty/whitespace throws
 FirebasePhpInvalidArgumentException; when
 validateRegistrationTokens() returns the token under valid, returns TRUE;
 when only under invalid/unknown, returns FALSE. Use the config mock so
 log_token_validation_result reads FALSE (no logging path). For the
 exception-mapping cases, stub $config->get('logging_level') to 'none' so
 the error-logging branch is skipped.

 2. Unit/Form/FirebasePhpConfigurationFormValidateTest.php

 Drupal\Tests\firebase_php\Unit\Form\...Test extends UnitTestCase,
 @group firebase_php. validateForm() is pure logic over $form_state
 values plus filesystem checks and $this->t(). Instantiate the form directly
 (new FirebasePhpConfigurationForm(...) — it is a ConfigFormBase; pass the
 constructor deps it needs, or build via newInstanceWithoutConstructor() if
 the parent constructor is awkward) and call
 setStringTranslation($this->getStringTranslationStub()). Mock
 FormStateInterface: getValue('credentials') returns the case input; assert
 setErrorByName() is/ isn't called via mock expectations
 (expects($this->once())
 vs ->expects($this->never())).

 Cases:
 - Empty string → no error (env-override path allowed).
 - Pasted JSON (e.g. {"type":"service_account"}) → error.
 - Non-existent path (/no/such/file.json) → error.
 - public://creds.json → error (insecure). Note: this branch is only reached
 for an existing readable file; if mocking the stream wrapper is heavy,
 cover the JSON/missing-file branches which need no filesystem, and document
 the public:// check as covered by the missing-file path it shares. Prefer
 using vfsStream (bundled with Drupal core tests) or a real temp file via
 sys_get_temp_dir() for the "valid file passes" and "unreadable" cases.
 - Valid readable non-public temp file → no error.

 3. Kernel/FirebasePhpCredentialsResolutionTest.php

 Drupal\Tests\firebase_php\Kernel\...Test extends
 Drupal\KernelTests\KernelTestBase, @group firebase_php,
 protected static $modules = ['firebase_php']; and install the module config.

 Tests the constructor branches in FirebasePhpMessagingService that run
 before the real Kreait\Firebase\Factory is built (so no valid Firebase
 credentials are required):
 - Empty credentials config → requesting firebase_php.messaging from the
 container throws FirebasePhpCredentialsNotFoundException (matches the
 default install config of credentials: '').
 - A non-empty value that is neither valid JSON nor an existing file (e.g.
 '/definitely/not/a/file') → FirebasePhpCredentialsNotFoundException with
 the "neither valid JSON ... nor an existing file path" message.

 Set config with $this->config('firebase_php.settings')->set('credentials',
 ...)->save()
 then $this->expectException(...) around
 $this->container->get('firebase_php.messaging'). Do not attempt the
 happy path (would require a structurally valid service-account JSON/RSA key
 and
 exercises only the vendor library).

 What is intentionally NOT covered

 - Real network sends and the success-path Factory build (would need live
 Firebase credentials / vendor internals).
 - The Send test message / Validate token forms' submit handlers and route
 access (user opted out of functional tests).
 - Logging-middleware wiring in the constructor (LoggingLevel::Debug/Standard
 switch) — exercised indirectly; not asserted.
Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

ptmkenny created an issue. See original summary.

ptmkenny’s picture

Title: Add more tests » Add basic unit tests
Issue summary: View changes

ptmkenny’s picture

And add some more tests:

Part 2: Functional tests of the admin forms

 Context (Part 2)

 The module exposes three admin forms, all gated on the
 administer site configuration permission:

 - FirebasePhpConfigurationForm — /admin/config/system/firebase_php
 - FirebasePhpSendTestMessageForm — /admin/config/system/firebase_php/test
 - FirebasePhpValidateTokenForm —
 /admin/config/system/firebase_php/validate_token

 Part 1 unit-tested the config form's validateForm() logic in isolation but
 nothing exercises the forms through a real request: route access control,
 form
 rendering, successful submission, and the user-facing status/error messages.
 The user asked to add full functional coverage of all three forms, including
 error paths.

 Key constraint that shapes the design: the send-test and validate-token
 forms call $container->get('firebase_php.messaging_drupal_api') in their
 create(). The real service's constructor
 (FirebasePhpMessagingService::__construct()) throws
 FirebasePhpCredentialsNotFoundException when no credentials are configured,
 so
 those two routes return a 500 in a test site — they cannot even render. A
 small
 test sub-module that overrides the service with a fake is therefore required
 for any coverage of those forms. The fake is driven by Drupal State so tests
 can simulate both success and failure across the test-runner/web-request
 process
 boundary (State is DB-backed and shared in BrowserTestBase).

 Faking strategy — kreait has no official test double

 Investigated the vendored kreait/firebase-php: it ships no fake /
 in-memory / stub Messaging, and the package excludes its own tests/ dir, so
 there is nothing reusable. The only concrete Contract\Messaging
 implementation
 is the real Kreait\Firebase\Messaging, built by Factory::createMessaging()
 with no handler-injection argument. The single HTTP-layer seam is
 HttpClientOptions::withGuzzleMiddleware() (push a Guzzle middleware that
 short-circuits requests with canned responses) — but that also requires a
 structurally valid service account with a real generated RSA key so the auth
 layer can sign its JWT, and it couples tests to Google's HTTP response
 shapes.

 Decision: implement a fake of Kreait\Firebase\Contract\Messaging in the
 test sub-module (below). The forms depend only on the
 FirebasePhpMessagingApiInterface / Messaging contract, so a contract-level
 fake is simpler, deterministic, and decoupled from SDK internals. The
 Guzzle-middleware HTTP fake is rejected as too heavy for form coverage; it
 would
 only add value for end-to-end-testing the real Factory/constructor wiring,
 which Part 1 intentionally leaves out and which message-building unit tests
 already cover.

 Files to add

 A. Test sub-module: tests/modules/firebase_php_test/

 - firebase_php_test.info.yml — type: module,
 core_version_requirement: ^10 || ^11, package: Testing,
 dependencies: [firebase_php:firebase_php].
 - firebase_php_test.services.yml — redefines the existing service id so the
 later-loaded test module wins (it depends on firebase_php, so loads after):
 services:
   firebase_php.messaging_drupal_api:
     class: Drupal\firebase_php_test\TestFirebasePhpMessagingApi
     arguments: ['@state']
 - src/TestFirebasePhpMessagingApi.php — implements
 Drupal\firebase_php\FirebasePhpMessagingApiInterface with a trivial
 constructor taking StateInterface. Behaviour keyed off State:
   - sendMessageSingleDevice() — if firebase_php_test.send_should_fail is set,
 throw new FirebasePhpException('Test send failure', 0, new
 \Exception('detail'))
 (no errors() method → exercises the form's getPrevious() branch);
 otherwise record firebase_php_test.last_send (token, title, badge, silent)
 and return [].
   - getMessaging() — returns a TestMessaging (below) so the validate-token
 form works.
   - sendMessageTopic(), sendMessageMultipleDevices(), validateToken() —
 minimal real implementations (the forms don't call them, but the interface
 requires them; keep them simple rather than throwing).
   - Use #[\Override] + {@inheritdoc} on every interface method.
 - src/TestMessaging.php — implements
 Kreait\Firebase\Contract\Messaging (11 methods). Only
 validateRegistrationTokens() is meaningful: default returns all input tokens
 under valid; if firebase_php_test.token_invalid State is set, returns them
 under invalid instead. The other 10 methods
 (send, sendMulticast, sendAll, validate, subscribeToTopic(s),
 unsubscribeFromTopic(s), unsubscribeFromAllTopics, getAppInstance)
 throw new \LogicException('Not used in tests.') with their exact contract
 signatures (import RegistrationToken(s), Topic, Messages, Message,
 MulticastSendReport, AppInstance).

 B. Functional tests: tests/src/Functional/

 Three classes, extends \Drupal\Tests\BrowserTestBase, @group firebase_php,
 protected $defaultTheme = 'stark';,
 protected static $modules = ['firebase_php', 'firebase_php_test'];.

 1. FirebasePhpConfigurationFormTest (no fake needed for behaviour, but the
 module set installs cleanly):
   - Access: anonymous → 403; user lacking the permission → 403; user with
 administer site configuration → 200. (drupalCreateUser, drupalLogin,
 assertSession()->statusCodeEquals().)
   - Save: create a readable temp file (tempnam(sys_get_temp_dir(), …),
 cleaned up in tearDown()), submit
 credentials + logging_level: standard + log_token_validation_result: 1
 with button Save configuration; assert the saved-config status message and
 that firebase_php.settings:credentials now equals the (trimmed) path.
   - Validation errors: submitting pasted JSON shows the "Do not paste the
 credentials JSON" error; a non-existent path shows "There is no file at the
 specified location." Use assertSession()->statusMessageContains(…, 'error').
 2. FirebasePhpSendTestMessageFormTest (uses the fake):
   - Admin renders the form (200).
   - Happy path: submit title/message/device_token: tok123/badge_count: 3,
 button Submit; assert status "The test message has been sent."; then read
 firebase_php_test.last_send from State and assert token tok123, badge 3.
   - Error path: set firebase_php_test.send_should_fail in State, submit,
 assert the error "Failed to send the test message".
 3. FirebasePhpValidateTokenFormTest (uses the fake):
   - Admin renders the form (200).
   - Valid path: submit device_token: tokA,tokB; assert a status message
 containing "Device token validation results".
   - Invalid path: set firebase_php_test.token_invalid in State, submit;
 assert an error message containing "Device token validation results".

 Field machine names for submitForm() (the config form's details wrapper is
 not #tree, so values are flat): credentials, logging_level,
 log_token_validation_result, title, message, device_token,
 badge_count, silent.

 State is set from the test via $this->container->get('state')->set(...)
 before
 submitForm(), and read back after; do not pre-read a key before the submit
 that writes it (avoids the runner's State static cache returning a stale
 value).

 What is intentionally NOT covered

 - Real network sends and the success-path Firebase Factory build (needs live
 credentials / vendor internals) — the fake stands in for the SDK.
 - Logging-middleware wiring in the constructor
 (LoggingLevel::Debug/Standard).
 - FunctionalJavascript — the forms have no AJAX, so plain BrowserTestBase
 suffices.

  • ptmkenny committed a7feb670 on 8.0.x
    task: #3593701 Add basic unit tests with assistance from Claude Code (...
ptmkenny’s picture

Status: Active » Fixed

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.