feat(contact): implement contact management with endpoints for retrieving and updating contact details

This commit is contained in:
2026-10-01 11:37:45 -03:00
parent 1986961942
commit 35fd1277cb
9 changed files with 440 additions and 1 deletions

View File

@@ -0,0 +1,232 @@
<?php
namespace Tests\Feature\Tenant;
use App\Domains\Core\Address\Models\Address;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Authorization\Enums\RoleCode;
use App\Domains\Core\Tenant\Models\SocialMedia;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Core\Tenant\Resources\TenantResource;
use App\Domains\Core\Tenant\Services\ContactService;
use App\Domains\Ticketing\Event\Models\Event;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AdminAppContactControllerTest extends TestCase
{
private Tenant $tenant;
protected function setUp(): void
{
parent::setUp();
// Use the real contact migrations in the guarded in-memory database. The
// unrelated historical migration chain cannot currently run on SQLite.
Schema::create('tenants', function (Blueprint $table): void {
$table->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);
}
}