feat(health): dynamic schema conformity inspection from Migrator definitions and Oracle DUAL ping

This commit is contained in:
Jérémy Munsch 2026-08-28 17:33:13 +02:00
parent d2c9e0807a
commit b4f3c0bdb9
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
3 changed files with 113 additions and 253 deletions

View file

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

View file

@ -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,

View file

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