2 Commits

Author SHA1 Message Date
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
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
11 changed files with 7 additions and 622 deletions

View File

@@ -1,43 +0,0 @@
<?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',
};
}
}

View File

@@ -1,76 +0,0 @@
<?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.");
}
}

View File

@@ -1,41 +0,0 @@
<?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

@@ -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

@@ -1,42 +0,0 @@
<?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

@@ -1,35 +0,0 @@
<?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

@@ -309,42 +309,10 @@ new #[Layout('components.layouts.app')] class extends Component {
</div>
{{-- Journeys --}}
<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 mb-4">
<div class="card-header fw-semibold d-flex justify-content-between align-items-center">
<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>
Journeys
<button type="button" wire:click="addJourney" class="btn btn-sm btn-outline-primary">+ Add Journey</button>
</div>
<div class="card-body">
@error('journeys') <div class="alert alert-danger">{{ $message }}</div> @enderror
@@ -360,53 +328,13 @@ 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>
<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
<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>
<div class="col-md-3">
<label class="form-label">Destination <span class="text-danger">*</span></label>
<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
<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>
<div class="col-md-2">
<label class="form-label">Date <span class="text-danger">*</span></label>

View File

@@ -1,12 +1,8 @@
<?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,7 +1,5 @@
<?php
use App\Models\Town;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
@@ -13,34 +11,9 @@ 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

@@ -1,150 +0,0 @@
<?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

@@ -1,119 +0,0 @@
<?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'));
}
}