producers) {
*/
private boolean isValidProvider(ProductConsumerDTO provider) {
- if (provider.getValidity() == null || provider.getValidity().equals(BigDecimal.ZERO)) return true;
+ if (provider.getValidity() == null || provider.getValidity().compareTo(BigDecimal.ZERO) == 0) return true;
return isValidGrantedTs(provider.getGrantedTs(), provider.getValidity());
}
diff --git a/src/main/resources/db/migration/V20260902120000__add_attribute_schema_tables.sql b/src/main/resources/db/migration/V20260902120000__add_attribute_schema_tables.sql
new file mode 100644
index 0000000..2034188
--- /dev/null
+++ b/src/main/resources/db/migration/V20260902120000__add_attribute_schema_tables.sql
@@ -0,0 +1,124 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally
+ * attributed to the Department for Business and Trade (UK) as the governing entity.
+ */
+
+-- Which core entity types may carry dynamic attributes, and the table entity_id resolves against.
+CREATE TABLE attribute_scope (
+ id BIGSERIAL PRIMARY KEY,
+ code VARCHAR(50) NOT NULL,
+ table_name VARCHAR(150) NOT NULL,
+ description VARCHAR(500),
+ CONSTRAINT uq_attribute_scope__code UNIQUE (code)
+);
+
+-- Attribute vocabulary: name/type/validation metadata, independent of which scope(s) it applies to.
+CREATE TABLE attribute_definition (
+ id BIGSERIAL PRIMARY KEY,
+ namespace VARCHAR(150) NOT NULL,
+ name VARCHAR(150) NOT NULL,
+ display_name VARCHAR(255),
+ description TEXT NOT NULL,
+ data_type VARCHAR(50) NOT NULL,
+ multi_valued BOOLEAN NOT NULL DEFAULT FALSE,
+ allowed_values JSONB,
+ validation_pattern VARCHAR(500),
+ classification JSONB,
+ sensitive BOOLEAN NOT NULL DEFAULT FALSE,
+ is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ created_at TIMESTAMP NOT NULL DEFAULT now(),
+ created_by VARCHAR(255) NOT NULL,
+ updated_at TIMESTAMP,
+ updated_by VARCHAR(255),
+ CONSTRAINT uq_attribute_definition__namespace_name UNIQUE (namespace, name)
+);
+
+-- Which scopes a definition is valid on, whether required there, and its default.
+CREATE TABLE attribute_definition_scope (
+ id BIGSERIAL PRIMARY KEY,
+ attribute_definition_id BIGINT NOT NULL,
+ attribute_scope_id BIGINT NOT NULL,
+ required BOOLEAN NOT NULL DEFAULT FALSE,
+ default_value JSONB,
+ is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ created_at TIMESTAMP NOT NULL DEFAULT now(),
+ created_by VARCHAR(255) NOT NULL,
+ updated_at TIMESTAMP,
+ updated_by VARCHAR(255),
+ CONSTRAINT fk_attribute_definition_scope__attribute_definition_id
+ FOREIGN KEY (attribute_definition_id) REFERENCES attribute_definition (id),
+ CONSTRAINT fk_attribute_definition_scope__attribute_scope_id
+ FOREIGN KEY (attribute_scope_id) REFERENCES attribute_scope (id),
+ CONSTRAINT uq_attribute_definition_scope__definition_scope
+ UNIQUE (attribute_definition_id, attribute_scope_id)
+);
+CREATE INDEX idx_attribute_definition_scope__attribute_definition_id
+ ON attribute_definition_scope (attribute_definition_id);
+CREATE INDEX idx_attribute_definition_scope__attribute_scope_id
+ ON attribute_definition_scope (attribute_scope_id);
+
+-- Actual values. entity_id is polymorphic: PK of the row in attribute_scope.table_name for that pairing's
+-- scope, not a declared FK — enforced by the soft-delete trigger below, not by the database schema.
+CREATE TABLE attribute_value (
+ id BIGSERIAL PRIMARY KEY,
+ attribute_definition_scope_id BIGINT NOT NULL,
+ entity_id BIGINT NOT NULL,
+ value JSONB NOT NULL,
+ is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ created_at TIMESTAMP NOT NULL DEFAULT now(),
+ created_by VARCHAR(255) NOT NULL,
+ updated_at TIMESTAMP,
+ updated_by VARCHAR(255),
+ CONSTRAINT fk_attribute_value__attribute_definition_scope_id
+ FOREIGN KEY (attribute_definition_scope_id) REFERENCES attribute_definition_scope (id)
+);
+CREATE INDEX idx_attribute_value__entity_id ON attribute_value (entity_id);
+-- Backs the PEP's EXISTS sub-queries per constraint (attribute_definition_scope_id, entity_id, value).
+CREATE UNIQUE INDEX uq_attr_value_live
+ ON attribute_value (attribute_definition_scope_id, entity_id, value)
+ WHERE is_deleted = FALSE;
+
+INSERT INTO attribute_scope (code, table_name, description) VALUES
+ ('ORGANISATION', 'organisation', 'Attributes carried by an organisation'),
+ ('CONSUMER', 'consumer', 'Attributes carried by a consumer'),
+ ('PRODUCER', 'producer', 'Attributes carried by a producer'),
+ ('PRODUCT', 'product', 'Attributes carried by a product'),
+ ('SUBSCRIPTION', 'product_consumer', 'Attributes carried by a product/consumer subscription');
+
+-- Soft-delete any live attribute_value rows for the entity being removed, scoped to the deleted table.
+CREATE FUNCTION fn_attribute_value_soft_delete_on_entity_delete() RETURNS TRIGGER AS $$
+BEGIN
+ UPDATE attribute_value av
+ SET is_deleted = TRUE,
+ updated_at = now(),
+ updated_by = 'trigger:' || TG_TABLE_NAME
+ FROM attribute_definition_scope ads
+ JOIN attribute_scope asc_ ON asc_.id = ads.attribute_scope_id
+ WHERE av.attribute_definition_scope_id = ads.id
+ AND asc_.table_name = TG_TABLE_NAME
+ AND av.entity_id = OLD.id
+ AND av.is_deleted = FALSE;
+ RETURN OLD;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trg_organisation_attribute_value_soft_delete
+ AFTER DELETE ON organisation
+ FOR EACH ROW EXECUTE FUNCTION fn_attribute_value_soft_delete_on_entity_delete();
+
+CREATE TRIGGER trg_consumer_attribute_value_soft_delete
+ AFTER DELETE ON consumer
+ FOR EACH ROW EXECUTE FUNCTION fn_attribute_value_soft_delete_on_entity_delete();
+
+CREATE TRIGGER trg_producer_attribute_value_soft_delete
+ AFTER DELETE ON producer
+ FOR EACH ROW EXECUTE FUNCTION fn_attribute_value_soft_delete_on_entity_delete();
+
+CREATE TRIGGER trg_product_attribute_value_soft_delete
+ AFTER DELETE ON product
+ FOR EACH ROW EXECUTE FUNCTION fn_attribute_value_soft_delete_on_entity_delete();
+
+CREATE TRIGGER trg_product_consumer_attribute_value_soft_delete
+ AFTER DELETE ON product_consumer
+ FOR EACH ROW EXECUTE FUNCTION fn_attribute_value_soft_delete_on_entity_delete();
diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/PolicyAttributeFieldsSerializationTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/PolicyAttributeFieldsSerializationTest.java
new file mode 100644
index 0000000..ba54518
--- /dev/null
+++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/PolicyAttributeFieldsSerializationTest.java
@@ -0,0 +1,53 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally
+ * attributed to the Department for Business and Trade (UK) as the governing entity.
+ */
+
+package uk.gov.dbt.ndtp.ia.node.management.model.dto;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+
+/**
+ * A freshly-built (unpopulated) {@link ProducerDTO}/{@link ConsumerDTO}/{@link
+ * ProductConsumerDTO} must serialise its new policy attribute fields as {@code []}, never {@code
+ * null} or an omitted field - see design.md's "Empty, not null, arrays" decision.
+ */
+class PolicyAttributeFieldsSerializationTest {
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ @Test
+ void producerDto_policyAttributesSerialisesAsEmptyArray() throws Exception {
+ JsonNode json = objectMapper.readTree(
+ objectMapper.writeValueAsString(ProducerDTO.builder().build()));
+
+ assertThat(json.has("policyAttributes")).isTrue();
+ assertThat(json.get("policyAttributes").isArray()).isTrue();
+ assertThat(json.get("policyAttributes")).isEmpty();
+ }
+
+ @Test
+ void consumerDto_policyAttributeFieldsSerialiseAsEmptyArrays() throws Exception {
+ JsonNode json = objectMapper.readTree(
+ objectMapper.writeValueAsString(ConsumerDTO.builder().build()));
+
+ assertThat(json.get("policyAttributes").isArray()).isTrue();
+ assertThat(json.get("policyAttributes")).isEmpty();
+ assertThat(json.get("organisationPolicyAttributes").isArray()).isTrue();
+ assertThat(json.get("organisationPolicyAttributes")).isEmpty();
+ }
+
+ @Test
+ void productConsumerDto_policyAttributesSerialisesAsEmptyArray() throws Exception {
+ JsonNode json = objectMapper.readTree(
+ objectMapper.writeValueAsString(ProductConsumerDTO.builder().build()));
+
+ assertThat(json.get("policyAttributes").isArray()).isTrue();
+ assertThat(json.get("policyAttributes")).isEmpty();
+ }
+}
diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AbstractPostgresRepositoryTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AbstractPostgresRepositoryTest.java
new file mode 100644
index 0000000..df49d13
--- /dev/null
+++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AbstractPostgresRepositoryTest.java
@@ -0,0 +1,53 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally
+ * attributed to the Department for Business and Trade (UK) as the governing entity.
+ */
+
+package uk.gov.dbt.ndtp.ia.node.management.persistency.repository;
+
+import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
+import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.utility.DockerImageName;
+
+/**
+ * Base class for repository tests that need the real Flyway migrations and real
+ * Postgres behaviour (partial unique indexes, {@code plpgsql} triggers) that the
+ * project's shared H2 test profile ({@code src/test/resources/application.yml}) cannot
+ * provide. Points the Spring context at a shared Postgres container and re-enables
+ * Flyway (disabled in the shared profile) so migrations apply for real.
+ *
+ * The container is started eagerly in a static initializer rather than left to the
+ * {@code @Testcontainers}/{@code @Container} JUnit extension. With multiple concrete
+ * subclasses - each getting its own Spring context - relying on the extension's
+ * per-class {@code beforeAll} to start (or no-op past) the container raced against
+ * context refresh on CI and on some local Docker setups: the first class or two would
+ * see the container "started" but not yet accepting TCP connections, and every test in
+ * that class would time out. Starting synchronously here, before any JUnit lifecycle
+ * callback runs for any subclass, removes that race. Testcontainers' Ryuk reaper still
+ * cleans the container up at JVM exit; it is not tied to the JUnit5 extension.
+ */
+@DataJpaTest
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+public abstract class AbstractPostgresRepositoryTest {
+
+ static final PostgreSQLContainer> POSTGRES =
+ new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine"));
+
+ static {
+ POSTGRES.start();
+ }
+
+ @DynamicPropertySource
+ static void datasourceProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
+ registry.add("spring.datasource.username", POSTGRES::getUsername);
+ registry.add("spring.datasource.password", POSTGRES::getPassword);
+ registry.add("spring.datasource.driver-class-name", POSTGRES::getDriverClassName);
+ registry.add("spring.jpa.properties.hibernate.dialect", () -> "org.hibernate.dialect.PostgreSQLDialect");
+ registry.add("spring.flyway.enabled", () -> "true");
+ }
+}
diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionRepositoryTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionRepositoryTest.java
new file mode 100644
index 0000000..1ebdfb4
--- /dev/null
+++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionRepositoryTest.java
@@ -0,0 +1,64 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally
+ * attributed to the Department for Business and Trade (UK) as the governing entity.
+ */
+
+package uk.gov.dbt.ndtp.ia.node.management.persistency.repository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DataIntegrityViolationException;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinition;
+
+class AttributeDefinitionRepositoryTest extends AbstractPostgresRepositoryTest {
+
+ @Autowired
+ private AttributeDefinitionRepository attributeDefinitionRepository;
+
+ private static AttributeDefinition newDefinition(String namespace, String name) {
+ AttributeDefinition definition = new AttributeDefinition();
+ definition.setNamespace(namespace);
+ definition.setName(name);
+ definition.setDescription("Test attribute definition");
+ definition.setDataType("STRING");
+ definition.setCreatedAt(Timestamp.from(Instant.now()));
+ definition.setCreatedBy("test");
+ return definition;
+ }
+
+ @Test
+ void findByNamespaceAndName_returnsPersistedDefinition() {
+ attributeDefinitionRepository.saveAndFlush(newDefinition("policy", "risk-tier"));
+
+ Optional found =
+ attributeDefinitionRepository.findByNamespaceAndName("policy", "risk-tier");
+
+ assertThat(found).isPresent();
+ assertThat(found.get().getDataType()).isEqualTo("STRING");
+ assertThat(found.get().getMultiValued()).isFalse();
+ assertThat(found.get().getSensitive()).isFalse();
+ }
+
+ @Test
+ void findByNamespaceAndName_returnsEmptyForUnknownPair() {
+ Optional found = attributeDefinitionRepository.findByNamespaceAndName("nope", "nope");
+
+ assertThat(found).isEmpty();
+ }
+
+ @Test
+ void save_rejectsDuplicateNamespaceAndName() {
+ attributeDefinitionRepository.saveAndFlush(newDefinition("policy", "duplicate-check"));
+ AttributeDefinition duplicate = newDefinition("policy", "duplicate-check");
+
+ assertThrows(
+ DataIntegrityViolationException.class, () -> attributeDefinitionRepository.saveAndFlush(duplicate));
+ }
+}
diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionScopeRepositoryTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionScopeRepositoryTest.java
new file mode 100644
index 0000000..605dcb4
--- /dev/null
+++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionScopeRepositoryTest.java
@@ -0,0 +1,88 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally
+ * attributed to the Department for Business and Trade (UK) as the governing entity.
+ */
+
+package uk.gov.dbt.ndtp.ia.node.management.persistency.repository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DataIntegrityViolationException;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinition;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinitionScope;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeScope;
+
+class AttributeDefinitionScopeRepositoryTest extends AbstractPostgresRepositoryTest {
+
+ @Autowired
+ private AttributeDefinitionRepository attributeDefinitionRepository;
+
+ @Autowired
+ private AttributeScopeRepository attributeScopeRepository;
+
+ @Autowired
+ private AttributeDefinitionScopeRepository attributeDefinitionScopeRepository;
+
+ private AttributeDefinition persistDefinition(String name) {
+ AttributeDefinition definition = new AttributeDefinition();
+ definition.setNamespace("policy");
+ definition.setName(name);
+ definition.setDescription("Test attribute definition");
+ definition.setDataType("STRING");
+ definition.setCreatedAt(Timestamp.from(Instant.now()));
+ definition.setCreatedBy("test");
+ return attributeDefinitionRepository.saveAndFlush(definition);
+ }
+
+ private static AttributeDefinitionScope newBinding(
+ AttributeDefinition definition, AttributeScope scope, boolean required) {
+ AttributeDefinitionScope binding = new AttributeDefinitionScope();
+ binding.setAttributeDefinition(definition);
+ binding.setAttributeScope(scope);
+ binding.setRequired(required);
+ binding.setCreatedAt(Timestamp.from(Instant.now()));
+ binding.setCreatedBy("test");
+ return binding;
+ }
+
+ @Test
+ void findByAttributeDefinitionId_returnsAllBoundScopes() {
+ AttributeDefinition definition = persistDefinition("multi-scope-attr");
+ AttributeScope productScope =
+ attributeScopeRepository.findByCode("PRODUCT").orElseThrow();
+ AttributeScope consumerScope =
+ attributeScopeRepository.findByCode("CONSUMER").orElseThrow();
+
+ attributeDefinitionScopeRepository.saveAndFlush(newBinding(definition, productScope, true));
+ attributeDefinitionScopeRepository.saveAndFlush(newBinding(definition, consumerScope, false));
+
+ List bindings =
+ attributeDefinitionScopeRepository.findByAttributeDefinitionId(definition.getId());
+
+ assertThat(bindings).hasSize(2);
+ assertThat(bindings)
+ .extracting(b -> b.getAttributeScope().getId())
+ .containsExactlyInAnyOrder(productScope.getId(), consumerScope.getId());
+ }
+
+ @Test
+ void save_rejectsDuplicateDefinitionScopePair() {
+ AttributeDefinition definition = persistDefinition("duplicate-binding-attr");
+ AttributeScope productScope =
+ attributeScopeRepository.findByCode("PRODUCT").orElseThrow();
+ attributeDefinitionScopeRepository.saveAndFlush(newBinding(definition, productScope, false));
+
+ AttributeDefinitionScope duplicate = newBinding(definition, productScope, true);
+
+ assertThrows(
+ DataIntegrityViolationException.class,
+ () -> attributeDefinitionScopeRepository.saveAndFlush(duplicate));
+ }
+}
diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeScopeRepositoryTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeScopeRepositoryTest.java
new file mode 100644
index 0000000..a76c944
--- /dev/null
+++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeScopeRepositoryTest.java
@@ -0,0 +1,47 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally
+ * attributed to the Department for Business and Trade (UK) as the governing entity.
+ */
+
+package uk.gov.dbt.ndtp.ia.node.management.persistency.repository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DataIntegrityViolationException;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeScope;
+
+class AttributeScopeRepositoryTest extends AbstractPostgresRepositoryTest {
+
+ @Autowired
+ private AttributeScopeRepository attributeScopeRepository;
+
+ @Test
+ void findByCode_returnsSeededScope() {
+ Optional found = attributeScopeRepository.findByCode("PRODUCT");
+
+ assertThat(found).isPresent();
+ assertThat(found.get().getTableName()).isEqualTo("product");
+ }
+
+ @Test
+ void findByCode_returnsEmptyForUnknownCode() {
+ Optional found = attributeScopeRepository.findByCode("DOES_NOT_EXIST");
+
+ assertThat(found).isEmpty();
+ }
+
+ @Test
+ void save_rejectsDuplicateCode() {
+ AttributeScope duplicate = new AttributeScope();
+ duplicate.setCode("PRODUCT");
+ duplicate.setTableName("product");
+ duplicate.setDescription("Duplicate of the seeded PRODUCT scope");
+
+ assertThrows(DataIntegrityViolationException.class, () -> attributeScopeRepository.saveAndFlush(duplicate));
+ }
+}
diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueRepositoryTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueRepositoryTest.java
new file mode 100644
index 0000000..3f08068
--- /dev/null
+++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueRepositoryTest.java
@@ -0,0 +1,206 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally
+ * attributed to the Department for Business and Trade (UK) as the governing entity.
+ */
+
+package uk.gov.dbt.ndtp.ia.node.management.persistency.repository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import jakarta.persistence.EntityManagerFactory;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.List;
+import org.hibernate.SessionFactory;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinition;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinitionScope;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeScope;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeValue;
+
+class AttributeValueRepositoryTest extends AbstractPostgresRepositoryTest {
+
+ @DynamicPropertySource
+ static void statisticsProperty(DynamicPropertyRegistry registry) {
+ registry.add("spring.jpa.properties.hibernate.generate_statistics", () -> "true");
+ }
+
+ @Autowired
+ private AttributeDefinitionRepository attributeDefinitionRepository;
+
+ @Autowired
+ private AttributeScopeRepository attributeScopeRepository;
+
+ @Autowired
+ private AttributeDefinitionScopeRepository attributeDefinitionScopeRepository;
+
+ @Autowired
+ private AttributeValueRepository attributeValueRepository;
+
+ @Autowired
+ private EntityManagerFactory entityManagerFactory;
+
+ private AttributeDefinitionScope persistProductScopedBinding(String attributeName) {
+ return persistScopedBinding(attributeName, "PRODUCT");
+ }
+
+ private AttributeDefinitionScope persistScopedBinding(String attributeName, String scopeCode) {
+ AttributeDefinition definition = new AttributeDefinition();
+ definition.setNamespace("policy");
+ definition.setName(attributeName);
+ definition.setDescription("Test attribute definition");
+ definition.setDataType("STRING");
+ definition.setCreatedAt(Timestamp.from(Instant.now()));
+ definition.setCreatedBy("test");
+ definition = attributeDefinitionRepository.saveAndFlush(definition);
+
+ AttributeScope scope = attributeScopeRepository.findByCode(scopeCode).orElseThrow();
+
+ AttributeDefinitionScope binding = new AttributeDefinitionScope();
+ binding.setAttributeDefinition(definition);
+ binding.setAttributeScope(scope);
+ binding.setRequired(false);
+ binding.setCreatedAt(Timestamp.from(Instant.now()));
+ binding.setCreatedBy("test");
+ return attributeDefinitionScopeRepository.saveAndFlush(binding);
+ }
+
+ private static AttributeValue newValue(AttributeDefinitionScope binding, Long entityId, String json) {
+ AttributeValue value = new AttributeValue();
+ value.setAttributeDefinitionScope(binding);
+ value.setEntityId(entityId);
+ value.setValue(json);
+ value.setCreatedAt(Timestamp.from(Instant.now()));
+ value.setCreatedBy("test");
+ return value;
+ }
+
+ @Test
+ void findLiveValue_returnsNonDeletedValue() {
+ AttributeDefinitionScope binding = persistProductScopedBinding("live-value-attr");
+ attributeValueRepository.saveAndFlush(newValue(binding, 1001L, "\"gold\""));
+
+ List live =
+ attributeValueRepository.findByAttributeDefinitionScopeIdAndEntityIdAndIsDeletedFalse(
+ binding.getId(), 1001L);
+
+ assertThat(live).hasSize(1);
+ assertThat(live.get(0).getValue()).isEqualTo("\"gold\"");
+ }
+
+ @Test
+ void findLiveValue_excludesSoftDeletedValue() {
+ AttributeDefinitionScope binding = persistProductScopedBinding("soft-deleted-attr");
+ AttributeValue value = newValue(binding, 1002L, "\"silver\"");
+ value.setIsDeleted(true);
+ attributeValueRepository.saveAndFlush(value);
+
+ List live =
+ attributeValueRepository.findByAttributeDefinitionScopeIdAndEntityIdAndIsDeletedFalse(
+ binding.getId(), 1002L);
+
+ assertThat(live).isEmpty();
+ }
+
+ @Test
+ void save_rejectsExactDuplicateLiveValue() {
+ AttributeDefinitionScope binding = persistProductScopedBinding("duplicate-value-attr");
+ attributeValueRepository.saveAndFlush(newValue(binding, 1003L, "\"gold\""));
+
+ AttributeValue duplicate = newValue(binding, 1003L, "\"gold\"");
+
+ assertThrows(DataIntegrityViolationException.class, () -> attributeValueRepository.saveAndFlush(duplicate));
+ }
+
+ @Test
+ void save_acceptsDistinctValueForSameBindingAndEntity() {
+ // uq_attr_value_live keys on (scope, entity, value) - it is an idempotency guard against exact
+ // duplicates, not a single-valuedness constraint, so a different value is allowed. See design.md.
+ AttributeDefinitionScope binding = persistProductScopedBinding("multi-valued-attr");
+ attributeValueRepository.saveAndFlush(newValue(binding, 1004L, "\"gold\""));
+
+ attributeValueRepository.saveAndFlush(newValue(binding, 1004L, "\"silver\""));
+
+ List live =
+ attributeValueRepository.findByAttributeDefinitionScopeIdAndEntityIdAndIsDeletedFalse(
+ binding.getId(), 1004L);
+ assertThat(live)
+ .hasSize(2)
+ .extracting(AttributeValue::getValue)
+ .containsExactlyInAnyOrder("\"gold\"", "\"silver\"");
+ }
+
+ @Test
+ void save_allowsSameValueAgainAfterPriorDuplicateIsSoftDeleted() {
+ AttributeDefinitionScope binding = persistProductScopedBinding("resurrected-attr");
+ AttributeValue first = newValue(binding, 1005L, "\"gold\"");
+ first = attributeValueRepository.saveAndFlush(first);
+ first.setIsDeleted(true);
+ attributeValueRepository.saveAndFlush(first);
+
+ AttributeValue resurrected = newValue(binding, 1005L, "\"gold\"");
+ attributeValueRepository.saveAndFlush(resurrected);
+
+ List live =
+ attributeValueRepository.findByAttributeDefinitionScopeIdAndEntityIdAndIsDeletedFalse(
+ binding.getId(), 1005L);
+ assertThat(live).hasSize(1);
+ assertThat(live.get(0).getValue()).isEqualTo("\"gold\"");
+ }
+
+ // findLiveByEntityIdAndScopeCode - task 1.1
+
+ @Test
+ void findLiveByEntityIdAndScopeCode_returnsMatchWithDefinitionEagerlyFetched() {
+ AttributeDefinitionScope binding = persistScopedBinding("producer-tier", "PRODUCER");
+ attributeValueRepository.saveAndFlush(newValue(binding, 2001L, "\"gold\""));
+
+ SessionFactory sessionFactory = entityManagerFactory.unwrap(SessionFactory.class);
+ sessionFactory.getStatistics().clear();
+
+ List live = attributeValueRepository.findLiveByEntityIdAndScopeCode(2001L, "PRODUCER");
+
+ assertThat(live).hasSize(1);
+ AttributeValue found = live.get(0);
+ // Accessing the definition must not trigger an additional query - proves the JOIN FETCH,
+ // not lazy N+1 loading, populated it.
+ assertThat(found.getAttributeDefinitionScope().getAttributeDefinition().getName())
+ .isEqualTo("producer-tier");
+ assertThat(sessionFactory.getStatistics().getQueryExecutionCount()).isEqualTo(1);
+ }
+
+ @Test
+ void findLiveByEntityIdAndScopeCode_excludesSoftDeletedValue() {
+ AttributeDefinitionScope binding = persistScopedBinding("producer-tier-deleted", "PRODUCER");
+ AttributeValue value = newValue(binding, 2002L, "\"gold\"");
+ value.setIsDeleted(true);
+ attributeValueRepository.saveAndFlush(value);
+
+ List live = attributeValueRepository.findLiveByEntityIdAndScopeCode(2002L, "PRODUCER");
+
+ assertThat(live).isEmpty();
+ }
+
+ @Test
+ void findLiveByEntityIdAndScopeCode_excludesValueUnderDifferentScope() {
+ AttributeDefinitionScope binding = persistScopedBinding("consumer-only-tier", "CONSUMER");
+ attributeValueRepository.saveAndFlush(newValue(binding, 2003L, "\"gold\""));
+
+ List live = attributeValueRepository.findLiveByEntityIdAndScopeCode(2003L, "PRODUCER");
+
+ assertThat(live).isEmpty();
+ }
+
+ @Test
+ void findLiveByEntityIdAndScopeCode_returnsEmptyForUnknownEntity() {
+ List live = attributeValueRepository.findLiveByEntityIdAndScopeCode(999999L, "PRODUCER");
+
+ assertThat(live).isEmpty();
+ }
+}
diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueSoftDeleteTriggerTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueSoftDeleteTriggerTest.java
new file mode 100644
index 0000000..c8ffe58
--- /dev/null
+++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueSoftDeleteTriggerTest.java
@@ -0,0 +1,235 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally
+ * attributed to the Department for Business and Trade (UK) as the governing entity.
+ */
+
+package uk.gov.dbt.ndtp.ia.node.management.persistency.repository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.math.BigDecimal;
+import java.sql.Timestamp;
+import java.time.Instant;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinition;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinitionScope;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeScope;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeValue;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Consumer;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Organisation;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Producer;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Product;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.ProductConsumer;
+
+/**
+ * Verifies the migration's five {@code AFTER DELETE} triggers, which soft-delete
+ * {@code attribute_value} rows scoped to the deleted owning entity rather than
+ * leaving them orphaned.
+ */
+class AttributeValueSoftDeleteTriggerTest extends AbstractPostgresRepositoryTest {
+
+ @Autowired
+ private OrganisationRepository organisationRepository;
+
+ @Autowired
+ private ProducerRepository producerRepository;
+
+ @Autowired
+ private ConsumerRepository consumerRepository;
+
+ @Autowired
+ private ProductRepository productRepository;
+
+ @Autowired
+ private ProductConsumerRepository productConsumerRepository;
+
+ @Autowired
+ private AttributeScopeRepository attributeScopeRepository;
+
+ @Autowired
+ private AttributeDefinitionRepository attributeDefinitionRepository;
+
+ @Autowired
+ private AttributeDefinitionScopeRepository attributeDefinitionScopeRepository;
+
+ @Autowired
+ private AttributeValueRepository attributeValueRepository;
+
+ @Autowired
+ private TestEntityManager testEntityManager;
+
+ private AttributeDefinitionScope bindingFor(String scopeCode, String attributeName) {
+ AttributeDefinition definition = new AttributeDefinition();
+ definition.setNamespace("policy");
+ definition.setName(attributeName);
+ definition.setDescription("Trigger test attribute definition");
+ definition.setDataType("STRING");
+ definition.setCreatedAt(Timestamp.from(Instant.now()));
+ definition.setCreatedBy("test");
+ definition = attributeDefinitionRepository.saveAndFlush(definition);
+
+ AttributeScope scope = attributeScopeRepository.findByCode(scopeCode).orElseThrow();
+
+ AttributeDefinitionScope binding = new AttributeDefinitionScope();
+ binding.setAttributeDefinition(definition);
+ binding.setAttributeScope(scope);
+ binding.setRequired(false);
+ binding.setCreatedAt(Timestamp.from(Instant.now()));
+ binding.setCreatedBy("test");
+ return attributeDefinitionScopeRepository.saveAndFlush(binding);
+ }
+
+ private Long persistLiveValue(AttributeDefinitionScope binding, Long entityId) {
+ AttributeValue value = new AttributeValue();
+ value.setAttributeDefinitionScope(binding);
+ value.setEntityId(entityId);
+ value.setValue("\"trigger-test-value\"");
+ value.setCreatedAt(Timestamp.from(Instant.now()));
+ value.setCreatedBy("test");
+ return attributeValueRepository.saveAndFlush(value).getId();
+ }
+
+ private Organisation persistOrganisation() {
+ Organisation organisation = new Organisation();
+ organisation.setName("Trigger Test Org");
+ return organisationRepository.saveAndFlush(organisation);
+ }
+
+ private Consumer persistConsumer(Organisation organisation) {
+ Consumer consumer = new Consumer();
+ consumer.setName("Trigger Test Consumer");
+ consumer.setOrg(organisation);
+ consumer.setIdpClientId("trigger-test-consumer");
+ consumer.setScheduleType("cron");
+ return consumerRepository.saveAndFlush(consumer);
+ }
+
+ private Producer persistProducer(Organisation organisation) {
+ Producer producer = new Producer();
+ producer.setName("Trigger Test Producer");
+ producer.setDescription("Trigger test producer");
+ producer.setOrg(organisation);
+ producer.setActive(true);
+ producer.setHost("localhost");
+ producer.setPort(BigDecimal.valueOf(8080));
+ producer.setTls(true);
+ producer.setIdpClientId("trigger-test-producer");
+ return producerRepository.saveAndFlush(producer);
+ }
+
+ private Product persistProduct(Producer producer) {
+ Product product = new Product();
+ product.setName("Trigger Test Product");
+ product.setTopic("topic.trigger-test");
+ product.setProducer(producer);
+ return productRepository.saveAndFlush(product);
+ }
+
+ private ProductConsumer persistProductConsumer(Product product, Consumer consumer) {
+ ProductConsumer productConsumer = new ProductConsumer();
+ productConsumer.setProduct(product);
+ productConsumer.setConsumer(consumer);
+ productConsumer.setGrantedTs(Timestamp.from(Instant.now()));
+ productConsumer.setValidity(BigDecimal.valueOf(30));
+ productConsumer.setScheduleType("cron");
+ return productConsumerRepository.saveAndFlush(productConsumer);
+ }
+
+ @Test
+ void deletingOrganisation_softDeletesItsAttributeValues() {
+ Organisation organisation = persistOrganisation();
+ AttributeDefinitionScope binding = bindingFor("ORGANISATION", "org-trigger-attr");
+ Long valueId = persistLiveValue(binding, organisation.getId());
+
+ organisationRepository.delete(organisation);
+ organisationRepository.flush();
+ testEntityManager.clear();
+
+ assertThat(attributeValueRepository.findById(valueId)).isPresent().get().satisfies(v -> assertThat(
+ v.getIsDeleted())
+ .isTrue());
+ }
+
+ @Test
+ void deletingConsumer_softDeletesItsAttributeValues() {
+ Organisation organisation = persistOrganisation();
+ Consumer consumer = persistConsumer(organisation);
+ AttributeDefinitionScope binding = bindingFor("CONSUMER", "consumer-trigger-attr");
+ Long valueId = persistLiveValue(binding, consumer.getId());
+
+ consumerRepository.delete(consumer);
+ consumerRepository.flush();
+ testEntityManager.clear();
+
+ assertThat(attributeValueRepository.findById(valueId)).isPresent().get().satisfies(v -> assertThat(
+ v.getIsDeleted())
+ .isTrue());
+ }
+
+ @Test
+ void deletingProducer_softDeletesItsAttributeValues() {
+ Organisation organisation = persistOrganisation();
+ Producer producer = persistProducer(organisation);
+ AttributeDefinitionScope binding = bindingFor("PRODUCER", "producer-trigger-attr");
+ Long valueId = persistLiveValue(binding, producer.getId());
+
+ producerRepository.delete(producer);
+ producerRepository.flush();
+ testEntityManager.clear();
+
+ assertThat(attributeValueRepository.findById(valueId)).isPresent().get().satisfies(v -> assertThat(
+ v.getIsDeleted())
+ .isTrue());
+ }
+
+ @Test
+ void deletingProduct_softDeletesItsAttributeValues() {
+ Organisation organisation = persistOrganisation();
+ Producer producer = persistProducer(organisation);
+ Product product = persistProduct(producer);
+ AttributeDefinitionScope binding = bindingFor("PRODUCT", "product-trigger-attr");
+ Long valueId = persistLiveValue(binding, product.getId());
+
+ productRepository.delete(product);
+ productRepository.flush();
+ testEntityManager.clear();
+
+ assertThat(attributeValueRepository.findById(valueId)).isPresent().get().satisfies(v -> assertThat(
+ v.getIsDeleted())
+ .isTrue());
+ }
+
+ @Test
+ void deletingProductConsumer_softDeletesItsAttributeValues() {
+ Organisation organisation = persistOrganisation();
+ Producer producer = persistProducer(organisation);
+ Product product = persistProduct(producer);
+ Consumer consumer = persistConsumer(organisation);
+ ProductConsumer productConsumer = persistProductConsumer(product, consumer);
+ AttributeDefinitionScope binding = bindingFor("SUBSCRIPTION", "subscription-trigger-attr");
+ Long valueId = persistLiveValue(binding, productConsumer.getId());
+
+ productConsumerRepository.delete(productConsumer);
+ productConsumerRepository.flush();
+ testEntityManager.clear();
+
+ assertThat(attributeValueRepository.findById(valueId)).isPresent().get().satisfies(v -> assertThat(
+ v.getIsDeleted())
+ .isTrue());
+ }
+
+ @Test
+ void deletingEntityWithNoAttributeValues_succeedsAndLeavesAttributeValueTableUntouched() {
+ Organisation organisation = persistOrganisation();
+ long countBefore = attributeValueRepository.count();
+
+ organisationRepository.delete(organisation);
+ organisationRepository.flush();
+
+ assertThat(organisationRepository.existsById(organisation.getId())).isFalse();
+ assertThat(attributeValueRepository.count()).isEqualTo(countBefore);
+ }
+}
diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/PolicyAttributeScopeTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/PolicyAttributeScopeTest.java
new file mode 100644
index 0000000..db8eb11
--- /dev/null
+++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/PolicyAttributeScopeTest.java
@@ -0,0 +1,40 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally
+ * attributed to the Department for Business and Trade (UK) as the governing entity.
+ */
+
+package uk.gov.dbt.ndtp.ia.node.management.service.data;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.springframework.beans.factory.annotation.Autowired;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.AbstractPostgresRepositoryTest;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.AttributeScopeRepository;
+
+/**
+ * Verifies every {@link PolicyAttributeScope} constant's code names a real, seeded {@code
+ * attribute_scope.code} row - against real Postgres, so a future rename of a seeded code would
+ * be caught here rather than only surfacing as an always-empty result at runtime.
+ */
+class PolicyAttributeScopeTest extends AbstractPostgresRepositoryTest {
+
+ @Autowired
+ private AttributeScopeRepository attributeScopeRepository;
+
+ @ParameterizedTest
+ @EnumSource(PolicyAttributeScope.class)
+ void code_namesASeededAttributeScopeRow(PolicyAttributeScope scope) {
+ assertThat(attributeScopeRepository.findByCode(scope.code())).isPresent();
+ }
+
+ @Test
+ void doesNotIncludeProductScope() {
+ assertThat(PolicyAttributeScope.values())
+ .extracting(PolicyAttributeScope::code)
+ .doesNotContain("PRODUCT");
+ }
+}
diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/PolicyAttributeServiceImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/PolicyAttributeServiceImplTest.java
new file mode 100644
index 0000000..51ef86d
--- /dev/null
+++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/PolicyAttributeServiceImplTest.java
@@ -0,0 +1,100 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally
+ * attributed to the Department for Business and Trade (UK) as the governing entity.
+ */
+
+package uk.gov.dbt.ndtp.ia.node.management.service.data.impl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.util.List;
+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 uk.gov.dbt.ndtp.ia.node.management.model.dto.PolicyAttributeDTO;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinition;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinitionScope;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeValue;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.AttributeValueRepository;
+import uk.gov.dbt.ndtp.ia.node.management.service.data.PolicyAttributeScope;
+
+@ExtendWith(MockitoExtension.class)
+class PolicyAttributeServiceImplTest {
+
+ @Mock
+ private AttributeValueRepository attributeValueRepository;
+
+ private PolicyAttributeServiceImpl service;
+
+ @BeforeEach
+ void setUp() {
+ service = new PolicyAttributeServiceImpl(attributeValueRepository, new ObjectMapper());
+ }
+
+ private static AttributeValue attributeValue(String namespace, String name, String dataType, String rawJson) {
+ AttributeDefinition definition = new AttributeDefinition();
+ definition.setNamespace(namespace);
+ definition.setName(name);
+ definition.setDataType(dataType);
+
+ AttributeDefinitionScope binding = new AttributeDefinitionScope();
+ binding.setAttributeDefinition(definition);
+
+ AttributeValue value = new AttributeValue();
+ value.setAttributeDefinitionScope(binding);
+ value.setValue(rawJson);
+ return value;
+ }
+
+ @Test
+ void findAttributes_mapsNamespaceDotNameValueAndType() {
+ when(attributeValueRepository.findLiveByEntityIdAndScopeCode(10L, "PRODUCER"))
+ .thenReturn(List.of(attributeValue("policy", "risk-tier", "STRING", "\"gold\"")));
+
+ List result = service.findAttributes(10L, PolicyAttributeScope.PRODUCER);
+
+ assertThat(result).hasSize(1);
+ PolicyAttributeDTO dto = result.get(0);
+ assertThat(dto.getName()).isEqualTo("policy.risk-tier");
+ assertThat(dto.getValue()).isEqualTo("gold");
+ assertThat(dto.getType()).isEqualTo("STRING");
+ }
+
+ @Test
+ void findAttributes_returnsEmptyListWhenRepositoryFindsNothing() {
+ when(attributeValueRepository.findLiveByEntityIdAndScopeCode(11L, "CONSUMER"))
+ .thenReturn(List.of());
+
+ List result = service.findAttributes(11L, PolicyAttributeScope.CONSUMER);
+
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ void findAttributes_rendersNonStringValueAsPlainText() {
+ when(attributeValueRepository.findLiveByEntityIdAndScopeCode(12L, "ORGANISATION"))
+ .thenReturn(List.of(
+ attributeValue("policy", "priority", "INTEGER", "42"),
+ attributeValue("policy", "enabled", "BOOLEAN", "true")));
+
+ List result = service.findAttributes(12L, PolicyAttributeScope.ORGANISATION);
+
+ assertThat(result).extracting(PolicyAttributeDTO::getValue).containsExactly("42", "true");
+ }
+
+ @Test
+ void findAttributes_fallsBackToRawTextOnMalformedStoredValue() {
+ when(attributeValueRepository.findLiveByEntityIdAndScopeCode(13L, "SUBSCRIPTION"))
+ .thenReturn(List.of(attributeValue("policy", "broken", "STRING", "not-valid-json{")));
+
+ List result = service.findAttributes(13L, PolicyAttributeScope.SUBSCRIPTION);
+
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).getValue()).isEqualTo("not-valid-json{");
+ }
+}
diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java
index 27a429e..d3d62c1 100644
--- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java
+++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java
@@ -27,6 +27,8 @@
import org.mockito.MockitoAnnotations;
import uk.gov.dbt.ndtp.ia.node.management.model.dto.*;
import uk.gov.dbt.ndtp.ia.node.management.service.data.ConsumerService;
+import uk.gov.dbt.ndtp.ia.node.management.service.data.PolicyAttributeScope;
+import uk.gov.dbt.ndtp.ia.node.management.service.data.PolicyAttributeService;
import uk.gov.dbt.ndtp.ia.node.management.service.data.ProducerService;
import uk.gov.dbt.ndtp.ia.node.management.service.data.ProductConsumerService;
import uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate.CertificateValidationProvider;
@@ -45,6 +47,9 @@ class ConfigurationProviderImplTest {
@Mock
private CertificateValidationProvider certificateValidationProvider;
+ @Mock
+ private PolicyAttributeService policyAttributeService;
+
@InjectMocks
private ConfigurationProviderImpl configurationProvider;
@@ -52,7 +57,11 @@ class ConfigurationProviderImplTest {
void setUp() {
MockitoAnnotations.openMocks(this);
configurationProvider = new ConfigurationProviderImpl(
- consumerService, productConsumerService, producerService, certificateValidationProvider);
+ consumerService,
+ productConsumerService,
+ producerService,
+ certificateValidationProvider,
+ policyAttributeService);
// Default: treat all orgs as having active certificates, override in specific
// tests to simulate inactive/missing certs.
when(certificateValidationProvider.findActiveOrganisationIds(any())).thenAnswer(invocation -> {
@@ -159,8 +168,7 @@ void getConsumerConfigByClientId_whenNoValidProducts_clearsAllProducerProducts()
void getConsumerConfigByClientId_withConsumerIdFilter_appliesFilter_andRemovesNullProductIds() {
String clientId = "clientC";
ConsumerDTO c1 = consumer(3L, clientId, "c3", "CRON", "@daily");
- ConsumerDTO cOther = consumer(99L, clientId, "other", "CRON", "@minutely");
- when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(c1, cOther));
+ when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(c1));
ProductConsumerDTO pc = productConsumer(300L, 3L, null, null);
when(productConsumerService.findByConsumerId(3L)).thenReturn(List.of(pc));
@@ -272,8 +280,8 @@ void getProducerConfigByClientId_withValidValidity_includesConsumer() {
void getConsumerConfigByClientId_withConsumerId_filtersByConsumerId() {
String clientId = "clientA";
ConsumerDTO c1 = consumer(1L, clientId, "c1", "CRON", "@hourly");
- ConsumerDTO c2 = consumer(2L, clientId, "c2", "CRON", "@daily");
- when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(c1, c2));
+
+ when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(c1));
ConsumerConfigDTO cfg = configurationProvider.getConsumerConfigByClientId(clientId, Optional.of(1L));
@@ -284,11 +292,9 @@ void getConsumerConfigByClientId_withConsumerId_filtersByConsumerId() {
void getProducerConfigByClientId_withProducerId_filtersByProducerId() {
String clientId = "producerClient";
ProductDTO p1 = product(100L, "p1");
- ProductDTO p2 = product(101L, "p2");
ProducerDTO pr1 = producer(1L, true, p1);
- ProducerDTO pr2 = producer(2L, true, p2);
- when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(pr1, pr2));
+ when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(pr1));
ProducerConfigDTO cfg = configurationProvider.getProducerConfigByClientId(clientId, Optional.of(1L));
@@ -368,4 +374,93 @@ void getProducerConfig_filtersOutConsumersWithInactiveCerts() {
assertThat(cfg.getProducers().get(0).getProducts().get(0).getConsumers())
.containsExactly(activeOrgConsumer);
}
+
+ // DPAV-3162: policy attribute wiring
+
+ @Test
+ void getProducerConfigByClientId_populatesPolicyAttributesForEveryScope() {
+ String clientId = "policyClient";
+ ProductDTO product = product(700L, "prod");
+ ProducerDTO producer = producer(70L, true, product);
+ when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(producer));
+ when(consumerService.getConsumersOfProviders(any())).thenReturn(Map.of());
+
+ ProductConsumerDTO subscription = productConsumer(700L, 701L, null, null);
+ subscription.setId(9001L);
+ when(productConsumerService.findByDataProviderId(700L)).thenReturn(List.of(subscription));
+
+ ConsumerDTO consumer = consumer(701L, "cid701", "c701", "CRON", "@hourly");
+ consumer.setOrgId(801L);
+ when(consumerService.findById(701L)).thenReturn(Optional.of(consumer));
+
+ PolicyAttributeDTO producerAttr = PolicyAttributeDTO.builder()
+ .name("policy.a")
+ .value("1")
+ .type("STRING")
+ .build();
+ PolicyAttributeDTO consumerAttr = PolicyAttributeDTO.builder()
+ .name("policy.b")
+ .value("2")
+ .type("STRING")
+ .build();
+ PolicyAttributeDTO orgAttr = PolicyAttributeDTO.builder()
+ .name("policy.c")
+ .value("3")
+ .type("STRING")
+ .build();
+ PolicyAttributeDTO subscriptionAttr = PolicyAttributeDTO.builder()
+ .name("policy.d")
+ .value("4")
+ .type("STRING")
+ .build();
+ when(policyAttributeService.findAttributes(70L, PolicyAttributeScope.PRODUCER))
+ .thenReturn(List.of(producerAttr));
+ when(policyAttributeService.findAttributes(701L, PolicyAttributeScope.CONSUMER))
+ .thenReturn(List.of(consumerAttr));
+ when(policyAttributeService.findAttributes(801L, PolicyAttributeScope.ORGANISATION))
+ .thenReturn(List.of(orgAttr));
+ when(policyAttributeService.findAttributes(9001L, PolicyAttributeScope.SUBSCRIPTION))
+ .thenReturn(List.of(subscriptionAttr));
+
+ ProducerConfigDTO cfg = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty());
+
+ ProducerDTO returnedProducer = cfg.getProducers().get(0);
+ assertThat(returnedProducer.getPolicyAttributes()).containsExactly(producerAttr);
+
+ ConsumerDTO returnedConsumer =
+ returnedProducer.getProducts().get(0).getConsumers().get(0);
+ assertThat(returnedConsumer.getPolicyAttributes()).containsExactly(consumerAttr);
+ assertThat(returnedConsumer.getOrganisationPolicyAttributes()).containsExactly(orgAttr);
+
+ ProductConsumerDTO returnedSubscription =
+ returnedProducer.getProducts().get(0).getConfigurations().get(0);
+ assertThat(returnedSubscription.getPolicyAttributes()).containsExactly(subscriptionAttr);
+ }
+
+ @Test
+ void getProducerConfigByClientId_producerWithNoAttributes_getsEmptyPolicyAttributesList() {
+ String clientId = "noAttrClient";
+ ProducerDTO producer = producer(71L, true);
+ when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(producer));
+ when(consumerService.getConsumersOfProviders(any())).thenReturn(Map.of());
+ when(policyAttributeService.findAttributes(71L, PolicyAttributeScope.PRODUCER))
+ .thenReturn(List.of());
+
+ ProducerConfigDTO cfg = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty());
+
+ assertThat(cfg.getProducers().get(0).getPolicyAttributes()).isEmpty();
+ }
+
+ @Test
+ void getConsumerConfigByClientId_neverCallsPolicyAttributeService() {
+ String clientId = "clientA";
+ ConsumerDTO c1 = consumer(1L, clientId, "c1", "CRON", "@hourly");
+ when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(c1));
+ when(productConsumerService.findByConsumerId(1L)).thenReturn(List.of());
+ when(producerService.getProducersByConsumerIds(List.of(1L))).thenReturn(List.of());
+
+ configurationProvider.getConsumerConfigByClientId(clientId, Optional.empty());
+
+ verifyNoInteractions(policyAttributeService);
+ }
}
diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ProducerConfigPolicyAttributesIntegrationTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ProducerConfigPolicyAttributesIntegrationTest.java
new file mode 100644
index 0000000..cce6505
--- /dev/null
+++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ProducerConfigPolicyAttributesIntegrationTest.java
@@ -0,0 +1,258 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally
+ * attributed to the Department for Business and Trade (UK) as the governing entity.
+ */
+
+package uk.gov.dbt.ndtp.ia.node.management.service.providers.configuration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.groups.Tuple.tuple;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.persistence.EntityManager;
+import java.math.BigDecimal;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.HashSet;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Import;
+import org.springframework.transaction.annotation.Transactional;
+import uk.gov.dbt.ndtp.ia.node.management.converter.impl.ConsumerConverter;
+import uk.gov.dbt.ndtp.ia.node.management.converter.impl.OrganisationProducerConverter;
+import uk.gov.dbt.ndtp.ia.node.management.converter.impl.ProductConsumerConverter;
+import uk.gov.dbt.ndtp.ia.node.management.converter.impl.ProductConverter;
+import uk.gov.dbt.ndtp.ia.node.management.model.dto.ConsumerDTO;
+import uk.gov.dbt.ndtp.ia.node.management.model.dto.PolicyAttributeDTO;
+import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProducerConfigDTO;
+import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProducerDTO;
+import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductConsumerDTO;
+import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductDTO;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinition;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinitionScope;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeScope;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeValue;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Consumer;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Organisation;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Producer;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Product;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.ProductConsumer;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.ProductType;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.AbstractPostgresRepositoryTest;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.AttributeDefinitionRepository;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.AttributeDefinitionScopeRepository;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.AttributeScopeRepository;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.AttributeValueRepository;
+import uk.gov.dbt.ndtp.ia.node.management.service.data.ConsumerService;
+import uk.gov.dbt.ndtp.ia.node.management.service.data.PolicyAttributeService;
+import uk.gov.dbt.ndtp.ia.node.management.service.data.ProducerService;
+import uk.gov.dbt.ndtp.ia.node.management.service.data.ProductConsumerService;
+import uk.gov.dbt.ndtp.ia.node.management.service.data.impl.ConsumerServiceImpl;
+import uk.gov.dbt.ndtp.ia.node.management.service.data.impl.PolicyAttributeServiceImpl;
+import uk.gov.dbt.ndtp.ia.node.management.service.data.impl.ProducerServiceImpl;
+import uk.gov.dbt.ndtp.ia.node.management.service.data.impl.ProductConsumerServiceImpl;
+import uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate.CertificateValidationProvider;
+
+/**
+ * End-to-end coverage (real Postgres, real converters/services, real {@link
+ * PolicyAttributeServiceImpl}) of DPAV-3162's core requirement: {@code
+ * GET /api/v1/configuration/producer}'s response carries each resource's live policy attributes
+ * across all four scopes, and a resource with none gets {@code []}. {@link
+ * CertificateValidationProvider} is a plain Mockito mock (not the real certificate chain) - out
+ * of scope for what this test needs to prove, already covered elsewhere.
+ */
+@Transactional
+@Import({
+ OrganisationProducerConverter.class,
+ ProductConverter.class,
+ ConsumerConverter.class,
+ ProductConsumerConverter.class,
+ ProducerServiceImpl.class,
+ ConsumerServiceImpl.class,
+ ProductConsumerServiceImpl.class
+})
+class ProducerConfigPolicyAttributesIntegrationTest extends AbstractPostgresRepositoryTest {
+
+ @Autowired
+ private EntityManager entityManager;
+
+ @Autowired
+ private ProducerService producerService;
+
+ @Autowired
+ private ConsumerService consumerService;
+
+ @Autowired
+ private ProductConsumerService productConsumerService;
+
+ @Autowired
+ private AttributeValueRepository attributeValueRepository;
+
+ @Autowired
+ private AttributeDefinitionRepository attributeDefinitionRepository;
+
+ @Autowired
+ private AttributeDefinitionScopeRepository attributeDefinitionScopeRepository;
+
+ @Autowired
+ private AttributeScopeRepository attributeScopeRepository;
+
+ private ConfigurationProviderImpl configurationProvider() {
+ CertificateValidationProvider certificateValidationProvider = mock(CertificateValidationProvider.class);
+ when(certificateValidationProvider.findActiveOrganisationIds(any()))
+ .thenAnswer(invocation -> new HashSet<>(invocation.getArgument(0)));
+ PolicyAttributeService policyAttributeService =
+ new PolicyAttributeServiceImpl(attributeValueRepository, new ObjectMapper());
+ return new ConfigurationProviderImpl(
+ consumerService,
+ productConsumerService,
+ producerService,
+ certificateValidationProvider,
+ policyAttributeService);
+ }
+
+ private Organisation persistOrganisation(String name) {
+ Organisation org = new Organisation();
+ org.setName(name);
+ entityManager.persist(org);
+ return org;
+ }
+
+ private Producer persistProducer(Organisation org, String name, String clientId) {
+ Producer producer = new Producer();
+ producer.setName(name);
+ producer.setDescription("test");
+ producer.setOrg(org);
+ producer.setActive(true);
+ producer.setHost("host.example");
+ producer.setPort(BigDecimal.valueOf(443));
+ producer.setTls(true);
+ producer.setIdpClientId(clientId);
+ entityManager.persist(producer);
+ return producer;
+ }
+
+ private Product persistProduct(Producer producer, String name) {
+ ProductType topicType = entityManager
+ .createQuery("SELECT t FROM ProductType t WHERE t.name = :name", ProductType.class)
+ .setParameter("name", "topic")
+ .getSingleResult();
+
+ Product product = new Product();
+ product.setName(name);
+ product.setTopic(name + "-topic");
+ product.setProducer(producer);
+ product.setProductType(topicType);
+ entityManager.persist(product);
+ return product;
+ }
+
+ private Consumer persistConsumer(Organisation org, String name, String clientId) {
+ Consumer consumer = new Consumer();
+ consumer.setName(name);
+ consumer.setScheduleType("cron");
+ consumer.setOrg(org);
+ consumer.setIdpClientId(clientId);
+ entityManager.persist(consumer);
+ return consumer;
+ }
+
+ private ProductConsumer persistSubscription(Product product, Consumer consumer) {
+ ProductConsumer subscription = new ProductConsumer();
+ subscription.setProduct(product);
+ subscription.setConsumer(consumer);
+ subscription.setGrantedTs(Timestamp.from(Instant.now()));
+ subscription.setValidity(BigDecimal.ZERO);
+ subscription.setScheduleType("cron");
+ entityManager.persist(subscription);
+ return subscription;
+ }
+
+ private void persistAttribute(String scopeCode, Long entityId, String attrName, String rawJsonValue) {
+ AttributeDefinition definition = new AttributeDefinition();
+ definition.setNamespace("policy");
+ definition.setName(attrName);
+ definition.setDescription("test");
+ definition.setDataType("STRING");
+ definition.setCreatedAt(Timestamp.from(Instant.now()));
+ definition.setCreatedBy("test");
+ definition = attributeDefinitionRepository.saveAndFlush(definition);
+
+ AttributeScope scope = attributeScopeRepository.findByCode(scopeCode).orElseThrow();
+ AttributeDefinitionScope binding = new AttributeDefinitionScope();
+ binding.setAttributeDefinition(definition);
+ binding.setAttributeScope(scope);
+ binding.setRequired(false);
+ binding.setCreatedAt(Timestamp.from(Instant.now()));
+ binding.setCreatedBy("test");
+ binding = attributeDefinitionScopeRepository.saveAndFlush(binding);
+
+ AttributeValue value = new AttributeValue();
+ value.setAttributeDefinitionScope(binding);
+ value.setEntityId(entityId);
+ value.setValue(rawJsonValue);
+ value.setCreatedAt(Timestamp.from(Instant.now()));
+ value.setCreatedBy("test");
+ entityManager.persist(value);
+ }
+
+ @Test
+ void producerConfig_carriesPolicyAttributesAcrossAllFourScopes_andEmptyForSiblingWithNone() {
+ Organisation producerOrg = persistOrganisation("producer-org");
+ Producer producer = persistProducer(producerOrg, "attributed-producer", "client-with-attrs");
+ Product product = persistProduct(producer, "prod-1");
+
+ Organisation consumerOrg = persistOrganisation("consumer-org");
+ Consumer consumer = persistConsumer(consumerOrg, "attributed-consumer", "consumer-client");
+ ProductConsumer subscription = persistSubscription(product, consumer);
+
+ entityManager.flush();
+
+ persistAttribute("PRODUCER", producer.getId(), "producer-tier", "\"gold\"");
+ persistAttribute("CONSUMER", consumer.getId(), "consumer-tier", "\"silver\"");
+ persistAttribute("ORGANISATION", consumerOrg.getId(), "org-region", "\"uk\"");
+ persistAttribute("SUBSCRIPTION", subscription.getId(), "sub-priority", "1");
+
+ // A sibling producer with no attributes at all, for the empty-array assertion. It still
+ // needs a product - getProducersByClientId inner-joins products, so a producer with none
+ // would never appear in the response regardless of policy attributes.
+ Producer bareProducer = persistProducer(producerOrg, "bare-producer", "client-no-attrs");
+ persistProduct(bareProducer, "prod-2");
+ entityManager.flush();
+ // Force subsequent reads through the query layer's JOIN FETCHes rather than reusing the
+ // self-constructed, association-less entity instances already sitting in this session's
+ // first-level cache.
+ entityManager.clear();
+
+ ProducerConfigDTO cfg =
+ configurationProvider().getProducerConfigByClientId("client-with-attrs", Optional.empty());
+
+ ProducerDTO producerDto = cfg.getProducers().get(0);
+ assertThat(producerDto.getPolicyAttributes())
+ .extracting(PolicyAttributeDTO::getName, PolicyAttributeDTO::getValue, PolicyAttributeDTO::getType)
+ .containsExactly(tuple("policy.producer-tier", "gold", "STRING"));
+
+ ProductDTO productDto = producerDto.getProducts().get(0);
+ ConsumerDTO consumerDto = productDto.getConsumers().get(0);
+ assertThat(consumerDto.getPolicyAttributes())
+ .extracting(PolicyAttributeDTO::getName, PolicyAttributeDTO::getValue, PolicyAttributeDTO::getType)
+ .containsExactly(tuple("policy.consumer-tier", "silver", "STRING"));
+ assertThat(consumerDto.getOrganisationPolicyAttributes())
+ .extracting(PolicyAttributeDTO::getName, PolicyAttributeDTO::getValue, PolicyAttributeDTO::getType)
+ .containsExactly(tuple("policy.org-region", "uk", "STRING"));
+
+ ProductConsumerDTO subscriptionDto = productDto.getConfigurations().get(0);
+ assertThat(subscriptionDto.getPolicyAttributes())
+ .extracting(PolicyAttributeDTO::getName, PolicyAttributeDTO::getValue, PolicyAttributeDTO::getType)
+ .containsExactly(tuple("policy.sub-priority", "1", "STRING"));
+
+ ProducerConfigDTO bareCfg =
+ configurationProvider().getProducerConfigByClientId("client-no-attrs", Optional.empty());
+ assertThat(bareCfg.getProducers().get(0).getPolicyAttributes()).isEmpty();
+ }
+}