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); } + } +}