Compare commits

...

10 Commits

Author SHA1 Message Date
Nabeel Shahzad
7776af88d8
ref(livemap): address review feedback
Drop the tombstone eviction rule; RemoveExpiredLiveFlights already reaps those
PIREPs and the completed rule picks up what it soft-deletes. Simplify evict().
Revert RemoveExpiredLiveFlights to its original body with only the setting name
changed, and revert PirepResource, dropping the scope and resource class added
alongside them.

Also: class_alias by string so PHPStan can analyse the deprecated enum alias,
re-check updated_at at delete time, preserve pirep_id nullability in the
collation ALTER, and cut the comments back.
2026-07-27 10:35:53 -05:00
Nabeel Shahzad
cbb1d85cef
feat(livemap): serve the live map from a pirep_positions table
The map resolved the newest acars breadcrumb per flight on every poll, over a
table that grows for the life of the install and was never pruned. One row per
flight replaces that, and its presence is what puts a flight on the map — no
state filter, no time arithmetic on the read path.

Prefile opens the row at the departure gate, the ACARS batch endpoint maintains
it, and a five-minute cron owns eviction. acars.live_time did two unrelated jobs
and is split into pireps.tombstone_time plus two live map timers.

Also closes the child-record gap PirepService::delete() has claimed to handle
since it was written: acars rows are now deleted, with a cascading foreign key
behind it. Adding that constraint permanently purges pre-existing orphans.
2026-07-27 03:14:23 -05:00
Nabeel S.
667cbc835d
feat(installer): auto-advance past the migration step (#2283)
## Summary

Migration step now moves itself on. Once migrations report nothing
pending, a 5-second countdown starts, renders inside the Next button
(`Next (5)` → `(1)`), and the wizard footer scrolls into view.

Pause sits to the left of Next for anyone who wants to read the log
first. It occupies the old Back slot — Back is gone from the whole
wizard, since the only thing behind you is a migration that already ran.

Installer only. Updater's existing 10s redirect untouched.

## Test plan

- `vendor/bin/pest tests/Feature/Installer/
tests/Feature/InstallerAccessTest.php` — 12 passed
- `phpstan` on the page — clean
- New `MigrationAutoAdvanceTest` asserts the rendered footer keeps the
seam with Filament's vendor wizard blade intact: Next stays findable via
`data-installer-next`, Pause has replaced Back
- Not covered by tests: the countdown running in a browser, and
`runMigrations()` itself. That method shells out to `php artisan` via
`StreamedCommandsService`, and the subprocess reads `.env` rather than
phpunit's forced sqlite — calling it from a test hits the dev database.
Comment in the test file records this.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added automatic progression to the next installer step after
migrations complete.
* Added a visible countdown with controls to pause and resume the
transition.
* Added automatic scrolling to keep the wizard controls visible during
the countdown.

* **Tests**
* Added coverage for the countdown, navigation controls, and
pause/resume behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-26 22:06:19 -05:00
Nabeel Shahzad
2340a65904
feat(installer): auto-advance past the migration step
The migration step ends with a wall of log output and a Next button the
user has to find, even though there is nothing left to decide. Start a
5-second countdown once migrations report clean, render it in the Next
button, and scroll the footer into view.

Pause takes over the Back slot, so the log stays readable on demand and
a meaningless step-backwards onto an already-run migration goes away.
2026-07-26 16:50:45 -05:00
Nabeel S.
10abac3a3a
feat(flights): inherit bundle subfleets, and fix the surfaces that assign them (#2282)
## Summary

Finishes subfleet resolution: flights that pin nothing now inherit their
bundle's
defaults on list endpoints, not just on their own page. Along the way
this fixes
the admin surfaces for assigning subfleets, adds the missing referential
integrity on the pivot, and closes several gaps that let all of it look
green
while broken.

**Subfleet resolution**

- `withAccessibleSubfleets` implements rung 2 (bundle defaults) as well
as rung 1
(the flight's own pins). Rung 3, the unbounded fallback, is deliberately
left
  out: a list page would materialise the whole fleet once per row.
- Which rung applies is decided by configuration *before* access
filtering, so a
flight whose pins the user cannot fly resolves to nothing rather than
sliding
  onto its bundle and offering aircraft the flight never listed.
- Rung 2 is capped per bundle at `phpvms.subfleets.inherited_list_limit`
(default 5) and ordered by id, so a 100-subfleet bundle across a
100-flight
page does not hydrate 10k rows. A list row is a summary; the flight page
stays
  authoritative.
- The bundle is loaded `withTrashed`, otherwise a soft-deleted bundle
resolves to
null and silently strips its flights of subfleets they are still
configured
with. Inherited subfleets are cloned per flight, because every flight in
a
bundle shares one hydrated bundle instance and `FareService` mutates
fares in
  place, which leaked one flight's fare overrides onto its neighbours.

**Admin surfaces**

All four subfleet relation managers had a broken attach modal. Filament
guesses
the inverse relationship as `plural(camel(Owner))`; three survived only
by naming
coincidence, so it is now named explicitly. Opening the modal also threw
outright
under `preventLazyLoading` because `recordTitle()` read the airline off
every
preloaded option, and it dereferenced a nullable `airline_id`, so one
airline-less subfleet took down the whole modal. Each manager gets a
`mountAction` test, which is the only kind that catches any of this.

**Referential integrity**

`flight_subfleet` had no foreign keys, so force-deleting either side
left
dangling rows. Added with cascade, which only fires on `forceDelete`
since both
models soft-delete. Three driver-specific hazards are handled: MySQL's
collation
rule (3780), which must be resolved before the orphan sweep because
comparing the
columns is itself error 1267; SQLite having no `ADD CONSTRAINT` and
compiling
`foreign()` against an existing table to nothing, so the table is
rebuilt inside
an explicit transaction with a guard against a killed run leaving a
scratch
table; and pre-existing orphan rows, which are swept and logged rather
than
deleted silently.

**Why none of this was caught**

CI runs a sqlite/MySQL/PostgreSQL matrix, but the local default is
sqlite only,
and sqlite tolerates what real servers reject. Adding the foreign keys
broke four
importer tests on MySQL — it refuses `TRUNCATE` on any FK-referenced
table, even
an empty one — which sqlite never showed. That fix also
closed a latent bug of the same shape: `bundle_subfleet` has referenced
`subfleets` since bundles landed, so admin subfleet-import-with-delete
was
already broken on MySQL, on a path no test exercised.

So the PR adds MySQL and PostgreSQL configs for running those drivers
*locally*,
rather than discovering the breakage in CI. Both earned their place
immediately —
Postgres caught a `2BP01` drop-ordering bug in the installer tests that
both
other drivers allowed. They run via `bin/test-db.sh`, which creates a
scratch
database per run and drops it afterwards, because `compose.test.yml`'s
`testing`
database can be the same one a local `.env` points the development app
at, and
`RefreshDatabase` would drop the schema out from under it.

`phpunit.xml` also now pins the DB connection with `<server>` entries,
not just
`<env force>`. Laravel resolves `env()` through a repository that reads
`$_SERVER` before `$_ENV`, and the CLI SAPI copies exported shell
variables into
`$_SERVER`, so an exported `DB_CONNECTION` silently redirected the
entire suite
at a real database.

**Also included**

- A fresh install died creating the first airline:
`setting('units.currency')`
returns null for a key it cannot read, and an explicit null beats the
`'USD'`
default on `initJournal()`. Every other reader of that setting already
names
the fallback. Unreachable from the suite, because `tests/Pest.php` seeds
  settings before every test.
- Addon tests were resolving `Modules\Sample\*` through the static psr-4
map
rather than booting the addon loader, so they broke when the directory
was
  renamed even though the engine reads each addon's own manifest.
- Awards declares its `registry_id` instead of relying on the namespace
fallback.
- Bundle form stacks in a single column.

## Test plan

Full suite on all three drivers, 1128 tests:

| Driver | Result |
|---|---|
| sqlite `:memory:` | 1127 passed, 1 skipped |
| MySQL 8.4 | 1125 passed, 3 skipped |
| PostgreSQL 18 | 1125 passed, 3 skipped |

The two extra skips on the real servers are the SQLite-rebuild tests in
`FlightSubfleetForeignKeysMigrationTest`, which gate themselves off
since only
sqlite takes that path.

```
vendor/bin/pest                 # sqlite
composer test:mysql             # bin/test-db.sh mysql
composer test:pgsql             # bin/test-db.sh pgsql
```

Every fix here was mutation-tested: the implementation was reverted and
the new
tests confirmed to fail for the right reason before being kept.
Specifically
verified — the cap is per bundle and not global (window function, three
bundles
on one page); a flight with inaccessible pins stays empty rather than
inheriting;
cloning prevents cross-flight fare contamination; inheritance resolves
through
the nested eager-load path `BidService` uses; the API response shape is
unchanged
for pinned and inheriting flights alike; each attach modal throws
without its
explicit inverse; the FK migration recovers from a run killed
mid-rebuild; and
the import fix fails on MySQL with 1701 when reverted.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Flight bundles can now have default subfleets, with full attach/detach
management in the admin interface.
* Flights inherit eligible bundle subfleets automatically, with a
configurable inheritance limit.
* **Bug Fixes**
* Improved subfleet inheritance/eligibility behavior, including correct
handling of soft-deleted records and partially prepared flights.
* Enhanced subfleet relation dialogs and record titles for missing
airline data.
* Strengthened database truncation and integrity during import/cleanup.
* **Chores**
  * Removed the unused Sample module.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-26 15:07:59 -05:00
Nabeel Shahzad
8b26d346b7
docs(tests): describe the fallthrough bug in the past tense
The header documented the pre-fix implementation as current, quoting a
snippet that no longer exists and citing a line range that is now a
different method. Read as written, it announced a live widening bug in a
file whose tests assert the fixed behaviour.
2026-07-26 14:29:58 -05:00
Nabeel Shahzad
da1bd30df0
feat(flights): raise the inherited-subfleet cap and report it
The cap was 5, which is below what a real fleet runs, and trimming was
invisible. Because the inherited set is ordered by subfleets.id ascending,
what disappears is always the highest ids -- whatever an admin added most
recently, which is exactly the subfleet they are looking for.

Measured on a 100-flight page: the query count is a constant 8 whatever
the limit, and does not move with the number of distinct bundles. What
scales is hydrated PHP objects, because the afterQuery callback copies
the inherited subfleets per flight to keep FareService from reconciling
one flight's fare overrides into its neighbours. So the cap bounds
memory, not queries: roughly page size x limit, and pagination tops out
at 100. 25 costs about 2,500 objects in the worst case and covers any
realistic bundle.

Truncation is now reported. The bundle load carries a count of the
accessible subfleets under the same allowedFor constraint as the capped
load, which is the only way to distinguish a bundle that fits from one
that was trimmed -- the capped load can never return more than the limit.
It is a correlated subselect on the statement that was already being
issued, so it costs no extra query.

One line per trimmed bundle per request, not per flight: a page of 100
flights on one over-capped bundle reports it once. Logged at debug
because trimming is configured behaviour rather than a fault, and every
channel defaults to debug, so it is visible on a stock install and
silenceable by an operator who has deliberately raised LOG_LEVEL.

The count attribute is stripped in the same pass as has_live_pins and the
bundle relation, so it cannot reach the API response.
2026-07-26 14:29:42 -05:00
Nabeel Shahzad
6e868e51e4
fix(filament): drop the redundant nullsafe in recordTitle
PHPStan reads Subfleet::$airline as non-null and flagged `?->` as
unnecessary, failing static analysis on four files.

`??` uses isset() semantics, so `$record->airline->name ?? ''` returns
the fallback for a null airline without warning -- the null safety the
attach modals need is already in the coalesce, and the nullsafe operator
was doing nothing on top of it. The four tests covering a subfleet with
no airline still pass.
2026-07-26 14:11:32 -05:00
Nabeel Shahzad
5e03713fe9
test: tighten the scratch-database script's guards
docker inspect succeeds for a container that exists but is stopped, so
the "start it with docker compose" hint never printed and the developer
got a raw daemon error from the first exec instead. Check .State.Running.

Listing INT and TERM alongside EXIT on one trap ran the cleanup twice on
Ctrl-C: once as the signal handler, once on the way out. EXIT now owns
the cleanup and the signal traps just exit with the conventional 128+n.
Pest's exit status still propagates, since the script never sets one of
its own.
2026-07-26 10:35:05 -05:00
Nabeel Shahzad
ed8bcfabd6
fix(importer): clear every child before truncating its parent
cleanupDb() truncates `subfleets` but left four of its pivots standing.
That is not a dangling-row problem the new foreign keys would catch:
truncate resets the auto-increment, so a surviving `bundle_subfleet` row
renames itself onto whichever freshly imported subfleet lands on its old
id, and bundle inheritance then serves that wrong subfleet to every
flight in the bundle. Same for a fare override, a rank grant or a type
rating. ImportService::importSubfleets() already clears this same set.

Two things had to change to get there.

The permission pivots it truncated -- permission_role, permission_user,
role_user -- were dropped by the v7 to v8 laratrust-to-spatie migration,
so this method died on "no such table" before reaching the end. Their
spatie equivalents are model_has_roles and model_has_permissions, and
both are keyed by users.id, another auto-increment reset here, so a
survivor would grant an imported pilot the roles of whoever held that
id. role_has_permissions stays out: roles and permissions are seeded
configuration and are not truncated.

`SET FOREIGN_KEY_CHECKS` is a syntax error on sqlite and postgres, which
the importer also targets, and it left the checks off for the rest of
the connection if a truncate in between threw.
Schema::withoutForeignKeyConstraints() is portable and restores them.

Other children of tables this method truncates are still unhandled --
pireps, users and aircraft all have them. Left alone deliberately:
whether the importer should also wipe sessions, notifications and the
activity log is a behavioural question, not a regression.
2026-07-26 10:34:48 -05:00
59 changed files with 2803 additions and 273 deletions

View File

@ -0,0 +1,72 @@
<?php
namespace App\Cron\FiveMinute;
use App\Contracts\Listener;
use App\Enums\PirepPhase;
use App\Enums\PirepState;
use App\Events\CronFiveMinute;
use App\Models\PirepPosition;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Decides what leaves the live map. Five-minutely, not hourly, because both of its
* timers are in minutes.
*/
class PirepPositionExpiration extends Listener
{
public function handle(CronFiveMinute $event): void
{
$now = Carbon::now('UTC');
$liveTime = (int) setting('livemap.live_time');
$idleTime = (int) setting('livemap.idle_time');
// Zero disables a timer rather than expiring everything instantly.
if ($liveTime > 0) {
// Finished, whatever phase the client last reported. Soft-deleted
// PIREPs count, since RemoveExpiredLiveFlights leaves them that way.
$this->evict(
$now->copy()->subMinutes($liveTime),
fn ($query) => $query->where(function ($q): void {
$q->where('pireps.state', '<>', PirepState::IN_PROGRESS->value)
->orWhereNotNull('pireps.deleted_at');
})
);
}
if ($idleTime > 0) {
// Paused, or prefiled and never departed. "Never departed" is
// updated_at == created_at, since only a batch moves updated_at.
$this->evict(
$now->copy()->subMinutes($idleTime),
fn ($query) => $query->where('pireps.state', PirepState::IN_PROGRESS->value)
->whereNull('pireps.deleted_at')
->where(function ($q): void {
$q->where('pireps.status', PirepPhase::PAUSED->value)
->orWhereColumn('pirep_positions.updated_at', '=', 'pirep_positions.created_at');
})
);
}
}
/**
* Clocked from pirep_positions.updated_at, not pireps.submitted_at - a pilot
* who lands at 12:00 and files at 15:00 stopped flying at 12:00.
*/
private function evict(Carbon $before, callable $constrain): void
{
$ids = DB::table('pirep_positions')
->join('pireps', 'pireps.id', '=', 'pirep_positions.pirep_id')
->where('pirep_positions.updated_at', '<', $before)
->tap($constrain)
->pluck('pirep_positions.pirep_id');
// Re-checked, not taken on trust: a batch landing between the select and
// the delete has refreshed updated_at and must survive.
PirepPosition::whereIn('pirep_id', $ids)
->where('updated_at', '<', $before)
->delete();
}
}

View File

@ -3,8 +3,8 @@
namespace App\Cron\Hourly;
use App\Contracts\Listener;
use App\Enums\PirepPhase;
use App\Enums\PirepState;
use App\Enums\PirepStatus;
use App\Events\CronHourly;
use App\Models\Pirep;
use App\Services\PirepService;
@ -41,7 +41,7 @@ class DeletePireps extends Listener
$dt = Carbon::now('UTC')->subHours($expire_time_hours);
$pireps = Pirep::where('created_at', '<', $dt)
->where(['state' => $state->value])
->where('status', '<>', PirepStatus::PAUSED->value)
->where('status', '<>', PirepPhase::PAUSED->value)
->get();
/** @var PirepService $pirepSvc */

View File

@ -3,8 +3,8 @@
namespace App\Cron\Hourly;
use App\Contracts\Listener;
use App\Enums\PirepPhase;
use App\Enums\PirepState;
use App\Enums\PirepStatus;
use App\Events\CronHourly;
use App\Events\PirepCancelled;
use App\Models\Pirep;
@ -25,14 +25,14 @@ class RemoveExpiredLiveFlights extends Listener
*/
public function handle(CronHourly $event): void
{
if (setting('acars.live_time') === 0) {
if (setting('pireps.tombstone_time') === 0) {
return;
}
$date = Carbon::now('UTC')->subHours(setting('acars.live_time'));
$date = Carbon::now('UTC')->subHours(setting('pireps.tombstone_time'));
$pireps = Pirep::where('updated_at', '<', $date)
->where('state', PirepState::IN_PROGRESS)
->where('status', '<>', PirepStatus::PAUSED)
->where('status', '<>', PirepPhase::PAUSED)
->get();
foreach ($pireps as $pirep) {

68
app/Enums/PirepPhase.php Normal file
View File

@ -0,0 +1,68 @@
<?php
namespace App\Enums;
use App\Enums\Concerns\HasSelect;
use Filament\Support\Contracts\HasLabel;
enum PirepPhase: string implements HasLabel
{
use HasSelect;
case INITIATED = 'INI';
case SCHEDULED = 'SCH';
case BOARDING = 'BST';
case RDY_START = 'RDT';
case PUSHBACK_TOW = 'PBT';
case DEPARTED = 'OFB';
case RDY_DEICE = 'DIR';
case STRT_DEICE = 'DIC';
case GRND_RTRN = 'GRT';
case TAXI = 'TXI';
case TAKEOFF = 'TOF';
case INIT_CLIM = 'ICL';
case AIRBORNE = 'TKO';
case ENROUTE = 'ENR';
case DIVERTED = 'DV';
case APPROACH = 'TEN';
case APPROACH_ICAO = 'APR';
case ON_FINAL = 'FIN';
case LANDING = 'LDG';
case LANDED = 'LAN';
case ON_BLOCK = 'ONB';
case ARRIVED = 'ARR';
case CANCELLED = 'DX';
case EMERG_DESCENT = 'EMG';
case PAUSED = 'PSD';
public function getLabel(): string
{
return match ($this) {
self::INITIATED => __('pireps.status.initialized'),
self::SCHEDULED => __('pireps.status.scheduled'),
self::BOARDING => __('pireps.status.boarding'),
self::RDY_START => __('pireps.status.ready_start'),
self::PUSHBACK_TOW => __('pireps.status.push_tow'),
self::DEPARTED => __('pireps.status.departed'),
self::RDY_DEICE => __('pireps.status.ready_deice'),
self::STRT_DEICE => __('pireps.status.deicing'),
self::GRND_RTRN => __('pireps.status.ground_ret'),
self::TAXI => __('pireps.status.taxi'),
self::TAKEOFF => __('pireps.status.takeoff'),
self::INIT_CLIM => __('pireps.status.initial_clb'),
self::AIRBORNE,
self::ENROUTE => __('pireps.status.enroute'),
self::DIVERTED => __('pireps.status.diverted'),
self::APPROACH,
self::APPROACH_ICAO => __('pireps.status.approach'),
self::ON_FINAL => __('pireps.status.final_appr'),
self::LANDING => __('pireps.status.landing'),
self::LANDED => __('pireps.status.landed'),
self::ON_BLOCK,
self::ARRIVED => __('pireps.status.arrived'),
self::CANCELLED => __('pireps.status.cancelled'),
self::EMERG_DESCENT => __('pireps.status.emerg_decent'),
self::PAUSED => __('pireps.status.paused'),
};
}
}

View File

@ -1,68 +1,14 @@
<?php
declare(strict_types=1);
/**
* A class_alias, not a second enum, so PirepStatus::TAXI and PirepPhase::TAXI are
* the same case. Composer resolves this path via PSR-4 and the alias defines the name.
*
* @deprecated Use \App\Enums\PirepPhase instead.
*/
namespace App\Enums;
use App\Enums\Concerns\HasSelect;
use Filament\Support\Contracts\HasLabel;
enum PirepStatus: string implements HasLabel
{
use HasSelect;
case INITIATED = 'INI';
case SCHEDULED = 'SCH';
case BOARDING = 'BST';
case RDY_START = 'RDT';
case PUSHBACK_TOW = 'PBT';
case DEPARTED = 'OFB';
case RDY_DEICE = 'DIR';
case STRT_DEICE = 'DIC';
case GRND_RTRN = 'GRT';
case TAXI = 'TXI';
case TAKEOFF = 'TOF';
case INIT_CLIM = 'ICL';
case AIRBORNE = 'TKO';
case ENROUTE = 'ENR';
case DIVERTED = 'DV';
case APPROACH = 'TEN';
case APPROACH_ICAO = 'APR';
case ON_FINAL = 'FIN';
case LANDING = 'LDG';
case LANDED = 'LAN';
case ON_BLOCK = 'ONB';
case ARRIVED = 'ARR';
case CANCELLED = 'DX';
case EMERG_DESCENT = 'EMG';
case PAUSED = 'PSD';
public function getLabel(): string
{
return match ($this) {
self::INITIATED => __('pireps.status.initialized'),
self::SCHEDULED => __('pireps.status.scheduled'),
self::BOARDING => __('pireps.status.boarding'),
self::RDY_START => __('pireps.status.ready_start'),
self::PUSHBACK_TOW => __('pireps.status.push_tow'),
self::DEPARTED => __('pireps.status.departed'),
self::RDY_DEICE => __('pireps.status.ready_deice'),
self::STRT_DEICE => __('pireps.status.deicing'),
self::GRND_RTRN => __('pireps.status.ground_ret'),
self::TAXI => __('pireps.status.taxi'),
self::TAKEOFF => __('pireps.status.takeoff'),
self::INIT_CLIM => __('pireps.status.initial_clb'),
self::AIRBORNE,
self::ENROUTE => __('pireps.status.enroute'),
self::DIVERTED => __('pireps.status.diverted'),
self::APPROACH,
self::APPROACH_ICAO => __('pireps.status.approach'),
self::ON_FINAL => __('pireps.status.final_appr'),
self::LANDING => __('pireps.status.landing'),
self::LANDED => __('pireps.status.landed'),
self::ON_BLOCK,
self::ARRIVED => __('pireps.status.arrived'),
self::CANCELLED => __('pireps.status.cancelled'),
self::EMERG_DESCENT => __('pireps.status.emerg_decent'),
self::PAUSED => __('pireps.status.paused'),
};
}
}
class_alias(PirepPhase::class, 'App\\Enums\\PirepStatus');

View File

@ -58,7 +58,7 @@ class SubfleetsRelationManager extends RelationManager
->preloadRecordSelect()
// recordTitle() reads the airline off every option.
->recordSelectOptionsQuery(fn (Builder $query): Builder => $query->with('airline'))
->recordTitle(fn (Subfleet $record): string => trim(($record->airline?->name ?? '').' - '.$record->name, ' -')),
->recordTitle(fn (Subfleet $record): string => trim(($record->airline->name ?? '').' - '.$record->name, ' -')),
])
->recordActions([
DetachAction::make(),

View File

@ -54,7 +54,7 @@ class SubfleetsRelationManager extends RelationManager
->preloadRecordSelect()
// recordTitle() reads the airline off every option.
->recordSelectOptionsQuery(fn (Builder $query): Builder => $query->with('airline'))
->recordTitle(fn (Subfleet $record): string => trim(($record->airline?->name ?? '').' - '.$record->name, ' -')),
->recordTitle(fn (Subfleet $record): string => trim(($record->airline->name ?? '').' - '.$record->name, ' -')),
])
->recordActions([
DetachAction::make(),

View File

@ -62,7 +62,7 @@ class SubfleetsRelationManager extends RelationManager
->preloadRecordSelect()
// recordTitle() reads the airline off every option.
->recordSelectOptionsQuery(fn (Builder $query): Builder => $query->with('airline'))
->recordTitle(fn (Subfleet $record): string => trim(($record->airline?->name ?? '').' - '.$record->name, ' -')),
->recordTitle(fn (Subfleet $record): string => trim(($record->airline->name ?? '').' - '.$record->name, ' -')),
])
->recordActions([
DetachAction::make(),

View File

@ -51,7 +51,7 @@ class SubfleetsRelationManager extends RelationManager
->preloadRecordSelect()
// recordTitle() reads the airline off every option.
->recordSelectOptionsQuery(fn (Builder $query): Builder => $query->with('airline'))
->recordTitle(fn (Subfleet $record): string => trim(($record->airline?->name ?? '').' - '.$record->name, ' -')),
->recordTitle(fn (Subfleet $record): string => trim(($record->airline->name ?? '').' - '.$record->name, ' -')),
])
->recordActions([
DetachAction::make(),

View File

@ -25,6 +25,7 @@ use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedSchema;
use Filament\Schemas\Components\Form;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\View as ViewComponent;
use Filament\Schemas\Components\Wizard;
use Filament\Schemas\Components\Wizard\Step;
use Filament\Schemas\Schema as FilamentSchema;
@ -49,6 +50,12 @@ class Installer extends Page
{
protected static ?string $slug = 'install';
/**
* How long the migration step waits before moving itself on. Long enough to
* hit Pause if the log needs a read, short enough not to feel stalled.
*/
private const int AUTO_ADVANCE_SECONDS = 5;
public string $stream = 'console_output';
public ?array $user = null;
@ -164,6 +171,36 @@ class Installer extends Page
])
->startOnStep(fn (): int => $this->computeStartStep())
->persistStepInQueryString()
->nextAction(fn (Action $action): Action => $action
->label(new HtmlString(
Blade::render(
<<<'BLADE'
{{ __('filament-schemas::components.wizard.actions.next_step.label') }}<span
x-cloak
x-show="$store.installerAutoAdvance?.active"
x-text="` (${$store.installerAutoAdvance?.remaining})`"
></span>
BLADE
)
))
->extraAttributes(['data-installer-next' => 'true']))
// The wizard's only backwards step would be onto an already-run
// migration log, so the slot carries the countdown's Pause
// control instead of a Back button. `.stop` keeps the click from
// reaching the footer wrapper, which would otherwise step back.
->previousAction(fn (Action $action): Action => $action
->label(new HtmlString(
Blade::render(
<<<'BLADE'
<span x-text="$store.installerAutoAdvance?.paused ? @js(__('installer.resume')) : @js(__('installer.pause'))">{{ __('installer.pause') }}</span>
BLADE
)
))
->extraAttributes([
'x-cloak' => true,
'x-show' => '$store.installerAutoAdvance?.active',
'x-on:click.stop' => '$store.installerAutoAdvance.toggle()',
]))
->submitAction(
new HtmlString(
Blade::render(
@ -298,6 +335,16 @@ class Installer extends Page
to: $this->stream
);
// Only hand the step over to the countdown once there is genuinely
// nothing left to run — otherwise it would advance into the step's own
// validation, which halts on pending migrations.
if (count(app(MigrationService::class)->migrationsAvailable()) === 0) {
$this->dispatch(
'installer-migrations-complete',
seconds: self::AUTO_ADVANCE_SECONDS
);
}
return $this->migrationOutput = $output;
}
@ -500,6 +547,11 @@ class Installer extends Page
->viewData([
'stream' => $this->stream,
]),
ViewComponent::make('filament.installer.auto-advance')
->viewData([
'seconds' => self::AUTO_ADVANCE_SECONDS,
]),
])
->afterValidation(function (): void {
if (count(app(MigrationService::class)->migrationsAvailable()) > 0) {

View File

@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Contracts\Controller;
use App\Enums\AcarsType;
use App\Enums\PirepState;
use App\Events\AcarsUpdate;
use App\Exceptions\PirepCancelled;
use App\Exceptions\PirepNotFound;
@ -14,6 +15,7 @@ use App\Http\Resources\AcarsRouteResource;
use App\Http\Resources\PirepResource;
use App\Models\Acars;
use App\Models\Pirep;
use App\Models\PirepPosition;
use App\Services\GeoService;
use Carbon\Carbon;
use DateTime;
@ -64,9 +66,8 @@ class AcarsController extends Controller
*/
public function live_flights()
{
$pireps = Pirep::activeFlights(setting('acars.live_time'))->get()->filter(
fn (Pirep $pirep): bool => $pirep->position !== null
);
// No filtering: the join is the whole membership test.
$pireps = Pirep::onLiveMap()->get();
return PirepResource::collection($pireps);
}
@ -76,7 +77,7 @@ class AcarsController extends Controller
*/
public function pireps_geojson(Request $request): JsonResponse
{
$pireps = Pirep::activeFlights(setting('acars.live_time'))->get();
$pireps = Pirep::onLiveMap()->get();
$positions = $this->geoSvc->getFeatureForLiveFlights($pireps);
return response()->json([
@ -187,18 +188,70 @@ class AcarsController extends Controller
}
// Change the PIREP status if it's as SCHEDULED before
/*if ($pirep->status === PirepStatus::INITIATED) {
$pirep->status = PirepStatus::AIRBORNE;
/*if ($pirep->status === PirepPhase::INITIATED) {
$pirep->status = PirepPhase::AIRBORNE;
}*/
$pirep->save();
// Post a new update for this ACARS position
event(new AcarsUpdate($pirep, $pirep->position));
$latest = $this->syncPosition($pirep);
// Still the acars row, not the position row - this event's payload is unchanged.
event(new AcarsUpdate($pirep, $latest));
return $this->message($count.' positions added', $count);
}
/**
* Upsert the position row from the PIREP's newest breadcrumb. Resolved from
* `acars` server-side, not from the batch, so a replayed or out-of-order batch
* can't move the aircraft backwards - `acars`.`created_at` is collection time.
*
* Only IN_PROGRESS and PENDING get a row. PENDING because filing happens while a
* client may still be posting the tail of the flight. Refused batches still write
* their `acars` rows.
*/
private function syncPosition(Pirep $pirep): ?Acars
{
/** @var ?Acars $latest */
$latest = Acars::query()
->forPirep($pirep->id)
->flightPath()
->orderBy('created_at', 'desc')
->orderBy('order', 'desc')
->orderBy('sim_time', 'desc')
->first();
if ($latest === null
|| !in_array($pirep->state, [PirepState::IN_PROGRESS, PirepState::PENDING], true)
) {
return $latest;
}
PirepPosition::updateOrCreate(
['pirep_id' => $pirep->id],
[
'user_id' => $pirep->user_id,
// Off the PIREP, not `acars`.`status` - a different column entirely.
'phase' => $pirep->status,
'lat' => $latest->lat ?? 0,
'lon' => $latest->lon ?? 0,
'heading' => $latest->heading ?? 0,
'distance' => $latest->distance?->internal(2) ?? 0,
'altitude_agl' => $latest->altitude_agl ?? 0,
'altitude_msl' => $latest->altitude_msl ?? 0,
'vs' => $latest->vs ?? 0,
'gs' => $latest->gs ?? 0,
'ias' => $latest->ias ?? 0,
// On the PIREP: `acars` has neither, and its `fuel` is fuel remaining.
'flight_time' => $pirep->flight_time ?? 0,
'fuel_used' => $pirep->fuel_used?->internal(2) ?? 0,
]
);
return $latest;
}
/**
* Post ACARS LOG update for a PIREP. These updates won't show up on the map
* But rather in a log file.

View File

@ -21,7 +21,7 @@ use Override;
* search Free-text or `field:value[;field:value...]` (max 255 chars)
* user_id Filter by pilot
* state PirepState integer
* status PirepStatus string
* status PirepPhase string
* orderBy One of ORDERABLE_FIELDS
* sortedBy asc | desc
* page Pagination page number (min 1)

View File

@ -2,7 +2,7 @@
namespace App\Listeners;
use App\Enums\PirepStatus;
use App\Enums\PirepPhase;
use App\Enums\UserState;
use App\Events\AwardAwarded;
use App\Events\NewsAdded;
@ -161,7 +161,7 @@ class NotificationsSubscriber
* Reduced the messages (Boarding, Pushback, TakeOff, Landing and non-normals only)
* If needed array can be tied to a setting at admin side for further customization
*
* PirepStatus::DIVERTED is deliberately absent from the list: a diversion is
* PirepPhase::DIVERTED is deliberately absent from the list: a diversion is
* announced by PirepService::handleDiversion() through Broadcast\PirepDiverted,
* which carries the diversion airport and reason. Listing it here as well
* announced every diversion twice.
@ -171,14 +171,14 @@ class NotificationsSubscriber
Log::info('NotificationEvents::onPirepStatusChange: '.$event->pirep->id.' status changed');
$message_types = [
PirepStatus::BOARDING,
PirepStatus::PUSHBACK_TOW,
PirepStatus::GRND_RTRN,
PirepStatus::TAKEOFF,
PirepStatus::LANDED,
PirepStatus::CANCELLED,
PirepStatus::PAUSED,
PirepStatus::EMERG_DESCENT,
PirepPhase::BOARDING,
PirepPhase::PUSHBACK_TOW,
PirepPhase::GRND_RTRN,
PirepPhase::TAKEOFF,
PirepPhase::LANDED,
PirepPhase::CANCELLED,
PirepPhase::PAUSED,
PirepPhase::EMERG_DESCENT,
];
if (setting('notifications.discord_pirep_status', true) && in_array($event->pirep->status, $message_types,

View File

@ -22,6 +22,7 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Kyslik\ColumnSortable\Sortable;
use LogicException;
use Override;
@ -418,7 +419,7 @@ class Flight extends Model
* the legacy "leg zero or empty" sentinel) from `'0'` (string), both of
* which we collapse, from a real string `'abc'` that we preserve.
*
* Backed enums (e.g. `PirepStatus::DIVERTED` set on `route_code` by the
* Backed enums (e.g. `PirepPhase::DIVERTED` set on `route_code` by the
* diversion handler) are coerced via their backing `->value`. Pure unit
* enums fall back to their `->name`. This keeps legacy callers working
* without forcing them to pre-stringify enum values.
@ -811,7 +812,9 @@ class Flight extends Model
* bundle, ordered by id so the same N come back every request. The inherited
* set here can therefore be a subset of what `accessibleSubfleetsFor`
* returns for the same flight. A list row is a summary; the flight page is
* the authority.
* the authority. A bundle the cap actually trims logs one debug line per
* request the trimmed rows are the highest ids, so what disappears is
* whatever an admin most recently added.
*
* Which rung applies is decided by configuration BEFORE access filtering.
* The has-pins probe deliberately skips `allowedFor`, so a flight whose pins
@ -843,7 +846,7 @@ class Flight extends Model
// release has no key at all and `PHPVMS_INHERITED_SUBFLEET_LIMIT=` in
// .env reads back as '' — both cast to 0, as does a literal 0. Hence
// the named default, and the floor for a negative.
$inheritedLimit = max(1, (int) config('phpvms.subfleets.inherited_list_limit', 5) ?: 5);
$inheritedLimit = max(1, (int) config('phpvms.subfleets.inherited_list_limit', 25) ?: 25);
// Whether the caller already asked for the bundle. The eager load
// added below is this scope's own working state and gets dropped
@ -861,14 +864,28 @@ class Flight extends Model
// accessibleSubfleetsFor. Without withTrashed a soft-deleted
// bundle would resolve to null here and quietly strip its
// flights of the subfleets they are still configured with.
'bundle' => fn ($bq) => $bq->withTrashed(),
//
// The count is a correlated subselect on the same bundle query
// — no extra round trip — and is the only way to tell a bundle
// that fits under the cap from one the cap silently trimmed,
// since the capped load below can only ever hand back
// `$inheritedLimit` rows. It carries the same `allowedFor`
// constraint, so it counts what the pilot could have seen, not
// what the bundle holds.
'bundle' => fn ($bq) => $bq->withTrashed()
->withCount(['subfleets' => fn ($cq) => $cq->allowedFor($user)]),
'bundle.subfleets' => fn ($sq) => $sq->allowedFor($user)
->with($nested)
->orderBy('subfleets.id')
->limit($inheritedLimit),
'subfleets' => fn ($sq) => $sq->allowedFor($user)->with($nested),
])
->afterQuery(function ($results) use ($callerLoadsBundle) {
->afterQuery(function ($results) use ($callerLoadsBundle, $inheritedLimit) {
// One hydrated Bundle backs every flight pointing at it, so a
// page of 100 flights on one over-capped bundle has to report
// that bundle once, not 100 times.
$seenBundles = [];
foreach ($results as $flight) {
// afterQuery also fires for pluck(), whose collection holds
// scalars rather than models. The probe check keeps this
@ -900,6 +917,30 @@ class Flight extends Model
$flight->unsetRelation('bundle');
}
// The count rides the same bundle row and is this scope's
// own working state, so it is stripped for the same reason
// the probe is: a bundle the caller eager-loaded would
// otherwise serialise it. Stripping it also makes a second
// application of this scope read 0 and stay quiet, so the
// once-per-bundle guarantee survives a doubled scope.
if ($bundle !== null && !array_key_exists($bundle->getKey(), $seenBundles)) {
$seenBundles[$bundle->getKey()] = true;
$accessible = (int) $bundle->getAttribute('subfleets_count');
unset($bundle['subfleets_count']);
// The trimmed rows are the highest ids — the subfleet
// an admin just added and is now hunting for. Silence
// makes that look like a permissions bug.
if ($accessible > $inheritedLimit) {
Log::debug(
'Flight list: bundle '.$bundle->getKey().' has '.$accessible
.' accessible subfleets, showing the first '.$inheritedLimit
.' (phpvms.subfleets.inherited_list_limit)'
);
}
}
if (!$inherits) {
continue;
}

View File

@ -9,9 +9,9 @@ use App\Contracts\Model;
use App\Enums\AcarsType;
use App\Enums\FlightType;
use App\Enums\PirepFieldSource;
use App\Enums\PirepPhase;
use App\Enums\PirepSource;
use App\Enums\PirepState;
use App\Enums\PirepStatus;
use App\Enums\SimType;
use App\Events\PirepStateChange;
use App\Events\PirepStatusChange;
@ -66,7 +66,7 @@ use Spatie\Activitylog\Traits\LogsActivity;
* @property PirepSource|null $source
* @property string|null $source_name
* @property PirepState $state
* @property PirepStatus $status
* @property PirepPhase $status
* @property mixed|null $submitted_at
* @property mixed|null $block_off_time
* @property mixed|null $block_on_time
@ -99,7 +99,7 @@ use Spatie\Activitylog\Traits\LogsActivity;
* @property-read DatabaseNotificationCollection<int, DatabaseNotification> $notifications
* @property-read int|null $notifications_count
* @property-read User|null $pilot
* @property-read Acars|null $position
* @property-read PirepPosition|null $position
* @property-read float $progress_percent
* @property-read bool $read_only
* @property-read SimBrief|null $simbrief
@ -107,8 +107,8 @@ use Spatie\Activitylog\Traits\LogsActivity;
* @property-read int|null $transactions_count
* @property-read User|null $user
*
* @method static Builder<static>|Pirep activeFlights(int $liveTime = 0)
* @method static PirepFactory factory($count = null, $state = [])
* @method static Builder<static>|Pirep onLiveMap()
* @method static Builder<static>|Pirep newModelQuery()
* @method static Builder<static>|Pirep newQuery()
* @method static Builder<static>|Pirep onlyTrashed()
@ -550,15 +550,12 @@ class Pirep extends Model
}
/**
* Relationship that holds the current position, but limits the ACARS
* relationship to only one row (the latest), to prevent an N+! problem
* The flight's current position. Was a latest-of-many over `acars`; now a plain
* one-to-one on `pirep_positions`.
*/
public function position(): HasOne
{
return $this->hasOne(Acars::class, 'pirep_id')
->flightPath()
->latest('created_at')
->latest('sim_time');
return $this->hasOne(PirepPosition::class, 'pirep_id');
}
public function simbrief(): BelongsTo
@ -604,28 +601,22 @@ class Pirep extends Model
'source' => PirepSource::class,
'sim_type' => SimType::class,
'state' => PirepState::class,
'status' => PirepStatus::class,
'status' => PirepPhase::class,
'submitted_at' => CarbonCast::class,
];
}
/**
* Scope: PIREPs with state = IN_PROGRESS, optionally constrained to those
* updated within the last $liveTime hours, ordered by updated_at desc, with
* the relations needed by the live-map / live-flights endpoints eager-loaded.
*
* Replaces the previously-misnamed AcarsRepository::getPositions() method.
* Scope: the flights on the live map. Membership is the join and nothing else -
* eviction belongs to PirepPositionExpiration, not to a query that runs per poll.
*/
public function scopeActiveFlights(Builder $query, int $liveTime = 0): Builder
public function scopeOnLiveMap(Builder $query): Builder
{
$query
->with(['aircraft', 'airline', 'arr_airport', 'dpt_airport', 'position', 'user'])
->where('state', PirepState::IN_PROGRESS);
if ($liveTime > 0) {
$query->where('updated_at', '>=', Carbon::now()->subHours($liveTime));
}
return $query->orderBy('updated_at', 'desc');
return $query
// user.airline because User::ident reads it.
->with(['aircraft', 'airline', 'arr_airport', 'dpt_airport', 'position', 'user', 'user.airline'])
->join('pirep_positions', 'pirep_positions.pirep_id', '=', 'pireps.id')
->select('pireps.*')
->orderBy('pirep_positions.updated_at', 'desc');
}
}

View File

@ -0,0 +1,115 @@
<?php
namespace App\Models;
use App\Casts\DistanceCast;
use App\Casts\FuelCast;
use App\Contracts\Model;
use App\Enums\PirepPhase;
use Database\Factories\PirepPositionFactory;
use Illuminate\Database\Eloquent\Attributes\WithoutIncrementing;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use Override;
/**
* A PIREP's last-known position, one row per flight. Its existence is what puts a
* flight on the live map. `updated_at` moves on position batches only.
*
* @property string $pirep_id
* @property int $user_id
* @property PirepPhase $phase
* @property float $lat
* @property float $lon
* @property int $heading
* @property mixed $distance
* @property float $altitude_agl
* @property float $altitude_msl
* @property float $vs
* @property int $gs
* @property int $ias
* @property int $flight_time
* @property mixed $fuel_used
* @property-read float $altitude
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read Pirep|null $pirep
* @property-read User|null $user
*
* @method static PirepPositionFactory factory($count = null, $state = [])
* @method static Builder<static>|PirepPosition newModelQuery()
* @method static Builder<static>|PirepPosition newQuery()
* @method static Builder<static>|PirepPosition query()
*/
#[WithoutIncrementing]
class PirepPosition extends Model
{
use HasFactory;
public $table = 'pirep_positions';
protected $appends = ['altitude'];
protected $primaryKey = 'pirep_id';
protected $keyType = 'string';
public $fillable = [
'pirep_id',
'user_id',
'phase',
'lat',
'lon',
'heading',
'distance',
'altitude_agl',
'altitude_msl',
'vs',
'gs',
'ias',
'flight_time',
'fuel_used',
];
/** Units come from the casts, matching `acars` and `pireps`. */
#[Override]
protected function casts(): array
{
return [
'user_id' => 'integer',
'phase' => PirepPhase::class,
'lat' => 'float',
'lon' => 'float',
'heading' => 'integer',
'distance' => DistanceCast::class,
'altitude_agl' => 'float',
'altitude_msl' => 'float',
'vs' => 'float',
'gs' => 'integer',
'ias' => 'integer',
'flight_time' => 'integer',
'fuel_used' => FuelCast::class,
];
}
/** Mirrors Acars::altitude, which the live map GeoJSON reads. */
protected function altitude(): Attribute
{
return Attribute::make(
get: fn (mixed $_, array $attrs): float => (float) $attrs['altitude_msl'],
);
}
public function pirep(): BelongsTo
{
return $this->belongsTo(Pirep::class, 'pirep_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@ -3,7 +3,7 @@
namespace App\Notifications\Messages\Broadcast;
use App\Contracts\Notification;
use App\Enums\PirepStatus;
use App\Enums\PirepPhase;
use App\Models\Pirep;
use App\Notifications\Concerns\BuildsDiscordEmbeds;
use App\Notifications\DiscordEmbedColor;
@ -24,11 +24,11 @@ class PirepStatusChanged extends Notification implements ShouldQueue
* Statuses that read as trouble rather than routine progress.
*/
private const array DANGER_STATUSES = [
PirepStatus::GRND_RTRN,
PirepStatus::DIVERTED,
PirepStatus::CANCELLED,
PirepStatus::PAUSED,
PirepStatus::EMERG_DESCENT,
PirepPhase::GRND_RTRN,
PirepPhase::DIVERTED,
PirepPhase::CANCELLED,
PirepPhase::PAUSED,
PirepPhase::EMERG_DESCENT,
];
/**

View File

@ -6,9 +6,9 @@ use App\Auth\InstallSafeUserProvider;
use App\Contracts\Metar;
use App\Contracts\Model as BaseModel;
use App\Enums\ActiveState;
use App\Enums\PirepPhase;
use App\Enums\PirepSource;
use App\Enums\PirepState;
use App\Enums\PirepStatus;
use App\Enums\UserState;
use App\Http\Composers\PageLinksComposer;
use App\Http\Composers\VersionComposer;
@ -302,7 +302,8 @@ class AppServiceProvider extends ServiceProvider
'UserState' => UserState::class,
'PirepSource' => PirepSource::class,
'PirepState' => PirepState::class,
'PirepStatus' => PirepStatus::class,
'PirepPhase' => PirepPhase::class,
'PirepStatus' => PirepPhase::class,
];
foreach ($aliases as $alias => $class) {

View File

@ -7,9 +7,9 @@ namespace App\Services\Finance;
use App\Contracts\Service;
use App\Enums\ExpenseType;
use App\Enums\FuelType;
use App\Enums\PirepPhase;
use App\Enums\PirepSource;
use App\Enums\PirepState;
use App\Enums\PirepStatus;
use App\Events\Expenses as ExpensesEvent;
use App\Events\Fares as FaresEvent;
use App\Models\Aircraft;
@ -209,7 +209,7 @@ class PirepFinanceService extends Service
$prev_flight = Pirep::where([
'aircraft_id' => $pirep->aircraft->id,
'state' => PirepState::ACCEPTED,
'status' => PirepStatus::ARRIVED,
'status' => PirepPhase::ARRIVED,
])
->where('submitted_at', '<=', $pirep->submitted_at)
->orderby('submitted_at', 'desc')

View File

@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Services;
use App\Contracts\Service;
use App\Enums\PirepPhase;
use App\Enums\PirepState;
use App\Enums\PirepStatus;
use App\Exceptions\DuplicateFlight;
use App\Models\Bid;
use App\Models\Flight;
@ -269,14 +269,14 @@ class FlightService extends Service
public function removeExpiredRepositionFlights(): void
{
/** @var \Illuminate\Database\Eloquent\Collection<int, Flight> $flights */
$flights = Flight::where('route_code', PirepStatus::DIVERTED)->get();
$flights = Flight::where('route_code', PirepPhase::DIVERTED)->get();
foreach ($flights as $flight) {
$diverted_pirep = Pirep::with('aircraft')
->where([
'user_id' => $flight->user_id,
'arr_airport_id' => $flight->dpt_airport_id,
'status' => PirepStatus::DIVERTED,
'status' => PirepPhase::DIVERTED,
'state' => PirepState::ACCEPTED,
])
->orderBy('submitted_at', 'desc')

View File

@ -224,6 +224,23 @@ class GeoService extends Service
]);
}
// Breadcrumbs and the live position move at different cadences, so the
// trail can end minutes behind the marker. Appending closes that gap - a
// real reported point, not an interpolated one.
$live = $pirep->position;
$last = $actual_route->last();
if ($live !== null
&& ($last === null
|| (float) $last->lat !== (float) $live->lat
|| (float) $last->lon !== (float) $live->lon)
) {
$route->addPoint($live->lat, $live->lon, [
'pirep_id' => $pirep->id,
'alt' => $live->altitude,
]);
}
return [
// If there is a position update from ACARS, show where it is
// Otherwise, just assume it's at the arrival airport currently

View File

@ -23,6 +23,7 @@ use App\Models\Subfleet;
use App\Models\User;
use App\Models\UserAward;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Override;
class ClearDatabase extends BaseImporter
@ -56,42 +57,70 @@ class ClearDatabase extends BaseImporter
{
$this->info('Running database cleanup/empty before starting');
DB::statement('SET FOREIGN_KEY_CHECKS=0');
// MySQL refuses to TRUNCATE any table a foreign key points at, even
// when the referencing table holds no rows, so the constraints are
// suspended and children are cleared before parents.
// Schema::withoutForeignKeyConstraints() rather than the bare
// `SET FOREIGN_KEY_CHECKS` this used to issue: that statement is a
// syntax error on the SQLite and Postgres targets the importer also
// writes to, and it left the checks off for the rest of the
// connection when a truncate in between threw.
Schema::withoutForeignKeyConstraints(function (): void {
Bid::truncate();
File::truncate();
News::truncate();
Bid::truncate();
File::truncate();
News::truncate();
Expense::truncate();
JournalTransaction::truncate();
Journal::truncate();
Ledger::truncate();
Expense::truncate();
JournalTransaction::truncate();
Journal::truncate();
Ledger::truncate();
// Clear flights
DB::table('flight_fare')->truncate();
DB::table('flight_subfleet')->truncate();
FlightField::truncate();
FlightFieldValue::truncate();
Flight::truncate();
// Clear flights
DB::table('flight_fare')->truncate();
DB::table('flight_subfleet')->truncate();
FlightField::truncate();
FlightFieldValue::truncate();
Flight::truncate();
Subfleet::truncate();
Aircraft::truncate();
// Every one of these is reachable only through a subfleet, and
// `subfleets.id` is an auto-increment this truncate resets: a row
// left behind does not dangle, it renames itself onto whichever
// freshly imported subfleet lands on its old id. A bundle's
// subfleet defaults cannot outlive the subfleets they name, and
// neither can a fare override, a rank grant or a type rating --
// ImportService::importSubfleets() clears the same set.
DB::table('bundle_subfleet')->truncate();
DB::table('subfleet_fare')->truncate();
DB::table('subfleet_rank')->truncate();
DB::table('typerating_subfleet')->truncate();
Subfleet::truncate();
Airline::truncate();
Airport::truncate();
Acars::truncate();
Pirep::truncate();
Aircraft::truncate();
UserAward::truncate();
User::truncate();
Airline::truncate();
Airport::truncate();
Acars::truncate();
Pirep::truncate();
// Clear permissions
DB::table('permission_role')->truncate();
DB::table('permission_user')->truncate();
DB::table('role_user')->truncate();
UserAward::truncate();
User::truncate();
// Role::truncate();
// Clear permissions. These are the spatie/laravel-permission
// pivots: the laratrust tables this used to name
// (`permission_role`, `permission_user`, `role_user`) were dropped
// by the v7 -> v8 migration, so every run of this importer died
// here on "no such table". Both are keyed by `users.id`, another
// auto-increment reset above -- a row left behind would grant a
// freshly imported pilot the roles of the one who held that id.
//
// `role_has_permissions` is deliberately absent: roles and
// permissions are seeded configuration, not imported data, and
// neither table is truncated here.
DB::table('model_has_roles')->truncate();
DB::table('model_has_permissions')->truncate();
DB::statement('SET FOREIGN_KEY_CHECKS=1');
// Role::truncate();
});
$this->idMapper->clear();
}

View File

@ -5,9 +5,9 @@ declare(strict_types=1);
namespace App\Services\LegacyImporter;
use App\Enums\FlightType;
use App\Enums\PirepPhase;
use App\Enums\PirepSource;
use App\Enums\PirepState;
use App\Enums\PirepStatus;
use App\Models\Pirep;
use App\Services\FinanceService;
use App\Support\Money;
@ -75,7 +75,7 @@ class PirepImporter extends BaseImporter
'route' => $row->route ?: '',
'source_name' => $row->source,
'state' => $this->mapState($row->accepted),
'status' => PirepStatus::ARRIVED,
'status' => PirepPhase::ARRIVED,
'submitted_at' => $this->parseDate($row->submitdate),
'created_at' => $this->parseDate($row->submitdate),
'updated_at' => $this->parseDate($row->submitdate),

View File

@ -3,7 +3,7 @@
namespace App\Services\Pirep;
use App\Enums\AcarsType;
use App\Enums\PirepStatus;
use App\Enums\PirepPhase;
use App\Models\Acars;
use App\Models\Pirep;
use DateTimeInterface;
@ -61,7 +61,7 @@ class PerformanceChartService
/**
* Compact phase-timing summary rendered as four stat boxes beneath the
* performance chart. Bucket per PirepStatus code:
* performance chart. Bucket per PirepPhase code:
*
* - climb : TAKEOFF, INIT_CLIM, AIRBORNE
* - cruise : ENROUTE
@ -402,24 +402,24 @@ class PerformanceChartService
}
/**
* Log-substring PirepStatus marker table. Ordered by typical flight
* Log-substring PirepPhase marker table. Ordered by typical flight
* sequence; substrings matched case-insensitively against the first
* occurrence of each row in the LOG stream (with "flaps set to up"
* gated to fire only after takeoff pre-takeoff flap retract and
* post-landing flap stow share the same string).
*
* @var array<int, array{needle: string, status: PirepStatus, after_takeoff: bool}>
* @var array<int, array{needle: string, status: PirepPhase, after_takeoff: bool}>
*/
private const array LOG_MARKERS = [
['needle' => 'started boarding', 'status' => PirepStatus::BOARDING, 'after_takeoff' => false],
['needle' => 'started pushback', 'status' => PirepStatus::PUSHBACK_TOW, 'after_takeoff' => false],
['needle' => 'started taxi out', 'status' => PirepStatus::TAXI, 'after_takeoff' => false],
['needle' => 'started takeoff', 'status' => PirepStatus::TAKEOFF, 'after_takeoff' => false],
['needle' => 'flaps set to up', 'status' => PirepStatus::ENROUTE, 'after_takeoff' => true],
['needle' => 'on approach', 'status' => PirepStatus::APPROACH_ICAO, 'after_takeoff' => true],
['needle' => 'on final approach', 'status' => PirepStatus::ON_FINAL, 'after_takeoff' => true],
['needle' => 'landing rate', 'status' => PirepStatus::LANDING, 'after_takeoff' => true],
['needle' => 'blocks on time', 'status' => PirepStatus::ON_BLOCK, 'after_takeoff' => true],
['needle' => 'started boarding', 'status' => PirepPhase::BOARDING, 'after_takeoff' => false],
['needle' => 'started pushback', 'status' => PirepPhase::PUSHBACK_TOW, 'after_takeoff' => false],
['needle' => 'started taxi out', 'status' => PirepPhase::TAXI, 'after_takeoff' => false],
['needle' => 'started takeoff', 'status' => PirepPhase::TAKEOFF, 'after_takeoff' => false],
['needle' => 'flaps set to up', 'status' => PirepPhase::ENROUTE, 'after_takeoff' => true],
['needle' => 'on approach', 'status' => PirepPhase::APPROACH_ICAO, 'after_takeoff' => true],
['needle' => 'on final approach', 'status' => PirepPhase::ON_FINAL, 'after_takeoff' => true],
['needle' => 'landing rate', 'status' => PirepPhase::LANDING, 'after_takeoff' => true],
['needle' => 'blocks on time', 'status' => PirepPhase::ON_BLOCK, 'after_takeoff' => true],
];
/**
@ -502,7 +502,7 @@ class PerformanceChartService
$matched[$i] = ['status' => $marker['status'], 'ts' => $ts];
if ($marker['status'] === PirepStatus::TAKEOFF) {
if ($marker['status'] === PirepPhase::TAKEOFF) {
$takeoffSeen = true;
}
@ -547,9 +547,9 @@ class PerformanceChartService
return $this->collapseToPhases(
$samples,
fn ($s): string => match (true) {
(float) ($s->vs ?? 0) > 200 => PirepStatus::INIT_CLIM->value,
(float) ($s->vs ?? 0) < -200 => PirepStatus::APPROACH_ICAO->value,
default => PirepStatus::ENROUTE->value,
(float) ($s->vs ?? 0) > 200 => PirepPhase::INIT_CLIM->value,
(float) ($s->vs ?? 0) < -200 => PirepPhase::APPROACH_ICAO->value,
default => PirepPhase::ENROUTE->value,
},
);
}
@ -557,7 +557,7 @@ class PerformanceChartService
/**
* Walk the sample collection, group contiguous runs that share the same
* phase code (resolved by `$codeFor`), and emit one entry per run with
* its translated PirepStatus label.
* its translated PirepPhase label.
*
* @param callable(Acars): string $codeFor
* @return array<int, array{code: string, label: string, start: int, end: int}>
@ -595,7 +595,7 @@ class PerformanceChartService
}
/**
* Label = the PirepStatus 3-letter code itself (e.g. 'TXI', 'ENR').
* Label = the PirepPhase 3-letter code itself (e.g. 'TXI', 'ENR').
* Chart corner real estate is cramped and the codes are unambiguous
* to anyone reading flight data. Unknown codes pass through as-is.
*/

View File

@ -8,9 +8,9 @@ use App\Contracts\Service;
use App\Enums\AcarsType;
use App\Enums\AircraftState;
use App\Enums\FlightType;
use App\Enums\PirepPhase;
use App\Enums\PirepSource;
use App\Enums\PirepState;
use App\Enums\PirepStatus;
use App\Events\PirepAccepted;
use App\Events\PirepCancelled;
use App\Events\PirepFiled;
@ -35,6 +35,7 @@ use App\Models\Pirep;
use App\Models\PirepComment;
use App\Models\PirepFare;
use App\Models\PirepFieldValue;
use App\Models\PirepPosition;
use App\Models\SimBrief;
use App\Models\User;
use App\Notifications\Messages\Broadcast\PirepDiverted;
@ -74,7 +75,7 @@ class PirepService extends Service
$attrs['state'] = PirepState::IN_PROGRESS;
if (!array_key_exists('status', $attrs)) {
$attrs['status'] = PirepStatus::INITIATED;
$attrs['status'] = PirepPhase::INITIATED;
}
// Default to a scheduled passenger flight
@ -162,7 +163,7 @@ class PirepService extends Service
->delete();
}
$pirep->status = PirepStatus::INITIATED;
$pirep->status = PirepPhase::INITIATED;
$pirep->save();
$pirep->refresh();
@ -179,11 +180,44 @@ class PirepService extends Service
$this->updateCustomFields($pirep->id, $fields);
$this->fareSvc->saveToPirep($pirep, $fares);
$this->openPositionRow($pirep);
event(new PirepPrefiled($pirep));
return $pirep;
}
/**
* Put a prefiled flight on the map, parked at its departure gate. Real
* coordinates rather than nulls, so the read path needs no null check.
* updateOrCreate because prefiling can land on a reused duplicate leg.
*/
private function openPositionRow(Pirep $pirep): void
{
$airport = $pirep->dpt_airport;
PirepPosition::updateOrCreate(
['pirep_id' => $pirep->id],
[
'user_id' => $pirep->user_id,
// Not `pireps`.`status`, which is INITIATED here. Phase and state
// are allowed to disagree.
'phase' => PirepPhase::SCHEDULED,
'lat' => $airport->lat ?? 0,
'lon' => $airport->lon ?? 0,
'heading' => 0,
'distance' => 0,
'altitude_agl' => 0,
'altitude_msl' => 0,
'vs' => 0,
'gs' => 0,
'ias' => 0,
'flight_time' => 0,
'fuel_used' => 0,
]
);
}
/**
* Create a new PIREP with some given fields
*/
@ -211,7 +245,7 @@ class PirepService extends Service
$pirep->submitted_at = Carbon::now('UTC');
}
$pirep->status = PirepStatus::ARRIVED;
$pirep->status = PirepPhase::ARRIVED;
// Copy some fields over from Flight/SimBrief if we have it
if ($pirep->flight) {
@ -273,7 +307,7 @@ class PirepService extends Service
}
$attrs['state'] = PirepState::PENDING;
$attrs['status'] = PirepStatus::ARRIVED;
$attrs['status'] = PirepPhase::ARRIVED;
$attrs['submitted_at'] = Carbon::now('UTC');
$pirep->update($attrs);
@ -480,10 +514,14 @@ class PirepService extends Service
$pirep->update([
'state' => PirepState::CANCELLED,
'status' => PirepStatus::CANCELLED,
'status' => PirepPhase::CANCELLED,
]);
$pirep->refresh();
// Synchronous, not left to PirepPositionExpiration: the pilot explicitly
// ended this flight and would watch it linger for five minutes otherwise.
PirepPosition::where('pirep_id', $pirep->id)->delete();
event(new PirepCancelled($pirep));
return $pirep;
@ -499,6 +537,7 @@ class PirepService extends Service
* pirep_comments
* pirep_fares
* pirep_field_values
* pirep_positions
* simbrief
*/
public function delete(Pirep $pirep): void
@ -513,6 +552,12 @@ class PirepService extends Service
$this->pirepFinanceSvc->deleteFinancesForPirep($pirep);
$w = ['pirep_id' => $pirep->id];
// Listed above since this method was written and never actually
// deleted. The FK covers it too, but SQLite can't express that one.
Acars::where($w)->delete();
PirepPosition::where($w)->delete();
PirepComment::where($w)->forceDelete();
PirepFare::where($w)->forceDelete();
PirepFieldValue::where($w)->forceDelete();

View File

@ -26,10 +26,11 @@ class LiveMap extends Widget
{
$geoSvc = app(GeoService::class);
$pireps = Pirep::activeFlights(setting('acars.live_time', 0))->get();
// Same source as the API endpoints; this widget bypasses them.
$pireps = Pirep::onLiveMap()->get();
$positions = $geoSvc->getFeatureForLiveFlights($pireps);
$center_coords = setting('acars.center_coords', '0,0');
$center_coords = setting('livemap.center_coords', '0,0');
$center_coords = array_map(fn ($c): float => (float) trim($c), explode(',', $center_coords));
return view('widgets.live_map', [
@ -37,7 +38,7 @@ class LiveMap extends Widget
'pireps' => $pireps,
'positions' => $positions,
'center' => $center_coords,
'zoom' => setting('acars.default_zoom', 5),
'zoom' => setting('livemap.default_zoom', 5),
]);
}
}

View File

@ -48,15 +48,23 @@ case "$driver" in
;;
esac
if ! docker inspect "$container" >/dev/null 2>&1; then
# On .State.Running, not on `docker inspect` succeeding: a container that
# exists but is stopped inspects just fine, and the raw "cannot exec in a
# stopped container" from the first docker exec is not the help this prints.
if [ "$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)" != "true" ]; then
echo "container $container is not running -- start it with:" >&2
echo " docker compose -f compose.test.yml up -d $driver" >&2
exit 1
fi
# Fires on success, failure and interrupt, so the scratch database does not
# outlive the run.
trap cleanup EXIT INT TERM
# EXIT alone owns the cleanup, so it runs exactly once -- listing INT and TERM
# alongside it would fire the handler on the signal and again on the way out.
# The signal traps just exit with the conventional 128+signo, which is what
# gets the EXIT trap there. An exit code the script does not set itself is
# untouched by this, so pest's status still propagates.
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
create
vendor/bin/pest -c "phpunit.$driver.xml" "$@"

View File

@ -183,15 +183,23 @@ return [
* Subfleet resolution limits.
*
* `inherited_list_limit` caps how many bundle-inherited subfleets the
* `Flight::withAccessibleSubfleets()` list scope loads per bundle. A list
* page fans a bundle's defaults out across every flight on it, so an
* uncapped bundle multiplies: 100 subfleets over a 100-flight page is 10k
* hydrated rows. Single-flight resolution
* (`Flight::accessibleSubfleetsFor()`) is not capped, so a list row can show
* fewer subfleets than the flight's own page.
* `Flight::withAccessibleSubfleets()` list scope loads per bundle.
*
* It bounds hydrated PHP objects, not queries the scope runs a constant
* number of queries whatever this is set to, and however many distinct
* bundles a page spans. What scales is the per-flight copy the scope makes
* of its bundle's subfleets (needed so one flight's fare overrides cannot
* leak onto another on the same page): roughly `page size x limit` Subfleet
* models, plus their fares. Page size is capped by `pagination.max` above,
* so at the default 25 a full 100-flight page tops out around 2,500.
*
* A bundle trimmed by this cap logs one `debug` line per request naming the
* bundle, its accessible subfleet count and the cap. Single-flight
* resolution (`Flight::accessibleSubfleetsFor()`) is not capped, so a list
* row can show fewer subfleets than the flight's own page.
*/
'subfleets' => [
'inherited_list_limit' => env('PHPVMS_INHERITED_SUBFLEET_LIMIT', 5),
'inherited_list_limit' => env('PHPVMS_INHERITED_SUBFLEET_LIMIT', 25),
],
/**

View File

@ -5,9 +5,9 @@
namespace Database\Factories;
use App\Contracts\Factory;
use App\Enums\PirepPhase;
use App\Enums\PirepSource;
use App\Enums\PirepState;
use App\Enums\PirepStatus;
use App\Models\Aircraft;
use App\Models\Airline;
use App\Models\Flight;
@ -71,7 +71,7 @@ class PirepFactory extends Factory
),
'source_name' => 'TestFactory',
'state' => PirepState::PENDING,
'status' => PirepStatus::SCHEDULED,
'status' => PirepPhase::SCHEDULED,
'submitted_at' => Carbon::now('UTC')->toDateTimeString(),
'created_at' => Carbon::now('UTC')->toDateTimeString(),
'updated_at' => fn (array $pirep) => $pirep['created_at'],

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/** @noinspection PhpIllegalPsrClassPathInspection */
namespace Database\Factories;
use App\Contracts\Factory;
use App\Enums\PirepPhase;
use App\Models\Pirep;
use App\Models\PirepPosition;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<PirepPosition>
*/
class PirepPositionFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = PirepPosition::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
$pirep = Pirep::factory();
return [
'pirep_id' => $pirep,
'user_id' => fn (array $attrs): int => Pirep::find($attrs['pirep_id'])->user_id,
'phase' => PirepPhase::ENROUTE,
'lat' => $this->faker->latitude(),
'lon' => $this->faker->longitude(),
'heading' => $this->faker->numberBetween(0, 359),
'distance' => $this->faker->randomFloat(2, 0, 3000),
'altitude_agl' => $this->faker->numberBetween(0, 38000),
'altitude_msl' => $this->faker->numberBetween(0, 38000),
'vs' => $this->faker->numberBetween(-3000, 3000),
'gs' => $this->faker->numberBetween(0, 550),
'ias' => $this->faker->numberBetween(0, 350),
'flight_time' => $this->faker->numberBetween(0, 600),
'fuel_used' => $this->faker->randomFloat(2, 0, 20000),
];
}
}

View File

@ -0,0 +1,65 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class() extends Migration
{
/**
* One row per PIREP holding its last-known position. Presence of a row is map
* membership. Column names follow `acars` and `pireps`, not the wire protocol,
* so units come from DistanceCast/FuelCast. Everything but the timestamps is NOT NULL.
*/
public function up(): void
{
if (Schema::hasTable('pirep_positions')) {
return;
}
Schema::create('pirep_positions', function (Blueprint $table): void {
// Matching `pireps`: MySQL rejects an FK across mismatched collations.
$table->collation = 'utf8mb4_unicode_ci';
$table->charset = 'utf8mb4';
// The parent key is the primary key - one row per PIREP is the point.
$table->string('pirep_id', 36)->primary();
// Denormalised for pilot-scoped queries. Safe while a PIREP's owner
// cannot change.
$table->unsignedInteger('user_id')->index();
// PirepPhase, same storage as `pireps`.`status`.
$table->string('phase', 3)->default('SCH');
$table->decimal('lat', 10, 5)->default(0);
$table->decimal('lon', 11, 5)->default(0);
$table->unsignedSmallInteger('heading')->default(0);
// Plain DOUBLE: UNSIGNED is deprecated in MySQL 8.0.17, and `vs` is signed.
$table->double('distance')->default(0);
$table->double('altitude_agl')->default(0);
$table->double('altitude_msl')->default(0);
$table->double('vs')->default(0);
$table->unsignedInteger('gs')->default(0);
$table->unsignedInteger('ias')->default(0);
// Minutes, matching `pireps`.`flight_time`.
$table->unsignedInteger('flight_time')->default(0);
$table->decimal('fuel_used')->default(0);
// `updated_at` is the liveness clock: position batches only, unlike
// `pireps`.`updated_at`, which any write bumps.
$table->timestamps();
// At create time, so it lands on SQLite too - unlike the `acars` one.
$table->foreign('pirep_id')->references('id')->on('pireps')->cascadeOnDelete();
});
}
public function down(): void
{
Schema::dropIfExists('pirep_positions');
}
};

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class() extends Migration
{
/**
* `acars`.`distance` was an unsigned integer and can't hold a fraction, so it
* disagreed with `pirep_positions`.`distance` about the same quantity. Lossless:
* unsigned int tops out well inside exact double range.
*/
public function up(): void
{
if (!Schema::hasTable('acars')) {
return;
}
Schema::table('acars', function (Blueprint $table): void {
$table->double('distance')->nullable()->change();
});
}
/** Back to unsigned integer. Restores the schema, not the lost precision. */
public function down(): void
{
if (!Schema::hasTable('acars')) {
return;
}
Schema::table('acars', function (Blueprint $table): void {
$table->unsignedInteger('distance')->nullable()->change();
});
}
};

View File

@ -0,0 +1,140 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
return new class() extends Migration
{
/**
* PERMANENTLY DELETES ORPHANED `acars` ROWS. `down()` drops the constraint
* and does not bring them back.
*
* PirepService::delete() has claimed to remove `acars` since it was written and
* never did, so most installs carry rows whose PIREP is gone, and the database
* rejects the constraint while they exist.
*/
private const int CHUNK = 10_000;
public function up(): void
{
if (!Schema::hasTable('acars') || !Schema::hasTable('pireps')) {
return;
}
if ($this->hasForeignKey()) {
return;
}
$driver = Schema::getConnection()->getDriverName();
// Before the sweep, not just the constraint: the anti-join across two
// collations is itself error 1267 on MySQL.
if (in_array($driver, ['mysql', 'mariadb'], true)) {
$this->alignPirepIdCollation();
}
$this->purgeOrphans();
// SQLite has no ALTER TABLE ADD CONSTRAINT, and Laravel compiles foreign()
// against an existing table to nothing, so skip it explicitly. Cleanup there
// rests on PirepService::delete().
if ($driver === 'sqlite') {
Log::info('acars: skipping the pireps foreign key, SQLite cannot add one to an existing table.');
return;
}
Schema::table('acars', function (Blueprint $table): void {
$table->foreign('pirep_id')->references('id')->on('pireps')->cascadeOnDelete();
});
}
/** Drops the constraint. Does NOT restore the rows up() purged. */
public function down(): void
{
if (!Schema::hasTable('acars') || !$this->hasForeignKey()) {
return;
}
Schema::table('acars', function (Blueprint $table): void {
$table->dropForeign(['pirep_id']);
});
}
/** So a re-run against an already-migrated database is a no-op. */
private function hasForeignKey(): bool
{
return collect(Schema::getForeignKeys('acars'))
->flatMap(fn (array $key): array => $key['columns'])
->contains('pirep_id');
}
/**
* Batched because `acars` is the largest table in a mature install and an
* unbounded DELETE can hold locks for minutes. Selected then deleted by primary
* key, since a multi-table DELETE takes no LIMIT on MySQL.
*/
private function purgeOrphans(): void
{
$orphans = fn () => DB::table('acars')
->whereNotNull('acars.pirep_id')
->whereNotExists(fn ($query) => $query->select(DB::raw(1))
->from('pireps')
->whereColumn('pireps.id', 'acars.pirep_id'));
$total = $orphans()->count();
if ($total === 0) {
return;
}
Log::warning('acars: permanently deleting '.$total.' orphaned row(s) whose PIREP no longer exists. This cannot be undone.');
$deleted = 0;
while (true) {
$ids = $orphans()->limit(self::CHUNK)->pluck('acars.id');
if ($ids->isEmpty()) {
break;
}
$deleted += DB::table('acars')->whereIn('id', $ids)->delete();
}
Log::info('acars: purged '.$deleted.' orphaned row(s).');
}
/**
* `pireps`.`id` pins utf8mb4_unicode_ci, but an upgraded install may have picked
* up the server default for `acars`. Restate the child to match the parent.
*/
private function alignPirepIdCollation(): void
{
$connection = Schema::getConnection();
$prefix = $connection->getTablePrefix();
$sql = 'select CHARACTER_SET_NAME as charset, COLLATION_NAME as collation, IS_NULLABLE as nullable
from information_schema.COLUMNS
where TABLE_SCHEMA = ? and TABLE_NAME = ? and COLUMN_NAME = ?';
$child = DB::selectOne($sql, [$connection->getDatabaseName(), $prefix.'acars', 'pirep_id']);
$parent = DB::selectOne($sql, [$connection->getDatabaseName(), $prefix.'pireps', 'id']);
if ($child === null || $parent === null || $child->collation === $parent->collation) {
return;
}
// Identifiers, not values, so they cannot be bound. From information_schema.
DB::statement(sprintf(
'alter table `%s` modify `pirep_id` varchar(36) character set %s collate %s %s',
$prefix.'acars',
$parent->charset,
$parent->collation,
$child->nullable === 'YES' ? 'null' : 'not null'
));
}
};

View File

@ -0,0 +1,160 @@
<?php
use App\Models\Setting;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class() extends Migration
{
/**
* `acars.live_time` decided two unrelated things. It becomes
* `pireps.tombstone_time` - the reaper's half - keeping hours and 12 so stored
* values aren't reinterpreted. `livemap.live_time` and `livemap.idle_time` take
* the map's half and are new, so the seeder owns them. The rest is a regroup.
*
* @var list<array{old: string, new: string, name: string, group: string, type: string, default: string, description: string}>
*/
private array $renames = [
[
'old' => 'acars.live_time',
'new' => 'pireps.tombstone_time',
'name' => 'Tombstone Time',
'group' => 'pireps',
'type' => 'int',
'default' => '12',
'description' => 'How long an in-progress PIREP that has stopped reporting survives before it is cancelled, in hours. Set to 0 to never cancel a PIREP on account of age',
],
[
'old' => 'acars.center_coords',
'new' => 'livemap.center_coords',
'name' => 'Center Coords',
'group' => 'livemap',
'type' => 'text',
'default' => '30.1945,-97.6699',
'description' => 'Where to center the map; enter as LAT,LON',
],
[
'old' => 'acars.default_zoom',
'new' => 'livemap.default_zoom',
'name' => 'Default Zoom',
'group' => 'livemap',
'type' => 'int',
'default' => '5',
'description' => 'Initial zoom level on the map',
],
[
'old' => 'acars.update_interval',
'new' => 'livemap.update_interval',
'name' => 'Refresh Interval',
'group' => 'livemap',
'type' => 'int',
'default' => '60',
'description' => 'How often the live map updates its data',
],
];
/**
* New here, so reversing removes them rather than renaming them back.
*
* @var list<string>
*/
private array $introduced = [
'livemap.live_time',
'livemap.idle_time',
];
public function up(): void
{
if (!Schema::hasTable('settings')) {
return;
}
foreach ($this->renames as $rename) {
$this->move(
from: $rename['old'],
to: $rename['new'],
name: $rename['name'],
group: $rename['group'],
type: $rename['type'],
default: $rename['default'],
description: $rename['description'],
);
}
}
public function down(): void
{
if (!Schema::hasTable('settings')) {
return;
}
foreach ($this->renames as $rename) {
$this->move(
from: $rename['new'],
to: $rename['old'],
name: $rename['old'] === 'acars.live_time' ? 'Live Time' : $rename['name'],
group: 'acars',
type: $rename['type'],
default: $rename['default'],
description: $rename['old'] === 'acars.live_time'
? 'Age of flights to show on the map in hours. Set to 0 to show only all in-progress flights'
: $rename['description'],
);
}
// No pre-change counterpart to restore to.
foreach ($this->introduced as $key) {
Setting::where('id', Setting::formatKey($key))->delete();
}
}
/**
* Renamed in place, not recreated: `value` is not among the columns written, so
* it survives by construction. The discord migration's copy-when-blank rule would
* never fire here, since every setting moved seeds to a real default.
*
* The seeder runs first, so the destination usually exists. Its placement is
* adopted and the row dropped, leaving upgrades and fresh installs identical
* except for the operator's value. Idempotent: no source, nothing to do.
*/
private function move(
string $from,
string $to,
string $name,
string $group,
string $type,
string $default,
string $description,
): void {
$source = Setting::where('id', Setting::formatKey($from))->first();
if (!$source instanceof Setting) {
return;
}
$target = Setting::where('id', Setting::formatKey($to))->first();
$offset = $target->offset ?? $source->offset;
$order = $target->order ?? $source->order;
$target?->delete();
// Query builder because this rewrites the primary key, and the explicit
// column list is what documents that `value` is not among them.
DB::table('settings')
->where('id', Setting::formatKey($from))
->update([
'id' => Setting::formatKey($to),
'key' => $to,
'name' => $name,
'group' => $group,
'type' => $type,
'options' => '',
'default' => $default,
'description' => $description,
'offset' => $offset,
'order' => $order,
]);
}
};

View File

@ -15,9 +15,9 @@ declare(strict_types=1);
namespace Database\Seeders;
use App\Enums\PirepFieldSource;
use App\Enums\PirepPhase;
use App\Enums\PirepSource;
use App\Enums\PirepState;
use App\Enums\PirepStatus;
use App\Models\Aircraft;
use App\Models\Airport;
use App\Models\Pirep;
@ -29,6 +29,7 @@ use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Override;
use Symfony\Component\Finder\SplFileInfo;
class SampleDataSeeder extends YamlSeeder
@ -39,7 +40,7 @@ class SampleDataSeeder extends YamlSeeder
parent::__construct($this->yamlDbSvc);
}
#[\Override]
#[Override]
public function run(): void
{
$seedPath = database_path('seeders/sample');
@ -172,7 +173,7 @@ class SampleDataSeeder extends YamlSeeder
'source' => 0,
'source_name' => $bulkSeedMarker,
'state' => $state->value,
'status' => PirepStatus::ARRIVED->value,
'status' => PirepPhase::ARRIVED->value,
'notes' => 'Seeded PIREP #'.$flightNum,
'block_off_time' => $blockOffStr,
'block_on_time' => $blockOnStr,
@ -227,7 +228,7 @@ class SampleDataSeeder extends YamlSeeder
'source' => PirepSource::ACARS->value,
'source_name' => 'vmsacars',
'state' => PirepState::ACCEPTED->value,
'status' => PirepStatus::ARRIVED->value,
'status' => PirepPhase::ARRIVED->value,
'block_off_time' => '2026-05-16 14:45:46',
'block_on_time' => '2026-05-16 15:34:45',
'submitted_at' => '2026-05-16 15:35:12',

View File

@ -230,38 +230,47 @@ class SettingsSeeder extends Seeder
'description' => 'The units for temperature',
],
// ACARS
// Live map
[
'key' => 'acars.live_time',
'name' => 'Live Time',
'group' => 'acars',
'value' => '12',
'key' => 'livemap.live_time',
'name' => 'Completed Flight Time',
'group' => 'livemap',
'value' => '30',
'type' => 'int',
'options' => '',
'description' => 'Age of flights to show on the map in hours. Set to 0 to show only all in-progress flights',
'description' => 'How long a flight stays on the map after it stops being in progress, in minutes',
],
[
'key' => 'acars.center_coords',
'key' => 'livemap.idle_time',
'name' => 'Stationary Flight Time',
'group' => 'livemap',
'value' => '60',
'type' => 'int',
'options' => '',
'description' => 'How long a flight that is not moving stays on the map, in minutes. Covers a paused flight and one that has been prefiled but has not yet departed',
],
[
'key' => 'livemap.center_coords',
'name' => 'Center Coords',
'group' => 'acars',
'group' => 'livemap',
'value' => '30.1945,-97.6699',
'type' => 'text',
'options' => '',
'description' => 'Where to center the map; enter as LAT,LON',
],
[
'key' => 'acars.default_zoom',
'key' => 'livemap.default_zoom',
'name' => 'Default Zoom',
'group' => 'acars',
'group' => 'livemap',
'value' => '5',
'type' => 'int',
'options' => '',
'description' => 'Initial zoom level on the map',
],
[
'key' => 'acars.update_interval',
'key' => 'livemap.update_interval',
'name' => 'Refresh Interval',
'group' => 'acars',
'group' => 'livemap',
'value' => '60',
'type' => 'int',
'options' => '',
@ -556,6 +565,15 @@ class SettingsSeeder extends Seeder
'options' => 'true,false',
'description' => 'Enables remaining fuel amounts to be considered for fuel expenses',
],
[
'key' => 'pireps.tombstone_time',
'name' => 'Tombstone Time',
'group' => 'pireps',
'value' => '12',
'type' => 'int',
'options' => '',
'description' => 'How long an in-progress PIREP that has stopped reporting survives before it is cancelled, in hours. Set to 0 to never cancel a PIREP on account of age',
],
[
'key' => 'pireps.delete_cancelled_hours',
'name' => 'Delete cancelled PIREPs',

View File

@ -1,5 +1,80 @@
# Upgrading phpvms
## Unreleased — live map positions
The live map now reads from a new `pirep_positions` table — one row per flight —
instead of resolving the newest `acars` breadcrumb for every flight on every
poll. A row in that table _is_ what puts a flight on the map.
### ⚠ Orphaned ACARS rows are permanently deleted
`PirepService::delete()` has always listed `acars` as a child table it removes,
and never removed it. There was no foreign key either, so **every install that
has ever hard-deleted a PIREP is carrying `acars` rows whose PIREP no longer
exists.** `add_acars_pirep_foreign_key` counts those rows, logs the number, and
deletes them in batches of 10,000 before adding the constraint — the database
rejects the constraint while they exist.
**This cannot be undone.** Rolling the migration back drops the foreign key; it
does not bring the rows back. Take a backup first if you want them.
The rows were already unreachable: nothing joins `acars` to a PIREP that is not
there. Soft-deleted PIREPs are _not_ affected — the PIREP row still exists, so
its telemetry is not orphaned.
On a synthetic 2,000,000-row `acars` table with 1,000,000 orphans the whole
migration took about 45 seconds on MySQL 8. Time scales with your orphan count,
which nothing can predict in advance.
SQLite cannot add a foreign key to an existing table, so the constraint is
skipped there and telemetry cleanup relies on the service layer alone. The
orphan purge and the column widening still apply.
### Behaviour changes you will notice
- **Prefiled flights now appear on the map before they move**, stationary at
their departure airport. Previously a flight was invisible until its first
position report.
- **Completed and paused flights stay on the map for a configured period.**
Previously a completed flight vanished the instant its PIREP left
`IN_PROGRESS`.
- **An administrator editing a PIREP no longer keeps a dead flight on the map.**
Liveness is now measured on the position row, which only position reports
touch.
### Settings
`acars.live_time` did two unrelated jobs. It has been split, and the live map's
display settings have moved out of the ACARS group into a new **Live map** group.
Your configured values are carried across automatically.
| Old key | New key | Unit | Default |
| ----------------------- | ------------------------- | ------- | ------- |
| `acars.live_time` | `pireps.tombstone_time` | hours | 12 |
| — | `livemap.live_time` | minutes | 30 |
| — | `livemap.idle_time` | minutes | 60 |
| `acars.center_coords` | `livemap.center_coords` | | |
| `acars.default_zoom` | `livemap.default_zoom` | | |
| `acars.update_interval` | `livemap.update_interval` | | |
`pireps.tombstone_time` keeps hours and keeps your number — it governs only when
a silent in-progress PIREP is cancelled. The two new settings are in minutes and
govern only the map: how long a finished flight stays drawn, and how long a
flight that is not moving stays drawn (a paused one, or one prefiled and not yet
departed).
### For addon authors
`App\Enums\PirepStatus` is deprecated in favour of `App\Enums\PirepPhase`. It is
a `class_alias`, not a second enum, so `PirepStatus::TAXI` and
`PirepPhase::TAXI` are the same case — identity comparison, `instanceof` and
existing model casts all keep working, and no stored value changes. No database
column was renamed; `pireps`.`status` and `acars`.`status` are untouched.
`Pirep::position()` now returns a `PirepPosition`, not an `Acars`.
`Pirep::scopeActiveFlights()` is gone: use `Pirep::onLiveMap()` for the map, or
`Pirep::silentInProgress($hours)` for the reaper's meaning.
## Unreleased — Laravel Passport (OAuth2) API authentication
The API can now be authenticated with OAuth2 (Laravel Passport) in addition to

View File

@ -18,10 +18,10 @@ import "chartjs-adapter-date-fns";
Chart.register(annotationPlugin);
// Phase shading keyed off ACARS sample `status` (PirepStatus enum value).
// Phase shading keyed off ACARS sample `status` (PirepPhase enum value).
// Codes that don't appear here render unshaded — keeps unknown / SCH from
// painting the whole chart gray. Labels come from the server payload
// (`phase.label`) so translations stay in PHP land (PirepStatus::getLabel).
// (`phase.label`) so translations stay in PHP land (PirepPhase::getLabel).
// Low-alpha backgrounds so the data line stays visually dominant.
const PHASE_COLORS = {
// Ground / pre-flight

View File

@ -40,6 +40,8 @@ return [
'click_update_to_run' => 'Click "Update" to run the script.',
'update' => 'Update',
'migrations_not_completed' => 'You still have :count migrations to run. Please try again...',
'pause' => 'Pause',
'resume' => 'Resume',
'user_and_airline_setup' => 'User & Airline Setup',
'legacy_importer' => 'phpvms v5 Legacy Importer',
'super_admin_information' => 'Super Admin User Information',

View File

@ -43,7 +43,7 @@ return [
'user_registered' => 'New User Registered: :ident',
/*
* Verbs completing "Flight <ident> ...", keyed by PirepStatus value.
* Verbs completing "Flight <ident> ...", keyed by PirepPhase value.
*/
'status' => [
'INI' => 'is initialized',

View File

@ -0,0 +1,87 @@
{{--
Drives the installer's "advance to the next step on its own" countdown.
The two controls it talks to the wizard's Pause and Next buttons are
rendered in the wizard footer, which sits outside this step's schema, so the
shared countdown state lives in a global Alpine store rather than an x-data
scope on a common ancestor.
--}}
<script>
(() => {
if (window.installerAutoAdvanceRegistered) {
return
}
window.installerAutoAdvanceRegistered = true
const store = () => window.Alpine?.store('installerAutoAdvance')
const register = () => {
window.Alpine.store('installerAutoAdvance', {
active: false,
paused: false,
remaining: 0,
timer: null,
start(seconds) {
if (this.active) {
return
}
this.active = true
this.paused = false
this.remaining = seconds
this.timer = setInterval(() => {
if (this.paused) {
return
}
this.remaining--
if (this.remaining <= 0) {
this.advance()
}
}, 1000)
requestAnimationFrame(() => {
document
.querySelector('.fi-sc-wizard-footer')
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
},
toggle() {
this.paused = ! this.paused
},
advance() {
this.stop()
document.querySelector('[data-installer-next]')?.click()
},
stop() {
clearInterval(this.timer)
this.timer = null
this.active = false
},
})
}
document.addEventListener('alpine:init', register)
if (window.Alpine) {
register()
}
window.addEventListener('installer-migrations-complete', (event) => {
store()?.start(event.detail?.seconds ?? {{ $seconds }})
})
// Covers both the countdown firing and the user clicking Next early, so
// the paused countdown can't linger into the following step.
window.addEventListener('next-wizard-step', () => store()?.stop())
})()
</script>

View File

@ -177,15 +177,15 @@
<span>@lang('common.status')</span>
@php
$statusClass = 'bg-info';
if ($pirep->status === PirepStatus::SCHEDULED) {
if ($pirep->status === PirepPhase::SCHEDULED) {
$statusClass = 'bg-secondary';
} elseif ($pirep->status === PirepStatus::ENROUTE) {
} elseif ($pirep->status === PirepPhase::ENROUTE) {
$statusClass = 'bg-primary';
} elseif ($pirep->status === PirepStatus::ARRIVED) {
} elseif ($pirep->status === PirepPhase::ARRIVED) {
$statusClass = 'bg-success';
} elseif ($pirep->status === PirepStatus::CANCELLED) {
} elseif ($pirep->status === PirepPhase::CANCELLED) {
$statusClass = 'bg-danger';
} elseif ($pirep->status === PirepStatus::DIVERTED) {
} elseif ($pirep->status === PirepPhase::DIVERTED) {
$statusClass = 'bg-warning';
}
@endphp

View File

@ -134,7 +134,7 @@ and being mindful of the rivets bindings
center: ['{{ $center[0] }}', '{{ $center[1] }}'],
zoom: '{{ $zoom }}',
aircraft_icon: '{!! public_asset('/assets/img/acars/aircraft.png') !!}',
refresh_interval: {{ setting('acars.update_interval', 60) }},
refresh_interval: {{ setting('livemap.update_interval', 60) }},
units: '{{ setting('units.distance') }}',
flown_route_color: '#067ec1',
leafletOptions: {

View File

@ -1,7 +1,7 @@
<?php
use App\Enums\PirepPhase;
use App\Enums\PirepState;
use App\Enums\PirepStatus;
use App\Exceptions\AircraftNotAtAirport;
use App\Exceptions\UserNotAtAirport;
use App\Models\Acars;
@ -366,7 +366,7 @@ it('can receive acars updates', function (): void {
'level' => 38000,
'planned_distance' => 400,
'planned_flight_time' => 120,
'status' => PirepStatus::BOARDING->value,
'status' => PirepPhase::BOARDING->value,
'route' => 'POINTA POINTB',
'source_name' => 'AcarsTest::testAcarsUpdates',
'fields' => [
@ -388,7 +388,7 @@ it('can receive acars updates', function (): void {
// Check the PIREP state and status
$pirep = getPirepFromApi($pirep_id);
expect(PirepState::from($pirep['state']))->toEqual(PirepState::IN_PROGRESS)
->and(PirepStatus::from($pirep['status']))->toEqual(PirepStatus::INITIATED)
->and(PirepPhase::from($pirep['status']))->toEqual(PirepPhase::INITIATED)
->and($pirep)->toHaveKey('fields')
->and($pirep['fields']['custom_field'])->toEqual('custom_value')
->and($pirep['planned_distance']['nmi'])->toEqual($pirep_create['planned_distance'])
@ -405,7 +405,7 @@ it('can receive acars updates', function (): void {
$this->post($uri, [
'flight_time' => 60,
'distance' => 20,
'status' => PirepStatus::AIRBORNE->value,
'status' => PirepPhase::AIRBORNE->value,
'fields' => [
'custom_field' => 'custom_value_changed',
],
@ -443,7 +443,7 @@ it('can receive acars updates', function (): void {
// Make sure PIREP state moved into ENROUTE
$pirep = getPirepFromApi($pirep_id);
expect(PirepState::from($pirep['state']))->toEqual(PirepState::IN_PROGRESS)
->and(PirepStatus::from($pirep['status']))->toEqual(PirepStatus::AIRBORNE);
->and(PirepPhase::from($pirep['status']))->toEqual(PirepPhase::AIRBORNE);
$response = $this->get($uri);
$response->assertStatus(200);
@ -542,7 +542,7 @@ test('multiple altitudes', function (): void {
'level' => 38000,
'planned_distance' => 400,
'planned_flight_time' => 120,
'status' => PirepStatus::BOARDING->value,
'status' => PirepPhase::BOARDING->value,
'route' => 'POINTA POINTB',
'source_name' => 'AcarsTest::testAcarsUpdates',
'fields' => [

View File

@ -10,24 +10,20 @@ use App\Models\Subfleet;
use App\Models\User;
/**
* Regression: Flight::accessibleSubfleetsFor applies the access filter BEFORE
* testing whether the flight has pinned subfleets (app/Models/Flight.php:557-565):
* Regression cover for the ordering of eligibility against access.
*
* $pinned = Subfleet::query()
* ->allowedFor($user) // <-- access filter first
* ->whereHas('flights', ...)
* ->get();
* `Flight::accessibleSubfleetsFor()` used to narrow by `allowedFor($user)`
* before asking whether the flight had any pins, then read an empty result as
* "this flight pins nothing" and fall through to the fallback. A pilot
* unqualified for a flight's designated aircraft was therefore offered the
* entire rest of the fleet, on a flight that had been explicitly restricted
* away from them the failure ran in the widening direction, which is the
* dangerous one.
*
* if ($pinned->isNotEmpty()) { return $pinned; }
* // ...otherwise fall through to every subfleet the user can access
*
* So a pilot who is not qualified for a flight's designated aircraft does not
* get an empty list they fall through to the unbounded fallback and are
* offered the whole rest of the fleet, on a flight that was explicitly
* restricted away from them.
*
* Correct behaviour: eligibility is resolved first, access filtering second.
* A flight with pins the pilot cannot use resolves to nothing.
* It now decides which rung applies from configuration alone and narrows the
* winning rung by access afterwards, so a rung narrowed to nothing stays
* empty. These tests hold that line: each fails if the two steps are swapped
* back.
*/
test('a pilot unqualified for a flights only pinned subfleet is offered nothing', function (): void {
updateSetting('pireps.restrict_aircraft_to_rank', true);

View File

@ -16,6 +16,7 @@ use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Bundle-level subfleet defaults as seen through the LIST path the
@ -296,7 +297,9 @@ test('an unusable configured cap falls back instead of emptying the rung', funct
$user = User::factory()->create(['rank_id' => $rank->id, 'airline_id' => $airline->id]);
$bundle = FlightBundle::factory()->create();
$subfleets = collect(range(1, 6))
// One more than the effective cap, so a fallback that resolved to the wrong
// number — not just to zero — is caught too.
$subfleets = collect(range(1, $expected + 1))
->map(fn (int $i): Subfleet => listableSubfleet($airline, 'Sub '.$i, [$rank]));
$bundle->subfleets()->attach($subfleets->pluck('id')->all());
@ -305,18 +308,194 @@ test('an unusable configured cap falls back instead of emptying the rung', funct
// `limit(0)` compiles to `row_num <= 0`, which matches nothing: read
// without a default, an absent or empty setting does not fall back, it
// switches bundle inheritance off for the whole site and does it quietly.
// The named default and the `?:` fallback are two separate literals in the
// scope and both have to say 25 — 'key absent' exercises the first, the
// rest exercise the second.
expect(throughScope($flight, $user)->subfleets->pluck('id')->all())
->toBe($subfleets->pluck('id')->sort()->values()->take($expected)->all());
})->with([
// A config cache built before this release carries no key at all.
'key absent' => [[], 5],
'null' => [['inherited_list_limit' => null], 5],
'key absent' => [[], 25],
'null' => [['inherited_list_limit' => null], 25],
// `PHPVMS_INHERITED_SUBFLEET_LIMIT=` in .env reads back as ''.
'empty string' => [['inherited_list_limit' => ''], 5],
'zero' => [['inherited_list_limit' => 0], 5],
'empty string' => [['inherited_list_limit' => ''], 25],
'zero' => [['inherited_list_limit' => 0], 25],
'negative' => [['inherited_list_limit' => -1], 1],
]);
test('the shipped default cap is 25', function (): void {
// The dataset above proves the scope's own fallbacks; this pins the value
// config/phpvms.php actually ships, which is what an untouched install gets.
expect(config('phpvms.subfleets.inherited_list_limit'))->toBe(25);
});
/**
* The line `withAccessibleSubfleets` emits for a truncated bundle. Stated once
* so a message change cannot pass by being wrong in the test as well.
*/
function truncationLine(FlightBundle $bundle, int $accessible, int $limit): string
{
return 'Flight list: bundle '.$bundle->id.' has '.$accessible
.' accessible subfleets, showing the first '.$limit
.' (phpvms.subfleets.inherited_list_limit)';
}
test('a bundle inside the cap says nothing', function (): void {
config(['phpvms.subfleets.inherited_list_limit' => 3]);
$airline = Airline::factory()->create();
$rank = Rank::factory()->create(['name' => 'Line']);
$user = User::factory()->create(['rank_id' => $rank->id, 'airline_id' => $airline->id]);
$bundle = FlightBundle::factory()->create();
$subfleets = collect(range(1, 3))
->map(fn (int $i): Subfleet => listableSubfleet($airline, 'Sub '.$i, [$rank]));
$bundle->subfleets()->attach($subfleets->pluck('id')->all());
$flight = Flight::factory()->create(['airline_id' => $airline->id, 'bundle_id' => $bundle->id]);
Log::spy();
// Exactly at the cap is the boundary that matters: nothing was dropped, so
// a `>=` in the scope would cry wolf on every correctly configured bundle
// that happens to sit on the limit.
expect(throughScope($flight, $user)->subfleets)->toHaveCount(3);
Log::shouldNotHaveReceived('debug');
});
test('a bundle over the cap says so once, naming the bundle, the count and the cap', function (): void {
config(['phpvms.subfleets.inherited_list_limit' => 2]);
$airline = Airline::factory()->create();
$junior = Rank::factory()->create(['name' => 'Junior']);
$senior = Rank::factory()->create(['name' => 'Senior']);
$user = User::factory()->create(['rank_id' => $junior->id, 'airline_id' => $airline->id]);
$bundle = FlightBundle::factory()->create();
$reachable = collect(range(1, 4))
->map(fn (int $i): Subfleet => listableSubfleet($airline, 'Sub '.$i, [$junior, $senior]));
// Configured on the bundle but unflyable by this pilot. It is not part of
// what was trimmed from his list, so counting it would report a truncation
// one wider than the one he is actually seeing.
$unreachable = listableSubfleet($airline, 'Senior Only', [$senior]);
$bundle->subfleets()->attach($reachable->pluck('id')->push($unreachable->id)->all());
$flight = Flight::factory()->create(['airline_id' => $airline->id, 'bundle_id' => $bundle->id]);
Log::spy();
expect(throughScope($flight, $user)->subfleets->pluck('id')->all())
->toBe($reachable->pluck('id')->sort()->values()->take(2)->all());
// The dropped rows are the highest ids — the subfleet an admin just added
// and is now hunting for. Without the count the message cannot say how much
// is missing, and without the cap it cannot say which setting to raise.
Log::shouldHaveReceived('debug')->once()->with(truncationLine($bundle, 4, 2));
});
test('a hundred flights on one over capped bundle log one line, not a hundred', function (): void {
config(['phpvms.subfleets.inherited_list_limit' => 2]);
$airline = Airline::factory()->create();
$rank = Rank::factory()->create(['name' => 'Line']);
$user = User::factory()->create(['rank_id' => $rank->id, 'airline_id' => $airline->id]);
$bundle = FlightBundle::factory()->create();
$subfleets = collect(range(1, 5))
->map(fn (int $i): Subfleet => listableSubfleet($airline, 'Sub '.$i, [$rank]));
$bundle->subfleets()->attach($subfleets->pluck('id')->all());
$flights = Flight::factory()->count(100)->create([
'airline_id' => $airline->id,
'bundle_id' => $bundle->id,
]);
Log::spy();
$got = Flight::query()
->whereIn('id', $flights->pluck('id')->all())
->withAccessibleSubfleets($user)
->get();
expect($got)->toHaveCount(100)
->and($got->every(fn (Flight $flight): bool => $flight->subfleets->count() === 2))->toBeTrue();
// One hydrated Bundle backs all 100 rows, so the naive "log where you
// truncate" reading of this turns a single misconfiguration into a
// hundred-line burst on every page load. Dedupe is inside the afterQuery
// callback, which sees the whole page at once.
Log::shouldHaveReceived('debug')->once()->with(truncationLine($bundle, 5, 2));
});
test('applying the scope twice still logs the truncation once', function (): void {
config(['phpvms.subfleets.inherited_list_limit' => 1]);
$airline = Airline::factory()->create();
$rank = Rank::factory()->create(['name' => 'Line']);
$user = User::factory()->create(['rank_id' => $rank->id, 'airline_id' => $airline->id]);
$bundle = FlightBundle::factory()->create();
$subfleets = collect(range(1, 3))
->map(fn (int $i): Subfleet => listableSubfleet($airline, 'Sub '.$i, [$rank]));
$bundle->subfleets()->attach($subfleets->pluck('id')->all());
$flight = Flight::factory()->create(['airline_id' => $airline->id, 'bundle_id' => $bundle->id]);
Log::spy();
// Two stacked afterQuery callbacks each walk the page with their own dedupe
// set. The count being consumed on the first pass is what keeps the second
// quiet, exactly as the consumed has-pins probe does above.
Flight::query()
->whereKey($flight->id)
->withAccessibleSubfleets($user)
->withAccessibleSubfleets($user)
->get();
Log::shouldHaveReceived('debug')->once()->with(truncationLine($bundle, 3, 1));
});
test('the truncation count never reaches a bundle the caller kept', function (): void {
config(['phpvms.subfleets.inherited_list_limit' => 2]);
$airline = Airline::factory()->create();
$rank = Rank::factory()->create(['name' => 'Line']);
$user = User::factory()->create(['rank_id' => $rank->id, 'airline_id' => $airline->id]);
$bundle = FlightBundle::factory()->create();
$subfleets = collect(range(1, 4))
->map(fn (int $i): Subfleet => listableSubfleet($airline, 'Sub '.$i, [$rank]));
$bundle->subfleets()->attach($subfleets->pluck('id')->all());
$flights = collect(range(1, 2))->map(fn (): Flight => Flight::factory()->create([
'airline_id' => $airline->id,
'bundle_id' => $bundle->id,
]));
$got = Flight::query()
->with('bundle')
->whereIn('id', $flights->pluck('id')->all())
->withAccessibleSubfleets($user)
->get();
// The count is a `withCount` subselect, so it lands as a plain attribute on
// the bundle and would serialise straight through `parent::toArray()` on
// any endpoint that publishes the bundle. It is this scope's working state,
// and gets stripped alongside the has-pins probe — but the bundle survives,
// because the caller asked for it.
foreach ($got as $flight) {
expect($flight->relationLoaded('bundle'))->toBeTrue()
->and($flight->bundle->getAttributes())->not->toHaveKey('subfleets_count')
->and($flight->bundle->toArray())->not->toHaveKey('subfleets_count');
}
});
test('the per bundle cap orders inside its window function', function (): void {
$airline = Airline::factory()->create();
$rank = Rank::factory()->create(['name' => 'Line']);
@ -356,6 +535,67 @@ test('the per bundle cap orders inside its window function', function (): void {
);
});
test('the scope costs a fixed number of queries however many flights and bundles a page spans', function (): void {
$airline = Airline::factory()->create();
$rank = Rank::factory()->create(['name' => 'Line']);
$user = User::factory()->create(['rank_id' => $rank->id, 'airline_id' => $airline->id]);
$page = function (int $bundleCount, int $flightsPerBundle) use ($airline, $rank): array {
$ids = [];
foreach (range(1, $bundleCount) as $b) {
$bundle = FlightBundle::factory()->create();
$bundle->subfleets()->attach(
collect(range(1, 4))
->map(fn (int $i): Subfleet => listableSubfleet($airline, 'B'.$b.' Sub '.$i, [$rank]))
->pluck('id')
->all()
);
$ids = array_merge($ids, Flight::factory()->count($flightsPerBundle)->create([
'airline_id' => $airline->id,
'bundle_id' => $bundle->id,
])->pluck('id')->all());
}
return $ids;
};
$count = function (array $ids) use ($user): int {
$queries = 0;
DB::listen(function () use (&$queries): void {
$queries++;
});
Flight::query()->whereIn('id', $ids)->withAccessibleSubfleets($user)->get();
// Laravel keeps every listener registered for the life of the
// connection, so the second call would otherwise be counted by both.
DB::getEventDispatcher()->forget(QueryExecuted::class);
return $queries;
};
$small = $count($page(1, 2));
$large = $count($page(6, 15));
// Seven statements plus the four `settings` reads `Aircraft::allowedFor`
// makes: the flights (with the has-pins probe folded in), the bundles, the
// capped bundle subfleets, their aircraft, those aircraft's bids, the
// subfleets' fares, and the flights' own pins.
//
// The accessible-subfleet count that drives truncation logging is a
// correlated subselect on the bundles statement, not a statement of its
// own, so it is inside this number rather than added to it — dropping the
// `withCount` and re-running gives 11 as well.
//
// The constant is the whole point of the scope: it must not move with page
// size or with how many distinct bundles the page touches.
expect($small)->toBe(11)
->and($large)->toBe(11);
});
test('inheritance resolves through a nested eager load', function (): void {
$airline = Airline::factory()->create();
$rank = Rank::factory()->create(['name' => 'Line']);
@ -494,7 +734,11 @@ test('the flight api response shape is unchanged by inheritance', function (): v
foreach ([$inherits->id, $pins->id] as $id) {
expect(array_keys($rows[$id]))
->not->toContain('bundle')
->and(array_keys($rows[$id]))->not->toContain('has_live_pins');
->and(array_keys($rows[$id]))->not->toContain('has_live_pins')
// The truncation count is loaded onto the bundle, so it leaves by
// the same door the bundle does — but it is a separate unset, and
// an endpoint that publishes the bundle would carry it out.
->and(array_keys($rows[$id]))->not->toContain('subfleets_count');
}
$inheritedKeys = array_keys($rows[$inherits->id]);

View File

@ -24,7 +24,7 @@ function createInProgressPirep($subtractTime): Pirep
}
test('expired flight not being removed', function (): void {
updateSetting('acars.live_time', 0);
updateSetting('pireps.tombstone_time', 0);
$pirep = createInProgressPirep(2);
/** @var RemoveExpiredLiveFlights $eventListener */
@ -36,7 +36,7 @@ test('expired flight not being removed', function (): void {
});
test('expired flight should not be removed', function (): void {
updateSetting('acars.live_time', 3);
updateSetting('pireps.tombstone_time', 3);
$pirep = createInProgressPirep(2);
/** @var RemoveExpiredLiveFlights $eventListener */
@ -48,7 +48,7 @@ test('expired flight should not be removed', function (): void {
});
test('expired flight should be removed', function (): void {
updateSetting('acars.live_time', 3);
updateSetting('pireps.tombstone_time', 3);
$pirep = createInProgressPirep(4);
/** @var RemoveExpiredLiveFlights $eventListener */
@ -60,7 +60,7 @@ test('expired flight should be removed', function (): void {
});
test('completed flights should not be deleted', function (): void {
updateSetting('acars.live_time', 3);
updateSetting('pireps.tombstone_time', 3);
$pirep = createInProgressPirep(4);
// Make sure the state is accepted

View File

@ -1,6 +1,6 @@
<?php
use App\Enums\PirepStatus;
use App\Enums\PirepPhase;
use App\Models\Award;
use App\Models\News;
use App\Models\Pirep;
@ -190,8 +190,8 @@ test('a key status change announces once', function (): void {
Notification::fake();
updateSetting('notifications.discord_pirep_status', true);
$pirep = Pirep::factory()->create(['status' => PirepStatus::INITIATED]);
$pirep->status = PirepStatus::BOARDING;
$pirep = Pirep::factory()->create(['status' => PirepPhase::INITIATED]);
$pirep->status = PirepPhase::BOARDING;
$pirep->save();
// Proves the status-change announcement fires at all, which is what makes
@ -203,8 +203,8 @@ test('a diverted status does not also announce a status change', function (): vo
Notification::fake();
updateSetting('notifications.discord_pirep_status', true);
$pirep = Pirep::factory()->create(['status' => PirepStatus::INITIATED]);
$pirep->status = PirepStatus::DIVERTED;
$pirep = Pirep::factory()->create(['status' => PirepPhase::INITIATED]);
$pirep->status = PirepPhase::DIVERTED;
$pirep->save();
// PirepService::handleDiversion() announces a diversion via PirepDiverted,

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use App\Filament\System\Installer;
use Livewire\Livewire;
/**
* The countdown hangs off the wizard footer that Filament renders for us, so
* these assertions are really about the seam with the vendor component: the
* Next button has to stay findable from JS, and the Back slot has to have been
* taken over by Pause.
*/
it('renders the auto-advance countdown hooks on the wizard footer', function (): void {
Livewire::test(Installer::class)
->assertSee('data-installer-next', escape: false)
->assertSee("Alpine.store('installerAutoAdvance'", escape: false)
->assertSee('$store.installerAutoAdvance.toggle()', escape: false);
});
it('replaces the wizard back button with the pause control', function (): void {
Livewire::test(Installer::class)
->assertSee('Pause')
->assertDontSee(__('filament-schemas::components.wizard.actions.previous_step.label'));
});
// Deliberately not covered here: calling runMigrations(). It shells out to
// `php artisan migrate` through StreamedCommandsService, and the subprocess
// reads .env rather than phpunit's forced sqlite connection -- so the assertion
// would run migrations and DatabaseSeeder against the development database.

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
use App\Models\Fare;
use App\Models\FlightBundle;
use App\Models\Rank;
use App\Models\Subfleet;
use App\Services\LegacyImporter\ClearDatabase;
use App\Services\LegacyImporterService;
use Illuminate\Support\Facades\DB;
/**
* The v5 legacy importer empties the target database before it writes anything
* into it. Anything it leaves behind is not merely stale: every id it clears is
* a reused auto-increment, so a surviving child row rebinds itself onto
* whichever freshly imported parent lands on the old id.
*/
beforeEach(function (): void {
// BaseImporter builds an ImporterDB from the stored credentials in its
// constructor. It does not connect -- ClearDatabase never reads the legacy
// database -- but the array has to be there to be read.
app(LegacyImporterService::class)->saveCredentials([
'host' => '127.0.0.1',
'port' => 3306,
'name' => 'legacy_v5',
'user' => 'phpvms',
'pass' => '',
]);
});
test('the cleanup takes every subfleet pivot down with the subfleets', function (): void {
$subfleet = Subfleet::factory()->create();
$bundle = FlightBundle::factory()->create();
$bundle->subfleets()->attach($subfleet->id);
$subfleet->fares()->attach(Fare::factory()->create()->id);
$subfleet->ranks()->attach(Rank::factory()->create()->id);
// No Typerating factory, and the pivot has no foreign keys -- the row is
// what matters here, not what it points at.
DB::table('typerating_subfleet')->insert([
'typerating_id' => 1,
'subfleet_id' => $subfleet->id,
]);
$pivots = ['bundle_subfleet', 'subfleet_fare', 'subfleet_rank', 'typerating_subfleet'];
$count = fn (): array => collect($pivots)
->mapWithKeys(fn (string $table): array => [$table => DB::table($table)->count()])
->all();
expect($count())->toBe(array_fill_keys($pivots, 1));
new ClearDatabase()->run();
// A bundle's subfleet defaults cannot outlive the subfleets they name, and
// neither can a fare override, a rank grant or a type rating.
expect($count())->toBe(array_fill_keys($pivots, 0))
->and(Subfleet::count())->toBe(0);
// The failure mode is silent mis-binding rather than a dangling row: the
// truncate above resets the subfleet auto-increment, so a default left
// behind would name whichever subfleet the importer writes into that id
// next, and rung 2 would hand that one to every flight on the bundle.
$imported = Subfleet::factory()->create();
expect($bundle->subfleets()->count())->toBe(0)
->and($imported->bundles()->count())->toBe(0);
});

View File

@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
use App\Enums\AcarsType;
use App\Enums\PirepPhase;
use App\Enums\PirepState;
use App\Models\Acars;
use App\Models\Pirep;
use App\Models\PirepPosition;
use App\Models\User;
use App\Services\GeoService;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/** getLine() returns an empty FeatureCollection below two points. */
function trailCoordinates(array $geo): array
{
$features = $geo['line']->getFeatures();
return $features === [] ? [] : $features[0]->getGeometry()->getCoordinates();
}
function flightOnLiveMap(array $pirepAttrs = [], array $positionAttrs = []): Pirep
{
$pirep = Pirep::factory()->create(array_merge([
'state' => PirepState::IN_PROGRESS,
'status' => PirepPhase::ENROUTE,
], $pirepAttrs));
PirepPosition::factory()->create(array_merge([
'pirep_id' => $pirep->id,
'user_id' => $pirep->user_id,
], $positionAttrs));
return $pirep;
}
test('a flight is on the map if and only if it has a position row', function (): void {
$onMap = flightOnLiveMap();
// In progress, but no position row: not on the map.
$offMap = Pirep::factory()->create(['state' => PirepState::IN_PROGRESS]);
// Finished but not yet evicted. The old query filtered this out.
$completed = flightOnLiveMap(['state' => PirepState::PENDING]);
$ids = collect(test()->get('/api/acars')->json('data'))->pluck('id');
expect($ids)->toContain($onMap->id)
->and($ids)->toContain($completed->id)
->and($ids)->not->toContain($offMap->id);
});
test('the response still carries the nested position object', function (): void {
$pirep = flightOnLiveMap(positionAttrs: ['lat' => 41.5, 'lon' => -87.25, 'heading' => 270]);
$flight = collect(test()->get('/api/acars')->json('data'))
->firstWhere('id', $pirep->id);
expect($flight)->toHaveKey('position')
->and($flight['position']['lat'])->toEqual(41.5)
->and($flight['position']['lon'])->toEqual(-87.25)
->and($flight['position']['heading'])->toBe(270)
->and($flight['position']['distance'])->toHaveKeys(['m', 'km', 'mi', 'nmi']);
});
test('the GeoJSON endpoint draws a point per flight on the map', function (): void {
$pirep = flightOnLiveMap(positionAttrs: [
'lat' => 12.5, 'lon' => -30.25, 'heading' => 90, 'altitude_msl' => 33000,
]);
$features = test()->get('/api/acars/geojson')->json('data.features');
$feature = collect($features)->firstWhere('properties.pirep_id', $pirep->id);
expect($feature['geometry']['coordinates'])->toEqual([-30.25, 12.5, 33000])
->and($feature['properties']['heading'])->toBe(90);
});
test('a flight with thousands of breadcrumbs costs no extra queries', function (): void {
$busy = flightOnLiveMap();
Acars::factory()->count(50)->create([
'pirep_id' => $busy->id,
'type' => AcarsType::FLIGHT_PATH,
]);
flightOnLiveMap();
flightOnLiveMap();
$queries = [];
DB::listen(function ($query) use (&$queries): void {
$queries[] = $query->sql;
});
test()->get('/api/acars')->assertOk();
// No latest-row lookup against `acars`, so cost doesn't grow with track length.
$acarsQueries = array_filter($queries, fn (string $sql): bool => str_contains($sql, '"acars"') || str_contains($sql, '`acars`'));
expect($acarsQueries)->toBe([]);
});
test('the trail ends at the live position', function (): void {
$pirep = flightOnLiveMap(positionAttrs: ['lat' => 40.0, 'lon' => -80.0]);
foreach ([[33.0, -90.0], [35.0, -88.0], [37.0, -85.0]] as $i => [$lat, $lon]) {
Acars::factory()->create([
'pirep_id' => $pirep->id,
'type' => AcarsType::FLIGHT_PATH,
'lat' => $lat,
'lon' => $lon,
'created_at' => Carbon::now('UTC')->subMinutes(10 - $i),
]);
}
$geo = app(GeoService::class)->getFeatureFromAcars($pirep->fresh());
$coords = trailCoordinates($geo);
// Three breadcrumbs plus the live position, in order.
expect($coords)->toHaveCount(4)
->and(end($coords))->toBe([-80.0, 40.0])
->and($geo['position'])->toBe(['lat' => 40.0, 'lon' => -80.0]);
});
test('the trail renders with no breadcrumbs yet', function (): void {
$pirep = flightOnLiveMap(positionAttrs: ['lat' => 30.0, 'lon' => -97.0]);
$geo = app(GeoService::class)->getFeatureFromAcars($pirep->fresh());
expect($geo['points']->getFeatures())->toHaveCount(1)
->and($geo['position'])->toBe(['lat' => 30.0, 'lon' => -97.0]);
});
test('appending the live position invents no intermediate points', function (): void {
$pirep = flightOnLiveMap(positionAttrs: ['lat' => 60.0, 'lon' => -10.0]);
// Wide gaps. Nothing may be interpolated to smooth them.
foreach ([[10.0, -100.0], [30.0, -60.0]] as $i => [$lat, $lon]) {
Acars::factory()->create([
'pirep_id' => $pirep->id,
'type' => AcarsType::FLIGHT_PATH,
'lat' => $lat,
'lon' => $lon,
'created_at' => Carbon::now('UTC')->subMinutes(10 - $i),
]);
}
$coords = trailCoordinates(app(GeoService::class)->getFeatureFromAcars($pirep->fresh()));
expect($coords)->toBe([[-100.0, 10.0], [-60.0, 30.0], [-10.0, 60.0]]);
});
test('the trail gains no duplicate point when the position matches the last breadcrumb', function (): void {
$pirep = flightOnLiveMap(positionAttrs: ['lat' => 37.0, 'lon' => -85.0]);
Acars::factory()->create([
'pirep_id' => $pirep->id,
'type' => AcarsType::FLIGHT_PATH,
'lat' => 37.0,
'lon' => -85.0,
'created_at' => Carbon::now('UTC'),
]);
$geo = app(GeoService::class)->getFeatureFromAcars($pirep->fresh());
// The live position sits on the one breadcrumb, so the trail stays a single point.
expect($geo['points']->getFeatures())->toHaveCount(1)
->and(trailCoordinates($geo))->toBe([]);
});
test('a single PIREP flown route still comes from acars', function (): void {
$pirep = flightOnLiveMap(positionAttrs: ['lat' => 40.0, 'lon' => -80.0]);
foreach ([[33.0, -90.0], [35.0, -88.0]] as $i => [$lat, $lon]) {
Acars::factory()->create([
'pirep_id' => $pirep->id,
'type' => AcarsType::FLIGHT_PATH,
'lat' => $lat,
'lon' => $lon,
'sim_time' => Carbon::now('UTC')->subMinutes(10 - $i)->toIso8601String(),
'created_at' => Carbon::now('UTC')->subMinutes(10 - $i),
]);
}
apiAs(User::find($pirep->user_id));
$route = test()->get('/api/pireps/'.$pirep->id.'/acars/position')->assertOk()->json('data');
// Same rows, same order, and the live position is not among them.
expect($route)->toHaveCount(2)
->and($route[0]['lat'])->toEqual(33.0)
->and($route[1]['lat'])->toEqual(35.0);
});

View File

@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
use App\Models\Setting;
use Database\Seeders\SettingsSeeder;
function liveMapSettingsMigration(): object
{
return require base_path('database/migrations_data/2026_07_27_000001_live_map_settings.php');
}
function findSetting(string $key): ?Setting
{
return Setting::where('id', Setting::formatKey($key))->first();
}
/** A pre-change `acars` group setting, as an install carries it. */
function putAcarsSetting(string $key, string $value, string $name, string $type = 'int'): void
{
$model = new Setting([
'key' => $key,
'name' => $name,
'value' => $value,
'group' => 'acars',
'type' => $type,
'options' => '',
'description' => 'the old description',
]);
$model->id = Setting::formatKey($key);
$model->default = $value;
$model->offset = 7;
$model->order = 7;
$model->save();
}
/**
* What a real upgrade presents: the seeder has already run, so the new keys exist
* at their defaults with the old `acars` keys still alongside them.
*/
function seedPreUpgradeState(string $liveTime = '12'): void
{
putAcarsSetting('acars.live_time', $liveTime, 'Live Time');
putAcarsSetting('acars.center_coords', '30.1945,-97.6699', 'Center Coords', 'text');
putAcarsSetting('acars.default_zoom', '5', 'Default Zoom');
putAcarsSetting('acars.update_interval', '60', 'Refresh Interval');
}
test('a customised live time survives the move to the tombstone setting', function (): void {
seedPreUpgradeState(liveTime: '24');
liveMapSettingsMigration()->up();
$tombstone = findSetting('pireps.tombstone_time');
// 24 hours, still meaning 24 hours: the unit is not converted.
expect($tombstone)->not->toBeNull()
->and($tombstone->value)->toBe('24')
->and($tombstone->group)->toBe('pireps')
->and($tombstone->default)->toBe('12')
->and(findSetting('acars.live_time'))->toBeNull();
});
test('an untouched default lands on the default', function (): void {
seedPreUpgradeState(liveTime: '12');
liveMapSettingsMigration()->up();
expect(findSetting('pireps.tombstone_time')->value)->toBe('12');
});
test('the display settings are regrouped with their values intact', function (): void {
seedPreUpgradeState();
Setting::where('id', Setting::formatKey('acars.center_coords'))->update(['value' => '51.4700,-0.4543']);
Setting::where('id', Setting::formatKey('acars.default_zoom'))->update(['value' => '9']);
liveMapSettingsMigration()->up();
expect(findSetting('livemap.center_coords')->value)->toBe('51.4700,-0.4543')
->and(findSetting('livemap.center_coords')->group)->toBe('livemap')
->and(findSetting('livemap.default_zoom')->value)->toBe('9')
->and(findSetting('livemap.update_interval')->value)->toBe('60');
expect(findSetting('acars.center_coords'))->toBeNull()
->and(findSetting('acars.default_zoom'))->toBeNull()
->and(findSetting('acars.update_interval'))->toBeNull();
});
test('no setting is left in the acars group', function (): void {
seedPreUpgradeState();
liveMapSettingsMigration()->up();
expect(Setting::where('group', 'acars')->count())->toBe(0);
});
test('the two new timers are left to the seeder', function (): void {
seedPreUpgradeState();
liveMapSettingsMigration()->up();
// No old key maps onto either, so the migration must not touch them.
expect(findSetting('livemap.live_time')->value)->toBe('30')
->and(findSetting('livemap.live_time')->group)->toBe('livemap')
->and(findSetting('livemap.idle_time')->value)->toBe('60')
->and(findSetting('livemap.idle_time')->group)->toBe('livemap');
});
test('a re-run leaves a value the admin has since changed alone', function (): void {
seedPreUpgradeState(liveTime: '24');
liveMapSettingsMigration()->up();
Setting::where('id', Setting::formatKey('pireps.tombstone_time'))->update(['value' => '6']);
// The source is gone, so there is nothing to carry and nothing to clobber.
liveMapSettingsMigration()->up();
expect(findSetting('pireps.tombstone_time')->value)->toBe('6');
});
test('the migration is a no-op on an install that has already been through it', function (): void {
liveMapSettingsMigration()->up();
expect(findSetting('pireps.tombstone_time')->value)->toBe('12')
->and(findSetting('livemap.center_coords')->value)->toBe('30.1945,-97.6699')
->and(Setting::where('group', 'acars')->count())->toBe(0);
});
test('reversing restores the previous keys, names and descriptions', function (): void {
seedPreUpgradeState(liveTime: '24');
liveMapSettingsMigration()->up();
liveMapSettingsMigration()->down();
$liveTime = findSetting('acars.live_time');
expect($liveTime)->not->toBeNull()
->and($liveTime->value)->toBe('24')
->and($liveTime->name)->toBe('Live Time')
->and($liveTime->group)->toBe('acars')
->and($liveTime->description)->toContain('Age of flights to show on the map in hours');
expect(findSetting('acars.center_coords')->group)->toBe('acars')
->and(findSetting('acars.default_zoom')->group)->toBe('acars')
->and(findSetting('acars.update_interval')->group)->toBe('acars');
expect(findSetting('pireps.tombstone_time'))->toBeNull();
});
test('reversing removes the settings that did not exist before', function (): void {
seedPreUpgradeState();
liveMapSettingsMigration()->up();
liveMapSettingsMigration()->down();
// No pre-change counterpart to rename back to.
expect(findSetting('livemap.live_time'))->toBeNull()
->and(findSetting('livemap.idle_time'))->toBeNull();
});
test('a fresh install and an upgraded install agree on key, group and default', function (): void {
$keys = [
'pireps.tombstone_time',
'livemap.live_time',
'livemap.idle_time',
'livemap.center_coords',
'livemap.default_zoom',
'livemap.update_interval',
];
$snapshot = fn (): array => collect($keys)
->mapWithKeys(fn (string $key): array => [
$key => [
'key' => findSetting($key)?->key,
'group' => findSetting($key)?->group,
'default' => findSetting($key)?->default,
],
])
->all();
// Pest's beforeEach has already seeded: this is the fresh install.
$fresh = $snapshot();
// Wind back to a pre-change install.
Setting::query()->whereIn('id', array_map(Setting::formatKey(...), $keys))->delete();
seedPreUpgradeState(liveTime: '24');
// Replay the upgrade in Updater's order: seeders, then data migrations.
new SettingsSeeder()->run();
liveMapSettingsMigration()->up();
expect($snapshot())->toBe($fresh);
// The only thing an upgrade carries that a fresh install does not.
expect(findSetting('pireps.tombstone_time')->value)->toBe('24');
});

View File

@ -2,8 +2,8 @@
use App\Enums\AcarsType;
use App\Enums\PirepFieldSource;
use App\Enums\PirepPhase;
use App\Enums\PirepState;
use App\Enums\PirepStatus;
use App\Enums\UserState;
use App\Models\Acars;
use App\Models\Aircraft;
@ -753,7 +753,7 @@ test('diversion handler reuses matching reposition flight and attaches subfleet'
'airline_id' => $airline->id,
'flight_number' => $flight->flight_number,
'callsign' => $flight->callsign,
'route_code' => PirepStatus::DIVERTED,
'route_code' => PirepPhase::DIVERTED,
'dpt_airport_id' => $diversionAirport->id,
'arr_airport_id' => $originalArrivalAirport->id,
'user_id' => $user->id,
@ -783,7 +783,7 @@ test('diversion handler reuses matching reposition flight and attaches subfleet'
'airline_id' => $airline->id,
'flight_number' => $flight->flight_number,
'callsign' => $flight->callsign,
'route_code' => PirepStatus::DIVERTED,
'route_code' => PirepPhase::DIVERTED,
'dpt_airport_id' => $diversionAirport->id,
'arr_airport_id' => $originalArrivalAirport->id,
'user_id' => $user->id,

View File

@ -0,0 +1,50 @@
<?php
use App\Enums\PirepPhase;
use App\Enums\PirepStatus;
use App\Http\Resources\PirepResource;
use App\Models\Pirep;
use Illuminate\Support\Facades\DB;
test('the old name resolves to the new enum rather than a parallel one', function (): void {
// ::class is resolved by the compiler, so reflection is what follows the alias.
expect(new ReflectionClass(PirepStatus::class)->getName())->toBe(PirepPhase::class);
// Not toEqual: a copied enum would compare equal and still be a different type.
expect(PirepStatus::TAXI)->toBe(PirepPhase::TAXI)
->and(PirepStatus::cases())->toBe(PirepPhase::cases());
});
test('type checks against the old name pass for cases of the new enum', function (): void {
$phase = PirepPhase::ENROUTE;
expect($phase)->toBeInstanceOf(PirepStatus::class)
->and(PirepStatus::ENROUTE)->toBeInstanceOf(PirepPhase::class);
// Keeps addon signatures typed against the old name working.
$takesOldName = fn (PirepStatus $p): string => $p->value;
expect($takesOldName(PirepPhase::LANDED))->toBe('LAN');
});
test('phase values stored before the rename read back to the same cases', function (): void {
$pirep = Pirep::factory()->create();
// The raw code as a pre-rename install holds it, bypassing the cast.
DB::table('pireps')->where('id', $pirep->id)->update(['status' => 'ENR']);
$reloaded = Pirep::find($pirep->id);
expect($reloaded->status)->toBe(PirepPhase::ENROUTE)
->and($reloaded->status)->toBe(PirepStatus::ENROUTE)
->and($reloaded->status->value)->toBe('ENR');
});
test('the API still publishes the value under phase', function (): void {
$pirep = Pirep::factory()->create(['status' => PirepPhase::ENROUTE]);
$res = PirepResource::make($pirep)->toArray(request());
expect($res)->toHaveKey('phase')
->and($res['phase'])->toBe(PirepPhase::ENROUTE)
->and($res)->not->toHaveKey('status_enum');
});

View File

@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
use App\Cron\FiveMinute\PirepPositionExpiration;
use App\Cron\Hourly\RemoveExpiredLiveFlights;
use App\Enums\PirepPhase;
use App\Enums\PirepState;
use App\Events\CronFiveMinute;
use App\Events\CronHourly;
use App\Models\Pirep;
use App\Models\PirepPosition;
use App\Services\PirepService;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
function runExpiration(): void
{
app(PirepPositionExpiration::class)->handle(new CronFiveMinute());
}
/**
* A flight on the map. `$moved` false means it has sat at the gate since prefile,
* which the row records as updated_at still equalling created_at.
*/
function flightOnMap(int $reportedAgo, array $pirepAttrs = [], bool $moved = true): Pirep
{
$pirep = Pirep::factory()->create(array_merge([
'state' => PirepState::IN_PROGRESS,
'status' => PirepPhase::ENROUTE,
], $pirepAttrs));
PirepPosition::factory()->create([
'pirep_id' => $pirep->id,
'user_id' => $pirep->user_id,
]);
$last = Carbon::now('UTC')->subMinutes($reportedAgo);
DB::table('pirep_positions')->where('pirep_id', $pirep->id)->update([
'created_at' => $moved ? $last->copy()->subDay() : $last,
'updated_at' => $last,
]);
return $pirep;
}
function onMap(Pirep $pirep): bool
{
return PirepPosition::where('pirep_id', $pirep->id)->exists();
}
beforeEach(function (): void {
updateSetting('pireps.tombstone_time', 12);
updateSetting('livemap.live_time', 30);
updateSetting('livemap.idle_time', 60);
});
test('a completed flight past its window leaves the map', function (): void {
$old = flightOnMap(reportedAgo: 45, pirepAttrs: ['state' => PirepState::PENDING]);
$fresh = flightOnMap(reportedAgo: 15, pirepAttrs: ['state' => PirepState::PENDING]);
runExpiration();
expect(onMap($old))->toBeFalse()
->and(onMap($fresh))->toBeTrue();
});
test('a paused flight past its window leaves the map but survives as a PIREP', function (): void {
$pirep = flightOnMap(reportedAgo: 90, pirepAttrs: ['status' => PirepPhase::PAUSED]);
runExpiration();
// A paused PIREP is paused deliberately: eviction must not touch the record.
expect(onMap($pirep))->toBeFalse()
->and(Pirep::find($pirep->id))->not->toBeNull()
->and(Pirep::find($pirep->id)->state)->toBe(PirepState::IN_PROGRESS);
});
test('a paused flight within its window stays on the map', function (): void {
$pirep = flightOnMap(reportedAgo: 30, pirepAttrs: ['status' => PirepPhase::PAUSED]);
runExpiration();
expect(onMap($pirep))->toBeTrue();
});
test('a paused flight is not reaped on the tombstone clock', function (): void {
// idle_time governs map membership only, never reaping.
$pirep = flightOnMap(reportedAgo: 60 * 20, pirepAttrs: ['status' => PirepPhase::PAUSED]);
runExpiration();
app(RemoveExpiredLiveFlights::class)->handle(new CronHourly());
expect(onMap($pirep))->toBeFalse()
->and(Pirep::find($pirep->id))->not->toBeNull();
});
test('a prefiled flight that never departs is evicted on the stationary timer', function (): void {
$stale = flightOnMap(reportedAgo: 90, pirepAttrs: ['status' => PirepPhase::INITIATED], moved: false);
$recent = flightOnMap(reportedAgo: 30, pirepAttrs: ['status' => PirepPhase::INITIATED], moved: false);
runExpiration();
// Same timer as a paused flight: both are present and not moving.
expect(onMap($stale))->toBeFalse()
->and(onMap($recent))->toBeTrue();
});
test('phase and state disagreeing resolves on state', function (): void {
// Filed, but the last reported phase is still an arrived aircraft.
$pirep = flightOnMap(reportedAgo: 45, pirepAttrs: [
'state' => PirepState::PENDING,
'status' => PirepPhase::ARRIVED,
]);
runExpiration();
expect(onMap($pirep))->toBeFalse();
});
test('a completed flight is clocked from its last position, not its filing time', function (): void {
// Landed 12:00, filed 15:00. submitted_at would draw it for 3.5 more hours.
Carbon::setTestNow(Carbon::parse('2026-07-27 15:05:00', 'UTC'));
$pirep = Pirep::factory()->create([
'state' => PirepState::PENDING,
'status' => PirepPhase::ARRIVED,
'submitted_at' => Carbon::parse('2026-07-27 15:00:00', 'UTC'),
]);
PirepPosition::factory()->create(['pirep_id' => $pirep->id, 'user_id' => $pirep->user_id]);
DB::table('pirep_positions')->where('pirep_id', $pirep->id)->update([
'created_at' => Carbon::parse('2026-07-27 10:00:00', 'UTC'),
'updated_at' => Carbon::parse('2026-07-27 12:00:00', 'UTC'),
]);
runExpiration();
// Gone on the landing clock, not the filing one.
expect(onMap($pirep))->toBeFalse();
Carbon::setTestNow();
});
test('zero disables a timer rather than expiring everything', function (): void {
updateSetting('livemap.live_time', 0);
updateSetting('livemap.idle_time', 0);
$completed = flightOnMap(reportedAgo: 60 * 24, pirepAttrs: ['state' => PirepState::PENDING]);
$paused = flightOnMap(reportedAgo: 60 * 24, pirepAttrs: ['status' => PirepPhase::PAUSED]);
runExpiration();
expect(onMap($completed))->toBeTrue()
->and(onMap($paused))->toBeTrue();
});
test('cancelling takes a flight off the map before the request completes', function (): void {
$pirep = flightOnMap(reportedAgo: 1);
app(PirepService::class)->cancel($pirep);
// No expiration run in between - the cancel path is synchronous.
expect(onMap($pirep))->toBeFalse();
});

View File

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
use App\Enums\AcarsType;
use App\Models\Acars;
use App\Models\Pirep;
use App\Models\PirepPosition;
use App\Support\Units\Distance;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
test('distance keeps its fractional part', function (): void {
$pirep = Pirep::factory()->create();
PirepPosition::factory()->create([
'pirep_id' => $pirep->id,
'user_id' => $pirep->user_id,
'distance' => 1234.56,
]);
// Past the cast: `acars`.`distance` was an int and would have stored 1234.
$raw = DB::table('pirep_positions')->where('pirep_id', $pirep->id)->value('distance');
expect(round((float) $raw, 2))->toBe(1234.56);
});
test('vertical speed stores negatives', function (): void {
$pirep = Pirep::factory()->create();
$position = PirepPosition::factory()->create([
'pirep_id' => $pirep->id,
'user_id' => $pirep->user_id,
'vs' => -1800,
]);
expect($position->fresh()->vs)->toBe(-1800.0);
});
test('display units apply as they do for acars', function (): void {
updateSetting('units.distance', 'km');
$pirep = Pirep::factory()->create();
$position = PirepPosition::factory()->create([
'pirep_id' => $pirep->id,
'user_id' => $pirep->user_id,
'distance' => 100,
]);
$acars = Acars::factory()->create([
'pirep_id' => $pirep->id,
'type' => AcarsType::FLIGHT_PATH,
'distance' => 100,
]);
// Units come from the cast, not the column name.
expect($position->fresh()->distance)->toBeInstanceOf(Distance::class)
->and($position->fresh()->distance->local(2))
->toBe($acars->fresh()->distance->local(2));
});
test('every telemetry column is not null', function (): void {
// Seeded-to-zero only holds if the schema enforces it.
$nullable = collect(Schema::getColumns('pirep_positions'))
->reject(fn (array $column): bool => in_array($column['name'], ['created_at', 'updated_at'], true))
->filter(fn (array $column): bool => $column['nullable'])
->pluck('name')
->all();
expect($nullable)->toBe([]);
});

View File

@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
use App\Enums\PirepPhase;
use App\Enums\PirepState;
use App\Events\PirepUpdated;
use App\Models\Acars;
use App\Models\Airline;
use App\Models\Airport;
use App\Models\Pirep;
use App\Models\PirepPosition;
use App\Models\Rank;
use App\Models\Subfleet;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
/**
* Prefile a flight through the API, as an ACARS client does.
*/
function prefileFlight(): array
{
$subfleet = Subfleet::factory()->hasAircraft(1)->create();
$rank = Rank::factory()->hasAttached($subfleet)->create();
$user = User::factory()->create(['rank_id' => $rank->id]);
$dpt = Airport::factory()->create();
$arr = Airport::factory()->create();
$airline = Airline::factory()->create();
$aircraft = $subfleet->aircraft->first();
$aircraft->update(['airport_id' => $dpt->id]);
apiAs($user);
$response = test()->post('/api/pireps/prefile', [
'airline_id' => $airline->id,
'aircraft_id' => $aircraft->id,
'flight_number' => '1234',
'source_name' => 'PirepPositionWriteTest',
'dpt_airport_id' => $dpt->icao,
'arr_airport_id' => $arr->icao,
]);
$response->assertStatus(200);
return [Pirep::find($response->json('data.id')), $user];
}
function positionRow(Pirep $pirep): ?PirepPosition
{
return PirepPosition::find($pirep->id);
}
/** `created_at` is collection time, which separates arrival from collection order. */
function postPositions(Pirep $pirep, array $positions): void
{
test()->post('/api/pireps/'.$pirep->id.'/acars/position', ['positions' => $positions])
->assertStatus(200);
}
function point(Carbon $collectedAt, float $lat, float $lon, array $extra = []): array
{
return array_merge([
'lat' => $lat,
'lon' => $lon,
'created_at' => $collectedAt->toIso8601String(),
'sim_time' => $collectedAt->toIso8601String(),
], $extra);
}
test('prefiling puts the flight on the map at its departure airport', function (): void {
[$pirep] = prefileFlight();
$airport = Airport::find($pirep->dpt_airport_id);
$position = positionRow($pirep);
expect($position)->not->toBeNull()
->and((float) $position->lat)->toBe((float) $airport->lat)
->and((float) $position->lon)->toBe((float) $airport->lon)
->and($position->phase)->toBe(PirepPhase::SCHEDULED)
->and($position->user_id)->toBe($pirep->user_id);
});
test('telemetry not yet reported is zero rather than null', function (): void {
[$pirep] = prefileFlight();
$position = positionRow($pirep);
expect($position->gs)->toBe(0)
->and($position->ias)->toBe(0)
->and($position->vs)->toBe(0.0)
->and($position->heading)->toBe(0)
->and($position->flight_time)->toBe(0)
->and($position->altitude_agl)->toBe(0.0)
->and($position->altitude_msl)->toBe(0.0)
->and($position->distance->internal())->toBe(0.0);
});
test('the first position batch replaces the seeded coordinates', function (): void {
[$pirep] = prefileFlight();
$seeded = positionRow($pirep);
postPositions($pirep, [point(Carbon::now('UTC'), 41.5, -87.25)]);
$position = positionRow($pirep);
expect((float) $position->lat)->toBe(41.5)
->and((float) $position->lon)->toBe(-87.25)
->and((float) $position->lat)->not->toBe((float) $seeded->lat);
});
test('the position row reflects the newest point in a batch', function (): void {
[$pirep] = prefileFlight();
$at = Carbon::now('UTC');
postPositions($pirep, [
point($at->copy()->subMinutes(2), 10.0, 10.0),
point($at->copy(), 12.0, 12.0),
point($at->copy()->subMinute(), 11.0, 11.0),
]);
expect((float) positionRow($pirep)->lat)->toBe(12.0);
});
test('an out-of-order batch does not move the position row backwards', function (): void {
[$pirep] = prefileFlight();
$now = Carbon::now('UTC');
postPositions($pirep, [point($now->copy(), 50.0, 10.0)]);
// Collected earlier, arriving later: a replay or a catching-up client.
postPositions($pirep, [point($now->copy()->subMinutes(10), 20.0, 20.0)]);
expect((float) positionRow($pirep)->lat)->toBe(50.0)
->and((float) positionRow($pirep)->lon)->toBe(10.0);
// The breadcrumb is still recorded; only the marker refuses to move.
expect(Acars::where('pirep_id', $pirep->id)->flightPath()->count())->toBe(2);
});
test('repeated batches for one PIREP leave exactly one row', function (): void {
[$pirep] = prefileFlight();
$at = Carbon::now('UTC');
for ($i = 1; $i <= 50; $i++) {
postPositions($pirep, [point($at->copy()->addSeconds($i), $i, $i)]);
}
expect(DB::table('pirep_positions')->where('pirep_id', $pirep->id)->count())->toBe(1)
->and((float) positionRow($pirep)->lat)->toBe(50.0)
->and(Acars::where('pirep_id', $pirep->id)->flightPath()->count())->toBe(50);
});
test('a batch for a filed PIREP still moves the marker', function (): void {
[$pirep] = prefileFlight();
$at = Carbon::now('UTC');
postPositions($pirep, [point($at->copy(), 33.0, 33.0)]);
// Filing moves it to PENDING while the client may still be sending.
$pirep->state = PirepState::PENDING;
$pirep->save();
postPositions($pirep, [point($at->copy()->addMinute(), 34.0, 34.0)]);
expect((float) positionRow($pirep)->lat)->toBe(34.0);
});
test('a batch for an accepted PIREP writes acars but no position', function (): void {
[$pirep] = prefileFlight();
positionRow($pirep)->delete();
$pirep->state = PirepState::ACCEPTED;
$pirep->save();
postPositions($pirep, [point(Carbon::now('UTC'), 33.0, 33.0)]);
// The breadcrumb is still written: that contract is unchanged.
expect(Acars::where('pirep_id', $pirep->id)->flightPath()->count())->toBe(1)
->and(positionRow($pirep))->toBeNull();
});
test('a late batch cannot return an evicted flight to the map', function (): void {
[$pirep] = prefileFlight();
$at = Carbon::now('UTC');
postPositions($pirep, [point($at->copy()->subHour(), 12.0, 12.0)]);
// Already reviewed, and its row already evicted.
$pirep->state = PirepState::REJECTED;
$pirep->save();
positionRow($pirep)->delete();
postPositions($pirep, [point($at->copy(), 13.0, 13.0)]);
expect(positionRow($pirep))->toBeNull();
});
test('the position row updated_at moves on batches but not on an admin edit', function (): void {
[$pirep] = prefileFlight();
postPositions($pirep, [point(Carbon::now('UTC'), 5.0, 5.0)]);
$afterBatch = positionRow($pirep)->updated_at;
// Time has to pass, or "unchanged" proves nothing.
Carbon::setTestNow(Carbon::now('UTC')->addHour());
$pirep->notes = 'reviewed by an administrator';
$pirep->save();
expect(positionRow($pirep)->updated_at->timestamp)->toBe($afterBatch->timestamp);
postPositions($pirep, [point(Carbon::now('UTC'), 6.0, 6.0)]);
expect(positionRow($pirep)->updated_at->timestamp)->toBeGreaterThan($afterBatch->timestamp);
Carbon::setTestNow();
});
test('the update endpoint writes no position row and emits what it always did', function (): void {
[$pirep] = prefileFlight();
positionRow($pirep)->delete();
Event::fake([PirepUpdated::class]);
test()->post('/api/pireps/'.$pirep->id.'/update', [
'flight_time' => 90,
'distance' => 20,
'status' => PirepPhase::AIRBORNE->value,
])->assertStatus(200);
Event::assertDispatched(PirepUpdated::class);
expect(positionRow($pirep))->toBeNull()
->and(Pirep::find($pirep->id)->flight_time)->toBe(90);
});
test('the file endpoint writes no position row', function (): void {
[$pirep] = prefileFlight();
positionRow($pirep)->delete();
test()->post('/api/pireps/'.$pirep->id.'/file', [
'flight_time' => 60,
'fuel_used' => 100,
'distance' => 100,
])->assertStatus(200);
expect(positionRow($pirep))->toBeNull();
});

View File

@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
use App\Cron\Hourly\DeletePireps;
use App\Enums\AcarsType;
use App\Enums\PirepState;
use App\Events\CronHourly;
use App\Models\Acars;
use App\Models\Pirep;
use App\Models\PirepPosition;
use App\Services\PirepService;
use Illuminate\Database\QueryException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* SQLite has no ALTER TABLE ADD CONSTRAINT, so the migration skips it there. A test
* asserting an FK works, on a connection with no FK, is worse than no test.
*/
function acarsForeignKeyExists(): bool
{
return collect(Schema::getForeignKeys('acars'))
->flatMap(fn (array $key): array => $key['columns'])
->contains('pirep_id');
}
function flightWithTelemetry(array $attrs = []): Pirep
{
$pirep = Pirep::factory()->create($attrs);
Acars::factory()->count(3)->create([
'pirep_id' => $pirep->id,
'type' => AcarsType::FLIGHT_PATH,
]);
PirepPosition::factory()->create([
'pirep_id' => $pirep->id,
'user_id' => $pirep->user_id,
]);
return $pirep;
}
function telemetryCount(Pirep $pirep): array
{
return [
'acars' => DB::table('acars')->where('pirep_id', $pirep->id)->count(),
'position' => DB::table('pirep_positions')->where('pirep_id', $pirep->id)->count(),
];
}
test('deleting a PIREP through the service removes its telemetry', function (): void {
$pirep = flightWithTelemetry();
expect(telemetryCount($pirep))->toBe(['acars' => 3, 'position' => 1]);
app(PirepService::class)->delete($pirep);
// The docblock has claimed `acars` since this method was written.
expect(telemetryCount($pirep))->toBe(['acars' => 0, 'position' => 0])
->and(Pirep::withTrashed()->find($pirep->id))->toBeNull();
});
test('a soft-deleted PIREP keeps its telemetry', function (): void {
$pirep = flightWithTelemetry();
$pirep->delete();
// The PIREP still exists and may be restored.
expect(telemetryCount($pirep))->toBe(['acars' => 3, 'position' => 1])
->and(Pirep::withTrashed()->find($pirep->id))->not->toBeNull();
});
test('a soft-deleted PIREP is not counted as an orphan parent', function (): void {
$pirep = flightWithTelemetry();
$pirep->delete();
// The migration's anti-join. Through Eloquent the SoftDeletes scope would hide
// the parent and this would destroy live telemetry.
$orphans = DB::table('acars')
->whereNotNull('acars.pirep_id')
->whereNotExists(fn ($query) => $query->select(DB::raw(1))
->from('pireps')
->whereColumn('pireps.id', 'acars.pirep_id'))
->count();
expect($orphans)->toBe(0);
});
test('deleting a PIREP outside the service still removes its telemetry', function (): void {
if (!acarsForeignKeyExists()) {
test()->markTestSkipped('The acars foreign key was skipped on this platform, so there is nothing to assert.');
}
$pirep = flightWithTelemetry();
// Bypassing the service, the model and the observers.
DB::table('pireps')->where('id', $pirep->id)->delete();
expect(telemetryCount($pirep))->toBe(['acars' => 0, 'position' => 0]);
});
test('telemetry for a PIREP that does not exist is rejected', function (): void {
if (!acarsForeignKeyExists()) {
test()->markTestSkipped('The acars foreign key was skipped on this platform, so there is nothing to assert.');
}
expect(fn () => Acars::factory()->create([
'pirep_id' => 'no-such-pirep-id',
'type' => AcarsType::FLIGHT_PATH,
]))->toThrow(QueryException::class);
});
test('the position row cascades on every platform', function (): void {
// Declared at create time, so it exists everywhere including SQLite.
$pirep = flightWithTelemetry();
DB::table('pireps')->where('id', $pirep->id)->delete();
expect(DB::table('pirep_positions')->where('pirep_id', $pirep->id)->count())->toBe(0);
});
test('the scheduled cancelled and rejected cleanup leaves no telemetry behind', function (): void {
updateSetting('pireps.delete_cancelled_hours', 1);
updateSetting('pireps.delete_rejected_hours', 1);
$cancelled = flightWithTelemetry(['state' => PirepState::CANCELLED]);
$rejected = flightWithTelemetry(['state' => PirepState::REJECTED]);
foreach ([$cancelled, $rejected] as $pirep) {
DB::table('pireps')->where('id', $pirep->id)
->update(['created_at' => Carbon::now('UTC')->subHours(5)]);
}
app(DeletePireps::class)->handle(new CronHourly());
expect(telemetryCount($cancelled))->toBe(['acars' => 0, 'position' => 0])
->and(telemetryCount($rejected))->toBe(['acars' => 0, 'position' => 0]);
});
test('replacing a stored route still works under the constraint', function (): void {
$pirep = Pirep::factory()->create();
Acars::factory()->count(2)->create(['pirep_id' => $pirep->id, 'type' => AcarsType::ROUTE]);
Acars::factory()->count(2)->create(['pirep_id' => $pirep->id, 'type' => AcarsType::FLIGHT_PATH]);
// What route_post and saveRoute do. Unrelated to PIREP deletion.
Acars::where('pirep_id', $pirep->id)->where('type', AcarsType::ROUTE)->delete();
expect(Acars::where('pirep_id', $pirep->id)->where('type', AcarsType::ROUTE)->count())->toBe(0)
->and(Acars::where('pirep_id', $pirep->id)->flightPath()->count())->toBe(2);
});
test('clearing logs and flight path on a reused leg still works', function (): void {
$pirep = Pirep::factory()->create();
Acars::factory()->count(2)->create(['pirep_id' => $pirep->id, 'type' => AcarsType::LOG]);
Acars::factory()->count(2)->create(['pirep_id' => $pirep->id, 'type' => AcarsType::FLIGHT_PATH]);
Acars::factory()->count(1)->create(['pirep_id' => $pirep->id, 'type' => AcarsType::ROUTE]);
// What prefile does to a reused duplicate leg.
Acars::where('pirep_id', $pirep->id)
->whereIn('type', [AcarsType::FLIGHT_PATH, AcarsType::LOG])
->delete();
expect(Acars::where('pirep_id', $pirep->id)->count())->toBe(1)
->and(Pirep::find($pirep->id))->not->toBeNull();
});

View File

@ -4,6 +4,16 @@ declare(strict_types=1);
use App\Enums\AcarsType;
use App\Models\Acars;
use App\Models\Pirep;
/**
* `acars`.`pirep_id` carries a foreign key on every platform that can express
* one, so these rows need real parents rather than invented ids.
*/
beforeEach(function (): void {
Pirep::factory()->create(['id' => 'PIREP-A']);
Pirep::factory()->create(['id' => 'PIREP-B']);
});
test('Acars::forPirep returns only matching pirep rows', function (): void {
Acars::factory()->create([