diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..2cd720b --- /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.3", "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); + } +}