laravel-addresses/tests/Unit/HasAddressesTest.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);
}
}