From 9047cac343d7645dd520371234a9eb16e5d7f251 Mon Sep 17 00:00:00 2001 From: Josiah King Date: Sat, 12 Sep 2026 15:51:02 +0100 Subject: [PATCH] Phase 7.15: add modernization cutover acceptance --- CHANGELOG.md | 1 + .../tests/fixtures/modernization-cutover.json | 61 ++ compat/legacy-http-client/tests/run.php | 110 +++ ...Php2ModernizationCutoverAcceptanceTest.php | 292 +++++++ .../ModernizationCutoverAcceptanceSupport.php | 797 ++++++++++++++++++ 5 files changed, 1261 insertions(+) create mode 100644 compat/legacy-http-client/tests/fixtures/modernization-cutover.json create mode 100644 tests/Architecture/EvolvePhp2ModernizationCutoverAcceptanceTest.php create mode 100644 tests/Support/ModernizationCutoverAcceptanceSupport.php diff --git a/CHANGELOG.md b/CHANGELOG.md index c27a66b..7cbcb22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ ### Adoption planning +- Added cross-host modernization acceptance coverage demonstrating consistent `billing.invoice-summary` delegation through Laravel, Symfony and legacy remote Bridge paths, with explicit Audit evidence, parity checks, cutover ownership declarations, rollback evidence and legacy retirement state declarations. - Added development-time adoption planning models inside `evolvephp/dev-tools`, including public experimental declarations for one bounded capability, embedded or remote integration mode, route ownership transitions, data ownership transitions, migration/source-of-truth states, compatibility requirements, identity/security requirements, migration evidence, rollback evidence and measurable acceptance criteria. The model records maintainer decisions only: it does not generate plans from Audit, discover routes, infer ownership, score migrations, certify compatibility, certify migration readiness, implement Bridge, define protocols, provide adapters, synchronize databases, execute cutovers or execute rollbacks. ### Audit foundation diff --git a/compat/legacy-http-client/tests/fixtures/modernization-cutover.json b/compat/legacy-http-client/tests/fixtures/modernization-cutover.json new file mode 100644 index 0000000..028ec71 --- /dev/null +++ b/compat/legacy-http-client/tests/fixtures/modernization-cutover.json @@ -0,0 +1,61 @@ +{ + "scenario": "modernization-cutover", + "capability": "billing.invoice-summary", + "operation": "billing.invoice-summary", + "method": "POST", + "target": "/evolve/billing/invoice-summary", + "headers": { + "accept": [ + "application/json" + ], + "content-type": [ + "application/json" + ], + "traceparent": [ + "00-11111111111111111111111111111111-2222222222222222-01" + ], + "x-host-only-state": [ + "must-not-forward" + ] + }, + "body": "{\"invoice_ids\":[\"INV-1001\",\"INV-1002\"],\"amounts\":[1250,3750],\"currency\":\"USD\"}", + "payload": { + "invoice_ids": [ + "INV-1001", + "INV-1002" + ], + "amounts": [ + 1250, + 3750 + ], + "currency": "USD" + }, + "request_id": "modernization-cutover-request-1", + "correlation_id": "modernization-cutover-correlation-1", + "caller_id": "legacy-billing-host", + "principal_id": "principal-123", + "tenant_id": "tenant-456", + "locale": "en_US", + "timezone": "UTC", + "deadline": "2999-01-01T00:00:00+00:00", + "idempotency_key": "billing.invoice-summary:modernization-cutover-request-1", + "trace": { + "traceparent": "00-11111111111111111111111111111111-2222222222222222-01" + }, + "expected_result": { + "capability": "billing.invoice-summary", + "invoice_count": 2, + "total_amount": 5000, + "currency": "USD", + "principal_id": "principal-123", + "tenant_id": "tenant-456" + }, + "expected_application": { + "status": 200, + "headers": { + "content-type": [ + "application/json" + ] + } + } +} diff --git a/compat/legacy-http-client/tests/run.php b/compat/legacy-http-client/tests/run.php index 66b1fb2..e476397 100644 --- a/compat/legacy-http-client/tests/run.php +++ b/compat/legacy-http-client/tests/run.php @@ -80,6 +80,16 @@ function fixture(): array return $data; } +function modernizationFixture(): array +{ + $json = file_get_contents(__DIR__ . '/fixtures/modernization-cutover.json'); + ok(is_string($json), 'Modernization cutover fixture must be readable.'); + $data = json_decode($json, true); + ok(is_array($data), 'Modernization cutover fixture must decode.'); + + return $data; +} + function fixtureInvocation(): LegacyRemoteInvocation { return new LegacyRemoteInvocation( @@ -333,6 +343,50 @@ function fixtureInvocation(): LegacyRemoteInvocation same(7500, $options['request_timeout_ms']); }); +test('modernization cutover vector encodes and consumes canonical application result', function (): void { + $fixture = modernizationFixture(); + $transport = new ModernizationCutoverTransport($fixture); + $client = new LegacyRemoteClient('https://bridge.example.test/evolve-remote', $transport, new NullAuthenticator()); + $result = $client->invoke(new LegacyRemoteInvocation( + $fixture['operation'], + $fixture['method'], + $fixture['target'], + $fixture['headers'], + $fixture['body'], + $fixture['payload'], + $fixture['request_id'], + $fixture['correlation_id'], + $fixture['caller_id'], + $fixture['principal_id'], + $fixture['tenant_id'], + $fixture['locale'], + $fixture['timezone'], + $fixture['deadline'], + $fixture['idempotency_key'], + $fixture['trace'], + )); + + ok($result->received()); + same('application', $result->outcome()); + same($fixture['request_id'], $result->requestIdentifier()); + same($fixture['correlation_id'], $result->correlationIdentifier()); + same($fixture['expected_application']['status'], $result->applicationStatus()); + same($fixture['expected_application']['headers'], $result->applicationHeaders()); + same($fixture['expected_result'], json_decode($result->applicationBody(), true)); + same(1, $transport->calls); + same('POST', $transport->method); + same('https://bridge.example.test/evolve-remote', $transport->endpoint); + same(LegacyRemoteProtocol::MEDIA_TYPE, $transport->headers['content-type'][0]); + same($fixture['request_id'], $transport->headers['x-request-id'][0]); + same($fixture['correlation_id'], $transport->headers['x-correlation-id'][0]); + same($fixture['idempotency_key'], $transport->headers['x-idempotency-key'][0]); + same($fixture['operation'], $transport->encoded['operation']); + same($fixture['payload']['invoice_ids'], $transport->encoded['payload']['invoice_ids']); + same($fixture['payload']['amounts'], $transport->encoded['payload']['amounts']); + same($fixture['payload']['currency'], $transport->encoded['payload']['currency']); + same($fixture['headers'], $transport->encoded['headers']); +}); + final class NullAuthenticator implements LegacyRemoteClientAuthenticator { public function authenticationHeaders(LegacyRemoteInvocation $invocation): array @@ -421,6 +475,62 @@ public function send(string $method, string $endpoint, array $headers, string $b } } +final class ModernizationCutoverTransport implements LegacyRemoteTransport +{ + public $calls = 0; + public $method; + public $endpoint; + public $headers = []; + public $encoded = []; + private $fixture; + + public function __construct(array $fixture) + { + $this->fixture = $fixture; + } + + public function send(string $method, string $endpoint, array $headers, string $body, float $connectTimeoutSeconds, float $requestTimeoutSeconds): LegacyRemoteTransportResponse + { + ++$this->calls; + $this->method = $method; + $this->endpoint = $endpoint; + $this->headers = $headers; + $decoded = json_decode($body, true); + ok(is_array($decoded), 'Modernization cutover request must decode.'); + $this->encoded = $decoded; + + same($this->fixture['operation'], $decoded['operation']); + same($this->fixture['method'], $decoded['method']); + same($this->fixture['target'], $decoded['target']); + same($this->fixture['request_id'], $decoded['request_id']); + same($this->fixture['correlation_id'], $decoded['correlation_id']); + same($this->fixture['caller_id'], $decoded['caller_id']); + same($this->fixture['principal_id'], $decoded['principal_id']); + same($this->fixture['tenant_id'], $decoded['tenant_id']); + + return new LegacyRemoteTransportResponse( + 200, + ['content-type' => [LegacyRemoteProtocol::MEDIA_TYPE]], + json_encode([ + 'application' => [ + 'body' => json_encode($this->fixture['expected_result']), + 'headers' => $this->fixture['expected_application']['headers'], + 'status' => $this->fixture['expected_application']['status'], + ], + 'bridge_error' => null, + 'correlation_id' => $this->fixture['correlation_id'], + 'outcome' => 'application', + 'outer_status' => 200, + 'protocol' => LegacyRemoteProtocol::MEDIA_TYPE, + 'requires_quarantine' => false, + 'request_id' => $this->fixture['request_id'], + 'reusable' => true, + 'version' => LegacyRemoteProtocol::VERSION, + ]), + ); + } +} + $failures = 0; foreach ($tests as $name => $callback) { diff --git a/tests/Architecture/EvolvePhp2ModernizationCutoverAcceptanceTest.php b/tests/Architecture/EvolvePhp2ModernizationCutoverAcceptanceTest.php new file mode 100644 index 0000000..9ea59c1 --- /dev/null +++ b/tests/Architecture/EvolvePhp2ModernizationCutoverAcceptanceTest.php @@ -0,0 +1,292 @@ +createLegacyBillingProjectFixture(); + + $report = (new AuditRunner([ + new ComposerProjectInspector(), + new PhpSourceCouplingInspector(), + new PhpSourceStructureInspector(), + ]))->inspect($legacyProject); + + $findings = $this->findingsByIdentifier($report->findings()); + + self::assertArrayHasKey('composer_json.present', $findings); + self::assertArrayHasKey('composer.frameworks', $findings); + self::assertArrayHasKey('composer.autoload', $findings); + self::assertArrayHasKey('modernization.autoload_signals', $findings); + self::assertArrayHasKey('php_source.inventory', $findings); + self::assertArrayHasKey('php_source.session_access', $findings); + self::assertArrayHasKey('php_source.static_state_declaration', $findings); + self::assertArrayHasKey('modernization.source_signals', $findings); + + self::assertSame([ + [ + 'family' => 'laravel', + 'package' => 'laravel/framework', + 'scope' => 'runtime', + 'constraint' => '^8.0', + ], + ], $findings['composer.frameworks']->evidence()['frameworks']); + self::assertContains('src/Billing/LegacyInvoiceSummary.php', $findings['php_source.inventory']->evidence()['inspected_paths']); + self::assertStringContainsString('direct native session state access evidence only', $findings['php_source.session_access']->evidence()['claim']); + } + + public function testAdoptionDeclarationsRecordParityCutoverRollbackAndRetirementEvidence(): void + { + $preCutover = new AdoptionPlan( + new MigrationManifest( + 'billing.invoice-summary', + IntegrationMode::Embedded, + [ + new RouteOwnership('/billing/invoices/summary', RouteOwner::Host, RouteOwner::Evolve), + ], + [ + new DataOwnership( + 'billing.invoice-summary.read-model', + 'legacy-billing-store', + 'evolve-billing-store', + OwnershipSystem::Host, + OwnershipSystem::Evolve, + DataMigrationState::LegacyAuthoritative, + DataMigrationState::EvolveAuthoritative, + 'bounded parity sample comparison before cutover', + ), + ], + [ + 'Laravel, Symfony and legacy remote Bridge paths must normalize the same invoice summary result.', + 'No host-only state, session data or private headers are forwarded to Evolve.', + ], + [ + 'BridgeContext request, correlation, principal and tenant identifiers are explicit.', + 'Legacy remote callers must use the unchanged Remote Bridge v1 protocol envelope.', + ], + ), + [ + 'Audit findings were reviewed before adoption declarations were accepted.', + 'Reference legacy result was reconciled against each delegated Evolve path.', + ], + [ + 'Host route ownership and legacy billing writer remain available before authoritative cutover.', + 'Rollback review was accepted before route and data authority move to Evolve.', + ], + [ + 'invoice_count, total_amount, currency and capability identity match across all paths.', + 'Successful delegated operation executes the Evolve capability exactly once per path.', + ], + ); + + self::assertSame('billing.invoice-summary', $preCutover->manifest()->capability()); + self::assertSame(RouteOwner::Host, $preCutover->manifest()->routeOwnership()[0]->currentOwner()); + self::assertSame(RouteOwner::Evolve, $preCutover->manifest()->routeOwnership()[0]->targetOwner()); + self::assertSame(OwnershipSystem::Host, $preCutover->manifest()->dataOwnership()[0]->currentWriter()); + self::assertSame(OwnershipSystem::Evolve, $preCutover->manifest()->dataOwnership()[0]->targetWriter()); + self::assertSame(DataMigrationState::LegacyAuthoritative, $preCutover->manifest()->dataOwnership()[0]->currentState()); + self::assertSame(DataMigrationState::EvolveAuthoritative, $preCutover->manifest()->dataOwnership()[0]->targetState()); + self::assertNotEmpty($preCutover->rollbackEvidence()); + self::assertNotEmpty($preCutover->acceptanceCriteria()); + + $postCutover = new AdoptionPlan( + new MigrationManifest( + 'billing.invoice-summary', + IntegrationMode::Embedded, + [ + new RouteOwnership('/billing/invoices/summary', RouteOwner::Evolve, RouteOwner::Evolve), + ], + [ + new DataOwnership( + 'billing.invoice-summary.read-model', + 'legacy-billing-store', + 'evolve-billing-store', + OwnershipSystem::Evolve, + OwnershipSystem::Evolve, + DataMigrationState::EvolveAuthoritative, + DataMigrationState::EvolveAuthoritative, + null, + ), + ], + [ + 'Laravel, Symfony and legacy remote Bridge paths continue to normalize the same invoice summary result.', + ], + [ + 'BridgeContext remains the explicit request, correlation, principal and tenant boundary after cutover.', + ], + ), + [ + 'Accepted parity evidence promoted the route and writer authority to Evolve.', + ], + [ + 'Rollback evidence was accepted before cutover and remains the cutover decision record.', + ], + [ + 'Evolve is the current route owner and current data writer for billing.invoice-summary.', + ], + ); + + self::assertSame('billing.invoice-summary', $postCutover->manifest()->capability()); + self::assertSame(RouteOwner::Evolve, $postCutover->manifest()->routeOwnership()[0]->currentOwner()); + self::assertSame(RouteOwner::Evolve, $postCutover->manifest()->routeOwnership()[0]->targetOwner()); + self::assertSame(OwnershipSystem::Evolve, $postCutover->manifest()->dataOwnership()[0]->currentWriter()); + self::assertSame(OwnershipSystem::Evolve, $postCutover->manifest()->dataOwnership()[0]->targetWriter()); + self::assertSame(DataMigrationState::EvolveAuthoritative, $postCutover->manifest()->dataOwnership()[0]->currentState()); + self::assertSame(DataMigrationState::EvolveAuthoritative, $postCutover->manifest()->dataOwnership()[0]->targetState()); + self::assertNotSame(OwnershipSystem::Host, $postCutover->manifest()->dataOwnership()[0]->currentWriter()); + + $retirement = new AdoptionPlan( + new MigrationManifest( + 'billing.invoice-summary', + IntegrationMode::Embedded, + [ + new RouteOwnership('/billing/invoices/summary', RouteOwner::Evolve, RouteOwner::Evolve), + ], + [ + new DataOwnership( + 'billing.invoice-summary.read-model', + 'legacy-billing-store', + 'evolve-billing-store', + OwnershipSystem::Evolve, + OwnershipSystem::Evolve, + DataMigrationState::LegacyReadOnly, + DataMigrationState::LegacyRetired, + null, + ), + ], + [ + 'Legacy billing data is no longer authoritative during retirement acceptance.', + ], + [ + 'Retirement keeps Evolve as the sole authoritative writer.', + ], + ), + [ + 'Legacy read-only state was accepted after Evolve became authoritative.', + ], + [ + 'Rollback compatibility ends after accepted legacy retirement; restoration would require a new migration plan.', + ], + [ + 'Legacy state progresses from read-only to retired without restoring host write authority.', + ], + ); + + self::assertSame(OwnershipSystem::Evolve, $retirement->manifest()->dataOwnership()[0]->currentWriter()); + self::assertSame(OwnershipSystem::Evolve, $retirement->manifest()->dataOwnership()[0]->targetWriter()); + self::assertSame(DataMigrationState::LegacyReadOnly, $retirement->manifest()->dataOwnership()[0]->currentState()); + self::assertSame(DataMigrationState::LegacyRetired, $retirement->manifest()->dataOwnership()[0]->targetState()); + self::assertNull($retirement->manifest()->dataOwnership()[0]->temporarySynchronization()); + self::assertNotSame(OwnershipSystem::Host, $retirement->manifest()->dataOwnership()[0]->currentWriter()); + self::assertContains( + 'Rollback compatibility ends after accepted legacy retirement; restoration would require a new migration plan.', + $retirement->rollbackEvidence(), + ); + } + + public function testLaravelSymfonyAndLegacyRemoteDelegateTheSameCapabilityWithParity(): void + { + $scenario = ModernizationCutoverAcceptanceSupport::scenario(); + $expected = $scenario['expected_result']; + + $laravel = ModernizationCutoverAcceptanceSupport::invokeLaravel($scenario); + $symfony = ModernizationCutoverAcceptanceSupport::invokeSymfony($scenario); + $remote = ModernizationCutoverAcceptanceSupport::invokeLegacyRemote($scenario); + + self::assertSame($expected, $laravel['result']); + self::assertSame($expected, $symfony['result']); + self::assertSame($expected, $remote['result']); + self::assertSame($laravel['result'], $symfony['result']); + self::assertSame($laravel['result'], $remote['result']); + + foreach ([$laravel, $symfony, $remote] as $delegation) { + self::assertSame(1, $delegation['execution_count']); + self::assertSame($scenario['request_id'], $delegation['context']['request_id']); + self::assertSame($scenario['correlation_id'], $delegation['context']['correlation_id']); + self::assertSame($scenario['principal_id'], $delegation['context']['principal_id']); + self::assertSame($scenario['tenant_id'], $delegation['context']['tenant_id']); + self::assertSame('billing.invoice-summary', $delegation['operation']); + self::assertArrayNotHasKey('x-host-only-state', $delegation['forwarded_headers']); + } + } + + /** + * @param list $findings + * + * @return array + */ + private function findingsByIdentifier(array $findings): array + { + $indexed = []; + + foreach ($findings as $finding) { + $indexed[$finding->identifier()] = $finding; + } + + return $indexed; + } + + private function createLegacyBillingProjectFixture(): string + { + $root = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) + . DIRECTORY_SEPARATOR + . 'evolvephp-modernization-cutover-' + . bin2hex(random_bytes(6)); + + mkdir($root . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'Billing', 0777, true); + + file_put_contents($root . DIRECTORY_SEPARATOR . 'composer.json', json_encode([ + 'require' => [ + 'php' => '^7.4', + 'laravel/framework' => '^8.0', + ], + 'autoload' => [ + 'psr-4' => [ + 'LegacyBilling\\' => 'src/Billing/', + ], + ], + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + file_put_contents($root . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'Billing' . DIRECTORY_SEPARATOR . 'LegacyInvoiceSummary.php', <<<'PHP' + + */ + public static function scenario(): array + { + $json = file_get_contents(dirname(__DIR__, 2) . '/compat/legacy-http-client/tests/fixtures/modernization-cutover.json'); + + if (! is_string($json)) { + throw new RuntimeException('Modernization cutover fixture is not readable.'); + } + + $scenario = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + + if (! is_array($scenario)) { + throw new RuntimeException('Modernization cutover fixture must decode to an object.'); + } + + return $scenario; + } + + /** + * @param array $scenario + * + * @return array + */ + public static function invokeLaravel(array $scenario): array + { + $handler = new InvoiceSummaryHandler(); + $adapter = new LaravelBridgeAdapter(self::embedded($handler), new PsrServerRequestFactory(), new PsrStreamFactory()); + $request = LaravelRequest::create( + $scenario['target'], + $scenario['method'], + $scenario['payload'], + [], + [], + [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_TRACEPARENT' => $scenario['trace']['traceparent'], + 'HTTP_X_HOST_ONLY_STATE' => 'must-not-forward', + ], + $scenario['body'], + ); + + $result = $adapter->invoke($request, self::context($scenario)); + $response = $result->response(); + + if ($response === null) { + throw new RuntimeException('Laravel delegation did not return an application response.'); + } + + return self::delegationResult($handler, (string) $response->getContent()); + } + + /** + * @param array $scenario + * + * @return array + */ + public static function invokeSymfony(array $scenario): array + { + $handler = new InvoiceSummaryHandler(); + $adapter = new SymfonyBridgeAdapter(self::embedded($handler), new PsrServerRequestFactory(), new PsrStreamFactory()); + $request = SymfonyRequest::create( + $scenario['target'], + $scenario['method'], + [], + [], + [], + [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_TRACEPARENT' => $scenario['trace']['traceparent'], + 'HTTP_X_HOST_ONLY_STATE' => 'must-not-forward', + ], + $scenario['body'], + ); + + $request->request->replace($scenario['payload']); + + $result = $adapter->invoke($request, self::context($scenario)); + $response = $result->response(); + + if ($response === null) { + throw new RuntimeException('Symfony delegation did not return an application response.'); + } + + return self::delegationResult($handler, (string) $response->getContent()); + } + + /** + * @param array $scenario + * + * @return array + */ + public static function invokeLegacyRemote(array $scenario): array + { + $handler = new InvoiceSummaryHandler(); + $server = new RemoteBridgeServerHandler( + self::embedded($handler), + new RemoteBridgeCodec(), + new AcceptingRemoteAuthenticator(), + new PsrResponseFactory(), + new PsrServerRequestFactory(), + new PsrStreamFactory(), + ); + $transport = new InMemoryLegacyRemoteTransport($server); + $client = new LegacyRemoteClient('https://bridge.example.test/evolve-remote', $transport, new NullLegacyAuthenticator()); + $result = $client->invoke(new LegacyRemoteInvocation( + $scenario['operation'], + $scenario['method'], + $scenario['target'], + $scenario['headers'], + $scenario['body'], + $scenario['payload'], + $scenario['request_id'], + $scenario['correlation_id'], + $scenario['caller_id'], + $scenario['principal_id'], + $scenario['tenant_id'], + $scenario['locale'], + $scenario['timezone'], + $scenario['deadline'], + $scenario['idempotency_key'], + $scenario['trace'], + )); + + if (! $result->received() || $result->outcome() !== 'application') { + throw new RuntimeException('Legacy remote delegation did not return an application response.'); + } + + return self::delegationResult($handler, (string) $result->applicationBody()); + } + + /** + * @param array $scenario + */ + private static function context(array $scenario): BridgeContext + { + return new BridgeContext( + $scenario['request_id'], + $scenario['correlation_id'], + $scenario['principal_id'], + $scenario['tenant_id'], + $scenario['locale'], + $scenario['timezone'], + ); + } + + private static function embedded(InvoiceSummaryHandler $handler): EmbeddedBridgeAdapter + { + $services = new ServiceRegistry(); + $services->freeze(); + $responses = new PsrResponseFactory(); + + return new EmbeddedBridgeAdapter( + new HttpKernel($handler, new ExecutionOrchestrator($services)), + new ExecutionOutcomeResponseResolver($responses), + new ReadyCheck(), + ); + } + + /** + * @return array + */ + private static function delegationResult(InvoiceSummaryHandler $handler, string $body): array + { + $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + + if (! is_array($decoded)) { + throw new RuntimeException('Delegated response body must decode to an object.'); + } + + return [ + 'execution_count' => $handler->calls, + 'operation' => $handler->operation, + 'context' => $handler->context, + 'forwarded_headers' => $handler->forwardedHeaders, + 'result' => $decoded, + ]; + } +} + +final class InvoiceSummaryHandler implements RequestHandlerInterface +{ + public int $calls = 0; + public string $operation = ''; + + /** + * @var array + */ + public array $context = []; + + /** + * @var array> + */ + public array $forwardedHeaders = []; + + public function handle(ServerRequestInterface $request): ResponseInterface + { + ++$this->calls; + $context = $request->getAttribute(BridgeContext::class); + + if (! $context instanceof BridgeContext) { + throw new RuntimeException('Bridge context was not attached.'); + } + + $payload = $request->getParsedBody(); + $remotePayload = $request->getAttribute('evolve.bridge.remote.payload'); + + if (is_array($remotePayload)) { + $payload = $remotePayload; + } + + if (! is_array($payload)) { + $payload = json_decode((string) $request->getBody(), true, 512, JSON_THROW_ON_ERROR); + } + + if (! is_array($payload)) { + throw new RuntimeException('Invoice summary payload must be an array.'); + } + + $remoteInvocation = $request->getAttribute(RemoteBridgeInvocation::class); + $this->operation = $remoteInvocation instanceof RemoteBridgeInvocation + ? $remoteInvocation->operation() + : 'billing.invoice-summary'; + $this->context = [ + 'request_id' => $context->requestIdentifier(), + 'correlation_id' => $context->correlationIdentifier(), + 'principal_id' => $context->principalIdentifier(), + 'tenant_id' => $context->tenantIdentifier(), + ]; + $this->forwardedHeaders = $request->getHeaders(); + + $result = [ + 'capability' => 'billing.invoice-summary', + 'invoice_count' => count($payload['invoice_ids'] ?? []), + 'total_amount' => array_sum($payload['amounts'] ?? []), + 'currency' => $payload['currency'] ?? '', + 'principal_id' => $context->principalIdentifier(), + 'tenant_id' => $context->tenantIdentifier(), + ]; + + return new PsrResponse(200, ['content-type' => ['application/json']], json_encode($result, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + } +} + +final class ReadyCheck implements ReadinessCheck +{ + public function isReady(): bool + { + return true; + } +} + +final class AcceptingRemoteAuthenticator implements RemoteBridgeAuthenticator +{ + public function authenticate(ServerRequestInterface $request, RemoteBridgeInvocation $invocation): ?\Evolve\Bridge\Contracts\BridgeError + { + return null; + } +} + +final class NullLegacyAuthenticator implements LegacyRemoteClientAuthenticator +{ + public function authenticationHeaders(LegacyRemoteInvocation $invocation): array + { + return []; + } +} + +final class InMemoryLegacyRemoteTransport implements LegacyRemoteTransport +{ + public function __construct(private RemoteBridgeServerHandler $server) {} + + public function send(string $method, string $endpoint, array $headers, string $body, float $connectTimeoutSeconds, float $requestTimeoutSeconds): LegacyRemoteTransportResponse + { + $request = (new PsrServerRequest($method, new PsrUri($endpoint), $headers, new PsrStream($body))) + ->withHeader('content-type', [LegacyRemoteProtocol::MEDIA_TYPE]); + $response = $this->server->handle($request); + + return new LegacyRemoteTransportResponse( + $response->getStatusCode(), + $response->getHeaders(), + (string) $response->getBody(), + ); + } +} + +final class PsrResponseFactory implements ResponseFactoryInterface +{ + public function createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface + { + return new PsrResponse($code, [], '', $reasonPhrase); + } +} + +final class PsrServerRequestFactory implements ServerRequestFactoryInterface +{ + public function createServerRequest(string $method, $uri, array $serverParams = []): ServerRequestInterface + { + return new PsrServerRequest($method, $uri instanceof UriInterface ? $uri : new PsrUri((string) $uri), [], new PsrStream(), $serverParams); + } +} + +final class PsrStreamFactory implements StreamFactoryInterface +{ + public function createStream(string $content = ''): StreamInterface + { + return new PsrStream($content); + } + + public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface + { + throw new BadMethodCallException('File streams are not used by modernization cutover acceptance tests.'); + } + + public function createStreamFromResource($resource): StreamInterface + { + return new PsrStream((string) stream_get_contents($resource)); + } +} + +class PsrMessage implements MessageInterface +{ + /** + * @param array> $headers + */ + public function __construct( + protected array $headers = [], + protected StreamInterface $body = new PsrStream(), + private string $protocolVersion = '1.1', + ) { + $this->headers = self::normalizeHeaders($headers); + } + + public function getProtocolVersion(): string + { + return $this->protocolVersion; + } + + public function withProtocolVersion(string $version): MessageInterface + { + $clone = clone $this; + $clone->protocolVersion = $version; + + return $clone; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function hasHeader(string $name): bool + { + return $this->getHeader($name) !== []; + } + + public function getHeader(string $name): array + { + return $this->headers[strtolower($name)] ?? []; + } + + public function getHeaderLine(string $name): string + { + return implode(', ', $this->getHeader($name)); + } + + public function withHeader(string $name, $value): MessageInterface + { + $clone = clone $this; + $clone->headers[strtolower($name)] = is_array($value) ? array_values($value) : [(string) $value]; + + return $clone; + } + + public function withAddedHeader(string $name, $value): MessageInterface + { + $clone = clone $this; + $clone->headers[strtolower($name)] = array_merge($clone->getHeader($name), is_array($value) ? array_values($value) : [(string) $value]); + + return $clone; + } + + public function withoutHeader(string $name): MessageInterface + { + $clone = clone $this; + unset($clone->headers[strtolower($name)]); + + return $clone; + } + + public function getBody(): StreamInterface + { + return $this->body; + } + + public function withBody(StreamInterface $body): MessageInterface + { + $clone = clone $this; + $clone->body = $body; + + return $clone; + } + + /** + * @param array> $headers + * + * @return array> + */ + private static function normalizeHeaders(array $headers): array + { + $normalized = []; + + foreach ($headers as $name => $values) { + $normalized[strtolower($name)] = array_values($values); + } + + ksort($normalized); + + return $normalized; + } +} + +final class PsrServerRequest extends PsrMessage implements ServerRequestInterface +{ + /** + * @param array> $headers + * @param array $serverParams + * @param array $queryParams + * @param array|object|null $parsedBody + * @param array $attributes + */ + public function __construct( + private string $method, + private UriInterface $uri, + array $headers = [], + StreamInterface $body = new PsrStream(), + private array $serverParams = [], + private array $queryParams = [], + private mixed $parsedBody = null, + private array $attributes = [], + ) { + parent::__construct($headers, $body); + } + + public function getRequestTarget(): string + { + return (string) $this->uri; + } + + public function withRequestTarget(string $requestTarget): RequestInterface + { + return $this; + } + + public function getMethod(): string + { + return strtoupper($this->method); + } + + public function withMethod(string $method): RequestInterface + { + $clone = clone $this; + $clone->method = $method; + + return $clone; + } + + public function getUri(): UriInterface + { + return $this->uri; + } + + public function withUri(UriInterface $uri, bool $preserveHost = false): RequestInterface + { + $clone = clone $this; + $clone->uri = $uri; + + return $clone; + } + + public function getServerParams(): array + { + return $this->serverParams; + } + + public function getCookieParams(): array + { + return []; + } + + public function withCookieParams(array $cookies): ServerRequestInterface + { + return $this; + } + + public function getQueryParams(): array + { + return $this->queryParams; + } + + public function withQueryParams(array $query): ServerRequestInterface + { + $clone = clone $this; + $clone->queryParams = $query; + + return $clone; + } + + public function getUploadedFiles(): array + { + return []; + } + + public function withUploadedFiles(array $uploadedFiles): ServerRequestInterface + { + return $this; + } + + public function getParsedBody(): mixed + { + return $this->parsedBody; + } + + public function withParsedBody($data): ServerRequestInterface + { + $clone = clone $this; + $clone->parsedBody = $data; + + return $clone; + } + + public function getAttributes(): array + { + return $this->attributes; + } + + public function getAttribute(string $name, $default = null): mixed + { + return $this->attributes[$name] ?? $default; + } + + public function withAttribute(string $name, $value): ServerRequestInterface + { + $clone = clone $this; + $clone->attributes[$name] = $value; + + return $clone; + } + + public function withoutAttribute(string $name): ServerRequestInterface + { + $clone = clone $this; + unset($clone->attributes[$name]); + + return $clone; + } +} + +final class PsrResponse extends PsrMessage implements ResponseInterface +{ + /** + * @param array> $headers + */ + public function __construct( + private int $statusCode = 200, + array $headers = [], + string $body = '', + private string $reasonPhrase = '', + ) { + parent::__construct($headers, new PsrStream($body)); + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + public function withStatus(int $code, string $reasonPhrase = ''): ResponseInterface + { + $clone = clone $this; + $clone->statusCode = $code; + $clone->reasonPhrase = $reasonPhrase; + + return $clone; + } + + public function getReasonPhrase(): string + { + return $this->reasonPhrase; + } +} + +final class PsrUri implements UriInterface +{ + public function __construct(private string $value) {} + + public function getScheme(): string + { + return (string) parse_url($this->value, PHP_URL_SCHEME); + } + + public function getAuthority(): string + { + return $this->getHost(); + } + + public function getUserInfo(): string + { + return ''; + } + + public function getHost(): string + { + return (string) parse_url($this->value, PHP_URL_HOST); + } + + public function getPort(): ?int + { + return null; + } + + public function getPath(): string + { + return (string) parse_url($this->value, PHP_URL_PATH); + } + + public function getQuery(): string + { + return (string) parse_url($this->value, PHP_URL_QUERY); + } + + public function getFragment(): string + { + return ''; + } + + public function withScheme(string $scheme): UriInterface + { + return $this; + } + + public function withUserInfo(string $user, ?string $password = null): UriInterface + { + return $this; + } + + public function withHost(string $host): UriInterface + { + return $this; + } + + public function withPort(?int $port): UriInterface + { + return $this; + } + + public function withPath(string $path): UriInterface + { + return new self($path . ($this->getQuery() === '' ? '' : '?' . $this->getQuery())); + } + + public function withQuery(string $query): UriInterface + { + return new self($this->getPath() . ($query === '' ? '' : '?' . $query)); + } + + public function withFragment(string $fragment): UriInterface + { + return $this; + } + + public function __toString(): string + { + return $this->value; + } +} + +final class PsrStream implements StreamInterface +{ + public function __construct(private string $content = '') {} + + public function __toString(): string + { + return $this->content; + } + + public function close(): void {} + + public function detach() + { + return null; + } + + public function getSize(): int + { + return strlen($this->content); + } + + public function tell(): int + { + return 0; + } + + public function eof(): bool + { + return true; + } + + public function isSeekable(): bool + { + return true; + } + + public function seek(int $offset, int $whence = SEEK_SET): void {} + + public function rewind(): void {} + + public function isWritable(): bool + { + return true; + } + + public function write(string $string): int + { + $this->content .= $string; + + return strlen($string); + } + + public function isReadable(): bool + { + return true; + } + + public function read(int $length): string + { + return $this->content; + } + + public function getContents(): string + { + return $this->content; + } + + public function getMetadata(?string $key = null): mixed + { + return null; + } +}