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']);
+ }
+}