diff --git a/appinfo/info.xml b/appinfo/info.xml index 434ab18243..31b6a5c3c0 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata Vrij en open source onder de EUPL-licentie. ]]> - 2.1.35-unstable.20261010120000 + 2.1.35-unstable.20261010220000 EUPL-1.2 Conduction OpenRegister @@ -274,6 +274,7 @@ Vrij en open source onder de EUPL-licentie. and a reversal that has nothing to read. --> OCA\OpenRegister\Repair\ImportMergeOperationRegister OCA\OpenRegister\Repair\ImportDismissedPairRegister + OCA\OpenRegister\Repair\ImportDecisionTableRegister OCA\OpenRegister\Repair\ImportMergeOperationRegister OCA\OpenRegister\Repair\ImportDismissedPairRegister + OCA\OpenRegister\Repair\ImportDecisionTableRegister + +- [x] 1.1 Ship the `decision-tables` register and its import repair step (REQ-SDT-01) +- [x] 1.2 Refuse a stored table the evaluator cannot run, and a duplicate key, at save (REQ-SDT-02) +- [x] 1.3 `DecisionTableStore` resolves by key or uuid and refuses an unknown reference (REQ-SDT-03) +- [x] 1.4 The decision-table step accepts `tableRef`, exactly one of `table` and `tableRef` (REQ-SDT-04) +- [ ] 1.5 Live: import the register on a running instance, store a table, run a flow step by `tableRef` (owed to the live pass) +- [ ] 1.6 Point the decision-table editor at the register (follow-up) diff --git a/tests/Unit/Listener/DecisionTableValidationListenerTest.php b/tests/Unit/Listener/DecisionTableValidationListenerTest.php new file mode 100644 index 0000000000..3b73f7585d --- /dev/null +++ b/tests/Unit/Listener/DecisionTableValidationListenerTest.php @@ -0,0 +1,130 @@ +setSlug($slug); + $schemas = $this->createMock(SchemaMapper::class); + $schemas->method('find')->willReturn($schema); + + $objects = $this->createMock(ObjectService::class); + $objects->method('searchObjectsBySlug')->willReturn($sameKey); + + $registers = $this->createMock(RegisterMapper::class); + if ($registerSlug === null) { + $registers->method('find')->willThrowException(new DoesNotExistException('no register')); + } else { + $register = new Register(); + $register->setSlug($registerSlug); + $registers->method('find')->willReturn($register); + } + + return new DecisionTableValidationListener($schemas, new DecisionTableValidator(), $objects, $registers); + } + + private function row(array $data, string $uuid = 'u-1'): ObjectEntity { + $object = new ObjectEntity(); + $object->setUuid($uuid); + $object->setSchema('7'); + $object->setRegister('3'); + $object->setObject($data); + return $object; + } + + public function testAnExecutableTableIsSaved(): void { + $event = new ObjectCreatingEvent($this->row(DecisionTableStoreTest::shippingTable())); + $this->listener('decision-table')->handle($event); + $this->assertFalse($event->isPropagationStopped()); + } + + public function testATableTheEngineCannotRunIsRefused(): void { + $table = DecisionTableStoreTest::shippingTable(); + $table['rules'][0]['inputEntries'] = []; + $event = new ObjectCreatingEvent($this->row($table)); + $this->listener('decision-table')->handle($event); + $this->assertTrue($event->isPropagationStopped()); + $this->assertSame(DecisionTableValidationListener::ERROR_CODE, $event->getErrors()['code']); + } + + public function testASecondTableUnderTheSameKeyIsRefused(): void { + $other = $this->row(DecisionTableStoreTest::shippingTable(), 'u-2'); + $event = new ObjectCreatingEvent($this->row(DecisionTableStoreTest::shippingTable())); + $this->listener('decision-table', [$other])->handle($event); + $this->assertTrue($event->isPropagationStopped()); + $this->assertStringContainsString('already used by decision table u-2', $event->getErrors()['message']); + } + + public function testUpdatingATableKeepsItsOwnKey(): void { + $self = $this->row(DecisionTableStoreTest::shippingTable()); + $event = new ObjectUpdatingEvent($this->row(DecisionTableStoreTest::shippingTable()), $self); + $this->listener('decision-table', [$self])->handle($event); + $this->assertFalse($event->isPropagationStopped()); + } + + public function testOtherSchemasAreLeftAlone(): void { + $event = new ObjectCreatingEvent($this->row(['anything' => true])); + $this->listener('invoice')->handle($event); + $this->assertFalse($event->isPropagationStopped()); + } + + /** + * A schema of the same slug in another app's register is not OpenRegister's + * table: its rows are left alone (buildiq's own `decision-table` until it moved). + * + * @return void + */ + public function testTheSameSlugInAnotherRegisterIsLeftAlone(): void { + $event = new ObjectCreatingEvent($this->row(['ruleSetId' => 'loan', 'hitPolicy' => 'first'])); + $this->listener('decision-table', [], 'buildiq')->handle($event); + $this->assertFalse($event->isPropagationStopped()); + } + + /** + * A row whose register cannot be resolved is left alone. + * + * @return void + */ + public function testAnUnresolvableRegisterIsLeftAlone(): void { + $event = new ObjectCreatingEvent($this->row(['anything' => true])); + $this->listener('decision-table', [], null)->handle($event); + $this->assertFalse($event->isPropagationStopped()); + } + + /** + * A row without a schema is left alone. + * + * @return void + */ + public function testARowWithoutASchemaIsLeftAlone(): void { + $object = new ObjectEntity(); + $object->setUuid('u-9'); + $object->setObject(['anything' => true]); + $event = new ObjectCreatingEvent($object); + $this->listener('decision-table')->handle($event); + $this->assertFalse($event->isPropagationStopped()); + } +} diff --git a/tests/Unit/Repair/ImportDecisionTableRegisterTest.php b/tests/Unit/Repair/ImportDecisionTableRegisterTest.php new file mode 100644 index 0000000000..b469164c5b --- /dev/null +++ b/tests/Unit/Repair/ImportDecisionTableRegisterTest.php @@ -0,0 +1,168 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * @spec openspec/changes/stored-decision-tables/specs/flow-decision-tables/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Repair; + +use OCA\OpenRegister\Repair\ImportDecisionTableRegister; +use OCA\OpenRegister\Service\ConfigurationService; +use OCP\App\IAppManager; +use OCP\Migration\IOutput; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * Tests for the decision-table register import. + */ +class ImportDecisionTableRegisterTest extends TestCase { + + /** + * @var ConfigurationService&MockObject + */ + private ConfigurationService&MockObject $configurationService; + + /** + * @var IAppManager&MockObject + */ + private IAppManager&MockObject $appManager; + + /** + * @var IOutput&MockObject + */ + private IOutput&MockObject $output; + + /** + * Build the step with mocked collaborators. + * + * @return void + */ + protected function setUp(): void { + $this->configurationService = $this->createMock(ConfigurationService::class); + $this->appManager = $this->createMock(IAppManager::class); + $this->output = $this->createMock(IOutput::class); + + }//end setUp() + + /** + * The step, with the app living at a path. + * + * @param string $appPath Where the app is installed. + * + * @return ImportDecisionTableRegister + */ + private function step(string $appPath): ImportDecisionTableRegister { + $this->appManager->method('getAppPath')->with('openregister')->willReturn($appPath); + + return new ImportDecisionTableRegister( + configurationService: $this->configurationService, + appManager: $this->appManager, + logger: $this->createMock(LoggerInterface::class) + ); + + }//end step() + + /** + * The shipped descriptor is imported as the decision-tables register with + * its `decision-table` schema, under the step's own configuration id. + * + * @return void + */ + public function testTheShippedDescriptorIsImported(): void { + $this->configurationService->expects($this->once()) + ->method('importFromApp') + ->with( + $this->equalTo('openregister.decision-tables'), + $this->callback( + static function (array $data): bool { + return isset($data['components']['registers']['decision-tables']) === true + && ($data['components']['schemas']['decision-table']['slug'] ?? null) === 'decision-table'; + } + ), + $this->equalTo('1.0.0'), + $this->equalTo(false) + ); + $this->output->expects($this->once())->method('info'); + + $step = $this->step(appPath: (string)realpath(__DIR__ . '/../../..')); + $step->run($this->output); + + $this->assertStringContainsString('decision-table', $step->getName()); + + }//end testTheShippedDescriptorIsImported() + + /** + * A missing descriptor is a warning, not an import. + * + * @return void + */ + public function testAMissingDescriptorWarnsAndImportsNothing(): void { + $this->configurationService->expects($this->never())->method('importFromApp'); + $this->output->expects($this->once())->method('warning') + ->with($this->stringContains('not found')); + + $this->step(appPath: sys_get_temp_dir() . '/no-such-openregister-' . getmypid())->run($this->output); + + }//end testAMissingDescriptorWarnsAndImportsNothing() + + /** + * A descriptor that is not JSON is a warning, not an import. + * + * @return void + */ + public function testADescriptorThatIsNotJsonWarns(): void { + $root = sys_get_temp_dir() . '/or-dt-' . getmypid() . '-' . bin2hex(random_bytes(4)); + mkdir($root . '/lib/Settings', 0777, true); + file_put_contents($root . '/lib/Settings/decision_table_register.json', '{not json'); + + $this->configurationService->expects($this->never())->method('importFromApp'); + $this->output->expects($this->once())->method('warning') + ->with($this->stringContains('not valid JSON')); + + try { + $this->step(appPath: $root)->run($this->output); + } finally { + unlink($root . '/lib/Settings/decision_table_register.json'); + rmdir($root . '/lib/Settings'); + rmdir($root . '/lib'); + rmdir($root); + } + + }//end testADescriptorThatIsNotJsonWarns() + + /** + * A failing import is caught and reported, never thrown. + * + * @return void + */ + public function testAFailingImportIsReportedNotThrown(): void { + $this->configurationService->method('importFromApp')->willThrowException(new RuntimeException('database gone')); + $this->output->expects($this->once())->method('warning') + ->with($this->stringContains('database gone')); + + $this->step(appPath: (string)realpath(__DIR__ . '/../../..'))->run($this->output); + + }//end testAFailingImportIsReportedNotThrown() +}//end class diff --git a/tests/Unit/Service/Dmn/DecisionTableStoreTest.php b/tests/Unit/Service/Dmn/DecisionTableStoreTest.php new file mode 100644 index 0000000000..764137ec8d --- /dev/null +++ b/tests/Unit/Service/Dmn/DecisionTableStoreTest.php @@ -0,0 +1,123 @@ + + */ + public static function shippingTable(string $key = 'parcel-carrier', string $carrier = 'freight'): array { + return [ + 'key' => $key, + 'name' => 'Parcel carrier', + 'hitPolicy' => 'FIRST', + 'inputs' => [['name' => 'weight', 'type' => 'number']], + 'outputs' => [['name' => 'carrier', 'type' => 'string']], + 'rules' => [ + ['id' => 'light', 'inputEntries' => ['< 2'], 'outputEntries' => ['letterbox']], + ['id' => 'heavy', 'inputEntries' => ['>= 2'], 'outputEntries' => [$carrier]], + ], + ]; + }//end shippingTable() + + /** + * A store over the given rows. + * + * @param array}> $rows Uuid and data per row. + * @param bool $unreadable Whether the register read fails. + * + * @return DecisionTableStore + */ + private function store(array $rows, bool $unreadable = false): DecisionTableStore { + $objects = $this->createMock(ObjectService::class); + $method = $objects->method('searchObjectsBySlug')->with(DecisionTableStore::REGISTER, DecisionTableStore::SCHEMA); + if ($unreadable === true) { + $method->willThrowException(new RuntimeException('register missing')); + } else { + $entities = []; + foreach ($rows as [$uuid, $data]) { + $entity = new ObjectEntity(); + $entity->setUuid($uuid); + $entity->setObject($data); + $entities[] = $entity; + } + + $method->willReturn($entities); + } + + return new DecisionTableStore($objects, new DecisionTableValidator(), new DecisionTableEvaluator()); + }//end store() + + public function testATableIsFoundByKey(): void { + $store = $this->store([['u-1', self::shippingTable()]]); + $this->assertSame('Parcel carrier', $store->find('parcel-carrier')['name']); + } + + public function testATableIsFoundByUuid(): void { + $store = $this->store([['u-1', self::shippingTable()]]); + $this->assertSame('parcel-carrier', $store->find('u-1')['key']); + } + + public function testAKeyWinsOverAUuidThatHappensToMatch(): void { + $store = $this->store([['parcel-carrier', self::shippingTable('other', 'van')], ['u-2', self::shippingTable()]]); + $this->assertSame('freight', $store->evaluate('parcel-carrier', ['weight' => 5])['outputs']['carrier']); + } + + public function testEvaluateRunsTheStoredTable(): void { + $store = $this->store([['u-1', self::shippingTable()]]); + $result = $store->evaluate('parcel-carrier', ['weight' => 1.5]); + $this->assertSame(['carrier' => 'letterbox'], $result['outputs']); + $this->assertSame(['light'], $result['matchedRuleIds']); + $this->assertSame('FIRST', $result['hitPolicy']); + $this->assertSame('parcel-carrier', $result['tableKey']); + } + + public function testAnUnknownReferenceIsRefusedNotAnsweredEmpty(): void { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('No decision table has the key or id "nope"'); + $this->store([['u-1', self::shippingTable()]])->find('nope'); + } + + public function testAnEmptyReferenceIsRefused(): void { + $this->expectException(UnexpectedValueException::class); + $this->store([])->find(' '); + } + + public function testAStoredTableTheEngineCannotRunIsRefusedOnRead(): void { + $broken = self::shippingTable(); + $broken['hitPolicy'] = 'OUTPUT ORDER'; + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('cannot be run'); + $this->store([['u-1', $broken]])->find('parcel-carrier'); + } + + public function testAnUnreadableRegisterIsAFaultNotANoMatch(): void { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('could not be read'); + $this->store([], unreadable: true)->find('parcel-carrier'); + } +} diff --git a/tests/Unit/Service/Flow/Nodes/DecisionTableNodeStoredTableTest.php b/tests/Unit/Service/Flow/Nodes/DecisionTableNodeStoredTableTest.php new file mode 100644 index 0000000000..186ff2c424 --- /dev/null +++ b/tests/Unit/Service/Flow/Nodes/DecisionTableNodeStoredTableTest.php @@ -0,0 +1,104 @@ +createMock(IL10N::class); + $l10n->method('t')->willReturnCallback( + static fn (string $text, array $params = []): string => vsprintf($text, $params) + ); + $urls = $this->createMock(IURLGenerator::class); + + $entity = new ObjectEntity(); + $entity->setUuid('u-1'); + $entity->setObject(DecisionTableStoreTest::shippingTable()); + $objects = $this->createMock(ObjectService::class); + $objects->method('searchObjectsBySlug')->willReturn([$entity]); + + $store = null; + if ($withStore === true) { + $store = new DecisionTableStore($objects, new DecisionTableValidator(), new DecisionTableEvaluator()); + } + + return new DecisionTableNode( + engine: new DecisionTableEvaluator(), + validator: new DecisionTableValidator(), + l10n: $l10n, + urls: $urls, + store: $store, + ); + } + + public function testAStepDecidesWithAStoredTable(): void { + $out = $this->node()->execute( + items: [FlowItems::item(json: ['parcel' => ['kg' => 7]])], + config: [ + 'tableRef' => 'parcel-carrier', + 'inputMapping' => ['weight' => 'parcel.kg'], + 'resultKey' => 'decision', + ], + context: [] + ); + + $json = $out[0][FlowItems::JSON]; + $this->assertSame('freight', $json['carrier']); + $this->assertSame(['heavy'], $json['decision']['matchedRuleIds']); + $this->assertSame('parcel-carrier', $json['decision']['tableKey']); + } + + public function testAStoredTableIsAcceptedAtSave(): void { + $this->node()->validateConfig(['tableRef' => 'u-1', 'outputMapping' => ['carrier' => 'shipping.carrier']]); + $this->addToAssertionCount(1); + } + + public function testAnUnknownReferenceIsRefusedAtSave(): void { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('No decision table has the key or id "missing"'); + $this->node()->validateConfig(['tableRef' => 'missing']); + } + + public function testAMappingTheStoredTableDoesNotDeclareIsRefused(): void { + $this->expectException(UnexpectedValueException::class); + $this->node()->validateConfig(['tableRef' => 'parcel-carrier', 'inputMapping' => ['volume' => 'parcel.m3']]); + } + + public function testBothAnInlineAndAStoredTableAreRefused(): void { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('not both'); + $this->node()->validateConfig(['tableRef' => 'parcel-carrier', 'table' => DecisionTableStoreTest::shippingTable()]); + } + + public function testNeitherIsRefused(): void { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('needs a table'); + $this->node()->validateConfig([]); + } + + public function testWithoutAStoreAReferenceIsRefusedNotIgnored(): void { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('not available'); + $this->node(withStore: false)->validateConfig(['tableRef' => 'parcel-carrier']); + } +}