From b4f3c0bdb94c2d024b9f6404fb8a9556b05dc9da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Munsch?= Date: Fri, 28 Aug 2026 17:33:13 +0200 Subject: [PATCH] feat(health): dynamic schema conformity inspection from Migrator definitions and Oracle DUAL ping --- src/Infra/Providers/DomainServiceProvider.php | 76 ++++--- .../Health/LaravelSystemHealthChecker.php | 189 ++++-------------- ...ravelSystemHealthCheckerConformityTest.php | 101 +++------- 3 files changed, 113 insertions(+), 253 deletions(-) diff --git a/src/Infra/Providers/DomainServiceProvider.php b/src/Infra/Providers/DomainServiceProvider.php index a90d89b..2f6fe5c 100644 --- a/src/Infra/Providers/DomainServiceProvider.php +++ b/src/Infra/Providers/DomainServiceProvider.php @@ -32,43 +32,14 @@ use Psr\Log\LoggerInterface; use Shared\DDD\Event\DomainEventDispatcher; use Shared\DDD\Event\TransactionalDomainEventDispatcher; use Shared\DDD\Usecase\Builder\UsecaseExecutor; +use Webmozart\Assert\Assert; class DomainServiceProvider extends ServiceProvider { #[\Override] public function register(): void { - $this->app->bind('arcadiaConnection', static function (Application $app): Connection { - $storageConfig = $app->make(ArcadiaStorageConfig::class); - - return DB::connection($storageConfig->databaseConnection); - }); - $this->app->bind( - EmployeeDirectory::class, - fn (Application $app) => new ArcadiaEmployeeDirectory($app->make('arcadiaConnection')) - ); - $this->app->bind( - IncoSheetDirectory::class, - fn (Application $app) => new ArcadiaIncoSheetDirectory( - $app->make('arcadiaConnection'), - $app->make(BrandRepository::class), - $app->make(IncoMapper::class) - ) - ); - $this->app->bind( - IncoShortSheetDirectory::class, - fn (Application $app) => new ArcadiaIncoShortSheetDirectory($app->make('arcadiaConnection'), $app->make(BrandRepository::class)) - ); - $this->app->bind( - IncoCompositionDirectory::class, - fn (Application $app) => new ArcadiaIncoCompositionDirectory($app->make('arcadiaConnection')) - ); - $this->app->bind( - ArcadiaBrandDirectory::class, - fn (Application $app) => new ArcadiaMarqueBrandDirectory($app->make('arcadiaConnection')) - ); - $this->app->bind(BrandRepository::class, EloquentBrandRepository::class); - $this->app->bind(ClientAuditLogDirectory::class, EloquentClientAuditLogDirectory::class); + $this->registerArcadiaDirectory($this->app); $this->app->bind(BrandCache::class, static function (Application $app): BrandCache { $storageConfig = $app->make(ArcadiaStorageConfig::class); @@ -84,4 +55,47 @@ class DomainServiceProvider extends ServiceProvider $this->app->singleton(UsecaseExecutor::class, LaravelExecutor::class); } + + private function registerArcadiaDirectory(Application $app): void + { + $this->app->bind('arcadiaConnection', static function (Application $app): Connection { + $storageConfig = $app->make(ArcadiaStorageConfig::class); + + return DB::connection($storageConfig->databaseConnection); + }); + $this->app->bind( + EmployeeDirectory::class, + fn (Application $app) => new ArcadiaEmployeeDirectory($this->getArcadiaConnection($app)) + ); + $this->app->bind( + IncoSheetDirectory::class, + fn (Application $app) => new ArcadiaIncoSheetDirectory( + $this->getArcadiaConnection($app), + $app->make(BrandRepository::class), + $app->make(IncoMapper::class) + ) + ); + $this->app->bind( + IncoShortSheetDirectory::class, + fn (Application $app) => new ArcadiaIncoShortSheetDirectory($this->getArcadiaConnection($app), $app->make(BrandRepository::class)) + ); + $this->app->bind( + IncoCompositionDirectory::class, + fn (Application $app) => new ArcadiaIncoCompositionDirectory($this->getArcadiaConnection($app)) + ); + $this->app->bind( + ArcadiaBrandDirectory::class, + fn (Application $app) => new ArcadiaMarqueBrandDirectory($this->getArcadiaConnection($app)) + ); + $this->app->bind(BrandRepository::class, EloquentBrandRepository::class); + $this->app->bind(ClientAuditLogDirectory::class, EloquentClientAuditLogDirectory::class); + } + + private function getArcadiaConnection(Application $app): Connection + { + $connection = $app->make('arcadiaConnection'); + Assert::isInstanceOf($connection, Connection::class); + + return $connection; + } } diff --git a/src/Infra/Services/Health/LaravelSystemHealthChecker.php b/src/Infra/Services/Health/LaravelSystemHealthChecker.php index 7433341..78a1118 100644 --- a/src/Infra/Services/Health/LaravelSystemHealthChecker.php +++ b/src/Infra/Services/Health/LaravelSystemHealthChecker.php @@ -14,31 +14,18 @@ use Application\Health\Dto\HealthChecks; use Application\Health\Dto\MailCheckResult; use Application\Health\Enums\ComponentStatus; use Application\Health\Enums\HealthStatus; -use Domain\Models\ValueObjects\Employees\EmployeeSortColumn; -use Domain\Models\ValueObjects\Employees\EmployeeSorter; -use Domain\Models\ValueObjects\Inco\IncoId; -use Domain\Models\ValueObjects\Inco\IncoSheetSortColumn; -use Domain\Models\ValueObjects\Inco\IncoSheetSorter; -use Domain\Ports\Directories\ArcadiaBrandDirectory; -use Domain\Ports\Directories\EmployeeDirectory; -use Domain\Ports\Directories\IncoCompositionDirectory; -use Domain\Ports\Directories\IncoSheetDirectory; +use Database\ArcadiaReplicate\Migrator; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\DB; use Infra\Config\ArcadiaStorageConfig; -use Shared\Pagination; use Shared\Time\Utc; final readonly class LaravelSystemHealthChecker implements HealthChecker { public function __construct( private ArcadiaStorageConfig $storageConfig, - private ArcadiaBrandDirectory $brandDirectory, - private EmployeeDirectory $employeeDirectory, - private IncoSheetDirectory $incoSheetDirectory, - private IncoCompositionDirectory $incoCompositionDirectory, ) { } @@ -69,22 +56,34 @@ final readonly class LaravelSystemHealthChecker implements HealthChecker #[\Override] public function checkArcadiaConformity(): ArcadiaConformityResult { - $brandCheck = $this->probeBrands(); - $employeeCheck = $this->probeEmployees(); - $incoSheetProbe = $this->probeIncoSheets(); - $incoCompoCheck = $this->probeIncoCompositions($incoSheetProbe['firstIncoId']); + $connection = DB::connection($this->storageConfig->databaseConnection); + $checks = []; + $isAllUp = true; - $checks = [ - $brandCheck, - $employeeCheck, - $incoSheetProbe['check'], - $incoCompoCheck, - ]; + foreach (Migrator::DEFINITIONS as $definition) { + $tableName = $definition::name(); + $schemaName = $definition::schemaName(); + $target = "{$schemaName}.{$tableName}"; - $isAllUp = $brandCheck->isUp() - && $employeeCheck->isUp() - && $incoSheetProbe['check']->isUp() - && $incoCompoCheck->isUp(); + try { + $connection->table($tableName)->limit(0)->get(); + $checks[] = new DirectoryConformityCheck( + name: strtolower($tableName), + status: ComponentStatus::UP, + target: $target, + sampleCount: 0, + ); + } catch (\Throwable $e) { + $isAllUp = false; + $checks[] = new DirectoryConformityCheck( + name: strtolower($tableName), + status: ComponentStatus::DOWN, + target: $target, + sampleCount: 0, + errorMessage: $e->getMessage(), + ); + } + } return new ArcadiaConformityResult( status: $isAllUp ? HealthStatus::HEALTHY : HealthStatus::UNHEALTHY, @@ -93,138 +92,24 @@ final readonly class LaravelSystemHealthChecker implements HealthChecker ); } - private function probeBrands(): DirectoryConformityCheck - { - try { - $brands = $this->brandDirectory->findAll(); - - return new DirectoryConformityCheck( - name: 'brands', - status: ComponentStatus::UP, - target: 'MARQUE', - sampleCount: count($brands), - ); - } catch (\Throwable $e) { - return new DirectoryConformityCheck( - name: 'brands', - status: ComponentStatus::DOWN, - target: 'MARQUE', - sampleCount: 0, - errorMessage: $e->getMessage(), - ); - } - } - - private function probeEmployees(): DirectoryConformityCheck - { - try { - $employees = $this->employeeDirectory->findAll( - Pagination::ofPage(1, 5), - new EmployeeSorter(EmployeeSortColumn::arcadiaId, \SortDirection::Ascending) - ); - - return new DirectoryConformityCheck( - name: 'employees', - status: ComponentStatus::UP, - target: 'RU_PERSONNE_SIRH', - sampleCount: count($employees->getItems()), - ); - } catch (\Throwable $e) { - return new DirectoryConformityCheck( - name: 'employees', - status: ComponentStatus::DOWN, - target: 'RU_PERSONNE_SIRH', - sampleCount: 0, - errorMessage: $e->getMessage(), - ); - } - } - - /** - * @return array{check: DirectoryConformityCheck, firstIncoId: ?IncoId} - */ - private function probeIncoSheets(): array - { - try { - $incoSheets = $this->incoSheetDirectory->findAll( - Pagination::ofPage(1, 5), - new IncoSheetSorter(IncoSheetSortColumn::incoSheetId, \SortDirection::Ascending) - ); - $incoItems = $incoSheets->getItems(); - $firstIncoId = ! empty($incoItems) ? $incoItems[0]->id : null; - - return [ - 'check' => new DirectoryConformityCheck( - name: 'inco_sheets', - status: ComponentStatus::UP, - target: 'INCO + FAM_VENTE', - sampleCount: count($incoItems), - ), - 'firstIncoId' => $firstIncoId, - ]; - } catch (\Throwable $e) { - return [ - 'check' => new DirectoryConformityCheck( - name: 'inco_sheets', - status: ComponentStatus::DOWN, - target: 'INCO + FAM_VENTE', - sampleCount: 0, - errorMessage: $e->getMessage(), - ), - 'firstIncoId' => null, - ]; - } - } - - private function probeIncoCompositions(?IncoId $firstIncoId): DirectoryConformityCheck - { - try { - $targetIncoId = $firstIncoId ?? new IncoId('000000'); - $composition = $this->incoCompositionDirectory->findByIncoId($targetIncoId); - - return new DirectoryConformityCheck( - name: 'inco_compositions', - status: ComponentStatus::UP, - target: 'INCO_COMPO', - sampleCount: $composition !== null ? 1 : 0, - ); - } catch (\Throwable $e) { - return new DirectoryConformityCheck( - name: 'inco_compositions', - status: ComponentStatus::DOWN, - target: 'INCO_COMPO', - sampleCount: 0, - errorMessage: $e->getMessage(), - ); - } - } - private function checkDefaultDatabase(): DatabaseCheckResult { - $connection = Config::string('database.default'); - - try { - DB::connection($connection)->select('SELECT 1'); - - return new DatabaseCheckResult( - status: ComponentStatus::UP, - connection: $connection, - ); - } catch (\Throwable $e) { - return new DatabaseCheckResult( - status: ComponentStatus::DOWN, - connection: $connection, - message: $e->getMessage(), - ); - } + return $this->checkDatabaseConnection(Config::string('database.default')); } private function checkArcadiaDatabase(): DatabaseCheckResult { - $connection = $this->storageConfig->databaseConnection; + return $this->checkDatabaseConnection($this->storageConfig->databaseConnection); + } + private function checkDatabaseConnection(string $connection): DatabaseCheckResult + { try { - DB::connection($connection)->select('SELECT 1'); + $conn = DB::connection($connection); + $driver = $conn->getDriverName(); + $query = $driver === 'oracle' ? 'SELECT 1 FROM DUAL' : 'SELECT 1'; + + $conn->select($query); return new DatabaseCheckResult( status: ComponentStatus::UP, diff --git a/tests/Integration/Infra/Services/Health/LaravelSystemHealthCheckerConformityTest.php b/tests/Integration/Infra/Services/Health/LaravelSystemHealthCheckerConformityTest.php index 4aae168..e594b3f 100644 --- a/tests/Integration/Infra/Services/Health/LaravelSystemHealthCheckerConformityTest.php +++ b/tests/Integration/Infra/Services/Health/LaravelSystemHealthCheckerConformityTest.php @@ -6,15 +6,11 @@ namespace Tests\Integration\Infra\Services\Health; use Application\Health\Enums\ComponentStatus; use Application\Health\Enums\HealthStatus; -use Domain\Ports\Directories\ArcadiaBrandDirectory; -use Domain\Ports\Directories\EmployeeDirectory; -use Domain\Ports\Directories\IncoCompositionDirectory; -use Domain\Ports\Directories\IncoSheetDirectory; +use Database\ArcadiaReplicate\Migrator; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Schema; use Infra\Config\ArcadiaStorageConfig; use Infra\Services\Health\LaravelSystemHealthChecker; -use Shared\Paginated; -use Shared\Pagination; use Tests\TestCase; covers(LaravelSystemHealthChecker::class); @@ -22,32 +18,10 @@ covers(LaravelSystemHealthChecker::class); uses(TestCase::class, RefreshDatabase::class); describe('LaravelSystemHealthChecker checkArcadiaConformity Integration Test', function (): void { - it('returns healthy status when all directory probes succeed', function (): void { + it('returns healthy status when all 56 replicated tables and views exist', function (): void { // GIVEN - $storageConfig = resolve(ArcadiaStorageConfig::class); - $brandDirectory = mock(ArcadiaBrandDirectory::class); - $employeeDirectory = mock(EmployeeDirectory::class); - $incoSheetDirectory = mock(IncoSheetDirectory::class); - $incoCompoDirectory = mock(IncoCompositionDirectory::class); - - $brandDirectory->shouldReceive('findAll')->withAnyArgs()->once()->andReturn([]); - $employeeDirectory->shouldReceive('findAll') - ->withAnyArgs() - ->once() - ->andReturn(new Paginated(Pagination::ofPage(1, 5), [], 0)); - $incoSheetDirectory->shouldReceive('findAll') - ->withAnyArgs() - ->once() - ->andReturn(new Paginated(Pagination::ofPage(1, 5), [], 0)); - $incoCompoDirectory->shouldReceive('findByIncoId')->withAnyArgs()->once()->andReturn(null); - - $checker = new LaravelSystemHealthChecker( - $storageConfig, - $brandDirectory, - $employeeDirectory, - $incoSheetDirectory, - $incoCompoDirectory, - ); + $storageConfig = resolve(ArcadiaStorageConfig::class); + $checker = new LaravelSystemHealthChecker($storageConfig); // WHEN $result = $checker->checkArcadiaConformity(); @@ -55,45 +29,22 @@ describe('LaravelSystemHealthChecker checkArcadiaConformity Integration Test', f // THEN expect($result->isConform())->toBeTrue() ->and($result->status)->toBe(HealthStatus::HEALTHY) - ->and(count($result->checks))->toBe(4) - ->and($result->checks[0]->status)->toBe(ComponentStatus::UP) - ->and($result->checks[1]->status)->toBe(ComponentStatus::UP) - ->and($result->checks[2]->status)->toBe(ComponentStatus::UP) - ->and($result->checks[3]->status)->toBe(ComponentStatus::UP); + ->and(count($result->checks))->toBe(count(Migrator::DEFINITIONS)); + + foreach ($result->checks as $check) { + expect($check->status)->toBe(ComponentStatus::UP) + ->and($check->errorMessage)->toBeNull(); + } }); - it('returns unhealthy status with captured dissonance when a directory probe fails', function (): void { + it('returns unhealthy status with captured dissonance when tables like ADDITIF or FAM_VENTE are missing or renamed', function (): void { // GIVEN - $storageConfig = resolve(ArcadiaStorageConfig::class); - $brandDirectory = mock(ArcadiaBrandDirectory::class); - $employeeDirectory = mock(EmployeeDirectory::class); - $incoSheetDirectory = mock(IncoSheetDirectory::class); - $incoCompoDirectory = mock(IncoCompositionDirectory::class); + $storageConfig = resolve(ArcadiaStorageConfig::class); + $checker = new LaravelSystemHealthChecker($storageConfig); - $brandDirectory->shouldReceive('findAll') - ->withAnyArgs() - ->once() - ->andThrow(new \RuntimeException('ORA-00904: "LIB_MARQ": invalid identifier')); - $employeeDirectory->shouldReceive('findAll') - ->withAnyArgs() - ->once() - ->andReturn(new Paginated(Pagination::ofPage(1, 5), [], 0)); - $incoSheetDirectory->shouldReceive('findAll') - ->withAnyArgs() - ->once() - ->andReturn(new Paginated(Pagination::ofPage(1, 5), [], 0)); - $incoCompoDirectory->shouldReceive('findByIncoId') - ->withAnyArgs() - ->once() - ->andReturn(null); - - $checker = new LaravelSystemHealthChecker( - $storageConfig, - $brandDirectory, - $employeeDirectory, - $incoSheetDirectory, - $incoCompoDirectory, - ); + // Drop ADDITIF and FAM_VENTE to simulate table rename/removal in Oracle schema + Schema::connection($storageConfig->databaseConnection)->dropIfExists('ADDITIF'); + Schema::connection($storageConfig->databaseConnection)->dropIfExists('FAM_VENTE'); // WHEN $result = $checker->checkArcadiaConformity(); @@ -101,9 +52,19 @@ describe('LaravelSystemHealthChecker checkArcadiaConformity Integration Test', f // THEN expect($result->isConform())->toBeFalse() ->and($result->hasDissonances())->toBeTrue() - ->and($result->status)->toBe(HealthStatus::UNHEALTHY) - ->and($result->checks[0]->status)->toBe(ComponentStatus::DOWN) - ->and($result->checks[0]->errorMessage)->toContain('ORA-00904') - ->and($result->checks[1]->status)->toBe(ComponentStatus::UP); + ->and($result->status)->toBe(HealthStatus::UNHEALTHY); + + $additifCheck = collect($result->checks)->firstWhere('name', 'additif'); + $famVenteCheck = collect($result->checks)->firstWhere('name', 'fam_vente'); + $marqueCheck = collect($result->checks)->firstWhere('name', 'marque'); + + expect($additifCheck)->not->toBeNull() + ->and($additifCheck->status)->toBe(ComponentStatus::DOWN) + ->and($additifCheck->errorMessage)->not->toBeNull() + ->and($famVenteCheck)->not->toBeNull() + ->and($famVenteCheck->status)->toBe(ComponentStatus::DOWN) + ->and($famVenteCheck->errorMessage)->not->toBeNull() + ->and($marqueCheck)->not->toBeNull() + ->and($marqueCheck->status)->toBe(ComponentStatus::UP); }); });