Add comprehensive Pest tests for HoneypotHit and Service model methods: HoneypotHitModelTest (50 tests): - Bot detection (detectBot) for 15+ user agents incl. case-insensitive matching - Severity classification (severityForPath, constants, configurable paths) - Query scopes (recent, fromIp, bots, critical, warning) with chaining - Model configuration (fillable, casts, constants) - Mass assignment and persistence round-trips - getStats aggregation (totals, unique IPs, top IPs/bots) ServiceModelTest (45 tests): - Query scopes (enabled, public, featured, ordered, withMarketingDomain) with chaining - findByCode lookup and case sensitivity - getDomainMappings filtering (disabled, missing domain/class) - Marketing URL accessor fallback logic - Metadata helpers (hasMeta, getMeta, setMeta) incl. key validation - Model configuration (table, fillable, casts, constants) - Mass assignment and persistence round-trips Also adds tests/Pest.php to bootstrap Orchestra Testbench and register the Core\Mod\Hub\ PSR-4 namespace for package module autoloading. Fixes #8 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
601 lines
22 KiB
PHP
601 lines
22 KiB
PHP
<?php
|
|
|
|
/*
|
|
* Core PHP Framework
|
|
*
|
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
|
* See LICENSE file for details.
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
use Core\Mod\Hub\Models\Service;
|
|
|
|
/**
|
|
* Tests for Service model methods.
|
|
*
|
|
* Covers query scopes, findByCode, getDomainMappings, marketing URL accessor,
|
|
* metadata helpers (hasMeta, getMeta, setMeta), fillable attributes, and casts.
|
|
*/
|
|
|
|
beforeEach(function () {
|
|
if (! \Illuminate\Support\Facades\Schema::hasTable('platform_services')) {
|
|
\Illuminate\Support\Facades\Schema::create('platform_services', function ($table) {
|
|
$table->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);
|
|
});
|
|
});
|