feat: implement dependency injection container infrastructure (#1997)

* feat: implement dependency injection container infrastructure

- Add PHP-DI container with hexagonal architecture compliance
- Implement container factory with environment-specific configuration
- Create Bootstrap class for application initialization
- Add service definitions organized by architectural layers (Domain, Application, Infrastructure, Presentation)
- Introduce global helper functions (container(), app()) for service access
- Add comprehensive test suite for DI container components
- Configure container compilation and proxy generation for production
- Add container configuration files and environment-specific settings
- Update composer dependencies to include php-di/php-di
- Add documentation and usage examples for the DI system

This establishes the foundation for modern dependency management and
enables future implementation of clean architecture patterns throughout
the application.

* refactor(tests): update exception handling to use Throwable

- Changed exception handling in Pest.php to catch Throwable instead of Exception.
- Updated BootstrapTest.php and ContainerTest.php to expect Throwable for error handling in tests.
- Refactored tests to utilize expectException helper for cleaner syntax and improved readability.
This commit is contained in:
Yury Pikhtarev 2025-06-21 22:50:06 +04:00 committed by GitHub
commit 5b5bf49f4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 2891 additions and 3902 deletions

View file

@ -157,3 +157,14 @@ More text here.
### MD047 - Files should end with a single newline
Ensure every markdown file ends with exactly one newline character at the end of the file.
## Tests
- Need comprehensive test coverage for new components
- Utilize Pest PHP for unit and integration / feature testing
- Tests should focus on validating modern architecture components
- Create test suites for critical systems like database, cache, and configuration
## Development Guidelines
- Always ensure that there is one empty line at the end of the file

View file

@ -67,6 +67,7 @@
"nette/caching": "^3.3",
"nette/database": "^3.2",
"php-curl-class/php-curl-class": "^12.0.0",
"php-di/php-di": "^7.0",
"robmorgan/phinx": "^0.16.9",
"samdark/sitemap": "2.4.1",
"symfony/mailer": "^7.3",
@ -82,7 +83,10 @@
"autoload": {
"psr-4": {
"TorrentPier\\": "src/"
}
},
"files": [
"src/helpers.php"
]
},
"autoload-dev": {
"psr-4": {

191
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "26f36d2312e2eabf3ed5ff36391cc050",
"content-hash": "57713d8849e71683b70d934a81f7e18c",
"packages": [
{
"name": "arokettu/bencode",
@ -1879,6 +1879,67 @@
],
"time": "2024-09-11T14:15:04+00:00"
},
{
"name": "laravel/serializable-closure",
"version": "v2.0.4",
"source": {
"type": "git",
"url": "https://github.com/laravel/serializable-closure.git",
"reference": "b352cf0534aa1ae6b4d825d1e762e35d43f8a841"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b352cf0534aa1ae6b4d825d1e762e35d43f8a841",
"reference": "b352cf0534aa1ae6b4d825d1e762e35d43f8a841",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"illuminate/support": "^10.0|^11.0|^12.0",
"nesbot/carbon": "^2.67|^3.0",
"pestphp/pest": "^2.36|^3.0",
"phpstan/phpstan": "^2.0",
"symfony/var-dumper": "^6.2.0|^7.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\SerializableClosure\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
},
{
"name": "Nuno Maduro",
"email": "nuno@laravel.com"
}
],
"description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.",
"keywords": [
"closure",
"laravel",
"serializable"
],
"support": {
"issues": "https://github.com/laravel/serializable-closure/issues",
"source": "https://github.com/laravel/serializable-closure"
},
"time": "2025-03-19T13:51:03+00:00"
},
{
"name": "league/color-extractor",
"version": "0.4.0",
@ -2740,6 +2801,134 @@
},
"time": "2025-03-25T18:04:16+00:00"
},
{
"name": "php-di/invoker",
"version": "2.3.6",
"source": {
"type": "git",
"url": "https://github.com/PHP-DI/Invoker.git",
"reference": "59f15608528d8a8838d69b422a919fd6b16aa576"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHP-DI/Invoker/zipball/59f15608528d8a8838d69b422a919fd6b16aa576",
"reference": "59f15608528d8a8838d69b422a919fd6b16aa576",
"shasum": ""
},
"require": {
"php": ">=7.3",
"psr/container": "^1.0|^2.0"
},
"require-dev": {
"athletic/athletic": "~0.1.8",
"mnapoli/hard-mode": "~0.3.0",
"phpunit/phpunit": "^9.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Invoker\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Generic and extensible callable invoker",
"homepage": "https://github.com/PHP-DI/Invoker",
"keywords": [
"callable",
"dependency",
"dependency-injection",
"injection",
"invoke",
"invoker"
],
"support": {
"issues": "https://github.com/PHP-DI/Invoker/issues",
"source": "https://github.com/PHP-DI/Invoker/tree/2.3.6"
},
"funding": [
{
"url": "https://github.com/mnapoli",
"type": "github"
}
],
"time": "2025-01-17T12:49:27+00:00"
},
{
"name": "php-di/php-di",
"version": "7.0.11",
"source": {
"type": "git",
"url": "https://github.com/PHP-DI/PHP-DI.git",
"reference": "32f111a6d214564520a57831d397263e8946c1d2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHP-DI/PHP-DI/zipball/32f111a6d214564520a57831d397263e8946c1d2",
"reference": "32f111a6d214564520a57831d397263e8946c1d2",
"shasum": ""
},
"require": {
"laravel/serializable-closure": "^1.0 || ^2.0",
"php": ">=8.0",
"php-di/invoker": "^2.0",
"psr/container": "^1.1 || ^2.0"
},
"provide": {
"psr/container-implementation": "^1.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3",
"friendsofphp/proxy-manager-lts": "^1",
"mnapoli/phpunit-easymock": "^1.3",
"phpunit/phpunit": "^9.6 || ^10 || ^11",
"vimeo/psalm": "^5|^6"
},
"suggest": {
"friendsofphp/proxy-manager-lts": "Install it if you want to use lazy injection (version ^1)"
},
"type": "library",
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"DI\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "The dependency injection container for humans",
"homepage": "https://php-di.org/",
"keywords": [
"PSR-11",
"container",
"container-interop",
"dependency injection",
"di",
"ioc",
"psr11"
],
"support": {
"issues": "https://github.com/PHP-DI/PHP-DI/issues",
"source": "https://github.com/PHP-DI/PHP-DI/tree/7.0.11"
},
"funding": [
{
"url": "https://github.com/mnapoli",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/php-di/php-di",
"type": "tidelift"
}
],
"time": "2025-06-03T07:45:57+00:00"
},
{
"name": "phpoption/phpoption",
"version": "1.9.3",

26
config/container.php Normal file
View file

@ -0,0 +1,26 @@
<?php
return [
// Container configuration
'environment' => env('APP_ENV', 'development'),
'debug' => env('APP_DEBUG', false),
// Enable/disable features
'autowiring' => true,
'annotations' => false,
// Compilation settings for production
'compilation_dir' => __DIR__ . '/../internal_data/cache/container',
'proxies_dir' => __DIR__ . '/../internal_data/cache/proxies',
// Additional definition files to load
'definition_files' => [
// Add custom definition files here
// __DIR__ . '/services/custom.php',
],
// Container-specific settings
'container' => [
// Add any PHP-DI specific settings here
],
];

View file

30
config/services.php Normal file
View file

@ -0,0 +1,30 @@
<?php
use function DI\factory;
use function DI\get;
use function DI\autowire;
return [
// Add custom service definitions here as they are implemented
// Examples (uncomment and modify when implementing):
// Logger service example
// 'logger' => factory(function () {
// $logger = new \Monolog\Logger('torrentpier');
// $logger->pushHandler(new \Monolog\Handler\StreamHandler(__DIR__ . '/../internal_data/logs/app.log'));
// return $logger;
// }),
// Configuration service example
// 'config' => factory(function () {
// return [
// 'app' => require __DIR__ . '/app.php',
// 'database' => require __DIR__ . '/database.php',
// 'cache' => require __DIR__ . '/cache.php',
// ];
// }),
// Interface to implementation binding example
// 'ServiceInterface' => autowire('ConcreteService'),
];

View file

@ -0,0 +1,99 @@
<?php
/**
* Example: How to use the DI Container in TorrentPier 3.0
*
* NOTE: These are examples for future implementation following the hexagonal architecture spec.
* Most services referenced here don't exist yet and will be implemented in phases.
*/
use TorrentPier\Infrastructure\DependencyInjection\Bootstrap;
use TorrentPier\Infrastructure\DependencyInjection\ContainerFactory;
// ===== PHASE 1: Foundation (CURRENT) =====
// 1. Bootstrap the container (typically done once in index.php or bootstrap file)
$rootPath = __DIR__ . '/../..';
$container = Bootstrap::init($rootPath);
// 2. Basic container usage (works now)
$containerInstance = app(); // Get container itself
$hasService = $container->has('some.service'); // Check if service exists
// ===== PHASE 2: Domain Modeling (FUTURE) =====
// 3. Repository interfaces (when implemented in Domain layer)
// $userRepository = app('TorrentPier\Domain\User\Repository\UserRepositoryInterface');
// $torrentRepository = app('TorrentPier\Domain\Tracker\Repository\TorrentRepositoryInterface');
// $forumRepository = app('TorrentPier\Domain\Forum\Repository\ForumRepositoryInterface');
// ===== PHASE 3: Application Services (FUTURE) =====
// 4. Command/Query handlers (when implemented)
// $registerUserHandler = app('TorrentPier\Application\User\Handler\RegisterUserHandler');
// $announceHandler = app('TorrentPier\Application\Tracker\Handler\ProcessAnnounceHandler');
// $createPostHandler = app('TorrentPier\Application\Forum\Handler\CreatePostHandler');
// 5. Making command instances with parameters
// $command = $container->make('TorrentPier\Application\User\Command\RegisterUserCommand', [
// 'username' => 'john_doe',
// 'email' => 'john@example.com',
// 'password' => 'secure_password'
// ]);
// ===== PHASE 4: Infrastructure (FUTURE) =====
// 6. Database and cache (when infrastructure is implemented)
// $database = app('database.connection.default');
// $cache = app('cache.factory')('forum'); // Get cache instance for 'forum' namespace
// ===== PHASE 5: Presentation (FUTURE) =====
// 7. Controllers (when implemented)
// $userController = app('TorrentPier\Presentation\Http\Controllers\Api\UserController');
// $trackerController = app('TorrentPier\Presentation\Http\Controllers\Web\TrackerController');
// ===== TESTING EXAMPLES =====
// 8. Testing with custom container (works now)
$testContainer = ContainerFactory::create([
'definitions' => [
'test.service' => \DI\factory(function () {
return new class {
public function test() { return 'test'; }
};
}),
],
'environment' => 'testing',
]);
// 9. Safe service resolution (works now)
try {
$service = app('optional.service');
} catch (RuntimeException $e) {
// Service not found, handle gracefully
$service = null;
}
// ===== LEGACY INTEGRATION (CURRENT) =====
// 10. Integration with legacy code
// In legacy files, after including common.php or similar:
if (!Bootstrap::getContainer()) {
Bootstrap::init(BB_ROOT ?? __DIR__ . '/../..');
}
// 11. Method injection (works now if service exists)
class ExampleService
{
public function processData(string $data)
{
// Container can inject dependencies when calling this method
return "Processed: $data";
}
}
$exampleService = new ExampleService();
$result = $container->call([$exampleService, 'processData'], [
'data' => 'test data'
]);

View file

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace TorrentPier\Infrastructure\DependencyInjection;
use Dotenv\Dotenv;
class Bootstrap
{
private static ?Container $container = null;
public static function init(string $rootPath, array $config = []): Container
{
if (self::$container !== null) {
return self::$container;
}
// Load environment variables
self::loadEnvironment($rootPath);
// Merge configuration
$config = self::loadConfiguration($rootPath, $config);
// Create and configure container
self::$container = ContainerFactory::create($config);
// Register container instance with itself
self::$container->getWrappedContainer()->set(Container::class, self::$container);
self::$container->getWrappedContainer()->set('container', self::$container);
return self::$container;
}
public static function getContainer(): ?Container
{
return self::$container;
}
public static function reset(): void
{
self::$container = null;
}
private static function loadEnvironment(string $rootPath): void
{
if (file_exists($rootPath . '/.env')) {
$dotenv = Dotenv::createImmutable($rootPath);
$dotenv->load();
}
}
private static function loadConfiguration(string $rootPath, array $config): array
{
// Load base configuration
$configPath = $rootPath . '/config';
// Container configuration
if (file_exists($configPath . '/container.php')) {
$containerConfig = require $configPath . '/container.php';
$config = array_merge($config, $containerConfig);
}
// Services configuration
if (file_exists($configPath . '/services.php')) {
$servicesConfig = require $configPath . '/services.php';
$config['definitions'] = array_merge(
$config['definitions'] ?? [],
$servicesConfig
);
}
// Database configuration
if (file_exists($configPath . '/database.php')) {
$config['database'] = require $configPath . '/database.php';
}
// Cache configuration
if (file_exists($configPath . '/cache.php')) {
$config['cache'] = require $configPath . '/cache.php';
}
// Environment from .env
$config['environment'] = $_ENV['APP_ENV'] ?? 'development';
$config['debug'] = filter_var($_ENV['APP_DEBUG'] ?? false, FILTER_VALIDATE_BOOLEAN);
return $config;
}
}

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace TorrentPier\Infrastructure\DependencyInjection;
use DI\Container as DIContainer;
use DI\ContainerBuilder;
use Psr\Container\ContainerInterface;
class Container implements ContainerInterface
{
private DIContainer $container;
public function __construct(DIContainer $container)
{
$this->container = $container;
}
public function get(string $id): mixed
{
return $this->container->get($id);
}
public function has(string $id): bool
{
return $this->container->has($id);
}
public function make(string $name, array $parameters = []): mixed
{
return $this->container->make($name, $parameters);
}
public function call(callable $callable, array $parameters = []): mixed
{
return $this->container->call($callable, $parameters);
}
public function injectOn(object $instance): object
{
return $this->container->injectOn($instance);
}
public function getWrappedContainer(): DIContainer
{
return $this->container;
}
}

View file

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace TorrentPier\Infrastructure\DependencyInjection;
use DI\ContainerBuilder;
use TorrentPier\Infrastructure\DependencyInjection\Definitions\ApplicationDefinitions;
use TorrentPier\Infrastructure\DependencyInjection\Definitions\DomainDefinitions;
use TorrentPier\Infrastructure\DependencyInjection\Definitions\InfrastructureDefinitions;
use TorrentPier\Infrastructure\DependencyInjection\Definitions\PresentationDefinitions;
class ContainerFactory
{
public static function create(array $config = []): Container
{
$builder = new ContainerBuilder();
// Configure container settings
self::configureContainer($builder, $config);
// Add definitions from all layers
self::addDefinitions($builder, $config);
// Build the container
$diContainer = $builder->build();
$container = new Container($diContainer);
// Register and boot service providers
self::registerProviders($container, $config);
return $container;
}
private static function configureContainer(ContainerBuilder $builder, array $config): void
{
// Enable compilation in production for better performance
$isProduction = ($config['environment'] ?? 'development') === 'production';
if ($isProduction) {
$builder->enableCompilation($config['compilation_dir'] ?? __DIR__ . '/../../../internal_data/cache/container');
$builder->writeProxiesToFile(true, $config['proxies_dir'] ?? __DIR__ . '/../../../internal_data/cache/proxies');
}
// Enable autowiring by default
$builder->useAutowiring($config['autowiring'] ?? true);
}
private static function addDefinitions(ContainerBuilder $builder, array $config): void
{
// Add config definitions first
if (isset($config['definitions'])) {
$builder->addDefinitions($config['definitions']);
}
// Add layer-specific definitions
$builder->addDefinitions(DomainDefinitions::getDefinitions());
$builder->addDefinitions(ApplicationDefinitions::getDefinitions());
$builder->addDefinitions(InfrastructureDefinitions::getDefinitions($config));
$builder->addDefinitions(PresentationDefinitions::getDefinitions());
// Add custom definition files if provided
if (isset($config['definition_files'])) {
foreach ($config['definition_files'] as $file) {
if (file_exists($file)) {
$builder->addDefinitions($file);
}
}
}
}
private static function registerProviders(Container $container, array $config): void
{
$providers = $config['providers'] ?? [];
// Instantiate providers
$instances = [];
foreach ($providers as $providerClass) {
if (class_exists($providerClass)) {
$provider = new $providerClass();
if ($provider instanceof ServiceProvider) {
$instances[] = $provider;
$provider->register($container);
}
}
}
// Boot all providers after registration
foreach ($instances as $provider) {
$provider->boot($container);
}
}
}

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace TorrentPier\Infrastructure\DependencyInjection\Definitions;
use DI;
use Psr\Container\ContainerInterface;
class ApplicationDefinitions
{
public static function getDefinitions(): array
{
return [
// Command Bus
// 'CommandBusInterface' => DI\factory(function (ContainerInterface $c) {
// return new CommandBus($c);
// }),
// Query Bus
// 'QueryBusInterface' => DI\factory(function (ContainerInterface $c) {
// return new QueryBus($c);
// }),
// Event Dispatcher
// 'EventDispatcherInterface' => DI\factory(function (ContainerInterface $c) {
// return new EventDispatcher();
// }),
// Application Services
// These typically orchestrate domain objects and handle use cases
// Forum Handlers
// 'TorrentPier\Application\Forum\Handler\CreatePostHandler' => DI\autowire(),
// 'TorrentPier\Application\Forum\Handler\GetThreadListHandler' => DI\autowire(),
// Tracker Handlers
// 'TorrentPier\Application\Tracker\Handler\RegisterTorrentHandler' => DI\autowire(),
// 'TorrentPier\Application\Tracker\Handler\ProcessAnnounceHandler' => DI\autowire(),
// User Handlers
// 'TorrentPier\Application\User\Handler\RegisterUserHandler' => DI\autowire(),
// 'TorrentPier\Application\User\Handler\AuthenticateUserHandler' => DI\autowire(),
];
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace TorrentPier\Infrastructure\DependencyInjection\Definitions;
use DI;
use Psr\Container\ContainerInterface;
class DomainDefinitions
{
public static function getDefinitions(): array
{
return [
// Domain services should not depend on infrastructure
// These are typically created by factories in the application layer
// Example domain service factory definitions:
// 'TorrentPier\Domain\Forum\Repository\ForumRepositoryInterface' => DI\factory(function (ContainerInterface $c) {
// return $c->get('TorrentPier\Infrastructure\Persistence\Repository\ForumRepository');
// }),
// 'TorrentPier\Domain\Tracker\Repository\TorrentRepositoryInterface' => DI\factory(function (ContainerInterface $c) {
// return $c->get('TorrentPier\Infrastructure\Persistence\Repository\TorrentRepository');
// }),
// 'TorrentPier\Domain\User\Repository\UserRepositoryInterface' => DI\factory(function (ContainerInterface $c) {
// return $c->get('TorrentPier\Infrastructure\Persistence\Repository\UserRepository');
// }),
];
}
}

View file

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace TorrentPier\Infrastructure\DependencyInjection\Definitions;
use DI;
use Nette\Caching\Cache;
use Nette\Caching\Storages\FileStorage;
use Nette\Caching\Storages\MemcachedStorage;
use Nette\Caching\Storages\SQLiteStorage;
use Nette\Database\Connection;
use Psr\Container\ContainerInterface;
class InfrastructureDefinitions
{
public static function getDefinitions(array $config = []): array
{
return [
// TODO: Add infrastructure service definitions as they are implemented
// Example: Database Connection (implement when Nette Database integration is ready)
// 'database.connection.default' => DI\factory(function () use ($config) {
// $dbConfig = $config['database'] ?? [];
// $dsn = sprintf(
// 'mysql:host=%s;port=%s;dbname=%s;charset=%s',
// $dbConfig['host'] ?? '127.0.0.1',
// $dbConfig['port'] ?? 3306,
// $dbConfig['database'] ?? 'tp',
// $dbConfig['charset'] ?? 'utf8mb4'
// );
//
// return new Connection(
// $dsn,
// $dbConfig['username'] ?? 'root',
// $dbConfig['password'] ?? ''
// );
// }),
// Example: Cache Storage (implement when cache infrastructure is ready)
// 'cache.storage' => DI\factory(function () use ($config) {
// $cacheConfig = $config['cache'] ?? [];
// $driver = $cacheConfig['driver'] ?? 'file';
//
// switch ($driver) {
// case 'memcached':
// $memcached = new \Memcached();
// $memcached->addServer(
// $cacheConfig['memcached']['host'] ?? '127.0.0.1',
// $cacheConfig['memcached']['port'] ?? 11211
// );
// return new MemcachedStorage($memcached);
//
// case 'sqlite':
// return new SQLiteStorage($cacheConfig['sqlite']['path'] ?? __DIR__ . '/../../../../internal_data/cache/cache.db');
//
// case 'file':
// default:
// return new FileStorage($cacheConfig['file']['path'] ?? __DIR__ . '/../../../../internal_data/cache');
// }
// }),
// Example: Repository Implementations (implement when repositories are created)
// 'TorrentPier\Infrastructure\Persistence\Repository\ForumRepository' => DI\autowire()
// ->constructorParameter('connection', DI\get('database.connection.default'))
// ->constructorParameter('cache', DI\get('cache.factory')),
// Example: Email Service (implement when email infrastructure is ready)
// 'EmailServiceInterface' => DI\factory(function (ContainerInterface $c) use ($config) {
// $emailConfig = $config['email'] ?? [];
// return new SmtpEmailService($emailConfig);
// }),
// Example: File Storage (implement when file storage abstraction is ready)
// 'FileStorageInterface' => DI\factory(function (ContainerInterface $c) use ($config) {
// $storageConfig = $config['storage'] ?? [];
// return match ($storageConfig['driver'] ?? 'local') {
// 's3' => new S3FileStorage($storageConfig['s3']),
// default => new LocalFileStorage($storageConfig['local']['path'] ?? __DIR__ . '/../../../../internal_data/uploads'),
// };
// }),
];
}
}

View file

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace TorrentPier\Infrastructure\DependencyInjection\Definitions;
use DI;
use Psr\Container\ContainerInterface;
class PresentationDefinitions
{
public static function getDefinitions(): array
{
return [
// HTTP Controllers
// Controllers are typically autowired with their dependencies
// Web Controllers
// 'TorrentPier\Presentation\Http\Controllers\Web\HomeController' => DI\autowire(),
// 'TorrentPier\Presentation\Http\Controllers\Web\ForumController' => DI\autowire(),
// 'TorrentPier\Presentation\Http\Controllers\Web\TrackerController' => DI\autowire(),
// 'TorrentPier\Presentation\Http\Controllers\Web\UserController' => DI\autowire(),
// API Controllers
// 'TorrentPier\Presentation\Http\Controllers\Api\UserController' => DI\autowire(),
// 'TorrentPier\Presentation\Http\Controllers\Api\TorrentController' => DI\autowire(),
// 'TorrentPier\Presentation\Http\Controllers\Api\ForumController' => DI\autowire(),
// Admin Controllers
// 'TorrentPier\Presentation\Http\Controllers\Admin\DashboardController' => DI\autowire(),
// 'TorrentPier\Presentation\Http\Controllers\Admin\UserController' => DI\autowire(),
// 'TorrentPier\Presentation\Http\Controllers\Admin\ForumController' => DI\autowire(),
// 'TorrentPier\Presentation\Http\Controllers\Admin\TrackerController' => DI\autowire(),
// Middleware
// 'AuthenticationMiddleware' => DI\autowire('TorrentPier\Presentation\Http\Middleware\AuthenticationMiddleware'),
// 'AuthorizationMiddleware' => DI\autowire('TorrentPier\Presentation\Http\Middleware\AuthorizationMiddleware'),
// 'CorsMiddleware' => DI\autowire('TorrentPier\Presentation\Http\Middleware\CorsMiddleware'),
// 'RateLimitMiddleware' => DI\autowire('TorrentPier\Presentation\Http\Middleware\RateLimitMiddleware'),
// CLI Commands
// 'TorrentPier\Presentation\Cli\Commands\CacheCommand' => DI\autowire(),
// 'TorrentPier\Presentation\Cli\Commands\MigrateCommand' => DI\autowire(),
// 'TorrentPier\Presentation\Cli\Commands\SeedCommand' => DI\autowire(),
// 'TorrentPier\Presentation\Cli\Commands\TrackerCommand' => DI\autowire(),
// View/Response Transformers
// 'JsonResponseTransformer' => DI\autowire('TorrentPier\Presentation\Http\Responses\JsonResponseTransformer'),
// 'HtmlResponseTransformer' => DI\autowire('TorrentPier\Presentation\Http\Responses\HtmlResponseTransformer'),
];
}
}

View file

@ -0,0 +1,171 @@
# Dependency Injection Infrastructure
This directory contains the dependency injection container implementation using PHP-DI, following hexagonal architecture principles.
## Architecture Overview
The DI container is placed in the Infrastructure layer because:
- Dependency injection is a technical implementation detail
- It handles wiring and bootstrapping, not business logic
- The domain layer remains pure without framework dependencies
## Structure
```
DependencyInjection/
├── Container.php # Wrapper around PHP-DI container
├── ContainerFactory.php # Factory for building configured containers
├── Bootstrap.php # Application bootstrapper
└── Definitions/ # Service definitions by layer
├── DomainDefinitions.php
├── ApplicationDefinitions.php
├── InfrastructureDefinitions.php
└── PresentationDefinitions.php
```
## Usage
### Basic Bootstrap (Works Now)
```php
use TorrentPier\Infrastructure\DependencyInjection\Bootstrap;
// Initialize the container
$container = Bootstrap::init(__DIR__ . '/../..');
// Basic usage
$containerInstance = app(); // Get container itself
$hasService = $container->has('some.service'); // Check if service exists
```
### Manual Container Creation (Works Now)
```php
use TorrentPier\Infrastructure\DependencyInjection\ContainerFactory;
$config = [
'environment' => 'production',
'definitions' => [
'custom.service' => \DI\factory(function () {
return new CustomService();
}),
],
];
$container = ContainerFactory::create($config);
```
### Future Usage (When Services Are Implemented)
```php
// These will work when the respective layers are implemented:
// $userRepository = $container->get(UserRepositoryInterface::class);
// $commandBus = $container->get(CommandBusInterface::class);
```
## Service Definitions
Services are organized by architectural layer following the hexagonal architecture spec:
### Domain Layer (`DomainDefinitions.php`)
- Repository interface mappings (when implemented in Phase 2)
- Domain service factories
- No direct infrastructure dependencies
### Application Layer (`ApplicationDefinitions.php`)
- Command/Query buses (when implemented in Phase 3)
- Command/Query handlers
- Event dispatcher
- Application services
### Infrastructure Layer (`InfrastructureDefinitions.php`)
- Database connections (when Nette Database integration is ready)
- Cache implementations (when cache infrastructure is ready)
- Repository implementations (when implemented in Phase 4)
- External service adapters
### Presentation Layer (`PresentationDefinitions.php`)
- HTTP controllers (when implemented in Phase 5)
- CLI commands
- Middleware
- Response transformers
**Note**: Most definitions are currently commented out as examples until the actual services are implemented according to the implementation phases.
## Configuration
Configuration is loaded from multiple sources:
1. **Environment Variables** (`.env` file)
2. **Configuration Files** (`/config/*.php`)
3. **Runtime Configuration** (passed to factory)
### Production Optimization
In production mode, the container:
- Compiles definitions for performance
- Generates proxies for lazy loading
- Caches resolved dependencies
Enable by setting `APP_ENV=production` in your `.env` file.
## Best Practices
1. **Use Interfaces**: Define interfaces in domain, implement in infrastructure
2. **Explicit Definitions**: Prefer explicit over magic for complex services
3. **Layer Separation**: Keep definitions organized by architectural layer
4. **Lazy Loading**: Use factories for expensive services
5. **Immutable Services**: Services should be stateless and immutable
## Example Service Registration
### Current Usage (Works Now)
```php
// In services.php or custom definitions
return [
'custom.service' => \DI\factory(function () {
return new CustomService();
}),
'test.service' => \DI\autowire(TestService::class),
];
```
### Future Examples (When Infrastructure Is Ready)
```php
// These will be uncommented when the services are implemented:
// UserRepositoryInterface::class => autowire(UserRepository::class)
// ->constructorParameter('connection', get('database.connection.default'))
// ->constructorParameter('cache', get('cache.factory')),
//
// 'email.service' => factory(function (ContainerInterface $c) {
// $config = $c->get('config')['email'];
// return new SmtpEmailService($config);
// }),
```
## Testing
For testing, create a test container with mocked services:
```php
// Current testing approach (works now)
$testConfig = [
'definitions' => [
'test.service' => \DI\factory(function () {
return new MockTestService();
}),
],
'environment' => 'testing',
];
$container = ContainerFactory::create($testConfig);
// Future testing (when services are implemented)
// $testConfig = [
// 'definitions' => [
// UserRepositoryInterface::class => $mockUserRepository,
// EmailServiceInterface::class => $mockEmailService,
// ],
// ];
```

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace TorrentPier\Infrastructure\DependencyInjection;
interface ServiceProvider
{
/**
* Register services in the container
*
* @param Container $container
* @return void
*/
public function register(Container $container): void;
/**
* Bootstrap services after all providers have been registered
*
* @param Container $container
* @return void
*/
public function boot(Container $container): void;
}

50
src/helpers.php Normal file
View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use TorrentPier\Infrastructure\DependencyInjection\Bootstrap;
use TorrentPier\Infrastructure\DependencyInjection\Container;
if (!function_exists('container')) {
/**
* Get the dependency injection container instance
*
* @return Container|null
*/
function container(): ?Container
{
return Bootstrap::getContainer();
}
}
if (!function_exists('app')) {
/**
* Get a service from the container or the container itself
*
* @param string|null $id Service identifier
* @return mixed
* @throws RuntimeException If container is not initialized or service not found
*/
function app(?string $id = null): mixed
{
$container = container();
if ($container === null) {
throw new RuntimeException('Container has not been initialized. Call Bootstrap::init() first.');
}
if ($id === null) {
return $container;
}
try {
return $container->get($id);
} catch (NotFoundExceptionInterface $e) {
throw new RuntimeException("Service '$id' not found in container: " . $e->getMessage(), 0, $e);
} catch (ContainerExceptionInterface $e) {
throw new RuntimeException("Container error while resolving '$id': " . $e->getMessage(), 0, $e);
}
}
}

View file

@ -0,0 +1,193 @@
<?php
use TorrentPier\Infrastructure\DependencyInjection\Bootstrap;
use TorrentPier\Infrastructure\DependencyInjection\Container;
describe('Container Integration', function () {
afterEach(function () {
Bootstrap::reset();
});
it('can bootstrap the container with real configuration', function () {
$rootPath = $this->createTestRootDirectory();
$this->createTestConfigFiles($rootPath, [
'services' => [
'integration.test' => 'integration_value',
],
]);
$container = Bootstrap::init($rootPath);
expect($container)->toBeInstanceOf(Container::class);
expect($container->get('integration.test'))->toBe('integration_value');
removeTempDirectory($rootPath);
});
it('integrates with global helper functions', function () {
$rootPath = $this->createTestRootDirectory();
$this->createTestConfigFiles($rootPath, [
'services' => [
'helper.test' => 'helper_value',
],
]);
Bootstrap::init($rootPath);
// Test container() helper
expect(container())->toBeInstanceOf(Container::class);
// Test app() helper without parameter
expect(app())->toBeInstanceOf(Container::class);
// Test app() helper with service ID
expect(app('helper.test'))->toBe('helper_value');
removeTempDirectory($rootPath);
});
it('handles missing services gracefully in helpers', function () {
$rootPath = $this->createTestRootDirectory();
Bootstrap::init($rootPath);
// Should throw RuntimeException for missing service
expect(fn() => app('missing.service'))
->toThrow(RuntimeException::class)
->toThrow('not found in container');
removeTempDirectory($rootPath);
});
it('supports autowiring for simple classes', function () {
$rootPath = $this->createTestRootDirectory();
$container = Bootstrap::init($rootPath);
// Should be able to autowire stdClass
expect($container->has(stdClass::class))->toBeTrue();
expect($container->get(stdClass::class))->toBeInstanceOf(stdClass::class);
removeTempDirectory($rootPath);
});
it('loads all architectural layer definitions', function () {
$rootPath = $this->createTestRootDirectory();
$container = Bootstrap::init($rootPath);
// Container should be created successfully with all layer definitions loaded
// Even though most definitions are commented out, the loading should work
expect($container)->toBeInstanceOf(Container::class);
// Container should have itself registered
expect($container->get(Container::class))->toBe($container);
expect($container->get('container'))->toBe($container);
removeTempDirectory($rootPath);
});
it('supports environment-based configuration', function () {
$rootPath = $this->createTestRootDirectory();
$this->createTestConfigFiles($rootPath, [
'container' => [
'environment' => 'production',
'compilation_dir' => $rootPath . '/internal_data/cache/container',
'proxies_dir' => $rootPath . '/internal_data/cache/proxies',
],
]);
$container = Bootstrap::init($rootPath);
expect($container)->toBeInstanceOf(Container::class);
removeTempDirectory($rootPath);
});
it('supports service provider registration', function () {
$testProviderClass = new class implements \TorrentPier\Infrastructure\DependencyInjection\ServiceProvider {
public static bool $wasRegistered = false;
public static bool $wasBooted = false;
public function register(\TorrentPier\Infrastructure\DependencyInjection\Container $container): void
{
self::$wasRegistered = true;
$container->getWrappedContainer()->set('provider.test', 'provider_registered');
}
public function boot(\TorrentPier\Infrastructure\DependencyInjection\Container $container): void
{
self::$wasBooted = true;
}
};
$rootPath = $this->createTestRootDirectory();
$this->createTestConfigFiles($rootPath, [
'container' => [
'providers' => [get_class($testProviderClass)],
],
]);
$container = Bootstrap::init($rootPath);
expect($testProviderClass::$wasRegistered)->toBeTrue();
expect($testProviderClass::$wasBooted)->toBeTrue();
expect($container->get('provider.test'))->toBe('provider_registered');
removeTempDirectory($rootPath);
});
it('handles configuration file loading priority', function () {
$rootPath = $this->createTestRootDirectory();
$this->createTestConfigFiles($rootPath, [
'services' => [
'priority.test' => \DI\factory(function () {
return 'from_services_file';
}),
],
]);
// Initialize with runtime config that should override file config
$container = Bootstrap::init($rootPath, [
'definitions' => [
'priority.test' => \DI\factory(function () {
return 'from_runtime_config';
}),
'runtime.only' => \DI\factory(function () {
return 'runtime_value';
}),
],
]);
// Runtime config should override file config
expect($container->get('priority.test'))->toBe('from_runtime_config');
expect($container->get('runtime.only'))->toBe('runtime_value');
removeTempDirectory($rootPath);
});
it('provides meaningful error messages', function () {
$rootPath = $this->createTestRootDirectory();
Bootstrap::init($rootPath);
try {
app('definitely.missing.service');
fail('Expected exception to be thrown');
} catch (RuntimeException $e) {
expect($e->getMessage())->toContain('definitely.missing.service');
expect($e->getMessage())->toContain('not found in container');
}
removeTempDirectory($rootPath);
});
it('supports performance measurement', function () {
$rootPath = $this->createTestRootDirectory();
$time = measureExecutionTime(function () use ($rootPath) {
Bootstrap::init($rootPath);
});
// Container initialization should be reasonably fast
expect($time)->toBeLessThan(1.0); // Should take less than 1 second
removeTempDirectory($rootPath);
});
});

View file

@ -1,5 +0,0 @@
<?php
test('example', function () {
expect(true)->toBeTrue();
});

View file

@ -12,6 +12,7 @@
*/
pest()->extend(Tests\TestCase::class)->in('Feature');
pest()->extend(Tests\TestCase::class)->in('Unit');
/*
|--------------------------------------------------------------------------
@ -28,22 +29,6 @@ expect()->extend('toBeOne', function () {
return $this->toBe(1);
});
expect()->extend('toBeValidDatabaseConfig', function () {
$requiredKeys = ['dbhost', 'dbport', 'dbname', 'dbuser', 'dbpasswd', 'charset', 'persist'];
foreach ($requiredKeys as $key) {
if (!array_key_exists($key, $this->value)) {
return $this->toBeNull("Missing required config key: $key");
}
}
return $this->toBeArray();
});
expect()->extend('toHaveDebugInfo', function () {
return $this->toHaveKeys(['sql', 'src', 'file', 'line', 'time']);
});
/*
|--------------------------------------------------------------------------
| Functions
@ -55,236 +40,6 @@ expect()->extend('toHaveDebugInfo', function () {
|
*/
use Nette\Caching\Storages\MemoryStorage;
use Nette\Database\Connection;
use Nette\Database\ResultSet;
use TorrentPier\Cache\CacheManager;
use TorrentPier\Cache\DatastoreManager;
use TorrentPier\Cache\UnifiedCacheSystem;
use TorrentPier\Database\Database;
use TorrentPier\Database\DatabaseDebugger;
/**
* Test Environment Setup
*/
function setupTestEnvironment(): void
{
// Define test constants if not already defined
if (!defined('BB_ROOT')) {
define('BB_ROOT', __DIR__ . '/../');
}
if (!defined('INC_DIR')) {
define('INC_DIR', BB_ROOT . 'library/includes');
}
if (!defined('SQL_PREPEND_SRC')) {
define('SQL_PREPEND_SRC', true);
}
if (!defined('SQL_CALC_QUERY_TIME')) {
define('SQL_CALC_QUERY_TIME', true);
}
if (!defined('SQL_LOG_SLOW_QUERIES')) {
define('SQL_LOG_SLOW_QUERIES', false);
}
if (!defined('LOG_SEPR')) {
define('LOG_SEPR', ' | ');
}
if (!defined('LOG_LF')) {
define('LOG_LF', "\n");
}
}
/**
* Database Test Configuration
*/
function getTestDatabaseConfig(): array
{
return [
'dbhost' => 'localhost',
'dbport' => 3306,
'dbname' => 'test_torrentpier',
'dbuser' => 'test_user',
'dbpasswd' => 'test_password',
'charset' => 'utf8mb4',
'persist' => false
];
}
function getInvalidDatabaseConfig(): array
{
return [
'dbhost' => 'nonexistent.host',
'dbport' => 9999,
'dbname' => 'invalid_db',
'dbuser' => 'invalid_user',
'dbpasswd' => 'invalid_password',
'charset' => 'utf8mb4',
'persist' => false
];
}
/**
* Mock Database Components
*/
function mockDatabase(): Database
{
$mock = Mockery::mock(Database::class);
$mock->shouldReceive('init')->andReturn(true);
$mock->shouldReceive('connect')->andReturn(true);
$mock->shouldReceive('sql_query')->andReturn(mockResultSet());
$mock->shouldReceive('num_rows')->andReturn(1);
$mock->shouldReceive('affected_rows')->andReturn(1);
$mock->shouldReceive('sql_nextid')->andReturn(123);
$mock->shouldReceive('close')->andReturn(true);
return $mock;
}
function mockResultSet(): ResultSet
{
$mock = Mockery::mock(ResultSet::class);
// For testing purposes, just return null to indicate empty result set
// This avoids complex Row object mocking and type issues
$mock->shouldReceive('fetch')->andReturn(null);
$mock->shouldReceive('getRowCount')->andReturn(0);
return $mock;
}
function mockConnection(): Connection
{
$mock = Mockery::mock(Connection::class);
$mock->shouldReceive('query')->andReturn(mockResultSet());
$mock->shouldReceive('getInsertId')->andReturn(123);
$mock->shouldReceive('getPdo')->andReturn(mockPdo());
return $mock;
}
function mockPdo(): PDO
{
$mock = Mockery::mock(PDO::class);
$mock->shouldReceive('prepare')->andReturn(mockPdoStatement());
$mock->shouldReceive('errorInfo')->andReturn(['00000', null, null]);
return $mock;
}
function mockPdoStatement(): PDOStatement
{
$mock = Mockery::mock(PDOStatement::class);
$mock->shouldReceive('execute')->andReturn(true);
$mock->shouldReceive('fetch')->andReturn(['id' => 1, 'name' => 'test']);
$mock->shouldReceive('fetchAll')->andReturn([['id' => 1, 'name' => 'test']]);
return $mock;
}
function mockDatabaseDebugger(): DatabaseDebugger
{
$mockDb = mockDatabase();
$mock = Mockery::mock(DatabaseDebugger::class, [$mockDb]);
$mock->shouldReceive('debug')->andReturn(true);
$mock->shouldReceive('debug_find_source')->andReturn('test.php(123)');
$mock->shouldReceive('log_query')->andReturn(true);
$mock->shouldReceive('log_error')->andReturn(true);
return $mock;
}
/**
* Mock Cache Components
*/
function mockCacheManager(): CacheManager
{
$mock = Mockery::mock(CacheManager::class);
$mock->shouldReceive('get')->andReturn('test_value');
$mock->shouldReceive('set')->andReturn(true);
$mock->shouldReceive('rm')->andReturn(true);
$mock->shouldReceive('load')->andReturn('test_value');
$mock->shouldReceive('save')->andReturn(true);
$mock->shouldReceive('clean')->andReturn(true);
return $mock;
}
function mockDatastoreManager(): DatastoreManager
{
$mock = Mockery::mock(DatastoreManager::class);
$mock->shouldReceive('get')->andReturn(['test' => 'data']);
$mock->shouldReceive('store')->andReturn(true);
$mock->shouldReceive('update')->andReturn(true);
$mock->shouldReceive('rm')->andReturn(true);
$mock->shouldReceive('clean')->andReturn(true);
return $mock;
}
function mockMemoryStorage(): MemoryStorage
{
return new MemoryStorage();
}
/**
* Test Data Factories
*/
function createTestUser(array $overrides = []): array
{
return array_merge([
'id' => 1,
'username' => 'testuser',
'email' => 'test@example.com',
'active' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
], $overrides);
}
function createTestTorrent(array $overrides = []): array
{
return array_merge([
'id' => 1,
'info_hash' => 'test_hash_' . uniqid(),
'name' => 'Test Torrent',
'size' => 1048576,
'seeders' => 5,
'leechers' => 2,
'completed' => 10
], $overrides);
}
function createTestCacheConfig(): array
{
return [
'prefix' => 'test_',
'engine' => 'Memory',
'enabled' => true,
'ttl' => 3600
];
}
/**
* Exception Testing Helpers
*/
function expectException(callable $callback, string $exceptionClass, ?string $message = null): void
{
try {
$callback();
fail("Expected exception $exceptionClass was not thrown");
} catch (Exception $e) {
expect($e)->toBeInstanceOf($exceptionClass);
if ($message) {
expect($e->getMessage())->toContain($message);
}
}
}
/**
* Performance Testing Helpers
*/
@ -301,150 +56,6 @@ function expectExecutionTimeUnder(callable $callback, float $maxSeconds): void
expect($time)->toBeLessThan($maxSeconds, "Execution took {$time}s, expected under {$maxSeconds}s");
}
/**
* Database Query Testing Helpers
*/
function createSelectQuery(array $options = []): array
{
return array_merge([
'SELECT' => '*',
'FROM' => 'test_table',
'WHERE' => '1=1',
'ORDER BY' => 'id ASC',
'LIMIT' => '10'
], $options);
}
function createInsertQuery(array $data = []): array
{
$defaultData = ['name' => 'test', 'value' => 'test_value'];
return [
'INSERT' => 'test_table',
'VALUES' => array_merge($defaultData, $data)
];
}
function createUpdateQuery(array $data = [], string $where = 'id = 1'): array
{
$defaultData = ['updated_at' => date('Y-m-d H:i:s')];
return [
'UPDATE' => 'test_table',
'SET' => array_merge($defaultData, $data),
'WHERE' => $where
];
}
function createDeleteQuery(string $where = 'id = 1'): array
{
return [
'DELETE' => 'test_table',
'WHERE' => $where
];
}
/**
* Cache Testing Helpers
*/
function createTestCacheKey(string $suffix = ''): string
{
return 'test_key_' . uniqid() . ($suffix ? '_' . $suffix : '');
}
function createTestCacheValue(array $data = []): array
{
return array_merge([
'data' => 'test_value',
'timestamp' => time(),
'version' => '1.0'
], $data);
}
/**
* Debug Testing Helpers
*/
function createDebugEntry(array $overrides = []): array
{
return array_merge([
'sql' => 'SELECT * FROM test_table',
'src' => 'test.php(123)',
'file' => 'test.php',
'line' => '123',
'time' => 0.001,
'info' => 'Test query',
'mem_before' => 1024,
'mem_after' => 1024
], $overrides);
}
function assertDebugEntryValid(array $entry): void
{
expect($entry)->toHaveDebugInfo();
expect($entry['sql'])->toBeString();
expect($entry['time'])->toBeFloat();
expect($entry['src'])->toBeString();
}
/**
* Cleanup Helpers
*/
function cleanupSingletons(): void
{
// Reset database instances
if (class_exists(Database::class) && method_exists(Database::class, 'destroyInstances')) {
Database::destroyInstances();
}
// Reset cache instances
if (class_exists(UnifiedCacheSystem::class) && method_exists(UnifiedCacheSystem::class, 'destroyInstance')) {
UnifiedCacheSystem::destroyInstance();
}
// Close mockery
Mockery::close();
}
function resetGlobalState(): void
{
// Reset any global variables that might affect tests
$_COOKIE = [];
$_SESSION = [];
// Reset any global database connections
global $db;
$db = null;
// Initialize critical global variables needed by datastore builders
mockForumBitfieldMappings();
}
/**
* Mock forum bitfield mappings needed by datastore builders
* This prevents "Trying to access array offset on null" warnings in tests
*/
function mockForumBitfieldMappings(): void
{
global $bf;
if (!isset($bf) || !isset($bf['forum_perm'])) {
$bf = [];
$bf['forum_perm'] = [
'auth_view' => 0, // AUTH_VIEW
'auth_read' => 1, // AUTH_READ
'auth_mod' => 2, // AUTH_MOD
'auth_post' => 3, // AUTH_POST
'auth_reply' => 4, // AUTH_REPLY
'auth_edit' => 5, // AUTH_EDIT
'auth_delete' => 6, // AUTH_DELETE
'auth_sticky' => 7, // AUTH_STICKY
'auth_announce' => 8, // AUTH_ANNOUNCE
'auth_vote' => 9, // AUTH_VOTE
'auth_pollcreate' => 10, // AUTH_POLLCREATE
'auth_attachments' => 11, // AUTH_ATTACH
'auth_download' => 12, // AUTH_DOWNLOAD
];
}
}
/**
* File System Helpers
*/
@ -468,53 +79,17 @@ function removeTempDirectory(string $dir): void
}
/**
* Function Mocking Helpers
* Exception Testing Helpers
*/
function mockGlobalFunction(string $functionName, $returnValue): void
function expectException(callable $callback, string $exceptionClass, ?string $message = null): void
{
if (!function_exists($functionName)) {
eval("function $functionName() { return " . var_export($returnValue, true) . "; }");
try {
$callback();
fail("Expected exception $exceptionClass was not thrown");
} catch (Throwable $e) {
expect($e)->toBeInstanceOf($exceptionClass);
if ($message) {
expect($e->getMessage())->toContain($message);
}
}
}
function mockDevFunction(): void
{
if (!function_exists('dev')) {
eval('
function dev() {
return new class {
public function checkSqlDebugAllowed() { return true; }
public function formatShortQuery($query, $escape = false) { return $query; }
};
}
');
}
}
function mockBbLogFunction(): void
{
if (!function_exists('bb_log')) {
eval('function bb_log($message, $file = "test", $append = true) { return true; }');
}
}
function mockHideBbPathFunction(): void
{
if (!function_exists('hide_bb_path')) {
eval('function hide_bb_path($path) { return basename($path); }');
}
}
function mockUtimeFunction(): void
{
if (!function_exists('utime')) {
eval('function utime() { return microtime(true); }');
}
}
// Initialize test environment when Pest loads
setupTestEnvironment();
mockDevFunction();
mockBbLogFunction();
mockHideBbPathFunction();
mockUtimeFunction();

File diff suppressed because it is too large Load diff

View file

@ -3,8 +3,118 @@
namespace Tests;
use PHPUnit\Framework\TestCase as BaseTestCase;
use TorrentPier\Infrastructure\DependencyInjection\Bootstrap;
use TorrentPier\Infrastructure\DependencyInjection\Container;
use TorrentPier\Infrastructure\DependencyInjection\ContainerFactory;
abstract class TestCase extends BaseTestCase
{
//
protected function setUp(): void
{
parent::setUp();
// Reset container state for each test
Bootstrap::reset();
}
protected function tearDown(): void
{
// Clean up container state
Bootstrap::reset();
parent::tearDown();
}
/**
* Create a test container with optional custom configuration
*/
protected function createTestContainer(array $config = []): Container
{
$defaultConfig = [
'environment' => 'testing',
'autowiring' => true,
'definitions' => [],
];
return ContainerFactory::create(array_merge($defaultConfig, $config));
}
/**
* Create a container with custom service definitions
*/
protected function createContainerWithDefinitions(array $definitions): Container
{
return $this->createTestContainer([
'definitions' => $definitions,
]);
}
/**
* Create a temporary test root directory
*/
protected function createTestRootDirectory(): string
{
$tempDir = createTempDirectory();
// Create basic directory structure
mkdir($tempDir . '/config', 0755, true);
mkdir($tempDir . '/internal_data/cache', 0755, true);
return $tempDir;
}
/**
* Create test configuration files
*/
protected function createTestConfigFiles(string $rootPath, array $configs = []): void
{
$configPath = $rootPath . '/config';
// Create container.php
if (isset($configs['container'])) {
file_put_contents(
$configPath . '/container.php',
'<?php return ' . var_export($configs['container'], true) . ';'
);
}
// Create services.php - simplified approach for testing
if (isset($configs['services'])) {
$servicesContent = "<?php\n\nuse function DI\\factory;\n\nreturn [\n";
foreach ($configs['services'] as $key => $value) {
if (is_string($value)) {
$servicesContent .= " '$key' => factory(function () { return '$value'; }),\n";
}
}
$servicesContent .= "];\n";
file_put_contents($configPath . '/services.php', $servicesContent);
}
// Create .env file
if (isset($configs['env'])) {
$envContent = '';
foreach ($configs['env'] as $key => $value) {
$envContent .= "$key=$value\n";
}
file_put_contents($rootPath . '/.env', $envContent);
}
}
/**
* Assert that a service can be resolved from the container
*/
protected function assertCanResolve(Container $container, string $serviceId): void
{
$this->assertTrue($container->has($serviceId), "Container should have service: $serviceId");
$this->assertNotNull($container->get($serviceId), "Should be able to resolve service: $serviceId");
}
/**
* Assert that a service cannot be resolved from the container
*/
protected function assertCannotResolve(Container $container, string $serviceId): void
{
$this->assertFalse($container->has($serviceId), "Container should not have service: $serviceId");
}
}

View file

@ -1,461 +0,0 @@
<?php
use Nette\Caching\Cache;
use Nette\Caching\Storage;
use Nette\Caching\Storages\FileStorage;
use Nette\Caching\Storages\MemoryStorage;
use TorrentPier\Cache\CacheManager;
describe('CacheManager Class', function () {
beforeEach(function () {
resetGlobalState();
mockDevFunction();
mockBbLogFunction();
mockHideBbPathFunction();
mockUtimeFunction();
// Create memory storage for testing
$this->storage = new MemoryStorage();
$this->config = createTestCacheConfig();
$this->cacheManager = CacheManager::getInstance('test_namespace', $this->storage, $this->config);
});
afterEach(function () {
cleanupSingletons();
});
describe('Singleton Pattern', function () {
it('creates singleton instance correctly', function () {
$manager1 = CacheManager::getInstance('test', $this->storage, $this->config);
$manager2 = CacheManager::getInstance('test', $this->storage, $this->config);
expect($manager1)->toBe($manager2);
});
it('creates different instances for different namespaces', function () {
$manager1 = CacheManager::getInstance('namespace1', $this->storage, $this->config);
$manager2 = CacheManager::getInstance('namespace2', $this->storage, $this->config);
expect($manager1)->not->toBe($manager2);
});
it('stores configuration correctly', function () {
expect($this->cacheManager->prefix)->toBe($this->config['prefix']);
expect($this->cacheManager->engine)->toBe($this->config['engine']);
});
it('initializes Nette Cache with correct namespace', function () {
$cache = $this->cacheManager->getCache();
expect($cache)->toBeInstanceOf(Cache::class);
});
});
describe('Basic Cache Operations', function () {
it('stores and retrieves values correctly', function () {
$key = 'test_key';
$value = 'test_value';
$result = $this->cacheManager->set($key, $value);
expect($result)->toBeTrue();
expect($this->cacheManager->get($key))->toBe($value);
});
it('returns false for non-existent keys', function () {
$result = $this->cacheManager->get('non_existent_key');
expect($result)->toBeFalse();
});
it('handles different data types', function () {
$testCases = [
['string_key', 'string_value'],
['int_key', 42],
['float_key', 3.14],
['bool_key', true],
['array_key', ['nested' => ['data' => 'value']]],
['object_key', (object)['property' => 'value']]
];
foreach ($testCases as [$key, $value]) {
$this->cacheManager->set($key, $value);
expect($this->cacheManager->get($key))->toBe($value);
}
});
it('respects TTL expiration', function () {
$key = 'ttl_test';
$value = 'expires_soon';
// Set with 1 second TTL
$this->cacheManager->set($key, $value, 1);
// Should be available immediately
expect($this->cacheManager->get($key))->toBe($value);
// Wait for expiration (simulate with manual cache clear for testing)
$this->cacheManager->clean([Cache::All => true]);
// Should be expired now
expect($this->cacheManager->get($key))->toBeFalse();
});
it('handles zero TTL as permanent storage', function () {
$key = 'permanent_key';
$value = 'permanent_value';
$this->cacheManager->set($key, $value, 0);
expect($this->cacheManager->get($key))->toBe($value);
});
});
describe('Cache Removal', function () {
beforeEach(function () {
// Set up test data
$this->cacheManager->set('key1', 'value1');
$this->cacheManager->set('key2', 'value2');
$this->cacheManager->set('key3', 'value3');
});
it('removes individual keys', function () {
$result = $this->cacheManager->rm('key1');
expect($result)->toBeTrue();
expect($this->cacheManager->get('key1'))->toBeFalse();
expect($this->cacheManager->get('key2'))->toBe('value2'); // Others should remain
});
it('removes all keys when null is passed', function () {
$result = $this->cacheManager->rm(null);
expect($result)->toBeTrue();
expect($this->cacheManager->get('key1'))->toBeFalse();
expect($this->cacheManager->get('key2'))->toBeFalse();
expect($this->cacheManager->get('key3'))->toBeFalse();
});
it('removes specific key using remove method', function () {
$this->cacheManager->remove('key2');
expect($this->cacheManager->get('key2'))->toBeFalse();
expect($this->cacheManager->get('key1'))->toBe('value1'); // Others should remain
});
});
describe('Advanced Nette Caching Features', function () {
it('loads with callback function', function () {
$key = 'callback_test';
$callbackExecuted = false;
$result = $this->cacheManager->load($key, function () use (&$callbackExecuted) {
$callbackExecuted = true;
return 'callback_result';
});
expect($result)->toBe('callback_result');
expect($callbackExecuted)->toBeTrue();
// Second call should use cached value
$callbackExecuted = false;
$result2 = $this->cacheManager->load($key);
expect($result2)->toBe('callback_result');
expect($callbackExecuted)->toBeFalse(); // Callback should not be executed again
});
it('saves with dependencies', function () {
$key = 'dependency_test';
$value = 'dependent_value';
$dependencies = [
Cache::Expire => '1 hour',
Cache::Tags => ['user', 'data']
];
expect(fn() => $this->cacheManager->save($key, $value, $dependencies))->not->toThrow(Exception::class);
expect($this->cacheManager->get($key))->toBe($value);
});
it('performs bulk loading', function () {
// Pre-populate some data
$this->cacheManager->set('bulk1', 'value1');
$this->cacheManager->set('bulk2', 'value2');
$keys = ['bulk1', 'bulk2', 'bulk3'];
$results = $this->cacheManager->bulkLoad($keys);
expect($results)->toBeArray();
expect($results)->toHaveCount(3);
});
it('memoizes function calls', function () {
// Test with string function name instead of closure to avoid serialization
$callCount = 0;
// Create a global counter for testing
$GLOBALS['test_call_count'] = 0;
// Define a named function that can be cached
if (!function_exists('test_expensive_function')) {
function test_expensive_function($param)
{
$GLOBALS['test_call_count']++;
return "result_$param";
}
}
// Reset counter
$GLOBALS['test_call_count'] = 0;
// For closures that can't be serialized, just test that the method exists
// and doesn't throw exceptions with simpler data
expect(method_exists($this->cacheManager, 'call'))->toBeTrue();
// Test with serializable function name
$result1 = $this->cacheManager->call('test_expensive_function', 'test');
expect($result1)->toBe('result_test');
expect($GLOBALS['test_call_count'])->toBe(1);
});
it('wraps functions for memoization', function () {
// Test that wrap method exists and is callable, but skip actual closure wrapping
// due to serialization limitations in test environment
expect(method_exists($this->cacheManager, 'wrap'))->toBeTrue();
// For actual wrapping test, use a simple approach that doesn't rely on closure serialization
if (!function_exists('test_double_function')) {
function test_double_function($x)
{
return $x * 2;
}
}
// Test with named function
$wrappedFunction = $this->cacheManager->wrap('test_double_function');
expect($wrappedFunction)->toBeCallable();
expect($wrappedFunction(5))->toBe(10);
});
it('captures output', function () {
// Output capture is complex in test environment, just verify method exists
expect(method_exists($this->cacheManager, 'capture'))->toBeTrue();
// Capture method may start output buffering which is hard to test cleanly
// Skip actual capture test to avoid buffer conflicts
expect(true)->toBeTrue();
});
});
describe('Cache Cleaning', function () {
beforeEach(function () {
// Set up test data with tags
$this->cacheManager->save('tagged1', 'value1', [Cache::Tags => ['tag1', 'tag2']]);
$this->cacheManager->save('tagged2', 'value2', [Cache::Tags => ['tag2', 'tag3']]);
$this->cacheManager->save('untagged', 'value3');
});
it('cleans cache by criteria', function () {
expect(fn() => $this->cacheManager->clean([Cache::All => true]))->not->toThrow(Exception::class);
// All items should be removed
expect($this->cacheManager->get('tagged1'))->toBeFalse();
expect($this->cacheManager->get('tagged2'))->toBeFalse();
expect($this->cacheManager->get('untagged'))->toBeFalse();
});
it('cleans cache by tags if supported', function () {
// This depends on the storage supporting tags
expect(fn() => $this->cacheManager->clean([Cache::Tags => ['tag1']]))->not->toThrow(Exception::class);
});
});
describe('Debug Functionality', function () {
it('initializes debug properties', function () {
expect($this->cacheManager->dbg_enabled)->toBeBool();
// Reset num_queries as it may have been incremented by previous operations
$this->cacheManager->num_queries = 0;
expect($this->cacheManager->num_queries)->toBe(0);
expect($this->cacheManager->dbg)->toBeArray();
});
it('tracks query count', function () {
$initialQueries = $this->cacheManager->num_queries;
$this->cacheManager->set('debug_test', 'value');
$this->cacheManager->get('debug_test');
expect($this->cacheManager->num_queries)->toBeGreaterThan($initialQueries);
});
it('captures debug information when enabled', function () {
$this->cacheManager->dbg_enabled = true;
$this->cacheManager->set('debug_key', 'debug_value');
if ($this->cacheManager->dbg_enabled) {
expect($this->cacheManager->dbg)->not->toBeEmpty();
}
});
it('finds debug source information', function () {
$source = $this->cacheManager->debug_find_source();
expect($source)->toBeString();
});
it('handles debug timing correctly', function () {
$this->cacheManager->dbg_enabled = true;
$this->cacheManager->debug('start', 'test_operation');
usleep(1000); // 1ms delay
$this->cacheManager->debug('stop');
expect($this->cacheManager->cur_query_time)->toBeFloat();
expect($this->cacheManager->cur_query_time)->toBeGreaterThan(0);
});
});
describe('Storage Integration', function () {
it('provides access to underlying storage', function () {
$storage = $this->cacheManager->getStorage();
expect($storage)->toBeInstanceOf(Storage::class);
// Note: Due to possible storage wrapping/transformation, check type instead of reference
expect($storage)->toBeInstanceOf(get_class($this->storage));
});
it('provides access to Nette Cache instance', function () {
$cache = $this->cacheManager->getCache();
expect($cache)->toBeInstanceOf(Cache::class);
});
it('works with different storage types', function () {
// Test with file storage
$tempDir = createTempDirectory();
$fileStorage = new FileStorage($tempDir);
$fileManager = CacheManager::getInstance('file_test', $fileStorage, $this->config);
$fileManager->set('file_key', 'file_value');
expect($fileManager->get('file_key'))->toBe('file_value');
removeTempDirectory($tempDir);
});
});
describe('Error Handling', function () {
it('handles storage errors gracefully', function () {
// Mock a storage that throws exceptions
$mockStorage = Mockery::mock(Storage::class);
$mockStorage->shouldReceive('write')->andThrow(new Exception('Storage error'));
$mockStorage->shouldReceive('lock')->andReturn(true);
$errorManager = CacheManager::getInstance('error_test', $mockStorage, $this->config);
// Only set() method has exception handling - get() method will throw
expect($errorManager->set('any_key', 'any_value'))->toBeFalse();
// Test that the method exists but note that get() doesn't handle storage errors
expect(method_exists($errorManager, 'get'))->toBeTrue();
});
it('handles invalid cache operations', function () {
// Test with null values - note that CacheManager converts null to false for backward compatibility
expect($this->cacheManager->set('null_test', null))->toBeTrue();
// Due to backward compatibility, null values are returned as false when not found
// But when explicitly stored as null, they should return null
$result = $this->cacheManager->get('null_test');
expect($result === null || $result === false)->toBeTrue();
});
});
describe('Magic Properties', function () {
it('provides legacy database property', function () {
$db = $this->cacheManager->__get('db');
expect($db)->toBeObject();
expect($db->dbg)->toBeArray();
expect($db->engine)->toBe($this->cacheManager->engine);
});
it('throws exception for invalid properties', function () {
expect(fn() => $this->cacheManager->__get('invalid_property'))
->toThrow(InvalidArgumentException::class);
});
});
describe('Performance Testing', function () {
it('handles high-volume operations efficiently')
->group('performance')
->expect(function () {
return measureExecutionTime(function () {
for ($i = 0; $i < 1000; $i++) {
$this->cacheManager->set("perf_key_$i", "value_$i");
$this->cacheManager->get("perf_key_$i");
}
});
})
->toBeLessThan(1.0); // 1 second for 1000 operations
it('maintains consistent performance across operations', function () {
$times = [];
for ($i = 0; $i < 10; $i++) {
$time = measureExecutionTime(function () use ($i) {
$this->cacheManager->set("consistency_$i", "value_$i");
$this->cacheManager->get("consistency_$i");
});
$times[] = $time;
}
$averageTime = array_sum($times) / count($times);
expect($averageTime)->toBeLessThan(0.01); // 10ms average
});
});
describe('Memory Usage', function () {
it('handles large datasets efficiently', function () {
$largeData = array_fill(0, 1000, str_repeat('x', 1000)); // 1MB of data
expect(fn() => $this->cacheManager->set('large_data', $largeData))->not->toThrow(Exception::class);
expect($this->cacheManager->get('large_data'))->toBe($largeData);
});
it('handles concurrent cache operations', function () {
// Simulate concurrent operations
$keys = [];
for ($i = 0; $i < 100; $i++) {
$key = "concurrent_$i";
$keys[] = $key;
$this->cacheManager->set($key, "value_$i");
}
// Verify all operations completed successfully
foreach ($keys as $i => $key) {
expect($this->cacheManager->get($key))->toBe("value_$i");
}
});
});
describe('Backward Compatibility', function () {
it('maintains legacy Cache API compatibility', function () {
// Test that all legacy methods exist and work
expect(method_exists($this->cacheManager, 'get'))->toBeTrue();
expect(method_exists($this->cacheManager, 'set'))->toBeTrue();
expect(method_exists($this->cacheManager, 'rm'))->toBeTrue();
// Test legacy behavior
expect($this->cacheManager->get('non_existent'))->toBeFalse(); // Returns false, not null
});
it('provides backward compatible debug properties', function () {
expect(property_exists($this->cacheManager, 'num_queries'))->toBeTrue();
expect(property_exists($this->cacheManager, 'dbg'))->toBeTrue();
expect(property_exists($this->cacheManager, 'dbg_enabled'))->toBeTrue();
});
});
});

View file

@ -1,516 +0,0 @@
<?php
use Nette\Caching\Cache;
use Nette\Caching\Storage;
use Nette\Caching\Storages\MemoryStorage;
use TorrentPier\Cache\CacheManager;
use TorrentPier\Cache\DatastoreManager;
describe('DatastoreManager Class', function () {
beforeEach(function () {
resetGlobalState();
mockDevFunction();
mockBbLogFunction();
mockHideBbPathFunction();
mockUtimeFunction();
// Create memory storage for testing
$this->storage = new MemoryStorage();
$this->config = createTestCacheConfig();
$this->datastore = DatastoreManager::getInstance($this->storage, $this->config);
});
afterEach(function () {
cleanupSingletons();
});
describe('Singleton Pattern', function () {
it('creates singleton instance correctly', function () {
$manager1 = DatastoreManager::getInstance($this->storage, $this->config);
$manager2 = DatastoreManager::getInstance($this->storage, $this->config);
expect($manager1)->toBe($manager2);
});
it('initializes with correct configuration', function () {
expect($this->datastore->engine)->toBe($this->config['engine']);
expect($this->datastore->dbg_enabled)->toBeBool();
});
it('creates underlying cache manager', function () {
$cacheManager = $this->datastore->getCacheManager();
expect($cacheManager)->toBeInstanceOf(CacheManager::class);
});
});
describe('Known Items Configuration', function () {
it('defines known datastore items', function () {
expect($this->datastore->known_items)->toBeArray();
expect($this->datastore->known_items)->not->toBeEmpty();
});
it('includes essential datastore items', function () {
$essentialItems = [
'cat_forums',
'censor',
'moderators',
'stats',
'ranks',
'ban_list',
'attach_extensions',
'smile_replacements'
];
foreach ($essentialItems as $item) {
expect($this->datastore->known_items)->toHaveKey($item);
expect($this->datastore->known_items[$item])->toBeString();
expect($this->datastore->known_items[$item])->toContain('.php');
}
});
it('maps items to builder scripts', function () {
expect($this->datastore->known_items['cat_forums'])->toBe('build_cat_forums.php');
expect($this->datastore->known_items['censor'])->toBe('build_censor.php');
expect($this->datastore->known_items['moderators'])->toBe('build_moderators.php');
});
});
describe('Data Storage and Retrieval', function () {
it('stores and retrieves data correctly', function () {
$testData = ['test' => 'value', 'number' => 42];
$result = $this->datastore->store('test_item', $testData);
expect($result)->toBeTrue();
expect($this->datastore->get('test_item'))->toBe($testData);
});
it('handles different data types', function () {
$testCases = [
['string_item', 'string_value'],
['int_item', 42],
['float_item', 3.14],
['bool_item', true],
['array_item', ['nested' => ['data' => 'value']]],
['object_item', (object)['property' => 'value']]
];
foreach ($testCases as [$key, $value]) {
$this->datastore->store($key, $value);
expect($this->datastore->get($key))->toBe($value);
}
});
it('stores data permanently without TTL', function () {
$this->datastore->store('permanent_item', 'permanent_value');
// Data should persist (no TTL applied)
expect($this->datastore->get('permanent_item'))->toBe('permanent_value');
});
it('updates existing data', function () {
$this->datastore->store('update_test', 'original_value');
$this->datastore->store('update_test', 'updated_value');
expect($this->datastore->get('update_test'))->toBe('updated_value');
});
});
describe('Queue Management', function () {
it('enqueues items for batch loading', function () {
$items = ['item1', 'item2', 'item3'];
$this->datastore->enqueue($items);
expect($this->datastore->queued_items)->toContain('item1', 'item2', 'item3');
});
it('avoids duplicate items in queue', function () {
$this->datastore->enqueue(['item1', 'item2']);
$this->datastore->enqueue(['item2', 'item3']); // item2 is duplicate
expect($this->datastore->queued_items)->toHaveCount(3);
expect(array_count_values($this->datastore->queued_items)['item2'])->toBe(1);
});
it('skips already loaded items', function () {
// Pre-load data
$this->datastore->store('loaded_item', 'loaded_value');
$this->datastore->enqueue(['loaded_item', 'new_item']);
expect($this->datastore->queued_items)->not->toContain('loaded_item');
expect($this->datastore->queued_items)->toContain('new_item');
});
it('triggers fetch of queued items automatically', function () {
// Create a scenario where item is in cache but not in memory
$testItem = 'test_item';
// First store the item to put it in cache and memory
$this->datastore->store($testItem, 'test_value');
// Manually clear from memory to simulate cache-only state
unset($this->datastore->data[$testItem]);
// Directly enqueue the item
$this->datastore->queued_items = [$testItem];
// Verify item is queued
expect($this->datastore->queued_items)->toContain($testItem);
// Manually call _fetch_from_store to simulate the cache retrieval part
$this->datastore->_fetch_from_store();
// Verify the item was loaded back from cache into memory
expect($this->datastore->data)->toHaveKey($testItem);
expect($this->datastore->data[$testItem])->toBe('test_value');
// Now manually clear the queue (simulating what _fetch() does)
$this->datastore->queued_items = [];
// Verify queue is cleared
expect($this->datastore->queued_items)->toBeEmpty();
});
});
describe('Memory Management', function () {
it('removes data from memory cache', function () {
$this->datastore->store('memory_test1', 'value1');
$this->datastore->store('memory_test2', 'value2');
$this->datastore->rm('memory_test1');
// Should be removed from memory but might still be in cache
expect($this->datastore->data)->not->toHaveKey('memory_test1');
expect($this->datastore->data)->toHaveKey('memory_test2');
});
it('removes multiple items from memory', function () {
$this->datastore->store('multi1', 'value1');
$this->datastore->store('multi2', 'value2');
$this->datastore->store('multi3', 'value3');
$this->datastore->rm(['multi1', 'multi3']);
expect($this->datastore->data)->not->toHaveKey('multi1');
expect($this->datastore->data)->toHaveKey('multi2');
expect($this->datastore->data)->not->toHaveKey('multi3');
});
});
describe('Cache Cleaning', function () {
it('cleans all datastore cache', function () {
$this->datastore->store('clean_test', 'value');
expect(fn() => $this->datastore->clean())->not->toThrow(Exception::class);
});
it('cleans cache by criteria', function () {
expect(fn() => $this->datastore->cleanByCriteria([Cache::All => true]))->not->toThrow(Exception::class);
});
it('cleans cache by tags if supported', function () {
$tags = ['datastore', 'test'];
expect(fn() => $this->datastore->cleanByTags($tags))->not->toThrow(Exception::class);
});
});
describe('Advanced Nette Caching Features', function () {
it('loads with dependencies', function () {
$key = 'dependency_test';
$value = 'dependent_value';
$dependencies = [Cache::Expire => '1 hour'];
expect(fn() => $this->datastore->load($key, null, $dependencies))->not->toThrow(Exception::class);
});
it('saves with dependencies', function () {
$key = 'save_dependency_test';
$value = 'dependent_value';
$dependencies = [Cache::Tags => ['datastore']];
expect(fn() => $this->datastore->save($key, $value, $dependencies))->not->toThrow(Exception::class);
});
it('uses callback for loading missing data', function () {
$key = 'callback_test';
$callbackExecuted = false;
$result = $this->datastore->load($key, function () use (&$callbackExecuted) {
$callbackExecuted = true;
return ['generated' => 'data'];
});
expect($callbackExecuted)->toBeTrue();
expect($result)->toBe(['generated' => 'data']);
});
});
describe('Builder System Integration', function () {
it('tracks builder script directory', function () {
expect($this->datastore->ds_dir)->toBe('datastore');
});
it('builds items using known scripts', function () {
// Mock INC_DIR constant if not defined
if (!defined('INC_DIR')) {
define('INC_DIR', __DIR__ . '/../../../library/includes');
}
// We can't actually build items without the real files,
// but we can test the error handling
expect(fn() => $this->datastore->_build_item('non_existent_item'))
->toThrow(Exception::class);
});
it('updates specific datastore items', function () {
// Mock the update process (would normally rebuild from database)
expect(fn() => $this->datastore->update(['censor']))->not->toThrow(Exception::class);
});
it('updates all items when requested', function () {
expect(fn() => $this->datastore->update('all'))->not->toThrow(Exception::class);
});
});
describe('Bulk Operations', function () {
it('fetches multiple items from store', function () {
// Pre-populate data
$this->datastore->store('bulk1', 'value1');
$this->datastore->store('bulk2', 'value2');
$this->datastore->enqueue(['bulk1', 'bulk2', 'bulk3']);
expect(fn() => $this->datastore->_fetch_from_store())->not->toThrow(Exception::class);
});
it('handles bulk loading efficiently', function () {
// Setup bulk data directly in memory and cache
for ($i = 1; $i <= 10; $i++) {
$this->datastore->store("bulk_item_$i", "value_$i");
}
// Now test the fetching logic without building unknown items
$items = array_map(fn($i) => "bulk_item_$i", range(1, 10));
$this->datastore->queued_items = $items;
// Test the fetch_from_store part which should work fine
expect(fn() => $this->datastore->_fetch_from_store())->not->toThrow(Exception::class);
// Manually clear the queue since we're not testing the full _fetch()
$this->datastore->queued_items = [];
// Verify items are accessible
for ($i = 1; $i <= 10; $i++) {
expect($this->datastore->data["bulk_item_$i"])->toBe("value_$i");
}
});
});
describe('Debug Integration', function () {
it('updates debug counters from cache manager', function () {
$initialQueries = $this->datastore->num_queries;
$this->datastore->store('debug_test', 'value');
$this->datastore->get('debug_test');
expect($this->datastore->num_queries)->toBeGreaterThan($initialQueries);
});
it('tracks timing information', function () {
$initialTime = $this->datastore->sql_timetotal;
$this->datastore->store('timing_test', 'value');
expect($this->datastore->sql_timetotal)->toBeGreaterThanOrEqual($initialTime);
});
it('maintains debug arrays', function () {
expect($this->datastore->dbg)->toBeArray();
expect($this->datastore->dbg_id)->toBeInt();
});
});
describe('Source Debugging', function () {
it('finds debug caller information', function () {
$caller = $this->datastore->_debug_find_caller('enqueue');
expect($caller)->toBeString();
// Caller might return "caller not found" in test environment
expect($caller)->toBeString();
if (!str_contains($caller, 'not found')) {
expect($caller)->toContain('(');
expect($caller)->toContain(')');
}
});
it('handles missing caller gracefully', function () {
$caller = $this->datastore->_debug_find_caller('non_existent_function');
expect($caller)->toBe('caller not found');
});
});
describe('Magic Methods', function () {
it('delegates property access to cache manager', function () {
// Test accessing cache manager properties
expect($this->datastore->prefix)->toBeString();
expect($this->datastore->used)->toBeTrue();
});
it('delegates method calls to cache manager', function () {
// Test calling cache manager methods
expect(fn() => $this->datastore->bulkLoad(['test1', 'test2']))->not->toThrow(Exception::class);
});
it('provides legacy database property', function () {
$db = $this->datastore->__get('db');
expect($db)->toBeObject();
expect($db->dbg)->toBeArray();
expect($db->engine)->toBe($this->datastore->engine);
});
it('throws exception for invalid property access', function () {
expect(fn() => $this->datastore->__get('invalid_property'))
->toThrow(InvalidArgumentException::class);
});
it('throws exception for invalid method calls', function () {
expect(fn() => $this->datastore->__call('invalid_method', []))
->toThrow(BadMethodCallException::class);
});
});
describe('Tag Support Detection', function () {
it('detects tag support in storage', function () {
$supportsTagsBefore = $this->datastore->supportsTags();
expect($supportsTagsBefore)->toBeBool();
});
it('returns engine information', function () {
$engine = $this->datastore->getEngine();
expect($engine)->toBe($this->config['engine']);
});
});
describe('Performance Testing', function () {
it('handles high-volume datastore operations efficiently')
->group('performance')
->expect(function () {
return measureExecutionTime(function () {
for ($i = 0; $i < 100; $i++) {
$this->datastore->store("perf_item_$i", ['data' => "value_$i"]);
$this->datastore->get("perf_item_$i");
}
});
})
->toBeLessThan(0.5); // 500ms for 100 operations
it('efficiently handles bulk enqueue operations', function () {
$items = array_map(fn($i) => "bulk_perf_$i", range(1, 1000));
$time = measureExecutionTime(function () use ($items) {
$this->datastore->enqueue($items);
});
expect($time)->toBeLessThan(0.1); // 100ms for 1000 items
});
});
describe('Error Handling', function () {
it('handles missing builder scripts', function () {
// Test with non-existent item
expect(fn() => $this->datastore->_build_item('non_existent'))
->toThrow(Exception::class);
});
it('handles empty queue operations', function () {
$this->datastore->queued_items = [];
// Should handle empty queue gracefully - just test that queue is empty
expect($this->datastore->queued_items)->toBeEmpty();
});
});
describe('Memory Optimization', function () {
it('manages memory efficiently with large datasets', function () {
// Create large dataset
$largeData = array_fill(0, 1000, str_repeat('x', 1000)); // ~1MB
expect(fn() => $this->datastore->store('large_dataset', $largeData))->not->toThrow(Exception::class);
expect($this->datastore->get('large_dataset'))->toBe($largeData);
});
it('handles concurrent datastore operations', function () {
// Simulate concurrent operations
$operations = [];
for ($i = 0; $i < 50; $i++) {
$operations[] = function () use ($i) {
$this->datastore->store("concurrent_$i", ['id' => $i, 'data' => "value_$i"]);
return $this->datastore->get("concurrent_$i");
};
}
// Execute operations
foreach ($operations as $i => $operation) {
$result = $operation();
expect($result['id'])->toBe($i);
}
});
});
describe('Backward Compatibility', function () {
it('maintains legacy Datastore API compatibility', function () {
// Test that all legacy methods exist and work
expect(method_exists($this->datastore, 'get'))->toBeTrue();
expect(method_exists($this->datastore, 'store'))->toBeTrue();
expect(method_exists($this->datastore, 'update'))->toBeTrue();
expect(method_exists($this->datastore, 'rm'))->toBeTrue();
expect(method_exists($this->datastore, 'clean'))->toBeTrue();
});
it('provides backward compatible properties', function () {
expect(property_exists($this->datastore, 'data'))->toBeTrue();
expect(property_exists($this->datastore, 'queued_items'))->toBeTrue();
expect(property_exists($this->datastore, 'known_items'))->toBeTrue();
expect(property_exists($this->datastore, 'ds_dir'))->toBeTrue();
});
it('maintains reference semantics for get method', function () {
$testData = ['modifiable' => 'data'];
$this->datastore->store('reference_test', $testData);
$retrieved = &$this->datastore->get('reference_test');
$retrieved['modifiable'] = 'modified';
// Should maintain reference semantics
expect($this->datastore->data['reference_test']['modifiable'])->toBe('modified');
});
});
describe('Integration Features', function () {
it('integrates with cache manager debug features', function () {
$cacheManager = $this->datastore->getCacheManager();
expect($this->datastore->dbg_enabled)->toBe($cacheManager->dbg_enabled);
});
it('synchronizes debug counters properly', function () {
$initialQueries = $this->datastore->num_queries;
// Perform operations through datastore
$this->datastore->store('sync_test', 'value');
$this->datastore->get('sync_test');
// Counters should be synchronized
$cacheManager = $this->datastore->getCacheManager();
expect($this->datastore->num_queries)->toBe($cacheManager->num_queries);
});
});
});

View file

@ -1,90 +0,0 @@
<?php
use TorrentPier\Database\Database;
describe('Database Affected Rows Fix', function () {
beforeEach(function () {
// Reset singleton instances
Database::destroyInstances();
});
afterEach(function () {
Database::destroyInstances();
});
it('has affected_rows method that returns integer', function () {
// Create a simple mock to test the method exists
$db = Mockery::mock(Database::class)->makePartial();
expect(method_exists($db, 'affected_rows'))->toBeTrue();
// Mock the method to return a value
$db->shouldReceive('affected_rows')->andReturn(1);
$result = $db->affected_rows();
expect($result)->toBeInt();
expect($result)->toBe(1);
});
it('affected_rows method returns 0 initially', function () {
$db = Mockery::mock(Database::class)->makePartial();
$db->shouldReceive('affected_rows')->andReturn(0);
expect($db->affected_rows())->toBe(0);
});
it('affected_rows can track INSERT operations', function () {
$db = Mockery::mock(Database::class)->makePartial();
// Mock that INSERT affects 1 row
$db->shouldReceive('affected_rows')->andReturn(1);
expect($db->affected_rows())->toBe(1);
});
it('affected_rows can track UPDATE operations', function () {
$db = Mockery::mock(Database::class)->makePartial();
// Mock that UPDATE affects 3 rows
$db->shouldReceive('affected_rows')->andReturn(3);
expect($db->affected_rows())->toBe(3);
});
it('affected_rows can track DELETE operations', function () {
$db = Mockery::mock(Database::class)->makePartial();
// Mock that DELETE affects 2 rows
$db->shouldReceive('affected_rows')->andReturn(2);
expect($db->affected_rows())->toBe(2);
});
it('affected_rows returns 0 when no rows affected', function () {
$db = Mockery::mock(Database::class)->makePartial();
// Mock operation that affects no rows
$db->shouldReceive('affected_rows')->andReturn(0);
expect($db->affected_rows())->toBe(0);
});
it('validates Database class has last_affected_rows property', function () {
// Test that the Database class structure supports affected_rows tracking
$reflection = new ReflectionClass(Database::class);
expect($reflection->hasProperty('last_affected_rows'))->toBeTrue();
expect($reflection->hasMethod('affected_rows'))->toBeTrue();
});
it('validates fix is present in source code', function () {
// Simple source code validation to ensure fix is in place
$databaseSource = file_get_contents(__DIR__ . '/../../../src/Database/Database.php');
// Check that our fix is present: getRowCount() usage
expect($databaseSource)->toContain('getRowCount()');
// Check that the last_affected_rows property exists
expect($databaseSource)->toContain('last_affected_rows');
});
});

View file

@ -1,572 +0,0 @@
<?php
use TorrentPier\Database\Database;
use TorrentPier\Database\DatabaseDebugger;
describe('DatabaseDebugger Class', function () {
beforeEach(function () {
Database::destroyInstances();
resetGlobalState();
mockDevFunction();
mockBbLogFunction();
mockHideBbPathFunction();
// Set up test database instance
$this->db = Database::getInstance(getTestDatabaseConfig());
$this->db->connection = mockConnection();
$this->debugger = $this->db->debugger;
});
afterEach(function () {
cleanupSingletons();
});
describe('Initialization', function () {
it('initializes with database reference', function () {
// Test that debugger is properly constructed with database reference
expect($this->debugger)->toBeInstanceOf(DatabaseDebugger::class);
// Test that it has necessary public properties/methods
expect(property_exists($this->debugger, 'dbg_enabled'))->toBe(true);
expect(property_exists($this->debugger, 'dbg'))->toBe(true);
});
it('sets up debug configuration', function () {
expect($this->debugger->dbg_enabled)->toBeBool();
expect($this->debugger->do_explain)->toBeBool();
expect($this->debugger->slow_time)->toBeFloat();
});
it('initializes debug arrays', function () {
expect($this->debugger->dbg)->toBeArray();
expect($this->debugger->dbg_id)->toBe(0);
expect($this->debugger->legacy_queries)->toBeArray();
});
it('sets up timing properties', function () {
expect($this->debugger->sql_starttime)->toBeFloat();
expect($this->debugger->cur_query_time)->toBeFloat();
});
});
describe('Debug Configuration', function () {
it('enables debug based on dev settings', function () {
// Test that debug configuration is working
$originalEnabled = $this->debugger->dbg_enabled;
// Test that the debugger has debug configuration
expect($this->debugger->dbg_enabled)->toBeBool();
expect(isset($this->debugger->dbg_enabled))->toBe(true);
});
it('enables explain based on cookie', function () {
$_COOKIE['explain'] = '1';
// Test that explain functionality can be configured
expect(property_exists($this->debugger, 'do_explain'))->toBe(true);
expect($this->debugger->do_explain)->toBeBool();
unset($_COOKIE['explain']);
});
it('respects slow query time constants', function () {
if (!defined('SQL_SLOW_QUERY_TIME')) {
define('SQL_SLOW_QUERY_TIME', 5.0);
}
$debugger = new DatabaseDebugger($this->db);
expect($debugger->slow_time)->toBe(5.0);
});
});
describe('Debug Information Collection', function () {
beforeEach(function () {
$this->debugger->dbg_enabled = true;
$this->db->cur_query = 'SELECT * FROM test_table';
});
it('captures debug info on start', function () {
$this->debugger->debug('start');
expect($this->debugger->dbg[0])->toHaveKey('sql');
expect($this->debugger->dbg[0])->toHaveKey('src');
expect($this->debugger->dbg[0])->toHaveKey('file');
expect($this->debugger->dbg[0])->toHaveKey('line');
expect($this->debugger->dbg[0]['sql'])->toContain('SELECT * FROM test_table');
});
it('captures timing info on stop', function () {
$this->debugger->debug('start');
usleep(1000); // 1ms delay
$this->debugger->debug('stop');
expect($this->debugger->dbg[0])->toHaveKey('time');
expect($this->debugger->dbg[0]['time'])->toBeFloat();
expect($this->debugger->dbg[0]['time'])->toBeGreaterThan(0);
});
it('captures memory usage if available', function () {
// Mock sys function
if (!function_exists('sys')) {
eval('function sys($what) { return $what === "mem" ? 1024 : 0; }');
}
$this->debugger->debug('start');
$this->debugger->debug('stop');
expect($this->debugger->dbg[0])->toHaveKey('mem_before');
expect($this->debugger->dbg[0])->toHaveKey('mem_after');
});
it('increments debug ID after each query', function () {
$initialId = $this->debugger->dbg_id;
$this->debugger->debug('start');
$this->debugger->debug('stop');
expect($this->debugger->dbg_id)->toBe($initialId + 1);
});
it('handles multiple debug entries', function () {
// First query
$this->db->cur_query = 'SELECT 1';
$this->debugger->debug('start');
$this->debugger->debug('stop');
// Second query
$this->db->cur_query = 'SELECT 2';
$this->debugger->debug('start');
$this->debugger->debug('stop');
expect($this->debugger->dbg)->toHaveCount(2);
expect($this->debugger->dbg[0]['sql'])->toContain('SELECT 1');
expect($this->debugger->dbg[1]['sql'])->toContain('SELECT 2');
});
});
describe('Source Detection', function () {
it('finds debug source information', function () {
$source = $this->debugger->debug_find_source();
expect($source)->toBeString();
expect($source)->toContain('(');
expect($source)->toContain(')');
});
it('extracts file path only when requested', function () {
$file = $this->debugger->debug_find_source('file');
expect($file)->toBeString();
expect($file)->toContain('.php');
});
it('extracts line number only when requested', function () {
$line = $this->debugger->debug_find_source('line');
expect($line)->toBeString();
expect(is_numeric($line) || $line === '?')->toBeTrue();
});
it('returns "src disabled" when SQL_PREPEND_SRC is false', function () {
if (defined('SQL_PREPEND_SRC')) {
// Create new constant for this test
eval('define("TEST_SQL_PREPEND_SRC", false);');
}
// This test would need modification of the actual method to test properly
// For now, we'll test the positive case
$source = $this->debugger->debug_find_source();
expect($source)->not->toBe('src disabled');
});
it('skips Database-related files in stack trace', function () {
$source = $this->debugger->debug_find_source();
// Should not contain Database.php or DatabaseDebugger.php in the result
expect($source)->not->toContain('Database.php');
expect($source)->not->toContain('DatabaseDebugger.php');
});
});
describe('Nette Explorer Detection', function () {
it('detects Nette Explorer in call stack', function () {
// Create a mock trace that includes Nette Database classes
$trace = [
['class' => 'Nette\\Database\\Table\\Selection', 'function' => 'select'],
['class' => 'TorrentPier\\Database\\DebugSelection', 'function' => 'where'],
['file' => '/path/to/DatabaseTest.php', 'function' => 'testMethod']
];
$result = $this->debugger->detectNetteExplorerInTrace($trace);
expect($result)->toBeTrue();
});
it('detects Nette Explorer by SQL syntax patterns', function () {
$netteSQL = 'SELECT `id`, `name` FROM `users` WHERE (`active` = 1)';
$result = $this->debugger->detectNetteExplorerBySqlSyntax($netteSQL);
expect($result)->toBeTrue();
});
it('does not detect regular SQL as Nette Explorer', function () {
$regularSQL = 'SELECT id, name FROM users WHERE active = 1';
$result = $this->debugger->detectNetteExplorerBySqlSyntax($regularSQL);
expect($result)->toBeFalse();
});
it('marks queries as Nette Explorer when detected', function () {
$this->debugger->markAsNetteExplorerQuery();
expect($this->debugger->is_nette_explorer_query)->toBeTrue();
});
it('resets Nette Explorer flag after query completion', function () {
$this->debugger->markAsNetteExplorerQuery();
$this->debugger->resetNetteExplorerFlag();
expect($this->debugger->is_nette_explorer_query)->toBeFalse();
});
it('adds Nette Explorer marker to debug info', function () {
$this->debugger->dbg_enabled = true;
$this->debugger->markAsNetteExplorerQuery();
$this->db->cur_query = 'SELECT `id` FROM `users`';
$this->debugger->debug('start');
$this->debugger->debug('stop');
$debugEntry = $this->debugger->dbg[0];
expect($debugEntry['is_nette_explorer'])->toBeTrue();
expect($debugEntry['info'])->toContain('[Nette Explorer]');
});
});
describe('Query Logging', function () {
beforeEach(function () {
$this->db->DBS['log_counter'] = 0;
$this->db->DBS['log_file'] = 'test_queries';
});
it('prepares for query logging', function () {
$this->debugger->log_next_query(3, 'custom_log');
expect($this->db->DBS['log_counter'])->toBe(3);
expect($this->db->DBS['log_file'])->toBe('custom_log');
});
it('logs queries when enabled', function () {
$this->debugger->log_next_query(1);
$this->db->inited = true;
$this->db->cur_query = 'SELECT 1';
$this->debugger->cur_query_time = 0.001;
$this->debugger->sql_starttime = microtime(true);
// Should not throw
expect(fn() => $this->debugger->log_query())->not->toThrow(Exception::class);
});
it('logs slow queries when they exceed threshold', function () {
$this->debugger->slow_time = 0.001; // Very low threshold
$this->debugger->cur_query_time = 0.002; // Exceeds threshold
$this->db->cur_query = 'SELECT SLEEP(1)';
expect(fn() => $this->debugger->log_slow_query())->not->toThrow(Exception::class);
});
it('respects slow query cache setting', function () {
// Mock CACHE function
if (!function_exists('CACHE')) {
eval('
function CACHE($name) {
return new class {
public function get($key) { return true; } // Indicates not to log
};
}
');
}
$this->debugger->slow_time = 0.001;
$this->debugger->cur_query_time = 0.002;
// Should not log due to cache setting
expect(fn() => $this->debugger->log_slow_query())->not->toThrow(Exception::class);
});
});
describe('Error Logging', function () {
it('logs exceptions with detailed information', function () {
$exception = new Exception('Test database error', 1064);
expect(fn() => $this->debugger->log_error($exception))->not->toThrow(Exception::class);
});
it('logs PDO exceptions with specific details', function () {
$pdoException = new PDOException('Connection failed');
$pdoException->errorInfo = ['42000', 1045, 'Access denied'];
expect(fn() => $this->debugger->log_error($pdoException))->not->toThrow(Exception::class);
});
it('logs comprehensive context information', function () {
$this->db->cur_query = 'SELECT * FROM nonexistent_table';
$this->db->selected_db = 'test_db';
$this->db->db_server = 'test_server';
$exception = new Exception('Table does not exist');
expect(fn() => $this->debugger->log_error($exception))->not->toThrow(Exception::class);
});
it('handles empty or no-error states gracefully', function () {
// Mock sql_error to return no error
$this->db->connection = mockConnection();
expect(fn() => $this->debugger->log_error())->not->toThrow(Exception::class);
});
it('checks connection status during error logging', function () {
$this->db->connection = null; // No connection
$exception = new Exception('No connection');
expect(fn() => $this->debugger->log_error($exception))->not->toThrow(Exception::class);
});
});
describe('Legacy Query Tracking', function () {
it('logs legacy queries that needed compatibility fixes', function () {
$problematicQuery = 'SELECT t.*, f.* FROM table t, forum f';
$error = 'Found duplicate columns';
$this->debugger->logLegacyQuery($problematicQuery, $error);
expect($this->debugger->legacy_queries)->not->toBeEmpty();
expect($this->debugger->legacy_queries[0]['query'])->toBe($problematicQuery);
expect($this->debugger->legacy_queries[0]['error'])->toBe($error);
});
it('marks debug entries as legacy when logging', function () {
$this->debugger->dbg_enabled = true;
// Create a debug entry first
$this->db->cur_query = 'SELECT t.*, f.*';
$this->debugger->debug('start');
$this->debugger->debug('stop');
// Now log it as legacy
$this->debugger->logLegacyQuery('SELECT t.*, f.*', 'Duplicate columns');
$debugEntry = $this->debugger->dbg[0];
expect($debugEntry['is_legacy_query'])->toBeTrue();
expect($debugEntry['info'])->toContain('LEGACY COMPATIBILITY FIX APPLIED');
});
it('records detailed legacy query information', function () {
$query = 'SELECT * FROM old_table';
$error = 'Compatibility issue';
$this->debugger->logLegacyQuery($query, $error);
$entry = $this->debugger->legacy_queries[0];
expect($entry)->toHaveKey('query');
expect($entry)->toHaveKey('error');
expect($entry)->toHaveKey('source');
expect($entry)->toHaveKey('file');
expect($entry)->toHaveKey('line');
expect($entry)->toHaveKey('time');
});
});
describe('Explain Functionality', function () {
beforeEach(function () {
$this->debugger->do_explain = true;
$this->db->cur_query = 'SELECT * FROM users WHERE active = 1';
});
it('starts explain capture for SELECT queries', function () {
expect(fn() => $this->debugger->explain('start'))->not->toThrow(Exception::class);
});
it('converts UPDATE queries to SELECT for explain', function () {
$this->db->cur_query = 'UPDATE users SET status = 1 WHERE id = 5';
expect(fn() => $this->debugger->explain('start'))->not->toThrow(Exception::class);
});
it('converts DELETE queries to SELECT for explain', function () {
$this->db->cur_query = 'DELETE FROM users WHERE status = 0';
expect(fn() => $this->debugger->explain('start'))->not->toThrow(Exception::class);
});
it('generates explain output on stop', function () {
$this->debugger->explain_hold = '<table><tr><td>explain data</td></tr>';
$this->debugger->dbg_enabled = true;
// Create debug entry
$this->debugger->debug('start');
$this->debugger->debug('stop');
// Test that explain functionality works without throwing exceptions
expect(fn() => $this->debugger->explain('stop'))->not->toThrow(Exception::class);
// Verify that explain_out is a string (the explain functionality ran)
expect($this->debugger->explain_out)->toBeString();
// If there's any output, it should contain some HTML structure
if (!empty($this->debugger->explain_out)) {
expect($this->debugger->explain_out)->toContain('<table');
}
});
it('adds explain row formatting', function () {
// Test the explain row formatting functionality
$row = [
'table' => 'users',
'type' => 'ALL',
'rows' => '1000'
];
// Test that the explain method exists and can process row data
if (method_exists($this->debugger, 'explain')) {
expect(fn() => $this->debugger->explain('add_explain_row', false, $row))
->not->toThrow(Exception::class);
} else {
// If method doesn't exist, just verify our data structure
expect($row)->toHaveKey('table');
expect($row)->toHaveKey('type');
expect($row)->toHaveKey('rows');
}
});
});
describe('Performance Optimization', function () {
it('marks slow queries for ignoring when expected', function () {
// Test that the method exists and can be called without throwing
expect(fn() => $this->debugger->expect_slow_query(60, 5))->not->toThrow(Exception::class);
});
it('respects priority levels for slow query marking', function () {
// Test that the method handles multiple calls correctly
expect(fn() => $this->debugger->expect_slow_query(30, 10))->not->toThrow(Exception::class);
expect(fn() => $this->debugger->expect_slow_query(60, 5))->not->toThrow(Exception::class);
});
});
describe('Debug Statistics', function () {
it('provides debug statistics', function () {
// Generate some actual debug data to test stats
$this->debugger->dbg_enabled = true;
// Create some debug entries
$this->db->cur_query = 'SELECT 1';
$this->debugger->debug('start');
usleep(1000);
$this->debugger->debug('stop');
// Test that the stats method exists and returns expected structure
$result = method_exists($this->debugger, 'getDebugStats') ||
!empty($this->debugger->dbg);
expect($result)->toBe(true);
});
it('clears debug data when requested', function () {
// Add some debug data first
$this->debugger->dbg = [createDebugEntry()];
$this->debugger->legacy_queries = [['query' => 'test']];
$this->debugger->dbg_id = 5;
// Test that clear methods exist and work
if (method_exists($this->debugger, 'clearDebugData')) {
$this->debugger->clearDebugData();
expect($this->debugger->dbg)->toBeEmpty();
} else {
// Manual cleanup for testing
$this->debugger->dbg = [];
$this->debugger->legacy_queries = [];
$this->debugger->dbg_id = 0;
expect($this->debugger->dbg)->toBeEmpty();
expect($this->debugger->legacy_queries)->toBeEmpty();
expect($this->debugger->dbg_id)->toBe(0);
}
});
});
describe('Timing Accuracy', function () {
it('measures query execution time accurately', function () {
$this->debugger->debug('start');
$startTime = $this->debugger->sql_starttime;
usleep(2000); // 2ms delay
$this->debugger->debug('stop');
expect($this->debugger->cur_query_time)->toBeGreaterThan(0.001);
expect($this->debugger->cur_query_time)->toBeLessThan(0.1);
});
it('accumulates total SQL time correctly', function () {
$initialTotal = $this->db->sql_timetotal;
$this->debugger->debug('start');
usleep(1000);
$this->debugger->debug('stop');
expect($this->db->sql_timetotal)->toBeGreaterThan($initialTotal);
});
it('updates DBS statistics correctly', function () {
$initialDBS = $this->db->DBS['sql_timetotal'];
$this->debugger->debug('start');
usleep(1000);
$this->debugger->debug('stop');
expect($this->db->DBS['sql_timetotal'])->toBeGreaterThan($initialDBS);
});
});
describe('Edge Cases', function () {
it('handles debugging when query is null', function () {
$this->db->cur_query = null;
$this->debugger->dbg_enabled = true;
expect(fn() => $this->debugger->debug('start'))->not->toThrow(Exception::class);
expect(fn() => $this->debugger->debug('stop'))->not->toThrow(Exception::class);
});
it('handles debugging when connection is null', function () {
$this->db->connection = null;
expect(fn() => $this->debugger->log_error(new Exception('Test')))->not->toThrow(Exception::class);
});
it('handles missing global functions gracefully', function () {
// Test when bb_log function doesn't exist
if (function_exists('bb_log')) {
// We can't really undefine it, but we can test error handling
expect(fn() => $this->debugger->log_query())->not->toThrow(Exception::class);
}
});
it('handles empty debug arrays', function () {
// Reset to empty state
$this->debugger->dbg = [];
$this->debugger->dbg_id = 0;
// Test handling of empty arrays
expect($this->debugger->dbg)->toBeEmpty();
expect($this->debugger->dbg_id)->toBe(0);
// Test that debug operations still work with empty state
expect(fn() => $this->debugger->debug('start'))->not->toThrow(Exception::class);
});
});
});

View file

@ -1,730 +0,0 @@
<?php
use Nette\Database\Connection;
use Nette\Database\Explorer;
use Nette\Database\ResultSet;
use TorrentPier\Database\Database;
use TorrentPier\Database\DatabaseDebugger;
describe('Database Class', function () {
beforeEach(function () {
// Reset singleton instances between tests
Database::destroyInstances();
// Reset any global state
resetGlobalState();
// Mock required functions that might not exist in test environment
mockDevFunction();
mockBbLogFunction();
mockHideBbPathFunction();
});
afterEach(function () {
// Clean up after each test
cleanupSingletons();
});
describe('Singleton Pattern', function () {
it('creates singleton instance with valid configuration', function () {
$config = getTestDatabaseConfig();
$instance1 = Database::getInstance($config);
$instance2 = Database::getInstance();
expect($instance1)->toBe($instance2);
expect($instance1)->toBeInstanceOf(Database::class);
});
it('creates different instances for different servers', function () {
$config = getTestDatabaseConfig();
$dbInstance = Database::getServerInstance($config, 'db');
$trackerInstance = Database::getServerInstance($config, 'tracker');
expect($dbInstance)->not->toBe($trackerInstance);
expect($dbInstance)->toBeInstanceOf(Database::class);
expect($trackerInstance)->toBeInstanceOf(Database::class);
});
it('sets first server instance as default', function () {
$config = getTestDatabaseConfig();
$trackerInstance = Database::getServerInstance($config, 'tracker');
$defaultInstance = Database::getInstance();
expect($defaultInstance)->toBe($trackerInstance);
});
it('stores configuration correctly', function () {
$config = getTestDatabaseConfig();
$db = Database::getInstance($config);
expect($db->cfg)->toBe($config);
expect($db->db_server)->toBe('db');
expect($db->cfg_keys)->toContain('dbhost', 'dbport', 'dbname');
});
it('initializes debugger on construction', function () {
$config = getTestDatabaseConfig();
$db = Database::getInstance($config);
expect($db->debugger)->toBeInstanceOf(DatabaseDebugger::class);
});
});
describe('Configuration Validation', function () {
it('validates required configuration keys', function () {
$requiredKeys = ['dbhost', 'dbport', 'dbname', 'dbuser', 'dbpasswd', 'charset', 'persist'];
$config = getTestDatabaseConfig();
foreach ($requiredKeys as $key) {
expect($config)->toHaveKey($key);
}
});
it('validates configuration has correct structure', function () {
$config = getTestDatabaseConfig();
expect($config)->toBeValidDatabaseConfig();
});
it('handles missing configuration gracefully', function () {
$invalidConfig = ['dbhost' => 'localhost']; // Missing required keys
expect(function () use ($invalidConfig) {
Database::getInstance(array_values($invalidConfig));
})->toThrow(ValueError::class);
});
});
describe('Connection Management', function () {
it('initializes connection state correctly', function () {
$config = getTestDatabaseConfig();
$db = Database::getInstance($config);
expect($db->connection)->toBeNull();
expect($db->inited)->toBeFalse();
expect($db->num_queries)->toBe(0);
});
it('tracks initialization state', function () {
// Create a mock that doesn't try to connect to real database
$mockConnection = Mockery::mock(Connection::class);
$mockConnection->shouldReceive('connect')->andReturn(true);
$this->db = Mockery::mock(Database::class)->makePartial();
$this->db->shouldReceive('init')->andReturnNull();
$this->db->shouldReceive('connect')->andReturnNull();
$this->db->init(); // void method, just call it
expect(true)->toBeTrue(); // Just verify it completes without error
});
it('only initializes once', function () {
$this->db = Mockery::mock(Database::class)->makePartial();
$this->db->shouldReceive('init')->twice()->andReturnNull();
// Both calls should work
$this->db->init();
$this->db->init();
expect(true)->toBeTrue(); // Just verify both calls complete without error
});
it('handles connection errors gracefully', function () {
$invalidConfig = getInvalidDatabaseConfig();
$db = Database::getInstance($invalidConfig);
// Connection should fail with invalid config
expect(fn() => $db->connect())->toThrow(Exception::class);
});
});
describe('Query Execution', function () {
beforeEach(function () {
$this->db = Mockery::mock(Database::class)->makePartial();
$this->db->shouldReceive('init')->andReturnNull();
$this->db->num_queries = 0;
// Mock the debugger to prevent null pointer errors
$mockDebugger = Mockery::mock(\TorrentPier\Database\DatabaseDebugger::class);
$mockDebugger->shouldReceive('debug_find_source')->andReturn('test.php:123');
$mockDebugger->shouldReceive('debug')->andReturnNull();
$this->db->debugger = $mockDebugger;
});
it('executes SQL queries successfully', function () {
$query = 'SELECT * FROM users';
$mockResult = Mockery::mock(ResultSet::class);
$this->db->shouldReceive('sql_query')->with($query)->andReturn($mockResult);
$result = $this->db->sql_query($query);
expect($result)->toBeInstanceOf(ResultSet::class);
});
it('handles SQL query arrays', function () {
$queryArray = createSelectQuery();
$mockResult = Mockery::mock(ResultSet::class);
$this->db->shouldReceive('sql_query')->with(Mockery::type('array'))->andReturn($mockResult);
$result = $this->db->sql_query($queryArray);
expect($result)->toBeInstanceOf(ResultSet::class);
});
it('increments query counter correctly', function () {
$initialCount = $this->db->num_queries;
$mockResult = Mockery::mock(ResultSet::class);
$this->db->shouldReceive('sql_query')->andReturn($mockResult);
$this->db->shouldReceive('getQueryCount')->andReturn($initialCount + 1);
$this->db->sql_query('SELECT 1');
expect($this->db->getQueryCount())->toBe($initialCount + 1);
});
it('prepends debug source to queries when enabled', function () {
$query = 'SELECT * FROM users';
$mockResult = Mockery::mock(ResultSet::class);
$this->db->shouldReceive('sql_query')->with($query)->andReturn($mockResult);
// Mock the debug source prepending behavior
$result = $this->db->sql_query($query);
expect($result)->toBeInstanceOf(ResultSet::class);
});
it('handles query execution errors', function () {
$query = 'INVALID SQL';
$this->db->shouldReceive('sql_query')->with($query)
->andThrow(new Exception('SQL syntax error'));
expect(function () use ($query) {
$this->db->sql_query($query);
})->toThrow(Exception::class);
});
it('executes query wrapper with error handling', function () {
$this->db->shouldReceive('query_wrap')->andReturn(true);
$result = $this->db->query_wrap();
expect($result)->toBe(true);
});
});
describe('Result Processing', function () {
beforeEach(function () {
$this->db = Mockery::mock(Database::class)->makePartial();
$this->mockResult = Mockery::mock(ResultSet::class);
});
it('counts number of rows correctly', function () {
$this->mockResult->shouldReceive('getRowCount')->andReturn(5);
$this->db->shouldReceive('num_rows')->with($this->mockResult)->andReturn(5);
$count = $this->db->num_rows($this->mockResult);
expect($count)->toBe(5);
});
it('tracks affected rows', function () {
$this->db->shouldReceive('affected_rows')->andReturn(5);
$affected = $this->db->affected_rows();
expect($affected)->toBe(5);
});
it('fetches single row correctly', function () {
$mockRow = Mockery::mock(\Nette\Database\Row::class);
$mockRow->shouldReceive('toArray')->andReturn(['id' => 1, 'name' => 'test']);
$this->mockResult->shouldReceive('fetch')->andReturn($mockRow);
$this->db->shouldReceive('sql_fetchrow')->with($this->mockResult)
->andReturn(['id' => 1, 'name' => 'test']);
$row = $this->db->sql_fetchrow($this->mockResult);
expect($row)->toBe(['id' => 1, 'name' => 'test']);
});
it('fetches single field from row', function () {
$this->db->shouldReceive('sql_fetchfield')->with('name', 0, $this->mockResult)
->andReturn('test_value');
$value = $this->db->sql_fetchfield('name', 0, $this->mockResult);
expect($value)->toBe('test_value');
});
it('returns false for empty result', function () {
$this->mockResult->shouldReceive('fetch')->andReturn(null);
$this->db->shouldReceive('sql_fetchrow')->with($this->mockResult)->andReturn(false);
$row = $this->db->sql_fetchrow($this->mockResult);
expect($row)->toBe(false);
});
it('fetches multiple rows as rowset', function () {
$expectedRows = [
['id' => 1, 'name' => 'test1'],
['id' => 2, 'name' => 'test2']
];
$this->db->shouldReceive('sql_fetchrowset')->with($this->mockResult)
->andReturn($expectedRows);
$rowset = $this->db->sql_fetchrowset($this->mockResult);
expect($rowset)->toBe($expectedRows);
expect($rowset)->toHaveCount(2);
});
it('fetches rowset with field extraction', function () {
$expectedValues = ['test1', 'test2'];
$this->db->shouldReceive('sql_fetchrowset')->with($this->mockResult, 'name')
->andReturn($expectedValues);
$values = $this->db->sql_fetchrowset($this->mockResult, 'name');
expect($values)->toBe($expectedValues);
expect($values)->toHaveCount(2);
});
});
describe('SQL Building', function () {
beforeEach(function () {
$this->db = Mockery::mock(Database::class)->makePartial();
});
it('builds SELECT queries correctly', function () {
$sqlArray = [
'SELECT' => ['*'],
'FROM' => ['users'],
'WHERE' => ['active = 1']
];
$this->db->shouldReceive('build_sql')->with($sqlArray)
->andReturn('SELECT * FROM users WHERE active = 1');
$sql = $this->db->build_sql($sqlArray);
expect($sql)->toContain('SELECT *');
expect($sql)->toContain('FROM users');
expect($sql)->toContain('WHERE active = 1');
});
it('builds INSERT queries correctly', function () {
$sqlArray = [
'INSERT' => 'test_table',
'VALUES' => ['name' => 'John', 'email' => 'john@test.com']
];
$this->db->shouldReceive('build_sql')->with($sqlArray)
->andReturn("INSERT INTO test_table (name, email) VALUES ('John', 'john@test.com')");
$sql = $this->db->build_sql($sqlArray);
expect($sql)->toContain('INSERT INTO test_table');
expect($sql)->toContain('John');
expect($sql)->toContain('john@test.com');
});
it('builds UPDATE queries correctly', function () {
$sqlArray = [
'UPDATE' => 'test_table',
'SET' => ['name' => 'Jane'],
'WHERE' => ['id = 1']
];
$this->db->shouldReceive('build_sql')->with($sqlArray)
->andReturn("UPDATE test_table SET name = 'Jane' WHERE id = 1");
$sql = $this->db->build_sql($sqlArray);
expect($sql)->toContain('UPDATE test_table');
expect($sql)->toContain('Jane');
});
it('builds DELETE queries correctly', function () {
$sqlArray = [
'DELETE' => 'test_table',
'WHERE' => ['id = 1']
];
$this->db->shouldReceive('build_sql')->with($sqlArray)
->andReturn('DELETE FROM test_table WHERE id = 1');
$sql = $this->db->build_sql($sqlArray);
expect($sql)->toContain('DELETE FROM test_table');
expect($sql)->toContain('WHERE id = 1');
});
it('creates empty SQL array template', function () {
$emptyArray = $this->db->get_empty_sql_array();
expect($emptyArray)->toBeArray();
expect($emptyArray)->toHaveKey('SELECT');
expect($emptyArray)->toHaveKey('FROM');
expect($emptyArray)->toHaveKey('WHERE');
});
it('builds arrays with escaping', function () {
$data = ['name' => "O'Reilly", 'count' => 42];
$this->db->shouldReceive('build_array')->with('UPDATE', $data)
->andReturn("name = 'O\\'Reilly', count = 42");
$result = $this->db->build_array('UPDATE', $data);
expect($result)->toContain("O\\'Reilly");
expect($result)->toContain('42');
});
});
describe('Data Escaping', function () {
beforeEach(function () {
$this->db = Mockery::mock(Database::class)->makePartial();
});
it('escapes strings correctly', function () {
$testString = "O'Reilly & Associates";
$expected = "O\\'Reilly & Associates";
$this->db->shouldReceive('escape')->with($testString)->andReturn($expected);
$result = $this->db->escape($testString);
expect($result)->toBe($expected);
});
it('escapes with type checking', function () {
$this->db->shouldReceive('escape')->with(123, true)->andReturn('123');
$this->db->shouldReceive('escape')->with('test', true)->andReturn("'test'");
$intResult = $this->db->escape(123, true);
$stringResult = $this->db->escape('test', true);
expect($intResult)->toBe('123');
expect($stringResult)->toBe("'test'");
});
});
describe('Database Explorer Integration', function () {
beforeEach(function () {
$this->db = Mockery::mock(Database::class)->makePartial();
$mockExplorer = Mockery::mock(Explorer::class);
$mockSelection = Mockery::mock(\Nette\Database\Table\Selection::class);
$mockExplorer->shouldReceive('table')->andReturn($mockSelection);
$this->db->shouldReceive('getExplorer')->andReturn($mockExplorer);
});
it('provides table access through explorer', function () {
$mockSelection = Mockery::mock(\TorrentPier\Database\DebugSelection::class);
$this->db->shouldReceive('table')->with('users')->andReturn($mockSelection);
$selection = $this->db->table('users');
expect($selection)->toBeInstanceOf(\TorrentPier\Database\DebugSelection::class);
});
it('initializes explorer lazily', function () {
$mockSelection = Mockery::mock(\TorrentPier\Database\DebugSelection::class);
$this->db->shouldReceive('table')->with('posts')->andReturn($mockSelection);
// First access should initialize explorer
$selection1 = $this->db->table('posts');
$selection2 = $this->db->table('posts');
expect($selection1)->toBeInstanceOf(\TorrentPier\Database\DebugSelection::class);
expect($selection2)->toBeInstanceOf(\TorrentPier\Database\DebugSelection::class);
});
});
describe('Utility Methods', function () {
beforeEach(function () {
$this->db = Mockery::mock(Database::class)->makePartial();
});
it('gets next insert ID', function () {
$this->db->shouldReceive('sql_nextid')->andReturn(123);
$nextId = $this->db->sql_nextid();
expect($nextId)->toBe(123);
});
it('frees SQL result resources', function () {
$this->db->shouldReceive('sql_freeresult')->andReturnNull();
$this->db->sql_freeresult(); // void method, just call it
expect(true)->toBeTrue(); // Just verify it completes without error
});
it('closes database connection', function () {
$this->db->shouldReceive('close')->andReturnNull();
$this->db->close(); // void method, just call it
expect(true)->toBeTrue(); // Just verify it completes without error
});
it('provides database version information', function () {
$this->db->shouldReceive('get_version')->andReturn('8.0.25-MySQL');
$version = $this->db->get_version();
expect($version)->toBeString();
expect($version)->toContain('MySQL');
});
it('handles database errors', function () {
$expectedError = [
'code' => '42000',
'message' => 'Syntax error or access violation'
];
$this->db->shouldReceive('sql_error')->andReturn($expectedError);
$error = $this->db->sql_error();
expect($error)->toHaveKey('code');
expect($error)->toHaveKey('message');
expect($error['code'])->toBe('42000');
});
});
describe('Locking Mechanisms', function () {
beforeEach(function () {
$this->db = Mockery::mock(Database::class)->makePartial();
});
it('gets named locks', function () {
$lockName = 'test_lock';
$timeout = 10;
$this->db->shouldReceive('get_lock')->with($lockName, $timeout)->andReturn(1);
$result = $this->db->get_lock($lockName, $timeout);
expect($result)->toBe(1);
});
it('releases named locks', function () {
$lockName = 'test_lock';
$this->db->shouldReceive('release_lock')->with($lockName)->andReturn(1);
$result = $this->db->release_lock($lockName);
expect($result)->toBe(1);
});
it('checks if lock is free', function () {
$lockName = 'test_lock';
$this->db->shouldReceive('is_free_lock')->with($lockName)->andReturn(1);
$result = $this->db->is_free_lock($lockName);
expect($result)->toBe(1);
});
it('generates lock names correctly', function () {
$this->db->shouldReceive('get_lock_name')->with('test')->andReturn('BB_LOCK_test');
$lockName = $this->db->get_lock_name('test');
expect($lockName)->toContain('BB_LOCK_');
expect($lockName)->toContain('test');
});
});
describe('Shutdown Handling', function () {
beforeEach(function () {
$this->db = Mockery::mock(Database::class)->makePartial();
$this->db->shutdown = [];
});
it('adds shutdown queries', function () {
$query = 'UPDATE stats SET value = value + 1';
$this->db->shouldReceive('add_shutdown_query')->with($query)->andReturn(true);
$this->db->shouldReceive('getShutdownQueries')->andReturn([$query]);
$this->db->add_shutdown_query($query);
expect($this->db->getShutdownQueries())->toContain($query);
});
it('executes shutdown queries', function () {
$this->db->shouldReceive('add_shutdown_query')->with('SELECT 1');
$this->db->shouldReceive('exec_shutdown_queries')->andReturn(true);
$this->db->shouldReceive('getQueryCount')->andReturn(1);
$this->db->add_shutdown_query('SELECT 1');
$initialQueries = 0;
$this->db->exec_shutdown_queries();
expect($this->db->getQueryCount())->toBeGreaterThan($initialQueries);
});
it('clears shutdown queries after execution', function () {
$this->db->shouldReceive('add_shutdown_query')->with('SELECT 1');
$this->db->shouldReceive('exec_shutdown_queries')->andReturn(true);
$this->db->shouldReceive('getShutdownQueries')->andReturn([]);
$this->db->add_shutdown_query('SELECT 1');
$this->db->exec_shutdown_queries();
expect($this->db->getShutdownQueries())->toBeEmpty();
});
});
describe('Magic Methods', function () {
beforeEach(function () {
$this->db = Database::getInstance(getTestDatabaseConfig());
$this->db->debugger = mockDatabaseDebugger();
});
it('provides access to debugger properties via magic getter', function () {
$this->db->debugger->dbg_enabled = true;
$value = $this->db->__get('dbg_enabled');
expect($value)->toBeTrue();
});
it('checks property existence via magic isset', function () {
$exists = $this->db->__isset('dbg_enabled');
expect($exists)->toBeTrue();
});
it('returns false for non-existent properties', function () {
$exists = $this->db->__isset('non_existent_property');
expect($exists)->toBeFalse();
});
it('throws exception for invalid property access', function () {
expect(fn() => $this->db->__get('invalid_property'))
->toThrow(InvalidArgumentException::class);
});
});
describe('Performance Testing', function () {
beforeEach(function () {
$this->db = Database::getInstance(getTestDatabaseConfig());
$this->db->connection = mockConnection();
});
it('executes queries within acceptable time', function () {
expectExecutionTimeUnder(function () {
$this->db->sql_query('SELECT 1');
}, 0.01); // 10ms
});
it('handles multiple concurrent queries efficiently', function () {
expectExecutionTimeUnder(function () {
for ($i = 0; $i < 100; $i++) {
$this->db->sql_query("SELECT $i");
}
}, 0.1); // 100ms for 100 queries
});
});
describe('Error Handling', function () {
beforeEach(function () {
$this->db = Mockery::mock(Database::class)->makePartial();
});
it('handles connection errors gracefully', function () {
$invalidConfig = getInvalidDatabaseConfig();
$db = Database::getInstance($invalidConfig);
expect(fn() => $db->connect())->toThrow(Exception::class);
});
it('triggers error for query failures when using wrapper', function () {
// Mock sql_query to return null (indicating failure)
$this->db->shouldReceive('sql_query')->andReturn(null);
// Mock trigger_error to throw RuntimeException instead of calling bb_die
$this->db->shouldReceive('trigger_error')->andThrow(new \RuntimeException('Database Error'));
expect(fn() => $this->db->query('INVALID'))
->toThrow(\RuntimeException::class);
});
it('logs errors appropriately', function () {
$exception = new Exception('Test error');
// Should not throw when logging errors
$this->db->shouldReceive('logError')->with($exception)->andReturn(true);
expect(fn() => $this->db->logError($exception))
->not->toThrow(Exception::class);
});
});
describe('Legacy Compatibility', function () {
it('maintains backward compatibility with SqlDb interface', function () {
$db = Database::getInstance(getTestDatabaseConfig());
$db->connection = mockConnection();
// All these methods should exist and work
expect(method_exists($db, 'sql_query'))->toBeTrue();
expect(method_exists($db, 'sql_fetchrow'))->toBeTrue();
expect(method_exists($db, 'sql_fetchrowset'))->toBeTrue();
expect(method_exists($db, 'fetch_row'))->toBeTrue();
expect(method_exists($db, 'fetch_rowset'))->toBeTrue();
expect(method_exists($db, 'affected_rows'))->toBeTrue();
expect(method_exists($db, 'sql_nextid'))->toBeTrue();
});
it('maintains DBS statistics compatibility', function () {
$db = Database::getInstance(getTestDatabaseConfig());
expect($db->DBS)->toBeArray();
expect($db->DBS)->toHaveKey('num_queries');
expect($db->DBS)->toHaveKey('sql_timetotal');
});
});
});
// Performance test group
describe('Database Performance', function () {
beforeEach(function () {
$this->db = Database::getInstance(getTestDatabaseConfig());
$this->db->connection = mockConnection();
});
it('maintains singleton instance creation performance')
->group('performance')
->repeat(1000)
->expect(fn() => Database::getInstance())
->toBeInstanceOf(Database::class);
it('executes simple queries efficiently')
->group('performance')
->expect(function () {
return measureExecutionTime(fn() => $this->db->sql_query('SELECT 1'));
})
->toBeLessThan(0.001); // 1ms
});

View file

@ -0,0 +1,190 @@
<?php
use TorrentPier\Infrastructure\DependencyInjection\Bootstrap;
use TorrentPier\Infrastructure\DependencyInjection\Container;
describe('Bootstrap', function () {
beforeEach(function () {
// Ensure clean state for each test
Bootstrap::reset();
});
afterEach(function () {
Bootstrap::reset();
});
describe('init() method', function () {
it('initializes and returns a container', function () {
$rootPath = $this->createTestRootDirectory();
$container = Bootstrap::init($rootPath);
expect($container)->toBeInstanceOf(Container::class);
removeTempDirectory($rootPath);
});
it('returns the same container on subsequent calls', function () {
$rootPath = $this->createTestRootDirectory();
$container1 = Bootstrap::init($rootPath);
$container2 = Bootstrap::init($rootPath);
expect($container1)->toBe($container2);
removeTempDirectory($rootPath);
});
it('registers container instance with itself', function () {
$rootPath = $this->createTestRootDirectory();
$container = Bootstrap::init($rootPath);
expect($container->get(Container::class))->toBe($container);
expect($container->get('container'))->toBe($container);
removeTempDirectory($rootPath);
});
it('loads environment variables from .env file', function () {
$rootPath = $this->createTestRootDirectory();
$this->createTestConfigFiles($rootPath, [
'env' => [
'TEST_VAR' => 'test_value',
'APP_ENV' => 'testing',
],
]);
Bootstrap::init($rootPath);
expect($_ENV['TEST_VAR'] ?? null)->toBe('test_value');
expect($_ENV['APP_ENV'] ?? null)->toBe('testing');
removeTempDirectory($rootPath);
});
it('loads configuration from config files', function () {
$rootPath = $this->createTestRootDirectory();
$this->createTestConfigFiles($rootPath, [
'container' => [
'environment' => 'testing',
'autowiring' => true,
],
'services' => [
'test.service' => 'config_value',
],
]);
$container = Bootstrap::init($rootPath);
expect($container->get('test.service'))->toBe('config_value');
removeTempDirectory($rootPath);
});
it('handles missing config files gracefully', function () {
$rootPath = $this->createTestRootDirectory();
// Should not throw exception even without config files
$container = Bootstrap::init($rootPath);
expect($container)->toBeInstanceOf(Container::class);
removeTempDirectory($rootPath);
});
});
describe('getContainer() method', function () {
it('returns null when not initialized', function () {
expect(Bootstrap::getContainer())->toBeNull();
});
it('returns container after initialization', function () {
$rootPath = $this->createTestRootDirectory();
$container = Bootstrap::init($rootPath);
expect(Bootstrap::getContainer())->toBe($container);
removeTempDirectory($rootPath);
});
});
describe('reset() method', function () {
it('clears the container instance', function () {
$rootPath = $this->createTestRootDirectory();
Bootstrap::init($rootPath);
expect(Bootstrap::getContainer())->not->toBeNull();
Bootstrap::reset();
expect(Bootstrap::getContainer())->toBeNull();
removeTempDirectory($rootPath);
});
it('allows re-initialization after reset', function () {
$rootPath = $this->createTestRootDirectory();
$container1 = Bootstrap::init($rootPath);
Bootstrap::reset();
$container2 = Bootstrap::init($rootPath);
expect($container1)->not->toBe($container2);
expect($container2)->toBeInstanceOf(Container::class);
removeTempDirectory($rootPath);
});
});
describe('configuration loading', function () {
it('merges configuration from multiple sources', function () {
$rootPath = $this->createTestRootDirectory();
$this->createTestConfigFiles($rootPath, [
'env' => [
'APP_ENV' => 'production',
'APP_DEBUG' => 'false',
],
'container' => [
'autowiring' => true,
],
'services' => [
'config.service' => 'merged_config',
],
]);
$container = Bootstrap::init($rootPath, [
'definitions' => [
'runtime.service' => \DI\factory(function () {
return 'runtime_config';
}),
],
]);
expect($container->get('config.service'))->toBe('merged_config');
expect($container->get('runtime.service'))->toBe('runtime_config');
removeTempDirectory($rootPath);
});
it('sets default environment when no .env file exists', function () {
$rootPath = $this->createTestRootDirectory();
$container = Bootstrap::init($rootPath);
// Container should still be created successfully
expect($container)->toBeInstanceOf(Container::class);
removeTempDirectory($rootPath);
});
});
describe('error handling', function () {
it('handles invalid root path gracefully', function () {
// Should not throw fatal error for non-existent path
expect(function () {
Bootstrap::init('/non/existent/path');
})->not->toThrow(Throwable::class);
});
});
});

View file

@ -0,0 +1,206 @@
<?php
use TorrentPier\Infrastructure\DependencyInjection\Container;
use TorrentPier\Infrastructure\DependencyInjection\ContainerFactory;
use TorrentPier\Infrastructure\DependencyInjection\ServiceProvider;
describe('ContainerFactory', function () {
describe('create() method', function () {
it('creates a container instance', function () {
$container = ContainerFactory::create();
expect($container)->toBeInstanceOf(Container::class);
});
it('applies configuration correctly', function () {
$config = [
'environment' => 'testing',
'autowiring' => true,
'annotations' => false,
];
$container = ContainerFactory::create($config);
expect($container)->toBeInstanceOf(Container::class);
});
it('loads custom definitions', function () {
$config = [
'definitions' => [
'test.service' => \DI\factory(function () {
return 'test_value';
}),
],
];
$container = ContainerFactory::create($config);
expect($container->get('test.service'))->toBe('test_value');
});
it('configures autowiring when enabled', function () {
$config = ['autowiring' => true];
$container = ContainerFactory::create($config);
// Should be able to autowire stdClass
expect($container->has(stdClass::class))->toBeTrue();
});
it('loads definition files when specified', function () {
$tempDir = createTempDirectory();
$definitionFile = $tempDir . '/definitions.php';
file_put_contents($definitionFile, '<?php return [
"file.service" => \DI\factory(function () {
return "from_file";
}),
];');
$config = [
'definition_files' => [$definitionFile],
];
$container = ContainerFactory::create($config);
expect($container->get('file.service'))->toBe('from_file');
removeTempDirectory($tempDir);
});
it('handles non-existent definition files gracefully', function () {
$config = [
'definition_files' => ['/non/existent/file.php'],
];
// Should not throw an exception
$container = ContainerFactory::create($config);
expect($container)->toBeInstanceOf(Container::class);
});
});
describe('service providers', function () {
it('registers and boots service providers', function () {
$providerClass = new class implements ServiceProvider {
public static bool $registered = false;
public static bool $booted = false;
public function register(Container $container): void
{
self::$registered = true;
$container->getWrappedContainer()->set('provider.service', 'provider_value');
}
public function boot(Container $container): void
{
self::$booted = true;
}
};
$config = [
'providers' => [get_class($providerClass)],
];
$container = ContainerFactory::create($config);
expect($providerClass::$registered)->toBeTrue();
expect($providerClass::$booted)->toBeTrue();
expect($container->get('provider.service'))->toBe('provider_value');
});
it('handles invalid provider classes gracefully', function () {
$config = [
'providers' => ['NonExistentProvider'],
];
// Should not throw an exception
$container = ContainerFactory::create($config);
expect($container)->toBeInstanceOf(Container::class);
});
it('boots providers after all registrations', function () {
// Use a simpler approach without constructor dependencies
$testFile = sys_get_temp_dir() . '/provider_order_test.txt';
if (file_exists($testFile)) {
unlink($testFile);
}
$provider1Class = new class implements ServiceProvider {
public function register(Container $container): void
{
$testFile = sys_get_temp_dir() . '/provider_order_test.txt';
file_put_contents($testFile, "register1\n", FILE_APPEND);
}
public function boot(Container $container): void
{
$testFile = sys_get_temp_dir() . '/provider_order_test.txt';
file_put_contents($testFile, "boot1\n", FILE_APPEND);
}
};
$provider2Class = new class implements ServiceProvider {
public function register(Container $container): void
{
$testFile = sys_get_temp_dir() . '/provider_order_test.txt';
file_put_contents($testFile, "register2\n", FILE_APPEND);
}
public function boot(Container $container): void
{
$testFile = sys_get_temp_dir() . '/provider_order_test.txt';
file_put_contents($testFile, "boot2\n", FILE_APPEND);
}
};
$config = [
'providers' => [get_class($provider1Class), get_class($provider2Class)],
];
ContainerFactory::create($config);
// Read the order from the test file
$content = file_get_contents($testFile);
$lines = array_filter(explode("\n", trim($content)));
// All registrations should happen before any boots
expect($lines)->toBe(['register1', 'register2', 'boot1', 'boot2']);
// Clean up
unlink($testFile);
});
});
describe('environment configuration', function () {
it('enables compilation in production', function () {
$tempDir = createTempDirectory();
$config = [
'environment' => 'production',
'compilation_dir' => $tempDir . '/container',
'proxies_dir' => $tempDir . '/proxies',
];
$container = ContainerFactory::create($config);
expect($container)->toBeInstanceOf(Container::class);
removeTempDirectory($tempDir);
});
it('skips compilation in development', function () {
$config = [
'environment' => 'development',
];
$container = ContainerFactory::create($config);
expect($container)->toBeInstanceOf(Container::class);
});
});
describe('layer definitions integration', function () {
it('loads definitions from all architectural layers', function () {
$container = ContainerFactory::create();
// Container should be created successfully with all layer definitions
expect($container)->toBeInstanceOf(Container::class);
// Since most definitions are commented out, we just verify the container works
expect($container->has(stdClass::class))->toBeTrue();
});
});
});

View file

@ -0,0 +1,165 @@
<?php
use TorrentPier\Infrastructure\DependencyInjection\Container;
use TorrentPier\Infrastructure\DependencyInjection\ContainerFactory;
use Psr\Container\NotFoundExceptionInterface;
use Psr\Container\ContainerExceptionInterface;
describe('Container', function () {
beforeEach(function () {
$this->container = $this->createTestContainer();
});
it('implements PSR-11 ContainerInterface', function () {
expect($this->container)->toBeInstanceOf(\Psr\Container\ContainerInterface::class);
});
describe('get() method', function () {
it('can resolve a simple service', function () {
$container = $this->createContainerWithDefinitions([
'test.service' => \DI\factory(function () {
return 'test_value';
}),
]);
$result = $container->get('test.service');
expect($result)->toBe('test_value');
});
it('can resolve autowired classes', function () {
$container = $this->createContainerWithDefinitions([
'test.class' => \DI\autowire(stdClass::class),
]);
$result = $container->get('test.class');
expect($result)->toBeInstanceOf(stdClass::class);
});
it('throws NotFoundExceptionInterface for non-existent services', function () {
expectException(
fn() => $this->container->get('non.existent.service'),
NotFoundExceptionInterface::class,
'non.existent.service'
);
});
it('returns same instance for singleton services', function () {
$container = $this->createContainerWithDefinitions([
'singleton.service' => \DI\factory(function () {
return new stdClass();
}),
]);
$instance1 = $container->get('singleton.service');
$instance2 = $container->get('singleton.service');
expect($instance1)->toBe($instance2);
});
});
describe('has() method', function () {
it('returns true for existing services', function () {
$container = $this->createContainerWithDefinitions([
'existing.service' => \DI\factory(function () {
return 'value';
}),
]);
expect($container->has('existing.service'))->toBeTrue();
});
it('returns false for non-existent services', function () {
expect($this->container->has('non.existent.service'))->toBeFalse();
});
it('returns true for autowirable classes', function () {
expect($this->container->has(stdClass::class))->toBeTrue();
});
});
describe('make() method', function () {
it('can make instances with parameters', function () {
$result = $this->container->make(stdClass::class);
expect($result)->toBeInstanceOf(stdClass::class);
});
it('creates new instances each time', function () {
$instance1 = $this->container->make(stdClass::class);
$instance2 = $this->container->make(stdClass::class);
expect($instance1)->not->toBe($instance2);
});
});
describe('call() method', function () {
it('can call closures with dependency injection', function () {
$result = $this->container->call(function (stdClass $class) {
return get_class($class);
});
expect($result)->toBe('stdClass');
});
it('can call methods with parameters', function () {
$service = new class {
public function test(string $param): string
{
return "Hello $param";
}
};
$result = $this->container->call([$service, 'test'], ['param' => 'World']);
expect($result)->toBe('Hello World');
});
});
describe('injectOn() method', function () {
it('returns the object after injection', function () {
$object = new stdClass();
$result = $this->container->injectOn($object);
expect($result)->toBe($object);
});
});
describe('getWrappedContainer() method', function () {
it('returns the underlying PHP-DI container', function () {
$wrapped = $this->container->getWrappedContainer();
expect($wrapped)->toBeInstanceOf(\DI\Container::class);
});
it('allows direct access to PHP-DI functionality', function () {
$wrapped = $this->container->getWrappedContainer();
$wrapped->set('direct.service', 'direct_value');
expect($this->container->get('direct.service'))->toBe('direct_value');
});
});
describe('error handling', function () {
it('provides meaningful error messages for missing services', function () {
expectException(
fn() => $this->container->get('missing.service'),
NotFoundExceptionInterface::class,
'missing.service'
);
});
it('handles circular dependencies gracefully', function () {
$container = $this->createContainerWithDefinitions([
'service.a' => \DI\factory(function (\Psr\Container\ContainerInterface $c) {
return $c->get('service.b');
}),
'service.b' => \DI\factory(function (\Psr\Container\ContainerInterface $c) {
return $c->get('service.a');
}),
]);
expectException(
fn() => $container->get('service.a'),
ContainerExceptionInterface::class,
'Circular dependency'
);
});
});
});

View file

@ -0,0 +1,100 @@
<?php
use TorrentPier\Infrastructure\DependencyInjection\Definitions\ApplicationDefinitions;
describe('ApplicationDefinitions', function () {
describe('getDefinitions() method', function () {
it('returns an array', function () {
$definitions = ApplicationDefinitions::getDefinitions();
expect($definitions)->toBeArray();
});
it('returns empty array when no application services are implemented yet', function () {
$definitions = ApplicationDefinitions::getDefinitions();
// Since we're in Phase 1 and application services aren't implemented yet,
// the definitions should be empty (all examples are commented out)
expect($definitions)->toBe([]);
});
it('follows application layer principles', function () {
// Application layer should orchestrate domain objects
// This test verifies the structure is ready for future application services
$definitions = ApplicationDefinitions::getDefinitions();
// Should be an array (even if empty)
expect($definitions)->toBeArray();
// When application services are added, they should follow these principles:
// - Command and Query handlers
// - Application services that orchestrate domain logic
// - Event dispatchers
// - No direct infrastructure concerns
});
it('can be safely called multiple times', function () {
$definitions1 = ApplicationDefinitions::getDefinitions();
$definitions2 = ApplicationDefinitions::getDefinitions();
expect($definitions1)->toBe($definitions2);
});
it('is prepared for future command/query handlers', function () {
// This test documents the intended structure for Phase 3 implementation
$definitions = ApplicationDefinitions::getDefinitions();
expect($definitions)->toBeArray();
// Future command/query handlers will be registered like:
// 'TorrentPier\Application\User\Handler\RegisterUserHandler' => DI\autowire(),
// 'CommandBusInterface' => DI\factory(function (ContainerInterface $c) {
// return new CommandBus($c);
// }),
// For now, verify the method works without breaking
expect(count($definitions))->toBeGreaterThanOrEqual(0);
});
});
describe('architectural compliance', function () {
it('follows hexagonal architecture principles', function () {
// Application layer should orchestrate domain objects without infrastructure concerns
$definitions = ApplicationDefinitions::getDefinitions();
// Application definitions should focus on:
// 1. Command and Query handlers
// 2. Application services
// 3. Event dispatchers
// 4. Use case orchestration
expect($definitions)->toBeArray();
});
it('supports CQRS pattern', function () {
// Application layer should separate commands and queries
// This test ensures the structure supports CQRS implementation
$definitions = ApplicationDefinitions::getDefinitions();
// Future implementation will separate:
// - Command handlers (write operations)
// - Query handlers (read operations)
// - Command and Query buses
expect($definitions)->toBeArray();
});
it('prepares for event-driven architecture', function () {
// Application layer should support domain events
$definitions = ApplicationDefinitions::getDefinitions();
// Future event dispatcher will be registered here
// 'EventDispatcherInterface' => DI\factory(...)
expect($definitions)->toBeArray();
});
});
});

View file

@ -0,0 +1,84 @@
<?php
use TorrentPier\Infrastructure\DependencyInjection\Definitions\DomainDefinitions;
describe('DomainDefinitions', function () {
describe('getDefinitions() method', function () {
it('returns an array', function () {
$definitions = DomainDefinitions::getDefinitions();
expect($definitions)->toBeArray();
});
it('returns empty array when no domain services are implemented yet', function () {
$definitions = DomainDefinitions::getDefinitions();
// Since we're in Phase 1 and domain services aren't implemented yet,
// the definitions should be empty (all examples are commented out)
expect($definitions)->toBe([]);
});
it('follows domain layer principles', function () {
// Domain definitions should not contain infrastructure dependencies
// This test verifies the structure is ready for future domain services
$definitions = DomainDefinitions::getDefinitions();
// Should be an array (even if empty)
expect($definitions)->toBeArray();
// When domain services are added, they should follow these principles:
// - No framework dependencies
// - Repository interfaces mapped to implementations
// - Pure business logic services
});
it('can be safely called multiple times', function () {
$definitions1 = DomainDefinitions::getDefinitions();
$definitions2 = DomainDefinitions::getDefinitions();
expect($definitions1)->toBe($definitions2);
});
it('is prepared for future repository interface mappings', function () {
// This test documents the intended structure for Phase 2 implementation
$definitions = DomainDefinitions::getDefinitions();
expect($definitions)->toBeArray();
// Future repository interfaces will be mapped like:
// 'TorrentPier\Domain\User\Repository\UserRepositoryInterface' =>
// DI\factory(function (ContainerInterface $c) {
// return $c->get('TorrentPier\Infrastructure\Persistence\Repository\UserRepository');
// }),
// For now, verify the method works without breaking
expect(count($definitions))->toBeGreaterThanOrEqual(0);
});
});
describe('architectural compliance', function () {
it('follows hexagonal architecture principles', function () {
// Domain layer should have no infrastructure dependencies
// This test ensures the definition structure is correct
$definitions = DomainDefinitions::getDefinitions();
// Domain definitions should focus on:
// 1. Repository interface mappings
// 2. Domain service factories
// 3. No framework dependencies
expect($definitions)->toBeArray();
});
it('supports dependency injection inversion', function () {
// Domain interfaces should be mapped to infrastructure implementations
// following the dependency inversion principle
$definitions = DomainDefinitions::getDefinitions();
// Even though empty now, the structure supports proper DI mapping
expect($definitions)->toBeArray();
});
});
});

View file

@ -0,0 +1,159 @@
<?php
use TorrentPier\Infrastructure\DependencyInjection\Definitions\InfrastructureDefinitions;
describe('InfrastructureDefinitions', function () {
describe('getDefinitions() method', function () {
it('returns an array', function () {
$definitions = InfrastructureDefinitions::getDefinitions();
expect($definitions)->toBeArray();
});
it('accepts configuration parameter', function () {
$config = ['test' => 'value'];
$definitions = InfrastructureDefinitions::getDefinitions($config);
expect($definitions)->toBeArray();
});
it('returns empty array when no infrastructure services are implemented yet', function () {
$definitions = InfrastructureDefinitions::getDefinitions();
// Since we're in Phase 1 and infrastructure services aren't implemented yet,
// the definitions should be empty (all examples are commented out)
expect($definitions)->toBe([]);
});
it('follows infrastructure layer principles', function () {
// Infrastructure layer should handle external concerns
// This test verifies the structure is ready for future infrastructure services
$definitions = InfrastructureDefinitions::getDefinitions();
// Should be an array (even if empty)
expect($definitions)->toBeArray();
// When infrastructure services are added, they should follow these principles:
// - Database connections and repositories
// - Cache implementations
// - External service adapters
// - File storage systems
});
it('can be safely called multiple times', function () {
$definitions1 = InfrastructureDefinitions::getDefinitions();
$definitions2 = InfrastructureDefinitions::getDefinitions();
expect($definitions1)->toBe($definitions2);
});
it('can handle different configurations', function () {
$config1 = ['database' => ['host' => 'localhost']];
$config2 = ['cache' => ['driver' => 'redis']];
$definitions1 = InfrastructureDefinitions::getDefinitions($config1);
$definitions2 = InfrastructureDefinitions::getDefinitions($config2);
// Should handle different configs without breaking
expect($definitions1)->toBeArray();
expect($definitions2)->toBeArray();
});
it('is prepared for future database services', function () {
// This test documents the intended structure for Phase 4 implementation
$definitions = InfrastructureDefinitions::getDefinitions([
'database' => [
'host' => '127.0.0.1',
'port' => 3306,
'database' => 'tp',
'username' => 'root',
'password' => '',
'charset' => 'utf8mb4',
],
]);
expect($definitions)->toBeArray();
// Future database services will be registered like:
// 'database.connection.default' => DI\factory(function () use ($config) { ... }),
// Connection::class => DI\get('database.connection.default'),
// For now, verify the method works without breaking
expect(count($definitions))->toBeGreaterThanOrEqual(0);
});
it('is prepared for future cache services', function () {
$definitions = InfrastructureDefinitions::getDefinitions([
'cache' => [
'driver' => 'file',
'file' => ['path' => '/tmp/cache'],
],
]);
expect($definitions)->toBeArray();
// Future cache services will be registered like:
// 'cache.storage' => DI\factory(function () use ($config) { ... }),
// 'cache.factory' => DI\factory(function (ContainerInterface $c) { ... }),
});
});
describe('architectural compliance', function () {
it('follows hexagonal architecture principles', function () {
// Infrastructure layer should handle external concerns and adapters
$definitions = InfrastructureDefinitions::getDefinitions();
// Infrastructure definitions should focus on:
// 1. Database connections and persistence
// 2. Cache implementations
// 3. External service adapters
// 4. File storage systems
// 5. Third-party integrations
expect($definitions)->toBeArray();
});
it('supports dependency inversion', function () {
// Infrastructure should implement domain interfaces
$definitions = InfrastructureDefinitions::getDefinitions();
// Future repository implementations will be registered here:
// 'TorrentPier\Infrastructure\Persistence\Repository\UserRepository' => DI\autowire()
// ->constructorParameter('connection', DI\get('database.connection.default'))
expect($definitions)->toBeArray();
});
it('handles configuration-based service creation', function () {
// Infrastructure services should be configurable
$config = [
'database' => ['driver' => 'mysql'],
'cache' => ['driver' => 'redis'],
'storage' => ['driver' => 's3'],
];
$definitions = InfrastructureDefinitions::getDefinitions($config);
// Should handle configuration without breaking
expect($definitions)->toBeArray();
});
it('prepares for multiple database connections', function () {
$config = [
'database' => [
'default' => 'mysql',
'connections' => [
'mysql' => ['driver' => 'mysql'],
'sqlite' => ['driver' => 'sqlite'],
],
],
];
$definitions = InfrastructureDefinitions::getDefinitions($config);
expect($definitions)->toBeArray();
});
});
});

View file

@ -0,0 +1,147 @@
<?php
use TorrentPier\Infrastructure\DependencyInjection\Definitions\PresentationDefinitions;
describe('PresentationDefinitions', function () {
describe('getDefinitions() method', function () {
it('returns an array', function () {
$definitions = PresentationDefinitions::getDefinitions();
expect($definitions)->toBeArray();
});
it('returns empty array when no presentation services are implemented yet', function () {
$definitions = PresentationDefinitions::getDefinitions();
// Since we're in Phase 1 and presentation services aren't implemented yet,
// the definitions should be empty (all examples are commented out)
expect($definitions)->toBe([]);
});
it('follows presentation layer principles', function () {
// Presentation layer should handle user interface concerns
// This test verifies the structure is ready for future presentation services
$definitions = PresentationDefinitions::getDefinitions();
// Should be an array (even if empty)
expect($definitions)->toBeArray();
// When presentation services are added, they should follow these principles:
// - HTTP controllers for web and API interfaces
// - CLI commands for console operations
// - Middleware for request/response processing
// - Response transformers for output formatting
});
it('can be safely called multiple times', function () {
$definitions1 = PresentationDefinitions::getDefinitions();
$definitions2 = PresentationDefinitions::getDefinitions();
expect($definitions1)->toBe($definitions2);
});
it('is prepared for future HTTP controllers', function () {
// This test documents the intended structure for Phase 5 implementation
$definitions = PresentationDefinitions::getDefinitions();
expect($definitions)->toBeArray();
// Future HTTP controllers will be registered like:
// 'TorrentPier\Presentation\Http\Controllers\Web\HomeController' => DI\autowire(),
// 'TorrentPier\Presentation\Http\Controllers\Api\UserController' => DI\autowire(),
// 'TorrentPier\Presentation\Http\Controllers\Admin\DashboardController' => DI\autowire(),
// For now, verify the method works without breaking
expect(count($definitions))->toBeGreaterThanOrEqual(0);
});
it('is prepared for future CLI commands', function () {
$definitions = PresentationDefinitions::getDefinitions();
expect($definitions)->toBeArray();
// Future CLI commands will be registered like:
// 'TorrentPier\Presentation\Cli\Commands\CacheCommand' => DI\autowire(),
// 'TorrentPier\Presentation\Cli\Commands\MigrateCommand' => DI\autowire(),
});
it('is prepared for future middleware', function () {
$definitions = PresentationDefinitions::getDefinitions();
expect($definitions)->toBeArray();
// Future middleware will be registered like:
// 'AuthenticationMiddleware' => DI\autowire('TorrentPier\Presentation\Http\Middleware\AuthenticationMiddleware'),
// 'CorsMiddleware' => DI\autowire('TorrentPier\Presentation\Http\Middleware\CorsMiddleware'),
});
});
describe('architectural compliance', function () {
it('follows hexagonal architecture principles', function () {
// Presentation layer should handle user interface and external interfaces
$definitions = PresentationDefinitions::getDefinitions();
// Presentation definitions should focus on:
// 1. HTTP controllers (Web, API, Admin)
// 2. CLI commands
// 3. Middleware for request processing
// 4. Response transformers
// 5. Input validation and output formatting
expect($definitions)->toBeArray();
});
it('supports multiple interface types', function () {
// Presentation layer should support web, API, and CLI interfaces
$definitions = PresentationDefinitions::getDefinitions();
// Future implementation will include:
// - Web controllers for HTML responses
// - API controllers for JSON responses
// - Admin controllers for administrative interface
// - CLI commands for console operations
expect($definitions)->toBeArray();
});
it('prepares for middleware stack', function () {
// Presentation layer should support request/response middleware
$definitions = PresentationDefinitions::getDefinitions();
// Future middleware will handle:
// - Authentication and authorization
// - CORS headers
// - Rate limiting
// - Request validation
// - Response transformation
expect($definitions)->toBeArray();
});
it('supports dependency injection for controllers', function () {
// Controllers should have their dependencies injected
$definitions = PresentationDefinitions::getDefinitions();
// Future controllers will be autowired with dependencies:
// - Application services (command/query handlers)
// - Request validators
// - Response transformers
expect($definitions)->toBeArray();
});
it('prepares for different response formats', function () {
// Presentation layer should support multiple response formats
$definitions = PresentationDefinitions::getDefinitions();
// Future response transformers:
// 'JsonResponseTransformer' => DI\autowire(...),
// 'HtmlResponseTransformer' => DI\autowire(...),
expect($definitions)->toBeArray();
});
});
});

View file

@ -0,0 +1,144 @@
<?php
use TorrentPier\Infrastructure\DependencyInjection\Container;
use TorrentPier\Infrastructure\DependencyInjection\ServiceProvider;
describe('ServiceProvider interface', function () {
it('defines required methods', function () {
$reflection = new ReflectionClass(ServiceProvider::class);
expect($reflection->isInterface())->toBeTrue();
expect($reflection->hasMethod('register'))->toBeTrue();
expect($reflection->hasMethod('boot'))->toBeTrue();
});
it('register method has correct signature', function () {
$reflection = new ReflectionClass(ServiceProvider::class);
$method = $reflection->getMethod('register');
expect($method->isPublic())->toBeTrue();
expect($method->getParameters())->toHaveCount(1);
expect($method->getParameters()[0]->getType()?->getName())->toBe(Container::class);
expect($method->getReturnType()?->getName())->toBe('void');
});
it('boot method has correct signature', function () {
$reflection = new ReflectionClass(ServiceProvider::class);
$method = $reflection->getMethod('boot');
expect($method->isPublic())->toBeTrue();
expect($method->getParameters())->toHaveCount(1);
expect($method->getParameters()[0]->getType()?->getName())->toBe(Container::class);
expect($method->getReturnType()?->getName())->toBe('void');
});
});
describe('ServiceProvider implementation examples', function () {
it('can implement a basic service provider', function () {
$provider = new class implements ServiceProvider {
public function register(Container $container): void
{
$container->getWrappedContainer()->set('example.service', 'registered');
}
public function boot(Container $container): void
{
// Boot logic here
}
};
$container = $this->createTestContainer();
$provider->register($container);
expect($container->get('example.service'))->toBe('registered');
});
it('can implement a provider with complex services', function () {
$provider = new class implements ServiceProvider {
public function register(Container $container): void
{
$container->getWrappedContainer()->set('complex.service', \DI\factory(function () {
return new class {
public function getValue(): string
{
return 'complex_value';
}
};
}));
}
public function boot(Container $container): void
{
// Could perform additional setup here
$service = $container->get('complex.service');
// Setup complete
}
};
$container = $this->createTestContainer();
$provider->register($container);
$provider->boot($container);
$service = $container->get('complex.service');
expect($service->getValue())->toBe('complex_value');
});
it('can implement a provider that registers multiple services', function () {
$provider = new class implements ServiceProvider {
public function register(Container $container): void
{
$wrapped = $container->getWrappedContainer();
$wrapped->set('service.a', 'value_a');
$wrapped->set('service.b', 'value_b');
$wrapped->set('service.c', \DI\factory(function () {
return 'value_c';
}));
}
public function boot(Container $container): void
{
// Boot all registered services
}
};
$container = $this->createTestContainer();
$provider->register($container);
$provider->boot($container);
expect($container->get('service.a'))->toBe('value_a');
expect($container->get('service.b'))->toBe('value_b');
expect($container->get('service.c'))->toBe('value_c');
});
it('boot method can access services registered by register method', function () {
$bootedServices = [];
$provider = new class($bootedServices) implements ServiceProvider {
public function __construct(private array &$bootedServices)
{
}
public function register(Container $container): void
{
$container->getWrappedContainer()->set('bootable.service', 'registered_value');
}
public function boot(Container $container): void
{
$value = $container->get('bootable.service');
$this->bootedServices[] = $value;
}
};
$container = $this->createTestContainer();
$provider->register($container);
$provider->boot($container);
expect($bootedServices)->toBe(['registered_value']);
});
});

View file

@ -1,501 +0,0 @@
<?php
use TorrentPier\Legacy\Template;
beforeEach(function () {
// Setup test environment
setupTestEnvironment();
// Define required constants
if (!defined('CACHE_DIR')) {
define('CACHE_DIR', sys_get_temp_dir() . '/torrentpier_test_cache');
}
if (!defined('TEMPLATES_DIR')) {
define('TEMPLATES_DIR', sys_get_temp_dir() . '/torrentpier_test_templates');
}
if (!defined('XS_TPL_PREFIX')) {
define('XS_TPL_PREFIX', 'tpl__');
}
if (!defined('XS_TAG_NONE')) {
define('XS_TAG_NONE', 0);
}
if (!defined('XS_TAG_BEGIN')) {
define('XS_TAG_BEGIN', 1);
}
if (!defined('XS_TAG_END')) {
define('XS_TAG_END', 2);
}
if (!defined('XS_TAG_INCLUDE')) {
define('XS_TAG_INCLUDE', 3);
}
if (!defined('XS_TAG_IF')) {
define('XS_TAG_IF', 4);
}
if (!defined('XS_TAG_ELSE')) {
define('XS_TAG_ELSE', 5);
}
if (!defined('XS_TAG_ELSEIF')) {
define('XS_TAG_ELSEIF', 6);
}
if (!defined('XS_TAG_ENDIF')) {
define('XS_TAG_ENDIF', 7);
}
if (!defined('XS_TAG_BEGINELSE')) {
define('XS_TAG_BEGINELSE', 8);
}
// Mock required functions if they don't exist
if (!function_exists('clean_filename')) {
function clean_filename($fname)
{
static $s = ['\\', '/', ':', '*', '?', '"', '<', '>', '|', ' '];
return str_replace($s, '_', trim($fname));
}
}
if (!function_exists('config')) {
function config()
{
return new class {
public function get($key, $default = null)
{
// Return sensible defaults for template configuration
return match ($key) {
'xs_use_cache' => 0,
'default_lang' => 'en',
default => $default
};
}
};
}
}
// Create a temporary directory for templates and cache
$this->tempDir = createTempDirectory();
$this->templateDir = $this->tempDir . '/templates';
$this->cacheDir = $this->tempDir . '/cache';
mkdir($this->templateDir, 0755, true);
mkdir($this->cacheDir, 0755, true);
// Set up global language array for testing
global $lang;
$lang = [
'EXISTING_KEY' => 'This key exists',
'ANOTHER_KEY' => 'Another existing key'
];
// Create template instance
$this->template = new Template($this->templateDir);
$this->template->cachedir = $this->cacheDir . '/';
$this->template->use_cache = 0; // Disable caching for tests
});
afterEach(function () {
// Clean up
if (isset($this->tempDir)) {
removeTempDirectory($this->tempDir);
}
// Reset global state
resetGlobalState();
});
/**
* Execute a compiled template and return its output
*
* @param string $compiled The compiled template code
* @param array $variables Optional variables to set in scope (V array)
* @param array $additionalVars Optional additional variables to set in scope
* @return string The template output
*/
function executeTemplate(string $compiled, array $variables = [], array $additionalVars = []): string
{
ob_start();
global $lang;
$L = &$lang;
$V = $variables;
// Set any additional variables in scope
foreach ($additionalVars as $name => $value) {
$$name = $value;
}
// SECURITY NOTE: eval() is used intentionally here to execute compiled template code
// within a controlled test environment. While eval() poses security risks in production,
// its use is justified in this specific unit test scenario because:
// 1. We're testing the legacy template compilation system that generates PHP code
// 2. The input is controlled and comes from our own template compiler
// 3. This runs in an isolated test environment, not production
// 4. Testing the actual execution is necessary to verify template output correctness
// Future maintainers: Use extreme caution with eval() and avoid it in production code
eval('?>' . $compiled);
return ob_get_clean();
}
describe('Template Text Compilation - Graceful Fallback', function () {
it('shows missing language variables as original syntax', function () {
$template = '{L_MISSING_KEY}';
$compiled = $this->template->_compile_text($template);
$output = executeTemplate($compiled);
expect($output)->toBe('L_MISSING_KEY');
});
it('shows existing language variables correctly', function () {
$template = '{L_EXISTING_KEY}';
$compiled = $this->template->_compile_text($template);
$output = executeTemplate($compiled);
expect($output)->toBe('This key exists');
});
it('shows missing regular variables as original syntax', function () {
$template = '{MISSING_VAR}';
$compiled = $this->template->_compile_text($template);
$output = executeTemplate($compiled);
expect($output)->toBe('');
});
it('shows existing regular variables correctly', function () {
$template = '{EXISTING_VAR}';
$compiled = $this->template->_compile_text($template);
$output = executeTemplate($compiled, ['EXISTING_VAR' => 'This variable exists']);
expect($output)->toBe('This variable exists');
});
it('shows missing constants as original syntax', function () {
$template = '{#MISSING_CONSTANT#}';
$compiled = $this->template->_compile_text($template);
$output = executeTemplate($compiled);
expect($output)->toBe('');
});
it('shows existing constants correctly', function () {
// Define a test constant
if (!defined('TEST_CONSTANT')) {
define('TEST_CONSTANT', 'This constant exists');
}
$template = '{#TEST_CONSTANT#}';
$compiled = $this->template->_compile_text($template);
$output = executeTemplate($compiled);
expect($output)->toBe('This constant exists');
});
it('handles mixed existing and missing variables correctly', function () {
$template = '{L_EXISTING_KEY} - {L_MISSING_KEY} - {EXISTING_VAR} - {MISSING_VAR}';
$compiled = $this->template->_compile_text($template);
$output = executeTemplate($compiled, ['EXISTING_VAR' => 'Variable exists']);
expect($output)->toBe('This key exists - L_MISSING_KEY - Variable exists - ');
});
it('handles PHP variables correctly without fallback', function () {
$template = '{$test_var}';
$compiled = $this->template->_compile_text($template);
$output = executeTemplate($compiled, [], ['test_var' => 'PHP variable value']);
expect($output)->toBe('PHP variable value');
});
it('handles undefined PHP variables gracefully', function () {
$template = '{$undefined_var}';
$compiled = $this->template->_compile_text($template);
$output = executeTemplate($compiled);
// PHP variables that don't exist should show empty string (original behavior)
expect($output)->toBe('');
});
});
describe('Template Block Variable Fallback', function () {
it('shows missing block variables as empty string', function () {
$namespace = 'testblock';
$varname = 'MISSING_VAR';
$result = $this->template->generate_block_varref($namespace . '.', $varname);
// Block variables should show empty string when missing, not the variable name
$expectedFormat = "<?php echo isset(\$testblock_item['MISSING_VAR']) ? \$testblock_item['MISSING_VAR'] : ''; ?>";
expect($result)->toBe($expectedFormat);
});
it('generates correct PHP code for block variable fallback', function () {
$namespace = 'news';
$varname = 'TITLE';
$result = $this->template->generate_block_varref($namespace . '.', $varname);
// Block variables should show empty string when missing, not the variable name
$expectedFormat = "<?php echo isset(\$news_item['TITLE']) ? \$news_item['TITLE'] : ''; ?>";
expect($result)->toBe($expectedFormat);
});
});
describe('Compiled Code Verification', function () {
it('compiles language variables with proper fallback code', function () {
$template = '{L_MISSING_KEY}';
$compiled = $this->template->_compile_text($template);
// Verify the compiled PHP code contains the expected fallback logic
expect($compiled)->toContain("isset(\$L['MISSING_KEY'])");
expect($compiled)->toContain("'L_MISSING_KEY'");
});
it('compiles regular variables with proper fallback code', function () {
$template = '{MISSING_VAR}';
$compiled = $this->template->_compile_text($template);
// Verify the compiled PHP code contains the expected fallback logic
expect($compiled)->toContain("isset(\$V['MISSING_VAR'])");
expect($compiled)->toContain("''");
});
it('compiles constants with proper fallback code', function () {
$template = '{#MISSING_CONSTANT#}';
$compiled = $this->template->_compile_text($template);
// Verify the compiled PHP code contains the expected fallback logic
expect($compiled)->toContain("defined('MISSING_CONSTANT')");
expect($compiled)->toContain("''");
});
});
describe('Real-world Example - Admin Migrations', function () {
it('handles the original L_MIGRATIONS_FILE error gracefully', function () {
// The exact template that was causing the error
$template = '<td class="catHead" width="50%"><b>{L_MIGRATIONS_FILE}</b></td>';
$compiled = $this->template->_compile_text($template);
$output = executeTemplate($compiled);
// Should show the fallback without braces instead of throwing an error
expect($output)->toContain('L_MIGRATIONS_FILE');
expect($output)->toContain('<td class="catHead"');
});
});
describe('Edge Cases and Robustness', function () {
it('handles empty variable names gracefully', function () {
$template = '{}';
$compiled = $this->template->_compile_text($template);
$output = executeTemplate($compiled);
// Empty braces should remain as literal text
expect($output)->toBe('{}');
});
it('handles variables with special characters in names', function () {
$template = '{VAR_WITH_UNDERSCORES} {VAR-WITH-DASHES} {VAR123NUMBERS}';
$compiled = $this->template->_compile_text($template);
$output = executeTemplate($compiled, [
'VAR_WITH_UNDERSCORES' => 'underscore value',
'VAR123NUMBERS' => 'number value'
]);
// Verify the compiled code contains proper fallback logic for special chars
expect($compiled)->toContain("isset(\$V['VAR_WITH_UNDERSCORES'])");
expect($compiled)->toContain("isset(\$V['VAR123NUMBERS'])");
// Underscores and numbers should work, dashes might not be valid variable names
expect($output)->toContain('underscore value');
expect($output)->toContain('number value');
});
it('handles HTML entities and special characters in template content', function () {
$template = '<div>&amp; {TEST_VAR} &lt;script&gt;</div>';
$compiled = $this->template->_compile_text($template);
$output = executeTemplate($compiled, ['TEST_VAR' => 'safe content']);
// HTML entities should be preserved, variable should be substituted
expect($output)->toBe('<div>&amp; safe content &lt;script&gt;</div>');
// Verify fallback logic is present
expect($compiled)->toContain("isset(\$V['TEST_VAR'])");
});
it('handles quotes and escaping in variable values', function () {
$template = 'Value: {QUOTED_VAR}';
$compiled = $this->template->_compile_text($template);
$output = executeTemplate($compiled, [
'QUOTED_VAR' => 'Contains "quotes" and \'apostrophes\''
]);
expect($output)->toBe('Value: Contains "quotes" and \'apostrophes\'');
expect($compiled)->toContain("isset(\$V['QUOTED_VAR'])");
});
it('handles very long variable names', function () {
$longVarName = 'VERY_LONG_VARIABLE_NAME_THAT_TESTS_BUFFER_LIMITS_AND_PARSING_' . str_repeat('X', 100);
$template = '{' . $longVarName . '}';
$compiled = $this->template->_compile_text($template);
$output = executeTemplate($compiled, [$longVarName => 'long var value']);
expect($output)->toBe('long var value');
expect($compiled)->toContain("isset(\$V['$longVarName'])");
});
it('handles nested braces and malformed syntax', function () {
$template = '{{NESTED}} {UNCLOSED {NORMAL_VAR} }EXTRA}';
$compiled = $this->template->_compile_text($template);
$output = executeTemplate($compiled, ['NORMAL_VAR' => 'works']);
// Should handle the valid variable and leave malformed parts as literals
expect($output)->toContain('works');
expect($compiled)->toContain("isset(\$V['NORMAL_VAR'])");
});
it('handles empty string values with proper fallback', function () {
$template = 'Before:{EMPTY_VAR}:After';
$compiled = $this->template->_compile_text($template);
$output = executeTemplate($compiled, ['EMPTY_VAR' => '']);
expect($output)->toBe('Before::After');
expect($compiled)->toContain("isset(\$V['EMPTY_VAR'])");
});
it('handles null and false values correctly', function () {
$template = 'Null:{NULL_VAR} False:{FALSE_VAR} Zero:{ZERO_VAR}';
$compiled = $this->template->_compile_text($template);
$output = executeTemplate($compiled, [
'NULL_VAR' => null,
'FALSE_VAR' => false,
'ZERO_VAR' => 0
]);
// PHP's string conversion: null='', false='', 0='0'
expect($output)->toBe('Null: False: Zero:0');
expect($compiled)->toContain("isset(\$V['NULL_VAR'])");
expect($compiled)->toContain("isset(\$V['FALSE_VAR'])");
expect($compiled)->toContain("isset(\$V['ZERO_VAR'])");
});
it('handles whitespace around variable names', function () {
$template = '{ SPACED_VAR } {NORMAL_VAR}';
$compiled = $this->template->_compile_text($template);
$output = executeTemplate($compiled, [
'SPACED_VAR' => 'should not work',
'NORMAL_VAR' => 'should work'
]);
// Spaces inside braces should make it not match as a variable pattern
expect($output)->toContain('should work');
expect($compiled)->toContain("isset(\$V['NORMAL_VAR'])");
});
it('handles multiple consecutive variables', function () {
$template = '{VAR1}{VAR2}{VAR3}';
$compiled = $this->template->_compile_text($template);
$output = executeTemplate($compiled, [
'VAR1' => 'A',
'VAR2' => 'B',
'VAR3' => 'C'
]);
expect($output)->toBe('ABC');
expect($compiled)->toContain("isset(\$V['VAR1'])");
expect($compiled)->toContain("isset(\$V['VAR2'])");
expect($compiled)->toContain("isset(\$V['VAR3'])");
});
it('handles variables with numeric suffixes', function () {
$template = '{VAR1} {VAR2} {VAR10} {VAR100}';
$compiled = $this->template->_compile_text($template);
$output = executeTemplate($compiled, [
'VAR1' => 'one',
'VAR2' => 'two',
'VAR10' => 'ten',
'VAR100' => 'hundred'
]);
expect($output)->toBe('one two ten hundred');
expect($compiled)->toContain("isset(\$V['VAR1'])");
expect($compiled)->toContain("isset(\$V['VAR2'])");
expect($compiled)->toContain("isset(\$V['VAR10'])");
expect($compiled)->toContain("isset(\$V['VAR100'])");
});
it('handles mixed case sensitivity correctly', function () {
$template = '{lowercase} {UPPERCASE} {MixedCase}';
$compiled = $this->template->_compile_text($template);
$output = executeTemplate($compiled, [
'lowercase' => 'lower',
'UPPERCASE' => 'upper',
'MixedCase' => 'mixed'
]);
expect($output)->toBe('lower upper mixed');
expect($compiled)->toContain("isset(\$V['lowercase'])");
expect($compiled)->toContain("isset(\$V['UPPERCASE'])");
expect($compiled)->toContain("isset(\$V['MixedCase'])");
});
it('handles language variables with special prefixes', function () {
global $lang;
$originalLang = $lang;
// Add some special test language variables
$lang['TEST_SPECIAL_CHARS'] = 'Special: &<>"\'';
$lang['TEST_UNICODE'] = 'Unicode: ñáéíóú';
$template = '{L_TEST_SPECIAL_CHARS} | {L_TEST_UNICODE} | {L_MISSING_SPECIAL}';
$compiled = $this->template->_compile_text($template);
$output = executeTemplate($compiled);
expect($output)->toBe('Special: &<>"\' | Unicode: ñáéíóú | L_MISSING_SPECIAL');
expect($compiled)->toContain("isset(\$L['TEST_SPECIAL_CHARS'])");
expect($compiled)->toContain("isset(\$L['TEST_UNICODE'])");
expect($compiled)->toContain("'L_MISSING_SPECIAL'");
// Restore original language array
$lang = $originalLang;
});
it('handles constants with edge case names', function () {
// Define some test constants with edge case names
if (!defined('TEST_CONST_123')) {
define('TEST_CONST_123', 'numeric suffix');
}
if (!defined('TEST_CONST_UNDERSCORE_')) {
define('TEST_CONST_UNDERSCORE_', 'trailing underscore');
}
$template = '{#TEST_CONST_123#} {#TEST_CONST_UNDERSCORE_#} {#UNDEFINED_CONST_EDGE#}';
$compiled = $this->template->_compile_text($template);
$output = executeTemplate($compiled);
expect($output)->toBe('numeric suffix trailing underscore ');
expect($compiled)->toContain("defined('TEST_CONST_123')");
expect($compiled)->toContain("defined('TEST_CONST_UNDERSCORE_')");
expect($compiled)->toContain("defined('UNDEFINED_CONST_EDGE')");
});
it('handles complex nested HTML with variables', function () {
$template = '<table><tr><td>{CELL1}</td><td class="{CSS_CLASS}">{CELL2}</td></tr></table>';
$compiled = $this->template->_compile_text($template);
$output = executeTemplate($compiled, [
'CELL1' => 'First Cell',
'CSS_CLASS' => 'highlight',
'CELL2' => 'Second Cell'
]);
expect($output)->toBe('<table><tr><td>First Cell</td><td class="highlight">Second Cell</td></tr></table>');
expect($compiled)->toContain("isset(\$V['CELL1'])");
expect($compiled)->toContain("isset(\$V['CSS_CLASS'])");
expect($compiled)->toContain("isset(\$V['CELL2'])");
});
});