*/ private array $providers; /** * Closure that produces the `environment` branch. * * @var callable(): array */ private $envProvider; /** * Clock used to stamp defensive provider rows when * `healthCheck()` throws. */ private Clock $clock; /** * Construct the UseCase with explicit dependencies. * * @param SchemaVersionRepository $schemaVersions Backing schema-version repository. * @param SyncJobRepository $jobs Backing sync-job repository. * @param array $providers Provider registry; defaults to empty. * @param (callable(): array)|null $envProvider Override seam for the `environment` block; * defaults to a runtime probe. * @param Clock $clock Clock used to stamp defensive provider rows. * * @throws InvalidArgumentException When any registry entry has * a non-string or empty key, * or a value that does not * implement * {@see ProviderInterface}. */ public function __construct( SchemaVersionRepository $schemaVersions, SyncJobRepository $jobs, array $providers, ?callable $envProvider, Clock $clock ) { $normalized = array(); foreach ( $providers as $key => $provider ) { if ( ! is_string( $key ) ) { $message = sprintf( 'GetDiagnosticsUseCase: provider registry keys must be strings; got %s.', gettype( $key ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( trim( $key ) === '' ) { $message = 'GetDiagnosticsUseCase: provider registry keys must be non-empty strings.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! $provider instanceof ProviderInterface ) { $message = sprintf( 'GetDiagnosticsUseCase: provider registry["%s"] must implement ProviderInterface; got %s.', $key, is_object( $provider ) ? get_class( $provider ) : gettype( $provider ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalized[ $key ] = $provider; } $this->schemaVersions = $schemaVersions; $this->jobs = $jobs; $this->providers = $normalized; $this->envProvider = $envProvider ?? self::defaultEnvProvider(); $this->clock = $clock; } /** * Execute the §12.1 enumeration and produce the result VO. * * See the file-level docblock for the per-branch derivation * rules; this method is the orchestration glue that combines * the env provider, the schema-version repository, the * provider registry, the sync-job repository, and the * MVP-static observability / entry_points constants into a * single {@see DiagnosticsResult}. */ public function execute(): DiagnosticsResult { $environment = ( $this->envProvider )(); $schemaVersion = $this->schemaVersions->currentVersion(); $database = array( 'schema_version' => (string) $schemaVersion, 'tables_ok' => $this->schemaVersions->isInitialized(), ); $providerRows = array(); foreach ( $this->providers as $registryKey => $provider ) { $providerRows[] = $this->projectProvider( $registryKey, $provider ); } $jobs = $this->projectJobCounts( $this->jobs->countByStatus() ); $observability = array( 'logs_available' => true, 'audit_available' => true, 'request_id_tracing' => true, 'correlation_id_tracing' => true, ); $entryPoints = array( 'logs' => '/affilun/v1/logs', 'audit' => '/affilun/v1/audit/events', 'audit_summary' => '/affilun/v1/audit/events/summary', ); return new DiagnosticsResult( $environment, $database, $providerRows, $jobs, $observability, $entryPoints ); } /** * Project a single registered provider onto the §12.1 row * shape. * * Per-flag derivation: * * - `key` reads from the provider's own * {@see ProviderInterface::key()} accessor on the happy * path; if any accessor — including `healthCheck()` — * throws, the row falls back to the registry key for * `key` and to an `unconfigured` snapshot pinned to the * current clock instant for the remaining flags. * * - `configured` is true when * {@see ProviderHealthStatus::status()} is anything other * than {@see ProviderHealthStatus::STATUS_UNCONFIGURED}. * Other unhealthy statuses (degraded, rate_limited, * unreachable, invalid_credentials) are deliberately * reported as `configured = true` — the §12.1 surface * answers the bare "is the admin form filled in?" * question, and a misconfigured-but-set provider is still * "configured but broken" rather than "not configured at * all". This mirrors the §10.3 projection used by * {@see \Affilun\Application\Editor\UseCase\ListAvailableProvidersUseCase}. * * - `last_success_at` is the ISO-8601 (RFC 3339) string of * {@see ProviderHealthStatus::checkedAt()} when the status * is {@see ProviderHealthStatus::STATUS_HEALTHY}, else * null. The §12.1 example shows `last_success_at` * populated only on healthy providers; the field is the * "last successful probe" axis, not the "last probe of any * kind" axis. * * - `last_error` is {@see ProviderHealthStatus::message()} * when the status is one of the four non-healthy, * non-unconfigured tokens (`degraded`, `rate_limited`, * `unreachable`, `invalid_credentials`) AND a message is * present, else null. An `unconfigured` provider has no * "error" to report; a healthy one likewise. * * @param string $registryKey Registry key for the provider. * @param ProviderInterface $provider Registered provider instance. * * @return array{key: string, configured: bool, last_success_at: ?string, last_error: ?string} */ private function projectProvider( string $registryKey, ProviderInterface $provider ): array { try { $key = $provider->key(); $health = $provider->healthCheck(); } catch ( Throwable $unused ) { $key = $registryKey; $health = ProviderHealthStatus::unconfigured( $this->clock->now() ); } $status = $health->status(); $configured = $status !== ProviderHealthStatus::STATUS_UNCONFIGURED; $lastSuccessAt = null; if ( $status === ProviderHealthStatus::STATUS_HEALTHY ) { $lastSuccessAt = $health->checkedAt()->format( DateTimeInterface::ATOM ); } $errorStatuses = array( ProviderHealthStatus::STATUS_DEGRADED, ProviderHealthStatus::STATUS_RATE_LIMITED, ProviderHealthStatus::STATUS_UNREACHABLE, ProviderHealthStatus::STATUS_INVALID_CREDENTIALS, ); $lastError = null; if ( in_array( $status, $errorStatuses, true ) ) { $lastError = $health->message(); } return array( 'key' => $key, 'configured' => $configured, 'last_success_at' => $lastSuccessAt, 'last_error' => $lastError, ); } /** * Project a {@see SyncJobRepository::countByStatus()} result * onto the §12.1 `jobs` branch. * * The repository returns DB-vocabulary keys (`pending`, * `running`, `completed`, `failed`, `cancelled`) per §6.0, * with statuses that have zero occurrences omitted entirely. * The §12.1 surface only exposes `queued` and `failed`; the * mapping here projects DB `pending` to wire `queued` and * discards every other DB-vocabulary key. Missing keys default * to 0 so a fresh repository (`array()`) yields * `{queued: 0, failed: 0}`. * * @param array $countsByStatus DB-vocabulary histogram. * * @return array{queued: int, failed: int} */ private function projectJobCounts( array $countsByStatus ): array { $queued = 0; if ( array_key_exists( self::DB_STATUS_PENDING, $countsByStatus ) ) { $queued = (int) $countsByStatus[ self::DB_STATUS_PENDING ]; } $failed = 0; if ( array_key_exists( self::DB_STATUS_FAILED, $countsByStatus ) ) { $failed = (int) $countsByStatus[ self::DB_STATUS_FAILED ]; } return array( 'queued' => $queued, 'failed' => $failed, ); } /** * Build the default `environment` provider closure. * * The closure reads the five §12.1 environment axes from PHP * constants and WordPress globals when available. It is only * instantiated when the constructor's `$envProvider` argument * is null; tests override it to drive the branch * deterministically. * * Axis derivation: * * - `php_version` reads the `PHP_VERSION` constant. * - `wp_version` reads the global `$wp_version` when * defined, falling back to the literal `'unknown'` so the * §12.1 contract still produces a string. * - `rest_available` is true when `rest_get_server` is a * defined function (the WordPress REST runtime is loaded). * - `object_cache_available` is true when * `wp_using_ext_object_cache` exists AND reports true; an * unbound function or a built-in non-persistent cache both * surface as false. * - `cron_healthy` is true when `DISABLE_WP_CRON` is not * defined, or defined and falsy. See the file-level * docblock for the deferred P1 liveness probe note. * * @return callable(): array */ private static function defaultEnvProvider(): callable { return static function (): array { $wpVersion = 'unknown'; if ( isset( $GLOBALS['wp_version'] ) && is_string( $GLOBALS['wp_version'] ) && $GLOBALS['wp_version'] !== '' ) { $wpVersion = $GLOBALS['wp_version']; } $objectCacheAvailable = false; if ( function_exists( 'wp_using_ext_object_cache' ) ) { $objectCacheAvailable = (bool) wp_using_ext_object_cache(); } $cronHealthy = true; if ( defined( 'DISABLE_WP_CRON' ) ) { $cronHealthy = ! constant( 'DISABLE_WP_CRON' ); } return array( 'php_version' => PHP_VERSION, 'wp_version' => $wpVersion, 'rest_available' => function_exists( 'rest_get_server' ), 'object_cache_available' => $objectCacheAvailable, 'cron_healthy' => $cronHealthy, ); }; } }