diff --git a/CHANGELOG.md b/CHANGELOG.md index 24c66cd..e7869e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,16 @@ Latest ------ ### Changes +* [#23](https://github.com/cleverage/soap-process-bundle/issues/23) Add missing tests: Client (with a fake SoapClient), RequestTask, RequestTransformer, MissingClientException, bundle and DI extension. * [#27](https://github.com/cleverage/soap-process-bundle/issues/27) Give the ids of both services in the error on duplicate client codes: the clients are registered by a compiler pass of the bundle, `ClientRegistry::addClient()` gets an optional `$serviceId` argument. Update documentation, add tests. +### Fixes +* [#29](https://github.com/cleverage/soap-process-bundle/issues/29) Fix RequestTask: keep the SOAP options and headers of the client definition when `soap_call_options` / `soap_call_headers` are not set (they were overwritten by `null`). Update documentation, add tests. +* [#30](https://github.com/cleverage/soap-process-bundle/issues/30) Fix RequestTask and RequestTransformer: set `soap_call_options` / `soap_call_headers` for the call only (they leaked to the next calls of the client), add these options to the `soap_request` transformer. Update documentation, add tests. +* [#31](https://github.com/cleverage/soap-process-bundle/issues/31) Fix Client: throw the `SoapFault` of a failed call (it returned `false`), so a method returning `false` is no longer handled as failed, and the `soap_request` transformer fails on a `SoapFault`. Update documentation, add tests. +* [#32](https://github.com/cleverage/soap-process-bundle/issues/32) Fix Client: a `SoapFault` returned with the `exceptions: false` option makes the call fail (it was returned as a result). Update documentation, add tests. +* [#33](https://github.com/cleverage/soap-process-bundle/issues/33) Fix RequestTask: throw an explicit `\UnexpectedValueException` on a non-array input (a `TypeError` was triggered); Client: log the notice of the calls handled by a `soapCall()` override. Update documentation, add tests. + v3.1 ------ diff --git a/docs/reference/client.md b/docs/reference/client.md index 56283e1..14efa54 100644 --- a/docs/reference/client.md +++ b/docs/reference/client.md @@ -113,9 +113,8 @@ class CountryInfoClient extends Client */ protected function soapCallCountryName(array $input): mixed { - $result = $this->doSoapCall('CountryName', $input); - - return false === $result ? false : $result->CountryNameResult; + // Throws the SoapFault when the call fails + return $this->doSoapCall('CountryName', $input)->CountryNameResult; } } ``` @@ -134,10 +133,11 @@ Notes * The `trace` option is always forced to `true` when the `SoapClient` is created, so that the last request and response are always available (they are added to the log context of a failed call). Setting `trace: true` in `$options` additionally logs them at `debug` level after each successful call. -* Each generic call is logged at `notice` level (`Soap call '' on ''`). When a `SoapFault` is thrown - (i.e. with `exceptions: true`, the default of `SoapClient`), the error is logged at `alert` level, with the last - request and response in the log context, and `false` is returned instead of throwing. With `exceptions: false`, - `SoapClient` returns the `SoapFault` object, which is then returned as a regular result. -* A client service is shared by default: the SOAP call options and headers set by a [RequestTask](tasks/request_task.md) remain - set on the client for the following calls, including calls made by the - [RequestTransformer](transformers/request_transformer.md). +* Each call is logged at `notice` level (`Soap call '' on ''`), including the ones handled by a + `soapCall()` override. When the call fails with a `SoapFault`, the error is logged at `alert` level, with the + last request and response in the log context, then the `SoapFault` is thrown. With the `exceptions: false` option, + `SoapClient` returns the `SoapFault` instead of throwing it: it is handled the same way. +* A client service is shared by default: the SOAP options and headers set with `setSoapOptions()` / + `setSoapHeaders()` (e.g. in the `calls` of the service definition) are used by every call. The + [RequestTask](tasks/request_task.md) and the [RequestTransformer](transformers/request_transformer.md) set their + own `soap_call_options` / `soap_call_headers` for their call only, then restore these values. diff --git a/docs/reference/tasks/request_task.md b/docs/reference/tasks/request_task.md index 81ffca6..0aebd3f 100644 --- a/docs/reference/tasks/request_task.md +++ b/docs/reference/tasks/request_task.md @@ -15,6 +15,8 @@ Accepted inputs [SoapClient::__soapCall()](https://www.php.net/manual/en/soapclient.soapcall.php). An empty input (`null`, `[]`, …) calls the method without argument. +Any other non-empty input (e.g. a `string`) throws an `\UnexpectedValueException`. + For a document/literal service, the arguments of the method are usually wrapped in a single array, e.g. `{ parameters: { sCountryISOCode: FR } }` (in WSDL mode, the keys of the first level are ignored, only the order matters). @@ -85,14 +87,16 @@ get_order: Notes ----- -* When the call fails (the client returns `false`, see [client](../client.md)), the task logs an error - `Empty resultset for query`, with the options and the last request and response in the log context, then throws a - `RuntimeException` (`Soap call '' on client '' failed`), handled by the `error_strategy`: +* When the call fails (the client throws a `SoapFault`, see [client](../client.md)), the task logs an error + `Empty resultset for query`, with the options, the fault message and the last request and response in the log + context, then throws a `RuntimeException` (`Soap call '' on client '' failed`, with the `SoapFault` + as previous exception), handled by the `error_strategy`: - with `error_strategy: skip`, the `outputs` tasks are skipped and the `error_outputs` tasks receive the input of the task - with `error_strategy: stop`, the process fails and the console command returns an error code -* An exception thrown by the client (e.g. a `SoapFault` when the WSDL cannot be loaded, or a +* A method returning `false` is a successful call: `false` is output. +* An exception thrown by the client before the call (e.g. a `SoapFault` when the WSDL cannot be loaded, or a `MissingClientException` for an unknown `client`) is handled as usual by the `error_strategy`. -* `soap_call_options` and `soap_call_headers` are set on the client each time the task is executed, including when - they are `null`: they overwrite the values configured with `setSoapOptions` / `setSoapHeaders` in the client - service definition. +* `soap_call_options` and `soap_call_headers` are set on the client for the call only, then the previous values are + restored: when they are not set (`null`), the values configured with `setSoapOptions` / `setSoapHeaders` in the + client service definition are used, and they never leak to the following calls of the client. diff --git a/docs/reference/transformers/request_transformer.md b/docs/reference/transformers/request_transformer.md index a4296e2..0807c28 100644 --- a/docs/reference/transformers/request_transformer.md +++ b/docs/reference/transformers/request_transformer.md @@ -24,15 +24,17 @@ Possible outputs `mixed`: the result of the SOAP call, usually a `stdClass` built by `SoapClient` from the response. -`false` when the call failed (`SoapFault`), see Notes. +A failed call (`SoapFault`) throws a `RuntimeException`, see Notes. Options ------- -| Code | Type | Required | Default | Description | -|----------|----------|:--------:|---------|-------------------------------------------| -| `client` | `string` | **X** | | Code of the [client](../client.md) to use | -| `method` | `string` | **X** | | Name of the SOAP method to call | +| Code | Type | Required | Default | Description | +|---------------------|---------------|:--------:|---------|------------------------------------------------------------------------------------------------------| +| `client` | `string` | **X** | | Code of the [client](../client.md) to use | +| `method` | `string` | **X** | | Name of the SOAP method to call | +| `soap_call_options` | `array\|null` | | `null` | `$options` of `SoapClient::__soapCall()`, as for the [RequestTask](../tasks/request_task.md#options) | +| `soap_call_headers` | `array\|null` | | `null` | Headers sent with the request, as for the [RequestTask](../tasks/request_task.md#options) | Examples -------- @@ -61,12 +63,11 @@ property_accessor: Notes ----- -* The transformer does not handle any SOAP call option or header: the values currently set on the client are used - (from the `calls` of the client service definition, or from the last [RequestTask](../tasks/request_task.md) - executed with the same client). -* A failed call is logged by the [client](../client.md) and returns `false`, without any exception: check the result - (or chain a transformer that fails on `false`, like `property_accessor` above) if the process must not go on - silently with a `false` value. +* `soap_call_options` and `soap_call_headers` are set on the client for the call only, then the previous values are + restored: when they are not set, the values of the client service definition (`calls`) are used. +* A failed call (`SoapFault`) is logged by the [client](../client.md), then the transformer throws a + `RuntimeException` (`Soap call '' on client '' failed`). A method returning `false` returns + `false`. * One SOAP call is made each time the transformer is applied: to avoid calling the service several times with the same arguments, wrap it in the [cached](https://github.com/cleverage/process-bundle/blob/main/docs/reference/transformers/cached_transformer.md) diff --git a/src/Client/Client.php b/src/Client/Client.php index 9d98faa..48f0ea8 100644 --- a/src/Client/Client.php +++ b/src/Client/Client.php @@ -167,22 +167,22 @@ public function call(string $method, array $input = []): mixed { $this->initializeSoapClient(); + $this->getLogger()->notice( + \sprintf("Soap call '%s' on '%s'", $method, $this->getWsdl()) + ); + $callMethod = \sprintf('soapCall%s', ucfirst($method)); if (method_exists($this, $callMethod)) { return $this->$callMethod($input); } - $this->getLogger()->notice( - \sprintf("Soap call '%s' on '%s'", $method, $this->getWsdl()) - ); - return $this->doSoapCall($method, $input); } /** * @param array $input * - * @return bool|mixed + * @throws \SoapFault when the call fails, after logging it */ protected function doSoapCall(string $method, array $input = []): mixed { @@ -191,6 +191,10 @@ protected function doSoapCall(string $method, array $input = []): mixed } try { $result = $this->getSoapClient()->__soapCall($method, $input, $this->getSoapOptions(), $this->getSoapHeaders()); + // With the "exceptions: false" option, SoapClient returns the fault instead of throwing it + if ($result instanceof \SoapFault) { + throw $result; + } } catch (\SoapFault $e) { $this->getLastRequestTrace(); $this->getLogger()->alert( @@ -198,7 +202,7 @@ protected function doSoapCall(string $method, array $input = []): mixed $this->getLastRequestTraceArray() ); - return false; + throw $e; } $this->getLastRequestTrace(); diff --git a/src/Client/ClientInterface.php b/src/Client/ClientInterface.php index 10fbd55..73731a9 100644 --- a/src/Client/ClientInterface.php +++ b/src/Client/ClientInterface.php @@ -97,7 +97,7 @@ public function getLastResponseHeaders(): ?string; * * @param array $input * - * @return bool|mixed + * @throws \SoapFault when the call fails */ public function call(string $method, array $input = []): mixed; } diff --git a/src/Client/SoapCallOptionsTrait.php b/src/Client/SoapCallOptionsTrait.php new file mode 100644 index 0000000..16bf833 --- /dev/null +++ b/src/Client/SoapCallOptionsTrait.php @@ -0,0 +1,92 @@ +setDefaults( + [ + 'soap_call_options' => null, + 'soap_call_headers' => null, + ] + ); + $resolver->setAllowedTypes('soap_call_options', ['array', 'null']); + $resolver->setAllowedTypes('soap_call_headers', ['array', 'null']); + + $resolver->setNormalizer('soap_call_headers', function (Options $options, $headers) { + if (null === $headers) { + return null; + } + + $headerResolver = new OptionsResolver(); + $this->configureSoapCallHeaderOption($headerResolver); + + $resolvedHeaders = []; + /** @var array> $headers */ + foreach ($headers as $name => $header) { + /** @var array{'namespace': string, 'data': array} $resolvedHeader */ + $resolvedHeader = $headerResolver->resolve($header); + $resolvedHeaders[] = new \SoapHeader($resolvedHeader['namespace'], $name, $resolvedHeader['data']); + } + + return $resolvedHeaders; + }); + } + + protected function configureSoapCallHeaderOption(OptionsResolver $resolver): void + { + $resolver->setRequired('namespace'); + $resolver->setRequired('data'); + } + + /** + * @param array $input + * @param array|null $soapCallOptions null to keep the options of the client + * @param array<\SoapHeader>|null $soapCallHeaders null to keep the headers of the client + */ + protected function callWithSoapOptions( + ClientInterface $client, + string $method, + array $input, + ?array $soapCallOptions, + ?array $soapCallHeaders, + ): mixed { + $clientOptions = $client->getSoapOptions(); + $clientHeaders = $client->getSoapHeaders(); + if (null !== $soapCallOptions) { + $client->setSoapOptions($soapCallOptions); + } + if (null !== $soapCallHeaders) { + $client->setSoapHeaders($soapCallHeaders); + } + + try { + return $client->call($method, $input); + } finally { + $client->setSoapOptions($clientOptions); + $client->setSoapHeaders($clientHeaders); + } + } +} diff --git a/src/Task/RequestTask.php b/src/Task/RequestTask.php index 17065b8..10873e4 100644 --- a/src/Task/RequestTask.php +++ b/src/Task/RequestTask.php @@ -15,9 +15,9 @@ use CleverAge\ProcessBundle\Model\AbstractConfigurableTask; use CleverAge\ProcessBundle\Model\ProcessState; +use CleverAge\SoapProcessBundle\Client\SoapCallOptionsTrait; use CleverAge\SoapProcessBundle\Registry\ClientRegistry; use Psr\Log\LoggerInterface; -use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; /** @@ -30,6 +30,8 @@ */ class RequestTask extends AbstractConfigurableTask { + use SoapCallOptionsTrait; + public function __construct(protected LoggerInterface $logger, protected ClientRegistry $registry) { } @@ -41,22 +43,17 @@ public function execute(ProcessState $state): void $client = $this->registry->getClient($options['client']); - /** @var array $input */ $input = $state->getInput() ?: []; + if (!\is_array($input)) { + throw new \UnexpectedValueException(\sprintf('RequestTask expects an array or empty input, %s given', get_debug_type($input))); + } - /** @var array|null $soapCallOptions */ - $soapCallOptions = $this->getOption($state, 'soap_call_options'); - $client->setSoapOptions($soapCallOptions); - /** @var array<\SoapHeader>|null $soapCallHeaders */ - $soapCallHeaders = $this->getOption($state, 'soap_call_headers'); - $client->setSoapHeaders($soapCallHeaders); - - $result = $client->call($options['method'], $input); - - // Handle empty results - if (false === $result) { + try { + $result = $this->callWithSoapOptions($client, $options['method'], $input, $options['soap_call_options'], $options['soap_call_headers']); + } catch (\SoapFault $e) { $logContext = [ 'options' => $options, + 'message' => $e->getMessage(), 'last_request' => $client->getLastRequest(), 'last_request_headers' => $client->getLastRequestHeaders(), 'last_response' => $client->getLastResponse(), @@ -67,7 +64,7 @@ public function execute(ProcessState $state): void // The process manager applies the error strategy: the process fails with "stop", the error outputs // receive the task input with "skip" - throw new \RuntimeException(\sprintf("Soap call '%s' on client '%s' failed", $options['method'], $options['client'])); + throw new \RuntimeException(\sprintf("Soap call '%s' on client '%s' failed", $options['method'], $options['client']), 0, $e); } $state->setOutput($result); @@ -81,40 +78,9 @@ protected function configureOptions(OptionsResolver $resolver): void 'method', ] ); - $resolver->setDefaults( - [ - 'soap_call_options' => null, - 'soap_call_headers' => null, - ] - ); $resolver->setAllowedTypes('client', ['string']); $resolver->setAllowedTypes('method', ['string']); - $resolver->setAllowedTypes('soap_call_options', ['array', 'null']); - $resolver->setAllowedTypes('soap_call_headers', ['array', 'null']); - $resolver->setNormalizer('soap_call_headers', function (Options $options, $headers) { - if (null === $headers) { - return null; - } - - $headerResolver = new OptionsResolver(); - $this->configureSoapCallHeaderOption($headerResolver); - - $resolvedHeaders = []; - /** @var array> $headers */ - foreach ($headers as $name => $header) { - /** @var array{'namespace': string, 'data': array} $resolvedHeader */ - $resolvedHeader = $headerResolver->resolve($header); - $resolvedHeaders[] = new \SoapHeader($resolvedHeader['namespace'], $name, $resolvedHeader['data']); - } - - return $resolvedHeaders; - }); - } - - protected function configureSoapCallHeaderOption(OptionsResolver $resolver): void - { - $resolver->setRequired('namespace'); - $resolver->setRequired('data'); + $this->configureSoapCallOptions($resolver); } } diff --git a/src/Transformer/RequestTransformer.php b/src/Transformer/RequestTransformer.php index 3e3c2d5..c819a4b 100644 --- a/src/Transformer/RequestTransformer.php +++ b/src/Transformer/RequestTransformer.php @@ -14,6 +14,7 @@ namespace CleverAge\SoapProcessBundle\Transformer; use CleverAge\ProcessBundle\Transformer\ConfigurableTransformerInterface; +use CleverAge\SoapProcessBundle\Client\SoapCallOptionsTrait; use CleverAge\SoapProcessBundle\Registry\ClientRegistry; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -21,10 +22,14 @@ * @phpstan-type TransformerOptions array{ * 'client': string, * 'method': string, + * 'soap_call_options': array|null, + * 'soap_call_headers': array<\SoapHeader>|null, * } */ class RequestTransformer implements ConfigurableTransformerInterface { + use SoapCallOptionsTrait; + public function __construct(protected ClientRegistry $registry) { } @@ -45,7 +50,11 @@ public function transform(mixed $value, array $options = []): mixed $client = $this->registry->getClient($options['client']); - return $client->call($options['method'], $value); + try { + return $this->callWithSoapOptions($client, $options['method'], $value, $options['soap_call_options'], $options['soap_call_headers']); + } catch (\SoapFault $e) { + throw new \RuntimeException(\sprintf("Soap call '%s' on client '%s' failed", $options['method'], $options['client']), 0, $e); + } } /** @@ -66,5 +75,7 @@ public function configureOptions(OptionsResolver $resolver): void ); $resolver->setAllowedTypes('client', ['string']); $resolver->setAllowedTypes('method', ['string']); + + $this->configureSoapCallOptions($resolver); } } diff --git a/tests/CleverAgeSoapProcessBundleTest.php b/tests/CleverAgeSoapProcessBundleTest.php new file mode 100644 index 0000000..4a1bafc --- /dev/null +++ b/tests/CleverAgeSoapProcessBundleTest.php @@ -0,0 +1,55 @@ +getPath(); + + self::assertSame(\dirname(__DIR__), $path); + self::assertDirectoryExists($path.'/config/services'); + } + + public function testTaggedClientsAreRegistered(): void + { + $container = new ContainerBuilder(); + (new CleverAgeSoapProcessBundle())->build($container); + $container->setDefinition('cleverage_soap_process.registry.client', new Definition(ClientRegistry::class)) + ->setPublic(true); + $container->setDefinition('app.client', new Definition(Client::class, [new Definition(NullLogger::class), 'test', null])) + ->addTag('cleverage.soap.client'); + $container->compile(true); + + /** @var ClientRegistry $registry */ + $registry = $container->get('cleverage_soap_process.registry.client'); + self::assertSame('test', $registry->getClient('test')->getCode()); + } +} diff --git a/tests/Client/ClientTest.php b/tests/Client/ClientTest.php new file mode 100644 index 0000000..fdd0ea0 --- /dev/null +++ b/tests/Client/ClientTest.php @@ -0,0 +1,172 @@ + */ + private array $logs = []; + + public function testAccessors(): void + { + $client = $this->createClient(); + + self::assertSame('test', $client->getCode()); + self::assertSame('http://soap.test/?wsdl', $client->getWsdl()); + self::assertSame(['trace' => true], $client->getOptions()); + self::assertNull($client->getSoapOptions()); + self::assertNull($client->getSoapHeaders()); + + $header = new \SoapHeader('urn:demo', 'Token', 'secret'); + $client->setWsdl(null); + $client->setOptions(['exceptions' => true]); + $client->setSoapOptions(['soapaction' => 'urn:demo#Other']); + $client->setSoapHeaders([$header]); + + self::assertNull($client->getWsdl()); + self::assertSame(['exceptions' => true], $client->getOptions()); + self::assertSame(['soapaction' => 'urn:demo#Other'], $client->getSoapOptions()); + self::assertSame([$header], $client->getSoapHeaders()); + } + + public function testUndefinedCode(): void + { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Client code is not defined'); + (new Client($this->createLogger(), '', null))->getCode(); + } + + public function testCall(): void + { + $client = $this->createClient(); + $soapClient = new FakeSoapClient(['GetBook' => 'xsi:type="xsd:string">It']); + $client->setSoapClient($soapClient); + + self::assertSame('It', $client->call('GetBook', ['id' => 1])); + self::assertStringContainsString('', (string) $client->getLastRequest()); + self::assertStringContainsString('It', (string) $client->getLastResponse()); + // The trace is logged with the "trace" option + self::assertSame(["notice: Soap call 'GetBook' on 'http://soap.test/?wsdl'", "debug: Trace of soap call 'GetBook' on 'http://soap.test/?wsdl'"], $this->logs); + } + + public function testSoapOptionsAndHeadersAreSent(): void + { + $client = $this->createClient(); + $soapClient = new FakeSoapClient(['GetBook' => 'xsi:type="xsd:string">It']); + $client->setSoapClient($soapClient); + $client->setSoapHeaders([new \SoapHeader('urn:demo', 'Token', 'secret')]); + + $client->call('GetBook', ['id' => 1]); + + self::assertStringContainsString('secret', $soapClient->requests[0]); + } + + public function testFalseResult(): void + { + $client = $this->createClient(); + $client->setSoapClient(new FakeSoapClient(['IsAvailable' => 'xsi:type="xsd:boolean">false'])); + + self::assertFalse($client->call('IsAvailable', ['id' => 1])); + } + + public function testSoapFaultIsLoggedAndThrown(): void + { + $client = $this->createClient(); + $client->setSoapClient(new FakeSoapClient()); + + try { + $client->call('Fail'); + self::fail('The call must fail'); + } catch (\SoapFault $e) { + self::assertSame('Unknown method', $e->getMessage()); + } + self::assertSame("alert: Soap call 'Fail' on 'http://soap.test/?wsdl' failed : Unknown method", $this->logs[1]); + self::assertStringContainsString('Unknown method', (string) $client->getLastResponse()); + } + + public function testSoapFaultIsThrownWithoutExceptions(): void + { + $client = $this->createClient(); + // With the "exceptions: false" option, __soapCall() returns the fault + $client->setSoapClient(new FakeSoapClient([], ['exceptions' => false])); + + $this->expectException(\SoapFault::class); + $this->expectExceptionMessage('Unknown method'); + $client->call('Fail'); + } + + public function testCallOverride(): void + { + $client = new class($this->createLogger(), 'test', null, ['location' => 'http://soap.test/soap', 'uri' => 'urn:demo']) extends Client { + /** + * @param array $input + */ + public function soapCallGetBook(array $input): string + { + return 'Book '.(\is_scalar($input['id'] ?? null) ? $input['id'] : ''); + } + }; + + self::assertSame('Book 1', $client->call('getBook', ['id' => 1])); + // The SoapClient is initialized with the client options, and the call is logged before the override + self::assertInstanceOf(\SoapClient::class, $client->getSoapClient()); + self::assertSame(["notice: Soap call 'getBook' on ''"], $this->logs); + } + + public function testDoSoapCallWithoutSoapClient(): void + { + $client = new class($this->createLogger(), 'test', null) extends Client { + public function doCall(): mixed + { + return $this->doSoapCall('GetBook'); + } + }; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Soap client is not initialized'); + $client->doCall(); + } + + private function createClient(): Client + { + return new Client($this->createLogger(), 'test', 'http://soap.test/?wsdl', ['trace' => true]); + } + + private function createLogger(): AbstractLogger + { + $onLog = function (string $log): void { + $this->logs[] = $log; + }; + + return new class($onLog) extends AbstractLogger { + public function __construct(private readonly \Closure $onLog) + { + } + + /** + * @param array $context + */ + public function log($level, string|\Stringable $message, array $context = []): void + { + ($this->onLog)(\sprintf('%s: %s', \is_string($level) ? $level : '', $message)); + } + }; + } +} diff --git a/tests/Client/FakeSoapClient.php b/tests/Client/FakeSoapClient.php new file mode 100644 index 0000000..90acbd0 --- /dev/null +++ b/tests/Client/FakeSoapClient.php @@ -0,0 +1,48 @@ + Requests sent, in order */ + public array $requests = []; + + /** + * @param array $results XML results (content of ) indexed by method, a missing method + * answers a SOAP fault + * @param array $options + */ + public function __construct(private readonly array $results = [], array $options = []) + { + parent::__construct(null, $options + ['location' => 'http://soap.test/soap', 'uri' => 'urn:demo', 'trace' => true]); + } + + #[\Override] + public function __doRequest(string $request, string $location, string $action, int $version, bool $oneWay = false, mixed ...$extra): ?string + { + $this->requests[] = $request; + $method = substr($action, (int) strrpos($action, '#') + 1); + $body = \array_key_exists($method, $this->results) + ? "results[$method]}" + : 'SOAP-ENV:ServerUnknown method'; + + return '' + .'' + ."{$body}"; + } +} diff --git a/tests/DependencyInjection/CleverAgeSoapProcessExtensionTest.php b/tests/DependencyInjection/CleverAgeSoapProcessExtensionTest.php new file mode 100644 index 0000000..987a739 --- /dev/null +++ b/tests/DependencyInjection/CleverAgeSoapProcessExtensionTest.php @@ -0,0 +1,50 @@ +load([], $container); + + self::assertSame(ClientRegistry::class, $container->getDefinition('cleverage_soap_process.registry.client')->getClass()); + + $taskIds = array_keys(array_filter($container->getDefinitions(), static fn ($definition): bool => RequestTask::class === $definition->getClass())); + self::assertCount(1, $taskIds); + $task = $container->getDefinition($taskIds[0]); + // Tasks are stateful: each process execution must get its own instance + self::assertFalse($task->isShared()); + // Referenced as '@' in process configurations + self::assertSame($taskIds[0], (string) $container->getAlias(RequestTask::class)); + self::assertTrue($container->getAlias(RequestTask::class)->isPublic()); + + $transformers = array_filter($container->getDefinitions(), static fn ($definition): bool => RequestTransformer::class === $definition->getClass()); + self::assertCount(1, $transformers); + $transformer = current($transformers); + self::assertInstanceOf(Definition::class, $transformer); + self::assertTrue($transformer->hasTag('cleverage.transformer')); + } +} diff --git a/tests/Exception/MissingClientExceptionTest.php b/tests/Exception/MissingClientExceptionTest.php new file mode 100644 index 0000000..20d8d5e --- /dev/null +++ b/tests/Exception/MissingClientExceptionTest.php @@ -0,0 +1,27 @@ +getMessage()); + } +} diff --git a/tests/Task/RequestTaskTest.php b/tests/Task/RequestTaskTest.php index 1402e84..751f56f 100644 --- a/tests/Task/RequestTaskTest.php +++ b/tests/Task/RequestTaskTest.php @@ -13,67 +13,177 @@ namespace CleverAge\SoapProcessBundle\Tests\Task; +use CleverAge\ProcessBundle\Configuration\ProcessConfiguration; +use CleverAge\ProcessBundle\Configuration\TaskConfiguration; +use CleverAge\ProcessBundle\Context\ContextualOptionResolver; +use CleverAge\ProcessBundle\Model\ProcessHistory; use CleverAge\ProcessBundle\Model\ProcessState; -use CleverAge\SoapProcessBundle\Client\ClientInterface; +use CleverAge\SoapProcessBundle\Client\Client; +use CleverAge\SoapProcessBundle\Client\SoapCallOptionsTrait; use CleverAge\SoapProcessBundle\Registry\ClientRegistry; use CleverAge\SoapProcessBundle\Task\RequestTask; +use CleverAge\SoapProcessBundle\Tests\Client\FakeSoapClient; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\CoversTrait; use PHPUnit\Framework\Attributes\UsesClass; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Psr\Log\NullLogger; +use Psr\Log\AbstractLogger; +use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; #[CoversClass(RequestTask::class)] +#[CoversTrait(SoapCallOptionsTrait::class)] +#[UsesClass(Client::class)] #[UsesClass(ClientRegistry::class)] class RequestTaskTest extends TestCase { - public function testExecute(): void + /** @var list}> */ + private array $logs = []; + + private Client $client; + + private FakeSoapClient $soapClient; + + protected function setUp(): void + { + $this->soapClient = new FakeSoapClient([ + 'GetBook' => 'xsi:type="xsd:string">It', + 'IsAvailable' => 'xsi:type="xsd:boolean">false', + ]); + $this->client = new Client($this->createLogger(), 'test', null); + $this->client->setSoapClient($this->soapClient); + // Set by the client definition (e.g. with "calls") + $this->client->setSoapHeaders([new \SoapHeader('urn:demo', 'ClientToken', 'client')]); + } + + public function testOutputsTheResult(): void { - $response = new \stdClass(); - $state = $this->createState(); - $state->expects($this->once())->method('setOutput')->with($response); + [$task, $state] = $this->createTask([]); + $state->setInput(['id' => 1]); - $task = $this->createTask($response); - $task->initialize($state); $task->execute($state); + + self::assertSame('It', $state->getOutput()); + self::assertStringContainsString('1', $this->soapClient->requests[0]); } - public function testFailedCallThrows(): void + public function testFalseResultIsOutput(): void { - $state = $this->createState(); - $state->expects($this->never())->method('setOutput'); - $state->expects($this->never())->method('setErrorOutput'); - $state->expects($this->never())->method('setStopped'); + [$task, $state] = $this->createTask(['method' => 'IsAvailable']); - $task = $this->createTask(false); - $task->initialize($state); + $task->execute($state); + + self::assertFalse($state->getOutput()); + } + + public function testClientHeadersAreKept(): void + { + [$task, $state] = $this->createTask([]); + + $task->execute($state); + + self::assertStringContainsString('client', $this->soapClient->requests[0]); + } + + public function testTaskHeadersAndOptionsAreSetForTheCallOnly(): void + { + [$task, $state] = $this->createTask([ + 'soap_call_options' => ['soapaction' => 'urn:demo#GetBook'], + 'soap_call_headers' => ['TaskToken' => ['namespace' => 'urn:demo', 'data' => 'task']], + ]); + + $task->execute($state); + + self::assertStringContainsString('task', $this->soapClient->requests[0]); + self::assertStringNotContainsString('ClientToken', $this->soapClient->requests[0]); + // The client options and headers are restored + self::assertNull($this->client->getSoapOptions()); + $headers = $this->client->getSoapHeaders(); + self::assertIsArray($headers); + self::assertSame('ClientToken', $headers[0]->name); + } + + public function testFailedCall(): void + { + [$task, $state] = $this->createTask(['method' => 'Fail', 'soap_call_headers' => ['TaskToken' => ['namespace' => 'urn:demo', 'data' => 'task']]]); + + try { + $task->execute($state); + self::fail('The task must fail'); + } catch (\RuntimeException $e) { + self::assertSame("Soap call 'Fail' on client 'test' failed", $e->getMessage()); + self::assertInstanceOf(\SoapFault::class, $e->getPrevious()); + } + + self::assertNull($state->getOutput()); + $errors = array_values(array_filter($this->logs, static fn (array $log): bool => 'error' === $log['level'])); + self::assertCount(1, $errors); + self::assertSame('Empty resultset for query', $errors[0]['message']); + self::assertSame('Unknown method', $errors[0]['context']['message']); + self::assertIsString($errors[0]['context']['last_response']); + self::assertStringContainsString('Unknown method', $errors[0]['context']['last_response']); + // The client headers are restored after a failure too + $headers = $this->client->getSoapHeaders(); + self::assertIsArray($headers); + self::assertSame('ClientToken', $headers[0]->name); + } + + public function testNonArrayInputIsRejected(): void + { + [$task, $state] = $this->createTask([]); + $state->setInput('GetBook'); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage("Soap call 'FullCountryInfo' on client 'test' failed"); + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('RequestTask expects an array or empty input, string given'); $task->execute($state); } - private function createTask(mixed $callResult): RequestTask + public function testInvalidHeader(): void { - $client = $this->createStub(ClientInterface::class); - $client->method('getCode')->willReturn('test'); - $client->method('call')->willReturn($callResult); + $this->expectException(MissingOptionsException::class); + $this->createTask(['soap_call_headers' => ['TaskToken' => ['data' => 'task']]]); + } + /** + * @param array $options + * + * @return array{RequestTask, ProcessState} + */ + private function createTask(array $options): array + { $registry = new ClientRegistry(); - $registry->addClient($client); + $registry->addClient($this->client); - return new RequestTask(new NullLogger(), $registry); + $processConfiguration = new ProcessConfiguration('test', []); + $state = new ProcessState($processConfiguration, new ProcessHistory($processConfiguration)); + $state->setContextualOptionResolver(new ContextualOptionResolver()); + $state->setContext([]); + $state->reset(true); + $state->setTaskConfiguration(new TaskConfiguration('request', RequestTask::class, $options + ['client' => 'test', 'method' => 'GetBook'])); + + $task = new RequestTask($this->createLogger(), $registry); + $task->initialize($state); + + return [$task, $state]; } - private function createState(): ProcessState&MockObject + private function createLogger(): AbstractLogger { - $state = $this->createMock(ProcessState::class); - $state->method('getContextualizedOptions')->willReturn([ - 'client' => 'test', - 'method' => 'FullCountryInfo', - ]); - $state->method('getInput')->willReturn(['sCountryISOCode' => 'FR']); + $onLog = function (string $level, string $message, array $context): void { + $this->logs[] = ['level' => $level, 'message' => $message, 'context' => $context]; + }; + + return new class($onLog) extends AbstractLogger { + public function __construct(private readonly \Closure $onLog) + { + } - return $state; + /** + * @param array $context + */ + public function log($level, string|\Stringable $message, array $context = []): void + { + ($this->onLog)(\is_string($level) ? $level : '', (string) $message, $context); + } + }; } } diff --git a/tests/Transformer/RequestTransformerTest.php b/tests/Transformer/RequestTransformerTest.php new file mode 100644 index 0000000..b185714 --- /dev/null +++ b/tests/Transformer/RequestTransformerTest.php @@ -0,0 +1,87 @@ +soapClient = new FakeSoapClient(['GetBook' => 'xsi:type="xsd:string">It', 'IsAvailable' => 'xsi:type="xsd:boolean">false']); + $this->client = new Client(new NullLogger(), 'test', null); + $this->client->setSoapClient($this->soapClient); + $registry = new ClientRegistry(); + $registry->addClient($this->client); + $this->transformer = new RequestTransformer($registry); + } + + public function testCode(): void + { + self::assertSame('soap_request', $this->transformer->getCode()); + } + + public function testTransform(): void + { + self::assertSame('It', $this->transformer->transform(['id' => 1], ['client' => 'test', 'method' => 'GetBook'])); + self::assertFalse($this->transformer->transform(['id' => 1], ['client' => 'test', 'method' => 'IsAvailable'])); + } + + public function testHeadersAreSetForTheCallOnly(): void + { + $this->transformer->transform([], [ + 'client' => 'test', + 'method' => 'GetBook', + 'soap_call_headers' => ['TransformerToken' => ['namespace' => 'urn:demo', 'data' => 'transformer']], + ]); + $this->transformer->transform([], ['client' => 'test', 'method' => 'GetBook']); + + self::assertStringContainsString('transformer', $this->soapClient->requests[0]); + self::assertStringNotContainsString('TransformerToken', $this->soapClient->requests[1]); + self::assertNull($this->client->getSoapHeaders()); + } + + public function testFailedCall(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage("Soap call 'Fail' on client 'test' failed"); + $this->transformer->transform([], ['client' => 'test', 'method' => 'Fail']); + } + + public function testNonArrayValueIsRejected(): void + { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Expecting an array of value'); + $this->transformer->transform('GetBook', ['client' => 'test', 'method' => 'GetBook']); + } +}