TenantAtlas/tests/Unit/Tenants/TenantActionPolicySurfaceTest.php
ahmido 440e63edff feat: implement tenant action taxonomy lifecycle visibility (#174)
## Summary

Implements Spec 145 for tenant action taxonomy and lifecycle-safe visibility.

This PR:
- adds a central tenant action policy surface and supporting value objects
- aligns tenant list, detail, edit, onboarding, and widget surfaces around lifecycle-safe actions
- standardizes operator-facing lifecycle wording around View, Resume onboarding, Archive, Restore, and Complete onboarding
- tightens onboarding and tenant lifecycle authorization semantics, including honest 404 vs 403 behavior
- updates related regression coverage and spec artifacts for Spec 145
- fixes follow-on full-suite regressions uncovered during validation, including onboarding browser flows, provider consent fixtures, workspace redirect DI expectations, and critical table/action/UI expectation drift

## Validation

Executed and passed:
- vendor/bin/sail bin pint --dirty --format agent
- vendor/bin/sail artisan test --compact

Result:
- 2581 passed
- 8 skipped
- 13534 assertions

## Notes

- Base branch: dev
- Feature branch commit: a33a41b
- Filament v5 / Livewire v4 compliance preserved
- No panel provider registration changes; Laravel 12 provider registration remains in bootstrap/providers.php
- No new globally searchable resource behavior added in this slice
- Destructive lifecycle actions remain confirmation-gated and authorization-protected

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #174
2026-03-16 00:57:17 +00:00

170 lines
6.5 KiB
PHP

<?php
declare(strict_types=1);
use App\Filament\Resources\TenantResource;
use App\Models\Tenant;
use App\Services\Tenants\TenantActionPolicySurface;
use App\Support\Tenants\TenantActionSurface;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('does not expose archive as a lifecycle action for draft and onboarding tenants', function (\Closure $tenantFactory): void {
$tenant = $tenantFactory();
expect(app(TenantActionPolicySurface::class)->lifecycleActionForTenant($tenant))
->toBeNull();
})->with([
'draft' => [fn (): Tenant => Tenant::factory()->draft()->create()],
'onboarding' => [fn (): Tenant => Tenant::factory()->onboarding()->create()],
]);
it('returns archive only for active tenants and restore only for archived tenants', function (
\Closure $tenantFactory,
string $expectedKey,
string $expectedLabel,
): void {
$tenant = $tenantFactory();
$descriptor = app(TenantActionPolicySurface::class)->lifecycleActionForTenant($tenant);
expect($descriptor)
->not->toBeNull()
->and($descriptor?->key)->toBe($expectedKey)
->and($descriptor?->label)->toBe($expectedLabel)
->and($descriptor?->requiresConfirmation)->toBeTrue();
})->with([
'active' => [fn (): Tenant => Tenant::factory()->active()->create(), 'archive', 'Archive'],
'archived' => [fn (): Tenant => Tenant::factory()->archived()->create(), 'restore', 'Restore'],
]);
it('returns resume onboarding as the primary action for draft and onboarding tenants with resumable drafts', function (\Closure $tenantFactory): void {
$tenant = $tenantFactory();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
createOnboardingDraft([
'workspace' => $tenant->workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
],
]);
$catalog = tenantActionCatalog($tenant, TenantActionSurface::TenantIndexRow, $user);
expect(tenantActionKeys($catalog))
->toBe(['view', 'related_onboarding'])
->and($catalog[1]->label)->toBe('Resume onboarding');
})->with([
'draft' => [fn (): Tenant => Tenant::factory()->draft()->create()],
'onboarding' => [fn (): Tenant => Tenant::factory()->onboarding()->create()],
]);
it('keeps completed onboarding as a view-only overflow action for active tenants', function (): void {
$tenant = Tenant::factory()->active()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
createOnboardingDraft([
'workspace' => $tenant->workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'status' => 'completed',
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
],
]);
$catalog = tenantActionCatalog($tenant, TenantActionSurface::TenantIndexRow, $user);
expect(tenantActionKeys($catalog))
->toBe(['view', 'archive', 'related_onboarding'])
->and($catalog[2]->label)->toBe('View completed onboarding');
});
it('keeps tenant index catalogs within the two-primary-action overflow contract', function (): void {
$tenant = Tenant::factory()->active()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
createOnboardingDraft([
'workspace' => $tenant->workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'status' => 'completed',
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
],
]);
$catalog = tenantActionCatalog($tenant, TenantActionSurface::TenantIndexRow, $user);
$primaryKeys = collect($catalog)
->filter(static fn ($action): bool => $action->group === 'primary')
->map(static fn ($action): string => $action->key)
->values()
->all();
$overflowKeys = collect($catalog)
->filter(static fn ($action): bool => $action->group === 'overflow')
->map(static fn ($action): string => $action->key)
->values()
->all();
expect($primaryKeys)->toBe(['view', 'archive'])
->and($overflowKeys)->toBe(['related_onboarding'])
->and(TenantResource::actionSurfaceDeclaration()->listRowPrimaryActionLimit())->toBe(2);
});
it('invalidates cached tenant index catalogs when the related onboarding draft lifecycle changes', function (): void {
$tenant = Tenant::factory()->active()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$draft = createOnboardingDraft([
'workspace' => $tenant->workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'status' => 'in_progress',
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
],
]);
$initialCatalog = tenantActionCatalog($tenant, TenantActionSurface::TenantIndexRow, $user);
expect(tenantActionKeys($initialCatalog))->toBe(['view', 'archive', 'related_onboarding'])
->and($initialCatalog[2]->group)->toBe('overflow')
->and($initialCatalog[2]->label)->toBe('View related onboarding');
$draft->forceFill([
'completed_at' => now()->addSecond(),
'lifecycle_state' => 'completed',
'updated_at' => now()->addSecond(),
])->save();
$tenant->refresh();
$updatedCatalog = tenantActionCatalog($tenant, TenantActionSurface::TenantIndexRow, $user);
expect(tenantActionKeys($updatedCatalog))->toBe(['view', 'archive', 'related_onboarding'])
->and($updatedCatalog[2]->group)->toBe('overflow')
->and($updatedCatalog[2]->label)->toBe('View completed onboarding');
});
it('uses workflow-accurate onboarding entry labels', function (int $draftCount, string $expectedLabel): void {
$descriptor = app(TenantActionPolicySurface::class)->onboardingEntryDescriptor($draftCount);
expect($descriptor->label)->toBe($expectedLabel);
})->with([
'no drafts' => [0, 'Add tenant'],
'one draft' => [1, 'Resume onboarding'],
'multiple drafts' => [2, 'Choose onboarding draft'],
]);