Fix tests
This commit is contained in:
parent
c845868f41
commit
0229077954
|
|
@ -131,7 +131,7 @@ Phred supports REST and JSON:API via env setting; batteries-included defaults, s
|
|||
* Establish testing structure with Codeception (unit, integration, API suites).
|
||||
* Add fixtures/factories via Faker for examples.
|
||||
* PHPStan level selection and baseline; code style via php-cs-fixer ruleset.
|
||||
* Pre‑commit hooks (e.g., GrumPHP) optional.
|
||||
* Pre‑commit hooks (e.g., GrumPHP) or custom git hooks for staged files.
|
||||
* Define TestRunnerInterface and a Codeception adapter; otherwise, state tests are run via Composer script only.
|
||||
* Acceptance:
|
||||
* `composer test` runs green across suites; static analysis passes.
|
||||
|
|
|
|||
|
|
@ -1,57 +1,5 @@
|
|||
{
|
||||
"name": "getphred/phred",
|
||||
"description": "Phred Framework",
|
||||
"type": "project",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"crell/api-problem": "^3.7",
|
||||
"filp/whoops": "^2.15",
|
||||
"getphred/eyrie": "dev-main",
|
||||
"getphred/flagpole": "dev-main",
|
||||
"getphred/pairity": "dev-main",
|
||||
"laravel/serializable-closure": "^1.3",
|
||||
"lcobucci/jwt": "^5.2",
|
||||
"league/flysystem": "^3.24",
|
||||
"middlewares/cors": "^0.4.0",
|
||||
"monolog/monolog": "^3.5",
|
||||
"nyholm/psr7": "^1.8",
|
||||
"nyholm/psr7-server": "^1.1",
|
||||
"php-di/php-di": "^7.0",
|
||||
"relay/relay": "^2.1",
|
||||
"symfony/console": "^7.0",
|
||||
"vlucas/phpdotenv": "^5.6",
|
||||
"zircote/swagger-php": "^4.8"
|
||||
},
|
||||
"require-dev": {
|
||||
"codeception/codeception": "^5.1",
|
||||
"codeception/module-asserts": "^3.0",
|
||||
"codeception/module-phpbrowser": "^3.0",
|
||||
"codeception/module-rest": "^3.3",
|
||||
"fakerphp/faker": "^1.23",
|
||||
"phpstan/phpstan": "^1.10",
|
||||
"phpunit/phpunit": "^10.5"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Phred\\": "src/",
|
||||
"App\\": "app/",
|
||||
"Modules\\": "modules/",
|
||||
"Pairity\\": "vendor/getphred/pairity/src/",
|
||||
"Project\\Modules\\Blog\\": "modules/Blog/",
|
||||
"Project\\Modules\\StubTest\\": "modules/StubTest/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Phred\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"bin": [
|
||||
"phred"
|
||||
],
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"php-http/discovery": true
|
||||
}
|
||||
"psr-4": []
|
||||
}
|
||||
}
|
||||
|
|
@ -58,13 +58,32 @@ final class Kernel
|
|||
$corsSettings->enableAllMethodsAllowed();
|
||||
$corsSettings->enableAllHeadersAllowed();
|
||||
|
||||
$middleware = [
|
||||
$middleware = [];
|
||||
if (filter_var($config->get('APP_DEBUG', false), FILTER_VALIDATE_BOOLEAN)) {
|
||||
$middleware[] = new class extends Middleware\Middleware {
|
||||
public function process(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Server\RequestHandlerInterface $handler): \Psr\Http\Message\ResponseInterface
|
||||
{
|
||||
self::$timings = []; // Reset timings for each request in debug mode
|
||||
$response = $handler->handle($request);
|
||||
$timings = self::getTimings();
|
||||
if (!empty($timings)) {
|
||||
$encoded = json_encode($timings, JSON_UNESCAPED_SLASHES);
|
||||
if ($encoded) {
|
||||
$response = $response->withHeader('X-Phred-Timings', $encoded);
|
||||
}
|
||||
}
|
||||
return $response;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
$middleware = array_merge($middleware, [
|
||||
// Security headers
|
||||
new Middleware\Security\SecureHeadersMiddleware($config),
|
||||
// CORS
|
||||
new \Middlewares\Cors(\Neomerx\Cors\Analyzer::instance($corsSettings)),
|
||||
new Middleware\ProblemDetailsMiddleware(
|
||||
$config->get('APP_DEBUG', 'false') === 'true',
|
||||
filter_var($config->get('APP_DEBUG', 'false'), FILTER_VALIDATE_BOOLEAN),
|
||||
null,
|
||||
null,
|
||||
filter_var($config->get('API_PROBLEM_DETAILS', 'true'), FILTER_VALIDATE_BOOLEAN)
|
||||
|
|
@ -74,7 +93,8 @@ final class Kernel
|
|||
new Middleware\ContentNegotiationMiddleware(),
|
||||
new Middleware\RoutingMiddleware($this->dispatcher, $psr17),
|
||||
new Middleware\DispatchMiddleware($psr17),
|
||||
];
|
||||
]);
|
||||
|
||||
$relay = new Relay($middleware);
|
||||
return $relay->handle($request);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class ContentNegotiationMiddleware extends Middleware
|
|||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$format = $this->profileSelf(function () use ($request) {
|
||||
$cfg = $this->config ?? new DefaultConfig();
|
||||
$format = strtolower((string) $cfg->get('API_FORMAT', $cfg->get('api.format', 'rest')));
|
||||
|
||||
|
|
@ -31,6 +32,8 @@ class ContentNegotiationMiddleware extends Middleware
|
|||
if ($neg->apiFormat($request) === 'jsonapi') {
|
||||
$format = 'jsonapi';
|
||||
}
|
||||
return $format;
|
||||
});
|
||||
|
||||
return $handler->handle($request->withAttribute(self::ATTR_API_FORMAT, $format));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,48 @@ use Psr\Http\Server\RequestHandlerInterface;
|
|||
|
||||
abstract class Middleware implements MiddlewareInterface
|
||||
{
|
||||
/** @var array<string, float> */
|
||||
protected static array $timings = [];
|
||||
|
||||
abstract public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface;
|
||||
|
||||
/**
|
||||
* Wrap a handler and measure its execution time.
|
||||
*/
|
||||
protected function profile(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$start = microtime(true);
|
||||
try {
|
||||
return $handler->handle($request);
|
||||
} finally {
|
||||
$duration = microtime(true) - $start;
|
||||
self::$timings[static::class] = (self::$timings[static::class] ?? 0) + $duration;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple profiler for middleware that don't need to wrap the handler.
|
||||
*/
|
||||
protected function profileSelf(callable $callback): mixed
|
||||
{
|
||||
$start = microtime(true);
|
||||
try {
|
||||
return $callback();
|
||||
} finally {
|
||||
$duration = microtime(true) - $start;
|
||||
self::$timings[static::class] = (self::$timings[static::class] ?? 0) + $duration;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all recorded timings.
|
||||
* @return array<string, float>
|
||||
*/
|
||||
public static function getTimings(): array
|
||||
{
|
||||
return self::$timings;
|
||||
}
|
||||
|
||||
protected function json(array $data, int $status = 200): ResponseInterface
|
||||
{
|
||||
$response = new \Nyholm\Psr7\Response($status, ['Content-Type' => 'application/json']);
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ class ProblemDetailsMiddleware extends Middleware
|
|||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
try {
|
||||
return $handler->handle($request);
|
||||
return $this->profile($request, $handler);
|
||||
} catch (Throwable $e) {
|
||||
$useProblem = $this->shouldUseProblemDetails();
|
||||
$format = $this->determineApiFormat($request);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ final class SecureHeadersMiddleware extends Middleware
|
|||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$response = $handler->handle($request);
|
||||
$response = $this->profile($request, $handler);
|
||||
|
||||
// Standard security headers
|
||||
$response = $response->withHeader('X-Content-Type-Options', 'nosniff')
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ final class DefaultErrorFormatNegotiator implements ErrorFormatNegotiatorInterfa
|
|||
public function wantsHtml(ServerRequest $request): bool
|
||||
{
|
||||
$accept = $request->getHeaderLine('Accept');
|
||||
return $accept === '' || str_contains($accept, 'text/html');
|
||||
// Only return true if text/html is explicitly mentioned and is likely the preferred format
|
||||
return str_contains($accept, 'text/html');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,4 +90,12 @@ final class Config
|
|||
// non-dotted: try exact file key
|
||||
return self::$store[$key] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the config store (useful for tests).
|
||||
*/
|
||||
public static function clear(): void
|
||||
{
|
||||
self::$store = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
67
src/commands/create_command.php
Normal file
67
src/commands/create_command.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phred\Console\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface as Input;
|
||||
use Symfony\Component\Console\Output\OutputInterface as Output;
|
||||
|
||||
return new class extends Command {
|
||||
protected string $command = 'create:command';
|
||||
protected string $description = 'Scaffold a new CLI command in console/commands.';
|
||||
protected array $options = [
|
||||
'name' => [
|
||||
'mode' => 'argument',
|
||||
'required' => true,
|
||||
'description' => 'Command name (e.g., hello:world)',
|
||||
],
|
||||
'--description' => [
|
||||
'mode' => 'option',
|
||||
'valueRequired' => true,
|
||||
'description' => 'Optional command description.',
|
||||
],
|
||||
];
|
||||
|
||||
public function handle(Input $input, Output $output): int
|
||||
{
|
||||
$name = trim((string) $input->getArgument('name'));
|
||||
$description = $input->getOption('description') ?: 'Custom CLI command.';
|
||||
|
||||
if ($name === '') {
|
||||
$output->writeln('<error>Command name is required.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$root = getcwd();
|
||||
$commandsDir = $root . '/console/commands';
|
||||
|
||||
if (!is_dir($commandsDir)) {
|
||||
@mkdir($commandsDir, 0777, true);
|
||||
}
|
||||
|
||||
// Convert name to StudlyCase for filename, e.g., hello:world -> HelloWorld.php
|
||||
$filename = str_replace([':', '-', '_'], ' ', $name);
|
||||
$filename = str_replace(' ', '', ucwords($filename)) . '.php';
|
||||
$path = $commandsDir . '/' . $filename;
|
||||
|
||||
if (file_exists($path)) {
|
||||
$output->writeln("<error>Command file '$filename' already exists.</error>");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$stub = file_get_contents(dirname(__DIR__) . '/stubs/command.stub');
|
||||
$code = strtr($stub, [
|
||||
'{{namespace}}' => '', // Global namespace for console/commands by default or project specific?
|
||||
// bin/phred uses anonymous class require, so namespace is optional but good for structure.
|
||||
'{{command}}' => $name,
|
||||
'{{description}}' => $description,
|
||||
]);
|
||||
|
||||
// Remove empty namespace line if present
|
||||
$code = str_replace("namespace ;\n\n", "", $code);
|
||||
|
||||
file_put_contents($path, $code);
|
||||
$output->writeln("<info>created</info> console/commands/$filename");
|
||||
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
23
src/stubs/command.stub
Normal file
23
src/stubs/command.stub
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {{namespace}};
|
||||
|
||||
use Phred\Console\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface as Input;
|
||||
use Symfony\Component\Console\Output\OutputInterface as Output;
|
||||
|
||||
return new class extends Command {
|
||||
protected string $command = '{{command}}';
|
||||
protected string $description = '{{description}}';
|
||||
protected array $options = [
|
||||
// 'name' => ['mode' => 'argument', 'required' => true, 'description' => '...'],
|
||||
// '--force' => ['mode' => 'flag', 'description' => '...'],
|
||||
];
|
||||
|
||||
public function handle(Input $input, Output $output): int
|
||||
{
|
||||
$output->writeln('<info>{{command}}</info> works!');
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
|
@ -32,4 +32,20 @@ class SecurityTest extends TestCase
|
|||
|
||||
$this->assertEquals('http://example.com', $response->getHeaderLine('Access-Control-Allow-Origin'));
|
||||
}
|
||||
|
||||
public function testProfilingHeadersPresentInDebug(): void
|
||||
{
|
||||
putenv('APP_DEBUG=true');
|
||||
$_ENV['APP_DEBUG'] = 'true';
|
||||
$_SERVER['APP_DEBUG'] = 'true';
|
||||
\Phred\Support\Config::clear();
|
||||
$kernel = new Kernel();
|
||||
$request = new ServerRequest('GET', '/_phred/health');
|
||||
|
||||
$response = $kernel->handle($request);
|
||||
|
||||
$this->assertTrue($response->hasHeader('X-Phred-Timings'));
|
||||
$timings = json_decode($response->getHeaderLine('X-Phred-Timings'), true);
|
||||
$this->assertIsArray($timings);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
65
tests/Feature/StubCompilationTest.php
Normal file
65
tests/Feature/StubCompilationTest.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Tests\Feature;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class StubCompilationTest extends TestCase
|
||||
{
|
||||
public function testStubsCompile(): void
|
||||
{
|
||||
$stubRoot = dirname(__DIR__, 2) . '/src/stubs';
|
||||
$stubs = $this->findStubs($stubRoot);
|
||||
|
||||
foreach ($stubs as $stubPath) {
|
||||
$content = file_get_contents($stubPath);
|
||||
$compiled = $this->interpolateStub($content);
|
||||
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'stub_test_');
|
||||
file_put_contents($tmpFile, $compiled);
|
||||
|
||||
$output = [];
|
||||
$returnVar = 0;
|
||||
exec("php -l " . escapeshellarg($tmpFile) . " 2>&1", $output, $returnVar);
|
||||
|
||||
$this->assertSame(0, $returnVar, "Syntax error in stub: $stubPath\n" . implode("\n", $output));
|
||||
|
||||
unlink($tmpFile);
|
||||
}
|
||||
}
|
||||
|
||||
private function findStubs(string $dir): array
|
||||
{
|
||||
$files = [];
|
||||
$items = scandir($dir);
|
||||
foreach ($items as $item) {
|
||||
if ($item === '.' || $item === '..') continue;
|
||||
$path = $dir . '/' . $item;
|
||||
if (is_dir($path)) {
|
||||
$files = array_merge($files, $this->findStubs($path));
|
||||
} elseif (str_ends_with($item, '.stub')) {
|
||||
$files[] = $path;
|
||||
}
|
||||
}
|
||||
return $files;
|
||||
}
|
||||
|
||||
private function interpolateStub(string $content): string
|
||||
{
|
||||
$replacements = [
|
||||
'{{namespace}}' => 'App\\Test',
|
||||
'{{viewNamespace}}' => 'App\\Views\\TestView',
|
||||
'{{class}}' => 'TestClass',
|
||||
'{{name}}' => 'TestName',
|
||||
'{{moduleName}}' => 'TestModule',
|
||||
'{{template}}' => 'test_template',
|
||||
'{{params}}' => '$request',
|
||||
'{{body}}' => 'return $request;',
|
||||
'{{useView}}' => 'use App\\Views\\TestView;',
|
||||
'$dollar' => '$', // For some stubs that use $dollar
|
||||
];
|
||||
|
||||
return strtr($content, $replacements);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue