diff --git a/calm-hub/mongo/init-mongo.js b/calm-hub/mongo/init-mongo.js index 153634f4e..77ac44639 100644 --- a/calm-hub/mongo/init-mongo.js +++ b/calm-hub/mongo/init-mongo.js @@ -62,6 +62,16 @@ if (db.counters.countDocuments({ _id: "userAccessStoreCounter" }) === 0) { print("userAccessStoreCounter already exists, no initialization needed"); } +if (db.counters.countDocuments({ _id: "decoratorStoreCounter" }) === 0) { + db.counters.insertOne({ + _id: "decoratorStoreCounter", + sequence_value: 1 + }); + print("Initialized decoratorStoreCounter with sequence_value 1"); +} else { + print("decoratorStoreCounter already exists, no initialization needed"); +} + db.schemas.insertMany([ // Insert initial documents into the schemas collection { version: "2025-03", @@ -2814,4 +2824,135 @@ Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliqu }, ], }, -]); \ No newline at end of file +]); + +db.decorators.insertMany([ + { + namespace: "finos", + decorators: [ + { + decoratorId: NumberInt(1), + decorator: { + "$schema": "https://calm.finos.org/draft/2026-03/standards/deployment/deployment.decorator.schema.json", + "unique-id": "finos-architecture-1-deployment", + "type": "deployment", + "target": [ + "/calm/namespaces/finos/architectures/1/versions/1-0-0" + ], + "target-type": [ + "architecture" + ], + "applies-to": [ + "example-node" + ], + "data": { + "start-time": "2026-02-23T10:00:00Z", + "end-time": "2026-02-23T10:05:30Z", + "status": "completed", + "observability": "https://grafana.example.com/d/finos-architecture-1", + "deployment-url": "https://jenkins.example.com/job/finos-architecture/123/", + "notes": "Production deployment of FINOS Architecture 1 with baseline configuration" + } + } + }, + { + decoratorId: NumberInt(2), + decorator: { + "$schema": "https://calm.finos.org/draft/2026-03/standards/deployment/deployment.decorator.schema.json", + "unique-id": "finos-architecture-1-deployment-v2", + "type": "deployment", + "target": [ + "/calm/namespaces/finos/architectures/1/versions/1-0-0" + ], + "target-type": [ + "architecture" + ], + "applies-to": [ + "example-node" + ], + "data": { + "start-time": "2026-03-04T15:00:00Z", + "end-time": "2026-03-04T15:08:15Z", + "status": "completed", + "notes": "Second production deployment of FINOS Architecture 1 with performance improvements and bug fixes" + } + } + }, + { + decoratorId: NumberInt(3), + decorator: { + "$schema": "https://calm.finos.org/draft/2026-03/standards/deployment/deployment.decorator.schema.json", + "unique-id": "finos-pattern-1-deployment", + "type": "deployment", + "target": [ + "/calm/namespaces/finos/patterns/1/versions/1-0-0" + ], + "target-type": [ + "pattern" + ], + "applies-to": [ + "node-a", "relationship-x" + ], + "data": { + "start-time": "2026-02-15T09:30:00Z", + "end-time": "2026-02-15T09:35:20Z", + "status": "completed", + "deployment-url": "https://github.com/finos/actions/runs/987654321", + "notes": "Pattern deployment via GitHub Actions" + } + } + } + ] + }, + { + namespace: "workshop", + decorators: [ + { + decoratorId: NumberInt(1), + decorator: { + "$schema": "https://calm.finos.org/draft/2026-03/standards/deployment/deployment.decorator.schema.json", + "unique-id": "workshop-conference-deployment", + "type": "deployment", + "target": [ + "/calm/namespaces/workshop/architectures/1/versions/1-0-0" + ], + "target-type": [ + "architecture" + ], + "applies-to": [ + "conference-website", + "load-balancer" + ], + "data": { + "start-time": "2026-03-01T14:30:00Z", + "end-time": "2026-03-01T14:35:45Z", + "status": "completed", + "deployment-url": "https://vercel.com/workshop/deployments/abc123xyz", + "notes": "Workshop conference system deployment via Vercel" + } + } + }, + { + decoratorId: NumberInt(2), + decorator: { + "$schema": "https://calm.finos.org/draft/2026-03/standards/observability/observability.decorator.schema.json", + "unique-id": "workshop-conference-monitoring", + "type": "observability", + "target": [ + "/calm/namespaces/workshop/architectures/1/versions/1-0-0" + ], + "target-type": [ + "architecture" + ], + "applies-to": [ + "conference-website" + ], + "data": { + "dashboard-url": "https://datadog.example.com/dashboard/workshop-conference", + "notes": "Monitoring dashboard for workshop conference system" + } + } + } + ] + } +]); diff --git a/calm-hub/src/main/java/org/finos/calm/config/NitriteDBConfig.java b/calm-hub/src/main/java/org/finos/calm/config/NitriteDBConfig.java index 27bc61054..b4ece0de3 100644 --- a/calm-hub/src/main/java/org/finos/calm/config/NitriteDBConfig.java +++ b/calm-hub/src/main/java/org/finos/calm/config/NitriteDBConfig.java @@ -92,6 +92,7 @@ private void initializeDatabase() throws IOException, JsonParseException { db.getCollection("flows"); db.getCollection("schemas"); db.getCollection("counters"); + db.getCollection("decorators"); } /** diff --git a/calm-hub/src/main/java/org/finos/calm/resources/DecoratorResource.java b/calm-hub/src/main/java/org/finos/calm/resources/DecoratorResource.java new file mode 100644 index 000000000..ae80b51bc --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/resources/DecoratorResource.java @@ -0,0 +1,69 @@ +package org.finos.calm.resources; + +import jakarta.inject.Inject; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.finos.calm.domain.ValueWrapper; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.security.CalmHubScopes; +import org.finos.calm.security.PermittedScopes; +import org.finos.calm.store.DecoratorStore; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE; +import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_REGEX; +import static org.finos.calm.resources.ResourceValidationConstants.QUERY_PARAM_NO_WHITESPACE_MESSAGE; +import static org.finos.calm.resources.ResourceValidationConstants.QUERY_PARAM_NO_WHITESPACE_REGEX; + +/** + * Resource for managing decorators in a given namespace + */ +@Path("/calm/namespaces") +public class DecoratorResource { + + private final DecoratorStore decoratorStore; + private final Logger logger = LoggerFactory.getLogger(DecoratorResource.class); + + @Inject + public DecoratorResource(DecoratorStore decoratorStore) { + this.decoratorStore = decoratorStore; + } + + /** + * Retrieve a list of decorator IDs in a given namespace with optional filtering + * + * @param namespace the namespace to retrieve decorators for + * @param target optional target path to filter by (e.g., "/calm/namespaces/finos/architectures/1/versions/1-0-0") + * @param type optional decorator type to filter by (e.g., "deployment", "observability") + * @return a list of decorator IDs matching the criteria + */ + @GET + @Path("{namespace}/decorators") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Retrieve decorators in a given namespace", + description = "Decorator IDs stored in a given namespace, optionally filtered by target and/or type" + ) + @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + public Response getDecoratorsForNamespace( + @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, + @QueryParam("target") @Size(max = 500) @Pattern(regexp = QUERY_PARAM_NO_WHITESPACE_REGEX, message = QUERY_PARAM_NO_WHITESPACE_MESSAGE) String target, + @QueryParam("type") @Size(max = 100) @Pattern(regexp = QUERY_PARAM_NO_WHITESPACE_REGEX, message = QUERY_PARAM_NO_WHITESPACE_MESSAGE) String type + ) { + try { + return Response.ok(new ValueWrapper<>(decoratorStore.getDecoratorsForNamespace(namespace, target, type))).build(); + } catch (NamespaceNotFoundException e) { + logger.error("Invalid namespace [{}] when retrieving decorators", namespace, e); + return CalmResourceErrorResponses.invalidNamespaceResponse(namespace); + } + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/resources/ResourceValidationConstants.java b/calm-hub/src/main/java/org/finos/calm/resources/ResourceValidationConstants.java index 95657a9a9..1767cdb14 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/ResourceValidationConstants.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/ResourceValidationConstants.java @@ -10,6 +10,8 @@ public class ResourceValidationConstants { public static final String DOMAIN_NAME_MESSAGE = "domain name must match pattern '^[A-Za-z0-9-]+$'"; public static final String VERSION_REGEX = "^(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)$"; public static final String VERSION_MESSAGE = "version must match pattern '^(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)$'"; + public static final String QUERY_PARAM_NO_WHITESPACE_REGEX = "^[A-Za-z0-9_/-]+$"; + public static final String QUERY_PARAM_NO_WHITESPACE_MESSAGE = "Query parameter must contain only alphanumeric characters, hyphens, underscores, and forward slashes"; public static final PolicyFactory STRICT_SANITIZATION_POLICY = new HtmlPolicyBuilder().toFactory(); } diff --git a/calm-hub/src/main/java/org/finos/calm/store/DecoratorStore.java b/calm-hub/src/main/java/org/finos/calm/store/DecoratorStore.java new file mode 100644 index 000000000..03a881a70 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/DecoratorStore.java @@ -0,0 +1,22 @@ +package org.finos.calm.store; + +import org.finos.calm.domain.exception.NamespaceNotFoundException; + +import java.util.List; + +/** + * Interface for decorator storage operations. + */ +public interface DecoratorStore { + + /** + * Retrieve decorator IDs for a given namespace with optional filtering. + * + * @param namespace the namespace to retrieve decorators for + * @param target optional target path to filter by (e.g., "/calm/namespaces/finos/architectures/1/versions/1-0-0") + * @param type optional decorator type to filter by (e.g., "deployment", "observability") + * @return a list of decorator IDs matching the criteria + * @throws NamespaceNotFoundException if the namespace does not exist + */ + List getDecoratorsForNamespace(String namespace, String target, String type) throws NamespaceNotFoundException; +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoDecoratorStore.java b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoDecoratorStore.java new file mode 100644 index 000000000..69a4b08dc --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoDecoratorStore.java @@ -0,0 +1,140 @@ +package org.finos.calm.store.mongo; + +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; +import com.mongodb.client.model.Filters; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Typed; +import jakarta.inject.Inject; +import org.bson.Document; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.store.DecoratorStore; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + +/** + * MongoDB implementation of DecoratorStore. + */ +@ApplicationScoped +@Typed(MongoDecoratorStore.class) +public class MongoDecoratorStore implements DecoratorStore { + + private static final Logger LOG = LoggerFactory.getLogger(MongoDecoratorStore.class); + private final MongoCollection decoratorCollection; + private final MongoNamespaceStore namespaceStore; + + @Inject + public MongoDecoratorStore(MongoDatabase database, MongoNamespaceStore namespaceStore) { + this.decoratorCollection = database.getCollection("decorators"); + this.namespaceStore = namespaceStore; + } + + @Override + public List getDecoratorsForNamespace(String namespace, String target, String type) throws NamespaceNotFoundException { + validateNamespace(namespace); + + Document namespaceDocument = fetchNamespaceDocument(namespace); + if (namespaceDocument == null || namespaceDocument.isEmpty()) { + LOG.debug("No decorators found for namespace '{}'", namespace); + return List.of(); + } + + List decorators = extractDecorators(namespaceDocument, namespace); + if (decorators.isEmpty()) { + return List.of(); + } + + List decoratorIds = filterDecorators(decorators, target, type); + + LOG.debug("Retrieved {} decorators for namespace '{}' with filters (target: {}, type: {})", + decoratorIds.size(), namespace, target, type); + return decoratorIds; + } + + /** + * Validates that the namespace exists, throwing an exception if it doesn't + */ + private void validateNamespace(String namespace) throws NamespaceNotFoundException { + if (!namespaceStore.namespaceExists(namespace)) { + LOG.warn("Namespace '{}' not found when retrieving decorators", namespace); + throw new NamespaceNotFoundException(); + } + } + + /** + * Fetches the namespace document from MongoDB + */ + private Document fetchNamespaceDocument(String namespace) { + return decoratorCollection.find(Filters.eq("namespace", namespace)).first(); + } + + /** + * Extracts the list of decorators from the namespace document + */ + private List extractDecorators(Document namespaceDocument, String namespace) { + List decorators = namespaceDocument.getList("decorators", Document.class); + if (decorators == null || decorators.isEmpty()) { + LOG.debug("Decorators list is empty for namespace '{}'", namespace); + return List.of(); + } + return decorators; + } + + /** + * Filters decorators based on target and type criteria + */ + private List filterDecorators(List decorators, String target, String type) { + List decoratorIds = new ArrayList<>(); + + for (Document decoratorDoc : decorators) { + Integer decoratorId = decoratorDoc.getInteger("decoratorId"); + if (decoratorId == null) { + continue; + } + + Document decorator = decoratorDoc.get("decorator", Document.class); + if (decorator != null && matchesFilters(decorator, target, type)) { + decoratorIds.add(decoratorId); + } else if (decorator == null) { + decoratorIds.add(decoratorId); + } + } + + return decoratorIds; + } + + /** + * Checks if a decorator matches the provided filters + */ + private boolean matchesFilters(Document decorator, String target, String type) { + return matchesTypeFilter(decorator, type) && matchesTargetFilter(decorator, target); + } + + /** + * Checks if the decorator matches the type filter (if provided) + */ + private boolean matchesTypeFilter(Document decorator, String type) { + if (type == null || type.isEmpty()) { + return true; + } + + String decoratorType = decorator.getString("type"); + return decoratorType != null && decoratorType.equals(type); + } + + /** + * Checks if the decorator matches the target filter (if provided) + */ + private boolean matchesTargetFilter(Document decorator, String target) { + if (target == null || target.isEmpty()) { + return true; + } + + @SuppressWarnings("unchecked") + List targets = (List) decorator.get("target"); + return targets != null && targets.contains(target); + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteDecoratorStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteDecoratorStore.java new file mode 100644 index 000000000..bbd2a781a --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteDecoratorStore.java @@ -0,0 +1,159 @@ +package org.finos.calm.store.nitrite; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Typed; +import jakarta.inject.Inject; +import org.dizitart.no2.Nitrite; +import org.dizitart.no2.collection.Document; +import org.dizitart.no2.collection.NitriteCollection; +import org.dizitart.no2.filters.Filter; +import org.finos.calm.config.StandaloneQualifier; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.store.DecoratorStore; +import org.finos.calm.store.util.TypeSafeNitriteDocument; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + +import static org.dizitart.no2.filters.FluentFilter.where; + +/** + * NitriteDB implementation of DecoratorStore. + * This implementation is used when the application is running in standalone mode. + */ +@ApplicationScoped +@Typed(NitriteDecoratorStore.class) +public class NitriteDecoratorStore implements DecoratorStore { + + private static final Logger LOG = LoggerFactory.getLogger(NitriteDecoratorStore.class); + private static final String COLLECTION_NAME = "decorators"; + private static final String NAMESPACE_FIELD = "namespace"; + private static final String DECORATORS_FIELD = "decorators"; + private static final String DECORATOR_ID_FIELD = "decoratorId"; + + private final NitriteCollection decoratorCollection; + private final NitriteNamespaceStore namespaceStore; + + @Inject + public NitriteDecoratorStore(@StandaloneQualifier Nitrite db, NitriteNamespaceStore namespaceStore) { + this.decoratorCollection = db.getCollection(COLLECTION_NAME); + this.namespaceStore = namespaceStore; + LOG.info("NitriteDecoratorStore initialized with collection: {}", COLLECTION_NAME); + } + + @Override + public List getDecoratorsForNamespace(String namespace, String target, String type) throws NamespaceNotFoundException { + validateNamespace(namespace); + + Document namespaceDoc = fetchNamespaceDocument(namespace); + if (namespaceDoc == null) { + LOG.debug("No decorators found for namespace '{}'", namespace); + return List.of(); + } + + List decorators = extractDecorators(namespaceDoc, namespace); + if (decorators.isEmpty()) { + return List.of(); + } + + List decoratorIds = filterDecorators(decorators, target, type); + + LOG.debug("Retrieved {} decorators for namespace '{}' with filters (target: {}, type: {})", + decoratorIds.size(), namespace, target, type); + return decoratorIds; + } + + /** + * Validates that the namespace exists, throwing an exception if it doesn't + */ + private void validateNamespace(String namespace) throws NamespaceNotFoundException { + if (!namespaceStore.namespaceExists(namespace)) { + LOG.warn("Namespace '{}' not found when retrieving decorators", namespace); + throw new NamespaceNotFoundException(); + } + } + + /** + * Fetches the namespace document from NitriteDB + */ + private Document fetchNamespaceDocument(String namespace) { + Filter filter = where(NAMESPACE_FIELD).eq(namespace); + return decoratorCollection.find(filter).firstOrNull(); + } + + /** + * Extracts the list of decorators from the namespace document + */ + private List extractDecorators(Document namespaceDoc, String namespace) { + TypeSafeNitriteDocument typeSafeDoc = new TypeSafeNitriteDocument<>(namespaceDoc, Document.class); + List decorators = typeSafeDoc.getList(DECORATORS_FIELD); + + if (decorators == null || decorators.isEmpty()) { + LOG.debug("Decorators list is empty for namespace '{}'", namespace); + return List.of(); + } + return decorators; + } + + /** + * Filters decorators based on target and type criteria + */ + private List filterDecorators(List decorators, String target, String type) { + List decoratorIds = new ArrayList<>(); + + for (Document decoratorDoc : decorators) { + Integer decoratorId = decoratorDoc.get(DECORATOR_ID_FIELD, Integer.class); + if (decoratorId == null) { + continue; + } + + Document decorator = decoratorDoc.get("decorator", Document.class); + if (decorator != null && matchesFilters(decorator, target, type)) { + decoratorIds.add(decoratorId); + } else if (decorator == null) { + decoratorIds.add(decoratorId); + } + } + + return decoratorIds; + } + + /** + * Checks if a decorator matches the provided filters + */ + private boolean matchesFilters(Document decorator, String target, String type) { + return matchesTypeFilter(decorator, type) && matchesTargetFilter(decorator, target); + } + + /** + * Checks if the decorator matches the type filter (if provided) + */ + private boolean matchesTypeFilter(Document decorator, String type) { + if (type == null || type.isEmpty()) { + return true; + } + + String decoratorType = decorator.get("type", String.class); + return decoratorType != null && decoratorType.equals(type); + } + + /** + * Checks if the decorator matches the target filter (if provided) + */ + private boolean matchesTargetFilter(Document decorator, String target) { + if (target == null || target.isEmpty()) { + return true; + } + + Object targetObj = decorator.get("target"); + if (!(targetObj instanceof List)) { + return false; + } + + @SuppressWarnings("unchecked") + List targets = (List) targetObj; + return targets.contains(target); + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/DecoratorStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/DecoratorStoreProducer.java new file mode 100644 index 000000000..60cbbbf32 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/DecoratorStoreProducer.java @@ -0,0 +1,41 @@ +package org.finos.calm.store.producer; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.store.DecoratorStore; +import org.finos.calm.store.mongo.MongoDecoratorStore; +import org.finos.calm.store.nitrite.NitriteDecoratorStore; + +/** + * Producer for DecoratorStore implementations. + * Selects the appropriate implementation based on the configured database mode. + */ +@ApplicationScoped +public class DecoratorStoreProducer { + + @Inject + @ConfigProperty(name = "calm.database.mode", defaultValue = "mongo") + String databaseMode; + + @Inject + MongoDecoratorStore mongoDecoratorStore; + + @Inject + NitriteDecoratorStore nitriteDecoratorStore; + + /** + * Produces the appropriate DecoratorStore implementation based on the configured database mode. + * + * @return the DecoratorStore implementation + */ + @Produces + @ApplicationScoped + public DecoratorStore produceDecoratorStore() { + if ("standalone".equals(databaseMode)) { + return nitriteDecoratorStore; + } + return mongoDecoratorStore; + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/config/NitriteDBConfigTest.java b/calm-hub/src/test/java/org/finos/calm/config/NitriteDBConfigTest.java index 58abb73e3..cacc8545a 100644 --- a/calm-hub/src/test/java/org/finos/calm/config/NitriteDBConfigTest.java +++ b/calm-hub/src/test/java/org/finos/calm/config/NitriteDBConfigTest.java @@ -73,6 +73,7 @@ public void testInitializeInStandaloneMode() throws Exception { assertTrue(db.hasCollection("flows"), "flows collection should exist"); assertTrue(db.hasCollection("schemas"), "schemas collection should exist"); assertTrue(db.hasCollection("counters"), "counters collection should exist"); + assertTrue(db.hasCollection("decorators"), "decorators collection should exist"); } @Test diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestDecoratorResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestDecoratorResourceShould.java new file mode 100644 index 000000000..9e0026556 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestDecoratorResourceShould.java @@ -0,0 +1,241 @@ +package org.finos.calm.resources; + +import io.quarkus.test.InjectMock; +import io.quarkus.test.junit.QuarkusTest; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.store.DecoratorStore; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@QuarkusTest +@ExtendWith(MockitoExtension.class) +public class TestDecoratorResourceShould { + + @InjectMock + DecoratorStore decoratorStore; + + @Test + void return_decorator_ids_when_namespace_exists() throws NamespaceNotFoundException { + when(decoratorStore.getDecoratorsForNamespace("finos", null, null)) + .thenReturn(List.of(1, 2, 3)); + + given() + .when() + .get("/calm/namespaces/finos/decorators") + .then() + .statusCode(200) + .body(equalTo("{\"values\":[1,2,3]}")); + + verify(decoratorStore, times(1)).getDecoratorsForNamespace("finos", null, null); + } + + @Test + void return_decorator_ids_filtered_by_target() throws NamespaceNotFoundException { + String target = "/calm/namespaces/finos/architectures/1/versions/1-0-0"; + when(decoratorStore.getDecoratorsForNamespace("finos", target, null)) + .thenReturn(List.of(1, 2)); + + given() + .queryParam("target", target) + .when() + .get("/calm/namespaces/finos/decorators") + .then() + .statusCode(200) + .body(equalTo("{\"values\":[1,2]}")); + + verify(decoratorStore, times(1)).getDecoratorsForNamespace("finos", target, null); + } + + @Test + void return_decorator_ids_filtered_by_type() throws NamespaceNotFoundException { + when(decoratorStore.getDecoratorsForNamespace("finos", null, "deployment")) + .thenReturn(List.of(1, 2, 3)); + + given() + .queryParam("type", "deployment") + .when() + .get("/calm/namespaces/finos/decorators") + .then() + .statusCode(200) + .body(equalTo("{\"values\":[1,2,3]}")); + + verify(decoratorStore, times(1)).getDecoratorsForNamespace("finos", null, "deployment"); + } + + @Test + void return_decorator_ids_filtered_by_target_and_type() throws NamespaceNotFoundException { + String target = "/calm/namespaces/finos/architectures/1/versions/1-0-0"; + when(decoratorStore.getDecoratorsForNamespace("finos", target, "deployment")) + .thenReturn(List.of(1, 2)); + + given() + .queryParam("target", target) + .queryParam("type", "deployment") + .when() + .get("/calm/namespaces/finos/decorators") + .then() + .statusCode(200) + .body(equalTo("{\"values\":[1,2]}")); + + verify(decoratorStore, times(1)).getDecoratorsForNamespace("finos", target, "deployment"); + } + + @Test + void accept_query_params_with_valid_characters() throws NamespaceNotFoundException { + String target = "/calm/namespaces/finos-org/architectures/arch_1/versions/1-0-0"; + String type = "deployment_prod"; + when(decoratorStore.getDecoratorsForNamespace("finos", target, type)) + .thenReturn(List.of(1)); + + given() + .queryParam("target", target) + .queryParam("type", type) + .when() + .get("/calm/namespaces/finos/decorators") + .then() + .statusCode(200) + .body(equalTo("{\"values\":[1]}")); + + verify(decoratorStore, times(1)).getDecoratorsForNamespace("finos", target, type); + } + + @Test + void return_empty_list_when_namespace_has_no_decorators() throws NamespaceNotFoundException { + when(decoratorStore.getDecoratorsForNamespace("empty-namespace", null, null)) + .thenReturn(List.of()); + + given() + .when() + .get("/calm/namespaces/empty-namespace/decorators") + .then() + .statusCode(200) + .body(equalTo("{\"values\":[]}")); + + verify(decoratorStore, times(1)).getDecoratorsForNamespace("empty-namespace", null, null); + } + + @Test + void return_404_when_namespace_does_not_exist_for_decorators() throws NamespaceNotFoundException { + when(decoratorStore.getDecoratorsForNamespace("invalid-namespace", null, null)) + .thenThrow(new NamespaceNotFoundException()); + + given() + .when() + .get("/calm/namespaces/invalid-namespace/decorators") + .then() + .statusCode(404) + .body(containsString("Invalid namespace provided: invalid-namespace")); + + verify(decoratorStore, times(1)).getDecoratorsForNamespace("invalid-namespace", null, null); + } + + @Test + void return_400_when_namespace_has_invalid_characters() throws NamespaceNotFoundException { + given() + .when() + .get("/calm/namespaces/invalid@namespace/decorators") + .then() + .statusCode(400) + .body(containsString("namespace must match pattern")); + + verify(decoratorStore, never()).getDecoratorsForNamespace(any(), any(), any()); + } + + @Test + void return_400_when_query_params_are_empty_strings() throws NamespaceNotFoundException { + given() + .queryParam("target", "") + .queryParam("type", "") + .when() + .get("/calm/namespaces/finos/decorators") + .then() + .statusCode(400); + + verify(decoratorStore, never()).getDecoratorsForNamespace(any(), any(), any()); + } + + @Test + void return_400_when_query_params_are_only_whitespace() throws NamespaceNotFoundException { + given() + .queryParam("target", " ") + .queryParam("type", " ") + .when() + .get("/calm/namespaces/finos/decorators") + .then() + .statusCode(400); + + verify(decoratorStore, never()).getDecoratorsForNamespace(any(), any(), any()); + } + + @Test + void return_400_when_query_params_have_whitespace() throws NamespaceNotFoundException { + given() + .queryParam("target", " /calm/namespaces/finos/architectures/1 ") + .queryParam("type", " deployment ") + .when() + .get("/calm/namespaces/finos/decorators") + .then() + .statusCode(400); + + verify(decoratorStore, never()).getDecoratorsForNamespace(any(), any(), any()); + } + + @Test + void return_400_when_query_params_contain_invalid_characters() throws NamespaceNotFoundException { + given() + .queryParam("target", "/calm/namespaces/finos@invalid") + .when() + .get("/calm/namespaces/finos/decorators") + .then() + .statusCode(400); + + given() + .queryParam("type", "deploy ment") + .when() + .get("/calm/namespaces/finos/decorators") + .then() + .statusCode(400); + + verify(decoratorStore, never()).getDecoratorsForNamespace(any(), any(), any()); + } + + @Test + void return_400_when_target_exceeds_max_length() throws NamespaceNotFoundException { + String longTarget = "a".repeat(501); + + given() + .queryParam("target", longTarget) + .when() + .get("/calm/namespaces/finos/decorators") + .then() + .statusCode(400); + + verify(decoratorStore, never()).getDecoratorsForNamespace(any(), any(), any()); + } + + @Test + void return_400_when_type_exceeds_max_length() throws NamespaceNotFoundException { + String longType = "a".repeat(101); + + given() + .queryParam("type", longType) + .when() + .get("/calm/namespaces/finos/decorators") + .then() + .statusCode(400); + + verify(decoratorStore, never()).getDecoratorsForNamespace(any(), any(), any()); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoDecoratorStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoDecoratorStoreShould.java new file mode 100644 index 000000000..70833845e --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoDecoratorStoreShould.java @@ -0,0 +1,329 @@ +package org.finos.calm.store.mongo; + +import com.mongodb.client.FindIterable; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; +import org.bson.Document; +import org.bson.conversions.Bson; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TestMongoDecoratorStoreShould { + + @Mock + private MongoDatabase database; + + @Mock + private MongoCollection decoratorCollection; + + @Mock + private MongoNamespaceStore namespaceStore; + + @Mock + private FindIterable findIterable; + + private MongoDecoratorStore decoratorStore; + + @BeforeEach + void setUp() { + when(database.getCollection("decorators")).thenReturn(decoratorCollection); + decoratorStore = new MongoDecoratorStore(database, namespaceStore); + } + + @Test + void should_return_decorator_ids_when_namespace_exists_with_decorators() throws NamespaceNotFoundException { + // Given + String namespace = "finos"; + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + + Document decorator1 = new Document("decoratorId", 1) + .append("decorator", new Document("unique-id", "finos-architecture-1-deployment") + .append("type", "deployment") + .append("target", List.of("/calm/namespaces/finos/architectures/1/versions/1-0-0"))); + Document decorator2 = new Document("decoratorId", 2) + .append("decorator", new Document("unique-id", "finos-architecture-1-deployment-v2") + .append("type", "deployment") + .append("target", List.of("/calm/namespaces/finos/architectures/1/versions/1-0-0"))); + + Document namespaceDocument = new Document("namespace", namespace) + .append("decorators", List.of(decorator1, decorator2)); + + when(decoratorCollection.find(any(Bson.class))).thenReturn(findIterable); + when(findIterable.first()).thenReturn(namespaceDocument); + + // When + List decoratorIds = decoratorStore.getDecoratorsForNamespace(namespace, null, null); + + // Then + assertNotNull(decoratorIds); + assertEquals(2, decoratorIds.size()); + assertTrue(decoratorIds.contains(1)); + assertTrue(decoratorIds.contains(2)); + verify(namespaceStore).namespaceExists(namespace); + verify(decoratorCollection).find(any(Bson.class)); + } + + @Test + void should_filter_decorators_by_type() throws NamespaceNotFoundException { + // Given + String namespace = "workshop"; + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + + Document decorator1 = new Document("decoratorId", 1) + .append("decorator", new Document("type", "deployment") + .append("target", List.of("/calm/namespaces/workshop/architectures/1"))); + Document decorator2 = new Document("decoratorId", 2) + .append("decorator", new Document("type", "observability") + .append("target", List.of("/calm/namespaces/workshop/architectures/1"))); + + Document namespaceDocument = new Document("namespace", namespace) + .append("decorators", List.of(decorator1, decorator2)); + + when(decoratorCollection.find(any(Bson.class))).thenReturn(findIterable); + when(findIterable.first()).thenReturn(namespaceDocument); + + // When + List decoratorIds = decoratorStore.getDecoratorsForNamespace(namespace, null, "deployment"); + + // Then + assertNotNull(decoratorIds); + assertEquals(1, decoratorIds.size()); + assertEquals(1, decoratorIds.get(0)); + verify(namespaceStore).namespaceExists(namespace); + } + + @Test + void should_filter_decorators_by_target() throws NamespaceNotFoundException { + // Given + String namespace = "finos"; + String targetPath = "/calm/namespaces/finos/architectures/1/versions/1-0-0"; + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + + Document decorator1 = new Document("decoratorId", 1) + .append("decorator", new Document("type", "deployment") + .append("target", List.of(targetPath))); + Document decorator2 = new Document("decoratorId", 2) + .append("decorator", new Document("type", "deployment") + .append("target", List.of(targetPath))); + Document decorator3 = new Document("decoratorId", 3) + .append("decorator", new Document("type", "deployment") + .append("target", List.of("/calm/namespaces/finos/patterns/1/versions/1-0-0"))); + + Document namespaceDocument = new Document("namespace", namespace) + .append("decorators", List.of(decorator1, decorator2, decorator3)); + + when(decoratorCollection.find(any(Bson.class))).thenReturn(findIterable); + when(findIterable.first()).thenReturn(namespaceDocument); + + // When + List decoratorIds = decoratorStore.getDecoratorsForNamespace(namespace, targetPath, null); + + // Then + assertNotNull(decoratorIds); + assertEquals(2, decoratorIds.size()); + assertTrue(decoratorIds.contains(1)); + assertTrue(decoratorIds.contains(2)); + assertFalse(decoratorIds.contains(3)); + verify(namespaceStore).namespaceExists(namespace); + } + + @Test + void should_filter_decorators_by_both_target_and_type() throws NamespaceNotFoundException { + // Given + String namespace = "finos"; + String targetPath = "/calm/namespaces/finos/architectures/1/versions/1-0-0"; + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + + Document decorator1 = new Document("decoratorId", 1) + .append("decorator", new Document("type", "deployment") + .append("target", List.of(targetPath))); + Document decorator2 = new Document("decoratorId", 2) + .append("decorator", new Document("type", "observability") + .append("target", List.of(targetPath))); + Document decorator3 = new Document("decoratorId", 3) + .append("decorator", new Document("type", "deployment") + .append("target", List.of("/calm/namespaces/finos/patterns/1/versions/1-0-0"))); + + Document namespaceDocument = new Document("namespace", namespace) + .append("decorators", List.of(decorator1, decorator2, decorator3)); + + when(decoratorCollection.find(any(Bson.class))).thenReturn(findIterable); + when(findIterable.first()).thenReturn(namespaceDocument); + + // When + List decoratorIds = decoratorStore.getDecoratorsForNamespace(namespace, targetPath, "deployment"); + + // Then + assertNotNull(decoratorIds); + assertEquals(1, decoratorIds.size()); + assertEquals(1, decoratorIds.get(0)); + verify(namespaceStore).namespaceExists(namespace); + } + + @Test + void should_return_empty_list_when_namespace_has_no_decorators() throws NamespaceNotFoundException { + // Given + String namespace = "empty-namespace"; + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + + Document namespaceDocument = new Document("namespace", namespace) + .append("decorators", List.of()); + + when(decoratorCollection.find(any(Bson.class))).thenReturn(findIterable); + when(findIterable.first()).thenReturn(namespaceDocument); + + // When + List decoratorIds = decoratorStore.getDecoratorsForNamespace(namespace, null, null); + + // Then + assertNotNull(decoratorIds); + assertTrue(decoratorIds.isEmpty()); + verify(namespaceStore).namespaceExists(namespace); + } + + @Test + void should_return_empty_list_when_decorators_field_is_null() throws NamespaceNotFoundException { + // Given + String namespace = "test-namespace"; + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + + Document namespaceDocument = new Document("namespace", namespace); + // No "decorators" field + + when(decoratorCollection.find(any(Bson.class))).thenReturn(findIterable); + when(findIterable.first()).thenReturn(namespaceDocument); + + // When + List decoratorIds = decoratorStore.getDecoratorsForNamespace(namespace, null, null); + + // Then + assertNotNull(decoratorIds); + assertTrue(decoratorIds.isEmpty()); + verify(namespaceStore).namespaceExists(namespace); + } + + @Test + void should_return_empty_list_when_namespace_document_is_null() throws NamespaceNotFoundException { + // Given + String namespace = "missing-namespace"; + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + when(decoratorCollection.find(any(Bson.class))).thenReturn(findIterable); + when(findIterable.first()).thenReturn(null); + + // When + List decoratorIds = decoratorStore.getDecoratorsForNamespace(namespace, null, null); + + // Then + assertNotNull(decoratorIds); + assertTrue(decoratorIds.isEmpty()); + verify(namespaceStore).namespaceExists(namespace); + } + + @Test + void should_return_empty_list_when_namespace_document_is_empty() throws NamespaceNotFoundException { + // Given + String namespace = "empty-doc-namespace"; + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + + Document emptyDocument = new Document(); + + when(decoratorCollection.find(any(Bson.class))).thenReturn(findIterable); + when(findIterable.first()).thenReturn(emptyDocument); + + // When + List decoratorIds = decoratorStore.getDecoratorsForNamespace(namespace, null, null); + + // Then + assertNotNull(decoratorIds); + assertTrue(decoratorIds.isEmpty()); + verify(namespaceStore).namespaceExists(namespace); + } + + @Test + void should_throw_namespace_not_found_exception_when_namespace_does_not_exist() { + // Given + String namespace = "invalid-namespace"; + when(namespaceStore.namespaceExists(namespace)).thenReturn(false); + + // When & Then + assertThrows(NamespaceNotFoundException.class, () -> { + decoratorStore.getDecoratorsForNamespace(namespace, null, null); + }); + + verify(namespaceStore).namespaceExists(namespace); + verify(decoratorCollection, never()).find(any(Bson.class)); + } + + @Test + void should_skip_decorators_with_null_decorator_id() throws NamespaceNotFoundException { + // Given + String namespace = "finos"; + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + + Document decorator1 = new Document("decoratorId", 1) + .append("decorator", new Document("unique-id", "decorator-1")); + Document decorator2 = new Document() // No decoratorId + .append("decorator", new Document("unique-id", "decorator-2")); + Document decorator3 = new Document("decoratorId", 3) + .append("decorator", new Document("unique-id", "decorator-3")); + + Document namespaceDocument = new Document("namespace", namespace) + .append("decorators", List.of(decorator1, decorator2, decorator3)); + + when(decoratorCollection.find(any(Bson.class))).thenReturn(findIterable); + when(findIterable.first()).thenReturn(namespaceDocument); + + // When + List decoratorIds = decoratorStore.getDecoratorsForNamespace(namespace, null, null); + + // Then + assertNotNull(decoratorIds); + assertEquals(2, decoratorIds.size()); + assertTrue(decoratorIds.contains(1)); + assertTrue(decoratorIds.contains(3)); + assertFalse(decoratorIds.contains(null)); + verify(namespaceStore).namespaceExists(namespace); + } + + @Test + void should_handle_single_decorator() throws NamespaceNotFoundException { + // Given + String namespace = "workshop"; + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + + Document decorator = new Document("decoratorId", 1) + .append("decorator", new Document("unique-id", "workshop-conference-deployment")); + + Document namespaceDocument = new Document("namespace", namespace) + .append("decorators", List.of(decorator)); + + when(decoratorCollection.find(any(Bson.class))).thenReturn(findIterable); + when(findIterable.first()).thenReturn(namespaceDocument); + + // When + List decoratorIds = decoratorStore.getDecoratorsForNamespace(namespace, null, null); + + // Then + assertNotNull(decoratorIds); + assertEquals(1, decoratorIds.size()); + assertEquals(1, decoratorIds.get(0)); + verify(namespaceStore).namespaceExists(namespace); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteDecoratorStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteDecoratorStoreShould.java new file mode 100644 index 000000000..842b06690 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteDecoratorStoreShould.java @@ -0,0 +1,351 @@ +package org.finos.calm.store.nitrite; + +import org.dizitart.no2.Nitrite; +import org.dizitart.no2.collection.Document; +import org.dizitart.no2.collection.DocumentCursor; +import org.dizitart.no2.collection.NitriteCollection; +import org.dizitart.no2.filters.Filter; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TestNitriteDecoratorStoreShould { + + @Mock + private Nitrite db; + + @Mock + private NitriteCollection decoratorCollection; + + @Mock + private NitriteNamespaceStore namespaceStore; + + @Mock + private DocumentCursor cursor; + + private NitriteDecoratorStore decoratorStore; + + @BeforeEach + void setUp() { + when(db.getCollection("decorators")).thenReturn(decoratorCollection); + decoratorStore = new NitriteDecoratorStore(db, namespaceStore); + } + + @Test + void should_return_decorator_ids_when_namespace_exists_with_decorators() throws NamespaceNotFoundException { + // Given + String namespace = "finos"; + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + + Document decorator1 = Document.createDocument("decoratorId", 1) + .put("decorator", Document.createDocument("unique-id", "finos-architecture-1-deployment") + .put("type", "deployment") + .put("target", List.of("/calm/namespaces/finos/architectures/1/versions/1-0-0"))); + Document decorator2 = Document.createDocument("decoratorId", 2) + .put("decorator", Document.createDocument("unique-id", "finos-architecture-1-deployment-v2") + .put("type", "deployment") + .put("target", List.of("/calm/namespaces/finos/architectures/1/versions/1-0-0"))); + + Document namespaceDocument = Document.createDocument("namespace", namespace) + .put("decorators", List.of(decorator1, decorator2)); + + when(decoratorCollection.find(any(Filter.class))).thenReturn(cursor); + when(cursor.firstOrNull()).thenReturn(namespaceDocument); + + // When + List decoratorIds = decoratorStore.getDecoratorsForNamespace(namespace, null, null); + + // Then + assertNotNull(decoratorIds); + assertEquals(2, decoratorIds.size()); + assertTrue(decoratorIds.contains(1)); + assertTrue(decoratorIds.contains(2)); + verify(namespaceStore).namespaceExists(namespace); + verify(decoratorCollection).find(any(Filter.class)); + } + + @Test + void should_filter_decorators_by_type() throws NamespaceNotFoundException { + // Given + String namespace = "workshop"; + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + + Document decorator1 = Document.createDocument("decoratorId", 1) + .put("decorator", Document.createDocument("type", "deployment") + .put("target", List.of("/calm/namespaces/workshop/architectures/1"))); + Document decorator2 = Document.createDocument("decoratorId", 2) + .put("decorator", Document.createDocument("type", "observability") + .put("target", List.of("/calm/namespaces/workshop/architectures/1"))); + + Document namespaceDocument = Document.createDocument("namespace", namespace) + .put("decorators", List.of(decorator1, decorator2)); + + when(decoratorCollection.find(any(Filter.class))).thenReturn(cursor); + when(cursor.firstOrNull()).thenReturn(namespaceDocument); + + // When + List decoratorIds = decoratorStore.getDecoratorsForNamespace(namespace, null, "deployment"); + + // Then + assertNotNull(decoratorIds); + assertEquals(1, decoratorIds.size()); + assertEquals(1, decoratorIds.get(0)); + verify(namespaceStore).namespaceExists(namespace); + } + + @Test + void should_filter_decorators_by_target() throws NamespaceNotFoundException { + // Given + String namespace = "finos"; + String targetPath = "/calm/namespaces/finos/architectures/1/versions/1-0-0"; + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + + Document decorator1 = Document.createDocument("decoratorId", 1) + .put("decorator", Document.createDocument("type", "deployment") + .put("target", List.of(targetPath))); + Document decorator2 = Document.createDocument("decoratorId", 2) + .put("decorator", Document.createDocument("type", "deployment") + .put("target", List.of(targetPath))); + Document decorator3 = Document.createDocument("decoratorId", 3) + .put("decorator", Document.createDocument("type", "deployment") + .put("target", List.of("/calm/namespaces/finos/patterns/1/versions/1-0-0"))); + + Document namespaceDocument = Document.createDocument("namespace", namespace) + .put("decorators", List.of(decorator1, decorator2, decorator3)); + + when(decoratorCollection.find(any(Filter.class))).thenReturn(cursor); + when(cursor.firstOrNull()).thenReturn(namespaceDocument); + + // When + List decoratorIds = decoratorStore.getDecoratorsForNamespace(namespace, targetPath, null); + + // Then + assertNotNull(decoratorIds); + assertEquals(2, decoratorIds.size()); + assertTrue(decoratorIds.contains(1)); + assertTrue(decoratorIds.contains(2)); + assertFalse(decoratorIds.contains(3)); + verify(namespaceStore).namespaceExists(namespace); + } + + @Test + void should_filter_decorators_by_both_target_and_type() throws NamespaceNotFoundException { + // Given + String namespace = "finos"; + String targetPath = "/calm/namespaces/finos/architectures/1/versions/1-0-0"; + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + + Document decorator1 = Document.createDocument("decoratorId", 1) + .put("decorator", Document.createDocument("type", "deployment") + .put("target", List.of(targetPath))); + Document decorator2 = Document.createDocument("decoratorId", 2) + .put("decorator", Document.createDocument("type", "observability") + .put("target", List.of(targetPath))); + Document decorator3 = Document.createDocument("decoratorId", 3) + .put("decorator", Document.createDocument("type", "deployment") + .put("target", List.of("/calm/namespaces/finos/patterns/1/versions/1-0-0"))); + + Document namespaceDocument = Document.createDocument("namespace", namespace) + .put("decorators", List.of(decorator1, decorator2, decorator3)); + + when(decoratorCollection.find(any(Filter.class))).thenReturn(cursor); + when(cursor.firstOrNull()).thenReturn(namespaceDocument); + + // When + List decoratorIds = decoratorStore.getDecoratorsForNamespace(namespace, targetPath, "deployment"); + + // Then + assertNotNull(decoratorIds); + assertEquals(1, decoratorIds.size()); + assertEquals(1, decoratorIds.get(0)); + verify(namespaceStore).namespaceExists(namespace); + } + + @Test + void should_return_empty_list_when_namespace_has_no_decorators() throws NamespaceNotFoundException { + // Given + String namespace = "empty-namespace"; + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + + Document namespaceDocument = Document.createDocument("namespace", namespace) + .put("decorators", List.of()); + + when(decoratorCollection.find(any(Filter.class))).thenReturn(cursor); + when(cursor.firstOrNull()).thenReturn(namespaceDocument); + + // When + List decoratorIds = decoratorStore.getDecoratorsForNamespace(namespace, null, null); + + // Then + assertNotNull(decoratorIds); + assertTrue(decoratorIds.isEmpty()); + verify(namespaceStore).namespaceExists(namespace); + } + + @Test + void should_return_empty_list_when_decorators_field_is_null() throws NamespaceNotFoundException { + // Given + String namespace = "test-namespace"; + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + + Document namespaceDocument = Document.createDocument("namespace", namespace); + // No "decorators" field + + when(decoratorCollection.find(any(Filter.class))).thenReturn(cursor); + when(cursor.firstOrNull()).thenReturn(namespaceDocument); + + // When + List decoratorIds = decoratorStore.getDecoratorsForNamespace(namespace, null, null); + + // Then + assertNotNull(decoratorIds); + assertTrue(decoratorIds.isEmpty()); + verify(namespaceStore).namespaceExists(namespace); + } + + @Test + void should_return_empty_list_when_namespace_document_is_null() throws NamespaceNotFoundException { + // Given + String namespace = "missing-namespace"; + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + when(decoratorCollection.find(any(Filter.class))).thenReturn(cursor); + when(cursor.firstOrNull()).thenReturn(null); + + // When + List decoratorIds = decoratorStore.getDecoratorsForNamespace(namespace, null, null); + + // Then + assertNotNull(decoratorIds); + assertTrue(decoratorIds.isEmpty()); + verify(namespaceStore).namespaceExists(namespace); + } + + @Test + void should_throw_namespace_not_found_exception_when_namespace_does_not_exist() { + // Given + String namespace = "invalid-namespace"; + when(namespaceStore.namespaceExists(namespace)).thenReturn(false); + + // When & Then + assertThrows(NamespaceNotFoundException.class, () -> { + decoratorStore.getDecoratorsForNamespace(namespace, null, null); + }); + + verify(namespaceStore).namespaceExists(namespace); + verify(decoratorCollection, never()).find(any(Filter.class)); + } + + @Test + void should_skip_decorators_with_null_decorator_id() throws NamespaceNotFoundException { + // Given + String namespace = "finos"; + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + + Document decorator1 = Document.createDocument("decoratorId", 1) + .put("decorator", Document.createDocument("unique-id", "decorator-1")); + Document decorator2 = Document.createDocument() // No decoratorId + .put("decorator", Document.createDocument("unique-id", "decorator-2")); + Document decorator3 = Document.createDocument("decoratorId", 3) + .put("decorator", Document.createDocument("unique-id", "decorator-3")); + + Document namespaceDocument = Document.createDocument("namespace", namespace) + .put("decorators", List.of(decorator1, decorator2, decorator3)); + + when(decoratorCollection.find(any(Filter.class))).thenReturn(cursor); + when(cursor.firstOrNull()).thenReturn(namespaceDocument); + + // When + List decoratorIds = decoratorStore.getDecoratorsForNamespace(namespace, null, null); + + // Then + assertNotNull(decoratorIds); + assertEquals(2, decoratorIds.size()); + assertTrue(decoratorIds.contains(1)); + assertTrue(decoratorIds.contains(3)); + assertFalse(decoratorIds.contains(null)); + verify(namespaceStore).namespaceExists(namespace); + } + + @Test + void should_handle_single_decorator() throws NamespaceNotFoundException { + // Given + String namespace = "workshop"; + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + + Document decorator = Document.createDocument("decoratorId", 1) + .put("decorator", Document.createDocument("unique-id", "workshop-conference-deployment")); + + Document namespaceDocument = Document.createDocument("namespace", namespace) + .put("decorators", List.of(decorator)); + + when(decoratorCollection.find(any(Filter.class))).thenReturn(cursor); + when(cursor.firstOrNull()).thenReturn(namespaceDocument); + + // When + List decoratorIds = decoratorStore.getDecoratorsForNamespace(namespace, null, null); + + // Then + assertNotNull(decoratorIds); + assertEquals(1, decoratorIds.size()); + assertEquals(1, decoratorIds.get(0)); + verify(namespaceStore).namespaceExists(namespace); + } + + @Test + void should_log_initialization_message() { + // Given/When - setUp already called + + // Then + verify(db).getCollection("decorators"); + // Logger message is logged but we can't easily verify it in unit tests + // This test mainly verifies the constructor completes successfully + } + + @Test + void should_handle_multiple_decorators_in_order() throws NamespaceNotFoundException { + // Given + String namespace = "finos"; + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + + Document decorator1 = Document.createDocument("decoratorId", 5) + .put("decorator", Document.createDocument("unique-id", "decorator-5")); + Document decorator2 = Document.createDocument("decoratorId", 10) + .put("decorator", Document.createDocument("unique-id", "decorator-10")); + Document decorator3 = Document.createDocument("decoratorId", 1) + .put("decorator", Document.createDocument("unique-id", "decorator-1")); + + Document namespaceDocument = Document.createDocument("namespace", namespace) + .put("decorators", List.of(decorator1, decorator2, decorator3)); + + when(decoratorCollection.find(any(Filter.class))).thenReturn(cursor); + when(cursor.firstOrNull()).thenReturn(namespaceDocument); + + // When + List decoratorIds = decoratorStore.getDecoratorsForNamespace(namespace, null, null); + + // Then + assertNotNull(decoratorIds); + assertEquals(3, decoratorIds.size()); + // Verify order is maintained + assertEquals(5, decoratorIds.get(0)); + assertEquals(10, decoratorIds.get(1)); + assertEquals(1, decoratorIds.get(2)); + verify(namespaceStore).namespaceExists(namespace); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/producer/TestDecoratorStoreProducerShould.java b/calm-hub/src/test/java/org/finos/calm/store/producer/TestDecoratorStoreProducerShould.java new file mode 100644 index 000000000..2f5c58b24 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/producer/TestDecoratorStoreProducerShould.java @@ -0,0 +1,68 @@ +package org.finos.calm.store.producer; + +import io.quarkus.test.InjectMock; +import io.quarkus.test.junit.QuarkusTest; +import org.finos.calm.store.DecoratorStore; +import org.finos.calm.store.mongo.MongoDecoratorStore; +import org.finos.calm.store.nitrite.NitriteDecoratorStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.sameInstance; + +@QuarkusTest +public class TestDecoratorStoreProducerShould { + + @InjectMock + MongoDecoratorStore mongoDecoratorStore; + + @InjectMock + NitriteDecoratorStore nitriteDecoratorStore; + + private DecoratorStoreProducer decoratorStoreProducer; + + @BeforeEach + void setup() { + decoratorStoreProducer = new DecoratorStoreProducer(); + decoratorStoreProducer.mongoDecoratorStore = mongoDecoratorStore; + decoratorStoreProducer.nitriteDecoratorStore = nitriteDecoratorStore; + } + + @Test + void return_mongo_decorator_store_when_database_mode_is_mongo() { + // Given + decoratorStoreProducer.databaseMode = "mongo"; + + // When + DecoratorStore result = decoratorStoreProducer.produceDecoratorStore(); + + // Then + assertThat(result, is(sameInstance(mongoDecoratorStore))); + } + + @Test + void return_nitrite_decorator_store_when_database_mode_is_standalone() { + // Given + decoratorStoreProducer.databaseMode = "standalone"; + + // When + DecoratorStore result = decoratorStoreProducer.produceDecoratorStore(); + + // Then + assertThat(result, is(sameInstance(nitriteDecoratorStore))); + } + + @Test + void return_mongo_decorator_store_when_database_mode_is_not_recognized() { + // Given + decoratorStoreProducer.databaseMode = "unknown"; + + // When + DecoratorStore result = decoratorStoreProducer.produceDecoratorStore(); + + // Then + assertThat(result, is(sameInstance(mongoDecoratorStore))); + } +}