diff --git a/resources/v2-coverage-contract.json b/resources/v2-coverage-contract.json index 9a77392c..58bc014e 100644 --- a/resources/v2-coverage-contract.json +++ b/resources/v2-coverage-contract.json @@ -12,11 +12,11 @@ } }, "ratchet": { - "accepted_baseline_basis_points": 8551, + "accepted_baseline_basis_points": 8553, "target_basis_points": 10000 }, "provenance": { "observed_at": "2026-09-01", - "evidence": "Public full qualification run 33520656271 measured 51,604 of 60,342 executable lines (85.51%) at commit 125078474405030d161bd0b287d71339a2d6c0fa by unioning one unit report with all four MySQL feature-shard reports across the complete production source inventory." + "evidence": "Public full qualification run 33524718088 measured 51,616 of 60,342 executable lines (85.53%) at commit 470c2815e30af9ce9125bc4f89b321cc8970de7e by unioning one unit report with all four MySQL feature-shard reports across the complete production source inventory." } } diff --git a/tests/Unit/V2/WorkflowDefinitionContractTest.php b/tests/Unit/V2/WorkflowDefinitionContractTest.php new file mode 100644 index 00000000..40b0ee1b --- /dev/null +++ b/tests/Unit/V2/WorkflowDefinitionContractTest.php @@ -0,0 +1,391 @@ +assertSame(['approval-stage', 'approvalMatches'], $contract['queries']); + $this->assertSame(['approved-by', 'rejected-by'], $contract['signals']); + $this->assertSame(['mark-approved'], $contract['updates']); + $this->assertSame('handle', $contract['entry_method']); + $this->assertSame('canonical', $contract['entry_mode']); + $this->assertSame(TestCommandTargetWorkflow::class, $contract['entry_declaring_class']); + + $approvalQuery = [ + 'name' => 'approval-stage', + 'parameters' => [], + ]; + $matchingQuery = [ + 'name' => 'approvalMatches', + 'parameters' => [[ + 'name' => 'stage', + 'position' => 0, + 'required' => true, + 'variadic' => false, + 'default_available' => false, + 'default' => null, + 'type' => 'string', + 'allows_null' => false, + ]], + ]; + + $this->assertSame([$approvalQuery, $matchingQuery], $contract['query_contracts']); + $this->assertSame($approvalQuery, WorkflowDefinition::queryContract( + TestCommandTargetWorkflow::class, + 'approval-stage', + )); + $this->assertSame($approvalQuery, WorkflowDefinition::queryContract( + TestCommandTargetWorkflow::class, + 'approvalStage', + )); + $this->assertSame([ + 'name' => 'approval-stage', + 'method' => 'approvalStage', + ], WorkflowDefinition::resolveQueryTarget(TestCommandTargetWorkflow::class, 'approval-stage')); + $this->assertSame([ + 'name' => 'approval-stage', + 'method' => 'approvalStage', + ], WorkflowDefinition::resolveQueryTarget(TestCommandTargetWorkflow::class, 'approvalStage')); + $this->assertNull(WorkflowDefinition::queryContract(TestCommandTargetWorkflow::class, 'missing')); + $this->assertTrue(WorkflowDefinition::hasQueryMethod(TestCommandTargetWorkflow::class, 'approvalStage')); + $this->assertFalse(WorkflowDefinition::hasQueryMethod(TestCommandTargetWorkflow::class, 'missing')); + + $signalContract = [ + 'name' => 'approved-by', + 'parameters' => [[ + 'name' => 'actor', + 'position' => 0, + 'required' => true, + 'variadic' => false, + 'default_available' => false, + 'default' => null, + 'type' => 'string', + 'allows_null' => true, + ]], + ]; + + $this->assertSame([$signalContract], $contract['signal_contracts']); + $this->assertSame( + $signalContract, + WorkflowDefinition::signalContract(TestCommandTargetWorkflow::class, 'approved-by'), + ); + $this->assertNull(WorkflowDefinition::signalContract(TestCommandTargetWorkflow::class, 'missing')); + $this->assertTrue(WorkflowDefinition::hasSignal(TestCommandTargetWorkflow::class, 'rejected-by')); + $this->assertFalse(WorkflowDefinition::hasSignal(TestCommandTargetWorkflow::class, 'missing')); + + $updateContract = [ + 'name' => 'mark-approved', + 'parameters' => [[ + 'name' => 'approved', + 'position' => 0, + 'required' => true, + 'variadic' => false, + 'default_available' => false, + 'default' => null, + 'type' => 'bool', + 'allows_null' => false, + ]], + ]; + + $this->assertSame([$updateContract], $contract['update_contracts']); + $this->assertSame($updateContract, WorkflowDefinition::updateContract( + TestCommandTargetWorkflow::class, + 'mark-approved', + )); + $this->assertSame($updateContract, WorkflowDefinition::updateContract( + TestCommandTargetWorkflow::class, + 'approve', + )); + $this->assertSame([ + 'name' => 'mark-approved', + 'method' => 'approve', + ], WorkflowDefinition::resolveUpdateTarget(TestCommandTargetWorkflow::class, 'mark-approved')); + $this->assertSame([ + 'name' => 'mark-approved', + 'method' => 'approve', + ], WorkflowDefinition::resolveUpdateTarget(TestCommandTargetWorkflow::class, 'approve')); + $this->assertNull(WorkflowDefinition::updateContract(TestCommandTargetWorkflow::class, 'missing')); + $this->assertTrue(WorkflowDefinition::hasUpdateMethod(TestCommandTargetWorkflow::class, 'approve')); + $this->assertFalse(WorkflowDefinition::hasUpdateMethod(TestCommandTargetWorkflow::class, 'missing')); + } + + public function testItTreatsNonWorkflowClassesAsHavingNoDurableContract(): void + { + $class = stdClass::class; + + $this->assertSame([], WorkflowDefinition::queryMethods($class)); + $this->assertSame([], WorkflowDefinition::queryContracts($class)); + $this->assertNull(WorkflowDefinition::queryContract($class, 'query')); + $this->assertNull(WorkflowDefinition::resolveQueryTarget($class, 'query')); + $this->assertSame([], WorkflowDefinition::signalNames($class)); + $this->assertSame([], WorkflowDefinition::signalContracts($class)); + $this->assertNull(WorkflowDefinition::signalContract($class, 'signal')); + $this->assertSame([], WorkflowDefinition::updateMethods($class)); + $this->assertSame([], WorkflowDefinition::updateContracts($class)); + $this->assertNull(WorkflowDefinition::updateContract($class, 'update')); + $this->assertNull(WorkflowDefinition::resolveUpdateTarget($class, 'update')); + $this->assertFalse(WorkflowDefinition::hasQueryMethod($class, 'query')); + $this->assertFalse(WorkflowDefinition::hasSignal($class, 'signal')); + $this->assertFalse(WorkflowDefinition::hasUpdateMethod($class, 'update')); + $this->assertNull(WorkflowDefinition::fingerprint($class)); + + WorkflowDefinition::assertWorkflowTypeRegistration('', TestCommandTargetWorkflow::class); + WorkflowDefinition::assertWorkflowTypeRegistration('invalid-definition', $class); + + $this->assertTrue(true); + } + + public function testCommandContractRejectsNonWorkflowClasses(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('must extend'); + + WorkflowDefinition::commandContract(stdClass::class); + } + + public function testItIndexesFingerprintsAndRejectsConflictingTypeRegistrations(): void + { + $fingerprint = WorkflowDefinition::fingerprint(TestCommandTargetWorkflow::class); + + $this->assertNotNull($fingerprint); + $this->assertSame( + TestCommandTargetWorkflow::class, + WorkflowDefinition::findClassByFingerprint($fingerprint), + ); + $this->assertNull(WorkflowDefinition::findClassByFingerprint('sha256:missing')); + + WorkflowDefinition::assertWorkflowTypeRegistration( + 'coverage.workflow-definition', + TestCommandTargetWorkflow::class, + ); + WorkflowDefinition::assertWorkflowTypeRegistration( + 'coverage.workflow-definition', + TestCommandTargetWorkflow::class, + ); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('cannot be re-registered'); + + WorkflowDefinition::assertWorkflowTypeRegistration( + 'coverage.workflow-definition', + AlternateWorkflowDefinition::class, + ); + } + + public function testItExcludesContainerInjectedParametersFromPortableContracts(): void + { + $this->assertSame([[ + 'name' => 'inspect', + 'parameters' => [ + [ + 'name' => 'required', + 'position' => 0, + 'required' => true, + 'variadic' => false, + 'default_available' => false, + 'default' => null, + 'type' => 'string', + 'allows_null' => false, + ], + [ + 'name' => 'optional', + 'position' => 1, + 'required' => false, + 'variadic' => false, + 'default_available' => true, + 'default' => null, + 'type' => '?int', + 'allows_null' => true, + ], + [ + 'name' => 'tags', + 'position' => 2, + 'required' => false, + 'variadic' => true, + 'default_available' => false, + 'default' => null, + 'type' => 'string', + 'allows_null' => false, + ], + ], + ]], WorkflowDefinition::queryContracts(InjectedParameterWorkflowDefinition::class)); + + $this->assertSame([[ + 'name' => 'change', + 'parameters' => [ + [ + 'name' => 'approved', + 'position' => 0, + 'required' => true, + 'variadic' => false, + 'default_available' => false, + 'default' => null, + 'type' => 'bool', + 'allows_null' => false, + ], + [ + 'name' => 'count', + 'position' => 1, + 'required' => false, + 'variadic' => false, + 'default_available' => true, + 'default' => 2, + 'type' => 'int', + 'allows_null' => false, + ], + ], + ]], WorkflowDefinition::updateContracts(InjectedParameterWorkflowDefinition::class)); + } + + public function testItRejectsDuplicateDurableSignalNames(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('duplicate durable signal name [duplicate]'); + + WorkflowDefinition::signalNames(DuplicateSignalWorkflowDefinition::class); + } + + public function testSignalContractsRejectDuplicateDurableSignalNames(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('duplicate durable signal name [duplicate]'); + + WorkflowDefinition::signalContracts(DuplicateSignalWorkflowDefinition::class); + } + + public function testItRejectsDuplicateDurableQueryNames(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('duplicate durable query name [duplicate]'); + + WorkflowDefinition::queryMethods(DuplicateQueryWorkflowDefinition::class); + } + + public function testItRejectsDuplicateDurableUpdateNames(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('duplicate durable update name [duplicate]'); + + WorkflowDefinition::updateMethods(DuplicateUpdateWorkflowDefinition::class); + } + + public function testFingerprintsIncludeTraitAndInheritedWorkflowSources(): void + { + $baseFingerprint = WorkflowDefinition::fingerprint(TraitedWorkflowDefinition::class); + $childFingerprint = WorkflowDefinition::fingerprint(InheritedWorkflowDefinition::class); + + $this->assertNotNull($baseFingerprint); + $this->assertNotNull($childFingerprint); + $this->assertNotSame($baseFingerprint, $childFingerprint); + } +} + +final class AlternateWorkflowDefinition extends Workflow +{ + public function handle(): string + { + return 'alternate'; + } +} + +final class InjectedParameterWorkflowDefinition extends Workflow +{ + public function handle(): void + { + } + + #[QueryMethod('inspect')] + public function inspect(stdClass $dependency, string $required, ?int $optional = null, string ...$tags): void + { + } + + #[UpdateMethod('change')] + public function change(stdClass $dependency, bool $approved, int $count = 2): void + { + } +} + +#[Signal('duplicate')] +#[Signal('duplicate')] +final class DuplicateSignalWorkflowDefinition extends Workflow +{ + public function handle(): void + { + } +} + +final class DuplicateQueryWorkflowDefinition extends Workflow +{ + public function handle(): void + { + } + + #[QueryMethod('duplicate')] + public function firstQuery(): void + { + } + + #[QueryMethod('duplicate')] + public function secondQuery(): void + { + } +} + +final class DuplicateUpdateWorkflowDefinition extends Workflow +{ + public function handle(): void + { + } + + #[UpdateMethod('duplicate')] + public function firstUpdate(): void + { + } + + #[UpdateMethod('duplicate')] + public function secondUpdate(): void + { + } +} + +trait WorkflowDefinitionFingerprintTrait +{ + public function sharedBehavior(): string + { + return 'shared'; + } +} + +class TraitedWorkflowDefinition extends Workflow +{ + use WorkflowDefinitionFingerprintTrait; + + public function handle(): string + { + return $this->sharedBehavior(); + } +} + +final class InheritedWorkflowDefinition extends TraitedWorkflowDefinition +{ + public function handle(): string + { + return parent::handle() . '-child'; + } +} diff --git a/tests/Unit/V2/WorkflowQueryContractTest.php b/tests/Unit/V2/WorkflowQueryContractTest.php index 60bb280e..e9d7c201 100644 --- a/tests/Unit/V2/WorkflowQueryContractTest.php +++ b/tests/Unit/V2/WorkflowQueryContractTest.php @@ -263,6 +263,11 @@ public function testItSupportsDurableParametersWithoutCompleteTypeMetadata(): vo 'type' => 'int|mixed', 'required' => true, ], + [ + 'name' => 'non_string_type', + 'type' => ['string'], + 'required' => true, + ], ]); $result = WorkflowQueryContract::validatedArgumentsForRun($run, 'typed-query', [ @@ -272,10 +277,11 @@ public function testItSupportsDurableParametersWithoutCompleteTypeMetadata(): vo 'nullable_union' => null, 'explicit_nullable' => null, 'mixed_union' => [], + 'non_string_type' => null, ]); $this->assertSame([], $result['validation_errors']); - $this->assertCount(6, $result['arguments']); + $this->assertCount(7, $result['arguments']); } public function testItUsesTheGenericArgumentNameForMalformedDurableParameters(): void