13 Commits

Author SHA1 Message Date
558759ac48 Merge pull request 'Fix admin unable to access Filament panel' (#14) from fix/ldap-admin-email into master
All checks were successful
linter / quality (push) Successful in 1m53s
security / Dependency Audit (push) Successful in 1m45s
security / Static Analysis (push) Successful in 2m16s
tests / ci (8.4) (push) Successful in 2m40s
tests / ci (8.5) (push) Successful in 1m56s
Reviewed-on: #14
2026-03-06 15:27:01 +08:00
66316328bb Fix seeder emails to match LDAP mail attributes
All checks were successful
linter / quality (pull_request) Successful in 1m27s
security / Dependency Audit (pull_request) Successful in 2m20s
security / Static Analysis (pull_request) Successful in 1m50s
tests / ci (8.4) (pull_request) Successful in 1m33s
tests / ci (8.5) (pull_request) Successful in 2m5s
Seeded user emails were using example.com domains which don't match
the LDAP mail attributes (travel.local). When users log in via LDAP,
LdapRecord syncs by email — a mismatch caused new DB records to be
created without roles, preventing admin access to the Filament panel.
2026-03-06 07:11:08 +00:00
7b26fc57f4 Merge pull request 'Fix form submission staying in draft status' (#13) from fix/submit-draft-status into master
All checks were successful
linter / quality (push) Successful in 1m22s
security / Dependency Audit (push) Successful in 1m27s
security / Static Analysis (push) Successful in 1m24s
tests / ci (8.4) (push) Successful in 1m52s
tests / ci (8.5) (push) Successful in 2m10s
Reviewed-on: #13
2026-03-06 12:29:00 +08:00
35723fb226 Merge pull request 'Add nightly town sync with autocomplete on journey fields' (#12) from feature/town-sync into master
Some checks failed
linter / quality (push) Has been cancelled
security / Dependency Audit (push) Has been cancelled
security / Static Analysis (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Reviewed-on: #12
2026-03-06 12:24:19 +08:00
e01caf678b Fix form submission staying in draft status
All checks were successful
linter / quality (pull_request) Successful in 1m34s
security / Dependency Audit (pull_request) Successful in 1m30s
security / Static Analysis (pull_request) Successful in 1m32s
tests / ci (8.4) (pull_request) Successful in 1m25s
tests / ci (8.5) (pull_request) Successful in 1m58s
When no active approval workflow exists, ApprovalService::submit() was
silently returning, leaving the request in Draft while showing a false
success message. Now throws a RuntimeException as a safety net, and the
Livewire component guards before creating any records and shows a clear
error to the user.
2026-03-06 04:23:55 +00:00
72fb80b673 Add tests for town sync job and search endpoint
All checks were successful
linter / quality (pull_request) Successful in 1m37s
security / Dependency Audit (pull_request) Successful in 1m36s
security / Static Analysis (pull_request) Successful in 1m25s
tests / ci (8.4) (pull_request) Successful in 1m57s
tests / ci (8.5) (pull_request) Successful in 1m31s
- SyncTownsTest: covers API syncing, pagination, upsert, retired towns, error logging, and success logging
- TownSearchTest: covers auth requirement, min query length, prefix matching, state filtering, retired town exclusion, result limit, response shape, and ordering
- Update TownFactory with retired() and inState() states
2026-03-06 04:22:49 +00:00
a37ada96e2 Add nightly town sync from ArcGIS API with autocomplete
All checks were successful
linter / quality (pull_request) Successful in 1m21s
security / Dependency Audit (pull_request) Successful in 1m25s
security / Static Analysis (pull_request) Successful in 1m49s
tests / ci (8.4) (pull_request) Successful in 1m23s
tests / ci (8.5) (pull_request) Successful in 1m27s
- Add towns table with town_pid, town_name, state, population, town_class, date_retired
- Add AustralianState enum with label and abbreviation helpers
- Add Town model with active() and search() scopes
- Add SyncTowns job that paginates ArcGIS API and upserts all 1977 towns
- Schedule SyncTowns to run nightly at 02:00
- Add /towns/search endpoint returning JSON suggestions filtered by name and state
- Add Alpine-powered autocomplete on origin/destination fields in create form
- Add state filter dropdown in journeys card header to narrow autocomplete results
2026-03-06 04:16:55 +00:00
89b3ddb793 Merge pull request 'Fix persistent LDAP volumes preventing bootstrap.ldif from reloading' (#11) from fix/ldap-bootstrap into master
Some checks failed
linter / quality (push) Successful in 1m22s
security / Static Analysis (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
security / Dependency Audit (push) Has been cancelled
Reviewed-on: #11
2026-03-06 12:08:18 +08:00
a4a31c97e3 Merge pull request 'Update project config and remove tailwindcss skill' (#10) from feature/ldap-email-bootstrap into master
Some checks failed
linter / quality (push) Has been cancelled
security / Dependency Audit (push) Has been cancelled
security / Static Analysis (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Reviewed-on: #10
2026-03-06 12:02:20 +08:00
d59ec55999 Remove persistent LDAP volumes so bootstrap.ldif always applies on startup
All checks were successful
linter / quality (pull_request) Successful in 1m45s
security / Dependency Audit (pull_request) Successful in 1m21s
security / Static Analysis (pull_request) Successful in 2m11s
tests / ci (8.4) (pull_request) Successful in 1m27s
tests / ci (8.5) (pull_request) Successful in 2m21s
The osixia/openldap image only runs bootstrap LDIF when the database is
empty. Named volumes (sail-ldap-data, sail-ldap-config) caused changes
to bootstrap.ldif to be ignored after the first run. Removing these
volumes ensures the test LDAP directory is always seeded fresh from the
bootstrap file on each sail up.
2026-03-06 04:02:18 +00:00
2ef5014919 Merge pull request 'Default Bootstrap theme to dark mode' (#9) from feature/dark-mode-default into master
Some checks failed
linter / quality (push) Has been cancelled
security / Dependency Audit (push) Has been cancelled
security / Static Analysis (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Reviewed-on: #9
2026-03-06 12:01:57 +08:00
e07984fa97 Default Bootstrap theme to dark mode
All checks were successful
linter / quality (pull_request) Successful in 1m51s
security / Dependency Audit (pull_request) Successful in 1m24s
security / Static Analysis (pull_request) Successful in 1m53s
tests / ci (8.4) (pull_request) Successful in 1m28s
tests / ci (8.5) (pull_request) Successful in 1m54s
- Change default theme from light to dark for first-time visitors
- Fix Alpine reactive theme toggle so icons and data-bs-theme update correctly
- Remove hardcoded bg-light classes that prevented dark mode from applying
- Fix broken duplicate Bootstrap bundle import in app.js
2026-03-06 03:54:34 +00:00
dd3c623bfc Merge pull request 'Seed OpenLDAP with bootstrap users including email addresses' (#8) from feature/ldap-email-bootstrap into master
All checks were successful
linter / quality (push) Successful in 1m34s
security / Dependency Audit (push) Successful in 1m24s
security / Static Analysis (push) Successful in 2m42s
tests / ci (8.4) (push) Successful in 1m23s
tests / ci (8.5) (push) Successful in 1m51s
Reviewed-on: #8
2026-03-06 10:48:31 +08:00
16 changed files with 761 additions and 23 deletions

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Enums;
enum AustralianState: int
{
case NSW = 1;
case VIC = 2;
case QLD = 3;
case SA = 4;
case WA = 5;
case TAS = 6;
case NT = 7;
case ACT = 8;
public function label(): string
{
return match ($this) {
self::NSW => 'New South Wales',
self::VIC => 'Victoria',
self::QLD => 'Queensland',
self::SA => 'South Australia',
self::WA => 'Western Australia',
self::TAS => 'Tasmania',
self::NT => 'Northern Territory',
self::ACT => 'Australian Capital Territory',
};
}
public function abbreviation(): string
{
return match ($this) {
self::NSW => 'NSW',
self::VIC => 'VIC',
self::QLD => 'QLD',
self::SA => 'SA',
self::WA => 'WA',
self::TAS => 'TAS',
self::NT => 'NT',
self::ACT => 'ACT',
};
}
}

76
app/Jobs/SyncTowns.php Normal file
View File

@@ -0,0 +1,76 @@
<?php
namespace App\Jobs;
use App\Models\Town;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class SyncTowns implements ShouldQueue
{
use Queueable;
private const API_URL = 'https://services-ap1.arcgis.com/ypkPEy1AmwPKGNNv/arcgis/rest/services/Town_Point/FeatureServer/0/query';
private const PAGE_SIZE = 1000;
public int $timeout = 300;
public function handle(): void
{
$offset = 0;
$synced = 0;
do {
$response = Http::timeout(30)->get(self::API_URL, [
'outFields' => 'town_pid,town_name,state,population,town_class,date_retired',
'where' => '1=1',
'f' => 'json',
'resultOffset' => $offset,
'resultRecordCount' => self::PAGE_SIZE,
'orderByFields' => 'objectid ASC',
]);
if ($response->failed()) {
Log::error('SyncTowns: API request failed', ['offset' => $offset, 'status' => $response->status()]);
break;
}
$features = $response->json('features', []);
if (empty($features)) {
break;
}
$records = collect($features)->map(fn (array $feature) => [
'town_pid' => $feature['attributes']['town_pid'],
'town_name' => $feature['attributes']['town_name'],
'state' => $feature['attributes']['state'],
'population' => $feature['attributes']['population'],
'town_class' => $feature['attributes']['town_class'],
'date_retired' => isset($feature['attributes']['date_retired'])
? Carbon::createFromTimestampMs($feature['attributes']['date_retired'])
: null,
'updated_at' => now(),
'created_at' => now(),
])->toArray();
Town::upsert($records, uniqueBy: ['town_pid'], update: [
'town_name',
'state',
'population',
'town_class',
'date_retired',
'updated_at',
]);
$synced += count($features);
$offset += self::PAGE_SIZE;
} while (count($features) === self::PAGE_SIZE);
Log::info("SyncTowns: synced {$synced} towns.");
}
}

41
app/Models/Town.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
namespace App\Models;
use App\Enums\AustralianState;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Town extends Model
{
/** @use HasFactory<\Database\Factories\TownFactory> */
use HasFactory;
protected $fillable = [
'town_pid',
'town_name',
'state',
'population',
'town_class',
'date_retired',
];
protected function casts(): array
{
return [
'state' => AustralianState::class,
'date_retired' => 'datetime',
];
}
public function scopeActive(Builder $query): void
{
$query->whereNull('date_retired');
}
public function scopeSearch(Builder $query, string $term): void
{
$query->where('town_name', 'like', $term.'%');
}
}

View File

@@ -17,7 +17,7 @@ class ApprovalService
$workflow = $travelRequest->workflow;
if (! $workflow) {
return;
throw new \RuntimeException('No active approval workflow is configured.');
}
$steps = $workflow->steps()->orderBy('order')->get();

View File

@@ -97,8 +97,6 @@ services:
LDAP_READONLY_USER_USERNAME: '${LDAP_READONLY_USERNAME:-readonly}'
LDAP_READONLY_USER_PASSWORD: '${LDAP_READONLY_PASSWORD:-readonly}'
volumes:
- 'sail-ldap-data:/var/lib/ldap'
- 'sail-ldap-config:/etc/ldap/slapd.d'
- './docker/openldap/bootstrap.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/bootstrap.ldif'
networks:
- sail
@@ -136,7 +134,3 @@ volumes:
driver: local
sail-redis:
driver: local
sail-ldap-data:
driver: local
sail-ldap-config:
driver: local

View File

@@ -0,0 +1,42 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Town>
*/
class TownFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'town_pid' => 'TWN'.$this->faker->unique()->numerify('####'),
'town_name' => $this->faker->city(),
'state' => $this->faker->numberBetween(1, 8),
'population' => $this->faker->numberBetween(100, 500000),
'town_class' => $this->faker->numberBetween(0, 5),
'date_retired' => null,
];
}
public function retired(): static
{
return $this->state(fn (array $attributes) => [
'date_retired' => now()->subDays(30),
]);
}
public function inState(\App\Enums\AustralianState $state): static
{
return $this->state(fn (array $attributes) => [
'state' => $state->value,
]);
}
}

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('towns', function (Blueprint $table) {
$table->id();
$table->string('town_pid', 15)->unique();
$table->string('town_name', 50);
$table->unsignedTinyInteger('state');
$table->unsignedInteger('population')->nullable();
$table->unsignedTinyInteger('town_class')->nullable();
$table->timestamp('date_retired')->nullable();
$table->timestamps();
$table->index(['town_name', 'state']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('towns');
}
};

View File

@@ -16,28 +16,28 @@ class DatabaseSeeder extends Seeder
// Create roles
$this->call(RoleSeeder::class);
// Create admin user
// Create admin user — email matches LDAP mail attribute
$admin = User::factory()->create([
'name' => 'Administrator',
'email' => 'admin@example.com',
'email' => 'admin@travel.local',
'username' => 'admin',
'password' => Hash::make('password'),
]);
$admin->assignRole('administrator');
// Create a travel approver
// Create a travel approver — email matches LDAP mail attribute
$approver = User::factory()->create([
'name' => 'Travel Approver',
'email' => 'approver@example.com',
'email' => 'approver@travel.local',
'username' => 'approver',
'password' => Hash::make('password'),
]);
$approver->assignRole('travel_approver');
// Create a staff user
// Create a staff user — email matches LDAP mail attribute
$staff = User::factory()->create([
'name' => 'Staff Member',
'email' => 'staff@example.com',
'email' => 'staff@travel.local',
'username' => 'staff',
'password' => Hash::make('password'),
]);

View File

@@ -1,6 +1,6 @@
<!doctype html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}"
x-data="{ theme: localStorage.getItem('theme') || 'light' }"
x-data="{ theme: localStorage.getItem('theme') || 'dark' }"
x-init="$watch('theme', val => { document.documentElement.setAttribute('data-bs-theme', val); localStorage.setItem('theme', val); }); document.documentElement.setAttribute('data-bs-theme', theme);"
:data-bs-theme="theme"
>

View File

@@ -1,6 +1,6 @@
<!doctype html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}"
x-data="{ theme: localStorage.getItem('theme') || 'light' }"
x-data="{ theme: localStorage.getItem('theme') || 'dark' }"
x-init="document.documentElement.setAttribute('data-bs-theme', theme);"
:data-bs-theme="theme"
>

View File

@@ -92,7 +92,6 @@ new #[Layout('components.layouts.app')] class extends Component {
public function saveDraft(): void
{
$this->saveRequest(submit: false);
session()->flash('success', 'Draft saved successfully.');
}
public function submit(): void
@@ -102,6 +101,12 @@ new #[Layout('components.layouts.app')] class extends Component {
private function saveRequest(bool $submit): void
{
if ($submit && ! ApprovalWorkflow::where('is_active', true)->exists()) {
$this->addError('workflow', 'No active approval workflow is configured. Please contact an administrator.');
return;
}
$this->validate([
'emergencyFullName' => ['required', 'string', 'max:255'],
'emergencyPhone' => ['required', 'string', 'max:50'],
@@ -182,6 +187,8 @@ new #[Layout('components.layouts.app')] class extends Component {
if ($submit) {
app(ApprovalService::class)->submit($travelRequest);
session()->flash('success', 'Travel request submitted for approval.');
} else {
session()->flash('success', 'Draft saved successfully.');
}
$this->redirect(route('travel-requests.show', $travelRequest), navigate: true);
@@ -209,6 +216,10 @@ new #[Layout('components.layouts.app')] class extends Component {
</div>
@endif
@error('workflow')
<div class="alert alert-danger">{{ $message }}</div>
@enderror
<form wire:submit.prevent>
{{-- Applicant Details --}}
@@ -309,10 +320,42 @@ new #[Layout('components.layouts.app')] class extends Component {
</div>
{{-- Journeys --}}
<div class="card mb-4">
<div class="card mb-4" x-data="{
stateFilter: '',
suggestions: {},
open: {},
async searchTowns(key, query) {
if (query.length < 2) {
this.suggestions[key] = [];
this.open[key] = false;
return;
}
const params = new URLSearchParams({ query });
if (this.stateFilter) params.append('state', this.stateFilter);
const res = await fetch(`/towns/search?${params}`, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await res.json();
this.suggestions[key] = data;
this.open[key] = data.length > 0;
},
selectTown(key, wirePath, name) {
$wire.set(wirePath, name);
this.suggestions[key] = [];
this.open[key] = false;
}
}">
<div class="card-header fw-semibold d-flex justify-content-between align-items-center">
Journeys
<button type="button" wire:click="addJourney" class="btn btn-sm btn-outline-primary">+ Add Journey</button>
<span>Journeys</span>
<div class="d-flex align-items-center gap-2">
<select x-model="stateFilter" class="form-select form-select-sm" style="width: auto;" title="Filter town autocomplete by state">
<option value="">All states</option>
@foreach(\App\Enums\AustralianState::cases() as $state)
<option value="{{ $state->value }}">{{ $state->abbreviation() }}</option>
@endforeach
</select>
<button type="button" wire:click="addJourney" class="btn btn-sm btn-outline-primary">+ Add Journey</button>
</div>
</div>
<div class="card-body">
@error('journeys') <div class="alert alert-danger">{{ $message }}</div> @enderror
@@ -328,13 +371,53 @@ new #[Layout('components.layouts.app')] class extends Component {
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Origin <span class="text-danger">*</span></label>
<input type="text" wire:model="journeys.{{ $i }}.origin" class="form-control @error('journeys.'.$i.'.origin') is-invalid @enderror" placeholder="e.g. Perth">
@error('journeys.'.$i.'.origin') <div class="invalid-feedback">{{ $message }}</div> @enderror
<div class="position-relative">
<input
type="text"
wire:model="journeys.{{ $i }}.origin"
class="form-control @error('journeys.'.$i.'.origin') is-invalid @enderror"
placeholder="e.g. Perth"
autocomplete="off"
x-on:input.debounce.300ms="searchTowns('{{ $i }}-origin', $event.target.value)"
x-on:blur="$nextTick(() => open['{{ $i }}-origin'] = false)"
>
<ul class="dropdown-menu w-100" x-show="open['{{ $i }}-origin'] ?? false" style="z-index: 1055;">
<template x-for="s in (suggestions['{{ $i }}-origin'] ?? [])">
<li>
<button type="button" class="dropdown-item small"
x-text="s.label"
x-on:mousedown.prevent="selectTown('{{ $i }}-origin', 'journeys.{{ $i }}.origin', s.name)"
></button>
</li>
</template>
</ul>
</div>
@error('journeys.'.$i.'.origin') <div class="invalid-feedback d-block">{{ $message }}</div> @enderror
</div>
<div class="col-md-3">
<label class="form-label">Destination <span class="text-danger">*</span></label>
<input type="text" wire:model="journeys.{{ $i }}.destination" class="form-control @error('journeys.'.$i.'.destination') is-invalid @enderror" placeholder="e.g. Sydney">
@error('journeys.'.$i.'.destination') <div class="invalid-feedback">{{ $message }}</div> @enderror
<div class="position-relative">
<input
type="text"
wire:model="journeys.{{ $i }}.destination"
class="form-control @error('journeys.'.$i.'.destination') is-invalid @enderror"
placeholder="e.g. Sydney"
autocomplete="off"
x-on:input.debounce.300ms="searchTowns('{{ $i }}-destination', $event.target.value)"
x-on:blur="$nextTick(() => open['{{ $i }}-destination'] = false)"
>
<ul class="dropdown-menu w-100" x-show="open['{{ $i }}-destination'] ?? false" style="z-index: 1055;">
<template x-for="s in (suggestions['{{ $i }}-destination'] ?? [])">
<li>
<button type="button" class="dropdown-item small"
x-text="s.label"
x-on:mousedown.prevent="selectTown('{{ $i }}-destination', 'journeys.{{ $i }}.destination', s.name)"
></button>
</li>
</template>
</ul>
</div>
@error('journeys.'.$i.'.destination') <div class="invalid-feedback d-block">{{ $message }}</div> @enderror
</div>
<div class="col-md-2">
<label class="form-label">Date <span class="text-danger">*</span></label>

View File

@@ -1,8 +1,12 @@
<?php
use App\Jobs\SyncTowns;
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
Schedule::job(new SyncTowns)->dailyAt('02:00');

View File

@@ -1,5 +1,7 @@
<?php
use App\Models\Town;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
@@ -11,9 +13,34 @@ Route::post('/logout', function () {
Auth::logout();
session()->invalidate();
session()->regenerateToken();
return redirect()->route('login');
})->name('logout')->middleware('auth');
// Town autocomplete search
Route::middleware(['auth'])->get('/towns/search', function (Request $request) {
$query = $request->string('query')->trim();
if ($query->length() < 2) {
return response()->json([]);
}
$towns = Town::active()
->search($query)
->when($request->filled('state'), fn ($q) => $q->where('state', $request->integer('state')))
->orderBy('town_name')
->limit(10)
->get(['town_name', 'state']);
return response()->json(
$towns->map(fn ($town) => [
'name' => $town->town_name,
'state' => $town->state->abbreviation(),
'label' => $town->town_name.', '.$town->state->abbreviation(),
])
);
})->name('towns.search');
// Authenticated
Route::middleware(['auth'])->group(function () {
Route::livewire('/dashboard', 'dashboard')->name('dashboard');

View File

@@ -0,0 +1,150 @@
<?php
namespace Tests\Feature\Jobs;
use App\Jobs\SyncTowns;
use App\Models\Town;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;
class SyncTownsTest extends TestCase
{
use RefreshDatabase;
private const API_URL = 'https://services-ap1.arcgis.com/ypkPEy1AmwPKGNNv/arcgis/rest/services/Town_Point/FeatureServer/0/query*';
private function makeTownFeature(array $overrides = []): array
{
return [
'attributes' => array_merge([
'town_pid' => 'TWN0001',
'town_name' => 'Perth',
'state' => 5,
'population' => 2000000,
'town_class' => 1,
'date_retired' => null,
], $overrides),
];
}
public function test_syncs_towns_from_api(): void
{
Http::fake([
self::API_URL => Http::sequence()
->push(['features' => [
$this->makeTownFeature(['town_pid' => 'TWN0001', 'town_name' => 'Perth', 'state' => 5]),
$this->makeTownFeature(['town_pid' => 'TWN0002', 'town_name' => 'Fremantle', 'state' => 5]),
]])
->push(['features' => []]),
]);
(new SyncTowns)->handle();
$this->assertDatabaseHas('towns', ['town_pid' => 'TWN0001', 'town_name' => 'Perth']);
$this->assertDatabaseHas('towns', ['town_pid' => 'TWN0002', 'town_name' => 'Fremantle']);
$this->assertDatabaseCount('towns', 2);
}
public function test_paginates_through_all_pages(): void
{
$pageOne = [];
for ($i = 1; $i <= 1000; $i++) {
$pageOne[] = $this->makeTownFeature(['town_pid' => 'TWN'.str_pad($i, 4, '0', STR_PAD_LEFT)]);
}
Http::fake([
self::API_URL => Http::sequence()
->push(['features' => $pageOne])
->push(['features' => [
$this->makeTownFeature(['town_pid' => 'TWN1001', 'town_name' => 'Albany']),
]])
->push(['features' => []]),
]);
(new SyncTowns)->handle();
$this->assertDatabaseCount('towns', 1001);
$this->assertDatabaseHas('towns', ['town_pid' => 'TWN1001', 'town_name' => 'Albany']);
}
public function test_upserts_existing_towns_with_updated_data(): void
{
Town::factory()->create([
'town_pid' => 'TWN0001',
'town_name' => 'Old Name',
'state' => 5,
]);
Http::fake([
self::API_URL => Http::sequence()
->push(['features' => [
$this->makeTownFeature(['town_pid' => 'TWN0001', 'town_name' => 'Perth', 'population' => 2100000]),
]])
->push(['features' => []]),
]);
(new SyncTowns)->handle();
$this->assertDatabaseCount('towns', 1);
$this->assertDatabaseHas('towns', ['town_pid' => 'TWN0001', 'town_name' => 'Perth', 'population' => 2100000]);
}
public function test_marks_retired_towns_with_date_retired(): void
{
$retiredAt = Carbon::parse('2024-01-15')->startOfDay();
Http::fake([
self::API_URL => Http::sequence()
->push(['features' => [
$this->makeTownFeature([
'town_pid' => 'TWN0001',
'town_name' => 'OldTown',
'date_retired' => $retiredAt->getTimestampMs(),
]),
]])
->push(['features' => []]),
]);
(new SyncTowns)->handle();
$town = Town::where('town_pid', 'TWN0001')->first();
$this->assertNotNull($town->date_retired);
}
public function test_logs_error_and_stops_when_api_fails(): void
{
Log::spy();
Http::fake([
self::API_URL => Http::response(null, 500),
]);
(new SyncTowns)->handle();
$this->assertDatabaseCount('towns', 0);
Log::shouldHaveReceived('error')
->once()
->with('SyncTowns: API request failed', \Mockery::any());
}
public function test_logs_synced_count_on_success(): void
{
Log::spy();
Http::fake([
self::API_URL => Http::sequence()
->push(['features' => [
$this->makeTownFeature(['town_pid' => 'TWN0001']),
$this->makeTownFeature(['town_pid' => 'TWN0002']),
]])
->push(['features' => []]),
]);
(new SyncTowns)->handle();
Log::shouldHaveReceived('info')->once()->with('SyncTowns: synced 2 towns.');
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace Tests\Feature\Towns;
use App\Enums\AustralianState;
use App\Models\Town;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TownSearchTest extends TestCase
{
use RefreshDatabase;
private function actingAsUser(): static
{
return $this->actingAs(User::factory()->create());
}
public function test_requires_authentication(): void
{
$this->get('/towns/search?query=Perth')->assertRedirect('/login');
}
public function test_returns_empty_array_for_query_shorter_than_two_characters(): void
{
Town::factory()->create(['town_name' => 'Perth']);
$this->actingAsUser()
->getJson('/towns/search?query=P')
->assertOk()
->assertExactJson([]);
}
public function test_returns_matching_towns_by_prefix(): void
{
Town::factory()->create(['town_name' => 'Perth', 'state' => AustralianState::WA->value]);
Town::factory()->create(['town_name' => 'Pertham', 'state' => AustralianState::WA->value]);
Town::factory()->create(['town_name' => 'Sydney', 'state' => AustralianState::NSW->value]);
$response = $this->actingAsUser()
->getJson('/towns/search?query=Per')
->assertOk();
$this->assertCount(2, $response->json());
$this->assertSame('Perth, WA', $response->json('0.label'));
$this->assertSame('Pertham, WA', $response->json('1.label'));
}
public function test_filters_results_by_state(): void
{
Town::factory()->create(['town_name' => 'Port Hedland', 'state' => AustralianState::WA->value]);
Town::factory()->create(['town_name' => 'Port Augusta', 'state' => AustralianState::SA->value]);
$response = $this->actingAsUser()
->getJson('/towns/search?query=Port&state='.AustralianState::WA->value)
->assertOk();
$this->assertCount(1, $response->json());
$this->assertSame('Port Hedland', $response->json('0.name'));
}
public function test_excludes_retired_towns(): void
{
Town::factory()->create(['town_name' => 'Perth', 'state' => AustralianState::WA->value]);
Town::factory()->retired()->create(['town_name' => 'Perthville', 'state' => AustralianState::WA->value]);
$response = $this->actingAsUser()
->getJson('/towns/search?query=Per')
->assertOk();
$this->assertCount(1, $response->json());
$this->assertSame('Perth', $response->json('0.name'));
}
public function test_returns_at_most_ten_results(): void
{
Town::factory()->count(15)->create([
'town_name' => 'Perth',
'state' => AustralianState::WA->value,
]);
$response = $this->actingAsUser()
->getJson('/towns/search?query=Per')
->assertOk();
$this->assertCount(10, $response->json());
}
public function test_response_includes_name_state_and_label_fields(): void
{
Town::factory()->create(['town_name' => 'Perth', 'state' => AustralianState::WA->value]);
$response = $this->actingAsUser()
->getJson('/towns/search?query=Per')
->assertOk();
$result = $response->json('0');
$this->assertArrayHasKey('name', $result);
$this->assertArrayHasKey('state', $result);
$this->assertArrayHasKey('label', $result);
$this->assertSame('Perth', $result['name']);
$this->assertSame('WA', $result['state']);
$this->assertSame('Perth, WA', $result['label']);
}
public function test_results_are_ordered_alphabetically(): void
{
Town::factory()->create(['town_name' => 'Pertham', 'state' => AustralianState::WA->value]);
Town::factory()->create(['town_name' => 'Perth', 'state' => AustralianState::WA->value]);
$response = $this->actingAsUser()
->getJson('/towns/search?query=Per')
->assertOk();
$this->assertSame('Perth', $response->json('0.name'));
$this->assertSame('Pertham', $response->json('1.name'));
}
}

View File

@@ -0,0 +1,124 @@
<?php
namespace Tests\Feature;
use App\Enums\ApprovalStatus;
use App\Enums\JourneyMethod;
use App\Enums\TravelStatus;
use App\Models\ApprovalStep;
use App\Models\ApprovalWorkflow;
use App\Models\TravelRequest;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class TravelRequestSubmissionTest extends TestCase
{
use RefreshDatabase;
private User $staff;
/** @var array<string, mixed> */
private array $validFormData;
protected function setUp(): void
{
parent::setUp();
Role::firstOrCreate(['name' => 'staff']);
Role::firstOrCreate(['name' => 'travel_approver']);
Role::firstOrCreate(['name' => 'administrator']);
$this->staff = User::factory()->create();
$this->staff->assignRole('staff');
$this->validFormData = [
'emergencyFullName' => 'Jane Doe',
'emergencyPhone' => '0400000000',
'emergencyRelationship' => 'Spouse',
'reasonSummary' => 'Attending a medical conference in Sydney',
'journeys' => [
[
'origin' => 'Perth',
'destination' => 'Sydney',
'date' => '2026-04-01',
'time' => '09:00',
'method' => JourneyMethod::Air->value,
],
],
'costCodes' => [],
];
}
private function makeWorkflow(): ApprovalWorkflow
{
$workflow = ApprovalWorkflow::factory()->create(['is_active' => true]);
ApprovalStep::factory()->create([
'workflow_id' => $workflow->id,
'order' => 1,
'name' => 'Travel Approver Review',
'role' => 'travel_approver',
]);
return $workflow;
}
public function test_submitting_form_with_active_workflow_sets_status_to_pending(): void
{
$this->makeWorkflow();
Livewire::actingAs($this->staff)
->test('travel-request.create')
->set($this->validFormData)
->call('submit');
$request = TravelRequest::first();
$this->assertNotNull($request);
$this->assertSame(TravelStatus::Pending, $request->status);
$this->assertNotNull($request->submitted_at);
}
public function test_submitting_form_with_active_workflow_creates_pending_approval(): void
{
$this->makeWorkflow();
Livewire::actingAs($this->staff)
->test('travel-request.create')
->set($this->validFormData)
->call('submit');
$request = TravelRequest::first();
$this->assertCount(1, $request->approvals);
$this->assertSame(ApprovalStatus::Pending->value, $request->approvals->first()->status->value);
}
public function test_submitting_form_with_no_active_workflow_shows_error(): void
{
Livewire::actingAs($this->staff)
->test('travel-request.create')
->set($this->validFormData)
->call('submit')
->assertHasErrors(['workflow']);
$this->assertDatabaseCount('travel_requests', 0);
}
public function test_saving_draft_does_not_submit_for_approval(): void
{
$this->makeWorkflow();
Livewire::actingAs($this->staff)
->test('travel-request.create')
->set($this->validFormData)
->call('saveDraft');
$request = TravelRequest::first();
$this->assertNotNull($request);
$this->assertSame(TravelStatus::Draft, $request->status);
$this->assertNull($request->submitted_at);
$this->assertCount(0, $request->approvals);
}
}