From 50d71fd077869aaae4bf09e2745be4cdfaa0a92c Mon Sep 17 00:00:00 2001 From: Tobias Munk Date: Sun, 14 Jun 2026 02:44:23 +0200 Subject: [PATCH 1/2] :white_check_mark: test: OperationInputSchemaResolver + JsonSchema Unit-Tests + CLI-Compose-Runner + GitHub-Action [*] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit-Tests (PHPUnit 12) für die öffentlichen Kern-Klassen, plus je Paket ein CLI-only docker-compose-Runner (composer install + vendor/bin/phpunit), ein GitHub-Actions-Workflow und .gitignore. phpunit ^12 in require-dev. OperationInputSchemaResolverTest aus der App-Test-Suite hierher übernommen (kanonischer Ort). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/tests.yml | 24 +++ .gitignore | 4 + composer.json | 3 + docker-compose.yml | 15 ++ phpunit.dist.xml | 18 ++ tests/Attribute/JsonSchemaTest.php | 39 +++++ .../OperationInputSchemaResolverTest.php | 162 ++++++++++++++++++ 7 files changed, 265 insertions(+) create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 docker-compose.yml create mode 100644 phpunit.dist.xml create mode 100644 tests/Attribute/JsonSchemaTest.php create mode 100644 tests/Service/OperationInputSchemaResolverTest.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..afd9a6f --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,24 @@ +# file generated with AI assistance: Claude Code - 2026-06-13 23:14:54 UTC +name: Tests + +on: + push: + branches: [main, master, "feature/**"] + pull_request: + +jobs: + phpunit: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ["8.2", "8.4"] + name: PHPUnit (PHP ${{ matrix.php }}) + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + - run: composer install --no-interaction --no-progress + - run: vendor/bin/phpunit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cff2af8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# file generated with AI assistance: Claude Code - 2026-06-13 23:14:54 UTC +/vendor/ +/composer.lock +/.phpunit.cache/ diff --git a/composer.json b/composer.json index a7db8a4..2330e13 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,9 @@ "psr/log": "^3.0", "psr/cache": "^3.0" }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, "suggest": { "opis/json-schema": "For file-based JSON schema validation" }, diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8086794 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +# file generated with AI assistance: Claude Code - 2026-06-13 23:14:54 UTC +# CLI-only test runner for this bundle — no MySQL/FPM required. +# Usage (on host): +# docker compose run --rm php +# Runs `composer install` then PHPUnit against tests/. +services: + php: + build: + dockerfile_inline: | + FROM php:8.4-cli-alpine + COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + working_dir: /repo + volumes: + - .:/repo + command: sh -c "composer install --no-interaction --no-progress && vendor/bin/phpunit" diff --git a/phpunit.dist.xml b/phpunit.dist.xml new file mode 100644 index 0000000..9dc4142 --- /dev/null +++ b/phpunit.dist.xml @@ -0,0 +1,18 @@ + + + + + + tests + + + + + src + + + diff --git a/tests/Attribute/JsonSchemaTest.php b/tests/Attribute/JsonSchemaTest.php new file mode 100644 index 0000000..3cd5e5a --- /dev/null +++ b/tests/Attribute/JsonSchemaTest.php @@ -0,0 +1,39 @@ +schemaName); + } + + public function testSchemaNameIsStored(): void + { + self::assertSame('metadata', (new JsonSchema('metadata'))->schemaName); + } + + public function testReadableAsPropertyAttributeViaReflection(): void + { + $fixture = new class { + #[JsonSchema(schemaName: 'config')] + public array $configJson = []; + }; + + $property = new \ReflectionProperty($fixture, 'configJson'); + $attributes = $property->getAttributes(JsonSchema::class); + + self::assertCount(1, $attributes); + self::assertSame('config', $attributes[0]->newInstance()->schemaName); + } +} diff --git a/tests/Service/OperationInputSchemaResolverTest.php b/tests/Service/OperationInputSchemaResolverTest.php new file mode 100644 index 0000000..3742169 --- /dev/null +++ b/tests/Service/OperationInputSchemaResolverTest.php @@ -0,0 +1,162 @@ +tempRoot = sys_get_temp_dir().'/oapi-resolver-'.uniqid('', true); + mkdir($this->tempRoot, 0o755, true); + } + + protected function tearDown(): void + { + $this->removeDir($this->tempRoot); + } + + public function testEntityLocalResolutionHits(): void + { + $entityRoot = $this->tempRoot.'/entity'; + $this->writeFile($entityRoot.'/Refs/RefProject/scan.input.json', '{}'); + + $resolver = new OperationInputSchemaResolver( + schemaBasePaths: [], + entityResolutionRoots: [[$entityRoot, 'App\\Entity']], + ); + + $resolved = $resolver->getSchemaFile('ref_project_scan'); + self::assertSame($entityRoot.'/Refs/RefProject/scan.input.json', $resolved); + } + + public function testFallbackToPathConvention(): void + { + $baseRoot = $this->tempRoot.'/schemas'; + $this->writeFile($baseRoot.'/custom-op-input.json', '{}'); + + $resolver = new OperationInputSchemaResolver( + schemaBasePaths: [$baseRoot], + entityResolutionRoots: [[$this->tempRoot.'/empty-entity', 'App\\Entity']], + ); + + $resolved = $resolver->getSchemaFile('custom_op'); + self::assertSame($baseRoot.'/custom-op-input.json', $resolved); + } + + public function testReturnsNullWhenNothingMatches(): void + { + $resolver = new OperationInputSchemaResolver( + schemaBasePaths: [$this->tempRoot.'/none'], + entityResolutionRoots: [[$this->tempRoot.'/also-none', 'App\\Entity']], + ); + + self::assertNull($resolver->getSchemaFile('nothing_to_find')); + } + + public function testEntityLocalTakesPrecedenceOverPathConvention(): void + { + $entityRoot = $this->tempRoot.'/entity'; + $baseRoot = $this->tempRoot.'/schemas'; + $this->writeFile($entityRoot.'/Refs/RefProject/scan.input.json', '{"src":"entity"}'); + $this->writeFile($baseRoot.'/ref-project-scan-input.json', '{"src":"base"}'); + + $resolver = new OperationInputSchemaResolver( + schemaBasePaths: [$baseRoot], + entityResolutionRoots: [[$entityRoot, 'App\\Entity']], + ); + + $resolved = $resolver->getSchemaFile('ref_project_scan'); + self::assertSame($entityRoot.'/Refs/RefProject/scan.input.json', $resolved); + } + + public function testCachesLookupResultAcrossCalls(): void + { + $entityRoot = $this->tempRoot.'/entity'; + $this->writeFile($entityRoot.'/Refs/RefProject/scan.input.json', '{}'); + + $resolver = new OperationInputSchemaResolver( + schemaBasePaths: [], + entityResolutionRoots: [[$entityRoot, 'App\\Entity']], + ); + + $first = $resolver->getSchemaFile('ref_project_scan'); + unlink($entityRoot.'/Refs/RefProject/scan.input.json'); + $second = $resolver->getSchemaFile('ref_project_scan'); + + self::assertSame($first, $second, 'Cached lookup must survive filesystem changes within a single resolver lifetime.'); + } + + public function testEmptyOperationNameReturnsNull(): void + { + $resolver = new OperationInputSchemaResolver(schemaBasePaths: [], entityResolutionRoots: []); + self::assertNull($resolver->getSchemaFile('')); + } + + public function testLoadSchemaReturnsDecodedArray(): void + { + $entityRoot = $this->tempRoot.'/entity'; + $this->writeFile($entityRoot.'/Refs/RefTodo/sync.input.json', '{"type":"object","required":["id"]}'); + + $resolver = new OperationInputSchemaResolver( + schemaBasePaths: [], + entityResolutionRoots: [[$entityRoot, 'App\\Entity']], + ); + + $schema = $resolver->loadSchema('ref_todo_sync'); + self::assertNotNull($schema); + self::assertSame('object', $schema['type']); + self::assertTrue($resolver->isRequired($schema)); + } + + public function testIsRequiredFalseForEmptyOrMissingRequired(): void + { + $resolver = new OperationInputSchemaResolver(schemaBasePaths: [], entityResolutionRoots: []); + + self::assertFalse($resolver->isRequired([])); + self::assertFalse($resolver->isRequired(['required' => []])); + } + + private function writeFile(string $path, string $contents): void + { + $dir = dirname($path); + if (!is_dir($dir) && !mkdir($dir, 0o755, true) && !is_dir($dir)) { + self::fail('Failed to create '.$dir); + } + file_put_contents($path, $contents); + } + + private function removeDir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + $items = scandir($dir); + if (false === $items) { + return; + } + foreach ($items as $item) { + if ('.' === $item || '..' === $item) { + continue; + } + $path = $dir.'/'.$item; + is_dir($path) ? $this->removeDir($path) : unlink($path); + } + rmdir($dir); + } +} From 3cc48a26bae9f5bae7e97fceb6f5235c0c1a71e2 Mon Sep 17 00:00:00 2001 From: Tobias Munk Date: Sun, 14 Jun 2026 03:28:21 +0200 Subject: [PATCH 2/2] =?UTF-8?q?:green=5Fheart:=20ci:=20PHP-Matrix=20auf=20?= =?UTF-8?q?8.3/8.4=20(phpunit=20^12=20ben=C3=B6tigt=20PHP=20>=3D8.3)=20[*]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8.2 fiel raus: PHPUnit 12 verlangt PHP >=8.3, daher schlug composer install im 8.2-Job fehl. Runtime-Constraint des Pakets (>=8.2) bleibt unverändert. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index afd9a6f..2cd720b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - php: ["8.2", "8.4"] + php: ["8.3", "8.4"] name: PHPUnit (PHP ${{ matrix.php }}) steps: - uses: actions/checkout@v4