Repository: BlueBrain/nexus (scala)

Issue:
Patch file path, content size and checksum in distributions during import batch
**Motivation**
At least the path should change in distributions, the other may also change as for example in some cases they also may be absent.

```
"distribution": {
    "@type": "DataDownload",
    "atLocation": {
      "@type": "Location",
      "location": "/path/bbp/atlas/b/7/a/3/4/4/6/1/L1_HAC%7CcNAC_INH_densities_v3.nrrd",
      "store": {
        "@id": "https://bbp.epfl.ch/neurosciencegraph/data/xxx",
        "@type": "RemoteDiskStorage",
        "_rev": 1
      }
    },
    "contentSize": {
      "unitCode": "bytes",
      "value": 1920289
    },
    "contentUrl": "https://bbp.epfl.ch/nexus/v1/files/bbp/project/...",
    "digest": {
      "algorithm": "SHA-256",
      "value": "6e9eb5e1169ee937c37651e7ff6c60de47dc3c2e58a5d1cf22c6ee44d2023b50"
    },
    "encodingFormat": "application/nrrd",
    "name": "L1_HAC|cNAC_INH_densities_v3.nrrd"
  }
```

**Acceptance criteria**
1. The distribution is patched with the information from the file
2. If the file can't be resolved, we keep the distribution as it was

**Implementation notes**
We already patch the contentUrl where we extract the project and file id.
This can be used to get the file and its attributes for this other step

Pull request:
Fixes #4892

Patch:
diff --git a/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/RunShip.scala b/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/RunShip.scala
index f3116b8400..74e132c13d 100644
--- a/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/RunShip.scala
+++ b/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/RunShip.scala
@@ -63,7 +63,7 @@ object RunShip {
                     resolverProcessor            = ResolverProcessor(fetchContext, projectMapper, eventLogConfig, eventClock, xas)
                     schemaProcessor              = SchemaProcessor(schemaLog, fetchContext, schemaImports, rcr, projectMapper, eventClock)
                     fileSelf                     = FileSelf(originalProjectContext)(originalBaseUri)
-                    sourcePatcher                = SourcePatcher(fileSelf, projectMapper, targetBaseUri)
+                    sourcePatcher                = SourcePatcher(fileSelf, projectMapper, targetBaseUri, fetchContext, eventClock, xas, config)
                     resourceProcessor            = ResourceProcessor(resourceLog, rcr, projectMapper, fetchContext, sourcePatcher, config.resourceTypesToIgnore, eventClock)
                     viewPatcher                  = new ViewPatcher(config.projectMapping)
                     esViewsProcessor             = ElasticSearchViewProcessor(fetchContext, rcr, projectMapper, viewPatcher, eventLogConfig, eventClock, xas)
diff --git a/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/resources/DistributionPatcher.scala b/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/resources/DistributionPatcher.scala
index d8a086ada4..59ac6c0b92 100644
--- a/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/resources/DistributionPatcher.scala
+++ b/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/resources/DistributionPatcher.scala
@@ -1,19 +1,28 @@
 package ch.epfl.bluebrain.nexus.ship.resources
 
+import akka.http.scaladsl.model.Uri
 import cats.effect.IO
 import cats.syntax.all._
 import ch.epfl.bluebrain.nexus.delta.kernel.Logger
 import ch.epfl.bluebrain.nexus.delta.plugins.storage.FileSelf
+import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.Digest.{ComputedDigest, MultiPartDigest, NoDigest, NotComputedDigest}
+import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.{Digest, File, FileId}
 import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.defaultS3StorageId
 import ch.epfl.bluebrain.nexus.delta.rdf.utils.UriUtils
-import ch.epfl.bluebrain.nexus.delta.sdk.model.{BaseUri, ResourceUris}
+import ch.epfl.bluebrain.nexus.delta.sdk.model.{BaseUri, IdSegmentRef, ResourceUris}
+import ch.epfl.bluebrain.nexus.delta.sourcing.model.{ProjectRef, ResourceRef}
 import ch.epfl.bluebrain.nexus.ship.ProjectMapper
 import ch.epfl.bluebrain.nexus.ship.resources.DistributionPatcher._
-import io.circe.Json
+import io.circe.{Encoder, Json, JsonObject}
 import io.circe.optics.JsonPath.root
-import io.circe.syntax.KeyOps
+import io.circe.syntax.{EncoderOps, KeyOps}
 
-final class DistributionPatcher(fileSelfParser: FileSelf, projectMapper: ProjectMapper, targetBase: BaseUri) {
+final class DistributionPatcher(
+    fileSelfParser: FileSelf,
+    projectMapper: ProjectMapper,
+    targetBase: BaseUri,
+    fetchFileResource: FileId => IO[File]
+) {
 
   /**
     * Distribution may be defined as an object or as an array in original payloads
@@ -25,26 +34,76 @@ final class DistributionPatcher(fileSelfParser: FileSelf, projectMapper: Project
     }
   }(_)
 
-  private[resources] def single: Json => IO[Json] = (json: Json) => fileContentUrl(json).map(toS3Location)
+  private def modificationsForFile(project: ProjectRef, resourceRef: ResourceRef): IO[Json => Json] = {
+    val targetProject                                = projectMapper.map(project)
+    val fileId                                       = FileId(IdSegmentRef(resourceRef), targetProject)
+    val newContentUrl                                = ResourceUris("files", targetProject, resourceRef.original).accessUri(targetBase).toString()
+    val fileAttributeModifications: IO[Json => Json] = fetchFileResource(fileId).attempt.map {
+      case Right(file) =>
+        logger.info(s"File '$file' fetched successfully")
+        setLocation(file.attributes.location.toString())
+          .andThen(setContentSize(file.attributes.bytes))
+          .andThen(setDigest(file.attributes.digest))
+      case Left(e)     =>
+        logger.error(e)(s"File '$fileId' could not be fetched")
+        identity
+    }
 
-  private def toS3Location: Json => Json = root.atLocation.store.json.replace(targetStorage)
+    fileAttributeModifications.map(_.andThen(setContentUrl(newContentUrl)))
+  }
+
+  private[resources] def single(json: Json): IO[Json] = {
 
-  private def fileContentUrl: Json => IO[Json] = root.contentUrl.string.modifyA { string: String =>
     for {
-      uri             <- parseAsUri(string)
-      fileSelfAttempt <- fileSelfParser.parse(uri).attempt
-      result          <- fileSelfAttempt match {
-                           case Right((project, resourceRef)) =>
-                             val targetProject = projectMapper.map(project)
-                             IO.pure(ResourceUris("files", targetProject, resourceRef.original).accessUri(targetBase).toString())
-                           case Left(error)                   =>
-                             // We log and keep the value
-                             logger.error(error)(s"'$string' could not be parsed as a file self").as(string)
-                         }
-    } yield result
-  }(_)
+      ids                    <- extractIds(json)
+      fileBasedModifications <- ids match {
+                                  case Some((project, resource)) => modificationsForFile(project, resource)
+                                  case None                      => IO.pure((json: Json) => json)
+                                }
+    } yield {
+      toS3Location.andThen(fileBasedModifications)(json)
+    }
+  }
+
+  private def setContentUrl(newContentUrl: String) = root.contentUrl.string.replace(newContentUrl)
+  private def setLocation(newLocation: String)     = (json: Json) =>
+    json.deepMerge(Json.obj("atLocation" := Json.obj("location" := newLocation)))
+  private def setContentSize(newSize: Long)        = (json: Json) =>
+    json.deepMerge(Json.obj("contentSize" := Json.obj("unitCode" := "bytes", "value" := newSize)))
+  private def setDigest(digest: Digest)            = (json: Json) => json.deepMerge(Json.obj("digest" := digest))
+
+  private def toS3Location: Json => Json = root.atLocation.store.json.replace(targetStorage)
+
+  private def extractIds(json: Json): IO[Option[(ProjectRef, ResourceRef)]] = {
+    root.contentUrl.string.getOption(json).flatTraverse { contentUrl =>
+      for {
+        uri <- parseAsUri(contentUrl)
+        ids <- parseFileSelf(uri)
+      } yield {
+        ids
+      }
+    }
+  }
+
+  private def parseFileSelf(uri: Uri): IO[Option[(ProjectRef, ResourceRef)]] = {
+    fileSelfParser.parse(uri).attempt.flatMap {
+      case Right((projectRef, resourceRef)) =>
+        IO.pure(Some((projectRef, resourceRef)))
+      case Left(error)                      =>
+        logger.error(error)(s"'$uri' could not be parsed as a file self").as(None)
+    }
+  }
+
+  private def parseAsUri(string: String): IO[Uri] =
+    IO.fromEither(UriUtils.uri(string).leftMap(new IllegalArgumentException(_)))
 
-  private def parseAsUri(string: String) = IO.fromEither(UriUtils.uri(string).leftMap(new IllegalArgumentException(_)))
+  implicit private val digestEncoder: Encoder.AsObject[Digest] = Encoder.encodeJsonObject.contramapObject {
+    case ComputedDigest(algorithm, value)                 => JsonObject("algorithm" -> algorithm.asJson, "value" -> value.asJson)
+    case MultiPartDigest(algorithm, value, numberOfParts) =>
+      JsonObject("algorithm" -> algorithm.asJson, "value" -> value.asJson, "numberOfParts" -> numberOfParts.asJson)
+    case NotComputedDigest                                => JsonObject("value" -> "".asJson)
+    case NoDigest                                         => JsonObject("value" -> "".asJson)
+  }
 
 }
 
diff --git a/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/resources/SourcePatcher.scala b/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/resources/SourcePatcher.scala
index 79773bfac5..4796b929f5 100644
--- a/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/resources/SourcePatcher.scala
+++ b/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/resources/SourcePatcher.scala
@@ -1,9 +1,17 @@
 package ch.epfl.bluebrain.nexus.ship.resources
 
-import cats.effect.IO
+import cats.effect.{Clock, IO}
 import ch.epfl.bluebrain.nexus.delta.plugins.storage.FileSelf
+import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.Files.{definition, FilesLog}
+import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.FileRejection.{FileNotFound, RevisionNotFound, TagNotFound}
+import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.{File, FileId, FileState}
+import ch.epfl.bluebrain.nexus.delta.rdf.IriOrBNode.Iri
 import ch.epfl.bluebrain.nexus.delta.sdk.model.BaseUri
+import ch.epfl.bluebrain.nexus.delta.sdk.model.IdSegmentRef.{Latest, Revision, Tag}
+import ch.epfl.bluebrain.nexus.delta.sdk.projects.FetchContext
+import ch.epfl.bluebrain.nexus.delta.sourcing.{ScopedEventLog, Transactors}
 import ch.epfl.bluebrain.nexus.ship.ProjectMapper
+import ch.epfl.bluebrain.nexus.ship.config.InputConfig
 import ch.epfl.bluebrain.nexus.ship.resources.SourcePatcher.removeEmptyIds
 import io.circe.Json
 import io.circe.optics.JsonPath.root
@@ -25,6 +33,33 @@ object SourcePatcher {
     case obj                                     => obj
   }
 
-  def apply(fileSelfParser: FileSelf, projectMapper: ProjectMapper, targetBase: BaseUri): SourcePatcher =
-    new SourcePatcher(new DistributionPatcher(fileSelfParser, projectMapper, targetBase))
+  def apply(
+      fileSelfParser: FileSelf,
+      projectMapper: ProjectMapper,
+      targetBase: BaseUri,
+      fetchContext: FetchContext,
+      clock: Clock[IO],
+      xas: Transactors,
+      config: InputConfig
+  ): SourcePatcher = {
+    val log: FilesLog = ScopedEventLog(definition(clock), config.eventLog, xas)
+
+    def fetchState(id: FileId, iri: Iri): IO[FileState] = {
+      val notFound = FileNotFound(iri, id.project)
+      id.id match {
+        case Latest(_)        => log.stateOr(id.project, iri, notFound)
+        case Revision(_, rev) => log.stateOr(id.project, iri, rev, notFound, RevisionNotFound)
+        case Tag(_, tag)      => log.stateOr(id.project, iri, tag, notFound, TagNotFound(tag))
+      }
+    }
+
+    val fetchFileResource: FileId => IO[File] = id => {
+      for {
+        (iri, _) <- id.expandIri(fetchContext.onRead)
+        state    <- fetchState(id, iri)
+      } yield state.toResource.value
+    }
+
+    new SourcePatcher(new DistributionPatcher(fileSelfParser, projectMapper, targetBase, fetchFileResource))
+  }
 }


Test patch:
diff --git a/ship/src/test/scala/ch/epfl/bluebrain/nexus/ship/resources/DistributionPatcherSuite.scala b/ship/src/test/scala/ch/epfl/bluebrain/nexus/ship/resources/DistributionPatcherSuite.scala
index d39666ede5..7758dce666 100644
--- a/ship/src/test/scala/ch/epfl/bluebrain/nexus/ship/resources/DistributionPatcherSuite.scala
+++ b/ship/src/test/scala/ch/epfl/bluebrain/nexus/ship/resources/DistributionPatcherSuite.scala
@@ -1,49 +1,111 @@
 package ch.epfl.bluebrain.nexus.ship.resources
 
+import akka.http.scaladsl.model.Uri
 import cats.effect.IO
+import ch.epfl.bluebrain.nexus.delta.kernel.utils.UrlUtils
 import ch.epfl.bluebrain.nexus.delta.plugins.storage.FileSelf
 import ch.epfl.bluebrain.nexus.delta.plugins.storage.FileSelf.ParsingError.InvalidFileId
+import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.FileAttributes.FileAttributesOrigin
+import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.FileRejection.FileNotFound
+import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.{Digest, File, FileAttributes, FileId}
+import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.model.{DigestAlgorithm, StorageType}
 import ch.epfl.bluebrain.nexus.delta.rdf.IriOrBNode
 import ch.epfl.bluebrain.nexus.delta.rdf.IriOrBNode.Iri
 import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.nxv
-import ch.epfl.bluebrain.nexus.delta.sdk.model.{BaseUri, ResourceUris}
+import ch.epfl.bluebrain.nexus.delta.sdk.model.{BaseUri, IdSegmentRef, ResourceUris, Tags}
 import ch.epfl.bluebrain.nexus.delta.sdk.syntax._
+import ch.epfl.bluebrain.nexus.delta.sourcing.model.ResourceRef.Revision
 import ch.epfl.bluebrain.nexus.delta.sourcing.model.{Label, ProjectRef, ResourceRef}
 import ch.epfl.bluebrain.nexus.ship.ProjectMapper
 import ch.epfl.bluebrain.nexus.testkit.mu.NexusSuite
+import io.circe.{Json, JsonObject}
+
+import java.util.UUID
 
 class DistributionPatcherSuite extends NexusSuite {
 
-  private val project1       = ProjectRef.unsafe("bbp", "proj1")
-  private val targetProject1 = ProjectRef.unsafe("obp", "proj1")
+  private val projectWithMapping = ProjectRef.unsafe("bbp", "proj1")
+  private val mappedProject      = ProjectRef.unsafe("obp", "proj1")
+  private val projectNoMapping   = ProjectRef.unsafe("bbp", "proj2")
 
-  private val resourceIri: Iri = nxv + "resourceId"
+  private val resource1: Iri = nxv + "resource1"
+  private val resource2: Iri = nxv + "resource2"
 
-  private val prefix          = Label.unsafe("v1")
-  private val originalBaseUri = BaseUri(uri"http://bbp.epfl.ch/nexus", prefix)
-  private val targetBaseUri   = BaseUri(uri"https://www.openbrainplatform.org/api/nexus", prefix)
+  private val prefix             = Label.unsafe("v1")
+  private val sourceBaseUri      = BaseUri(uri"http://bbp.epfl.ch/nexus", prefix)
+  private val destinationBaseUri = BaseUri(uri"https://www.openbrainplatform.org/api/nexus", prefix)
 
-  private val validFileSelfUri = buildFileSelfUri(project1, resourceIri).accessUri(originalBaseUri)
+  private val location = Uri("/actual/path/file.txt")
+  private val path     = Uri.Path("/actual/path/file.txt")
+  private val size     = 420469L
+  private val digest   = "6e9eb5e1169ee937c37651e7ff6c60de47dc3c2e58a5d1cf22c6ee44d2023b50"
 
-  private def buildFileSelfUri(project: ProjectRef, id: Iri) =
-    ResourceUris("files", project, id)
+  private def sourceFileSelf(project: ProjectRef, id: Iri)      = ResourceUris("files", project, id).accessUri(sourceBaseUri)
+  private def destinationFileSelf(project: ProjectRef, id: Iri) =
+    ResourceUris("files", project, id).accessUri(destinationBaseUri)
 
   private val fileSelf = new FileSelf {
-    override def parse(input: IriOrBNode.Iri): IO[(ProjectRef, ResourceRef)] =
-      input match {
-        case value if value.startsWith(originalBaseUri.iriEndpoint) =>
-          IO.pure(project1 -> ResourceRef.Latest(resourceIri))
-        case value                                                  => IO.raiseError(InvalidFileId(value))
+    override def parse(input: IriOrBNode.Iri): IO[(ProjectRef, ResourceRef)] = {
+      val path = if (input.startsWith(sourceBaseUri.iriEndpoint)) {
+        IO.pure(input.stripPrefix(sourceBaseUri.iriEndpoint))
+      } else if (input.startsWith(destinationBaseUri.iriEndpoint)) {
+        IO.pure(input.stripPrefix(destinationBaseUri.iriEndpoint))
+      } else {
+        IO.raiseError(InvalidFileId(input))
       }
+      path
+        .map(_.split("/").filter(_.nonEmpty).toList)
+        .flatMap {
+          case "files" :: org :: project :: id :: Nil =>
+            IO.pure(ProjectRef.unsafe(org, project) -> ResourceRef.Latest(Iri.unsafe(UrlUtils.decode(id))))
+          case _                                      =>
+            IO.raiseError(InvalidFileId(input))
+        }
+    }
   }
 
-  private val patcherNoProjectMapping   = new DistributionPatcher(fileSelf, ProjectMapper(Map.empty), targetBaseUri)
-  private val patcherWithProjectMapping =
-    new DistributionPatcher(fileSelf, ProjectMapper(Map(project1 -> targetProject1)), targetBaseUri)
+  val fileResolver = (id: FileId) =>
+    id match {
+      case FileId(id, project) if project == mappedProject && id == IdSegmentRef(ResourceRef.Latest(resource1)) =>
+        IO.pure(
+          File(
+            resource1,
+            mappedProject,
+            Revision(resource1, 1),
+            StorageType.S3Storage,
+            FileAttributes(
+              UUID.randomUUID(),
+              location,
+              path,
+              "file.txt",
+              None,
+              Map.empty,
+              None,
+              None,
+              size,
+              Digest.ComputedDigest(
+                DigestAlgorithm.SHA256,
+                "6e9eb5e1169ee937c37651e7ff6c60de47dc3c2e58a5d1cf22c6ee44d2023b50"
+              ),
+              FileAttributesOrigin.Storage
+            ),
+            Tags.empty
+          )
+        )
+      case _                                                                                                    => IO.raiseError(FileNotFound(Iri.unsafe(id.id.value.asString), id.project))
+    }
+
+  private val patcher =
+    new DistributionPatcher(
+      fileSelf,
+      ProjectMapper(Map(projectWithMapping -> mappedProject)),
+      destinationBaseUri,
+      fileResolver
+    )
 
   test("Do nothing on a distribution payload without fields to patch") {
     val input = json"""{ "anotherField": "XXX" }"""
-    patcherNoProjectMapping.single(input).assertEquals(input)
+    patcher.single(input).assertEquals(input)
   }
 
   test("Patch location on a distribution to point to the new unique S3 storage") {
@@ -67,45 +129,152 @@ class DistributionPatcherSuite extends NexusSuite {
               }
             }
           }"""
-    patcherWithProjectMapping.single(input).assertEquals(expected)
+    patcher.single(input).assertEquals(expected)
   }
 
   test("Patching an invalid file self should preserve the initial value") {
     val input = json"""{ "contentUrl": "xxx" }"""
-    patcherNoProjectMapping.single(input).assertEquals(input)
+    patcher.single(input).assertEquals(input)
   }
 
   test("Patch a valid file self on a distribution without project mapping") {
-    val input              = json"""{ "contentUrl": "$validFileSelfUri" }"""
-    val expectedContentUri = buildFileSelfUri(project1, resourceIri).accessUri(targetBaseUri)
-    val expected           = json"""{ "contentUrl": "$expectedContentUri" }"""
-    patcherNoProjectMapping.single(input).assertEquals(expected)
+    val input    = json"""{ "contentUrl": "${sourceFileSelf(projectNoMapping, resource1)}" }"""
+    val expected = json"""{ "contentUrl": "${destinationFileSelf(projectNoMapping, resource1)}" }"""
+    patcher.single(input).assertEquals(expected)
   }
 
   test("Patch a valid file self on a distribution with project mapping") {
-    val input              = json"""{ "contentUrl": "$validFileSelfUri" }"""
-    val expectedContentUri = buildFileSelfUri(targetProject1, resourceIri).accessUri(targetBaseUri)
-    val expected           = json"""{ "contentUrl": "$expectedContentUri" }"""
-    patcherWithProjectMapping.single(input).assertEquals(expected)
+    val input              = json"""{ "contentUrl": "${sourceFileSelf(projectWithMapping, resource1)}" }"""
+    val expectedContentUri = destinationFileSelf(mappedProject, resource1).toString()
+    patcher.single(input).map(contentUrl).assertEquals(expectedContentUri)
   }
 
   test("Patch an invalid distribution self should preserve the initial value") {
     val input = json"""{ "distribution":"xxx" }"""
-    patcherNoProjectMapping.singleOrArray(input).assertEquals(input)
+    patcher.singleOrArray(input).assertEquals(input)
   }
 
   test("Patch a valid file self on a distribution as an object") {
-    val input              = json"""{ "distribution": { "contentUrl": "$validFileSelfUri" } }"""
-    val expectedContentUri = buildFileSelfUri(project1, resourceIri).accessUri(targetBaseUri)
-    val expected           = json"""{ "distribution": { "contentUrl": "$expectedContentUri" } }"""
-    patcherNoProjectMapping.singleOrArray(input).assertEquals(expected)
+    val input = json"""{ "distribution": { "contentUrl": "${sourceFileSelf(projectWithMapping, resource1)}" } }"""
+    patcher
+      .singleOrArray(input)
+      .map(distribution)
+      .map(contentUrl)
+      .assertEquals(destinationFileSelf(mappedProject, resource1).toString())
   }
 
   test("Patch a valid file self on a distribution as an array") {
-    val input              = json"""{ "distribution": [{ "contentUrl": "$validFileSelfUri" }] }"""
-    val expectedContentUri = buildFileSelfUri(project1, resourceIri).accessUri(targetBaseUri)
-    val expected           = json"""{ "distribution": [{ "contentUrl": "$expectedContentUri" }] }"""
-    patcherNoProjectMapping.singleOrArray(input).assertEquals(expected)
+    val input    = json"""{ "distribution": [{ "contentUrl": "${sourceFileSelf(projectNoMapping, resource2)}" }] }"""
+    val expected =
+      json"""{ "distribution": [{ "contentUrl": "${destinationFileSelf(projectNoMapping, resource2)}" }] }"""
+    patcher.singleOrArray(input).assertEquals(expected)
+  }
+
+  test("Patch a file location based on what the resource says") {
+    val input =
+      json"""{
+        "distribution": {
+          "contentUrl": "${sourceFileSelf(projectWithMapping, resource1)}",
+          "atLocation": {
+            "location": "/old/path/file.txt"
+          }
+        }
+      }"""
+
+    patcher
+      .singleOrArray(input)
+      .map(distributionLocation)
+      .assertEquals("/actual/path/file.txt")
+  }
+
+  test("Patch a file location based on what the resource says when no existing location present") {
+    val input =
+      json"""{
+        "distribution": {
+          "contentUrl": "${sourceFileSelf(projectWithMapping, resource1)}"
+        }
+      }"""
+
+    patcher
+      .singleOrArray(input)
+      .map(distributionLocation)
+      .assertEquals("/actual/path/file.txt")
+  }
+
+  test("Patch a file size based on what the resource says") {
+    val input =
+      json"""{
+        "distribution": {
+          "contentUrl": "${sourceFileSelf(projectWithMapping, resource1)}"
+        }
+      }"""
+
+    patcher
+      .singleOrArray(input)
+      .map(distrubutionContentSize)
+      .assertEquals(jobj"""{"unitCode": "bytes", "value": $size}""")
+  }
+
+  test("Patch a file digest based on what the resource says") {
+    val input =
+      json"""{
+        "distribution": {
+          "contentUrl": "${sourceFileSelf(projectWithMapping, resource1)}"
+        }
+      }"""
+
+    patcher
+      .singleOrArray(input)
+      .map(distrubutionDigest)
+      .assertEquals(jobj"""{
+                            "algorithm": "SHA-256",
+                            "value": "${digest}"
+                          }""")
+  }
+
+  private def distrubutionContentSize(json: Json): JsonObject = {
+    json.hcursor
+      .downField("distribution")
+      .downField("contentSize")
+      .as[JsonObject]
+      .toOption
+      .getOrElse(fail("contentSize was not present"))
+  }
+
+  private def distrubutionDigest(json: Json): JsonObject = {
+    json.hcursor
+      .downField("distribution")
+      .downField("digest")
+      .as[JsonObject]
+      .toOption
+      .getOrElse(fail("digest was not present"))
+  }
+
+  private def distributionLocation(json: Json): String = {
+    json.hcursor
+      .downField("distribution")
+      .downField("atLocation")
+      .downField("location")
+      .as[String]
+      .toOption
+      .getOrElse(fail("location was not present"))
+  }
+
+  private def contentUrl(json: Json): String = {
+    json.hcursor
+      .downField("contentUrl")
+      .as[String]
+      .toOption
+      .getOrElse(fail("contentUrl was not present"))
+  }
+
+  private def distribution(json: Json): Json = {
+    json.hcursor
+      .downField("distribution")
+      .as[JsonObject]
+      .toOption
+      .getOrElse(fail("distribution was not present"))
+      .toJson
   }
 
 }