From e024ad9a22ed1e9e8a760465c1cea651c3a0d711 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Tue, 28 Apr 2026 10:56:20 +0600 Subject: [PATCH 1/2] htmx support: --- .../Commands/FrontendInstallCommand.php | 23 ++++++-- .../Commands/FrontendUninstallCommand.php | 1 + .../stubs/frontend/entries/bootstrap.js.stub | 2 +- .../stubs/frontend/entries/bootstrap.ts.stub | 2 +- tests/Console/FrontendInstallCommandTest.php | 54 +++++++++++++++++-- 5 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/Phaseolies/Console/Commands/FrontendInstallCommand.php b/src/Phaseolies/Console/Commands/FrontendInstallCommand.php index bed3439..a5aa6c2 100644 --- a/src/Phaseolies/Console/Commands/FrontendInstallCommand.php +++ b/src/Phaseolies/Console/Commands/FrontendInstallCommand.php @@ -55,6 +55,11 @@ public function handle(): int in_array($framework, ['react', 'vue', 'svelte'], true) ); + $htmx = $this->confirm( + 'Do you want HTMX support?', + false + ); + $installDependencies = (bool) ($this->option('install') ?: $this->confirm( 'Do you want to install frontend dependencies now?', false @@ -68,6 +73,7 @@ public function handle(): int 'cssStack' => $cssStack, 'framework' => $framework, 'typescript' => $typescript, + 'htmx' => $htmx, 'installDependencies' => $installDependencies, 'packageManager' => $packageManager, 'force' => (bool) $this->option('force'), @@ -141,14 +147,15 @@ protected function writeFrontendFiles(array $options, array &$state): void { $framework = $options['framework']; $typescript = $options['typescript']; + $htmx = $options['htmx']; $cssStack = $options['cssStack']; $force = $options['force']; $files = [ - base_path('package.json') => $this->packageJson($framework, $cssStack, $typescript), + base_path('package.json') => $this->packageJson($framework, $cssStack, $typescript, $htmx), base_path('vite.config.js') => $this->viteConfig($framework, $typescript), client_path('css/app.css') => $this->clientCss($cssStack), - client_path('js/' . $this->bootstrapFilename($typescript)) => $this->bootstrapFile($cssStack, $typescript), + client_path('js/' . $this->bootstrapFilename($typescript)) => $this->bootstrapFile($cssStack, $typescript, $htmx), client_path('js/' . $this->entryFilename($framework, $typescript)) => $this->entryFile($framework, $cssStack, $typescript), base_path('resources/views/layouts/app.odo.php') => $this->appLayoutView($framework, $typescript), base_path('resources/views/welcome.odo.php') => $this->welcomeView($framework, $typescript), @@ -335,7 +342,7 @@ protected function entryFilename(string $framework, bool $typescript): string * @param bool $typescript * @return string */ - protected function packageJson(string $framework, string $cssStack, bool $typescript): string + protected function packageJson(string $framework, string $cssStack, bool $typescript, bool $htmx): string { $dependencies = []; $devDependencies = [ @@ -362,6 +369,10 @@ protected function packageJson(string $framework, string $cssStack, bool $typesc $dependencies['bootstrap'] = '^5.3.3'; } + if ($htmx) { + $dependencies['htmx.org'] = '^2.0.9'; + } + if ($cssStack === 'tailwind') { $devDependencies['postcss'] = '^8.4.49'; $devDependencies['tailwindcss'] = '^4.0.0'; @@ -477,16 +488,20 @@ protected function frameworkComponentFiles(string $framework, bool $typescript): * @param bool $typescript * @return string */ - protected function bootstrapFile(string $cssStack, bool $typescript): string + protected function bootstrapFile(string $cssStack, bool $typescript, bool $htmx): string { $bootstrapVendorImport = $cssStack === 'bootstrap' ? "import 'bootstrap/dist/js/bootstrap.bundle.min.js';\n" : ''; + $htmxImport = $htmx + ? "import 'htmx.org';\n" + : ''; return $this->renderFrontendStub( 'entries/' . ($typescript ? 'bootstrap.ts.stub' : 'bootstrap.js.stub'), [ 'bootstrapVendorImport' => $bootstrapVendorImport, + 'htmxImport' => $htmxImport, ] ); } diff --git a/src/Phaseolies/Console/Commands/FrontendUninstallCommand.php b/src/Phaseolies/Console/Commands/FrontendUninstallCommand.php index b348549..2ea6f3a 100644 --- a/src/Phaseolies/Console/Commands/FrontendUninstallCommand.php +++ b/src/Phaseolies/Console/Commands/FrontendUninstallCommand.php @@ -320,6 +320,7 @@ protected function isGeneratedPackageJson(string $contents): bool 'vue', 'svelte', 'bootstrap', + 'htmx.org', 'postcss', 'tailwindcss', '@tailwindcss/postcss', diff --git a/src/Phaseolies/Console/Commands/stubs/frontend/entries/bootstrap.js.stub b/src/Phaseolies/Console/Commands/stubs/frontend/entries/bootstrap.js.stub index df24346..851c639 100644 --- a/src/Phaseolies/Console/Commands/stubs/frontend/entries/bootstrap.js.stub +++ b/src/Phaseolies/Console/Commands/stubs/frontend/entries/bootstrap.js.stub @@ -1,4 +1,4 @@ -{{ bootstrapVendorImport }}const csrfToken = document +{{ bootstrapVendorImport }}{{ htmxImport }}const csrfToken = document .querySelector('meta[name="csrf-token"]') ?.getAttribute('content'); diff --git a/src/Phaseolies/Console/Commands/stubs/frontend/entries/bootstrap.ts.stub b/src/Phaseolies/Console/Commands/stubs/frontend/entries/bootstrap.ts.stub index ab834fc..244ffb3 100644 --- a/src/Phaseolies/Console/Commands/stubs/frontend/entries/bootstrap.ts.stub +++ b/src/Phaseolies/Console/Commands/stubs/frontend/entries/bootstrap.ts.stub @@ -1,4 +1,4 @@ -{{ bootstrapVendorImport }}const csrfToken = document +{{ bootstrapVendorImport }}{{ htmxImport }}const csrfToken = document .querySelector('meta[name="csrf-token"]') ?.getAttribute('content'); diff --git a/tests/Console/FrontendInstallCommandTest.php b/tests/Console/FrontendInstallCommandTest.php index e7ce21a..43bcfba 100644 --- a/tests/Console/FrontendInstallCommandTest.php +++ b/tests/Console/FrontendInstallCommandTest.php @@ -97,7 +97,7 @@ public function testClientBootstrapExposesCsrfHeaderFromMetaToken(): void $command = new FrontendInstallCommand(); $method = new \ReflectionMethod($command, 'bootstrapFile'); - $bootstrap = $method->invoke($command, 'bootstrap', true); + $bootstrap = $method->invoke($command, 'bootstrap', true, false); $this->assertStringContainsString("meta[name=\"csrf-token\"]", $bootstrap); $this->assertStringContainsString("headers: csrfToken ? { 'X-CSRF-TOKEN': csrfToken } : {}", $bootstrap); @@ -105,6 +105,7 @@ public function testClientBootstrapExposesCsrfHeaderFromMetaToken(): void $this->assertStringNotContainsString('XMLHttpRequest.prototype', $bootstrap); $this->assertStringNotContainsString('X-Requested-With', $bootstrap); $this->assertStringContainsString("import 'bootstrap/dist/js/bootstrap.bundle.min.js';", $bootstrap); + $this->assertStringNotContainsString("import 'htmx.org';", $bootstrap); $this->assertStringContainsString('declare global', $bootstrap); } @@ -113,7 +114,7 @@ public function testJavascriptBootstrapStubAvoidsTypescriptOnlySyntax(): void $command = new FrontendInstallCommand(); $method = new \ReflectionMethod($command, 'bootstrapFile'); - $bootstrap = $method->invoke($command, 'bootstrap', false); + $bootstrap = $method->invoke($command, 'bootstrap', false, false); $this->assertStringContainsString("meta[name=\"csrf-token\"]", $bootstrap); $this->assertStringContainsString("headers: csrfToken ? { 'X-CSRF-TOKEN': csrfToken } : {}", $bootstrap); @@ -121,17 +122,37 @@ public function testJavascriptBootstrapStubAvoidsTypescriptOnlySyntax(): void $this->assertStringNotContainsString('declare global', $bootstrap); } + public function testBootstrapStubCanImportHtmxSupport(): void + { + $command = new FrontendInstallCommand(); + $method = new \ReflectionMethod($command, 'bootstrapFile'); + + $bootstrap = $method->invoke($command, 'none', false, true); + + $this->assertStringContainsString("import 'htmx.org';", $bootstrap); + } + public function testVuePackageJsonUsesViteSevenCompatiblePluginVersion(): void { $command = new FrontendInstallCommand(); $method = new \ReflectionMethod($command, 'packageJson'); - $packageJson = $method->invoke($command, 'vue', 'tailwind', true); + $packageJson = $method->invoke($command, 'vue', 'tailwind', true, false); $this->assertStringContainsString('"vite": "^7.0.0"', $packageJson); $this->assertStringContainsString('"@vitejs/plugin-vue": "^6.0.0"', $packageJson); } + public function testPackageJsonCanIncludeHtmxDependency(): void + { + $command = new FrontendInstallCommand(); + $method = new \ReflectionMethod($command, 'packageJson'); + + $packageJson = $method->invoke($command, 'vanilla', 'none', false, true); + + $this->assertStringContainsString('"htmx.org": "^2.0.4"', $packageJson); + } + public function testTailwindCssIncludesExplicitSourceDirectives(): void { $command = new FrontendInstallCommand(); @@ -248,6 +269,33 @@ public function testFrontendUninstallDetectsGeneratedPackageJsonTemplate(): void $this->assertFalse($method->invoke($command, $custom)); } + public function testFrontendUninstallDetectsGeneratedPackageJsonTemplateWithHtmx(): void + { + $command = new FrontendUninstallCommand(); + $method = new \ReflectionMethod($command, 'isGeneratedPackageJson'); + + $generated = <<<'JSON' +{ + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "bootstrap": "^5.3.3", + "htmx.org": "^2.0.4" + }, + "devDependencies": { + "vite": "^7.0.0" + } +} +JSON; + + $this->assertTrue($method->invoke($command, $generated)); + } + private function normalizePath(string $path): string { return str_replace(['\\', '/'], DIRECTORY_SEPARATOR, $path); From 7ecd201423e86669bbb3ace8c641ce6d86fadde4 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Tue, 28 Apr 2026 13:40:25 +0600 Subject: [PATCH 2/2] Improve CacheStore correctness, add cache() helper --- src/Phaseolies/Cache/CacheStore.php | 308 +++++++++++++----- .../Cache/IncrementableCacheInterface.php | 27 ++ src/Phaseolies/Cache/RateLimiter.php | 42 ++- src/Phaseolies/Helpers/helpers.php | 24 ++ .../Providers/CacheServiceProvider.php | 3 + .../Providers/RateLimiterServiceProvider.php | 4 +- tests/CacheStoreTest.php | 145 ++++++++- tests/Requests/RateLimiterTest.php | 189 ++++++++--- 8 files changed, 592 insertions(+), 150 deletions(-) create mode 100644 src/Phaseolies/Cache/IncrementableCacheInterface.php diff --git a/src/Phaseolies/Cache/CacheStore.php b/src/Phaseolies/Cache/CacheStore.php index 246093c..3e3d0e8 100644 --- a/src/Phaseolies/Cache/CacheStore.php +++ b/src/Phaseolies/Cache/CacheStore.php @@ -2,18 +2,19 @@ namespace Phaseolies\Cache; +use DateTime; use Symfony\Component\Cache\Adapter\AdapterInterface; -use Psr\SimpleCache\CacheInterface; use Phaseolies\Cache\Lock\AtomicLock; +use Symfony\Contracts\Cache\CacheInterface as ContractsCacheInterface; -class CacheStore implements CacheInterface +class CacheStore implements IncrementableCacheInterface { /** * The cache adapter instance. * * @var AdapterInterface */ - protected $adapter; + protected AdapterInterface $adapter; /** * The cache prefix @@ -32,7 +33,7 @@ class CacheStore implements CacheInterface public function __construct(AdapterInterface $adapter, ?string $prefix = null) { $this->adapter = $adapter; - $this->prefix = $prefix ?? config('caching.prefix'); + $this->prefix = (string) ($prefix ?? config('caching.prefix')); } /** @@ -46,6 +47,19 @@ protected function prefixedKey(string $key): string return $this->prefix . $key; } + /** + * Validate a cache key and return the prefixed adapter key. + * + * @param mixed $key + * @return string + */ + protected function prefixedValidatedKey($key): string + { + $key = $this->normalizeKey($key); + + return $this->prefixedKey($key); + } + /** * Get the cache data by key * @@ -56,8 +70,7 @@ protected function prefixedKey(string $key): string #[\Override] public function get($key, $default = null): mixed { - $key = $this->prefixedKey($key); - $this->validateKey($key); + $key = $this->prefixedValidatedKey($key); $item = $this->adapter->getItem($key); return $item->isHit() ? $item->get() : $default; @@ -74,8 +87,7 @@ public function get($key, $default = null): mixed #[\Override] public function set($key, $value, $ttl = null): bool { - $key = $this->prefixedKey($key); - $this->validateKey($key); + $key = $this->prefixedValidatedKey($key); $item = $this->adapter->getItem($key); $item->set($value); @@ -96,9 +108,7 @@ public function set($key, $value, $ttl = null): bool #[\Override] public function delete($key): bool { - $key = $this->prefixedKey($key); - - $this->validateKey($key); + $key = $this->prefixedValidatedKey($key); return $this->adapter->deleteItem($key); } @@ -111,7 +121,7 @@ public function delete($key): bool #[\Override] public function clear(): bool { - return $this->adapter->clear(); + return $this->adapter->clear($this->prefix); } /** @@ -124,14 +134,14 @@ public function clear(): bool #[\Override] public function getMultiple($keys, $default = null): iterable { - if (!is_iterable($keys)) { - throw new \InvalidArgumentException('Keys must be an array or traversable'); - } + $keys = $this->normalizeKeyList($keys); + $prefixedKeys = []; - $prefixedKeys = array_map(fn($key) => $this->prefixedKey($key), (array)$keys); - $validatedKeys = $this->validateKeys($prefixedKeys); + foreach ($keys as $key) { + $prefixedKeys[] = $this->prefixedValidatedKey($key); + } - $items = $this->adapter->getItems($validatedKeys); + $items = $this->adapter->getItems($prefixedKeys); $results = []; foreach ($items as $key => $item) { @@ -139,6 +149,10 @@ public function getMultiple($keys, $default = null): iterable $results[$originalKey] = $item->isHit() ? $item->get() : $default; } + foreach ($keys as $key) { + $results[$key] ??= $default; + } + return $results; } @@ -152,16 +166,13 @@ public function getMultiple($keys, $default = null): iterable #[\Override] public function setMultiple($values, $ttl = null): bool { - if (!is_iterable($values)) { - throw new \InvalidArgumentException('Values must be an array or traversable'); - } + $values = $this->normalizeValueMap($values); $success = true; $ttl = $this->convertTtlToSeconds($ttl); foreach ($values as $key => $value) { - $prefixedKey = $this->prefixedKey($key); - $this->validateKey($prefixedKey); + $prefixedKey = $this->prefixedValidatedKey($key); $item = $this->adapter->getItem($prefixedKey); $item->set($value); @@ -184,14 +195,14 @@ public function setMultiple($values, $ttl = null): bool #[\Override] public function deleteMultiple($keys): bool { - if (!is_iterable($keys)) { - throw new \InvalidArgumentException('Keys must be an array or traversable'); - } + $keys = $this->normalizeKeyList($keys); + $prefixedKeys = []; - $prefixedKeys = array_map(fn($key) => $this->prefixedKey($key), (array)$keys); - $validatedKeys = $this->validateKeys($prefixedKeys); + foreach ($keys as $key) { + $prefixedKeys[] = $this->prefixedValidatedKey($key); + } - return $this->adapter->deleteItems($validatedKeys); + return $this->adapter->deleteItems($prefixedKeys); } /** @@ -203,9 +214,7 @@ public function deleteMultiple($keys): bool #[\Override] public function has($key): bool { - $key = $this->prefixedKey($key); - - $this->validateKey($key); + $key = $this->prefixedValidatedKey($key); return $this->adapter->hasItem($key); } @@ -219,25 +228,25 @@ public function has($key): bool */ public function increment($key, $value = 1): int|bool { - $key = $this->prefixedKey($key); - $this->validateKey($key); - $item = $this->adapter->getItem($key); + $key = $this->prefixedValidatedKey($key); - if (!$item->isHit()) { - return false; - } + return $this->withKeyLock($key, function () use ($key, $value) { + $item = $this->adapter->getItem($key); - $current = (int)$item->get(); - $new = $current + $value; - $item->set($new); + if (!$item->isHit()) { + return false; + } - // Preserve existing expiration - if ($item->getMetadata()['expiry'] ?? null) { - $item->expiresAt(\DateTime::createFromFormat('U', $item->getMetadata()['expiry'])); - } + $current = (int) $item->get(); + $new = $current + $value; + $item->set($new); + + if ($item->getMetadata()['expiry'] ?? null) { + $item->expiresAt(DateTime::createFromFormat('U', (string) $item->getMetadata()['expiry'])); + } - $this->adapter->save($item); - return $new; + return $this->adapter->save($item) ? $new : false; + }); } /** @@ -249,25 +258,25 @@ public function increment($key, $value = 1): int|bool */ public function decrement($key, $value = 1): int|bool { - $key = $this->prefixedKey($key); - $this->validateKey($key); - $item = $this->adapter->getItem($key); + $key = $this->prefixedValidatedKey($key); - if (!$item->isHit()) { - return false; - } + return $this->withKeyLock($key, function () use ($key, $value) { + $item = $this->adapter->getItem($key); + + if (!$item->isHit()) { + return false; + } - $current = (int)$item->get(); - $new = $current - $value; - $item->set($new); + $current = (int) $item->get(); + $new = $current - $value; + $item->set($new); - // Preserve existing expiration - if ($item->getMetadata()['expiry'] ?? null) { - $item->expiresAt(\DateTime::createFromFormat('U', $item->getMetadata()['expiry'])); - } + if ($item->getMetadata()['expiry'] ?? null) { + $item->expiresAt(DateTime::createFromFormat('U', (string) $item->getMetadata()['expiry'])); + } - $this->adapter->save($item); - return $new; + return $this->adapter->save($item) ? $new : false; + }); } /** @@ -280,21 +289,39 @@ public function decrement($key, $value = 1): int|bool */ public function add($key, $value, $ttl = null): bool { - $key = $this->prefixedKey($key); - $this->validateKey($key); + $key = $this->prefixedValidatedKey($key); + $seconds = $this->convertTtlToSeconds($ttl); - if ($this->adapter->hasItem($key)) { - return false; - } + if ($this->adapter instanceof ContractsCacheInterface) { + $created = false; - $item = $this->adapter->getItem($key); - $item->set($value); + $this->adapter->get($key, function ($item) use (&$created, $value, $seconds) { + $created = true; - if ($ttl !== null) { - $item->expiresAfter($this->convertTtlToSeconds($ttl)); + if ($seconds !== null) { + $item->expiresAfter($seconds); + } + + return $value; + }); + + return $created; } - return $this->adapter->save($item); + return $this->withKeyLock($key, function () use ($key, $value, $seconds) { + if ($this->adapter->hasItem($key)) { + return false; + } + + $item = $this->adapter->getItem($key); + $item->set($value); + + if ($seconds !== null) { + $item->expiresAfter($seconds); + } + + return $this->adapter->save($item); + }); } /** @@ -306,8 +333,7 @@ public function add($key, $value, $ttl = null): bool */ public function forever($key, $value): bool { - $key = $this->prefixedKey($key); - $this->validateKey($key); + $key = $this->prefixedValidatedKey($key); $item = $this->adapter->getItem($key); $item->set($value); @@ -324,8 +350,7 @@ public function forever($key, $value): bool */ public function forget($key): bool { - $key = $this->prefixedKey($key); - $this->validateKey($key); + $key = $this->prefixedValidatedKey($key); if (!$this->adapter->hasItem($key)) { return false; @@ -350,7 +375,11 @@ protected function validateKey($key): void )); } - if (preg_match('/[{}()\/\\\\@]/', $key)) { + if ($key === '') { + throw new \InvalidArgumentException('Cache key must not be empty'); + } + + if (preg_match('/[{}()\/\\\\@\:]/', $key)) { throw new \InvalidArgumentException(sprintf( 'Invalid key: "%s". The key contains one or more characters reserved for future extension', $key @@ -394,6 +423,119 @@ protected function convertTtlToSeconds($ttl): ?int return (int) $ttl; } + /** + * Normalize an individual cache key. + * + * @param mixed $key + * @return string + */ + protected function normalizeKey($key): string + { + $this->validateKey($key); + + return $key; + } + + /** + * Normalize an iterable of cache keys into a sequential array of strings. + * + * @param mixed $keys + * @return array + */ + protected function normalizeKeyList($keys): array + { + if ($keys instanceof \Traversable) { + $keys = iterator_to_array($keys, false); + } elseif (!is_array($keys)) { + throw new \InvalidArgumentException('Keys must be an array or traversable'); + } + + $normalized = []; + + foreach ($keys as $key) { + $normalized[] = $this->normalizeKey($key); + } + + return $normalized; + } + + /** + * Normalize an iterable of key/value pairs into an array. + * + * @param mixed $values + * @return array + */ + protected function normalizeValueMap($values): array + { + if ($values instanceof \Traversable) { + $values = iterator_to_array($values, true); + } elseif (!is_array($values)) { + throw new \InvalidArgumentException('Values must be an array or traversable'); + } + + $normalized = []; + + foreach ($values as $key => $value) { + $normalized[$this->normalizeKey($key)] = $value; + } + + return $normalized; + } + + /** + * Execute a callback while holding a process-local lock for the key. + * + * @template T + * @param string $key + * @param \Closure(): T $callback + * @return T + */ + protected function withKeyLock(string $key, \Closure $callback): mixed + { + $directory = $this->lockDirectory(); + + if (!is_dir($directory)) { + mkdir($directory, 0777, true); + } + + $path = $directory . DIRECTORY_SEPARATOR . sha1($key) . '.lock'; + $handle = fopen($path, 'c+'); + + if ($handle === false) { + throw new \RuntimeException(sprintf('Unable to open cache lock file: %s', $path)); + } + + try { + if (!flock($handle, LOCK_EX)) { + throw new \RuntimeException(sprintf('Unable to acquire cache lock: %s', $path)); + } + + return $callback(); + } finally { + flock($handle, LOCK_UN); + fclose($handle); + } + } + + /** + * Resolve the directory used for process-local cache locks. + * + * @return string + */ + protected function lockDirectory(): string + { + if (function_exists('storage_path')) { + try { + return storage_path('framework/cache/locks'); + } catch (\Throwable) { + // Fall back to the system temp directory when the application container + // is not fully bootstrapped, e.g. in isolated unit tests. + } + } + + return sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'doppar-cache-locks'; + } + /** * Get the current adapter * @@ -414,10 +556,8 @@ public function getAdapter(): AdapterInterface */ public function stash(string $key, $ttl, \Closure $callback): mixed { - $value = $this->get($key); - - if (!is_null($value)) { - return $value; + if ($this->has($key)) { + return $this->get($key); } $value = $callback(); @@ -436,10 +576,8 @@ public function stash(string $key, $ttl, \Closure $callback): mixed */ public function stashForever(string $key, \Closure $callback): mixed { - $value = $this->get($key); - - if (!is_null($value)) { - return $value; + if ($this->has($key)) { + return $this->get($key); } $value = $callback(); diff --git a/src/Phaseolies/Cache/IncrementableCacheInterface.php b/src/Phaseolies/Cache/IncrementableCacheInterface.php new file mode 100644 index 0000000..3184acb --- /dev/null +++ b/src/Phaseolies/Cache/IncrementableCacheInterface.php @@ -0,0 +1,27 @@ +cache = $cache; } @@ -39,16 +38,21 @@ public function attempt(string $key, int $maxAttempts, int $decaySeconds): RateL { $timerKey = $key . '_timer'; $now = time(); + $resetAt = $now + $decaySeconds; try { - if (!$this->cache->has($key)) { - $this->cache->set($key, 1, $decaySeconds); - $this->cache->set($timerKey, $now + $decaySeconds, $decaySeconds); + if ($this->cache->add($key, 1, $decaySeconds)) { + $this->cache->add($timerKey, $resetAt, $decaySeconds); $hits = 1; } else { $hits = $this->cache->increment($key); - if ($hits <= $maxAttempts) { - $this->cache->set($timerKey, $now + $decaySeconds, $decaySeconds); + + if ($hits === false) { + $this->cache->set($key, 1, $decaySeconds); + $this->cache->set($timerKey, $resetAt, $decaySeconds); + $hits = 1; + } elseif ($hits <= $maxAttempts) { + $this->cache->set($timerKey, $resetAt, $decaySeconds); } } @@ -127,17 +131,25 @@ public function hit(string $key, int $decaySeconds): int { $timerKey = $key . '_timer'; $now = time(); + $resetAt = $now + $decaySeconds; try { - if (!$this->cache->has($key)) { + if ($this->cache->add($key, 1, $decaySeconds)) { + $this->cache->add($timerKey, $resetAt, $decaySeconds); + return 1; + } + + $hits = $this->cache->increment($key); + + if ($hits === false) { $this->cache->set($key, 1, $decaySeconds); - $this->cache->set($timerKey, $now + $decaySeconds, $decaySeconds); + $this->cache->set($timerKey, $resetAt, $decaySeconds); return 1; - } else { - $hits = $this->cache->increment($key); - $this->cache->set($timerKey, $now + $decaySeconds, $decaySeconds); - return $hits; } + + $this->cache->set($timerKey, $resetAt, $decaySeconds); + + return $hits; } catch (InvalidArgumentException $e) { throw $e; } diff --git a/src/Phaseolies/Helpers/helpers.php b/src/Phaseolies/Helpers/helpers.php index ff264d8..5da7c74 100644 --- a/src/Phaseolies/Helpers/helpers.php +++ b/src/Phaseolies/Helpers/helpers.php @@ -343,6 +343,30 @@ function config(string|array $key, ?string $default = null): mixed } } +if (!function_exists('cache')) { + /** + * Get the cache store instance, retrieve an item, or store multiple items + * + * @param string|array|null $key + * @param mixed $default + * @return mixed + */ + function cache(string|array|null $key = null, mixed $default = null): mixed + { + $store = app('cache'); + + if (is_null($key)) { + return $store; + } + + if (is_array($key)) { + return $store->setMultiple($key, $default); + } + + return $store->get($key, $default); + } +} + if (!function_exists('is_auth')) { /** * Check if the user is authenticated. diff --git a/src/Phaseolies/Providers/CacheServiceProvider.php b/src/Phaseolies/Providers/CacheServiceProvider.php index a464729..ce17c84 100644 --- a/src/Phaseolies/Providers/CacheServiceProvider.php +++ b/src/Phaseolies/Providers/CacheServiceProvider.php @@ -9,6 +9,7 @@ use Psr\SimpleCache\CacheInterface; use Phaseolies\Providers\ServiceProvider; use Phaseolies\Cache\CacheStore; +use Phaseolies\Cache\IncrementableCacheInterface; class CacheServiceProvider extends ServiceProvider { @@ -26,6 +27,8 @@ public function register(): void { $adapter = $this->createAdapter(config('caching.default', 'file')); $cacheStore = new CacheStore($adapter, config('caching.prefix')); + $this->app->singleton(CacheStore::class, fn() => $cacheStore); + $this->app->singleton(IncrementableCacheInterface::class, fn() => $cacheStore); $this->app->singleton(CacheInterface::class, fn() => $cacheStore); $this->app->singleton('cache', fn() => $cacheStore); } diff --git a/src/Phaseolies/Providers/RateLimiterServiceProvider.php b/src/Phaseolies/Providers/RateLimiterServiceProvider.php index 30f02de..bc5d206 100644 --- a/src/Phaseolies/Providers/RateLimiterServiceProvider.php +++ b/src/Phaseolies/Providers/RateLimiterServiceProvider.php @@ -2,8 +2,8 @@ namespace Phaseolies\Providers; -use Psr\SimpleCache\CacheInterface; use Phaseolies\Providers\ServiceProvider; +use Phaseolies\Cache\IncrementableCacheInterface; use Phaseolies\Cache\RateLimiter; class RateLimiterServiceProvider extends ServiceProvider @@ -16,7 +16,7 @@ class RateLimiterServiceProvider extends ServiceProvider public function register(): void { $this->app->singleton(RateLimiter::class, function ($app) { - return new RateLimiter($app->make(CacheInterface::class)); + return new RateLimiter($app->make(IncrementableCacheInterface::class)); }); } diff --git a/tests/CacheStoreTest.php b/tests/CacheStoreTest.php index 69323e3..b9dfe15 100644 --- a/tests/CacheStoreTest.php +++ b/tests/CacheStoreTest.php @@ -2,6 +2,8 @@ namespace Tests\Unit; +use ArrayIterator; +use Phaseolies\DI\Container; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Phaseolies\Cache\Lock\AtomicLock; use Phaseolies\Cache\CacheStore; @@ -17,6 +19,14 @@ protected function setUp(): void { $this->adapter = new ArrayAdapter(); $this->cache = new CacheStore($this->adapter, 'test_'); + $container = new Container(); + $container->instance('cache', $this->cache); + Container::setInstance($container); + } + + protected function tearDown(): void + { + Container::forgetInstance(); } public function testGetReturnsDefaultForMissingKey() @@ -25,6 +35,30 @@ public function testGetReturnsDefaultForMissingKey() $this->assertEquals('default_value', $result); } + public function testCacheHelperReturnsBoundStore() + { + $this->assertSame($this->cache, cache()); + } + + public function testCacheHelperGetsValues() + { + $this->cache->set('helper_key', 'helper_value'); + + $this->assertSame('helper_value', cache('helper_key')); + $this->assertSame('fallback', cache('missing_helper_key', 'fallback')); + } + + public function testCacheHelperStoresMultipleValues() + { + $this->assertTrue(cache([ + 'helper_multi_1' => 'value1', + 'helper_multi_2' => 'value2', + ], 60)); + + $this->assertSame('value1', $this->cache->get('helper_multi_1')); + $this->assertSame('value2', $this->cache->get('helper_multi_2')); + } + public function testSetAndGet() { $this->assertTrue($this->cache->set('test_key', 'test_value')); @@ -60,6 +94,20 @@ public function testClear() $this->assertFalse($this->cache->has('key2')); } + public function testClearOnlyRemovesItemsForCurrentPrefix() + { + $sharedAdapter = new ArrayAdapter(); + $primary = new CacheStore($sharedAdapter, 'alpha_'); + $secondary = new CacheStore($sharedAdapter, 'beta_'); + + $primary->set('shared', 'alpha'); + $secondary->set('shared', 'beta'); + + $this->assertTrue($primary->clear()); + $this->assertNull($primary->get('shared')); + $this->assertSame('beta', $secondary->get('shared')); + } + public function testGetMultiple() { $this->cache->set('key1', 'value1'); @@ -73,6 +121,20 @@ public function testGetMultiple() ], $result); } + public function testGetMultipleAcceptsTraversableKeys() + { + $this->cache->set('key1', 'value1'); + $this->cache->set('key2', 'value2'); + + $result = $this->cache->getMultiple(new ArrayIterator(['key1', 'key2', 'key3']), 'default'); + + $this->assertEquals([ + 'key1' => 'value1', + 'key2' => 'value2', + 'key3' => 'default', + ], $result); + } + public function testSetMultiple() { $values = [ @@ -85,6 +147,18 @@ public function testSetMultiple() $this->assertEquals('value2', $this->cache->get('multi2')); } + public function testSetMultipleAcceptsTraversableValues() + { + $values = new ArrayIterator([ + 'multi1' => 'value1', + 'multi2' => 'value2', + ]); + + $this->assertTrue($this->cache->setMultiple($values)); + $this->assertEquals('value1', $this->cache->get('multi1')); + $this->assertEquals('value2', $this->cache->get('multi2')); + } + public function testDeleteMultiple() { $this->cache->set('key1', 'value1'); @@ -97,6 +171,18 @@ public function testDeleteMultiple() $this->assertTrue($this->cache->has('key3')); } + public function testDeleteMultipleAcceptsTraversableKeys() + { + $this->cache->set('key1', 'value1'); + $this->cache->set('key2', 'value2'); + $this->cache->set('key3', 'value3'); + + $this->assertTrue($this->cache->deleteMultiple(new ArrayIterator(['key1', 'key2']))); + $this->assertFalse($this->cache->has('key1')); + $this->assertFalse($this->cache->has('key2')); + $this->assertTrue($this->cache->has('key3')); + } + public function testHas() { $this->assertFalse($this->cache->has('some_key')); @@ -200,6 +286,48 @@ public function testStashForever() $this->assertEquals('forever_value', $this->cache->get('forever_stash')); } + public function testStashDoesNotRecomputeCachedNullValues() + { + $calls = 0; + + $first = $this->cache->stash('nullable', 60, function () use (&$calls) { + $calls++; + + return null; + }); + + $second = $this->cache->stash('nullable', 60, function () use (&$calls) { + $calls++; + + return 'recomputed'; + }); + + $this->assertNull($first); + $this->assertNull($second); + $this->assertSame(1, $calls); + } + + public function testStashForeverDoesNotRecomputeCachedNullValues() + { + $calls = 0; + + $first = $this->cache->stashForever('nullable_forever', function () use (&$calls) { + $calls++; + + return null; + }); + + $second = $this->cache->stashForever('nullable_forever', function () use (&$calls) { + $calls++; + + return 'recomputed'; + }); + + $this->assertNull($first); + $this->assertNull($second); + $this->assertSame(1, $calls); + } + public function testStashWhen() { $callback = function () { @@ -223,6 +351,18 @@ public function testInvalidKeyThrowsException() $this->cache->get('invalid/key'); } + public function testEmptyKeyThrowsException() + { + $this->expectException(\InvalidArgumentException::class); + $this->cache->get(''); + } + + public function testReservedColonKeyThrowsException() + { + $this->expectException(\InvalidArgumentException::class); + $this->cache->get('invalid:key'); + } + public function testPrefixIsApplied() { $this->cache->set('prefixed', 'value'); @@ -509,11 +649,12 @@ public function testAtomicLockBlockWaitsUntilAvailable() // Release the first lock after a short delay in another thread simulation // Since we can’t sleep in test too long, we manually simulate expiry sleep(1); - $this->adapter->deleteItem('lock_test'); + $this->adapter->deleteItem('test_lock_test'); $this->assertTrue($lock2->block(2)); - $this->assertFalse($this->adapter->hasItem('lock_test')); + $this->assertTrue($this->adapter->hasItem('test_lock_test')); $this->assertTrue($lock2->release()); + $this->assertFalse($this->adapter->hasItem('test_lock_test')); } public function testLockOwnerGeneration() diff --git a/tests/Requests/RateLimiterTest.php b/tests/Requests/RateLimiterTest.php index 04c908b..adb958f 100644 --- a/tests/Requests/RateLimiterTest.php +++ b/tests/Requests/RateLimiterTest.php @@ -2,22 +2,22 @@ namespace Tests\Unit\Requests; -use Phaseolies\Cache\RateLimiter; +use Phaseolies\Cache\IncrementableCacheInterface; use Phaseolies\Cache\RateLimit; -use Psr\SimpleCache\CacheInterface; -use Psr\SimpleCache\InvalidArgumentException; -use PHPUnit\Framework\TestCase; +use Phaseolies\Cache\RateLimiter; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; +use PHPUnit\Framework\TestCase; +use Psr\SimpleCache\InvalidArgumentException; #[AllowMockObjectsWithoutExpectations] class RateLimiterTest extends TestCase { - protected $cache; - protected $limiter; + protected IncrementableCacheInterface $cache; + protected RateLimiter $limiter; protected function setUp(): void { - $this->cache = $this->createMock(CacheInterface::class); + $this->cache = $this->createMock(IncrementableCacheInterface::class); $this->limiter = new RateLimiter($this->cache); } @@ -33,36 +33,31 @@ public function testAttemptWithNewKey() $decaySeconds = 60; $now = time(); - // Mock has() to return false for both key checks - $this->cache->expects($this->exactly(1)) - ->method('has') - ->willReturnCallback(function ($arg) use ($key) { - TestCase::assertTrue(in_array($arg, [$key, $key . '_timer'])); - }) - ->willReturn(false); - - // Mock set() calls $this->cache->expects($this->exactly(2)) - ->method('set') + ->method('add') ->willReturnCallback(function ($keyArg, $valueArg, $ttlArg) use ($key, $decaySeconds, $now) { static $call = 0; $call++; if ($call === 1) { - // First expected call: [$key, 1, $decaySeconds] - TestCase::assertEquals($key, $keyArg); - TestCase::assertEquals(1, $valueArg); - TestCase::assertEquals($decaySeconds, $ttlArg); - } elseif ($call === 2) { - // Second expected call: [$key . '_timer', $now + $decaySeconds, $decaySeconds] - TestCase::assertEquals($key . '_timer', $keyArg); - TestCase::assertEquals($now + $decaySeconds, $valueArg); - TestCase::assertEquals($decaySeconds, $ttlArg); + TestCase::assertSame($key, $keyArg); + TestCase::assertSame(1, $valueArg); + TestCase::assertSame($decaySeconds, $ttlArg); + + return true; } + TestCase::assertSame($key . '_timer', $keyArg); + TestCase::assertSame($now + $decaySeconds, $valueArg); + TestCase::assertSame($decaySeconds, $ttlArg); + return true; - }) - ->willReturn(true); + }); + + $this->cache->expects($this->once()) + ->method('get') + ->with($key . '_timer') + ->willReturn($now + $decaySeconds); $result = $this->limiter->attempt($key, $maxAttempts, $decaySeconds); @@ -72,6 +67,84 @@ public function testAttemptWithNewKey() $this->assertEquals($now + $decaySeconds, $result->resetAt); } + public function testAttemptWithExistingKeyUsesIncrementPath() + { + $key = 'test_key'; + $maxAttempts = 5; + $decaySeconds = 60; + $now = time(); + + $this->cache->expects($this->once()) + ->method('add') + ->with($key, 1, $decaySeconds) + ->willReturn(false); + + $this->cache->expects($this->once()) + ->method('increment') + ->with($key) + ->willReturn(3); + + $this->cache->expects($this->once()) + ->method('set') + ->with($key . '_timer', $now + $decaySeconds, $decaySeconds) + ->willReturn(true); + + $this->cache->expects($this->once()) + ->method('get') + ->with($key . '_timer') + ->willReturn($now + $decaySeconds); + + $result = $this->limiter->attempt($key, $maxAttempts, $decaySeconds); + + $this->assertSame(2, $result->remaining); + } + + public function testAttemptRecoversWhenIncrementReturnsFalse() + { + $key = 'test_key'; + $maxAttempts = 5; + $decaySeconds = 60; + $now = time(); + + $this->cache->expects($this->once()) + ->method('add') + ->with($key, 1, $decaySeconds) + ->willReturn(false); + + $this->cache->expects($this->once()) + ->method('increment') + ->with($key) + ->willReturn(false); + + $this->cache->expects($this->exactly(2)) + ->method('set') + ->willReturnCallback(function ($keyArg, $valueArg, $ttlArg) use ($key, $decaySeconds, $now) { + static $call = 0; + $call++; + + if ($call === 1) { + TestCase::assertSame($key, $keyArg); + TestCase::assertSame(1, $valueArg); + } else { + TestCase::assertSame($key . '_timer', $keyArg); + TestCase::assertSame($now + $decaySeconds, $valueArg); + } + + TestCase::assertSame($decaySeconds, $ttlArg); + + return true; + }); + + $this->cache->expects($this->once()) + ->method('get') + ->with($key . '_timer') + ->willReturn($now + $decaySeconds); + + $result = $this->limiter->attempt($key, $maxAttempts, $decaySeconds); + + $this->assertSame($maxAttempts - 1, $result->remaining); + } + public function testTooManyAttempts() { $key = 'test_key'; @@ -125,32 +198,55 @@ public function testHitWithNewKey() $decaySeconds = 60; $now = time(); - $this->cache->method('has') - ->with($key) - ->willReturn(false); - $this->cache->expects($this->exactly(2)) - ->method('set') + ->method('add') ->willReturnCallback(function ($keyArg, $valueArg, $ttlArg) use ($key, $decaySeconds, $now) { static $call = 0; $call++; if ($call === 1) { - TestCase::assertEquals($key, $keyArg); - TestCase::assertEquals(1, $valueArg); - TestCase::assertEquals($decaySeconds, $ttlArg); - } elseif ($call === 2) { - TestCase::assertEquals($key . '_timer', $keyArg); - TestCase::assertEquals($now + $decaySeconds, $valueArg); - TestCase::assertEquals($decaySeconds, $ttlArg); + TestCase::assertSame($key, $keyArg); + TestCase::assertSame(1, $valueArg); + TestCase::assertSame($decaySeconds, $ttlArg); + + return true; } - }) - ->willReturn(true); + + TestCase::assertSame($key . '_timer', $keyArg); + TestCase::assertSame($now + $decaySeconds, $valueArg); + TestCase::assertSame($decaySeconds, $ttlArg); + + return true; + }); $result = $this->limiter->hit($key, $decaySeconds); $this->assertEquals(1, $result); } + public function testHitWithExistingKeyUsesIncrement() + { + $key = 'test_key'; + $decaySeconds = 60; + $now = time(); + + $this->cache->expects($this->once()) + ->method('add') + ->with($key, 1, $decaySeconds) + ->willReturn(false); + + $this->cache->expects($this->once()) + ->method('increment') + ->with($key) + ->willReturn(4); + + $this->cache->expects($this->once()) + ->method('set') + ->with($key . '_timer', $now + $decaySeconds, $decaySeconds) + ->willReturn(true); + + $this->assertSame(4, $this->limiter->hit($key, $decaySeconds)); + } + public function testClear() { $key = 'test_key'; @@ -166,8 +262,9 @@ public function testClear() } elseif ($call === 2) { TestCase::assertEquals($key . '_timer', $keyArg); } - }) - ->willReturn(true); + + return true; + }); $this->limiter->clear($key); } @@ -214,8 +311,8 @@ public function testCacheExceptionHandling() $key = 'test_key'; $exception = new class extends \Exception implements InvalidArgumentException {}; - $this->cache->method('has') - ->with($key) + $this->cache->method('add') + ->with($key, 1, 60) ->willThrowException($exception); $this->expectException(InvalidArgumentException::class);