diff --git a/lib-cloudinfo/src/main/java/io/seqera/cloudinfo/api/ProductsQuery.java b/lib-cloudinfo/src/main/java/io/seqera/cloudinfo/api/ProductsQuery.java index 897b893..5580d7e 100644 --- a/lib-cloudinfo/src/main/java/io/seqera/cloudinfo/api/ProductsQuery.java +++ b/lib-cloudinfo/src/main/java/io/seqera/cloudinfo/api/ProductsQuery.java @@ -17,12 +17,21 @@ package io.seqera.cloudinfo.api; +import java.util.Objects; + /** * Optional query parameters for the Cloudinfo products endpoint. * *

Each flag is applied server-side; unset flags (the default) leave the * corresponding filter disabled and all products are returned. * + *

Implements value-based {@link #equals(Object)} and {@link #hashCode()} so + * instances can be used as map keys / set members and so consumers can rely on + * a deterministic hash for caching. When new filter fields are added, update + * both methods to include them — {@code ProductsQueryTest} guards this with a + * reflection-based check that fails if any declared field is missing from the + * hash. + * *

Usage example: *

{@code
  * ProductsQuery query = ProductsQuery.builder()
@@ -61,6 +70,24 @@ public boolean isNvme() {
         return nvme;
     }
 
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (!(o instanceof ProductsQuery)) return false;
+        ProductsQuery that = (ProductsQuery) o;
+        return sched == that.sched && nvme == that.nvme;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(sched, nvme);
+    }
+
+    @Override
+    public String toString() {
+        return "ProductsQuery{sched=" + sched + ", nvme=" + nvme + '}';
+    }
+
     /**
      * Creates a new Builder instance for constructing ProductsQuery objects.
      *
diff --git a/lib-cloudinfo/src/test/groovy/io/seqera/cloudinfo/api/ProductsQueryTest.groovy b/lib-cloudinfo/src/test/groovy/io/seqera/cloudinfo/api/ProductsQueryTest.groovy
new file mode 100644
index 0000000..5e9f41a
--- /dev/null
+++ b/lib-cloudinfo/src/test/groovy/io/seqera/cloudinfo/api/ProductsQueryTest.groovy
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2026, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package io.seqera.cloudinfo.api
+
+import spock.lang.Specification
+
+import java.lang.reflect.Field
+import java.lang.reflect.Modifier
+
+class ProductsQueryTest extends Specification {
+
+    def 'equals returns true for instances with identical field values'() {
+        expect:
+        ProductsQuery.builder().sched(true).nvme(true).build() ==
+                ProductsQuery.builder().sched(true).nvme(true).build()
+    }
+
+    def 'equals returns false when sched differs'() {
+        expect:
+        ProductsQuery.builder().sched(true).build() !=
+                ProductsQuery.builder().sched(false).build()
+    }
+
+    def 'equals returns false when nvme differs'() {
+        expect:
+        ProductsQuery.builder().nvme(true).build() !=
+                ProductsQuery.builder().nvme(false).build()
+    }
+
+    def 'hashCode is consistent with equals'() {
+        given:
+        def a = ProductsQuery.builder().sched(true).nvme(false).build()
+        def b = ProductsQuery.builder().sched(true).nvme(false).build()
+
+        expect:
+        a == b
+        a.hashCode() == b.hashCode()
+    }
+
+    /**
+     * Safety-net guard: any declared instance field on ProductsQuery must
+     * influence hashCode. If a future field is added to ProductsQuery without
+     * being included in hashCode, this test fails for that field. Avoids
+     * silent caching bugs in consumers that key off ProductsQuery.hashCode.
+     */
+    def 'every declared instance field on ProductsQuery affects hashCode'() {
+        given:
+        def declaredFields = ProductsQuery.getDeclaredFields()
+                .findAll { Field f ->
+                    !Modifier.isStatic(f.modifiers) && !f.synthetic
+                }
+
+        expect: 'at least one field exists (sanity)'
+        !declaredFields.isEmpty()
+
+        and: 'each field, when flipped from default, produces a different hashCode'
+        declaredFields.each { Field f ->
+            f.accessible = true
+            def defaults = ProductsQuery.builder().build()
+            def flipped = ProductsQuery.builder().build()
+            // Mutate the flipped instance via reflection to a non-default value of the field's type
+            def flippedValue = nonDefaultValueFor(f.type, f.get(defaults))
+            f.set(flipped, flippedValue)
+            assert defaults.hashCode() != flipped.hashCode(),
+                    "Field '${f.name}' on ProductsQuery does not contribute to hashCode. " +
+                    "Update ProductsQuery.hashCode (and equals) to include this field."
+            assert defaults != flipped,
+                    "Field '${f.name}' on ProductsQuery does not contribute to equals. " +
+                    "Update ProductsQuery.equals to include this field."
+        }
+    }
+
+    private static Object nonDefaultValueFor(Class type, Object currentValue) {
+        if (type == boolean.class || type == Boolean) {
+            return !((Boolean) currentValue)
+        }
+        if (type == int.class || type == Integer) {
+            return (currentValue == null ? 0 : (int) currentValue) + 1
+        }
+        if (type == long.class || type == Long) {
+            return (currentValue == null ? 0L : (long) currentValue) + 1L
+        }
+        if (type == String) {
+            return currentValue == null ? 'X' : currentValue + 'X'
+        }
+        // Extend as ProductsQuery grows. Fail loudly if a new type appears.
+        throw new IllegalStateException(
+                "ProductsQueryTest does not know how to flip a field of type ${type.name}; " +
+                "extend nonDefaultValueFor() or update the test.")
+    }
+}