Repository: BlueBrain/nexus (scala)

Issue:
Process resolver events in the import batch
**Motivation**
Following #4662, we now have a first version of the import batch to import project events.

This ticket allows to add the processing of resolver events

**Acceptance criteria**
1. Every line is processed and the resolvers are created / updated according to the events
2. An error that is not related to the fact that the event has already been processed should end the batch (resolvers are important to correctly generate the resources later)
3. Auditing metadata is preserved.

Pull request:
Fixes #4697

Patch:
diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/quotas/Quotas.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/quotas/Quotas.scala
index 75c1e64421..65040193a3 100644
--- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/quotas/Quotas.scala
+++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/quotas/Quotas.scala
@@ -2,6 +2,7 @@ package ch.epfl.bluebrain.nexus.delta.sdk.quotas
 
 import cats.effect.IO
 import ch.epfl.bluebrain.nexus.delta.sdk.quotas.model.Quota
+import ch.epfl.bluebrain.nexus.delta.sdk.quotas.model.QuotaRejection.QuotasDisabled
 import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Subject
 import ch.epfl.bluebrain.nexus.delta.sourcing.model.ProjectRef
 
@@ -36,3 +37,16 @@ trait Quotas {
   def reachedForEvents(ref: ProjectRef, subject: Subject): IO[Unit]
 
 }
+
+object Quotas {
+
+  val disabled = new Quotas {
+
+    override def fetch(ref: ProjectRef): IO[Quota] = IO.raiseError(QuotasDisabled(ref))
+
+    override def reachedForResources(ref: ProjectRef, subject: Subject): IO[Unit] = IO.unit
+
+    override def reachedForEvents(ref: ProjectRef, subject: Subject): IO[Unit] = IO.unit
+  }
+
+}
diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resolvers/ResolverContextResolution.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resolvers/ResolverContextResolution.scala
index 9eb028a28b..c14a36c5a4 100644
--- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resolvers/ResolverContextResolution.scala
+++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resolvers/ResolverContextResolution.scala
@@ -123,4 +123,9 @@ object ResolverContextResolution {
       rcr: RemoteContextResolution
   ): ResolverContextResolution =
     apply(rcr, ResourceResolution.dataResource(aclCheck, resolvers, resources, excludeDeprecated = false))
+
+  /**
+    * A [[ResolverContextResolution]] that never resolves
+    */
+  val never: ResolverContextResolution = apply(RemoteContextResolution.never)
 }
diff --git a/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/EventProcessor.scala b/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/EventProcessor.scala
index 22bd91c5c4..09370b56af 100644
--- a/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/EventProcessor.scala
+++ b/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/EventProcessor.scala
@@ -5,6 +5,7 @@ import ch.epfl.bluebrain.nexus.delta.kernel.Logger
 import ch.epfl.bluebrain.nexus.delta.sourcing.event.Event.ScopedEvent
 import ch.epfl.bluebrain.nexus.delta.sourcing.model.EntityType
 import ch.epfl.bluebrain.nexus.delta.sourcing.offset.Offset
+import ch.epfl.bluebrain.nexus.ship.EventProcessor.logger
 import ch.epfl.bluebrain.nexus.ship.model.InputEvent
 import fs2.Stream
 import io.circe.Decoder
@@ -21,7 +22,9 @@ trait EventProcessor[Event <: ScopedEvent] {
   def evaluate(event: Event): IO[Unit]
 
   def evaluate(event: InputEvent): IO[Unit] =
-    IO.fromEither(decoder.decodeJson(event.value)).flatMap(evaluate)
+    IO.fromEither(decoder.decodeJson(event.value))
+      .onError(err => logger.error(err)(s"Error while attempting to decode $resourceType at offset ${event.ordering}"))
+      .flatMap(evaluate)
 }
 
 object EventProcessor {
@@ -35,7 +38,12 @@ object EventProcessor {
     eventStream
       .evalTap { event =>
         processorsMap.get(event.`type`) match {
-          case Some(processor) => processor.evaluate(event)
+          case Some(processor) =>
+            processor.evaluate(event).onError { err =>
+              logger.error(err)(
+                s"Error while processing event with offset '${event.ordering.value}' with processor '${event.`type`}'."
+              )
+            }
           case None            => logger.warn(s"No processor is provided for '${event.`type`}', skipping...")
         }
       }
diff --git a/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/Main.scala b/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/Main.scala
index a37e2c66cc..848ee3b5bb 100644
--- a/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/Main.scala
+++ b/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/Main.scala
@@ -4,7 +4,11 @@ import cats.effect.{Clock, ExitCode, IO}
 import cats.syntax.all._
 import ch.epfl.bluebrain.nexus.delta.kernel.Logger
 import ch.epfl.bluebrain.nexus.delta.kernel.utils.UUIDF
+import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.api.{JsonLdApi, JsonLdJavaApi}
 import ch.epfl.bluebrain.nexus.delta.sdk.organizations.FetchActiveOrganization
+import ch.epfl.bluebrain.nexus.delta.sdk.projects.FetchContext
+import ch.epfl.bluebrain.nexus.delta.sdk.projects.model.ApiMappings
+import ch.epfl.bluebrain.nexus.delta.sdk.quotas.Quotas
 import ch.epfl.bluebrain.nexus.delta.ship.BuildInfo
 import ch.epfl.bluebrain.nexus.delta.sourcing.Transactors
 import ch.epfl.bluebrain.nexus.delta.sourcing.offset.Offset
@@ -12,6 +16,7 @@ import ch.epfl.bluebrain.nexus.ship.config.ShipConfig
 import ch.epfl.bluebrain.nexus.ship.model.InputEvent
 import ch.epfl.bluebrain.nexus.ship.organizations.OrganizationProvider
 import ch.epfl.bluebrain.nexus.ship.projects.ProjectProcessor
+import ch.epfl.bluebrain.nexus.ship.resolvers.ResolverProcessor
 import com.monovore.decline.Opts
 import com.monovore.decline.effect.CommandIOApp
 import fs2.Stream
@@ -54,28 +59,33 @@ object Main
       }
       .map(_.as(ExitCode.Success))
 
-  private def run(file: Path, config: Option[Path]): IO[Unit] = {
-    val clock = Clock[IO]
-    val uuidF = UUIDF.random
+  private[ship] def run(file: Path, config: Option[Path]): IO[Unit] = {
+    val clock                         = Clock[IO]
+    val uuidF                         = UUIDF.random
+    // Resources may have been created with different configurations so we adopt the lenient one for the import
+    implicit val jsonLdApi: JsonLdApi = JsonLdJavaApi.lenient
     for {
       _      <- logger.info(s"Running the import with file $file, config $config and from offset $offset")
       config <- ShipConfig.load(config)
       _      <- Transactors.init(config.database).use { xas =>
-                  val orgProvider = OrganizationProvider(config.eventLog, config.serviceAccount.value, xas, clock)(uuidF)
+                  val orgProvider    = OrganizationProvider(config.eventLog, config.serviceAccount.value, xas, clock)(uuidF)
+                  val fetchContext   = FetchContext(ApiMappings.empty, xas, Quotas.disabled)
+                  val eventLogConfig = config.eventLog
+                  val baseUri        = config.baseUri
                   for {
                     // Provision organizations
-                    _                <- orgProvider.create(config.organizations.values)
-                    events            = eventStream(file)
-                    fetchActiveOrg    = FetchActiveOrganization(xas)
-                    projectProcessor <-
-                      ProjectProcessor(fetchActiveOrg, config.eventLog, xas)(config.baseUri)
-                    _                <- EventProcessor.run(events, projectProcessor)
+                    _                 <- orgProvider.create(config.organizations.values)
+                    events             = eventStream(file)
+                    fetchActiveOrg     = FetchActiveOrganization(xas)
+                    projectProcessor  <- ProjectProcessor(fetchActiveOrg, eventLogConfig, xas)(baseUri)
+                    resolverProcessor <- ResolverProcessor(fetchContext, eventLogConfig, xas)
+                    _                 <- EventProcessor.run(events, projectProcessor, resolverProcessor)
                   } yield ()
                 }
     } yield ()
   }
 
-  private def showConfig(config: Option[Path]) =
+  private[ship] def showConfig(config: Option[Path]) =
     for {
       _      <- logger.info(s"Showing reconciled config")
       config <- ShipConfig.merge(config).map(_._2)
@@ -83,7 +93,11 @@ object Main
     } yield ()
 
   private def eventStream(file: Path): Stream[IO, InputEvent] =
-    Files[IO].readUtf8Lines(file).map(decode[InputEvent]).rethrow
+    Files[IO].readUtf8Lines(file).zipWithIndex.evalMap { case (line, index) =>
+      IO.fromEither(decode[InputEvent](line)).onError { err =>
+        logger.error(err)(s"Error parsing to event at line $index")
+      }
+    }
 
   sealed private trait Command
 
diff --git a/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/model/InputEvent.scala b/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/model/InputEvent.scala
index 3487cce007..51efe74a19 100644
--- a/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/model/InputEvent.scala
+++ b/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/model/InputEvent.scala
@@ -9,7 +9,7 @@ import java.time.Instant
 import scala.annotation.nowarn
 
 final case class InputEvent(
-    ordering: Offset,
+    ordering: Offset.At,
     `type`: EntityType,
     org: Label,
     project: Label,
@@ -24,8 +24,9 @@ object InputEvent {
   @nowarn("cat=unused")
   implicit final val elasticSearchViewValueEncoder: Decoder[InputEvent] = {
     import io.circe.generic.extras.Configuration
-    import io.circe.generic.extras.semiauto._
-    implicit val config: Configuration = Configuration.default
+    import io.circe.generic.extras.semiauto.deriveConfiguredDecoder
+    implicit val offsetDecoder: Decoder[Offset.At] = Decoder.decodeLong.map(Offset.At)
+    implicit val config: Configuration             = Configuration.default
     deriveConfiguredDecoder[InputEvent]
   }
 }
diff --git a/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/resolvers/ResolverProcessor.scala b/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/resolvers/ResolverProcessor.scala
index 15c2980503..8c3b9de8c4 100644
--- a/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/resolvers/ResolverProcessor.scala
+++ b/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/resolvers/ResolverProcessor.scala
@@ -9,7 +9,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.projects.FetchContext
 import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.{ResolverContextResolution, Resolvers, ResolversImpl}
 import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.model.IdentityResolution._
 import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.model.ResolverEvent._
-import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.model.ResolverRejection.IncorrectRev
+import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.model.ResolverRejection.{IncorrectRev, ResourceAlreadyExists}
 import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.model.ResolverValue.{CrossProjectValue, InProjectValue}
 import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.model.{ResolverEvent, ResolverValue}
 import ch.epfl.bluebrain.nexus.delta.sourcing.Transactors
@@ -50,8 +50,9 @@ class ResolverProcessor private (resolvers: Resolvers, clock: EventClock) extend
       case _: ResolverDeprecated                    =>
         resolvers.deprecate(id, projectRef, cRev)
     }
-  }.recoverWith { case i: IncorrectRev =>
-    logger.warn(i)("An incorrect revision as been provided")
+  }.recoverWith {
+    case a: ResourceAlreadyExists => logger.warn(a)("The resolver already exists")
+    case i: IncorrectRev          => logger.warn(i)("An incorrect revision as been provided")
   }.void
 
   private def identities(value: ResolverValue) =
@@ -71,7 +72,6 @@ object ResolverProcessor {
 
   def apply(
       fetchContext: FetchContext,
-      contextResolution: ResolverContextResolution,
       config: EventLogConfig,
       xas: Transactors
   )(implicit api: JsonLdApi): IO[ResolverProcessor] =
@@ -79,7 +79,8 @@ object ResolverProcessor {
       implicit val uuidF: UUIDF = FailingUUID
       val resolvers             = ResolversImpl(
         fetchContext,
-        contextResolution,
+        // We rely on the parsed values and not on the original value
+        ResolverContextResolution.never,
         config,
         xas,
         clock


Test patch:
diff --git a/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/MainSuite.scala b/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/MainSuite.scala
index 48811bf1cc..7d781aae26 100644
--- a/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/MainSuite.scala
+++ b/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/MainSuite.scala
@@ -1,11 +1,12 @@
 package ch.epfl.bluebrain.nexus.delta
 
 import akka.http.scaladsl.server.Route
-import cats.effect.{IO, Resource}
+import cats.effect.IO
 import ch.epfl.bluebrain.nexus.delta.plugin.PluginsLoader.PluginLoaderConfig
 import ch.epfl.bluebrain.nexus.delta.sdk.plugin.PluginDef
 import ch.epfl.bluebrain.nexus.delta.sourcing.postgres.Doobie._
 import ch.epfl.bluebrain.nexus.delta.wiring.DeltaModule
+import ch.epfl.bluebrain.nexus.testkit.config.SystemPropertyOverride
 import ch.epfl.bluebrain.nexus.testkit.elasticsearch.ElasticSearchContainer
 import ch.epfl.bluebrain.nexus.testkit.mu.NexusSuite
 import ch.epfl.bluebrain.nexus.testkit.postgres.PostgresContainer
@@ -13,8 +14,8 @@ import com.typesafe.config.impl.ConfigImpl
 import izumi.distage.model.definition.{Module, ModuleDef}
 import izumi.distage.model.plan.Roots
 import izumi.distage.planning.solver.PlanVerifier
-import munit.{AnyFixture, CatsEffectSuite}
 import munit.catseffect.IOFixture
+import munit.{AnyFixture, CatsEffectSuite}
 
 import java.nio.file.{Files, Paths}
 
@@ -68,62 +69,44 @@ object MainSuite {
   trait Fixture { self: CatsEffectSuite =>
 
     // Overload config via system properties
-    private def acquire(postgres: PostgresContainer, elastic: ElasticSearchContainer): IO[Unit] = IO.delay {
-      val resourceTypesFile = Files.createTempFile("resource-types", ".json")
-      Files.writeString(resourceTypesFile, """["https://neuroshapes.org/Entity"]""")
-      val mappingFile       = Files.createTempFile("mapping", ".json")
-      Files.writeString(mappingFile, "{}")
-      val queryFile         = Files.createTempFile("query", ".json")
-      Files.writeString(
-        queryFile,
-        """CONSTRUCT { {resource_id} <http://schema.org/name> ?name } WHERE { {resource_id} <http://localhost/name> ?name }"""
-      )
-      System.setProperty("app.defaults.database.access.host", postgres.getHost)
-      System.setProperty("app.defaults.database.access.port", postgres.getMappedPort(5432).toString)
-      System.setProperty("app.database.tables-autocreate", "true")
-      System.setProperty("app.defaults.database.access.username", PostgresUser)
-      System.setProperty("app.default.database.access.password", PostgresPassword)
-      System.setProperty("akka.actor.testkit.typed.throw-on-shutdown-timeout", "false")
-      System.setProperty("plugins.elasticsearch.base", s"http://${elastic.getHost}:${elastic.getMappedPort(9200)}")
-      System.setProperty("plugins.elasticsearch.credentials.username", "elastic")
-      System.setProperty("plugins.elasticsearch.credentials.password", "password")
-      //TODO Investigate how to remove this property from the config
-      System.setProperty("plugins.elasticsearch.disable-metrics-projection", "true")
-
-      System.setProperty("plugins.graph-analytics.enabled", "true")
-      System.setProperty("plugins.search.enabled", "true")
-      System.setProperty("plugins.search.indexing.resource-types", resourceTypesFile.toString)
-      System.setProperty("plugins.search.indexing.mapping", mappingFile.toString)
-      System.setProperty("plugins.search.indexing.query", queryFile.toString)
-
-      ConfigImpl.reloadSystemPropertiesConfig()
-    }
-
-    // Resetting system properties
-    private def release = IO.delay {
-      System.clearProperty("app.defaults.database.access.host")
-      System.clearProperty("app.defaults.database.access.port")
-      System.clearProperty("app.defaults.database.access.username")
-      System.clearProperty("app.defaults.database.access.password")
-      System.clearProperty("akka.actor.testkit.typed.throw-on-shutdown-timeout")
-      System.clearProperty("plugins.elasticsearch.credentials.username")
-      System.clearProperty("plugins.elasticsearch.credentials.password")
-
-      System.clearProperty("plugins.graph-analytics.enabled")
-      System.clearProperty("plugins.search.enabled")
-
-      System.clearProperty("plugins.search.indexing.mapping")
-      System.clearProperty("plugins.search.indexing.query")
-      System.clearProperty("plugins.search.indexing.resource-types")
-      ConfigImpl.reloadSystemPropertiesConfig()
-    }
+    private def initConfig(postgres: PostgresContainer, elastic: ElasticSearchContainer): IO[Map[String, String]] =
+      IO.blocking {
+        val resourceTypesFile = Files.createTempFile("resource-types", ".json")
+        Files.writeString(resourceTypesFile, """["https://neuroshapes.org/Entity"]""")
+        val mappingFile       = Files.createTempFile("mapping", ".json")
+        Files.writeString(mappingFile, "{}")
+        val queryFile         = Files.createTempFile("query", ".json")
+        Files.writeString(
+          queryFile,
+          """CONSTRUCT { {resource_id} <http://schema.org/name> ?name } WHERE { {resource_id} <http://localhost/name> ?name }"""
+        )
+
+        Map(
+          "app.defaults.database.access.host"                  -> postgres.getHost,
+          "app.defaults.database.access.port"                  -> postgres.getMappedPort(5432).toString,
+          "app.database.tables-autocreate"                     -> "true",
+          "app.defaults.database.access.username"              -> PostgresUser,
+          "app.default.database.access.password"               -> PostgresPassword,
+          "akka.actor.testkit.typed.throw-on-shutdown-timeout" -> "false",
+          "plugins.elasticsearch.base"                         -> s"http://${elastic.getHost}:${elastic.getMappedPort(9200)}",
+          "plugins.elasticsearch.credentials.username"         -> "elastic",
+          "plugins.elasticsearch.credentials.password"         -> "password",
+          //TODO Investigate how to remove this property from the config
+          "plugins.elasticsearch.disable-metrics-projection"   -> "true",
+          "plugins.graph-analytics.enabled"                    -> "true",
+          "plugins.search.enabled"                             -> "true",
+          "plugins.search.indexing.resource-types"             -> resourceTypesFile.toString,
+          "plugins.search.indexing.mapping"                    -> mappingFile.toString,
+          "plugins.search.indexing.query"                      -> queryFile.toString
+        )
+      }
 
     // Start the necessary containers
     private def resource() =
       for {
         postgres <- PostgresContainer.resource(PostgresUser, PostgresPassword)
         elastic  <- ElasticSearchContainer.resource()
-        _        <- Resource.make(acquire(postgres, elastic))(_ => release)
+        _        <- SystemPropertyOverride(initConfig(postgres, elastic))
       } yield ()
 
     val main: IOFixture[Unit] = ResourceSuiteLocalFixture("main", resource())
diff --git a/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/config/SystemPropertyOverride.scala b/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/config/SystemPropertyOverride.scala
new file mode 100644
index 0000000000..d6f7bedef1
--- /dev/null
+++ b/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/config/SystemPropertyOverride.scala
@@ -0,0 +1,21 @@
+package ch.epfl.bluebrain.nexus.testkit.config
+
+import cats.effect.{IO, Resource}
+import com.typesafe.config.impl.ConfigImpl
+
+object SystemPropertyOverride {
+
+  private def reload: IO[Unit] = IO.delay(ConfigImpl.reloadSystemPropertiesConfig())
+  def apply(io: IO[Map[String, String]]): Resource[IO, Map[String, String]] = {
+    def acquire                                        = io.flatTap { values =>
+      IO.delay(values.foreach { case (k, v) => System.setProperty(k, v) }) >> reload
+    }
+    def release(values: Map[String, String]): IO[Unit] = IO.delay(
+      values.foreach { case (k, _) => System.clearProperty(k) }
+    )
+    Resource.make(acquire)(release)
+  }
+
+  def apply(values: Map[String, String]): Resource[IO, Map[String, String]] = apply(IO.pure(values))
+
+}
diff --git a/ship/src/test/resources/import/import.json b/ship/src/test/resources/import/import.json
new file mode 100644
index 0000000000..925ad19cb1
--- /dev/null
+++ b/ship/src/test/resources/import/import.json
@@ -0,0 +1,9 @@
+{"ordering":2163821,"type":"project","org":"public","project":"sscx","id":"projects/public/sscx","rev":1,"value":{"rev": 1, "base": "https://bbp.epfl.ch/neurosciencegraph/data/", "uuid": "c7d70522-4305-480a-b190-75d757ed9a49", "@type": "ProjectCreated", "label": "sscx", "vocab": "https://bbp.epfl.ch/nexus/v1/resources/public/sscx/_/", "instant": "2020-01-22T16:03:33.105Z", "subject": {"@type": "User", "realm": "bbp", "subject": "bob"}, "apiMappings": {"prov": "http://www.w3.org/ns/prov#", "context": "https://incf.github.io/neuroshapes/contexts/", "schemaorg": "http://schema.org/", "datashapes": "https://neuroshapes.org/dash/", "ontologies": "https://neuroshapes.org/dash/ontology", "taxonomies": "https://neuroshapes.org/dash/taxonomy", "commonshapes": "https://neuroshapes.org/commons/", "provdatashapes": "https://provshapes.org/datashapes/", "provcommonshapes": "https://provshapes.org/commons/"}, "description": "This project contains somatosensorycortex dissemination publication data.", "organizationUuid": "d098a020-508b-4131-a28d-75f73b7f5f0e", "organizationLabel": "public"},"instant":"2020-01-22T17:03:33.105+01:00"}
+{"ordering":2163822,"type":"resolver","org":"public","project":"sscx","id":"https://bluebrain.github.io/nexus/vocabulary/defaultInProject","rev":1,"value":{"id": "https://bluebrain.github.io/nexus/vocabulary/defaultInProject", "rev": 1, "@type": "ResolverCreated", "value": {"name": "Default resolver", "@type": "InProjectValue", "priority": 1, "description": "Resolver created with the project"}, "source": {"@id": "https://bluebrain.github.io/nexus/vocabulary/defaultInProject", "@type": ["InProject", "Resolver"], "@context": "https://bluebrain.github.io/nexus/contexts/resolvers.json", "priority": 1}, "instant": "2020-01-22T16:03:34.841Z", "project": "public/sscx", "subject": {"@type": "User", "realm": "bbp", "subject": "bob"}},"instant":"2020-01-22T17:03:34.841+01:00"}
+{"ordering":2163826,"type":"resolver","org":"public","project":"sscx","id":"https://bluebrain.github.io/nexus/vocabulary/crossProject1","rev":1,"value":{"id": "https://bluebrain.github.io/nexus/vocabulary/crossProject1", "rev": 1, "@type": "ResolverCreated", "value": {"@type": "CrossProjectValue", "priority": 40, "projects": ["neurosciencegraph/datamodels"], "resourceTypes": [], "identityResolution": {"@type": "ProvidedIdentities", "value": [{"@type": "Authenticated", "realm": "bbp"}]}}, "source": {"@id": "https://bluebrain.github.io/nexus/vocabulary/crossProject1", "@type": ["Resolver", "CrossProject"], "@context": "https://bluebrain.github.io/nexus/contexts/resolvers.json", "priority": 50, "projects": ["neurosciencegraph/datamodels"], "identities": [{"@id": "https://bbp.epfl.ch/nexus/v1/realms/bbp/authenticated", "@type": "Authenticated", "realm": "bbp"}]}, "instant": "2020-01-22T16:03:35.040Z", "project": "public/sscx", "subject": {"@type": "User", "realm": "bbp", "subject": "bob"}},"instant":"2020-01-22T17:03:35.04+01:00"}
+{"ordering":2173453,"type":"resolver","org":"public","project":"sscx","id":"https://bluebrain.github.io/nexus/vocabulary/crossProject1","rev":2,"value":{"id": "https://bluebrain.github.io/nexus/vocabulary/crossProject1", "rev": 2, "tpe": "CrossProject", "@type": "ResolverDeprecated", "instant": "2020-01-28T08:23:36.270Z", "project": "public/sscx", "subject": {"@type": "User", "realm": "bbp", "subject": "bob"}},"instant":"2020-01-28T09:23:36.27+01:00"}
+{"ordering":2408475,"type":"resolver","org":"public","project":"sscx","id":"https://bluebrain.github.io/nexus/vocabulary/crossProject2","rev":1,"value":{"id": "https://bluebrain.github.io/nexus/vocabulary/crossProject2", "rev": 1, "@type": "ResolverCreated", "value": {"@type": "CrossProjectValue", "priority": 50, "projects": ["neurosciencegraph/datamodels"], "resourceTypes": [], "identityResolution": {"@type": "ProvidedIdentities", "value": [{"@type": "Authenticated", "realm": "bbp"}]}}, "source": {"@id": "https://bluebrain.github.io/nexus/vocabulary/crossProject2", "@type": ["CrossProject", "Resolver"], "@context": "https://bluebrain.github.io/nexus/contexts/resolvers.json", "priority": 50, "projects": ["neurosciencegraph/datamodels"], "identities": [{"@id": "https://bbp.epfl.ch/nexus/v1/realms/bbp/authenticated", "@type": "Authenticated", "realm": "bbp"}]}, "instant": "2020-03-09T13:36:19.246Z", "project": "public/sscx", "subject": {"@type": "User", "realm": "bbp", "subject": "bob"}},"instant":"2020-03-09T14:36:19.246+01:00"}
+{"ordering":4879496,"type":"resolver","org":"public","project":"sscx","id":"https://bluebrain.github.io/nexus/vocabulary/crossProject2","rev":2,"value":{"id": "https://bluebrain.github.io/nexus/vocabulary/crossProject2", "rev": 2, "@type": "ResolverUpdated", "value": {"@type": "CrossProjectValue", "priority": 50, "projects": ["neurosciencegraph/datamodels"], "resourceTypes": [], "identityResolution": {"@type": "UseCurrentCaller"}}, "source": {"@id": "https://bluebrain.github.io/nexus/vocabulary/crossProject2", "@type": ["Resolver", "CrossProject"], "@context": ["https://bluebrain.github.io/nexus/contexts/resolvers.json", "https://bluebrain.github.io/nexus/contexts/metadata.json"], "priority": 50, "projects": ["neurosciencegraph/datamodels"], "resourceTypes": [], "useCurrentCaller": true}, "instant": "2022-11-16T13:42:07.498Z", "project": "public/sscx", "subject": {"@type": "User", "realm": "bbp", "subject": "bob"}},"instant":"2022-11-16T14:42:07.498+01:00"}
+{"ordering":5300965,"type":"project","org":"public","project":"sscx","id":"projects/public/sscx","rev":2,"value":{"rev": 2, "base": "https://bbp.epfl.ch/neurosciencegraph/data/", "uuid": "c7d70522-4305-480a-b190-75d757ed9a49", "@type": "ProjectUpdated", "label": "sscx", "vocab": "https://bbp.epfl.ch/ontologies/core/bmo/", "instant": "2023-07-16T18:42:59.530Z", "subject": {"@type": "User", "realm": "bbp", "subject": "alice"}, "apiMappings": {"prov": "http://www.w3.org/ns/prov#", "context": "https://incf.github.io/neuroshapes/contexts/", "schemaorg": "http://schema.org/", "datashapes": "https://neuroshapes.org/dash/", "ontologies": "https://neuroshapes.org/dash/ontology", "taxonomies": "https://neuroshapes.org/dash/taxonomy", "commonshapes": "https://neuroshapes.org/commons/", "provdatashapes": "https://provshapes.org/datashapes/", "provcommonshapes": "https://provshapes.org/commons/"}, "description": "This project contains somatosensorycortex dissemination publication data.", "organizationUuid": "d098a020-508b-4131-a28d-75f73b7f5f0e", "organizationLabel": "public"},"instant":"2023-07-16T20:42:59.53+02:00"}
+{"ordering":5318566,"type":"project","org":"public","project":"sscx","id":"projects/public/sscx","rev":3,"value":{"rev": 3, "base": "https://bbp.epfl.ch/nexus/v1/resources/public/sscx/_/", "uuid": "c7d70522-4305-480a-b190-75d757ed9a49", "@type": "ProjectUpdated", "label": "sscx", "vocab": "https://bbp.epfl.ch/ontologies/core/bmo/", "instant": "2023-07-21T13:55:02.463Z", "subject": {"@type": "User", "realm": "bbp", "subject": "alice"}, "apiMappings": {"prov": "http://www.w3.org/ns/prov#", "context": "https://incf.github.io/neuroshapes/contexts/", "schemaorg": "http://schema.org/", "datashapes": "https://neuroshapes.org/dash/", "ontologies": "https://neuroshapes.org/dash/ontology", "taxonomies": "https://neuroshapes.org/dash/taxonomy", "commonshapes": "https://neuroshapes.org/commons/", "provdatashapes": "https://provshapes.org/datashapes/", "provcommonshapes": "https://provshapes.org/commons/"}, "description": "This project contains somatosensorycortex dissemination publication data.", "organizationUuid": "d098a020-508b-4131-a28d-75f73b7f5f0e", "organizationLabel": "public"},"instant":"2023-07-21T15:55:02.463+02:00"}
+{"ordering":5418473,"type":"project","org":"public","project":"sscx","id":"projects/public/sscx","rev":4,"value":{"rev": 4, "base": "https://bbp.epfl.ch/data/public/sscx/", "uuid": "c7d70522-4305-480a-b190-75d757ed9a49", "@type": "ProjectUpdated", "label": "sscx", "vocab": "https://bbp.epfl.ch/ontologies/core/bmo/", "instant": "2023-08-22T15:05:13.654Z", "subject": {"@type": "User", "realm": "bbp", "subject": "alice"}, "apiMappings": {"prov": "http://www.w3.org/ns/prov#", "context": "https://incf.github.io/neuroshapes/contexts/", "schemaorg": "http://schema.org/", "datashapes": "https://neuroshapes.org/dash/", "ontologies": "https://neuroshapes.org/dash/ontology", "taxonomies": "https://neuroshapes.org/dash/taxonomy", "commonshapes": "https://neuroshapes.org/commons/", "provdatashapes": "https://provshapes.org/datashapes/", "provcommonshapes": "https://provshapes.org/commons/"}, "description": "This project contains somatosensorycortex dissemination publication data.", "organizationUuid": "d098a020-508b-4131-a28d-75f73b7f5f0e", "organizationLabel": "public"},"instant":"2023-08-22T17:05:13.654+02:00"}
\ No newline at end of file
diff --git a/ship/src/test/scala/ch/epfl/bluebrain/nexus/ship/MainSuite.scala b/ship/src/test/scala/ch/epfl/bluebrain/nexus/ship/MainSuite.scala
new file mode 100644
index 0000000000..6c8c349530
--- /dev/null
+++ b/ship/src/test/scala/ch/epfl/bluebrain/nexus/ship/MainSuite.scala
@@ -0,0 +1,54 @@
+package ch.epfl.bluebrain.nexus.ship
+
+import cats.effect.{IO, Resource}
+import cats.syntax.all._
+import ch.epfl.bluebrain.nexus.delta.kernel.utils.ClasspathResourceLoader
+import ch.epfl.bluebrain.nexus.delta.sourcing.postgres.Doobie.{PostgresPassword, PostgresUser}
+import ch.epfl.bluebrain.nexus.testkit.config.SystemPropertyOverride
+import ch.epfl.bluebrain.nexus.testkit.mu.NexusSuite
+import ch.epfl.bluebrain.nexus.testkit.postgres.PostgresContainer
+import fs2.io.file.Path
+import munit.{AnyFixture, CatsEffectSuite}
+import munit.catseffect.IOFixture
+
+class MainSuite extends NexusSuite with MainSuite.Fixture {
+
+  override def munitFixtures: Seq[AnyFixture[_]] = List(mainFixture)
+
+  test("Run import") {
+    for {
+      importFile <- ClasspathResourceLoader().absolutePath("import/import.json").map(Path(_))
+      _          <- Main.run(importFile, None)
+    } yield ()
+  }
+
+  test("Show config") {
+    Main.showConfig(None)
+  }
+
+}
+
+object MainSuite {
+
+  trait Fixture { self: CatsEffectSuite =>
+
+    private def initConfig(postgres: PostgresContainer) =
+      Map(
+        "ship.database.access.host"        -> postgres.getHost,
+        "ship.database.access.port"        -> postgres.getMappedPort(5432).toString,
+        "ship.database.tables-autocreate"  -> "true",
+        "ship.organizations.values.public" -> "The public organization"
+      )
+
+    private def resource(): Resource[IO, Unit] =
+      PostgresContainer
+        .resource(PostgresUser, PostgresPassword)
+        .flatMap { postgres =>
+          SystemPropertyOverride(initConfig(postgres))
+        }
+        .void
+
+    val mainFixture: IOFixture[Unit] = ResourceSuiteLocalFixture("main", resource())
+  }
+
+}