id(); $table->string('codigo')->unique(); $table->string('nombre'); $table->string('dominio'); foreach (array_keys((new Tenant)->getAttributes()) as $column) { $table->string($column)->nullable(); } $table->unsignedBigInteger('active_event_id')->nullable(); $table->timestamps(); }); foreach ([ '2026_07_23_000200_create_social_media_tables.php', '2026_07_23_000300_add_orden_to_tenant_social_media_table.php', '2026_09_30_000000_create_addresses_and_tenant_addresses_tables.php', '2026_09_30_000400_create_phone_numbers_and_tenant_phone_numbers_tables.php', '2026_09_30_000700_split_phone_number_components.php', ] as $migration) { (require database_path('migrations/'.$migration))->up(); } Schema::create('events', function (Blueprint $table): void { $table->id(); $table->string('tenant_code'); $table->string('slug'); $table->string('title'); $table->unsignedBigInteger('address_id')->nullable(); $table->timestamps(); }); Schema::create('event_social_media', function (Blueprint $table): void { $table->id(); $table->unsignedBigInteger('event_id'); $table->string('social_media_code'); $table->string('url'); $table->unsignedInteger('orden'); $table->timestamps(); }); $this->tenant = Tenant::create(['codigo' => 'contact-test', 'nombre' => 'Contact', 'dominio' => 'contact.test']); SocialMedia::firstOrCreate(['code' => 'instagram'], ['name' => 'Instagram', 'icon' => 'instagram']); } private function login(): void { Sanctum::actingAs(new User([ 'rol_codigo' => RoleCode::AdminApp->value, 'tenant_codigo' => $this->tenant->codigo, ])); } private function payload(): array { return [ 'social_media' => [['code' => 'instagram', 'url' => 'https://instagram.com/contact']], 'addresses' => [['address_text' => 'San Martín 123']], 'phone_numbers' => [[ 'country_code' => '+54', 'mobile_prefix' => null, 'area_code' => '341', 'local_number' => '5551234', ]], ]; } public function test_authentication_and_admin_role_are_required(): void { $this->getJson('/api/v1/adminapp/tenant/contact')->assertUnauthorized(); $this->putJson('/api/v1/adminapp/tenant/contact', $this->payload())->assertUnauthorized(); Sanctum::actingAs(new User(['tenant_codigo' => $this->tenant->codigo])); $this->putJson('/api/v1/adminapp/tenant/contact', $this->payload())->assertForbidden(); } public function test_contact_is_persisted_and_exposed_in_storefront_resource(): void { $this->login(); $this->putJson('/api/v1/adminapp/tenant/contact', $this->payload())->assertOk() ->assertJsonPath('data.phone_numbers.0.mobile_prefix', null) ->assertJsonPath('data.addresses.0.is_main', true); $this->getJson('/api/v1/adminapp/tenant/contact')->assertOk() ->assertJsonPath('data.addresses.0.address_text', 'San Martín 123'); $storefront = $this->storefrontData(); $this->assertSame('https://instagram.com/contact', $storefront['tenant_social_media'][0]['url']); $this->assertSame('San Martín 123', $storefront['main_address']->resolve()['address_text']); $this->assertSame('tel:+543415551234', $storefront['main_phone_number']->resolve()['tel_url']); } public function test_existing_contacts_can_be_updated_replaced_and_cleared(): void { $this->login(); $first = $this->putJson('/api/v1/adminapp/tenant/contact', $this->payload())->assertOk()->json('data'); $payload = $this->payload(); $payload['addresses'] = [ ['id' => $first['addresses'][0]['id'], 'address_text' => 'Updated', 'is_main' => false], ['address_text' => 'New principal', 'is_main' => true], ]; $payload['phone_numbers'][0]['id'] = $first['phone_numbers'][0]['id']; $payload['phone_numbers'][0]['local_number'] = '7771234'; $payload['phone_numbers'][0]['is_main'] = false; $payload['phone_numbers'][] = [...$this->payload()['phone_numbers'][0], 'local_number' => '8881234', 'is_main' => true]; $updated = $this->putJson('/api/v1/adminapp/tenant/contact', $payload)->assertOk()->assertJsonCount(2, 'data.addresses')->json('data'); // Position wins, even if a client sends conflicting is_main flags. $this->assertSame('Updated', $this->tenant->mainAddress()->firstOrFail()->address_text); $this->assertSame('7771234', $this->tenant->mainPhoneNumber()->firstOrFail()->local_number); $payload['addresses'] = array_reverse($updated['addresses']); $payload['addresses'] = array_map(fn ($item) => array_intersect_key($item, array_flip(['id', 'address_text'])), $payload['addresses']); $payload['phone_numbers'] = array_reverse($payload['phone_numbers']); $this->putJson('/api/v1/adminapp/tenant/contact', $payload)->assertOk() ->assertJsonPath('data.addresses.0.address_text', 'New principal') ->assertJsonPath('data.addresses.0.is_main', true) ->assertJsonPath('data.addresses.1.is_main', false); $this->getJson('/api/v1/adminapp/tenant/contact')->assertOk() ->assertJsonPath('data.addresses.0.address_text', 'New principal'); $this->assertSame('New principal', $this->tenant->mainAddress()->firstOrFail()->address_text); $this->assertSame('8881234', $this->tenant->mainPhoneNumber()->firstOrFail()->local_number); $this->putJson('/api/v1/adminapp/tenant/contact', [ 'social_media' => [], 'addresses' => [], 'phone_numbers' => [], ])->assertOk()->assertJsonCount(0, 'data.phone_numbers'); $this->assertNull($this->tenant->mainAddress()->first()); $this->assertNull($this->tenant->mainPhoneNumber()->first()); } public function test_foreign_ids_and_invalid_payloads_do_not_change_contact(): void { $this->login(); $this->putJson('/api/v1/adminapp/tenant/contact', $this->payload())->assertOk(); $foreign = Address::create(['label' => 'Foreign', 'address_text' => 'Private']); $payload = $this->payload(); $payload['social_media'][0]['url'] = 'https://instagram.com/changed'; $payload['addresses'][0]['id'] = $foreign->id; $this->putJson('/api/v1/adminapp/tenant/contact', $payload)->assertUnprocessable()->assertJsonValidationErrors('addresses.0.id'); $payload = $this->payload(); $payload['phone_numbers'][0]['id'] = 999999; $this->putJson('/api/v1/adminapp/tenant/contact', $payload)->assertUnprocessable()->assertJsonValidationErrors('phone_numbers.0.id'); $payload = $this->payload(); $payload['social_media'][0]['url'] = 'javascript:alert(1)'; $payload['addresses'][] = $payload['addresses'][0]; $this->putJson('/api/v1/adminapp/tenant/contact', $payload)->assertUnprocessable() ->assertJsonValidationErrors(['social_media.0.url']); $this->getJson('/api/v1/adminapp/tenant/contact')->assertJsonPath('data.social_media.0.url', 'https://instagram.com/contact'); } public function test_service_rolls_back_all_changes_if_contact_membership_changes(): void { $this->login(); $this->putJson('/api/v1/adminapp/tenant/contact', $this->payload())->assertOk(); $payload = $this->payload(); $payload['social_media'] = []; $payload['phone_numbers'][0]['id'] = 999999; try { app(ContactService::class)->update($this->tenant, $payload); $this->fail('An invalid contact ID must reject the whole transaction.'); } catch (ValidationException) { $this->assertSame(1, $this->tenant->socialMedia()->count()); $this->assertSame(1, $this->tenant->addresses()->count()); } } public function test_shared_records_are_not_modified_for_other_tenants(): void { $this->login(); $first = $this->putJson('/api/v1/adminapp/tenant/contact', $this->payload())->assertOk()->json('data'); $other = Tenant::create(['codigo' => 'other-contact', 'nombre' => 'Other', 'dominio' => 'other-contact.test']); $other->addresses()->attach($first['addresses'][0]['id'], ['is_main' => true]); $other->phoneNumbers()->attach($first['phone_numbers'][0]['id'], ['is_main' => true]); $payload = $this->payload(); $payload['addresses'][0] = [...$payload['addresses'][0], 'id' => $first['addresses'][0]['id'], 'address_text' => 'Changed']; $payload['phone_numbers'][0] = [...$payload['phone_numbers'][0], 'id' => $first['phone_numbers'][0]['id'], 'local_number' => '9991234']; $this->putJson('/api/v1/adminapp/tenant/contact', $payload)->assertOk(); $this->assertSame('San Martín 123', $other->addresses()->firstOrFail()->address_text); $this->assertSame('5551234', $other->phoneNumbers()->firstOrFail()->local_number); } public function test_contact_networks_are_independent_of_active_event_networks(): void { $this->login(); $event = Event::create(['tenant_code' => $this->tenant->codigo, 'slug' => 'event', 'title' => 'Event']); $event->socialMedia()->attach('instagram', ['url' => 'https://instagram.com/event', 'orden' => 0]); $this->tenant->update(['active_event_id' => $event->id]); $this->putJson('/api/v1/adminapp/tenant/contact', $this->payload())->assertOk(); $this->getJson('/api/v1/adminapp/tenant/contact')->assertOk() ->assertJsonPath('data.social_media.0.url', 'https://instagram.com/contact'); $this->assertSame('https://instagram.com/event', $event->socialMedia()->firstOrFail()->pivot->url); $tenant = $this->tenant->fresh()->load(['socialMedia', 'activeEvent.socialMedia']); $this->assertSame('https://instagram.com/contact', $tenant->socialMedia->first()->pivot->url); } private function storefrontData(): array { $tenant = $this->tenant->fresh()->load(['socialMedia', 'addresses', 'mainAddress', 'phoneNumbers', 'mainPhoneNumber']); foreach (['favicon', 'headerLogo', 'footerLogo', 'headerBackgroundImage', 'footerBackgroundImage', 'storefrontWebsiteType'] as $relation) { $tenant->setRelation($relation, null); } return TenantResource::make($tenant)->resolve(request()); } public function test_address_coordinates_are_preserved_until_the_address_changes(): void { $this->login(); $address = Address::create(['label' => 'Sucursal', 'address_text' => 'Original', 'latitude' => -32.9, 'longitude' => -60.6]); $this->tenant->addresses()->attach($address->id, ['is_main' => true]); $payload = $this->payload(); $payload['addresses'] = [['id' => $address->id, 'address_text' => 'Original', 'is_main' => true]]; $this->putJson('/api/v1/adminapp/tenant/contact', $payload)->assertOk()->assertJsonPath('data.addresses.0.latitude', -32.9); $payload['addresses'][0]['address_text'] = 'New address'; $this->putJson('/api/v1/adminapp/tenant/contact', $payload)->assertOk()->assertJsonPath('data.addresses.0.latitude', null); $this->assertSame('Sucursal', $address->fresh()->label); } }