Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 80 additions & 9 deletions java/src/test/java/eu/opendppnode/sdk/OpenDppLiveIT.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
Expand All @@ -21,6 +20,9 @@
import eu.opendppnode.sdk.model.MerkleTreeAttestationProof;
import eu.opendppnode.sdk.model.PublicPassportJsonLd;
import eu.opendppnode.sdk.model.ServiceVersion;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
Expand All @@ -34,6 +36,21 @@
* Opt-in (network + public rate limits): {@code OPENDPP_LIVE_TEST=1 ./gradlew test}. Uses only
* public endpoints (no API key) and a curated, stable demo passport listed in the public sitemap.
* Stays well under the 30 req/min public-resolution limit.
*
* <p>SPEC-TOLERANT BY DESIGN, and it has to be: this file is compiled against the client generated
* from whichever spec is in play, and there are two. This repository's own CI generates from the
* VENDORED {@code openapi.json}, while opendpp-node's "SDK regen" gate generates from its LIVE
* contract — so at any moment the two can be a contract apart. A hand-written test that names a
* generated signature or a generated getter therefore breaks the OTHER lane, which is exactly what
* happened when contract 1.16.0 added the {@code representation} query parameter to the two public
* resolvers and moved the passport body out of a {@code metadata} object onto the document root: this
* file stopped compiling in node's gate and blocked that release, while remaining green here.
*
* <p>So resolver calls go through {@link #resolve} — which finds the method by NAME and supplies
* {@code null} for every parameter after the key, i.e. no grant and the default representation — and
* an assertion about a field only one spec declares is made through {@link #optionalMap}. Both keep
* this file compiling against a client generated from either contract. Anything asserted
* unconditionally below must be true of BOTH.
*/
@EnabledIfEnvironmentVariable(named = "OPENDPP_LIVE_TEST", matches = "1")
class OpenDppLiveIT {
Expand All @@ -42,6 +59,49 @@ class OpenDppLiveIT {

private final ApiClient client = OpenDpp.client();

/**
* Call a generated resolver by name, whatever its arity. The key is the first parameter in every
* contract; everything after it is optional on the wire (a grant token, and since 1.16.0 the
* {@code representation} flag), so passing {@code null} asks for the anonymous tier and the
* default compressed document — the same request the two-argument form used to make.
*/
private PublicPassportJsonLd resolve(PublicResolutionApi api, String name, String key) throws ApiException {
Method m = Arrays.stream(PublicResolutionApi.class.getMethods())
.filter(x -> x.getName().equals(name) && x.getReturnType() == PublicPassportJsonLd.class)
.findFirst()
.orElseThrow(() -> new AssertionError(
"the generated client has no " + name + " returning PublicPassportJsonLd — has the operation been renamed?"));
Object[] args = new Object[m.getParameterCount()];
args[0] = key;
try {
return (PublicPassportJsonLd) m.invoke(api, args);
} catch (InvocationTargetException e) {
// Surface the real failure: the 404 case below asserts on a typed ApiException.
if (e.getCause() instanceof ApiException cause) {
throw cause;
}
if (e.getCause() instanceof RuntimeException cause) {
throw cause;
}
throw new AssertionError(name + " failed", e.getCause());
} catch (ReflectiveOperationException e) {
throw new AssertionError("cannot invoke " + name, e);
}
}

/**
* Read a {@code Map}-valued getter that only SOME contracts declare, without naming it at compile
* time. Returns {@code null} when this client's model has no such property.
*/
private Map<?, ?> optionalMap(Object model, String getter) {
try {
Object value = model.getClass().getMethod(getter).invoke(model);
return value instanceof Map<?, ?> map ? map : null;
} catch (ReflectiveOperationException e) {
return null;
}
}

@Test
void healthAndVersionRoundTrip() throws ApiException {
ServiceApi service = new ServiceApi(client);
Expand All @@ -57,18 +117,29 @@ void healthAndVersionRoundTrip() throws ApiException {

@Test
void resolvesDemoPassportIntoTypedModel() throws ApiException {
PublicPassportJsonLd passport = new PublicResolutionApi(client).resolvePublicPassport(DEMO_PASSPORT_ID, null);
PublicPassportJsonLd passport = resolve(new PublicResolutionApi(client), "resolvePublicPassport", DEMO_PASSPORT_ID);

// Identity + typed fields survived deserialization.
assertEquals(DEMO_PASSPORT_ID, passport.getId(), "id");
assertNotNull(passport.getProductId(), "productId");
assertNotNull(passport.getAtContext(), "@context (relaxed to Object) should still carry the value");
assertNotNull(passport.getCreatedAt(), "createdAt should parse as OffsetDateTime");

// The free-form metadata object deserializes as a non-empty JSON map.
Object metadata = passport.getMetadata();
Map<?, ?> metadataMap = assertInstanceOf(Map.class, metadata, "metadata");
assertFalse(metadataMap.isEmpty(), "demo passport metadata should be non-empty");
// Untyped JSON survives deserialization. This is what the old `getMetadata()` assertion was
// really about, and it is asserted here through a field BOTH contracts declare: `@context` is
// relaxed to Object in the spec, so a structured value arriving intact proves Jackson handled
// an untyped shape rather than flattening it to a string.
assertTrue(passport.getAtContext() instanceof Map || passport.getAtContext() instanceof java.util.List,
"@context should deserialize as a structured untyped value, got " + passport.getAtContext().getClass());

// And where the client's model still declares `metadata` — contracts up to 1.15.0, before the
// body moved onto the document root — the map must be non-empty. Absent on 1.16.0+, and then
// the body's own elements are root members with no generated getter, so there is nothing to
// read here: the untyped-deserialization claim above is the part that holds either way.
Map<?, ?> legacyMetadata = optionalMap(passport, "getMetadata");
if (legacyMetadata != null) {
assertFalse(legacyMetadata.isEmpty(), "demo passport metadata should be non-empty");
}

// Enum tolerance: the live status must parse into a KNOWN constant, not the unknown sentinel.
assertNotNull(passport.getStatus(), "status enum");
Expand All @@ -84,21 +155,21 @@ void resolvesDemoPassportIntoTypedModel() throws ApiException {
@Test
void resolvesTheSamePassportThroughTheGs1Path() throws ApiException {
PublicResolutionApi resolution = new PublicResolutionApi(client);
PublicPassportJsonLd byId = resolution.resolvePublicPassport(DEMO_PASSPORT_ID, null);
PublicPassportJsonLd byId = resolve(resolution, "resolvePublicPassport", DEMO_PASSPORT_ID);

String gtin = byId.getProductId();
assertTrue(gtin != null && gtin.matches("\\d{14}"),
"demo battery passport should be GS1-keyed (14-digit GTIN), got " + gtin);

PublicPassportJsonLd byGtin = resolution.resolveGs1Gtin(gtin, null);
PublicPassportJsonLd byGtin = resolve(resolution, "resolveGs1Gtin", gtin);
assertEquals(byId.getId(), byGtin.getId(),
"GS1 Digital Link resolution should land on the same passport");
}

@Test
void missingPassportSurfacesTypedApiException() {
ApiException e = assertThrows(ApiException.class,
() -> new PublicResolutionApi(client).resolvePublicPassport("definitely-not-a-passport-xyz", null));
() -> resolve(new PublicResolutionApi(client), "resolvePublicPassport", "definitely-not-a-passport-xyz"));
assertEquals(404, e.getCode(), "expected a 404 for a missing passport");
assertNotNull(e.getResponseBody(), "error body should be captured for diagnostics");
}
Expand Down
Loading