feat(alerts): send test message, last test status, deep links
This commit is contained in:
parent
270181d509
commit
8ee12d8e89
@ -7,8 +7,11 @@
|
|||||||
use App\Filament\Clusters\Monitoring\AlertsCluster;
|
use App\Filament\Clusters\Monitoring\AlertsCluster;
|
||||||
use App\Filament\Resources\AlertDeliveryResource\Pages;
|
use App\Filament\Resources\AlertDeliveryResource\Pages;
|
||||||
use App\Models\AlertDelivery;
|
use App\Models\AlertDelivery;
|
||||||
|
use App\Models\AlertDestination;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -114,7 +117,10 @@ public static function getEloquentQuery(): Builder
|
|||||||
)
|
)
|
||||||
->when(
|
->when(
|
||||||
$user instanceof User,
|
$user instanceof User,
|
||||||
fn (Builder $query): Builder => $query->whereIn('tenant_id', $user->tenantMemberships()->select('tenant_id')),
|
fn (Builder $query): Builder => $query->where(function (Builder $q) use ($user): void {
|
||||||
|
$q->whereIn('tenant_id', $user->tenantMemberships()->select('tenant_id'))
|
||||||
|
->orWhereNull('tenant_id');
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
->when(
|
->when(
|
||||||
Filament::getTenant() instanceof Tenant,
|
Filament::getTenant() instanceof Tenant,
|
||||||
@ -136,8 +142,9 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('status')
|
TextEntry::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn (?string $state): string => self::statusLabel((string) $state))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::AlertDeliveryStatus))
|
||||||
->color(fn (?string $state): string => self::statusColor((string) $state)),
|
->color(BadgeRenderer::color(BadgeDomain::AlertDeliveryStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::AlertDeliveryStatus)),
|
||||||
TextEntry::make('event_type')
|
TextEntry::make('event_type')
|
||||||
->label('Event')
|
->label('Event')
|
||||||
->badge()
|
->badge()
|
||||||
@ -214,8 +221,9 @@ public static function table(Table $table): Table
|
|||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
TextColumn::make('status')
|
TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn (?string $state): string => self::statusLabel((string) $state))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::AlertDeliveryStatus))
|
||||||
->color(fn (?string $state): string => self::statusColor((string) $state)),
|
->color(BadgeRenderer::color(BadgeDomain::AlertDeliveryStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::AlertDeliveryStatus)),
|
||||||
TextColumn::make('rule.name')
|
TextColumn::make('rule.name')
|
||||||
->label('Rule')
|
->label('Rule')
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
@ -235,6 +243,29 @@ public static function table(Table $table): Table
|
|||||||
AlertDelivery::STATUS_SUPPRESSED => 'Suppressed',
|
AlertDelivery::STATUS_SUPPRESSED => 'Suppressed',
|
||||||
AlertDelivery::STATUS_CANCELED => 'Canceled',
|
AlertDelivery::STATUS_CANCELED => 'Canceled',
|
||||||
]),
|
]),
|
||||||
|
SelectFilter::make('event_type')
|
||||||
|
->label('Event type')
|
||||||
|
->options(function (): array {
|
||||||
|
$options = AlertRuleResource::eventTypeOptions();
|
||||||
|
$options[AlertDelivery::EVENT_TYPE_TEST] = 'Test';
|
||||||
|
|
||||||
|
return $options;
|
||||||
|
}),
|
||||||
|
SelectFilter::make('alert_destination_id')
|
||||||
|
->label('Destination')
|
||||||
|
->options(function (): array {
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if (! is_int($workspaceId)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return AlertDestination::query()
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
|
->orderBy('name')
|
||||||
|
->pluck('name', 'id')
|
||||||
|
->all();
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
ViewAction::make()->label('View'),
|
ViewAction::make()->label('View'),
|
||||||
@ -249,30 +280,4 @@ public static function getPages(): array
|
|||||||
'view' => Pages\ViewAlertDelivery::route('/{record}'),
|
'view' => Pages\ViewAlertDelivery::route('/{record}'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function statusLabel(string $status): string
|
|
||||||
{
|
|
||||||
return match ($status) {
|
|
||||||
AlertDelivery::STATUS_QUEUED => 'Queued',
|
|
||||||
AlertDelivery::STATUS_DEFERRED => 'Deferred',
|
|
||||||
AlertDelivery::STATUS_SENT => 'Sent',
|
|
||||||
AlertDelivery::STATUS_FAILED => 'Failed',
|
|
||||||
AlertDelivery::STATUS_SUPPRESSED => 'Suppressed',
|
|
||||||
AlertDelivery::STATUS_CANCELED => 'Canceled',
|
|
||||||
default => ucfirst($status),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function statusColor(string $status): string
|
|
||||||
{
|
|
||||||
return match ($status) {
|
|
||||||
AlertDelivery::STATUS_QUEUED => 'gray',
|
|
||||||
AlertDelivery::STATUS_DEFERRED => 'warning',
|
|
||||||
AlertDelivery::STATUS_SENT => 'success',
|
|
||||||
AlertDelivery::STATUS_FAILED => 'danger',
|
|
||||||
AlertDelivery::STATUS_SUPPRESSED => 'info',
|
|
||||||
AlertDelivery::STATUS_CANCELED => 'gray',
|
|
||||||
default => 'gray',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -173,7 +173,7 @@ public static function table(Table $table): Table
|
|||||||
->defaultSort('name')
|
->defaultSort('name')
|
||||||
->recordUrl(fn (AlertDestination $record): ?string => static::canEdit($record)
|
->recordUrl(fn (AlertDestination $record): ?string => static::canEdit($record)
|
||||||
? static::getUrl('edit', ['record' => $record])
|
? static::getUrl('edit', ['record' => $record])
|
||||||
: null)
|
: static::getUrl('view', ['record' => $record]))
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('name')
|
TextColumn::make('name')
|
||||||
->searchable(),
|
->searchable(),
|
||||||
@ -266,6 +266,7 @@ public static function getPages(): array
|
|||||||
return [
|
return [
|
||||||
'index' => Pages\ListAlertDestinations::route('/'),
|
'index' => Pages\ListAlertDestinations::route('/'),
|
||||||
'create' => Pages\CreateAlertDestination::route('/create'),
|
'create' => Pages\CreateAlertDestination::route('/create'),
|
||||||
|
'view' => Pages\ViewAlertDestination::route('/{record}'),
|
||||||
'edit' => Pages\EditAlertDestination::route('/{record}/edit'),
|
'edit' => Pages\EditAlertDestination::route('/{record}/edit'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,9 +4,15 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\AlertDestinationResource\Pages;
|
namespace App\Filament\Resources\AlertDestinationResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AlertDeliveryResource;
|
||||||
use App\Filament\Resources\AlertDestinationResource;
|
use App\Filament\Resources\AlertDestinationResource;
|
||||||
use App\Models\AlertDestination;
|
use App\Models\AlertDestination;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Alerts\AlertDestinationLastTestResolver;
|
||||||
|
use App\Services\Alerts\AlertDestinationTestMessageService;
|
||||||
|
use App\Support\Alerts\AlertDestinationLastTestStatus;
|
||||||
use App\Support\Audit\AuditActionId;
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use Filament\Actions\Action;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
@ -14,6 +20,80 @@ class EditAlertDestination extends EditRecord
|
|||||||
{
|
{
|
||||||
protected static string $resource = AlertDestinationResource::class;
|
protected static string $resource = AlertDestinationResource::class;
|
||||||
|
|
||||||
|
private ?AlertDestinationLastTestStatus $lastTestStatus = null;
|
||||||
|
|
||||||
|
public function mount(int|string $record): void
|
||||||
|
{
|
||||||
|
parent::mount($record);
|
||||||
|
$this->resolveLastTestStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
$record = $this->record;
|
||||||
|
$canManage = $user instanceof User
|
||||||
|
&& $record instanceof AlertDestination
|
||||||
|
&& $user->can('update', $record);
|
||||||
|
|
||||||
|
return [
|
||||||
|
Action::make('send_test_message')
|
||||||
|
->label('Send test message')
|
||||||
|
->icon('heroicon-o-paper-airplane')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Send test message')
|
||||||
|
->modalDescription('A test delivery will be queued for this destination. This verifies the delivery pipeline is working.')
|
||||||
|
->modalSubmitActionLabel('Send')
|
||||||
|
->visible(fn (): bool => $record instanceof AlertDestination)
|
||||||
|
->disabled(fn (): bool => ! $canManage)
|
||||||
|
->action(function () use ($record): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $record instanceof AlertDestination) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = app(AlertDestinationTestMessageService::class);
|
||||||
|
$result = $service->sendTest($record, $user);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
Notification::make()
|
||||||
|
->title($result['message'])
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
} else {
|
||||||
|
Notification::make()
|
||||||
|
->title($result['message'])
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->resolveLastTestStatus();
|
||||||
|
}),
|
||||||
|
|
||||||
|
Action::make('view_last_delivery')
|
||||||
|
->label('View last delivery')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->url(fn (): ?string => $this->buildDeepLinkUrl())
|
||||||
|
->openUrlInNewTab()
|
||||||
|
->visible(fn (): bool => $this->lastTestStatus?->deliveryId !== null),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSubheading(): ?string
|
||||||
|
{
|
||||||
|
if ($this->lastTestStatus === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = ucfirst($this->lastTestStatus->status->value);
|
||||||
|
$timestamp = $this->lastTestStatus->timestamp?->diffForHumans();
|
||||||
|
|
||||||
|
return $timestamp !== null
|
||||||
|
? "Last test: {$label} ({$timestamp})"
|
||||||
|
: "Last test: {$label}";
|
||||||
|
}
|
||||||
|
|
||||||
protected function mutateFormDataBeforeSave(array $data): array
|
protected function mutateFormDataBeforeSave(array $data): array
|
||||||
{
|
{
|
||||||
$record = $this->record;
|
$record = $this->record;
|
||||||
@ -46,4 +126,34 @@ protected function afterSave(): void
|
|||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveLastTestStatus(): void
|
||||||
|
{
|
||||||
|
$record = $this->record;
|
||||||
|
|
||||||
|
if (! $record instanceof AlertDestination) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->lastTestStatus = app(AlertDestinationLastTestResolver::class)->resolve($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildDeepLinkUrl(): ?string
|
||||||
|
{
|
||||||
|
$record = $this->record;
|
||||||
|
|
||||||
|
if (! $record instanceof AlertDestination || $this->lastTestStatus?->deliveryId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseUrl = AlertDeliveryResource::getUrl('index');
|
||||||
|
$params = http_build_query([
|
||||||
|
'filters' => [
|
||||||
|
'event_type' => ['value' => 'alerts.test'],
|
||||||
|
'alert_destination_id' => ['value' => (string) $record->getKey()],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return "{$baseUrl}?{$params}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AlertDestinationResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AlertDeliveryResource;
|
||||||
|
use App\Filament\Resources\AlertDestinationResource;
|
||||||
|
use App\Models\AlertDestination;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Alerts\AlertDestinationLastTestResolver;
|
||||||
|
use App\Services\Alerts\AlertDestinationTestMessageService;
|
||||||
|
use App\Support\Alerts\AlertDestinationLastTestStatus;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class ViewAlertDestination extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = AlertDestinationResource::class;
|
||||||
|
|
||||||
|
private ?AlertDestinationLastTestStatus $lastTestStatus = null;
|
||||||
|
|
||||||
|
public function mount(int|string $record): void
|
||||||
|
{
|
||||||
|
parent::mount($record);
|
||||||
|
$this->resolveLastTestStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
$record = $this->record;
|
||||||
|
$canManage = $user instanceof User
|
||||||
|
&& $record instanceof AlertDestination
|
||||||
|
&& $user->can('update', $record);
|
||||||
|
|
||||||
|
return [
|
||||||
|
Action::make('send_test_message')
|
||||||
|
->label('Send test message')
|
||||||
|
->icon('heroicon-o-paper-airplane')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Send test message')
|
||||||
|
->modalDescription('A test delivery will be queued for this destination. This verifies the delivery pipeline is working.')
|
||||||
|
->modalSubmitActionLabel('Send')
|
||||||
|
->visible(fn (): bool => $record instanceof AlertDestination)
|
||||||
|
->disabled(fn (): bool => ! $canManage)
|
||||||
|
->action(function () use ($record): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $record instanceof AlertDestination) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = app(AlertDestinationTestMessageService::class);
|
||||||
|
$result = $service->sendTest($record, $user);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
Notification::make()
|
||||||
|
->title($result['message'])
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
} else {
|
||||||
|
Notification::make()
|
||||||
|
->title($result['message'])
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->resolveLastTestStatus();
|
||||||
|
}),
|
||||||
|
|
||||||
|
Action::make('view_last_delivery')
|
||||||
|
->label('View last delivery')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->url(fn (): ?string => $this->buildDeepLinkUrl())
|
||||||
|
->openUrlInNewTab()
|
||||||
|
->visible(fn (): bool => $this->lastTestStatus?->deliveryId !== null),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function infolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
$lastTest = $this->lastTestStatus ?? AlertDestinationLastTestStatus::never();
|
||||||
|
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
Section::make('Last test')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('last_test_status')
|
||||||
|
->label('Status')
|
||||||
|
->badge()
|
||||||
|
->state($lastTest->status->value)
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::AlertDestinationLastTestStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::AlertDestinationLastTestStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::AlertDestinationLastTestStatus)),
|
||||||
|
TextEntry::make('last_test_timestamp')
|
||||||
|
->label('Timestamp')
|
||||||
|
->state($lastTest->timestamp?->toDateTimeString())
|
||||||
|
->placeholder('—'),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
Section::make('Details')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('name'),
|
||||||
|
TextEntry::make('type')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(fn (?string $state): string => AlertDestinationResource::typeLabel((string) $state)),
|
||||||
|
TextEntry::make('is_enabled')
|
||||||
|
->label('Enabled')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(fn (bool $state): string => $state ? 'Yes' : 'No')
|
||||||
|
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
|
||||||
|
TextEntry::make('created_at')
|
||||||
|
->dateTime(),
|
||||||
|
TextEntry::make('updated_at')
|
||||||
|
->dateTime(),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveLastTestStatus(): void
|
||||||
|
{
|
||||||
|
$record = $this->record;
|
||||||
|
|
||||||
|
if (! $record instanceof AlertDestination) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->lastTestStatus = app(AlertDestinationLastTestResolver::class)->resolve($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildDeepLinkUrl(): ?string
|
||||||
|
{
|
||||||
|
$record = $this->record;
|
||||||
|
|
||||||
|
if (! $record instanceof AlertDestination || $this->lastTestStatus?->deliveryId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AlertDeliveryResource::getUrl(panel: 'admin').'?'.http_build_query([
|
||||||
|
'filters' => [
|
||||||
|
'event_type' => ['value' => 'alerts.test'],
|
||||||
|
'alert_destination_id' => ['value' => (string) $record->getKey()],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
use App\Support\Concerns\DerivesWorkspaceIdFromTenantWhenPresent;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@ -13,10 +13,12 @@
|
|||||||
|
|
||||||
class AlertDelivery extends Model
|
class AlertDelivery extends Model
|
||||||
{
|
{
|
||||||
use DerivesWorkspaceIdFromTenant;
|
use DerivesWorkspaceIdFromTenantWhenPresent;
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
use Prunable;
|
use Prunable;
|
||||||
|
|
||||||
|
public const string EVENT_TYPE_TEST = 'alerts.test';
|
||||||
|
|
||||||
public const string STATUS_QUEUED = 'queued';
|
public const string STATUS_QUEUED = 'queued';
|
||||||
|
|
||||||
public const string STATUS_DEFERRED = 'deferred';
|
public const string STATUS_DEFERRED = 'deferred';
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
namespace App\Policies;
|
namespace App\Policies;
|
||||||
|
|
||||||
use App\Models\AlertDelivery;
|
use App\Models\AlertDelivery;
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
@ -40,8 +39,8 @@ public function view(User $user, AlertDelivery $alertDelivery): bool|Response
|
|||||||
|
|
||||||
$tenant = $alertDelivery->tenant;
|
$tenant = $alertDelivery->tenant;
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if ($tenant === null) {
|
||||||
return Response::denyAsNotFound();
|
return $this->authorizeForWorkspace($user, $workspace, Capabilities::ALERTS_VIEW);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
|||||||
71
app/Services/Alerts/AlertDestinationLastTestResolver.php
Normal file
71
app/Services/Alerts/AlertDestinationLastTestResolver.php
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Alerts;
|
||||||
|
|
||||||
|
use App\Models\AlertDelivery;
|
||||||
|
use App\Models\AlertDestination;
|
||||||
|
use App\Support\Alerts\AlertDestinationLastTestStatus;
|
||||||
|
use App\Support\Alerts\AlertDestinationLastTestStatusEnum;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
|
||||||
|
class AlertDestinationLastTestResolver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Resolve the derived "last test" status for a given destination.
|
||||||
|
*
|
||||||
|
* Uses the most recent `alert_deliveries` row with `event_type = 'alerts.test'`,
|
||||||
|
* ordered by `created_at` desc then `id` desc (deterministic tie-breaking).
|
||||||
|
*/
|
||||||
|
public function resolve(AlertDestination $destination): AlertDestinationLastTestStatus
|
||||||
|
{
|
||||||
|
$delivery = AlertDelivery::query()
|
||||||
|
->where('workspace_id', (int) $destination->workspace_id)
|
||||||
|
->where('alert_destination_id', (int) $destination->getKey())
|
||||||
|
->where('event_type', AlertDelivery::EVENT_TYPE_TEST)
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($delivery === null) {
|
||||||
|
return AlertDestinationLastTestStatus::never();
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = $this->mapStatus((string) $delivery->status);
|
||||||
|
$timestamp = $this->mapTimestamp($delivery, $status);
|
||||||
|
|
||||||
|
return new AlertDestinationLastTestStatus(
|
||||||
|
status: $status,
|
||||||
|
timestamp: $timestamp,
|
||||||
|
deliveryId: (int) $delivery->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapStatus(string $deliveryStatus): AlertDestinationLastTestStatusEnum
|
||||||
|
{
|
||||||
|
return match ($deliveryStatus) {
|
||||||
|
AlertDelivery::STATUS_SENT => AlertDestinationLastTestStatusEnum::Sent,
|
||||||
|
AlertDelivery::STATUS_FAILED => AlertDestinationLastTestStatusEnum::Failed,
|
||||||
|
AlertDelivery::STATUS_QUEUED,
|
||||||
|
AlertDelivery::STATUS_DEFERRED => AlertDestinationLastTestStatusEnum::Pending,
|
||||||
|
default => AlertDestinationLastTestStatusEnum::Failed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapTimestamp(AlertDelivery $delivery, AlertDestinationLastTestStatusEnum $status): ?CarbonImmutable
|
||||||
|
{
|
||||||
|
return match ($status) {
|
||||||
|
AlertDestinationLastTestStatusEnum::Sent => $delivery->sent_at
|
||||||
|
? CarbonImmutable::instance($delivery->sent_at)
|
||||||
|
: null,
|
||||||
|
AlertDestinationLastTestStatusEnum::Failed => $delivery->updated_at
|
||||||
|
? CarbonImmutable::instance($delivery->updated_at)
|
||||||
|
: null,
|
||||||
|
AlertDestinationLastTestStatusEnum::Pending => $delivery->send_after
|
||||||
|
? CarbonImmutable::instance($delivery->send_after)
|
||||||
|
: ($delivery->created_at ? CarbonImmutable::instance($delivery->created_at) : null),
|
||||||
|
AlertDestinationLastTestStatusEnum::Never => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
115
app/Services/Alerts/AlertDestinationTestMessageService.php
Normal file
115
app/Services/Alerts/AlertDestinationTestMessageService.php
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Alerts;
|
||||||
|
|
||||||
|
use App\Jobs\Alerts\DeliverAlertsJob;
|
||||||
|
use App\Models\AlertDelivery;
|
||||||
|
use App\Models\AlertDestination;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
|
||||||
|
class AlertDestinationTestMessageService
|
||||||
|
{
|
||||||
|
private const int RATE_LIMIT_SECONDS = 60;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private WorkspaceAuditLogger $auditLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a test message for the given alert destination.
|
||||||
|
*
|
||||||
|
* @return array{success: bool, message: string, delivery_id: int|null}
|
||||||
|
*/
|
||||||
|
public function sendTest(AlertDestination $destination, User $actor): array
|
||||||
|
{
|
||||||
|
if (! $actor->can('update', $destination)) {
|
||||||
|
throw new AuthorizationException('You do not have permission to send test messages for this destination.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $destination->is_enabled) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'This destination is currently disabled. Enable it before sending a test message.',
|
||||||
|
'delivery_id' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isRateLimited($destination)) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'A test message was sent recently. Please wait before trying again.',
|
||||||
|
'delivery_id' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$delivery = $this->createTestDelivery($destination);
|
||||||
|
|
||||||
|
$this->auditLog($destination, $actor);
|
||||||
|
|
||||||
|
DeliverAlertsJob::dispatch((int) $destination->workspace_id);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Test message queued for delivery.',
|
||||||
|
'delivery_id' => (int) $delivery->getKey(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isRateLimited(AlertDestination $destination): bool
|
||||||
|
{
|
||||||
|
return AlertDelivery::query()
|
||||||
|
->where('workspace_id', (int) $destination->workspace_id)
|
||||||
|
->where('alert_destination_id', (int) $destination->getKey())
|
||||||
|
->where('event_type', AlertDelivery::EVENT_TYPE_TEST)
|
||||||
|
->where('created_at', '>=', now()->subSeconds(self::RATE_LIMIT_SECONDS))
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createTestDelivery(AlertDestination $destination): AlertDelivery
|
||||||
|
{
|
||||||
|
return AlertDelivery::create([
|
||||||
|
'workspace_id' => (int) $destination->workspace_id,
|
||||||
|
'tenant_id' => null,
|
||||||
|
'alert_rule_id' => null,
|
||||||
|
'alert_destination_id' => (int) $destination->getKey(),
|
||||||
|
'event_type' => AlertDelivery::EVENT_TYPE_TEST,
|
||||||
|
'status' => AlertDelivery::STATUS_QUEUED,
|
||||||
|
'severity' => null,
|
||||||
|
'fingerprint_hash' => 'test:'.(int) $destination->getKey(),
|
||||||
|
'attempt_count' => 0,
|
||||||
|
'payload' => [
|
||||||
|
'title' => 'Test alert',
|
||||||
|
'body' => 'This is a test delivery for destination verification.',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditLog(AlertDestination $destination, User $actor): void
|
||||||
|
{
|
||||||
|
$workspace = $destination->workspace;
|
||||||
|
|
||||||
|
if ($workspace === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->auditLogger->log(
|
||||||
|
workspace: $workspace,
|
||||||
|
action: AuditActionId::AlertDestinationTestRequested->value,
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'alert_destination_id' => (int) $destination->getKey(),
|
||||||
|
'name' => (string) $destination->name,
|
||||||
|
'type' => (string) $destination->type,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actor: $actor,
|
||||||
|
resourceType: 'alert_destination',
|
||||||
|
resourceId: (string) $destination->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Support/Alerts/AlertDestinationLastTestStatus.php
Normal file
25
app/Support/Alerts/AlertDestinationLastTestStatus.php
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Alerts;
|
||||||
|
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
|
||||||
|
final class AlertDestinationLastTestStatus
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly AlertDestinationLastTestStatusEnum $status,
|
||||||
|
public readonly ?CarbonImmutable $timestamp,
|
||||||
|
public readonly ?int $deliveryId,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function never(): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
status: AlertDestinationLastTestStatusEnum::Never,
|
||||||
|
timestamp: null,
|
||||||
|
deliveryId: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/Support/Alerts/AlertDestinationLastTestStatusEnum.php
Normal file
13
app/Support/Alerts/AlertDestinationLastTestStatusEnum.php
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Alerts;
|
||||||
|
|
||||||
|
enum AlertDestinationLastTestStatusEnum: string
|
||||||
|
{
|
||||||
|
case Never = 'never';
|
||||||
|
case Sent = 'sent';
|
||||||
|
case Failed = 'failed';
|
||||||
|
case Pending = 'pending';
|
||||||
|
}
|
||||||
@ -36,6 +36,7 @@ enum AuditActionId: string
|
|||||||
case AlertDestinationDeleted = 'alert_destination.deleted';
|
case AlertDestinationDeleted = 'alert_destination.deleted';
|
||||||
case AlertDestinationEnabled = 'alert_destination.enabled';
|
case AlertDestinationEnabled = 'alert_destination.enabled';
|
||||||
case AlertDestinationDisabled = 'alert_destination.disabled';
|
case AlertDestinationDisabled = 'alert_destination.disabled';
|
||||||
|
case AlertDestinationTestRequested = 'alert_destination.test_requested';
|
||||||
|
|
||||||
case AlertRuleCreated = 'alert_rule.created';
|
case AlertRuleCreated = 'alert_rule.created';
|
||||||
case AlertRuleUpdated = 'alert_rule.updated';
|
case AlertRuleUpdated = 'alert_rule.updated';
|
||||||
|
|||||||
@ -37,6 +37,8 @@ final class BadgeCatalog
|
|||||||
BadgeDomain::VerificationCheckStatus->value => Domains\VerificationCheckStatusBadge::class,
|
BadgeDomain::VerificationCheckStatus->value => Domains\VerificationCheckStatusBadge::class,
|
||||||
BadgeDomain::VerificationCheckSeverity->value => Domains\VerificationCheckSeverityBadge::class,
|
BadgeDomain::VerificationCheckSeverity->value => Domains\VerificationCheckSeverityBadge::class,
|
||||||
BadgeDomain::VerificationReportOverall->value => Domains\VerificationReportOverallBadge::class,
|
BadgeDomain::VerificationReportOverall->value => Domains\VerificationReportOverallBadge::class,
|
||||||
|
BadgeDomain::AlertDeliveryStatus->value => Domains\AlertDeliveryStatusBadge::class,
|
||||||
|
BadgeDomain::AlertDestinationLastTestStatus->value => Domains\AlertDestinationLastTestStatusBadge::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -29,4 +29,6 @@ enum BadgeDomain: string
|
|||||||
case VerificationCheckStatus = 'verification_check_status';
|
case VerificationCheckStatus = 'verification_check_status';
|
||||||
case VerificationCheckSeverity = 'verification_check_severity';
|
case VerificationCheckSeverity = 'verification_check_severity';
|
||||||
case VerificationReportOverall = 'verification_report_overall';
|
case VerificationReportOverall = 'verification_report_overall';
|
||||||
|
case AlertDeliveryStatus = 'alert_delivery_status';
|
||||||
|
case AlertDestinationLastTestStatus = 'alert_destination_last_test_status';
|
||||||
}
|
}
|
||||||
|
|||||||
28
app/Support/Badges/Domains/AlertDeliveryStatusBadge.php
Normal file
28
app/Support/Badges/Domains/AlertDeliveryStatusBadge.php
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
|
use App\Models\AlertDelivery;
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeMapper;
|
||||||
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
|
||||||
|
final class AlertDeliveryStatusBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
|
return match ($state) {
|
||||||
|
AlertDelivery::STATUS_QUEUED => new BadgeSpec('Queued', 'gray', 'heroicon-m-clock'),
|
||||||
|
AlertDelivery::STATUS_DEFERRED => new BadgeSpec('Deferred', 'warning', 'heroicon-m-clock'),
|
||||||
|
AlertDelivery::STATUS_SENT => new BadgeSpec('Sent', 'success', 'heroicon-m-check-circle'),
|
||||||
|
AlertDelivery::STATUS_FAILED => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
||||||
|
AlertDelivery::STATUS_SUPPRESSED => new BadgeSpec('Suppressed', 'info', 'heroicon-m-no-symbol'),
|
||||||
|
AlertDelivery::STATUS_CANCELED => new BadgeSpec('Canceled', 'gray', 'heroicon-m-stop'),
|
||||||
|
default => BadgeSpec::unknown(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
|
use App\Support\Alerts\AlertDestinationLastTestStatusEnum;
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeMapper;
|
||||||
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
|
||||||
|
final class AlertDestinationLastTestStatusBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
|
return match ($state) {
|
||||||
|
AlertDestinationLastTestStatusEnum::Never->value => new BadgeSpec('Never', 'gray', 'heroicon-m-minus-circle'),
|
||||||
|
AlertDestinationLastTestStatusEnum::Sent->value => new BadgeSpec('Sent', 'success', 'heroicon-m-check-circle'),
|
||||||
|
AlertDestinationLastTestStatusEnum::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
||||||
|
AlertDestinationLastTestStatusEnum::Pending->value => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'),
|
||||||
|
default => BadgeSpec::unknown(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
134
app/Support/Concerns/DerivesWorkspaceIdFromTenantWhenPresent.php
Normal file
134
app/Support/Concerns/DerivesWorkspaceIdFromTenantWhenPresent.php
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Concerns;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\WorkspaceIsolation\WorkspaceIsolationViolation;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like DerivesWorkspaceIdFromTenant, but allows tenant_id to be null.
|
||||||
|
*
|
||||||
|
* When tenant_id IS present the trait enforces the same workspace-derivation
|
||||||
|
* and immutability rules as DerivesWorkspaceIdFromTenant.
|
||||||
|
* When tenant_id IS NULL the caller MUST supply workspace_id explicitly.
|
||||||
|
*/
|
||||||
|
trait DerivesWorkspaceIdFromTenantWhenPresent
|
||||||
|
{
|
||||||
|
public static function bootDerivesWorkspaceIdFromTenantWhenPresent(): void
|
||||||
|
{
|
||||||
|
static::creating(static function (Model $model): void {
|
||||||
|
self::enforceWorkspaceBindingOptional($model);
|
||||||
|
});
|
||||||
|
|
||||||
|
static::updating(static function (Model $model): void {
|
||||||
|
self::enforceWorkspaceBindingOptional($model);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function enforceWorkspaceBindingOptional(Model $model): void
|
||||||
|
{
|
||||||
|
$tenantId = $model->getAttribute('tenant_id');
|
||||||
|
|
||||||
|
if ($tenantId === null || $tenantId === '') {
|
||||||
|
self::requireExplicitWorkspaceId($model);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_numeric($tenantId)) {
|
||||||
|
throw WorkspaceIsolationViolation::missingTenantId(class_basename($model));
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantId = (int) $tenantId;
|
||||||
|
|
||||||
|
self::ensureTenantIdIsImmutableOptional($model, $tenantId);
|
||||||
|
|
||||||
|
$tenantWorkspaceId = self::resolveTenantWorkspaceIdOptional($model, $tenantId);
|
||||||
|
|
||||||
|
$workspaceId = $model->getAttribute('workspace_id');
|
||||||
|
|
||||||
|
if ($workspaceId === null || $workspaceId === '') {
|
||||||
|
$model->setAttribute('workspace_id', $tenantWorkspaceId);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_numeric($workspaceId)) {
|
||||||
|
throw WorkspaceIsolationViolation::workspaceMismatch(
|
||||||
|
class_basename($model),
|
||||||
|
$tenantId,
|
||||||
|
$tenantWorkspaceId,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = (int) $workspaceId;
|
||||||
|
|
||||||
|
if ($workspaceId !== $tenantWorkspaceId) {
|
||||||
|
throw WorkspaceIsolationViolation::workspaceMismatch(
|
||||||
|
class_basename($model),
|
||||||
|
$tenantId,
|
||||||
|
$tenantWorkspaceId,
|
||||||
|
$workspaceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function requireExplicitWorkspaceId(Model $model): void
|
||||||
|
{
|
||||||
|
$workspaceId = $model->getAttribute('workspace_id');
|
||||||
|
|
||||||
|
if (! is_numeric($workspaceId) || (int) $workspaceId <= 0) {
|
||||||
|
throw WorkspaceIsolationViolation::missingTenantId(
|
||||||
|
class_basename($model).' (tenantless record requires explicit workspace_id)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function ensureTenantIdIsImmutableOptional(Model $model, int $tenantId): void
|
||||||
|
{
|
||||||
|
if (! $model->exists || ! $model->isDirty('tenant_id')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$originalTenantId = $model->getOriginal('tenant_id');
|
||||||
|
|
||||||
|
if (! is_numeric($originalTenantId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$originalTenantId = (int) $originalTenantId;
|
||||||
|
|
||||||
|
if ($originalTenantId === $tenantId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw WorkspaceIsolationViolation::tenantImmutable(
|
||||||
|
class_basename($model),
|
||||||
|
$originalTenantId,
|
||||||
|
$tenantId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveTenantWorkspaceIdOptional(Model $model, int $tenantId): int
|
||||||
|
{
|
||||||
|
$tenant = $model->relationLoaded('tenant') ? $model->getRelation('tenant') : null;
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || (int) $tenant->getKey() !== $tenantId) {
|
||||||
|
$tenant = Tenant::query()->find($tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
throw WorkspaceIsolationViolation::tenantNotFound(class_basename($model), $tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_numeric($tenant->workspace_id)) {
|
||||||
|
throw WorkspaceIsolationViolation::tenantWorkspaceMissing(class_basename($model), $tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $tenant->workspace_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,6 +19,8 @@ class AlertDeliveryFactory extends Factory
|
|||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
// tenant_id and alert_rule_id are nullable; test() state sets them to null.
|
||||||
|
// Default definition creates a tenant-scoped delivery.
|
||||||
'tenant_id' => Tenant::factory(),
|
'tenant_id' => Tenant::factory(),
|
||||||
'workspace_id' => function (array $attributes): int {
|
'workspace_id' => function (array $attributes): int {
|
||||||
$tenantId = $attributes['tenant_id'] ?? null;
|
$tenantId = $attributes['tenant_id'] ?? null;
|
||||||
@ -75,4 +77,22 @@ public function definition(): array
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tenantless test delivery for a specific destination.
|
||||||
|
*/
|
||||||
|
public function test(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'tenant_id' => null,
|
||||||
|
'alert_rule_id' => null,
|
||||||
|
'event_type' => AlertDelivery::EVENT_TYPE_TEST,
|
||||||
|
'severity' => null,
|
||||||
|
'fingerprint_hash' => 'test:'.($attributes['alert_destination_id'] instanceof \Closure ? 0 : ($attributes['alert_destination_id'] ?? 0)),
|
||||||
|
'payload' => [
|
||||||
|
'title' => 'Test alert',
|
||||||
|
'body' => 'This is a test delivery for destination verification.',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('alert_deliveries', function (Blueprint $table): void {
|
||||||
|
$table->unsignedBigInteger('tenant_id')->nullable()->change();
|
||||||
|
$table->unsignedBigInteger('alert_rule_id')->nullable()->change();
|
||||||
|
|
||||||
|
$table->index(
|
||||||
|
['workspace_id', 'alert_destination_id', 'event_type', 'created_at'],
|
||||||
|
'alert_deliveries_last_test_lookup_index',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('alert_deliveries', function (Blueprint $table): void {
|
||||||
|
$table->dropIndex('alert_deliveries_last_test_lookup_index');
|
||||||
|
|
||||||
|
$table->unsignedBigInteger('tenant_id')->nullable(false)->change();
|
||||||
|
$table->unsignedBigInteger('alert_rule_id')->nullable(false)->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
180
specs/100-alert-target-test-actions/tasks.md
Normal file
180
specs/100-alert-target-test-actions/tasks.md
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
description: "Task list for 100 — Alert Targets: Send Test Message + Last Test Status (input: 099.1)"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tasks: Alert Targets — Send Test Message + Last Test Status (Teams + Email)
|
||||||
|
|
||||||
|
**Input**: Design documents from `specs/100-alert-target-test-actions/`
|
||||||
|
|
||||||
|
**Prerequisites**:
|
||||||
|
- `specs/100-alert-target-test-actions/spec.md` (user stories + priorities)
|
||||||
|
- `specs/100-alert-target-test-actions/plan.md` (implementation approach)
|
||||||
|
- `specs/100-alert-target-test-actions/research.md` (decisions)
|
||||||
|
- `specs/100-alert-target-test-actions/data-model.md` (tenantless test deliveries)
|
||||||
|
- `specs/100-alert-target-test-actions/contracts/` (event type + deep link filters)
|
||||||
|
|
||||||
|
**Tests**: REQUIRED (Pest) for all runtime behavior changes.
|
||||||
|
|
||||||
|
## Format: `- [ ] [TaskID] [P?] [Story?] Description with file path`
|
||||||
|
|
||||||
|
- **[P]**: can run in parallel (different files, no dependencies on incomplete tasks)
|
||||||
|
- **[US1] [US2] [US3]**: user story mapping (required for story phases)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared)
|
||||||
|
|
||||||
|
**Purpose**: Ensure local dev/test workflow is concrete for this feature.
|
||||||
|
|
||||||
|
- [X] T001 Update test quickstart commands in specs/100-alert-target-test-actions/quickstart.md
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Enable tenantless test deliveries + keep workspace isolation and RBAC correct.
|
||||||
|
|
||||||
|
- [X] T002 Create migration to allow tenantless test deliveries + add supporting index in database/migrations/2026_02_18_000001_make_alert_deliveries_tenant_and_rule_nullable_for_test_deliveries.php
|
||||||
|
- [X] T003 Create tenant-optional workspace isolation trait in app/Support/Concerns/DerivesWorkspaceIdFromTenantWhenPresent.php
|
||||||
|
- [X] T004 Switch AlertDelivery to tenant-optional isolation in app/Models/AlertDelivery.php
|
||||||
|
- [X] T005 Add AlertDeliveryFactory support for tenantless deliveries in database/factories/AlertDeliveryFactory.php
|
||||||
|
- [X] T006 Add audit action id for test request in app/Support/Audit/AuditActionId.php
|
||||||
|
|
||||||
|
**Checkpoint**: Tenantless `alert_deliveries` records can be created safely, and audit IDs exist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 — Send a test message for a target (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Capability-gated “Send test message” action creates a test delivery record and queues delivery asynchronously.
|
||||||
|
|
||||||
|
**Independent Test**: A manage-capable workspace member can request a test send; a new `alert_deliveries` row exists with `event_type=alerts.test`, and the queue has a `DeliverAlertsJob`.
|
||||||
|
|
||||||
|
### Tests (write first)
|
||||||
|
|
||||||
|
- [X] T007 [P] [US1] Add send-test action happy-path test in tests/Feature/Alerts/AlertDestinationSendTestMessageTest.php
|
||||||
|
- [X] T008 [P] [US1] Add rate-limit refusal test in tests/Feature/Alerts/AlertDestinationSendTestMessageTest.php
|
||||||
|
- [X] T009 [P] [US1] Add authorization (readonly forbidden) test in tests/Feature/Alerts/AlertDestinationSendTestMessageTest.php
|
||||||
|
- [X] T010 [P] [US1] Add non-member deny-as-not-found (404) regression test in tests/Feature/Alerts/AlertDestinationSendTestMessageTest.php
|
||||||
|
- [X] T011 [P] [US1] Add audit log assertion test in tests/Feature/Alerts/AlertDestinationSendTestMessageTest.php
|
||||||
|
- [X] T012 [P] [US1] Add confirmation requirement test for the Filament action in tests/Feature/Alerts/AlertDestinationSendTestMessageTest.php
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
- [X] T013 [US1] Add canonical test event type constant in app/Models/AlertDelivery.php
|
||||||
|
- [X] T014 [US1] Implement test delivery creation service in app/Services/Alerts/AlertDestinationTestMessageService.php
|
||||||
|
- [X] T015 [US1] Add "Send test message" header action on edit page in app/Filament/Resources/AlertDestinationResource/Pages/EditAlertDestination.php
|
||||||
|
|
||||||
|
**Checkpoint**: US1 passes its feature tests and performs no outbound calls in the request thread.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 — See the last test status at a glance (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Show derived “Last test: Never/Sent/Failed/Pending” badge with deterministic, DB-only semantics.
|
||||||
|
|
||||||
|
**Independent Test**: With controlled `alert_deliveries` rows, the target page shows the correct derived status and timestamp without external calls.
|
||||||
|
|
||||||
|
### Tests (write first)
|
||||||
|
|
||||||
|
- [X] T016 [P] [US2] Add badge semantics tests for delivery + last-test domains in tests/Feature/Badges/AlertDeliveryAndLastTestBadgeSemanticsTest.php
|
||||||
|
- [X] T017 [P] [US2] Add derived last-test mapping test (never/sent/failed/pending) in tests/Feature/Alerts/AlertDestinationLastTestStatusTest.php
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
- [ ] T018 [P] [US2] Add last-test status enum + result DTO in app/Support/Alerts/AlertDestinationLastTestStatus.php
|
||||||
|
- [ ] T019 [US2] Implement last-test resolver (latest alerts.test row) in app/Services/Alerts/AlertDestinationLastTestResolver.php
|
||||||
|
- [ ] T020 [US2] Add badge domains for alert delivery + destination last-test in app/Support/Badges/BadgeDomain.php
|
||||||
|
- [ ] T021 [US2] Register new badge mappers in app/Support/Badges/BadgeCatalog.php
|
||||||
|
- [ ] T022 [P] [US2] Implement badge mapper for delivery status in app/Support/Badges/Domains/AlertDeliveryStatusBadge.php
|
||||||
|
- [ ] T023 [P] [US2] Implement badge mapper for destination last-test status in app/Support/Badges/Domains/AlertDestinationLastTestStatusBadge.php
|
||||||
|
- [ ] T024 [US2] Refactor delivery status badges to use BadgeCatalog in app/Filament/Resources/AlertDeliveryResource.php
|
||||||
|
- [ ] T025 [US2] Add read-only View page for alert destinations in app/Filament/Resources/AlertDestinationResource/Pages/ViewAlertDestination.php
|
||||||
|
- [ ] T026 [US2] Register view page and route view-only users from list in app/Filament/Resources/AlertDestinationResource.php
|
||||||
|
- [ ] T027 [US2] Render “Last test” badge + timestamp on view page in app/Filament/Resources/AlertDestinationResource/Pages/ViewAlertDestination.php
|
||||||
|
- [ ] T028 [US2] Render “Last test” badge + timestamp on edit page in app/Filament/Resources/AlertDestinationResource/Pages/EditAlertDestination.php
|
||||||
|
|
||||||
|
**Checkpoint**: US2 is fully readable by view-only users (via View page) and meets BADGE-001.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 — Jump from target to the relevant delivery details (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Provide a “View last delivery” deep link into the Deliveries viewer filtered by `alerts.test` and destination.
|
||||||
|
|
||||||
|
**Independent Test**: When at least one test delivery exists, the link appears and routes to `/admin/alert-deliveries` with the correct `filters[...]` query string.
|
||||||
|
|
||||||
|
### Tests (write first)
|
||||||
|
|
||||||
|
- [X] T029 [P] [US3] Add deep-link visibility + URL contract test in tests/Feature/Alerts/AlertDestinationViewLastDeliveryLinkTest.php
|
||||||
|
- [X] T030 [P] [US3] Add deliveries viewer filter coverage test in tests/Feature/Alerts/AlertDeliveryDeepLinkFiltersTest.php
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
- [X] T031 [US3] Add table filters for event_type + alert_destination_id in app/Filament/Resources/AlertDeliveryResource.php
|
||||||
|
- [X] T032 [US3] Allow tenantless deliveries in deliveries list query in app/Filament/Resources/AlertDeliveryResource.php
|
||||||
|
- [X] T033 [US3] Update tenant entitlement policy to allow tenantless delivery records in app/Policies/AlertDeliveryPolicy.php
|
||||||
|
- [X] T034 [US3] Add "View last delivery" header action (view page) in app/Filament/Resources/AlertDestinationResource/Pages/ViewAlertDestination.php
|
||||||
|
- [X] T035 [US3] Add "View last delivery" header action (edit page) in app/Filament/Resources/AlertDestinationResource/Pages/EditAlertDestination.php
|
||||||
|
|
||||||
|
**Checkpoint**: US3 deep links match the contract in specs/100-alert-target-test-actions/contracts/filament-deep-link-filters.md.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
- [X] T036 [P] Run Pint on changed files with vendor/bin/sail bin pint --dirty
|
||||||
|
- [X] T037 Run focused test suite for this feature with vendor/bin/sail artisan test --compact tests/Feature/Alerts/AlertDestinationSendTestMessageTest.php
|
||||||
|
- [X] T038 Run focused badge tests with vendor/bin/sail artisan test --compact tests/Feature/Badges/AlertDeliveryAndLastTestBadgeSemanticsTest.php
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Dependency graph (user stories)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
F[Phase 2: Foundational] --> US1[US1: Send test message]
|
||||||
|
F --> US2[US2: Last test status]
|
||||||
|
US2 --> US3[US3: View last delivery deep link]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Foundational (Phase 2)** blocks all user stories.
|
||||||
|
- **US1** can be delivered as MVP after Phase 2.
|
||||||
|
- **US2** adds read-only visibility and centralized badge semantics.
|
||||||
|
- **US3** depends on US2 resolver/view surface (or duplicate a small existence query if needed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel execution examples
|
||||||
|
|
||||||
|
### US1
|
||||||
|
|
||||||
|
- `T007–T012` can be written in parallel (single test file, but separate assertions can be split across commits).
|
||||||
|
- `T013` (model constant) and `T014` (service) can be done before `T015` (Filament action).
|
||||||
|
|
||||||
|
### US2
|
||||||
|
|
||||||
|
- `T022` and `T023` (badge mappers) can be implemented in parallel.
|
||||||
|
- `T018` (enum/DTO) and `T019` (resolver) can be done before wiring UI.
|
||||||
|
|
||||||
|
### US3
|
||||||
|
|
||||||
|
- `T031–T033` (deliveries viewer plumbing) can proceed in parallel with `T034–T035` (target header actions) once the resolver is available.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation strategy
|
||||||
|
|
||||||
|
### MVP first
|
||||||
|
|
||||||
|
1. Complete Phase 2 (Foundational)
|
||||||
|
2. Implement and ship US1 (Send test message)
|
||||||
|
3. Validate with focused tests
|
||||||
|
|
||||||
|
### Incremental delivery
|
||||||
|
|
||||||
|
- Add US2 (Last test status + View page) after US1 is stable
|
||||||
|
- Add US3 (Deep link + deliveries filters) last
|
||||||
118
tests/Feature/Alerts/AlertDeliveryDeepLinkFiltersTest.php
Normal file
118
tests/Feature/Alerts/AlertDeliveryDeepLinkFiltersTest.php
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\AlertDeliveryResource\Pages\ListAlertDeliveries;
|
||||||
|
use App\Models\AlertDelivery;
|
||||||
|
use App\Models\AlertDestination;
|
||||||
|
use App\Models\AlertRule;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// T030 — Deliveries viewer filter coverage (event_type + alert_destination_id)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('filters deliveries by event_type', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||||
|
|
||||||
|
$destination = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'is_enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rule = AlertRule::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$testDelivery = AlertDelivery::factory()->test()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'alert_destination_id' => $destination->getKey(),
|
||||||
|
'status' => AlertDelivery::STATUS_SENT,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$normalDelivery = AlertDelivery::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'alert_rule_id' => $rule->getKey(),
|
||||||
|
'alert_destination_id' => $destination->getKey(),
|
||||||
|
'event_type' => 'high_drift',
|
||||||
|
'status' => AlertDelivery::STATUS_SENT,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ListAlertDeliveries::class)
|
||||||
|
->assertCanSeeTableRecords([$testDelivery, $normalDelivery])
|
||||||
|
->filterTable('event_type', AlertDelivery::EVENT_TYPE_TEST)
|
||||||
|
->assertCanSeeTableRecords([$testDelivery])
|
||||||
|
->assertCanNotSeeTableRecords([$normalDelivery]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters deliveries by alert_destination_id', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||||
|
|
||||||
|
$destinationA = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'is_enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$destinationB = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'is_enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rule = AlertRule::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$deliveryA = AlertDelivery::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'alert_rule_id' => $rule->getKey(),
|
||||||
|
'alert_destination_id' => $destinationA->getKey(),
|
||||||
|
'status' => AlertDelivery::STATUS_SENT,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$deliveryB = AlertDelivery::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'alert_rule_id' => $rule->getKey(),
|
||||||
|
'alert_destination_id' => $destinationB->getKey(),
|
||||||
|
'status' => AlertDelivery::STATUS_SENT,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ListAlertDeliveries::class)
|
||||||
|
->assertCanSeeTableRecords([$deliveryA, $deliveryB])
|
||||||
|
->filterTable('alert_destination_id', (string) $destinationA->getKey())
|
||||||
|
->assertCanSeeTableRecords([$deliveryA])
|
||||||
|
->assertCanNotSeeTableRecords([$deliveryB]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes tenantless test deliveries in the list', function (): void {
|
||||||
|
[$user] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||||
|
|
||||||
|
$destination = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'is_enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$testDelivery = AlertDelivery::factory()->test()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'alert_destination_id' => $destination->getKey(),
|
||||||
|
'status' => AlertDelivery::STATUS_SENT,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ListAlertDeliveries::class)
|
||||||
|
->assertCanSeeTableRecords([$testDelivery]);
|
||||||
|
});
|
||||||
181
tests/Feature/Alerts/AlertDestinationLastTestStatusTest.php
Normal file
181
tests/Feature/Alerts/AlertDestinationLastTestStatusTest.php
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\AlertDelivery;
|
||||||
|
use App\Models\AlertDestination;
|
||||||
|
use App\Models\AlertRule;
|
||||||
|
use App\Services\Alerts\AlertDestinationLastTestResolver;
|
||||||
|
use App\Support\Alerts\AlertDestinationLastTestStatusEnum;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('returns Never status when no test delivery exists', function (): void {
|
||||||
|
[$user] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||||
|
$destination = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resolver = new AlertDestinationLastTestResolver;
|
||||||
|
$result = $resolver->resolve($destination);
|
||||||
|
|
||||||
|
expect($result->status)->toBe(AlertDestinationLastTestStatusEnum::Never);
|
||||||
|
expect($result->timestamp)->toBeNull();
|
||||||
|
expect($result->deliveryId)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns Sent status when the latest test delivery was sent', function (): void {
|
||||||
|
[$user] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||||
|
$destination = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$delivery = AlertDelivery::factory()->test()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'alert_destination_id' => (int) $destination->getKey(),
|
||||||
|
'status' => AlertDelivery::STATUS_SENT,
|
||||||
|
'sent_at' => now()->subMinutes(5),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resolver = new AlertDestinationLastTestResolver;
|
||||||
|
$result = $resolver->resolve($destination);
|
||||||
|
|
||||||
|
expect($result->status)->toBe(AlertDestinationLastTestStatusEnum::Sent);
|
||||||
|
expect($result->deliveryId)->toBe((int) $delivery->getKey());
|
||||||
|
expect($result->timestamp)->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns Failed status when the latest test delivery failed', function (): void {
|
||||||
|
[$user] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||||
|
$destination = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$delivery = AlertDelivery::factory()->test()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'alert_destination_id' => (int) $destination->getKey(),
|
||||||
|
'status' => AlertDelivery::STATUS_FAILED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resolver = new AlertDestinationLastTestResolver;
|
||||||
|
$result = $resolver->resolve($destination);
|
||||||
|
|
||||||
|
expect($result->status)->toBe(AlertDestinationLastTestStatusEnum::Failed);
|
||||||
|
expect($result->deliveryId)->toBe((int) $delivery->getKey());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns Pending status when the latest test delivery is queued', function (): void {
|
||||||
|
[$user] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||||
|
$destination = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$delivery = AlertDelivery::factory()->test()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'alert_destination_id' => (int) $destination->getKey(),
|
||||||
|
'status' => AlertDelivery::STATUS_QUEUED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resolver = new AlertDestinationLastTestResolver;
|
||||||
|
$result = $resolver->resolve($destination);
|
||||||
|
|
||||||
|
expect($result->status)->toBe(AlertDestinationLastTestStatusEnum::Pending);
|
||||||
|
expect($result->deliveryId)->toBe((int) $delivery->getKey());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns Pending status when the latest test delivery is deferred', function (): void {
|
||||||
|
[$user] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||||
|
$destination = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$delivery = AlertDelivery::factory()->test()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'alert_destination_id' => (int) $destination->getKey(),
|
||||||
|
'status' => AlertDelivery::STATUS_DEFERRED,
|
||||||
|
'send_after' => now()->addMinutes(5),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resolver = new AlertDestinationLastTestResolver;
|
||||||
|
$result = $resolver->resolve($destination);
|
||||||
|
|
||||||
|
expect($result->status)->toBe(AlertDestinationLastTestStatusEnum::Pending);
|
||||||
|
expect($result->deliveryId)->toBe((int) $delivery->getKey());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the most recent test delivery when multiple exist', function (): void {
|
||||||
|
[$user] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||||
|
$destination = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AlertDelivery::factory()->test()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'alert_destination_id' => (int) $destination->getKey(),
|
||||||
|
'status' => AlertDelivery::STATUS_SENT,
|
||||||
|
'sent_at' => now()->subHour(),
|
||||||
|
'created_at' => now()->subHour(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$latestDelivery = AlertDelivery::factory()->test()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'alert_destination_id' => (int) $destination->getKey(),
|
||||||
|
'status' => AlertDelivery::STATUS_FAILED,
|
||||||
|
'created_at' => now()->subMinutes(5),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resolver = new AlertDestinationLastTestResolver;
|
||||||
|
$result = $resolver->resolve($destination);
|
||||||
|
|
||||||
|
expect($result->status)->toBe(AlertDestinationLastTestStatusEnum::Failed);
|
||||||
|
expect($result->deliveryId)->toBe((int) $latestDelivery->getKey());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non-test deliveries when resolving last test status', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||||
|
$destination = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rule = AlertRule::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AlertDelivery::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'alert_rule_id' => (int) $rule->getKey(),
|
||||||
|
'alert_destination_id' => (int) $destination->getKey(),
|
||||||
|
'event_type' => 'high_drift',
|
||||||
|
'status' => AlertDelivery::STATUS_SENT,
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resolver = new AlertDestinationLastTestResolver;
|
||||||
|
$result = $resolver->resolve($destination);
|
||||||
|
|
||||||
|
expect($result->status)->toBe(AlertDestinationLastTestStatusEnum::Never);
|
||||||
|
});
|
||||||
178
tests/Feature/Alerts/AlertDestinationSendTestMessageTest.php
Normal file
178
tests/Feature/Alerts/AlertDestinationSendTestMessageTest.php
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\AlertDestinationResource;
|
||||||
|
use App\Filament\Resources\AlertDestinationResource\Pages\EditAlertDestination;
|
||||||
|
use App\Jobs\Alerts\DeliverAlertsJob;
|
||||||
|
use App\Models\AlertDelivery;
|
||||||
|
use App\Models\AlertDestination;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// T007 — Happy path: owner sends a test message, delivery + job created
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
it('creates a test delivery and dispatches a deliver job on send test message', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||||
|
$destination = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'is_enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(EditAlertDestination::class, ['record' => $destination->getRouteKey()])
|
||||||
|
->callAction('send_test_message');
|
||||||
|
|
||||||
|
$delivery = AlertDelivery::query()
|
||||||
|
->where('alert_destination_id', $destination->getKey())
|
||||||
|
->where('event_type', AlertDelivery::EVENT_TYPE_TEST)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($delivery)->not->toBeNull();
|
||||||
|
expect($delivery->workspace_id)->toBe($workspaceId);
|
||||||
|
expect($delivery->tenant_id)->toBeNull();
|
||||||
|
expect($delivery->alert_rule_id)->toBeNull();
|
||||||
|
expect($delivery->status)->toBe(AlertDelivery::STATUS_QUEUED);
|
||||||
|
|
||||||
|
Queue::assertPushed(DeliverAlertsJob::class, function (DeliverAlertsJob $job) use ($workspaceId): bool {
|
||||||
|
return $job->workspaceId === $workspaceId;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// T008 — Rate-limit refusal: second request within 60 seconds is rejected
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
it('refuses a second test message within 60 seconds', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||||
|
$destination = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'is_enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AlertDelivery::factory()->test()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'alert_destination_id' => (int) $destination->getKey(),
|
||||||
|
'created_at' => now()->subSeconds(30),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(EditAlertDestination::class, ['record' => $destination->getRouteKey()])
|
||||||
|
->callAction('send_test_message')
|
||||||
|
->assertNotified();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
AlertDelivery::query()
|
||||||
|
->where('alert_destination_id', $destination->getKey())
|
||||||
|
->where('event_type', AlertDelivery::EVENT_TYPE_TEST)
|
||||||
|
->count()
|
||||||
|
)->toBe(1);
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// T009 — Authorization: readonly member is forbidden from edit page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
it('forbids readonly members from accessing the edit page for destinations', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user] = createUserWithTenant(role: 'readonly');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||||
|
$destination = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'is_enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get(AlertDestinationResource::getUrl('edit', ['record' => $destination], panel: 'admin'))
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// T010 — Non-member: denied as not found (404)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
it('returns 404 for non-member trying to access edit page with send test action', function (): void {
|
||||||
|
[$user] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$otherWorkspace = Workspace::factory()->create();
|
||||||
|
$destination = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => (int) $otherWorkspace->getKey(),
|
||||||
|
'is_enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(AlertDestinationResource::getUrl('edit', ['record' => $destination], panel: 'admin'))
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// T011 — Audit log assertion: test request is audit-logged
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
it('creates an audit log entry when a test message is sent', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||||
|
$destination = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'is_enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(EditAlertDestination::class, ['record' => $destination->getRouteKey()])
|
||||||
|
->callAction('send_test_message');
|
||||||
|
|
||||||
|
$auditLog = AuditLog::query()
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
|
->where('action', AuditActionId::AlertDestinationTestRequested->value)
|
||||||
|
->where('resource_type', 'alert_destination')
|
||||||
|
->where('resource_id', (string) $destination->getKey())
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($auditLog)->not->toBeNull();
|
||||||
|
expect($auditLog->actor_id)->toBe((int) $user->getKey());
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// T012 — Confirmation requirement: action requires confirmation before execution
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
it('requires confirmation before sending a test message', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||||
|
$destination = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'is_enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(EditAlertDestination::class, ['record' => $destination->getRouteKey()])
|
||||||
|
->assertActionExists('send_test_message')
|
||||||
|
->mountAction('send_test_message')
|
||||||
|
->assertActionMounted('send_test_message');
|
||||||
|
|
||||||
|
expect(AlertDelivery::query()->count())->toBe(0);
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
});
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\AlertDestinationResource\Pages\EditAlertDestination;
|
||||||
|
use App\Filament\Resources\AlertDestinationResource\Pages\ViewAlertDestination;
|
||||||
|
use App\Models\AlertDelivery;
|
||||||
|
use App\Models\AlertDestination;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// T029 — Deep-link visibility + URL contract
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('shows view_last_delivery action on view page when a test delivery exists', function (): void {
|
||||||
|
[$user] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||||
|
$destination = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'is_enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AlertDelivery::factory()->test()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'alert_destination_id' => $destination->getKey(),
|
||||||
|
'status' => AlertDelivery::STATUS_SENT,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ViewAlertDestination::class, ['record' => $destination->getRouteKey()])
|
||||||
|
->assertActionVisible('view_last_delivery');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides view_last_delivery action on view page when no test delivery exists', function (): void {
|
||||||
|
[$user] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||||
|
$destination = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'is_enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ViewAlertDestination::class, ['record' => $destination->getRouteKey()])
|
||||||
|
->assertActionHidden('view_last_delivery');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows view_last_delivery action on edit page when a test delivery exists', function (): void {
|
||||||
|
[$user] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||||
|
$destination = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'is_enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AlertDelivery::factory()->test()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'alert_destination_id' => $destination->getKey(),
|
||||||
|
'status' => AlertDelivery::STATUS_SENT,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(EditAlertDestination::class, ['record' => $destination->getRouteKey()])
|
||||||
|
->assertActionVisible('view_last_delivery');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides view_last_delivery action on edit page when no test delivery exists', function (): void {
|
||||||
|
[$user] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||||
|
$destination = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'is_enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(EditAlertDestination::class, ['record' => $destination->getRouteKey()])
|
||||||
|
->assertActionHidden('view_last_delivery');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates deep link URL matching the contract filters', function (): void {
|
||||||
|
[$user] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||||
|
$destination = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'is_enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AlertDelivery::factory()->test()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'alert_destination_id' => $destination->getKey(),
|
||||||
|
'status' => AlertDelivery::STATUS_SENT,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component = Livewire::test(ViewAlertDestination::class, ['record' => $destination->getRouteKey()]);
|
||||||
|
|
||||||
|
$url = $component->instance()->getAction('view_last_delivery')->getUrl();
|
||||||
|
|
||||||
|
expect($url)->toContain('alert-deliveries');
|
||||||
|
expect($url)->toContain('filters%5Bevent_type%5D%5Bvalue%5D=alerts.test');
|
||||||
|
expect($url)->toContain('filters%5Balert_destination_id%5D%5Bvalue%5D='.$destination->getKey());
|
||||||
|
});
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
|
||||||
|
it('maps alert delivery status values to canonical badge semantics', function (): void {
|
||||||
|
$queued = BadgeCatalog::spec(BadgeDomain::AlertDeliveryStatus, 'queued');
|
||||||
|
expect($queued->label)->toBe('Queued');
|
||||||
|
expect($queued->color)->toBe('gray');
|
||||||
|
|
||||||
|
$deferred = BadgeCatalog::spec(BadgeDomain::AlertDeliveryStatus, 'deferred');
|
||||||
|
expect($deferred->label)->toBe('Deferred');
|
||||||
|
expect($deferred->color)->toBe('warning');
|
||||||
|
|
||||||
|
$sent = BadgeCatalog::spec(BadgeDomain::AlertDeliveryStatus, 'sent');
|
||||||
|
expect($sent->label)->toBe('Sent');
|
||||||
|
expect($sent->color)->toBe('success');
|
||||||
|
|
||||||
|
$failed = BadgeCatalog::spec(BadgeDomain::AlertDeliveryStatus, 'failed');
|
||||||
|
expect($failed->label)->toBe('Failed');
|
||||||
|
expect($failed->color)->toBe('danger');
|
||||||
|
|
||||||
|
$suppressed = BadgeCatalog::spec(BadgeDomain::AlertDeliveryStatus, 'suppressed');
|
||||||
|
expect($suppressed->label)->toBe('Suppressed');
|
||||||
|
expect($suppressed->color)->toBe('info');
|
||||||
|
|
||||||
|
$canceled = BadgeCatalog::spec(BadgeDomain::AlertDeliveryStatus, 'canceled');
|
||||||
|
expect($canceled->label)->toBe('Canceled');
|
||||||
|
expect($canceled->color)->toBe('gray');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unknown badge for invalid alert delivery status', function (): void {
|
||||||
|
$unknown = BadgeCatalog::spec(BadgeDomain::AlertDeliveryStatus, 'invalid_status');
|
||||||
|
expect($unknown->label)->toBe('Unknown');
|
||||||
|
expect($unknown->color)->toBe('gray');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps alert destination last test status values to canonical badge semantics', function (): void {
|
||||||
|
$never = BadgeCatalog::spec(BadgeDomain::AlertDestinationLastTestStatus, 'never');
|
||||||
|
expect($never->label)->toBe('Never');
|
||||||
|
expect($never->color)->toBe('gray');
|
||||||
|
|
||||||
|
$sent = BadgeCatalog::spec(BadgeDomain::AlertDestinationLastTestStatus, 'sent');
|
||||||
|
expect($sent->label)->toBe('Sent');
|
||||||
|
expect($sent->color)->toBe('success');
|
||||||
|
|
||||||
|
$failed = BadgeCatalog::spec(BadgeDomain::AlertDestinationLastTestStatus, 'failed');
|
||||||
|
expect($failed->label)->toBe('Failed');
|
||||||
|
expect($failed->color)->toBe('danger');
|
||||||
|
|
||||||
|
$pending = BadgeCatalog::spec(BadgeDomain::AlertDestinationLastTestStatus, 'pending');
|
||||||
|
expect($pending->label)->toBe('Pending');
|
||||||
|
expect($pending->color)->toBe('warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unknown badge for invalid last test status', function (): void {
|
||||||
|
$unknown = BadgeCatalog::spec(BadgeDomain::AlertDestinationLastTestStatus, 'nonexistent');
|
||||||
|
expect($unknown->label)->toBe('Unknown');
|
||||||
|
expect($unknown->color)->toBe('gray');
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user