From 3e0a691e853af599e4cd702fdf0d15233d7c34a8 Mon Sep 17 00:00:00 2001 From: Jorni Cornelese Date: Wed, 18 Mar 2026 22:51:30 +0100 Subject: [PATCH] feat: add deleteCache() to purge cache without sending a request Adds a deleteCache() method to the HasCaching trait that allows deleting a cached response without going through the middleware pipeline. Useful for applications that only want to delete the cache without immediately retrieving fresh data. --- src/Traits/HasCaching.php | 32 +++++++ tests/Feature/DeleteCacheTest.php | 140 ++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 tests/Feature/DeleteCacheTest.php diff --git a/src/Traits/HasCaching.php b/src/Traits/HasCaching.php index f96ad3d..0c8fae7 100644 --- a/src/Traits/HasCaching.php +++ b/src/Traits/HasCaching.php @@ -5,9 +5,12 @@ namespace Saloon\CachePlugin\Traits; use Saloon\Enums\Method; +use Saloon\Http\Request; +use Saloon\Http\Connector; use Saloon\Enums\PipeOrder; use Saloon\Http\PendingRequest; use Saloon\CachePlugin\Contracts\Cacheable; +use Saloon\CachePlugin\Helpers\CacheKeyHelper; use Saloon\CachePlugin\Exceptions\HasCachingException; use Saloon\CachePlugin\Http\Middleware\CacheMiddleware; @@ -111,6 +114,35 @@ public function invalidateCache(): static return $this; } + /** + * Delete the cached response without sending a request. + * + * When used on a Request, pass the Connector. + * When used on a Connector, pass the Request. + * + * @throws \JsonException + */ + public function deleteCache(Connector|Request $counterpart): void + { + if ($this instanceof Request) { + $pendingRequest = $counterpart->createPendingRequest($this); + } else { + $pendingRequest = $this->createPendingRequest($counterpart); + } + + $request = $pendingRequest->getRequest(); + $connector = $pendingRequest->getConnector(); + + $cacheDriver = $request instanceof Cacheable + ? $request->resolveCacheDriver() + : $connector->resolveCacheDriver(); + + $rawKey = $this->cacheKey($pendingRequest) ?? CacheKeyHelper::create($pendingRequest); + $cacheKey = hash('sha256', $rawKey); + + $cacheDriver->delete($cacheKey); + } + /** * Define the cacheable methods that can be used * diff --git a/tests/Feature/DeleteCacheTest.php b/tests/Feature/DeleteCacheTest.php new file mode 100644 index 0000000..aa00162 --- /dev/null +++ b/tests/Feature/DeleteCacheTest.php @@ -0,0 +1,140 @@ +deleteDirectory('/'); +}); + +test('deleteCache removes a cached response without sending a request', function () { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + ]); + + $connector = new TestConnector; + $request = new CachedUserRequest; + + // Send and cache the response + $responseA = $connector->send($request, $mockClient); + expect($responseA->isCached())->toBeFalse(); + + // Verify it is cached + $responseB = $connector->send(new CachedUserRequest); + expect($responseB->isCached())->toBeTrue(); + + // Delete the cache without sending a request + $request = new CachedUserRequest; + $request->deleteCache($connector); + + // Now sending should result in a cache miss + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Michael']), + ]); + + $responseC = $connector->send(new CachedUserRequest, $mockClient); + expect($responseC->isCached())->toBeFalse(); + expect($responseC->json())->toEqual(['name' => 'Michael']); +}); + +test('deleteCache on an uncached request does not throw', function () { + $connector = new TestConnector; + $request = new CachedUserRequest; + + // Should not throw + $request->deleteCache($connector); + + expect(true)->toBeTrue(); +}); + +test('deleteCache uses a custom cacheKey override', function () use ($filesystem) { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + ]); + + $connector = new TestConnector; + + // Send and cache with the custom key + $connector->send(new CustomKeyCachedUserRequest, $mockClient); + + $hash = hash('sha256', 'Howdy!'); + expect($filesystem->fileExists($hash))->toBeTrue(); + + // Delete using the custom key + $request = new CustomKeyCachedUserRequest; + $request->deleteCache($connector); + + expect($filesystem->fileExists($hash))->toBeFalse(); +}); + +test('after deleteCache the next send fetches fresh and repopulates cache', function () { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + ]); + + $connector = new TestConnector; + + // Send and cache + $connector->send(new CachedUserRequest, $mockClient); + + // Confirm cached + $responseB = $connector->send(new CachedUserRequest); + expect($responseB->isCached())->toBeTrue(); + expect($responseB->json())->toEqual(['name' => 'Sam']); + + // Delete cache + $request = new CachedUserRequest; + $request->deleteCache($connector); + + // Send again - should be a fresh response + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Teo']), + ]); + + $responseC = $connector->send(new CachedUserRequest, $mockClient); + expect($responseC->isCached())->toBeFalse(); + expect($responseC->json())->toEqual(['name' => 'Teo']); + + // Verify the new response is cached + $responseD = $connector->send(new CachedUserRequest); + expect($responseD->isCached())->toBeTrue(); + expect($responseD->json())->toEqual(['name' => 'Teo']); +}); + +test('deleteCache works when called from the connector', function () { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + ]); + + $connector = new CachedConnector; + + // Send and cache + $connector->send(new CachedConnectorRequest, $mockClient); + + // Confirm cached + $responseB = $connector->send(new CachedConnectorRequest); + expect($responseB->isCached())->toBeTrue(); + + // Delete cache from the connector side + $connector->deleteCache(new CachedConnectorRequest); + + // Should be a cache miss now + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Michael']), + ]); + + $responseC = $connector->send(new CachedConnectorRequest, $mockClient); + expect($responseC->isCached())->toBeFalse(); + expect($responseC->json())->toEqual(['name' => 'Michael']); +});