From 2e50e4e25e0809be24e60637dab2591a1b5d50b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 16:55:49 +0000 Subject: [PATCH] test: add unit tests for Model methods 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) --- tests/Feature/Models/HoneypotHitModelTest.php | 599 +++++++++++++++++ tests/Feature/Models/ServiceModelTest.php | 601 ++++++++++++++++++ tests/Pest.php | 39 ++ 3 files changed, 1239 insertions(+) create mode 100644 tests/Feature/Models/HoneypotHitModelTest.php create mode 100644 tests/Feature/Models/ServiceModelTest.php create mode 100644 tests/Pest.php diff --git a/tests/Feature/Models/HoneypotHitModelTest.php b/tests/Feature/Models/HoneypotHitModelTest.php new file mode 100644 index 0000000..cf776b6 --- /dev/null +++ b/tests/Feature/Models/HoneypotHitModelTest.php @@ -0,0 +1,599 @@ +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); + }); +}); diff --git a/tests/Feature/Models/ServiceModelTest.php b/tests/Feature/Models/ServiceModelTest.php new file mode 100644 index 0000000..0145cef --- /dev/null +++ b/tests/Feature/Models/ServiceModelTest.php @@ -0,0 +1,601 @@ +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); + }); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..06fc55e --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,39 @@ +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/');