diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..e1e36f6 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,14 @@ +name: Deploy if not a version bump PR +on: + pull_request: + types: + - closed +jobs: + deploy: + runs-on: ubuntu-latest + if: ${{ github.event.pull_request.merged == true && !contains(github.event.pull_request.labels.*.name, 'Version bump') }} + steps: + - uses: actions/checkout@v3 + - run: gh workflow run deploy.yml + env: + GITHUB_TOKEN: ${{ secrets.WORKFLOW_PAT }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..d767959 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,15 @@ +name: TDR Deploy Metadata Validation +on: + workflow_dispatch: +jobs: + deploy: + uses: nationalarchives/tdr-github-actions/.github/workflows/sbt_release.yml@main + with: + library-name: "Metadata validation" + secrets: + WORKFLOW_PAT: ${{ secrets.WORKFLOW_PAT }} + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} + SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..5583cca --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,20 @@ +name: TDR Run Tests +on: + pull_request: + push: + branches-ignore: + - master + - release-* +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: nationalarchives/tdr-github-actions/.github/actions/run-git-secrets@main + - uses: nationalarchives/tdr-github-actions/.github/actions/slack-send@main + if: failure() + with: + message: ":warning: Secrets found in repository ${{ inputs.repo-name }}" + slack-url: ${{ secrets.SLACK_WEBHOOK }} + - name: Run tests + run: sbt scalafmtCheckAll test diff --git a/.gitignore b/.gitignore index 7169cab..73fcdf7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,8 @@ # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* +.idea +.bsp +target +project/project +project/target diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..f8aa32c --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,4 @@ +version = 3.7.11 +preset = default +runner.dialect = scala213 +maxColumn = 180 diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..4b04b00 --- /dev/null +++ b/build.sbt @@ -0,0 +1,61 @@ +import Dependencies._ +import sbt.url +import sbtrelease.ReleaseStateTransformations._ + +ThisBuild / organization := "uk.gov.nationalarchives" +ThisBuild / organizationName := "National Archives" + +scalaVersion := "2.13.11" +version := version.value + + +ThisBuild / scmInfo := Some( + ScmInfo( + url("https://github.com/nationalarchives/tdr-metadata-validation"), + "git@github.com:nationalarchives/tdr-metadata-validation.git" + ) +) + +developers := List( + Developer( + id = "tna-digital-archiving-jenkins", + name = "TNA Digital Archiving", + email = "digitalpreservation@nationalarchives.gov.uk", + url = url("https://github.com/nationalarchives/tdr-metadata-validation") + ) +) + +ThisBuild / description := "A library to validate input metadata for Transfer Digital Records" +ThisBuild / licenses := List("MIT" -> new URL("https://choosealicense.com/licenses/mit/")) +ThisBuild / homepage := Some(url("https://github.com/nationalarchives/tdr-metadata-validation")) + +useGpgPinentry := true +publishTo := sonatypePublishToBundle.value +publishMavenStyle := true + +releaseProcess := Seq[ReleaseStep]( + checkSnapshotDependencies, + inquireVersions, + runClean, + runTest, + setReleaseVersion, + commitReleaseVersion, + tagRelease, + releaseStepCommand("publishSigned"), + releaseStepCommand("sonatypeBundleRelease"), + setNextVersion, + commitNextVersion, + pushChanges +) + +resolvers += + "Sonatype OSS Snapshots" at "https://oss.sonatype.org/content/repositories/snapshots" + +lazy val root = (project in file(".")) + .settings( + name := "tdr-metadata-validation", + libraryDependencies ++= Seq( + commonsLang3, + scalaTest % Test, + ) + ) diff --git a/project/Dependencies.scala b/project/Dependencies.scala new file mode 100644 index 0000000..baa8562 --- /dev/null +++ b/project/Dependencies.scala @@ -0,0 +1,6 @@ +import sbt._ + +object Dependencies { + lazy val commonsLang3 = "org.apache.commons" % "commons-lang3" % "3.13.0" + lazy val scalaTest = "org.scalatest" %% "scalatest" % "3.2.12" +} diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..3040987 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.9.4 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..5cfd3e2 --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,5 @@ +addSbtPlugin("com.github.sbt" % "sbt-release" % "1.1.0") +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.0") +resolvers += Resolver.jcenterRepo +addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.9.21") +addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.2.1") diff --git a/src/main/scala/uk/gov/nationalarchives/tdr/validation/DataType.scala b/src/main/scala/uk/gov/nationalarchives/tdr/validation/DataType.scala new file mode 100644 index 0000000..0541909 --- /dev/null +++ b/src/main/scala/uk/gov/nationalarchives/tdr/validation/DataType.scala @@ -0,0 +1,161 @@ +package uk.gov.nationalarchives.tdr.validation + +import uk.gov.nationalarchives.tdr.validation.ErrorCode._ + +import java.time.{LocalDateTime, Year} +import scala.util.control.Exception.allCatch + +sealed trait DataType + +case object Integer extends DataType with Product with Serializable { + def checkValue(value: String, criteria: MetadataCriteria): Option[String] = { + value match { + case "" if criteria.required => Some(EMPTY_VALUE_ERROR) + case t if allCatch.opt(t.toInt).isEmpty => Some(NUMBER_ONLY_ERROR) + case t if t.toInt < 0 => Some(NEGATIVE_NUMBER_ERROR) + case _ => None + } + } +} + +case object DateTime extends DataType with Product with Serializable { + def checkValue(value: String, criteria: MetadataCriteria): Option[String] = { + value match { + case "" if criteria.required => Some(EMPTY_VALUE_ERROR) + case "" if !criteria.required => None + case v => + val date = v.replace("T", "-").split("[-:]") + if (date.length < 6) { + Some(INVALID_DATE_FORMAT_ERROR) + } else { + validate(date(2), date(1), date(0), criteria) + } + } + } + + val isInvalidDay: Int => Boolean = (day: Int) => day < 1 || day > 31 + val isInvalidMonth: Int => Boolean = (month: Int) => month < 1 || month > 12 + val isInvalidYear: Int => Boolean = (year: Int) => year.toString.length != 4 + val isALeapYear: Int => Boolean = (year: Int) => Year.of(year).isLeap + + lazy val monthsWithLessThan31Days: Map[Int, String] = Map( + 2 -> "February", + 4 -> "April", + 6 -> "June", + 9 -> "September", + 11 -> "November" + ) + + private def validate(day: String, month: String, year: String, criteria: MetadataCriteria): Option[String] = { + val emptyDate: Boolean = day.isEmpty && month.isEmpty && year.isEmpty + + emptyDate match { + case false => validateDateValues(day, month, year, criteria) + case true if criteria.required => Some(EMPTY_VALUE_ERROR) + case _ => None + } + } + + private def validateDateValues(day: String, month: String, year: String, criteria: MetadataCriteria): Option[String] = { + val dayError = validateDay(day) + val monthError = if (dayError.isEmpty) validateMonth(month) else dayError + val yearError = if (monthError.isEmpty) validateYear(year) else monthError + val dayForMonthError = if (yearError.isEmpty) checkDayForTheMonthAndYear(day.toInt, month.toInt, year.toInt) else yearError + if (dayForMonthError.isEmpty) checkIfFutureDateIsAllowed(day.toInt, month.toInt, year.toInt, criteria) else dayForMonthError + } + + private def validateDay(day: String): Option[String] = { + day match { + case v if v.isEmpty => Some(EMPTY_VALUE_ERROR_FOR_DAY) + case v if allCatch.opt(v.toInt).isEmpty => Some(NUMBER_ERROR_FOR_DAY) + case v if v.toInt < 0 => Some(NEGATIVE_NUMBER_ERROR_FOR_DAY) + case v if isInvalidDay(v.toInt) => Some(INVALID_NUMBER_ERROR_FOR_DAY) + case _ => None + } + } + + private def validateMonth(month: String): Option[String] = { + month match { + case v if v.isEmpty => Some(EMPTY_VALUE_ERROR_FOR_MONTH) + case v if allCatch.opt(v.toInt).isEmpty => Some(NUMBER_ERROR_FOR_MONTH) + case v if v.toInt < 0 => Some(NEGATIVE_NUMBER_ERROR_FOR_MONTH) + case v if isInvalidMonth(v.toInt) => Some(INVALID_NUMBER_ERROR_FOR_MONTH) + case _ => None + } + } + + private def validateYear(year: String): Option[String] = { + year match { + case v if v.isEmpty => Some(EMPTY_VALUE_ERROR_FOR_YEAR) + case v if allCatch.opt(v.toInt).isEmpty => Some(NUMBER_ERROR_FOR_YEAR) + case v if v.toInt < 0 => Some(NEGATIVE_NUMBER_ERROR_FOR_YEAR) + case v if isInvalidYear(v.toInt) => Some(INVALID_NUMBER_ERROR_FOR_YEAR) + case _ => None + } + } + + private def checkDayForTheMonthAndYear(dayNumber: Int, monthNumber: Int, yearNumber: Int): Option[String] = { + val monthHasLessThan31Days = monthsWithLessThan31Days.contains(monthNumber) + + if (dayNumber > 30 && monthHasLessThan31Days || dayNumber == 30 && monthNumber == 2) { + Some(INVALID_DAY_FOR_MONTH_ERROR) + } else if (dayNumber == 29 && monthNumber == 2 && !isALeapYear(yearNumber)) { + Some(INVALID_DAY_FOR_MONTH_ERROR) + } else { + None + } + } + + private def checkIfFutureDateIsAllowed(day: Int, month: Int, year: Int, criteria: MetadataCriteria): Option[String] = + if (!criteria.isFutureDateAllowed && LocalDateTime.now().isBefore(LocalDateTime.of(year, month, day, 0, 0))) { + Some(FUTURE_DATE_ERROR) + } else { + None + } +} + +case object Text extends DataType with Product with Serializable { + + def checkValue(value: String, criteria: MetadataCriteria): Option[String] = { + val definedValues = criteria.definedValues + value match { + case "" if criteria.required => Some(EMPTY_VALUE_ERROR) + case v if definedValues.nonEmpty && !criteria.isMultiValueAllowed && v.split(",").length > 1 => Some(MULTI_VALUE_ERROR) + case v if definedValues.nonEmpty && !v.split(",").toList.forall(definedValues.contains) => Some(UNDEFINED_VALUE_ERROR) + case _ => None + } + } +} + +case object Boolean extends DataType with Product with Serializable { + def checkValue(value: String, criteria: MetadataCriteria, requiredMetadata: Option[Metadata]): Option[String] = { + value match { + case "" if criteria.required => + if (isRequiredMetadataIsEmpty(criteria, requiredMetadata)) { + None + } else { + Some(NO_OPTION_SELECTED_ERROR) + } + case v if criteria.requiredProperty.isDefined && requiredMetadata.exists(_.value.isEmpty) => Some(REQUIRED_PROPERTY_IS_EMPTY) + case v if !criteria.definedValues.contains(v) => Some(UNDEFINED_VALUE_ERROR) + case _ => None + } + } + + def isRequiredMetadataIsEmpty(criteria: MetadataCriteria, requiredMetadata: Option[Metadata]): Boolean = { + criteria.requiredProperty.isDefined && requiredMetadata.exists(_.value.isEmpty) + } +} +case object Decimal extends DataType with Product with Serializable + +object DataType { + def get(dataType: String): DataType = { + dataType match { + case "Integer" => Integer + case "DateTime" => DateTime + case "Text" => Text + case "Boolean" => Boolean + case "Decimal" => Decimal + } + } +} diff --git a/src/main/scala/uk/gov/nationalarchives/tdr/validation/MetadataCriteria.scala b/src/main/scala/uk/gov/nationalarchives/tdr/validation/MetadataCriteria.scala new file mode 100644 index 0000000..26e1d74 --- /dev/null +++ b/src/main/scala/uk/gov/nationalarchives/tdr/validation/MetadataCriteria.scala @@ -0,0 +1,46 @@ +package uk.gov.nationalarchives.tdr.validation + +case class Metadata(name: String, value: String) +case class MetadataCriteria( + name: String, + dataType: DataType, + required: Boolean, + isFutureDateAllowed: Boolean, + isMultiValueAllowed: Boolean, + definedValues: List[String], + requiredProperty: Option[String] = None, + dependencies: Option[Map[String, List[MetadataCriteria]]] = None, + defaultValue: Option[String] = None +) + +object MetadataProperty { + val closureType = "ClosureType" + val descriptiveType = "DescriptiveType" +} + +object ErrorCode { + val CLOSURE_STATUS_IS_MISSING = "CLOSURE_STATUS_IS_MISSING" + val CLOSURE_METADATA_EXISTS_WHEN_FILE_IS_OPEN = "CLOSURE_METADATA_EXISTS_WHEN_FILE_IS_OPEN" + val NUMBER_ONLY_ERROR = "NUMBER_ONLY_ERROR" + val NEGATIVE_NUMBER_ERROR = "NEGATIVE_NUMBER_ERROR" + val EMPTY_VALUE_ERROR = "EMPTY_VALUE_ERROR" + val NO_OPTION_SELECTED_ERROR = "NO_OPTION_SELECTED_ERROR" + val INVALID_DATE_FORMAT_ERROR = "INVALID_DATE_FORMAT_ERROR" + val EMPTY_VALUE_ERROR_FOR_DAY = "EMPTY_VALUE_ERROR_FOR_DAY" + val NUMBER_ERROR_FOR_DAY = "NUMBER_ERROR_FOR_DAY" + val NEGATIVE_NUMBER_ERROR_FOR_DAY = "NEGATIVE_NUMBER_ERROR_FOR_DAY" + val INVALID_NUMBER_ERROR_FOR_DAY = "INVALID_NUMBER_ERROR_FOR_DAY" + val EMPTY_VALUE_ERROR_FOR_MONTH = "EMPTY_VALUE_ERROR_FOR_MONTH" + val NUMBER_ERROR_FOR_MONTH = "NUMBER_ERROR_FOR_MONTH" + val NEGATIVE_NUMBER_ERROR_FOR_MONTH = "NEGATIVE_NUMBER_ERROR_FOR_MONTH" + val INVALID_NUMBER_ERROR_FOR_MONTH = "INVALID_NUMBER_ERROR_FOR_MONTH" + val EMPTY_VALUE_ERROR_FOR_YEAR = "EMPTY_VALUE_ERROR_FOR_YEAR" + val NUMBER_ERROR_FOR_YEAR = "NUMBER_ERROR_FOR_YEAR" + val NEGATIVE_NUMBER_ERROR_FOR_YEAR = "NEGATIVE_NUMBER_ERROR_FOR_YEAR" + val INVALID_NUMBER_ERROR_FOR_YEAR = "INVALID_NUMBER_ERROR_FOR_YEAR" + val INVALID_DAY_FOR_MONTH_ERROR = "INVALID_DAY_FOR_MONTH_ERROR" + val FUTURE_DATE_ERROR = "FUTURE_DATE_ERROR" + val MULTI_VALUE_ERROR = "MULTI_VALUE_ERROR" + val UNDEFINED_VALUE_ERROR = "UNDEFINED_VALUE_ERROR" + val REQUIRED_PROPERTY_IS_EMPTY = "REQUIRED_PROPERTY_IS_EMPTY" +} diff --git a/src/main/scala/uk/gov/nationalarchives/tdr/validation/MetadataValidation.scala b/src/main/scala/uk/gov/nationalarchives/tdr/validation/MetadataValidation.scala new file mode 100644 index 0000000..fe590b0 --- /dev/null +++ b/src/main/scala/uk/gov/nationalarchives/tdr/validation/MetadataValidation.scala @@ -0,0 +1,71 @@ +package uk.gov.nationalarchives.tdr.validation + +import org.apache.commons.lang3.BooleanUtils +import uk.gov.nationalarchives.tdr.validation.ErrorCode._ +import uk.gov.nationalarchives.tdr.validation.MetadataProperty._ + +case class FileRow(fileName: String, metadata: List[Metadata]) +case class Error(propertyName: String, errorCode: String) + +class MetadataValidation(closureMetadataCriteria: MetadataCriteria, descriptiveMetadataCriteria: List[MetadataCriteria]) { + + def validateMetadata(fileRows: List[FileRow]): Map[String, List[Error]] = { + fileRows.map(row => row.fileName -> (validateClosureMetadata(row.metadata) ++ validateDescriptiveMetadata(row.metadata))).toMap + } + + def validateClosureMetadata(input: List[Metadata]): List[Error] = { + val closureStatus = input.find(_.name == closureType) + closureStatus match { + case Some(Metadata(_, "Open")) => + val hasAnyClosureMetadata = hasClosureMetadata(input, closureMetadataCriteria.dependencies.flatMap(_.get("Closed"))) + if (hasAnyClosureMetadata) { + List(Error(closureType, CLOSURE_METADATA_EXISTS_WHEN_FILE_IS_OPEN)) + } else { + List.empty + } + case Some(Metadata(_, "Closed")) => validateMetadata(input, closureMetadataCriteria.dependencies.flatMap(_.get("Closed")).getOrElse(Nil)) + case Some(Metadata(_, _)) => List(Error(closureType, UNDEFINED_VALUE_ERROR)) + case None => List(Error(closureType, CLOSURE_STATUS_IS_MISSING)) + } + } + + def hasClosureMetadata(input: List[Metadata], metadataCriteria: Option[List[MetadataCriteria]]): Boolean = { + metadataCriteria.exists( + _.exists(criteria => + input.find(_.name == criteria.name).exists(_.value != criteria.defaultValue.getOrElse("")) + || hasClosureMetadata(input, criteria.dependencies.flatMap(_.get(criteria.defaultValue.getOrElse("")))) + ) + ) + } + + def validateDescriptiveMetadata(input: List[Metadata]): List[Error] = validateMetadata(input, descriptiveMetadataCriteria) + + private def validateMetadata(input: List[Metadata], metadataCriteria: List[MetadataCriteria]): List[Error] = { + input.flatMap(metadata => { + metadataCriteria + .find(_.name == metadata.name) + .flatMap(criteria => { + val value = metadata.value + val errorCode = criteria.dataType match { + case Integer | Decimal => Integer.checkValue(value, criteria) + case Boolean => + Boolean.checkValue(value, criteria, criteria.requiredProperty.flatMap(p => input.find(_.name == p))) match { + case None if value.nonEmpty => + criteria.dependencies + .flatMap( + _.collect { + case (definedValue, criteria) if BooleanUtils.toBoolean(definedValue) == BooleanUtils.toBoolean(value) => + validateMetadata(input.filter(r => criteria.exists(_.name == r.name)), criteria.map(_.copy(required = true))).map(_.errorCode).headOption + }.head + ) + case error => error + } + case Text => Text.checkValue(value, criteria) + case DateTime => DateTime.checkValue(value, criteria) + case _ => None + } + errorCode.map(Error(criteria.name, _)) + }) + }) + } +} diff --git a/src/test/scala/uk/gov/nationalarchives/tdr/validation/DataTypeSpec.scala b/src/test/scala/uk/gov/nationalarchives/tdr/validation/DataTypeSpec.scala new file mode 100644 index 0000000..82177b3 --- /dev/null +++ b/src/test/scala/uk/gov/nationalarchives/tdr/validation/DataTypeSpec.scala @@ -0,0 +1,141 @@ +package uk.gov.nationalarchives.tdr.validation + +import org.scalatest.matchers.should.Matchers._ +import org.scalatest.wordspec.AnyWordSpec +import uk.gov.nationalarchives.tdr.validation.ErrorCode._ + +class DataTypeSpec extends AnyWordSpec { + + "Integer" should { + + val criteria = MetadataCriteria("Property1", Integer, required = true, isFutureDateAllowed = false, isMultiValueAllowed = false, Nil, None, None) + + "checkValue should return an error if the value is empty" in { + Integer.checkValue("", criteria) should be(Some(EMPTY_VALUE_ERROR)) + } + + "checkValue should return an error if the value is not an integer" in { + Integer.checkValue("e", criteria) should be(Some(NUMBER_ONLY_ERROR)) + } + + "checkValue should return an error if the value is a negative number" in { + Integer.checkValue("-1", criteria) should be(Some(NEGATIVE_NUMBER_ERROR)) + } + + "checkValue should not return any errors if the value is a valid number" in { + Integer.checkValue("1", criteria) should be(None) + } + } + + "DateTime" should { + + val criteria = MetadataCriteria("Property1", DateTime, required = true, isFutureDateAllowed = false, isMultiValueAllowed = false, Nil, None, None) + + "checkValue should not return any errors if the date is valid" in { + DateTime.checkValue("1990-12-10T00:00:00", criteria) should be(None) + DateTime.checkValue("2000-2-29T00:00:00", criteria) should be(None) + } + + "checkValue should not return any errors if the value is empty but it is not mandatory" in { + DateTime.checkValue("", criteria.copy(required = false)) should be(None) + } + + "checkValue should not return any errors if future date is allowed" in { + DateTime.checkValue("1990-12-10T00:00:00", criteria.copy(isFutureDateAllowed = true)) should be(None) + DateTime.checkValue("2090-12-10T00:00:00", criteria.copy(isFutureDateAllowed = true)) should be(None) + } + + "checkValue should return an error if the date format is invalid" in { + DateTime.checkValue("1990/12/10T00:00:00", criteria) should be(Some(INVALID_DATE_FORMAT_ERROR)) + DateTime.checkValue("2000-2-29", criteria) should be(Some(INVALID_DATE_FORMAT_ERROR)) + } + + "checkValue should return an error if the value is empty and mandatory" in { + DateTime.checkValue("", criteria) should be(Some(EMPTY_VALUE_ERROR)) + DateTime.checkValue("--T00:00:00", criteria) should be(Some(EMPTY_VALUE_ERROR)) + } + + "checkValue should return an error if the day or month or year is empty" in { + DateTime.checkValue("1990-10-T00:00:00", criteria) should be(Some(EMPTY_VALUE_ERROR_FOR_DAY)) + DateTime.checkValue("1990--10T00:00:00", criteria) should be(Some(EMPTY_VALUE_ERROR_FOR_MONTH)) + DateTime.checkValue("-10-10T00:00:00", criteria) should be(Some(EMPTY_VALUE_ERROR_FOR_YEAR)) + } + + "checkValue should return an error if the day or month or year is not valid" in { + DateTime.checkValue("1990-10-32T00:00:00", criteria) should be(Some(INVALID_NUMBER_ERROR_FOR_DAY)) + DateTime.checkValue("1990-28-10T00:00:00", criteria) should be(Some(INVALID_NUMBER_ERROR_FOR_MONTH)) + DateTime.checkValue("19999-10-10T00:00:00", criteria) should be(Some(INVALID_NUMBER_ERROR_FOR_YEAR)) + DateTime.checkValue("199-10-10T00:00:00", criteria) should be(Some(INVALID_NUMBER_ERROR_FOR_YEAR)) + } + + "checkValue should return an error if the day is not valid for given month" in { + DateTime.checkValue("1990-2-29T00:00:00", criteria) should be(Some(INVALID_DAY_FOR_MONTH_ERROR)) + DateTime.checkValue("1990-4-31T00:00:00", criteria) should be(Some(INVALID_DAY_FOR_MONTH_ERROR)) + DateTime.checkValue("1990-6-31T00:00:00", criteria) should be(Some(INVALID_DAY_FOR_MONTH_ERROR)) + DateTime.checkValue("1990-9-31T00:00:00", criteria) should be(Some(INVALID_DAY_FOR_MONTH_ERROR)) + DateTime.checkValue("1990-11-31T00:00:00", criteria) should be(Some(INVALID_DAY_FOR_MONTH_ERROR)) + } + + "checkValue should return an error if future date is not allowed" in { + DateTime.checkValue("2050-2-1T00:00:00", criteria) should be(Some(FUTURE_DATE_ERROR)) + } + } + + "Text" should { + + val criteria = MetadataCriteria("Property1", Text, required = true, isFutureDateAllowed = false, isMultiValueAllowed = false, Nil, None, None) + + "checkValue should not return any errors if the value is valid" in { + Text.checkValue("hello", criteria) should be(None) + } + + "checkValue should not return any errors if the value is empty but it is not mandatory" in { + Text.checkValue("", criteria.copy(required = false)) should be(None) + } + + "checkValue should return an error if the value is empty" in { + Text.checkValue("", criteria) should be(Some(EMPTY_VALUE_ERROR)) + } + + "checkValue should not return any errors if the value has multiple values but multiple values are allowed" in { + Text.checkValue("22,44", criteria.copy(definedValues = List("22", "44"), isMultiValueAllowed = true)) should be(None) + } + + "checkValue should return an error if the value has multiple values but multiple values are not allowed" in { + Text.checkValue("22,44", criteria.copy(definedValues = List("22", "44"))) should be(Some(MULTI_VALUE_ERROR)) + } + + "checkValue should return an error if the value is not matching its defined values" in { + Text.checkValue("22,44", criteria.copy(definedValues = List("22", "33"), isMultiValueAllowed = true)) should be(Some(UNDEFINED_VALUE_ERROR)) + } + } + + "Boolean" should { + + val criteria = MetadataCriteria("Property1", Boolean, required = true, isFutureDateAllowed = false, isMultiValueAllowed = false, List("yes", "no"), None, None) + + "checkValue should not return any errors if the value is valid" in { + Boolean.checkValue("yes", criteria, None) should be(None) + } + + "checkValue should not return any errors if the value is empty but required property is empty too" in { + Boolean.checkValue("", criteria.copy(requiredProperty = Some("property2")), Some(Metadata("property2", ""))) should be(None) + } + + "checkValue should return an error if the value is empty and no required property" in { + Boolean.checkValue("", criteria, None) should be(Some(NO_OPTION_SELECTED_ERROR)) + } + + "checkValue should return an error if the value is empty and has required property" in { + Boolean.checkValue("", criteria.copy(requiredProperty = Some("property2")), Some(Metadata("property2", "value"))) should be(Some(NO_OPTION_SELECTED_ERROR)) + } + + "checkValue should return an error if the value doesn't match its defined values" in { + Boolean.checkValue("true", criteria, None) should be(Some(UNDEFINED_VALUE_ERROR)) + } + + "checkValue should return an error if the required property value is empty" in { + Boolean.checkValue("yes", criteria.copy(requiredProperty = Some("property2")), Some(Metadata("property2", ""))) should be(Some(REQUIRED_PROPERTY_IS_EMPTY)) + } + } +} diff --git a/src/test/scala/uk/gov/nationalarchives/tdr/validation/MetadataValidationSpec.scala b/src/test/scala/uk/gov/nationalarchives/tdr/validation/MetadataValidationSpec.scala new file mode 100644 index 0000000..b8d7893 --- /dev/null +++ b/src/test/scala/uk/gov/nationalarchives/tdr/validation/MetadataValidationSpec.scala @@ -0,0 +1,231 @@ +package uk.gov.nationalarchives.tdr.validation + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers._ +import uk.gov.nationalarchives.tdr.validation.ErrorCode._ +import uk.gov.nationalarchives.tdr.validation.MetadataProperty.closureType + +class MetadataValidationSpec extends AnyFlatSpec { + + "validateClosureMetadata" should "validate closure metadata" in { + + val dependentMetadataCriteria = List( + MetadataCriteria("Property1", Boolean, true, false, false, List("yes", "no"), None, None), + MetadataCriteria("Property2", Text, true, false, false, Nil, None, None), + MetadataCriteria("Property3", DateTime, true, false, false, Nil, None, None), + MetadataCriteria("Property4", Integer, true, false, false, Nil, None, None) + ) + val closureMetadataCriteria = MetadataCriteria("ClosureType", Boolean, true, false, false, List("yes", "no"), None, Some(Map("Closed" -> dependentMetadataCriteria)), None) + val input = List( + Metadata("ClosureType", "Closed"), + Metadata("Property1", "yes"), + Metadata("Property2", "hello"), + Metadata("Property3", "1990-10-10T00:00:00"), + Metadata("Property4", "90") + ) + val metadataValidation = new MetadataValidation(closureMetadataCriteria, Nil) + val error = metadataValidation.validateClosureMetadata(input) + error should be(Nil) + } + + "validateClosureMetadata" should "return an error if closure type is empty" in { + + val dependentMetadataCriteria = List( + MetadataCriteria("Property1", Boolean, true, false, false, List("yes", "no"), None, None) + ) + val closureMetadataCriteria = MetadataCriteria("ClosureType", Boolean, true, false, false, List("yes", "no"), None, Some(Map("Closed" -> dependentMetadataCriteria)), None) + val input = List( + Metadata("ClosureType", ""), + Metadata("Property1", "yes") + ) + val metadataValidation = new MetadataValidation(closureMetadataCriteria, Nil) + val error = metadataValidation.validateClosureMetadata(input) + error should be(List(Error(closureType, UNDEFINED_VALUE_ERROR))) + } + + "validateClosureMetadata" should "return an error if closure type is missing" in { + + val dependentMetadataCriteria = List( + MetadataCriteria("Property1", Boolean, true, false, false, List("yes", "no"), None, None) + ) + val closureMetadataCriteria = MetadataCriteria("ClosureType", Boolean, true, false, false, List("yes", "no"), None, Some(Map("Closed" -> dependentMetadataCriteria)), None) + val input = List( + Metadata("Property1", "yes") + ) + val metadataValidation = new MetadataValidation(closureMetadataCriteria, Nil) + val error = metadataValidation.validateClosureMetadata(input) + error should be(List(Error(closureType, CLOSURE_STATUS_IS_MISSING))) + } + + "validateClosureMetadata" should "not return any errors if closure status is open and closure metadata has empty or default values" in { + + val dependentMetadataCriteria = List( + MetadataCriteria("Property1", Boolean, true, false, false, List("yes", "no"), defaultValue = Some("no")), + MetadataCriteria("Property2", Text, true, false, false, Nil, None, None) + ) + val closureMetadataCriteria = MetadataCriteria("ClosureType", Boolean, true, false, false, List("yes", "no"), None, Some(Map("Closed" -> dependentMetadataCriteria)), None) + val input = List( + Metadata("ClosureType", "Open"), + Metadata("Property1", "no"), + Metadata("Property2", "") + ) + val metadataValidation = new MetadataValidation(closureMetadataCriteria, Nil) + val error = metadataValidation.validateClosureMetadata(input) + error should be(Nil) + } + + "validateClosureMetadata" should "return an error if closure status is open but has closure metadata" in { + + val dependentMetadataCriteria = List( + MetadataCriteria("Property1", Boolean, true, false, false, List("yes", "no"), None, None), + MetadataCriteria("Property2", Text, true, false, false, Nil, None, None) + ) + val closureMetadataCriteria = MetadataCriteria("ClosureType", Boolean, true, false, false, List("yes", "no"), None, Some(Map("Closed" -> dependentMetadataCriteria)), None) + val input = List( + Metadata("ClosureType", "Open"), + Metadata("Property1", "yes"), + Metadata("Property2", "hello") + ) + val metadataValidation = new MetadataValidation(closureMetadataCriteria, Nil) + val error = metadataValidation.validateClosureMetadata(input) + error should be(List(Error(closureType, CLOSURE_METADATA_EXISTS_WHEN_FILE_IS_OPEN))) + } + + "validateClosureMetadata" should "return an error if closure metadata has invalid values" in { + + val dependentMetadataCriteria = List( + MetadataCriteria("Property1", Boolean, true, false, false, List("yes", "no"), None, None), + MetadataCriteria("Property2", Text, true, false, false, Nil, None, None), + MetadataCriteria("Property3", DateTime, true, false, false, Nil, None, None), + MetadataCriteria("Property4", Integer, true, false, false, Nil, None, None) + ) + val closureMetadataCriteria = MetadataCriteria("ClosureType", Boolean, true, false, false, List("yes", "no"), None, Some(Map("Closed" -> dependentMetadataCriteria)), None) + val input = List( + Metadata("ClosureType", "Closed"), + Metadata("Property1", ""), + Metadata("Property2", ""), + Metadata("Property3", "1990-55-10T00:00:00"), + Metadata("Property4", "tt") + ) + val metadataValidation = new MetadataValidation(closureMetadataCriteria, Nil) + val error = metadataValidation.validateClosureMetadata(input) + error should be( + List( + Error("Property1", NO_OPTION_SELECTED_ERROR), + Error("Property2", EMPTY_VALUE_ERROR), + Error("Property3", INVALID_NUMBER_ERROR_FOR_MONTH), + Error("Property4", NUMBER_ONLY_ERROR) + ) + ) + } + + "validateClosureMetadata" should "return an error if dependent closure metadata has invalid values" in { + + val dependentMetadataCriteria = List( + MetadataCriteria( + "Property1", + Boolean, + true, + false, + false, + List("yes", "no"), + dependencies = Some(Map("yes" -> List(MetadataCriteria("Property11", Text, true, false, false, Nil)))) + ), + MetadataCriteria("Property2", Text, true, false, false, Nil, None, None), + MetadataCriteria("Property4", Integer, true, false, false, Nil, None, None) + ) + val closureMetadataCriteria = MetadataCriteria("ClosureType", Boolean, true, false, false, List("yes", "no"), None, Some(Map("Closed" -> dependentMetadataCriteria)), None) + val input = List( + Metadata("ClosureType", "Closed"), + Metadata("Property1", "yes"), + Metadata("Property11", ""), + Metadata("Property2", ""), + Metadata("Property4", "") + ) + val metadataValidation = new MetadataValidation(closureMetadataCriteria, Nil) + val error = metadataValidation.validateClosureMetadata(input) + error should be( + List( + Error("Property1", EMPTY_VALUE_ERROR), + Error("Property2", EMPTY_VALUE_ERROR), + Error("Property4", EMPTY_VALUE_ERROR) + ) + ) + } + + "validateClosureMetadata" should "not return an error if closure metadata is dependent on descriptive metadata which is empty" in { + + val dependentMetadataCriteria = List( + MetadataCriteria( + "Property1", + Boolean, + true, + false, + false, + List("yes", "no"), + requiredProperty = Some("Property5"), + dependencies = Some(Map("yes" -> List(MetadataCriteria("Property11", Text, true, false, false, Nil)))) + ) + ) + val closureMetadataCriteria = MetadataCriteria("ClosureType", Boolean, true, false, false, List("yes", "no"), None, Some(Map("Closed" -> dependentMetadataCriteria)), None) + val input = List( + Metadata("ClosureType", "Closed"), + Metadata("Property1", ""), + Metadata("Property11", ""), + Metadata("Property5", "") + ) + val metadataValidation = new MetadataValidation(closureMetadataCriteria, Nil) + val error = metadataValidation.validateClosureMetadata(input) + error should be(Nil) + } + + "validateDescriptiveMetadata" should "validate descriptive metadata" in { + + val descriptiveMetadataCriteria = List( + MetadataCriteria("Property1", Text, false, false, false, Nil, None, None), + MetadataCriteria("Property2", Text, false, false, false, Nil, None, None), + MetadataCriteria("Property3", Text, false, false, false, Nil, None, None) + ) + val closureMetadataCriteria = MetadataCriteria("ClosureType", Boolean, true, false, false, List("yes", "no")) + val input = List( + Metadata("Property1", ""), + Metadata("Property2", ""), + Metadata("Property3", "test") + ) + val metadataValidation = new MetadataValidation(closureMetadataCriteria, descriptiveMetadataCriteria) + val error = metadataValidation.validateDescriptiveMetadata(input) + error should be(Nil) + } + + "validateMetadata" should "validate closure and descriptive metadata" in { + + val descriptiveMetadataCriteria = List( + MetadataCriteria("Property21", Text, false, false, false, Nil, None, None), + MetadataCriteria("Property22", Boolean, false, false, false, List("yes", "no"), None, None) + ) + val dependentMetadataCriteria = List( + MetadataCriteria("Property1", Boolean, true, false, false, List("yes", "no"), None, None), + MetadataCriteria("Property2", Text, true, false, false, Nil, None, None) + ) + val closureMetadataCriteria = MetadataCriteria("ClosureType", Boolean, true, false, false, List("yes", "no"), None, Some(Map("Closed" -> dependentMetadataCriteria)), None) + val metadata = List( + Metadata("ClosureType", "Closed"), + Metadata("Property1", ""), + Metadata("Property2", ""), + Metadata("Property21", ""), + Metadata("Property22", "hh") + ) + val fileRows = FileRow("file1", metadata) + val metadataValidation = new MetadataValidation(closureMetadataCriteria, descriptiveMetadataCriteria) + val error = metadataValidation.validateMetadata(List(fileRows)) + error should be( + Map( + "file1" -> List( + Error("Property1", NO_OPTION_SELECTED_ERROR), + Error("Property2", EMPTY_VALUE_ERROR), + Error("Property22", UNDEFINED_VALUE_ERROR) + ) + ) + ) + } +} diff --git a/version.sbt b/version.sbt new file mode 100644 index 0000000..a3a28d6 --- /dev/null +++ b/version.sbt @@ -0,0 +1 @@ +ThisBuild / version := "0.0.1-SNAPSHOT"