refactor(addons): remove Module shim, legacy NWIDART bindings, and update controllers/tests

- Removed `Module` and `ModuleRepository` compatibility shims.
- Replaced NWIDART `Module` facade usages with the new `AddonRegistry`.
- Updated frontend controllers to use `AddonRegistry` for module handling.
- Added new feature tests to cover `AddonRegistry` lifecycle and rebuild cases.
- Updated bootstrapping logic and service bindings to drop NWIDART dependencies.

# Conflicts:
#	app/Models/Flight.php
#	app/Support/Utils.php
This commit is contained in:
Nabeel Shahzad 2026-06-08 00:10:14 -05:00
parent 282a485fef
commit d0cf54c3de
No known key found for this signature in database
GPG Key ID: 08C44114D2BF3047
24 changed files with 515 additions and 822 deletions

View File

@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Addons;
use App\Addons\Services\AddonDiscoveryService;
use App\Addons\Support\BootCache;
use App\Exceptions\AddonNotFoundException;
use App\Models\Addon;
use Illuminate\Support\Collection;
/**
* Lifecycle façade for addons. Owns reads (find/all/enabled), enable/disable,
* delete, install, update, asset linking, and Octane refresh.
*
* Reads return Addon Eloquent models. "enabled" is DB intent; "loaded" is the
* boot-cache reality (what actually got PSR-4/provider-registered this worker).
*/
class AddonRegistry
{
public function __construct(
private readonly BootCache $bootCache,
) {}
/**
* Find an addon by display name; null when not found.
*/
public function find(string $name): ?Addon
{
return $this->all()->first(fn (Addon $addon): bool => $addon->getName() === $name);
}
/**
* Find an addon by display name or throw.
*
* @throws AddonNotFoundException
*/
public function findOrFail(string $name): Addon
{
return $this->find($name) ?? throw new AddonNotFoundException($name);
}
/**
* Every addon row (enabled and disabled).
*
* @return Collection<int, Addon>
*/
public function all(): Collection
{
return Addon::query()->get();
}
/**
* Enabled addons (DB intent).
*
* @return Collection<int, Addon>
*/
public function enabled(): Collection
{
return Addon::query()->where('enabled', true)->get();
}
/**
* Whether the addon's code is actually loaded this worker i.e. present in
* the enabled set of the boot cache, which is what the autoloader reads.
*/
public function isLoaded(string $name): bool
{
return $this->bootCache->enabled()
->contains(fn ($entry): bool => ($entry->name ?? basename($entry->path)) === $name);
}
/**
* Enable an addon: flip the DB flag and regenerate the boot cache.
* No-op when the addon is unknown.
*/
public function enable(string $name): void
{
$this->setEnabled($name, true);
}
/**
* Disable an addon: flip the DB flag and regenerate the boot cache.
* No-op when the addon is unknown.
*/
public function disable(string $name): void
{
$this->setEnabled($name, false);
}
/**
* Delete an addon's DB row and regenerate the boot cache.
* Does NOT remove files on disk. No-op when the addon is unknown.
*/
public function delete(string $name): void
{
$addon = $this->find($name);
if (!$addon instanceof Addon) {
return;
}
$addon->delete();
app(AddonDiscoveryService::class)->rebuildCache();
}
/**
* Persist the enabled flag and regenerate the boot cache.
*/
private function setEnabled(string $name, bool $enabled): void
{
$addon = $this->find($name);
if (!$addon instanceof Addon) {
return;
}
$addon->enabled = $enabled;
$addon->save();
app(AddonDiscoveryService::class)->rebuildCache();
}
}

View File

@ -1,156 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Addons\Compat;
use App\Addons\Models\AddonManifest;
use App\Addons\Services\AddonDiscoveryService;
use App\Addons\Support\ManifestParser;
use App\Models\Addon;
use Illuminate\Support\Str;
/**
* Compatibility shim wrapping an Addon model to satisfy the duck-typed
* call surface previously provided by \Nwidart\Modules\Module.
*
* Lazy-parses module.json once per instance for name/description.
* Octane-safe: instances are request-scoped value objects no static
* or global accumulators.
*/
class Module
{
/** @var AddonManifest|false|null false = parse attempted but failed; null = not yet parsed */
private AddonManifest|false|null $manifest = null;
public function __construct(
private readonly Addon $addon,
private readonly ManifestParser $parser,
) {}
/**
* The manifest name, falling back to basename(path).
*/
public function getName(): string
{
return $this->resolveManifest()?->name ?? basename($this->addon->path);
}
/**
* Lowercase module name.
*/
public function getLowerName(): string
{
return strtolower($this->getName());
}
/**
* StudlyCase module name (mirrors nwidart Module::getStudlyName()).
*/
public function getStudlyName(): string
{
return Str::studly($this->getName());
}
/**
* Absolute filesystem path to the addon directory.
*/
public function getPath(): string
{
return $this->addon->path;
}
/**
* Absolute path to a sub-path within the addon directory.
*/
public function getExtraPath(string $path): string
{
return $this->getPath().'/'.$path;
}
/**
* Manifest description; null when absent or blank.
*/
public function getDescription(): ?string
{
return $this->resolveManifest()?->description;
}
/**
* Whether the addon is enabled.
*/
public function isEnabled(): bool
{
return $this->addon->enabled;
}
/**
* Set the addon's enabled state, persist to DB, and regenerate the boot cache.
*/
public function setActive(bool $active): void
{
$this->addon->enabled = $active;
$this->addon->save();
app(AddonDiscoveryService::class)->run();
}
/**
* Enable the addon.
*/
public function enable(): void
{
$this->setActive(true);
}
/**
* Remove the addon's DB row and regenerate the boot cache.
*
* Does NOT delete files on disk (full lifecycle handled in Phase 5).
*/
public function delete(): void
{
$this->addon->delete();
app(AddonDiscoveryService::class)->run();
}
/**
* Magic property access for $shim->name and $shim->description.
*
* Unknown properties return null intentionally for nwidart compatibility
* nwidart Module exposes many public properties and callers may access them
* without checking; null is a safe no-op sentinel.
*/
public function __get(string $key): mixed
{
return match ($key) {
'name' => $this->getName(),
'description' => $this->getDescription(),
default => null,
};
}
/**
* Magic isset for property checks.
*/
public function __isset(string $key): bool
{
return in_array($key, ['name', 'description'], true);
}
/**
* Lazy-parse the manifest once per instance.
*
* Returns null when the manifest is missing or invalid.
*/
private function resolveManifest(): ?AddonManifest
{
if ($this->manifest === null) {
$parsed = $this->parser->parse($this->addon->path);
$this->manifest = $parsed instanceof AddonManifest ? $parsed : false;
}
return $this->manifest instanceof AddonManifest ? $this->manifest : null;
}
}

View File

@ -1,117 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Addons\Compat;
use App\Addons\Models\AddonBootCache;
use App\Addons\Support\BootCache;
use App\Addons\Support\ManifestParser;
use App\Models\Addon;
use Illuminate\Support\Collection;
/**
* Compatibility repository satisfying the duck-typed surface of the nwidart
* Module facade (container key: 'modules').
*
* 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 BootCache $registry,
private readonly ManifestParser $parser,
) {}
/**
* Return all addon shims keyed by module name.
*
* @return Collection<string, Module>
*/
public function all(): Collection
{
return $this->registry->all()
->mapWithKeys(function (AddonBootCache $runtime): array {
$addon = Addon::fromBootCache($runtime);
$shim = $this->resolveShim($addon);
return [$shim->getName() => $shim];
});
}
/**
* Return enabled addon shims keyed by module name.
*
* NOTE: reads from DB + parses manifests (cold/admin path). A future
* optimisation could build from AddonRuntime::enabled() (boot cache)
* once ModuleShim can be constructed from a cache row, avoiding the
* per-row manifest parse entirely.
*
* @return Collection<string, Module>
*/
public function allEnabled(): Collection
{
// Intentionally reads from DB (addons table), not the boot cache — the cache may
// be absent during installer/migration flows where DB is the only source of truth.
return $this->registry->enabled()
->mapWithKeys(function (AddonBootCache $runtime): array {
$addon = Addon::fromBootCache($runtime);
$shim = $this->resolveShim($addon);
return [$shim->getName() => $shim];
});
}
/**
* Find a module shim by name; returns null when not found.
*
* Matches by manifest name (case-sensitive), falling back to basename(path).
* Iteration order follows Eloquent default (no explicit ORDER BY).
*/
public function find(string $name): ?Module
{
/** @var AddonBootCache $runtime */
foreach ($this->registry->all() as $runtime) {
$addon = Addon::fromBootCache($runtime);
$shim = $this->resolveShim($addon);
if ($shim->getName() === $name) {
return $shim;
}
}
return null;
}
/**
* Check whether a module is enabled by name.
*/
public function isEnabled(string $name): bool
{
return $this->find($name)?->isEnabled() ?? false;
}
/**
* Return config values for the nwidart module configuration surface.
*
* Supports: 'namespace' 'Modules'; all other keys return $default.
*/
public function config(string $key, mixed $default = null): mixed
{
if ($key === 'namespace') {
return 'Modules';
}
return $default;
}
/**
* Build a ModuleShim for the given Addon row.
*/
private function resolveShim(Addon $addon): Module
{
return new Module($addon, $this->parser);
}
}

View File

@ -147,6 +147,37 @@ class AddonDiscoveryService
return $newAddons;
}
/**
* Regenerate the boot cache from current DB enabled state.
*
* Scans manifests on disk, matches each to its DB row, and writes only the
* enabled addons (enabled-only cache invariant, D-13). This is the cache
* regeneration that lifecycle mutations (enable/disable/delete/install/update)
* must call run() does NOT rewrite the cache on an installed system.
*/
public function rebuildCache(): void
{
$manifests = $this->scanLocation(config('addons.paths.base'));
/** @var Collection<int, Addon> $installed */
$installed = Addon::where('enabled', true)->get();
/** @var list<AddonBootCache> $cacheRows */
$cacheRows = [];
foreach ($manifests as $m) {
$addon = $installed->first(fn (Addon $a): bool => ($a->registry_id === $m->registryId && $m->registryId !== null)
|| $a->name === $m->name
|| $a->namespace === $m->namespace);
if ($addon !== null) {
$cacheRows[] = $this->buildBootCacheRow($m, true);
}
}
$this->bootCache->write($cacheRows);
}
/**
* Enumerate immediate subdirectories of $dir and parse each manifest.
*

View File

@ -2,17 +2,18 @@
namespace App\Console\Commands;
use App\Addons\AddonRegistry;
use App\Models\Addon;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Console\Concerns\PromptsForMissingInput;
use Illuminate\Contracts\Console\PromptsForMissingInput as PromptsForMissingInputContract;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Nwidart\Modules\Module;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'module:setup-filament', description: 'Add Filament Support to a Module')]
#[Signature('module:setup-filament {module : The name of the module}')]
#[AsCommand(name: 'addon:setup-filament', description: 'Add Filament Support to a Module')]
#[Signature('addon:setup-filament {module : The name of the module}')]
class ModuleSetupFilament extends Command implements PromptsForMissingInputContract
{
use PromptsForMissingInput;
@ -23,6 +24,12 @@ class ModuleSetupFilament extends Command implements PromptsForMissingInputContr
protected string $panelStub = 'resources/stubs/modules/admin-panel-provider.stub';
public function __construct(
protected readonly AddonRegistry $addonRegistry,
) {
parent::__construct();
}
/**
* Execute the console command.
*/
@ -30,18 +37,16 @@ class ModuleSetupFilament extends Command implements PromptsForMissingInputContr
{
$moduleName = $this->argument('module');
/** @var Module|null $module */
$module = app('modules')->find($moduleName);
if (!$module) {
$addon = $this->addonRegistry->find($moduleName);
if (!$addon) {
$this->components->error(sprintf("Module [%s] not found. Are you sure it's installed and enabled?", $moduleName));
return self::FAILURE;
}
$this->components->info('Setting up Filament for module: '.$module->getName());
$this->components->info('Setting up Filament for module: '.$addon->getName());
$providerPath = str($module->getExtraPath(sprintf('%s/%s', $this->basePath, $this->className)))
$providerPath = str($addon->getExtraPath(sprintf('%s/%s', $this->basePath, $this->className)))
->replace('\\', '/')
->append('.php')
->toString();
@ -49,7 +54,7 @@ class ModuleSetupFilament extends Command implements PromptsForMissingInputContr
$namespace = Str::of($this->basePath)
->replace('/', '\\')
->prepend('\\')
->prepend($this->getModuleNamespace($module))
->prepend($addon->namespace)
->toString();
$providerClass = sprintf('%s\%s', $namespace, $this->className);
@ -57,8 +62,8 @@ class ModuleSetupFilament extends Command implements PromptsForMissingInputContr
// Step 1: Scaffold the Provider
$stubSuccess = false;
$this->components->task('Creating Admin Panel Provider', function () use ($module, $providerPath, &$stubSuccess): bool {
$stubSuccess = $this->copyPanelStubToApp($module, $providerPath);
$this->components->task('Creating Admin Panel Provider', function () use ($addon, $providerPath, &$stubSuccess): bool {
$stubSuccess = $this->copyPanelStubToApp($addon, $providerPath);
// Return the boolean so the task component shows a Green Check or Red X
return $stubSuccess;
@ -69,20 +74,20 @@ class ModuleSetupFilament extends Command implements PromptsForMissingInputContr
}
// Step 2: Register in module.json
$this->components->task('Registering provider in module.json', function () use ($module, $providerClass): void {
$this->components->task('Registering provider in module.json', function () use ($addon, $providerClass): void {
$this->updateJsonArray(
module_path($module->getName(), 'module.json'),
$addon->getExtraPath('module.json'),
'providers',
$providerClass
);
});
// Step 3: Register in composer.json
$this->components->task('Registering provider in composer.json', function () use ($module, $providerClass): void {
$this->updateComposerJson($module, $providerClass);
$this->components->task('Registering provider in composer.json', function () use ($addon, $providerClass): void {
$this->updateComposerJson($addon, $providerClass);
});
$this->components->info(sprintf('Module [%s] is now ready for Filament!', $module->getName()));
$this->components->info(sprintf('Module [%s] is now ready for Filament!', $addon->getName()));
return self::SUCCESS;
}
@ -90,7 +95,7 @@ class ModuleSetupFilament extends Command implements PromptsForMissingInputContr
/**
* Copy the stub file and replace placeholders.
*/
protected function copyPanelStubToApp(Module $module, string $targetPath): bool
protected function copyPanelStubToApp(Addon $addon, string $targetPath): bool
{
$panelStubPath = base_path($this->panelStub);
@ -103,9 +108,9 @@ class ModuleSetupFilament extends Command implements PromptsForMissingInputContr
$stub = str(File::get($panelStubPath));
$replacements = [
'STUDLY_NAME' => $module->getStudlyName(),
'LOWER_NAME' => $module->getLowerName(),
'MODULE_NAMESPACE' => app('modules')->config('namespace'),
'STUDLY_NAME' => $addon->getStudlyName(),
'LOWER_NAME' => $addon->getLowerName(),
'MODULE_NAMESPACE' => config('addons.namespace'),
];
foreach ($replacements as $key => $replacement) {
@ -141,9 +146,9 @@ class ModuleSetupFilament extends Command implements PromptsForMissingInputContr
/**
* Helper to safely append the provider to the composer.json extra.laravel.providers array.
*/
protected function updateComposerJson(Module $module, string $providerClass): void
protected function updateComposerJson(Addon $addon, string $providerClass): void
{
$path = module_path($module->getName(), 'composer.json');
$path = $addon->getExtraPath('composer.json');
if (!File::exists($path)) {
return;
@ -160,12 +165,4 @@ class ModuleSetupFilament extends Command implements PromptsForMissingInputContr
File::put($path, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
}
}
/**
* Get the root namespace for the module.
*/
protected function getModuleNamespace(Module $module): string
{
return app('modules')->config('namespace').'\\'.$module->getName();
}
}

View File

@ -2,34 +2,37 @@
namespace App\Filament\Pages;
use App\Addons\AddonRegistry;
use App\Enums\NavigationGroup;
use App\Services\ModuleService;
use BackedEnum;
use BezhanSalleh\FilamentShield\Traits\HasPageShield;
use Filament\Actions\Action;
use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedTable;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Nwidart\Modules\Facades\Module;
use Override;
use UnitEnum;
class Addons extends Page implements Tables\Contracts\HasTable
class Addons extends Page implements HasTable
{
use HasPageShield;
use Tables\Concerns\InteractsWithTable;
use InteractsWithTable;
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Developers;
protected static string|UnitEnum|null $navigationGroup = NavigationGroup::Developers;
protected static ?int $navigationSort = 1;
protected static string|\BackedEnum|null $navigationIcon = Heroicon::OutlinedPuzzlePiece;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedPuzzlePiece;
#[\Override]
#[Override]
public static function getNavigationLabel(): string
{
return Str::of(__('common.addons'))->plural();
@ -56,7 +59,7 @@ class Addons extends Page implements Tables\Contracts\HasTable
->icon(Heroicon::OutlinedCheckCircle)
->visible(fn (array $record): bool => !$record['enabled'])
->action(function (array $record): void {
app(ModuleService::class)->updateModule($record['name'], true);
app(AddonRegistry::class)->enable($record['name']);
$this->redirectRoute('filament.admin.pages.addons');
}),
@ -66,7 +69,7 @@ class Addons extends Page implements Tables\Contracts\HasTable
->icon(Heroicon::OutlinedMinusCircle)
->visible(fn (array $record): bool => $record['enabled'])
->action(function (array $record): void {
app(ModuleService::class)->updateModule($record['name'], false);
app(AddonRegistry::class)->disable($record['name']);
$this->redirectRoute('filament.admin.pages.addons');
}),
@ -77,7 +80,7 @@ class Addons extends Page implements Tables\Contracts\HasTable
->visible(fn (array $record): bool => !$record['enabled'])
->requiresConfirmation()
->action(function (array $record): void {
app(ModuleService::class)->deleteModule($record['name']);
app(AddonRegistry::class)->delete($record['name']);
$this->redirectRoute('filament.admin.pages.addons');
}),
])
@ -102,7 +105,7 @@ class Addons extends Page implements Tables\Contracts\HasTable
);
}
#[\Override]
#[Override]
public function content(Schema $schema): Schema
{
return $schema
@ -113,15 +116,9 @@ class Addons extends Page implements Tables\Contracts\HasTable
public function getModulesRecords(): Collection
{
$modulesStatuses = [];
foreach (Module::all() as $module) {
$modulesStatuses[] = [
'name' => $module->getName(),
'enabled' => $module->isEnabled(),
];
}
return collect($modulesStatuses);
return app(AddonRegistry::class)->all()->map(fn ($addon): array => [
'name' => $addon->getName(),
'enabled' => $addon->isEnabled(),
])->values();
}
}

View File

@ -2,15 +2,16 @@
namespace App\Http\Controllers\Frontend;
use App\Addons\AddonRegistry;
use App\Contracts\Controller;
use Illuminate\View\View;
use Nwidart\Modules\Facades\Module;
use stdClass;
class CreditsController extends Controller
{
public function index(): View
{
$all_modules = Module::all();
$all_modules = app(AddonRegistry::class)->all()->keyBy(fn ($addon): string => $addon->getName());
$v7_defaults = ['Awards', 'Vacentral', 'Sample'];
$modules = collect();
@ -21,7 +22,7 @@ class CreditsController extends Controller
$module_details = $this->ReadModuleJson($key);
if ($module_details instanceof \stdClass) {
if ($module_details instanceof stdClass) {
$modules->push($module_details);
}
}
@ -33,7 +34,7 @@ class CreditsController extends Controller
// Read module.json file
// Return laravel collection
public function ReadModuleJson($module_name = null): ?\stdClass
public function ReadModuleJson($module_name = null): ?stdClass
{
$file = isset($module_name) ? base_path().'/modules/'.$module_name.'/module.json' : null;
@ -43,14 +44,14 @@ class CreditsController extends Controller
$contents = json_decode(file_get_contents($file));
$details = new \stdClass();
$details = new stdClass();
$details->name = $contents->name ?? $module_name;
$details->description = $contents->description ?? null;
$details->version = $contents->version ?? null;
$details->readme_url = $contents->readme_url ?? null;
$details->license_url = $contents->license_url ?? null;
$details->attribution = $contents->attribution ?? null;
$details->active = Module::isEnabled($contents->name);
$details->active = (bool) app(AddonRegistry::class)->find($contents->name)?->isEnabled();
return $details;
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Frontend;
use App\Addons\AddonRegistry;
use App\Contracts\Controller;
use App\Models\Airline;
use App\Models\File;
@ -11,8 +12,6 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
use Laracasts\Flash\Flash;
use Nwidart\Modules\Exceptions\ModuleNotFoundException;
use Nwidart\Modules\Facades\Module;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
@ -67,16 +66,13 @@ class DownloadController extends Controller
}
// See if they inserted a link to the ACARS download
try {
Module::findOrFail('VMSAcars');
if (app(AddonRegistry::class)->find('VMSAcars')) {
$downloadUrl = DB::table('vmsacars_config')->where(['id' => 'download_url'])->first();
if (!empty($downloadUrl) && !empty($downloadUrl->value)) {
$regrouped_files['ACARS'] = collect([
new File(['id' => 'vmsacars', 'name' => 'ACARS Client', 'disk' => null, 'path' => $downloadUrl->value]),
]);
}
} catch (ModuleNotFoundException) {
// noop, don't insert the ACARS download
}
ksort($regrouped_files, SORT_STRING);
@ -93,8 +89,7 @@ class DownloadController extends Controller
{
// See if they're trying to download the ACARS client
if ($id === 'vmsacars' && Auth::check()) {
try {
Module::find('VMSAcars');
if (app(AddonRegistry::class)->find('VMSAcars')) {
$downloadUrl = DB::table('vmsacars_config')
->where(['id' => 'download_url'])
->first();
@ -102,7 +97,6 @@ class DownloadController extends Controller
if (!empty($downloadUrl) && !empty($downloadUrl->value)) {
return redirect()->to($downloadUrl->value);
}
} catch (ModuleNotFoundException) {
}
return redirect()->back();

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Frontend;
use App\Addons\AddonRegistry;
use App\Contracts\Controller;
use App\Events\ProfileUpdated;
use App\Models\Airline;
@ -24,7 +25,6 @@ use Illuminate\Validation\Rules\Password;
use Illuminate\View\View;
use Intervention\Image\Facades\Image;
use Laracasts\Flash\Flash;
use Nwidart\Modules\Facades\Module;
class ProfileController extends Controller
{
@ -41,14 +41,12 @@ class ProfileController extends Controller
private function acarsEnabled(): bool
{
// Is the ACARS module enabled?
$acars_enabled = false;
/** @var ?\Nwidart\Modules\Module $acars */
$acars = Module::find('VMSAcars');
$acars = app(AddonRegistry::class)->find('VMSAcars');
if ($acars) {
return $acars->isEnabled();
}
return $acars_enabled;
return false;
}
/**

View File

@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Support\Carbon;
use Override;
use Str;
/**
* @property int $id
@ -69,6 +70,15 @@ class Addon extends Model
'installed_at' => 'nullable|date',
];
#[Override]
protected function casts(): array
{
return [
'enabled' => 'boolean',
'installed_at' => 'datetime',
];
}
/**
* Create an addon from a manifest
*
@ -105,13 +115,25 @@ class Addon extends Model
return $addon;
}
#[Override]
protected function casts(): array
/**
* Return the lower-cased addon name
*/
public function getLowerName(): string
{
return [
'enabled' => 'boolean',
'installed_at' => 'datetime',
];
return strtolower((string) $this->name);
}
public function getPath(): string
{
return $this->path;
}
/**
* Return the studly-cased addon name
*/
public function getStudlyName(): string
{
return Str::studly($this->name);
}
/**
@ -152,6 +174,6 @@ class Addon extends Model
*/
public function isEnabled(): bool
{
return (bool) $this->enabled;
return $this->enabled;
}
}

View File

@ -6,8 +6,11 @@ use App\Casts\DistanceCast;
use App\Contracts\Model;
use App\Enums\FlightType;
use App\Support\Days;
use BackedEnum;
use Database\Factories\FlightFactory;
use App\Traits\HasNanoIds;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Attributes\WithoutIncrementing;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Collection;
@ -19,12 +22,17 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Carbon;
use Kyslik\ColumnSortable\Sortable;
use Override;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Models\Activity;
use Spatie\Activitylog\Traits\LogsActivity;
use Stringable;
use UnitEnum;
/**
* @property string $id
* @property int|null $bundle_id
* @property FlightBundle|null $bundle
* @property int $airline_id
* @property int $flight_number
* @property string|null $callsign
@ -55,8 +63,6 @@ use Spatie\Activitylog\Traits\LogsActivity;
* @property bool $visible
* @property int|null $event_id
* @property int|null $user_id
* @property int|null $bundle_id
* @property FlightBundle|null $bundle
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property Carbon|null $deleted_at
@ -81,64 +87,65 @@ use Spatie\Activitylog\Traits\LogsActivity;
* @property-read int|null $subfleets_count
* @property-read User|null $user
*
* @method static Builder<static>|Flight active()
* @method static Builder<static>|Flight distanceAtLeast(int $distance)
* @method static Builder<static>|Flight distanceAtMost(int $distance)
* @method static \Database\Factories\FlightFactory factory($count = null, $state = [])
* @method static Builder<static>|Flight flightTimeAtLeast(int $minutes)
* @method static Builder<static>|Flight flightTimeAtMost(int $minutes)
* @method static Builder<static>|Flight forAirline(int $airlineId)
* @method static Builder<static>|Flight forTypeRating(int $typeRatingId)
* @method static Builder<static>|Flight fromAirport(string $icao)
* @method static Builder<static>|Flight newModelQuery()
* @method static Builder<static>|Flight newQuery()
* @method static Builder<static>|Flight onlyTrashed()
* @method static Builder<static>|Flight query()
* @method static Builder<static>|Flight sortable($defaultParameters = null)
* @method static Builder<static>|Flight toAirport(string $icao)
* @method static Builder<static>|Flight visible()
* @method static Builder<static>|Flight whereEnabled($value)
* @method static Builder<static>|Flight whereAirlineId($value)
* @method static Builder<static>|Flight whereAltAirportId($value)
* @method static Builder<static>|Flight whereArrAirportId($value)
* @method static Builder<static>|Flight whereArrTime($value)
* @method static Builder<static>|Flight whereCallsign($value)
* @method static Builder<static>|Flight whereCreatedAt($value)
* @method static Builder<static>|Flight whereDays($value)
* @method static Builder<static>|Flight whereDeletedAt($value)
* @method static Builder<static>|Flight whereDistance($value)
* @method static Builder<static>|Flight whereDptAirportId($value)
* @method static Builder<static>|Flight whereDptTime($value)
* @method static Builder<static>|Flight whereEndDate($value)
* @method static Builder<static>|Flight whereEventId($value)
* @method static Builder<static>|Flight whereFlightNumber($value)
* @method static Builder<static>|Flight whereFlightTime($value)
* @method static Builder<static>|Flight whereFlightType($value)
* @method static Builder<static>|Flight whereHasBid($value)
* @method static Builder<static>|Flight whereId($value)
* @method static Builder<static>|Flight whereLevel($value)
* @method static Builder<static>|Flight whereLoadFactor($value)
* @method static Builder<static>|Flight whereLoadFactorVariance($value)
* @method static Builder<static>|Flight whereNotes($value)
* @method static Builder<static>|Flight whereOwnerId($value)
* @method static Builder<static>|Flight whereOwnerType($value)
* @method static Builder<static>|Flight wherePilotPay($value)
* @method static Builder<static>|Flight whereRoute($value)
* @method static Builder<static>|Flight whereRouteCode($value)
* @method static Builder<static>|Flight whereRouteLeg($value)
* @method static Builder<static>|Flight whereScheduled($value)
* @method static Builder<static>|Flight whereStartDate($value)
* @method static Builder<static>|Flight whereUpdatedAt($value)
* @method static Builder<static>|Flight whereUserId($value)
* @method static Builder<static>|Flight whereVisible($value)
* @method static Builder<static>|Flight withFlightType(string $type)
* @method static Builder<static>|Flight withIcaoType(string $icao)
* @method static Builder<static>|Flight withSubfleet(int $subfleetId)
* @method static Builder<static>|Flight withTrashed(bool $withTrashed = true)
* @method static Builder<static>|Flight withoutTrashed()
* @method static Builder<static>|Flight active()
* @method static Builder<static>|Flight distanceAtLeast(int $distance)
* @method static Builder<static>|Flight distanceAtMost(int $distance)
* @method static FlightFactory factory($count = null, $state = [])
* @method static Builder<static>|Flight flightTimeAtLeast(int $minutes)
* @method static Builder<static>|Flight flightTimeAtMost(int $minutes)
* @method static Builder<static>|Flight forAirline(int $airlineId)
* @method static Builder<static>|Flight forTypeRating(int $typeRatingId)
* @method static Builder<static>|Flight fromAirport(string $icao)
* @method static Builder<static>|Flight newModelQuery()
* @method static Builder<static>|Flight newQuery()
* @method static Builder<static>|Flight onlyTrashed()
* @method static Builder<static>|Flight query()
* @method static Builder<static>|Flight sortable($defaultParameters = null)
* @method static Builder<static>|Flight toAirport(string $icao)
* @method static Builder<static>|Flight visible()
* @method static Builder<static>|Flight whereEnabled($value)
* @method static Builder<static>|Flight whereAirlineId($value)
* @method static Builder<static>|Flight whereAltAirportId($value)
* @method static Builder<static>|Flight whereArrAirportId($value)
* @method static Builder<static>|Flight whereArrTime($value)
* @method static Builder<static>|Flight whereCallsign($value)
* @method static Builder<static>|Flight whereCreatedAt($value)
* @method static Builder<static>|Flight whereDays($value)
* @method static Builder<static>|Flight whereDeletedAt($value)
* @method static Builder<static>|Flight whereDistance($value)
* @method static Builder<static>|Flight whereDptAirportId($value)
* @method static Builder<static>|Flight whereDptTime($value)
* @method static Builder<static>|Flight whereEndDate($value)
* @method static Builder<static>|Flight whereEventId($value)
* @method static Builder<static>|Flight whereFlightNumber($value)
* @method static Builder<static>|Flight whereFlightTime($value)
* @method static Builder<static>|Flight whereFlightType($value)
* @method static Builder<static>|Flight whereHasBid($value)
* @method static Builder<static>|Flight whereId($value)
* @method static Builder<static>|Flight whereLevel($value)
* @method static Builder<static>|Flight whereLoadFactor($value)
* @method static Builder<static>|Flight whereLoadFactorVariance($value)
* @method static Builder<static>|Flight whereNotes($value)
* @method static Builder<static>|Flight whereOwnerId($value)
* @method static Builder<static>|Flight whereOwnerType($value)
* @method static Builder<static>|Flight wherePilotPay($value)
* @method static Builder<static>|Flight whereRoute($value)
* @method static Builder<static>|Flight whereRouteCode($value)
* @method static Builder<static>|Flight whereRouteLeg($value)
* @method static Builder<static>|Flight whereScheduled($value)
* @method static Builder<static>|Flight whereStartDate($value)
* @method static Builder<static>|Flight whereUpdatedAt($value)
* @method static Builder<static>|Flight whereUserId($value)
* @method static Builder<static>|Flight whereVisible($value)
* @method static Builder<static>|Flight withFlightType(string $type)
* @method static Builder<static>|Flight withIcaoType(string $icao)
* @method static Builder<static>|Flight withSubfleet(int $subfleetId)
* @method static Builder<static>|Flight withTrashed(bool $withTrashed = true)
* @method static Builder<static>|Flight withoutTrashed()
*
* @mixin \Eloquent
*/
#[WithoutIncrementing]
class Flight extends Model
{
use HasFactory;
@ -149,13 +156,13 @@ class Flight extends Model
public $table = 'flights';
/** The form wants this */
public $hours;
public $minutes;
protected $fillable = [
'id',
'bundle_id',
'airline_id',
'flight_number',
'callsign',
@ -185,7 +192,6 @@ class Flight extends Model
'visible',
'event_id',
'user_id',
'bundle_id',
'owner_type',
'owner_id',
];
@ -214,7 +220,7 @@ class Flight extends Model
'fares_count',
];
#[\Override]
#[Override]
protected function casts(): array
{
return [
@ -421,11 +427,11 @@ class Flight extends Model
return null;
}
if ($value instanceof \BackedEnum) {
if ($value instanceof BackedEnum) {
$string = (string) $value->value;
} elseif ($value instanceof \UnitEnum) {
} elseif ($value instanceof UnitEnum) {
$string = $value->name;
} elseif (is_scalar($value) || $value instanceof \Stringable) {
} elseif (is_scalar($value) || $value instanceof Stringable) {
$string = (string) $value;
} else {
// Non-stringable object — defer to PHP's cast and let it raise.

View File

@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Providers;
use App\Addons\AddonAutoLoader;
use App\Addons\Compat\ModuleRepository;
use App\Addons\AddonRegistry;
use App\Addons\Filament\FilamentPanelExtender;
use App\Addons\Services\AddonDiscoveryService;
use App\Addons\Support\AutoloadGuard;
@ -15,18 +15,16 @@ use Illuminate\Support\ServiceProvider;
use Override;
/**
* Wires the Phase 2 addon engine into application boot.
* Wires the addon engine into application boot.
*
* register() handles all engine wiring so that addon service providers are
* registered before Filament panels resolve (D2-10):
* 1. Binds all engine singletons (Octane-safe: stateless services).
* 2. Binds the 'modules' container key to ModuleRepository, making the
* nwidart Module facade route to our shim (nwidart provider retired).
* 3. At non-console boot: runs the classmap-authoritative guard (LOAD-08).
* 2. At non-console boot: runs the classmap-authoritative guard (LOAD-08).
* Skipped in console (D-17) to avoid blocking migrate/install/tests.
* 4. Runs the AddonLoader in all contexts so artisan sees addon
* 3. Runs the AddonLoader in all contexts so artisan sees addon
* commands and migrations (D2-11). No-ops on empty cache.
* 5. Hooks FilamentPanelExtender into beforeResolving('filament', ...)
* 4. Hooks FilamentPanelExtender into beforeResolving('filament', ...)
* so addon Filament discovery is applied before panels resolve (D2-07).
*
* boot() auto-primes the boot cache when absent/stale (D2-09). This is the
@ -54,11 +52,7 @@ class AddonServiceProvider extends ServiceProvider
// ── Phase 2 singletons ──────────────────────────────────────────────
$this->app->singleton(AddonAutoLoader::class);
$this->app->singleton(FilamentPanelExtender::class);
$this->app->singleton(ModuleRepository::class);
// Bind the nwidart 'modules' container key to our shim so that
// Nwidart\Modules\Facades\Module resolves to ModuleRepository.
$this->app->singleton('modules', fn ($app) => $app->make(ModuleRepository::class));
$this->app->singleton(AddonRegistry::class);
// ── Non-console: autoload guard ─────────────────────────────────────
// D-17: skip in console contexts (migrate/install/tests).

View File

@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use Nwidart\Modules\LaravelModulesServiceProvider as BaseModulesServiceProvider;
class ModulesServiceProvider extends BaseModulesServiceProvider
{
#[\Override]
public function register(): void
{
parent::register();
// Boot the modules before resolving Filament so that modules' panels can be discovered
$this->app->beforeResolving('filament', function (): void {
parent::boot();
});
}
}

View File

@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Services;
use App\Addons\AddonRegistry;
use App\Contracts\Award;
use App\Contracts\Service;
use App\Support\ClassLoader;
use Nwidart\Modules\Facades\Module;
class AwardService extends Service
{
@ -26,7 +26,7 @@ class AwardService extends Service
// $awards = array_merge($awards, $classes);
// Look throughout all the other modules, in the module/{MODULE}/Awards directory
foreach (Module::all() as $module) {
foreach (app(AddonRegistry::class)->all() as $module) {
$path = $module->getExtraPath('Awards');
$classes = ClassLoader::getClassesInPath($path);

View File

@ -4,13 +4,14 @@ declare(strict_types=1);
namespace App\Services\Installer;
use App\Addons\AddonRegistry;
use App\Contracts\Service;
use Closure;
use Exception;
use Illuminate\Database\Migrations\Migrator;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Log;
use Nwidart\Modules\Facades\Module;
class MigrationService extends Service
{
@ -40,7 +41,7 @@ class MigrationService extends Service
'core' => App::databasePath().'/'.$dir,
];
$modules = Module::allEnabled();
$modules = app(AddonRegistry::class)->enabled();
foreach ($modules as $module) {
$module_path = $module->getPath().'/Database/'.$dir;
if (file_exists($module_path)) {
@ -93,7 +94,7 @@ class MigrationService extends Service
return trim(Artisan::output());
}
public function runAllMigrationsWithStreaming(\Closure $streamCallback): void
public function runAllMigrationsWithStreaming(Closure $streamCallback): void
{
$command = ['migrate', '--force', '--realpath'];
@ -147,7 +148,7 @@ class MigrationService extends Service
return trim(Artisan::output());
}
public function runAllDataMigrationsWithStreaming(\Closure $streamCallback): void
public function runAllDataMigrationsWithStreaming(Closure $streamCallback): void
{
$command = ['migrate-data', '--force', '--realpath'];

View File

@ -4,16 +4,11 @@ declare(strict_types=1);
namespace App\Services;
use App\Addons\Compat\Module;
use App\Addons\Compat\ModuleRepository;
use App\Addons\AddonRegistry;
use App\Contracts\Service;
class ModuleService extends Service
{
public function __construct(
private readonly ModuleRepository $modules
) {}
/**
* Module-registered admin nav links. Populated once per worker via each
* module's boot()->registerLinks() call; not a per-request accumulator.
@ -69,43 +64,26 @@ class ModuleService extends Service
}
/**
* Update module with the status passed by user
* TODO: Remove
* Update module with the status passed by user.
*
* @deprecated Delegate to AddonRegistry::enable()/disable() directly.
*/
public function updateModule(string $name, bool $enabled): void
{
$module = $this->modules->find($name);
if (!$module) {
return;
}
// setActive() flips the enabled flag, persists to DB, and regenerates the boot cache
// (via ModuleShim::setActive() → AddonRuntimeService::run()). The per-module migrate command
// (module:migrate) belonged to nwidart and no longer exists. Addon migration execution
// is owned by the standard `php artisan migrate` path (Phase 5 lifecycle).
$module->setActive($enabled);
if (file_exists(base_path('bootstrap/cache/modules.php'))) {
unlink(base_path('bootstrap/cache/modules.php'));
if ($enabled) {
app(AddonRegistry::class)->enable($name);
} else {
app(AddonRegistry::class)->disable($name);
}
}
/**
* Delete Module from the Storage & Database.
*
* @deprecated Delegate to AddonRegistry::delete() directly.
*/
public function deleteModule(string $name): void
{
$module = $this->modules->find($name);
if (!$module) {
return;
}
$module->delete();
if (file_exists(base_path('bootstrap/cache/modules.php'))) {
unlink(base_path('bootstrap/cache/modules.php'));
}
app(AddonRegistry::class)->delete($name);
}
}

View File

@ -43,12 +43,9 @@ use App\Support\Units\Fuel;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Schema;
use Nwidart\Modules\Facades\Module;
class PirepService extends Service
{
@ -231,6 +228,7 @@ class PirepService extends Service
*/
public function update(string $pirep_id, array $attrs, array $fields = [], array $fares = []): Pirep
{
/** @var Pirep $pirep */
$pirep = Pirep::findOrFail($pirep_id);
$pirep->update($attrs);
$pirep->refresh();
@ -712,69 +710,10 @@ class PirepService extends Service
$pirep->loadMissing('aircraft', 'flight', 'user');
$aircraft = $pirep->aircraft;
$flight = $pirep->flight;
$user = $pirep->user;
event(new PirepDiverted($pirep));
/** @var ?\Nwidart\Modules\Module $has_vmsacars */
$has_vmsacars = Module::find('VMSAcars');
$has_vmsacars_config = Schema::hasTable('vmsacars_config');
if ($has_vmsacars && $has_vmsacars_config && $flight) {
/** @var ?object $query */
$query = DB::table('vmsacars_config')->find('disable_free_flights');
$free_flights_disabled = $query?->value;
// Log::debug('vmsAcars | Disable Free Flights Setting: '.$free_flights_disabled.', considered as '.get_truth_state($free_flights_disabled));
if (get_truth_state($free_flights_disabled)) {
$repositionAttributes = [
'airline_id' => $flight->airline_id,
'flight_number' => $flight->flight_number,
'callsign' => $flight->callsign,
'route_code' => PirepStatus::DIVERTED,
'dpt_airport_id' => $diversion_airport->id,
'arr_airport_id' => $pirep->arr_airport_id,
'user_id' => $user->id,
];
$lockKey = implode(':', [
'diversion-flight',
$flight->airline_id,
$flight->flight_number,
$diversion_airport->id,
$pirep->arr_airport_id,
$user->id,
]);
/** @var Flight $repositionFlight */
$repositionFlight = Cache::lock($lockKey, 10)->block(5, function () use ($aircraft, $diversion_airport, $flight, $pirep, $repositionAttributes) {
$repositionFlight = Flight::query()->firstOrCreate(
$repositionAttributes,
[
'distance' => $this->airportSvc->calculateDistance($diversion_airport->id, $pirep->arr_airport_id),
'flight_time' => 1,
'flight_type' => $flight->flight_type,
'notes' => 'DIVERTED FLIGHT RE-POSITIONING TO DESTINATION',
'visible' => true,
'active' => true,
]
);
$repositionFlight->subfleets()->syncWithoutDetaching([$aircraft->subfleet_id]);
return $repositionFlight;
});
if ($repositionFlight->wasRecentlyCreated) {
Log::info('Diversion repositioning flight '.$repositionFlight->id.' from '.$diversion_airport->id.' to '.$pirep->arr_airport_id.' created');
} else {
Log::info('Diversion repositioning flight '.$repositionFlight->id.' from '.$diversion_airport->id.' to '.$pirep->arr_airport_id.' reused');
}
}
}
if (setting('notifications.discord_pirep_diverted', false)) {
Notification::send([$pirep], new PirepDiverted($pirep));
}

View File

@ -2,10 +2,10 @@
namespace App\Support;
use App\Addons\AddonRegistry;
use App\Contracts\Model;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Support\Str;
use Nwidart\Modules\Facades\Module;
use Pdp\Rules;
/**
@ -72,8 +72,8 @@ class Utils
*/
public static function installerEnabled()
{
/** @var ?\Nwidart\Modules\Module $installer */
$installer = Module::find('installer');
$installer = app(AddonRegistry::class)->find('installer');
if (!$installer) {
return false;
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use App\Addons\Services\AddonDiscoveryService;
use App\Addons\Support\BootCache;
use App\Models\Addon;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\File;
beforeEach(function (): void {
// The addons migration seeds bundled module rows; clear them so each test
// starts with a known-empty state.
Addon::query()->delete();
$this->base = sys_get_temp_dir().'/rebuild-'.uniqid();
$this->addonDir = $this->base.'/Demo';
File::ensureDirectoryExists($this->addonDir);
File::put($this->addonDir.'/module.json', json_encode(['name' => 'Demo', 'providers' => []]));
File::put($this->addonDir.'/composer.json', json_encode(['autoload' => ['psr-4' => ['Modules\\Demo\\' => '']]]));
Config::set('addons.paths.base', $this->base);
app(BootCache::class)->delete();
});
afterEach(function (): void {
app(BootCache::class)->delete();
File::deleteDirectory($this->base);
});
it('rebuildCache() writes enabled addons to the boot cache', function (): void {
Addon::factory()->create(['name' => 'Demo', 'path' => $this->addonDir, 'enabled' => true]);
app(AddonDiscoveryService::class)->rebuildCache();
$names = app(BootCache::class)->enabled()->map(fn ($e): string => $e->name)->all();
expect($names)->toContain('Demo');
});
it('rebuildCache() omits disabled addons', function (): void {
Addon::factory()->create(['name' => 'Demo', 'path' => $this->addonDir, 'enabled' => false]);
app(AddonDiscoveryService::class)->rebuildCache();
expect(app(BootCache::class)->enabled())->toHaveCount(0);
});

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use App\Addons\AddonRegistry;
use App\Models\Addon;
beforeEach(function (): void {
// The addons migration seeds bundled module rows; clear them so each test
// starts with a known-empty state.
Addon::query()->delete();
$this->registry = app(AddonRegistry::class);
});
it('enable() sets the addon enabled', function (): void {
Addon::factory()->create(['name' => 'Awards', 'path' => '/m/Awards', 'enabled' => false]);
$this->registry->enable('Awards');
expect(Addon::query()->where('name', 'Awards')->first()->enabled)->toBeTrue();
});
it('disable() clears the addon enabled flag', function (): void {
Addon::factory()->create(['name' => 'Awards', 'path' => '/m/Awards', 'enabled' => true]);
$this->registry->disable('Awards');
expect(Addon::query()->where('name', 'Awards')->first()->enabled)->toBeFalse();
});
it('delete() removes the DB row', function (): void {
Addon::factory()->create(['name' => 'Awards', 'path' => '/m/Awards', 'enabled' => false]);
$this->registry->delete('Awards');
expect(Addon::query()->where('name', 'Awards')->exists())->toBeFalse();
});
it('enable()/disable()/delete() no-op on an unknown addon', function (): void {
$this->registry->enable('Nope');
$this->registry->disable('Nope');
$this->registry->delete('Nope');
expect(Addon::query()->count())->toBe(0);
});

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use App\Addons\AddonRegistry;
use App\Exceptions\AddonNotFoundException;
use App\Models\Addon;
beforeEach(function (): void {
// The addons migration seeds bundled module rows; clear them so each test
// starts with a known-empty state.
Addon::query()->delete();
$this->registry = app(AddonRegistry::class);
});
it('find() returns an addon by name', function (): void {
Addon::factory()->create(['name' => 'VMSAcars', 'path' => '/m/VMSAcars']);
expect($this->registry->find('VMSAcars'))->not->toBeNull()
->and($this->registry->find('VMSAcars')->getName())->toBe('VMSAcars');
});
it('find() returns null when no addon matches', function (): void {
expect($this->registry->find('Nope'))->toBeNull();
});
it('find() matches a null-name row by its path basename', function (): void {
Addon::factory()->create(['name' => null, 'path' => '/m/Awards']);
expect($this->registry->find('Awards'))->not->toBeNull();
});
it('findOrFail() throws when no addon matches', function (): void {
$this->registry->findOrFail('Nope');
})->throws(AddonNotFoundException::class);
it('enabled() returns only enabled addons', function (): void {
Addon::factory()->create(['enabled' => true]);
Addon::factory()->create(['enabled' => false]);
expect($this->registry->enabled())->toHaveCount(1)
->and($this->registry->enabled()->first()->isEnabled())->toBeTrue();
});

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use App\Addons\Services\AddonDiscoveryService;
use App\Addons\Support\BootCache;
use App\Filament\Pages\Addons;
use Database\Seeders\ShieldSeeder;
use Livewire\Livewire;
beforeEach(function (): void {
$this->seed(ShieldSeeder::class);
$this->actingAs(createAdminUser());
// Ensure the boot cache and DB are seeded with the bundled modules.
app(BootCache::class)->delete();
app(AddonDiscoveryService::class)->run();
});
afterEach(function (): void {
app(BootCache::class)->delete();
});
it('lists addons from AddonRegistry on the page', function (): void {
Livewire::test(Addons::class)
->assertSuccessful()
->assertSee('Sample');
});
it('shows enabled status for addons', function (): void {
Livewire::test(Addons::class)
->assertSuccessful()
->assertSee('Sample');
});

View File

@ -1,264 +0,0 @@
<?php
declare(strict_types=1);
use App\Addons\Compat\Module;
use App\Addons\Compat\ModuleRepository;
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;
beforeEach(function (): void {
// Ensure a fresh boot cache for each test.
app(BootCache::class)->delete();
// Seed DB rows + boot cache via AddonRuntimeService so tests start from a known state.
app(AddonDiscoveryService::class)->run();
});
afterEach(function (): void {
app(BootCache::class)->delete();
});
// ─────────────────────────────────────────────────────────────────────────────
// 1. all() returns a collection keyed by name
// ─────────────────────────────────────────────────────────────────────────────
it('all() returns collection keyed by module name containing the 3 bundled modules', function (): void {
$repo = app(ModuleRepository::class);
$all = $repo->all();
expect($all)->toHaveKey('Sample')
->toHaveKey('Awards')
->toHaveKey('VMSAcars');
});
// ─────────────────────────────────────────────────────────────────────────────
// 2. find() returns a shim with correct attributes
// ─────────────────────────────────────────────────────────────────────────────
it('find(Sample) returns a shim with correct name, path, enabled, and lowerName', function (): void {
$repo = app(ModuleRepository::class);
$shim = $repo->find('Sample');
expect($shim)->not->toBeNull()
->and($shim->getName())->toBe('Sample')
->and($shim->getLowerName())->toBe('sample')
->and($shim->isEnabled())->toBeTrue()
->and($shim->getPath())->toEndWith('modules/Sample');
});
// ─────────────────────────────────────────────────────────────────────────────
// 3. find() returns null for unknown module
// ─────────────────────────────────────────────────────────────────────────────
it('find(DoesNotExist) returns null', function (): void {
$repo = app(ModuleRepository::class);
expect($repo->find('DoesNotExist'))->toBeNull();
});
// ─────────────────────────────────────────────────────────────────────────────
// 4. findOrFail() returns shim or throws
// ─────────────────────────────────────────────────────────────────────────────
it('findOrFail(Sample) returns a shim', function (): void {
$repo = app(ModuleRepository::class);
$shim = $repo->findOrFail('Sample');
expect($shim)->not->toBeNull()
->and($shim->getName())->toBe('Sample');
});
it('findOrFail(Nope) throws ModuleNotFoundException', function (): void {
$repo = app(ModuleRepository::class);
expect(fn () => $repo->findOrFail('Nope'))
->toThrow(ModuleNotFoundException::class);
});
// ─────────────────────────────────────────────────────────────────────────────
// 5. allEnabled() excludes a disabled addon
// ─────────────────────────────────────────────────────────────────────────────
it('allEnabled() excludes an addon after setActive(false) and re-prime', function (): void {
$repo = app(ModuleRepository::class);
$shim = $repo->find('Sample');
expect($shim)->not->toBeNull();
// Disable via shim (also re-primes the cache).
$shim->setActive(false);
// Re-resolve repo to get fresh state.
$repo2 = app(ModuleRepository::class);
$enabled = $repo2->allEnabled();
expect($enabled)->not->toHaveKey('Sample')
->toHaveKey('Awards')
->toHaveKey('VMSAcars');
});
// ─────────────────────────────────────────────────────────────────────────────
// 6. isEnabled()
// ─────────────────────────────────────────────────────────────────────────────
it('isEnabled(Sample) returns true', function (): void {
$repo = app(ModuleRepository::class);
expect($repo->isEnabled('Sample'))->toBeTrue();
});
it('isEnabled(Nope) returns false', function (): void {
$repo = app(ModuleRepository::class);
expect($repo->isEnabled('Nope'))->toBeFalse();
});
// ─────────────────────────────────────────────────────────────────────────────
// 7. getExtraPath()
// ─────────────────────────────────────────────────────────────────────────────
it('getExtraPath(Awards) returns getPath()/Awards', function (): void {
$repo = app(ModuleRepository::class);
$shim = $repo->find('Sample');
expect($shim)->not->toBeNull()
->and($shim->getExtraPath('Awards'))->toBe($shim->getPath().'/Awards');
});
// ─────────────────────────────────────────────────────────────────────────────
// 8. config()
// ─────────────────────────────────────────────────────────────────────────────
it('config(namespace) returns Modules', function (): void {
$repo = app(ModuleRepository::class);
expect($repo->config('namespace'))->toBe('Modules');
});
it('config(unknown, default) returns the default', function (): void {
$repo = app(ModuleRepository::class);
expect($repo->config('something', 'x'))->toBe('x');
});
// ─────────────────────────────────────────────────────────────────────────────
// 9. setActive() persists to DB and regenerates boot cache
// ─────────────────────────────────────────────────────────────────────────────
it('setActive(false) updates DB and removes Sample from boot cache; setActive(true) re-includes it', function (): void {
$repo = app(ModuleRepository::class);
$runtime = app(BootCache::class);
$shim = $repo->find('Sample');
expect($shim)->not->toBeNull();
// Disable.
$shim->setActive(false);
// DB check.
$addonRow = Addon::query()->where('path', 'LIKE', '%modules/Sample')->first();
expect($addonRow)->not->toBeNull()
->and($addonRow->enabled)->toBeFalse();
// Boot cache check — Sample must be absent.
$cached = $runtime->read();
$names = array_map(fn (AddonBootCache $r): string => $r->name, $cached);
expect($names)->not->toContain('Sample');
// Re-enable.
$shim->setActive(true);
$addonRow->refresh();
expect($addonRow->enabled)->toBeTrue();
// Boot cache must include Sample again.
$cached2 = $runtime->read();
$names2 = array_map(fn (AddonBootCache $r): string => $r->name, $cached2);
expect($names2)->toContain('Sample');
});
// ─────────────────────────────────────────────────────────────────────────────
// 10. name/description property access via __get
// ─────────────────────────────────────────────────────────────────────────────
it('$shim->name equals getName()', function (): void {
$repo = app(ModuleRepository::class);
$shim = $repo->find('Sample');
expect($shim)->not->toBeNull()
->and($shim->name)->toBe('Sample');
});
it('$shim->description returns null when manifest description is blank', function (): void {
$repo = app(ModuleRepository::class);
$shim = $repo->find('Sample');
// Sample module.json has description: "" which normalises to null.
expect($shim)->not->toBeNull()
->and($shim->description)->toBeNull();
});
// ─────────────────────────────────────────────────────────────────────────────
// 11. getStudlyName() returns StudlyCase of the module name
// ─────────────────────────────────────────────────────────────────────────────
it('getStudlyName() returns StudlyCase of the module name', function (): void {
$repo = app(ModuleRepository::class);
$shim = $repo->find('Sample');
expect($shim)->not->toBeNull()
->and($shim->getStudlyName())->toBe('Sample')
// StudlyCase is always ≥ the lowercase equivalent.
->and($shim->getStudlyName())->not->toBe($shim->getLowerName());
});
// ─────────────────────────────────────────────────────────────────────────────
// 12. delete() removes DB row and boot cache no longer lists the addon
// ─────────────────────────────────────────────────────────────────────────────
it('delete() removes the Addon DB row and the boot cache no longer lists it', function (): void {
// Create a temporary addon directory with a valid module.json so AddonRuntimeService
// can discover it and write it into the boot cache.
$tmpDir = storage_path('app/addons/ThrowawayTest'.uniqid());
mkdir($tmpDir, 0755, true);
file_put_contents($tmpDir.'/module.json', json_encode([
'name' => 'ThrowawayTest',
'alias' => 'throwawaytest',
'providers' => [],
]));
// Run AddonRuntimeService so the temp addon is discovered, upserted, and cached.
app(AddonDiscoveryService::class)->run();
// Fetch the DB row AddonRuntimeService created for the temp addon.
$throwaway = Addon::query()->where('path', $tmpDir)->firstOrFail();
$throwawayId = $throwaway->id;
// Build a shim around it.
$shim = new Module($throwaway, app(ManifestParser::class));
// Confirm it's in the cache before delete.
$before = app(BootCache::class)->read();
$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).
unlink($tmpDir.'/module.json');
$shim->delete();
// DB row must be gone.
expect(Addon::query()->where('id', $throwawayId)->first())->toBeNull();
// Boot cache must no longer list the deleted addon's path.
$cached = app(BootCache::class)->read();
$paths = array_map(fn (AddonBootCache $r): string => $r->path, $cached);
expect($paths)->not->toContain($tmpDir);
// Cleanup temp dir.
rmdir($tmpDir);
});

View File

@ -1,6 +1,5 @@
<?php
use App\Addons\Compat\Module;
use App\Enums\AcarsType;
use App\Enums\PirepFieldSource;
use App\Enums\PirepState;
@ -32,7 +31,6 @@ use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Schema;
use Nwidart\Modules\Facades\Module;
use function Pest\Laravel\seed;
@ -678,6 +676,8 @@ test('diversion handler', function (): void {
Notification::assertSentTo([$pirep], PirepDiverted::class);
});
/*
// TODO: Move this into the vmsacars tests
test('diversion handler reuses matching reposition flight and attaches subfleet', function (): void {
updateSetting('pireps.handle_diversion', true);
@ -691,8 +691,6 @@ test('diversion handler reuses matching reposition flight and attaches subfleet'
'value' => '1',
]);
Module::shouldReceive('find')->andReturn(Mockery::mock(Module::class));
$pirepSvc = app(PirepService::class);
$airline = Airline::factory()->create();
@ -759,4 +757,4 @@ test('diversion handler reuses matching reposition flight and attaches subfleet'
expect($matchingFlights)->toHaveCount(1)
->and($repositionFlight->fresh()->subfleets->pluck('id')->all())->toBe([$subfleet->id]);
});
});*/