markTestSkipped('SQLite PDO driver is required for client onboarding database regression tests.'); } foreach ([ 'communication_delivery_attempts', 'communication_messages', 'audit_events', 'user_roles', 'roles', 'users', 'client_account_invitations', 'contracts', 'payments', 'invoices', 'applications', ] as $table) { Schema::dropIfExists($table); } $this->createSchema(); $this->seedEligibleApplication(); } public function test_contract_acceptance_invitation_uses_client_setup_url_and_sends_email(): void { config(['app.client_portal_setup_url' => 'https://example.test']); $result = app(ClientOnboardingService::class)->inviteAfterContractAcceptance('app-1'); $this->assertSame('INVITED', $result['status']); $this->assertSame('applicant@example.com', $result['email']); $this->assertTrue($result['email_sent']); $this->assertStringStartsWith('https://example.test/client/setup/', $result['setup_url']); $this->assertSame(1, DB::table('client_account_invitations')->where('status', 'INVITED')->count()); $message = DB::table('communication_messages')->where('event_type', 'CLIENT_PORTAL_SETUP_INVITED')->first(); $this->assertNotNull($message); $this->assertStringContainsString($result['setup_url'], $message->body); $this->assertStringNotContainsString('internal My Diaspora Nexus email address', $message->body); } public function test_setup_token_creates_client_account_and_cannot_be_reused(): void { config(['app.client_portal_setup_url' => 'https://example.test']); $invitation = app(ClientOnboardingService::class)->inviteAfterContractAcceptance('app-1'); $token = basename(parse_url($invitation['setup_url'], PHP_URL_PATH)); $setup = app(ClientOnboardingService::class)->getSetupInvitation($token); $this->assertSame('applicant@example.com', $setup['invitation']['email']); $result = app(ClientOnboardingService::class)->completeSetup($token, 'StrongPass123!'); $this->assertSame('applicant@example.com', $result['user']['email']); $this->assertSame('CLIENT', $result['user']['type']); $storedInvitation = DB::table('client_account_invitations')->where('id', $invitation['invitation_id'])->first(); $application = DB::table('applications')->where('id', 'app-1')->first(); $this->assertSame('ACCEPTED', $storedInvitation->status); $this->assertNotNull($storedInvitation->user_id); $this->assertSame($storedInvitation->user_id, $application->user_id); $this->expectException(ApiException::class); app(ClientOnboardingService::class)->getSetupInvitation($token); } private function createSchema(): void { Schema::create('applications', function (Blueprint $table): void { $table->string('id')->primary(); $table->string('application_reference')->nullable(); $table->string('status'); $table->string('first_name')->nullable(); $table->string('last_name')->nullable(); $table->string('email')->nullable(); $table->string('user_id')->nullable(); $table->timestamp('approved_at')->nullable(); $table->timestamps(); $table->softDeletes(); }); Schema::create('invoices', function (Blueprint $table): void { $table->string('id')->primary(); $table->string('application_id')->nullable(); $table->string('fee_type_code'); $table->string('status'); $table->timestamps(); $table->softDeletes(); }); Schema::create('payments', function (Blueprint $table): void { $table->string('id')->primary(); $table->string('invoice_id'); $table->string('status'); $table->timestamps(); $table->softDeletes(); }); Schema::create('contracts', function (Blueprint $table): void { $table->string('id')->primary(); $table->string('application_id')->nullable(); $table->string('status'); $table->timestamps(); $table->softDeletes(); }); Schema::create('client_account_invitations', function (Blueprint $table): void { $table->string('id')->primary(); $table->string('application_id'); $table->string('user_id')->nullable(); $table->string('email'); $table->string('token_hash')->unique(); $table->string('status'); $table->timestamp('expires_at'); $table->timestamp('accepted_at')->nullable(); $table->string('invited_by_user_id')->nullable(); $table->timestamps(); $table->softDeletes(); }); Schema::create('users', function (Blueprint $table): void { $table->string('id')->primary(); $table->string('email'); $table->string('email_normalized')->nullable(); $table->string('password_hash')->nullable(); $table->string('user_status')->nullable(); $table->string('user_type')->nullable(); $table->string('portal_status')->nullable(); $table->timestamp('email_verified_at')->nullable(); $table->timestamps(); $table->softDeletes(); }); Schema::create('roles', function (Blueprint $table): void { $table->string('id')->primary(); $table->string('code'); $table->timestamps(); $table->softDeletes(); }); Schema::create('user_roles', function (Blueprint $table): void { $table->string('user_id'); $table->string('role_id'); $table->timestamp('effective_from')->nullable(); $table->timestamp('effective_to')->nullable(); $table->string('assigned_by_user_id')->nullable(); $table->string('assignment_reason')->nullable(); }); Schema::create('audit_events', function (Blueprint $table): void { $table->string('id')->primary(); $table->string('event_type'); $table->string('event_category'); $table->string('actor_user_id')->nullable(); $table->string('actor_role_code')->nullable(); $table->string('subject_type'); $table->string('subject_id')->nullable(); $table->string('affected_owner_user_id')->nullable(); $table->string('result'); $table->timestamp('occurred_at'); $table->string('correlation_id')->nullable(); $table->string('ip_address')->nullable(); $table->text('user_agent')->nullable(); $table->text('metadata')->nullable(); }); Schema::create('communication_messages', function (Blueprint $table): void { $table->string('id')->primary(); $table->string('template_id')->nullable(); $table->string('channel'); $table->string('recipient_user_id')->nullable(); $table->string('recipient_email')->nullable(); $table->string('recipient_phone')->nullable(); $table->string('event_type'); $table->string('subject')->nullable(); $table->text('body'); $table->string('status'); $table->timestamp('queued_at')->nullable(); $table->timestamp('sent_at')->nullable(); $table->string('correlation_id')->nullable(); $table->text('context')->nullable(); $table->timestamps(); $table->softDeletes(); }); Schema::create('communication_delivery_attempts', function (Blueprint $table): void { $table->string('id')->primary(); $table->string('message_id'); $table->integer('attempt_number'); $table->string('provider_code'); $table->string('provider_reference')->nullable(); $table->string('status'); $table->timestamp('attempted_at'); $table->string('error_code')->nullable(); $table->text('error_message')->nullable(); $table->timestamps(); }); } private function seedEligibleApplication(): void { DB::table('applications')->insert([ 'id' => 'app-1', 'application_reference' => 'APP-1', 'status' => 'APPROVED', 'first_name' => 'Test', 'last_name' => 'Applicant', 'email' => 'applicant@example.com', 'approved_at' => now(), 'created_at' => now(), 'updated_at' => now(), ]); DB::table('invoices')->insert([ 'id' => 'invoice-1', 'application_id' => 'app-1', 'fee_type_code' => 'ANNUAL_MEMBERSHIP_FEE', 'status' => 'PAID', 'created_at' => now(), 'updated_at' => now(), ]); DB::table('payments')->insert([ 'id' => 'payment-1', 'invoice_id' => 'invoice-1', 'status' => 'PAID', 'created_at' => now(), 'updated_at' => now(), ]); DB::table('contracts')->insert([ 'id' => 'contract-1', 'application_id' => 'app-1', 'status' => 'ACCEPTED', 'created_at' => now(), 'updated_at' => now(), ]); DB::table('roles')->insert([ 'id' => 'role-client', 'code' => 'CLIENT', 'created_at' => now(), 'updated_at' => now(), ]); } }