From 32dedd15e8ad1ce2edd7b8051b188bdbf3e2e34f Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 17 Mar 2026 12:56:16 -0300 Subject: [PATCH 1/5] feat: support raw json bodies in send step Signed-off-by: Vitor Mattos --- src/NextcloudApiContext.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/NextcloudApiContext.php b/src/NextcloudApiContext.php index 90f495c..4e0a339 100644 --- a/src/NextcloudApiContext.php +++ b/src/NextcloudApiContext.php @@ -172,7 +172,7 @@ public function sendOCSRequest(string $verb, string $url, $body = null, array $h /** * @param string $verb * @param string $url - * @param TableNode|array|null $body + * @param TableNode|PyStringNode|array|null $body * @param array $headers */ #[Given('sending :verb to :url')] @@ -198,13 +198,16 @@ public function sendRequest(string $verb, string $url, $body = null, array $head $fd = $body->getRowsHash(); $options['form_params'] = $fd; $options['_decode_table_node_json'] = true; + } elseif ($body instanceof PyStringNode) { + $options['body'] = $body->getRaw(); + $options['headers']['Content-Type'] = 'application/json'; } elseif (is_array($body)) { $options['form_params'] = $body; } $options['headers'] = array_merge($headers, [ 'Accept' => 'application/json', - ], $this->customHeaders); + ], $this->customHeaders, $options['headers'] ?? []); if ($this->currentUser === 'admin') { $options['auth'] = ['admin', $this->adminPassword]; From f773a4f9d4cd0a25663da66b58adc36b725c3ea7 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 17 Mar 2026 13:01:06 -0300 Subject: [PATCH 2/5] test: cover raw json body requests Signed-off-by: Vitor Mattos --- features/bootstrap/FeatureContext.php | 9 +++++++++ features/test.feature | 25 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 26e01f3..74fa641 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -96,6 +96,15 @@ public function sendRequest(string $verb, string $url, $body = null, array $head if (array_key_exists('json', $this->requestOptions)) { Assert::assertEquals($this->requestOptions['json'], $parsedInput); } + + // Raw body payload + if (array_key_exists('body', $this->requestOptions)) { + Assert::assertEquals($this->requestOptions['body'], $lastRequest->getInput()); + Assert::assertStringContainsString( + 'application/json', + (string)($lastRequest->getHeaders()['Content-Type'] ?? $lastRequest->getHeaders()['CONTENT_TYPE'] ?? '') + ); + } } private function getParsedInputFromRequest(RequestInfo $requestInfo): array { diff --git a/features/test.feature b/features/test.feature index 9ab4762..caf5581 100644 --- a/features/test.feature +++ b/features/test.feature @@ -42,6 +42,31 @@ Feature: Test this extension | key | value | | status | true | + Scenario: Test POST with raw json body via PyStringNode + When set the response to: + """ + { + "status": { + "nested": true + } + } + """ + And sending "POST" to "/" + """ + { + "status": { + "nested": true + }, + "events": [ + "created", + "updated" + ] + } + """ + Then the response should be a JSON array with the following mandatory values + | key | value | + | (jq).status.nested | true | + Scenario: Test response of POST is json When set the response to: """ From a4337fea08586b6f7f458d47bc395bb0ab2f6899 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 17 Mar 2026 13:12:20 -0300 Subject: [PATCH 3/5] refactor: switch behat context hooks to docblocks Signed-off-by: Vitor Mattos --- src/NextcloudApiContext.php | 100 +++++++++++++++++++++++++----------- 1 file changed, 71 insertions(+), 29 deletions(-) diff --git a/src/NextcloudApiContext.php b/src/NextcloudApiContext.php index 4e0a339..9d8716d 100644 --- a/src/NextcloudApiContext.php +++ b/src/NextcloudApiContext.php @@ -5,10 +5,6 @@ use Behat\Behat\Context\Context; use Behat\Gherkin\Node\PyStringNode; use Behat\Gherkin\Node\TableNode; -use Behat\Hook\AfterScenario; -use Behat\Hook\BeforeScenario; -use Behat\Hook\BeforeSuite; -use Behat\Step\Given; use Behat\Testwork\Hook\Scope\BeforeSuiteScope; use DOMDocument; use Exception; @@ -57,7 +53,9 @@ public function __construct(?array $parameters = []) { } } - #[BeforeSuite()] + /** + * @BeforeSuite + */ public static function beforeSuite(BeforeSuiteScope $scope):void { $whoami = (string) exec('whoami'); if (get_current_user() !== $whoami) { @@ -70,18 +68,24 @@ public static function beforeSuite(BeforeSuiteScope $scope):void { } } - #[BeforeScenario()] + /** + * @BeforeScenario + */ public static function beforeScenario(): void { self::$createdUsers = []; self::$environments = []; } - #[Given('as user :user')] + /** + * @Given as user :user + */ public function setCurrentUser(string $user): void { $this->currentUser = $user; } - #[Given('user :user exists')] + /** + * @Given user :user exists + */ public function assureUserExists(string $user): void { $response = $this->userExists($user); if ($response->getStatusCode() !== 200) { @@ -94,7 +98,9 @@ public function assureUserExists(string $user): void { } } - #[Given('guest :guest exists')] + /** + * @Given guest :guest exists + */ public function assureGuestExists(string $guest): void { $response = $this->userExists($guest); if ($response->getStatusCode() !== 200) { @@ -134,7 +140,9 @@ protected function createUser(string $user): void { $this->setCurrentUser($currentUser); } - #[Given('/^set the display name of user "([^"]*)" to "([^"]*)"$/')] + /** + * @Given /^set the display name of user "([^"]*)" to "([^"]*)"$/ + */ public function setUserDisplayName(string $user, ?string $displayName = null): void { $currentUser = $this->currentUser; $this->setCurrentUser('admin'); @@ -146,7 +154,9 @@ public function setUserDisplayName(string $user, ?string $displayName = null): v $this->setCurrentUser($currentUser); } - #[Given('/^set the email of user "([^"]*)" to "([^"]*)"$/')] + /** + * @Given /^set the email of user "([^"]*)" to "([^"]*)"$/ + */ public function setUserEmail(string $user, string $email): void { $currentUser = $this->currentUser; $this->setCurrentUser('admin'); @@ -161,8 +171,8 @@ public function setUserEmail(string $user, string $email): void { * @param string $verb * @param string $url * @param TableNode|array|null $body + * @Given sending :verb to ocs :url */ - #[Given('sending :verb to ocs :url')] public function sendOCSRequest(string $verb, string $url, $body = null, array $headers = [], array $options = []): void { $url = '/ocs/v2.php' . $url; $headers['OCS-ApiRequest'] = 'true'; @@ -174,8 +184,8 @@ public function sendOCSRequest(string $verb, string $url, $body = null, array $h * @param string $url * @param TableNode|PyStringNode|array|null $body * @param array $headers + * @Given sending :verb to :url */ - #[Given('sending :verb to :url')] public function sendRequest(string $verb, string $url, $body = null, array $headers = [], array $options = []): void { if (!str_starts_with($url, '/')) { $url = '/' . $url; @@ -263,7 +273,9 @@ private function normalizePayloadForRequest(string $verb, array $options): array return $options; } - #[Given('/^set the custom http header "([^"]*)" with "([^"]*)" as value to next request$/')] + /** + * @Given /^set the custom http header "([^"]*)" with "([^"]*)" as value to next request$/ + */ public function setTheCustomHttpHeaderAsValueToNextRequest(string $header, string $value):void { if (empty($value)) { unset($this->customHeaders[$header]); @@ -313,7 +325,9 @@ protected function assertStatusCode(ResponseInterface $response, int $statusCode /** * @throws \InvalidArgumentException */ - #[Given('the response should have a status code :code')] + /** + * @Given the response should have a status code :code + */ public function theResponseShouldHaveStatusCode(string $code): void { $currentCode = $this->response->getStatusCode(); Assert::assertEquals($code, $currentCode, $this->response->getBody()->getContents()); @@ -322,7 +336,9 @@ public function theResponseShouldHaveStatusCode(string $code): void { /** * @throws \InvalidArgumentException */ - #[Given('the response should be a JSON array with the following mandatory values')] + /** + * @Given the response should be a JSON array with the following mandatory values + */ public function theResponseShouldBeAJsonArrayWithTheFollowingMandatoryValues(TableNode $table): void { $this->response->getBody()->seek(0); $expectedValues = $table->getColumnsHash(); @@ -396,7 +412,9 @@ private function validateAsJsonQuery(string $expected, string $actual): void { Assert::assertTrue($result, 'The jq "' . $expected . '" do not match with: ' . $actual); } - #[Given('fetch field :path from previous JSON response')] + /** + * @Given fetch field :path from previous JSON response + */ public function fetchFieldFromPreviousJsonResponse(string $path): void { $this->response->getBody()->seek(0); $body = $this->response->getBody()->getContents(); @@ -427,7 +445,9 @@ public function fetchFieldFromPreviousJsonResponse(string $path): void { $this->fields[$path] = $value; } - #[Given('the response should contain the initial state :name with the following values:')] + /** + * @Given the response should contain the initial state :name with the following values: + */ public function theResponseShouldContainTheInitialStateWithTheFollowingValues(string $name, PyStringNode $expected): void { $this->response->getBody()->seek(0); $html = $this->response->getBody()->getContents(); @@ -456,7 +476,9 @@ public function theResponseShouldContainTheInitialStateWithTheFollowingValues(st } } - #[Given('the response should contain the initial state :name json that match with:')] + /** + * @Given the response should contain the initial state :name json that match with: + */ public function theResponseShouldContainTheInitialStateJsonThatMatchWith(string $name, TableNode $table): void { $this->response->getBody()->seek(0); $html = $this->response->getBody()->getContents(); @@ -477,7 +499,9 @@ public function theResponseShouldContainTheInitialStateJsonThatMatchWith(string $this->jsonStringMatchWith($actual, $expectedValues); } - #[Given('the following :appId app config is set')] + /** + * @Given the following :appId app config is set + */ public function setAppConfig(string $appId, TableNode $formData): void { $currentUser = $this->currentUser; $this->setCurrentUser('admin'); @@ -526,7 +550,9 @@ protected function parseText(string $text): string { return $text; } - #[Given('/^run the command "(?P(?:[^"]|\\")*)"$/')] + /** + * @Given /^run the command "(?P(?:[^"]|\\")*)"$/ + */ public static function runCommand(string $command): array { $console = static::findParentDirContainingFile('console.php'); $console .= '/console.php'; @@ -596,47 +622,63 @@ private static function runBashCommand(string $command): array { ]; } - #[Given('the output of the last command should contain the following text:')] + /** + * @Given the output of the last command should contain the following text: + */ public static function theOutputOfTheLastCommandContains(PyStringNode $text): void { Assert::assertStringContainsString((string) $text, self::$commandOutput, 'The output of the last command does not contain: ' . (string) $text); } - #[Given('the output of the last command should be empty')] + /** + * @Given the output of the last command should be empty + */ public static function theOutputOfTheLastCommandShouldBeEmpty(): void { Assert::assertEmpty(self::$commandOutput, 'The output of the last command should be empty, but got: ' . self::$commandOutput); } - #[Given('/^run the command "(?P(?:[^"]|\\")*)" with result code (\d+)$/')] + /** + * @Given /^run the command "(?P(?:[^"]|\\")*)" with result code (\d+)$/ + */ public static function runCommandWithResultCode(string $command, int $resultCode = 0): void { $return = self::runCommand($command); Assert::assertEquals($resultCode, $return['resultCode'], print_r($return, true)); } - #[Given('/^run the bash command "(?P(?:[^"]|\\")*)" with result code (\d+)$/')] + /** + * @Given /^run the bash command "(?P(?:[^"]|\\")*)" with result code (\d+)$/ + */ public static function runBashCommandWithResultCode(string $command, int $resultCode = 0): void { $return = self::runBashCommand($command); Assert::assertEquals($resultCode, $return['resultCode'], print_r($return, true)); } - #[Given('create an environment :name with value :value to be used by occ command')] + /** + * @Given create an environment :name with value :value to be used by occ command + */ public static function createAnEnvironmentWithValueToBeUsedByOccCommand(string $name, string $value):void { self::$environments[$name] = $value; } - #[Given('/^wait for ([0-9]+) (second|seconds)$/')] + /** + * @Given /^wait for ([0-9]+) (second|seconds)$/ + */ public function waitForXSecond(int $seconds): void { $this->startWaitFor = $seconds; sleep($seconds); } - #[Given('/^past ([0-9]+) (second|seconds) since wait step$/')] + /** + * @Given /^past ([0-9]+) (second|seconds) since wait step$/ + */ public function pastXSecondsSinceWaitStep(int $seconds): void { $currentTime = time(); $startTime = $currentTime - $this->startWaitFor; Assert::assertGreaterThanOrEqual($startTime, $currentTime, 'The current time is not greater than or equal to the start time.'); } - #[AfterScenario()] + /** + * @AfterScenario + */ public function tearDown(): void { self::$environments = []; foreach (self::$createdUsers as $user) { From 2911c81a7c9857f7deef15141484ae4c647c60c2 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 17 Mar 2026 13:12:20 -0300 Subject: [PATCH 4/5] test: align feature context with psalm 6.16 Signed-off-by: Vitor Mattos --- features/bootstrap/FeatureContext.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 74fa641..f4e1deb 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -2,7 +2,6 @@ use Behat\Gherkin\Node\PyStringNode; use Behat\Gherkin\Node\TableNode; -use Behat\Step\Given; use donatj\MockWebServer\MockWebServer; use donatj\MockWebServer\RequestInfo; use donatj\MockWebServer\Response as MockWebServerResponse; @@ -23,8 +22,8 @@ public function __construct(?array $parameters = []) { /** * @inheritDoc + * @psalm-suppress MissingOverrideAttribute */ - #[\Override] public function setCurrentUser(string $user): void { parent::setCurrentUser($user); Assert::assertEquals($this->currentUser, $user); @@ -32,8 +31,8 @@ public function setCurrentUser(string $user): void { /** * @inheritDoc + * @psalm-suppress MissingOverrideAttribute */ - #[\Override] public function assureUserExists(string $user): void { parent::assureUserExists($user); $lastRequest = $this->getLastREquest(); @@ -58,16 +57,17 @@ private function getLastRequest(): RequestInfo { * When whe run the test suit of this repository at GitHub Actions, is * necessary to consider that we haven't Nextcloud installed and mock * the real path of files. + * @psalm-suppress MissingOverrideAttribute */ - #[\Override] public static function findParentDirContainingFile(string $filename): string { return __DIR__; } /** * @inheritDoc + * @param TableNode|PyStringNode|array|null $body + * @psalm-suppress MissingOverrideAttribute */ - #[\Override] public function sendRequest(string $verb, string $url, $body = null, array $headers = [], array $options = []): void { parent::sendRequest($verb, $url, $body, $headers, $options); $lastRequest = $this->getLastRequest(); @@ -131,7 +131,9 @@ private function hasNestedPayload(array $payload): bool { return false; } - #[Given('set the response to:')] + /** + * @Given set the response to: + */ public function setTheResponseTo(PyStringNode $response): void { // Mock response to be equal to body of request $this->mockServer->setDefaultResponse(new MockWebServerResponse( @@ -141,8 +143,8 @@ public function setTheResponseTo(PyStringNode $response): void { /** * @inheritDoc + * @psalm-suppress MissingOverrideAttribute */ - #[\Override] public function theResponseShouldBeAJsonArrayWithTheFollowingMandatoryValues(TableNode $table): void { $lastRequest = $this->getLastRequest(); $parsedInput = $this->getParsedInputFromRequest($lastRequest); From e448f9e714880c5fb6e060d86d7913fdcee05ace Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 17 Mar 2026 13:12:20 -0300 Subject: [PATCH 5/5] build: disable strict override attribute enforcement Signed-off-by: Vitor Mattos --- psalm.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/psalm.xml b/psalm.xml index 2f70544..4ef4fb0 100644 --- a/psalm.xml +++ b/psalm.xml @@ -1,6 +1,7 @@