TenantAtlas/apps/platform/tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php
ahmido 7ee4909212
Some checks failed
Main Confidence / confidence (push) Failing after 1m45s
feat: commercial lifecycle overlay for workspace entitlements (#292)
## Summary
- add the bounded workspace commercial lifecycle overlay from spec 251 on top of the existing entitlement substrate
- expose audited commercial state inspection and mutation on the system workspace detail surface
- gate onboarding activation and review-pack start actions through the shared lifecycle decision while preserving suspended read-only access to existing review, evidence, and generated-pack history
- add focused Pest coverage plus the spec/plan/tasks/data-model/contract artifacts for the feature

## Validation
- targeted Pest unit and feature lanes for lifecycle resolution, system-plane mutation, onboarding gating, review-pack enforcement, download preservation, customer review workspace access, and evidence snapshot access
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- integrated browser smoke on the system workspace detail and the preserved read-only review/evidence/review-pack surfaces

## Notes
- branch: `251-commercial-entitlements-billing-state`
- base: `dev`
- commit: `606e9760`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #292
2026-04-28 13:39:33 +00:00

200 lines
8.7 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* @return array{0: Workspace, 1: User}
*/
function commercialLifecycleWorkspaceManager(): array
{
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'manager',
]);
return [$workspace, $user];
}
function commercialLifecyclePlatformOperator(): PlatformUser
{
return PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]);
}
function setCommercialLifecycleState(Workspace $workspace, string $state, string $reason = 'Unit test commercial state change'): void
{
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
actor: commercialLifecyclePlatformOperator(),
workspace: $workspace,
state: $state,
reason: $reason,
);
}
it('falls back to active paid when no explicit commercial lifecycle setting exists', function (): void {
[$workspace] = commercialLifecycleWorkspaceManager();
$summary = app(WorkspaceCommercialLifecycleResolver::class)->summary($workspace);
expect($summary)
->toMatchArray([
'state' => WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID,
'state_label' => 'Active paid',
'source' => WorkspaceCommercialLifecycleResolver::SOURCE_DEFAULT_ACTIVE_PAID,
'source_label' => 'default active paid',
'rationale' => null,
])
->and($summary['last_changed_at'])->toBeNull()
->and($summary['last_changed_by'])->toBeNull()
->and($summary['action_decisions'][WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION]['outcome'])
->toBe(WorkspaceCommercialLifecycleResolver::OUTCOME_ALLOW)
->and($summary['action_decisions'][WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START]['outcome'])
->toBe(WorkspaceCommercialLifecycleResolver::OUTCOME_ALLOW);
});
it('resolves explicit stored commercial lifecycle states with source rationale and platform attribution', function (string $state, string $expectedLabel): void {
[$workspace] = commercialLifecycleWorkspaceManager();
$operator = commercialLifecyclePlatformOperator();
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
actor: $operator,
workspace: $workspace,
state: $state,
reason: 'Support approved commercial lifecycle transition',
);
$summary = app(WorkspaceCommercialLifecycleResolver::class)->summary($workspace);
expect($summary)
->toMatchArray([
'state' => $state,
'state_label' => $expectedLabel,
'source' => WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SETTING,
'source_label' => 'workspace setting',
'rationale' => 'Support approved commercial lifecycle transition',
'last_changed_by' => $operator->name,
])
->and($summary['last_changed_at'])->not->toBeNull();
})->with([
'trial' => [WorkspaceCommercialLifecycleResolver::STATE_TRIAL, 'Trial'],
'grace' => [WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace'],
'active paid' => [WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID, 'Active paid'],
'suspended read-only' => [WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Suspended / read-only'],
]);
it('blocks activation but warns review pack starts during grace', function (): void {
[$workspace] = commercialLifecycleWorkspaceManager();
setCommercialLifecycleState($workspace, WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Payment collection pending');
$resolver = app(WorkspaceCommercialLifecycleResolver::class);
$activation = $resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION);
$reviewPackStart = $resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START);
expect($activation)
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK,
'is_blocked' => true,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
'state' => WorkspaceCommercialLifecycleResolver::STATE_GRACE,
])
->and($activation['block_reason'])->toContain('grace')
->and($reviewPackStart)
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_WARN,
'is_blocked' => false,
'is_warning' => true,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
'state' => WorkspaceCommercialLifecycleResolver::STATE_GRACE,
])
->and($reviewPackStart['warning_reason'])->toContain('grace');
});
it('blocks new starts but allows read-only history during suspended read-only', function (): void {
[$workspace] = commercialLifecycleWorkspaceManager();
setCommercialLifecycleState($workspace, WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Commercial suspension');
$resolver = app(WorkspaceCommercialLifecycleResolver::class);
expect($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION))
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK,
'is_blocked' => true,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
])
->and($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START))
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK,
'is_blocked' => true,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
])
->and($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_EVIDENCE_READ))
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_ALLOW_READ_ONLY,
'is_blocked' => false,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
]);
});
it('preserves entitlement substrate blocks ahead of lifecycle outcomes', function (): void {
[$workspace, $manager] = commercialLifecycleWorkspaceManager();
setCommercialLifecycleState($workspace, WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace should not bypass substrate');
Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ACTIVE,
]);
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $manager,
workspace: $workspace,
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
value: 1,
);
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $manager,
workspace: $workspace,
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
key: WorkspaceEntitlementResolver::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE,
value: false,
);
$resolver = app(WorkspaceCommercialLifecycleResolver::class);
expect($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION))
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_ENTITLEMENT_SUBSTRATE,
])
->and($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START))
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_ENTITLEMENT_SUBSTRATE,
'is_warning' => false,
]);
});