Compare commits
12 Commits
fix/ldap-a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 558759ac48 | |||
| 7b26fc57f4 | |||
| 35723fb226 | |||
| e01caf678b | |||
| 72fb80b673 | |||
| a37ada96e2 | |||
| 89b3ddb793 | |||
| a4a31c97e3 | |||
| d59ec55999 | |||
| 2ef5014919 | |||
| e07984fa97 | |||
| dd3c623bfc |
43
app/Enums/AustralianState.php
Normal file
43
app/Enums/AustralianState.php
Normal 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
76
app/Jobs/SyncTowns.php
Normal 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
41
app/Models/Town.php
Normal 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.'%');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
42
database/factories/TownFactory.php
Normal file
42
database/factories/TownFactory.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
35
database/migrations/2026_03_06_041215_create_towns_table.php
Normal file
35
database/migrations/2026_03_06_041215_create_towns_table.php
Normal 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');
|
||||
}
|
||||
};
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
150
tests/Feature/Jobs/SyncTownsTest.php
Normal file
150
tests/Feature/Jobs/SyncTownsTest.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
119
tests/Feature/Towns/TownSearchTest.php
Normal file
119
tests/Feature/Towns/TownSearchTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
124
tests/Feature/TravelRequestSubmissionTest.php
Normal file
124
tests/Feature/TravelRequestSubmissionTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user