diff --git a/RELEASE.md b/RELEASE.md index c398fce9..254b05d3 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,15 +1,21 @@ -# v1.6.61 — Faster IAM user authorization loading +# v1.6.62 — Custom fields get a public id ## Improvements -- Reduce repeated database queries when listing IAM users by loading roles, policies, and permissions in batches and reading each user's primary role once. -- Match authorization to each user's company membership, including users belonging to multiple companies and system administrators viewing users across companies. User response fields remain unchanged. +- Give every custom field a public id, so an API that hands one out names it the way the rest of the platform names a resource rather than exposing an internal uuid. `CustomField` takes `HasPublicId` with the `custom_field` prefix, and `public_id` becomes fillable. +- Mint an id on the one path that would otherwise miss it: `HasCustomFields::setCustomField()` saves a field it creates on the fly with `saveQuietly()`, which skips the hook that assigns the id. + +## Fixes + +- Let an observer's refusal reach the caller on the update and bulk-delete paths. An observer that refused a write by throwing `FleetbaseRequestValidationException` had its explanation discarded: `HasApiModelBehavior::updateRecordFromRequest()` rewrapped every exception from the save as a plain `\Exception`, and `HasApiControllerBehavior::bulkDelete()` caught `\Exception` ahead of its dedicated handler, so callers saw `Invalid request` or a generic update error instead of the message the observer wrote. The exception now passes through untouched on both paths and is rendered with `getErrors()`, as it already was on create and single delete. Every other exception is wrapped exactly as before. Reported in [#256](https://github.com/fleetbase/core-api/issues/256). ## Reliability -- Add database-backed coverage for company isolation, missing and deleted memberships, recovery after a membership was initially absent, and matching responses between lazy and eager loading. -- Enable PHP CI and Postman checks for `release/v*` branches and support release tagging from both `release/v*` and `dev-v*` branches. +- Backfill existing rows in the migration, and add the column as nullable and indexed rather than unique-and-required, so it is safe on an already-populated `custom_fields` table. +- Cover id generation for `CustomField`, and add the column to the in-memory schemas whose saves now probe it for uniqueness. + +This is platform-wide: every custom field gains a public id, not only those used by inspections. Nothing reads the new column yet — `withCustomFields()`'s public projection emits field names and is unchanged — so the change is additive for existing consumers. -No database migration or configuration change is required. +A database migration is required. No configuration change is needed. -Changes: [#251](https://github.com/fleetbase/core-api/pull/251), [#250](https://github.com/fleetbase/core-api/pull/250), and release-branch CI updates in [#252](https://github.com/fleetbase/core-api/pull/252). +Changes: [#254](https://github.com/fleetbase/core-api/pull/254), [#259](https://github.com/fleetbase/core-api/pull/259). diff --git a/composer.json b/composer.json index 9d9aa28d..ecfbb64a 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/core-api", - "version": "1.6.61", + "version": "1.6.62", "description": "Core Framework and Resources for Fleetbase API", "keywords": [ "fleetbase", diff --git a/migrations/2026_09_14_000000_add_public_id_to_custom_fields_table.php b/migrations/2026_09_14_000000_add_public_id_to_custom_fields_table.php new file mode 100644 index 00000000..78f861e1 --- /dev/null +++ b/migrations/2026_09_14_000000_add_public_id_to_custom_fields_table.php @@ -0,0 +1,46 @@ +string('public_id', 191)->nullable()->after('uuid')->index(); + }); + + CustomField::withTrashed()->whereNull('public_id')->get()->each(function (CustomField $field) { + $field->update(['public_id' => CustomField::generatePublicId('custom_field')]); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (!Schema::hasTable('custom_fields') || !Schema::hasColumn('custom_fields', 'public_id')) { + return; + } + + Schema::table('custom_fields', function (Blueprint $table) { + $table->dropIndex(['public_id']); + $table->dropColumn(['public_id']); + }); + } +}; diff --git a/migrations/2026_09_15_000000_add_snooze_and_assignment_to_alerts_table.php b/migrations/2026_09_15_000000_add_snooze_and_assignment_to_alerts_table.php new file mode 100644 index 00000000..635dfd2e --- /dev/null +++ b/migrations/2026_09_15_000000_add_snooze_and_assignment_to_alerts_table.php @@ -0,0 +1,70 @@ +timestamp('snoozed_until')->nullable()->index()->after('acknowledged_at'); + } + + if (!Schema::hasColumn('alerts', 'snoozed_by_uuid')) { + $table->foreignUuid('snoozed_by_uuid')->nullable()->after('resolved_by_uuid')->constrained('users', 'uuid')->nullOnDelete(); + } + + if (!Schema::hasColumn('alerts', 'assigned_to_uuid')) { + $table->foreignUuid('assigned_to_uuid')->nullable()->after('snoozed_by_uuid')->constrained('users', 'uuid')->nullOnDelete(); + } + + if (!Schema::hasColumn('alerts', 'planned_at')) { + $table->timestamp('planned_at')->nullable()->index()->after('snoozed_until'); + } + }); + + Schema::table('alerts', function (Blueprint $table) { + $table->index(['company_uuid', 'status', 'snoozed_until'], 'alerts_company_status_snoozed_index'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('alerts', function (Blueprint $table) { + $table->dropIndex('alerts_company_status_snoozed_index'); + }); + + Schema::table('alerts', function (Blueprint $table) { + foreach (['snoozed_by_uuid', 'assigned_to_uuid'] as $column) { + if (Schema::hasColumn('alerts', $column)) { + $table->dropConstrainedForeignId($column); + } + } + + foreach (['planned_at', 'snoozed_until'] as $column) { + if (Schema::hasColumn('alerts', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; diff --git a/src/Models/Alert.php b/src/Models/Alert.php index b84a5f3a..6c49044f 100644 --- a/src/Models/Alert.php +++ b/src/Models/Alert.php @@ -81,6 +81,10 @@ class Alert extends Model 'resolved_at', 'acknowledged_by_uuid', 'resolved_by_uuid', + 'snoozed_until', + 'snoozed_by_uuid', + 'assigned_to_uuid', + 'planned_at', 'meta', ]; @@ -93,8 +97,10 @@ class Alert extends Model 'subject_name', 'acknowledged_by_name', 'resolved_by_name', + 'assigned_to_name', 'is_acknowledged', 'is_resolved', + 'is_snoozed', 'duration_minutes', 'age_minutes', ]; @@ -104,7 +110,7 @@ class Alert extends Model * * @var array */ - protected $hidden = ['subject', 'acknowledgedBy', 'resolvedBy']; + protected $hidden = ['subject', 'acknowledgedBy', 'resolvedBy', 'snoozedBy', 'assignedTo']; /** * The attributes that should be cast to native types. @@ -117,6 +123,8 @@ class Alert extends Model 'triggered_at' => 'datetime', 'acknowledged_at' => 'datetime', 'resolved_at' => 'datetime', + 'snoozed_until' => 'datetime', + 'planned_at' => 'datetime', 'meta' => Json::class, ]; @@ -159,6 +167,16 @@ public function resolvedBy(): BelongsTo return $this->belongsTo(User::class, 'resolved_by_uuid', 'uuid'); } + public function snoozedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'snoozed_by_uuid', 'uuid'); + } + + public function assignedTo(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to_uuid', 'uuid'); + } + public function subject(): MorphTo { return $this->morphTo(); @@ -192,6 +210,22 @@ public function getResolvedByNameAttribute(): ?string return $this->resolvedBy?->name; } + /** + * Get the name of the user the alert is assigned to. + */ + public function getAssignedToNameAttribute(): ?string + { + return $this->assignedTo?->name; + } + + /** + * Whether the alert is snoozed right now. + */ + public function getIsSnoozedAttribute(): bool + { + return $this->isSnoozed(); + } + /** * Check if the alert has been acknowledged. */ @@ -302,6 +336,32 @@ public function scopeUnacknowledged($query) return $query->whereNull('acknowledged_at'); } + /** + * Scope to alerts whose snooze has not ended yet. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeSnoozed($query) + { + return $query->whereNotNull('snoozed_until')->where('snoozed_until', '>', now()); + } + + /** + * Scope to alerts that still need someone: not resolved and not snoozed. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeActive($query) + { + return $query->where('status', '!=', 'resolved')->where(function ($query) { + $query->whereNull('snoozed_until')->orWhere('snoozed_until', '<=', now()); + }); + } + /** * Scope to get critical alerts. * @@ -337,10 +397,16 @@ public function acknowledge(?User $user = null): bool $user = $user ?? auth()->user(); - $updated = $this->update([ + $updateData = [ 'acknowledged_at' => now(), 'acknowledged_by_uuid' => $user?->uuid, - ]); + ]; + + if ($this->status === 'open') { + $updateData['status'] = 'acknowledged'; + } + + $updated = $this->update($updateData); if ($updated) { activity('alert_acknowledged') @@ -438,16 +504,25 @@ public function escalate(string $newSeverity, ?string $reason = null): bool /** * Snooze the alert for a specified duration. + * + * The wake time lives in `snoozed_until` so a queue can exclude snoozed + * alerts in the query; the reason stays in `meta` as before. */ - public function snooze(int $minutes, ?string $reason = null): bool + public function snooze(int $minutes, ?string $reason = null, ?User $user = null): bool { $snoozeUntil = now()->addMinutes($minutes); + $actor = auth()->user(); + $user = $user ?? ($actor instanceof User ? $actor : null); $meta = $this->meta ?? []; - $meta['snoozed_until'] = $snoozeUntil; $meta['snooze_reason'] = $reason; + unset($meta['snoozed_until']); - $updated = $this->update(['meta' => $meta]); + $updated = $this->update([ + 'snoozed_until' => $snoozeUntil, + 'snoozed_by_uuid' => $user?->uuid, + 'meta' => $meta, + ]); if ($updated) { activity('alert_snoozed') @@ -456,6 +531,7 @@ public function snooze(int $minutes, ?string $reason = null): bool 'snoozed_for_minutes' => $minutes, 'snoozed_until' => $snoozeUntil, 'reason' => $reason, + 'snoozed_by' => $user?->name, ]) ->log('Alert snoozed'); } @@ -463,13 +539,55 @@ public function snooze(int $minutes, ?string $reason = null): bool return $updated; } + /** + * End a snooze early so the alert is active again. + */ + public function unsnooze(): bool + { + if (!$this->snoozed_until) { + return false; + } + + $updated = $this->update([ + 'snoozed_until' => null, + 'snoozed_by_uuid' => null, + ]); + + if ($updated) { + activity('alert_unsnoozed') + ->performedOn($this) + ->log('Alert snooze ended'); + } + + return $updated; + } + + /** + * Give the alert an owner (or clear it with null). + */ + public function assignTo(?User $user): bool + { + $updated = $this->update(['assigned_to_uuid' => $user?->uuid]); + + if ($updated) { + activity('alert_assigned') + ->performedOn($this) + ->withProperties(['assigned_to' => $user?->name]) + ->log($user ? 'Alert assigned' : 'Alert unassigned'); + } + + return $updated; + } + /** * Check if the alert is currently snoozed. + * + * Reads the column, falling back to the `meta.snoozed_until` value older + * rows were written with before the column existed. */ public function isSnoozed(): bool { - $meta = $this->meta ?? []; - $snoozeUntil = $meta['snoozed_until'] ?? null; + $snoozeUntil = $this->snoozed_until ?? ($this->meta['snoozed_until'] ?? null); if (!$snoozeUntil) { return false; diff --git a/src/Models/CustomField.php b/src/Models/CustomField.php index 1f0d72e1..3796f864 100644 --- a/src/Models/CustomField.php +++ b/src/Models/CustomField.php @@ -5,11 +5,13 @@ use Fleetbase\Casts\Json; use Fleetbase\Casts\PolymorphicType; use Fleetbase\Traits\HasApiModelBehavior; +use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; class CustomField extends Model { use HasUuid; + use HasPublicId; use HasApiModelBehavior; /** @@ -19,12 +21,19 @@ class CustomField extends Model */ protected $table = 'custom_fields'; + /** + * The type of public Id to generate. + * + * @var string + */ + protected $publicIdType = 'custom_field'; + /** * The attributes that are mass assignable. * * @var array */ - protected $fillable = ['company_uuid', 'category_uuid', 'subject_uuid', 'subject_type', 'name', 'label', 'type', 'for', 'component', 'options', 'required', 'editable', 'default_value', 'validation_rules', 'meta', 'description', 'help_text', 'order']; + protected $fillable = ['public_id', 'company_uuid', 'category_uuid', 'subject_uuid', 'subject_type', 'name', 'label', 'type', 'for', 'component', 'options', 'required', 'editable', 'default_value', 'validation_rules', 'meta', 'description', 'help_text', 'order']; /** * The attributes that are guarded. diff --git a/src/Traits/HasApiControllerBehavior.php b/src/Traits/HasApiControllerBehavior.php index a6167cbd..79f80331 100644 --- a/src/Traits/HasApiControllerBehavior.php +++ b/src/Traits/HasApiControllerBehavior.php @@ -627,17 +627,13 @@ public function bulkDelete(BulkDeleteRequest $request) try { $count = $this->model->bulkRemove($ids); - } catch (\Exception $e) { - return response()->error($e->getMessage()); - // QueryException and FleetbaseRequestValidationException are covered by - // the preceding Exception catch in PHP's current catch order. - // @codeCoverageIgnoreStart - } catch (QueryException $e) { - return response()->error($e->getMessage()); } catch (FleetbaseRequestValidationException $e) { return response()->error($e->getErrors()); + } catch (QueryException $e) { + return response()->error($e->getMessage()); + } catch (\Exception $e) { + return response()->error($e->getMessage()); } - // @codeCoverageIgnoreEnd return response()->json( [ diff --git a/src/Traits/HasApiModelBehavior.php b/src/Traits/HasApiModelBehavior.php index e1a8bc89..2161f7cf 100644 --- a/src/Traits/HasApiModelBehavior.php +++ b/src/Traits/HasApiModelBehavior.php @@ -2,6 +2,7 @@ namespace Fleetbase\Traits; +use Fleetbase\Exceptions\FleetbaseRequestValidationException; use Fleetbase\Support\ApiModelCache; use Fleetbase\Support\Auth; use Fleetbase\Support\Http; @@ -416,6 +417,10 @@ public function updateRecordFromRequest(Request $request, $id, ?callable $onBefo $input = Arr::except($input, ['uuid', 'public_id', 'deleted_at', 'updated_at', 'created_at']); try { $record->update($input); + } catch (FleetbaseRequestValidationException $e) { + // An observer refusing the write is user-facing feedback, not an internal failure. + // Let it through untouched so the controller and global handler can render getErrors(). + throw $e; } catch (\Exception $e) { throw new \Exception(app()->hasDebugModeEnabled() ? $e->getMessage() : 'Failed to update ' . $this->getApiHumanReadableName()); } diff --git a/src/Traits/HasCustomFields.php b/src/Traits/HasCustomFields.php index 7852c84a..e770a48f 100644 --- a/src/Traits/HasCustomFields.php +++ b/src/Traits/HasCustomFields.php @@ -317,7 +317,12 @@ public function setCustomFieldValue(string|CustomField $fieldOrKey, mixed $value 'subject_uuid' => $this->getAttribute('uuid'), 'company_uuid' => $this->getAttribute('company_uuid') ?? session('company'), ]); - $field->forceFill(['uuid' => CustomField::generateUuid()]); + // A quiet save skips the `creating` hook that mints a public id, + // so a field created on the fly would be the only one without one. + $field->forceFill([ + 'uuid' => CustomField::generateUuid(), + 'public_id' => CustomField::generatePublicId('custom_field'), + ]); method_exists($field, 'saveQuietly') ? $field->saveQuietly() : $field->save(); // bust definition cache for subsequent lookups $this->customFieldCache = []; diff --git a/tests/Unit/Models/OperationalModelsTest.php b/tests/Unit/Models/OperationalModelsTest.php index 1c72b4fd..4978d81d 100644 --- a/tests/Unit/Models/OperationalModelsTest.php +++ b/tests/Unit/Models/OperationalModelsTest.php @@ -201,6 +201,10 @@ public function clear(): bool $table->timestamp('resolved_at')->nullable(); $table->string('acknowledged_by_uuid')->nullable(); $table->string('resolved_by_uuid')->nullable(); + $table->string('snoozed_by_uuid')->nullable(); + $table->string('assigned_to_uuid')->nullable(); + $table->timestamp('snoozed_until')->nullable(); + $table->timestamp('planned_at')->nullable(); $table->text('meta')->nullable(); $table->timestamps(); $table->softDeletes(); @@ -292,8 +296,12 @@ public function clear(): bool expect($alert->getActivitylogOptions()->logAttributes)->toBe(['*']) ->and($alert->acknowledgedBy()->getForeignKeyName())->toBe('acknowledged_by_uuid') ->and($alert->resolvedBy()->getForeignKeyName())->toBe('resolved_by_uuid') + ->and($alert->snoozedBy()->getForeignKeyName())->toBe('snoozed_by_uuid') + ->and($alert->assignedTo()->getForeignKeyName())->toBe('assigned_to_uuid') ->and($alert->subject()->getMorphType())->toBe('subject_type') ->and($alert->subject_name)->toBeNull() + ->and($alert->assigned_to_name)->toBeNull() + ->and($alert->is_snoozed)->toBeFalse() ->and($alert->duration_minutes)->toBeNull() ->and($alert->age_minutes)->toBe(15) ->and($alert->isSnoozed())->toBeFalse() @@ -326,6 +334,7 @@ public function clear(): bool expect($alert->acknowledge($user))->toBeTrue() ->and($alert->updates[0]['acknowledged_at']->toISOString())->toBe('2026-07-17T12:00:00.000000Z') ->and($alert->updates[0]['acknowledged_by_uuid'])->toBe('user-1') + ->and($alert->updates[0]['status'])->toBe('acknowledged') ->and($alert->acknowledge($user))->toBeFalse() ->and($alert->resolve($user, 'Sensor recalibrated'))->toBeTrue() ->and($alert->updates[1]['status'])->toBe('resolved') @@ -350,9 +359,79 @@ public function clear(): bool ->and($escalatingAlert->updates[0]['meta']['escalation_history'][0]['from'])->toBe('low') ->and($escalatingAlert->updates[0]['meta']['escalation_history'][0]['to'])->toBe('medium') ->and($escalatingAlert->updates[0]['meta']['escalation_history'][0]['escalated_by'])->toBe('session-user') - ->and($escalatingAlert->snooze(30, 'Awaiting technician'))->toBeTrue() + ->and($escalatingAlert->snooze(30, 'Awaiting technician', $user))->toBeTrue() ->and($escalatingAlert->updates[1]['meta']['snooze_reason'])->toBe('Awaiting technician') - ->and($escalatingAlert->isSnoozed())->toBeTrue(); + ->and($escalatingAlert->updates[1]['snoozed_until']->toISOString())->toBe('2026-07-17T12:30:00.000000Z') + ->and($escalatingAlert->updates[1]['snoozed_by_uuid'])->toBe('user-1') + ->and($escalatingAlert->isSnoozed())->toBeTrue() + ->and($escalatingAlert->is_snoozed)->toBeTrue() + ->and($escalatingAlert->unsnooze())->toBeTrue() + ->and($escalatingAlert->updates[2])->toBe(['snoozed_until' => null, 'snoozed_by_uuid' => null]) + ->and($escalatingAlert->isSnoozed())->toBeFalse() + ->and($escalatingAlert->unsnooze())->toBeFalse() + ->and($escalatingAlert->assignTo($user))->toBeTrue() + ->and($escalatingAlert->updates[3])->toBe(['assigned_to_uuid' => 'user-1']) + ->and($escalatingAlert->assignTo(null))->toBeTrue() + ->and($escalatingAlert->updates[4])->toBe(['assigned_to_uuid' => null]); + + // Rows written before the column existed kept the wake time in meta. + $legacyAlert = new Alert(); + $legacyAlert->setRawAttributes([ + 'uuid' => 'alert-legacy', + 'status' => 'open', + 'meta' => ['snoozed_until' => '2026-07-17 13:00:00'], + ], true); + + expect($legacyAlert->isSnoozed())->toBeTrue(); + + Carbon::setTestNow(); +}); + +it('filters alerts through the snoozed and active scopes', function () { + $capsule = operational_models_database(); + Carbon::setTestNow(Carbon::parse('2026-07-17 12:00:00', 'UTC')); + + $capsule->getConnection('mysql')->table('alerts')->insert([ + [ + 'uuid' => 'alert-open', + 'type' => 'temperature', + 'severity' => 'high', + 'status' => 'open', + 'snoozed_until' => null, + 'created_at' => '2026-07-17 10:00:00', + 'updated_at' => '2026-07-17 10:00:00', + ], + [ + 'uuid' => 'alert-snoozed', + 'type' => 'temperature', + 'severity' => 'high', + 'status' => 'acknowledged', + 'snoozed_until' => '2026-07-17 14:00:00', + 'created_at' => '2026-07-17 10:00:00', + 'updated_at' => '2026-07-17 10:00:00', + ], + [ + 'uuid' => 'alert-woken', + 'type' => 'temperature', + 'severity' => 'high', + 'status' => 'open', + 'snoozed_until' => '2026-07-17 11:00:00', + 'created_at' => '2026-07-17 10:00:00', + 'updated_at' => '2026-07-17 10:00:00', + ], + [ + 'uuid' => 'alert-resolved', + 'type' => 'temperature', + 'severity' => 'high', + 'status' => 'resolved', + 'snoozed_until' => null, + 'created_at' => '2026-07-17 10:00:00', + 'updated_at' => '2026-07-17 10:00:00', + ], + ]); + + expect(Alert::query()->snoozed()->pluck('uuid')->all())->toBe(['alert-snoozed']) + ->and(Alert::query()->active()->pluck('uuid')->all())->toBe(['alert-open', 'alert-woken']); Carbon::setTestNow(); }); diff --git a/tests/Unit/Models/RecordModelsTest.php b/tests/Unit/Models/RecordModelsTest.php index dce43c1d..e0351db8 100644 --- a/tests/Unit/Models/RecordModelsTest.php +++ b/tests/Unit/Models/RecordModelsTest.php @@ -126,6 +126,30 @@ public function clear(): bool $table->timestamps(); $table->softDeletes(); }); + $schema->create('custom_fields', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable()->unique(); + $table->string('company_uuid')->nullable(); + $table->string('category_uuid')->nullable(); + $table->string('subject_uuid')->nullable(); + $table->string('subject_type')->nullable(); + $table->string('name')->nullable(); + $table->string('label')->nullable(); + $table->string('type')->nullable(); + $table->string('for')->nullable(); + $table->string('component')->nullable(); + $table->text('options')->nullable(); + $table->boolean('required')->default(false); + $table->boolean('editable')->default(true); + $table->text('default_value')->nullable(); + $table->text('validation_rules')->nullable(); + $table->text('meta')->nullable(); + $table->text('description')->nullable(); + $table->text('help_text')->nullable(); + $table->integer('order')->default(0); + $table->timestamps(); + $table->softDeletes(); + }); $schema->create('user_devices', function ($table) { $table->string('uuid')->primary(); $table->string('public_id')->nullable()->unique(); @@ -249,6 +273,21 @@ public function clear(): bool ]); }); +it('generates custom field public ids', function () { + record_models_database(); + + $field = CustomField::query()->create([ + 'company_uuid' => 'company-1', + 'name' => 'brakes', + 'label' => 'Brakes', + 'type' => 'pass-fail', + ]); + + expect($field->public_id)->toStartWith('custom_field_') + ->and($field->public_id)->toHaveLength(strlen('custom_field_') + 10) + ->and($field->uuid)->not->toBeNull(); +}); + it('casts custom field configuration values and keeps relationship keys stable', function () { record_models_database(); diff --git a/tests/Unit/Traits/HasApiControllerBehaviorTest.php b/tests/Unit/Traits/HasApiControllerBehaviorTest.php index 31837c30..8ef2feef 100644 --- a/tests/Unit/Traits/HasApiControllerBehaviorTest.php +++ b/tests/Unit/Traits/HasApiControllerBehaviorTest.php @@ -1,5 +1,6 @@ and($updateQueryFailure->getData(true))->toBe(['errors' => ['Error occurred while trying to update a Widget']]); }); +test('api controller behavior surfaces observer refusals on update and bulk delete', function () { + $refusingModel = new class extends HasApiControllerBehaviorModel { + public function updateRecordFromRequest(Request $request, $id, ?callable $onBefore = null, ?callable $onAfter = null): self + { + throw new FleetbaseRequestValidationException(['Widget is locked and cannot be changed.']); + } + + public function bulkRemove(array $ids): int + { + throw new FleetbaseRequestValidationException(['Widget is locked and cannot be deleted.']); + } + }; + $queryFailingModel = new class extends HasApiControllerBehaviorModel { + public function bulkRemove(array $ids): int + { + throw new QueryException('mysql', 'delete from widgets', [], new RuntimeException('database unavailable')); + } + }; + + $refusingController = new HasApiControllerBehaviorController($refusingModel); + $updateRefusal = $refusingController->updateRecord(has_api_controller_behavior_request('/v1/widgets/widget-1', 'PATCH', ['name' => 'Nope']), 'widget-1'); + $bulkDeleteRefusal = $refusingController->bulkDelete(BulkDeleteRequest::create('/v1/widgets/bulk-delete', 'DELETE', ['ids' => ['widget-1']])); + $bulkDeleteQuery = (new HasApiControllerBehaviorController($queryFailingModel))->bulkDelete( + BulkDeleteRequest::create('/v1/widgets/bulk-delete', 'DELETE', ['ids' => ['widget-1']]) + ); + + expect($updateRefusal->getData(true))->toBe(['errors' => ['Widget is locked and cannot be changed.']]) + ->and($bulkDeleteRefusal->getData(true))->toBe(['errors' => ['Widget is locked and cannot be deleted.']]) + ->and($bulkDeleteQuery->getData(true)['errors'][0])->toContain('database unavailable'); +}); + test('api controller behavior validates fallback rule contracts before writing', function () { $controller = new HasApiControllerBehaviorController(); $controller->rules = ['name' => ['required']]; diff --git a/tests/Unit/Traits/HasApiModelBehaviorTest.php b/tests/Unit/Traits/HasApiModelBehaviorTest.php index d034aaff..f3b4f071 100644 --- a/tests/Unit/Traits/HasApiModelBehaviorTest.php +++ b/tests/Unit/Traits/HasApiModelBehaviorTest.php @@ -1,5 +1,6 @@ and(fn () => (new HasApiModelBehaviorRecord())->remove('record_alpha'))->toThrow(Exception::class); }); +test('api model behavior propagates observer update refusals without rewrapping them', function () { + $capsule = has_api_model_behavior_database(); + has_api_model_behavior_seed_records($capsule); + session(['company' => 'company-a']); + + $refusals = []; + foreach ([true, false] as $debug) { + config(['app.debug' => $debug]); + + try { + (new HasApiModelBehaviorRefusingUpdateRecord())->updateRecordFromRequest( + has_api_model_behavior_request(['name' => 'Refused update'], method: 'PATCH'), + 'record_alpha' + ); + } catch (Exception $exception) { + $refusals[] = $exception; + } + } + + expect($refusals)->toHaveCount(2) + ->and($refusals[0])->toBeInstanceOf(FleetbaseRequestValidationException::class) + ->and($refusals[0]->getErrors())->toBe(['Record is locked and cannot be updated.']) + ->and($refusals[1])->toBeInstanceOf(FleetbaseRequestValidationException::class) + ->and($refusals[1]->getErrors())->toBe(['Record is locked and cannot be updated.']) + ->and($capsule->getConnection('mysql')->table('api_model_behavior_records')->where('public_id', 'record_alpha')->value('name'))->toBe('Alpha Dispatch'); +}); + test('api model behavior exposes default searchable fields options and no-op query branches', function () { $capsule = has_api_model_behavior_database(); has_api_model_behavior_seed_records($capsule); diff --git a/tests/Unit/Traits/HasCustomFieldsTest.php b/tests/Unit/Traits/HasCustomFieldsTest.php index ac1fc872..de8b5d70 100644 --- a/tests/Unit/Traits/HasCustomFieldsTest.php +++ b/tests/Unit/Traits/HasCustomFieldsTest.php @@ -119,6 +119,7 @@ function has_custom_fields_database(string $routeUri = 'int/v1/subjects'): HasCu }); $schema->create('custom_fields', function ($table) { $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); $table->string('company_uuid')->nullable(); $table->string('category_uuid')->nullable(); $table->string('subject_uuid')->nullable();