From 429224121f2fd3b987d723c7b57972da9e05f5bb Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 13:02:08 +0800 Subject: [PATCH 1/5] fix(api-logs): log storefront API requests and relate them to the store or network Storefront routes are mounted under storefront/v1/, but isPublicRequest only matched v1/ since 39a6760c, so LogApiRequests never logged storefront writes. Treat storefront/v1/ as public too (internal int/ routes stay excluded), and add the storefront store or network public id to the log's related column so a request can be traced back to the key that made it. --- src/Jobs/LogApiRequest.php | 6 +++ src/Support/Http.php | 5 ++- tests/Unit/Http/MiddlewareContractsTest.php | 38 +++++++++++++++++++ tests/Unit/Jobs/LogApiRequestTest.php | 37 ++++++++++++++++++ .../Support/TemplateStringAndHttpTest.php | 3 ++ 5 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/Jobs/LogApiRequest.php b/src/Jobs/LogApiRequest.php index 2699eabf..27322b0c 100644 --- a/src/Jobs/LogApiRequest.php +++ b/src/Jobs/LogApiRequest.php @@ -82,6 +82,12 @@ public static function getPayload(Request $request, $response): array $related[] = Utils::get($content, 'id'); } + // If a storefront key made the request, push the store or network public id to related + $storefrontPublicId = session('storefront_store_public_id') ?? session('storefront_network_public_id'); + if ($storefrontPublicId) { + $related[] = $storefrontPublicId; + } + // Get api credential from session $apiCredential = session('api_credential'); diff --git a/src/Support/Http.php b/src/Support/Http.php index eded33da..485cc7cf 100644 --- a/src/Support/Http.php +++ b/src/Support/Http.php @@ -49,8 +49,9 @@ public static function isPublicRequest(?Request $request = null): bool $uri = ltrim((string) $route->uri(), '/'); - // Only log true public API: starts with "v1/" - return Str::startsWith($uri, 'v1/'); + // Only true public APIs: the core "v1/" API and the storefront "storefront/v1/" API. + // Internal routes such as "int/v1/..." and "storefront/int/v1/..." never match. + return Str::startsWith($uri, ['v1/', 'storefront/v1/']); } /** diff --git a/tests/Unit/Http/MiddlewareContractsTest.php b/tests/Unit/Http/MiddlewareContractsTest.php index 111efe5f..69c6d47d 100644 --- a/tests/Unit/Http/MiddlewareContractsTest.php +++ b/tests/Unit/Http/MiddlewareContractsTest.php @@ -660,6 +660,10 @@ function middleware_contracts_log_middleware(bool $enabled = true): LogApiReques middleware_contracts_mutation_request('/int/v1/orders', 'int/v1/orders'), fn () => new JsonResponse(['id' => 'order_2'], 201) ); + $storefrontInternal = middleware_contracts_log_middleware()->handle( + middleware_contracts_mutation_request('/storefront/int/v1/orders', 'storefront/int/v1/orders'), + fn () => new JsonResponse(['id' => 'order_3'], 201) + ); $excluded = middleware_contracts_log_middleware()->handle( middleware_contracts_mutation_request('/v1/drivers/driver_1/track', 'v1/drivers/{driver}/track'), fn () => new JsonResponse(['id' => 'track_1'], 201) @@ -668,6 +672,7 @@ function middleware_contracts_log_middleware(bool $enabled = true): LogApiReques expect($disabled->getStatusCode())->toBe(201) ->and($read->getStatusCode())->toBe(204) ->and($internal->getStatusCode())->toBe(201) + ->and($storefrontInternal->getStatusCode())->toBe(201) ->and($excluded->getStatusCode())->toBe(201) ->and($bus->jobs)->toBeEmpty(); }); @@ -712,6 +717,39 @@ function middleware_contracts_log_middleware(bool $enabled = true): LogApiReques ]); }); + test('log api requests dispatches storefront mutation request logs related to the store', function () { + middleware_contracts_fixture(['api.version' => 'v1']); + session([ + 'company' => 'company-1', + 'api_credential' => 'store_live_key', + 'storefront_store' => 'store-uuid-1', + 'storefront_store_public_id' => 'store_abc123', + 'storefront_network' => null, + 'storefront_network_public_id' => null, + ]); + $bus = new MiddlewareContractsBusFake(); + app()->instance('bus', $bus); + app()->instance(BusDispatcher::class, $bus); + Facade::clearResolvedInstance('bus'); + Facade::clearResolvedInstance(BusDispatcher::class); + + $response = middleware_contracts_log_middleware()->handle( + middleware_contracts_mutation_request('/storefront/v1/customers/request-creation-code', 'storefront/v1/customers/request-creation-code'), + fn () => new JsonResponse(['status' => 'ok'], 200) + ); + + expect($response->getStatusCode())->toBe(200) + ->and($bus->jobs)->toHaveCount(1) + ->and($bus->jobs[0])->toBeInstanceOf(Fleetbase\Jobs\LogApiRequest::class) + ->and($bus->jobs[0]->payload)->toMatchArray([ + 'company_uuid' => 'company-1', + 'method' => 'POST', + 'path' => 'storefront/v1/customers/request-creation-code', + 'status_code' => 200, + 'related' => ['store_abc123'], + ]); + }); + test('log api requests swallows dispatch errors and keeps original response', function () { middleware_contracts_fixture(['api.version' => 'v1']); $bus = new MiddlewareContractsBusFake(); diff --git a/tests/Unit/Jobs/LogApiRequestTest.php b/tests/Unit/Jobs/LogApiRequestTest.php index 1bcdc8ea..c7e4a37a 100644 --- a/tests/Unit/Jobs/LogApiRequestTest.php +++ b/tests/Unit/Jobs/LogApiRequestTest.php @@ -224,6 +224,43 @@ function loggable_request(float $startedAt): Request ->and($payload)->not->toHaveKeys(['api_credential_uuid', 'access_token_id']); }); + it('relates storefront payloads to the store or network whose key made the request', function () { + bind_test_container(['api.version' => 'v1']); + session([ + 'api_credential' => 'store_live_key', + 'company' => 'company-1', + 'storefront_store' => 'store-uuid-1', + 'storefront_store_public_id' => 'store_abc123', + 'storefront_network' => null, + 'storefront_network_public_id' => null, + ]); + + $storePayload = LogApiRequest::getPayload( + loggable_request(microtime(true) - 0.01), + new JsonResponse(['id' => 'customer_1'], 201) + ); + + session([ + 'api_credential' => 'network_live_key', + 'company' => 'company-2', + 'storefront_store' => null, + 'storefront_store_public_id' => null, + 'storefront_network' => 'network-uuid-1', + 'storefront_network_public_id' => 'network_xyz789', + ]); + + $networkPayload = LogApiRequest::getPayload( + loggable_request(microtime(true) - 0.01), + new JsonResponse(['status' => 'ok'], 200) + ); + + expect($storePayload['related'])->toBe(['customer_1', 'store_abc123']) + ->and($storePayload['company_uuid'])->toBe('company-1') + ->and($storePayload)->not->toHaveKeys(['api_credential_uuid', 'access_token_id']) + ->and($networkPayload['related'])->toBe(['network_xyz789']) + ->and($networkPayload['company_uuid'])->toBe('company-2'); + }); + it('persists API request logs on the selected connection and clears response cache', function () { $database = log_api_request_database(); $cache = new LogApiRequestResponseCacheFake(); diff --git a/tests/Unit/Support/TemplateStringAndHttpTest.php b/tests/Unit/Support/TemplateStringAndHttpTest.php index 24cb6645..3429e638 100644 --- a/tests/Unit/Support/TemplateStringAndHttpTest.php +++ b/tests/Unit/Support/TemplateStringAndHttpTest.php @@ -71,6 +71,9 @@ function routed_request(string $uri, array $action = []): Request ->and(Http::isInternalRequest(routed_request('v1/orders')))->toBeFalse() ->and(Http::isPublicRequest(routed_request('v1/orders')))->toBeTrue() ->and(Http::isPublicRequest(routed_request('int/v1/orders')))->toBeFalse() + ->and(Http::isPublicRequest(routed_request('storefront/v1/customers/request-creation-code')))->toBeTrue() + ->and(Http::isPublicRequest(routed_request('storefront/int/v1/orders')))->toBeFalse() + ->and(Http::isPublicRequest(routed_request('storefront/orders')))->toBeFalse() ->and(Http::isInternalRequest(Request::create('/v1/orders')))->toBeFalse() ->and(Http::isPublicRequest(Request::create('/v1/orders')))->toBeFalse() ->and(Http::useSort('-created_at'))->toBe(['created_at', 'desc']) From 21c959ded084b089b3ddcebbc8d54ff9f765d064 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 13:44:50 +0800 Subject: [PATCH 2/5] test(api-cache): cover the session scope of a request without a session getSessionScope returns no scope when the request has no session. Every test request had one, so the release branch fell one statement short of the coverage floor after #300. --- tests/Unit/Support/ApiModelCacheTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/Unit/Support/ApiModelCacheTest.php b/tests/Unit/Support/ApiModelCacheTest.php index 98978d5b..9450b993 100644 --- a/tests/Unit/Support/ApiModelCacheTest.php +++ b/tests/Unit/Support/ApiModelCacheTest.php @@ -742,6 +742,24 @@ public function gc(int $max_lifetime): int|false ->and($key(['customer' => 'customer-a', 'storefront_store' => 'store-a']))->toBe($key(['customer' => 'customer-a', 'storefront_store' => 'store-b'])); }); +test('api model cache leaves requests without a session unscoped', function () { + api_model_cache_fixture(); + + $sessionScope = new ReflectionMethod(ApiModelCache::class, 'getSessionScope'); + $sessionScope->setAccessible(true); + + expect($sessionScope->invoke(null, Request::create('/v1/orders', 'GET')))->toBe([]) + ->and($sessionScope->invoke(null, api_model_cache_scoped_request('/v1/orders', [ + 'company' => 'company-1', + 'storefront_store' => 'store-1', + 'api_credential' => 'credential-1', + 'storefront_network' => null, + ])))->toBe([ + 'api_credential' => 'credential-1', + 'storefront_store' => 'store-1', + ]); +}); + test('api model cache resolves tenant scope from authenticated user and request input fallbacks', function () { api_model_cache_fixture(); $model = api_model_cache_model(); From e159bfd90aec2b5c6c5174ca8dfd0d4738ae8a90 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 13:46:40 +0800 Subject: [PATCH 3/5] fix(api-cache): build list cache keys for requests without a session getCompanyUuid called $request->session() unguarded, so generateQueryCacheKey threw "Session store not set on request." before getSessionScope's hasSession check could apply. Check hasSession first and fall back to the user and request input as before. The test now covers the sessionless path through generateQueryCacheKey instead of calling getSessionScope by reflection. --- src/Support/ApiModelCache.php | 2 +- tests/Unit/Support/ApiModelCacheTest.php | 27 ++++++++++++------------ 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/Support/ApiModelCache.php b/src/Support/ApiModelCache.php index 2e1b99c9..e4a95684 100644 --- a/src/Support/ApiModelCache.php +++ b/src/Support/ApiModelCache.php @@ -496,7 +496,7 @@ public static function getRelationshipTtl(): int protected static function getCompanyUuid(Request $request): ?string { // Try to get from session - if ($request->session()->has('company')) { + if ($request->hasSession() && $request->session()->has('company')) { return $request->session()->get('company'); } diff --git a/tests/Unit/Support/ApiModelCacheTest.php b/tests/Unit/Support/ApiModelCacheTest.php index 9450b993..0362d446 100644 --- a/tests/Unit/Support/ApiModelCacheTest.php +++ b/tests/Unit/Support/ApiModelCacheTest.php @@ -742,22 +742,21 @@ public function gc(int $max_lifetime): int|false ->and($key(['customer' => 'customer-a', 'storefront_store' => 'store-a']))->toBe($key(['customer' => 'customer-a', 'storefront_store' => 'store-b'])); }); -test('api model cache leaves requests without a session unscoped', function () { +test('api model cache keys requests without a session by company input and leaves them unscoped', function () { api_model_cache_fixture(); + $model = api_model_cache_model(); - $sessionScope = new ReflectionMethod(ApiModelCache::class, 'getSessionScope'); - $sessionScope->setAccessible(true); - - expect($sessionScope->invoke(null, Request::create('/v1/orders', 'GET')))->toBe([]) - ->and($sessionScope->invoke(null, api_model_cache_scoped_request('/v1/orders', [ - 'company' => 'company-1', - 'storefront_store' => 'store-1', - 'api_credential' => 'credential-1', - 'storefront_network' => null, - ])))->toBe([ - 'api_credential' => 'credential-1', - 'storefront_store' => 'store-1', - ]); + $sessionless = ApiModelCache::generateQueryCacheKey($model, Request::create('/v1/orders', 'GET', [ + 'status' => 'active', + 'company_uuid' => 'company-1', + ])); + $unscoped = ApiModelCache::generateQueryCacheKey($model, api_model_cache_scoped_request('/v1/orders', [], [ + 'status' => 'active', + 'company_uuid' => 'company-1', + ])); + + expect($sessionless)->toStartWith('{api_query}:orders:company_company-1:v0:') + ->and($sessionless)->toBe($unscoped); }); test('api model cache resolves tenant scope from authenticated user and request input fallbacks', function () { From 08be0b8d24ea343ed0c502ec3ca192cb1bab95ae Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 14:00:31 +0800 Subject: [PATCH 4/5] refactor(api-logs): log any route that opts in to LogApiRequests and let extensions relate ids Core no longer names storefront. Every route that runs LogApiRequests opted in through its middleware group (core's public API or an extension's), so shouldLog only skips internal routes and requests without a route, instead of requiring the core v1/ prefix. isPublicRequest is back to v1/ only, so storefront filters and resources are unchanged. LogApiRequest::RELATED_ATTRIBUTE ("api_request_log_related") is a request attribute whose ids are added to the log's related column. Extensions set it from their middleware, for example storefront with the public id of the store or network whose key made the request. --- src/Http/Middleware/LogApiRequests.php | 5 ++- src/Jobs/LogApiRequest.php | 19 ++++++-- src/Support/Http.php | 5 +-- tests/Unit/Http/MiddlewareContractsTest.php | 22 ++++++---- tests/Unit/Jobs/LogApiRequestTest.php | 43 +++++++------------ .../Support/TemplateStringAndHttpTest.php | 3 -- 6 files changed, 50 insertions(+), 47 deletions(-) diff --git a/src/Http/Middleware/LogApiRequests.php b/src/Http/Middleware/LogApiRequests.php index 7405ecf7..9e2714e5 100644 --- a/src/Http/Middleware/LogApiRequests.php +++ b/src/Http/Middleware/LogApiRequests.php @@ -59,7 +59,10 @@ public function handle($request, \Closure $next) */ protected function shouldLog($request): bool { - if (!Http::isPublicRequest($request)) { + // Every route that runs this middleware opted in to logging through its + // middleware group (core's public API or an extension's), so only skip + // requests without a route and internal routes such as "int/v1/..." + if ($request->route() === null || Http::isInternalRequest($request)) { return false; } diff --git a/src/Jobs/LogApiRequest.php b/src/Jobs/LogApiRequest.php index 27322b0c..5049d13f 100644 --- a/src/Jobs/LogApiRequest.php +++ b/src/Jobs/LogApiRequest.php @@ -25,6 +25,16 @@ class LogApiRequest implements ShouldQueue use Queueable; use SerializesModels; + /** + * Request attribute holding ids to add to the log's related column. + * + * Extensions set it from their middleware so a logged request can be traced + * to the resource behind it, for example the owner of the key that made it. + * + * @var string + */ + public const RELATED_ATTRIBUTE = 'api_request_log_related'; + /** * The API Request/JsonResponse payload. * @@ -82,10 +92,11 @@ public static function getPayload(Request $request, $response): array $related[] = Utils::get($content, 'id'); } - // If a storefront key made the request, push the store or network public id to related - $storefrontPublicId = session('storefront_store_public_id') ?? session('storefront_network_public_id'); - if ($storefrontPublicId) { - $related[] = $storefrontPublicId; + // Push ids that middleware related to this request, such as the resource whose key made it + foreach ((array) $request->attributes->get(static::RELATED_ATTRIBUTE, []) as $id) { + if ($id) { + $related[] = $id; + } } // Get api credential from session diff --git a/src/Support/Http.php b/src/Support/Http.php index 485cc7cf..eded33da 100644 --- a/src/Support/Http.php +++ b/src/Support/Http.php @@ -49,9 +49,8 @@ public static function isPublicRequest(?Request $request = null): bool $uri = ltrim((string) $route->uri(), '/'); - // Only true public APIs: the core "v1/" API and the storefront "storefront/v1/" API. - // Internal routes such as "int/v1/..." and "storefront/int/v1/..." never match. - return Str::startsWith($uri, ['v1/', 'storefront/v1/']); + // Only log true public API: starts with "v1/" + return Str::startsWith($uri, 'v1/'); } /** diff --git a/tests/Unit/Http/MiddlewareContractsTest.php b/tests/Unit/Http/MiddlewareContractsTest.php index 69c6d47d..5da56557 100644 --- a/tests/Unit/Http/MiddlewareContractsTest.php +++ b/tests/Unit/Http/MiddlewareContractsTest.php @@ -668,12 +668,19 @@ function middleware_contracts_log_middleware(bool $enabled = true): LogApiReques middleware_contracts_mutation_request('/v1/drivers/driver_1/track', 'v1/drivers/{driver}/track'), fn () => new JsonResponse(['id' => 'track_1'], 201) ); + $unroutedRequest = middleware_contracts_mutation_request('/v1/orders', 'v1/orders'); + $unroutedRequest->setRouteResolver(fn () => null); + $unrouted = middleware_contracts_log_middleware()->handle( + $unroutedRequest, + fn () => new JsonResponse(['id' => 'order_4'], 201) + ); expect($disabled->getStatusCode())->toBe(201) ->and($read->getStatusCode())->toBe(204) ->and($internal->getStatusCode())->toBe(201) ->and($storefrontInternal->getStatusCode())->toBe(201) ->and($excluded->getStatusCode())->toBe(201) + ->and($unrouted->getStatusCode())->toBe(201) ->and($bus->jobs)->toBeEmpty(); }); @@ -717,15 +724,11 @@ function middleware_contracts_log_middleware(bool $enabled = true): LogApiReques ]); }); - test('log api requests dispatches storefront mutation request logs related to the store', function () { + test('log api requests dispatches extension mutation request logs with related ids from the request', function () { middleware_contracts_fixture(['api.version' => 'v1']); session([ - 'company' => 'company-1', - 'api_credential' => 'store_live_key', - 'storefront_store' => 'store-uuid-1', - 'storefront_store_public_id' => 'store_abc123', - 'storefront_network' => null, - 'storefront_network_public_id' => null, + 'company' => 'company-1', + 'api_credential' => 'store_live_key', ]); $bus = new MiddlewareContractsBusFake(); app()->instance('bus', $bus); @@ -733,8 +736,11 @@ function middleware_contracts_log_middleware(bool $enabled = true): LogApiReques Facade::clearResolvedInstance('bus'); Facade::clearResolvedInstance(BusDispatcher::class); + $request = middleware_contracts_mutation_request('/storefront/v1/customers/request-creation-code', 'storefront/v1/customers/request-creation-code'); + $request->attributes->set(Fleetbase\Jobs\LogApiRequest::RELATED_ATTRIBUTE, ['store_abc123']); + $response = middleware_contracts_log_middleware()->handle( - middleware_contracts_mutation_request('/storefront/v1/customers/request-creation-code', 'storefront/v1/customers/request-creation-code'), + $request, fn () => new JsonResponse(['status' => 'ok'], 200) ); diff --git a/tests/Unit/Jobs/LogApiRequestTest.php b/tests/Unit/Jobs/LogApiRequestTest.php index c7e4a37a..eb55e01b 100644 --- a/tests/Unit/Jobs/LogApiRequestTest.php +++ b/tests/Unit/Jobs/LogApiRequestTest.php @@ -224,41 +224,28 @@ function loggable_request(float $startedAt): Request ->and($payload)->not->toHaveKeys(['api_credential_uuid', 'access_token_id']); }); - it('relates storefront payloads to the store or network whose key made the request', function () { + it('adds ids that middleware related to the request to the payload', function () { bind_test_container(['api.version' => 'v1']); session([ - 'api_credential' => 'store_live_key', - 'company' => 'company-1', - 'storefront_store' => 'store-uuid-1', - 'storefront_store_public_id' => 'store_abc123', - 'storefront_network' => null, - 'storefront_network_public_id' => null, + 'api_credential' => 'store_live_key', + 'company' => 'company-1', ]); - $storePayload = LogApiRequest::getPayload( - loggable_request(microtime(true) - 0.01), - new JsonResponse(['id' => 'customer_1'], 201) - ); + $related = loggable_request(microtime(true) - 0.01); + $related->attributes->set(LogApiRequest::RELATED_ATTRIBUTE, ['store_abc123', null, '']); - session([ - 'api_credential' => 'network_live_key', - 'company' => 'company-2', - 'storefront_store' => null, - 'storefront_store_public_id' => null, - 'storefront_network' => 'network-uuid-1', - 'storefront_network_public_id' => 'network_xyz789', - ]); + $single = loggable_request(microtime(true) - 0.01); + $single->attributes->set(LogApiRequest::RELATED_ATTRIBUTE, 'network_xyz789'); - $networkPayload = LogApiRequest::getPayload( - loggable_request(microtime(true) - 0.01), - new JsonResponse(['status' => 'ok'], 200) - ); + $relatedPayload = LogApiRequest::getPayload($related, new JsonResponse(['id' => 'customer_1'], 201)); + $singlePayload = LogApiRequest::getPayload($single, new JsonResponse(['status' => 'ok'], 200)); + $plainPayload = LogApiRequest::getPayload(loggable_request(microtime(true) - 0.01), new JsonResponse(['status' => 'ok'], 200)); - expect($storePayload['related'])->toBe(['customer_1', 'store_abc123']) - ->and($storePayload['company_uuid'])->toBe('company-1') - ->and($storePayload)->not->toHaveKeys(['api_credential_uuid', 'access_token_id']) - ->and($networkPayload['related'])->toBe(['network_xyz789']) - ->and($networkPayload['company_uuid'])->toBe('company-2'); + expect($relatedPayload['related'])->toBe(['customer_1', 'store_abc123']) + ->and($relatedPayload['company_uuid'])->toBe('company-1') + ->and($relatedPayload)->not->toHaveKeys(['api_credential_uuid', 'access_token_id']) + ->and($singlePayload['related'])->toBe(['network_xyz789']) + ->and($plainPayload['related'])->toBe([]); }); it('persists API request logs on the selected connection and clears response cache', function () { diff --git a/tests/Unit/Support/TemplateStringAndHttpTest.php b/tests/Unit/Support/TemplateStringAndHttpTest.php index 3429e638..24cb6645 100644 --- a/tests/Unit/Support/TemplateStringAndHttpTest.php +++ b/tests/Unit/Support/TemplateStringAndHttpTest.php @@ -71,9 +71,6 @@ function routed_request(string $uri, array $action = []): Request ->and(Http::isInternalRequest(routed_request('v1/orders')))->toBeFalse() ->and(Http::isPublicRequest(routed_request('v1/orders')))->toBeTrue() ->and(Http::isPublicRequest(routed_request('int/v1/orders')))->toBeFalse() - ->and(Http::isPublicRequest(routed_request('storefront/v1/customers/request-creation-code')))->toBeTrue() - ->and(Http::isPublicRequest(routed_request('storefront/int/v1/orders')))->toBeFalse() - ->and(Http::isPublicRequest(routed_request('storefront/orders')))->toBeFalse() ->and(Http::isInternalRequest(Request::create('/v1/orders')))->toBeFalse() ->and(Http::isPublicRequest(Request::create('/v1/orders')))->toBeFalse() ->and(Http::useSort('-created_at'))->toBe(['created_at', 'desc']) From 304f95103cee1c9a5535078c8f7cc09fd26ce8de Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 14:00:31 +0800 Subject: [PATCH 5/5] test(api-cache): cover the dependency memo limit, uncallable relation methods and an unresolvable transactions manager #301 left three statements in ApiModelCache unrun, so the release branch is under the 100% coverage floor. --- tests/Unit/Support/ApiModelCacheTest.php | 55 +++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/tests/Unit/Support/ApiModelCacheTest.php b/tests/Unit/Support/ApiModelCacheTest.php index 2100752a..d302cab1 100644 --- a/tests/Unit/Support/ApiModelCacheTest.php +++ b/tests/Unit/Support/ApiModelCacheTest.php @@ -206,6 +206,24 @@ class ApiModelCachePartlyResolvableDependencyOrder extends ApiModelCacheRelation protected $apiCacheDependencies = ['tags', 'payload.customer']; } +class ApiModelCacheUncallableRelationsOrder extends ApiModelCacheRelationsOrder +{ + public static function staticPayload(): BelongsTo + { + return (new static())->belongsTo(ApiModelCacheDependencyPayload::class, 'payload_uuid', 'uuid'); + } + + public function payloadFor(string $key): BelongsTo + { + return $this->belongsTo(ApiModelCacheDependencyPayload::class, $key, 'uuid'); + } + + protected function hiddenPayload(): BelongsTo + { + return $this->belongsTo(ApiModelCacheDependencyPayload::class, 'payload_uuid', 'uuid'); + } +} + class ApiModelCacheTestLock { public function __construct(private ApiModelCacheTestStore $store) @@ -987,6 +1005,41 @@ public function gc(int $max_lifetime): int|false app()->forgetInstance('db.transactions'); }); +test('api model cache keeps the immediate bump when the transactions manager cannot be resolved', function () { + $capsule = api_model_cache_dependency_database(); + $store = app('cache'); + $store->flushRemovesValues = false; + $connection = $capsule->getConnection('mysql'); + app()->bind('db.transactions', fn () => throw new RuntimeException('transactions manager unavailable')); + + $connection->beginTransaction(); + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyPayload(), 'company-1'); + $connection->commit(); + + app()->offsetUnset('db.transactions'); + + expect($store->get('api_query_version:payloads:company-1'))->toBe(1); +}); + +test('api model cache only resolves public instance relation methods without required parameters', function () { + api_model_cache_dependency_database(); + $order = new ApiModelCacheUncallableRelationsOrder(); + + expect(ApiModelCache::dependencyTables($order, ['static_payload', 'payload_for', 'hidden_payload']))->toBe([]) + ->and(ApiModelCache::dependencyTables($order, ['payload']))->toBe(['payloads']); +}); + +test('api model cache clears the relation path memo once it reaches its limit', function () { + api_model_cache_dependency_database(); + $memo = new ReflectionProperty(ApiModelCache::class, 'dependencyTables'); + $limit = (new ReflectionClassConstant(ApiModelCache::class, 'DEPENDENCY_MEMO_LIMIT'))->getValue(); + $memo->setAccessible(true); + $memo->setValue(null, array_fill_keys(array_map(fn ($i) => "memo-{$i}", range(1, $limit)), [])); + + expect(ApiModelCache::dependencyTables(new ApiModelCacheRelationsOrder(), ['payload']))->toBe(['payloads']) + ->and($memo->getValue())->toHaveCount(1); +}); + test('api model cache resolves request style relation paths without side effects on the model', function () { api_model_cache_dependency_database(); $order = new ApiModelCacheRelationsOrder(); @@ -1195,7 +1248,7 @@ public function gc(int $max_lifetime): int|false 'company_uuid' => 'company-1', ])); - expect($sessionless)->toStartWith('{api_query}:orders:company_company-1:v0:') + expect($sessionless)->toStartWith('{api_query}:orders:company_company-1:v0.0:') ->and($sessionless)->toBe($unscoped); });