Some checks failed
Main Confidence / confidence (push) Failing after 1m45s
## 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
200 lines
8.7 KiB
PHP
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,
|
|
]);
|
|
});
|