feat(alerts): test message + last test status + deep links (#122)
Implements feature 100 (Alert Targets): - US1: “Send test message” action (RBAC + confirmation + rate limit + audit + async job) - US2: Derived “Last test” status badge (Never/Sent/Failed/Pending) on view + edit surfaces - US3: “View last delivery” deep link + deliveries viewer filters (event_type, destination) incl. tenantless test deliveries Tests: - Full suite green (1348 passed, 7 skipped) - Added focused feature tests for send test, last test resolver/badges, and deep-link filters Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #122
This commit is contained in:
parent
3ed275cef3
commit
d49d33ac27
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -29,6 +29,7 @@ ## Active Technologies
|
||||
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4 (085-tenant-operate-hub)
|
||||
- PostgreSQL (Sail), SQLite in tests (087-legacy-runs-removal)
|
||||
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4, Microsoft Graph integration via `GraphClientInterface` (095-graph-contracts-registry-completeness)
|
||||
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Laravel Queue, Laravel Notifications (100-alert-target-test-actions)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -48,8 +49,7 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 100-alert-target-test-actions: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Laravel Queue, Laravel Notifications
|
||||
- 095-graph-contracts-registry-completeness: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4, Microsoft Graph integration via `GraphClientInterface`
|
||||
- 090-action-surface-contract-compliance: Added PHP 8.4.15
|
||||
- 087-legacy-runs-removal: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -70,6 +70,7 @@ ### Tenant Isolation is Non-negotiable
|
||||
- Tenant-owned tables MUST include workspace_id and tenant_id as NOT NULL.
|
||||
- Workspace-owned tables MUST include workspace_id and MUST NOT include tenant_id.
|
||||
- Exception: OperationRun MAY have tenant_id nullable to support canonical workspace-context monitoring views; however, revealing any tenant-bound runs still MUST enforce entitlement checks to the referenced tenant scope.
|
||||
- Exception: AlertDelivery MAY have tenant_id nullable for workspace-scoped, non-tenant-operational artifacts (e.g., `event_type=alerts.test`). Tenant-bound delivery records still MUST enforce tenant entitlement checks, and tenantless delivery rows MUST NOT contain tenant-specific data.
|
||||
|
||||
### RBAC & UI Enforcement Standards (RBAC-UX)
|
||||
|
||||
|
||||
@ -7,8 +7,11 @@
|
||||
use App\Filament\Clusters\Monitoring\AlertsCluster;
|
||||
use App\Filament\Resources\AlertDeliveryResource\Pages;
|
||||
use App\Models\AlertDelivery;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\Tenant;
|
||||
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\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -114,7 +117,10 @@ public static function getEloquentQuery(): Builder
|
||||
)
|
||||
->when(
|
||||
$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(
|
||||
Filament::getTenant() instanceof Tenant,
|
||||
@ -136,8 +142,9 @@ public static function infolist(Schema $schema): Schema
|
||||
->schema([
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => self::statusLabel((string) $state))
|
||||
->color(fn (?string $state): string => self::statusColor((string) $state)),
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::AlertDeliveryStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::AlertDeliveryStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::AlertDeliveryStatus)),
|
||||
TextEntry::make('event_type')
|
||||
->label('Event')
|
||||
->badge()
|
||||
@ -214,8 +221,9 @@ public static function table(Table $table): Table
|
||||
->placeholder('—'),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => self::statusLabel((string) $state))
|
||||
->color(fn (?string $state): string => self::statusColor((string) $state)),
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::AlertDeliveryStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::AlertDeliveryStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::AlertDeliveryStatus)),
|
||||
TextColumn::make('rule.name')
|
||||
->label('Rule')
|
||||
->placeholder('—'),
|
||||
@ -235,6 +243,29 @@ public static function table(Table $table): Table
|
||||
AlertDelivery::STATUS_SUPPRESSED => 'Suppressed',
|
||||
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([
|
||||
ViewAction::make()->label('View'),
|
||||
@ -249,30 +280,4 @@ public static function getPages(): array
|
||||
'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')
|
||||
->recordUrl(fn (AlertDestination $record): ?string => static::canEdit($record)
|
||||
? static::getUrl('edit', ['record' => $record])
|
||||
: null)
|
||||
: static::getUrl('view', ['record' => $record]))
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->searchable(),
|
||||
@ -266,6 +266,7 @@ public static function getPages(): array
|
||||
return [
|
||||
'index' => Pages\ListAlertDestinations::route('/'),
|
||||
'create' => Pages\CreateAlertDestination::route('/create'),
|
||||
'view' => Pages\ViewAlertDestination::route('/{record}'),
|
||||
'edit' => Pages\EditAlertDestination::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
@ -4,9 +4,15 @@
|
||||
|
||||
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\Audit\AuditActionId;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
@ -14,6 +20,80 @@ class EditAlertDestination extends EditRecord
|
||||
{
|
||||
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
|
||||
{
|
||||
$record = $this->record;
|
||||
@ -46,4 +126,34 @@ protected function afterSave(): void
|
||||
->success()
|
||||
->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;
|
||||
|
||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenantWhenPresent;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@ -13,10 +13,12 @@
|
||||
|
||||
class AlertDelivery extends Model
|
||||
{
|
||||
use DerivesWorkspaceIdFromTenant;
|
||||
use DerivesWorkspaceIdFromTenantWhenPresent;
|
||||
use HasFactory;
|
||||
use Prunable;
|
||||
|
||||
public const string EVENT_TYPE_TEST = 'alerts.test';
|
||||
|
||||
public const string STATUS_QUEUED = 'queued';
|
||||
|
||||
public const string STATUS_DEFERRED = 'deferred';
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\AlertDelivery;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
@ -40,8 +39,8 @@ public function view(User $user, AlertDelivery $alertDelivery): bool|Response
|
||||
|
||||
$tenant = $alertDelivery->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return Response::denyAsNotFound();
|
||||
if ($tenant === null) {
|
||||
return $this->authorizeForWorkspace($user, $workspace, Capabilities::ALERTS_VIEW);
|
||||
}
|
||||
|
||||
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 AlertDestinationEnabled = 'alert_destination.enabled';
|
||||
case AlertDestinationDisabled = 'alert_destination.disabled';
|
||||
case AlertDestinationTestRequested = 'alert_destination.test_requested';
|
||||
|
||||
case AlertRuleCreated = 'alert_rule.created';
|
||||
case AlertRuleUpdated = 'alert_rule.updated';
|
||||
|
||||
@ -37,6 +37,8 @@ final class BadgeCatalog
|
||||
BadgeDomain::VerificationCheckStatus->value => Domains\VerificationCheckStatusBadge::class,
|
||||
BadgeDomain::VerificationCheckSeverity->value => Domains\VerificationCheckSeverityBadge::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 VerificationCheckSeverity = 'verification_check_severity';
|
||||
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
|
||||
{
|
||||
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(),
|
||||
'workspace_id' => function (array $attributes): int {
|
||||
$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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Alert Targets Test Actions
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-02-18
|
||||
**Feature**: [specs/100-alert-target-test-actions/spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Spec is ready for `/speckit.plan`.
|
||||
17
specs/100-alert-target-test-actions/contracts/event-types.md
Normal file
17
specs/100-alert-target-test-actions/contracts/event-types.md
Normal file
@ -0,0 +1,17 @@
|
||||
# Contract — Alert Event Types (workspace alerts)
|
||||
|
||||
## Overview
|
||||
|
||||
This add-on introduces a single new event type used for *test deliveries*.
|
||||
|
||||
## Event Type
|
||||
|
||||
- `alerts.test`
|
||||
- Purpose: user-triggered “Send test message” action on an alert target.
|
||||
- Storage: `alert_deliveries.event_type`
|
||||
- Delivery: sent via the existing alert delivery pipeline (`DeliverAlertsJob` + `AlertSender`).
|
||||
|
||||
## Notes
|
||||
|
||||
- This is an internal event type string; it is not a Microsoft Graph contract.
|
||||
- No secrets are stored in payload/error text.
|
||||
@ -0,0 +1,32 @@
|
||||
# Contract — Filament deep link into Alert Deliveries
|
||||
|
||||
## Goal
|
||||
|
||||
Allow a deterministic deep link from an Alert Target (destination) to the Deliveries viewer filtered to the most relevant rows.
|
||||
|
||||
## Target
|
||||
|
||||
- Resource: `AlertDeliveryResource`
|
||||
- Page: list/index
|
||||
|
||||
## Query-string filter contract (Filament v5)
|
||||
|
||||
Use Filament’s `filters[...]` query-string state to pre-apply table filters:
|
||||
|
||||
- Filter by event type:
|
||||
- `filters[event_type][value]=alerts.test`
|
||||
- Filter by destination:
|
||||
- `filters[alert_destination_id][value]={DESTINATION_ID}`
|
||||
|
||||
Combined example:
|
||||
|
||||
`/admin/alert-deliveries?filters[event_type][value]=alerts.test&filters[alert_destination_id][value]=123`
|
||||
|
||||
## Required table filters
|
||||
|
||||
The Deliveries list must define these filters with matching keys:
|
||||
|
||||
- `SelectFilter::make('event_type')`
|
||||
- `SelectFilter::make('alert_destination_id')`
|
||||
|
||||
(These are additive to the existing `status` filter.)
|
||||
79
specs/100-alert-target-test-actions/data-model.md
Normal file
79
specs/100-alert-target-test-actions/data-model.md
Normal file
@ -0,0 +1,79 @@
|
||||
# Phase 1 — Data Model (099.1 Add-on)
|
||||
|
||||
## Entities
|
||||
|
||||
### AlertDestination (existing)
|
||||
|
||||
- **Table**: `alert_destinations`
|
||||
- **Ownership**: workspace-owned
|
||||
- **Key fields (relevant here)**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `type` (e.g. Teams webhook, Email)
|
||||
- `config` (Teams webhook URL or list of recipients)
|
||||
- `is_enabled`
|
||||
|
||||
### AlertDelivery (existing, extended for test sends)
|
||||
|
||||
- **Table**: `alert_deliveries`
|
||||
- **Ownership**:
|
||||
- v1 deliveries for real alerts remain tenant-associated
|
||||
- **test deliveries for this add-on are tenantless** (workspace-only)
|
||||
|
||||
- **Key fields (relevant here)**:
|
||||
- `id`
|
||||
- `workspace_id` (required)
|
||||
- `tenant_id` (**nullable for test deliveries**)
|
||||
- `alert_rule_id` (**nullable for test deliveries**)
|
||||
- `alert_destination_id` (required)
|
||||
- `event_type` (string, includes `alerts.test`)
|
||||
- `status` (`queued|deferred|sent|failed|suppressed|canceled`)
|
||||
- `send_after` (nullable; used for deferral/backoff)
|
||||
- `sent_at` (nullable)
|
||||
- `attempt_count`
|
||||
- `last_error_code`, `last_error_message` (sanitized)
|
||||
- `payload` (array/json)
|
||||
- timestamps: `created_at`, `updated_at`
|
||||
|
||||
## Relationships
|
||||
|
||||
- `AlertDelivery` → `AlertDestination` (belongsTo via `alert_destination_id`)
|
||||
- `AlertDelivery` → `AlertRule` (belongsTo via `alert_rule_id`, nullable)
|
||||
- `AlertDelivery` → `Tenant` (belongsTo via `tenant_id`, nullable)
|
||||
|
||||
## New derived concepts (no storage)
|
||||
|
||||
### LastTestStatus (derived)
|
||||
|
||||
Derived from the most recent `alert_deliveries` record where:
|
||||
- `alert_destination_id = {destination}`
|
||||
- `event_type = 'alerts.test'`
|
||||
|
||||
Mapping:
|
||||
- no record → `Never`
|
||||
- `status in (queued, deferred)` → `Pending`
|
||||
- `status = sent` → `Sent`
|
||||
- `status = failed` → `Failed`
|
||||
|
||||
Associated timestamp (derived):
|
||||
- Sent → `sent_at`
|
||||
- Failed → `updated_at`
|
||||
- Pending → `send_after` (fallback `created_at`)
|
||||
|
||||
## Validation / invariants
|
||||
|
||||
- Creating a test delivery requires:
|
||||
- `alert_destination_id` exists and belongs to current workspace
|
||||
- destination is enabled (if disabled, refuse test request)
|
||||
- rate limit: no prior test delivery for this destination in last 60 seconds
|
||||
- Test delivery record must not persist secrets in payload or error message.
|
||||
|
||||
## Migration notes
|
||||
|
||||
To support tenantless test deliveries:
|
||||
- Make `alert_deliveries.tenant_id` nullable and adjust the FK behavior.
|
||||
- Make `alert_deliveries.alert_rule_id` nullable and adjust the FK behavior.
|
||||
- Add or adjust indexes for efficient status lookup per destination + event type:
|
||||
- `(workspace_id, alert_destination_id, event_type, created_at)`
|
||||
|
||||
(Exact migration steps and DB constraint changes are specified in the implementation plan.)
|
||||
221
specs/100-alert-target-test-actions/plan.md
Normal file
221
specs/100-alert-target-test-actions/plan.md
Normal file
@ -0,0 +1,221 @@
|
||||
# Implementation Plan: Alert Targets — Test Actions + Last Test Status
|
||||
|
||||
**Branch**: `feat/100-alert-target-test-actions` | **Date**: 2026-02-18 | **Spec**: `specs/100-alert-target-test-actions/spec.md`
|
||||
**Input**: Feature specification from `specs/100-alert-target-test-actions/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||
|
||||
## Summary
|
||||
|
||||
Add a derived “Last test status” indicator to Alert Targets (Filament `AlertDestinationResource` view and edit pages), plus a capability-gated “Send test message” action that enqueues a test delivery (no outbound calls in the request thread). The status is derived solely from the latest `alert_deliveries` row with `event_type = 'alerts.test'` for the destination.
|
||||
|
||||
## Technical Context
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: Replace the content in this section with the technical details
|
||||
for the project. The structure here is presented in advisory capacity to guide
|
||||
the iteration process.
|
||||
-->
|
||||
|
||||
**Language/Version**: PHP 8.4.15 (Laravel 12)
|
||||
**Primary Dependencies**: Filament v5, Livewire v4, Laravel Queue, Laravel Notifications
|
||||
**Storage**: PostgreSQL (Sail locally)
|
||||
**Testing**: Pest v4 (via `vendor/bin/sail artisan test`)
|
||||
**Target Platform**: Laravel web app (Sail-first locally; Dokploy containers in staging/prod)
|
||||
**Project Type**: Web application (monolith)
|
||||
**Performance Goals**: DB-only page rendering; avoid N+1; derived last-test status computed with a single indexed query
|
||||
**Constraints**: No outbound network calls in request/response; no “last test” DB field; deterministic mapping (Never/Sent/Failed/Pending)
|
||||
**Scale/Scope**: Workspace-scoped admin UX; minimal UI surfaces (view + edit pages)
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first: N/A (no inventory changes)
|
||||
- Read/write separation: test request is a small DB-only write; includes confirmation + audit + tests
|
||||
- Graph contract path: no Graph calls
|
||||
- Deterministic capabilities: uses existing `Capabilities::ALERTS_VIEW` / `Capabilities::ALERTS_MANAGE` registry
|
||||
- RBAC-UX: existing workspace policy semantics (non-member 404 via policy) + member-without-capability 403 on action execution
|
||||
- Workspace isolation: enforced via `WorkspaceContext` + `AlertDestinationPolicy`
|
||||
- Destructive confirmations: “Send test message” is not destructive but still requires confirmation per spec
|
||||
- Tenant isolation: deliveries viewer remains tenant-safe; tenantless test deliveries are treated as workspace-owned and are safe to reveal
|
||||
- Run observability: external sends happen via existing `alerts.deliver` operation run created by `DeliverAlertsJob`
|
||||
- Data minimization: test payload + errors are sanitized (no webhook URL / recipients)
|
||||
- Badge semantics: new badge domains/mappers added; no ad-hoc mappings
|
||||
- Filament action surface: edits are confined to edit header actions + read-only status section; action is capability-gated and audited
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/100-alert-target-test-actions/
|
||||
├── plan.md
|
||||
├── spec.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
└── checklists/
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
<!--
|
||||
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
|
||||
for this feature. Delete unused options and expand the chosen structure with
|
||||
real paths (e.g., apps/admin, packages/something). The delivered plan must
|
||||
not include Option labels.
|
||||
-->
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ └── Resources/
|
||||
│ ├── AlertDestinationResource.php
|
||||
│ ├── AlertDeliveryResource.php
|
||||
│ └── AlertDestinationResource/Pages/EditAlertDestination.php
|
||||
├── Jobs/Alerts/
|
||||
│ └── DeliverAlertsJob.php
|
||||
├── Models/
|
||||
│ └── AlertDelivery.php
|
||||
├── Policies/
|
||||
│ ├── AlertDestinationPolicy.php
|
||||
│ └── AlertDeliveryPolicy.php
|
||||
├── Services/Alerts/
|
||||
│ ├── AlertSender.php
|
||||
│ └── AlertDispatchService.php
|
||||
└── Support/
|
||||
├── Audit/AuditActionId.php
|
||||
└── Badges/
|
||||
|
||||
database/migrations/
|
||||
|
||||
tests/
|
||||
├── Feature/
|
||||
└── Unit/
|
||||
```
|
||||
|
||||
**Structure Decision**: Laravel monolith; Filament resources under `app/Filament`, domain services/jobs under `app/Services` and `app/Jobs`, tests in `tests/`.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
||||
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
||||
|
||||
## Phase 0 — Outline & Research (completed)
|
||||
|
||||
Artifacts:
|
||||
|
||||
- `specs/100-alert-target-test-actions/research.md`
|
||||
|
||||
Key outcomes:
|
||||
|
||||
- Deep link uses Filament v5 `filters[...]` query string.
|
||||
- Timestamp mapping uses existing columns (`sent_at`, `updated_at`, `send_after`).
|
||||
- Test deliveries must be tenantless to avoid tenant-entitlement divergence.
|
||||
|
||||
## Phase 1 — Design & Contracts
|
||||
|
||||
### Data model changes
|
||||
|
||||
The current `alert_deliveries` table enforces tenant and rule FKs as required. For test sends:
|
||||
|
||||
- Make `tenant_id` nullable (test deliveries have no tenant context)
|
||||
- Make `alert_rule_id` nullable (test deliveries are not tied to a routing rule)
|
||||
|
||||
Also add an index to support the derived “last test” lookup:
|
||||
|
||||
- `(workspace_id, alert_destination_id, event_type, created_at)`
|
||||
|
||||
### Backend design
|
||||
|
||||
1. Add a small resolver to compute `LastTestStatus` from `alert_deliveries`:
|
||||
- Input: `workspace_id`, `alert_destination_id`
|
||||
- Query: latest delivery with `event_type = 'alerts.test'`
|
||||
- Output: `{status, timestamp, delivery_id?}`
|
||||
|
||||
2. Add a service for creating a test delivery:
|
||||
- Authorize using existing `AlertDestinationPolicy::update` (manage capability)
|
||||
- Enforce rate limit (60s) by checking latest test delivery `created_at`
|
||||
- Create delivery with:
|
||||
- `workspace_id` = current workspace
|
||||
- `tenant_id` = null
|
||||
- `alert_rule_id` = null
|
||||
- `alert_destination_id` = destination id
|
||||
- `event_type` = `alerts.test`
|
||||
- `status` = `queued`
|
||||
- `fingerprint_hash` = deterministic stable string (no secrets) (e.g. `test:{destination_id}`)
|
||||
- `payload` = minimal safe text
|
||||
|
||||
3. Queue execution:
|
||||
- Dispatch `DeliverAlertsJob($workspaceId)` so the test is processed without waiting for a scheduler.
|
||||
|
||||
### Filament UI design
|
||||
|
||||
1. Alert Targets (View page)
|
||||
- Provide a read-only view for view-only users.
|
||||
- Render the derived “Last test” status (badge + timestamp).
|
||||
- Add header actions:
|
||||
- **Send test message**: visible but disabled when user lacks `ALERTS_MANAGE`.
|
||||
- **View last delivery**: visible only if at least one test delivery exists.
|
||||
|
||||
2. Alert Targets (Edit page)
|
||||
- Add a read-only “Last test” status at the top of the edit form using a form component that can render as a badge.
|
||||
- Add header actions:
|
||||
- **Send test message** (mutating): `Action::make(...)->requiresConfirmation()->action(...)`
|
||||
- Visible for workspace members with `ALERTS_VIEW`.
|
||||
- Disabled via UI enforcement when user lacks `ALERTS_MANAGE`.
|
||||
- **View last delivery** (navigation): `Action::make(...)->url(...)`
|
||||
- Visible only if at least one test delivery exists.
|
||||
|
||||
3. Deliveries viewer (List page)
|
||||
- Add table filters:
|
||||
- `event_type` (SelectFilter)
|
||||
- `alert_destination_id` (SelectFilter)
|
||||
- Adjust tenant entitlement filter to include tenantless deliveries (`tenant_id IS NULL`).
|
||||
|
||||
### Authorization & semantics
|
||||
|
||||
- Non-member: existing policies return `denyAsNotFound()`.
|
||||
- Member without `ALERTS_MANAGE`: action execution must result in 403; UI remains visible but disabled.
|
||||
|
||||
### Audit logging
|
||||
|
||||
- Add a new `AuditActionId` value: `alert_destination.test_requested`.
|
||||
- Log when a test is requested with redacted metadata (no webhook URL / recipients).
|
||||
|
||||
### Badge semantics
|
||||
|
||||
- Add badge domain(s) and tests:
|
||||
- Alert delivery status badge mapping (to replace the ad-hoc mapping in `AlertDeliveryResource`).
|
||||
- Alert destination last-test status badge mapping.
|
||||
|
||||
### Contracts
|
||||
|
||||
- Deep link contract + event type contract live in `specs/100-alert-target-test-actions/contracts/`.
|
||||
|
||||
## Post-Design Constitution Re-check
|
||||
|
||||
- RBAC-UX: enforced via policies + capability registry; test action server-authorized.
|
||||
- BADGE-001: new badge domains + tests planned; no ad-hoc mappings.
|
||||
- OPS/run observability: outbound delivery occurs only in queued job; `alerts.deliver` operation run remains the monitoring source.
|
||||
- DB-only rendering: derived status and links use indexed DB queries; no external calls.
|
||||
|
||||
## Phase 2 — Implementation planning (for tasks.md)
|
||||
|
||||
Next steps (to be expanded into `tasks.md`):
|
||||
|
||||
1. DB migration: make `alert_deliveries.tenant_id` and `alert_rule_id` nullable + add supporting index.
|
||||
2. Update `AlertDelivery` model/relationships/casts if needed for tenantless + ruleless deliveries.
|
||||
3. Update `AlertDeliveryPolicy` + `AlertDeliveryResource` query to allow tenantless deliveries.
|
||||
4. Add badge domains + mapping tests.
|
||||
5. Add “Send test message” header action + “Last test” badge section to `EditAlertDestination`.
|
||||
6. Add feature tests (Pest) for:
|
||||
- derived status mapping (Never/Sent/Failed/Pending)
|
||||
- rate limiting
|
||||
- RBAC (manage vs view)
|
||||
- deep link visibility
|
||||
20
specs/100-alert-target-test-actions/quickstart.md
Normal file
20
specs/100-alert-target-test-actions/quickstart.md
Normal file
@ -0,0 +1,20 @@
|
||||
# Quickstart (099.1 Add-on)
|
||||
|
||||
## Prereqs
|
||||
|
||||
- Laravel Sail is used for local dev.
|
||||
|
||||
## Run locally
|
||||
|
||||
- Start containers: `vendor/bin/sail up -d`
|
||||
- Run migrations: `vendor/bin/sail artisan migrate`
|
||||
|
||||
## Run tests (focused)
|
||||
|
||||
- `vendor/bin/sail artisan test --compact --filter=AlertTargetTest`
|
||||
|
||||
(Replace filter/name with the final Pest test names once implemented.)
|
||||
|
||||
## Format
|
||||
|
||||
- `vendor/bin/sail bin pint --dirty`
|
||||
68
specs/100-alert-target-test-actions/research.md
Normal file
68
specs/100-alert-target-test-actions/research.md
Normal file
@ -0,0 +1,68 @@
|
||||
# Phase 0 — Research (099.1 Add-on)
|
||||
|
||||
This research resolves the open technical questions from the feature spec and anchors decisions to existing code in this repository.
|
||||
|
||||
## Decision 1 — Where “Alert Target View/Edit” lives in code
|
||||
|
||||
- **Decision**: Implement “Last test status” + “Send test message” on the Alert Target **Edit** page only.
|
||||
- **Rationale**: The current Filament resource for alert targets is `AlertDestinationResource` and only defines `index/create/edit` pages (no View page).
|
||||
- **Alternatives considered**:
|
||||
- Add a new View page for `AlertDestinationResource` (rejected: expands UX surface and is not required for this add-on).
|
||||
|
||||
## Decision 2 — Canonical event type string
|
||||
|
||||
- **Decision**: Use a single canonical event type string for test sends: `alerts.test`.
|
||||
- **Rationale**: The add-on spec requires deriving status from `alert_deliveries.event_type`. Keeping this string stable allows deterministic filtering and deep links.
|
||||
- **Alternatives considered**:
|
||||
- Use `test` or `destination_test` (rejected: deviates from spec and makes intent less explicit).
|
||||
|
||||
## Decision 3 — Timestamp mapping vs actual schema
|
||||
|
||||
- **Decision**: Map timestamps using existing columns:
|
||||
- **Sent** → `sent_at`
|
||||
- **Failed** → `updated_at` (no `failed_at` column exists)
|
||||
- **Pending** → `send_after` if set, else `created_at`
|
||||
- **Rationale**: The `alert_deliveries` schema only has `send_after`, `sent_at`, and `updated_at`. The spec’s `deliver_at` / `failed_at` naming is treated as conceptual.
|
||||
- **Alternatives considered**:
|
||||
- Add `failed_at` / `deliver_at` columns (rejected: violates “no new DB fields”).
|
||||
|
||||
## Decision 4 — How to represent test deliveries given current tenant enforcement
|
||||
|
||||
- **Decision**: Store test deliveries in `alert_deliveries` as **workspace-scoped, tenantless** records:
|
||||
- `workspace_id` set
|
||||
- `tenant_id` nullable
|
||||
- `alert_rule_id` nullable
|
||||
- `alert_destination_id` set
|
||||
- **Rationale**:
|
||||
- Alert targets (`AlertDestination`) are workspace-owned and accessible without a selected tenant.
|
||||
- Current enforcement (`DerivesWorkspaceIdFromTenant`) requires a tenant and would make test deliveries invisible/uneven across users due to tenant entitlements.
|
||||
- Deliver job execution (`DeliverAlertsJob`) does not require a tenant; it only needs the destination + payload.
|
||||
- **Alternatives considered**:
|
||||
- Pick an arbitrary tenant ID for test deliveries (rejected: would be invisible to some operators and breaks “single truth” per target).
|
||||
- Create a synthetic “workspace tenant” record (rejected: adds data-model complexity).
|
||||
|
||||
## Decision 5 — Deep link mechanics for the Deliveries viewer
|
||||
|
||||
- **Decision**: Deep link into `AlertDeliveryResource` list view using Filament v5 filter query-string binding:
|
||||
- `?filters[event_type][value]=alerts.test&filters[alert_destination_id][value]={DESTINATION_ID}`
|
||||
- **Rationale**: Filament v5 `ListRecords` binds `tableFilters` to the URL under the `filters` key; `SelectFilter` uses `value`.
|
||||
- **Alternatives considered**:
|
||||
- Custom dedicated “Test deliveries” page (rejected: new surface beyond spec).
|
||||
|
||||
## Decision 6 — Badge semantics centralization (BADGE-001)
|
||||
|
||||
- **Decision**: Introduce centralized badge domains/mappers for:
|
||||
- `AlertDeliveryStatus` (queued/deferred/sent/failed/suppressed/canceled)
|
||||
- `AlertDestinationLastTestStatus` (never/pending/sent/failed)
|
||||
- **Rationale**: Current `AlertDeliveryResource` implements ad-hoc status label/color helpers. This add-on must comply with BADGE-001 and should not add more local mappings.
|
||||
- **Alternatives considered**:
|
||||
- Keep ad-hoc mappings in the new code (rejected: violates BADGE-001).
|
||||
|
||||
## Decision 7 — OperationRun usage
|
||||
|
||||
- **Decision**: Do **not** create a new per-test `OperationRun`.
|
||||
- **Rationale**:
|
||||
- The user-triggered action is DB-only (authorization + rate limit + create delivery + audit), typically <2s.
|
||||
- The external work runs via the existing `alerts.deliver` operation run (created by `DeliverAlertsJob`).
|
||||
- **Alternatives considered**:
|
||||
- Create a dedicated operation run type (rejected: higher complexity for small UX add-on).
|
||||
156
specs/100-alert-target-test-actions/spec.md
Normal file
156
specs/100-alert-target-test-actions/spec.md
Normal file
@ -0,0 +1,156 @@
|
||||
# Feature Specification: Alert Targets Test Actions
|
||||
|
||||
**Feature Branch**: `feat/100-alert-target-test-actions`
|
||||
**Created**: 2026-02-18
|
||||
**Status**: Draft
|
||||
**Input**: User description: "099.1 — Alert Targets: Send Test Message + Last Test Status (Teams + Email)"
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace
|
||||
- **Primary Routes**: Alert Target View, Alert Target Edit, Deliveries viewer (filtered deep links)
|
||||
- **Data Ownership**:
|
||||
- Workspace-owned: alert targets
|
||||
- Tenant-owned: alert delivery history (non-test)
|
||||
- Workspace-scoped: test deliveries (`event_type=alerts.test`) may be tenantless (`tenant_id` nullable)
|
||||
- **RBAC**:
|
||||
- Workspace membership is required to access Alert Targets and Deliveries.
|
||||
- Users with manage capability can request a test send.
|
||||
- Users with view-only capability can see test status but cannot request a test send.
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: Not applicable (workspace scope).
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Targets and deliveries are only visible within the current workspace; non-members must not receive any existence hints.
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-02-18
|
||||
|
||||
- Q: Which timestamps should the “Last test … at <timestamp>” subtext use? → A: Sent → `sent_at`; Failed → `updated_at`; Pending → `send_after`.
|
||||
- Q: What should the test-send rate limit be per target? → A: 60 seconds per target.
|
||||
- Q: What should “View last delivery” open? → B: Deliveries list viewer filtered to `alert_destination_id` + `event_type=alerts.test`.
|
||||
- Q: When should “View last delivery” be shown on the Alert Target pages? → B: Show only if at least one test delivery exists.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Send a test message for a target (Priority: P1)
|
||||
|
||||
As an admin, I want to send a test alert to a configured Alert Target so I can verify the integration works before relying on it in production.
|
||||
|
||||
**Why this priority**: This is the fastest way to detect misconfiguration (wrong destination, blocked network path, invalid credentials) and reduce support/incident time.
|
||||
|
||||
**Independent Test**: Can be fully tested by requesting a test send and confirming a new test delivery record exists and can be inspected.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** I am a workspace member with manage permission, **When** I confirm “Send test message” on an Alert Target, **Then** the system creates exactly one new test delivery record for that target and indicates the request was queued.
|
||||
2. **Given** I am a workspace member without manage permission, **When** I attempt to execute “Send test message”, **Then** the action is blocked and no delivery record is created.
|
||||
3. **Given** I have requested a test very recently for the same target, **When** I attempt another test immediately, **Then** the system refuses the request and does not create a new delivery record.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - See the last test status at a glance (Priority: P2)
|
||||
|
||||
As an admin, I want to see whether the last test send for an Alert Target succeeded, failed, was never executed, or is still pending so I can assess health without digging through deliveries.
|
||||
|
||||
**Why this priority**: Health visibility reduces troubleshooting time and prevents silent failures.
|
||||
|
||||
**Independent Test**: Can be fully tested by viewing a target with (a) no test deliveries, (b) a successful test delivery, (c) a failed test delivery and verifying the badge and timestamp.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a target has no test delivery records, **When** I open the target View or Edit page, **Then** I see a badge “Last test: Never”.
|
||||
2. **Given** the most recent test delivery record is successful, **When** I open the target View or Edit page, **Then** I see a badge “Last test: Sent” and an associated timestamp.
|
||||
3. **Given** the most recent test delivery record is failed, **When** I open the target View or Edit page, **Then** I see a badge “Last test: Failed” and an associated timestamp.
|
||||
4. **Given** the most recent test delivery record is queued or deferred, **When** I open the target View or Edit page, **Then** I see a badge “Last test: Pending”.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Jump from target to the relevant delivery details (Priority: P3)
|
||||
|
||||
As an admin, I want a quick link from the Alert Target to the most recent test delivery details so I can troubleshoot outcomes efficiently.
|
||||
|
||||
**Why this priority**: It reduces clicks and prevents mistakes when searching through delivery history.
|
||||
|
||||
**Independent Test**: Can be tested by clicking a “View last delivery” link and verifying the deliveries view is pre-filtered for this target and the test event type.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a target has at least one test delivery record, **When** I click “View last delivery” from the target page, **Then** I am taken to the deliveries list viewer scoped to the same workspace and filtered to that target and the test event type.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Target exists but has never been tested.
|
||||
- Target has multiple test deliveries; only the most recent one is used for status.
|
||||
- The most recent test delivery is queued/deferred; status must show pending without implying success.
|
||||
- Users without workspace membership attempt to access targets or deliveries (must be deny-as-not-found).
|
||||
- Failure details must not expose secrets (destination URLs, recipients).
|
||||
- Rapid repeated test requests (anti-spam / rate limiting) must not create additional delivery records.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature introduces a user-triggered action that creates a delivery record and schedules work to be executed asynchronously.
|
||||
The spec requires: confirmation, RBAC enforcement, anti-spam rate limiting, audit logging, tenant/workspace isolation, and tests.
|
||||
|
||||
**Constitution alignment (RBAC-UX):**
|
||||
|
||||
- Authorization planes involved: Admin UI (workspace-scoped).
|
||||
- 404 vs 403 semantics:
|
||||
- Non-member / not entitled to workspace scope → 404 (deny-as-not-found)
|
||||
- Workspace member but missing manage capability → 403 for executing the test action
|
||||
- All mutations (test request) require server-side authorization.
|
||||
|
||||
**Constitution alignment (BADGE-001):** “Last test: Sent/Failed/Pending/Never” MUST use centralized badge semantics (no ad-hoc mappings).
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** This feature modifies Filament pages (Alert Target view/edit) and therefore includes a UI Action Matrix.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001 (Derived status, no new fields)**: The system MUST display a “Last test status” indicator on Alert Target View and Edit pages derived solely from existing alert delivery records.
|
||||
- **FR-002 (Deterministic selection)**: The “Last test status” MUST be derived from the single most recent delivery record for the given target where the delivery event type is “alerts.test”, ordered by `created_at` (desc) then `id` (desc).
|
||||
- **FR-003 (Status mapping)**: The system MUST map the most recent test delivery record to one of: Never (no record), Sent, Failed, or Pending.
|
||||
- **FR-004 (Timestamp semantics)**: The UI MUST display a timestamp that reflects when the outcome occurred: Sent → `sent_at`; Failed → `updated_at`; Pending → `send_after`.
|
||||
- **FR-005 (DB-only UI)**: Requesting a test send MUST not perform synchronous external delivery attempts in the user’s request/response flow.
|
||||
- **FR-006 (Confirmation)**: The “Send test message” action MUST require explicit confirmation, explaining that it will contact the configured destination.
|
||||
- **FR-007 (Anti-spam rate limit)**: The system MUST prevent repeated test requests for the same target within 60 seconds.
|
||||
- **FR-008 (RBAC)**: Only workspace members with manage permission can request a test send; view-only users can see the action but cannot execute it.
|
||||
- **FR-009 (Deny-as-not-found)**: Users without workspace membership MUST receive deny-as-not-found behavior for targets and deliveries.
|
||||
- **FR-010 (Auditability)**: The system MUST record an audit event when a test is requested, without including destination secrets.
|
||||
- **FR-011 (Deep link)**: The system SHOULD provide a “View last delivery” link from the target to the Deliveries list viewer filtered to that target and `event_type=alerts.test`.
|
||||
- **FR-012 (Deep link visibility)**: The system SHOULD show “View last delivery” only when at least one test delivery exists for the target.
|
||||
|
||||
### Assumptions
|
||||
|
||||
- Alerts v1 already provides Alert Targets, Alert Deliveries, and a Deliveries viewer.
|
||||
- A test send is represented as a delivery record with event type “alerts.test”.
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- No new database fields for storing “last test status”.
|
||||
- No bulk “test all targets” feature.
|
||||
- No destination setup wizard.
|
||||
- No per-row list view badge (avoids performance/N+1 concerns in v1).
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Alert Target (View) | Alert Target view page | Send test message (confirm, manage-only), View last delivery (navigate) | Not changed in this feature | Not changed in this feature | None | None | Same as Header Actions | Not applicable | Yes | View-only users see disabled “Send test message”. |
|
||||
| Alert Target (Edit) | Alert Target edit page | Send test message (confirm, manage-only), View last delivery (navigate) | Not changed in this feature | Not changed in this feature | None | None | Same as Header Actions | Existing save/cancel | Yes | “Last test status” appears above the form. |
|
||||
| Deliveries viewer | Deliveries list/details | None (existing) | Filtered via deep link | Existing row actions | Existing | Existing | Existing | Not applicable | Existing | Must remain workspace-scoped. |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Alert Target**: A destination configuration for alerts (e.g., Teams webhook or email destination), scoped to a workspace.
|
||||
- **Alert Delivery**: A delivery attempt record that captures event type (including “alerts.test”), status, timestamps, and safe diagnostic details.
|
||||
- **Audit Event**: A workspace-scoped audit entry representing a user-triggered test request.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: An admin can determine “last test status” for a target within 5 seconds from the target page.
|
||||
- **SC-002**: An admin can request a test send in under 30 seconds (including confirmation) without leaving the target page.
|
||||
- **SC-003**: A test request always results in either (a) a new test delivery record being created, or (b) a clear refusal due to rate limiting or missing permissions.
|
||||
- **SC-004**: Troubleshooting time for “alerts not delivered” issues is reduced because the last test outcome and a direct link to details are immediately available.
|
||||
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