refactor(addons): remove AddonRegistry, refactor boot cache handling, and update tests

- Removed the obsolete `AddonRegistry` class and associated tests.
- Simplified addon boot cache logic by consolidating responsibilities into `BootCache`.
- Updated service bindings to align with the new architecture.
- Refactored tests for Filament, ModuleShim, and addon discovery to reflect the changes.
This commit is contained in:
Nabeel Shahzad 2026-06-07 19:01:26 -05:00
parent ab4734e38f
commit b29dc1e5b7
No known key found for this signature in database
GPG Key ID: 08C44114D2BF3047
25 changed files with 514 additions and 568 deletions

View File

@ -6,6 +6,7 @@ namespace App\Addons;
use App\Addons\Models\AddonBootCache;
use App\Addons\Support\AutoloadGuard;
use App\Addons\Support\BootCache;
use App\Exceptions\AutoloadModeException;
use Composer\Autoload\ClassLoader;
use Illuminate\Contracts\Foundation\Application;
@ -28,10 +29,10 @@ use RuntimeException;
* 3. Assert classmap-authoritative guard ONCE, BEFORE any addPsr4() call.
* 4. For each row: register PSR-4 then register service providers.
*/
class AddonLoader
class AddonAutoLoader
{
public function __construct(
private readonly AddonRegistry $registry,
private readonly BootCache $registry,
private readonly AutoloadGuard $guard,
) {}

View File

@ -1,63 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Addons;
use App\Addons\Models\AddonBootCache;
use App\Addons\Support\BootCache;
use App\Models\Addon;
use Illuminate\Support\Collection;
/**
* Read API over the boot cache and the addons table.
*
* Stateless and Octane-safe: reads fresh on every call. No mutable instance
* state another Octane worker may have re-primed the cache between requests.
*/
class AddonRegistry
{
public function __construct(
private readonly BootCache $bootCache,
) {}
/**
* Return enabled addons from the boot cache (DB-free hot path, D-10).
*
* Returns an empty array when the cache is absent.
*
* @return Collection<AddonBootCache>
*/
public function enabled(): Collection
{
return collect(array_values(
array_filter(
$this->bootCache->read(),
fn (AddonBootCache $entry): bool => $entry->enabled,
)
));
}
/**
* Return an Eloquent Collection of every Addon row (enabled and disabled).
*
* @return Collection<AddonBootCache>
*/
public function all(): Collection
{
return collect(array_map(fn (AddonBootCache $record): AddonBootCache => $record, $this->bootCache->read()));
}
/**
* Find an Addon by registry_id or path.
*
* Returns null when no matching row is found.
*/
public function find(string $registryIdOrPath): ?Addon
{
return Addon::query()
->where('registry_id', $registryIdOrPath)
->orWhere('path', $registryIdOrPath)
->first();
}
}

View File

@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Addons\Compat;
use App\Addons\Models\AddonManifest;
use App\Addons\Services\AddonRuntimeService;
use App\Addons\Services\AddonDiscoveryService;
use App\Addons\Support\ManifestParser;
use App\Models\Addon;
use Illuminate\Support\Str;
@ -92,7 +92,7 @@ class Module
$this->addon->enabled = $active;
$this->addon->save();
app(AddonRuntimeService::class)->run();
app(AddonDiscoveryService::class)->run();
}
/**
@ -112,7 +112,7 @@ class Module
{
$this->addon->delete();
app(AddonRuntimeService::class)->run();
app(AddonDiscoveryService::class)->run();
}
/**

View File

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Addons\Compat;
use App\Addons\AddonRegistry;
use App\Addons\Models\AddonBootCache;
use App\Addons\Support\BootCache;
use App\Addons\Support\ManifestParser;
use App\Models\Addon;
use Illuminate\Support\Collection;
@ -14,14 +14,14 @@ use Illuminate\Support\Collection;
* Compatibility repository satisfying the duck-typed surface of the nwidart
* Module facade (container key: 'modules').
*
* Backed by AddonRegistry + ManifestParser; stateless and Octane-safe.
* Backed by AddonRuntime + ManifestParser; stateless and Octane-safe.
* Do NOT bind this to the 'modules' key while nwidart is still active
* the binding is deferred to a later cutover task (Phase 8).
*/
class ModuleRepository
{
public function __construct(
private readonly AddonRegistry $registry,
private readonly BootCache $registry,
private readonly ManifestParser $parser,
) {}
@ -45,7 +45,7 @@ class ModuleRepository
* Return enabled addon shims keyed by module name.
*
* NOTE: reads from DB + parses manifests (cold/admin path). A future
* optimisation could build from AddonRegistry::enabled() (boot cache)
* optimisation could build from AddonRuntime::enabled() (boot cache)
* once ModuleShim can be constructed from a cache row, avoiding the
* per-row manifest parse entirely.
*

View File

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Addons\Filament;
use App\Addons\AddonRegistry;
use App\Addons\Models\AddonBootCache;
use App\Addons\Support\BootCache;
use Filament\PanelRegistry;
/**
@ -36,7 +36,7 @@ class FilamentPanelExtender
];
public function __construct(
private readonly AddonRegistry $registry,
private readonly BootCache $registry,
) {}
/**

View File

@ -26,7 +26,7 @@ use Illuminate\Support\Facades\Log;
*
* Stateless and Octane-safe: no mutable instance properties.
*/
class AddonRuntimeService
class AddonDiscoveryService
{
public function __construct(
private readonly ManifestParser $parser,
@ -54,7 +54,7 @@ class AddonRuntimeService
// Scenario 1: fresh install, all found addons are enabled
if (!installed()) {
foreach ($manifests as $m) {
$rows[] = $this->buildRow($m, true);
$rows[] = $this->buildBootCacheRow($m, true);
}
$this->bootCache->write($rows);
@ -62,45 +62,20 @@ class AddonRuntimeService
return;
}
// Scenario 2/3: installed, all addons are enabled or disabled
/**
* Scenario 2:
* This is an existing install
* Find any new addons that have popped up, set them to disabled
* Upsert them into the DB
*/
// Build a lookup of all DB rows keyed by registry_id and path.
$dbByRegistryId = [];
$dbByPath = [];
foreach (Addon::query()->get() as $addon) {
if ($addon->registry_id !== null) {
$dbByRegistryId[$addon->registry_id] = (bool) $addon->enabled;
} else {
$dbByPath[$addon->path] = (bool) $addon->enabled;
}
// This should be disabled by default, and only run the new
// addon discovery when you go to the admin panel for addons
if (!config('addons.scan_for_new_on_boot')) {
return;
}
/** @var AddonManifest $m */
foreach ($manifests as $m) {
// The addon does have a registry_id - so it's a legacy addon
if ($m->registryId !== null) {
// If the addon is already in the DB, use its enabled flag.
if (array_key_exists($m->registryId, $dbByRegistryId)) {
$enabled = $dbByRegistryId[$m->registryId];
}
// Otherwise, it's a new addon, so it's disabled by default
else {
$this->upsert($m);
$enabled = false;
}
} elseif (array_key_exists($m->path, $dbByPath)) {
// If the addon is already in the DB (search by the path), use its enabled flag.
$enabled = $dbByPath[$m->path];
} else {
$this->upsert($m);
$enabled = false;
}
$rows[] = $this->buildRow($m, $enabled);
}
$this->bootCache->write($rows);
$this->discoverNewAddons();
}
/**
@ -145,31 +120,28 @@ class AddonRuntimeService
*/
public function discoverNewAddons(): Collection
{
// Build a lookup of all DB rows keyed by registry_id and path.
$dbByPath = [];
$dbByRegistryId = [];
$newAddons = collect();
/** @var Addon $addon Get all addons from the database */
foreach (Addon::query()->get() as $addon) {
if ($addon->registry_id !== null) {
$dbByRegistryId[$addon->registry_id] = (bool) $addon->enabled;
} else {
$dbByPath[$addon->path] = (bool) $addon->enabled;
}
$manifests = $this->scanLocation(config('addons.paths.base'));
if ($manifests === []) {
return $newAddons;
}
foreach ($this->scanLocation(config('addons.paths.base')) as $manifest) {
// The addon does have a registry_id - so it's a legacy addon
if ($manifest->registryId !== null) {
// Found a new addon, upsert it
if (!array_key_exists($manifest->registryId, $dbByRegistryId)) {
$newAddons->push($this->upsert($manifest));
}
} elseif (!array_key_exists($manifest->path, $dbByPath)) {
// If the addon is not already in the DB, upsert it
$newAddons->push($this->upsert($manifest));
/** @var Collection<Addon> $installedAddons */
$installedAddons = Addon::query()->get();
/** @var AddonManifest $m */
foreach ($manifests as $m) {
// If the addon is already in the DB, don't do anything with it
$installed = $installedAddons->first(fn (Addon $addon, int $key): bool => ($addon->registry_id === $m->registryId)
|| ($addon->name === $m->name)
|| ($addon->namespace === $m->namespace));
if ($installed) {
continue;
}
$newAddons->push($this->upsert($m, isNew: true));
}
return $newAddons;
@ -178,7 +150,7 @@ class AddonRuntimeService
/**
* Enumerate immediate subdirectories of $dir and parse each manifest.
*
* Returns an empty array when the directory does not exist; storage/app/addons
* Returns an empty array when the directory does not exist; modules
* may be absent on a fresh install (LOAD-01).
*
* Path-traversal guard (T-04-03): resolved realpath must stay within the
@ -205,7 +177,7 @@ class AddonRuntimeService
// T-04-03: skip if the resolved path escapes the base directory.
if ($resolved === false || !str_starts_with($resolved, $realBase.DIRECTORY_SEPARATOR)) {
Log::warning(sprintf("AddonRuntimeService: skipping '%s' — path traversal guard triggered (T-04-03)", $subDir));
Log::warning(sprintf("AddonRuntimeService: skipping '%s' — path traversal guard triggered", $subDir));
continue;
}
@ -213,7 +185,7 @@ class AddonRuntimeService
$manifest = $this->parser->parse($resolved);
if (!$manifest instanceof AddonManifest) {
Log::warning(sprintf("AddonRuntimeService: skipping '%s' — module.json is missing or invalid (D-15)", $resolved));
Log::warning(sprintf("AddonRuntimeService: skipping '%s' — module.json is missing or invalid", $resolved));
continue;
}
@ -227,7 +199,7 @@ class AddonRuntimeService
/**
* Build an AddonCacheEntry from a manifest and an explicit enabled flag.
*/
private function buildRow(AddonManifest $m, bool $enabled): AddonBootCache
private function buildBootCacheRow(AddonManifest $m, bool $enabled): AddonBootCache
{
return new AddonBootCache(
name: $m->name,
@ -256,16 +228,11 @@ class AddonRuntimeService
* Uses firstOrNew + save so enabled is set only on row creation; an existing
* operator-disabled row keeps enabled=false across re-prime (D-12).
*/
private function upsert(AddonManifest $m): Addon
private function upsert(AddonManifest $m, bool $isNew): Addon
{
if ($m->registryId !== null) {
$addon = Addon::query()->firstOrNew(['registry_id' => $m->registryId]);
} else {
$addon = Addon::query()->firstOrNew(['path' => $m->path]);
}
$isNew = !$addon->exists;
$addon = Addon::fromManifest($m);
$addon->name = $m->name;
$addon->namespace = $m->namespace;
$addon->type = $m->type;
$addon->version = $m->version;

View File

@ -5,15 +5,20 @@ declare(strict_types=1);
namespace App\Addons\Support;
use App\Addons\Models\AddonBootCache;
use Illuminate\Support\Collection;
use RuntimeException;
/**
* Atomic reader/writer for the exported boot manifest
* Low-level read/write API over the exported boot manifest
* bootstrap/cache/addons.php (STATE-02, D-10, D-14).
*
* Stateless reads from disk on every call. Another Octane worker may
* have rewritten the cache between requests; never cache contents on
* the instance.
* Merges the former BootCache (atomic file I/O) and AddonRegistry (cache-backed
* read helpers) into a single surface. Operates exclusively on the boot cache
* file performs NO database reads.
*
* Stateless and Octane-safe: reads from disk on every call. Another Octane
* worker may have rewritten the cache between requests; never cache contents
* on the instance.
*/
class BootCache
{
@ -23,6 +28,30 @@ class BootCache
*/
public const int SCHEMA = 2;
/**
* Return enabled addons from the boot cache (DB-free hot path, D-10).
*
* Returns an empty collection when the cache is absent.
*
* @return Collection<int, AddonBootCache>
*/
public function enabled(): Collection
{
return collect($this->read())
->filter(fn (AddonBootCache $entry): bool => $entry->enabled)
->values();
}
/**
* Return every addon entry from the boot cache (enabled and disabled).
*
* @return Collection<int, AddonBootCache>
*/
public function all(): Collection
{
return collect($this->read());
}
/**
* Absolute path to the boot cache file.
*/
@ -52,7 +81,7 @@ class BootCache
}
/**
* Read the boot cache and return hydrated AddonCacheEntry rows.
* Read the boot cache and return hydrated AddonBootCache rows.
*
* Returns an empty array when:
* - the file is absent (D-10 absence-only trust), or
@ -87,24 +116,6 @@ class BootCache
);
}
/**
* Load and return the top-level envelope array from the cache file.
*
* Returns null when the file is absent or its content is not an array.
*
* @return array<string, mixed>|null
*/
private function loadEnvelope(): ?array
{
if (!$this->exists()) {
return null;
}
$data = require $this->path();
return is_array($data) ? $data : null;
}
/**
* Atomically write addon entries to the boot cache (D-14).
*
@ -134,7 +145,7 @@ class BootCache
if (!rename($tmp, $this->path())) {
@unlink($tmp);
throw new RuntimeException('BootCache: failed to atomically rename cache file.');
throw new RuntimeException('AddonRuntime: failed to atomically rename cache file.');
}
}
@ -147,4 +158,22 @@ class BootCache
unlink($this->path());
}
}
/**
* Load and return the top-level envelope array from the cache file.
*
* Returns null when the file is absent or its content is not an array.
*
* @return array<string, mixed>|null
*/
private function loadEnvelope(): ?array
{
if (!$this->exists()) {
return null;
}
$data = require $this->path();
return is_array($data) ? $data : null;
}
}

View File

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Console\Commands;
use App\Addons\Services\AddonRuntimeService;
use App\Addons\Services\AddonDiscoveryService;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Symfony\Component\Console\Attribute\AsCommand;
@ -23,7 +23,7 @@ class AddonsPrime extends Command
*
* Returns self::SUCCESS on completion; self::FAILURE on exception.
*/
public function handle(AddonRuntimeService $prime): int
public function handle(AddonDiscoveryService $prime): int
{
try {
if ($this->option('force')) {

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Models;
use App\Addons\Models\AddonBootCache;
use App\Addons\Models\AddonManifest;
use App\Contracts\Model;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -81,6 +82,20 @@ class Addon extends Model
return $addon;
}
public static function fromManifest(AddonManifest $m): Addon
{
$addon = new Addon();
$addon->name = $m->name;
$addon->registry_id = $m->registryId;
$addon->type = $m->type;
$addon->version = $m->version;
$addon->namespace = $m->namespace;
$addon->path = $m->path;
$addon->enabled = $m->enabled;
return $addon;
}
#[Override]
protected function casts(): array
{

View File

@ -4,11 +4,10 @@ declare(strict_types=1);
namespace App\Providers;
use App\Addons\AddonLoader;
use App\Addons\AddonRegistry;
use App\Addons\AddonAutoLoader;
use App\Addons\Compat\ModuleRepository;
use App\Addons\Filament\FilamentPanelExtender;
use App\Addons\Services\AddonRuntimeService;
use App\Addons\Services\AddonDiscoveryService;
use App\Addons\Support\AutoloadGuard;
use App\Addons\Support\BootCache;
use App\Addons\Support\ManifestParser;
@ -50,11 +49,10 @@ class AddonServiceProvider extends ServiceProvider
$this->app->singleton(BootCache::class);
$this->app->singleton(ManifestParser::class);
$this->app->singleton(AutoloadGuard::class);
$this->app->singleton(AddonRegistry::class);
$this->app->singleton(AddonRuntimeService::class);
$this->app->singleton(AddonDiscoveryService::class);
// ── Phase 2 singletons ──────────────────────────────────────────────
$this->app->singleton(AddonLoader::class);
$this->app->singleton(AddonAutoLoader::class);
$this->app->singleton(FilamentPanelExtender::class);
$this->app->singleton(ModuleRepository::class);
@ -77,7 +75,7 @@ class AddonServiceProvider extends ServiceProvider
// cache. Empty cache → no-op. Console needs this for commands/migrations.
// The loader contains its own guard call (re-checks the resolved ClassLoader);
// both guards are intentional — see provider-level comment above (LOAD-08).
$this->app->make(AddonLoader::class)->register($this->app);
$this->app->make(AddonAutoLoader::class)->register($this->app);
// ── Filament hook (D2-07) ────────────────────────────────────────────
// Apply addon Filament discovery paths before panels resolve.
@ -96,7 +94,7 @@ class AddonServiceProvider extends ServiceProvider
public function boot(): void
{
if (!$this->app->runningInConsole()) {
$this->app->make(AddonRuntimeService::class)->primeIfNeeded();
$this->app->make(AddonDiscoveryService::class)->primeIfNeeded();
}
}
}

View File

@ -4,14 +4,14 @@ declare(strict_types=1);
namespace App\Services;
use App\Addons\AddonRegistry;
use App\Addons\Compat\Module;
use App\Addons\Compat\ModuleRepository;
use App\Contracts\Service;
class ModuleService extends Service
{
public function __construct(
private readonly AddonRegistry $addonRegistry
private readonly ModuleRepository $modules
) {}
/**
@ -74,8 +74,7 @@ class ModuleService extends Service
*/
public function updateModule(string $name, bool $enabled): void
{
/** @var ?Module $module */
$module = $this->addonRegistry->find($name);
$module = $this->modules->find($name);
if (!$module) {
return;
@ -97,8 +96,7 @@ class ModuleService extends Service
*/
public function deleteModule(string $name): void
{
/** @var ?Module $module */
$module = $this->addonRegistry->find($name);
$module = $this->modules->find($name);
if (!$module) {
return;

View File

@ -3,8 +3,9 @@
declare(strict_types=1);
return [
'namespace' => 'Modules',
'paths' => [
'namespace' => 'Modules',
'scan_for_new_on_boot' => true,
'paths' => [
'base' => base_path('modules'),
'assets' => public_path('modules'),
],

View File

@ -39,7 +39,7 @@
│ AddonInstaller (orchestrator service) │
│ │ │
│ ├── AddonSource (interface) ← LocalZipSource | UrlSource | DirSrc │
│ │ resolves & extracts to: storage/app/addons/_staging/{tmpdir}/ │
│ │ resolves & extracts to: modules/_staging/{tmpdir}/ │
│ │ │
│ ├── AddonValidator │
│ │ ├─ zip-slip / path-traversal guard │

View File

@ -2,7 +2,7 @@
declare(strict_types=1);
use App\Addons\AddonLoader;
use App\Addons\AddonAutoLoader;
use Illuminate\Support\Facades\Route;
// ─────────────────────────────────────────────────────────────────────────────
@ -36,7 +36,7 @@ it('Sample module web route is registered after addon loader runs', function ():
// providers from the primed cache. Since the app is already booted,
// $app->register() boots each provider immediately, triggering
// SampleServiceProvider::boot() → registerRoutes() → loadRoutesFrom().
app(AddonLoader::class)->register(app());
app(AddonAutoLoader::class)->register(app());
// Step 3: refresh the router's name-lookup index so hasNamedRoute() sees
// routes added after initial boot.

View File

@ -2,7 +2,8 @@
declare(strict_types=1);
use App\Addons\Services\AddonRuntimeService;
use App\Addons\Models\AddonBootCache;
use App\Addons\Services\AddonDiscoveryService;
use App\Addons\Support\BootCache;
use App\Models\Addon;
use App\Services\ModuleService;
@ -10,7 +11,7 @@ use App\Services\ModuleService;
beforeEach(function (): void {
// Fresh boot cache + DB rows for each test.
app(BootCache::class)->delete();
app(AddonRuntimeService::class)->run();
app(AddonDiscoveryService::class)->run();
});
afterEach(function (): void {
@ -32,7 +33,7 @@ it('updateModule(Sample, false) does not throw and disables the addon', function
// Boot cache must exclude Sample.
$cached = app(BootCache::class)->read();
expect(array_column($cached, 'name'))->not->toContain('Sample');
expect(array_map(fn (AddonBootCache $r): string => $r->name, $cached))->not->toContain('Sample');
});
it('updateModule(Sample, true) does not throw and re-enables the addon', function (): void {
@ -48,7 +49,7 @@ it('updateModule(Sample, true) does not throw and re-enables the addon', functio
// Boot cache must include Sample again.
$cached = app(BootCache::class)->read();
expect(array_column($cached, 'name'))->toContain('Sample');
expect(array_map(fn (AddonBootCache $r): string => $r->name, $cached))->toContain('Sample');
});
it('deleteModule() does not throw and removes the addon DB row', function (): void {
@ -69,7 +70,7 @@ it('deleteModule() does not throw and removes the addon DB row', function (): vo
'providers' => [],
]));
app(AddonRuntimeService::class)->run();
app(AddonDiscoveryService::class)->run();
$throwaway = Addon::query()->where('path', $tmpDir)->firstOrFail();
$throwawayId = $throwaway->id;
@ -87,7 +88,7 @@ it('deleteModule() does not throw and removes the addon DB row', function (): vo
// Boot cache no longer lists it.
$cached = app(BootCache::class)->read();
expect(array_column($cached, 'path'))->not->toContain($tmpDir);
expect(array_map(fn (AddonBootCache $r): string => $r->path, $cached))->not->toContain($tmpDir);
// Cleanup temp dir.
@rmdir($tmpDir);

View File

@ -4,8 +4,9 @@ declare(strict_types=1);
use App\Addons\Compat\Module;
use App\Addons\Compat\ModuleRepository;
use App\Addons\Models\BootCache;
use App\Addons\Services\AddonRuntimeService;
use App\Addons\Models\AddonBootCache;
use App\Addons\Services\AddonDiscoveryService;
use App\Addons\Support\BootCache;
use App\Addons\Support\ManifestParser;
use App\Models\Addon;
use Nwidart\Modules\Exceptions\ModuleNotFoundException;
@ -15,7 +16,7 @@ beforeEach(function (): void {
app(BootCache::class)->delete();
// Seed DB rows + boot cache via AddonRuntimeService so tests start from a known state.
app(AddonRuntimeService::class)->run();
app(AddonDiscoveryService::class)->run();
});
afterEach(function (): void {
@ -151,7 +152,7 @@ it('config(unknown, default) returns the default', function (): void {
it('setActive(false) updates DB and removes Sample from boot cache; setActive(true) re-includes it', function (): void {
$repo = app(ModuleRepository::class);
$cache = app(BootCache::class);
$runtime = app(BootCache::class);
$shim = $repo->find('Sample');
expect($shim)->not->toBeNull();
@ -165,8 +166,8 @@ it('setActive(false) updates DB and removes Sample from boot cache; setActive(tr
->and($addonRow->enabled)->toBeFalse();
// Boot cache check — Sample must be absent.
$cached = $cache->read();
$names = array_column($cached, 'name');
$cached = $runtime->read();
$names = array_map(fn (AddonBootCache $r): string => $r->name, $cached);
expect($names)->not->toContain('Sample');
// Re-enable.
@ -176,8 +177,8 @@ it('setActive(false) updates DB and removes Sample from boot cache; setActive(tr
expect($addonRow->enabled)->toBeTrue();
// Boot cache must include Sample again.
$cached2 = $cache->read();
$names2 = array_column($cached2, 'name');
$cached2 = $runtime->read();
$names2 = array_map(fn (AddonBootCache $r): string => $r->name, $cached2);
expect($names2)->toContain('Sample');
});
@ -232,7 +233,7 @@ it('delete() removes the Addon DB row and the boot cache no longer lists it', fu
]));
// Run AddonRuntimeService so the temp addon is discovered, upserted, and cached.
app(AddonRuntimeService::class)->run();
app(AddonDiscoveryService::class)->run();
// Fetch the DB row AddonRuntimeService created for the temp addon.
$throwaway = Addon::query()->where('path', $tmpDir)->firstOrFail();
@ -243,7 +244,7 @@ it('delete() removes the Addon DB row and the boot cache no longer lists it', fu
// Confirm it's in the cache before delete.
$before = app(BootCache::class)->read();
$pathsBefore = array_column($before, 'path');
$pathsBefore = array_map(fn (AddonBootCache $r): string => $r->path, $before);
expect($pathsBefore)->toContain($tmpDir);
// Delete the DB row (also removes the dir's module.json so re-prime won't re-add it).
@ -255,7 +256,7 @@ it('delete() removes the Addon DB row and the boot cache no longer lists it', fu
// Boot cache must no longer list the deleted addon's path.
$cached = app(BootCache::class)->read();
$paths = array_column($cached, 'path');
$paths = array_map(fn (AddonBootCache $r): string => $r->path, $cached);
expect($paths)->not->toContain($tmpDir);
// Cleanup temp dir.

View File

@ -2,8 +2,9 @@
declare(strict_types=1);
use App\Addons\Models\BootCache;
use App\Addons\Services\AddonRuntimeService;
use App\Addons\Models\AddonBootCache;
use App\Addons\Services\AddonDiscoveryService;
use App\Addons\Support\BootCache;
use App\Addons\Support\ManifestParser;
use App\Models\Addon;
use Illuminate\Support\Facades\Log;
@ -23,9 +24,9 @@ afterEach(function (): void {
}
});
function makeService(): AddonRuntimeService
function makeService(): AddonDiscoveryService
{
return new AddonRuntimeService(new ManifestParser(), new BootCache());
return new AddonDiscoveryService(new ManifestParser(), new BootCache());
}
it('run() registers all three bundled modules from base_path(modules)', function (): void {
@ -67,18 +68,18 @@ it('run() excludes disabled addons from the boot cache (D-13)', function (): voi
$svc->run();
$cached = (new BootCache())->read();
$namespaces = array_column($cached, 'namespace');
$namespaces = array_map(fn (AddonBootCache $r): string => $r->namespace, $cached);
expect($namespaces)->not->toContain('Modules\\Awards');
});
it('run() writes enabled-only rows to the boot cache and the cache exists (STATE-02)', function (): void {
makeService()->run();
$cache = new BootCache();
expect($cache->exists())->toBeTrue();
$runtime = new BootCache();
expect($runtime->exists())->toBeTrue();
foreach ($cache->read() as $row) {
expect($row['enabled'])->toBeTrue();
foreach ($runtime->read() as $row) {
expect($row->enabled)->toBeTrue();
}
});
@ -129,8 +130,8 @@ it('primeIfNeeded() returns true and primes when boot cache is absent (D-10)', f
});
it('primeIfNeeded() re-primes when cache has a stale schema (D2-09)', function (): void {
$cache = new BootCache();
$path = $cache->path();
$runtime = new BootCache();
$path = $runtime->path();
// Write a Phase-1 bare-list file.
$bareList = [['registry_id' => null, 'namespace' => 'Modules\\Old', 'enabled' => true]];
@ -140,11 +141,11 @@ it('primeIfNeeded() re-primes when cache has a stale schema (D2-09)', function (
$result = $svc->primeIfNeeded();
expect($result)->toBeTrue()
->and($cache->isFresh())->toBeTrue();
->and($runtime->isFresh())->toBeTrue();
});
it('run() handles absent storage/app/addons directory without throwing', function (): void {
// The storage/app/addons dir may not exist on a fresh install; must not error.
it('run() handles absent modules directory without throwing', function (): void {
// The modules dir may not exist on a fresh install; must not error.
expect(fn () => makeService()->run())->not->toThrow(Throwable::class);
});
@ -155,17 +156,11 @@ it('run() produces enriched cache rows for the Sample module', function (): void
$sample = collect($cached)->firstWhere('namespace', 'Modules\\Sample');
expect($sample)->not->toBeNull()
->and($sample)->toHaveKey('providers')
->and($sample['providers'])->toBeArray()
->and($sample['providers'])->toContain(SampleServiceProvider::class)
->and($sample)->toHaveKey('autoload_path')
->and($sample['autoload_path'])->toBe(realpath(base_path('modules/Sample')))
->and($sample)->toHaveKey('layout')
->and($sample['layout'])->toBe('root')
->and($sample)->toHaveKey('name')
->and($sample['name'])->toBe('Sample')
->and($sample)->toHaveKey('description')
->and($sample['description'])->toBeNull()
->and($sample)->toHaveKey('filament')
->and($sample['filament'])->toBeArray();
->and($sample->providers)->toBeArray()
->and($sample->providers)->toContain(SampleServiceProvider::class)
->and($sample->autoloadPath)->toBe(realpath(base_path('modules/Sample')))
->and($sample->layout)->toBe('root')
->and($sample->name)->toBe('Sample')
->and($sample->description)->toBeNull()
->and($sample->filament)->toBeArray();
});

View File

@ -3,7 +3,7 @@
declare(strict_types=1);
use App\Addons\Filament\FilamentPanelExtender;
use App\Addons\Models\BootCache;
use App\Addons\Support\BootCache;
use Filament\PanelRegistry;
use Modules\Sample\Filament\Resources\SampleResource;
@ -19,15 +19,15 @@ it('phpvms:addons-prime records the Sample Filament Resources path in the boot c
$this->artisan('phpvms:addons-prime')->assertSuccessful();
$cached = app(BootCache::class)->read();
$sample = collect($cached)->first(fn (array $row): bool => ($row['name'] ?? null) === 'Sample');
$sample = collect($cached)->first(fn ($row): bool => $row->name === 'Sample');
expect($sample)->not->toBeNull('Sample row must exist in cache after prime')
->and($sample['filament'])->toBeArray()
->and($sample['filament']['admin'] ?? null)->toBeArray()
->and($sample['filament']['admin']['Resources'] ?? null)->not->toBeNull(
->and($sample->filament)->toBeArray()
->and($sample->filament['admin'] ?? null)->toBeArray()
->and($sample->filament['admin']['Resources'] ?? null)->not->toBeNull(
'Sample admin Resources path must be probed and recorded'
)
->and($sample['filament']['admin']['Resources'])->toEndWith('modules/Sample/Filament/Resources');
->and($sample->filament['admin']['Resources'])->toEndWith('modules/Sample/Filament/Resources');
});
it('FilamentPanelExtender::apply() registers SampleResource on the admin panel (criterion #2)', function (): void {

View File

@ -2,11 +2,13 @@
declare(strict_types=1);
use App\Addons\AddonRegistry;
use App\Addons\Models\AutoloadGuard;
use App\Addons\Models\BootCache;
use App\Addons\AddonAutoLoader;
use App\Addons\Models\AddonBootCache;
use App\Addons\Support\AutoloadGuard;
use App\Addons\Support\BootCache;
use App\Exceptions\AutoloadModeException;
use Composer\Autoload\ClassLoader;
use Illuminate\Support\Collection;
use Illuminate\Support\ServiceProvider;
// ---------------------------------------------------------------------------
@ -14,7 +16,7 @@ use Illuminate\Support\ServiceProvider;
// ---------------------------------------------------------------------------
/**
* Build a minimal boot-cache row for a given namespace / autoload path.
* Build a minimal boot-cache row array for a given namespace / autoload path.
*
* @param list<string> $providers
* @return array<string, mixed>
@ -39,28 +41,30 @@ function addonRow(string $namespace, string $autoloadPath, array $providers = []
}
/**
* Build an AddonLoader with a real guard and a registry stub whose enabled()
* returns $rows.
* Build an AddonLoader with a real guard and a runtime stub whose enabled()
* returns a Collection of AddonBootCache objects built from $rows.
*
* @param array<int, array<string, mixed>> $rows
*/
function loaderWithRows(array $rows): AddonLoader
function loaderWithRows(array $rows): AddonAutoLoader
{
$registry = new class($rows) extends AddonRegistry
$objects = array_map(AddonBootCache::fromArray(...), $rows);
$runtime = new class($objects) extends BootCache
{
/** @param array<int, array<string, mixed>> $rows */
public function __construct(private readonly array $rows)
/** @param list<AddonBootCache> $objects */
public function __construct(private readonly array $objects)
{
// Skip parent constructor (BootCache not needed).
// Skip parent constructor — no file I/O needed in tests.
}
public function enabled(): array
public function enabled(): Collection
{
return $this->rows;
return collect($this->objects);
}
};
return new AddonLoader($registry, new AutoloadGuard());
return new AddonAutoLoader($runtime, new AutoloadGuard());
}
// ---------------------------------------------------------------------------
@ -126,21 +130,24 @@ it('register() propagates the guard exception and adds no PSR-4 prefix', functio
}
};
$registry = new class([addonRow($namespace, $autoloadPath)]) extends AddonRegistry
$objects = [AddonBootCache::fromArray(addonRow($namespace, $autoloadPath))];
$runtime = new class($objects) extends BootCache
{
public function __construct(private readonly array $rows)
/** @param list<AddonBootCache> $objects */
public function __construct(private readonly array $objects)
{
// Skip parent constructor (BootCache not needed).
// Skip parent constructor.
}
public function enabled(): array
public function enabled(): Collection
{
return $this->rows;
return collect($this->objects);
}
};
$injectedLoader = new ClassLoader();
$addonLoader = new AddonLoader($registry, $throwingGuard);
$addonLoader = new AddonAutoLoader($runtime, $throwingGuard);
expect(fn () => $addonLoader->register(app(), $injectedLoader))
->toThrow(AutoloadModeException::class);

View File

@ -1,85 +0,0 @@
<?php
declare(strict_types=1);
use App\Addons\AddonRegistry;
use App\Addons\Models\BootCache;
use App\Models\Addon;
beforeEach(function (): void {
$path = base_path('bootstrap/cache/addons.php');
if (file_exists($path)) {
unlink($path);
}
});
afterEach(function (): void {
$path = base_path('bootstrap/cache/addons.php');
if (file_exists($path)) {
unlink($path);
}
});
it('enabled() returns what BootCache::read() returns when cache is written', function (): void {
$cache = new BootCache();
$addons = [
[
'registry_id' => 'acme/widget',
'namespace' => 'Modules\\Widget',
'enabled' => true,
'type' => 'module',
'version' => '1.0.0',
'path' => '/var/addons/Widget',
],
];
$cache->write($addons);
$registry = new AddonRegistry($cache);
expect($registry->enabled())->toBe($addons);
});
it('enabled() returns empty array when cache is absent', function (): void {
$cache = new BootCache();
$registry = new AddonRegistry($cache);
expect($registry->enabled())->toBe([]);
});
it('all() returns all Addon rows including disabled ones', function (): void {
Addon::factory()->create(['enabled' => true]);
Addon::factory()->create(['enabled' => false]);
$total = Addon::count();
$registry = new AddonRegistry(new BootCache());
expect($registry->all()->count())->toBe($total);
});
it('find() resolves by registry_id', function (): void {
$addon = Addon::factory()->create(['registry_id' => 'acme/widget']);
$registry = new AddonRegistry(new BootCache());
$found = $registry->find('acme/widget');
expect($found)->not->toBeNull()
->and($found->id)->toBe($addon->id);
});
it('find() resolves by path', function (): void {
// Use a path that won't conflict with the migration-seeded bundled modules.
$path = base_path('modules/TestOnlyWidget_'.uniqid());
$addon = Addon::factory()->create(['registry_id' => null, 'path' => $path]);
$registry = new AddonRegistry(new BootCache());
$found = $registry->find($path);
expect($found)->not->toBeNull()
->and($found->id)->toBe($addon->id);
});
it('find() returns null when no match is found', function (): void {
$registry = new AddonRegistry(new BootCache());
expect($registry->find('nope/nothing'))->toBeNull();
});

View File

@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
use App\Addons\Models\AddonBootCache;
use App\Addons\Support\BootCache;
use Illuminate\Support\Collection;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Build a minimal AddonBootCache object for testing.
*
* @param array<string, mixed> $overrides
*/
function makeAddonBootCache(array $overrides = []): AddonBootCache
{
return AddonBootCache::fromArray(array_merge([
'name' => 'TestAddon',
'alias' => 'testaddon',
'type' => 'module',
'registry_id' => 'acme/test',
'version' => '1.0.0',
'namespace' => 'Modules\\TestAddon',
'providers' => [],
'path' => '/var/addons/TestAddon',
'autoload_path' => '/var/addons/TestAddon',
'layout' => 'app',
'description' => null,
'enabled' => true,
'filament' => [],
], $overrides));
}
// ---------------------------------------------------------------------------
// Setup / teardown
// ---------------------------------------------------------------------------
beforeEach(function (): void {
$path = base_path('bootstrap/cache/addons.php');
if (file_exists($path)) {
unlink($path);
}
foreach (glob(base_path('bootstrap/cache/addons.php.tmp*')) ?: [] as $tmp) {
@unlink($tmp);
}
});
afterEach(function (): void {
$path = base_path('bootstrap/cache/addons.php');
if (file_exists($path)) {
unlink($path);
}
foreach (glob(base_path('bootstrap/cache/addons.php.tmp*')) ?: [] as $tmp) {
@unlink($tmp);
}
});
// ---------------------------------------------------------------------------
// write() / read() round-trips
// ---------------------------------------------------------------------------
it('write() then read() round-trips AddonBootCache objects', function (): void {
$runtime = new BootCache();
$addon1 = makeAddonBootCache([
'name' => 'Widget',
'namespace' => 'Modules\\Widget',
'registry_id' => 'acme/widget',
'enabled' => true,
'providers' => ['Modules\\Widget\\Providers\\WidgetServiceProvider'],
]);
$addon2 = makeAddonBootCache([
'name' => 'Awards',
'namespace' => 'Modules\\Awards',
'registry_id' => null,
'version' => null,
'enabled' => true,
]);
$runtime->write([$addon1, $addon2]);
$result = $runtime->read();
expect($result)->toHaveCount(2)
->and($result[0]->toArray())->toBe($addon1->toArray())
->and($result[1]->toArray())->toBe($addon2->toArray());
});
it('read() returns empty array when cache file is absent', function (): void {
$runtime = new BootCache();
expect($runtime->read())->toBe([]);
});
// ---------------------------------------------------------------------------
// isFresh()
// ---------------------------------------------------------------------------
it('isFresh() returns true after a real write()', function (): void {
$runtime = new BootCache();
$runtime->write([]);
expect($runtime->isFresh())->toBeTrue();
});
it('isFresh() returns false when cache file is absent', function (): void {
$runtime = new BootCache();
expect($runtime->isFresh())->toBeFalse();
});
// ---------------------------------------------------------------------------
// Stale-schema handling
// ---------------------------------------------------------------------------
it('bare-list cache (no schema key): read() returns [] and isFresh() returns false', function (): void {
$runtime = new BootCache();
$path = $runtime->path();
$bareList = [
['registry_id' => 'old/addon', 'namespace' => 'Modules\\Old', 'enabled' => true],
];
$content = '<?php'.PHP_EOL.'return '.var_export($bareList, true).';'.PHP_EOL;
file_put_contents($path, $content);
expect($runtime->isFresh())->toBeFalse()
->and($runtime->read())->toBe([]);
});
it('schema-1 wrapper cache: read() returns [] and isFresh() returns false', function (): void {
$runtime = new BootCache();
$path = $runtime->path();
$wrapper = [
'schema' => 1,
'addons' => [
['registry_id' => 'old/addon', 'enabled' => true],
],
];
$content = '<?php'.PHP_EOL.'return '.var_export($wrapper, true).';'.PHP_EOL;
file_put_contents($path, $content);
expect($runtime->isFresh())->toBeFalse()
->and($runtime->read())->toBe([]);
});
// ---------------------------------------------------------------------------
// delete()
// ---------------------------------------------------------------------------
it('delete() removes the file', function (): void {
$runtime = new BootCache();
$runtime->write([]);
expect($runtime->exists())->toBeTrue();
$runtime->delete();
expect($runtime->exists())->toBeFalse();
});
// ---------------------------------------------------------------------------
// Security: hostile values round-trip unchanged
// ---------------------------------------------------------------------------
it('hostile registryId round-trips unchanged through write()/read()', function (): void {
$runtime = new BootCache();
$hostileId = "'; echo 'pwned";
$addon = makeAddonBootCache(['registry_id' => $hostileId]);
$runtime->write([$addon]);
$result = $runtime->read();
expect($result[0]->registryId)->toBe($hostileId);
});
// ---------------------------------------------------------------------------
// No leftover temp files
// ---------------------------------------------------------------------------
it('leaves no leftover temp files in bootstrap/cache after write()', function (): void {
$runtime = new BootCache();
$runtime->write([makeAddonBootCache()]);
$tmpFiles = glob(base_path('bootstrap/cache/addons.php.tmp*')) ?: [];
expect($tmpFiles)->toBeEmpty();
});
// ---------------------------------------------------------------------------
// enabled() filtering
// ---------------------------------------------------------------------------
it('enabled() returns only enabled rows as a Collection', function (): void {
$runtime = new BootCache();
$enabledAddon = makeAddonBootCache(['name' => 'Enabled', 'namespace' => 'Modules\\Enabled', 'enabled' => true]);
$disabledAddon = makeAddonBootCache(['name' => 'Disabled', 'namespace' => 'Modules\\Disabled', 'enabled' => false]);
$runtime->write([$enabledAddon, $disabledAddon]);
$result = $runtime->enabled();
expect($result)->toBeInstanceOf(Collection::class)
->and($result)->toHaveCount(1)
->and($result->first()->name)->toBe('Enabled');
});
it('enabled() returns empty Collection when cache is absent', function (): void {
$runtime = new BootCache();
$result = $runtime->enabled();
expect($result)->toBeInstanceOf(Collection::class)
->and($result)->toBeEmpty();
});
// ---------------------------------------------------------------------------
// all()
// ---------------------------------------------------------------------------
it('all() returns a Collection of every row', function (): void {
$runtime = new BootCache();
$addon1 = makeAddonBootCache(['name' => 'Alpha', 'namespace' => 'Modules\\Alpha', 'enabled' => true]);
$addon2 = makeAddonBootCache(['name' => 'Beta', 'namespace' => 'Modules\\Beta', 'enabled' => false]);
$runtime->write([$addon1, $addon2]);
$result = $runtime->all();
expect($result)->toBeInstanceOf(Collection::class)
->and($result)->toHaveCount(2);
});
it('all() returns empty Collection when cache is absent', function (): void {
$runtime = new BootCache();
$result = $runtime->all();
expect($result)->toBeInstanceOf(Collection::class)
->and($result)->toBeEmpty();
});

View File

@ -2,7 +2,7 @@
declare(strict_types=1);
use App\Addons\Models\AutoloadGuard;
use App\Addons\Support\AutoloadGuard;
use App\Exceptions\AutoloadModeException;
use Composer\Autoload\ClassLoader;

View File

@ -1,181 +0,0 @@
<?php
declare(strict_types=1);
use App\Addons\Models\BootCache;
beforeEach(function (): void {
$path = base_path('bootstrap/cache/addons.php');
if (file_exists($path)) {
unlink($path);
}
foreach (glob(base_path('bootstrap/cache/addons.php.tmp*')) ?: [] as $tmp) {
@unlink($tmp);
}
});
afterEach(function (): void {
$path = base_path('bootstrap/cache/addons.php');
if (file_exists($path)) {
unlink($path);
}
foreach (glob(base_path('bootstrap/cache/addons.php.tmp*')) ?: [] as $tmp) {
@unlink($tmp);
}
});
it('exists() returns false when cache file is absent', function (): void {
$cache = new BootCache();
expect($cache->exists())->toBeFalse();
});
it('exists() returns true after write()', function (): void {
$cache = new BootCache();
$cache->write([]);
expect($cache->exists())->toBeTrue();
});
it('read() returns empty array when cache file is absent', function (): void {
$cache = new BootCache();
expect($cache->read())->toBe([]);
});
it('write() then read() round-trips the enabled-addon array', function (): void {
$cache = new BootCache();
$addons = [
[
'registry_id' => 'acme/widget',
'namespace' => 'Modules\\Widget',
'enabled' => true,
'type' => 'module',
'version' => '1.0.0',
'path' => 'storage/app/addons/Widget',
],
[
'registry_id' => null,
'namespace' => 'Modules\\Awards',
'enabled' => true,
'type' => 'module',
'version' => null,
'path' => 'modules/Awards',
],
];
$cache->write($addons);
expect($cache->read())->toBe($addons);
});
it('written cache file is valid PHP that require returns a versioned wrapper with addons', function (): void {
$cache = new BootCache();
$addons = [
['registry_id' => 'foo/bar', 'namespace' => 'Modules\\Foo', 'enabled' => true],
];
$cache->write($addons);
$path = $cache->path();
expect(file_exists($path))->toBeTrue();
// php -l syntax check
exec('php -l '.escapeshellarg($path).' 2>&1', $output, $exitCode);
expect($exitCode)->toBe(0);
// require returns versioned wrapper
$result = require $path;
expect($result)->toBeArray()
->and($result['schema'])->toBe(BootCache::SCHEMA)
->and($result['addons'])->toBe($addons);
});
it('leaves no leftover temp files in bootstrap/cache after write()', function (): void {
$cache = new BootCache();
$cache->write([['registry_id' => 'test/a', 'enabled' => true]]);
$tmpFiles = glob(base_path('bootstrap/cache/addons.php.tmp*')) ?: [];
expect($tmpFiles)->toBeEmpty();
});
it('delete() removes the file and exists() returns false', function (): void {
$cache = new BootCache();
$cache->write([]);
expect($cache->exists())->toBeTrue();
$cache->delete();
expect($cache->exists())->toBeFalse();
});
it('var_export escaping: hostile registry_id value round-trips unchanged', function (): void {
$cache = new BootCache();
$hostileId = "'; echo 'pwned"; // SQL/PHP injection attempt
$addons = [
[
'registry_id' => $hostileId,
'namespace' => 'Modules\\Evil',
'enabled' => true,
],
];
$cache->write($addons);
$result = $cache->read();
expect($result[0]['registry_id'])->toBe($hostileId);
});
it('path() returns the bootstrap/cache/addons.php path', function (): void {
$cache = new BootCache();
expect($cache->path())->toBe(base_path('bootstrap/cache/addons.php'));
});
it('isFresh() returns false when cache file is absent', function (): void {
$cache = new BootCache();
expect($cache->isFresh())->toBeFalse();
});
it('isFresh() returns true after write()', function (): void {
$cache = new BootCache();
$cache->write([]);
expect($cache->isFresh())->toBeTrue();
});
it('stale-schema cache: read() returns [] and isFresh() returns false', function (): void {
$cache = new BootCache();
$path = $cache->path();
// Simulate a Phase-1 bare-list cache file (no schema key).
$bareList = [
['registry_id' => 'old/addon', 'namespace' => 'Modules\\Old', 'enabled' => true],
];
$content = '<?php'.PHP_EOL.'return '.var_export($bareList, true).';'.PHP_EOL;
file_put_contents($path, $content);
expect($cache->isFresh())->toBeFalse()
->and($cache->read())->toBe([]);
});
it('schema-1 wrapper cache: read() returns [] and isFresh() returns false', function (): void {
$cache = new BootCache();
$path = $cache->path();
// Simulate a schema version 1 wrapper.
$wrapper = [
'schema' => 1,
'addons' => [
['registry_id' => 'old/addon', 'enabled' => true],
],
];
$content = '<?php'.PHP_EOL.'return '.var_export($wrapper, true).';'.PHP_EOL;
file_put_contents($path, $content);
expect($cache->isFresh())->toBeFalse()
->and($cache->read())->toBe([]);
});

View File

@ -2,34 +2,46 @@
declare(strict_types=1);
use App\Addons\AddonRegistry;
use App\Addons\Filament\FilamentPanelExtender;
use App\Addons\Models\BootCache;
use App\Addons\Models\AddonBootCache;
use App\Addons\Support\BootCache;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Build a FilamentPanelExtender with a stubbed registry that returns $rows.
* Build a FilamentPanelExtender backed by a real (no-cache) AddonRuntime.
*
* @param array<int, array<string, mixed>> $rows
* These tests call discoveriesFor() directly, so no cache reading happens.
*/
function makeFilamentPanelExtender(array $rows = []): FilamentPanelExtender
function makeFilamentPanelExtender(): FilamentPanelExtender
{
$cache = new class($rows) extends BootCache
{
/** @param array<int, array<string, mixed>> $rows */
public function __construct(private readonly array $rows) {}
return new FilamentPanelExtender(new BootCache());
}
/** @return array<int, array<string, mixed>> */
public function read(): array
{
return $this->rows;
}
};
return new FilamentPanelExtender(new AddonRegistry($cache));
/**
* Build an AddonBootCache from a minimal array, filling in required fields.
*
* @param array<string, mixed> $data
*/
function makeEntry(array $data): AddonBootCache
{
return AddonBootCache::fromArray(array_merge([
'name' => 'Test',
'alias' => null,
'type' => 'module',
'registry_id' => null,
'version' => null,
'namespace' => '',
'providers' => [],
'path' => '/tmp/test',
'autoload_path' => '/tmp/test',
'layout' => 'app',
'description' => null,
'enabled' => true,
'filament' => [],
], $data));
}
// ---------------------------------------------------------------------------
@ -39,7 +51,7 @@ function makeFilamentPanelExtender(array $rows = []): FilamentPanelExtender
it('produces three admin entries for a row with all admin components', function (): void {
$extender = makeFilamentPanelExtender();
$row = [
$entry = makeEntry([
'namespace' => 'Modules\\Acme',
'filament' => [
'admin' => [
@ -48,9 +60,9 @@ it('produces three admin entries for a row with all admin components', function
'Widgets' => '/var/app/modules/Acme/Filament/Widgets',
],
],
];
]);
$result = $extender->discoveriesFor($row);
$result = $extender->discoveriesFor($entry);
expect($result)->toHaveKey('admin')
->and($result)->not->toHaveKey('system')
@ -80,16 +92,16 @@ it('produces three admin entries for a row with all admin components', function
it('produces one system entry for a row with only system Resources', function (): void {
$extender = makeFilamentPanelExtender();
$row = [
$entry = makeEntry([
'namespace' => 'Modules\\Acme',
'filament' => [
'system' => [
'Resources' => '/var/app/modules/Acme/Filament/System/Resources',
],
],
];
]);
$result = $extender->discoveriesFor($row);
$result = $extender->discoveriesFor($entry);
expect($result)->not->toHaveKey('admin')
->and($result)->toHaveKey('system')
@ -104,27 +116,27 @@ it('produces one system entry for a row with only system Resources', function ()
it('returns an empty array when filament key is empty', function (): void {
$extender = makeFilamentPanelExtender();
$row = [
$entry = makeEntry([
'namespace' => 'Modules\\Acme',
'filament' => [],
];
]);
expect($extender->discoveriesFor($row))->toBe([]);
expect($extender->discoveriesFor($entry))->toBe([]);
});
it('strips trailing backslash from namespace before building for: string', function (): void {
$extender = makeFilamentPanelExtender();
$row = [
$entry = makeEntry([
'namespace' => 'Modules\\Acme\\', // trailing backslash
'filament' => [
'admin' => [
'Resources' => '/abs/path/Resources',
],
],
];
]);
$result = $extender->discoveriesFor($row);
$result = $extender->discoveriesFor($entry);
expect($result['admin'][0]['for'])->toBe('Modules\\Acme\\Filament\\Resources');
});
@ -132,29 +144,29 @@ it('strips trailing backslash from namespace before building for: string', funct
it('returns an empty array when namespace is absent and filament data is present', function (): void {
$extender = makeFilamentPanelExtender();
// Row has filament data for admin Resources but no namespace key.
$row = [
// namespace key missing — fromArray defaults to '' which triggers the empty guard.
$entry = makeEntry([
'filament' => [
'admin' => [
'Resources' => '/abs/path/Resources',
],
],
];
]);
expect($extender->discoveriesFor($row))->toBe([]);
expect($extender->discoveriesFor($entry))->toBe([]);
});
it('returns an empty array when namespace is empty string and filament data is present', function (): void {
$extender = makeFilamentPanelExtender();
$row = [
$entry = makeEntry([
'namespace' => '',
'filament' => [
'admin' => [
'Resources' => '/abs/path/Resources',
],
],
];
]);
expect($extender->discoveriesFor($row))->toBe([]);
expect($extender->discoveriesFor($entry))->toBe([]);
});

View File

@ -1,6 +1,6 @@
<?php
use App\Addons\Support\ManifestData;
use App\Addons\Models\AddonManifest;
use App\Addons\Support\ManifestParser;
use Modules\Awards\Providers\AwardServiceProvider;
@ -8,7 +8,7 @@ it('parses Awards module (legacy nwidart, no phpVMS keys, no composer.json)', fu
$parser = new ManifestParser();
$result = $parser->parse(base_path('modules/Awards'));
expect($result)->toBeInstanceOf(ManifestData::class)
expect($result)->toBeInstanceOf(AddonManifest::class)
->and($result->name)->toBe('Awards')
->and($result->type)->toBe('module')
->and($result->registryId)->toBeNull()
@ -22,7 +22,7 @@ it('parses Sample module (composer.json psr-4 dot key, no version)', function ()
$parser = new ManifestParser();
$result = $parser->parse(base_path('modules/Sample'));
expect($result)->toBeInstanceOf(ManifestData::class)
expect($result)->toBeInstanceOf(AddonManifest::class)
->and($result->namespace)->toBe('Modules\\Sample')
->and($result->version)->toBeNull();
});
@ -31,7 +31,7 @@ it('parses VMSAcars module (composer.json psr-4 empty string key, version from c
$parser = new ManifestParser();
$result = $parser->parse(base_path('modules/VMSAcars'));
expect($result)->toBeInstanceOf(ManifestData::class)
expect($result)->toBeInstanceOf(AddonManifest::class)
->and($result->namespace)->toBe('Modules\\VMSAcars')
->and($result->version)->toBe('1.1.0');
});
@ -52,7 +52,7 @@ it('parses phpVMS keys: type, compat, registry_id, version', function (): void {
$parser = new ManifestParser();
$result = $parser->parse($tmpDir);
expect($result)->toBeInstanceOf(ManifestData::class)
expect($result)->toBeInstanceOf(AddonManifest::class)
->and($result->type)->toBe('theme')
->and($result->compat)->toBe('^7.0')
->and($result->registryId)->toBe('acme/widget')
@ -106,7 +106,7 @@ it('normalises blank registry_id to null (D-03)', function (): void {
$parser = new ManifestParser();
$result = $parser->parse($tmpDir);
expect($result)->toBeInstanceOf(ManifestData::class)
expect($result)->toBeInstanceOf(AddonManifest::class)
->and($result->registryId)->toBeNull();
} finally {
unlink($tmpDir.'/module.json');