Files
breadcrumb-the-shire/tests/App/Module/ModuleRuntimePageBuilderTest.php

257 lines
10 KiB
PHP
Raw Normal View History

<?php
namespace MintyPHP\Tests\App\Module;
use MintyPHP\App\Module\ModuleManifest;
use MintyPHP\App\Module\ModuleRuntimePageBuilder;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class ModuleRuntimePageBuilderTest extends TestCase
{
private string $tmpDir;
protected function setUp(): void
{
$this->tmpDir = sys_get_temp_dir() . '/corecore-page-builder-test-' . uniqid();
mkdir($this->tmpDir, 0777, true);
}
protected function tearDown(): void
{
$this->removePath($this->tmpDir);
}
// ── Atomic build / swap ─────────────────────────────────────────
public function testBuildCreatesRuntimeDirWithCoreAndModuleSymlinks(): void
{
$coreDir = $this->createCorePages(['admin', 'login']);
$moduleA = $this->createModuleWithPages('mod-a', ['mod-a-page']);
$runtimeDir = $this->tmpDir . '/runtime/pages';
$builder = new ModuleRuntimePageBuilder();
$result = $builder->build($runtimeDir, $coreDir, ['mod-a' => $moduleA]);
self::assertSame(2, $result['core_entries']);
self::assertSame(1, $result['module_entries']);
self::assertDirectoryExists($runtimeDir);
self::assertTrue(is_link($runtimeDir . '/admin'));
self::assertTrue(is_link($runtimeDir . '/login'));
self::assertTrue(is_link($runtimeDir . '/mod-a-page'));
}
public function testBuildWithNoModulesCreatesSymlinkToCore(): void
{
$coreDir = $this->createCorePages(['admin']);
$runtimeDir = $this->tmpDir . '/runtime/pages';
mkdir(dirname($runtimeDir), 0777, true);
$builder = new ModuleRuntimePageBuilder();
$result = $builder->build($runtimeDir, $coreDir, []);
self::assertSame(0, $result['core_entries']);
self::assertSame(0, $result['module_entries']);
self::assertTrue(is_link($runtimeDir));
}
public function testBuildFailureDoesNotLeavePartialRuntimeState(): void
{
$coreDir = $this->createCorePages(['shared-page']);
// Module has a page that collides with core — will cause RuntimeException
$moduleA = $this->createModuleWithPages('mod-a', ['shared-page']);
$runtimeDir = $this->tmpDir . '/runtime/pages';
// Pre-populate a valid runtime dir to ensure it survives the failed build
mkdir($runtimeDir, 0777, true);
file_put_contents($runtimeDir . '/sentinel.txt', 'existing');
$builder = new ModuleRuntimePageBuilder();
try {
$builder->build($runtimeDir, $coreDir, ['mod-a' => $moduleA]);
self::fail('Expected RuntimeException for page path collision');
} catch (RuntimeException $e) {
self::assertStringContainsString('collision', $e->getMessage());
}
// The existing runtime dir must still be intact — atomic build
// should not have corrupted it
self::assertDirectoryExists($runtimeDir);
self::assertFileExists($runtimeDir . '/sentinel.txt');
self::assertSame('existing', file_get_contents($runtimeDir . '/sentinel.txt'));
}
public function testBuildReplacesExistingRuntimeDir(): void
{
$coreDir = $this->createCorePages(['admin']);
$moduleA = $this->createModuleWithPages('mod-a', ['mod-page']);
$runtimeDir = $this->tmpDir . '/runtime/pages';
// First build
$builder = new ModuleRuntimePageBuilder();
$builder->build($runtimeDir, $coreDir, ['mod-a' => $moduleA]);
self::assertTrue(is_link($runtimeDir . '/mod-page'));
// Second build with different module
$moduleB = $this->createModuleWithPages('mod-b', ['mod-b-page']);
$result = $builder->build($runtimeDir, $coreDir, ['mod-b' => $moduleB]);
self::assertSame(1, $result['core_entries']);
self::assertSame(1, $result['module_entries']);
self::assertTrue(is_link($runtimeDir . '/mod-b-page'));
// Old module page should be gone
self::assertFalse(is_link($runtimeDir . '/mod-page'));
}
public function testNoStagingLeftoverOnSuccess(): void
{
$coreDir = $this->createCorePages(['admin']);
$moduleA = $this->createModuleWithPages('mod-a', ['mod-page']);
$runtimeDir = $this->tmpDir . '/runtime/pages';
$builder = new ModuleRuntimePageBuilder();
$builder->build($runtimeDir, $coreDir, ['mod-a' => $moduleA]);
// Staging dir should be cleaned up
$parentDir = dirname($runtimeDir);
$entries = scandir($parentDir);
self::assertNotFalse($entries);
$stagingEntries = array_filter(
$entries,
static fn (string $e): bool => str_starts_with($e, '.pages-staging-'),
);
self::assertEmpty($stagingEntries, 'Staging directory should be removed after successful build');
}
// ── Fingerprint ─────────────────────────────────────────────────
public function testFingerprintChangesWhenManifestContentChanges(): void
{
$moduleDir = $this->tmpDir . '/modules/mod-a';
mkdir($moduleDir, 0777, true);
file_put_contents($moduleDir . '/module.php', '<?php return ["id" => "mod-a"];');
$manifest = ModuleManifest::fromArray(['id' => 'mod-a'], $moduleDir);
$fp1 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]);
// Change manifest content
file_put_contents($moduleDir . '/module.php', '<?php return ["id" => "mod-a", "version" => "2.0.0"];');
$fp2 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]);
self::assertNotSame($fp1, $fp2, 'Fingerprint must change when manifest file content changes');
}
public function testFingerprintChangesWhenPageEntriesChange(): void
{
$moduleDir = $this->tmpDir . '/modules/mod-a';
mkdir($moduleDir . '/pages', 0777, true);
file_put_contents($moduleDir . '/module.php', '<?php return ["id" => "mod-a"];');
file_put_contents($moduleDir . '/pages/page-a.php', '<?php');
$manifest = ModuleManifest::fromArray(['id' => 'mod-a'], $moduleDir);
$fp1 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]);
// Add a new page entry
file_put_contents($moduleDir . '/pages/page-b.php', '<?php');
$fp2 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]);
self::assertNotSame($fp1, $fp2, 'Fingerprint must change when page entries change');
}
public function testFingerprintStableForSameInputs(): void
{
$moduleDir = $this->tmpDir . '/modules/mod-a';
mkdir($moduleDir . '/pages', 0777, true);
file_put_contents($moduleDir . '/module.php', '<?php return ["id" => "mod-a"];');
file_put_contents($moduleDir . '/pages/page-a.php', '<?php');
$manifest = ModuleManifest::fromArray(['id' => 'mod-a'], $moduleDir);
$fp1 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]);
$fp2 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]);
self::assertSame($fp1, $fp2, 'Fingerprint must be deterministic for same inputs');
}
public function testFingerprintEmptyForNoModules(): void
{
$fp = ModuleRuntimePageBuilder::expectedFingerprint([]);
self::assertSame('', $fp);
}
public function testWriteAndReadFingerprintRoundtrip(): void
{
$runtimeDir = $this->tmpDir . '/runtime/pages';
mkdir(dirname($runtimeDir), 0777, true);
$moduleDir = $this->tmpDir . '/modules/mod-a';
mkdir($moduleDir, 0777, true);
file_put_contents($moduleDir . '/module.php', '<?php return ["id" => "mod-a"];');
$manifest = ModuleManifest::fromArray(['id' => 'mod-a'], $moduleDir);
$modules = ['mod-a' => $manifest];
ModuleRuntimePageBuilder::writeFingerprint($runtimeDir, $modules);
$expected = ModuleRuntimePageBuilder::expectedFingerprint($modules);
$actual = ModuleRuntimePageBuilder::readFingerprint($runtimeDir);
self::assertSame($expected, $actual);
}
public function testReadFingerprintReturnsEmptyWhenFileDoesNotExist(): void
{
$runtimeDir = $this->tmpDir . '/runtime/pages';
self::assertSame('', ModuleRuntimePageBuilder::readFingerprint($runtimeDir));
}
// ── Helpers ──────────────────────────────────────────────────────
private function createCorePages(array $entries): string
{
$coreDir = $this->tmpDir . '/pages';
mkdir($coreDir, 0777, true);
foreach ($entries as $entry) {
mkdir($coreDir . '/' . $entry, 0777, true);
}
return $coreDir;
}
private function createModuleWithPages(string $id, array $pageEntries): ModuleManifest
{
$moduleDir = $this->tmpDir . '/modules/' . $id;
mkdir($moduleDir . '/pages', 0777, true);
file_put_contents($moduleDir . '/module.php', '<?php return ' . var_export(['id' => $id], true) . ';');
foreach ($pageEntries as $entry) {
mkdir($moduleDir . '/pages/' . $entry, 0777, true);
}
return ModuleManifest::fromArray(['id' => $id], $moduleDir);
}
private function removePath(string $path): void
{
if (!file_exists($path) && !is_link($path)) {
return;
}
if (is_link($path) || is_file($path)) {
@unlink($path);
return;
}
if (!is_dir($path)) {
return;
}
$entries = scandir($path);
if ($entries !== false) {
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$this->removePath($path . '/' . $entry);
}
}
@rmdir($path);
}
}