id(); $table->string('codigo')->unique(); $table->string('address')->nullable(); $table->timestamps(); }); Schema::create('tenants_menues', function (Blueprint $table): void { $table->id(); $table->string('tenant_code'); $table->string('menu_code'); $table->json('static_content')->nullable(); $table->boolean('is_enabled')->default(true); }); DB::table('tenants')->insert([ ['codigo' => 'alpha', 'address' => 'Dirección anterior'], ['codigo' => 'beta', 'address' => null], ['codigo' => 'empty', 'address' => null], ]); $this->schemaMigration()->up(); } public function test_it_copies_all_contact_locations_without_changing_legacy_data_and_can_run_again(): void { $sharedAddress = [ 'label' => 'Sede principal', 'address' => 'San Lorenzo 1543, Rosario', 'coordinates' => [-32.9431184, -60.6437991], ]; $this->contact('alpha', [ 'rosario' => ['label' => 'Rosario', 'addresses' => [$sharedAddress]], 'santa_fe' => ['label' => 'Santa Fe', 'addresses' => [ ['label' => 'Sucursal', 'address' => 'San Martín 100', 'coordinates' => [0, 0]], ['label' => 'Sin mapa', 'address' => 'San Martín 200'], ]], ], enabled: false); $this->contact('beta', ['rosario' => ['addresses' => [$sharedAddress]]]); $this->contact('empty', []); DB::table('tenants_menues')->insert([ 'tenant_code' => 'empty', 'menu_code' => 'help.faq', 'static_content' => json_encode(['locations' => [['addresses' => [$sharedAddress]]]]), ]); $oldMenus = DB::table('tenants_menues')->orderBy('id')->get()->toArray(); $migration = $this->dataMigration(); $migration->up(); $migration->up(); $this->assertDatabaseCount('addresses', 4); $this->assertDatabaseCount('tenant_addresses', 4); $this->assertDatabaseHas('addresses', [ 'label' => 'Sede principal', 'address_text' => 'San Lorenzo 1543, Rosario', 'latitude' => -32.9431184, 'longitude' => -60.6437991, ]); $this->assertDatabaseHas('addresses', ['label' => 'Sucursal', 'latitude' => 0, 'longitude' => 0]); $this->assertDatabaseHas('addresses', ['label' => 'Sin mapa', 'latitude' => null, 'longitude' => null]); $this->assertEquals($oldMenus, DB::table('tenants_menues')->orderBy('id')->get()->toArray()); $this->assertDatabaseHas('tenants', ['codigo' => 'alpha', 'address' => 'Dirección anterior']); $alpha = Tenant::query()->where('codigo', 'alpha')->firstOrFail(); $beta = Tenant::query()->where('codigo', 'beta')->firstOrFail(); $this->assertCount(3, $alpha->addresses); $this->assertTrue($alpha->addresses->first()->pivot->is_main); $this->assertInstanceOf(TenantAddress::class, $alpha->addresses->first()->pivot); $this->assertSame('Sede principal', $alpha->mainAddress->label); $this->assertSame('-32.9431184', $alpha->mainAddress->latitude); $this->assertNotSame($alpha->mainAddress->id, $beta->mainAddress->id); $this->assertSame('alpha', $alpha->mainAddress->tenants->sole()->codigo); $this->assertNull(Tenant::query()->where('codigo', 'empty')->firstOrFail()->mainAddress); $this->assertSame(2, DB::table('tenant_addresses')->where('is_main', true)->count()); $migration->down(); $this->assertDatabaseCount('addresses', 4); $this->assertEquals($oldMenus, DB::table('tenants_menues')->orderBy('id')->get()->toArray()); } public function test_a_tenant_can_have_many_secondary_addresses_but_only_one_main(): void { $tenant = Tenant::query()->where('codigo', 'alpha')->firstOrFail(); $addresses = $this->addresses(3); $tenant->addresses()->attach($addresses[0], ['is_main' => true]); $tenant->addresses()->attach([$addresses[1], $addresses[2]], ['is_main' => false]); $this->assertCount(3, $tenant->addresses); $this->expectException(QueryException::class); $tenant->addresses()->updateExistingPivot($addresses[1], ['is_main' => true]); } public function test_it_prevents_duplicate_links(): void { $tenant = Tenant::query()->where('codigo', 'alpha')->firstOrFail(); $address = $this->addresses(1)[0]; $tenant->addresses()->attach($address); $this->expectException(QueryException::class); $tenant->addresses()->attach($address); } public function test_main_address_is_specific_to_each_tenant_and_can_be_switched(): void { $alpha = Tenant::query()->where('codigo', 'alpha')->firstOrFail(); $beta = Tenant::query()->where('codigo', 'beta')->firstOrFail(); $addresses = $this->addresses(2); $alpha->addresses()->attach($addresses[0], ['is_main' => true]); $alpha->addresses()->attach($addresses[1]); $beta->addresses()->attach($addresses[0], ['is_main' => true]); $this->assertSame($addresses[0], $alpha->mainAddress->id); app(TenantAddressService::class)->setMainAddress($alpha, Address::findOrFail($addresses[1])); $this->assertSame($addresses[1], $alpha->mainAddress->id); $this->assertSame($addresses[0], $beta->mainAddress->id); $this->assertSame(2, DB::table('tenant_addresses')->where('is_main', true)->count()); $loaded = Tenant::query()->with('mainAddress')->whereIn('codigo', ['alpha', 'beta'])->get()->keyBy('codigo'); $this->assertSame($addresses[1], $loaded['alpha']->mainAddress->id); $this->assertSame($addresses[0], $loaded['beta']->mainAddress->id); } public function test_switching_to_an_unlinked_address_preserves_the_previous_main(): void { $tenant = Tenant::query()->where('codigo', 'alpha')->firstOrFail(); $addresses = $this->addresses(2); $tenant->addresses()->attach($addresses[0], ['is_main' => true]); try { app(TenantAddressService::class)->setMainAddress($tenant, Address::findOrFail($addresses[1])); $this->fail('An unlinked address must be rejected.'); } catch (ModelNotFoundException) { $this->assertSame($addresses[0], $tenant->fresh()->mainAddress->id); } } public function test_invalid_coordinates_roll_back_all_copies_and_preserve_source_data(): void { $this->contact('alpha', [['addresses' => [ ['label' => 'Válida', 'address' => 'Primera 100', 'coordinates' => [-32, -60]], ['label' => 'Inválida', 'address' => 'Segunda 200', 'coordinates' => [-91, -60]], ]]]); $oldContent = DB::table('tenants_menues')->value('static_content'); try { $this->dataMigration()->up(); $this->fail('Invalid latitude must not be silently imported.'); } catch (\RuntimeException $exception) { $this->assertStringContainsString('alpha', $exception->getMessage()); $this->assertDatabaseCount('addresses', 0); $this->assertDatabaseCount('tenant_addresses', 0); $this->assertSame($oldContent, DB::table('tenants_menues')->value('static_content')); } } public function test_deleting_a_tenant_removes_links_but_keeps_shared_addresses(): void { $address = $this->addresses(1)[0]; foreach (['alpha', 'beta'] as $code) { Tenant::query()->where('codigo', $code)->firstOrFail()->addresses()->attach($address, ['is_main' => true]); } Tenant::query()->where('codigo', 'alpha')->delete(); $this->assertDatabaseCount('tenant_addresses', 1); $this->assertDatabaseCount('addresses', 1); Address::findOrFail($address)->delete(); $this->assertDatabaseCount('tenant_addresses', 0); } public function test_schema_can_be_rolled_back_without_removing_legacy_tables(): void { $this->schemaMigration()->down(); $this->assertFalse(Schema::hasTable('addresses')); $this->assertFalse(Schema::hasTable('tenant_addresses')); $this->assertTrue(Schema::hasTable('tenants_menues')); $this->assertTrue(Schema::hasColumn('tenants', 'address')); } private function contact(string $code, array $locations, bool $enabled = true): void { DB::table('tenants_menues')->insert([ 'tenant_code' => $code, 'menu_code' => 'help.contact', 'static_content' => json_encode(['phone' => '+54 123', 'locations' => $locations], JSON_THROW_ON_ERROR), 'is_enabled' => $enabled, ]); } private function addresses(int $count): array { $ids = []; for ($i = 0; $i < $count; $i++) { $ids[] = Address::create(['label' => "Sucursal {$i}", 'address_text' => "Calle {$i}"])->id; } return $ids; } private function schemaMigration(): Migration { return require database_path('migrations/2026_09_30_000000_create_addresses_and_tenant_addresses_tables.php'); } private function dataMigration(): Migration { return require database_path('migrations/2026_09_30_000100_copy_contact_menu_addresses.php'); } }