From 98f54e5eb7a6f2d0162de0faada7a7718f6dbcbb Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 23:05:35 +0800 Subject: [PATCH 1/3] fix(cache): invalidate cached lists after writes that raise no model events Query builder updates and deletes bump no ApiModelCache version, so the cached lists embedding those rows kept serving them until the TTL. Each such write now invalidates its table explicitly, with the company when known; the writes themselves stay quiet. - CatalogProduct (catalog_category_products) uses HasApiModelBehavior like the sibling CatalogSubject and NetworkStore pivots, so pivot creates bump it; CatalogCategory::setProducts removals and override edits and CatalogObserver::deleted invalidate it by hand. - CatalogController::syncSubjects and FoodTruck::setCatalogs invalidate catalog_subjects after their builder deletes. - NetworkController membership writes (add/remove/approve/reject/suspend/ reinstate, deleteCategory) invalidate network_stores alongside stores; deleteCategory also invalidates categories after its builder delete. - Product::setAddonCategories / setProductVariants and AddonCategory::setAddons invalidate product_addon_categories, product_variants, product_variant_options and product_addons. - v1 CheckoutController::discardFailedCapture invalidates transaction_items. - storefront:purge-carts advances the table wide carts version. The lists only read these versions once they declare the tables they embed (fleetbase/core-api#301), which the next commit does. --- .../Console/Commands/PurgeExpiredCarts.php | 8 + .../Http/Controllers/CatalogController.php | 9 +- .../Http/Controllers/NetworkController.php | 9 +- .../Controllers/v1/CheckoutController.php | 3 + server/src/Models/AddonCategory.php | 8 + server/src/Models/CatalogCategory.php | 22 ++- server/src/Models/CatalogProduct.php | 2 + server/src/Models/FoodTruck.php | 6 + server/src/Models/Product.php | 20 +++ server/src/Observers/CatalogObserver.php | 5 + server/tests/Support/ApiCacheTestHelpers.php | 14 ++ .../Unit/Console/CommandContractsTest.php | 7 +- .../Unit/Models/ApiCacheInvalidationTest.php | 163 ++++++++++++++++++ .../Unit/Models/CatalogPriceOverrideTest.php | 24 +++ .../Unit/Models/FoodTruckCatalogsTest.php | 18 ++ .../tests/Unit/Models/ProductBehaviorTest.php | 31 ++++ .../Models/StorefrontModelContractsTest.php | 8 +- 17 files changed, 352 insertions(+), 5 deletions(-) create mode 100644 server/tests/Support/ApiCacheTestHelpers.php create mode 100644 server/tests/Unit/Models/ApiCacheInvalidationTest.php diff --git a/server/src/Console/Commands/PurgeExpiredCarts.php b/server/src/Console/Commands/PurgeExpiredCarts.php index d7a9fafb..9a1685a4 100644 --- a/server/src/Console/Commands/PurgeExpiredCarts.php +++ b/server/src/Console/Commands/PurgeExpiredCarts.php @@ -2,6 +2,7 @@ namespace Fleetbase\Storefront\Console\Commands; +use Fleetbase\Storefront\Models\Cart; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; @@ -51,6 +52,13 @@ public function handle() $schema->enableForeignKeyConstraints(); } + // The rows were deleted through the query builder without model events and span + // companies, so the table wide cart cache version is advanced (which every + // company's cart caches read from fleetbase/core-api#301 on). + if ($dbDeletedCount > 0) { + Cart::invalidateApiCacheManually(); + } + // Log output $this->info("Successfully deleted {$dbDeletedCount} expired carts."); diff --git a/server/src/Http/Controllers/CatalogController.php b/server/src/Http/Controllers/CatalogController.php index fd793275..945bf62e 100644 --- a/server/src/Http/Controllers/CatalogController.php +++ b/server/src/Http/Controllers/CatalogController.php @@ -7,6 +7,7 @@ use Fleetbase\Storefront\Models\CatalogSubject; use Fleetbase\Storefront\Models\FoodTruck; use Fleetbase\Storefront\Models\Store; +use Fleetbase\Support\ApiModelCache; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\Support\Str; @@ -56,10 +57,16 @@ private function syncSubjects(Catalog $catalog, string $subjectType, array $subj ->unique() ->values(); - CatalogSubject::where(['catalog_uuid' => $catalog->uuid, 'subject_type' => $subjectType]) + $removed = CatalogSubject::where(['catalog_uuid' => $catalog->uuid, 'subject_type' => $subjectType]) ->whereNotIn('subject_uuid', $incoming) ->delete(); + // A builder delete raises no model events, so the cached lists embedding the + // assignments (catalogs, food trucks) are invalidated here. + if ($removed > 0) { + ApiModelCache::invalidateModelCache(new CatalogSubject(), $catalog->company_uuid); + } + foreach ($incoming as $subjectUuid) { CatalogSubject::firstOrCreate( [ diff --git a/server/src/Http/Controllers/NetworkController.php b/server/src/Http/Controllers/NetworkController.php index 214c543b..642398ac 100644 --- a/server/src/Http/Controllers/NetworkController.php +++ b/server/src/Http/Controllers/NetworkController.php @@ -766,12 +766,15 @@ protected function setMembershipStatus(string $networkId, array $stores, string /** * Membership rows change through the query builder, which raises no model events, * so the cached store listings (which carry category and membership status) are - * invalidated here the way a store update would. + * invalidated here the way a store update would, and so are the lists that embed + * `network_stores` (stores, products and food trucks, by their declared cache + * dependencies), the way a membership's own model events would. */ protected function forgetCachedResponses(): void { if (class_exists(ApiModelCache::class)) { ApiModelCache::invalidateModelCache(new Store(), session('company')); + ApiModelCache::invalidateModelCache(new NetworkStore(), session('company')); } } @@ -846,6 +849,10 @@ public function deleteCategory(string $id, NetworkActionRequest $request) // delete the category Category::where(['owner_uuid' => $id, 'uuid' => $category])->delete(); + // Both writes went through the query builder without model events + $this->forgetCachedResponses(); + Category::invalidateApiCacheManually(session('company')); + return response()->json(['status' => 'ok']); } } diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index dee6e4bd..2451f5f6 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -36,6 +36,7 @@ use Fleetbase\Storefront\Support\Storefront; use Fleetbase\Storefront\Support\StorefrontSocket; use Fleetbase\Storefront\Support\StripeUtils; +use Fleetbase\Support\ApiModelCache; use Fleetbase\Support\SocketCluster\SocketClusterService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -2147,6 +2148,8 @@ protected static function discardFailedCapture(?Transaction $transaction, array } if ($transaction) { TransactionItem::where('transaction_uuid', $transaction->uuid)->delete(); + // The builder delete raises no model events; clear the cached lists embedding the items + ApiModelCache::invalidateModelCache(new TransactionItem(), $transaction->company_uuid); $transaction->delete(); } } catch (\Throwable $cleanup) { diff --git a/server/src/Models/AddonCategory.php b/server/src/Models/AddonCategory.php index c1738328..d7526597 100644 --- a/server/src/Models/AddonCategory.php +++ b/server/src/Models/AddonCategory.php @@ -4,6 +4,7 @@ use Fleetbase\Casts\Money as MoneyCast; use Fleetbase\Models\Category; +use Fleetbase\Support\ApiModelCache; use Illuminate\Support\Str; class AddonCategory extends Category @@ -42,6 +43,7 @@ public function addons() public function setAddons(array $addons = []): AddonCategory { + $updatedQuietly = false; foreach ($addons as $addon) { // get uuid if set $id = data_get($addon, 'uuid'); @@ -60,6 +62,7 @@ public function setAddons(array $addons = []): AddonCategory // update product addon category if (Str::isUuid($id)) { ProductAddon::where('uuid', $id)->update($upsertableProductAddon); + $updatedQuietly = true; continue; } @@ -70,6 +73,11 @@ public function setAddons(array $addons = []): AddonCategory ]); } + // Builder updates raise no model events, so the cached addon category and product lists are invalidated here + if ($updatedQuietly) { + ApiModelCache::invalidateModelCache(new ProductAddon(), $this->company_uuid); + } + return $this; } } diff --git a/server/src/Models/CatalogCategory.php b/server/src/Models/CatalogCategory.php index 15229630..57d32290 100644 --- a/server/src/Models/CatalogCategory.php +++ b/server/src/Models/CatalogCategory.php @@ -3,6 +3,7 @@ namespace Fleetbase\Storefront\Models; use Fleetbase\Models\Category; +use Fleetbase\Support\ApiModelCache; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\MorphTo; @@ -111,11 +112,13 @@ public function setProducts(array $products = [], ?array $overrides = null): Cat $incomingProductUuids = array_values(array_unique($incomingProductUuids)); // 1) Remove pivot rows for products not in incoming list - $toRemove = array_diff($existingProductUuids, $incomingProductUuids); + $toRemove = array_diff($existingProductUuids, $incomingProductUuids); + $pivotsWrittenQuietly = false; if (!empty($toRemove)) { CatalogProduct::where('catalog_category_uuid', $this->uuid) ->whereIn('product_uuid', $toRemove) ->delete(); + $pivotsWrittenQuietly = true; } // 2) Create pivot rows for new products, carrying their overrides @@ -149,11 +152,28 @@ public function setProducts(array $products = [], ?array $overrides = null): Cat CatalogProduct::where('catalog_category_uuid', $this->uuid) ->where('product_uuid', $pivot->product_uuid) ->update($next); + $pivotsWrittenQuietly = true; + } + + // Removals and override edits go through the query builder, which raises no model + // events, so the cached lists embedding these pivots (catalogs, products) are + // invalidated here. + if ($pivotsWrittenQuietly) { + static::invalidateCatalogProductCache($this->company_uuid); } return $this; } + /** + * Invalidate the cached lists that embed `catalog_category_products`, after writes + * to it that raise no model events. + */ + public static function invalidateCatalogProductCache(?string $companyUuid = null): void + { + ApiModelCache::invalidateModelCache(new CatalogProduct(), $companyUuid); + } + /** * The storable form of a per-catalog override: a price in minor units or null, and an * availability flag or null (inherit from the store). diff --git a/server/src/Models/CatalogProduct.php b/server/src/Models/CatalogProduct.php index ebc349bb..7e2ed64f 100644 --- a/server/src/Models/CatalogProduct.php +++ b/server/src/Models/CatalogProduct.php @@ -2,6 +2,7 @@ namespace Fleetbase\Storefront\Models; +use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasUuid; use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Database\Eloquent\SoftDeletes; @@ -10,6 +11,7 @@ class CatalogProduct extends Pivot { use SoftDeletes; use HasUuid; + use HasApiModelBehavior; protected $connection = 'storefront'; protected $table = 'catalog_category_products'; diff --git a/server/src/Models/FoodTruck.php b/server/src/Models/FoodTruck.php index 9a65909e..6587a690 100644 --- a/server/src/Models/FoodTruck.php +++ b/server/src/Models/FoodTruck.php @@ -9,6 +9,7 @@ use Fleetbase\FleetOps\Models\Zone; use Fleetbase\LaravelMysqlSpatial\Types\Point; use Fleetbase\Storefront\Http\Resources\FoodTruck as FoodTruckResource; +use Fleetbase\Support\ApiModelCache; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasApiModelCache; use Fleetbase\Traits\HasPublicid; @@ -216,6 +217,11 @@ public function setCatalogs(array $catalogs = []): FoodTruck $this->invalidateApiCache(); } + // Removed assignments were deleted without model events; the catalog lists embed them too. + if ($removed->isNotEmpty()) { + ApiModelCache::invalidateModelCache(new CatalogSubject(), $this->company_uuid); + } + return $this; } diff --git a/server/src/Models/Product.php b/server/src/Models/Product.php index 75921818..e04d979b 100644 --- a/server/src/Models/Product.php +++ b/server/src/Models/Product.php @@ -8,6 +8,7 @@ use Fleetbase\Models\Category; use Fleetbase\Models\File; use Fleetbase\Models\User; +use Fleetbase\Support\ApiModelCache; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasApiModelCache; use Fleetbase\Traits\HasMetaAttributes; @@ -364,6 +365,7 @@ public function setSalePriceAttribute($value) */ public function setAddonCategories(array $addonCategories = []): Product { + $updatedQuietly = false; foreach ($addonCategories as $addonCategory) { // get uuid if set $id = data_get($addonCategory, 'uuid'); @@ -379,6 +381,7 @@ public function setAddonCategories(array $addonCategories = []): Product // update product addon category if (Str::isUuid($id)) { ProductAddonCategory::where('uuid', $id)->update($upsertableAddonCategory); + $updatedQuietly = true; continue; } @@ -386,6 +389,11 @@ public function setAddonCategories(array $addonCategories = []): Product ProductAddonCategory::create($upsertableAddonCategory); } + // Builder updates raise no model events, so the cached product lists are invalidated here + if ($updatedQuietly) { + ApiModelCache::invalidateModelCache(new ProductAddonCategory(), $this->company_uuid); + } + return $this; } @@ -404,6 +412,8 @@ public function setAddonCategories(array $addonCategories = []): Product */ public function setProductVariants(array $variants = []): Product { + $variantsUpdatedQuietly = false; + $optionsUpdatedQuietly = false; foreach ($variants as $variant) { $id = data_get($variant, 'uuid'); @@ -419,6 +429,7 @@ public function setProductVariants(array $variants = []): Product 'min' => data_get($variant, 'min') ?? 0, 'max' => data_get($variant, 'max') ?? 100, ]); + $variantsUpdatedQuietly = true; // Update product variant options if applicable if (is_array($variant['options'])) { @@ -438,6 +449,7 @@ public function setProductVariants(array $variants = []): Product $productVariantOptionInput = Arr::except($option, ['uuid', 'created_at', 'updated_at']); ProductVariantOption::where('uuid', $option['uuid'])->update($productVariantOptionInput); + $optionsUpdatedQuietly = true; continue; } @@ -464,6 +476,14 @@ public function setProductVariants(array $variants = []): Product } } + // Builder updates raise no model events, so the cached product lists are invalidated here + if ($variantsUpdatedQuietly) { + ApiModelCache::invalidateModelCache(new ProductVariant(), $this->company_uuid); + } + if ($optionsUpdatedQuietly) { + ApiModelCache::invalidateModelCache(new ProductVariantOption(), $this->company_uuid); + } + return $this; } diff --git a/server/src/Observers/CatalogObserver.php b/server/src/Observers/CatalogObserver.php index 46896640..5f1e06ce 100644 --- a/server/src/Observers/CatalogObserver.php +++ b/server/src/Observers/CatalogObserver.php @@ -36,5 +36,10 @@ public function deleted(Catalog $catalog): void // Delete the category itself $category->delete(); } + + // The pivot rows were deleted without model events + if ($categories->isNotEmpty()) { + CatalogCategory::invalidateCatalogProductCache($catalog->company_uuid); + } } } diff --git a/server/tests/Support/ApiCacheTestHelpers.php b/server/tests/Support/ApiCacheTestHelpers.php new file mode 100644 index 00000000..97151e90 --- /dev/null +++ b/server/tests/Support/ApiCacheTestHelpers.php @@ -0,0 +1,14 @@ +setOutput($output); + $cartsVersion = apiQueryCacheVersion('carts'); expect($command->handle())->toBe(Command::SUCCESS) ->and($connection->table('carts')->orderBy('id')->pluck('name')->all())->toBe(['abandoned', 'checked-out', 'checkout-linked-empty', 'cleared', 'current']) - ->and($buffer->fetch())->toContain('Successfully deleted 3 expired carts.'); + ->and($buffer->fetch())->toContain('Successfully deleted 3 expired carts.') + // The purge spans companies, so the table wide cart cache version is advanced + ->and(apiQueryCacheVersion('carts'))->toBe($cartsVersion + 1); }); test('purge carts restores foreign key enforcement when deletion fails', function () { diff --git a/server/tests/Unit/Models/ApiCacheInvalidationTest.php b/server/tests/Unit/Models/ApiCacheInvalidationTest.php new file mode 100644 index 00000000..fac021e3 --- /dev/null +++ b/server/tests/Unit/Models/ApiCacheInvalidationTest.php @@ -0,0 +1,163 @@ +connection('mysql'); + $schema = $connection->getSchemaBuilder(); + + foreach (['network_stores', 'categories', 'catalog_subjects', 'catalog_category_products', 'transactions', 'transaction_items'] as $table) { + $schema->dropIfExists($table); + } + + $schema->create('network_stores', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('network_uuid')->nullable(); + $table->string('store_uuid')->nullable(); + $table->string('category_uuid')->nullable(); + $table->string('status')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('categories', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('owner_uuid')->nullable(); + $table->string('owner_type')->nullable(); + $table->string('name')->nullable(); + $table->string('for')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('catalog_subjects', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('catalog_uuid')->nullable(); + $table->string('subject_uuid')->nullable(); + $table->string('subject_type')->nullable(); + $table->string('created_by_uuid')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('catalog_category_products', function ($table) { + $table->string('uuid')->nullable(); + $table->string('catalog_category_uuid')->nullable(); + $table->string('product_uuid')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('transactions', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('transaction_items', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('transaction_uuid')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); +} + +test('network membership changes made through the query builder invalidate the cached network store lists', function () { + createApiCacheInvalidationSchema(); + session(['company' => 'company_network']); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('network_stores')->insert([ + ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_a', 'category_uuid' => 'category_uuid', 'status' => 'pending'], + ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_b', 'category_uuid' => 'category_uuid', 'status' => 'active'], + ]); + $connection->table('categories')->insert(['uuid' => 'category_uuid', 'owner_uuid' => 'network_uuid', 'name' => 'Food', 'for' => 'storefront_network']); + + $controller = new NetworkController(); + $setStatus = new ReflectionMethod($controller, 'setMembershipStatus'); + $setStatus->setAccessible(true); + $networkStores = apiQueryCacheVersion('network_stores', 'company_network'); + $stores = apiQueryCacheVersion('stores', 'company_network'); + + $setStatus->invoke($controller, 'network_uuid', ['store_a'], 'active'); + + expect($connection->table('network_stores')->where('store_uuid', 'store_a')->value('status'))->toBe('active') + ->and(apiQueryCacheVersion('network_stores', 'company_network'))->toBe($networkStores + 1) + ->and(apiQueryCacheVersion('stores', 'company_network'))->toBe($stores + 1); + + $categories = apiQueryCacheVersion('categories', 'company_network'); + $controller->deleteCategory('network_uuid', NetworkActionRequest::create('/', 'POST', ['category' => 'category_uuid'])); + + expect($connection->table('network_stores')->whereNotNull('category_uuid')->count())->toBe(0) + ->and(apiQueryCacheVersion('network_stores', 'company_network'))->toBe($networkStores + 2) + ->and(apiQueryCacheVersion('categories', 'company_network'))->toBe($categories + 1); +}); + +test('removing catalog assignments through the query builder invalidates the cached assignment lists', function () { + createApiCacheInvalidationSchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('catalog_subjects')->insert([ + 'uuid' => 'assignment_uuid', 'company_uuid' => 'company_catalog', 'catalog_uuid' => 'catalog_uuid', 'subject_uuid' => '60000000-0000-4000-8000-000000000001', 'subject_type' => FoodTruck::class, + ]); + $catalog = new Catalog(); + $catalog->forceFill(['uuid' => 'catalog_uuid', 'company_uuid' => 'company_catalog']); + + $controller = new CatalogController(); + $sync = new ReflectionMethod($controller, 'syncSubjects'); + $sync->setAccessible(true); + + // Nothing to remove: no invalidation + $before = apiQueryCacheVersion('catalog_subjects', 'company_catalog'); + $sync->invoke($controller, $catalog, Store::class, []); + expect(apiQueryCacheVersion('catalog_subjects', 'company_catalog'))->toBe($before); + + $sync->invoke($controller, $catalog, FoodTruck::class, []); + expect($connection->table('catalog_subjects')->whereNull('deleted_at')->count())->toBe(0) + ->and(apiQueryCacheVersion('catalog_subjects', 'company_catalog'))->toBe($before + 1); +}); + +test('deleting a catalog invalidates the cached lists embedding its category products', function () { + createApiCacheInvalidationSchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('categories')->insert(['uuid' => 'catalog_category_uuid', 'company_uuid' => 'company_catalog', 'owner_uuid' => 'catalog_uuid', 'name' => 'Mains', 'for' => 'storefront_catalog']); + $connection->table('catalog_category_products')->insert(['uuid' => 'pivot_uuid', 'catalog_category_uuid' => 'catalog_category_uuid', 'product_uuid' => 'product_uuid']); + $catalog = new Catalog(); + $catalog->forceFill(['uuid' => 'catalog_uuid', 'company_uuid' => 'company_catalog']); + + $before = apiQueryCacheVersion('catalog_category_products', 'company_catalog'); + (new CatalogObserver())->deleted($catalog); + + expect($connection->table('catalog_category_products')->whereNull('deleted_at')->count())->toBe(0) + ->and(apiQueryCacheVersion('catalog_category_products', 'company_catalog'))->toBe($before + 1); +}); + +test('discarding a failed capture invalidates the cached lists embedding its transaction items', function () { + createApiCacheInvalidationSchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('transactions')->insert(['uuid' => 'transaction_uuid', 'public_id' => 'transaction_1', 'company_uuid' => 'company_checkout']); + $connection->table('transaction_items')->insert(['uuid' => 'item_uuid', 'transaction_uuid' => 'transaction_uuid']); + $transaction = Transaction::where('uuid', 'transaction_uuid')->firstOrFail(); + + $before = apiQueryCacheVersion('transaction_items', 'company_checkout'); + $discard = new ReflectionMethod(CheckoutController::class, 'discardFailedCapture'); + $discard->setAccessible(true); + $discard->invoke(null, $transaction, []); + + expect($connection->table('transaction_items')->whereNull('deleted_at')->count())->toBe(0) + ->and(apiQueryCacheVersion('transaction_items', 'company_checkout'))->toBe($before + 1); +}); diff --git a/server/tests/Unit/Models/CatalogPriceOverrideTest.php b/server/tests/Unit/Models/CatalogPriceOverrideTest.php index 229451cd..79969fca 100644 --- a/server/tests/Unit/Models/CatalogPriceOverrideTest.php +++ b/server/tests/Unit/Models/CatalogPriceOverrideTest.php @@ -1,5 +1,7 @@ value('price'))->toBeNull(); }); +test('catalog product removals and override edits invalidate the cached lists embedding them', function () { + createCatalogOverrideSchema(); + $category = CatalogCategory::where('uuid', '80000000-0000-4000-8000-000000000001')->first(); + $category->setProducts(['90000000-0000-4000-8000-000000000001', '90000000-0000-4000-8000-000000000002']); + $version = apiQueryCacheVersion('catalog_category_products', 'company_uuid'); + + // Unchanged products and overrides write nothing + $category->setProducts(['90000000-0000-4000-8000-000000000001', '90000000-0000-4000-8000-000000000002']); + expect(apiQueryCacheVersion('catalog_category_products', 'company_uuid'))->toBe($version); + + // An override edit is a query builder update + $category->setProducts(['90000000-0000-4000-8000-000000000001', '90000000-0000-4000-8000-000000000002'], [ + '90000000-0000-4000-8000-000000000002' => ['price' => 1000], + ]); + expect(apiQueryCacheVersion('catalog_category_products', 'company_uuid'))->toBe($version + 1); + + // A removal is a query builder delete + $category->setProducts(['90000000-0000-4000-8000-000000000001']); + expect(CatalogProduct::where('catalog_category_uuid', $category->uuid)->count())->toBe(1) + ->and(apiQueryCacheVersion('catalog_category_products', 'company_uuid'))->toBe($version + 2); +}); + test('the catalog product resource serves the catalog price and availability instead of the store ones', function () { createCatalogOverrideSchema(); $category = CatalogCategory::where('uuid', '80000000-0000-4000-8000-000000000001')->first(); diff --git a/server/tests/Unit/Models/FoodTruckCatalogsTest.php b/server/tests/Unit/Models/FoodTruckCatalogsTest.php index 77ecc28a..c46b6124 100644 --- a/server/tests/Unit/Models/FoodTruckCatalogsTest.php +++ b/server/tests/Unit/Models/FoodTruckCatalogsTest.php @@ -1,5 +1,7 @@ setCatalogs(['70000000-0000-4000-8000-000000000002']); expect($truck->catalogs->pluck('name')->all())->toBe(['Lunch']); }); + +test('removing a truck\'s catalogs invalidates the cached lists embedding the assignments', function () { + createFoodTruckCatalogSchema(); + + $truck = new FoodTruck(); + $truck->forceFill(['uuid' => '60000000-0000-4000-8000-000000000001', 'company_uuid' => 'company_uuid']); + $truck->exists = true; + + $truck->setCatalogs(['70000000-0000-4000-8000-000000000001', '70000000-0000-4000-8000-000000000002']); + $version = apiQueryCacheVersion('catalog_subjects', 'company_uuid'); + + $truck->setCatalogs(['70000000-0000-4000-8000-000000000002']); + + expect($truck->catalogs->pluck('name')->all())->toBe(['Lunch']) + ->and(apiQueryCacheVersion('catalog_subjects', 'company_uuid'))->toBe($version + 1); +}); diff --git a/server/tests/Unit/Models/ProductBehaviorTest.php b/server/tests/Unit/Models/ProductBehaviorTest.php index 194a0d03..eb3eb3c9 100644 --- a/server/tests/Unit/Models/ProductBehaviorTest.php +++ b/server/tests/Unit/Models/ProductBehaviorTest.php @@ -1,5 +1,7 @@ table('product_addon_categories')->insert(['uuid' => $addonCategoryUuid, 'product_uuid' => 'product_uuid', 'category_uuid' => 'addon_category']); + $connection->table('product_variants')->insert(['uuid' => $variantUuid, 'product_uuid' => 'product_uuid', 'name' => 'Size']); + $connection->table('product_variant_options')->insert(['uuid' => $optionUuid, 'product_variant_uuid' => $variantUuid, 'name' => 'Small', 'additional_cost' => 0]); + + $product = new Product(); + $product->forceFill(['uuid' => 'product_uuid', 'company_uuid' => 'company_products']); + $addonCategories = apiQueryCacheVersion('product_addon_categories', 'company_products'); + $variants = apiQueryCacheVersion('product_variants', 'company_products'); + $options = apiQueryCacheVersion('product_variant_options', 'company_products'); + + $product->setAddonCategories([['uuid' => $addonCategoryUuid, 'category_uuid' => 'addon_category', 'max_selectable' => 2]]); + $product->setProductVariants([[ + 'uuid' => $variantUuid, + 'name' => 'Portion', + 'options' => [['uuid' => $optionUuid, 'name' => 'Half', 'additional_cost' => 100]], + ]]); + + expect($connection->table('product_variant_options')->where('uuid', $optionUuid)->value('name'))->toBe('Half') + ->and(apiQueryCacheVersion('product_addon_categories', 'company_products'))->toBe($addonCategories + 1) + ->and(apiQueryCacheVersion('product_variants', 'company_products'))->toBe($variants + 1) + ->and(apiQueryCacheVersion('product_variant_options', 'company_products'))->toBe($options + 1); +}); + test('product network search enforces network store availability status and limit filters', function () { createProductNetworkSearchSchema(); $connection = Capsule::connection('mysql'); diff --git a/server/tests/Unit/Models/StorefrontModelContractsTest.php b/server/tests/Unit/Models/StorefrontModelContractsTest.php index cc7cb6a0..9059e7ab 100644 --- a/server/tests/Unit/Models/StorefrontModelContractsTest.php +++ b/server/tests/Unit/Models/StorefrontModelContractsTest.php @@ -1,5 +1,7 @@ 'user_test']); $category = new Fleetbase\Storefront\Models\AddonCategory(); - $category->forceFill(['uuid' => 'category_test']); + $category->forceFill(['uuid' => 'category_test', 'company_uuid' => 'company_test']); $category->setAddons([[ 'name' => 'Gift wrap', 'description' => 'Wrapped carefully', @@ -549,6 +551,7 @@ public function first(): object ->and($created->price)->toBe(1250) ->and($created->sale_price)->toBe(1000); + $addonsVersion = apiQueryCacheVersion('product_addons', 'company_test'); $category->setAddons([[ 'uuid' => $addonUuid, 'name' => 'Premium gift wrap', @@ -557,6 +560,9 @@ public function first(): object 'is_on_sale' => false, ]]); + // The update goes through the query builder, so the cached lists are invalidated by hand + expect(apiQueryCacheVersion('product_addons', 'company_test'))->toBe($addonsVersion + 1); + $updated = Fleetbase\Storefront\Models\ProductAddon::query() ->where('uuid', $addonUuid) ->firstOrFail(); From fd048df5e2cc2737c3f167344fc8096d30f2c637 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 23:05:48 +0800 Subject: [PATCH 2/3] feat(cache): declare the tables cached storefront lists embed ApiModelCache caches list results with every relation eager loaded inside the query, but versioned them by the model's own table only, so a write to an embedded table (a truck's catalog assignments, a product's variants, a store's network membership) left the list stale. fleetbase/core-api#301 adds $apiCacheDependencies; until it is released the property is unused. - Catalog: categories, categories.products, hours, assignments, and the assignment subjects' tables (morphTo: stores, food_trucks). - Store: reviews (rating average), networks (membership filter/loading). logo and backdrop are left out: a new one changes the store row, and depending on the busy files table would only cost cache hits. - FoodTruck: store, store.networks, network, vehicle, vehicle.driver, serviceArea, serviceArea.zones, zone, catalogs. - Product: addonCategories(.category(.addons)), variants(.options), files (images added to a product do not touch its row), store, store.networks, catalogCategories (catalogs_count). - AddonCategory: addons ($with). A test walks every declared relation path, so a renamed relation fails here instead of being logged and skipped in production. --- server/src/Models/AddonCategory.php | 8 +++ server/src/Models/Catalog.php | 10 +++ server/src/Models/FoodTruck.php | 10 +++ server/src/Models/Product.php | 11 ++++ server/src/Models/Store.php | 12 ++++ .../Unit/Models/ApiCacheDependenciesTest.php | 61 +++++++++++++++++++ 6 files changed, 112 insertions(+) create mode 100644 server/tests/Unit/Models/ApiCacheDependenciesTest.php diff --git a/server/src/Models/AddonCategory.php b/server/src/Models/AddonCategory.php index d7526597..469c0d52 100644 --- a/server/src/Models/AddonCategory.php +++ b/server/src/Models/AddonCategory.php @@ -16,6 +16,14 @@ class AddonCategory extends Category */ protected $with = ['addons']; + /** + * Tables the cached addon category lists embed: the addons loaded with every category + * (read by core's ApiModelCache from fleetbase/core-api#301 on). + * + * @var string[] + */ + protected $apiCacheDependencies = ['addons']; + /** * The key to use in the payload responses. */ diff --git a/server/src/Models/Catalog.php b/server/src/Models/Catalog.php index 8db33be6..85422091 100644 --- a/server/src/Models/Catalog.php +++ b/server/src/Models/Catalog.php @@ -34,6 +34,16 @@ class Catalog extends StorefrontModel */ protected $table = 'catalogs'; + /** + * Tables the cached catalog lists embed, so writes to them invalidate those lists + * (read by core's ApiModelCache from fleetbase/core-api#301 on). The console list + * eager loads `categories.products`, `hours` and `assignments.subject`; the subject + * is a morphTo, so its tables are named. + * + * @var string[] + */ + protected $apiCacheDependencies = ['categories', 'categories.products', 'hours', 'assignments', 'stores', 'food_trucks']; + /** * Searchable columns. * diff --git a/server/src/Models/FoodTruck.php b/server/src/Models/FoodTruck.php index 6587a690..5ff3ee05 100644 --- a/server/src/Models/FoodTruck.php +++ b/server/src/Models/FoodTruck.php @@ -52,6 +52,16 @@ class FoodTruck extends StorefrontModel */ protected $table = 'food_trucks'; + /** + * Tables the cached food truck lists embed, so writes to them invalidate those lists + * (read by core's ApiModelCache from fleetbase/core-api#301 on): the relations the + * console and storefront lists eager load (a service area loads its zones), and the + * store's networks, which decide the trucks a network lists. + * + * @var string[] + */ + protected $apiCacheDependencies = ['store', 'store.networks', 'network', 'vehicle', 'vehicle.driver', 'serviceArea', 'serviceArea.zones', 'zone', 'catalogs']; + /** * The attributes that are mass assignable. * diff --git a/server/src/Models/Product.php b/server/src/Models/Product.php index e04d979b..03623776 100644 --- a/server/src/Models/Product.php +++ b/server/src/Models/Product.php @@ -52,6 +52,17 @@ class Product extends StorefrontModel */ protected $table = 'products'; + /** + * Tables the cached product lists embed, so writes to them invalidate those lists + * (read by core's ApiModelCache from fleetbase/core-api#301 on): what the storefront + * list eager loads (addon categories with their addons, variants with their options, + * files for the images, the store and its logo), the store's networks a network + * storefront filters by, and the catalog categories `catalogs_count` counts. + * + * @var string[] + */ + protected $apiCacheDependencies = ['addonCategories', 'addonCategories.category', 'addonCategories.category.addons', 'variants', 'variants.options', 'files', 'store', 'store.networks', 'catalogCategories']; + /** * The default database connection to use. * diff --git a/server/src/Models/Store.php b/server/src/Models/Store.php index 793d8c09..35263e3c 100644 --- a/server/src/Models/Store.php +++ b/server/src/Models/Store.php @@ -49,6 +49,18 @@ class Store extends StorefrontModel */ protected $table = 'stores'; + /** + * Tables the cached store lists embed, so writes to them invalidate those lists + * (read by core's ApiModelCache from fleetbase/core-api#301 on): the rating average + * over `reviews`, and `networks` (membership, status and category in `network_stores`), + * which the console list loads and filters by. Logo and backdrop are left out: a new + * one changes `logo_uuid`/`backdrop_uuid` on the store row itself, and file rows are + * not edited, so depending on the busy `files` table would only cost cache hits. + * + * @var string[] + */ + protected $apiCacheDependencies = ['reviews', 'networks']; + /** * These attributes that can be queried. * diff --git a/server/tests/Unit/Models/ApiCacheDependenciesTest.php b/server/tests/Unit/Models/ApiCacheDependenciesTest.php new file mode 100644 index 00000000..7583b9d2 --- /dev/null +++ b/server/tests/Unit/Models/ApiCacheDependenciesTest.php @@ -0,0 +1,61 @@ +setAccessible(true); + + return $property->getValue($model); +} + +test('cached models declare cache dependencies that are relation paths or storefront tables', function (string $modelClass, array $expected) { + /** @var Model $model */ + $model = new $modelClass(); + $declared = declaredApiCacheDependencies($model); + $tables = [(new Store())->getTable(), (new FoodTruck())->getTable()]; + + expect($declared)->toBe($expected); + + foreach ($declared as $path) { + $segments = explode('.', $path); + + // A single segment that is no method on the model is a table name (morphTo targets) + if (count($segments) === 1 && !method_exists($model, $path)) { + expect($tables)->toContain($path); + continue; + } + + $current = $model; + foreach ($segments as $segment) { + expect(method_exists($current, $segment))->toBeTrue("{$modelClass}: {$path} has no relation {$segment}"); + + $parent = $current; + $relation = Relation::noConstraints(fn () => $parent->newInstance()->{$segment}()); + + expect($relation)->toBeInstanceOf(Relation::class) + ->and($relation)->not->toBeInstanceOf(MorphTo::class); + + $current = $relation->getRelated(); + } + } +})->with([ + 'catalog' => [Catalog::class, ['categories', 'categories.products', 'hours', 'assignments', 'stores', 'food_trucks']], + 'store' => [Store::class, ['reviews', 'networks']], + 'food truck' => [FoodTruck::class, ['store', 'store.networks', 'network', 'vehicle', 'vehicle.driver', 'serviceArea', 'serviceArea.zones', 'zone', 'catalogs']], + 'product' => [Product::class, ['addonCategories', 'addonCategories.category', 'addonCategories.category.addons', 'variants', 'variants.options', 'files', 'store', 'store.networks', 'catalogCategories']], + 'addon category' => [AddonCategory::class, ['addons']], +]); From aa62d6579af30c575f1fef5fc33cbe6b63cdbcc5 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 12:55:00 +0800 Subject: [PATCH 3/3] fix(cache): invalidate product lists on product image changes, not on every upload Product declared files as a cache dependency, but every upload in a company writes the files table (proof of delivery photos, signatures, avatars), so product lists, the busiest public storefront endpoint, would rarely be served from cache. A FileObserver now invalidates product lists only when a file whose subject is (or was) a product is saved or deleted. --- server/src/Models/Product.php | 10 +++-- server/src/Observers/FileObserver.php | 40 +++++++++++++++++++ .../Providers/StorefrontServiceProvider.php | 3 +- .../Unit/Models/ApiCacheDependenciesTest.php | 2 +- .../Unit/Models/ApiCacheInvalidationTest.php | 35 ++++++++++++++++ 5 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 server/src/Observers/FileObserver.php diff --git a/server/src/Models/Product.php b/server/src/Models/Product.php index 03623776..f418c39b 100644 --- a/server/src/Models/Product.php +++ b/server/src/Models/Product.php @@ -56,12 +56,16 @@ class Product extends StorefrontModel * Tables the cached product lists embed, so writes to them invalidate those lists * (read by core's ApiModelCache from fleetbase/core-api#301 on): what the storefront * list eager loads (addon categories with their addons, variants with their options, - * files for the images, the store and its logo), the store's networks a network - * storefront filters by, and the catalog categories `catalogs_count` counts. + * the store), the store's networks a network storefront filters by, and the catalog + * categories `catalogs_count` counts. The images (`files`, and the store's logo) are + * left out: every upload in a company writes the files table, which would keep + * product lists from ever being served from cache. FileObserver invalidates product + * lists when a product's own files change instead, and a new store logo is written + * to the store row. * * @var string[] */ - protected $apiCacheDependencies = ['addonCategories', 'addonCategories.category', 'addonCategories.category.addons', 'variants', 'variants.options', 'files', 'store', 'store.networks', 'catalogCategories']; + protected $apiCacheDependencies = ['addonCategories', 'addonCategories.category', 'addonCategories.category.addons', 'variants', 'variants.options', 'store', 'store.networks', 'catalogCategories']; /** * The default database connection to use. diff --git a/server/src/Observers/FileObserver.php b/server/src/Observers/FileObserver.php new file mode 100644 index 00000000..4b0e61e6 --- /dev/null +++ b/server/src/Observers/FileObserver.php @@ -0,0 +1,40 @@ +invalidateProductLists($file); + } + + public function deleted(File $file): void + { + $this->invalidateProductLists($file); + } + + protected function invalidateProductLists(File $file): void + { + // The subject as written now and before this write, so moving a file off a product counts too + foreach ([$file->subject_type, $file->getOriginal('subject_type')] as $subjectType) { + if (is_string($subjectType) && is_a($subjectType, Product::class, true)) { + ApiModelCache::invalidateModelCache(new Product(), $file->company_uuid); + + return; + } + } + } +} diff --git a/server/src/Providers/StorefrontServiceProvider.php b/server/src/Providers/StorefrontServiceProvider.php index 937912b0..4e642b4a 100644 --- a/server/src/Providers/StorefrontServiceProvider.php +++ b/server/src/Providers/StorefrontServiceProvider.php @@ -36,7 +36,8 @@ class StorefrontServiceProvider extends CoreServiceProvider \Fleetbase\Storefront\Models\FoodTruck::class => \Fleetbase\Storefront\Observers\FoodTruckObserver::class, \Fleetbase\Models\Company::class => \Fleetbase\Storefront\Observers\CompanyObserver::class, \Fleetbase\Models\ChatMessage::class => \Fleetbase\Storefront\Observers\ChatMessageObserver::class, - \Fleetbase\FleetOps\Models\Order::class => \Fleetbase\Storefront\Observers\OrderActivityObserver::class, + \Fleetbase\Models\File::class => \Fleetbase\Storefront\Observers\FileObserver::class, + \Fleetbase\FleetOps\Models\Order::class => \Fleetbase\Storefront\Observers\OrderActivityObserver::class, ]; /** diff --git a/server/tests/Unit/Models/ApiCacheDependenciesTest.php b/server/tests/Unit/Models/ApiCacheDependenciesTest.php index 7583b9d2..4d08fc1b 100644 --- a/server/tests/Unit/Models/ApiCacheDependenciesTest.php +++ b/server/tests/Unit/Models/ApiCacheDependenciesTest.php @@ -56,6 +56,6 @@ function declaredApiCacheDependencies(Model $model): array 'catalog' => [Catalog::class, ['categories', 'categories.products', 'hours', 'assignments', 'stores', 'food_trucks']], 'store' => [Store::class, ['reviews', 'networks']], 'food truck' => [FoodTruck::class, ['store', 'store.networks', 'network', 'vehicle', 'vehicle.driver', 'serviceArea', 'serviceArea.zones', 'zone', 'catalogs']], - 'product' => [Product::class, ['addonCategories', 'addonCategories.category', 'addonCategories.category.addons', 'variants', 'variants.options', 'files', 'store', 'store.networks', 'catalogCategories']], + 'product' => [Product::class, ['addonCategories', 'addonCategories.category', 'addonCategories.category.addons', 'variants', 'variants.options', 'store', 'store.networks', 'catalogCategories']], 'addon category' => [AddonCategory::class, ['addons']], ]); diff --git a/server/tests/Unit/Models/ApiCacheInvalidationTest.php b/server/tests/Unit/Models/ApiCacheInvalidationTest.php index fac021e3..37b65097 100644 --- a/server/tests/Unit/Models/ApiCacheInvalidationTest.php +++ b/server/tests/Unit/Models/ApiCacheInvalidationTest.php @@ -11,6 +11,8 @@ use Fleetbase\Storefront\Models\FoodTruck; use Fleetbase\Storefront\Models\Store; use Fleetbase\Storefront\Observers\CatalogObserver; +use Fleetbase\Storefront\Observers\FileObserver; +use Fleetbase\Storefront\Providers\StorefrontServiceProvider; use Illuminate\Database\Eloquent\Model; function createApiCacheInvalidationSchema(): void @@ -161,3 +163,36 @@ function createApiCacheInvalidationSchema(): void expect($connection->table('transaction_items')->whereNull('deleted_at')->count())->toBe(0) ->and(apiQueryCacheVersion('transaction_items', 'company_checkout'))->toBe($before + 1); }); + +test('product image changes invalidate the cached product lists, while other uploads leave them cached', function () { + $observer = new FileObserver(); + $before = apiQueryCacheVersion('products', 'company_files'); + + // An upload for anything else in the company + $upload = new Fleetbase\Models\File(); + $upload->forceFill(['uuid' => 'file_other', 'company_uuid' => 'company_files', 'subject_uuid' => 'user_uuid', 'subject_type' => Fleetbase\Models\User::class]); + $observer->saved($upload); + expect(apiQueryCacheVersion('products', 'company_files'))->toBe($before); + + // A product image attached, then deleted + $image = new Fleetbase\Models\File(); + $image->forceFill(['uuid' => 'file_image', 'company_uuid' => 'company_files', 'subject_uuid' => 'product_uuid', 'subject_type' => Fleetbase\Storefront\Models\Product::class]); + $observer->saved($image); + expect(apiQueryCacheVersion('products', 'company_files'))->toBe($before + 1); + + $observer->deleted($image); + expect(apiQueryCacheVersion('products', 'company_files'))->toBe($before + 2); + + // An image moved off a product + $image->syncOriginal(); + $image->forceFill(['subject_uuid' => 'user_uuid', 'subject_type' => Fleetbase\Models\User::class]); + $observer->saved($image); + expect(apiQueryCacheVersion('products', 'company_files'))->toBe($before + 3); +}); + +test('the storefront service provider observes files for product images', function () { + $observers = (new ReflectionProperty(StorefrontServiceProvider::class, 'observers'))->getDefaultValue(); + + expect($observers)->toHaveKey(Fleetbase\Models\File::class) + ->and($observers[Fleetbase\Models\File::class])->toBe(FileObserver::class); +});