diff --git a/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java b/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java index 76507f84..2b41dc00 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 e632dbc9..2fdcd787 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 d1c8090b..30d6837b 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 20fa2a47..ddc93abc 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 00000000..a8d36d11 --- /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(); + } + } + +}