Fix tests

This commit is contained in:
Funky Waddle 2025-12-22 16:04:15 -06:00
parent c845868f41
commit 0229077954
13 changed files with 259 additions and 68 deletions

View file

@ -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). * Establish testing structure with Codeception (unit, integration, API suites).
* Add fixtures/factories via Faker for examples. * Add fixtures/factories via Faker for examples.
* PHPStan level selection and baseline; code style via php-cs-fixer ruleset. * PHPStan level selection and baseline; code style via php-cs-fixer ruleset.
* Precommit hooks (e.g., GrumPHP) optional. * Precommit 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. * Define TestRunnerInterface and a Codeception adapter; otherwise, state tests are run via Composer script only.
* Acceptance: * Acceptance:
* `composer test` runs green across suites; static analysis passes. * `composer test` runs green across suites; static analysis passes.

View file

@ -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": { "autoload": {
"psr-4": { "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
}
} }
} }

View file

@ -58,13 +58,32 @@ final class Kernel
$corsSettings->enableAllMethodsAllowed(); $corsSettings->enableAllMethodsAllowed();
$corsSettings->enableAllHeadersAllowed(); $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 // Security headers
new Middleware\Security\SecureHeadersMiddleware($config), new Middleware\Security\SecureHeadersMiddleware($config),
// CORS // CORS
new \Middlewares\Cors(\Neomerx\Cors\Analyzer::instance($corsSettings)), new \Middlewares\Cors(\Neomerx\Cors\Analyzer::instance($corsSettings)),
new Middleware\ProblemDetailsMiddleware( new Middleware\ProblemDetailsMiddleware(
$config->get('APP_DEBUG', 'false') === 'true', filter_var($config->get('APP_DEBUG', 'false'), FILTER_VALIDATE_BOOLEAN),
null, null,
null, null,
filter_var($config->get('API_PROBLEM_DETAILS', 'true'), FILTER_VALIDATE_BOOLEAN) filter_var($config->get('API_PROBLEM_DETAILS', 'true'), FILTER_VALIDATE_BOOLEAN)
@ -74,7 +93,8 @@ final class Kernel
new Middleware\ContentNegotiationMiddleware(), new Middleware\ContentNegotiationMiddleware(),
new Middleware\RoutingMiddleware($this->dispatcher, $psr17), new Middleware\RoutingMiddleware($this->dispatcher, $psr17),
new Middleware\DispatchMiddleware($psr17), new Middleware\DispatchMiddleware($psr17),
]; ]);
$relay = new Relay($middleware); $relay = new Relay($middleware);
return $relay->handle($request); return $relay->handle($request);
} }

View file

@ -23,14 +23,17 @@ class ContentNegotiationMiddleware extends Middleware
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{ {
$cfg = $this->config ?? new DefaultConfig(); $format = $this->profileSelf(function () use ($request) {
$format = strtolower((string) $cfg->get('API_FORMAT', $cfg->get('api.format', 'rest'))); $cfg = $this->config ?? new DefaultConfig();
$format = strtolower((string) $cfg->get('API_FORMAT', $cfg->get('api.format', 'rest')));
// Optional: allow Accept header to override when JSON:API is explicitly requested // Optional: allow Accept header to override when JSON:API is explicitly requested
$neg = $this->negotiator ?? new DefaultErrorFormatNegotiator(); $neg = $this->negotiator ?? new DefaultErrorFormatNegotiator();
if ($neg->apiFormat($request) === 'jsonapi') { if ($neg->apiFormat($request) === 'jsonapi') {
$format = 'jsonapi'; $format = 'jsonapi';
} }
return $format;
});
return $handler->handle($request->withAttribute(self::ATTR_API_FORMAT, $format)); return $handler->handle($request->withAttribute(self::ATTR_API_FORMAT, $format));
} }

View file

@ -10,8 +10,48 @@ use Psr\Http\Server\RequestHandlerInterface;
abstract class Middleware implements MiddlewareInterface abstract class Middleware implements MiddlewareInterface
{ {
/** @var array<string, float> */
protected static array $timings = [];
abstract public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface; 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 protected function json(array $data, int $status = 200): ResponseInterface
{ {
$response = new \Nyholm\Psr7\Response($status, ['Content-Type' => 'application/json']); $response = new \Nyholm\Psr7\Response($status, ['Content-Type' => 'application/json']);

View file

@ -36,7 +36,7 @@ class ProblemDetailsMiddleware extends Middleware
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{ {
try { try {
return $handler->handle($request); return $this->profile($request, $handler);
} catch (Throwable $e) { } catch (Throwable $e) {
$useProblem = $this->shouldUseProblemDetails(); $useProblem = $this->shouldUseProblemDetails();
$format = $this->determineApiFormat($request); $format = $this->determineApiFormat($request);

View file

@ -20,7 +20,7 @@ final class SecureHeadersMiddleware extends Middleware
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{ {
$response = $handler->handle($request); $response = $this->profile($request, $handler);
// Standard security headers // Standard security headers
$response = $response->withHeader('X-Content-Type-Options', 'nosniff') $response = $response->withHeader('X-Content-Type-Options', 'nosniff')

View file

@ -17,6 +17,7 @@ final class DefaultErrorFormatNegotiator implements ErrorFormatNegotiatorInterfa
public function wantsHtml(ServerRequest $request): bool public function wantsHtml(ServerRequest $request): bool
{ {
$accept = $request->getHeaderLine('Accept'); $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');
} }
} }

View file

@ -90,4 +90,12 @@ final class Config
// non-dotted: try exact file key // non-dotted: try exact file key
return self::$store[$key] ?? null; return self::$store[$key] ?? null;
} }
/**
* Clear the config store (useful for tests).
*/
public static function clear(): void
{
self::$store = null;
}
} }

View 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
View 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;
}
};

View file

@ -32,4 +32,20 @@ class SecurityTest extends TestCase
$this->assertEquals('http://example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); $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);
}
} }

View 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);
}
}