From 1bbf727c4aedc539debd7bde225bd6593f8653b0 Mon Sep 17 00:00:00 2001 From: Ashley Caselli Date: Mon, 31 Aug 2026 12:14:50 +0200 Subject: [PATCH] feat(publish): check whether a minted ID has already been used An identifier minted under a fixed prefix -- a new space's IRI, say -- carries no artifact code, so nothing makes it unique: the same form filled with the same name twice yields the same IRI, and the second nanopublication silently extends the first one's resource instead of defining a new one. A nanopublication cannot be edited afterwards, so the collision is worth catching before publishing rather than after. The publish form now asks the query API whether any identifier it minted is already introduced, and refuses to publish (or to build a preview, which the preview page publishes without coming back through the form) naming the one that is taken. Which identifiers those are is knowable only from how the value was built, so TemplateContext records them as it mints them. Four kinds are deliberately left alone: - an IRI typed out in full, which names an existing thing rather than minting one -- "Defining an open-ended Space with existing URI" exists precisely to do that; - one minted under the new nanopublication's own namespace, whose artifact code is substituted at signing time; - one built by an auto-escaping placeholder, where the IRI is derived from the text itself: two people writing the same AIDA sentence are meant to arrive at the same IRI, so an existing one is agreement, not a collision; - supersede and override, where keeping the source's identifier is the point (docs/fill-modes.md). The carve-outs are drawn from the published templates rather than guessed: of the templates combining nt:IntroducedResource with nt:hasPrefix, every one mints a new resource except the AIDA family, which is exactly the auto-escaping kind. A query service that cannot be reached answers "not taken": a check that cannot be made is not evidence of a collision, and publishing should not depend on the query services being up. Closes #646 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K55p8T6uQoSv48C5SXueve --- .../nanodash/QueryApiAccess.java | 26 ++ .../nanodash/component/PublishForm.java | 53 ++++ .../nanodash/template/TemplateContext.java | 46 ++++ .../nanodash/QueryApiAccessTest.java | 37 +++ .../nanodash/template/PrefixMintedIdTest.java | 233 ++++++++++++++++++ 5 files changed, 395 insertions(+) create mode 100644 src/test/java/com/knowledgepixels/nanodash/template/PrefixMintedIdTest.java diff --git a/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java b/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java index 76507f841..2b41dc007 100644 --- a/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java +++ b/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java @@ -421,6 +421,32 @@ public static boolean isNanopubLoaded(String nanopubId) throws FailedApiCallExce return r != null && !r.getData().isEmpty(); } + /** + * Checks whether the given IRI has already been used as the identifier of a resource, + * i.e. whether some nanopublication already introduces it. + *

+ * This is the question an identifier that carries no artifact code raises: nothing makes + * it unique, so the same form filled with the same name twice yields the same IRI, and + * the second nanopublication silently attaches itself to the first one's resource (#646). + * The lookup runs against the meta repository, where {@code npx:introduces} is indexed. + *

+ * A query service that cannot be reached answers false: a check that cannot be made is + * not evidence of a collision, and publishing should not depend on the query services + * being up. + * + * @param uri The IRI to check. + * @return True if a nanopublication introducing the IRI was found. + */ + public static boolean isUriIntroduced(String uri) { + try { + ApiResponse r = get(new QueryRef(GET_INTRODUCING_NANOPUB, "thing", uri)); + return r != null && !r.getData().isEmpty(); + } catch (Exception ex) { + logger.error("Could not check whether IRI '{}' is already introduced", uri, ex); + return false; + } + } + /** * Extracts the query ID from a given query IRI. * diff --git a/src/main/java/com/knowledgepixels/nanodash/component/PublishForm.java b/src/main/java/com/knowledgepixels/nanodash/component/PublishForm.java index e632dbc9c..2fdcd787e 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/PublishForm.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/PublishForm.java @@ -638,6 +638,9 @@ protected void onSubmit() { try { Nanopub np = createNanopub(); logger.info("Nanopublication created: {}", np.getUri()); + if (!areMintedIdsUnused()) { + return; + } TransformContext tc = new TransformContext(SignatureAlgorithm.RSA, NanodashSession.get().getKeyPair(), NanodashSession.get().getUserIri(), false, false, false); signedNp = SignNanopub.signAndTransform(np, tc); logger.info("Nanopublication signed: {}", signedNp.getUri()); @@ -1045,6 +1048,11 @@ public void onSubmit() { } Nanopub np = createNanopub(); + // Checked here too: the preview page publishes the nanopublication it + // was given, without coming back through this form. + if (!areMintedIdsUnused()) { + return; + } TransformContext tc = new TransformContext(SignatureAlgorithm.RSA, NanodashSession.get().getKeyPair(), NanodashSession.get().getUserIri(), false, false, false); Nanopub signedNp = SignNanopub.signAndTransform(np, tc); String previewId = signedNp.getUri().stringValue(); @@ -1180,6 +1188,7 @@ private static void collectTemplateErrors(String part, TemplateContext context, private synchronized Nanopub createNanopub() throws MalformedNanopubException, NanopubAlreadyFinalizedException { assertionContext.getIntroducedIris().clear(); + assertionContext.getPrefixMintedIris().clear(); assertionContext.getRolePropertyPins().clear(); NanopubCreator npCreator = new NanopubCreator(targetNamespace); npCreator.setAssertionUri(vf.createIRI(targetNamespace + "assertion")); @@ -1338,6 +1347,50 @@ private boolean canPublishFromSource(PageParameters pageParams) { return false; } + private boolean areMintedIdsUnused() { + IRI takenId = findTakenMintedId(assertionContext); + if (takenId == null) { + return true; + } + feedbackPanel.error("The identifier " + takenId.stringValue() + + " is already in use. Pick a different one, or use a template for describing" + + " an existing resource if that is what you mean to do."); + return false; + } + + /** + * Returns the first identifier the given assertion context mints under a fixed prefix -- + * the IRI of a new space, say -- that is already in use, or null if all of them are free. + *

+ * An identifier minted under the new nanopublication's own namespace picks up its + * artifact code and is unique by construction; one minted under a prefix the template or + * the space supplies is not, so filling the same form with the same name twice yields the + * same IRI, and the second nanopublication silently extends the first one's resource + * instead of defining a new one. A nanopublication cannot be edited afterwards, so the + * collision is worth catching before publishing rather than after (#646). + *

+ * Not every identifier is checked. An IRI the user typed out in full names an existing + * thing rather than minting one -- templates such as "Defining an open-ended Space with + * existing URI" exist precisely to do that -- and neither superseding nor overriding + * mints anything: keeping the source's identifier is the point of both. + * + * @param assertionContext the assertion context, after its values have been processed + * @return the first minted identifier that is already in use, or null if none is + */ + public static IRI findTakenMintedId(TemplateContext assertionContext) { + FillMode fillMode = assertionContext.getFillMode(); + if (fillMode == FillMode.SUPERSEDE || fillMode == FillMode.OVERRIDE) { + return null; + } + for (IRI mintedIri : assertionContext.getPrefixMintedIris()) { + // Only identifiers the nanopublication declares as newly introduced resources: a + // prefixed field can just as well point at something that already exists. + if (!assertionContext.getIntroducedIris().contains(mintedIri)) continue; + if (QueryApiAccess.isUriIntroduced(mintedIri.stringValue())) return mintedIri; + } + return null; + } + /** * Returns the ID of the nanopublication that the given page parameters ask * to supersede or override, or null if they ask for neither. diff --git a/src/main/java/com/knowledgepixels/nanodash/template/TemplateContext.java b/src/main/java/com/knowledgepixels/nanodash/template/TemplateContext.java index d1c8090b1..30d6837b3 100644 --- a/src/main/java/com/knowledgepixels/nanodash/template/TemplateContext.java +++ b/src/main/java/com/knowledgepixels/nanodash/template/TemplateContext.java @@ -42,6 +42,7 @@ public class TemplateContext implements Serializable { private final Map> componentModels = new HashMap<>(); private Set introducedIris = new HashSet<>(); private Set embeddedIris = new HashSet<>(); + private Set prefixMintedIris = new LinkedHashSet<>(); private Map rolePropertyPins = new LinkedHashMap<>(); private List statementItems; private Set iriSet = new HashSet<>(); @@ -420,6 +421,23 @@ public Set getEmbeddedIris() { return embeddedIris; } + /** + * Returns the IRIs this context minted by putting a name the user typed under a prefix + * the template or the navigation context supplied, rather than under the namespace of + * the nanopublication being created. + *

+ * Such an IRI carries no artifact code, so nothing makes it unique: two people filling + * the same form with the same name arrive at the same identifier. That is what makes it + * worth checking against what has already been published, and the reason this set is + * kept apart from {@link #getIntroducedIris()} (see #646). An IRI the user typed out in + * full is not in here: naming an existing thing is not minting one. + * + * @return a set of IRIs minted under a prefix, in the order they were processed + */ + public Set getPrefixMintedIris() { + return prefixMintedIris; + } + /** * Returns the role-instantiation direction pins collected in this context, mapping * each filled/constant role predicate to its pin class @@ -516,6 +534,7 @@ public Value processValue(Value value) { if (v.matches("[^:# ]+")) v = targetNamespace + v; if (Utils.isUriValue(v)) { processedValue = vf.createIRI(v); + recordIfMintedUnderPrefix(iri, (IRI) processedValue, prefix); } else { processedValue = vf.createLiteral(tfObject); } @@ -549,6 +568,7 @@ public Value processValue(Value value) { if (!unresolvedPrefix) { if (v.matches("[^:# ]+")) v = targetNamespace + v; processedValue = vf.createIRI(v); + recordIfMintedUnderPrefix(iri, (IRI) processedValue, prefix); } } } else if (template.isIntroducedResource(iri) @@ -638,6 +658,32 @@ public Value processValue(Value value) { return processedValue; } + /** + * Records an IRI that was formed by appending what the user typed to a prefix, so that + * the publish form can check it against the identifiers already in use (#646). + *

+ * Three kinds of IRI are deliberately left out: + *

+ * + * @param placeholder the placeholder the value was entered into + * @param mintedIri the IRI that was just formed + * @param prefix the prefix it was formed under, empty if there was none + */ + private void recordIfMintedUnderPrefix(IRI placeholder, IRI mintedIri, String prefix) { + if (prefix == null || prefix.isEmpty()) return; + if (mintedIri.stringValue().startsWith(targetNamespace)) return; + if (template.isAutoEscapePlaceholder(placeholder)) return; + prefixMintedIris.add(mintedIri); + } + /** * Returns the statement items associated with this context. * diff --git a/src/test/java/com/knowledgepixels/nanodash/QueryApiAccessTest.java b/src/test/java/com/knowledgepixels/nanodash/QueryApiAccessTest.java index 20fa2a476..ddc93abc8 100644 --- a/src/test/java/com/knowledgepixels/nanodash/QueryApiAccessTest.java +++ b/src/test/java/com/knowledgepixels/nanodash/QueryApiAccessTest.java @@ -69,4 +69,41 @@ void getQueryNameExtractsQueryNameFromValidIRI() { assertEquals(queryName, result); } + // An identifier minted under a prefix carries no artifact code, so whether it is free has + // to be asked of what has already been published (#646). + @Test + void isUriIntroducedReportsAnIdentifierThatIsAlreadyTaken() { + ApiResponse response = new ApiResponse(); + ApiResponseEntry entry = new ApiResponseEntry(); + entry.add("np", "https://w3id.org/np/RAMVfH7NHhyXFkvbdYYBtimoivyFpQl6CrXgoKgmjGE6I"); + response.getData().add(entry); + + try (MockedStatic mockQueryAccess = mockStatic(QueryAccess.class)) { + mockQueryAccess.when(() -> QueryAccess.get(any(QueryRef.class))).thenReturn(response); + + assertTrue(QueryApiAccess.isUriIntroduced("https://w3id.org/spaces/example/bar")); + } + } + + @Test + void isUriIntroducedReportsAnIdentifierThatIsFree() { + try (MockedStatic mockQueryAccess = mockStatic(QueryAccess.class)) { + mockQueryAccess.when(() -> QueryAccess.get(any(QueryRef.class))).thenReturn(new ApiResponse()); + + assertFalse(QueryApiAccess.isUriIntroduced("https://w3id.org/spaces/example/unused")); + } + } + + // A check that cannot be made is not evidence of a collision: an unreachable query + // service must not be what stops someone from publishing. + @Test + void isUriIntroducedAnswersFalseWhenTheQueryFails() { + try (MockedStatic mockQueryAccess = mockStatic(QueryAccess.class)) { + mockQueryAccess.when(() -> QueryAccess.get(any(QueryRef.class))) + .thenThrow(new APINotReachableException("no instance reachable")); + + assertFalse(QueryApiAccess.isUriIntroduced("https://w3id.org/spaces/example/bar")); + } + } + } \ No newline at end of file diff --git a/src/test/java/com/knowledgepixels/nanodash/template/PrefixMintedIdTest.java b/src/test/java/com/knowledgepixels/nanodash/template/PrefixMintedIdTest.java new file mode 100644 index 000000000..a8d36d117 --- /dev/null +++ b/src/test/java/com/knowledgepixels/nanodash/template/PrefixMintedIdTest.java @@ -0,0 +1,233 @@ +package com.knowledgepixels.nanodash.template; + +import com.knowledgepixels.nanodash.QueryApiAccess; +import com.knowledgepixels.nanodash.WicketApplication; +import com.knowledgepixels.nanodash.component.PublishForm; +import com.knowledgepixels.nanodash.component.PublishForm.FillMode; +import org.apache.wicket.model.Model; +import org.apache.wicket.util.tester.WicketTester; +import org.eclipse.rdf4j.model.IRI; +import org.eclipse.rdf4j.model.Value; +import org.eclipse.rdf4j.model.ValueFactory; +import org.eclipse.rdf4j.model.impl.SimpleValueFactory; +import org.eclipse.rdf4j.model.vocabulary.RDF; +import org.eclipse.rdf4j.model.vocabulary.RDFS; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.nanopub.NanopubCreator; +import org.nanopub.vocabulary.NTEMPLATE; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +/** + * An identifier minted under a prefix the template supplies carries no artifact code, so + * nothing makes it unique and the publish form has to check it against the identifiers + * already in use (#646). {@link TemplateContext#getPrefixMintedIris()} is what tells those + * apart from the identifiers that need no checking: one the user typed out in full (naming an + * existing thing rather than minting a new one), one minted under the new nanopublication's + * own namespace (unique by construction), and one derived from the text itself (meant to be + * arrived at more than once). + */ +class PrefixMintedIdTest { + + private static final ValueFactory vf = SimpleValueFactory.getInstance(); + + private static final String NP_URI = "https://w3id.org/np/RAAbCdEfGhIjKlMnOpQrStUvWxYz0123456789-_AbCdE"; + private static final String TARGET_NAMESPACE = "https://w3id.org/np/~~~ARTIFACTCODE~~~/"; + private static final String SPACE_PREFIX = "https://w3id.org/spaces/"; + private static final String AIDA_PREFIX = "http://purl.org/aida/"; + + // As in "Defining an open-ended Space (with root definition)": an introduced resource + // named below a fixed prefix. + private static final IRI SPACE_FIELD = vf.createIRI(NP_URI + "/space"); + // As in "Introducing a user": an introduced resource the user types out in full. + private static final IRI AGENT_FIELD = vf.createIRI(NP_URI + "/agent"); + // An introduced resource minted under the nanopublication itself. + private static final IRI LOCAL_FIELD = vf.createIRI(NP_URI + "/local"); + // As in the AIDA sentence templates: an introduced resource whose IRI is derived from + // the text itself. + private static final IRI AIDA_FIELD = vf.createIRI(NP_URI + "/aida"); + + private MockedStatic templateDataMockedStatic; + + @BeforeEach + void setUp() { + new WicketTester(new WicketApplication()); + templateDataMockedStatic = mockStatic(TemplateData.class); + } + + @AfterEach + void tearDown() { + templateDataMockedStatic.close(); + } + + /** + * Builds a template that introduces four resources, one per way of arriving at an IRI. + */ + private TemplateContext spaceTemplateContext() throws Exception { + NanopubCreator creator = new NanopubCreator(NP_URI); + creator.addProvenanceStatement(vf.createStatement(creator.getAssertionUri(), RDFS.SEEALSO, creator.getAssertionUri())); + creator.addPubinfoStatement(vf.createStatement(creator.getNanopubUri(), RDFS.SEEALSO, creator.getNanopubUri())); + IRI templateNode = creator.getAssertionUri(); + IRI stSpace = vf.createIRI(NP_URI + "/st1"); + IRI stAgent = vf.createIRI(NP_URI + "/st2"); + IRI stLocal = vf.createIRI(NP_URI + "/st3"); + IRI stAida = vf.createIRI(NP_URI + "/st4"); + creator.addAssertionStatement(templateNode, RDF.TYPE, NTEMPLATE.ASSERTION_TEMPLATE); + creator.addAssertionStatement(templateNode, RDFS.LABEL, vf.createLiteral("Defining a space")); + creator.addAssertionStatement(templateNode, NTEMPLATE.HAS_STATEMENT, stSpace); + creator.addAssertionStatement(templateNode, NTEMPLATE.HAS_STATEMENT, stAgent); + creator.addAssertionStatement(templateNode, NTEMPLATE.HAS_STATEMENT, stLocal); + creator.addAssertionStatement(templateNode, NTEMPLATE.HAS_STATEMENT, stAida); + creator.addAssertionStatement(stSpace, RDF.SUBJECT, SPACE_FIELD); + creator.addAssertionStatement(stSpace, RDF.PREDICATE, RDF.TYPE); + creator.addAssertionStatement(stSpace, RDF.OBJECT, vf.createIRI("https://w3id.org/kpxl/gen/terms/Space")); + creator.addAssertionStatement(stAgent, RDF.SUBJECT, SPACE_FIELD); + creator.addAssertionStatement(stAgent, RDF.PREDICATE, vf.createIRI("https://w3id.org/kpxl/gen/terms/hasAdmin")); + creator.addAssertionStatement(stAgent, RDF.OBJECT, AGENT_FIELD); + creator.addAssertionStatement(stLocal, RDF.SUBJECT, SPACE_FIELD); + creator.addAssertionStatement(stLocal, RDF.PREDICATE, RDFS.SEEALSO); + creator.addAssertionStatement(stLocal, RDF.OBJECT, LOCAL_FIELD); + creator.addAssertionStatement(stAida, RDF.SUBJECT, SPACE_FIELD); + creator.addAssertionStatement(stAida, RDF.PREDICATE, RDFS.COMMENT); + creator.addAssertionStatement(stAida, RDF.OBJECT, AIDA_FIELD); + creator.addAssertionStatement(SPACE_FIELD, RDF.TYPE, NTEMPLATE.EXTERNAL_URI_PLACEHOLDER); + creator.addAssertionStatement(SPACE_FIELD, RDF.TYPE, NTEMPLATE.INTRODUCED_RESOURCE); + creator.addAssertionStatement(SPACE_FIELD, NTEMPLATE.HAS_PREFIX, vf.createLiteral(SPACE_PREFIX)); + creator.addAssertionStatement(SPACE_FIELD, RDFS.LABEL, vf.createLiteral("Space identifier")); + creator.addAssertionStatement(AGENT_FIELD, RDF.TYPE, NTEMPLATE.AGENT_PLACEHOLDER); + creator.addAssertionStatement(AGENT_FIELD, RDF.TYPE, NTEMPLATE.INTRODUCED_RESOURCE); + creator.addAssertionStatement(AGENT_FIELD, RDFS.LABEL, vf.createLiteral("an admin of the space")); + creator.addAssertionStatement(LOCAL_FIELD, RDF.TYPE, NTEMPLATE.URI_PLACEHOLDER); + creator.addAssertionStatement(LOCAL_FIELD, RDF.TYPE, NTEMPLATE.LOCAL_RESOURCE); + creator.addAssertionStatement(LOCAL_FIELD, RDF.TYPE, NTEMPLATE.INTRODUCED_RESOURCE); + creator.addAssertionStatement(LOCAL_FIELD, RDFS.LABEL, vf.createLiteral("short id of the record")); + creator.addAssertionStatement(AIDA_FIELD, RDF.TYPE, NTEMPLATE.AUTO_ESCAPE_URI_PLACEHOLDER); + creator.addAssertionStatement(AIDA_FIELD, RDF.TYPE, NTEMPLATE.INTRODUCED_RESOURCE); + creator.addAssertionStatement(AIDA_FIELD, NTEMPLATE.HAS_PREFIX, vf.createLiteral(AIDA_PREFIX)); + creator.addAssertionStatement(AIDA_FIELD, RDFS.LABEL, vf.createLiteral("the AIDA sentence")); + Template template = TemplateTestUtil.parseTemplate(creator.finalizeNanopub()); + + TemplateData templateDataMock = mock(TemplateData.class); + templateDataMockedStatic.when(TemplateData::get).thenReturn(templateDataMock); + when(templateDataMock.getTemplate(NP_URI)).thenReturn(template); + + TemplateContext context = new TemplateContext(ContextType.ASSERTION, NP_URI, "statement", TARGET_NAMESPACE); + context.initStatements(); + return context; + } + + // The headline case: the user types "my-space" and the template's prefix turns it into a + // full IRI that nothing else guarantees to be free. + @Test + void aNameTypedBelowAPrefixIsMinted() throws Exception { + TemplateContext context = spaceTemplateContext(); + context.getComponentModels().put(SPACE_FIELD, Model.of("my-space")); + Value processed = context.processValue(SPACE_FIELD); + assertEquals(SPACE_PREFIX + "my-space", processed.stringValue()); + assertTrue(context.getPrefixMintedIris().contains(processed), + "an IRI formed from the template's prefix has to be checked for collisions"); + assertTrue(context.getIntroducedIris().contains(processed)); + } + + // "Defining an open-ended Space with existing URI" exists precisely so that an existing + // IRI can be used, so a fully typed-out IRI is a reference, not a mint. + @Test + void anIriTypedOutInFullIsNotMinted() throws Exception { + TemplateContext context = spaceTemplateContext(); + context.getComponentModels().put(SPACE_FIELD, Model.of("https://example.org/spaces/existing")); + Value processed = context.processValue(SPACE_FIELD); + assertEquals("https://example.org/spaces/existing", processed.stringValue()); + assertFalse(context.getPrefixMintedIris().contains(processed), + "naming an existing resource is not minting a new identifier"); + assertTrue(context.getIntroducedIris().contains(processed)); + } + + // The user-introduction case: the ORCID is an introduced resource that is meant to be + // introduced again whenever a new key is declared, so it must not be checked. + @Test + void anAgentIriWithoutAPrefixIsNotMinted() throws Exception { + TemplateContext context = spaceTemplateContext(); + context.getComponentModels().put(AGENT_FIELD, Model.of("https://orcid.org/0000-0002-1267-0234")); + Value processed = context.processValue(AGENT_FIELD); + assertEquals("https://orcid.org/0000-0002-1267-0234", processed.stringValue()); + assertFalse(context.getPrefixMintedIris().contains(processed), + "a placeholder without a prefix mints nothing"); + assertTrue(context.getIntroducedIris().contains(processed)); + } + + // An AIDA sentence IRI is the sentence itself: two people writing the same sentence are + // meant to arrive at the same IRI, so finding it already published is agreement rather + // than a collision. + @Test + void anAutoEscapedIriIsNotMinted() throws Exception { + TemplateContext context = spaceTemplateContext(); + context.getComponentModels().put(AIDA_FIELD, Model.of("The cat sat on the mat.")); + Value processed = context.processValue(AIDA_FIELD); + assertEquals(AIDA_PREFIX + "The+cat+sat+on+the+mat.", processed.stringValue()); + assertFalse(context.getPrefixMintedIris().contains(processed), + "an IRI derived from the text itself is meant to be arrived at more than once"); + assertTrue(context.getIntroducedIris().contains(processed)); + } + + // A local resource picks up this nanopublication's artifact code at signing time, which + // is what makes it unique; there is nothing to check. + @Test + void aResourceMintedUnderTheNanopubIsNotChecked() throws Exception { + TemplateContext context = spaceTemplateContext(); + context.getComponentModels().put(LOCAL_FIELD, Model.of("record")); + Value processed = context.processValue(LOCAL_FIELD); + assertEquals(TARGET_NAMESPACE + "record", processed.stringValue()); + assertFalse(context.getPrefixMintedIris().contains(processed), + "an identifier carrying the nanopublication's artifact code is unique by construction"); + assertTrue(context.getIntroducedIris().contains(processed)); + } + + // What the publish form does with all of the above: refuse the publication and name the + // identifier that is taken. + @Test + void aTakenIdentifierIsReported() throws Exception { + TemplateContext context = spaceTemplateContext(); + context.getComponentModels().put(SPACE_FIELD, Model.of("example/bar")); + IRI minted = (IRI) context.processValue(SPACE_FIELD); + try (MockedStatic q = mockStatic(QueryApiAccess.class)) { + q.when(() -> QueryApiAccess.isUriIntroduced(minted.stringValue())).thenReturn(true); + assertEquals(minted, PublishForm.findTakenMintedId(context)); + } + } + + @Test + void aFreeIdentifierLetsThePublicationThrough() throws Exception { + TemplateContext context = spaceTemplateContext(); + context.getComponentModels().put(SPACE_FIELD, Model.of("nobody-took-this")); + context.processValue(SPACE_FIELD); + try (MockedStatic q = mockStatic(QueryApiAccess.class)) { + q.when(() -> QueryApiAccess.isUriIntroduced(anyString())).thenReturn(false); + assertNull(PublishForm.findTakenMintedId(context)); + } + } + + // Superseding and overriding keep the source's identifier on purpose (docs/fill-modes.md), + // so the identifier being in use is exactly what is expected there. + @Test + void supersedingKeepsTheIdentifierWithoutAsking() throws Exception { + TemplateContext context = spaceTemplateContext(); + context.setFillMode(FillMode.SUPERSEDE); + context.getComponentModels().put(SPACE_FIELD, Model.of("example/bar")); + context.processValue(SPACE_FIELD); + try (MockedStatic q = mockStatic(QueryApiAccess.class)) { + assertNull(PublishForm.findTakenMintedId(context)); + q.verifyNoInteractions(); + } + } + +}