From 73d52d2f64fb7ba33680fed7add4e8bd44fe48e1 Mon Sep 17 00:00:00 2001 From: ehennestad Date: Mon, 31 Aug 2026 11:48:56 +0200 Subject: [PATCH] test: exercise the extension points with a mock external backend The reason the serialization, deserialization and resolution architecture exists is that an external library can integrate a database or format without changing this repository. Nothing verified that end to end. This adds a mock external backend built purely on the extension surface: a serializer producing database records on openminds.abstract .BaseSerializer, a deserializer on openminds.abstract.BaseDeserializer, a store on openminds.interface.MetadataStore and a resolver on openminds.interface.LinkResolver holding its database client as instance state. ExternalIntegrationTest drives it through the five things an external backend has to be able to do: save a collection as records, load the records back into one connected graph, reproduce the canonical JSON-LD document byte for byte after the round trip, resolve a reference whose type only the database knows by replacement, and populate a typed reference in place. If a change breaks these tests, it breaks every external integration, openminds-kg-sync included. The mock classes double as a worked example of the contract. The verification that led to this test found the Collection.save store bug fixed earlier in this stack. Co-Authored-By: Claude Opus 5 --- .../+helper/+mock/MockGraphDatabase.m | 33 +++++ .../+helper/+mock/MockGraphDeserializer.m | 16 +++ .../+helper/+mock/MockGraphMetadataStore.m | 31 +++++ .../+helper/+mock/MockGraphResolver.m | 38 ++++++ .../+helper/+mock/MockGraphSerializer.m | 36 +++++ .../tests/unitTests/ExternalIntegrationTest.m | 127 ++++++++++++++++++ 6 files changed, 281 insertions(+) create mode 100644 tools/tests/+ommtest/+helper/+mock/MockGraphDatabase.m create mode 100644 tools/tests/+ommtest/+helper/+mock/MockGraphDeserializer.m create mode 100644 tools/tests/+ommtest/+helper/+mock/MockGraphMetadataStore.m create mode 100644 tools/tests/+ommtest/+helper/+mock/MockGraphResolver.m create mode 100644 tools/tests/+ommtest/+helper/+mock/MockGraphSerializer.m create mode 100644 tools/tests/unitTests/ExternalIntegrationTest.m diff --git a/tools/tests/+ommtest/+helper/+mock/MockGraphDatabase.m b/tools/tests/+ommtest/+helper/+mock/MockGraphDatabase.m new file mode 100644 index 00000000..f7cde6a7 --- /dev/null +++ b/tools/tests/+ommtest/+helper/+mock/MockGraphDatabase.m @@ -0,0 +1,33 @@ +classdef MockGraphDatabase < handle +% MockGraphDatabase - Stands for an external graph database, in memory +% +% Records are structs with fields Identifier, TypeIRI and Document, +% where Document is the node as JSON text with MATLAB-shim field names. + + properties (Access = private) + Records + end + + methods + function obj = MockGraphDatabase() + obj.Records = containers.Map('KeyType', 'char', 'ValueType', 'any'); + end + + function put(obj, record) + obj.Records(char(record.Identifier)) = record; + end + + function record = get(obj, identifier) + record = obj.Records(char(identifier)); + end + + function records = all(obj) + values = obj.Records.values; + records = [values{:}]; + end + + function n = count(obj) + n = obj.Records.Count; + end + end +end diff --git a/tools/tests/+ommtest/+helper/+mock/MockGraphDeserializer.m b/tools/tests/+ommtest/+helper/+mock/MockGraphDeserializer.m new file mode 100644 index 00000000..47cc2a6d --- /dev/null +++ b/tools/tests/+ommtest/+helper/+mock/MockGraphDeserializer.m @@ -0,0 +1,16 @@ +classdef MockGraphDeserializer < openminds.abstract.BaseDeserializer +% MockGraphDeserializer - Reads instances back from mock database records +% +% Only the record parsing lives here. Type dispatch, instance +% construction and cross-record link wiring come from +% openminds.abstract.BaseDeserializer. + + methods (Access = protected) + function rawStructs = parseToStructs(~, records) + rawStructs = cell(1, numel(records)); + for i = 1:numel(records) + rawStructs{i} = jsondecode(char(records(i).Document)); + end + end + end +end diff --git a/tools/tests/+ommtest/+helper/+mock/MockGraphMetadataStore.m b/tools/tests/+ommtest/+helper/+mock/MockGraphMetadataStore.m new file mode 100644 index 00000000..6eb4d9d0 --- /dev/null +++ b/tools/tests/+ommtest/+helper/+mock/MockGraphMetadataStore.m @@ -0,0 +1,31 @@ +classdef MockGraphMetadataStore < openminds.interface.MetadataStore +% MockGraphMetadataStore - Metadata store backed by the mock graph database + + properties (SetAccess = immutable) + Database ommtest.helper.mock.MockGraphDatabase + end + + methods + function obj = MockGraphMetadataStore(database) + obj = obj@openminds.interface.MetadataStore(); + obj.Database = database; + obj.Serializer = ommtest.helper.mock.MockGraphSerializer(); + end + + function identifiers = save(obj, instances, ~) + if isa(instances, 'openminds.Collection') + instances = instances.getAll(); + end + records = obj.Serializer.serialize(instances); + for i = 1:numel(records) + obj.Database.put(records(i)); + end + identifiers = [records.Identifier]; + end + + function instances = load(obj, ~) + deserializer = ommtest.helper.mock.MockGraphDeserializer(); + instances = deserializer.deserialize(obj.Database.all()); + end + end +end diff --git a/tools/tests/+ommtest/+helper/+mock/MockGraphResolver.m b/tools/tests/+ommtest/+helper/+mock/MockGraphResolver.m new file mode 100644 index 00000000..6dc2e1bf --- /dev/null +++ b/tools/tests/+ommtest/+helper/+mock/MockGraphResolver.m @@ -0,0 +1,38 @@ +classdef MockGraphResolver < openminds.interface.LinkResolver +% MockGraphResolver - Resolves references against the mock graph database +% +% Holds the database client as instance state, which the instance-method +% resolver contract exists for. Populates a reference in place when its +% type is known, and builds an instance of the recorded type when not. + + properties (Constant) + IRIPrefix = "https://graph.example/instances/" + end + + properties (SetAccess = immutable) + Database ommtest.helper.mock.MockGraphDatabase + end + + methods + function obj = MockGraphResolver(database) + obj.Database = database; + end + + function instance = resolveNode(obj, instance) + record = obj.Database.get(instance.id); + data = jsondecode(char(record.Document)); + + if isa(instance, 'openminds.internal.MixedTypeReference') + % The type was unknown until now: build the recorded type + typeEnum = openminds.enum.Types.fromAtType(record.TypeIRI); + instance = feval(typeEnum.ClassName, data); + else + instance.fromStruct(data); + end + end + + function tf = canResolve(obj, IRI) + tf = all(startsWith(IRI, obj.IRIPrefix)); + end + end +end diff --git a/tools/tests/+ommtest/+helper/+mock/MockGraphSerializer.m b/tools/tests/+ommtest/+helper/+mock/MockGraphSerializer.m new file mode 100644 index 00000000..c8ef9b8c --- /dev/null +++ b/tools/tests/+ommtest/+helper/+mock/MockGraphSerializer.m @@ -0,0 +1,36 @@ +classdef MockGraphSerializer < openminds.abstract.BaseSerializer +% MockGraphSerializer - Serializes instances into mock database records +% +% A non-JSON-LD output format built on the shared serialization core: +% graph traversal, reference handling and embedding come from +% openminds.abstract.BaseSerializer; only the record format is here. + + properties (Constant) + DefaultFileExtension = ".mockrecord" + end + + methods + function obj = MockGraphSerializer(config) + arguments + config.?openminds.internal.serializer.SerializationConfig + end + config.OutputMode = "multiple"; % one record per node + nvPairs = namedargs2cell(config); + obj = obj@openminds.abstract.BaseSerializer(nvPairs{:}); + end + end + + methods (Access = protected) + function records = formatOutput(~, processedStructs) + records = cell(1, numel(processedStructs)); + for i = 1:numel(processedStructs) + S = processedStructs{i}; + records{i} = struct( ... + 'Identifier', string(S.at_id), ... + 'TypeIRI', string(S.at_type), ... + 'Document', string(jsonencode(S))); + end + records = [records{:}]; + end + end +end diff --git a/tools/tests/unitTests/ExternalIntegrationTest.m b/tools/tests/unitTests/ExternalIntegrationTest.m new file mode 100644 index 00000000..c705596f --- /dev/null +++ b/tools/tests/unitTests/ExternalIntegrationTest.m @@ -0,0 +1,127 @@ +classdef ExternalIntegrationTest < matlab.unittest.TestCase +% ExternalIntegrationTest - An external backend built only on the extension points +% +% Exercises the contract that lets a library integrate a database or +% format without changing openMINDS_MATLAB: a mock graph database with +% its own serializer (openminds.abstract.BaseSerializer), deserializer +% (openminds.abstract.BaseDeserializer), store +% (openminds.interface.MetadataStore) and resolver +% (openminds.interface.LinkResolver). If a change here breaks these +% tests, it breaks every external integration, openminds-kg-sync +% included. + + properties (Access = private) + Database ommtest.helper.mock.MockGraphDatabase + Store ommtest.helper.mock.MockGraphMetadataStore + end + + methods (TestMethodSetup) + function createDatabase(testCase) + testCase.Database = ommtest.helper.mock.MockGraphDatabase(); + testCase.Store = ommtest.helper.mock.MockGraphMetadataStore(testCase.Database); + end + + function resetResolverRegistry(testCase) + registry = openminds.internal.resolver.LinkResolverRegistry.instance(); + registry.reset() + testCase.addTeardown(@() registry.reset()) + end + end + + methods (Test) + function testSaveThroughCustomSerializer(testCase) + % Every node of the collection becomes one record in the database. + + collection = ommtest.helper.buildFixtureCollection(); + testCase.Store.save(collection); + + testCase.verifyEqual(double(testCase.Database.count()), 7) + end + + function testLoadRebuildsTheConnectedGraph(testCase) + % Loading through the custom deserializer must reproduce values, + % wire links between records, and preserve embedded values. + + testCase.Store.save(ommtest.helper.buildFixtureCollection()); + + reloaded = openminds.Collection('MetadataStore', testCase.Store); + testCase.assertEqual(double(reloaded.length()), 7) + + person = reloaded.list(openminds.enum.Types("Person")); + testCase.verifyEqual(person.givenName, "Ada") + testCase.verifyEqual(person.contactInformation.email, "ada@example.org", ... + 'The linked contact record was not wired to the person.') + + subject = reloaded.list(openminds.enum.Types("Subject")); + testCase.verifyEqual(string(subject.species.id), ... + "https://openminds.om-i.org/instances/species/homoSapiens") + + state = subject.studiedState; + specimenAge = state.age; + quantity = specimenAge.age; + testCase.verifyEqual(quantity.value, 42, ... + 'The embedded quantitative value did not survive the round trip.') + end + + function testRoundTripIsCanonicallyEquivalent(testCase) + % The graph reloaded from the database serializes to exactly the + % same canonical JSON-LD document as the original graph. + + original = ommtest.helper.buildFixtureCollection(); + testCase.Store.save(original); + reloaded = openminds.Collection('MetadataStore', testCase.Store); + + serializer = openminds.internal.serializer.JsonLdSerializer('OutputMode', 'single'); + testCase.verifyEqual( ... + serializer.serialize(reloaded.getAll()), ... + serializer.serialize(original.getAll())) + end + + function testUnknownTypeReferenceResolvesByReplacement(testCase) + % A reference whose type is only known to the database resolves to + % an instance of the recorded type through the registered resolver. + + identifier = openminds.core.digitalidentifier.GenericIdentifier( ... + 'id', "https://graph.example/instances/generic-001"); + identifier.identifier = "external-thing"; + identifier.type = "mock"; + testCase.Store.save(identifier); + + openminds.registerLinkResolver( ... + ommtest.helper.mock.MockGraphResolver(testCase.Database)); + + dataset = openminds.core.Dataset(); + dataset.fullName = "D"; + dataset.digitalIdentifier = openminds.internal.MixedTypeReference( ... + "https://graph.example/instances/generic-001"); + + dataset.resolve('NumLinksToResolve', 1); + + resolved = dataset.digitalIdentifier; + testCase.verifyClass(resolved, 'openminds.core.digitalidentifier.GenericIdentifier') + testCase.verifyEqual(resolved.identifier, "external-thing") + end + + function testTypedReferenceResolvesInPlace(testCase) + % A reference whose type is already known is populated in place. + + document = struct( ... + 'at_id', "https://graph.example/instances/person-002", ... + 'at_type', "https://openminds.om-i.org/types/Person", ... + 'givenName', "Grace"); + testCase.Database.put(struct( ... + 'Identifier', "https://graph.example/instances/person-002", ... + 'TypeIRI', "https://openminds.om-i.org/types/Person", ... + 'Document', string(jsonencode(document)))); + + openminds.registerLinkResolver( ... + ommtest.helper.mock.MockGraphResolver(testCase.Database)); + + personStub = openminds.core.Person( ... + 'id', "https://graph.example/instances/person-002"); + personStub.resolve(); + + testCase.verifyEqual(personStub.givenName, "Grace") + end + end +end