diff --git a/src/Actions/Component/CreateComponent.php b/src/Actions/Component/CreateComponent.php index 56dbb642..00ac1356 100644 --- a/src/Actions/Component/CreateComponent.php +++ b/src/Actions/Component/CreateComponent.php @@ -4,6 +4,7 @@ use Cachet\Data\Requests\Component\CreateComponentRequestData; use Cachet\Models\Component; +use Illuminate\Support\Facades\DB; class CreateComponent { @@ -12,8 +13,10 @@ class CreateComponent */ public function handle(CreateComponentRequestData $component): Component { - return tap(Component::create($component->except('meta')->toArray()), function (Component $model) use ($component) { - $model->syncMeta($component->meta ?? []); + return DB::transaction(function () use ($component): Component { + return tap(Component::create($component->except('meta')->toArray()), function (Component $model) use ($component) { + $model->syncMeta($component->meta ?? []); + }); }); } } diff --git a/src/Actions/Component/DeleteComponent.php b/src/Actions/Component/DeleteComponent.php index 18c70639..90961304 100644 --- a/src/Actions/Component/DeleteComponent.php +++ b/src/Actions/Component/DeleteComponent.php @@ -8,11 +8,12 @@ class DeleteComponent { /** * Handle the action. + * + * The delete is soft, so the component keeps its subscriptions for a + * restore; the model purges them once it is hard deleted. */ public function handle(Component $component): void { - $component->subscribers()->detach(); - $component->delete(); } } diff --git a/src/Actions/Component/UpdateComponent.php b/src/Actions/Component/UpdateComponent.php index 503a8c86..21ee348c 100644 --- a/src/Actions/Component/UpdateComponent.php +++ b/src/Actions/Component/UpdateComponent.php @@ -3,6 +3,7 @@ namespace Cachet\Actions\Component; use Cachet\Data\Requests\Component\UpdateComponentRequestData; +use Cachet\Enums\ComponentStatusEnum; use Cachet\Enums\ComponentStatusSourceEnum; use Cachet\Models\Component; use Illuminate\Contracts\Auth\Authenticatable; @@ -23,9 +24,7 @@ public function handle(Component $component, UpdateComponentRequestData $data, ? DB::transaction(function () use ($component, $data, $user): void { $attributes = $data->except('meta', 'status')->toArray(); - if ($data->status === null) { - $component->update($attributes); - } else { + if ($data->status instanceof ComponentStatusEnum) { $this->changeComponentStatus->handle( $component, $data->status, @@ -33,9 +32,11 @@ public function handle(Component $component, UpdateComponentRequestData $data, ? $user, attributes: $attributes, ); + } else { + $component->update($attributes); } - if ($data->meta !== null) { + if (is_array($data->meta)) { $component->syncMeta($data->meta); } }); diff --git a/src/Actions/ComponentGroup/CreateComponentGroup.php b/src/Actions/ComponentGroup/CreateComponentGroup.php index cdd5c382..ba3c0201 100644 --- a/src/Actions/ComponentGroup/CreateComponentGroup.php +++ b/src/Actions/ComponentGroup/CreateComponentGroup.php @@ -5,29 +5,29 @@ use Cachet\Data\Requests\ComponentGroup\CreateComponentGroupRequestData; use Cachet\Models\Component; use Cachet\Models\ComponentGroup; +use Illuminate\Support\Facades\DB; class CreateComponentGroup { - /** - * Handle the action. - */ /** * Handle the action. */ public function handle(CreateComponentGroupRequestData $data): ComponentGroup { - return tap(ComponentGroup::create( - $data->except('components', 'meta')->toArray(), - ), function (ComponentGroup $componentGroup) use ($data) { - $componentGroup->syncMeta($data->meta ?? []); + return DB::transaction(function () use ($data): ComponentGroup { + return tap(ComponentGroup::create( + $data->except('components', 'meta')->toArray(), + ), function (ComponentGroup $componentGroup) use ($data) { + $componentGroup->syncMeta($data->meta ?? []); - if (! $data->components) { - return; - } + if (! $data->components) { + return; + } - Component::query()->whereIn('id', $data->components)->update([ - 'component_group_id' => $componentGroup->id, - ]); + Component::query()->whereIn('id', $data->components)->update([ + 'component_group_id' => $componentGroup->id, + ]); + }); }); } } diff --git a/src/Actions/ComponentGroup/DeleteComponentGroup.php b/src/Actions/ComponentGroup/DeleteComponentGroup.php index cd017e30..cfd6003e 100644 --- a/src/Actions/ComponentGroup/DeleteComponentGroup.php +++ b/src/Actions/ComponentGroup/DeleteComponentGroup.php @@ -3,6 +3,7 @@ namespace Cachet\Actions\ComponentGroup; use Cachet\Models\ComponentGroup; +use Illuminate\Support\Facades\DB; class DeleteComponentGroup { @@ -11,8 +12,10 @@ class DeleteComponentGroup */ public function handle(ComponentGroup $componentGroup): void { - $componentGroup->components()->update(['component_group_id' => null]); + DB::transaction(function () use ($componentGroup): void { + $componentGroup->components()->update(['component_group_id' => null]); - $componentGroup->delete(); + $componentGroup->delete(); + }); } } diff --git a/src/Actions/ComponentGroup/UpdateComponentGroup.php b/src/Actions/ComponentGroup/UpdateComponentGroup.php index 2857d366..f40efa44 100644 --- a/src/Actions/ComponentGroup/UpdateComponentGroup.php +++ b/src/Actions/ComponentGroup/UpdateComponentGroup.php @@ -5,6 +5,7 @@ use Cachet\Data\Requests\ComponentGroup\UpdateComponentGroupRequestData; use Cachet\Models\Component; use Cachet\Models\ComponentGroup; +use Illuminate\Support\Facades\DB; class UpdateComponentGroup { @@ -13,17 +14,19 @@ class UpdateComponentGroup */ public function handle(ComponentGroup $componentGroup, UpdateComponentGroupRequestData $data): ComponentGroup { - $componentGroup->update($data->except('components', 'meta')->toArray()); + DB::transaction(function () use ($componentGroup, $data): void { + $componentGroup->update($data->except('components', 'meta')->toArray()); - if ($data->meta !== null) { - $componentGroup->syncMeta($data->meta); - } + if (is_array($data->meta)) { + $componentGroup->syncMeta($data->meta); + } - if ($data->components) { - Component::query()->whereIn('id', $data->components)->update([ - 'component_group_id' => $componentGroup->id, - ]); - } + if (is_array($data->components) && $data->components !== []) { + Component::query()->whereIn('id', $data->components)->update([ + 'component_group_id' => $componentGroup->id, + ]); + } + }); return $componentGroup->fresh(); } diff --git a/src/Actions/Incident/CreateIncident.php b/src/Actions/Incident/CreateIncident.php index 7864aa75..3d99b085 100644 --- a/src/Actions/Incident/CreateIncident.php +++ b/src/Actions/Incident/CreateIncident.php @@ -26,7 +26,7 @@ public function handle(CreateIncidentRequestData $data): Incident if (isset($data->template)) { $template = IncidentTemplate::query() ->where('slug', $data->template) - ->first(); + ->firstOrFail(); $data = $data->withMessage($this->parseTemplate($template, $data)); } @@ -65,7 +65,7 @@ private function parseTemplate(IncidentTemplate $template, CreateIncidentRequest 'name' => $data->name, 'status' => $data->status, 'message' => $data->message ?? null, - 'visible' => $data->visible, + 'visible' => $data->visible->value, 'notify' => $data->notifications, 'stickied' => $data->stickied, 'occurred_at' => $data->occurredAt ?? Carbon::now(), diff --git a/src/Actions/Incident/DeleteIncident.php b/src/Actions/Incident/DeleteIncident.php index 4e1354ae..7d846c3a 100644 --- a/src/Actions/Incident/DeleteIncident.php +++ b/src/Actions/Incident/DeleteIncident.php @@ -8,11 +8,13 @@ class DeleteIncident { /** * Handle the action. + * + * The delete is soft, so the incident keeps its updates and component + * attachments for a restore; the model purges them once it is hard + * deleted. */ public function handle(Incident $incident): void { - $incident->updates()->delete(); - $incident->delete(); } } diff --git a/src/Actions/Incident/UpdateIncident.php b/src/Actions/Incident/UpdateIncident.php index adeffcc4..6dd741cf 100644 --- a/src/Actions/Incident/UpdateIncident.php +++ b/src/Actions/Incident/UpdateIncident.php @@ -4,6 +4,7 @@ use Cachet\Data\Requests\Incident\UpdateIncidentRequestData; use Cachet\Models\Incident; +use Illuminate\Support\Facades\DB; class UpdateIncident { @@ -12,11 +13,13 @@ class UpdateIncident */ public function handle(Incident $incident, UpdateIncidentRequestData $data): Incident { - $incident->update($data->except('meta')->toArray()); + DB::transaction(function () use ($incident, $data): void { + $incident->update($data->except('meta')->toArray()); - if ($data->meta !== null) { - $incident->syncMeta($data->meta); - } + if (is_array($data->meta)) { + $incident->syncMeta($data->meta); + } + }); return $incident->fresh(); } diff --git a/src/Actions/Metric/DeleteMetric.php b/src/Actions/Metric/DeleteMetric.php index 15f59617..fe17fc42 100644 --- a/src/Actions/Metric/DeleteMetric.php +++ b/src/Actions/Metric/DeleteMetric.php @@ -3,6 +3,7 @@ namespace Cachet\Actions\Metric; use Cachet\Models\Metric; +use Illuminate\Support\Facades\DB; class DeleteMetric { @@ -11,7 +12,9 @@ class DeleteMetric */ public function handle(Metric $metric): void { - $metric->metricPoints()->delete(); - $metric->delete(); + DB::transaction(function () use ($metric): void { + $metric->metricPoints()->delete(); + $metric->delete(); + }); } } diff --git a/src/Actions/Schedule/UpdateSchedule.php b/src/Actions/Schedule/UpdateSchedule.php index da071707..22fb2d3b 100644 --- a/src/Actions/Schedule/UpdateSchedule.php +++ b/src/Actions/Schedule/UpdateSchedule.php @@ -5,6 +5,7 @@ use Cachet\Data\Requests\Schedule\ScheduleComponentRequestData; use Cachet\Data\Requests\Schedule\UpdateScheduleRequestData; use Cachet\Models\Schedule; +use Illuminate\Support\Facades\DB; class UpdateSchedule { @@ -13,21 +14,23 @@ class UpdateSchedule */ public function handle(Schedule $schedule, UpdateScheduleRequestData $data): Schedule { - $schedule->update($data->except('components', 'meta')->toArray()); - - if ($data->meta !== null) { - $schedule->syncMeta($data->meta); - } - - if ($data->components) { - $components = collect($data->components) - ->mapWithKeys(fn (ScheduleComponentRequestData $component) => [ - $component->id => ['component_status' => $component->status], - ]) - ->all(); - - $schedule->components()->sync($components); - } + DB::transaction(function () use ($schedule, $data): void { + $schedule->update($data->except('components', 'meta')->toArray()); + + if (is_array($data->meta)) { + $schedule->syncMeta($data->meta); + } + + if (is_array($data->components) && $data->components !== []) { + $components = collect($data->components) + ->mapWithKeys(fn (ScheduleComponentRequestData $component) => [ + $component->id => ['component_status' => $component->status], + ]) + ->all(); + + $schedule->components()->sync($components); + } + }); // @todo Dispatch notification that maintenance was updated. diff --git a/src/Actions/Subscriber/CreateSubscriber.php b/src/Actions/Subscriber/CreateSubscriber.php index 7f616460..6480a4fe 100644 --- a/src/Actions/Subscriber/CreateSubscriber.php +++ b/src/Actions/Subscriber/CreateSubscriber.php @@ -3,6 +3,7 @@ namespace Cachet\Actions\Subscriber; use Cachet\Models\Subscriber; +use Illuminate\Support\Facades\DB; class CreateSubscriber { @@ -11,19 +12,21 @@ class CreateSubscriber */ public function handle(string $email, bool $global = true, array $components = [], bool $verified = false, ?array $meta = null): Subscriber { - $subscriber = Subscriber::firstOrCreate([ - 'email' => $email, - ], [ - 'global' => $global, - 'email_verified_at' => $verified ? now() : null, - ]); + return DB::transaction(function () use ($email, $global, $components, $verified, $meta): Subscriber { + $subscriber = Subscriber::firstOrCreate([ + 'email' => $email, + ], [ + 'global' => $global, + 'email_verified_at' => $verified ? now() : null, + ]); - $subscriber->components()->attach($components); + $subscriber->components()->attach($components); - if ($meta !== null) { - $subscriber->syncMeta($meta); - } + if ($meta !== null) { + $subscriber->syncMeta($meta); + } - return $subscriber; + return $subscriber; + }); } } diff --git a/src/Actions/Subscriber/UnsubscribeSubscriber.php b/src/Actions/Subscriber/UnsubscribeSubscriber.php index 821d44f3..a87da3d3 100644 --- a/src/Actions/Subscriber/UnsubscribeSubscriber.php +++ b/src/Actions/Subscriber/UnsubscribeSubscriber.php @@ -3,6 +3,7 @@ namespace Cachet\Actions\Subscriber; use Cachet\Models\Subscriber; +use Illuminate\Support\Facades\DB; class UnsubscribeSubscriber { @@ -13,8 +14,10 @@ class UnsubscribeSubscriber */ public function handle(Subscriber $subscriber): void { - $subscriber->components()->detach(); + DB::transaction(function () use ($subscriber): void { + $subscriber->components()->detach(); - $subscriber->delete(); + $subscriber->delete(); + }); } } diff --git a/src/Actions/Subscriber/UpdateSubscriber.php b/src/Actions/Subscriber/UpdateSubscriber.php index 6f5f955c..174dde7c 100644 --- a/src/Actions/Subscriber/UpdateSubscriber.php +++ b/src/Actions/Subscriber/UpdateSubscriber.php @@ -3,6 +3,7 @@ namespace Cachet\Actions\Subscriber; use Cachet\Models\Subscriber; +use Illuminate\Support\Facades\DB; class UpdateSubscriber { @@ -11,23 +12,25 @@ class UpdateSubscriber */ public function handle(Subscriber $subscriber, ?string $email = null, ?bool $global = null, ?array $components = null, ?array $meta = null): Subscriber { - $subscriber->update(array_filter([ - 'email' => $email, - 'global' => $global, - ], fn ($value) => $value !== null)); + return DB::transaction(function () use ($subscriber, $email, $global, $components, $meta): Subscriber { + $subscriber->update(array_filter([ + 'email' => $email, + 'global' => $global, + ], fn ($value) => $value !== null)); - if ($subscriber->wasChanged('email')) { - $subscriber->resetVerification(); - } + if ($subscriber->wasChanged('email')) { + $subscriber->resetVerification(); + } - if ($components !== null) { - $subscriber->components()->sync($components); - } + if ($components !== null) { + $subscriber->components()->sync($components); + } - if ($meta !== null) { - $subscriber->syncMeta($meta); - } + if ($meta !== null) { + $subscriber->syncMeta($meta); + } - return $subscriber; + return $subscriber; + }); } } diff --git a/src/Actions/Update/CreateUpdate.php b/src/Actions/Update/CreateUpdate.php index 181d9fb2..34b7a8fb 100644 --- a/src/Actions/Update/CreateUpdate.php +++ b/src/Actions/Update/CreateUpdate.php @@ -26,6 +26,11 @@ public function __construct( /** * Handle the action. + * + * A completion time carried by a schedule update is written in the same + * transaction as the update itself — quietly, since the update is the + * communication — so the window can never close without its update, or + * the other way around. */ public function handle(Incident|Schedule $resource, CreateIncidentUpdateRequestData|CreateScheduleUpdateRequestData $data, ?Authenticatable $user = null): Update { @@ -34,20 +39,24 @@ public function handle(Incident|Schedule $resource, CreateIncidentUpdateRequestD $data->except('completedAt')->toArray() )); - DB::transaction(function () use ($resource, $update): void { + DB::transaction(function () use ($resource, $update, $data): void { $resource->updates()->save($update); if ($resource instanceof Incident) { $this->syncIncidentStatus->handle($resource); } + + if ($resource instanceof Schedule && $data instanceof CreateScheduleUpdateRequestData && $data->completedAt !== null) { + $resource->updateQuietly(['completed_at' => $data->completedAt]); + } }); $this->notifyIncidentUpdateSubscribers->handle($update); if ($resource instanceof Schedule) { - $completed = $this->completeSchedule($resource, $data); - - if (! $completed) { + if ($this->scheduleCompleted($resource, $data)) { + $this->notifyScheduleCompletedSubscribers->handle($resource); + } else { $this->notifyScheduleUpdateSubscribers->handle($update); } } @@ -56,27 +65,14 @@ public function handle(Incident|Schedule $resource, CreateIncidentUpdateRequestD } /** - * Complete the schedule when the update provides a completion time. - * - * The window change is applied quietly — the update itself is the - * communication — and returns true when the schedule has actually - * completed, in which case the completion notification supersedes - * the update notification. + * Determine whether the update's completion time actually completed the + * schedule, in which case the completion notification supersedes the + * update notification. */ - private function completeSchedule(Schedule $schedule, CreateIncidentUpdateRequestData|CreateScheduleUpdateRequestData $data): bool + private function scheduleCompleted(Schedule $schedule, CreateIncidentUpdateRequestData|CreateScheduleUpdateRequestData $data): bool { - if (! $data instanceof CreateScheduleUpdateRequestData || $data->completedAt === null) { - return false; - } - - $schedule->updateQuietly(['completed_at' => $data->completedAt]); - - if ($schedule->status === ScheduleStatusEnum::complete) { - $this->notifyScheduleCompletedSubscribers->handle($schedule); - - return true; - } - - return false; + return $data instanceof CreateScheduleUpdateRequestData + && $data->completedAt !== null + && $schedule->status === ScheduleStatusEnum::complete; } } diff --git a/src/Actions/Update/DeleteUpdate.php b/src/Actions/Update/DeleteUpdate.php index 0e8f07d4..59178c02 100644 --- a/src/Actions/Update/DeleteUpdate.php +++ b/src/Actions/Update/DeleteUpdate.php @@ -5,6 +5,7 @@ use Cachet\Actions\Incident\SyncIncidentStatus; use Cachet\Models\Incident; use Cachet\Models\Update; +use Illuminate\Support\Facades\DB; class DeleteUpdate { @@ -18,12 +19,14 @@ public function __construct(private SyncIncidentStatus $syncIncidentStatus) */ public function handle(Update $update): void { - $incident = $update->updateable; + DB::transaction(function () use ($update): void { + $incident = $update->updateable; - $update->delete(); + $update->delete(); - if ($incident instanceof Incident) { - $this->syncIncidentStatus->handle($incident); - } + if ($incident instanceof Incident) { + $this->syncIncidentStatus->handle($incident); + } + }); } } diff --git a/src/Actions/Update/EditUpdate.php b/src/Actions/Update/EditUpdate.php index 936d27eb..cb91b10b 100644 --- a/src/Actions/Update/EditUpdate.php +++ b/src/Actions/Update/EditUpdate.php @@ -7,6 +7,7 @@ use Cachet\Data\Requests\ScheduleUpdate\EditScheduleUpdateRequestData; use Cachet\Models\Incident; use Cachet\Models\Update; +use Illuminate\Support\Facades\DB; class EditUpdate { @@ -21,13 +22,15 @@ public function __construct(private SyncIncidentStatus $syncIncidentStatus) public function handle(Update $update, EditIncidentUpdateRequestData|EditScheduleUpdateRequestData $data): Update { return tap($update, function (Update $update) use ($data) { - $update->update($data->toArray()); + DB::transaction(function () use ($update, $data): void { + $update->update($data->toArray()); - $incident = $update->updateable; + $incident = $update->updateable; - if ($incident instanceof Incident) { - $this->syncIncidentStatus->handle($incident); - } + if ($incident instanceof Incident) { + $this->syncIncidentStatus->handle($incident); + } + }); }); } } diff --git a/src/Data/BaseUpdateData.php b/src/Data/BaseUpdateData.php new file mode 100644 index 00000000..e1fd6e35 --- /dev/null +++ b/src/Data/BaseUpdateData.php @@ -0,0 +1,31 @@ + + */ +abstract class BaseUpdateData extends BaseData +{ + /** + * Get the instance as an array, keeping explicit nulls. + * + * @return array + */ + public function toArray(): array + { + return Data::toArray(); + } +} diff --git a/src/Data/Requests/Component/UpdateComponentRequestData.php b/src/Data/Requests/Component/UpdateComponentRequestData.php index bbd6fc2a..8fe2e2e4 100644 --- a/src/Data/Requests/Component/UpdateComponentRequestData.php +++ b/src/Data/Requests/Component/UpdateComponentRequestData.php @@ -2,34 +2,35 @@ namespace Cachet\Data\Requests\Component; -use Cachet\Data\BaseData; +use Cachet\Data\BaseUpdateData; use Cachet\Enums\ComponentStatusEnum; use Illuminate\Validation\Rule; +use Spatie\LaravelData\Optional; use Spatie\LaravelData\Support\Validation\ValidationContext; -final class UpdateComponentRequestData extends BaseData +final class UpdateComponentRequestData extends BaseUpdateData { public function __construct( - public readonly ?string $name = null, - public readonly ?string $description = null, - public readonly ?ComponentStatusEnum $status = null, - public readonly ?string $link = null, - public readonly ?int $order = null, - public readonly ?bool $enabled = null, - public readonly ?int $componentGroupId = null, - /** @var array|null */ - public readonly ?array $meta = null, + public readonly string|Optional $name = new Optional, + public readonly string|Optional|null $description = new Optional, + public readonly ComponentStatusEnum|Optional $status = new Optional, + public readonly string|Optional|null $link = new Optional, + public readonly int|Optional|null $order = new Optional, + public readonly bool|Optional $enabled = new Optional, + public readonly int|Optional|null $componentGroupId = new Optional, + /** @var array|Optional|null */ + public readonly array|Optional|null $meta = new Optional, ) {} public static function rules(ValidationContext $context): array { return [ 'name' => ['string', 'max:255'], - 'description' => ['string'], + 'description' => ['nullable', 'string'], 'status' => [Rule::enum(ComponentStatusEnum::class)], - 'link' => ['string', 'url:http,https'], - 'order' => ['int', 'min:0'], - 'component_group_id' => ['int', 'min:0', Rule::exists('component_groups', 'id')], + 'link' => ['nullable', 'string', 'url:http,https'], + 'order' => ['nullable', 'int', 'min:0'], + 'component_group_id' => ['nullable', 'int', 'min:0', Rule::exists('component_groups', 'id')], 'enabled' => ['boolean'], /** * Key/value metadata to store against the resource. diff --git a/src/Data/Requests/ComponentGroup/CreateComponentGroupRequestData.php b/src/Data/Requests/ComponentGroup/CreateComponentGroupRequestData.php index 2875e6fc..fab5481d 100644 --- a/src/Data/Requests/ComponentGroup/CreateComponentGroupRequestData.php +++ b/src/Data/Requests/ComponentGroup/CreateComponentGroupRequestData.php @@ -29,7 +29,10 @@ public static function rules(ValidationContext $context): array return [ 'name' => ['required', 'string', 'max:255'], 'order' => ['int', 'min:0'], - 'visible' => ['bool'], + /** + * Who the group is visible to: 0 authenticated users only, 1 everyone, 2 hidden. + */ + 'visible' => [Rule::enum(ResourceVisibilityEnum::class)], 'collapsed' => [Rule::enum(ComponentGroupVisibilityEnum::class)], 'order_column' => [Rule::enum(ResourceOrderColumnEnum::class)], 'order_direction' => [ diff --git a/src/Data/Requests/ComponentGroup/UpdateComponentGroupRequestData.php b/src/Data/Requests/ComponentGroup/UpdateComponentGroupRequestData.php index b603b21d..00d09f12 100644 --- a/src/Data/Requests/ComponentGroup/UpdateComponentGroupRequestData.php +++ b/src/Data/Requests/ComponentGroup/UpdateComponentGroupRequestData.php @@ -2,25 +2,28 @@ namespace Cachet\Data\Requests\ComponentGroup; -use Cachet\Data\BaseData; +use Cachet\Data\BaseUpdateData; use Cachet\Enums\ComponentGroupVisibilityEnum; use Cachet\Enums\ResourceOrderColumnEnum; use Cachet\Enums\ResourceOrderDirectionEnum; +use Cachet\Enums\ResourceVisibilityEnum; use Illuminate\Validation\Rule; +use Spatie\LaravelData\Optional; use Spatie\LaravelData\Support\Validation\ValidationContext; -final class UpdateComponentGroupRequestData extends BaseData +final class UpdateComponentGroupRequestData extends BaseUpdateData { public function __construct( - public readonly ?string $name = null, - public readonly ?int $order = null, - public readonly ?bool $visible = null, - public readonly ?ComponentGroupVisibilityEnum $collapsed = null, - public readonly ?ResourceOrderColumnEnum $orderColumn = null, - public readonly ?ResourceOrderDirectionEnum $orderDirection = null, - public readonly ?array $components = null, - /** @var array|null */ - public readonly ?array $meta = null, + public readonly string|Optional $name = new Optional, + public readonly int|Optional $order = new Optional, + public readonly ResourceVisibilityEnum|Optional $visible = new Optional, + public readonly ComponentGroupVisibilityEnum|Optional $collapsed = new Optional, + public readonly ResourceOrderColumnEnum|Optional|null $orderColumn = new Optional, + public readonly ResourceOrderDirectionEnum|Optional|null $orderDirection = new Optional, + /** @var array|Optional */ + public readonly array|Optional $components = new Optional, + /** @var array|Optional|null */ + public readonly array|Optional|null $meta = new Optional, ) {} public static function rules(ValidationContext $context): array @@ -28,9 +31,12 @@ public static function rules(ValidationContext $context): array return [ 'name' => ['string', 'max:255'], 'order' => ['int', 'min:0'], - 'visible' => ['bool'], + /** + * Who the group is visible to: 0 authenticated users only, 1 everyone, 2 hidden. + */ + 'visible' => [Rule::enum(ResourceVisibilityEnum::class)], 'collapsed' => [Rule::enum(ComponentGroupVisibilityEnum::class)], - 'order_column' => [Rule::enum(ResourceOrderColumnEnum::class)], + 'order_column' => ['nullable', Rule::enum(ResourceOrderColumnEnum::class)], 'order_direction' => [ 'nullable', Rule::requiredIf(function () use ($context) { diff --git a/src/Data/Requests/Incident/CreateIncidentRequestData.php b/src/Data/Requests/Incident/CreateIncidentRequestData.php index d3123c04..ac086028 100644 --- a/src/Data/Requests/Incident/CreateIncidentRequestData.php +++ b/src/Data/Requests/Incident/CreateIncidentRequestData.php @@ -5,6 +5,7 @@ use Cachet\Data\BaseData; use Cachet\Enums\ComponentStatusEnum; use Cachet\Enums\IncidentStatusEnum; +use Cachet\Enums\ResourceVisibilityEnum; use Cachet\Models\Component; use Illuminate\Validation\Rule; use Spatie\LaravelData\Attributes\DataCollectionOf; @@ -25,7 +26,7 @@ public function __construct( public readonly ?string $message = null, #[RequiredWithout('message')] public readonly ?string $template = null, - public readonly bool $visible = false, + public readonly ResourceVisibilityEnum $visible = ResourceVisibilityEnum::authenticated, public readonly bool $stickied = false, public readonly bool $notifications = false, public readonly ?string $occurredAt = null, @@ -48,7 +49,10 @@ public static function rules(ValidationContext $context): array 'message' => ['required_without:template', 'string'], 'template' => ['required_without:message', 'string', Rule::exists('incident_templates', 'slug')], 'status' => ['required', Rule::enum(IncidentStatusEnum::class)], - 'visible' => ['boolean'], + /** + * Who the incident is visible to: 0 authenticated users only, 1 everyone, 2 hidden. Defaults to authenticated users only. + */ + 'visible' => [Rule::enum(ResourceVisibilityEnum::class)], 'stickied' => ['boolean'], 'notifications' => ['boolean'], /** diff --git a/src/Data/Requests/Incident/UpdateIncidentRequestData.php b/src/Data/Requests/Incident/UpdateIncidentRequestData.php index 08cfb1ef..97847e6a 100644 --- a/src/Data/Requests/Incident/UpdateIncidentRequestData.php +++ b/src/Data/Requests/Incident/UpdateIncidentRequestData.php @@ -2,25 +2,26 @@ namespace Cachet\Data\Requests\Incident; -use Cachet\Data\BaseData; +use Cachet\Data\BaseUpdateData; use Cachet\Enums\IncidentStatusEnum; +use Cachet\Enums\ResourceVisibilityEnum; use Illuminate\Validation\Rule; use Spatie\LaravelData\Optional; use Spatie\LaravelData\Support\Validation\ValidationContext; -final class UpdateIncidentRequestData extends BaseData +final class UpdateIncidentRequestData extends BaseUpdateData { public function __construct( - public readonly Optional|string $name, - public readonly ?string $message = null, - public readonly ?IncidentStatusEnum $status = null, - public readonly ?bool $visible = null, - public readonly ?bool $stickied = null, - public readonly ?bool $notifications = null, - public readonly ?string $occurredAt = null, - public readonly ?string $publishedAt = null, - /** @var array|null */ - public readonly ?array $meta = null, + public readonly string|Optional $name = new Optional, + public readonly string|Optional $message = new Optional, + public readonly IncidentStatusEnum|Optional $status = new Optional, + public readonly ResourceVisibilityEnum|Optional $visible = new Optional, + public readonly bool|Optional $stickied = new Optional, + public readonly bool|Optional $notifications = new Optional, + public readonly string|Optional|null $occurredAt = new Optional, + public readonly string|Optional|null $publishedAt = new Optional, + /** @var array|Optional|null */ + public readonly array|Optional|null $meta = new Optional, ) {} public static function rules(ValidationContext $context): array @@ -29,15 +30,18 @@ public static function rules(ValidationContext $context): array 'name' => ['string', 'max:255'], 'message' => ['string'], 'status' => [Rule::enum(IncidentStatusEnum::class)], - 'visible' => ['boolean'], + /** + * Who the incident is visible to: 0 authenticated users only, 1 everyone, 2 hidden. + */ + 'visible' => [Rule::enum(ResourceVisibilityEnum::class)], 'stickied' => ['boolean'], 'notifications' => ['boolean'], /** - * The date/time the incident occurred, e.g. "2023-11-07 05:31:56" or ISO 8601. + * The date/time the incident occurred, e.g. "2023-11-07 05:31:56" or ISO 8601. Send null to clear it. */ 'occurred_at' => ['nullable', 'date'], /** - * The date/time to publish the incident, e.g. "2023-11-07 05:31:56" or ISO 8601. While set in the future the incident is hidden from the status page and public API. + * The date/time to publish the incident, e.g. "2023-11-07 05:31:56" or ISO 8601. While set in the future the incident is hidden from the status page and public API. Send null to publish immediately. */ 'published_at' => ['nullable', 'date'], /** @@ -50,9 +54,4 @@ public static function rules(ValidationContext $context): array 'meta' => ['nullable', 'array'], ]; } - - public function toArray(): array - { - return parent::toArray(); - } } diff --git a/src/Data/Requests/IncidentTemplate/UpdateIncidentTemplateRequestData.php b/src/Data/Requests/IncidentTemplate/UpdateIncidentTemplateRequestData.php index 79add9cb..1f153131 100644 --- a/src/Data/Requests/IncidentTemplate/UpdateIncidentTemplateRequestData.php +++ b/src/Data/Requests/IncidentTemplate/UpdateIncidentTemplateRequestData.php @@ -10,12 +10,16 @@ final class UpdateIncidentTemplateRequestData extends BaseData { + private readonly ?string $slug; + public function __construct( public readonly ?string $name = null, public readonly ?string $template = null, - private readonly ?string $slug = null, + ?string $slug = null, public readonly ?IncidentTemplateEngineEnum $engine = null, - ) {} + ) { + $this->slug = $slug; + } public static function rules(ValidationContext $context): array { @@ -27,15 +31,26 @@ public static function rules(ValidationContext $context): array ]; } - public function slug(): string + /** + * The slug to store, or null when the payload gives no basis for one. + * + * An explicit slug always wins; otherwise a new name regenerates the + * slug. A payload with neither must leave the stored slug untouched + * rather than derive one from nothing. + */ + public function slug(): ?string { - return $this->slug ?? Str::slug($this->name); + if ($this->slug !== null) { + return $this->slug; + } + + return $this->name === null ? null : Str::slug($this->name); } public function toArray(): array { - return array_merge(parent::toArray(), [ - 'slug' => $this->slug(), - ]); + $slug = $this->slug(); + + return array_merge(parent::toArray(), $slug === null ? [] : ['slug' => $slug]); } } diff --git a/src/Data/Requests/Metric/UpdateMetricRequestData.php b/src/Data/Requests/Metric/UpdateMetricRequestData.php index 0111da37..650345bb 100644 --- a/src/Data/Requests/Metric/UpdateMetricRequestData.php +++ b/src/Data/Requests/Metric/UpdateMetricRequestData.php @@ -2,18 +2,19 @@ namespace Cachet\Data\Requests\Metric; -use Cachet\Data\BaseData; +use Cachet\Data\BaseUpdateData; use Cachet\Rules\FactorOfSixty; +use Spatie\LaravelData\Optional; use Spatie\LaravelData\Support\Validation\ValidationContext; -final class UpdateMetricRequestData extends BaseData +final class UpdateMetricRequestData extends BaseUpdateData { public function __construct( - public readonly ?string $name = null, - public readonly ?string $suffix = null, - public readonly ?string $description = null, - public readonly ?float $defaultValue = null, - public readonly ?int $threshold = null, + public readonly string|Optional $name = new Optional, + public readonly string|Optional $suffix = new Optional, + public readonly string|Optional|null $description = new Optional, + public readonly float|Optional|null $defaultValue = new Optional, + public readonly int|Optional $threshold = new Optional, ) {} public static function rules(ValidationContext $context): array @@ -21,8 +22,8 @@ public static function rules(ValidationContext $context): array return [ 'name' => ['string', 'max:255'], 'suffix' => ['string', 'max:255'], - 'description' => ['string'], - 'default_value' => ['decimal:1,2'], + 'description' => ['nullable', 'string'], + 'default_value' => ['nullable', 'decimal:1,2'], 'threshold' => ['int', 'min:0', 'max:60', new FactorOfSixty], ]; } diff --git a/src/Data/Requests/Schedule/UpdateScheduleRequestData.php b/src/Data/Requests/Schedule/UpdateScheduleRequestData.php index fd45e980..40e558af 100644 --- a/src/Data/Requests/Schedule/UpdateScheduleRequestData.php +++ b/src/Data/Requests/Schedule/UpdateScheduleRequestData.php @@ -2,7 +2,7 @@ namespace Cachet\Data\Requests\Schedule; -use Cachet\Data\BaseData; +use Cachet\Data\BaseUpdateData; use Cachet\Data\Casts\FlexibleDateTimeCast; use Cachet\Enums\ComponentStatusEnum; use Cachet\Enums\ScheduleStatusEnum; @@ -10,41 +10,43 @@ use Illuminate\Validation\Rule; use Spatie\LaravelData\Attributes\DataCollectionOf; use Spatie\LaravelData\Attributes\WithCast; +use Spatie\LaravelData\Optional; use Spatie\LaravelData\Support\Validation\ValidationContext; -final class UpdateScheduleRequestData extends BaseData +final class UpdateScheduleRequestData extends BaseUpdateData { public function __construct( - public readonly ?string $name = null, - public readonly ?string $message = null, - public readonly ?ScheduleStatusEnum $status = null, + public readonly string|Optional $name = new Optional, + public readonly string|Optional|null $message = new Optional, + public readonly ScheduleStatusEnum|Optional $status = new Optional, #[WithCast(FlexibleDateTimeCast::class)] - public readonly ?Carbon $scheduledAt = null, + public readonly Carbon|Optional $scheduledAt = new Optional, #[WithCast(FlexibleDateTimeCast::class)] - public readonly ?Carbon $completedAt = null, + public readonly Carbon|Optional|null $completedAt = new Optional, #[WithCast(FlexibleDateTimeCast::class)] - public readonly ?Carbon $publishedAt = null, + public readonly Carbon|Optional|null $publishedAt = new Optional, + /** @var array|Optional */ #[DataCollectionOf(ScheduleComponentRequestData::class)] - public readonly ?array $components = null, - /** @var array|null */ - public readonly ?array $meta = null, + public readonly array|Optional $components = new Optional, + /** @var array|Optional|null */ + public readonly array|Optional|null $meta = new Optional, ) {} public static function rules(ValidationContext $context): array { return [ 'name' => ['string', 'max:255'], - 'message' => ['string'], + 'message' => ['nullable', 'string'], /** * The date/time the maintenance window starts, e.g. "2023-11-07 05:31:56" or ISO 8601. */ - 'scheduled_at' => ['nullable', 'date'], + 'scheduled_at' => ['date'], /** - * The date/time the maintenance window ends, e.g. "2023-11-07 05:31:56" or ISO 8601. + * The date/time the maintenance window ends, e.g. "2023-11-07 05:31:56" or ISO 8601. Send null to reopen the window. */ 'completed_at' => ['nullable', 'date'], /** - * The date/time to publish the maintenance, e.g. "2023-11-07 05:31:56" or ISO 8601. While set in the future the maintenance is hidden from the status page and public API. + * The date/time to publish the maintenance, e.g. "2023-11-07 05:31:56" or ISO 8601. While set in the future the maintenance is hidden from the status page and public API. Send null to publish immediately. */ 'published_at' => ['nullable', 'date'], 'components' => ['array'], diff --git a/src/Http/Controllers/Api/ScheduleUpdateController.php b/src/Http/Controllers/Api/ScheduleUpdateController.php index fa34442f..d022d90a 100644 --- a/src/Http/Controllers/Api/ScheduleUpdateController.php +++ b/src/Http/Controllers/Api/ScheduleUpdateController.php @@ -12,6 +12,7 @@ use Cachet\Http\Resources\Update as UpdateResource; use Cachet\Models\Schedule; use Cachet\Models\Update; +use Cachet\QueryBuilders\ScheduleBuilder; use Dedoc\Scramble\Attributes\Group; use Dedoc\Scramble\Attributes\QueryParameter; use Illuminate\Database\Eloquent\Relations\Relation; @@ -34,6 +35,8 @@ class ScheduleUpdateController extends Controller #[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)] public function index(Request $request, Schedule $schedule) { + $this->ensureScheduleVisible($schedule); + $query = Update::query() ->where('updateable_id', $schedule->id) ->where('updateable_type', Relation::getMorphAlias(Schedule::class)); @@ -65,6 +68,8 @@ public function store(Request $request, CreateScheduleUpdateRequestData $data, S */ public function show(Schedule $schedule, Update $update) { + $this->ensureScheduleVisible($schedule); + $updateQuery = QueryBuilder::for(Update::class) ->allowedIncludes([ AllowedInclude::relationship('schedule', 'updateable'), @@ -76,6 +81,24 @@ public function show(Schedule $schedule, Update $update) ->setStatusCode(Response::HTTP_OK); } + /** + * Abort with a 404 when the parent schedule is not readable by the caller. + * + * An unpublished maintenance window must not leak its updates either, so + * publication is checked here on exactly the same terms as the schedule + * endpoints. + */ + protected function ensureScheduleVisible(Schedule $schedule): void + { + abort_unless( + Schedule::query() + ->when(! $this->tokenCan('schedules.manage'), fn (ScheduleBuilder $query) => $query->published()) + ->whereKey($schedule->getKey()) + ->exists(), + Response::HTTP_NOT_FOUND, + ); + } + /** * Update Schedule Update */ diff --git a/src/Mcp/Tools/ComponentGroups/CreateComponentGroup.php b/src/Mcp/Tools/ComponentGroups/CreateComponentGroup.php index a3f7487b..7e44d163 100644 --- a/src/Mcp/Tools/ComponentGroups/CreateComponentGroup.php +++ b/src/Mcp/Tools/ComponentGroups/CreateComponentGroup.php @@ -7,6 +7,7 @@ use Cachet\Enums\ComponentGroupVisibilityEnum; use Cachet\Enums\ResourceOrderColumnEnum; use Cachet\Enums\ResourceOrderDirectionEnum; +use Cachet\Enums\ResourceVisibilityEnum; use Cachet\Mcp\Concerns\GuardsMcpAbilities; use Cachet\Mcp\Concerns\PresentsResources; use Illuminate\Contracts\JsonSchema\JsonSchema; @@ -32,7 +33,9 @@ public function schema(JsonSchema $schema): array return [ 'name' => $schema->string()->max(255)->required()->description('The name of the component group.'), 'order' => $schema->integer()->min(0)->description('The display order of the component group.'), - 'visible' => $schema->boolean()->description('Whether the component group is visible to guests.'), + 'visible' => $schema->integer() + ->enum(array_column(ResourceVisibilityEnum::cases(), 'value')) + ->description('Who the group is visible to: 0 authenticated users only, 1 everyone, 2 hidden.'), 'collapsed' => $schema->integer() ->enum(array_column(ComponentGroupVisibilityEnum::cases(), 'value')) ->description('Collapse behaviour: 0 expanded, 1 collapsed, 2 collapsed unless a component has an incident.'), diff --git a/src/Mcp/Tools/ComponentGroups/UpdateComponentGroup.php b/src/Mcp/Tools/ComponentGroups/UpdateComponentGroup.php index 62ae268b..f08fdc0c 100644 --- a/src/Mcp/Tools/ComponentGroups/UpdateComponentGroup.php +++ b/src/Mcp/Tools/ComponentGroups/UpdateComponentGroup.php @@ -7,6 +7,7 @@ use Cachet\Enums\ComponentGroupVisibilityEnum; use Cachet\Enums\ResourceOrderColumnEnum; use Cachet\Enums\ResourceOrderDirectionEnum; +use Cachet\Enums\ResourceVisibilityEnum; use Cachet\Mcp\Concerns\GuardsMcpAbilities; use Cachet\Mcp\Concerns\PresentsResources; use Cachet\Models\ComponentGroup; @@ -36,7 +37,9 @@ public function schema(JsonSchema $schema): array 'id' => $schema->integer()->required()->description('The component group ID.'), 'name' => $schema->string()->max(255)->description('The name of the component group.'), 'order' => $schema->integer()->min(0)->description('The display order of the component group.'), - 'visible' => $schema->boolean()->description('Whether the component group is visible to guests.'), + 'visible' => $schema->integer() + ->enum(array_column(ResourceVisibilityEnum::cases(), 'value')) + ->description('Who the group is visible to: 0 authenticated users only, 1 everyone, 2 hidden.'), 'collapsed' => $schema->integer() ->enum(array_column(ComponentGroupVisibilityEnum::cases(), 'value')) ->description('Collapse behaviour: 0 expanded, 1 collapsed, 2 collapsed unless a component has an incident.'), diff --git a/src/Mcp/Tools/Incidents/CreateIncident.php b/src/Mcp/Tools/Incidents/CreateIncident.php index 3fd6c5cc..66f7faea 100644 --- a/src/Mcp/Tools/Incidents/CreateIncident.php +++ b/src/Mcp/Tools/Incidents/CreateIncident.php @@ -6,6 +6,7 @@ use Cachet\Data\Requests\Incident\CreateIncidentRequestData; use Cachet\Enums\ComponentStatusEnum; use Cachet\Enums\IncidentStatusEnum; +use Cachet\Enums\ResourceVisibilityEnum; use Cachet\Mcp\Concerns\GuardsMcpAbilities; use Cachet\Mcp\Concerns\PresentsResources; use Illuminate\Contracts\JsonSchema\JsonSchema; @@ -37,7 +38,10 @@ public function schema(JsonSchema $schema): array 'message' => $schema->string()->description('The incident message, in Markdown. Required unless template is given.'), 'template' => $schema->string()->description('The slug of an incident template to render the message from.'), 'template_vars' => $schema->object()->description('Variables passed to the incident template.'), - 'visible' => $schema->boolean()->default(false)->description('Whether the incident is visible to guests.'), + 'visible' => $schema->integer() + ->enum(array_column(ResourceVisibilityEnum::cases(), 'value')) + ->default(ResourceVisibilityEnum::authenticated->value) + ->description('Who the incident is visible to: 0 authenticated users only, 1 everyone, 2 hidden.'), 'stickied' => $schema->boolean()->default(false)->description('Whether the incident is stickied to the top of the status page.'), 'notifications' => $schema->boolean()->default(false)->description('Whether to notify verified subscribers.'), 'occurred_at' => $schema->string()->description('When the incident occurred, as an ISO-8601 datetime. Defaults to now.'), diff --git a/src/Mcp/Tools/Incidents/UpdateIncident.php b/src/Mcp/Tools/Incidents/UpdateIncident.php index 647ae22c..6e4f9ba1 100644 --- a/src/Mcp/Tools/Incidents/UpdateIncident.php +++ b/src/Mcp/Tools/Incidents/UpdateIncident.php @@ -5,6 +5,7 @@ use Cachet\Actions\Incident\UpdateIncident as UpdateIncidentAction; use Cachet\Data\Requests\Incident\UpdateIncidentRequestData; use Cachet\Enums\IncidentStatusEnum; +use Cachet\Enums\ResourceVisibilityEnum; use Cachet\Mcp\Concerns\GuardsMcpAbilities; use Cachet\Mcp\Concerns\PresentsResources; use Cachet\Models\Incident; @@ -37,7 +38,9 @@ public function schema(JsonSchema $schema): array 'status' => $schema->integer() ->enum(array_column(IncidentStatusEnum::cases(), 'value')) ->description('The status: 0 unknown, 1 investigating, 2 identified, 3 watching, 4 fixed.'), - 'visible' => $schema->boolean()->description('Whether the incident is visible to guests.'), + 'visible' => $schema->integer() + ->enum(array_column(ResourceVisibilityEnum::cases(), 'value')) + ->description('Who the incident is visible to: 0 authenticated users only, 1 everyone, 2 hidden.'), 'stickied' => $schema->boolean()->description('Whether the incident is stickied to the top of the status page.'), 'notifications' => $schema->boolean()->description('Whether to notify verified subscribers.'), 'occurred_at' => $schema->string()->description('When the incident occurred, as an ISO-8601 datetime.'), diff --git a/src/Models/Component.php b/src/Models/Component.php index 77c2840d..182cd987 100644 --- a/src/Models/Component.php +++ b/src/Models/Component.php @@ -90,6 +90,26 @@ class Component extends Model implements Metable 'updated' => ComponentUpdated::class, ]; + /** + * Purge the component's links to other resources when it is removed. + * + * A soft-deleted component still exists, so it keeps its subscriptions + * and incident and schedule attachments for a restore; the rows are only + * purged once the component is hard deleted and gone from the database. + */ + protected static function booted(): void + { + self::deleted(function (Component $component): void { + if ($component->exists) { + return; + } + + $component->subscribers()->detach(); + $component->incidents()->detach(); + $component->schedules()->detach(); + }); + } + /** * Render the Markdown description. */ diff --git a/src/Models/Incident.php b/src/Models/Incident.php index 893eddce..855f743f 100644 --- a/src/Models/Incident.php +++ b/src/Models/Incident.php @@ -125,6 +125,15 @@ protected static function boot() $model->published_notified_at = $model->freshTimestamp(); } }); + + self::deleted(function (Incident $model) { + if ($model->exists) { + return; + } + + $model->updates()->delete(); + $model->components()->detach(); + }); } /** diff --git a/src/Models/Schedule.php b/src/Models/Schedule.php index fec51fb3..d675902c 100644 --- a/src/Models/Schedule.php +++ b/src/Models/Schedule.php @@ -59,7 +59,10 @@ class Schedule extends Model implements Metable use SoftDeletes; /** - * Notify subscribers when the schedule transitions to complete, or when its window moves. + * Notify subscribers when the schedule transitions to complete, or when + * its window moves, and purge child rows once it is hard deleted. A + * soft-deleted schedule keeps its updates and component attachments for + * a restore. */ protected static function booted(): void { @@ -85,6 +88,15 @@ protected static function booted(): void ); } }); + + self::deleted(function (Schedule $schedule) { + if ($schedule->exists) { + return; + } + + $schedule->updates()->delete(); + $schedule->components()->detach(); + }); } /** @var array */ diff --git a/tests/Architecture/DataTest.php b/tests/Architecture/DataTest.php index 2e168aa4..9abdcc86 100644 --- a/tests/Architecture/DataTest.php +++ b/tests/Architecture/DataTest.php @@ -1,6 +1,7 @@ toExtend(BaseData::class) ->ignoring(FlexibleDateTimeCast::class) ->toBeFinal() - ->ignoring(BaseData::class); + ->ignoring([BaseData::class, BaseUpdateData::class]); test('data requests test') ->expect('Cachet\Data\Requests') @@ -30,3 +31,9 @@ ->toHaveMethodsDocumented() ->toBeAbstract() ->toExtend(Data::class); + +test('base update data test') + ->expect(BaseUpdateData::class) + ->toHaveMethodsDocumented() + ->toBeAbstract() + ->toExtend(BaseData::class); diff --git a/tests/Feature/Api/ComponentGroupTest.php b/tests/Feature/Api/ComponentGroupTest.php index 89928c6d..ac612814 100644 --- a/tests/Feature/Api/ComponentGroupTest.php +++ b/tests/Feature/Api/ComponentGroupTest.php @@ -566,3 +566,63 @@ $response->assertOk(); $response->assertJsonCount(2, 'data'); }); + +it('can create a hidden component group', function () { + Sanctum::actingAs(User::factory()->create(), ['component-groups.manage']); + + $response = postJson('/status/api/component-groups', [ + 'name' => 'Internal Group', + 'visible' => ResourceVisibilityEnum::hidden->value, + ]); + + $response->assertCreated(); + $this->assertDatabaseHas('component_groups', [ + 'name' => 'Internal Group', + 'visible' => ResourceVisibilityEnum::hidden->value, + ]); +}); + +it('rejects an invalid visibility value for a component group', function () { + Sanctum::actingAs(User::factory()->create(), ['component-groups.manage']); + + $response = postJson('/status/api/component-groups', [ + 'name' => 'Internal Group', + 'visible' => 5, + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('visible'); +}); + +it('maps a legacy boolean visibility onto the visibility enum', function () { + Sanctum::actingAs(User::factory()->create(), ['component-groups.manage']); + + $response = postJson('/status/api/component-groups', [ + 'name' => 'Public Group', + 'visible' => true, + ]); + + $response->assertCreated(); + $this->assertDatabaseHas('component_groups', [ + 'name' => 'Public Group', + 'visible' => ResourceVisibilityEnum::guest->value, + ]); +}); + +it('can update a component group\'s visibility', function () { + Sanctum::actingAs(User::factory()->create(), ['component-groups.manage']); + + $componentGroup = ComponentGroup::factory()->create([ + 'visible' => ResourceVisibilityEnum::guest, + ]); + + $response = putJson('/status/api/component-groups/'.$componentGroup->id, [ + 'visible' => ResourceVisibilityEnum::hidden->value, + ]); + + $response->assertOk(); + $this->assertDatabaseHas('component_groups', [ + 'id' => $componentGroup->id, + 'visible' => ResourceVisibilityEnum::hidden->value, + ]); +}); diff --git a/tests/Feature/Api/ComponentTest.php b/tests/Feature/Api/ComponentTest.php index 2138c31f..5a5349a2 100644 --- a/tests/Feature/Api/ComponentTest.php +++ b/tests/Feature/Api/ComponentTest.php @@ -681,3 +681,49 @@ ->assertJsonPath('data.attributes.status.value', ComponentStatusEnum::operational->value) ->assertJsonPath('data.attributes.latest_status.value', ComponentStatusEnum::major_outage->value); }); + +it('can clear a component\'s nullable fields via an update', function () { + Sanctum::actingAs(User::factory()->create(), ['components.manage']); + + $componentGroup = ComponentGroup::factory()->create(); + $component = Component::factory()->create([ + 'description' => 'A component.', + 'link' => 'https://cachethq.io', + 'component_group_id' => $componentGroup->id, + ]); + + $response = putJson('/status/api/components/'.$component->id, [ + 'description' => null, + 'link' => null, + 'component_group_id' => null, + ]); + + $response->assertOk(); + $this->assertDatabaseHas('components', [ + 'id' => $component->id, + 'description' => null, + 'link' => null, + 'component_group_id' => null, + ]); +}); + +it('leaves nullable fields untouched when they are omitted from an update', function () { + Sanctum::actingAs(User::factory()->create(), ['components.manage']); + + $component = Component::factory()->create([ + 'description' => 'A component.', + 'link' => 'https://cachethq.io', + ]); + + $response = putJson('/status/api/components/'.$component->id, [ + 'name' => 'Updated Component Name', + ]); + + $response->assertOk(); + $this->assertDatabaseHas('components', [ + 'id' => $component->id, + 'name' => 'Updated Component Name', + 'description' => 'A component.', + 'link' => 'https://cachethq.io', + ]); +}); diff --git a/tests/Feature/Api/IncidentTest.php b/tests/Feature/Api/IncidentTest.php index 90b63758..4b1d096b 100644 --- a/tests/Feature/Api/IncidentTest.php +++ b/tests/Feature/Api/IncidentTest.php @@ -611,3 +611,80 @@ expect(Incident::query()->where('name', 'Duplicated')->exists())->toBeFalse(); }); + +it('can create a hidden incident', function () { + Sanctum::actingAs(User::factory()->create(), ['incidents.manage']); + + $response = postJson('/status/api/incidents', [ + 'name' => 'Internal Incident', + 'message' => 'Something went wrong.', + 'status' => IncidentStatusEnum::investigating->value, + 'visible' => ResourceVisibilityEnum::hidden->value, + ]); + + $response->assertCreated(); + $this->assertDatabaseHas('incidents', [ + 'name' => 'Internal Incident', + 'visible' => ResourceVisibilityEnum::hidden->value, + ]); +}); + +it('rejects an invalid visibility value for an incident', function () { + Sanctum::actingAs(User::factory()->create(), ['incidents.manage']); + + $response = postJson('/status/api/incidents', [ + 'name' => 'Internal Incident', + 'message' => 'Something went wrong.', + 'status' => IncidentStatusEnum::investigating->value, + 'visible' => 5, + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('visible'); +}); + +it('maps a legacy boolean visibility onto the visibility enum', function () { + Sanctum::actingAs(User::factory()->create(), ['incidents.manage']); + + $response = postJson('/status/api/incidents', [ + 'name' => 'Public Incident', + 'message' => 'Something went wrong.', + 'status' => IncidentStatusEnum::investigating->value, + 'visible' => true, + ]); + + $response->assertCreated(); + $this->assertDatabaseHas('incidents', [ + 'name' => 'Public Incident', + 'visible' => ResourceVisibilityEnum::guest->value, + ]); +}); + +it('can update an incident\'s visibility', function () { + Sanctum::actingAs(User::factory()->create(), ['incidents.manage']); + + $incident = Incident::factory()->create(['visible' => ResourceVisibilityEnum::guest]); + + $response = putJson('/status/api/incidents/'.$incident->id, [ + 'visible' => ResourceVisibilityEnum::hidden->value, + ]); + + $response->assertOk(); + $this->assertDatabaseHas('incidents', [ + 'id' => $incident->id, + 'visible' => ResourceVisibilityEnum::hidden->value, + ]); +}); + +it('can clear an incident\'s publish date to publish it immediately', function () { + Sanctum::actingAs(User::factory()->create(), ['incidents.manage']); + + $incident = Incident::factory()->create(['published_at' => now()->addDay()]); + + $response = putJson('/status/api/incidents/'.$incident->id, [ + 'published_at' => null, + ]); + + $response->assertOk(); + expect($incident->fresh()->published_at)->toBeNull(); +}); diff --git a/tests/Feature/Api/MetricTest.php b/tests/Feature/Api/MetricTest.php index 3189fff1..d6fc4957 100644 --- a/tests/Feature/Api/MetricTest.php +++ b/tests/Feature/Api/MetricTest.php @@ -420,3 +420,19 @@ $response->assertOk(); $response->assertJsonCount(2, 'data'); }); + +it('can clear a metric\'s description via an update', function () { + Sanctum::actingAs(User::factory()->create(), ['metrics.manage']); + + $metric = Metric::factory()->create(['description' => 'A metric.']); + + $response = putJson('/status/api/metrics/'.$metric->id, [ + 'description' => null, + ]); + + $response->assertOk(); + $this->assertDatabaseHas('metrics', [ + 'id' => $metric->id, + 'description' => null, + ]); +}); diff --git a/tests/Feature/Api/ScheduleTest.php b/tests/Feature/Api/ScheduleTest.php index 5f9ec561..3fd0bfa2 100644 --- a/tests/Feature/Api/ScheduleTest.php +++ b/tests/Feature/Api/ScheduleTest.php @@ -553,3 +553,21 @@ expect($included->firstWhere('id', (string) $component->id))->not->toBeNull() ->and($included->firstWhere('type', 'componentGroups'))->toBeNull(); }); + +it('can reopen a maintenance window by clearing its completion date', function () { + Sanctum::actingAs(User::factory()->create(), ['schedules.manage']); + + $schedule = Schedule::factory()->create([ + 'scheduled_at' => now()->subDay(), + 'completed_at' => now()->subHour(), + ]); + + $response = putJson('/status/api/schedules/'.$schedule->id, [ + 'completed_at' => null, + ]); + + $response->assertOk(); + expect($schedule->fresh()) + ->completed_at->toBeNull() + ->status->toBe(ScheduleStatusEnum::in_progress); +}); diff --git a/tests/Feature/Api/ScheduleUpdateTest.php b/tests/Feature/Api/ScheduleUpdateTest.php index aa7a9406..9554f9f4 100644 --- a/tests/Feature/Api/ScheduleUpdateTest.php +++ b/tests/Feature/Api/ScheduleUpdateTest.php @@ -229,3 +229,40 @@ 'updateable_id' => $scheduleUpdate->updateable_id, ]); }); + +it('does not list updates of an unpublished schedule to guests', function () { + $schedule = Schedule::factory()->hasUpdates(2)->create(['published_at' => now()->addDay()]); + + $response = getJson("/status/api/schedules/{$schedule->id}/updates"); + + $response->assertNotFound(); +}); + +it('does not show an update of an unpublished schedule to guests', function () { + $schedule = Schedule::factory()->hasUpdates(1)->create(['published_at' => now()->addDay()]); + $update = $schedule->updates()->first(); + + $response = getJson("/status/api/schedules/{$schedule->id}/updates/{$update->id}"); + + $response->assertNotFound(); +}); + +it('lists updates of an unpublished schedule with the manage ability', function () { + Sanctum::actingAs(User::factory()->create(), ['schedules.manage']); + + $schedule = Schedule::factory()->hasUpdates(2)->create(['published_at' => now()->addDay()]); + + $response = getJson("/status/api/schedules/{$schedule->id}/updates"); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); +}); + +it('lists updates of a published schedule to guests', function () { + $schedule = Schedule::factory()->hasUpdates(2)->create(['published_at' => now()->subDay()]); + + $response = getJson("/status/api/schedules/{$schedule->id}/updates"); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); +}); diff --git a/tests/Feature/Mcp/Tools/IncidentToolsTest.php b/tests/Feature/Mcp/Tools/IncidentToolsTest.php index 2f8d60df..002e9222 100644 --- a/tests/Feature/Mcp/Tools/IncidentToolsTest.php +++ b/tests/Feature/Mcp/Tools/IncidentToolsTest.php @@ -314,7 +314,7 @@ 'name' => 'API Outage', 'status' => IncidentStatusEnum::identified->value, 'message' => 'The API is down.', - 'visible' => true, + 'visible' => ResourceVisibilityEnum::guest->value, 'components' => [ ['id' => $component->id, 'status' => ComponentStatusEnum::major_outage->value], ], @@ -337,7 +337,7 @@ 'name' => 'Internal Outage', 'status' => IncidentStatusEnum::identified->value, 'message' => 'The API is down.', - 'visible' => false, + 'visible' => ResourceVisibilityEnum::authenticated->value, 'components' => [ ['id' => $component->id, 'status' => ComponentStatusEnum::major_outage->value], ], diff --git a/tests/Unit/Actions/Component/CreateComponentTest.php b/tests/Unit/Actions/Component/CreateComponentTest.php index 99056816..b59369b5 100644 --- a/tests/Unit/Actions/Component/CreateComponentTest.php +++ b/tests/Unit/Actions/Component/CreateComponentTest.php @@ -4,13 +4,12 @@ use Cachet\Data\Requests\Component\CreateComponentRequestData; use Cachet\Enums\ComponentStatusEnum; use Cachet\Events\Components\ComponentCreated; +use Cachet\Models\Meta; use Illuminate\Support\Facades\Event; -beforeEach(function () { +it('can create a component', function () { Event::fake(); -}); -it('can create a component', function () { $data = CreateComponentRequestData::from([ 'name' => 'My Component', 'description' => 'My component description', @@ -27,6 +26,8 @@ }); it('can create a component with a given status', function () { + Event::fake(); + $data = CreateComponentRequestData::from([ 'name' => 'My Component', 'description' => 'My component description', @@ -42,3 +43,18 @@ Event::assertDispatched(ComponentCreated::class, fn ($event) => $event->component->is($component)); }); + +it('creates nothing when a write fails part-way', function () { + Meta::creating(fn () => throw new RuntimeException('boom')); + + try { + app(CreateComponent::class)->handle(CreateComponentRequestData::from([ + 'name' => 'My Component', + 'meta' => ['cluster' => 'eu-west'], + ])); + } catch (RuntimeException) { + // + } + + $this->assertDatabaseCount('components', 0); +}); diff --git a/tests/Unit/Actions/Component/DeleteComponentTest.php b/tests/Unit/Actions/Component/DeleteComponentTest.php index 14d097cc..ae6ffeb5 100644 --- a/tests/Unit/Actions/Component/DeleteComponentTest.php +++ b/tests/Unit/Actions/Component/DeleteComponentTest.php @@ -1,8 +1,10 @@ $event->component->is($component)); }); -it('deletes attached subscriptions when deleted', function () { +it('keeps subscriptions when the component is soft deleted', function () { $component = Component::factory()->hasSubscribers(1, ['email' => 'james@alt-three.com'])->create(); $subscriber = $component->subscribers()->first(); + app(DeleteComponent::class)->handle($component); + + $this->assertSoftDeleted('components', [ + 'id' => $component->id, + ]); $this->assertDatabaseHas('subscriptions', [ 'subscriber_id' => $subscriber->id, ]); +}); + +it('restores a component with its subscriptions intact', function () { + $component = Component::factory()->hasSubscribers(1, ['email' => 'james@alt-three.com'])->create(); app(DeleteComponent::class)->handle($component); + $component->restore(); + + expect($component->subscribers()->count())->toBe(1); +}); + +it('purges subscriptions and pivot rows when the component is hard deleted', function () { + $component = Component::factory()->hasSubscribers(1, ['email' => 'james@alt-three.com'])->create(); + $subscriber = $component->subscribers()->first(); + + $incident = Incident::factory()->create(); + $incident->components()->attach($component->id, ['component_status' => ComponentStatusEnum::major_outage]); + + $component->forceDelete(); + $this->assertDatabaseMissing('subscriptions', [ 'subscriber_id' => $subscriber->id, ]); + $this->assertDatabaseMissing('incident_components', [ + 'component_id' => $component->id, + ]); }); diff --git a/tests/Unit/Actions/ComponentGroup/CreateComponentGroupTest.php b/tests/Unit/Actions/ComponentGroup/CreateComponentGroupTest.php index 3a1dad96..605ebe20 100644 --- a/tests/Unit/Actions/ComponentGroup/CreateComponentGroupTest.php +++ b/tests/Unit/Actions/ComponentGroup/CreateComponentGroupTest.php @@ -5,6 +5,7 @@ use Cachet\Enums\ComponentGroupVisibilityEnum; use Cachet\Enums\ResourceVisibilityEnum; use Cachet\Models\Component; +use Cachet\Models\Meta; it('can create a component group with just a name', function () { $data = [ @@ -60,3 +61,18 @@ 'component_group_id' => $componentGroup->id, ]); }); + +it('creates nothing when a write fails part-way', function () { + Meta::creating(fn () => throw new RuntimeException('boom')); + + try { + app(CreateComponentGroup::class)->handle(CreateComponentGroupRequestData::from([ + 'name' => 'My Group', + 'meta' => ['cluster' => 'eu-west'], + ])); + } catch (RuntimeException) { + // + } + + $this->assertDatabaseCount('component_groups', 0); +}); diff --git a/tests/Unit/Actions/ComponentGroup/DeleteComponentGroupTest.php b/tests/Unit/Actions/ComponentGroup/DeleteComponentGroupTest.php index 57476a1f..539f32ec 100644 --- a/tests/Unit/Actions/ComponentGroup/DeleteComponentGroupTest.php +++ b/tests/Unit/Actions/ComponentGroup/DeleteComponentGroupTest.php @@ -28,3 +28,19 @@ 'component_group_id' => null, ]); }); + +it('keeps components attached when the delete fails part-way', function () { + $componentGroup = ComponentGroup::factory()->create(); + $component = Component::factory()->create(['component_group_id' => $componentGroup->id]); + + ComponentGroup::deleted(fn () => throw new RuntimeException('boom')); + + try { + app(DeleteComponentGroup::class)->handle($componentGroup); + } catch (RuntimeException) { + // + } + + $this->assertDatabaseHas('component_groups', ['id' => $componentGroup->id]); + expect($component->fresh()->component_group_id)->toBe($componentGroup->id); +}); diff --git a/tests/Unit/Actions/ComponentGroup/UpdateComponentGroupTest.php b/tests/Unit/Actions/ComponentGroup/UpdateComponentGroupTest.php index 3e54a648..5e7a0173 100644 --- a/tests/Unit/Actions/ComponentGroup/UpdateComponentGroupTest.php +++ b/tests/Unit/Actions/ComponentGroup/UpdateComponentGroupTest.php @@ -5,6 +5,7 @@ use Cachet\Enums\ComponentGroupVisibilityEnum; use Cachet\Models\Component; use Cachet\Models\ComponentGroup; +use Cachet\Models\Meta; it('can update a component group with just a name', function () { $componentGroup = ComponentGroup::factory()->create(); @@ -63,3 +64,20 @@ 'component_group_id' => $componentGroup->id, ]); }); + +it('rolls the whole update back when a write fails part-way', function () { + $componentGroup = ComponentGroup::factory()->create(['name' => 'Original Name']); + + Meta::creating(fn () => throw new RuntimeException('boom')); + + try { + app(UpdateComponentGroup::class)->handle($componentGroup, UpdateComponentGroupRequestData::from([ + 'name' => 'Updated Name', + 'meta' => ['cluster' => 'eu-west'], + ])); + } catch (RuntimeException) { + // + } + + expect($componentGroup->fresh()->name)->toBe('Original Name'); +}); diff --git a/tests/Unit/Actions/Incident/CreateIncidentTest.php b/tests/Unit/Actions/Incident/CreateIncidentTest.php index e8e3c92c..6011300d 100644 --- a/tests/Unit/Actions/Incident/CreateIncidentTest.php +++ b/tests/Unit/Actions/Incident/CreateIncidentTest.php @@ -7,6 +7,7 @@ use Cachet\Events\Incidents\IncidentCreated; use Cachet\Models\Component; use Cachet\Models\IncidentTemplate; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Support\Facades\Event; beforeEach(function () { @@ -114,3 +115,13 @@ ->toContain(ComponentStatusEnum::performance_issues) ->toContain(ComponentStatusEnum::partial_outage); }); + +it('fails loudly when the incident template has gone missing', function () { + $data = CreateIncidentRequestData::factory()->withoutValidation()->from([ + 'name' => 'My Incident', + 'template' => 'missing-template', + 'status' => IncidentStatusEnum::investigating, + ]); + + app(CreateIncident::class)->handle($data); +})->throws(ModelNotFoundException::class); diff --git a/tests/Unit/Actions/Incident/DeleteIncidentTest.php b/tests/Unit/Actions/Incident/DeleteIncidentTest.php index fcae9d55..856579a6 100644 --- a/tests/Unit/Actions/Incident/DeleteIncidentTest.php +++ b/tests/Unit/Actions/Incident/DeleteIncidentTest.php @@ -1,7 +1,9 @@ $event->incident->is($incident)); }); -it('deletes attached incident updates', function () { +it('keeps updates and component attachments when the incident is soft deleted', function () { + $component = Component::factory()->create(); $incident = Incident::factory()->hasUpdates(2)->create(); + $incident->components()->attach($component->id, ['component_status' => ComponentStatusEnum::major_outage]); app(DeleteIncident::class)->handle($incident); $this->assertSoftDeleted('incidents', [ 'id' => $incident->id, ]); + $this->assertDatabaseHas('updates', [ + 'updateable_type' => Relation::getMorphAlias(Incident::class), + 'updateable_id' => $incident->id, + ]); + $this->assertDatabaseHas('incident_components', [ + 'incident_id' => $incident->id, + ]); +}); + +it('restores an incident with its updates intact', function () { + $incident = Incident::factory()->hasUpdates(2)->create(); + + app(DeleteIncident::class)->handle($incident); + + $incident->restore(); + + expect($incident->updates()->count())->toBe(2); +}); + +it('purges updates and component attachments when the incident is hard deleted', function () { + $component = Component::factory()->create(); + $incident = Incident::factory()->hasUpdates(2)->create(); + $incident->components()->attach($component->id, ['component_status' => ComponentStatusEnum::major_outage]); + + $incident->forceDelete(); + $this->assertDatabaseMissing('updates', [ 'updateable_type' => Relation::getMorphAlias(Incident::class), 'updateable_id' => $incident->id, ]); + $this->assertDatabaseMissing('incident_components', [ + 'incident_id' => $incident->id, + ]); }); diff --git a/tests/Unit/Actions/Incident/NotifyIncidentSubscribersTest.php b/tests/Unit/Actions/Incident/NotifyIncidentSubscribersTest.php index 742b1b26..d3b73241 100644 --- a/tests/Unit/Actions/Incident/NotifyIncidentSubscribersTest.php +++ b/tests/Unit/Actions/Incident/NotifyIncidentSubscribersTest.php @@ -98,7 +98,7 @@ function notifiableIncident(array $attributes = []): Incident 'name' => 'My Incident', 'message' => 'This is an incident message.', 'status' => IncidentStatusEnum::investigating, - 'visible' => true, + 'visible' => ResourceVisibilityEnum::guest, 'notifications' => true, 'components' => [ ['id' => $component->id, 'status' => ComponentStatusEnum::major_outage->value], diff --git a/tests/Unit/Actions/Incident/UpdateIncidentTest.php b/tests/Unit/Actions/Incident/UpdateIncidentTest.php index e9976b5b..69f5bb94 100644 --- a/tests/Unit/Actions/Incident/UpdateIncidentTest.php +++ b/tests/Unit/Actions/Incident/UpdateIncidentTest.php @@ -4,6 +4,7 @@ use Cachet\Data\Requests\Incident\UpdateIncidentRequestData; use Cachet\Events\Incidents\IncidentUpdated; use Cachet\Models\Incident; +use Cachet\Models\Meta; it('can update an incident', function () { $incident = Incident::factory()->create(); @@ -30,3 +31,20 @@ Event::assertDispatched(IncidentUpdated::class, fn (IncidentUpdated $event) => $event->incident->is($incident)); Event::assertDispatchedTimes(IncidentUpdated::class, 1); }); + +it('rolls the whole update back when a write fails part-way', function () { + $incident = Incident::factory()->create(['name' => 'Original Name']); + + Meta::creating(fn () => throw new RuntimeException('boom')); + + try { + app(UpdateIncident::class)->handle($incident, UpdateIncidentRequestData::from([ + 'name' => 'Updated Name', + 'meta' => ['cluster' => 'eu-west'], + ])); + } catch (RuntimeException) { + // + } + + expect($incident->fresh()->name)->toBe('Original Name'); +}); diff --git a/tests/Unit/Actions/IncidentTemplate/UpdateIncidentTemplateTest.php b/tests/Unit/Actions/IncidentTemplate/UpdateIncidentTemplateTest.php index 936bf5ed..315fafc6 100644 --- a/tests/Unit/Actions/IncidentTemplate/UpdateIncidentTemplateTest.php +++ b/tests/Unit/Actions/IncidentTemplate/UpdateIncidentTemplateTest.php @@ -19,3 +19,36 @@ ->template->toBe('Hey there.') ->engine->toBe(IncidentTemplateEngineEnum::twig); }); + +it('keeps the slug when updating only the template body', function () { + $incidentTemplate = IncidentTemplate::factory()->create(['slug' => 'my-template']); + + app(UpdateIncidentTemplate::class)->handle($incidentTemplate, UpdateIncidentTemplateRequestData::from([ + 'template' => 'An updated body.', + ])); + + expect($incidentTemplate->fresh()) + ->template->toBe('An updated body.') + ->slug->toBe('my-template'); +}); + +it('regenerates the slug when the name changes', function () { + $incidentTemplate = IncidentTemplate::factory()->create(['slug' => 'old-slug']); + + app(UpdateIncidentTemplate::class)->handle($incidentTemplate, UpdateIncidentTemplateRequestData::from([ + 'name' => 'New Name', + ])); + + expect($incidentTemplate->fresh())->slug->toBe('new-name'); +}); + +it('prefers an explicit slug over one derived from the name', function () { + $incidentTemplate = IncidentTemplate::factory()->create(); + + app(UpdateIncidentTemplate::class)->handle($incidentTemplate, UpdateIncidentTemplateRequestData::from([ + 'name' => 'New Name', + 'slug' => 'explicit-slug', + ])); + + expect($incidentTemplate->fresh())->slug->toBe('explicit-slug'); +}); diff --git a/tests/Unit/Actions/Metric/DeleteMetricTest.php b/tests/Unit/Actions/Metric/DeleteMetricTest.php index b45cd95d..079fb3f8 100644 --- a/tests/Unit/Actions/Metric/DeleteMetricTest.php +++ b/tests/Unit/Actions/Metric/DeleteMetricTest.php @@ -3,6 +3,7 @@ use Cachet\Actions\Metric\DeleteMetric; use Cachet\Events\Metrics\MetricDeleted; use Cachet\Models\Metric; +use Cachet\Models\MetricPoint; use Illuminate\Support\Facades\Event; it('can delete a metric', function () { @@ -16,3 +17,19 @@ ]); Event::assertDispatched(MetricDeleted::class); }); + +it('keeps the metric points when the delete fails part-way', function () { + $metric = Metric::factory()->create(); + $metricPoint = MetricPoint::factory()->create(['metric_id' => $metric->id]); + + Metric::deleted(fn () => throw new RuntimeException('boom')); + + try { + app(DeleteMetric::class)->handle($metric); + } catch (RuntimeException) { + // + } + + $this->assertDatabaseHas('metrics', ['id' => $metric->id]); + $this->assertDatabaseHas('metric_points', ['id' => $metricPoint->id]); +}); diff --git a/tests/Unit/Actions/Schedule/DeleteScheduleTest.php b/tests/Unit/Actions/Schedule/DeleteScheduleTest.php index 9fd49729..4bbc72e4 100644 --- a/tests/Unit/Actions/Schedule/DeleteScheduleTest.php +++ b/tests/Unit/Actions/Schedule/DeleteScheduleTest.php @@ -1,7 +1,10 @@ create(); @@ -12,3 +15,33 @@ 'id' => $schedule->id, ]); }); + +it('keeps updates when the schedule is soft deleted', function () { + $schedule = Schedule::factory()->hasUpdates(2)->create(); + + app(DeleteSchedule::class)->handle($schedule); + + $this->assertSoftDeleted('schedules', [ + 'id' => $schedule->id, + ]); + $this->assertDatabaseHas('updates', [ + 'updateable_type' => Relation::getMorphAlias(Schedule::class), + 'updateable_id' => $schedule->id, + ]); +}); + +it('purges updates and component attachments when the schedule is hard deleted', function () { + $component = Component::factory()->create(); + $schedule = Schedule::factory()->hasUpdates(2)->create(); + $schedule->components()->attach($component->id, ['component_status' => ComponentStatusEnum::under_maintenance]); + + $schedule->forceDelete(); + + $this->assertDatabaseMissing('updates', [ + 'updateable_type' => Relation::getMorphAlias(Schedule::class), + 'updateable_id' => $schedule->id, + ]); + $this->assertDatabaseMissing('schedule_components', [ + 'schedule_id' => $schedule->id, + ]); +}); diff --git a/tests/Unit/Actions/Schedule/UpdateScheduleTest.php b/tests/Unit/Actions/Schedule/UpdateScheduleTest.php index b52ce41a..ff25e2ca 100644 --- a/tests/Unit/Actions/Schedule/UpdateScheduleTest.php +++ b/tests/Unit/Actions/Schedule/UpdateScheduleTest.php @@ -4,6 +4,7 @@ use Cachet\Data\Requests\Schedule\UpdateScheduleRequestData; use Cachet\Enums\ComponentStatusEnum; use Cachet\Models\Component; +use Cachet\Models\Meta; use Cachet\Models\Schedule; it('can update a schedule', function () { @@ -45,3 +46,20 @@ 'component_status' => ComponentStatusEnum::major_outage, ]); }); + +it('rolls the whole update back when a write fails part-way', function () { + $schedule = Schedule::factory()->create(['name' => 'Original Name']); + + Meta::creating(fn () => throw new RuntimeException('boom')); + + try { + app(UpdateSchedule::class)->handle($schedule, UpdateScheduleRequestData::from([ + 'name' => 'Updated Name', + 'meta' => ['cluster' => 'eu-west'], + ])); + } catch (RuntimeException) { + // + } + + expect($schedule->fresh()->name)->toBe('Original Name'); +}); diff --git a/tests/Unit/Actions/Subscriber/CreateSubscriberTest.php b/tests/Unit/Actions/Subscriber/CreateSubscriberTest.php index 076a7195..b3e2bb8e 100644 --- a/tests/Unit/Actions/Subscriber/CreateSubscriberTest.php +++ b/tests/Unit/Actions/Subscriber/CreateSubscriberTest.php @@ -3,6 +3,7 @@ use Cachet\Actions\Subscriber\CreateSubscriber; use Cachet\Events\Subscribers\SubscriberCreated; use Cachet\Models\Component; +use Cachet\Models\Meta; use Illuminate\Support\Facades\Event; it('can create a subscriber', function () { @@ -64,3 +65,15 @@ Event::assertDispatched(SubscriberCreated::class); }); + +it('creates nothing when a write fails part-way', function () { + Meta::creating(fn () => throw new RuntimeException('boom')); + + try { + app(CreateSubscriber::class)->handle('james@alt-three.com', meta: ['plan' => 'pro']); + } catch (RuntimeException) { + // + } + + $this->assertDatabaseCount('subscribers', 0); +}); diff --git a/tests/Unit/Actions/Subscriber/UnsubscribeSubscriberTest.php b/tests/Unit/Actions/Subscriber/UnsubscribeSubscriberTest.php index 9feaee46..cb592649 100644 --- a/tests/Unit/Actions/Subscriber/UnsubscribeSubscriberTest.php +++ b/tests/Unit/Actions/Subscriber/UnsubscribeSubscriberTest.php @@ -28,3 +28,18 @@ expect($subscriber->fresh())->toBeNull() ->and(Component::query()->find($component->id))->not->toBeNull(); }); + +it('keeps the subscriptions when the delete fails part-way', function () { + $subscriber = Subscriber::factory()->hasComponents()->create(); + + Subscriber::deleted(fn () => throw new RuntimeException('boom')); + + try { + app(UnsubscribeSubscriber::class)->handle($subscriber); + } catch (RuntimeException) { + // + } + + $this->assertDatabaseHas('subscribers', ['id' => $subscriber->id]); + $this->assertDatabaseHas('subscriptions', ['subscriber_id' => $subscriber->id]); +}); diff --git a/tests/Unit/Actions/Subscriber/UpdateSubscriberTest.php b/tests/Unit/Actions/Subscriber/UpdateSubscriberTest.php index b99a553f..74b28aa3 100644 --- a/tests/Unit/Actions/Subscriber/UpdateSubscriberTest.php +++ b/tests/Unit/Actions/Subscriber/UpdateSubscriberTest.php @@ -2,6 +2,7 @@ use Cachet\Actions\Subscriber\UpdateSubscriber; use Cachet\Models\Component; +use Cachet\Models\Meta; use Cachet\Models\Subscriber; it('can update a subscriber\'s email address', function () { @@ -65,3 +66,17 @@ ->and($subscriber->components()->first()) ->toBeInstanceOf(Component::class); }); + +it('rolls the whole update back when a write fails part-way', function () { + $subscriber = Subscriber::factory()->create(['email' => 'james@alt-three.com']); + + Meta::creating(fn () => throw new RuntimeException('boom')); + + try { + app(UpdateSubscriber::class)->handle($subscriber, email: 'james@cachethq.io', meta: ['plan' => 'pro']); + } catch (RuntimeException) { + // + } + + expect($subscriber->fresh()->email)->toBe('james@alt-three.com'); +}); diff --git a/tests/Unit/Actions/Update/CreateUpdateTest.php b/tests/Unit/Actions/Update/CreateUpdateTest.php index 33bbc80c..c16dc2f6 100644 --- a/tests/Unit/Actions/Update/CreateUpdateTest.php +++ b/tests/Unit/Actions/Update/CreateUpdateTest.php @@ -9,6 +9,7 @@ use Cachet\Models\Component; use Cachet\Models\Incident; use Cachet\Models\Schedule; +use Cachet\Models\Update; it('can create an incident update', function () { $incident = Incident::factory()->create(); @@ -114,3 +115,21 @@ expect($incidentUpdate) ->message->toBe($data->message); }); + +it('persists nothing when the update cannot be saved', function () { + $schedule = Schedule::factory()->create(['completed_at' => null]); + + Update::created(fn () => throw new RuntimeException('boom')); + + try { + app(CreateUpdate::class)->handle($schedule, CreateScheduleUpdateRequestData::from([ + 'message' => 'Maintenance is complete.', + 'completed_at' => now()->toDateTimeString(), + ])); + } catch (RuntimeException) { + // + } + + $this->assertDatabaseCount('updates', 0); + expect($schedule->fresh()->completed_at)->toBeNull(); +}); diff --git a/tests/Unit/Actions/Update/DeleteUpdateTest.php b/tests/Unit/Actions/Update/DeleteUpdateTest.php index 9e7e7137..4f9f80cd 100644 --- a/tests/Unit/Actions/Update/DeleteUpdateTest.php +++ b/tests/Unit/Actions/Update/DeleteUpdateTest.php @@ -1,6 +1,7 @@ $update->updateable_id, ]); }); + +it('keeps the update when the incident status sync fails', function () { + $incident = Incident::factory()->create(['status' => IncidentStatusEnum::watching]); + Update::factory()->forIncident($incident)->create([ + 'status' => IncidentStatusEnum::identified, + 'created_at' => now()->subHour(), + ]); + $latest = Update::factory()->forIncident($incident)->create([ + 'status' => IncidentStatusEnum::watching, + 'created_at' => now(), + ]); + + Incident::updated(fn () => throw new RuntimeException('boom')); + + try { + app(DeleteUpdate::class)->handle($latest); + } catch (RuntimeException) { + // + } + + $this->assertDatabaseHas('updates', ['id' => $latest->id]); +}); diff --git a/tests/Unit/Actions/Update/EditUpdateTest.php b/tests/Unit/Actions/Update/EditUpdateTest.php index 8ed95f28..e021cadf 100644 --- a/tests/Unit/Actions/Update/EditUpdateTest.php +++ b/tests/Unit/Actions/Update/EditUpdateTest.php @@ -3,6 +3,8 @@ use Cachet\Actions\Update\EditUpdate; use Cachet\Data\Requests\IncidentUpdate\EditIncidentUpdateRequestData; use Cachet\Data\Requests\ScheduleUpdate\EditScheduleUpdateRequestData; +use Cachet\Enums\IncidentStatusEnum; +use Cachet\Models\Incident; use Cachet\Models\Update; it('can update an incident update', function () { @@ -32,3 +34,26 @@ ->message->toBe($data->message) ->status->toBe($update->status); }); + +it('rolls the edit back when the incident status sync fails', function () { + $incident = Incident::factory()->create(['status' => IncidentStatusEnum::investigating]); + $update = Update::factory()->forIncident($incident)->create([ + 'status' => IncidentStatusEnum::investigating, + 'message' => 'Original message.', + ]); + + Incident::updated(fn () => throw new RuntimeException('boom')); + + try { + app(EditUpdate::class)->handle($update, EditIncidentUpdateRequestData::from([ + 'status' => IncidentStatusEnum::fixed, + 'message' => 'Updated message.', + ])); + } catch (RuntimeException) { + // + } + + expect($update->fresh()) + ->message->toBe('Original message.') + ->status->toBe(IncidentStatusEnum::investigating); +});