From 46e9dae6cf2075e17b2959c0107b7290c1e15a30 Mon Sep 17 00:00:00 2001 From: Jakub Chrobasik Date: Mon, 2 Oct 2023 09:51:06 +0200 Subject: [PATCH] feat: test verifying CRUD project operations on KG API (#3255) * feat: test verifying CRUD project operations on KG API * chore: graph version updated --- CHANGELOG.rst | 40 ++++- acceptance-tests/build.sbt | 33 ++-- acceptance-tests/src/test/resources/bike.jpeg | Bin 0 -> 66061 bytes acceptance-tests/src/test/resources/wheel.png | Bin 0 -> 12137 bytes .../BatchProjectRemovalSpec.scala | 3 +- .../renku/acceptancetests/HandsOnSpec.scala | 2 +- .../ImportZenodoWithCliSpec.scala | 2 +- .../acceptancetests/ProjectAPISpec.scala | 52 +++++-- .../generators/Generators.scala | 20 ++- .../renku/acceptancetests/model/images.scala | 37 +++++ .../acceptancetests/model/projects.scala | 76 +++++++--- .../tooling/AcceptanceSpec.scala | 24 +-- .../acceptancetests/tooling/BddWording.scala | 4 +- .../acceptancetests/tooling/GitLabApi.scala | 35 +++-- .../acceptancetests/tooling/Grammar.scala | 3 +- .../tooling/KnowledgeGraphApi.scala | 127 ++++++++++------ .../tooling/KnowledgeGraphModel.scala | 142 ++++++++++++++++-- .../acceptancetests/tooling/RenkuApi.scala | 5 +- .../acceptancetests/tooling/RestClient.scala | 19 ++- .../tooling/WebDriveredSpec.scala | 50 ++++++ .../tooling/multipart/PartEncoder.scala | 43 ++++++ .../tooling/multipart/syntax.scala | 59 ++++++++ .../acceptancetests/workflows/Datasets.scala | 6 +- .../acceptancetests/workflows/Project.scala | 2 +- .../workflows/RemoveProject.scala | 10 +- docs/spelling_wordlist.txt | 1 + helm-chart/renku/requirements.yaml | 2 +- 27 files changed, 633 insertions(+), 164 deletions(-) create mode 100644 acceptance-tests/src/test/resources/bike.jpeg create mode 100644 acceptance-tests/src/test/resources/wheel.png create mode 100644 acceptance-tests/src/test/scala/ch/renku/acceptancetests/model/images.scala create mode 100644 acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/WebDriveredSpec.scala create mode 100644 acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/multipart/PartEncoder.scala create mode 100644 acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/multipart/syntax.scala diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c01e7908a8..735b1a461a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,11 +1,47 @@ .. _changelog: +0.38.0 +------ + +Renku ``0.38.0`` improves the Knowledge Graph API, with a new Project Creation functionality and a Project Update enhancement. + +User-Facing Changes +~~~~~~~~~~~~~~~~~~~ + +**🌟 New Features** + +- 🖼️ **Knowledge Graph**: New `Project Create API `_ + to create a project in GitLab and Knowledge Graph + (`#1635 `_). + +**🐞 Bug Fixes** + +- **Knowledge Graph**: Improves quality of the results returned by the Cross-Entity Search API. +- **Knowledge Graph**: The `Project Update API `_ to work for non-public projects + (`#1695 `_). + +Internal Changes +~~~~~~~~~~~~~~~~ + +**Bug Fixes** + +- **Knowledge Graph**: Various issues preventing Grafana dashboards not working. + (`#1717 `_) + (`#1719 `_). + +Individual components +~~~~~~~~~~~~~~~~~~~~~~ + +- `renku-graph 2.42.0 `_ +- `renku-graph 2.42.1 `_ + + 0.37.0 ------ -Renku ``0.37.0`` introduces a new feature to pause sessions and later resume them exactly where you left off. All of your work in progress, including files, data, and environment changes not saved to git, are resumed right as you left them. +Renku ``0.37.0`` introduces a new feature to pause sessions and later resume them exactly where you left off. All of your work in progress, including files, data, and environment changes not saved to git, are resumed right as you left them. -This feature replaces RenkuLab's branch-based auto-save mechanism. Most users do not have to do anything to transition from auto-saves to persistent sessions. However, if your last session went into an auto-save, you can still retrieve that work by using Start with Options and selecting your most recent auto-save branch. If your project contains auto-save branches that you do not need anymore, you can safely delete them. +This feature replaces RenkuLab's branch-based auto-save mechanism. Most users do not have to do anything to transition from auto-saves to persistent sessions. However, if your last session went into an auto-save, you can still retrieve that work by using Start with Options and selecting your most recent auto-save branch. If your project contains auto-save branches that you do not need anymore, you can safely delete them. User-Facing Changes ~~~~~~~~~~~~~~~~~~~ diff --git a/acceptance-tests/build.sbt b/acceptance-tests/build.sbt index b57c5c3617..25dd7f947b 100644 --- a/acceptance-tests/build.sbt +++ b/acceptance-tests/build.sbt @@ -30,22 +30,23 @@ publishTo := Some(Resolver.file("Unused transient repository", file("target/unus val circeVersion = "0.14.6" -libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.4.11" -libraryDependencies += "com.github.pureconfig" %% "pureconfig" % "0.17.4" % Test -libraryDependencies += "eu.timepit" %% "refined" % "0.11.0" % Test -libraryDependencies += "io.circe" %% "circe-core" % circeVersion % Test -libraryDependencies += "io.circe" %% "circe-literal" % circeVersion % Test -libraryDependencies += "io.circe" %% "circe-parser" % circeVersion % Test -libraryDependencies += "io.circe" %% "circe-optics" % "0.15.0" % Test -libraryDependencies += "org.http4s" %% "http4s-blaze-client" % "0.23.15" % Test -libraryDependencies += "org.http4s" %% "http4s-circe" % "0.23.23" % Test -libraryDependencies += "org.scalacheck" %% "scalacheck" % "1.17.0" % Test -libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.17" % Test -libraryDependencies += "org.scalatestplus" %% "selenium-4-1" % "3.2.12.1" % Test -libraryDependencies += "org.seleniumhq.selenium" % "selenium-http-jdk-client" % "4.13.0" % Test -libraryDependencies += "org.seleniumhq.selenium" % "selenium-java" % "4.7.1" % Test -libraryDependencies += "org.slf4j" % "slf4j-log4j12" % "2.0.9" % Test -libraryDependencies += "org.typelevel" %% "cats-effect" % "3.5.2" % Test +libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.4.11" +libraryDependencies += "com.github.pureconfig" %% "pureconfig" % "0.17.4" % Test +libraryDependencies += "eu.timepit" %% "refined" % "0.11.0" % Test +libraryDependencies += "io.circe" %% "circe-core" % circeVersion % Test +libraryDependencies += "io.circe" %% "circe-literal" % circeVersion % Test +libraryDependencies += "io.circe" %% "circe-parser" % circeVersion % Test +libraryDependencies += "io.circe" %% "circe-optics" % "0.15.0" % Test +libraryDependencies += "org.http4s" %% "http4s-blaze-client" % "0.23.15" % Test +libraryDependencies += "org.http4s" %% "http4s-circe" % "0.23.23" % Test +libraryDependencies += "org.scalacheck" %% "scalacheck" % "1.17.0" % Test +libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.17" % Test +libraryDependencies += "org.scalatestplus" %% "selenium-4-1" % "3.2.12.1" % Test +libraryDependencies += "org.seleniumhq.selenium" % "selenium-http-jdk-client" % "4.13.0" % Test +libraryDependencies += "org.seleniumhq.selenium" % "selenium-java" % "4.7.1" % Test +libraryDependencies += "org.slf4j" % "slf4j-log4j12" % "2.0.9" % Test +libraryDependencies += "org.typelevel" %% "cats-effect" % "3.5.2" % Test +libraryDependencies += "org.typelevel" %% "cats-effect-testing-scalatest" % "1.5.0" % Test scalacOptions += "-feature" scalacOptions += "-unchecked" diff --git a/acceptance-tests/src/test/resources/bike.jpeg b/acceptance-tests/src/test/resources/bike.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..10aafc4469fb1a548a535d128ab070695979b693 GIT binary patch literal 66061 zcmeFZWmsLwwl=ze;O_1Y!QBb&8rE@;yyG1;=a^Nas^*%D=Y{9503<0fNihHj2mk;A`~y5M1B3zKV6R@i z0s{wT;Naj8kZ@3tz=Q}33k`>ih=PKQh>VPifrp8ThJ%ibj75xvgO5*0NQjC_LPkPB zhDSh1@L~i6NQH!eM1X=qAV5P#BlusZ=WYNp)T<6iJTMRv04OpD7&6Fn4*(B%lAvH9 zFX#I!K|sC&hXMnALDByN{8RvWUIM^@0ZB+;NWg1s7W_r~&+$JV_>TwvLu`KD(B64n1-ivyMYBDP$)dGh*yCI1hS%PS;&_$I1y z0kH_3`$KrivC@8-w?|+yV%Q5b0D!yGE&El@UrkW8PC+Q0jnDoAffSuehsezOtKPpM z!D<%bDM;TBfy7dHU1}xTF8EgtDe`wb)gz+cKW|lf19W{#A@HZ5b;^9TfS;XlG5)_H z{?*jqNQ$i^-Z6qUF5z0gj`|xIDEd5J0<%1#7^jJ$$}jZyD*uKQ1SzKw$qw@<@4n9_ zk-1?lrVrx%-_bzAM;n|KK+7KH^OQONPT}8@!D*M=6@3T*0I-{cRN5<`?a$mEop#0P zPUIpLTc>8<+y0q})2yD|%a?{s+5=8BX;UjOg5cSIo9Pe~|z0ir^Y* z*3N?Twvw|LO8^M=5E1vXc$gzq=-+s#iYO%LuGq26PYPoGY|`alB7C-}RO|7`)H zt-w&AGXwz5tM81sDr%GUCj)K=jXAJ6^}U!m2gt)Vsgr0hn-lR%iaAW6KEU(QT(*C$ zSM?{t|E2<>sqx{|yt{$>h=~M;K78*B8pF=4v-z*qJxa@2;EF0n4~b7w~r! zo*xt(Il!5k68wwezb%NoT#eKQR0QWgz#~Jy-x(j4D+6(9haQ>QZqNLis0vzH)7;bz zb8}ujH97KcH}V%nLPEQ#)Gjp7gw^nX6dy0e`2?ns@V<1x+7n%^Hl@E#d4%L{3f64; z4C~@5uH1jI_HPS7qnqxFE@6_t&|jta09Zf#n6C1Rff?w>$86ry^^@`WS3`bs_g^Q$ zQOW9W_5T(6ck2Dj$ej14)Zvm|&BI6sK};t26nU!WNp%ju#b zKPq�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 literal 0 HcmV?d00001 diff --git a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/BatchProjectRemovalSpec.scala b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/BatchProjectRemovalSpec.scala index 0a7dfc3c9f..5f838a664e 100644 --- a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/BatchProjectRemovalSpec.scala +++ b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/BatchProjectRemovalSpec.scala @@ -18,6 +18,7 @@ package ch.renku.acceptancetests +import ch.renku.acceptancetests.model.projects import ch.renku.acceptancetests.tooling.TestLogger.logger import ch.renku.acceptancetests.tooling.{AcceptanceSpec, KnowledgeGraphApi} import ch.renku.acceptancetests.workflows._ @@ -61,7 +62,7 @@ class BatchProjectRemovalSpec extends AcceptanceSpec with Login with RemoveProje case (summary, ProjectInfo(_, path, fullPath, created)) => if (batchRemoveConfig.patterns.exists(_ matches path) && (created < Instant.now().minus(gracePeriod))) { logger.info(s"Removing '$path' - the removal pattern matched and it's older than ${gracePeriod.toDays} days") - `DELETE /knowledge-graph/projects/:slug`(fullPath) + `DELETE /knowledge-graph/projects/:slug`(projects.Slug(fullPath)) summary.incrementRemoved() } else summary.incrementFound() diff --git a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/HandsOnSpec.scala b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/HandsOnSpec.scala index 4433638915..4a4ec27151 100644 --- a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/HandsOnSpec.scala +++ b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/HandsOnSpec.scala @@ -46,7 +46,7 @@ class HandsOnSpec val flightsDatasetName = `follow the flights tutorial`(projectUrl) When("all the events are processed by the knowledge-graph") - `wait for KG to process events`(projectDetails.asProjectIdentifier, webDriver) + `wait for KG to process events`(projectDetails.asProjectIdentifier.asProjectSlug, webDriver) `verify dataset was created`(flightsDatasetName) diff --git a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/ImportZenodoWithCliSpec.scala b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/ImportZenodoWithCliSpec.scala index 5bfe66dd51..19403bede2 100644 --- a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/ImportZenodoWithCliSpec.scala +++ b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/ImportZenodoWithCliSpec.scala @@ -69,7 +69,7 @@ class ImportZenodoWithCliSpec sleep(10 seconds) When("all the events are processed by the knowledge-graph") - `wait for KG to process events`(projectDetails.asProjectIdentifier, webDriver) + `wait for KG to process events`(projectDetails.asProjectIdentifier.asProjectSlug, webDriver) sleep(5 seconds) diff --git a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/ProjectAPISpec.scala b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/ProjectAPISpec.scala index 245902b786..438577cd12 100644 --- a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/ProjectAPISpec.scala +++ b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/ProjectAPISpec.scala @@ -21,29 +21,55 @@ package ch.renku.acceptancetests import cats.syntax.all._ import ch.renku.acceptancetests.generators.Generators.Implicits._ import ch.renku.acceptancetests.model.projects -import ch.renku.acceptancetests.tooling.{AcceptanceSpec, KnowledgeGraphApi} -import ch.renku.acceptancetests.workflows.{Login, Project} +import ch.renku.acceptancetests.tooling.{AcceptanceSpec, GitLabApi, KnowledgeGraphApi} +import ch.renku.acceptancetests.workflows.Login import org.scalacheck.Gen +import org.scalatest.OptionValues import tooling.KnowledgeGraphModel._ -class ProjectAPISpec extends AcceptanceSpec with Login with Project with KnowledgeGraphApi { +import scala.concurrent.duration._ - scenario("User can update project using the API") { +class ProjectAPISpec extends AcceptanceSpec with Login with KnowledgeGraphApi with GitLabApi with OptionValues { + + scenario("User can do CRUD operations on a project using the API") { `log in to Renku` - `create, continue or open a project` + When("the user creates a new Project") + val namespaceId = `find user namespace ids`.headOption.getOrElse(fail("No namespaces found")) + val newProject = NewProject.generate(namespaceId, ProjectTemplate.pythonMinimal, Image.wheelPngExample) + val slug = `POST /knowledge-graph/projects`(newProject) + + `wait for KG to process events`(slug, webDriver) + + Then("the user should be able to get details of it") + val afterCreation = `GET /knowledge-graph/projects/:slug`(slug).value + newProject.visibility shouldBe afterCreation.visibility + newProject.maybeDescription shouldBe afterCreation.maybeDescription + newProject.keywords shouldBe afterCreation.keywords + newProject.maybeImage.map(_.toName).toList shouldBe afterCreation.images.map(_.toName) When("the user updates the project") - val newDesc = Some("new description") - val newKeywords = Set("new keyword") - val newVisibility = Gen.oneOf(projects.Visibility.all).suchThat(_ != projectDetails.visibility).generateOne - val updates = - ProjectUpdates(newDescription = newDesc.some, newKeywords = newKeywords.some, newVisibility = newVisibility.some) - `PATCH /knowledge-graph/projects/:slug`(projectDetails.asProjectSlug, updates) + val newDesc = projects.Description.generate().some + val newKeywords = Set(projects.Keyword.generate()) + val newVisibility = Gen.oneOf(projects.Visibility.all).suchThat(_ != newProject.visibility).generateOne + val newImage = Image.bikeJpgExample.some + val updates = ProjectUpdates(newDesc.some, newKeywords.some, newVisibility.some, newImage.some) + `PATCH /knowledge-graph/projects/:slug`(slug, updates) + + `wait for KG to process events`(slug, webDriver) Then("the API should return the updated values") - `GET /knowledge-graph/projects/:slug`(projectDetails.asProjectSlug) shouldBe - KGProjectDetails(description = newDesc, keywords = newKeywords, visibility = newVisibility) + val afterUpdate = `GET /knowledge-graph/projects/:slug`(slug).value + newVisibility shouldBe afterUpdate.visibility + newDesc shouldBe afterUpdate.maybeDescription + newKeywords shouldBe afterUpdate.keywords + newImage.map(_.toName).toList shouldBe afterUpdate.images.map(_.toName) + + When("the user removes the project") + `DELETE /knowledge-graph/projects/:slug`(slug) sleep (5 seconds) + + Then("the API should not be able to find the project any more") + `GET /knowledge-graph/projects/:slug`(slug) shouldBe None } } diff --git a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/generators/Generators.scala b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/generators/Generators.scala index b6862401e2..3bdfeb8372 100644 --- a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/generators/Generators.scala +++ b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/generators/Generators.scala @@ -93,9 +93,9 @@ object Generators { lines <- Gen.listOfN(size, nonEmptyStrings()) } yield lines - def listOf[T](generator: Gen[T], maxElements: Int Refined Positive = 5): Gen[List[T]] = + def listOf[T](generator: Gen[T], min: Int = 0, max: Int = 5): Gen[List[T]] = for { - size <- choose(0, maxElements.value) + size <- choose(min, max) list <- Gen.listOfN(size, generator) } yield list @@ -184,7 +184,10 @@ object Generators { implicit class GenOps[T](generator: Gen[T]) { - def generateOne: T = generator.sample getOrElse generateOne + def generateOne: T = generateExample(generator) + + def generateList(min: Int = 0, max: Int = 5): List[T] = + generateExample(listOf(generator, min, max)) def generateDifferentThan(value: T): T = { val generated = generator.sample.getOrElse(generateDifferentThan(value)) @@ -192,6 +195,17 @@ object Generators { else generated } + protected def generateExample[O](generator: Gen[O]): O = { + @annotation.tailrec + def loop(tries: Int): O = + generator.sample match { + case Some(o) => o + case None if tries >= 5000 => sys.error(s"Failed to generate example value after $tries tries") + case None => loop(tries + 1) + } + + loop(0) + } } implicit def asArbitrary[T](implicit generator: Gen[T]): Arbitrary[T] = Arbitrary(generator) diff --git a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/model/images.scala b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/model/images.scala new file mode 100644 index 0000000000..a318a58a56 --- /dev/null +++ b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/model/images.scala @@ -0,0 +1,37 @@ +/* + * Copyright 2023 Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ch.renku.acceptancetests.model + +import ch.renku.acceptancetests.generators.Generators.Implicits._ +import ch.renku.acceptancetests.generators.Generators.httpUrls +import org.scalacheck.Gen + +object images { + + final case class Name(value: String) { override lazy val toString: String = value } + + final case class ImageUri(value: String) { + override lazy val toString: String = value + lazy val toName: Name = Name(value.split("/").last) + } + object ImageUri { + val generator: Gen[ImageUri] = httpUrls.map(ImageUri(_)) + def generate(): ImageUri = generator.generateOne + } +} diff --git a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/model/projects.scala b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/model/projects.scala index fa7bdf3715..547c360831 100644 --- a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/model/projects.scala +++ b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/model/projects.scala @@ -24,9 +24,11 @@ import ch.renku.acceptancetests.generators.Generators._ import ch.renku.acceptancetests.model.AuthorizationToken.{OAuthAccessToken, PersonalAccessToken} import ch.renku.acceptancetests.model.projects.ProjectDetails._ import ch.renku.acceptancetests.model.users.UserCredentials +import ch.renku.acceptancetests.tooling.multipart.PartValueEncoder import io.circe.DecodingFailure.Reason import io.circe.syntax._ import io.circe.{Decoder, DecodingFailure, Encoder} +import org.scalacheck.Gen import java.net.URI import java.time.LocalDateTime @@ -36,8 +38,8 @@ import scala.util.Random object projects { final case class ProjectIdentifier(namespace: String, path: String) { - lazy val asProjectSlug: String = s"$namespace/$path" - override lazy val toString: String = s"$namespace/$path" + lazy val asProjectSlug: projects.Slug = projects.Slug(s"$namespace/$path") + override lazy val toString: String = s"$namespace/$path" } final case class ProjectDetails( @@ -53,12 +55,59 @@ object projects { path = title.toPathSegment ) - def asProjectSlug(implicit userCredentials: UserCredentials): String = + def asProjectSlug(implicit userCredentials: UserCredentials): projects.Slug = asProjectIdentifier.asProjectSlug } - sealed abstract class Visibility(val value: String) + final case class Slug(value: String) { override lazy val toString: String = value } + + final case class Name(value: String) { override lazy val toString: String = value } + object Name { + + implicit val pvEncoder: PartValueEncoder[Name] = _.value + + def generate(): Name = Name( + s"test ${LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH-mm-ss"))} ${Random.nextInt(100)}" + ) + } + + final case class NamespaceId(value: Int) { override lazy val toString: String = value.toString } + object NamespaceId { + implicit val pvEncoder: PartValueEncoder[NamespaceId] = _.value.toString + } + + final case class Description(value: String) { override lazy val toString: String = value } + object Description { + def generate(): Description = nonEmptyStrings().map(Description(_)).generateOne + + implicit val pvEncoder: PartValueEncoder[Description] = _.value + implicit val jsonEncoder: Encoder[Description] = Encoder.instance(_.value.asJson) + implicit val jsonDecoder: Decoder[Description] = Decoder.instance(_.as[String].map(Description(_))) + } + + final case class Keyword(value: String) { override lazy val toString: String = value } + object Keyword { + val generator: Gen[Keyword] = nonEmptyStrings().map(Keyword(_)) + def generate(): Keyword = generator.generateOne + + implicit val pvEncoder: PartValueEncoder[Keyword] = _.value + implicit val jsonEncoder: Encoder[Keyword] = Encoder.instance(_.value.asJson) + implicit val jsonDecoder: Decoder[Keyword] = Decoder.instance(_.as[String].map(Keyword(_))) + } + final case class TemplateRepoUrl(value: String) { override lazy val toString: String = value } + object TemplateRepoUrl { + implicit val pvEncoder: PartValueEncoder[TemplateRepoUrl] = _.value + } + + final case class TemplateId(value: String) { override lazy val toString: String = value } + object TemplateId { + implicit val pvEncoder: PartValueEncoder[TemplateId] = _.value + } + + final case class Template(name: String) { override lazy val toString: String = name } + + sealed abstract class Visibility(val value: String) object Visibility { case object Public extends Visibility(value = "public") case object Private extends Visibility(value = "private") @@ -66,7 +115,10 @@ object projects { val all: Set[Visibility] = Set(Public, Internal, Private) - implicit val jsonEncoder: Encoder[Visibility] = Encoder.instance(_.value.asJson) + def generate(): Visibility = Gen.oneOf(all).generateOne + + implicit val pvEncoder: PartValueEncoder[Visibility] = _.value + implicit val jsonEncoder: Encoder[Visibility] = Encoder.instance(_.value.asJson) implicit val jsonDecoder: Decoder[Visibility] = Decoder.instance { cur => cur.as[String].flatMap { v => all @@ -78,14 +130,7 @@ object projects { } } - case class Template(name: String) { - override lazy val toString: String = name - } - - final case class ProjectUrl(value: String) { - override lazy val toString: String = value - } - + final case class ProjectUrl(value: String) { override lazy val toString: String = value } object ProjectUrl { implicit class ProjectUrlOps(projectUrl: ProjectUrl)(implicit userCredentials: UserCredentials) { @@ -113,10 +158,7 @@ object projects { maybeDescription: Option[String] = None, maybeTemplate: Option[String] = None ): ProjectDetails = { - val now = LocalDateTime.now() - val title = maybeTitle.getOrElse( - s"test ${now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH-mm-ss"))} ${Random.nextInt(100)}" - ) + val title = maybeTitle.getOrElse(Name.generate().value) val desc = maybeDescription.getOrElse( prefixParagraph("Generated by tests: ", maxWords = 3).generateOne ) diff --git a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/AcceptanceSpec.scala b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/AcceptanceSpec.scala index 6c28f99fb0..0aea9dcfa5 100644 --- a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/AcceptanceSpec.scala +++ b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/AcceptanceSpec.scala @@ -37,35 +37,13 @@ trait AcceptanceSpec with ScreenCapturingSpec with AcceptanceSpecData with AcceptanceSpecPatience + with WebDriveredSpec with IOSpec { protected implicit val browser: AcceptanceSpec = this - implicit lazy val webDriver: WebDriver = { - System.setProperty("webdriver.http.factory", "jdk-http-client") - startWebDriver - } - protected implicit val docsScreenshots: DocsScreenshots = DocsScreenshots(this, webDriver) - protected override def afterAll(): Unit = { - webDriver.quit() - super.afterAll() - } - - private def startWebDriver: WebDriver = - sys.env.get("DOCKER") match { - case Some(_) => - new ChromeDriver( - new ChromeDriverService.Builder().withWhitelistedIps("127.0.0.1").build, - new ChromeOptions().addArguments("no-sandbox", "headless", "disable-gpu", "window-size=1920,1600") - ) - case None => - new ChromeDriver( - new ChromeOptions().addArguments("window-size=1920,1600") - ) - } - protected override type FixtureParam = Unit override def withFixture(test: OneArgTest): Outcome = diff --git a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/BddWording.scala b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/BddWording.scala index 3d13ccc91a..52d8fccd6a 100644 --- a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/BddWording.scala +++ b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/BddWording.scala @@ -18,11 +18,11 @@ package ch.renku.acceptancetests.tooling -import org.scalatest.Tag +import org.scalatest.{Suite, Tag} import org.scalatest.featurespec.FixtureAnyFeatureSpecLike trait BddWording extends FixtureAnyFeatureSpecLike { - self: AcceptanceSpec => + self: Suite => import TestLogger.logger diff --git a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/GitLabApi.scala b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/GitLabApi.scala index 9db85136b6..f71e865f1d 100644 --- a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/GitLabApi.scala +++ b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/GitLabApi.scala @@ -18,15 +18,18 @@ package ch.renku.acceptancetests.tooling +import TestLogger.logger +import cats.effect.unsafe.IORuntime import cats.syntax.all._ -import ch.renku.acceptancetests.model.AuthorizationToken import ch.renku.acceptancetests.model.AuthorizationToken.OAuthAccessToken import ch.renku.acceptancetests.model.projects.ProjectIdentifier +import ch.renku.acceptancetests.model.{AuthorizationToken, projects} import io.circe.Decoder._ import io.circe.{Decoder, Json} import org.http4s.Status._ import org.http4s.Uri.Path.SegmentEncoder._ import org.http4s.UrlForm +import org.scalatest.matchers.should import java.lang.Thread.sleep import java.time.Instant @@ -34,7 +37,9 @@ import scala.annotation.tailrec import scala.concurrent.duration._ trait GitLabApi extends RestClient { - self: AcceptanceSpecData with BddWording with IOSpec => + self: AcceptanceSpecData with should.Matchers => + + implicit val ioRuntime: IORuntime lazy val authorizationToken: AuthorizationToken = userCredentials.maybeGitLabAccessToken getOrElse oauthAccessToken @@ -102,23 +107,21 @@ trait GitLabApi extends RestClient { .and("order_by", "created_at") .and("sort", "desc") .and("page", page) - GET( - url - ) + GET(url) .withAuthorizationToken(authorizationToken) .send(whenReceived(status = Ok) >=> bodyToJson) .extract(decoder) } - def `get GitLab project id`(projectId: ProjectIdentifier): Int = - GET(gitLabAPIUrl / "projects" / projectId.asProjectSlug) + def `get GitLab project id`(projectSlug: projects.Slug): Int = + GET(gitLabAPIUrl / "projects" / projectSlug.value) .withAuthorizationToken(authorizationToken) .send(whenReceived(status = Ok) >=> bodyToJson) .extract(jsonRoot.id.int.getOption) - .getOrElse(fail(s"Cannot find '$projectId' project in GitLab")) + .getOrElse(fail(s"Cannot find '$projectSlug' project in GitLab")) def `project exists in GitLab`(projectId: ProjectIdentifier): Boolean = - GET(gitLabAPIUrl / "projects" / projectId.asProjectSlug) + GET(gitLabAPIUrl / "projects" / projectId.asProjectSlug.value) .withAuthorizationToken(authorizationToken) .send(mapResponse { _.status match { @@ -133,7 +136,7 @@ trait GitLabApi extends RestClient { val waitTime = 1 second if (!`project exists in GitLab`(projectId) && attempt < 120) { - And("waits for Project creation in GitLab") + logger.info("waits for Project creation in GitLab") sleep(waitTime.toMillis) `wait for project creation`(projectId) } else if (!`project exists in GitLab`(projectId)) { @@ -142,7 +145,17 @@ trait GitLabApi extends RestClient { } def `delete project in GitLab`(projectId: ProjectIdentifier): Unit = - DELETE(gitLabAPIUrl / "projects" / projectId.asProjectSlug) + DELETE(gitLabAPIUrl / "projects" / projectId.asProjectSlug.value) .withAuthorizationToken(authorizationToken) .send(expect(status = Accepted, otherwiseLog = s"Deletion of '${projectId.path}' project in GitLab failed")) + + def `find user namespace ids`: List[projects.NamespaceId] = { + implicit val decoder: Decoder[projects.NamespaceId] = + Decoder.instance[projects.NamespaceId](_.downField("id").as[Int].map(projects.NamespaceId(_))) + + GET(gitLabAPIUrl / "namespaces") + .withAuthorizationToken(authorizationToken) + .sendIO(whenReceived(status = Ok) >=> decodePayload[List[projects.NamespaceId]]) + .unsafeRunSync() + } } diff --git a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/Grammar.scala b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/Grammar.scala index 9c65144fff..755c8b71d5 100644 --- a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/Grammar.scala +++ b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/Grammar.scala @@ -25,6 +25,7 @@ import ch.renku.acceptancetests.pages.Page import org.openqa.selenium.{WebDriver, WebElement} import org.scalatest.concurrent.Eventually import org.scalatest.exceptions.TestFailedException +import org.scalatest.matchers.should import org.scalatestplus.selenium import org.scalatestplus.selenium.WebBrowser @@ -32,7 +33,7 @@ import scala.concurrent.duration._ import scala.language.implicitConversions trait Grammar extends WebElementOps with WebDriverOps with Scripts with Eventually { - self: WebBrowser with AcceptanceSpec => + self: WebBrowser with WebDriveredSpec with should.Matchers => implicit def toSeleniumPage[Url <: BaseUrl](page: Page[Url])(implicit baseUrl: Url): selenium.Page = new selenium.Page { diff --git a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/KnowledgeGraphApi.scala b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/KnowledgeGraphApi.scala index ea65a41d67..c3bdf7b97a 100644 --- a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/KnowledgeGraphApi.scala +++ b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/KnowledgeGraphApi.scala @@ -19,69 +19,96 @@ package ch.renku.acceptancetests.tooling import KnowledgeGraphModel._ -import TestLogger._ +import TestLogger.logger import cats.effect.IO +import cats.effect.unsafe.IORuntime import cats.syntax.all._ -import ch.renku.acceptancetests.model.projects.ProjectIdentifier +import ch.renku.acceptancetests.model.projects import io.circe._ -import io.circe.syntax._ import org.http4s.MediaType._ -import org.http4s.Status.{Accepted, NotFound, Ok} +import org.http4s.Status.{Accepted, Created, NotFound, Ok} import org.http4s.circe.CirceEntityCodec._ import org.http4s.headers.`Content-Type` import org.openqa.selenium.WebDriver +import org.scalatest.matchers.should import java.time.Instant import scala.annotation.tailrec import scala.concurrent.duration._ trait KnowledgeGraphApi extends RestClient { - self: AcceptanceSpecData with GitLabApi with Grammar with BddWording with IOSpec => + self: AcceptanceSpecData with GitLabApi with Grammar with should.Matchers => - def `wait for KG to process events`(projectId: ProjectIdentifier, browser: WebDriver): Unit = { + implicit val ioRuntime: IORuntime + + def `wait for KG to process events`(projectSlug: projects.Slug, browser: WebDriver): Unit = { sleep(1 second) - val gitLabProjectId = `get GitLab project id`(projectId) - checkStatusAndWait(projectId, gitLabProjectId, browser, 1) + val gitLabProjectId = `get GitLab project id`(projectSlug) + checkStatusAndWait(projectSlug, gitLabProjectId, browser, 1) } - def `wait for project activation`(projectId: ProjectIdentifier)(implicit browser: WebDriver): Either[String, Unit] = { + def `wait for project activation`(projectSlug: projects.Slug)(implicit browser: WebDriver): Either[String, Unit] = { sleep(1 second) - val gitLabProjectId = `get GitLab project id`(projectId) - checkActivatedAndWait(projectId, gitLabProjectId, browser, attempt = 1) + val gitLabProjectId = `get GitLab project id`(projectSlug) + checkActivatedAndWait(projectSlug, gitLabProjectId, browser, attempt = 1) } - def findLineage(projectPath: String, filePath: String): JsonObject = { + def findLineage(slug: projects.Slug, filePath: String): JsonObject = { val toSegments: String => List[String] = _.split('/').toList val uri = - toSegments(projectPath) + toSegments(slug.value) .foldLeft(renkuBaseUrl / "knowledge-graph" / "projects")(_ / _) / "files" / filePath / "lineage" GET(uri) .send(whenReceived(status = Ok) >=> bodyToJson) .extract(jsonRoot.obj.getOption) - .getOrElse(fail(s"Cannot find lineage data for project $projectPath file $filePath")) + .getOrElse(fail(s"Cannot find lineage data for project $slug file $filePath")) } - def `GET /knowledge-graph/projects/:slug`(slug: String): KGProjectDetails = { + def `POST /knowledge-graph/projects`(project: NewProject): projects.Slug = + NewProject.MultipartEncoder + .encode(project) + .flatMap(payload => + POST(renkuBaseUrl / "knowledge-graph" / "projects") + .withEntity(payload) + .putHeaders(payload.headers) + .withAuthorizationToken(authorizationToken) + .sendIO(whenReceived(status = Created) >=> decodePayload(_.downField("slug").as[String].map(projects.Slug))) + ) + .unsafeRunSync() + + def `PATCH /knowledge-graph/projects/:slug`(slug: projects.Slug, updates: ProjectUpdates): Unit = { val toSegments: String => List[String] = _.split('/').toList - val uri = toSegments(slug).foldLeft(renkuBaseUrl / "knowledge-graph" / "projects")(_ / _) - GET(uri) - .withAuthorizationToken(authorizationToken) - .putHeaders(`Content-Type`(application.json)) - .send(whenReceived(status = Ok) >=> decodePayload[KGProjectDetails]) + val uri = toSegments(slug.value).foldLeft(renkuBaseUrl / "knowledge-graph" / "projects")(_ / _) + ProjectUpdates.MultipartEncoder + .encode(updates) + .flatMap(payload => + PATCH(uri) + .withEntity(payload) + .putHeaders(payload.headers) + .withAuthorizationToken(authorizationToken) + .sendIO(expect(status = Accepted, otherwiseLog = s"Updating '$slug' project failed")) + ) + .unsafeRunSync() } - def `PATCH /knowledge-graph/projects/:slug`(slug: String, updates: ProjectUpdates): Unit = { + def `GET /knowledge-graph/projects/:slug`(slug: projects.Slug): Option[KGProjectDetails] = { val toSegments: String => List[String] = _.split('/').toList - val uri = toSegments(slug).foldLeft(renkuBaseUrl / "knowledge-graph" / "projects")(_ / _) - PATCH(uri) - .withEntity(updates.asJson) + val uri = toSegments(slug.value).foldLeft(renkuBaseUrl / "knowledge-graph" / "projects")(_ / _) + GET(uri) + .putHeaders(`Content-Type`(application.json)) .withAuthorizationToken(authorizationToken) - .send(expect(status = Accepted, otherwiseLog = s"Updating '$slug' project failed")) - } + .sendIO(mapResponseIO { response => + response.status match { + case Ok => response.as[KGProjectDetails].map(_.some) + case NotFound => Option.empty[KGProjectDetails].pure[IO] + case status => fail(s"finding project details in the KG returned $status") + } + }) + }.unsafeRunSync() - def `DELETE /knowledge-graph/projects/:slug`(slug: String): Unit = { + def `DELETE /knowledge-graph/projects/:slug`(slug: projects.Slug): Unit = { val toSegments: String => List[String] = _.split('/').toList - val uri = toSegments(slug).foldLeft(renkuBaseUrl / "knowledge-graph" / "projects")(_ / _) + val uri = toSegments(slug.value).foldLeft(renkuBaseUrl / "knowledge-graph" / "projects")(_ / _) DELETE(uri) .withAuthorizationToken(authorizationToken) .send(expect(status = Accepted, otherwiseLog = s"Deletion of '$slug' project failed")) @@ -89,57 +116,57 @@ trait KnowledgeGraphApi extends RestClient { @tailrec private def checkStatusAndWait( - projectId: ProjectIdentifier, + projectSlug: projects.Slug, gitLabProjectId: Int, browser: WebDriver, attempt: Int ): Unit = if (attempt >= 60 * 10) - fail(s"Events for '$projectId' project not processed after 10 minutes") - else if (findTotalDone(projectId, gitLabProjectId, browser) == 0) { + fail(s"Events for '$projectSlug' project not processed after 10 minutes") + else if (findTotalDone(projectSlug, gitLabProjectId, browser) == 0) { sleep(1 second) - checkStatusAndWait(projectId, gitLabProjectId, browser, attempt + 1) - } else if (findProgress(projectId, gitLabProjectId, browser) < 100d) { + checkStatusAndWait(projectSlug, gitLabProjectId, browser, attempt + 1) + } else if (findProgress(projectSlug, gitLabProjectId, browser) < 100d) { sleep(1 second) - checkStatusAndWait(projectId, gitLabProjectId, browser, attempt + 1) - } else if (findProgress(projectId, gitLabProjectId, browser) == 100d) { - val maybeDetails = findStatus(projectId, gitLabProjectId, browser).flatMap(_.maybeDetails) + checkStatusAndWait(projectSlug, gitLabProjectId, browser, attempt + 1) + } else if (findProgress(projectSlug, gitLabProjectId, browser) == 100d) { + val maybeDetails = findStatus(projectSlug, gitLabProjectId, browser).flatMap(_.maybeDetails) maybeDetails match { case Some(status) if status.status == "failure" => val stackTrace = status.maybeStackTrace.map(s => s"; stackTrace:\n${s.replace("; ", "; \n")}").getOrElse("") - fail(s"Project $projectId ($gitLabProjectId) failed with '${status.message}'$stackTrace") + fail(s"Project $projectSlug ($gitLabProjectId) failed with '${status.message}'$stackTrace") case _ => () } } @tailrec private def checkActivatedAndWait( - projectId: ProjectIdentifier, + projectSlug: projects.Slug, gitLabProjectId: Int, browser: WebDriver, attempt: Int, startTime: Instant = Instant.now() ): Either[String, Unit] = { - And(s"waits for Project Activation - attempt $attempt") + logger.info(s"waits for Project Activation - attempt $attempt") if (attempt >= 60) { val duration = Duration(Instant.now().toEpochMilli - startTime.toEpochMilli, MILLISECONDS).toSeconds - s"Activation status of '$projectId' project couldn't be determined after ${duration}s".asLeft[Unit] - } else if (!checkActivated(projectId, gitLabProjectId, browser)) { + s"Activation status of '$projectSlug' project couldn't be determined after ${duration}s".asLeft[Unit] + } else if (!checkActivated(projectSlug, gitLabProjectId, browser)) { sleep(1 second) - checkActivatedAndWait(projectId, gitLabProjectId, browser, attempt + 1, startTime) + checkActivatedAndWait(projectSlug, gitLabProjectId, browser, attempt + 1, startTime) } else ().asRight[String] } - private def checkActivated(projectId: ProjectIdentifier, gitLabProjectId: Int, browser: WebDriver): Boolean = - findStatus(projectId, gitLabProjectId, browser).exists(_.activated) + private def checkActivated(projectSlug: projects.Slug, gitLabProjectId: Int, browser: WebDriver): Boolean = + findStatus(projectSlug, gitLabProjectId, browser).exists(_.activated) - private def findProgress(projectId: ProjectIdentifier, gitLabProjectId: Int, browser: WebDriver): Double = - findStatus(projectId, gitLabProjectId, browser).map(_.progressPercentage).getOrElse(0d) + private def findProgress(projectSlug: projects.Slug, gitLabProjectId: Int, browser: WebDriver): Double = + findStatus(projectSlug, gitLabProjectId, browser).map(_.progressPercentage).getOrElse(0d) - private def findTotalDone(projectId: ProjectIdentifier, gitLabProjectId: Int, browser: WebDriver): Int = - findStatus(projectId, gitLabProjectId, browser).map(_.total).getOrElse(0) + private def findTotalDone(projectSlug: projects.Slug, gitLabProjectId: Int, browser: WebDriver): Int = + findStatus(projectSlug, gitLabProjectId, browser).map(_.total).getOrElse(0) - private def findStatus(projectId: ProjectIdentifier, gitLabProjectId: Int, browser: WebDriver): Option[GraphStatus] = + private def findStatus(projectSlug: projects.Slug, gitLabProjectId: Int, browser: WebDriver): Option[GraphStatus] = GET(renkuBaseUrl / "api" / "projects" / gitLabProjectId / "graph" / "status") .addCookiesFrom(browser) .send { response => @@ -147,7 +174,7 @@ trait KnowledgeGraphApi extends RestClient { case Ok => response.as[GraphStatus].map(_.some) case NotFound => None.pure[IO] case other => - IO(logger.warn(s"Finding processing status for '$projectId' returned $other")).as(None) + IO(logger.warn(s"Finding processing status for '$projectSlug' returned $other")).as(None) } } } diff --git a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/KnowledgeGraphModel.scala b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/KnowledgeGraphModel.scala index b944ad9120..09a845167b 100644 --- a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/KnowledgeGraphModel.scala +++ b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/KnowledgeGraphModel.scala @@ -18,29 +18,126 @@ package ch.renku.acceptancetests.tooling -import ch.renku.acceptancetests.model.projects -import io.circe.syntax._ +import cats.effect.IO +import cats.syntax.all._ +import ch.renku.acceptancetests.generators.Generators.Implicits._ +import ch.renku.acceptancetests.model +import ch.renku.acceptancetests.model.{images, projects} +import ch.renku.acceptancetests.tooling.multipart.PartEncoder +import fs2.io.file.Path +import io.circe.Decoder._ import io.circe.{Decoder, Encoder, Json} +import org.http4s.headers.{`Content-Disposition`, `Content-Type`} +import org.http4s.multipart.{Multipart, Multiparts, Part} +import org.http4s.{Headers, MediaType} +import org.typelevel.ci._ object KnowledgeGraphModel { - final case class KGProjectDetails(description: Option[String], keywords: Set[String], visibility: projects.Visibility) + final case class KGProjectDetails(maybeDescription: Option[projects.Description], + keywords: Set[projects.Keyword], + visibility: projects.Visibility, + images: List[model.images.ImageUri] + ) object KGProjectDetails { + private implicit val imageDecoder: Decoder[images.ImageUri] = + _.downField("location").as[String].map(images.ImageUri(_)) + + implicit val jsonDecoder: Decoder[KGProjectDetails] = { cur => + ( + cur.downField("description").as[Option[projects.Description]], + cur.downField("keywords").as[Set[projects.Keyword]], + cur.downField("visibility").as[projects.Visibility], + cur.downField("images").as[List[images.ImageUri]](decodeList(imageDecoder)), + ).mapN(KGProjectDetails.apply) + } + } + + final case class NewProject(name: projects.Name, + namespaceId: projects.NamespaceId, + maybeDescription: Option[projects.Description], + keywords: Set[projects.Keyword], + visibility: projects.Visibility, + template: ProjectTemplate, + maybeImage: Option[Image] + ) + + object NewProject { + + def generate(namespaceId: projects.NamespaceId, template: ProjectTemplate, image: Image): NewProject = { + val name = projects.Name.generate() + NewProject( + name, + namespaceId, + projects.Description(s"Description for '$name'").some, + projects.Keyword.generator.generateList().toSet, + projects.Visibility.generate(), + template, + image.some + ) + } + + object MultipartEncoder { + + def encode(newProject: NewProject): IO[Multipart[IO]] = { + import multipart.syntax._ + + val parts = + Vector( + newProject.maybeDescription.asParts("description"), + newProject.maybeImage.asParts("image") + ).flatten + .appended(newProject.name.asPart("name")(fromPartValueEncoder(projects.Name.pvEncoder))) + .appended(newProject.namespaceId.asPart("namespaceId")) + .appended(newProject.visibility.asPart("visibility")) + .appended(newProject.template.repoUrl.asPart("templateRepositoryUrl")) + .appended(newProject.template.id.asPart("templateId")) + .appendedAll(newProject.keywords.toList.asParts("keywords")) + + Multiparts.forSync[IO].flatMap(_.multipart(parts)) + } + } + } + + final case class Image(path: Path, mediaType: MediaType) { + def toName: images.Name = images.Name(path.fileName.toString) + } + + object Image { + + implicit val partEncoder: PartEncoder[IO, Image] = + PartEncoder.instance { case (partName, Image(path, mediaType)) => + Part.fileData(partName, path, `Content-Type`(mediaType)) + } + + val wheelPngExample: Image = + Image(Path(getClass.getClassLoader.getResource("wheel.png").getPath), MediaType.image.png) + val bikeJpgExample: Image = + Image(Path(getClass.getClassLoader.getResource("bike.jpeg").getPath), MediaType.image.jpeg) + } - implicit val jsonDecoder: Decoder[KGProjectDetails] = - Decoder.forProduct3("description", "keywords", "visibility")(KGProjectDetails.apply) + final case class ProjectTemplate(repoUrl: projects.TemplateRepoUrl, id: projects.TemplateId) + + object ProjectTemplate { + val pythonMinimal: ProjectTemplate = + ProjectTemplate(projects.TemplateRepoUrl("https://github.com/SwissDataScienceCenter/renku-project-template"), + projects.TemplateId("python-minimal") + ) } - final case class ProjectUpdates(newDescription: Option[Option[String]], - newKeywords: Option[Set[String]], - newVisibility: Option[projects.Visibility] + final case class ProjectUpdates(newDescription: Option[Option[projects.Description]], + newKeywords: Option[Set[projects.Keyword]], + newVisibility: Option[projects.Visibility], + newImage: Option[Option[Image]] ) object ProjectUpdates { implicit val jsonEncoder: Encoder[ProjectUpdates] = Encoder.instance { - case ProjectUpdates(newDescription, newKeywords, newVisibility) => + case ProjectUpdates(newDescription, newKeywords, newVisibility, None) => + import io.circe.syntax._ + Json.obj( List( newDescription.map(v => "description" -> v.fold(Json.Null)(_.asJson)), @@ -48,6 +145,33 @@ object KnowledgeGraphModel { newVisibility.map(v => "visibility" -> v.asJson) ).flatten: _* ) + case ProjectUpdates(_, _, _, Some(_)) => + throw new Exception("Cannot encode to JSON ProjectUpdates with image") + } + + object MultipartEncoder { + + def encode(updates: ProjectUpdates): IO[Multipart[IO]] = { + import multipart.syntax._ + + val parts = + Vector( + updates.newVisibility.map(_.asPart("visibility")), + updates.newDescription.map(_.fold("")(_.value).asPart("description")), + updates.newImage.map( + _.fold( + Part[IO]( + Headers(`Content-Disposition`("form-data", Map(ci"name" -> "image")), + `Content-Type`(MediaType.image.jpeg) + ), + fs2.Stream.empty + ) + )(_.asPart("image")) + ) + ).flatten.appendedAll(updates.newKeywords.map(_.toList.asParts("keywords")).getOrElse(List.empty)) + + Multiparts.forSync[IO].flatMap(_.multipart(parts)) + } } } } diff --git a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/RenkuApi.scala b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/RenkuApi.scala index fc2803da66..4165ca2e04 100644 --- a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/RenkuApi.scala +++ b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/RenkuApi.scala @@ -18,13 +18,16 @@ package ch.renku.acceptancetests.tooling +import cats.effect.unsafe.IORuntime import cats.syntax.all._ import ch.renku.acceptancetests.model.CliVersion import org.http4s.Status._ import org.scalatest.Assertions.fail trait RenkuApi extends RestClient { - self: AcceptanceSpecData with IOSpec => + self: AcceptanceSpecData => + + implicit val ioRuntime: IORuntime lazy val apiCliVersion: CliVersion = { val url = renkuBaseUrl / "api" / "renku" / "apiversion" diff --git a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/RestClient.scala b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/RestClient.scala index c0e670fb91..a379fa64bb 100644 --- a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/RestClient.scala +++ b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/RestClient.scala @@ -20,6 +20,7 @@ package ch.renku.acceptancetests.tooling import cats.Applicative import cats.effect.IO._ +import cats.effect.unsafe.IORuntime import cats.effect.{IO, Temporal} import cats.syntax.all._ import ch.renku.acceptancetests.model.{AuthorizationToken, BaseUrl} @@ -43,7 +44,8 @@ import scala.jdk.CollectionConverters._ import scala.util.control.NonFatal trait RestClient extends Http4sClientDsl[IO] { - self: IOSpec => + + implicit val ioRuntime: IORuntime val jsonRoot: JsonPath = root @@ -82,7 +84,10 @@ trait RestClient extends Http4sClientDsl[IO] { request.putHeaders(Headers(authorizationToken.asHeader)) def send[A](processResponse: Response[IO] => IO[A]): A = - clientBuilder.resource.use(sendRequest(processResponse)).unsafeRunSync() + sendIO(processResponse).unsafeRunSync() + + def sendIO[A](processResponse: Response[IO] => IO[A]): IO[A] = + clientBuilder.resource.use(sendRequest(processResponse)) private def sendRequest[A](processResponse: Response[IO] => IO[A])(client: Client[IO]): IO[A] = client @@ -118,14 +123,18 @@ trait RestClient extends Http4sClientDsl[IO] { def expect(status: Status, otherwiseLog: String): Response[IO] => IO[Unit] = { response => Applicative[IO].whenA(response.status != status) { - new Exception( - s"returned ${response.status} which is not expected $status. $otherwiseLog" - ).raiseError + response.as[Json] >>= { body => + new Exception( + s"returned ${response.status} which is not expected $status. $otherwiseLog\n${body.spaces2}" + ).raiseError + } } } def mapResponse[A](f: PartialFunction[Response[IO], A]): Response[IO] => IO[A] = response => f(response).pure[IO] + def mapResponseIO[A](f: PartialFunction[Response[IO], IO[A]]): Response[IO] => IO[A] = f + implicit class JsonOps(json: Json) { def extract[V](extractor: Json => V): V = extractor(json) } diff --git a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/WebDriveredSpec.scala b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/WebDriveredSpec.scala new file mode 100644 index 0000000000..67c39581ff --- /dev/null +++ b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/WebDriveredSpec.scala @@ -0,0 +1,50 @@ +/* + * Copyright 2023 Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ch.renku.acceptancetests.tooling + +import org.openqa.selenium.WebDriver +import org.openqa.selenium.chrome.{ChromeDriver, ChromeDriverService, ChromeOptions} +import org.scalatest.{BeforeAndAfterAll, Suite} + +trait WebDriveredSpec extends BeforeAndAfterAll { + self: Suite => + + implicit lazy val webDriver: WebDriver = { + System.setProperty("webdriver.http.factory", "jdk-http-client") + startWebDriver + } + + private def startWebDriver: WebDriver = + sys.env.get("DOCKER") match { + case Some(_) => + new ChromeDriver( + new ChromeDriverService.Builder().withWhitelistedIps("127.0.0.1").build, + new ChromeOptions().addArguments("no-sandbox", "headless", "disable-gpu", "window-size=1920,1600") + ) + case None => + new ChromeDriver( + new ChromeOptions().addArguments("window-size=1920,1600") + ) + } + + protected override def afterAll(): Unit = { + webDriver.quit() + super.afterAll() + } +} diff --git a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/multipart/PartEncoder.scala b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/multipart/PartEncoder.scala new file mode 100644 index 0000000000..33395c59a5 --- /dev/null +++ b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/multipart/PartEncoder.scala @@ -0,0 +1,43 @@ +/* + * Copyright 2023 Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ch.renku.acceptancetests.tooling.multipart + +import org.http4s.multipart.Part + +trait PartEncoder[F[_], A] { + def apply(partName: String, a: A): Part[F] +} + +object PartEncoder { + + def instance[F[_], A](f: (String, A) => Part[F]): PartEncoder[F, A] = + (partName: String, a: A) => f(partName, a) +} + +trait PartValueEncoder[A] { + def apply(a: A): String +} + +object PartValueEncoder { + + def instance[A](f: A => String): PartValueEncoder[A] = (a: A) => f(a) + + implicit def stringEnc: PartValueEncoder[String] = + instance(identity) +} diff --git a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/multipart/syntax.scala b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/multipart/syntax.scala new file mode 100644 index 0000000000..97f798dcc6 --- /dev/null +++ b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/tooling/multipart/syntax.scala @@ -0,0 +1,59 @@ +/* + * Copyright 2023 Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ch.renku.acceptancetests.tooling.multipart + +import cats.effect.IO +import cats.syntax.all._ +import cats.{Functor, MonadThrow} +import org.http4s.multipart.{Multipart, Part} + +object syntax { + + final implicit class EncoderOps[A](private val value: A) extends AnyVal { + + def asPart(partName: String)(implicit encoder: PartEncoder[IO, A]): Part[IO] = + encoder(partName, value) + } + + final implicit class EncoderFOps[M[_]: Functor, A](private val value: M[A]) { + + def asParts(partName: String)(implicit encoder: PartEncoder[IO, A]): M[Part[IO]] = + value.map(_.asPart(s"$partName$partNameSuffix")) + + private lazy val partNameSuffix: String = value match { + case _: Iterable[_] => "[]" + case _ => "" + } + } + + final implicit class MultipartOps[F[_]: MonadThrow](private val multipart: Multipart[F]) { + + def part(partName: String): F[Part[F]] = + findPart(partName).fold(new Exception(s"No '$partName' in the request").raiseError[F, Part[F]])(_.pure[F]) + + def findPart(partName: String): Option[Part[F]] = + multipart.parts.find(_.name contains partName) + + def filterParts(partName: String): List[Part[F]] = + multipart.parts.filter(_.name exists (_ startsWith partName)).toList + } + + implicit def fromPartValueEncoder[F[_], A](implicit pvEnc: PartValueEncoder[A]): PartEncoder[F, A] = + PartEncoder.instance[F, A]((partName: String, a: A) => Part.formData[F](partName, pvEnc(a))) +} diff --git a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/workflows/Datasets.scala b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/workflows/Datasets.scala index 369fd06fd6..10c305d8ae 100644 --- a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/workflows/Datasets.scala +++ b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/workflows/Datasets.scala @@ -63,7 +63,7 @@ trait Datasets { pause asLongAsBrowserAt newDatasetPage And("all the events are processed by the knowledge-graph ") - `wait for KG to process events`(projectPage.asProjectIdentifier, webDriver) + `wait for KG to process events`(projectPage.asProjectIdentifier.asProjectSlug, webDriver) Then("the user should see its newly created dataset") val datasetPage = DatasetPage(datasetName) @@ -107,7 +107,7 @@ trait Datasets { pause asLongAsBrowserAt newDatasetPage And("all the events are processed by the knowledge-graph") - `wait for KG to process events`(projectPage.asProjectIdentifier, webDriver) + `wait for KG to process events`(projectPage.asProjectIdentifier.asProjectSlug, webDriver) val datasetPage = DatasetPage(datasetName) Then("the user should see its newly created dataset") @@ -165,7 +165,7 @@ trait Datasets { verify browserAt datasetPage And("all the events are processed by the knowledge-graph") - `wait for KG to process events`(projectPage.asProjectIdentifier, webDriver) + `wait for KG to process events`(projectPage.asProjectIdentifier.asProjectSlug, webDriver) And("the user should see its newly created dataset") reloadPage() sleep (2 seconds) diff --git a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/workflows/Project.scala b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/workflows/Project.scala index 23610d5d2c..1afeadaadb 100644 --- a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/workflows/Project.scala +++ b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/workflows/Project.scala @@ -121,7 +121,7 @@ trait Project extends RemoveProject with ExtantProject with KnowledgeGraphApi { val projectPage = ProjectPage createFrom projectDetails - `wait for project activation`(projectPage.asProjectIdentifier) match { + `wait for project activation`(projectPage.asProjectIdentifier.asProjectSlug) match { case Left(err) => logger.error(s"$err - retrying the creation process") `create a new project` diff --git a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/workflows/RemoveProject.scala b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/workflows/RemoveProject.scala index abef59771b..8bb37f4a2f 100644 --- a/acceptance-tests/src/test/scala/ch/renku/acceptancetests/workflows/RemoveProject.scala +++ b/acceptance-tests/src/test/scala/ch/renku/acceptancetests/workflows/RemoveProject.scala @@ -18,6 +18,7 @@ package ch.renku.acceptancetests.workflows +import ch.renku.acceptancetests.model.projects import ch.renku.acceptancetests.model.projects.ProjectIdentifier import ch.renku.acceptancetests.tooling.{AcceptanceSpec, KnowledgeGraphApi} @@ -26,9 +27,12 @@ import scala.concurrent.duration._ trait RemoveProject extends BrowserNavigation { self: AcceptanceSpec with KnowledgeGraphApi => - def `remove project in GitLab`(implicit projectId: ProjectIdentifier): Unit = { - And(s"the '${projectId.asProjectSlug}' project is removed") - `DELETE /knowledge-graph/projects/:slug`(projectId.asProjectSlug) + def `remove project in GitLab`(implicit projectId: ProjectIdentifier): Unit = + `remove project in GitLab`(projectId.asProjectSlug) + + def `remove project in GitLab`(slug: projects.Slug): Unit = { + And(s"the '$slug' project is removed") + `DELETE /knowledge-graph/projects/:slug`(slug) sleep(1 second) } } diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index bbfedb970d..59be473e19 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -100,6 +100,7 @@ gitlabClientSecret GitPython golang gpu +Grafana GraphQL graphviz hexsha diff --git a/helm-chart/renku/requirements.yaml b/helm-chart/renku/requirements.yaml index b2ddc856e7..24356a4ffa 100644 --- a/helm-chart/renku/requirements.yaml +++ b/helm-chart/renku/requirements.yaml @@ -25,7 +25,7 @@ dependencies: - name: renku-graph alias: graph repository: "https://swissdatasciencecenter.github.io/helm-charts/" - version: 2.41.0 + version: 2.42.0 condition: graph.enabled - name: postgresql version: 9.1.1