diff --git a/.github/workflows/contract-tests.yaml b/.github/workflows/contract-tests.yaml
new file mode 100644
index 0000000..a8e346e
--- /dev/null
+++ b/.github/workflows/contract-tests.yaml
@@ -0,0 +1,37 @@
+name: Contract Tests
+
+on:
+ # Run on PRs (catches vol-api-calls changes)
+ pull_request:
+
+ # Run when triggered by olcs-transfer (catches backend contract changes)
+ repository_dispatch:
+ types: [contract-test]
+
+ # Weekly safety net
+ schedule:
+ - cron: '0 6 * * 1'
+
+jobs:
+ contract-tests:
+ name: Contract Tests
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: read
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Java 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'corretto'
+
+ - uses: whelk-io/maven-settings-xml-action@v22
+ with:
+ servers: '[{ "id":"dvsa-github-packages", "configuration": { "httpHeaders": { "property": { "name":"Authorization", "value":"Bearer ${{ secrets.GITHUB_TOKEN }}"} } } } ]'
+
+ - name: Run contract tests
+ run: mvn -B -P github test
diff --git a/pom.xml b/pom.xml
index 75324bf..c710941 100644
--- a/pom.xml
+++ b/pom.xml
@@ -310,6 +310,18 @@
${log4j-core.version}
+
+
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 5.11.3
+ test
+
+
diff --git a/src/main/java/apiCalls/enums/BusinessType.java b/src/main/java/apiCalls/enums/BusinessType.java
index fbcb114..0cd9c7f 100644
--- a/src/main/java/apiCalls/enums/BusinessType.java
+++ b/src/main/java/apiCalls/enums/BusinessType.java
@@ -5,7 +5,7 @@ public enum BusinessType {
SOLE_TRADER("org_t_st"),
PARTNERSHIP("org_t_p"),
LIMITED_PARTNERSHIP("org_t_llp"),
- OTHER(" org_t_pa");
+ OTHER("org_t_pa");
private final String value;
diff --git a/src/test/java/apiCalls/contract/builders/BuilderContractTest.java b/src/test/java/apiCalls/contract/builders/BuilderContractTest.java
new file mode 100644
index 0000000..906f23b
--- /dev/null
+++ b/src/test/java/apiCalls/contract/builders/BuilderContractTest.java
@@ -0,0 +1,264 @@
+package apiCalls.contract.builders;
+
+import apiCalls.Utils.volBuilders.*;
+import apiCalls.enums.TrackingStatus;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Contract tests validating that builder classes serialise to JSON with
+ * field names matching the olcs-transfer API contract.
+ *
+ * These tests catch:
+ * - @JsonProperty typos or renames
+ * - Missing fields in builders
+ * - Broken Jackson serialisation
+ * - Null-inclusion changes
+ *
+ * @see olcs-transfer
+ */
+@DisplayName("Builder serialisation matches olcs-transfer contract")
+class BuilderContractTest {
+
+ private static JsonNode toJson(Object builder) throws JsonProcessingException {
+ var m = new ObjectMapper();
+ return m.readTree(m.writeValueAsString(builder));
+ }
+
+ private static Set jsonFieldNames(JsonNode node) {
+ return StreamSupport.stream(
+ ((Iterable) node::fieldNames).spliterator(), false
+ ).collect(Collectors.toSet());
+ }
+
+ @Nested
+ @DisplayName("ApplicationBuilder → CreateApplication command")
+ class ApplicationBuilderContract {
+
+ @Test
+ @DisplayName("serialises with correct field names")
+ void fieldNames() throws JsonProcessingException {
+ var builder = new ApplicationBuilder()
+ .withOperatorType("lcat_gv")
+ .withLicenceType("ltyp_sn")
+ .withNiFlag("N")
+ .withOrganisation("123")
+ .withAppliedVia("applied_via_post")
+ .withVehicleType("app_veh_type_mixed")
+ .withLgvDeclarationConfirmation("1");
+
+ var json = toJson(builder);
+ assertThat(jsonFieldNames(json)).containsExactlyInAnyOrder(
+ "operatorType", "licenceType", "niFlag", "organisation",
+ "appliedVia", "vehicleType", "lgvDeclarationConfirmation"
+ );
+ }
+
+ @Test
+ @DisplayName("excludes null fields from payload")
+ void nullExclusion() throws JsonProcessingException {
+ var builder = new ApplicationBuilder()
+ .withOperatorType("lcat_gv")
+ .withLicenceType("ltyp_sn");
+
+ var json = toJson(builder);
+ assertThat(json.has("vehicleType")).isFalse();
+ assertThat(json.has("niFlag")).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("AddressBuilder → Address partial")
+ class AddressBuilderContract {
+
+ @Test
+ @DisplayName("serialises with correct field names")
+ void fieldNames() throws JsonProcessingException {
+ var builder = new AddressBuilder()
+ .withAddressLine1("123 Test St")
+ .withTown("Leeds")
+ .withPostcode("LS1 1AA")
+ .withCountryCode("GB");
+
+ var json = toJson(builder);
+ assertThat(jsonFieldNames(json)).containsExactlyInAnyOrder(
+ "addressLine1", "town", "postcode", "countryCode"
+ );
+ }
+
+ @Test
+ @DisplayName("supports all address lines")
+ void allLines() throws JsonProcessingException {
+ var builder = new AddressBuilder()
+ .withAddressLine1("Line 1")
+ .withAddressLine2("Line 2")
+ .withAddressLine3("Line 3")
+ .withAddressLine4("Line 4")
+ .withTown("Town")
+ .withPostcode("AB1 2CD")
+ .withCountryCode("GB")
+ .withVersion("1");
+
+ var json = toJson(builder);
+ assertThat(jsonFieldNames(json)).containsExactlyInAnyOrder(
+ "version", "addressLine1", "addressLine2", "addressLine3",
+ "addressLine4", "town", "postcode", "countryCode"
+ );
+ }
+ }
+
+ @Nested
+ @DisplayName("ContactDetailsBuilder → ContactDetails partial")
+ class ContactDetailsBuilderContract {
+
+ @Test
+ @DisplayName("field names map to olcs-transfer contract")
+ void fieldNameMapping() throws JsonProcessingException {
+ // olcs-transfer expects: email, fao, phone_primary, person, address
+ var builder = new ContactDetailsBuilder()
+ .withEmailAddress("test@example.com")
+ .withFullName("Test User")
+ .withPhoneNumber("01onal234567");
+
+ var json = toJson(builder);
+ // email (not emailAddress) — mapped via @JsonProperty
+ assertThat(json.has("email")).isTrue();
+ // fao (not fullName) — mapped via @JsonProperty
+ assertThat(json.has("fao")).isTrue();
+ // phone_primary (not phoneNumber) — mapped via @JsonProperty
+ assertThat(json.has("phone_primary")).isTrue();
+ }
+
+ @Test
+ @DisplayName("nested person and address serialise correctly")
+ void nestedObjects() throws JsonProcessingException {
+ var person = new PersonBuilder()
+ .withTitle("title_mr")
+ .withForename("John")
+ .withFamilyName("Smith");
+
+ var address = new AddressBuilder()
+ .withAddressLine1("123 Test St")
+ .withTown("Leeds")
+ .withPostcode("LS1 1AA")
+ .withCountryCode("GB");
+
+ var builder = new ContactDetailsBuilder()
+ .withEmailAddress("test@example.com")
+ .withPerson(person)
+ .withAddress(address);
+
+ var json = toJson(builder);
+ assertThat(json.get("person").has("forename")).isTrue();
+ assertThat(json.get("person").has("familyName")).isTrue();
+ assertThat(json.get("address").has("addressLine1")).isTrue();
+ assertThat(json.get("address").has("postcode")).isTrue();
+ }
+ }
+
+ @Nested
+ @DisplayName("TrackingBuilder → ApplicationTracking partial")
+ class TrackingBuilderContract {
+
+ // All field names expected by olcs-transfer ApplicationTracking
+ private static final Set EXPECTED_STATUS_FIELDS = Set.of(
+ "addressesStatus", "businessDetailsStatus", "businessTypeStatus",
+ "communityLicencesStatus", "conditionsUndertakingsStatus",
+ "convictionsPenaltiesStatus", "discsStatus", "financialEvidenceStatus",
+ "financialHistoryStatus", "licenceHistoryStatus", "operatingCentresStatus",
+ "peopleStatus", "safetyStatus", "taxiPhvStatus", "transportManagersStatus",
+ "typeOfLicenceStatus", "declarationsInternalStatus",
+ "vehiclesDeclarationsStatus", "vehiclesPsvStatus", "vehiclesStatus",
+ "vehiclesSizeStatus", "psvOperateSmallStatus", "psvOperateLargeStatus",
+ "psvSmallConditionsStatus", "psvOperateNoveltyStatus",
+ "psvSmallPartWrittenStatus", "psvDocumentaryEvidenceSmallStatus",
+ "psvDocumentaryEvidenceLargeStatus", "psvMainOccupationUndertakingsStatus"
+ );
+
+ @Test
+ @DisplayName("withAllStatuses populates all 29 status fields")
+ void withAllStatuses() throws JsonProcessingException {
+ var builder = new TrackingBuilder()
+ .withId("123")
+ .withVersion(1)
+ .withAllStatuses(TrackingStatus.ACCEPTED.asString());
+
+ var json = toJson(builder);
+ var fields = jsonFieldNames(json);
+
+ // Must contain id + version + all 29 status fields
+ assertThat(fields).containsAll(EXPECTED_STATUS_FIELDS);
+ assertThat(fields).contains("id", "version");
+
+ // All status values should be "1" (ACCEPTED)
+ for (String field : EXPECTED_STATUS_FIELDS) {
+ assertThat(json.get(field).asText())
+ .as("Field %s should be ACCEPTED", field)
+ .isEqualTo("1");
+ }
+ }
+
+ @Test
+ @DisplayName("contains exactly 29 status fields (no missing, no extras)")
+ void statusFieldCount() throws JsonProcessingException {
+ var builder = new TrackingBuilder()
+ .withId("1")
+ .withVersion(1)
+ .withAllStatuses("0");
+
+ var json = toJson(builder);
+ var statusFields = jsonFieldNames(json);
+ statusFields.remove("id");
+ statusFields.remove("version");
+
+ assertThat(statusFields).hasSameSizeAs(EXPECTED_STATUS_FIELDS);
+ assertThat(statusFields).containsExactlyInAnyOrderElementsOf(EXPECTED_STATUS_FIELDS);
+ }
+
+ @Test
+ @DisplayName("individual status setters produce correct field names")
+ void individualSetters() throws JsonProcessingException {
+ var builder = new TrackingBuilder()
+ .withId("1")
+ .withVersion(1)
+ .withAddressesStatus("1")
+ .withVehiclesPsvStatus("2")
+ .withPsvOperateSmallStatus("3");
+
+ var json = toJson(builder);
+ assertThat(json.get("addressesStatus").asText()).isEqualTo("1");
+ assertThat(json.get("vehiclesPsvStatus").asText()).isEqualTo("2");
+ assertThat(json.get("psvOperateSmallStatus").asText()).isEqualTo("3");
+ }
+ }
+
+ @Nested
+ @DisplayName("PersonBuilder → Person partial")
+ class PersonBuilderContract {
+
+ @Test
+ @DisplayName("serialises with correct field names")
+ void fieldNames() throws JsonProcessingException {
+ var builder = new PersonBuilder()
+ .withTitle("title_mr")
+ .withForename("John")
+ .withFamilyName("Smith")
+ .withBirthDate("1990-01-01");
+
+ var json = toJson(builder);
+ assertThat(jsonFieldNames(json)).containsExactlyInAnyOrder(
+ "title", "forename", "familyName", "birthDate"
+ );
+ }
+ }
+}
diff --git a/src/test/java/apiCalls/contract/enums/EnumContractTest.java b/src/test/java/apiCalls/contract/enums/EnumContractTest.java
new file mode 100644
index 0000000..41819a5
--- /dev/null
+++ b/src/test/java/apiCalls/contract/enums/EnumContractTest.java
@@ -0,0 +1,158 @@
+package apiCalls.contract.enums;
+
+import apiCalls.enums.*;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Contract tests validating that enum values in vol-api-calls match
+ * the InArray validators defined in olcs-transfer Command/Query classes.
+ *
+ * If these tests fail, it means the backend contract has changed and
+ * the enum values here need updating to match.
+ *
+ * @see olcs-transfer
+ */
+@DisplayName("Enum values match olcs-transfer contract")
+class EnumContractTest {
+
+ @Nested
+ @DisplayName("OperatorType → CreateApplication.operatorType")
+ class OperatorTypeContract {
+ // olcs-transfer: InArray {"lcat_gv", "lcat_psv"}
+ @Test void goods() { assertThat(OperatorType.GOODS.asString()).isEqualTo("lcat_gv"); }
+ @Test void psv() { assertThat(OperatorType.PUBLIC.asString()).isEqualTo("lcat_psv"); }
+ @Test void count() { assertThat(OperatorType.values()).hasSize(2); }
+ }
+
+ @Nested
+ @DisplayName("LicenceType → CreateApplication.licenceType")
+ class LicenceTypeContract {
+ // olcs-transfer: InArray {"ltyp_r", "ltyp_sn", "ltyp_si", "ltyp_sr"}
+ @Test void restricted() { assertThat(LicenceType.RESTRICTED.asString()).isEqualTo("ltyp_r"); }
+ @Test void standardNational() { assertThat(LicenceType.STANDARD_NATIONAL.asString()).isEqualTo("ltyp_sn"); }
+ @Test void standardInternational() { assertThat(LicenceType.STANDARD_INTERNATIONAL.asString()).isEqualTo("ltyp_si"); }
+ @Test void specialRestricted() { assertThat(LicenceType.SPECIAL_RESTRICTED.asString()).isEqualTo("ltyp_sr"); }
+ @Test void count() { assertThat(LicenceType.values()).hasSize(4); }
+ }
+
+ @Nested
+ @DisplayName("BusinessType → RegisterUserSelfserve.businessType")
+ class BusinessTypeContract {
+ // olcs-transfer: InArray {"org_t_p", "org_t_pa", "org_t_rc", "org_t_llp", "org_t_st"}
+ @Test void limitedCompany() { assertThat(BusinessType.LIMITED_COMPANY.asString()).isEqualTo("org_t_rc"); }
+ @Test void soleTrader() { assertThat(BusinessType.SOLE_TRADER.asString()).isEqualTo("org_t_st"); }
+ @Test void partnership() { assertThat(BusinessType.PARTNERSHIP.asString()).isEqualTo("org_t_p"); }
+ @Test void limitedPartnership(){ assertThat(BusinessType.LIMITED_PARTNERSHIP.asString()).isEqualTo("org_t_llp"); }
+ @Test void other() { assertThat(BusinessType.OTHER.asString()).isEqualTo("org_t_pa"); }
+ @Test void count() { assertThat(BusinessType.values()).hasSize(5); }
+ }
+
+ @Nested
+ @DisplayName("VehicleType → CreateApplication.vehicleType")
+ class VehicleTypeContract {
+ // olcs-transfer: InArray {"app_veh_type_mixed", "app_veh_type_lgv"}
+ @Test void mixed() { assertThat(VehicleType.MIXED_FLEET.asString()).isEqualTo("app_veh_type_mixed"); }
+ @Test void lgvOnly() { assertThat(VehicleType.LGV_ONLY_FLEET.asString()).isEqualTo("app_veh_type_lgv"); }
+ @Test void count() { assertThat(VehicleType.values()).hasSize(2); }
+ }
+
+ @Nested
+ @DisplayName("TransportManagerType → Tm/Create.type")
+ class TransportManagerTypeContract {
+ // olcs-transfer: InArray {"tm_t_e", "tm_t_i", "tm_t_b"}
+ @Test void internal() { assertThat(TransportManagerType.INTERNAL.asString()).isEqualTo("tm_t_e"); }
+ @Test void external() { assertThat(TransportManagerType.EXTERNAL.asString()).isEqualTo("tm_t_i"); }
+ @Test void count() { assertThat(TransportManagerType.values()).hasSize(2); }
+ }
+
+ @Nested
+ @DisplayName("TrackingStatus → ApplicationTracking InArray")
+ class TrackingStatusContract {
+ // olcs-transfer: InArray {"0", "1", "2", "3"}
+ @Test void notStarted() { assertThat(TrackingStatus.NOT_STARTED.asString()).isEqualTo("0"); }
+ @Test void accepted() { assertThat(TrackingStatus.ACCEPTED.asString()).isEqualTo("1"); }
+ @Test void inProgress() { assertThat(TrackingStatus.IN_PROGRESS.asString()).isEqualTo("2"); }
+ @Test void updated() { assertThat(TrackingStatus.UPDATED.asString()).isEqualTo("3"); }
+ @Test void count() { assertThat(TrackingStatus.values()).hasSize(4); }
+ }
+
+ @Nested
+ @DisplayName("TrafficArea → traffic area codes")
+ class TrafficAreaContract {
+ @Test void northEast() { assertThat(TrafficArea.NORTH_EAST.value()).isEqualTo("B"); }
+ @Test void northWest() { assertThat(TrafficArea.NORTH_WEST.value()).isEqualTo("C"); }
+ @Test void midlands() { assertThat(TrafficArea.MIDLANDS.value()).isEqualTo("D"); }
+ @Test void east() { assertThat(TrafficArea.EAST.value()).isEqualTo("F"); }
+ @Test void wales() { assertThat(TrafficArea.WALES.value()).isEqualTo("G"); }
+ @Test void west() { assertThat(TrafficArea.WEST.value()).isEqualTo("H"); }
+ @Test void london() { assertThat(TrafficArea.LONDON.value()).isEqualTo("K"); }
+ @Test void scotland() { assertThat(TrafficArea.SCOTLAND.value()).isEqualTo("M"); }
+ @Test void ni() { assertThat(TrafficArea.NORTHERN_IRELAND.value()).isEqualTo("N"); }
+ @Test void count() { assertThat(TrafficArea.values()).hasSize(9); }
+ }
+
+ @Nested
+ @DisplayName("EnforcementArea → enforcement area codes")
+ class EnforcementAreaContract {
+ @Test void northEast() { assertThat(EnforcementArea.NORTH_EAST.value()).isEqualTo("EA-B"); }
+ @Test void northWest() { assertThat(EnforcementArea.NORTH_WEST.value()).isEqualTo("EA-C"); }
+ @Test void midlands() { assertThat(EnforcementArea.MIDLANDS.value()).isEqualTo("EA-D"); }
+ @Test void east() { assertThat(EnforcementArea.EAST.value()).isEqualTo("EA-F"); }
+ @Test void wales() { assertThat(EnforcementArea.WALES.value()).isEqualTo("EA-E"); }
+ @Test void west() { assertThat(EnforcementArea.WEST.value()).isEqualTo("EA-J"); }
+ @Test void london() { assertThat(EnforcementArea.LONDON.value()).isEqualTo("EA-H"); }
+ @Test void scotland() { assertThat(EnforcementArea.SCOTLAND.value()).isEqualTo("EA-A"); }
+ @Test void ni() { assertThat(EnforcementArea.NORTHERN_IRELAND.value()).isEqualTo("EA-N"); }
+ @Test void count() { assertThat(EnforcementArea.values()).hasSize(9); }
+ }
+
+ @Nested
+ @DisplayName("UserRoles → CreateUser.roles")
+ class UserRolesContract {
+ @Test void systemAdmin() { assertThat(UserRoles.SYSTEM_ADMIN.asString()).isEqualTo("system-admin"); }
+ @Test void internalAdmin() { assertThat(UserRoles.INTERNAL_ADMIN.asString()).isEqualTo("internal-admin"); }
+ @Test void limitedReadOnly() { assertThat(UserRoles.INTERNAL_LIMITED_READ_ONLY.asString()).isEqualTo("internal-limited-read-only"); }
+ @Test void readOnly() { assertThat(UserRoles.INTERNAL_READ_ONLY.asString()).isEqualTo("internal-read-only"); }
+ @Test void caseWorker() { assertThat(UserRoles.INTERNAL_CASE_WORKER.asString()).isEqualTo("internal-case-worker"); }
+ @Test void internal() { assertThat(UserRoles.INTERNAL.asString()).isEqualTo("internal"); }
+ @Test void count() { assertThat(UserRoles.values()).hasSize(6); }
+ }
+
+ @Nested
+ @DisplayName("Realm → auth realm identifiers")
+ class RealmContract {
+ @Test void selfServe() { assertThat(Realm.SELF_SERVE.asString()).isEqualTo("selfserve"); }
+ @Test void internal() { assertThat(Realm.INTERNAL.asString()).isEqualTo("internal"); }
+ @Test void count() { assertThat(Realm.values()).hasSize(2); }
+ }
+
+ @Nested
+ @DisplayName("UserTitle → Person.title")
+ class UserTitleContract {
+ @Test void mr() { assertThat(UserTitle.MR.asString()).isEqualTo("title_mr"); }
+ @Test void mrs() { assertThat(UserTitle.MRS.asString()).isEqualTo("title_mrs"); }
+ @Test void ms() { assertThat(UserTitle.MS.asString()).isEqualTo("title_ms"); }
+ @Test void count() { assertThat(UserTitle.values()).hasSize(3); }
+ }
+
+ @Nested
+ @DisplayName("UserType → user type mapping")
+ class UserTypeContract {
+ @Test void internal() { assertThat(UserType.INTERNAL.asString()).isEqualTo("internal"); }
+ @Test void external() { assertThat(UserType.EXTERNAL.asString()).isEqualTo("selfserve"); }
+ @Test void count() { assertThat(UserType.values()).hasSize(2); }
+ }
+
+ @Nested
+ @DisplayName("FinancialStandingRateVehicleType → financial standing rates")
+ class FinancialStandingRateVehicleTypeContract {
+ @Test void na() { assertThat(FinancialStandingRateVehicleType.NA.asString()).isEqualTo("fin_sta_veh_typ_na"); }
+ @Test void hgv() { assertThat(FinancialStandingRateVehicleType.HGV.asString()).isEqualTo("fin_sta_veh_typ_hgv"); }
+ @Test void lgv() { assertThat(FinancialStandingRateVehicleType.LGV.asString()).isEqualTo("fin_sta_veh_typ_lgv"); }
+ @Test void count() { assertThat(FinancialStandingRateVehicleType.values()).hasSize(3); }
+ }
+}