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..469c0d52 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 @@ -15,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. */ @@ -42,6 +51,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 +70,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 +81,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/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/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..5ff3ee05 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; @@ -51,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. * @@ -216,6 +227,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..f418c39b 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; @@ -51,6 +52,21 @@ 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, + * 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', 'store', 'store.networks', 'catalogCategories']; + /** * The default database connection to use. * @@ -364,6 +380,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 +396,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 +404,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 +427,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 +444,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 +464,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 +491,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/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/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/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/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/ApiCacheDependenciesTest.php b/server/tests/Unit/Models/ApiCacheDependenciesTest.php new file mode 100644 index 00000000..4d08fc1b --- /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', '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 new file mode 100644 index 00000000..37b65097 --- /dev/null +++ b/server/tests/Unit/Models/ApiCacheInvalidationTest.php @@ -0,0 +1,198 @@ +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); +}); + +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); +}); 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();