2944 lines
108 KiB
PHP
2944 lines
108 KiB
PHP
<?php
|
|
|
|
namespace Blax\Addresses\Tests\Unit;
|
|
|
|
use Blax\Addresses\AddressesServiceProvider;
|
|
use Blax\Addresses\Enums\AddressLinkType;
|
|
use Blax\Addresses\Models\Address;
|
|
use Blax\Addresses\Models\AddressLink;
|
|
use Blax\Addresses\Models\AddressAssignment;
|
|
use Blax\Addresses\Services\AddressService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Orchestra\Testbench\TestCase;
|
|
use Workbench\App\Models\Company;
|
|
use Workbench\App\Models\Job;
|
|
use Workbench\App\Models\User;
|
|
|
|
class HasAddressesTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
protected function getPackageProviders($app): array
|
|
{
|
|
return [AddressesServiceProvider::class];
|
|
}
|
|
|
|
protected function defineEnvironment($app): void
|
|
{
|
|
$app['config']->set('database.default', 'testing');
|
|
$app['config']->set('database.connections.testing', [
|
|
'driver' => 'sqlite',
|
|
'database' => ':memory:',
|
|
'prefix' => '',
|
|
'foreign_key_constraints' => true,
|
|
]);
|
|
|
|
// Geocoding hits a live HTTP API (Nominatim) and is paced by a
|
|
// 1-req/sec cache lock — both of which would make the legacy
|
|
// suite hang. Geocoding has its own test file with Http::fake().
|
|
$app['config']->set('addresses.geocoding.enabled', false);
|
|
}
|
|
|
|
protected function defineDatabaseMigrations(): void
|
|
{
|
|
$this->loadMigrationsFrom(__DIR__ . '/../../workbench/database/migrations');
|
|
}
|
|
|
|
// ─── basic relationship ──────────────────────────────────────
|
|
|
|
public function test_model_has_no_addresses_by_default(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$this->assertCount(0, $user->addresses);
|
|
$this->assertCount(0, $user->addressLinks);
|
|
}
|
|
|
|
// ─── addAddress ──────────────────────────────────────────────
|
|
|
|
public function test_add_address_creates_address_and_link(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$link = $user->addAddress([
|
|
'street' => 'Hauptstraße 1',
|
|
'city' => 'Vienna',
|
|
'postal_code' => '1010',
|
|
'country_code' => 'AT',
|
|
], AddressLinkType::Home);
|
|
|
|
$this->assertInstanceOf(AddressLink::class, $link);
|
|
$this->assertInstanceOf(Address::class, $link->address);
|
|
$this->assertEquals('Hauptstraße 1', $link->address->street);
|
|
$this->assertEquals('Vienna', $link->address->city);
|
|
$this->assertEquals(AddressLinkType::Home, $link->type);
|
|
}
|
|
|
|
public function test_add_address_with_default_type(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$link = $user->addAddress([
|
|
'city' => 'London',
|
|
'country_code' => 'GB',
|
|
]);
|
|
|
|
$this->assertEquals(AddressLinkType::Other, $link->type);
|
|
}
|
|
|
|
public function test_add_address_with_string_type(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$link = $user->addAddress([
|
|
'city' => 'Berlin',
|
|
], 'office');
|
|
|
|
$this->assertEquals(AddressLinkType::Office, $link->type);
|
|
}
|
|
|
|
public function test_add_address_with_full_details(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$link = $user->addAddress([
|
|
'street' => '350 Fifth Avenue',
|
|
'street_extra' => 'Suite 3200',
|
|
'building' => 'Empire State Building',
|
|
'floor' => '32',
|
|
'room' => '3201',
|
|
'postal_code' => '10118',
|
|
'city' => 'New York',
|
|
'state' => 'NY',
|
|
'county' => 'New York County',
|
|
'country_code' => 'US',
|
|
'latitude' => 40.7484405,
|
|
'longitude' => -73.9856644,
|
|
'altitude' => 373.0, // approximate AMSL for floor 32
|
|
'notes' => 'Reception on the left',
|
|
], AddressLinkType::Office, ['label' => 'Empire State Office']);
|
|
|
|
$address = $link->address;
|
|
|
|
$this->assertEquals('Empire State Office', $link->label);
|
|
$this->assertEquals('350 Fifth Avenue', $address->street);
|
|
$this->assertEquals('Suite 3200', $address->street_extra);
|
|
$this->assertEquals('Empire State Building', $address->building);
|
|
$this->assertEquals('32', $address->floor);
|
|
$this->assertEquals('3201', $address->room);
|
|
$this->assertEquals('10118', $address->postal_code);
|
|
$this->assertEquals('New York', $address->city);
|
|
$this->assertEquals('NY', $address->state);
|
|
$this->assertEquals('New York County', $address->county);
|
|
$this->assertEquals('US', $address->country_code);
|
|
$this->assertEqualsWithDelta(40.7484405, $address->latitude, 0.0001);
|
|
$this->assertEqualsWithDelta(-73.9856644, $address->longitude, 0.0001);
|
|
$this->assertEqualsWithDelta(373.0, $address->altitude, 0.01);
|
|
$this->assertEquals('Reception on the left', $address->notes);
|
|
}
|
|
|
|
public function test_add_address_coordinates_only(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$link = $user->addAddress([
|
|
'latitude' => 47.0707,
|
|
'longitude' => 15.4395,
|
|
'altitude' => 853.2,
|
|
], AddressLinkType::PointOfInterest);
|
|
|
|
$address = $link->address;
|
|
|
|
$this->assertTrue($address->hasCoordinates());
|
|
$this->assertTrue($address->hasAltitude());
|
|
$this->assertNull($address->street);
|
|
$this->assertNull($address->city);
|
|
$this->assertEquals(AddressLinkType::PointOfInterest, $link->type);
|
|
}
|
|
|
|
// ─── linkAddress ─────────────────────────────────────────────
|
|
|
|
public function test_link_existing_address(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$address = Address::create([
|
|
'street' => 'Reusable Street 5',
|
|
'city' => 'Graz',
|
|
'country_code' => 'AT',
|
|
]);
|
|
|
|
$link = $user->linkAddress($address, AddressLinkType::Home);
|
|
|
|
$this->assertEquals($address->id, $link->address_id);
|
|
$this->assertTrue($user->hasAddresses());
|
|
}
|
|
|
|
public function test_link_address_by_id(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$address = Address::create(['city' => 'Salzburg', 'country_code' => 'AT']);
|
|
|
|
$link = $user->linkAddress($address->id, AddressLinkType::Billing);
|
|
|
|
$this->assertEquals($address->id, $link->address_id);
|
|
$this->assertEquals(AddressLinkType::Billing, $link->type);
|
|
}
|
|
|
|
public function test_same_address_linked_multiple_times_with_different_types(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$address = Address::create(['city' => 'Linz', 'country_code' => 'AT']);
|
|
|
|
$user->linkAddress($address, AddressLinkType::Office);
|
|
$user->linkAddress($address, AddressLinkType::Billing);
|
|
|
|
$this->assertCount(2, $user->addressLinks()->get());
|
|
$this->assertCount(2, $user->addresses()->get());
|
|
}
|
|
|
|
public function test_same_address_linked_to_different_models(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$company = Company::create(['name' => 'ACME Corp']);
|
|
$address = Address::create(['city' => 'Innsbruck', 'country_code' => 'AT']);
|
|
|
|
$user->linkAddress($address, AddressLinkType::Home);
|
|
$company->linkAddress($address, AddressLinkType::Headquarters);
|
|
|
|
$this->assertCount(2, $address->links()->get());
|
|
$this->assertTrue($user->hasAddresses());
|
|
$this->assertTrue($company->hasAddresses());
|
|
}
|
|
|
|
// ─── pivot data ──────────────────────────────────────────────
|
|
|
|
public function test_link_with_active_from_and_active_until(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$from = now()->subDay();
|
|
$until = now()->addYear();
|
|
|
|
$link = $user->addAddress(
|
|
['city' => 'Munich', 'country_code' => 'DE'],
|
|
AddressLinkType::Temporary,
|
|
[
|
|
'active_from' => $from,
|
|
'active_until' => $until,
|
|
]
|
|
);
|
|
|
|
$this->assertNotNull($link->active_from);
|
|
$this->assertNotNull($link->active_until);
|
|
$this->assertTrue($link->isActive());
|
|
}
|
|
|
|
public function test_expired_link(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$link = $user->addAddress(
|
|
['city' => 'Paris', 'country_code' => 'FR'],
|
|
AddressLinkType::Temporary,
|
|
[
|
|
'active_from' => now()->subYear(),
|
|
'active_until' => now()->subDay(),
|
|
]
|
|
);
|
|
|
|
$this->assertFalse($link->isActive());
|
|
}
|
|
|
|
public function test_link_with_meta(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$link = $user->addAddress(
|
|
['city' => 'Tokyo', 'country_code' => 'JP'],
|
|
AddressLinkType::Office,
|
|
[
|
|
'meta' => ['department' => 'Engineering', 'access_code' => 'A-123'],
|
|
]
|
|
);
|
|
|
|
$meta = $link->getMeta();
|
|
$this->assertEquals('Engineering', $meta->department);
|
|
$this->assertEquals('A-123', $meta->access_code);
|
|
}
|
|
|
|
public function test_link_with_custom_label(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$link = $user->addAddress(
|
|
['city' => 'Rome', 'country_code' => 'IT'],
|
|
AddressLinkType::Other,
|
|
['label' => 'Aunt Maria\'s house']
|
|
);
|
|
|
|
$this->assertEquals('Aunt Maria\'s house', $link->label);
|
|
$this->assertEquals(AddressLinkType::Other, $link->type);
|
|
}
|
|
|
|
public function test_link_with_is_primary(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$link = $user->addAddress(
|
|
['city' => 'Madrid', 'country_code' => 'ES'],
|
|
AddressLinkType::Home,
|
|
['is_primary' => true]
|
|
);
|
|
|
|
$this->assertTrue($link->is_primary);
|
|
}
|
|
|
|
// ─── querying ────────────────────────────────────────────────
|
|
|
|
public function test_addresses_of_type(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$user->addAddress(['city' => 'A'], AddressLinkType::Home);
|
|
$user->addAddress(['city' => 'B'], AddressLinkType::Office);
|
|
$user->addAddress(['city' => 'C'], AddressLinkType::Home);
|
|
|
|
$homes = $user->addressesOfType(AddressLinkType::Home);
|
|
$offices = $user->addressesOfType(AddressLinkType::Office);
|
|
|
|
$this->assertCount(2, $homes);
|
|
$this->assertCount(1, $offices);
|
|
}
|
|
|
|
public function test_addresses_of_type_with_string(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$user->addAddress(['city' => 'A'], AddressLinkType::Billing);
|
|
|
|
$result = $user->addressesOfType('billing');
|
|
$this->assertCount(1, $result);
|
|
}
|
|
|
|
public function test_has_address_of_type(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$user->addAddress(['city' => 'A'], AddressLinkType::Shipping);
|
|
|
|
$this->assertTrue($user->hasAddressOfType(AddressLinkType::Shipping));
|
|
$this->assertFalse($user->hasAddressOfType(AddressLinkType::Billing));
|
|
}
|
|
|
|
public function test_has_addresses(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$this->assertFalse($user->hasAddresses());
|
|
|
|
$user->addAddress(['city' => 'X']);
|
|
$this->assertTrue($user->hasAddresses());
|
|
}
|
|
|
|
public function test_primary_address(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$user->addAddress(['city' => 'First'], AddressLinkType::Home);
|
|
$user->addAddress(['city' => 'Second'], AddressLinkType::Home, ['is_primary' => true]);
|
|
|
|
$primary = $user->primaryAddress(AddressLinkType::Home);
|
|
$this->assertNotNull($primary);
|
|
$this->assertEquals('Second', $primary->city);
|
|
}
|
|
|
|
public function test_primary_address_returns_null_when_none(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$user->addAddress(['city' => 'NoPrimary'], AddressLinkType::Home);
|
|
|
|
$this->assertNull($user->primaryAddress(AddressLinkType::Home));
|
|
}
|
|
|
|
public function test_active_address_links(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
// Active link
|
|
$user->addAddress(['city' => 'Active'], AddressLinkType::Home, [
|
|
'active_from' => now()->subDay(),
|
|
'active_until' => now()->addYear(),
|
|
]);
|
|
|
|
// Expired link
|
|
$user->addAddress(['city' => 'Expired'], AddressLinkType::Office, [
|
|
'active_from' => now()->subYear(),
|
|
'active_until' => now()->subDay(),
|
|
]);
|
|
|
|
// No temporal constraints (always active)
|
|
$user->addAddress(['city' => 'Always'], AddressLinkType::Billing);
|
|
|
|
$active = $user->activeAddressLinks();
|
|
$this->assertCount(2, $active);
|
|
|
|
$cities = $active->pluck('address.city')->sort()->values()->toArray();
|
|
$this->assertEquals(['Active', 'Always'], $cities);
|
|
}
|
|
|
|
// ─── removing / detaching ────────────────────────────────────
|
|
|
|
public function test_remove_address_link(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress(['city' => 'ToRemove'], AddressLinkType::Home);
|
|
|
|
$this->assertTrue($user->removeAddressLink($link->id));
|
|
$this->assertFalse($user->hasAddresses());
|
|
|
|
// Address record still exists
|
|
$this->assertDatabaseHas('addresses', ['city' => 'ToRemove']);
|
|
}
|
|
|
|
public function test_detach_address(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$address = Address::create(['city' => 'Shared', 'country_code' => 'AT']);
|
|
$user->linkAddress($address, AddressLinkType::Home);
|
|
$user->linkAddress($address, AddressLinkType::Billing);
|
|
|
|
$removed = $user->detachAddress($address);
|
|
|
|
$this->assertEquals(2, $removed);
|
|
$this->assertFalse($user->hasAddresses());
|
|
// Address record still exists
|
|
$this->assertDatabaseHas('addresses', ['city' => 'Shared']);
|
|
}
|
|
|
|
public function test_detach_all_addresses(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$user->addAddress(['city' => 'A'], AddressLinkType::Home);
|
|
$user->addAddress(['city' => 'B'], AddressLinkType::Office);
|
|
$user->addAddress(['city' => 'C'], AddressLinkType::Billing);
|
|
|
|
$removed = $user->detachAllAddresses();
|
|
|
|
$this->assertEquals(3, $removed);
|
|
$this->assertFalse($user->hasAddresses());
|
|
}
|
|
|
|
// ─── set primary ─────────────────────────────────────────────
|
|
|
|
public function test_set_primary_address_link(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link1 = $user->addAddress(['city' => 'First'], AddressLinkType::Home, ['is_primary' => true]);
|
|
$link2 = $user->addAddress(['city' => 'Second'], AddressLinkType::Home);
|
|
|
|
$result = $user->setPrimaryAddressLink($link2->id);
|
|
|
|
$this->assertTrue($result);
|
|
$this->assertFalse($link1->fresh()->is_primary);
|
|
$this->assertTrue($link2->fresh()->is_primary);
|
|
}
|
|
|
|
public function test_set_primary_returns_false_for_nonexistent_link(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$this->assertFalse($user->setPrimaryAddressLink(999));
|
|
}
|
|
|
|
public function test_set_primary_does_not_affect_other_types(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$homeLink = $user->addAddress(['city' => 'Home'], AddressLinkType::Home, ['is_primary' => true]);
|
|
$officeLink = $user->addAddress(['city' => 'Office'], AddressLinkType::Office);
|
|
|
|
$user->setPrimaryAddressLink($officeLink->id);
|
|
|
|
// Home primary should remain untouched
|
|
$this->assertTrue($homeLink->fresh()->is_primary);
|
|
$this->assertTrue($officeLink->fresh()->is_primary);
|
|
}
|
|
|
|
// ─── Address model ───────────────────────────────────────────
|
|
|
|
public function test_address_has_coordinates(): void
|
|
{
|
|
$address = Address::create([
|
|
'latitude' => 48.2082,
|
|
'longitude' => 16.3738,
|
|
]);
|
|
|
|
$this->assertTrue($address->hasCoordinates());
|
|
}
|
|
|
|
public function test_address_without_coordinates(): void
|
|
{
|
|
$address = Address::create([
|
|
'city' => 'NoCoords',
|
|
]);
|
|
|
|
$this->assertFalse($address->hasCoordinates());
|
|
$this->assertFalse($address->hasAltitude());
|
|
}
|
|
|
|
public function test_address_to_coordinates(): void
|
|
{
|
|
$address = Address::create([
|
|
'latitude' => 47.0707,
|
|
'longitude' => 15.4395,
|
|
'altitude' => 353.0,
|
|
]);
|
|
|
|
$coords = $address->toCoordinates();
|
|
|
|
$this->assertArrayHasKey('latitude', $coords);
|
|
$this->assertArrayHasKey('longitude', $coords);
|
|
$this->assertArrayHasKey('altitude', $coords);
|
|
$this->assertEqualsWithDelta(353.0, $coords['altitude'], 0.01);
|
|
}
|
|
|
|
public function test_address_formatted_attribute(): void
|
|
{
|
|
$address = Address::create([
|
|
'street' => 'Rainerstraße 4',
|
|
'postal_code' => '4020',
|
|
'city' => 'Linz',
|
|
'country_code' => 'AT',
|
|
]);
|
|
|
|
$formatted = $address->formatted;
|
|
|
|
$this->assertStringContainsString('Rainerstraße 4', $formatted);
|
|
$this->assertStringContainsString('4020', $formatted);
|
|
$this->assertStringContainsString('Linz', $formatted);
|
|
$this->assertStringContainsString('AT', $formatted);
|
|
}
|
|
|
|
public function test_address_meta(): void
|
|
{
|
|
$address = Address::create([
|
|
'city' => 'MetaCity',
|
|
'meta' => ['plus_code' => '8FWR39JJ+XX', 'timezone' => 'Europe/Vienna'],
|
|
]);
|
|
|
|
$meta = $address->getMeta();
|
|
$this->assertEquals('8FWR39JJ+XX', $meta->plus_code);
|
|
$this->assertEquals('Europe/Vienna', $meta->timezone);
|
|
}
|
|
|
|
public function test_address_soft_delete(): void
|
|
{
|
|
$address = Address::create(['city' => 'SoftDelete']);
|
|
$address->delete();
|
|
|
|
$this->assertSoftDeleted('addresses', ['city' => 'SoftDelete']);
|
|
$this->assertNull(Address::find($address->id));
|
|
$this->assertNotNull(Address::withTrashed()->find($address->id));
|
|
}
|
|
|
|
// ─── AddressLink model scopes ────────────────────────────────
|
|
|
|
public function test_address_link_scope_active(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$user->addAddress(['city' => 'Active'], AddressLinkType::Home, [
|
|
'active_from' => now()->subDay(),
|
|
'active_until' => now()->addDay(),
|
|
]);
|
|
$user->addAddress(['city' => 'Expired'], AddressLinkType::Office, [
|
|
'active_until' => now()->subDay(),
|
|
]);
|
|
|
|
$active = $user->addressLinks()->active()->get();
|
|
$this->assertCount(1, $active);
|
|
$this->assertEquals('Active', $active->first()->address->city);
|
|
}
|
|
|
|
public function test_address_link_scope_expired(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$user->addAddress(['city' => 'Current'], AddressLinkType::Home);
|
|
$user->addAddress(['city' => 'Old'], AddressLinkType::Office, [
|
|
'active_until' => now()->subDay(),
|
|
]);
|
|
|
|
$expired = $user->addressLinks()->expired()->get();
|
|
$this->assertCount(1, $expired);
|
|
}
|
|
|
|
public function test_address_link_scope_of_type(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$user->addAddress(['city' => 'A'], AddressLinkType::Home);
|
|
$user->addAddress(['city' => 'B'], AddressLinkType::Office);
|
|
$user->addAddress(['city' => 'C'], AddressLinkType::Home);
|
|
|
|
$homes = $user->addressLinks()->ofType(AddressLinkType::Home)->get();
|
|
$this->assertCount(2, $homes);
|
|
}
|
|
|
|
public function test_address_link_scope_primary(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$user->addAddress(['city' => 'Primary'], AddressLinkType::Home, ['is_primary' => true]);
|
|
$user->addAddress(['city' => 'Secondary'], AddressLinkType::Home);
|
|
|
|
$primaries = $user->addressLinks()->primary()->get();
|
|
$this->assertCount(1, $primaries);
|
|
}
|
|
|
|
// ─── AddressLinkType enum ────────────────────────────────────
|
|
|
|
public function test_enum_values_are_strings(): void
|
|
{
|
|
$this->assertIsString(AddressLinkType::Home->value);
|
|
$this->assertEquals('home', AddressLinkType::Home->value);
|
|
$this->assertEquals('office', AddressLinkType::Office->value);
|
|
$this->assertEquals('billing', AddressLinkType::Billing->value);
|
|
}
|
|
|
|
public function test_enum_labels(): void
|
|
{
|
|
$this->assertEquals('Home', AddressLinkType::Home->label());
|
|
$this->assertEquals('Office', AddressLinkType::Office->label());
|
|
$this->assertEquals('Point of Interest', AddressLinkType::PointOfInterest->label());
|
|
$this->assertEquals('Secondary Residence', AddressLinkType::SecondaryResidence->label());
|
|
}
|
|
|
|
public function test_enum_can_be_cast_from_string(): void
|
|
{
|
|
$type = AddressLinkType::from('shipping');
|
|
$this->assertEquals(AddressLinkType::Shipping, $type);
|
|
}
|
|
|
|
// ─── polymorphic: Company model ──────────────────────────────
|
|
|
|
public function test_company_can_have_addresses(): void
|
|
{
|
|
$company = Company::create(['name' => 'Test Corp']);
|
|
|
|
$link = $company->addAddress([
|
|
'street' => 'Business Ave 42',
|
|
'city' => 'Zurich',
|
|
'country_code' => 'CH',
|
|
], AddressLinkType::Headquarters);
|
|
|
|
$this->assertTrue($company->hasAddresses());
|
|
$this->assertEquals(AddressLinkType::Headquarters, $link->type);
|
|
}
|
|
|
|
public function test_address_links_back_to_addressable(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress(['city' => 'Backref'], AddressLinkType::Home);
|
|
|
|
$freshLink = AddressLink::find($link->id);
|
|
$this->assertInstanceOf(User::class, $freshLink->addressable);
|
|
$this->assertEquals($user->id, $freshLink->addressable->id);
|
|
}
|
|
|
|
// ─── negative altitude (below sea level) ─────────────────────
|
|
|
|
public function test_negative_altitude(): void
|
|
{
|
|
$address = Address::create([
|
|
'latitude' => 31.5,
|
|
'longitude' => 35.5,
|
|
'altitude' => -430.5,
|
|
'country_code' => 'IL',
|
|
]);
|
|
|
|
$this->assertEqualsWithDelta(-430.5, $address->altitude, 0.01);
|
|
$this->assertTrue($address->hasAltitude());
|
|
}
|
|
|
|
// ─── cascade delete ──────────────────────────────────────────
|
|
|
|
public function test_deleting_address_cascades_to_links(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress(['city' => 'Cascade'], AddressLinkType::Home);
|
|
$addressId = $link->address_id;
|
|
|
|
// Force-delete the address to trigger cascade
|
|
Address::withTrashed()->find($addressId)->forceDelete();
|
|
|
|
$this->assertDatabaseMissing('address_links', ['address_id' => $addressId]);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// AddressService
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
protected function service(): AddressService
|
|
{
|
|
return app(AddressService::class);
|
|
}
|
|
|
|
// ─── helper function ─────────────────────────────────────────
|
|
|
|
public function test_address_helper_returns_service(): void
|
|
{
|
|
$this->assertInstanceOf(AddressService::class, address());
|
|
}
|
|
|
|
// ─── haversine / distance ────────────────────────────────────
|
|
|
|
public function test_haversine_same_point_returns_zero(): void
|
|
{
|
|
$distance = $this->service()->haversine(48.2082, 16.3738, 48.2082, 16.3738);
|
|
|
|
$this->assertEquals(0.0, $distance);
|
|
}
|
|
|
|
public function test_haversine_known_distance(): void
|
|
{
|
|
// Vienna (48.2082, 16.3738) → Graz (47.0707, 15.4395) ≈ 145 km
|
|
$distance = $this->service()->haversine(48.2082, 16.3738, 47.0707, 15.4395);
|
|
|
|
$this->assertEqualsWithDelta(145.0, $distance, 5.0);
|
|
}
|
|
|
|
public function test_haversine_miles(): void
|
|
{
|
|
$km = $this->service()->haversine(48.2082, 16.3738, 47.0707, 15.4395, 'km');
|
|
$mi = $this->service()->haversine(48.2082, 16.3738, 47.0707, 15.4395, 'mi');
|
|
|
|
// 1 km ≈ 0.621 mi
|
|
$this->assertLessThan($km, $mi);
|
|
}
|
|
|
|
public function test_distance_between_addresses(): void
|
|
{
|
|
$vienna = Address::create(['latitude' => 48.2082, 'longitude' => 16.3738]);
|
|
$graz = Address::create(['latitude' => 47.0707, 'longitude' => 15.4395]);
|
|
|
|
$distance = $this->service()->distanceBetween($vienna, $graz);
|
|
|
|
$this->assertNotNull($distance);
|
|
$this->assertEqualsWithDelta(145.0, $distance, 5.0);
|
|
}
|
|
|
|
public function test_distance_between_returns_null_without_coordinates(): void
|
|
{
|
|
$a = Address::create(['city' => 'A']);
|
|
$b = Address::create(['city' => 'B']);
|
|
|
|
$this->assertNull($this->service()->distanceBetween($a, $b));
|
|
}
|
|
|
|
public function test_altitude_difference(): void
|
|
{
|
|
$low = Address::create(['latitude' => 48.0, 'longitude' => 16.0, 'altitude' => 170.0]);
|
|
$high = Address::create(['latitude' => 47.0, 'longitude' => 15.0, 'altitude' => 850.0]);
|
|
|
|
$diff = $this->service()->altitudeDifference($low, $high);
|
|
|
|
$this->assertEqualsWithDelta(680.0, $diff, 0.01);
|
|
}
|
|
|
|
public function test_altitude_difference_returns_null_when_missing(): void
|
|
{
|
|
$a = Address::create(['latitude' => 48.0, 'longitude' => 16.0]);
|
|
$b = Address::create(['latitude' => 47.0, 'longitude' => 15.0, 'altitude' => 850.0]);
|
|
|
|
$this->assertNull($this->service()->altitudeDifference($a, $b));
|
|
}
|
|
|
|
// ─── proximity ───────────────────────────────────────────────
|
|
|
|
public function test_nearby_finds_addresses_within_radius(): void
|
|
{
|
|
// Vienna centre
|
|
Address::create(['city' => 'Centre', 'latitude' => 48.2082, 'longitude' => 16.3738]);
|
|
// ~5 km away
|
|
Address::create(['city' => 'Near', 'latitude' => 48.24, 'longitude' => 16.40]);
|
|
// ~145 km away (Graz)
|
|
Address::create(['city' => 'Far', 'latitude' => 47.0707, 'longitude' => 15.4395]);
|
|
|
|
$results = $this->service()->nearby(48.2082, 16.3738, 20);
|
|
|
|
$cities = $results->pluck('city')->toArray();
|
|
$this->assertContains('Centre', $cities);
|
|
$this->assertContains('Near', $cities);
|
|
$this->assertNotContains('Far', $cities);
|
|
}
|
|
|
|
public function test_nearby_results_are_sorted_by_distance(): void
|
|
{
|
|
Address::create(['city' => 'Far', 'latitude' => 48.30, 'longitude' => 16.50]);
|
|
Address::create(['city' => 'Near', 'latitude' => 48.21, 'longitude' => 16.38]);
|
|
Address::create(['city' => 'Mid', 'latitude' => 48.25, 'longitude' => 16.42]);
|
|
|
|
$results = $this->service()->nearby(48.2082, 16.3738, 50);
|
|
|
|
$cities = $results->pluck('city')->toArray();
|
|
$this->assertEquals('Near', $cities[0]);
|
|
}
|
|
|
|
public function test_nearby_results_have_distance_attribute(): void
|
|
{
|
|
Address::create(['city' => 'A', 'latitude' => 48.21, 'longitude' => 16.38]);
|
|
|
|
$results = $this->service()->nearby(48.2082, 16.3738, 50);
|
|
|
|
$this->assertNotEmpty($results);
|
|
$this->assertIsFloat($results->first()->distance);
|
|
}
|
|
|
|
public function test_nearby_address_excludes_self(): void
|
|
{
|
|
$ref = Address::create(['city' => 'Ref', 'latitude' => 48.2082, 'longitude' => 16.3738]);
|
|
Address::create(['city' => 'Buddy', 'latitude' => 48.21, 'longitude' => 16.38]);
|
|
|
|
$results = $this->service()->nearbyAddress($ref, 50);
|
|
|
|
$ids = $results->pluck('id')->toArray();
|
|
$this->assertNotContains($ref->id, $ids);
|
|
$this->assertCount(1, $results);
|
|
}
|
|
|
|
public function test_nearby_address_include_self(): void
|
|
{
|
|
$ref = Address::create(['city' => 'Ref', 'latitude' => 48.2082, 'longitude' => 16.3738]);
|
|
Address::create(['city' => 'Buddy', 'latitude' => 48.21, 'longitude' => 16.38]);
|
|
|
|
$results = $this->service()->nearbyAddress($ref, 50, 'km', false);
|
|
|
|
$this->assertCount(2, $results);
|
|
}
|
|
|
|
public function test_nearby_address_without_coordinates_returns_empty(): void
|
|
{
|
|
$ref = Address::create(['city' => 'NoCoords']);
|
|
|
|
$results = $this->service()->nearbyAddress($ref, 50);
|
|
|
|
$this->assertEmpty($results);
|
|
}
|
|
|
|
public function test_closest_address(): void
|
|
{
|
|
Address::create(['city' => 'Far', 'latitude' => 47.0707, 'longitude' => 15.4395]);
|
|
Address::create(['city' => 'Close', 'latitude' => 48.21, 'longitude' => 16.38]);
|
|
|
|
$closest = $this->service()->closest(48.2082, 16.3738);
|
|
|
|
$this->assertNotNull($closest);
|
|
$this->assertEquals('Close', $closest->city);
|
|
}
|
|
|
|
public function test_closest_returns_null_when_no_addresses(): void
|
|
{
|
|
$this->assertNull($this->service()->closest(48.0, 16.0));
|
|
}
|
|
|
|
// ─── bounding box ────────────────────────────────────────────
|
|
|
|
public function test_bounding_box(): void
|
|
{
|
|
$box = $this->service()->boundingBox(48.2082, 16.3738, 10);
|
|
|
|
$this->assertLessThan(48.2082, $box['minLat']);
|
|
$this->assertGreaterThan(48.2082, $box['maxLat']);
|
|
$this->assertLessThan(16.3738, $box['minLng']);
|
|
$this->assertGreaterThan(16.3738, $box['maxLng']);
|
|
}
|
|
|
|
// ─── duplicates ──────────────────────────────────────────────
|
|
|
|
public function test_find_duplicates(): void
|
|
{
|
|
$addr = Address::create(['street' => 'Hauptplatz 1', 'city' => 'Linz', 'postal_code' => '4020', 'country_code' => 'AT']);
|
|
$dup = Address::create(['street' => 'Hauptplatz 1', 'city' => 'Linz', 'postal_code' => '4020', 'country_code' => 'AT']);
|
|
Address::create(['street' => 'Nebenstraße 5', 'city' => 'Linz', 'postal_code' => '4020', 'country_code' => 'AT']);
|
|
|
|
$duplicates = $this->service()->findDuplicates($addr);
|
|
|
|
$this->assertCount(1, $duplicates);
|
|
$this->assertEquals($dup->id, $duplicates->first()->id);
|
|
}
|
|
|
|
public function test_merge_reassigns_links_and_soft_deletes(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$target = Address::create(['city' => 'Target']);
|
|
$duplicate = Address::create(['city' => 'Duplicate']);
|
|
|
|
$user->linkAddress($duplicate, AddressLinkType::Home);
|
|
$user->linkAddress($duplicate, AddressLinkType::Office);
|
|
|
|
$reassigned = $this->service()->merge($target, $duplicate);
|
|
|
|
$this->assertEquals(2, $reassigned);
|
|
$this->assertSoftDeleted('addresses', ['id' => $duplicate->id]);
|
|
$this->assertEquals(2, $user->addressLinks()->where('address_id', $target->id)->count());
|
|
}
|
|
|
|
// ─── query builders ──────────────────────────────────────────
|
|
|
|
public function test_in_country(): void
|
|
{
|
|
Address::create(['city' => 'Vienna', 'country_code' => 'AT']);
|
|
Address::create(['city' => 'Berlin', 'country_code' => 'DE']);
|
|
|
|
$result = $this->service()->inCountry('AT')->get();
|
|
|
|
$this->assertCount(1, $result);
|
|
$this->assertEquals('Vienna', $result->first()->city);
|
|
}
|
|
|
|
public function test_in_city(): void
|
|
{
|
|
Address::create(['city' => 'Vienna', 'country_code' => 'AT']);
|
|
Address::create(['city' => 'Vienna', 'country_code' => 'US']); // Vienna, Virginia
|
|
|
|
$all = $this->service()->inCity('Vienna')->get();
|
|
$atOnly = $this->service()->inCity('Vienna', 'AT')->get();
|
|
|
|
$this->assertCount(2, $all);
|
|
$this->assertCount(1, $atOnly);
|
|
}
|
|
|
|
public function test_in_postal_code(): void
|
|
{
|
|
Address::create(['postal_code' => '1010', 'country_code' => 'AT']);
|
|
Address::create(['postal_code' => '1010', 'country_code' => 'DE']);
|
|
|
|
$result = $this->service()->inPostalCode('1010', 'AT')->get();
|
|
|
|
$this->assertCount(1, $result);
|
|
}
|
|
|
|
public function test_with_coordinates(): void
|
|
{
|
|
Address::create(['city' => 'HasCoords', 'latitude' => 48.0, 'longitude' => 16.0]);
|
|
Address::create(['city' => 'NoCoords']);
|
|
|
|
$result = $this->service()->withCoordinates()->get();
|
|
|
|
$this->assertCount(1, $result);
|
|
$this->assertEquals('HasCoords', $result->first()->city);
|
|
}
|
|
|
|
// ─── formatting ──────────────────────────────────────────────
|
|
|
|
public function test_format_single_line(): void
|
|
{
|
|
$addr = Address::create([
|
|
'street' => 'Rainerstraße 4',
|
|
'postal_code' => '4020',
|
|
'city' => 'Linz',
|
|
'country_code' => 'AT',
|
|
]);
|
|
|
|
$formatted = $this->service()->format($addr);
|
|
|
|
$this->assertStringContainsString('Rainerstraße 4', $formatted);
|
|
$this->assertStringContainsString('4020', $formatted);
|
|
$this->assertStringContainsString('Linz', $formatted);
|
|
}
|
|
|
|
public function test_format_with_custom_separator(): void
|
|
{
|
|
$addr = Address::create(['street' => 'A', 'city' => 'B']);
|
|
|
|
$formatted = $this->service()->format($addr, ' | ');
|
|
|
|
$this->assertEquals('A | B', $formatted);
|
|
}
|
|
|
|
public function test_format_multiline(): void
|
|
{
|
|
$addr = Address::create([
|
|
'street' => '350 Fifth Avenue',
|
|
'street_extra' => 'Suite 3200',
|
|
'building' => 'Empire State Building',
|
|
'floor' => '32',
|
|
'room' => '3201',
|
|
'postal_code' => '10118',
|
|
'city' => 'New York',
|
|
'state' => 'NY',
|
|
'country_code' => 'US',
|
|
]);
|
|
|
|
$lines = explode("\n", $this->service()->formatMultiline($addr));
|
|
|
|
$this->assertStringContainsString('350 Fifth Avenue', $lines[0]);
|
|
$this->assertStringContainsString('Suite 3200', $lines[0]);
|
|
$this->assertStringContainsString('Empire State Building', $lines[1]);
|
|
$this->assertStringContainsString('Floor 32', $lines[1]);
|
|
$this->assertStringContainsString('10118 New York', $lines[2]);
|
|
$this->assertEquals('US', $lines[3]);
|
|
}
|
|
|
|
public function test_format_coordinates(): void
|
|
{
|
|
$addr = Address::create(['latitude' => 48.2082, 'longitude' => 16.3738, 'altitude' => 171.0]);
|
|
|
|
$result = $this->service()->formatCoordinates($addr);
|
|
|
|
$this->assertStringContainsString('N', $result);
|
|
$this->assertStringContainsString('E', $result);
|
|
$this->assertStringContainsString('AMSL', $result);
|
|
}
|
|
|
|
public function test_format_coordinates_southern_western(): void
|
|
{
|
|
$addr = Address::create(['latitude' => -33.8688, 'longitude' => -151.2093]);
|
|
|
|
$result = $this->service()->formatCoordinates($addr);
|
|
|
|
$this->assertStringContainsString('S', $result);
|
|
// Note: -151 is actually W
|
|
$this->assertStringContainsString('W', $result);
|
|
}
|
|
|
|
public function test_format_coordinates_returns_null_without_coords(): void
|
|
{
|
|
$addr = Address::create(['city' => 'NoCoordsCity']);
|
|
|
|
$this->assertNull($this->service()->formatCoordinates($addr));
|
|
}
|
|
|
|
// ─── coordinate conversion ───────────────────────────────────
|
|
|
|
public function test_dms_to_decimal(): void
|
|
{
|
|
// 48°12'29.5"N → 48.2082
|
|
$result = $this->service()->dmsToDecimal(48, 12, 29.52, 'N');
|
|
|
|
$this->assertEqualsWithDelta(48.2082, $result, 0.001);
|
|
}
|
|
|
|
public function test_dms_to_decimal_south(): void
|
|
{
|
|
$result = $this->service()->dmsToDecimal(33, 52, 7.7, 'S');
|
|
|
|
$this->assertLessThan(0, $result);
|
|
$this->assertEqualsWithDelta(-33.8688, $result, 0.01);
|
|
}
|
|
|
|
public function test_decimal_to_dms_latitude(): void
|
|
{
|
|
$result = $this->service()->decimalToDms(48.2082, 'lat');
|
|
|
|
$this->assertEquals(48, $result['degrees']);
|
|
$this->assertEquals(12, $result['minutes']);
|
|
$this->assertEquals('N', $result['direction']);
|
|
}
|
|
|
|
public function test_decimal_to_dms_longitude_west(): void
|
|
{
|
|
$result = $this->service()->decimalToDms(-73.9856, 'lng');
|
|
|
|
$this->assertEquals(73, $result['degrees']);
|
|
$this->assertEquals('W', $result['direction']);
|
|
}
|
|
|
|
public function test_dms_roundtrip(): void
|
|
{
|
|
$original = 48.2082;
|
|
$dms = $this->service()->decimalToDms($original, 'lat');
|
|
$back = $this->service()->dmsToDecimal($dms['degrees'], $dms['minutes'], $dms['seconds'], $dms['direction']);
|
|
|
|
$this->assertEqualsWithDelta($original, $back, 0.0001);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// AddressAssignment — assign an AddressLink to another model
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
private function createJobWithAssignment(string $role = 'pickup'): array
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress([
|
|
'street' => 'Hauptstraße 1',
|
|
'city' => 'Vienna',
|
|
'country_code' => 'AT',
|
|
], AddressLinkType::Office);
|
|
|
|
$job = Job::create(['title' => 'Piano Move']);
|
|
$assignment = $job->assignAddressLink($link, $role);
|
|
|
|
return compact('user', 'link', 'job', 'assignment');
|
|
}
|
|
|
|
// ─── assign address link ─────────────────────────────────────
|
|
|
|
public function test_assign_address_link_creates_assignment(): void
|
|
{
|
|
['job' => $job, 'assignment' => $assignment, 'link' => $link] = $this->createJobWithAssignment();
|
|
|
|
$this->assertInstanceOf(AddressAssignment::class, $assignment);
|
|
$this->assertEquals($link->id, $assignment->address_link_id);
|
|
$this->assertEquals('pickup', $assignment->role);
|
|
$this->assertEquals($job->getMorphClass(), $assignment->assignable_type);
|
|
$this->assertEquals($job->id, $assignment->assignable_id);
|
|
}
|
|
|
|
public function test_assign_address_link_by_id(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress(['city' => 'Berlin'], AddressLinkType::Office);
|
|
|
|
$job = Job::create(['title' => 'Delivery']);
|
|
$assignment = $job->assignAddressLink($link->id, 'delivery');
|
|
|
|
$this->assertEquals($link->id, $assignment->address_link_id);
|
|
$this->assertEquals('delivery', $assignment->role);
|
|
}
|
|
|
|
public function test_assign_address_link_with_label_and_meta(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress(['city' => 'Vienna'], AddressLinkType::Home);
|
|
|
|
$job = Job::create(['title' => 'Move']);
|
|
$assignment = $job->assignAddressLink($link, 'origin', [
|
|
'label' => 'Customer Home',
|
|
'meta' => ['floor_access' => 'elevator'],
|
|
]);
|
|
|
|
$this->assertEquals('Customer Home', $assignment->label);
|
|
$this->assertEquals('elevator', $assignment->meta->floor_access);
|
|
}
|
|
|
|
public function test_assign_address_link_without_role(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress(['city' => 'Graz']);
|
|
|
|
$job = Job::create(['title' => 'Task']);
|
|
$assignment = $job->assignAddressLink($link);
|
|
|
|
$this->assertNull($assignment->role);
|
|
}
|
|
|
|
public function test_assignment_loads_address_link_and_address(): void
|
|
{
|
|
['assignment' => $assignment] = $this->createJobWithAssignment();
|
|
|
|
$this->assertTrue($assignment->relationLoaded('addressLink'));
|
|
$this->assertTrue($assignment->addressLink->relationLoaded('address'));
|
|
$this->assertEquals('Vienna', $assignment->addressLink->address->city);
|
|
}
|
|
|
|
// ─── relationships ───────────────────────────────────────────
|
|
|
|
public function test_address_assignments_morphmany(): void
|
|
{
|
|
['job' => $job] = $this->createJobWithAssignment();
|
|
|
|
$this->assertCount(1, $job->addressAssignments);
|
|
}
|
|
|
|
public function test_assignment_belongs_to_address_link(): void
|
|
{
|
|
['assignment' => $assignment, 'link' => $link] = $this->createJobWithAssignment();
|
|
|
|
$freshAssignment = AddressAssignment::find($assignment->id);
|
|
$this->assertEquals($link->id, $freshAssignment->addressLink->id);
|
|
}
|
|
|
|
public function test_assignment_address_shortcut(): void
|
|
{
|
|
['assignment' => $assignment] = $this->createJobWithAssignment();
|
|
|
|
$freshAssignment = AddressAssignment::with('address')->find($assignment->id);
|
|
$this->assertNotNull($freshAssignment->address);
|
|
$this->assertEquals('Vienna', $freshAssignment->address->city);
|
|
}
|
|
|
|
public function test_assignment_assignable_morphto(): void
|
|
{
|
|
['assignment' => $assignment, 'job' => $job] = $this->createJobWithAssignment();
|
|
|
|
$fresh = AddressAssignment::find($assignment->id);
|
|
$this->assertInstanceOf(Job::class, $fresh->assignable);
|
|
$this->assertEquals($job->id, $fresh->assignable->id);
|
|
}
|
|
|
|
public function test_address_link_has_many_assignments(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress(['city' => 'Vienna'], AddressLinkType::Office);
|
|
|
|
$job1 = Job::create(['title' => 'Job 1']);
|
|
$job2 = Job::create(['title' => 'Job 2']);
|
|
|
|
$job1->assignAddressLink($link, 'pickup');
|
|
$job2->assignAddressLink($link, 'delivery');
|
|
|
|
$this->assertCount(2, $link->fresh()->assignments);
|
|
}
|
|
|
|
// ─── multiple assignments on one model ───────────────────────
|
|
|
|
public function test_multiple_assignments_on_one_model(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$officeLink = $user->addAddress(['city' => 'Vienna'], AddressLinkType::Office);
|
|
$homeLink = $user->addAddress(['city' => 'Graz'], AddressLinkType::Home);
|
|
|
|
$job = Job::create(['title' => 'Piano Move']);
|
|
$job->assignAddressLink($officeLink, 'pickup');
|
|
$job->assignAddressLink($homeLink, 'delivery');
|
|
|
|
$this->assertCount(2, $job->fresh()->addressAssignments);
|
|
}
|
|
|
|
// ─── removing assignments ────────────────────────────────────
|
|
|
|
public function test_remove_address_assignment(): void
|
|
{
|
|
['job' => $job, 'assignment' => $assignment] = $this->createJobWithAssignment();
|
|
|
|
$this->assertTrue($job->removeAddressAssignment($assignment->id));
|
|
$this->assertCount(0, $job->fresh()->addressAssignments);
|
|
}
|
|
|
|
public function test_remove_assignments_for_role(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress(['city' => 'Vienna'], AddressLinkType::Office);
|
|
$link2 = $user->addAddress(['city' => 'Graz'], AddressLinkType::Home);
|
|
|
|
$job = Job::create(['title' => 'Move']);
|
|
$job->assignAddressLink($link, 'pickup');
|
|
$job->assignAddressLink($link2, 'pickup');
|
|
$job->assignAddressLink($link, 'delivery');
|
|
|
|
$removed = $job->removeAssignmentsForRole('pickup');
|
|
|
|
$this->assertEquals(2, $removed);
|
|
$this->assertCount(1, $job->fresh()->addressAssignments);
|
|
}
|
|
|
|
public function test_remove_all_address_assignments(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress(['city' => 'Vienna'], AddressLinkType::Office);
|
|
|
|
$job = Job::create(['title' => 'Move']);
|
|
$job->assignAddressLink($link, 'pickup');
|
|
$job->assignAddressLink($link, 'delivery');
|
|
|
|
$removed = $job->removeAllAddressAssignments();
|
|
|
|
$this->assertEquals(2, $removed);
|
|
$this->assertCount(0, $job->fresh()->addressAssignments);
|
|
}
|
|
|
|
// ─── cascade delete ──────────────────────────────────────────
|
|
|
|
public function test_deleting_address_link_cascades_to_assignments(): void
|
|
{
|
|
['link' => $link, 'assignment' => $assignment] = $this->createJobWithAssignment();
|
|
|
|
$link->delete();
|
|
|
|
$this->assertNull(AddressAssignment::find($assignment->id));
|
|
}
|
|
|
|
public function test_deleting_address_cascades_through_link_to_assignments(): void
|
|
{
|
|
['link' => $link, 'assignment' => $assignment] = $this->createJobWithAssignment();
|
|
|
|
$link->address->forceDelete();
|
|
|
|
$this->assertNull(AddressLink::find($link->id));
|
|
$this->assertNull(AddressAssignment::find($assignment->id));
|
|
}
|
|
|
|
// ─── querying ────────────────────────────────────────────────
|
|
|
|
public function test_address_assignment_for_role(): void
|
|
{
|
|
['job' => $job] = $this->createJobWithAssignment('pickup');
|
|
|
|
$result = $job->addressAssignmentForRole('pickup');
|
|
|
|
$this->assertInstanceOf(AddressAssignment::class, $result);
|
|
$this->assertEquals('pickup', $result->role);
|
|
$this->assertTrue($result->relationLoaded('addressLink'));
|
|
}
|
|
|
|
public function test_address_assignment_for_role_returns_null_when_missing(): void
|
|
{
|
|
['job' => $job] = $this->createJobWithAssignment('pickup');
|
|
|
|
$this->assertNull($job->addressAssignmentForRole('delivery'));
|
|
}
|
|
|
|
public function test_address_assignments_for_role(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link1 = $user->addAddress(['city' => 'Vienna'], AddressLinkType::Office);
|
|
$link2 = $user->addAddress(['city' => 'Graz'], AddressLinkType::Home);
|
|
|
|
$job = Job::create(['title' => 'Move']);
|
|
$job->assignAddressLink($link1, 'stop');
|
|
$job->assignAddressLink($link2, 'stop');
|
|
$job->assignAddressLink($link1, 'delivery');
|
|
|
|
$stops = $job->addressAssignmentsForRole('stop');
|
|
|
|
$this->assertCount(2, $stops);
|
|
}
|
|
|
|
public function test_assigned_address_for_role(): void
|
|
{
|
|
['job' => $job] = $this->createJobWithAssignment('pickup');
|
|
|
|
$address = $job->assignedAddressForRole('pickup');
|
|
|
|
$this->assertInstanceOf(Address::class, $address);
|
|
$this->assertEquals('Vienna', $address->city);
|
|
}
|
|
|
|
public function test_assigned_address_for_role_returns_null_when_missing(): void
|
|
{
|
|
['job' => $job] = $this->createJobWithAssignment('pickup');
|
|
|
|
$this->assertNull($job->assignedAddressForRole('delivery'));
|
|
}
|
|
|
|
public function test_assigned_addresses(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link1 = $user->addAddress(['city' => 'Vienna'], AddressLinkType::Office);
|
|
$link2 = $user->addAddress(['city' => 'Graz'], AddressLinkType::Home);
|
|
|
|
$job = Job::create(['title' => 'Move']);
|
|
$job->assignAddressLink($link1, 'pickup');
|
|
$job->assignAddressLink($link2, 'delivery');
|
|
|
|
$addresses = $job->assignedAddresses();
|
|
|
|
$this->assertCount(2, $addresses);
|
|
$this->assertContains('Vienna', $addresses->pluck('city')->all());
|
|
$this->assertContains('Graz', $addresses->pluck('city')->all());
|
|
}
|
|
|
|
public function test_has_address_assignments(): void
|
|
{
|
|
$job = Job::create(['title' => 'Empty Job']);
|
|
$this->assertFalse($job->hasAddressAssignments());
|
|
|
|
['job' => $jobWithAssignment] = $this->createJobWithAssignment();
|
|
$this->assertTrue($jobWithAssignment->hasAddressAssignments());
|
|
}
|
|
|
|
public function test_has_assignment_for_role(): void
|
|
{
|
|
['job' => $job] = $this->createJobWithAssignment('pickup');
|
|
|
|
$this->assertTrue($job->hasAssignmentForRole('pickup'));
|
|
$this->assertFalse($job->hasAssignmentForRole('delivery'));
|
|
}
|
|
|
|
// ─── scope: forRole ──────────────────────────────────────────
|
|
|
|
public function test_for_role_scope_on_assignment(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress(['city' => 'Vienna'], AddressLinkType::Office);
|
|
|
|
$job = Job::create(['title' => 'Move']);
|
|
$job->assignAddressLink($link, 'pickup');
|
|
$job->assignAddressLink($link, 'delivery');
|
|
|
|
$pickups = AddressAssignment::forRole('pickup')->get();
|
|
$this->assertCount(1, $pickups);
|
|
$this->assertEquals('pickup', $pickups->first()->role);
|
|
}
|
|
|
|
// ─── cross-model assignment ──────────────────────────────────
|
|
|
|
public function test_same_address_link_assigned_to_different_models(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress([
|
|
'street' => 'Ringstraße 10',
|
|
'city' => 'Vienna',
|
|
'country_code' => 'AT',
|
|
], AddressLinkType::Office);
|
|
|
|
$job1 = Job::create(['title' => 'Job Alpha']);
|
|
$job2 = Job::create(['title' => 'Job Beta']);
|
|
|
|
$a1 = $job1->assignAddressLink($link, 'pickup');
|
|
$a2 = $job2->assignAddressLink($link, 'delivery');
|
|
|
|
// Both assignments point to the same address link
|
|
$this->assertEquals($link->id, $a1->address_link_id);
|
|
$this->assertEquals($link->id, $a2->address_link_id);
|
|
|
|
// Each job has its own assignment
|
|
$this->assertCount(1, $job1->fresh()->addressAssignments);
|
|
$this->assertCount(1, $job2->fresh()->addressAssignments);
|
|
|
|
// The address link knows about both
|
|
$this->assertCount(2, $link->fresh()->assignments);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// EXHAUSTIVE TESTS — every developer interaction surface
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
// ─── Address model — edge cases ──────────────────────────────
|
|
|
|
public function test_create_completely_empty_address(): void
|
|
{
|
|
$address = Address::create([]);
|
|
|
|
$this->assertNotNull($address->id);
|
|
$this->assertNull($address->street);
|
|
$this->assertNull($address->city);
|
|
$this->assertNull($address->country_code);
|
|
$this->assertFalse($address->hasCoordinates());
|
|
$this->assertFalse($address->hasAltitude());
|
|
}
|
|
|
|
public function test_update_address_fields(): void
|
|
{
|
|
$address = Address::create(['city' => 'Vienna', 'country_code' => 'AT']);
|
|
|
|
$address->update(['city' => 'Linz', 'street' => 'Hauptplatz 1']);
|
|
|
|
$fresh = $address->fresh();
|
|
$this->assertEquals('Linz', $fresh->city);
|
|
$this->assertEquals('Hauptplatz 1', $fresh->street);
|
|
$this->assertEquals('AT', $fresh->country_code);
|
|
}
|
|
|
|
public function test_address_formatted_with_building_floor_room(): void
|
|
{
|
|
$address = Address::create([
|
|
'building' => 'Tower A',
|
|
'floor' => 'B2',
|
|
'room' => '42',
|
|
]);
|
|
|
|
$formatted = $address->formatted;
|
|
|
|
$this->assertStringContainsString('(Tower A)', $formatted);
|
|
$this->assertStringContainsString('Floor B2', $formatted);
|
|
$this->assertStringContainsString('Room 42', $formatted);
|
|
}
|
|
|
|
public function test_address_formatted_when_all_empty(): void
|
|
{
|
|
$address = Address::create([]);
|
|
|
|
$this->assertEquals('', $address->formatted);
|
|
}
|
|
|
|
public function test_address_partial_coordinates_lat_only(): void
|
|
{
|
|
$address = Address::create(['latitude' => 48.2082]);
|
|
|
|
$this->assertFalse($address->hasCoordinates());
|
|
}
|
|
|
|
public function test_address_partial_coordinates_lng_only(): void
|
|
{
|
|
$address = Address::create(['longitude' => 16.3738]);
|
|
|
|
$this->assertFalse($address->hasCoordinates());
|
|
}
|
|
|
|
public function test_address_to_coordinates_without_any(): void
|
|
{
|
|
$address = Address::create(['city' => 'NoCoords']);
|
|
|
|
$coords = $address->toCoordinates();
|
|
|
|
$this->assertNull($coords['latitude']);
|
|
$this->assertNull($coords['longitude']);
|
|
$this->assertNull($coords['altitude']);
|
|
}
|
|
|
|
public function test_address_links_relationship(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$company = Company::create(['name' => 'Corp']);
|
|
|
|
$address = Address::create(['city' => 'Vienna']);
|
|
|
|
$user->linkAddress($address, AddressLinkType::Home);
|
|
$company->linkAddress($address, AddressLinkType::Headquarters);
|
|
|
|
$links = $address->links;
|
|
|
|
$this->assertCount(2, $links);
|
|
}
|
|
|
|
public function test_restore_soft_deleted_address(): void
|
|
{
|
|
$address = Address::create(['city' => 'Restored']);
|
|
$id = $address->id;
|
|
|
|
$address->delete();
|
|
$this->assertNull(Address::find($id));
|
|
|
|
Address::withTrashed()->find($id)->restore();
|
|
$this->assertNotNull(Address::find($id));
|
|
$this->assertEquals('Restored', Address::find($id)->city);
|
|
}
|
|
|
|
public function test_address_fillable_notes(): void
|
|
{
|
|
$address = Address::create([
|
|
'notes' => 'Ring doorbell twice. Dog in yard.',
|
|
]);
|
|
|
|
$this->assertEquals('Ring doorbell twice. Dog in yard.', $address->notes);
|
|
}
|
|
|
|
public function test_address_meta_via_has_meta(): void
|
|
{
|
|
$address = Address::create([
|
|
'city' => 'MetaTest',
|
|
'meta' => ['what3words' => 'filled.count.soap'],
|
|
]);
|
|
|
|
$meta = $address->getMeta();
|
|
$this->assertEquals('filled.count.soap', $meta->what3words);
|
|
}
|
|
|
|
public function test_address_latitude_longitude_cast_to_float(): void
|
|
{
|
|
$address = Address::create([
|
|
'latitude' => '48.2082',
|
|
'longitude' => '16.3738',
|
|
'altitude' => '171.5',
|
|
]);
|
|
|
|
$this->assertIsFloat($address->latitude);
|
|
$this->assertIsFloat($address->longitude);
|
|
$this->assertIsFloat($address->altitude);
|
|
}
|
|
|
|
// ─── AddressLink — edge cases ────────────────────────────────
|
|
|
|
public function test_update_link_type(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress(['city' => 'X'], AddressLinkType::Home);
|
|
|
|
$link->update(['type' => AddressLinkType::Office->value]);
|
|
|
|
$this->assertEquals(AddressLinkType::Office, $link->fresh()->type);
|
|
}
|
|
|
|
public function test_update_link_label(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress(['city' => 'X'], AddressLinkType::Other, ['label' => 'Old']);
|
|
|
|
$link->update(['label' => 'New Label']);
|
|
|
|
$this->assertEquals('New Label', $link->fresh()->label);
|
|
}
|
|
|
|
public function test_is_active_with_future_active_from(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress(['city' => 'Future'], AddressLinkType::Home, [
|
|
'active_from' => now()->addMonth(),
|
|
]);
|
|
|
|
$this->assertFalse($link->isActive());
|
|
}
|
|
|
|
public function test_is_active_with_past_active_from_only(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress(['city' => 'Past'], AddressLinkType::Home, [
|
|
'active_from' => now()->subMonth(),
|
|
]);
|
|
|
|
$this->assertTrue($link->isActive());
|
|
}
|
|
|
|
public function test_is_active_with_future_active_until_only(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress(['city' => 'StillGood'], AddressLinkType::Home, [
|
|
'active_until' => now()->addYear(),
|
|
]);
|
|
|
|
$this->assertTrue($link->isActive());
|
|
}
|
|
|
|
public function test_is_active_with_neither_temporal_bound(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress(['city' => 'Forever'], AddressLinkType::Home);
|
|
|
|
$this->assertTrue($link->isActive());
|
|
}
|
|
|
|
public function test_scope_chaining_active_and_of_type(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
// Active home
|
|
$user->addAddress(['city' => 'ActiveHome'], AddressLinkType::Home, [
|
|
'active_from' => now()->subDay(),
|
|
'active_until' => now()->addDay(),
|
|
]);
|
|
// Expired home
|
|
$user->addAddress(['city' => 'ExpiredHome'], AddressLinkType::Home, [
|
|
'active_until' => now()->subDay(),
|
|
]);
|
|
// Active office
|
|
$user->addAddress(['city' => 'ActiveOffice'], AddressLinkType::Office);
|
|
|
|
$activeHomes = $user->addressLinks()->active()->ofType(AddressLinkType::Home)->get();
|
|
|
|
$this->assertCount(1, $activeHomes);
|
|
$this->assertEquals('ActiveHome', $activeHomes->first()->address->city);
|
|
}
|
|
|
|
public function test_scope_primary_and_of_type(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$user->addAddress(['city' => 'A'], AddressLinkType::Home, ['is_primary' => true]);
|
|
$user->addAddress(['city' => 'B'], AddressLinkType::Home);
|
|
$user->addAddress(['city' => 'C'], AddressLinkType::Office, ['is_primary' => true]);
|
|
|
|
$primaryHomes = $user->addressLinks()->primary()->ofType(AddressLinkType::Home)->get();
|
|
|
|
$this->assertCount(1, $primaryHomes);
|
|
}
|
|
|
|
public function test_scope_of_type_with_string(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$user->addAddress(['city' => 'A'], AddressLinkType::Shipping);
|
|
|
|
$result = $user->addressLinks()->ofType('shipping')->get();
|
|
$this->assertCount(1, $result);
|
|
}
|
|
|
|
public function test_addressable_for_company(): void
|
|
{
|
|
$company = Company::create(['name' => 'Widget Inc']);
|
|
$link = $company->addAddress(['city' => 'Zurich'], AddressLinkType::Headquarters);
|
|
|
|
$fresh = AddressLink::find($link->id);
|
|
$this->assertInstanceOf(Company::class, $fresh->addressable);
|
|
$this->assertEquals('Widget Inc', $fresh->addressable->name);
|
|
}
|
|
|
|
// ─── HasAddresses trait — more interactions ──────────────────
|
|
|
|
public function test_same_address_reused_across_three_users(): void
|
|
{
|
|
$address = Address::create([
|
|
'street' => 'Shared Office 1',
|
|
'city' => 'Vienna',
|
|
'country_code' => 'AT',
|
|
]);
|
|
|
|
$u1 = User::factory()->create();
|
|
$u2 = User::factory()->create();
|
|
$u3 = User::factory()->create();
|
|
|
|
$u1->linkAddress($address, AddressLinkType::Office);
|
|
$u2->linkAddress($address, AddressLinkType::Office);
|
|
$u3->linkAddress($address, AddressLinkType::Office);
|
|
|
|
$this->assertCount(3, $address->links);
|
|
$this->assertTrue($u1->hasAddresses());
|
|
$this->assertTrue($u2->hasAddresses());
|
|
$this->assertTrue($u3->hasAddresses());
|
|
|
|
// All three see the same physical address
|
|
$this->assertEquals($address->id, $u1->addresses->first()->id);
|
|
$this->assertEquals($address->id, $u2->addresses->first()->id);
|
|
$this->assertEquals($address->id, $u3->addresses->first()->id);
|
|
}
|
|
|
|
public function test_multiple_addresses_of_same_type(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$user->addAddress(['city' => 'Office A'], AddressLinkType::Office);
|
|
$user->addAddress(['city' => 'Office B'], AddressLinkType::Office);
|
|
$user->addAddress(['city' => 'Office C'], AddressLinkType::Office);
|
|
|
|
$offices = $user->addressesOfType(AddressLinkType::Office);
|
|
$this->assertCount(3, $offices);
|
|
}
|
|
|
|
public function test_primary_address_without_type_filter(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$user->addAddress(['city' => 'NotPrimary'], AddressLinkType::Home);
|
|
$user->addAddress(['city' => 'IsPrimary'], AddressLinkType::Office, ['is_primary' => true]);
|
|
|
|
$primary = $user->primaryAddress();
|
|
|
|
$this->assertNotNull($primary);
|
|
$this->assertEquals('IsPrimary', $primary->city);
|
|
}
|
|
|
|
public function test_primary_address_null_when_no_primary_at_all(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$user->addAddress(['city' => 'A'], AddressLinkType::Home);
|
|
$user->addAddress(['city' => 'B'], AddressLinkType::Office);
|
|
|
|
$this->assertNull($user->primaryAddress());
|
|
}
|
|
|
|
public function test_remove_address_link_returns_false_for_nonexistent(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
// removeAddressLink deletes by query; 0 rows affected → bool(false)
|
|
$this->assertFalse($user->removeAddressLink(99999));
|
|
}
|
|
|
|
public function test_detach_address_by_id(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$address = Address::create(['city' => 'ById']);
|
|
$user->linkAddress($address, AddressLinkType::Home);
|
|
$user->linkAddress($address, AddressLinkType::Billing);
|
|
|
|
$removed = $user->detachAddress($address->id);
|
|
|
|
$this->assertEquals(2, $removed);
|
|
$this->assertFalse($user->hasAddresses());
|
|
}
|
|
|
|
public function test_link_address_with_all_pivot_fields(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$address = Address::create(['city' => 'Full Pivot']);
|
|
|
|
$link = $user->linkAddress($address, AddressLinkType::Temporary, [
|
|
'label' => 'Summer Rental',
|
|
'is_primary' => true,
|
|
'active_from' => now()->subDay(),
|
|
'active_until' => now()->addMonths(3),
|
|
'meta' => ['lease_id' => 'L-2026-001'],
|
|
]);
|
|
|
|
$this->assertEquals('Summer Rental', $link->label);
|
|
$this->assertTrue($link->is_primary);
|
|
$this->assertNotNull($link->active_from);
|
|
$this->assertNotNull($link->active_until);
|
|
$this->assertTrue($link->isActive());
|
|
$this->assertEquals('L-2026-001', $link->getMeta()->lease_id);
|
|
}
|
|
|
|
public function test_addresses_morphtomany_pivot_fields(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$user->addAddress(
|
|
['city' => 'PivotTest'],
|
|
AddressLinkType::Home,
|
|
['label' => 'My Flat', 'is_primary' => true]
|
|
);
|
|
|
|
$address = $user->addresses()->first();
|
|
$pivot = $address->pivot;
|
|
|
|
$this->assertEquals('home', $pivot->type);
|
|
$this->assertEquals('My Flat', $pivot->label);
|
|
$this->assertEquals(1, $pivot->is_primary);
|
|
$this->assertNotNull($pivot->id);
|
|
$this->assertNotNull($pivot->created_at);
|
|
}
|
|
|
|
public function test_same_address_billing_and_shipping(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$address = Address::create([
|
|
'street' => 'Main Street 1',
|
|
'city' => 'Graz',
|
|
'country_code' => 'AT',
|
|
]);
|
|
|
|
$billingLink = $user->linkAddress($address, AddressLinkType::Billing, ['is_primary' => true]);
|
|
$shippingLink = $user->linkAddress($address, AddressLinkType::Shipping, ['is_primary' => true]);
|
|
|
|
// Both link types exist
|
|
$this->assertTrue($user->hasAddressOfType(AddressLinkType::Billing));
|
|
$this->assertTrue($user->hasAddressOfType(AddressLinkType::Shipping));
|
|
|
|
// Both resolve to same address
|
|
$billingAddr = $user->primaryAddress(AddressLinkType::Billing);
|
|
$shippingAddr = $user->primaryAddress(AddressLinkType::Shipping);
|
|
$this->assertEquals($billingAddr->id, $shippingAddr->id);
|
|
}
|
|
|
|
public function test_set_primary_clears_only_same_type(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$home1 = $user->addAddress(['city' => 'Home1'], AddressLinkType::Home, ['is_primary' => true]);
|
|
$home2 = $user->addAddress(['city' => 'Home2'], AddressLinkType::Home);
|
|
$office1 = $user->addAddress(['city' => 'Office1'], AddressLinkType::Office, ['is_primary' => true]);
|
|
|
|
$user->setPrimaryAddressLink($home2->id);
|
|
|
|
$this->assertFalse($home1->fresh()->is_primary);
|
|
$this->assertTrue($home2->fresh()->is_primary);
|
|
$this->assertTrue($office1->fresh()->is_primary); // untouched
|
|
}
|
|
|
|
public function test_multiple_primary_addresses_across_types(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$user->addAddress(['city' => 'HomeP'], AddressLinkType::Home, ['is_primary' => true]);
|
|
$user->addAddress(['city' => 'OfficeP'], AddressLinkType::Office, ['is_primary' => true]);
|
|
$user->addAddress(['city' => 'BillingP'], AddressLinkType::Billing, ['is_primary' => true]);
|
|
|
|
$this->assertEquals('HomeP', $user->primaryAddress(AddressLinkType::Home)->city);
|
|
$this->assertEquals('OfficeP', $user->primaryAddress(AddressLinkType::Office)->city);
|
|
$this->assertEquals('BillingP', $user->primaryAddress(AddressLinkType::Billing)->city);
|
|
}
|
|
|
|
// ─── AddressLinkType enum — exhaustive ───────────────────────
|
|
|
|
public function test_all_enum_cases_exist(): void
|
|
{
|
|
$cases = AddressLinkType::cases();
|
|
$this->assertCount(17, $cases);
|
|
|
|
$values = array_map(fn($c) => $c->value, $cases);
|
|
$this->assertContains('home', $values);
|
|
$this->assertContains('secondary_residence', $values);
|
|
$this->assertContains('office', $values);
|
|
$this->assertContains('headquarters', $values);
|
|
$this->assertContains('branch', $values);
|
|
$this->assertContains('factory', $values);
|
|
$this->assertContains('warehouse', $values);
|
|
$this->assertContains('shipping', $values);
|
|
$this->assertContains('billing', $values);
|
|
$this->assertContains('return', $values);
|
|
$this->assertContains('pickup', $values);
|
|
$this->assertContains('point_of_interest', $values);
|
|
$this->assertContains('site', $values);
|
|
$this->assertContains('temporary', $values);
|
|
$this->assertContains('contact', $values);
|
|
$this->assertContains('legal', $values);
|
|
$this->assertContains('other', $values);
|
|
}
|
|
|
|
public function test_all_enum_values_are_unique(): void
|
|
{
|
|
$values = array_map(fn($c) => $c->value, AddressLinkType::cases());
|
|
$this->assertEquals(count($values), count(array_unique($values)));
|
|
}
|
|
|
|
public function test_all_enum_labels_are_nonempty(): void
|
|
{
|
|
foreach (AddressLinkType::cases() as $case) {
|
|
$this->assertNotEmpty($case->label(), "Label for {$case->value} is empty");
|
|
}
|
|
}
|
|
|
|
public function test_enum_try_from_invalid_returns_null(): void
|
|
{
|
|
$this->assertNull(AddressLinkType::tryFrom('nonexistent'));
|
|
$this->assertNull(AddressLinkType::tryFrom(''));
|
|
}
|
|
|
|
public function test_each_enum_type_can_be_used_as_link(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
foreach (AddressLinkType::cases() as $case) {
|
|
$link = $user->addAddress(['city' => "City_{$case->value}"], $case);
|
|
$this->assertEquals($case, $link->type, "Failed to link with type {$case->value}");
|
|
}
|
|
|
|
$this->assertCount(17, $user->addressLinks);
|
|
}
|
|
|
|
// ─── Address deletion cascades ───────────────────────────────
|
|
|
|
public function test_force_delete_address_cascades_links_and_assignments(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress([
|
|
'city' => 'CascadeAll',
|
|
], AddressLinkType::Office);
|
|
|
|
$job = Job::create(['title' => 'CascadeJob']);
|
|
$assignment = $job->assignAddressLink($link, 'pickup');
|
|
|
|
$addressId = $link->address_id;
|
|
|
|
// Force-delete the address (not soft-delete)
|
|
Address::withTrashed()->find($addressId)->forceDelete();
|
|
|
|
// Link gone
|
|
$this->assertDatabaseMissing('address_links', ['id' => $link->id]);
|
|
// Assignment gone
|
|
$this->assertDatabaseMissing('address_assignments', ['id' => $assignment->id]);
|
|
}
|
|
|
|
public function test_soft_delete_address_preserves_links(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress(['city' => 'SoftDel'], AddressLinkType::Home);
|
|
|
|
$link->address->delete(); // soft delete
|
|
|
|
// Links remain because we only soft-deleted
|
|
$this->assertDatabaseHas('address_links', ['id' => $link->id]);
|
|
}
|
|
|
|
// ─── AddressAssignment — additional interactions ─────────────
|
|
|
|
public function test_update_assignment_role(): void
|
|
{
|
|
['assignment' => $assignment] = $this->createJobWithAssignment('pickup');
|
|
|
|
$assignment->update(['role' => 'origin']);
|
|
|
|
$this->assertEquals('origin', $assignment->fresh()->role);
|
|
}
|
|
|
|
public function test_update_assignment_label(): void
|
|
{
|
|
['assignment' => $assignment] = $this->createJobWithAssignment('pickup');
|
|
|
|
$assignment->update(['label' => 'Updated Label']);
|
|
|
|
$this->assertEquals('Updated Label', $assignment->fresh()->label);
|
|
}
|
|
|
|
public function test_assignment_meta_get_and_set(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress(['city' => 'Y']);
|
|
|
|
$job = Job::create(['title' => 'Meta Job']);
|
|
$assignment = $job->assignAddressLink($link, 'delivery', [
|
|
'meta' => ['eta' => '14:00', 'requires_signature' => true],
|
|
]);
|
|
|
|
$meta = $assignment->getMeta();
|
|
$this->assertEquals('14:00', $meta->eta);
|
|
$this->assertTrue($meta->requires_signature);
|
|
}
|
|
|
|
public function test_remove_address_assignment_returns_false_for_nonexistent(): void
|
|
{
|
|
$job = Job::create(['title' => 'Empty']);
|
|
|
|
$this->assertFalse($job->removeAddressAssignment(99999));
|
|
}
|
|
|
|
public function test_duplicate_assignment_same_link_same_role(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress(['city' => 'Dup']);
|
|
|
|
$job = Job::create(['title' => 'Dup Test']);
|
|
$a1 = $job->assignAddressLink($link, 'stop');
|
|
$a2 = $job->assignAddressLink($link, 'stop');
|
|
|
|
// Both assignments are created (no unique constraint)
|
|
$this->assertNotEquals($a1->id, $a2->id);
|
|
$this->assertCount(2, $job->fresh()->addressAssignments);
|
|
}
|
|
|
|
public function test_replace_assignment_for_role(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link1 = $user->addAddress(['city' => 'Old'], AddressLinkType::Home);
|
|
$link2 = $user->addAddress(['city' => 'New'], AddressLinkType::Office);
|
|
|
|
$job = Job::create(['title' => 'Replace']);
|
|
$job->assignAddressLink($link1, 'pickup');
|
|
|
|
// Replace: remove old, add new
|
|
$job->removeAssignmentsForRole('pickup');
|
|
$job->assignAddressLink($link2, 'pickup');
|
|
|
|
$address = $job->assignedAddressForRole('pickup');
|
|
$this->assertEquals('New', $address->city);
|
|
$this->assertCount(1, $job->addressAssignmentsForRole('pickup'));
|
|
}
|
|
|
|
public function test_assignment_address_through_returns_null_for_soft_deleted_address(): void
|
|
{
|
|
['assignment' => $assignment, 'link' => $link] = $this->createJobWithAssignment();
|
|
|
|
$link->address->delete(); // soft delete
|
|
|
|
// HasOneThrough does not traverse soft-deleted by default
|
|
$fresh = AddressAssignment::with('address')->find($assignment->id);
|
|
$this->assertNull($fresh->address);
|
|
}
|
|
|
|
public function test_assignment_address_link_has_full_access_to_address(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress([
|
|
'street' => 'Long Road 42',
|
|
'city' => 'Salzburg',
|
|
'postal_code' => '5020',
|
|
'country_code' => 'AT',
|
|
'latitude' => 47.8095,
|
|
'longitude' => 13.0550,
|
|
], AddressLinkType::Home);
|
|
|
|
$job = Job::create(['title' => 'Full Access']);
|
|
$assignment = $job->assignAddressLink($link, 'delivery');
|
|
|
|
// Access through loaded relations
|
|
$address = $assignment->addressLink->address;
|
|
$this->assertEquals('Long Road 42', $address->street);
|
|
$this->assertEquals('Salzburg', $address->city);
|
|
$this->assertEquals('5020', $address->postal_code);
|
|
$this->assertEquals('AT', $address->country_code);
|
|
$this->assertTrue($address->hasCoordinates());
|
|
$this->assertStringContainsString('Salzburg', $address->formatted);
|
|
}
|
|
|
|
// ─── AddressService — additional coverage ────────────────────
|
|
|
|
public function test_merge_reassigns_assignments_through_repointed_links(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$target = Address::create(['city' => 'Target']);
|
|
$duplicate = Address::create(['city' => 'Dup']);
|
|
|
|
$link = $user->linkAddress($duplicate, AddressLinkType::Office);
|
|
$job = Job::create(['title' => 'MergeJob']);
|
|
$assignment = $job->assignAddressLink($link, 'pickup');
|
|
|
|
$this->service()->merge($target, $duplicate);
|
|
|
|
// Link now points to target
|
|
$freshLink = AddressLink::find($link->id);
|
|
$this->assertEquals($target->id, $freshLink->address_id);
|
|
|
|
// Assignment still works through the link
|
|
$assignedAddr = $job->assignedAddressForRole('pickup');
|
|
$this->assertEquals('Target', $assignedAddr->city);
|
|
}
|
|
|
|
public function test_nearby_with_miles(): void
|
|
{
|
|
Address::create(['city' => 'Close', 'latitude' => 48.21, 'longitude' => 16.38]);
|
|
Address::create(['city' => 'Far', 'latitude' => 47.0707, 'longitude' => 15.4395]);
|
|
|
|
// ~3 mi radius
|
|
$results = $this->service()->nearby(48.2082, 16.3738, 3, 'mi');
|
|
|
|
$this->assertCount(1, $results);
|
|
$this->assertEquals('Close', $results->first()->city);
|
|
}
|
|
|
|
public function test_nearby_empty_results(): void
|
|
{
|
|
Address::create(['city' => 'Far', 'latitude' => -33.8688, 'longitude' => 151.2093]);
|
|
|
|
// Search near Vienna, nothing within 1 km
|
|
$results = $this->service()->nearby(48.2082, 16.3738, 1);
|
|
|
|
$this->assertEmpty($results);
|
|
}
|
|
|
|
public function test_closest_among_many(): void
|
|
{
|
|
Address::create(['city' => 'A', 'latitude' => 48.30, 'longitude' => 16.50]);
|
|
Address::create(['city' => 'B', 'latitude' => 48.25, 'longitude' => 16.42]);
|
|
Address::create(['city' => 'C', 'latitude' => 48.21, 'longitude' => 16.38]);
|
|
Address::create(['city' => 'D', 'latitude' => 47.07, 'longitude' => 15.44]);
|
|
|
|
$closest = $this->service()->closest(48.2082, 16.3738);
|
|
|
|
$this->assertEquals('C', $closest->city);
|
|
}
|
|
|
|
public function test_in_country_with_lowercase(): void
|
|
{
|
|
Address::create(['city' => 'Vienna', 'country_code' => 'AT']);
|
|
|
|
// Service uppercases the input
|
|
$result = $this->service()->inCountry('at')->get();
|
|
|
|
$this->assertCount(1, $result);
|
|
}
|
|
|
|
public function test_find_duplicates_none(): void
|
|
{
|
|
$address = Address::create(['street' => 'Unique', 'city' => 'A']);
|
|
Address::create(['street' => 'Different', 'city' => 'B']);
|
|
|
|
$dups = $this->service()->findDuplicates($address);
|
|
|
|
$this->assertCount(0, $dups);
|
|
}
|
|
|
|
public function test_find_duplicates_ignores_soft_deleted(): void
|
|
{
|
|
$addr = Address::create(['street' => 'Same', 'city' => 'X', 'country_code' => 'AT']);
|
|
$dup = Address::create(['street' => 'Same', 'city' => 'X', 'country_code' => 'AT']);
|
|
$dup->delete(); // soft delete
|
|
|
|
$dups = $this->service()->findDuplicates($addr);
|
|
|
|
$this->assertCount(0, $dups);
|
|
}
|
|
|
|
public function test_format_minimal(): void
|
|
{
|
|
$addr = Address::create(['city' => 'Lonely']);
|
|
|
|
$this->assertEquals('Lonely', $this->service()->format($addr));
|
|
}
|
|
|
|
public function test_format_multiline_minimal(): void
|
|
{
|
|
$addr = Address::create(['city' => 'Solo']);
|
|
|
|
$this->assertEquals('Solo', $this->service()->formatMultiline($addr));
|
|
}
|
|
|
|
public function test_format_multiline_with_county(): void
|
|
{
|
|
$addr = Address::create([
|
|
'street' => 'Main St',
|
|
'city' => 'Springfield',
|
|
'state' => 'IL',
|
|
'county' => 'Sangamon',
|
|
'country_code' => 'US',
|
|
]);
|
|
|
|
$multi = $this->service()->formatMultiline($addr);
|
|
|
|
$this->assertStringContainsString('Sangamon', $multi);
|
|
$this->assertStringContainsString('Springfield', $multi);
|
|
$this->assertStringContainsString('US', $multi);
|
|
}
|
|
|
|
public function test_format_coordinates_with_altitude_negative(): void
|
|
{
|
|
$addr = Address::create([
|
|
'latitude' => 31.5,
|
|
'longitude' => 35.5,
|
|
'altitude' => -430.5,
|
|
]);
|
|
|
|
$result = $this->service()->formatCoordinates($addr);
|
|
|
|
$this->assertStringContainsString('N', $result);
|
|
$this->assertStringContainsString('E', $result);
|
|
$this->assertStringContainsString('-430.50', $result);
|
|
$this->assertStringContainsString('AMSL', $result);
|
|
}
|
|
|
|
public function test_dms_to_decimal_east(): void
|
|
{
|
|
// 16°22'25.68"E → 16.3738
|
|
$result = $this->service()->dmsToDecimal(16, 22, 25.68, 'E');
|
|
$this->assertEqualsWithDelta(16.3738, $result, 0.001);
|
|
}
|
|
|
|
public function test_dms_to_decimal_west(): void
|
|
{
|
|
$result = $this->service()->dmsToDecimal(73, 59, 8.4, 'W');
|
|
$this->assertLessThan(0, $result);
|
|
}
|
|
|
|
public function test_decimal_to_dms_south(): void
|
|
{
|
|
$result = $this->service()->decimalToDms(-33.8688, 'lat');
|
|
$this->assertEquals('S', $result['direction']);
|
|
$this->assertEquals(33, $result['degrees']);
|
|
}
|
|
|
|
public function test_decimal_to_dms_east(): void
|
|
{
|
|
$result = $this->service()->decimalToDms(16.3738, 'lng');
|
|
$this->assertEquals('E', $result['direction']);
|
|
$this->assertEquals(16, $result['degrees']);
|
|
}
|
|
|
|
public function test_dms_roundtrip_longitude(): void
|
|
{
|
|
$original = -73.9856;
|
|
$dms = $this->service()->decimalToDms($original, 'lng');
|
|
$back = $this->service()->dmsToDecimal($dms['degrees'], $dms['minutes'], $dms['seconds'], $dms['direction']);
|
|
|
|
$this->assertEqualsWithDelta($original, $back, 0.0001);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// INTEGRATION — complex multi-model scenarios
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
public function test_full_lifecycle_create_link_assign_reassign_remove(): void
|
|
{
|
|
// 1. Create user + address
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress([
|
|
'street' => 'Lifecycle Str. 1',
|
|
'city' => 'Vienna',
|
|
'country_code' => 'AT',
|
|
], AddressLinkType::Office, ['is_primary' => true]);
|
|
|
|
$this->assertTrue($user->hasAddresses());
|
|
|
|
// 2. Create job, assign the link
|
|
$job = Job::create(['title' => 'Lifecycle Job']);
|
|
$assignment = $job->assignAddressLink($link, 'pickup');
|
|
|
|
$this->assertTrue($job->hasAddressAssignments());
|
|
$this->assertEquals('Vienna', $job->assignedAddressForRole('pickup')->city);
|
|
|
|
// 3. User moves: create new address, reassign job
|
|
$newLink = $user->addAddress([
|
|
'street' => 'New Place 5',
|
|
'city' => 'Graz',
|
|
'country_code' => 'AT',
|
|
], AddressLinkType::Office);
|
|
|
|
// Promote new link as primary (unsets old primary for same type)
|
|
$user->setPrimaryAddressLink($newLink->id);
|
|
|
|
$job->removeAssignmentsForRole('pickup');
|
|
$job->assignAddressLink($newLink, 'pickup');
|
|
|
|
$this->assertEquals('Graz', $job->assignedAddressForRole('pickup')->city);
|
|
|
|
// 4. Old link still exists but no longer primary
|
|
$this->assertFalse($link->fresh()->is_primary);
|
|
$this->assertTrue($newLink->fresh()->is_primary);
|
|
|
|
// 5. Remove job assignments completely
|
|
$job->removeAllAddressAssignments();
|
|
$this->assertFalse($job->hasAddressAssignments());
|
|
|
|
// 6. User still has 2 addresses
|
|
$this->assertCount(2, $user->fresh()->addressLinks);
|
|
}
|
|
|
|
public function test_one_address_shared_user_company_job(): void
|
|
{
|
|
// One physical address used by 3 different models at different layers
|
|
$address = Address::create([
|
|
'street' => 'Shared Tower',
|
|
'city' => 'Vienna',
|
|
'country_code' => 'AT',
|
|
]);
|
|
|
|
$user = User::factory()->create();
|
|
$company = Company::create(['name' => 'SharedCorp']);
|
|
|
|
// User & Company own links to the same address
|
|
$userLink = $user->linkAddress($address, AddressLinkType::Office, ['label' => 'My Office']);
|
|
$companyLink = $company->linkAddress($address, AddressLinkType::Headquarters);
|
|
|
|
// Job is assigned both links for different roles
|
|
$job = Job::create(['title' => 'Shared Job']);
|
|
$job->assignAddressLink($userLink, 'pickup');
|
|
$job->assignAddressLink($companyLink, 'billing');
|
|
|
|
// Verify all three models reference the same address
|
|
$this->assertEquals($address->id, $user->addresses->first()->id);
|
|
$this->assertEquals($address->id, $company->addresses->first()->id);
|
|
$this->assertEquals($address->id, $job->assignedAddressForRole('pickup')->id);
|
|
$this->assertEquals($address->id, $job->assignedAddressForRole('billing')->id);
|
|
|
|
// Address has 2 links
|
|
$this->assertCount(2, $address->links);
|
|
|
|
// But job has 2 assignments
|
|
$this->assertCount(2, $job->addressAssignments);
|
|
}
|
|
|
|
public function test_labels_same_address_same_model_different_labels(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$address = Address::create([
|
|
'street' => 'Multi-Label Avenue',
|
|
'city' => 'Munich',
|
|
]);
|
|
|
|
$user->linkAddress($address, AddressLinkType::Other, ['label' => 'Start of project']);
|
|
$user->linkAddress($address, AddressLinkType::Other, ['label' => 'End of project']);
|
|
$user->linkAddress($address, AddressLinkType::Other, ['label' => 'Meeting point']);
|
|
|
|
$links = $user->addressLinks()->get();
|
|
$labels = $links->pluck('label')->sort()->values()->toArray();
|
|
|
|
$this->assertCount(3, $links);
|
|
$this->assertEquals(['End of project', 'Meeting point', 'Start of project'], $labels);
|
|
}
|
|
|
|
public function test_relabel_a_link(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress(['city' => 'X'], AddressLinkType::Other, ['label' => 'Temp Name']);
|
|
|
|
$link->update(['label' => 'Permanent Name']);
|
|
|
|
$this->assertEquals('Permanent Name', $link->fresh()->label);
|
|
}
|
|
|
|
public function test_counting_addresses_links_assignments(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link1 = $user->addAddress(['city' => 'A'], AddressLinkType::Home);
|
|
$link2 = $user->addAddress(['city' => 'B'], AddressLinkType::Office);
|
|
$link3 = $user->addAddress(['city' => 'C'], AddressLinkType::Billing);
|
|
|
|
$job1 = Job::create(['title' => 'J1']);
|
|
$job2 = Job::create(['title' => 'J2']);
|
|
|
|
$job1->assignAddressLink($link1, 'pickup');
|
|
$job1->assignAddressLink($link2, 'delivery');
|
|
$job2->assignAddressLink($link1, 'pickup');
|
|
|
|
$this->assertEquals(3, Address::count());
|
|
$this->assertEquals(3, AddressLink::count());
|
|
$this->assertEquals(3, AddressAssignment::count());
|
|
$this->assertCount(3, $user->addressLinks);
|
|
$this->assertCount(2, $job1->addressAssignments);
|
|
$this->assertCount(1, $job2->addressAssignments);
|
|
}
|
|
|
|
public function test_temporal_link_not_started_yet(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress(['city' => 'Future'], AddressLinkType::Office, [
|
|
'active_from' => now()->addWeek(),
|
|
]);
|
|
|
|
$this->assertFalse($link->isActive());
|
|
|
|
$activeLinks = $user->activeAddressLinks();
|
|
$this->assertCount(0, $activeLinks);
|
|
}
|
|
|
|
public function test_temporal_link_started_today(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress(['city' => 'Today'], AddressLinkType::Office, [
|
|
'active_from' => now(),
|
|
]);
|
|
|
|
$this->assertTrue($link->isActive());
|
|
}
|
|
|
|
public function test_address_used_for_distance_after_linking(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link1 = $user->addAddress([
|
|
'city' => 'Vienna',
|
|
'latitude' => 48.2082,
|
|
'longitude' => 16.3738,
|
|
], AddressLinkType::Home);
|
|
|
|
$link2 = $user->addAddress([
|
|
'city' => 'Graz',
|
|
'latitude' => 47.0707,
|
|
'longitude' => 15.4395,
|
|
], AddressLinkType::Office);
|
|
|
|
$dist = $this->service()->distanceBetween($link1->address, $link2->address);
|
|
|
|
$this->assertNotNull($dist);
|
|
$this->assertEqualsWithDelta(145.0, $dist, 5.0);
|
|
}
|
|
|
|
public function test_nearby_through_assigned_address(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
Address::create(['city' => 'Near', 'latitude' => 48.21, 'longitude' => 16.38]);
|
|
$link = $user->addAddress([
|
|
'city' => 'Vienna',
|
|
'latitude' => 48.2082,
|
|
'longitude' => 16.3738,
|
|
], AddressLinkType::Office);
|
|
|
|
$job = Job::create(['title' => 'Nearby Job']);
|
|
$job->assignAddressLink($link, 'origin');
|
|
|
|
// Get the address through the assignment, then find nearby
|
|
$origin = $job->assignedAddressForRole('origin');
|
|
$nearby = $this->service()->nearbyAddress($origin, 10);
|
|
|
|
$this->assertNotEmpty($nearby);
|
|
$this->assertNotContains($origin->id, $nearby->pluck('id')->all());
|
|
}
|
|
|
|
public function test_merge_with_assignments_full_scenario(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
// Two duplicate addresses
|
|
$addr1 = Address::create(['street' => 'Same St', 'city' => 'Linz', 'country_code' => 'AT']);
|
|
$addr2 = Address::create(['street' => 'Same St', 'city' => 'Linz', 'country_code' => 'AT']);
|
|
|
|
$link1 = $user->linkAddress($addr1, AddressLinkType::Home);
|
|
$link2 = $user->linkAddress($addr2, AddressLinkType::Office);
|
|
|
|
$job = Job::create(['title' => 'Merge Job']);
|
|
$job->assignAddressLink($link2, 'pickup');
|
|
|
|
// Merge addr2 into addr1
|
|
$reassigned = $this->service()->merge($addr1, $addr2);
|
|
|
|
// link2 now points to addr1
|
|
$this->assertEquals($addr1->id, AddressLink::find($link2->id)->address_id);
|
|
|
|
// addr2 is soft-deleted
|
|
$this->assertSoftDeleted('addresses', ['id' => $addr2->id]);
|
|
|
|
// Assignment still works, now resolving to addr1
|
|
$address = $job->assignedAddressForRole('pickup');
|
|
$this->assertEquals($addr1->id, $address->id);
|
|
}
|
|
|
|
public function test_company_with_multiple_branch_addresses(): void
|
|
{
|
|
$company = Company::create(['name' => 'Multi-Branch Corp']);
|
|
|
|
$company->addAddress(['city' => 'Vienna'], AddressLinkType::Headquarters, [
|
|
'label' => 'Main HQ',
|
|
'is_primary' => true,
|
|
]);
|
|
$company->addAddress(['city' => 'Graz'], AddressLinkType::Branch, ['label' => 'South Branch']);
|
|
$company->addAddress(['city' => 'Linz'], AddressLinkType::Branch, ['label' => 'North Branch']);
|
|
$company->addAddress(['city' => 'Salzburg'], AddressLinkType::Warehouse);
|
|
|
|
$this->assertCount(4, $company->addresses);
|
|
$this->assertCount(2, $company->addressesOfType(AddressLinkType::Branch));
|
|
$this->assertEquals('Vienna', $company->primaryAddress(AddressLinkType::Headquarters)->city);
|
|
$this->assertNull($company->primaryAddress(AddressLinkType::Branch));
|
|
}
|
|
|
|
public function test_user_and_company_share_address_with_different_types_and_labels(): void
|
|
{
|
|
$address = Address::create([
|
|
'street' => 'Business Park 5',
|
|
'city' => 'Vienna',
|
|
'country_code' => 'AT',
|
|
]);
|
|
|
|
$user = User::factory()->create();
|
|
$company = Company::create(['name' => 'Co-Located Inc']);
|
|
|
|
$userLink = $user->linkAddress($address, AddressLinkType::Office, ['label' => 'My desk at CO']);
|
|
$companyLink = $company->linkAddress($address, AddressLinkType::Headquarters, ['label' => 'Official HQ']);
|
|
|
|
// Same address, different contexts
|
|
$this->assertEquals($address->id, $userLink->address->id);
|
|
$this->assertEquals($address->id, $companyLink->address->id);
|
|
$this->assertEquals('My desk at CO', $userLink->label);
|
|
$this->assertEquals('Official HQ', $companyLink->label);
|
|
$this->assertEquals(AddressLinkType::Office, $userLink->type);
|
|
$this->assertEquals(AddressLinkType::Headquarters, $companyLink->type);
|
|
}
|
|
|
|
public function test_job_with_pickup_delivery_waypoints(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$home = $user->addAddress([
|
|
'street' => 'Home St 1',
|
|
'city' => 'Vienna',
|
|
'latitude' => 48.2082,
|
|
'longitude' => 16.3738,
|
|
], AddressLinkType::Home);
|
|
|
|
$office = $user->addAddress([
|
|
'street' => 'Office Blvd 42',
|
|
'city' => 'Graz',
|
|
'latitude' => 47.0707,
|
|
'longitude' => 15.4395,
|
|
], AddressLinkType::Office);
|
|
|
|
$company = Company::create(['name' => 'Warehouse Co']);
|
|
$warehouse = $company->addAddress([
|
|
'street' => 'Storage Lane 7',
|
|
'city' => 'Linz',
|
|
'latitude' => 48.3069,
|
|
'longitude' => 14.2858,
|
|
], AddressLinkType::Warehouse);
|
|
|
|
$job = Job::create(['title' => 'Piano Transport']);
|
|
$job->assignAddressLink($home, 'pickup', ['label' => 'Customer home']);
|
|
$job->assignAddressLink($warehouse, 'waypoint', ['label' => 'Temporary storage']);
|
|
$job->assignAddressLink($office, 'delivery', ['label' => 'Customer office']);
|
|
|
|
// All 3 assignments exist
|
|
$this->assertCount(3, $job->addressAssignments);
|
|
|
|
// Verify each role resolves to correct city
|
|
$this->assertEquals('Vienna', $job->assignedAddressForRole('pickup')->city);
|
|
$this->assertEquals('Linz', $job->assignedAddressForRole('waypoint')->city);
|
|
$this->assertEquals('Graz', $job->assignedAddressForRole('delivery')->city);
|
|
|
|
// Calculate distances along the route
|
|
$pickupAddr = $job->assignedAddressForRole('pickup');
|
|
$waypointAddr = $job->assignedAddressForRole('waypoint');
|
|
$deliveryAddr = $job->assignedAddressForRole('delivery');
|
|
|
|
$leg1 = $this->service()->distanceBetween($pickupAddr, $waypointAddr);
|
|
$leg2 = $this->service()->distanceBetween($waypointAddr, $deliveryAddr);
|
|
$total = $leg1 + $leg2;
|
|
|
|
$this->assertGreaterThan(0, $leg1);
|
|
$this->assertGreaterThan(0, $leg2);
|
|
$this->assertGreaterThan($leg1, $total);
|
|
}
|
|
|
|
public function test_addresses_survive_link_removal(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress(['city' => 'Survive'], AddressLinkType::Home);
|
|
$addressId = $link->address_id;
|
|
|
|
$user->removeAddressLink($link->id);
|
|
|
|
// Address record still exists
|
|
$this->assertNotNull(Address::find($addressId));
|
|
$this->assertEquals('Survive', Address::find($addressId)->city);
|
|
}
|
|
|
|
public function test_assignment_survives_when_job_is_deleted(): void
|
|
{
|
|
['job' => $job, 'assignment' => $assignment, 'link' => $link] = $this->createJobWithAssignment();
|
|
$assignmentId = $assignment->id;
|
|
$linkId = $link->id;
|
|
|
|
$job->delete();
|
|
|
|
// Assignment row still exists in DB (no cascade from assignable)
|
|
$this->assertNotNull(AddressAssignment::find($assignmentId));
|
|
// Link still exists
|
|
$this->assertNotNull(AddressLink::find($linkId));
|
|
}
|
|
|
|
public function test_bulk_detach_then_reattach(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$user->addAddress(['city' => 'A'], AddressLinkType::Home);
|
|
$user->addAddress(['city' => 'B'], AddressLinkType::Office);
|
|
$user->addAddress(['city' => 'C'], AddressLinkType::Billing);
|
|
|
|
$this->assertCount(3, $user->fresh()->addresses);
|
|
|
|
$user->detachAllAddresses();
|
|
$this->assertCount(0, $user->fresh()->addresses);
|
|
|
|
// Addresses still exist, can re-link
|
|
$addresses = Address::where('city', 'A')->orWhere('city', 'B')->get();
|
|
foreach ($addresses as $address) {
|
|
$user->linkAddress($address, AddressLinkType::Office);
|
|
}
|
|
|
|
$this->assertCount(2, $user->fresh()->addresses);
|
|
}
|
|
|
|
public function test_address_with_all_worldwide_formats(): void
|
|
{
|
|
// Japanese address
|
|
$jp = Address::create([
|
|
'street' => '丸の内1-9-2',
|
|
'building' => 'グラントウキョウサウスタワー',
|
|
'floor' => '20',
|
|
'room' => '2001',
|
|
'postal_code' => '100-6920',
|
|
'city' => '東京都千代田区',
|
|
'country_code' => 'JP',
|
|
]);
|
|
$this->assertStringContainsString('丸の内', $jp->formatted);
|
|
$this->assertStringContainsString('JP', $jp->formatted);
|
|
|
|
// German format
|
|
$de = Address::create([
|
|
'street' => 'Friedrichstraße 43-45',
|
|
'postal_code' => '10117',
|
|
'city' => 'Berlin',
|
|
'state' => 'Berlin',
|
|
'country_code' => 'DE',
|
|
]);
|
|
$this->assertStringContainsString('Friedrichstraße', $de->formatted);
|
|
|
|
// US format
|
|
$us = Address::create([
|
|
'street' => '1600 Pennsylvania Avenue NW',
|
|
'city' => 'Washington',
|
|
'state' => 'DC',
|
|
'postal_code' => '20500',
|
|
'country_code' => 'US',
|
|
]);
|
|
$this->assertStringContainsString('1600 Pennsylvania', $us->formatted);
|
|
|
|
// Rural GPS-only "address"
|
|
$rural = Address::create([
|
|
'latitude' => -23.5505,
|
|
'longitude' => -46.6333,
|
|
'notes' => 'Third rock on the left past the baobab tree',
|
|
]);
|
|
$this->assertTrue($rural->hasCoordinates());
|
|
$this->assertEquals('', $rural->formatted); // no postal fields
|
|
|
|
// Coordinates-only verification
|
|
$coords = $this->service()->formatCoordinates($rural);
|
|
$this->assertStringContainsString('S', $coords);
|
|
$this->assertStringContainsString('W', $coords);
|
|
}
|
|
|
|
public function test_address_floor_with_non_numeric_values(): void
|
|
{
|
|
$tests = ['GF', 'B2', 'Mezzanine', 'P3', 'Rooftop', '½'];
|
|
|
|
foreach ($tests as $floor) {
|
|
$addr = Address::create(['floor' => $floor]);
|
|
$this->assertEquals($floor, $addr->floor);
|
|
$this->assertStringContainsString("Floor {$floor}", $addr->formatted);
|
|
}
|
|
}
|
|
|
|
public function test_extreme_coordinates(): void
|
|
{
|
|
// North pole
|
|
$np = Address::create(['latitude' => 90.0, 'longitude' => 0.0]);
|
|
$this->assertTrue($np->hasCoordinates());
|
|
|
|
// South pole
|
|
$sp = Address::create(['latitude' => -90.0, 'longitude' => 0.0]);
|
|
$this->assertTrue($sp->hasCoordinates());
|
|
|
|
// Antimeridian
|
|
$am = Address::create(['latitude' => 0.0, 'longitude' => 180.0]);
|
|
$this->assertTrue($am->hasCoordinates());
|
|
$this->assertStringContainsString('E', $this->service()->formatCoordinates($am));
|
|
|
|
// Negative antimeridian
|
|
$amn = Address::create(['latitude' => 0.0, 'longitude' => -180.0]);
|
|
$this->assertStringContainsString('W', $this->service()->formatCoordinates($amn));
|
|
}
|
|
|
|
public function test_distance_between_poles(): void
|
|
{
|
|
$north = Address::create(['latitude' => 90.0, 'longitude' => 0.0]);
|
|
$south = Address::create(['latitude' => -90.0, 'longitude' => 0.0]);
|
|
|
|
$dist = $this->service()->distanceBetween($north, $south);
|
|
|
|
// Half circumference ≈ 20,015 km
|
|
$this->assertEqualsWithDelta(20015.0, $dist, 100.0);
|
|
}
|
|
|
|
public function test_haversine_zero_distance(): void
|
|
{
|
|
$dist = $this->service()->haversine(0.0, 0.0, 0.0, 0.0);
|
|
$this->assertEquals(0.0, $dist);
|
|
}
|
|
|
|
public function test_address_service_is_singleton(): void
|
|
{
|
|
$a = app(AddressService::class);
|
|
$b = app(AddressService::class);
|
|
$c = address();
|
|
|
|
$this->assertSame($a, $b);
|
|
$this->assertSame($a, $c);
|
|
}
|
|
|
|
public function test_bounding_box_miles(): void
|
|
{
|
|
$boxKm = $this->service()->boundingBox(48.2082, 16.3738, 10, 'km');
|
|
$boxMi = $this->service()->boundingBox(48.2082, 16.3738, 10, 'mi');
|
|
|
|
// 10 mi > 10 km, so mile box should be larger
|
|
$this->assertGreaterThan(
|
|
$boxKm['maxLat'] - $boxKm['minLat'],
|
|
$boxMi['maxLat'] - $boxMi['minLat']
|
|
);
|
|
}
|
|
|
|
public function test_in_city_without_country(): void
|
|
{
|
|
Address::create(['city' => 'Springfield', 'country_code' => 'US', 'state' => 'IL']);
|
|
Address::create(['city' => 'Springfield', 'country_code' => 'US', 'state' => 'MO']);
|
|
|
|
$result = $this->service()->inCity('Springfield')->get();
|
|
|
|
$this->assertCount(2, $result);
|
|
}
|
|
|
|
public function test_find_duplicates_multiple(): void
|
|
{
|
|
$origial = Address::create(['street' => 'A', 'city' => 'B', 'postal_code' => '1', 'country_code' => 'AT']);
|
|
Address::create(['street' => 'A', 'city' => 'B', 'postal_code' => '1', 'country_code' => 'AT']);
|
|
Address::create(['street' => 'A', 'city' => 'B', 'postal_code' => '1', 'country_code' => 'AT']);
|
|
|
|
$dups = $this->service()->findDuplicates($origial);
|
|
|
|
$this->assertCount(2, $dups);
|
|
}
|
|
|
|
public function test_format_multiline_street_only(): void
|
|
{
|
|
$addr = Address::create(['street' => 'Just a street']);
|
|
|
|
$this->assertEquals('Just a street', $this->service()->formatMultiline($addr));
|
|
}
|
|
|
|
public function test_format_multiline_postal_and_city(): void
|
|
{
|
|
$addr = Address::create(['postal_code' => '1010', 'city' => 'Vienna']);
|
|
|
|
$this->assertEquals('1010 Vienna', $this->service()->formatMultiline($addr));
|
|
}
|
|
|
|
// ─── trait coexistence ────────────────────────────────────────
|
|
|
|
public function test_model_can_be_link_owner_and_assignment_consumer(): void
|
|
{
|
|
// This tests the scenario where a model uses BOTH traits —
|
|
// we'll approximate by manually creating cross-references
|
|
|
|
$user1 = User::factory()->create();
|
|
$user2 = User::factory()->create();
|
|
|
|
// User1 owns an address
|
|
$link = $user1->addAddress([
|
|
'city' => 'Vienna',
|
|
], AddressLinkType::Home);
|
|
|
|
// User2 also owns an address
|
|
$link2 = $user2->addAddress([
|
|
'city' => 'Graz',
|
|
], AddressLinkType::Home);
|
|
|
|
// A Job references both
|
|
$job = Job::create(['title' => 'Cross']);
|
|
$a1 = $job->assignAddressLink($link, 'pickup');
|
|
$a2 = $job->assignAddressLink($link2, 'delivery');
|
|
|
|
// Through the assignment we can traverse back to the owner
|
|
$pickupOwner = AddressAssignment::find($a1->id)->addressLink->addressable;
|
|
$deliveryOwner = AddressAssignment::find($a2->id)->addressLink->addressable;
|
|
|
|
$this->assertInstanceOf(User::class, $pickupOwner);
|
|
$this->assertInstanceOf(User::class, $deliveryOwner);
|
|
$this->assertEquals($user1->id, $pickupOwner->id);
|
|
$this->assertEquals($user2->id, $deliveryOwner->id);
|
|
}
|
|
|
|
public function test_traverse_full_chain_assignment_to_owner(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress([
|
|
'street' => 'Full Chain 1',
|
|
'city' => 'Salzburg',
|
|
'country_code' => 'AT',
|
|
], AddressLinkType::Office, ['label' => 'Salzburg Office']);
|
|
|
|
$job = Job::create(['title' => 'Chain Job']);
|
|
$assignment = $job->assignAddressLink($link, 'pickup', ['label' => 'Pickup Point']);
|
|
|
|
// Start from assignment, traverse the full chain
|
|
$fresh = AddressAssignment::with(['addressLink.address', 'addressLink.addressable'])->find($assignment->id);
|
|
|
|
// Assignment → AddressLink
|
|
$this->assertEquals('Salzburg Office', $fresh->addressLink->label);
|
|
$this->assertEquals(AddressLinkType::Office, $fresh->addressLink->type);
|
|
|
|
// AddressLink → Address
|
|
$this->assertEquals('Full Chain 1', $fresh->addressLink->address->street);
|
|
$this->assertEquals('Salzburg', $fresh->addressLink->address->city);
|
|
|
|
// AddressLink → Owner
|
|
$this->assertInstanceOf(User::class, $fresh->addressLink->addressable);
|
|
$this->assertEquals($user->id, $fresh->addressLink->addressable->id);
|
|
|
|
// Assignment → Assignable (Job)
|
|
$this->assertEquals($job->id, $fresh->assignable->id);
|
|
}
|
|
|
|
public function test_addresses_with_all_enum_types_queryable(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
foreach (AddressLinkType::cases() as $type) {
|
|
$user->addAddress(['city' => "City_{$type->value}"], $type);
|
|
}
|
|
|
|
// Query each type individually
|
|
foreach (AddressLinkType::cases() as $type) {
|
|
$result = $user->addressesOfType($type);
|
|
$this->assertCount(1, $result, "Expected 1 address for type {$type->value}");
|
|
}
|
|
}
|
|
|
|
public function test_detach_specific_address_keeps_other_links(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$addr1 = Address::create(['city' => 'Keep']);
|
|
$addr2 = Address::create(['city' => 'Remove']);
|
|
|
|
$user->linkAddress($addr1, AddressLinkType::Home);
|
|
$user->linkAddress($addr2, AddressLinkType::Office);
|
|
$user->linkAddress($addr2, AddressLinkType::Billing);
|
|
|
|
$user->detachAddress($addr2);
|
|
|
|
$remaining = $user->fresh()->addressLinks;
|
|
$this->assertCount(1, $remaining);
|
|
$this->assertEquals($addr1->id, $remaining->first()->address_id);
|
|
}
|
|
|
|
public function test_active_links_with_mixed_temporal_data(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
// Always active (no bounds)
|
|
$user->addAddress(['city' => 'Always'], AddressLinkType::Home);
|
|
|
|
// Currently active (started yesterday, ends tomorrow)
|
|
$user->addAddress(['city' => 'Current'], AddressLinkType::Office, [
|
|
'active_from' => now()->subDay(),
|
|
'active_until' => now()->addDay(),
|
|
]);
|
|
|
|
// Expired (ended yesterday)
|
|
$user->addAddress(['city' => 'Expired'], AddressLinkType::Billing, [
|
|
'active_until' => now()->subDay(),
|
|
]);
|
|
|
|
// Not yet started (starts tomorrow)
|
|
$user->addAddress(['city' => 'Future'], AddressLinkType::Temporary, [
|
|
'active_from' => now()->addDay(),
|
|
]);
|
|
|
|
$activeLinks = $user->activeAddressLinks();
|
|
$cities = $activeLinks->pluck('address.city')->sort()->values()->toArray();
|
|
|
|
$this->assertCount(2, $activeLinks);
|
|
$this->assertEquals(['Always', 'Current'], $cities);
|
|
}
|
|
|
|
public function test_expired_scope_excludes_active_and_future(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$user->addAddress(['city' => 'Active'], AddressLinkType::Home);
|
|
$user->addAddress(['city' => 'Expired1'], AddressLinkType::Office, [
|
|
'active_until' => now()->subDay(),
|
|
]);
|
|
$user->addAddress(['city' => 'Expired2'], AddressLinkType::Billing, [
|
|
'active_until' => now()->subHour(),
|
|
]);
|
|
$user->addAddress(['city' => 'Future'], AddressLinkType::Temporary, [
|
|
'active_from' => now()->addDay(),
|
|
'active_until' => now()->addMonth(),
|
|
]);
|
|
|
|
$expired = $user->addressLinks()->expired()->get();
|
|
$this->assertCount(2, $expired);
|
|
|
|
$cities = $expired->pluck('address.city')->sort()->values()->toArray();
|
|
$this->assertContains('Expired1', $cities);
|
|
$this->assertContains('Expired2', $cities);
|
|
}
|
|
|
|
public function test_format_multiline_full_address(): void
|
|
{
|
|
$addr = Address::create([
|
|
'street' => '350 Fifth Avenue',
|
|
'street_extra' => 'Suite 3200',
|
|
'building' => 'Empire State Building',
|
|
'floor' => '32',
|
|
'room' => '3201',
|
|
'postal_code' => '10118',
|
|
'city' => 'New York',
|
|
'state' => 'NY',
|
|
'county' => 'New York County',
|
|
'country_code' => 'US',
|
|
]);
|
|
|
|
$lines = explode("\n", $this->service()->formatMultiline($addr));
|
|
|
|
$this->assertCount(5, $lines);
|
|
$this->assertEquals('350 Fifth Avenue, Suite 3200', $lines[0]);
|
|
$this->assertEquals('Empire State Building, Floor 32, Room 3201', $lines[1]);
|
|
$this->assertEquals('10118 New York, NY', $lines[2]);
|
|
$this->assertEquals('New York County', $lines[3]);
|
|
$this->assertEquals('US', $lines[4]);
|
|
}
|
|
|
|
public function test_with_coordinates_excludes_partial(): void
|
|
{
|
|
Address::create(['city' => 'Full', 'latitude' => 48.0, 'longitude' => 16.0]);
|
|
Address::create(['city' => 'LatOnly', 'latitude' => 48.0]);
|
|
Address::create(['city' => 'LngOnly', 'longitude' => 16.0]);
|
|
Address::create(['city' => 'None']);
|
|
|
|
$result = $this->service()->withCoordinates()->get();
|
|
|
|
$this->assertCount(1, $result);
|
|
$this->assertEquals('Full', $result->first()->city);
|
|
}
|
|
|
|
public function test_nearby_does_not_include_no_coords_addresses(): void
|
|
{
|
|
Address::create(['city' => 'NoCoords']);
|
|
Address::create(['city' => 'HasCoords', 'latitude' => 48.21, 'longitude' => 16.38]);
|
|
|
|
$results = $this->service()->nearby(48.2082, 16.3738, 50);
|
|
|
|
$cities = $results->pluck('city')->toArray();
|
|
$this->assertContains('HasCoords', $cities);
|
|
$this->assertNotContains('NoCoords', $cities);
|
|
}
|
|
|
|
public function test_address_assignment_meta_empty_object(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress(['city' => 'Z']);
|
|
$job = Job::create(['title' => 'NoMeta']);
|
|
|
|
$assignment = $job->assignAddressLink($link, 'pickup');
|
|
|
|
$this->assertNull($assignment->meta);
|
|
}
|
|
|
|
public function test_link_meta_complex_nested(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$link = $user->addAddress(['city' => 'Complex'], AddressLinkType::Office, [
|
|
'meta' => [
|
|
'access' => [
|
|
'code' => '4567',
|
|
'hours' => ['from' => '08:00', 'to' => '18:00'],
|
|
],
|
|
'contacts' => ['reception', 'security'],
|
|
],
|
|
]);
|
|
|
|
$meta = $link->getMeta();
|
|
$this->assertEquals('4567', $meta->access->code);
|
|
$this->assertEquals('08:00', $meta->access->hours->from);
|
|
$this->assertCount(2, (array) $meta->contacts);
|
|
}
|
|
}
|