withoutMiddleware(EnsurePlatformSession::class); config(['billing.api_url' => 'https://billing.test']); $this->owner = User::create([ 'public_id' => 'notify-owner-001', 'name' => 'Owner', 'email' => 'owner@example.com', ]); $this->organization = Organization::create([ 'owner_ref' => $this->owner->public_id, 'name' => 'Notify Org', 'slug' => 'notify-org', 'settings' => [ 'onboarded' => true, 'badge_expiry_hours' => 8, 'notification_channels' => ['email'], 'notification_events' => config('frontdesk.default_notification_events'), ], ]); Member::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'user_ref' => $this->owner->public_id, 'role' => 'org_admin', ]); Branch::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'name' => 'HQ', 'is_active' => true, ]); $this->birdKey = 'lsk_live_'.str_repeat('a', 32); MessagingCredential::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'bird_api_key_encrypted' => Crypt::encryptString($this->birdKey), 'bird_api_key_prefix' => substr($this->birdKey, 0, 16), 'bird_from_email' => 'notify@example.test', 'bird_from_name' => 'Notify Org', 'bird_status' => MessagingCredential::STATUS_VALID, 'bird_validated_at' => now(), ]); } public function test_host_email_notification_without_ladill_user_link(): void { Http::fake([ 'billing.test/can-afford*' => Http::response(['affordable' => true]), 'billing.test/debit' => Http::response(['ok' => true]), '*/api/smtp/send' => Http::response(['success' => true]), ]); $host = Host::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'name' => 'Email Host', 'email' => 'host-only@example.com', 'is_available' => true, ]); $this->actingAs($this->owner) ->post(route('frontdesk.visits.store'), [ 'full_name' => 'Walk-in', 'host_id' => $host->id, 'visitor_type' => 'visitor', 'policies_accepted' => '1', ]) ->assertRedirect(); Http::assertSent(fn ($request) => str_contains($request->url(), '/api/smtp/send')); Http::assertNotSent(fn ($request) => str_contains($request->url(), '/debit')); $usage = NotificationUsage::where('organization_id', $this->organization->id)->first(); $this->assertNotNull($usage); $this->assertSame(1, $usage->email_count); $this->assertSame(0, $usage->email_charged_minor); } public function test_customer_email_skips_wallet_debit_after_free_allowance(): void { Http::fake([ 'billing.test/can-afford*' => Http::response(['affordable' => true]), 'billing.test/debit' => Http::response(['ok' => true]), '*/api/smtp/send' => Http::response(['success' => true]), ]); NotificationUsage::create([ 'organization_id' => $this->organization->id, 'period' => now()->format('Y-m'), 'email_count' => 100, 'sms_count' => 0, 'email_charged_minor' => 0, 'sms_charged_minor' => 0, ]); $host = Host::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'name' => 'Paid Host', 'email' => 'paid@example.com', 'is_available' => true, ]); $this->actingAs($this->owner) ->post(route('frontdesk.visits.store'), [ 'full_name' => 'Paid Walk-in', 'host_id' => $host->id, 'visitor_type' => 'visitor', 'policies_accepted' => '1', ]) ->assertRedirect(); Http::assertNotSent(fn ($request) => str_contains($request->url(), '/debit')); $usage = NotificationUsage::where('organization_id', $this->organization->id)->first(); $this->assertSame(101, $usage->email_count); $this->assertSame(0, $usage->email_charged_minor); } public function test_missing_bird_credentials_skips_host_email(): void { MessagingCredential::query()->where('organization_id', $this->organization->id)->delete(); Http::fake([ 'billing.test/can-afford*' => Http::response(['affordable' => false]), ]); NotificationUsage::create([ 'organization_id' => $this->organization->id, 'period' => now()->format('Y-m'), 'email_count' => 100, ]); $host = Host::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'name' => 'Blocked Host', 'email' => 'blocked@example.com', 'is_available' => true, ]); $this->actingAs($this->owner) ->post(route('frontdesk.visits.store'), [ 'full_name' => 'No Alert', 'host_id' => $host->id, 'visitor_type' => 'visitor', 'policies_accepted' => '1', ]) ->assertRedirect(); Http::assertNotSent(fn ($request) => str_contains($request->url(), '/api/smtp/send')); } public function test_kiosk_returns_host_notified_flag(): void { Http::fake([ 'billing.test/can-afford*' => Http::response(['affordable' => true]), 'billing.test/debit' => Http::response(['ok' => true]), '*/api/smtp/send' => Http::response(['success' => true]), ]); $host = Host::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'name' => 'Kiosk Host', 'email' => 'kiosk-host@example.com', 'is_available' => true, ]); $this->actingAs($this->owner) ->postJson(route('frontdesk.kiosk.check-in'), [ 'full_name' => 'Kiosk Guest', 'host_id' => $host->id, 'visitor_type' => 'visitor', 'policies_accepted' => '1', ]) ->assertOk() ->assertJsonPath('visit.host_notified', true); } public function test_free_plan_blocks_second_branch(): void { $this->actingAs($this->owner) ->post(route('frontdesk.branches.store'), [ 'name' => 'Second Branch', ]) ->assertRedirect() ->assertSessionHas('error'); } public function test_settings_shows_plan_and_usage(): void { NotificationUsage::create([ 'organization_id' => $this->organization->id, 'period' => now()->format('Y-m'), 'email_count' => 12, 'sms_count' => 3, ]); $this->actingAs($this->owner) ->get(route('frontdesk.settings')) ->assertOk() ->assertSee('Free') ->assertSee('12') ->assertSee('Upgrade to Pro'); } public function test_pro_plan_emails_record_usage_without_wallet_debit(): void { Http::fake([ 'billing.test/can-afford*' => Http::response(['affordable' => true]), 'billing.test/debit' => Http::response(['ok' => true]), '*/api/smtp/send' => Http::response(['success' => true]), ]); $this->organization->update([ 'settings' => array_merge($this->organization->settings ?? [], [ 'plan' => 'pro', 'plan_expires_at' => now()->addMonth()->toIso8601String(), ]), ]); NotificationUsage::create([ 'organization_id' => $this->organization->id, 'period' => now()->format('Y-m'), 'email_count' => 10_000, ]); $host = Host::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'name' => 'Pro Host', 'email' => 'pro@example.com', 'is_available' => true, ]); $this->actingAs($this->owner) ->post(route('frontdesk.visits.store'), [ 'full_name' => 'Pro Guest', 'host_id' => $host->id, 'visitor_type' => 'visitor', 'policies_accepted' => '1', ]) ->assertRedirect(); Http::assertSent(fn ($request) => str_contains($request->url(), '/api/smtp/send')); Http::assertNotSent(fn ($request) => str_contains($request->url(), '/debit')); $usage = NotificationUsage::where('organization_id', $this->organization->id)->first(); $this->assertSame(10_001, $usage->email_count); $this->assertSame(0, $usage->email_charged_minor); } public function test_linked_user_still_gets_in_app_notification(): void { Notification::fake(); Http::fake([ 'billing.test/can-afford*' => Http::response(['affordable' => true]), 'billing.test/debit' => Http::response(['ok' => true]), '*/api/smtp/send' => Http::response(['success' => true]), ]); $linkedUser = User::create([ 'public_id' => 'linked-host-user', 'name' => 'Linked Host User', 'email' => 'linked@example.com', ]); $host = Host::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'name' => 'Linked Host', 'email' => 'linked-host@example.com', 'user_ref' => $linkedUser->public_id, 'is_available' => true, ]); $this->actingAs($this->owner) ->post(route('frontdesk.visits.store'), [ 'full_name' => 'Guest', 'host_id' => $host->id, 'visitor_type' => 'visitor', 'policies_accepted' => '1', ]); Notification::assertSentTo($linkedUser, FrontdeskAlertNotification::class); } }