- Add comprehensive health check system with multiple endpoints - Add Prometheus metrics endpoint - Add production logging configurations (5 strategies) - Add complete deployment documentation suite: * QUICKSTART.md - 30-minute deployment guide * DEPLOYMENT_CHECKLIST.md - Printable verification checklist * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference * production-logging.md - Logging configuration guide * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation * README.md - Navigation hub * DEPLOYMENT_SUMMARY.md - Executive summary - Add deployment scripts and automation - Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment - Update README with production-ready features All production infrastructure is now complete and ready for deployment.
426 lines
15 KiB
PHP
426 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Framework\Cache\Cache;
|
|
use App\Framework\Cache\CacheKey;
|
|
use App\Framework\View\Caching\Strategies\FragmentCacheStrategy;
|
|
use App\Framework\View\Caching\TemplateContext;
|
|
|
|
describe('FragmentCacheStrategy', function () {
|
|
beforeEach(function () {
|
|
$this->cache = Mockery::mock(Cache::class);
|
|
$this->strategy = new FragmentCacheStrategy($this->cache);
|
|
});
|
|
|
|
afterEach(function () {
|
|
Mockery::close();
|
|
});
|
|
|
|
describe('shouldCache()', function () {
|
|
it('returns true when fragment_id is set in metadata', function () {
|
|
$context = new TemplateContext(
|
|
template: 'page/dashboard',
|
|
data: ['stats' => [1, 2, 3]],
|
|
metadata: ['fragment_id' => 'user-stats']
|
|
);
|
|
|
|
expect($this->strategy->shouldCache($context))->toBeTrue();
|
|
});
|
|
|
|
it('returns false when fragment_id is not set', function () {
|
|
$context = new TemplateContext(
|
|
template: 'page/dashboard',
|
|
data: ['stats' => [1, 2, 3]],
|
|
metadata: []
|
|
);
|
|
|
|
expect($this->strategy->shouldCache($context))->toBeFalse();
|
|
});
|
|
|
|
it('returns false when metadata is empty', function () {
|
|
$context = new TemplateContext(
|
|
template: 'page/home',
|
|
data: ['content' => 'text']
|
|
);
|
|
|
|
expect($this->strategy->shouldCache($context))->toBeFalse();
|
|
});
|
|
|
|
it('returns true even with empty fragment_id string', function () {
|
|
$context = new TemplateContext(
|
|
template: 'page/dashboard',
|
|
data: [],
|
|
metadata: ['fragment_id' => '']
|
|
);
|
|
|
|
expect($this->strategy->shouldCache($context))->toBeTrue();
|
|
});
|
|
|
|
it('returns true with numeric fragment_id', function () {
|
|
$context = new TemplateContext(
|
|
template: 'page/report',
|
|
data: [],
|
|
metadata: ['fragment_id' => 123]
|
|
);
|
|
|
|
expect($this->strategy->shouldCache($context))->toBeTrue();
|
|
});
|
|
|
|
it('returns true regardless of other metadata keys', function () {
|
|
$context = new TemplateContext(
|
|
template: 'page/profile',
|
|
data: [],
|
|
metadata: [
|
|
'fragment_id' => 'user-profile',
|
|
'other_key' => 'value',
|
|
'version' => 2
|
|
]
|
|
);
|
|
|
|
expect($this->strategy->shouldCache($context))->toBeTrue();
|
|
});
|
|
});
|
|
|
|
describe('generateKey()', function () {
|
|
it('generates consistent keys for same fragment', function () {
|
|
$context = new TemplateContext(
|
|
template: 'page/dashboard',
|
|
data: ['count' => 5],
|
|
metadata: ['fragment_id' => 'stats']
|
|
);
|
|
|
|
$key1 = $this->strategy->generateKey($context);
|
|
$key2 = $this->strategy->generateKey($context);
|
|
|
|
expect((string) $key1)->toBe((string) $key2);
|
|
});
|
|
|
|
it('generates different keys for different fragment_ids', function () {
|
|
$context1 = new TemplateContext(
|
|
template: 'page/dashboard',
|
|
data: ['count' => 5],
|
|
metadata: ['fragment_id' => 'stats']
|
|
);
|
|
$context2 = new TemplateContext(
|
|
template: 'page/dashboard',
|
|
data: ['count' => 5],
|
|
metadata: ['fragment_id' => 'chart']
|
|
);
|
|
|
|
$key1 = $this->strategy->generateKey($context1);
|
|
$key2 = $this->strategy->generateKey($context2);
|
|
|
|
expect((string) $key1)->not->toBe((string) $key2);
|
|
});
|
|
|
|
it('generates different keys for different templates with same fragment', function () {
|
|
$context1 = new TemplateContext(
|
|
template: 'page/dashboard',
|
|
data: [],
|
|
metadata: ['fragment_id' => 'header']
|
|
);
|
|
$context2 = new TemplateContext(
|
|
template: 'page/profile',
|
|
data: [],
|
|
metadata: ['fragment_id' => 'header']
|
|
);
|
|
|
|
$key1 = $this->strategy->generateKey($context1);
|
|
$key2 = $this->strategy->generateKey($context2);
|
|
|
|
expect((string) $key1)->not->toBe((string) $key2);
|
|
});
|
|
|
|
it('generates different keys for different data', function () {
|
|
$context1 = new TemplateContext(
|
|
template: 'page/dashboard',
|
|
data: ['count' => 5],
|
|
metadata: ['fragment_id' => 'stats']
|
|
);
|
|
$context2 = new TemplateContext(
|
|
template: 'page/dashboard',
|
|
data: ['count' => 10],
|
|
metadata: ['fragment_id' => 'stats']
|
|
);
|
|
|
|
$key1 = $this->strategy->generateKey($context1);
|
|
$key2 = $this->strategy->generateKey($context2);
|
|
|
|
expect((string) $key1)->not->toBe((string) $key2);
|
|
});
|
|
|
|
it('uses "default" when fragment_id is missing', function () {
|
|
$context = new TemplateContext(
|
|
template: 'page/home',
|
|
data: ['content' => 'test'],
|
|
metadata: []
|
|
);
|
|
|
|
$key = $this->strategy->generateKey($context);
|
|
|
|
expect((string) $key)->toContain('fragment:page/home:default:');
|
|
});
|
|
|
|
it('includes fragment_id in cache key', function () {
|
|
$context = new TemplateContext(
|
|
template: 'page/dashboard',
|
|
data: [],
|
|
metadata: ['fragment_id' => 'user-stats']
|
|
);
|
|
|
|
$key = $this->strategy->generateKey($context);
|
|
|
|
expect((string) $key)->toContain('fragment:page/dashboard:user-stats:');
|
|
});
|
|
|
|
it('includes template name in cache key', function () {
|
|
$context = new TemplateContext(
|
|
template: 'reports/annual',
|
|
data: [],
|
|
metadata: ['fragment_id' => 'summary']
|
|
);
|
|
|
|
$key = $this->strategy->generateKey($context);
|
|
|
|
expect((string) $key)->toStartWith('fragment:reports/annual:summary:');
|
|
});
|
|
|
|
it('includes data hash in cache key', function () {
|
|
$context = new TemplateContext(
|
|
template: 'page/dashboard',
|
|
data: ['user_id' => 123, 'role' => 'admin'],
|
|
metadata: ['fragment_id' => 'permissions']
|
|
);
|
|
|
|
$key = $this->strategy->generateKey($context);
|
|
$keyString = (string) $key;
|
|
|
|
// Key should have format: fragment:{template}:{fragment_id}:{hash}
|
|
expect($keyString)->toStartWith('fragment:page/dashboard:permissions:');
|
|
expect(strlen($keyString))->toBeGreaterThan(strlen('fragment:page/dashboard:permissions:'));
|
|
});
|
|
|
|
it('handles numeric fragment_id', function () {
|
|
$context = new TemplateContext(
|
|
template: 'page/item',
|
|
data: [],
|
|
metadata: ['fragment_id' => 42]
|
|
);
|
|
|
|
$key = $this->strategy->generateKey($context);
|
|
|
|
expect((string) $key)->toContain('fragment:page/item:42:');
|
|
});
|
|
|
|
it('generates same key for identical complex data structures', function () {
|
|
$data = [
|
|
'items' => [
|
|
['id' => 1, 'name' => 'Item 1'],
|
|
['id' => 2, 'name' => 'Item 2']
|
|
],
|
|
'meta' => ['total' => 2, 'page' => 1]
|
|
];
|
|
|
|
$context1 = new TemplateContext(
|
|
template: 'list/items',
|
|
data: $data,
|
|
metadata: ['fragment_id' => 'item-list']
|
|
);
|
|
$context2 = new TemplateContext(
|
|
template: 'list/items',
|
|
data: $data,
|
|
metadata: ['fragment_id' => 'item-list']
|
|
);
|
|
|
|
$key1 = $this->strategy->generateKey($context1);
|
|
$key2 = $this->strategy->generateKey($context2);
|
|
|
|
expect((string) $key1)->toBe((string) $key2);
|
|
});
|
|
});
|
|
|
|
describe('getTtl()', function () {
|
|
it('returns 600 seconds for all fragments', function () {
|
|
$context = new TemplateContext(
|
|
template: 'page/dashboard',
|
|
data: [],
|
|
metadata: ['fragment_id' => 'stats']
|
|
);
|
|
|
|
expect($this->strategy->getTtl($context))->toBe(600);
|
|
});
|
|
|
|
it('returns consistent TTL for different fragments', function () {
|
|
$context1 = new TemplateContext(
|
|
template: 'page/dashboard',
|
|
data: [],
|
|
metadata: ['fragment_id' => 'stats']
|
|
);
|
|
$context2 = new TemplateContext(
|
|
template: 'page/profile',
|
|
data: [],
|
|
metadata: ['fragment_id' => 'bio']
|
|
);
|
|
|
|
expect($this->strategy->getTtl($context1))->toBe(600);
|
|
expect($this->strategy->getTtl($context2))->toBe(600);
|
|
});
|
|
|
|
it('returns consistent TTL regardless of data', function () {
|
|
$context1 = new TemplateContext(
|
|
template: 'page/dashboard',
|
|
data: ['small' => 'data'],
|
|
metadata: ['fragment_id' => 'header']
|
|
);
|
|
$context2 = new TemplateContext(
|
|
template: 'page/dashboard',
|
|
data: ['large' => str_repeat('x', 1000)],
|
|
metadata: ['fragment_id' => 'header']
|
|
);
|
|
|
|
expect($this->strategy->getTtl($context1))->toBe(600);
|
|
expect($this->strategy->getTtl($context2))->toBe(600);
|
|
});
|
|
|
|
it('returns consistent TTL when fragment_id is missing', function () {
|
|
$context = new TemplateContext(
|
|
template: 'page/home',
|
|
data: [],
|
|
metadata: []
|
|
);
|
|
|
|
expect($this->strategy->getTtl($context))->toBe(600);
|
|
});
|
|
});
|
|
|
|
describe('canInvalidate()', function () {
|
|
it('returns true for any template', function () {
|
|
expect($this->strategy->canInvalidate('page/home'))->toBeTrue();
|
|
expect($this->strategy->canInvalidate('components/button'))->toBeTrue();
|
|
expect($this->strategy->canInvalidate('layouts/main'))->toBeTrue();
|
|
});
|
|
|
|
it('returns true for empty string', function () {
|
|
expect($this->strategy->canInvalidate(''))->toBeTrue();
|
|
});
|
|
|
|
it('returns true for template with special characters', function () {
|
|
expect($this->strategy->canInvalidate('admin/users/edit-profile_form'))->toBeTrue();
|
|
});
|
|
});
|
|
|
|
describe('edge cases', function () {
|
|
it('handles empty data array', function () {
|
|
$context = new TemplateContext(
|
|
template: 'page/empty',
|
|
data: [],
|
|
metadata: ['fragment_id' => 'test']
|
|
);
|
|
|
|
expect($this->strategy->shouldCache($context))->toBeTrue();
|
|
|
|
$key = $this->strategy->generateKey($context);
|
|
expect($key)->toBeInstanceOf(CacheKey::class);
|
|
});
|
|
|
|
it('handles complex nested metadata', function () {
|
|
$context = new TemplateContext(
|
|
template: 'page/complex',
|
|
data: ['content' => 'test'],
|
|
metadata: [
|
|
'fragment_id' => 'main-content',
|
|
'version' => 2,
|
|
'options' => ['cache' => true, 'ttl' => 300]
|
|
]
|
|
);
|
|
|
|
expect($this->strategy->shouldCache($context))->toBeTrue();
|
|
|
|
$key = $this->strategy->generateKey($context);
|
|
expect((string) $key)->toContain('fragment:page/complex:main-content:');
|
|
});
|
|
|
|
it('handles special characters in fragment_id', function () {
|
|
$context = new TemplateContext(
|
|
template: 'page/dashboard',
|
|
data: [],
|
|
metadata: ['fragment_id' => 'user-stats_2024-Q1']
|
|
);
|
|
|
|
$key = $this->strategy->generateKey($context);
|
|
expect((string) $key)->toContain('fragment:page/dashboard:user-stats_2024-Q1:');
|
|
});
|
|
|
|
it('throws exception for very long fragment_id exceeding 250 chars', function () {
|
|
$longFragmentId = str_repeat('fragment-', 50); // Results in 500 chars
|
|
|
|
$context = new TemplateContext(
|
|
template: 'page/test',
|
|
data: [],
|
|
metadata: ['fragment_id' => $longFragmentId]
|
|
);
|
|
|
|
// CacheKey validates max 250 characters
|
|
expect(fn() => $this->strategy->generateKey($context))
|
|
->toThrow(\InvalidArgumentException::class, 'Cache key length exceeds maximum');
|
|
});
|
|
|
|
it('handles data with objects', function () {
|
|
$context = new TemplateContext(
|
|
template: 'page/dashboard',
|
|
data: [
|
|
'date' => new \DateTimeImmutable('2024-01-01 12:00:00'),
|
|
'user' => (object) ['id' => 1, 'name' => 'John']
|
|
],
|
|
metadata: ['fragment_id' => 'stats']
|
|
);
|
|
|
|
$key = $this->strategy->generateKey($context);
|
|
expect($key)->toBeInstanceOf(CacheKey::class);
|
|
expect((string) $key)->toStartWith('fragment:page/dashboard:stats:');
|
|
});
|
|
|
|
it('handles null fragment_id gracefully', function () {
|
|
$context = new TemplateContext(
|
|
template: 'page/test',
|
|
data: [],
|
|
metadata: ['fragment_id' => null]
|
|
);
|
|
|
|
// isset() returns false for null values
|
|
expect($this->strategy->shouldCache($context))->toBeFalse();
|
|
});
|
|
|
|
it('distinguishes between missing and null fragment_id', function () {
|
|
$contextMissing = new TemplateContext(
|
|
template: 'page/test',
|
|
data: [],
|
|
metadata: []
|
|
);
|
|
$contextNull = new TemplateContext(
|
|
template: 'page/test',
|
|
data: [],
|
|
metadata: ['fragment_id' => null]
|
|
);
|
|
|
|
expect($this->strategy->shouldCache($contextMissing))->toBeFalse();
|
|
expect($this->strategy->shouldCache($contextNull))->toBeFalse();
|
|
});
|
|
|
|
it('treats empty string fragment_id differently from null', function () {
|
|
$context = new TemplateContext(
|
|
template: 'page/test',
|
|
data: [],
|
|
metadata: ['fragment_id' => '']
|
|
);
|
|
|
|
// isset() returns true for empty string
|
|
expect($this->strategy->shouldCache($context))->toBeTrue();
|
|
|
|
$key = $this->strategy->generateKey($context);
|
|
expect((string) $key)->toStartWith('fragment:page/test::');
|
|
});
|
|
});
|
|
});
|