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
This commit was merged in pull request #12.
This commit is contained in:
2026-03-06 12:24:19 +08:00
10 changed files with 616 additions and 7 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

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

@@ -309,10 +309,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 +360,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'));
}
}