Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ This is a PHP library (`studio-design/openapi-contract-testing`) that validates

### Laravel Integration

- **`ValidatesOpenApiSchema`** trait — Used in Laravel test cases. Provides `assertResponseMatchesOpenApiSchema()` which auto-resolves method/path from the current request and records coverage. Requires implementing `openApiSpec(): string` to specify which spec to validate against.
- **`ValidatesOpenApiSchema`** trait — Used in Laravel test cases. Provides `assertResponseMatchesOpenApiSchema()` which auto-resolves method/path from the current request and records coverage. The default spec name is read from `config('openapi-contract-testing.default_spec')`; override `openApiSpec(): string` per-test-class if needed.
- **`OpenApiContractTestingServiceProvider`** — Auto-discovered service provider that registers and publishes the `openapi-contract-testing` config file (`default_spec` key).

### Key Enums

Expand Down
7 changes: 7 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@
"dealerdirect/phpcodesniffer-composer-installer": true
}
},
"extra": {
"laravel": {
"providers": [
"Studio\\OpenApiContractTesting\\Laravel\\OpenApiContractTestingServiceProvider"
]
}
},
"minimum-stability": "stable",
"prefer-stable": true
}
10 changes: 8 additions & 2 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,22 @@ parameters:
tmpDir: .phpstan.cache
ignoreErrors:
-
message: '#Function app not found#'
message: '#Function (app|config) not found#'
path: src/Laravel/ValidatesOpenApiSchema.php
-
message: '#Call to an undefined method Illuminate\\Testing\\TestResponse::#'
path: src/Laravel/ValidatesOpenApiSchema.php
-
message: '#Function config_path not found#'
path: src/Laravel/OpenApiContractTestingServiceProvider.php
-
message: '#does not specify its types: TResponse#'
paths:
- src/Laravel/ValidatesOpenApiSchema.php
- tests/Unit/ValidatesOpenApiSchemaTest.php
- tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php
-
message: '#expects TResponse of Symfony\\Component\\HttpFoundation\\Response#'
path: tests/Unit/ValidatesOpenApiSchemaTest.php
paths:
- tests/Unit/ValidatesOpenApiSchemaTest.php
- tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php
22 changes: 22 additions & 0 deletions src/Laravel/OpenApiContractTestingServiceProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Studio\OpenApiContractTesting\Laravel;

use Illuminate\Support\ServiceProvider;

class OpenApiContractTestingServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/config.php', 'openapi-contract-testing');
}

public function boot(): void
{
$this->publishes([
__DIR__ . '/config.php' => config_path('openapi-contract-testing.php'),
], 'openapi-contract-testing');
}
}
19 changes: 17 additions & 2 deletions src/Laravel/ValidatesOpenApiSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,20 @@
use Studio\OpenApiContractTesting\OpenApiCoverageTracker;
use Studio\OpenApiContractTesting\OpenApiResponseValidator;

use function is_string;

trait ValidatesOpenApiSchema
{
abstract protected function openApiSpec(): string;
protected function openApiSpec(): string
{
$spec = config('openapi-contract-testing.default_spec');

if (!is_string($spec) || $spec === '') {
return '';
}

return $spec;
}

protected function assertResponseMatchesOpenApiSchema(
TestResponse $response,
Expand All @@ -20,7 +31,11 @@ protected function assertResponseMatchesOpenApiSchema(
): void {
$specName = $this->openApiSpec();
if ($specName === '') {
$this->fail('openApiSpec() must return a non-empty spec name, but an empty string was returned.');
$this->fail(
'openApiSpec() must return a non-empty spec name, but an empty string was returned. '
. 'Either override openApiSpec() in your test class, or set the "default_spec" key '
. 'in config/openapi-contract-testing.php.',
);
}

$resolvedMethod = $method !== null ? $method->value : app('request')->getMethod();
Expand Down
7 changes: 7 additions & 0 deletions src/Laravel/config.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

declare(strict_types=1);

return [
'default_spec' => '',
];
32 changes: 32 additions & 0 deletions tests/Helpers/CreatesTestResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace Studio\OpenApiContractTesting\Tests\Helpers;

use Illuminate\Testing\TestResponse;

trait CreatesTestResponse
{
private function makeTestResponse(string $content, int $statusCode): TestResponse
{
$baseResponse = new class ($content, $statusCode) {
public function __construct(
private readonly string $content,
private readonly int $statusCode,
) {}

public function getContent(): string
{
return $this->content;
}

public function getStatusCode(): int
{
return $this->statusCode;
}
};

return new TestResponse($baseResponse);
}
}
29 changes: 29 additions & 0 deletions tests/Helpers/LaravelConfigMock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Studio\OpenApiContractTesting\Laravel;

/**
* Namespace-level config() mock for unit testing.
*
* This library does not depend on laravel/framework, so the global \config()
* helper is unavailable during unit tests. This namespaced function acts as a
* lightweight substitute.
*
* PHP resolves unqualified function calls by checking the current namespace first,
* then falling back to the global namespace. Because the ValidatesOpenApiSchema trait
* lives in Studio\OpenApiContractTesting\Laravel and calls config() without a leading
* backslash, this function takes priority over any global \config() that might exist
* at runtime.
*
* IMPORTANT: This relies on config() being called as an unqualified function
* in ValidatesOpenApiSchema.php (i.e., no "use function config" import).
* Adding such an import would bypass namespace resolution and break this mock.
*
* Test values are read from $GLOBALS['__openapi_testing_config'].
*/
function config(string $key, mixed $default = null): mixed
{
return $GLOBALS['__openapi_testing_config'][$key] ?? $default;
}
103 changes: 103 additions & 0 deletions tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

declare(strict_types=1);

namespace Studio\OpenApiContractTesting\Tests\Unit;

use const JSON_THROW_ON_ERROR;

use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Studio\OpenApiContractTesting\HttpMethod;
use Studio\OpenApiContractTesting\Laravel\ValidatesOpenApiSchema;
use Studio\OpenApiContractTesting\OpenApiCoverageTracker;
use Studio\OpenApiContractTesting\OpenApiSpecLoader;
use Studio\OpenApiContractTesting\Tests\Helpers\CreatesTestResponse;

use function json_encode;

// Load namespace-level config() mock before the trait resolves the function call.
require_once __DIR__ . '/../Helpers/LaravelConfigMock.php';

class ValidatesOpenApiSchemaDefaultSpecTest extends TestCase
{
use CreatesTestResponse;
use ValidatesOpenApiSchema;

protected function setUp(): void
{
parent::setUp();
OpenApiSpecLoader::reset();
OpenApiSpecLoader::configure(__DIR__ . '/../fixtures/specs');
OpenApiCoverageTracker::reset();
$GLOBALS['__openapi_testing_config'] = [];
}

protected function tearDown(): void
{
unset($GLOBALS['__openapi_testing_config']);
OpenApiSpecLoader::reset();
OpenApiCoverageTracker::reset();
parent::tearDown();
}

#[Test]
public function default_open_api_spec_returns_empty_string_when_config_not_set(): void
{
$this->assertSame('', $this->openApiSpec());
}

#[Test]
public function default_open_api_spec_returns_config_value(): void
{
$GLOBALS['__openapi_testing_config']['openapi-contract-testing.default_spec'] = 'petstore-3.0';

$this->assertSame('petstore-3.0', $this->openApiSpec());
}

#[Test]
public function null_config_value_returns_empty_string(): void
{
$GLOBALS['__openapi_testing_config']['openapi-contract-testing.default_spec'] = null;

$this->assertSame('', $this->openApiSpec());
}

#[Test]
public function empty_config_default_spec_fails_with_clear_message(): void
{
$response = $this->makeTestResponse('{}', 200);

$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessage(
'openApiSpec() must return a non-empty spec name, but an empty string was returned. '
. 'Either override openApiSpec() in your test class, or set the "default_spec" key '
. 'in config/openapi-contract-testing.php.',
);

$this->assertResponseMatchesOpenApiSchema(
$response,
HttpMethod::GET,
'/v1/pets',
);
}

#[Test]
public function configured_default_spec_validates_successfully(): void
{
$GLOBALS['__openapi_testing_config']['openapi-contract-testing.default_spec'] = 'petstore-3.0';

$body = (string) json_encode(
['data' => [['id' => 1, 'name' => 'Fido', 'tag' => null]]],
JSON_THROW_ON_ERROR,
);
$response = $this->makeTestResponse($body, 200);

$this->assertResponseMatchesOpenApiSchema(
$response,
HttpMethod::GET,
'/v1/pets',
);
}
}
25 changes: 2 additions & 23 deletions tests/Unit/ValidatesOpenApiSchemaTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,20 @@

use const JSON_THROW_ON_ERROR;

use Illuminate\Testing\TestResponse;
use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Studio\OpenApiContractTesting\HttpMethod;
use Studio\OpenApiContractTesting\Laravel\ValidatesOpenApiSchema;
use Studio\OpenApiContractTesting\OpenApiCoverageTracker;
use Studio\OpenApiContractTesting\OpenApiSpecLoader;
use Studio\OpenApiContractTesting\Tests\Helpers\CreatesTestResponse;

use function json_encode;

class ValidatesOpenApiSchemaTest extends TestCase
{
use CreatesTestResponse;
use ValidatesOpenApiSchema;

protected function setUp(): void
Expand Down Expand Up @@ -146,26 +147,4 @@ protected function openApiSpec(): string
{
return 'petstore-3.0';
}

private function makeTestResponse(string $content, int $statusCode): TestResponse
{
$baseResponse = new class ($content, $statusCode) {
public function __construct(
private readonly string $content,
private readonly int $statusCode,
) {}

public function getContent(): string
{
return $this->content;
}

public function getStatusCode(): int
{
return $this->statusCode;
}
};

return new TestResponse($baseResponse);
}
}