id(); $table->string('code')->unique(); $table->string('module')->nullable(); $table->string('name'); $table->string('tagline')->nullable(); $table->text('description')->nullable(); $table->string('icon')->nullable(); $table->string('color')->nullable(); $table->string('marketing_domain')->nullable(); $table->string('website_class')->nullable(); $table->string('marketing_url')->nullable(); $table->string('docs_url')->nullable(); $table->boolean('is_enabled')->default(true); $table->boolean('is_public')->default(true); $table->boolean('is_featured')->default(false); $table->string('entitlement_code')->nullable(); $table->integer('sort_order')->default(50); $table->json('metadata')->nullable(); $table->timestamps(); }); } }); afterEach(function () { Service::query()->delete(); }); // ============================================================================= // Model Configuration // ============================================================================= describe('Service model configuration', function () { describe('table name', function () { it('uses platform_services table', function () { $service = new Service(); expect($service->getTable())->toBe('platform_services'); }); }); describe('fillable attributes', function () { it('has the expected fillable fields', function () { $service = new Service(); $fillable = $service->getFillable(); expect($fillable)->toContain('code') ->and($fillable)->toContain('module') ->and($fillable)->toContain('name') ->and($fillable)->toContain('tagline') ->and($fillable)->toContain('description') ->and($fillable)->toContain('icon') ->and($fillable)->toContain('color') ->and($fillable)->toContain('marketing_domain') ->and($fillable)->toContain('website_class') ->and($fillable)->toContain('marketing_url') ->and($fillable)->toContain('docs_url') ->and($fillable)->toContain('is_enabled') ->and($fillable)->toContain('is_public') ->and($fillable)->toContain('is_featured') ->and($fillable)->toContain('entitlement_code') ->and($fillable)->toContain('sort_order') ->and($fillable)->toContain('metadata'); }); }); describe('casts', function () { it('casts is_enabled to boolean', function () { $casts = (new Service())->getCasts(); expect($casts['is_enabled'])->toBe('boolean'); }); it('casts is_public to boolean', function () { $casts = (new Service())->getCasts(); expect($casts['is_public'])->toBe('boolean'); }); it('casts is_featured to boolean', function () { $casts = (new Service())->getCasts(); expect($casts['is_featured'])->toBe('boolean'); }); it('casts metadata to array', function () { $casts = (new Service())->getCasts(); expect($casts['metadata'])->toBe('array'); }); it('casts sort_order to integer', function () { $casts = (new Service())->getCasts(); expect($casts['sort_order'])->toBe('integer'); }); }); describe('constants', function () { it('defines METADATA_MAX_SIZE', function () { expect(Service::METADATA_MAX_SIZE)->toBe(65_535); }); it('defines METADATA_MAX_KEYS', function () { expect(Service::METADATA_MAX_KEYS)->toBe(100); }); }); }); // ============================================================================= // Query Scopes // ============================================================================= describe('Service query scopes', function () { describe('scopeEnabled', function () { it('filters to enabled services only', function () { Service::create(['code' => 'enabled-svc', 'name' => 'Enabled', 'is_enabled' => true]); Service::create(['code' => 'disabled-svc', 'name' => 'Disabled', 'is_enabled' => false]); expect(Service::enabled()->count())->toBe(1); expect(Service::enabled()->first()->code)->toBe('enabled-svc'); }); }); describe('scopePublic', function () { it('filters to public services only', function () { Service::create(['code' => 'public-svc', 'name' => 'Public', 'is_public' => true]); Service::create(['code' => 'private-svc', 'name' => 'Private', 'is_public' => false]); expect(Service::public()->count())->toBe(1); expect(Service::public()->first()->code)->toBe('public-svc'); }); }); describe('scopeFeatured', function () { it('filters to featured services only', function () { Service::create(['code' => 'featured-svc', 'name' => 'Featured', 'is_featured' => true]); Service::create(['code' => 'normal-svc', 'name' => 'Normal', 'is_featured' => false]); expect(Service::featured()->count())->toBe(1); expect(Service::featured()->first()->code)->toBe('featured-svc'); }); }); describe('scopeOrdered', function () { it('orders by sort_order then name', function () { Service::create(['code' => 'z-last', 'name' => 'Zeta', 'sort_order' => 100]); Service::create(['code' => 'a-first', 'name' => 'Alpha', 'sort_order' => 10]); Service::create(['code' => 'b-mid-b', 'name' => 'Bravo', 'sort_order' => 50]); Service::create(['code' => 'a-mid-a', 'name' => 'Amber', 'sort_order' => 50]); $ordered = Service::ordered()->pluck('code')->all(); expect($ordered)->toBe(['a-first', 'a-mid-a', 'b-mid-b', 'z-last']); }); }); describe('scopeWithMarketingDomain', function () { it('filters to services with marketing domain and website class', function () { Service::create([ 'code' => 'with-domain', 'name' => 'With Domain', 'marketing_domain' => 'example.com', 'website_class' => 'App\\Website', ]); Service::create([ 'code' => 'domain-only', 'name' => 'Domain Only', 'marketing_domain' => 'no-class.com', 'website_class' => null, ]); Service::create([ 'code' => 'no-domain', 'name' => 'No Domain', 'marketing_domain' => null, 'website_class' => null, ]); expect(Service::withMarketingDomain()->count())->toBe(1); expect(Service::withMarketingDomain()->first()->code)->toBe('with-domain'); }); }); describe('scope chaining', function () { it('chains enabled and public scopes', function () { Service::create(['code' => 'both', 'name' => 'Both', 'is_enabled' => true, 'is_public' => true]); Service::create(['code' => 'enabled-only', 'name' => 'Enabled Only', 'is_enabled' => true, 'is_public' => false]); Service::create(['code' => 'public-only', 'name' => 'Public Only', 'is_enabled' => false, 'is_public' => true]); expect(Service::enabled()->public()->count())->toBe(1); expect(Service::enabled()->public()->first()->code)->toBe('both'); }); it('chains enabled, featured, and ordered scopes', function () { Service::create(['code' => 'feat-b', 'name' => 'Beta', 'is_enabled' => true, 'is_featured' => true, 'sort_order' => 20]); Service::create(['code' => 'feat-a', 'name' => 'Alpha', 'is_enabled' => true, 'is_featured' => true, 'sort_order' => 10]); Service::create(['code' => 'not-feat', 'name' => 'NotFeat', 'is_enabled' => true, 'is_featured' => false, 'sort_order' => 5]); Service::create(['code' => 'disabled', 'name' => 'Off', 'is_enabled' => false, 'is_featured' => true, 'sort_order' => 1]); $result = Service::enabled()->featured()->ordered()->pluck('code')->all(); expect($result)->toBe(['feat-a', 'feat-b']); }); }); }); // ============================================================================= // findByCode // ============================================================================= describe('Service findByCode', function () { it('returns service matching the code', function () { Service::create(['code' => 'my-service', 'name' => 'My Service']); $found = Service::findByCode('my-service'); expect($found)->not->toBeNull() ->and($found->name)->toBe('My Service'); }); it('returns null when code does not exist', function () { expect(Service::findByCode('nonexistent'))->toBeNull(); }); it('is case-sensitive', function () { Service::create(['code' => 'My-Service', 'name' => 'Case Test']); expect(Service::findByCode('my-service'))->toBeNull(); expect(Service::findByCode('My-Service'))->not->toBeNull(); }); }); // ============================================================================= // getDomainMappings // ============================================================================= describe('Service getDomainMappings', function () { it('returns domain to website_class mapping', function () { Service::create([ 'code' => 'svc-a', 'name' => 'A', 'is_enabled' => true, 'marketing_domain' => 'alpha.example.com', 'website_class' => 'App\\Website\\Alpha', ]); Service::create([ 'code' => 'svc-b', 'name' => 'B', 'is_enabled' => true, 'marketing_domain' => 'beta.example.com', 'website_class' => 'App\\Website\\Beta', ]); $mappings = Service::getDomainMappings(); expect($mappings)->toBe([ 'alpha.example.com' => 'App\\Website\\Alpha', 'beta.example.com' => 'App\\Website\\Beta', ]); }); it('excludes disabled services', function () { Service::create([ 'code' => 'active', 'name' => 'Active', 'is_enabled' => true, 'marketing_domain' => 'active.example.com', 'website_class' => 'App\\Active', ]); Service::create([ 'code' => 'inactive', 'name' => 'Inactive', 'is_enabled' => false, 'marketing_domain' => 'inactive.example.com', 'website_class' => 'App\\Inactive', ]); $mappings = Service::getDomainMappings(); expect($mappings)->toHaveCount(1) ->and($mappings)->toHaveKey('active.example.com'); }); it('excludes services without marketing domain or website class', function () { Service::create([ 'code' => 'complete', 'name' => 'Complete', 'is_enabled' => true, 'marketing_domain' => 'complete.example.com', 'website_class' => 'App\\Complete', ]); Service::create([ 'code' => 'no-class', 'name' => 'No Class', 'is_enabled' => true, 'marketing_domain' => 'no-class.example.com', 'website_class' => null, ]); Service::create([ 'code' => 'no-domain', 'name' => 'No Domain', 'is_enabled' => true, 'marketing_domain' => null, 'website_class' => 'App\\NoDomain', ]); $mappings = Service::getDomainMappings(); expect($mappings)->toHaveCount(1) ->and($mappings)->toHaveKey('complete.example.com'); }); it('returns empty array when no services match', function () { expect(Service::getDomainMappings())->toBe([]); }); }); // ============================================================================= // Marketing URL Accessor // ============================================================================= describe('Service marketing URL accessor', function () { it('returns explicit marketing_url when set', function () { $service = Service::create([ 'code' => 'with-url', 'name' => 'With URL', 'marketing_url' => 'https://custom.example.com/landing', 'marketing_domain' => 'fallback.example.com', ]); expect($service->marketing_url)->toBe('https://custom.example.com/landing'); }); it('falls back to marketing_domain when marketing_url is null', function () { $service = Service::create([ 'code' => 'with-domain', 'name' => 'With Domain', 'marketing_domain' => 'service.example.com', ]); // In testing environment, the scheme depends on app environment $url = $service->marketing_url; expect($url)->toContain('service.example.com') ->and($url)->toMatch('/^https?:\/\/service\.example\.com$/'); }); it('returns null when neither marketing_url nor marketing_domain is set', function () { $service = Service::create([ 'code' => 'no-url', 'name' => 'No URL', ]); expect($service->marketing_url)->toBeNull(); }); }); // ============================================================================= // Metadata Helpers // ============================================================================= describe('Service metadata helpers', function () { describe('hasMeta', function () { it('returns true when key exists', function () { $service = new Service(); $service->metadata = ['version' => '1.0', 'active' => true]; expect($service->hasMeta('version'))->toBeTrue(); expect($service->hasMeta('active'))->toBeTrue(); }); it('returns false when key does not exist', function () { $service = new Service(); $service->metadata = ['version' => '1.0']; expect($service->hasMeta('nonexistent'))->toBeFalse(); }); it('returns false when metadata is null', function () { $service = new Service(); $service->metadata = null; expect($service->hasMeta('anything'))->toBeFalse(); }); }); describe('getMeta', function () { it('returns value for existing key', function () { $service = new Service(); $service->metadata = ['version' => '2.0', 'count' => 42]; expect($service->getMeta('version'))->toBe('2.0'); expect($service->getMeta('count'))->toBe(42); }); it('returns default when key does not exist', function () { $service = new Service(); $service->metadata = ['version' => '1.0']; expect($service->getMeta('missing'))->toBeNull(); expect($service->getMeta('missing', 'fallback'))->toBe('fallback'); expect($service->getMeta('missing', 0))->toBe(0); }); it('returns null by default when metadata is null', function () { $service = new Service(); $service->metadata = null; expect($service->getMeta('anything'))->toBeNull(); }); }); describe('setMeta', function () { it('sets a new key on empty metadata', function () { $service = new Service(); $service->metadata = []; $service->setMeta('version', '1.0'); expect($service->getMeta('version'))->toBe('1.0'); }); it('sets a new key on null metadata', function () { $service = new Service(); $service->metadata = null; $service->setMeta('version', '1.0'); expect($service->getMeta('version'))->toBe('1.0'); }); it('overwrites an existing key', function () { $service = new Service(); $service->metadata = ['version' => '1.0']; $service->setMeta('version', '2.0'); expect($service->getMeta('version'))->toBe('2.0'); }); it('preserves other keys when setting', function () { $service = new Service(); $service->metadata = ['a' => 1, 'b' => 2]; $service->setMeta('c', 3); expect($service->getMeta('a'))->toBe(1); expect($service->getMeta('b'))->toBe(2); expect($service->getMeta('c'))->toBe(3); }); it('accepts alphanumeric keys with underscores', function () { $service = new Service(); $service->metadata = []; $service->setMeta('my_key_123', 'value'); expect($service->getMeta('my_key_123'))->toBe('value'); }); it('accepts keys with hyphens', function () { $service = new Service(); $service->metadata = []; $service->setMeta('my-key', 'value'); expect($service->getMeta('my-key'))->toBe('value'); }); it('rejects empty key', function () { $service = new Service(); $service->metadata = []; expect(fn () => $service->setMeta('', 'value')) ->toThrow(InvalidArgumentException::class); }); it('rejects key with dots', function () { $service = new Service(); $service->metadata = []; expect(fn () => $service->setMeta('key.nested', 'value')) ->toThrow(InvalidArgumentException::class); }); it('rejects key with spaces', function () { $service = new Service(); $service->metadata = []; expect(fn () => $service->setMeta('key with spaces', 'value')) ->toThrow(InvalidArgumentException::class); }); it('rejects key with slashes', function () { $service = new Service(); $service->metadata = []; expect(fn () => $service->setMeta('key/path', 'value')) ->toThrow(InvalidArgumentException::class); }); it('supports various value types', function () { $service = new Service(); $service->metadata = []; $service->setMeta('string', 'hello'); $service->setMeta('int', 42); $service->setMeta('bool', true); $service->setMeta('array', ['nested' => 'value']); expect($service->getMeta('string'))->toBe('hello'); expect($service->getMeta('int'))->toBe(42); expect($service->getMeta('bool'))->toBeTrue(); expect($service->getMeta('array'))->toBe(['nested' => 'value']); }); }); }); // ============================================================================= // Mass Assignment & Persistence // ============================================================================= describe('Service mass assignment', function () { it('creates a record with all fillable attributes', function () { $service = Service::create([ 'code' => 'full-service', 'module' => 'core', 'name' => 'Full Service', 'tagline' => 'A complete service', 'description' => 'This is a full test service.', 'icon' => 'star', 'color' => '#FF5733', 'marketing_domain' => 'full.example.com', 'website_class' => 'App\\Website\\Full', 'marketing_url' => 'https://full.example.com', 'docs_url' => 'https://docs.example.com/full', 'is_enabled' => true, 'is_public' => true, 'is_featured' => true, 'entitlement_code' => 'full-ent', 'sort_order' => 10, 'metadata' => ['key' => 'value'], ]); $fresh = Service::find($service->id); expect($fresh->code)->toBe('full-service') ->and($fresh->module)->toBe('core') ->and($fresh->name)->toBe('Full Service') ->and($fresh->tagline)->toBe('A complete service') ->and($fresh->description)->toBe('This is a full test service.') ->and($fresh->icon)->toBe('star') ->and($fresh->color)->toBe('#FF5733') ->and($fresh->marketing_domain)->toBe('full.example.com') ->and($fresh->website_class)->toBe('App\\Website\\Full') ->and($fresh->docs_url)->toBe('https://docs.example.com/full') ->and($fresh->is_enabled)->toBeTrue() ->and($fresh->is_public)->toBeTrue() ->and($fresh->is_featured)->toBeTrue() ->and($fresh->entitlement_code)->toBe('full-ent') ->and($fresh->sort_order)->toBe(10) ->and($fresh->metadata)->toBe(['key' => 'value']); }); it('creates a minimal record with required fields only', function () { $service = Service::create([ 'code' => 'minimal', 'name' => 'Minimal Service', ]); $fresh = Service::find($service->id); expect($fresh->code)->toBe('minimal') ->and($fresh->name)->toBe('Minimal Service') ->and($fresh->module)->toBeNull() ->and($fresh->tagline)->toBeNull() ->and($fresh->is_enabled)->toBeTrue() ->and($fresh->is_public)->toBeTrue() ->and($fresh->is_featured)->toBeFalse() ->and($fresh->sort_order)->toBe(50); }); });