Compare commits

..

No commits in common. "feat/test-model-methods" and "dev" have entirely different histories.

3 changed files with 0 additions and 1239 deletions

View file

@ -1,599 +0,0 @@
<?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\HoneypotHit;
/**
* Tests for HoneypotHit model methods.
*
* Covers bot detection, severity classification, query scopes,
* fillable attributes, casts, and the getStats() aggregation.
*/
beforeEach(function () {
if (! \Illuminate\Support\Facades\Schema::hasTable('honeypot_hits')) {
\Illuminate\Support\Facades\Schema::create('honeypot_hits', function ($table) {
$table->id();
$table->string('ip_address', 45);
$table->string('user_agent', 1000)->nullable();
$table->string('referer', 2000)->nullable();
$table->string('path', 255);
$table->string('method', 10);
$table->json('headers')->nullable();
$table->string('country', 2)->nullable();
$table->string('city', 100)->nullable();
$table->boolean('is_bot')->default(false);
$table->string('bot_name', 100)->nullable();
$table->string('severity', 20)->default('warning');
$table->timestamps();
$table->index('ip_address');
$table->index('created_at');
$table->index('is_bot');
});
}
});
afterEach(function () {
HoneypotHit::query()->delete();
});
// =============================================================================
// Bot Detection
// =============================================================================
describe('HoneypotHit bot detection', function () {
describe('detectBot', function () {
it('returns Unknown for null user agent', function () {
expect(HoneypotHit::detectBot(null))->toBe('Unknown (no UA)');
});
it('returns Unknown for empty user agent', function () {
expect(HoneypotHit::detectBot(''))->toBe('Unknown (no UA)');
});
it('returns null for legitimate browser user agent', function () {
$ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
expect(HoneypotHit::detectBot($ua))->toBeNull();
});
it('detects AhrefsBot', function () {
$ua = 'Mozilla/5.0 (compatible; AhrefsBot/7.0; +http://ahrefs.com/robot/)';
expect(HoneypotHit::detectBot($ua))->toBe('Ahrefs');
});
it('detects SemrushBot', function () {
$ua = 'Mozilla/5.0 (compatible; SemrushBot/7~bl; +http://www.semrush.com/bot.html)';
expect(HoneypotHit::detectBot($ua))->toBe('Semrush');
});
it('detects Googlebot', function () {
$ua = 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)';
expect(HoneypotHit::detectBot($ua))->toBe('Google');
});
it('detects GPTBot', function () {
$ua = 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko) GPTBot/1.0';
expect(HoneypotHit::detectBot($ua))->toBe('OpenAI');
});
it('detects ClaudeBot', function () {
$ua = 'Mozilla/5.0 ClaudeBot/1.0';
expect(HoneypotHit::detectBot($ua))->toBe('Anthropic');
});
it('detects anthropic-ai', function () {
$ua = 'anthropic-ai/1.0';
expect(HoneypotHit::detectBot($ua))->toBe('Anthropic');
});
it('detects curl', function () {
$ua = 'curl/7.88.1';
expect(HoneypotHit::detectBot($ua))->toBe('cURL');
});
it('detects python-requests', function () {
$ua = 'python-requests/2.31.0';
expect(HoneypotHit::detectBot($ua))->toBe('Python');
});
it('detects Go-http-client', function () {
$ua = 'Go-http-client/2.0';
expect(HoneypotHit::detectBot($ua))->toBe('Go');
});
it('detects Wget', function () {
$ua = 'Wget/1.21.3';
expect(HoneypotHit::detectBot($ua))->toBe('Wget');
});
it('detects HeadlessChrome', function () {
$ua = 'Mozilla/5.0 HeadlessChrome/120.0.0.0 Safari/537.36';
expect(HoneypotHit::detectBot($ua))->toBe('HeadlessChrome');
});
it('detects PhantomJS', function () {
$ua = 'Mozilla/5.0 PhantomJS/2.1.1 Safari/538.1';
expect(HoneypotHit::detectBot($ua))->toBe('PhantomJS');
});
it('detects Scrapy', function () {
$ua = 'Scrapy/2.11.0 (+https://scrapy.org)';
expect(HoneypotHit::detectBot($ua))->toBe('Scrapy');
});
it('detects Bytespider', function () {
$ua = 'Mozilla/5.0 (compatible; Bytespider; spider-feedback@bytedance.com)';
expect(HoneypotHit::detectBot($ua))->toBe('ByteDance');
});
it('performs case-insensitive matching', function () {
expect(HoneypotHit::detectBot('AHREFSBOT/7.0'))->toBe('Ahrefs');
expect(HoneypotHit::detectBot('semrushbot'))->toBe('Semrush');
expect(HoneypotHit::detectBot('GOOGLEBOT'))->toBe('Google');
});
});
});
// =============================================================================
// Severity Classification
// =============================================================================
describe('HoneypotHit severity classification', function () {
describe('severityForPath', function () {
it('classifies admin paths as critical', function () {
expect(HoneypotHit::severityForPath('/admin'))->toBe('critical');
expect(HoneypotHit::severityForPath('/wp-admin'))->toBe('critical');
expect(HoneypotHit::severityForPath('/wp-login.php'))->toBe('critical');
expect(HoneypotHit::severityForPath('/administrator'))->toBe('critical');
expect(HoneypotHit::severityForPath('/phpmyadmin'))->toBe('critical');
});
it('classifies sensitive file paths as critical', function () {
expect(HoneypotHit::severityForPath('/.env'))->toBe('critical');
expect(HoneypotHit::severityForPath('/.git'))->toBe('critical');
});
it('strips leading slashes before matching', function () {
expect(HoneypotHit::severityForPath('/admin'))->toBe('critical');
expect(HoneypotHit::severityForPath('admin'))->toBe('critical');
});
it('classifies non-critical paths as warning', function () {
expect(HoneypotHit::severityForPath('/teapot'))->toBe('warning');
expect(HoneypotHit::severityForPath('/robots.txt'))->toBe('warning');
expect(HoneypotHit::severityForPath('/some-random-path'))->toBe('warning');
});
it('matches paths that start with a critical prefix', function () {
expect(HoneypotHit::severityForPath('/admin/login'))->toBe('critical');
expect(HoneypotHit::severityForPath('/wp-admin/update.php'))->toBe('critical');
expect(HoneypotHit::severityForPath('/.env.bak'))->toBe('critical');
expect(HoneypotHit::severityForPath('/.git/config'))->toBe('critical');
});
});
describe('getSeverityCritical', function () {
it('returns default critical string', function () {
expect(HoneypotHit::getSeverityCritical())->toBe('critical');
});
});
describe('getSeverityWarning', function () {
it('returns default warning string', function () {
expect(HoneypotHit::getSeverityWarning())->toBe('warning');
});
});
describe('getCriticalPaths', function () {
it('returns default critical paths array', function () {
$paths = HoneypotHit::getCriticalPaths();
expect($paths)->toBeArray()
->and($paths)->toContain('admin')
->and($paths)->toContain('wp-admin')
->and($paths)->toContain('wp-login.php')
->and($paths)->toContain('.env')
->and($paths)->toContain('.git');
});
});
describe('severity constants', function () {
it('defines SEVERITY_WARNING constant', function () {
expect(HoneypotHit::SEVERITY_WARNING)->toBe('warning');
});
it('defines SEVERITY_CRITICAL constant', function () {
expect(HoneypotHit::SEVERITY_CRITICAL)->toBe('critical');
});
});
});
// =============================================================================
// Model Configuration
// =============================================================================
describe('HoneypotHit model configuration', function () {
describe('fillable attributes', function () {
it('has the expected fillable fields', function () {
$hit = new HoneypotHit();
$fillable = $hit->getFillable();
expect($fillable)->toContain('ip_address')
->and($fillable)->toContain('user_agent')
->and($fillable)->toContain('referer')
->and($fillable)->toContain('path')
->and($fillable)->toContain('method')
->and($fillable)->toContain('headers')
->and($fillable)->toContain('country')
->and($fillable)->toContain('city')
->and($fillable)->toContain('is_bot')
->and($fillable)->toContain('bot_name')
->and($fillable)->toContain('severity');
});
});
describe('casts', function () {
it('casts headers to array', function () {
$hit = new HoneypotHit();
$casts = $hit->getCasts();
expect($casts['headers'])->toBe('array');
});
it('casts is_bot to boolean', function () {
$hit = new HoneypotHit();
$casts = $hit->getCasts();
expect($casts['is_bot'])->toBe('boolean');
});
});
describe('constants', function () {
it('defines HEADERS_MAX_COUNT', function () {
expect(HoneypotHit::HEADERS_MAX_COUNT)->toBe(50);
});
it('defines HEADERS_MAX_SIZE', function () {
expect(HoneypotHit::HEADERS_MAX_SIZE)->toBe(16_384);
});
});
});
// =============================================================================
// Query Scopes
// =============================================================================
describe('HoneypotHit query scopes', function () {
describe('scopeRecent', function () {
it('filters to hits within given hours', function () {
// Create and then backdate to control created_at precisely
$recentHit = HoneypotHit::create([
'ip_address' => '10.0.0.1',
'path' => '/teapot',
'method' => 'GET',
'severity' => 'warning',
]);
$recentHit->forceFill(['created_at' => now()->subHours(2)])->save();
$oldHit = HoneypotHit::create([
'ip_address' => '10.0.0.2',
'path' => '/teapot',
'method' => 'GET',
'severity' => 'warning',
]);
$oldHit->forceFill(['created_at' => now()->subHours(48)])->save();
expect(HoneypotHit::recent(24)->count())->toBe(1);
expect(HoneypotHit::recent(72)->count())->toBe(2);
});
it('defaults to 24 hours', function () {
$recentHit = HoneypotHit::create([
'ip_address' => '10.0.0.1',
'path' => '/teapot',
'method' => 'GET',
'severity' => 'warning',
]);
$recentHit->forceFill(['created_at' => now()->subHours(12)])->save();
$oldHit = HoneypotHit::create([
'ip_address' => '10.0.0.2',
'path' => '/teapot',
'method' => 'GET',
'severity' => 'warning',
]);
$oldHit->forceFill(['created_at' => now()->subHours(30)])->save();
expect(HoneypotHit::recent()->count())->toBe(1);
});
});
describe('scopeFromIp', function () {
it('filters by IP address', function () {
HoneypotHit::create([
'ip_address' => '192.168.1.1',
'path' => '/teapot',
'method' => 'GET',
'severity' => 'warning',
]);
HoneypotHit::create([
'ip_address' => '10.0.0.1',
'path' => '/admin',
'method' => 'GET',
'severity' => 'critical',
]);
expect(HoneypotHit::fromIp('192.168.1.1')->count())->toBe(1);
expect(HoneypotHit::fromIp('10.0.0.1')->count())->toBe(1);
expect(HoneypotHit::fromIp('172.16.0.1')->count())->toBe(0);
});
});
describe('scopeBots', function () {
it('filters to bot hits only', function () {
HoneypotHit::create([
'ip_address' => '10.0.0.1',
'path' => '/teapot',
'method' => 'GET',
'severity' => 'warning',
'is_bot' => true,
'bot_name' => 'Ahrefs',
]);
HoneypotHit::create([
'ip_address' => '10.0.0.2',
'path' => '/teapot',
'method' => 'GET',
'severity' => 'warning',
'is_bot' => false,
]);
expect(HoneypotHit::bots()->count())->toBe(1);
});
});
describe('scopeCritical', function () {
it('filters to critical severity hits', function () {
HoneypotHit::create([
'ip_address' => '10.0.0.1',
'path' => '/admin',
'method' => 'GET',
'severity' => 'critical',
]);
HoneypotHit::create([
'ip_address' => '10.0.0.2',
'path' => '/teapot',
'method' => 'GET',
'severity' => 'warning',
]);
expect(HoneypotHit::critical()->count())->toBe(1);
});
});
describe('scopeWarning', function () {
it('filters to warning severity hits', function () {
HoneypotHit::create([
'ip_address' => '10.0.0.1',
'path' => '/admin',
'method' => 'GET',
'severity' => 'critical',
]);
HoneypotHit::create([
'ip_address' => '10.0.0.2',
'path' => '/teapot',
'method' => 'GET',
'severity' => 'warning',
]);
expect(HoneypotHit::warning()->count())->toBe(1);
});
});
describe('scope chaining', function () {
it('chains bots and critical scopes', function () {
HoneypotHit::create([
'ip_address' => '10.0.0.1',
'path' => '/admin',
'method' => 'GET',
'severity' => 'critical',
'is_bot' => true,
'bot_name' => 'Ahrefs',
]);
HoneypotHit::create([
'ip_address' => '10.0.0.2',
'path' => '/teapot',
'method' => 'GET',
'severity' => 'warning',
'is_bot' => true,
'bot_name' => 'Semrush',
]);
HoneypotHit::create([
'ip_address' => '10.0.0.3',
'path' => '/admin',
'method' => 'GET',
'severity' => 'critical',
'is_bot' => false,
]);
expect(HoneypotHit::bots()->critical()->count())->toBe(1);
});
it('chains fromIp and recent scopes', function () {
$hit1 = HoneypotHit::create([
'ip_address' => '192.168.1.1',
'path' => '/teapot',
'method' => 'GET',
'severity' => 'warning',
]);
$hit1->forceFill(['created_at' => now()->subHours(2)])->save();
$hit2 = HoneypotHit::create([
'ip_address' => '192.168.1.1',
'path' => '/admin',
'method' => 'GET',
'severity' => 'critical',
]);
$hit2->forceFill(['created_at' => now()->subHours(48)])->save();
HoneypotHit::create([
'ip_address' => '10.0.0.1',
'path' => '/teapot',
'method' => 'GET',
'severity' => 'warning',
]);
expect(HoneypotHit::fromIp('192.168.1.1')->recent(24)->count())->toBe(1);
});
});
});
// =============================================================================
// Mass Assignment & Persistence
// =============================================================================
describe('HoneypotHit mass assignment', function () {
it('creates a record with all fillable attributes', function () {
$hit = HoneypotHit::create([
'ip_address' => '203.0.113.50',
'user_agent' => 'AhrefsBot/7.0',
'referer' => 'https://example.com',
'path' => '/wp-admin',
'method' => 'GET',
'headers' => ['host' => ['example.com']],
'country' => 'US',
'city' => 'New York',
'is_bot' => true,
'bot_name' => 'Ahrefs',
'severity' => 'critical',
]);
$fresh = HoneypotHit::find($hit->id);
expect($fresh->ip_address)->toBe('203.0.113.50')
->and($fresh->user_agent)->toBe('AhrefsBot/7.0')
->and($fresh->referer)->toBe('https://example.com')
->and($fresh->path)->toBe('/wp-admin')
->and($fresh->method)->toBe('GET')
->and($fresh->headers)->toBe(['host' => ['example.com']])
->and($fresh->country)->toBe('US')
->and($fresh->city)->toBe('New York')
->and($fresh->is_bot)->toBeTrue()
->and($fresh->bot_name)->toBe('Ahrefs')
->and($fresh->severity)->toBe('critical');
});
it('creates a minimal record with required fields only', function () {
$hit = HoneypotHit::create([
'ip_address' => '127.0.0.1',
'path' => '/teapot',
'method' => 'GET',
'severity' => 'warning',
]);
$fresh = HoneypotHit::find($hit->id);
expect($fresh->ip_address)->toBe('127.0.0.1')
->and($fresh->path)->toBe('/teapot')
->and($fresh->method)->toBe('GET')
->and($fresh->severity)->toBe('warning')
->and($fresh->user_agent)->toBeNull()
->and($fresh->bot_name)->toBeNull();
});
});
// =============================================================================
// getStats
// =============================================================================
describe('HoneypotHit getStats', function () {
it('returns correct stats structure', function () {
$stats = HoneypotHit::getStats();
expect($stats)->toBeArray()
->and($stats)->toHaveKey('total')
->and($stats)->toHaveKey('today')
->and($stats)->toHaveKey('this_week')
->and($stats)->toHaveKey('unique_ips')
->and($stats)->toHaveKey('bots')
->and($stats)->toHaveKey('top_ips')
->and($stats)->toHaveKey('top_bots');
});
it('returns zero counts when empty', function () {
$stats = HoneypotHit::getStats();
expect($stats['total'])->toBe(0)
->and($stats['today'])->toBe(0)
->and($stats['this_week'])->toBe(0)
->and($stats['unique_ips'])->toBe(0)
->and($stats['bots'])->toBe(0);
});
it('counts total hits correctly', function () {
HoneypotHit::create(['ip_address' => '10.0.0.1', 'path' => '/a', 'method' => 'GET', 'severity' => 'warning']);
HoneypotHit::create(['ip_address' => '10.0.0.2', 'path' => '/b', 'method' => 'GET', 'severity' => 'critical']);
HoneypotHit::create(['ip_address' => '10.0.0.3', 'path' => '/c', 'method' => 'POST', 'severity' => 'warning']);
$stats = HoneypotHit::getStats();
expect($stats['total'])->toBe(3);
});
it('counts unique IPs correctly', function () {
HoneypotHit::create(['ip_address' => '10.0.0.1', 'path' => '/a', 'method' => 'GET', 'severity' => 'warning']);
HoneypotHit::create(['ip_address' => '10.0.0.1', 'path' => '/b', 'method' => 'GET', 'severity' => 'warning']);
HoneypotHit::create(['ip_address' => '10.0.0.2', 'path' => '/c', 'method' => 'GET', 'severity' => 'warning']);
$stats = HoneypotHit::getStats();
expect($stats['unique_ips'])->toBe(2);
});
it('counts bots correctly', function () {
HoneypotHit::create(['ip_address' => '10.0.0.1', 'path' => '/a', 'method' => 'GET', 'severity' => 'warning', 'is_bot' => true, 'bot_name' => 'Ahrefs']);
HoneypotHit::create(['ip_address' => '10.0.0.2', 'path' => '/b', 'method' => 'GET', 'severity' => 'warning', 'is_bot' => false]);
$stats = HoneypotHit::getStats();
expect($stats['bots'])->toBe(1);
});
it('aggregates top IPs', function () {
HoneypotHit::create(['ip_address' => '10.0.0.1', 'path' => '/a', 'method' => 'GET', 'severity' => 'warning']);
HoneypotHit::create(['ip_address' => '10.0.0.1', 'path' => '/b', 'method' => 'GET', 'severity' => 'warning']);
HoneypotHit::create(['ip_address' => '10.0.0.2', 'path' => '/c', 'method' => 'GET', 'severity' => 'warning']);
$stats = HoneypotHit::getStats();
expect($stats['top_ips'])->toHaveCount(2);
expect($stats['top_ips']->first()->ip_address)->toBe('10.0.0.1');
expect($stats['top_ips']->first()->hits)->toBe(2);
});
it('aggregates top bots', function () {
HoneypotHit::create(['ip_address' => '10.0.0.1', 'path' => '/a', 'method' => 'GET', 'severity' => 'warning', 'is_bot' => true, 'bot_name' => 'Ahrefs']);
HoneypotHit::create(['ip_address' => '10.0.0.2', 'path' => '/b', 'method' => 'GET', 'severity' => 'warning', 'is_bot' => true, 'bot_name' => 'Ahrefs']);
HoneypotHit::create(['ip_address' => '10.0.0.3', 'path' => '/c', 'method' => 'GET', 'severity' => 'warning', 'is_bot' => true, 'bot_name' => 'Semrush']);
$stats = HoneypotHit::getStats();
expect($stats['top_bots'])->toHaveCount(2);
expect($stats['top_bots']->first()->bot_name)->toBe('Ahrefs');
expect($stats['top_bots']->first()->hits)->toBe(2);
});
});

View file

@ -1,601 +0,0 @@
<?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);
});
});

View file

@ -1,39 +0,0 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
use Orchestra\Testbench\TestCase;
/*
|--------------------------------------------------------------------------
| Test Case
|--------------------------------------------------------------------------
|
| Feature tests use Orchestra Testbench to bootstrap a minimal Laravel
| application with SQLite in-memory database. This provides facades,
| Eloquent, and Schema support without needing the full application.
|
*/
uses(TestCase::class)->in('Feature');
/*
|--------------------------------------------------------------------------
| Package Autoloading
|--------------------------------------------------------------------------
|
| Register PSR-4 namespaces for package modules that sit outside the
| main Core\Admin\ autoload prefix. In CI these are resolved via the
| path repository configuration; locally we register them here.
|
*/
$loader = require __DIR__.'/../vendor/autoload.php';
$loader->addPsr4('Core\\Mod\\Hub\\', __DIR__.'/../src/Mod/Hub/');