~jan0sch/smederee
Showing details for patch 07a6440caec01be6e199b0618de8943cb08f413a.
diff -rN -u old-smederee/build.sbt new-smederee/build.sbt --- old-smederee/build.sbt 2025-01-16 05:02:15.359735379 +0000 +++ new-smederee/build.sbt 2025-01-16 05:02:15.367735375 +0000 @@ -9,8 +9,8 @@ // The initial starting year of the project. val projectStartYear: Int = 2022 -addCommandAlias("check", "Compile/scalafix --check; Test/scalafix --check; IntegrationTest/scalafix; scalafmtCheckAll") -addCommandAlias("fix", "Compile/scalafix; Test/scalafix; IntegrationTest/scalafix; headerCreateAll; IntegrationTest/headerCreateAll; scalafmtSbt; scalafmtAll") +addCommandAlias("check", "Compile/scalafix --check; Test/scalafix --check; scalafmtCheckAll") +addCommandAlias("fix", "Compile/scalafix; Test/scalafix; headerCreateAll; scalafmtSbt; scalafmtAll") inThisBuild( Seq( @@ -66,32 +66,21 @@ project .in(file("modules/darcs")) .enablePlugins(AutomateHeaderPlugin) - .configs(IntegrationTest) .settings(commonSettings) .settings( name := "darcs", version := "0.8.0-SNAPSHOT", Defaults.itSettings, - headerSettings(IntegrationTest), - inConfig(IntegrationTest)(scalafmtSettings), - IntegrationTest / console / scalacOptions --= Seq("-Xfatal-warnings"), - IntegrationTest / fork := true, - IntegrationTest / parallelExecution := false, libraryDependencies ++= Seq( library.catsCore, library.catsEffect, library.logback, library.osLib, - library.munit % IntegrationTest, - library.munitCatsEffect % IntegrationTest, - library.munitDiscipline % IntegrationTest, - library.munitScalaCheck % IntegrationTest, - library.scalaCheck % IntegrationTest, - library.munit % Test, - library.munitCatsEffect % Test, - library.munitDiscipline % Test, - library.munitScalaCheck % Test, - library.scalaCheck % Test + library.munit % Test, + library.munitCatsEffect % Test, + library.munitDiscipline % Test, + library.munitScalaCheck % Test, + library.scalaCheck % Test ) ) @@ -99,33 +88,22 @@ project .in(file("modules/email")) .enablePlugins(AutomateHeaderPlugin) - .configs(IntegrationTest) .settings(commonSettings) .settings( name := "email", version := "0.8.0-SNAPSHOT", Defaults.itSettings, - headerSettings(IntegrationTest), - inConfig(IntegrationTest)(scalafmtSettings), - IntegrationTest / console / scalacOptions --= Seq("-Xfatal-warnings"), - IntegrationTest / fork := true, - IntegrationTest / parallelExecution := false, libraryDependencies ++= Seq( library.catsCore, library.catsEffect, library.ip4sCore, library.logback, library.simpleJavaMail, - library.munit % IntegrationTest, - library.munitCatsEffect % IntegrationTest, - library.munitDiscipline % IntegrationTest, - library.munitScalaCheck % IntegrationTest, - library.scalaCheck % IntegrationTest, - library.munit % Test, - library.munitCatsEffect % Test, - library.munitDiscipline % Test, - library.munitScalaCheck % Test, - library.scalaCheck % Test + library.munit % Test, + library.munitCatsEffect % Test, + library.munitDiscipline % Test, + library.munitScalaCheck % Test, + library.scalaCheck % Test ) ) @@ -164,17 +142,11 @@ SbtTwirl, SystemdPlugin ) - .configs(IntegrationTest) .settings(commonSettings) .settings( name := "hub", version := "0.8.0-SNAPSHOT", Defaults.itSettings, - headerSettings(IntegrationTest), - inConfig(IntegrationTest)(scalafmtSettings), - IntegrationTest / console / scalacOptions --= Seq("-Xfatal-warnings"), - IntegrationTest / fork := true, - IntegrationTest / parallelExecution := false, libraryDependencies ++= Seq( library.apacheSshdCore, library.apacheSshdSftp, @@ -200,16 +172,11 @@ library.postgresql, library.pureConfig, library.springSecurityCrypto, - library.munit % IntegrationTest, - library.munitCatsEffect % IntegrationTest, - library.munitDiscipline % IntegrationTest, - library.munitScalaCheck % IntegrationTest, - library.scalaCheck % IntegrationTest, - library.munit % Test, - library.munitCatsEffect % Test, - library.munitDiscipline % Test, - library.munitScalaCheck % Test, - library.scalaCheck % Test + library.munit % Test, + library.munitCatsEffect % Test, + library.munitDiscipline % Test, + library.munitScalaCheck % Test, + library.scalaCheck % Test ), libraryDependencies := libraryDependencies.value.map { case module if module.name == "twirl-api" => @@ -265,14 +232,14 @@ Compile / packageDoc / publishArtifact := false, Compile / doc / sources := Seq.empty, // Require tests to be run before building a debian package. - Debian / packageBin := ((Debian / packageBin) dependsOn (Test / test) dependsOn (IntegrationTest / test)).value, + Debian / packageBin := ((Debian / packageBin) dependsOn (Test / test)).value, // Require tests to be run before building a RPM package. - Rpm / packageBin := ((Rpm / packageBin) dependsOn (Test / test) dependsOn (IntegrationTest / test)).value, + Rpm / packageBin := ((Rpm / packageBin) dependsOn (Test / test)).value, // Require tests to be run before building a universal package. - Universal / packageBin := ((Universal / packageBin) dependsOn (Test / test) dependsOn (IntegrationTest / test)).value, - Universal / packageOsxDmg := ((Universal / packageOsxDmg) dependsOn (Test / test) dependsOn (IntegrationTest / test)).value, - Universal / packageXzTarball := ((Universal / packageXzTarball) dependsOn (Test / test) dependsOn (IntegrationTest / test)).value, - Universal / packageZipTarball := ((Universal / packageZipTarball) dependsOn (Test / test) dependsOn (IntegrationTest / test)).value, + Universal / packageBin := ((Universal / packageBin) dependsOn (Test / test)).value, + Universal / packageOsxDmg := ((Universal / packageOsxDmg) dependsOn (Test / test)).value, + Universal / packageXzTarball := ((Universal / packageXzTarball) dependsOn (Test / test)).value, + Universal / packageZipTarball := ((Universal / packageZipTarball) dependsOn (Test / test)).value, // Prevent a customised local application.conf file to be packaged! Compile / packageBin / mappings ~= { files => files.filterNot { @@ -334,17 +301,11 @@ SbtTwirl, SystemdPlugin ) - .configs(IntegrationTest) .settings(commonSettings) .settings( name := "tickets", version := "0.8.0-SNAPSHOT", Defaults.itSettings, - headerSettings(IntegrationTest), - inConfig(IntegrationTest)(scalafmtSettings), - IntegrationTest / console / scalacOptions --= Seq("-Xfatal-warnings"), - IntegrationTest / fork := true, - IntegrationTest / parallelExecution := false, libraryDependencies ++= Seq( library.catsCore, library.circeCore, @@ -364,16 +325,11 @@ library.postgresql, library.pureConfig, library.springSecurityCrypto, - library.munit % IntegrationTest, - library.munitCatsEffect % IntegrationTest, - library.munitDiscipline % IntegrationTest, - library.munitScalaCheck % IntegrationTest, - library.scalaCheck % IntegrationTest, - library.munit % Test, - library.munitCatsEffect % Test, - library.munitDiscipline % Test, - library.munitScalaCheck % Test, - library.scalaCheck % Test + library.munit % Test, + library.munitCatsEffect % Test, + library.munitDiscipline % Test, + library.munitScalaCheck % Test, + library.scalaCheck % Test ) ) .settings( @@ -431,14 +387,14 @@ Compile / packageDoc / publishArtifact := false, Compile / doc / sources := Seq.empty, // Require tests to be run before building a debian package. - Debian / packageBin := ((Debian / packageBin) dependsOn (Test / test) dependsOn (IntegrationTest / test)).value, + Debian / packageBin := ((Debian / packageBin) dependsOn (Test / test)).value, // Require tests to be run before building a RPM package. - Rpm / packageBin := ((Rpm / packageBin) dependsOn (Test / test) dependsOn (IntegrationTest / test)).value, + Rpm / packageBin := ((Rpm / packageBin) dependsOn (Test / test)).value, // Require tests to be run before building a universal package. - Universal / packageBin := ((Universal / packageBin) dependsOn (Test / test) dependsOn (IntegrationTest / test)).value, - Universal / packageOsxDmg := ((Universal / packageOsxDmg) dependsOn (Test / test) dependsOn (IntegrationTest / test)).value, - Universal / packageXzTarball := ((Universal / packageXzTarball) dependsOn (Test / test) dependsOn (IntegrationTest / test)).value, - Universal / packageZipTarball := ((Universal / packageZipTarball) dependsOn (Test / test) dependsOn (IntegrationTest / test)).value, + Universal / packageBin := ((Universal / packageBin) dependsOn (Test / test)).value, + Universal / packageOsxDmg := ((Universal / packageOsxDmg) dependsOn (Test / test)).value, + Universal / packageXzTarball := ((Universal / packageXzTarball) dependsOn (Test / test)).value, + Universal / packageZipTarball := ((Universal / packageZipTarball) dependsOn (Test / test)).value, // Prevent a customised local application.conf file to be packaged! Compile / packageBin / mappings ~= { files => files.filterNot { diff -rN -u old-smederee/modules/darcs/src/it/resources/logback-test.xml new-smederee/modules/darcs/src/it/resources/logback-test.xml --- old-smederee/modules/darcs/src/it/resources/logback-test.xml 2025-01-16 05:02:15.359735379 +0000 +++ new-smederee/modules/darcs/src/it/resources/logback-test.xml 1970-01-01 00:00:00.000000000 +0000 @@ -1,25 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<configuration debug="false"> - <appender name="console" class="ch.qos.logback.core.ConsoleAppender"> - <filter class="ch.qos.logback.classic.filter.ThresholdFilter"> - <level>WARN</level> - </filter> - <encoder> - <pattern>%date %highlight(%-5level) %cyan(%logger{0}) - %msg%n</pattern> - </encoder> - </appender> - - <appender name="async-console" class="ch.qos.logback.classic.AsyncAppender"> - <appender-ref ref="console"/> - <queueSize>5000</queueSize> - <discardingThreshold>0</discardingThreshold> - </appender> - - <logger name="de.smederee.darcs" level="DEBUG" additivity="false"> - <appender-ref ref="async-console"/> - </logger> - - <root> - <appender-ref ref="async-console"/> - </root> -</configuration> diff -rN -u old-smederee/modules/hub/src/it/resources/application.conf new-smederee/modules/hub/src/it/resources/application.conf --- old-smederee/modules/hub/src/it/resources/application.conf 2025-01-16 05:02:15.359735379 +0000 +++ new-smederee/modules/hub/src/it/resources/application.conf 1970-01-01 00:00:00.000000000 +0000 @@ -1,12 +0,0 @@ -hub { - database { - host = localhost - host = ${?SMEDEREE_DB_HOST} - url = "jdbc:postgresql://"${hub.database.host}":5432/smederee_hub_it" - url = ${?SMEDEREE_HUB_TEST_DB_URL} - user = "smederee_hub" - user = ${?SMEDEREE_HUB_TEST_DB_USER} - pass = "secret" - pass = ${?SMEDEREE_HUB_TEST_DB_PASS} - } -} diff -rN -u old-smederee/modules/hub/src/it/resources/de/smederee/ssh/ssh-key-with-comment.pub new-smederee/modules/hub/src/it/resources/de/smederee/ssh/ssh-key-with-comment.pub --- old-smederee/modules/hub/src/it/resources/de/smederee/ssh/ssh-key-with-comment.pub 2025-01-16 05:02:15.359735379 +0000 +++ new-smederee/modules/hub/src/it/resources/de/smederee/ssh/ssh-key-with-comment.pub 1970-01-01 00:00:00.000000000 +0000 @@ -1 +0,0 @@ -ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH8ZU2xquZvstbesPktthwY2r5sanULBQKuM5bGHVdeP Some optional comment... diff -rN -u old-smederee/modules/hub/src/it/resources/de/smederee/ssh/ssh-key-without-comment.pub new-smederee/modules/hub/src/it/resources/de/smederee/ssh/ssh-key-without-comment.pub --- old-smederee/modules/hub/src/it/resources/de/smederee/ssh/ssh-key-without-comment.pub 2025-01-16 05:02:15.359735379 +0000 +++ new-smederee/modules/hub/src/it/resources/de/smederee/ssh/ssh-key-without-comment.pub 1970-01-01 00:00:00.000000000 +0000 @@ -1 +0,0 @@ -ssh-dss AAAAB3NzaC1kc3MAAACBAKn1DHh6DaIg/cN6vNVh1VXvHhH86eKelfsolIfvTPQSb3vkqoWPG3T3DGmrUjbqvrrfzaKILTBRv05KqMCbJKETGR0fuY7G1/Nkd/6dZjw1ngYkGd0fr2ERGuq87+gdd1A3TeIqvdjnl7MG3bEGf+fIEJOrRJraZ+u/tDFlSYq/AAAAFQCAUrv94uu1dVTTiyoagKV4Y4QWuQAAAIAuR5mFFYAgT1+t1u16eRCou1nPO4+q35/6uNNCyXtP0BmZaxXqQw25foJz5OzSQWXjjianfRfUyjsHt5DgM0PAIZaqmxMUiVw7BT7zUTa7ucl9NQmFBexiedCtokVb8++vHVZ7Y42tf2CpqVW8T2lw5b8sWb7rHYGarI935qv2bgAAAIABfRnu0PkvysY6QJhUCD4ZKt3qZ6E1cYDivLhDb4GAZxmxSeN5cFPXU3Gst0oNmNjUW55rsZwZP+KkXi3NwAsTd9dZBxkcc+28m8Dr4hGtPTnPp+4p8wzw/X6Lmyr6RSykCK6xuv9rc2td+1fgNyPoWwcLZZQclDj+OdgQVHWj3A== diff -rN -u old-smederee/modules/hub/src/it/resources/logback-test.xml new-smederee/modules/hub/src/it/resources/logback-test.xml --- old-smederee/modules/hub/src/it/resources/logback-test.xml 2025-01-16 05:02:15.359735379 +0000 +++ new-smederee/modules/hub/src/it/resources/logback-test.xml 1970-01-01 00:00:00.000000000 +0000 @@ -1,29 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<configuration debug="false"> - <appender name="console" class="ch.qos.logback.core.ConsoleAppender"> - <filter class="ch.qos.logback.classic.filter.ThresholdFilter"> - <level>WARN</level> - </filter> - <encoder> - <pattern>%date %highlight(%-5level) %cyan(%logger{0}) - %msg%n</pattern> - </encoder> - </appender> - - <appender name="async-console" class="ch.qos.logback.classic.AsyncAppender"> - <appender-ref ref="console"/> - <queueSize>5000</queueSize> - <discardingThreshold>0</discardingThreshold> - </appender> - - <logger name="de.smederee.hub" level="DEBUG" additivity="false"> - <appender-ref ref="async-console"/> - </logger> - - <logger name="org.flywaydb.core" level="ERROR" additivity="false"> - <appender-ref ref="async-console"/> - </logger> - - <root> - <appender-ref ref="async-console"/> - </root> -</configuration> diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/AuthenticationMiddlewareTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/AuthenticationMiddlewareTest.scala --- old-smederee/modules/hub/src/it/scala/de/smederee/hub/AuthenticationMiddlewareTest.scala 2025-01-16 05:02:15.359735379 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/AuthenticationMiddlewareTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,113 +0,0 @@ -/* - * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -package de.smederee.hub - -import java.time._ - -import cats.effect._ -import cats.syntax.all._ -import de.smederee.hub.Generators._ -import de.smederee.hub.config._ -import de.smederee.security._ -import doobie._ -import org.http4s._ - -final class AuthenticationMiddlewareTest extends BaseSpec with AuthenticationMiddleware { - test("extractSessionId must return the session id") { - (genSignAndValidate.sample, genSessionId.sample) match { - case (Some(signAndValidate), Some(sessionId)) => - val clock = java.time.Clock.systemUTC - val token = signAndValidate.signToken(sessionId.toString)(clock.millis.toString) - val request = Request[IO](method = Method.GET) - .addCookie(RequestCookie(Constants.authenticationCookieName.toString, token.toString)) - val test = for { - id <- extractSessionId[IO](request, signAndValidate) - } yield id - test.map { result => - assert(result === sessionId) - } - case _ => fail("Could not generate data samples!") - } - } - - test( - "resolveUser must return the account if session id and account exist and the session has not reached absolute timeout" - ) { - (genFiniteDuration.sample, genValidSession.sample, genValidAccount.sample) match { - case (Some(duration), Some(s), Some(account)) => - val createdAt = OffsetDateTime.now(ZoneOffset.UTC) - val session = s.copy(uid = account.uid, createdAt = createdAt) - val timeouts = AuthenticationTimeouts(duration, duration, duration) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieAuthenticationRepository[IO](tx) - val test = for { - _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) - _ <- createUserSession(session) - user <- resolveUser[IO](repo)(timeouts).run(session.id) - } yield user - test.map { maybeUser => - assert(clue(maybeUser) === clue(Option(account))) - } - case _ => fail("Could not generate data samples!") - } - } - - test( - "resolveUser must return None if session id and account exist but the session has reached absolute timeout" - ) { - (genFiniteDuration.sample, genValidSession.sample, genValidAccount.sample) match { - case (Some(duration), Some(s), Some(account)) => - val createdAt = OffsetDateTime.now(ZoneOffset.UTC).minusSeconds(duration.toSeconds + 5L) - val session = s.copy(uid = account.uid, createdAt = createdAt) - val timeouts = AuthenticationTimeouts(duration, duration, duration) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieAuthenticationRepository[IO](tx) - val test = for { - _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) - _ <- createUserSession(session) - user <- resolveUser[IO](repo)(timeouts).run(session.id) - } yield user - test.map { maybeUser => - assert(clue(maybeUser) === clue(Option(account))) - } - case _ => fail("Could not generate data samples!") - } - } - - test("resolveUser must return None if no session exists") { - (genFiniteDuration.sample, genValidSession.sample, genValidAccount.sample) match { - case (Some(duration), Some(s), Some(account)) => - val session = s.copy(uid = account.uid) - val timeouts = AuthenticationTimeouts(duration, duration, duration) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieAuthenticationRepository[IO](tx) - val test = for { - _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) - user <- resolveUser[IO](repo)(timeouts).run(session.id) - } yield user - test.map { maybeUser => - assert(clue(maybeUser) === None) - } - case _ => fail("Could not generate data samples!") - } - } - -} diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala --- old-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala 2025-01-16 05:02:15.359735379 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,379 +0,0 @@ -/* - * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -package de.smederee.hub - -import java.io.IOException -import java.net.ServerSocket -import java.nio.file._ -import java.nio.file.attribute.BasicFileAttributes - -import cats.effect._ -import cats.syntax.all._ -import com.comcast.ip4s._ -import com.typesafe.config.ConfigFactory -import de.smederee.email.EmailAddress -import de.smederee.hub.config._ -import de.smederee.i18n.LanguageCode -import de.smederee.security._ -import org.flywaydb.core.Flyway -import pureconfig._ - -import munit._ - -import scala.annotation.nowarn - -/** Base class for our integration test suites. - * - * It loads and provides the configuration as a resource suit fixture (loaded once for all tests within a suite) and - * does initialise the test database for each suite. The latter means a possibly existing database with the name - * configured **will be deleted**! - */ -abstract class BaseSpec extends CatsEffectSuite { - - protected final val configuration: SmedereeHubConfig = - ConfigSource - .fromConfig(ConfigFactory.load(getClass.getClassLoader)) - .at(SmedereeHubConfig.location) - .loadOrThrow[SmedereeHubConfig] - - protected final val flyway: Flyway = - DatabaseMigrator - .configureFlyway(configuration.database.url, configuration.database.user, configuration.database.pass) - .cleanDisabled(false) - .load() - - /** Connect to the DBMS using the generic "template1" database which should always be present. - * - * @param dbConfig - * The database configuration. - * @return - * The connection to the database ("template1"). - */ - private def connect(dbConfig: DatabaseConfig): IO[java.sql.Connection] = - for { - _ <- IO(Class.forName(dbConfig.driver)) - database <- IO(dbConfig.url.split("/").reverse.take(1).mkString) - connection <- IO( - java.sql.DriverManager - .getConnection(dbConfig.url.replace(database, "template1"), dbConfig.user, dbConfig.pass) - ) - } yield connection - - @nowarn("msg=discarded non-Unit value.*") - override def beforeAll(): Unit = { - // Extract the database name from the URL. - val database = configuration.database.url.split("/").reverse.take(1).mkString - val db = Resource.make(connect(configuration.database))(con => IO(con.close())) - // Create the test database if it does not already exist. - db.use { connection => - for { - statement <- IO(connection.createStatement()) - exists <- IO( - statement.executeQuery( - s"""SELECT datname FROM pg_catalog.pg_database WHERE datname = '$database'""" - ) - ) - _ <- IO { - if (!exists.next()) - statement.execute(s"""CREATE DATABASE "$database"""") - } - _ <- IO(exists.close) - _ <- IO(statement.close) - } yield () - }.unsafeRunSync() - } - - override def beforeEach(context: BeforeEach): Unit = { - val _ = flyway.migrate() - val _ = flyway.clean() - val _ = flyway.migrate() - } - - override def afterEach(context: AfterEach): Unit = { - val _ = flyway.migrate() - val _ = flyway.clean() - } - - /** Find and return a free port on the local machine by starting a server socket and closing it. The port number used - * by the socket is marked to allow reuse, considered free and returned. - * - * @return - * An optional port number if a free one can be found. - */ - protected def findFreePort(): Option[Port] = { - val socket = new ServerSocket(0) - val port = socket.getLocalPort - socket.setReuseAddress(true) // Allow instant rebinding of the socket. - socket.close() // Free the socket for further use by closing it. - Port.fromInt(port) - } - - /** Provide a resource with a database connection to allow db operations and proper resource release later. - * - * @param cfg - * The application configuration. - * @return - * A cats resource encapsulation a database connection as defined within the given configuration. - */ - protected def connectToDb(cfg: SmedereeHubConfig): Resource[IO, java.sql.Connection] = - Resource.make( - IO.delay(java.sql.DriverManager.getConnection(cfg.database.url, cfg.database.user, cfg.database.pass)) - )(c => IO.delay(c.close())) - - /** Create the given account in the database. - * - * @param account - * The account to be created. - * @param hash - * A password hash to be stored. - * @param unlockToken - * An optional unlock token to be stored. - * @param attempts - * Optional number of failed login attempts. - * @param validationToken - * An optional validation token to be stored. - * @return - * The number of affected database rows. - */ - @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") - protected def createAccount( - account: Account, - hash: PasswordHash, - unlockToken: Option[UnlockToken] = None, - attempts: Option[Int] = None, - validationToken: Option[ValidationToken] = None - ): IO[Int] = - connectToDb(configuration).use { con => - for { - statement <- IO.delay { - (unlockToken, validationToken) match { - case (None, None) => - con.prepareStatement( - """INSERT INTO "hub"."accounts" (uid, name, email, password, failed_attempts, created_at, updated_at, validated_email) VALUES(?, ?, ?, ?, ?, NOW(), NOW(), ?)""" - ) - case (Some(_), None) => - con.prepareStatement( - """INSERT INTO "hub"."accounts" (uid, name, email, password, failed_attempts, validated_email, locked_at, unlock_token, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, NOW(), ?, NOW(), NOW())""" - ) - case (None, Some(_)) => - con.prepareStatement( - """INSERT INTO "hub"."accounts" (uid, name, email, password, failed_attempts, validated_email, locked_at, validation_token, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, NOW(), ?, NOW(), NOW())""" - ) - case (Some(_), Some(_)) => - con.prepareStatement( - """INSERT INTO "hub"."accounts" (uid, name, email, password, failed_attempts, validated_email, locked_at, unlock_token, validation_token, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, NOW(), ?, NOW(), NOW())""" - ) - } - } - _ <- IO.delay(statement.setObject(1, account.uid)) - _ <- IO.delay(statement.setString(2, account.name.toString)) - _ <- IO.delay(statement.setString(3, account.email.toString)) - _ <- IO.delay(statement.setString(4, hash.toString)) - _ <- IO.delay(statement.setInt(5, attempts.getOrElse(1))) - _ <- IO.delay(statement.setBoolean(6, account.validatedEmail)) - _ <- (unlockToken, validationToken) match { - case (None, None) => IO.unit - case (Some(ut), None) => IO.delay(statement.setString(7, ut.toString)) - case (None, Some(vt)) => IO.delay(statement.setString(7, vt.toString)) - case (Some(ut), Some(vt)) => - IO.delay { - statement.setString(7, ut.toString) - statement.setString(8, vt.toString) - } - } - _ <- IO.delay { - unlockToken.foreach { token => - statement.setString(7, token.toString) - } - } - r <- IO.delay(statement.executeUpdate()) - _ <- IO.delay(statement.close()) - } yield r - } - - /** Create the given user session in the database. - * - * @param session - * The session that shall be created. The corresponding user account must exist! - * @return - * The number of affected database rows. - */ - @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") - protected def createUserSession(session: Session): IO[Int] = - connectToDb(configuration).use { con => - for { - statement <- IO.delay( - con.prepareStatement( - """INSERT INTO "hub"."sessions" (id, uid, created_at, updated_at) VALUES(?, ?, ?, ?)""" - ) - ) - _ <- IO.delay(statement.setString(1, session.id.toString)) - _ <- IO.delay(statement.setObject(2, session.uid)) - _ <- IO.delay( - statement.setTimestamp(3, java.sql.Timestamp.from(session.createdAt.toInstant)) - ) - _ <- IO.delay( - statement.setTimestamp(4, java.sql.Timestamp.from(session.updatedAt.toInstant)) - ) - r <- IO.delay(statement.executeUpdate()) - _ <- IO.delay(statement.close()) - } yield r - } - - /** Load the account with the given uid from the database. - * - * @param uid - * The unique identifier for the account. - * @return - * An option to the account if it exists. - */ - @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") - protected def loadAccount(uid: UserId): IO[Option[Account]] = - connectToDb(configuration).use { con => - for { - statement <- IO.delay( - con.prepareStatement( - """SELECT uid, name, email, password, created_at, updated_at, validated_email, language FROM "hub"."accounts" WHERE uid = ? LIMIT 1""" - ) - ) - _ <- IO.delay(statement.setObject(1, uid)) - result <- IO.delay(statement.executeQuery) - account <- IO.delay { - if (result.next()) { - val language = - if (result.getString("language") =!= null) - LanguageCode.from(result.getString("language")) - else - None - Option( - Account( - uid = uid, - name = Username(result.getString("name")), - email = EmailAddress(result.getString("email")), - validatedEmail = result.getBoolean("validated_email"), - language = language - ) - ) - } else { - None - } - } - _ <- IO(statement.close()) - } yield account - } - - /** Load the validation related columns for the account with the given unique user id. - * - * @param uid - * The unique identifier for the account. - * @return - * An option of the columns if it exists. - */ - @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") - protected def loadValidationColumns(uid: UserId): IO[Option[(Boolean, Option[ValidationToken])]] = - connectToDb(configuration).use { con => - for { - statement <- IO.delay( - con.prepareStatement( - """SELECT validated_email, validation_token FROM "hub"."accounts" WHERE uid = ? LIMIT 1""" - ) - ) - _ <- IO.delay(statement.setObject(1, uid)) - result <- IO.delay(statement.executeQuery) - columns <- IO.delay { - if (result.next()) { - Option( - ( - result.getBoolean("validated_email"), - ValidationToken - .from(result.getString("validation_token")) - ) - ) - } else { - None - } - } - _ <- IO.delay(statement.close()) - } yield columns - } - - /** Find the repository ID for the given owner and repository name. - * - * @param owner - * The unique ID of the user account that owns the repository. - * @param name - * The repository name which must be unique in regard to the owner. - * @return - * An option to the internal database ID. - */ - @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") - protected def loadVcsRepositoryId(owner: UserId, name: VcsRepositoryName): IO[Option[VcsRepositoryId]] = - connectToDb(configuration).use { con => - for { - statement <- IO.delay( - con.prepareStatement( - """SELECT id FROM "hub"."repositories" WHERE owner = ? AND name = ? LIMIT 1""" - ) - ) - _ <- IO.delay(statement.setObject(1, owner)) - _ <- IO.delay(statement.setString(2, name.toString)) - result <- IO.delay(statement.executeQuery) - account <- IO.delay { - if (result.next()) { - VcsRepositoryId.from(result.getLong("id")) - } else { - None - } - } - _ <- IO(statement.close()) - } yield account - } - - /** Delete the given directory recursively. - * - * @param path - * The path on the filesystem to the directory that shall be deleted. - * @return - * `true` if the directory was deleted. - */ - protected def deleteDirectory(path: Path): IO[Boolean] = - IO.delay { - if (path.toString.trim =!= "/") { - Files.walkFileTree( - path, - new FileVisitor[Path] { - override def visitFileFailed(file: Path, exc: IOException): FileVisitResult = FileVisitResult.CONTINUE - - override def visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult = { - Files.delete(file) - FileVisitResult.CONTINUE - } - - override def preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult = - FileVisitResult.CONTINUE - - override def postVisitDirectory(dir: Path, exc: IOException): FileVisitResult = { - Files.delete(dir) - FileVisitResult.CONTINUE - } - } - ) - Files.deleteIfExists(path) - } else false - } -} diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DatabaseMigratorTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DatabaseMigratorTest.scala --- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DatabaseMigratorTest.scala 2025-01-16 05:02:15.359735379 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DatabaseMigratorTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,56 +0,0 @@ -/* - * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -package de.smederee.hub - -import cats.effect._ -import cats.syntax.all._ - -final class DatabaseMigratorTest extends BaseSpec { - override def beforeEach(context: BeforeEach): Unit = { - val _ = flyway.migrate() - val _ = flyway.clean() - } - - override def afterEach(context: AfterEach): Unit = { - val _ = flyway.migrate() - val _ = flyway.clean() - } - - test("DatabaseMigrator must update available outdated database") { - val dbConfig = configuration.database - val migrator = new DatabaseMigrator[IO] - val test = migrator.migrate(dbConfig.url, dbConfig.user, dbConfig.pass) - test.map(result => assert(result.migrationsExecuted > 0)) - } - - test("DatabaseMigrator must not update an up to date database") { - val dbConfig = configuration.database - val migrator = new DatabaseMigrator[IO] - val test = for { - _ <- migrator.migrate(dbConfig.url, dbConfig.user, dbConfig.pass) - r <- migrator.migrate(dbConfig.url, dbConfig.user, dbConfig.pass) - } yield r - test.map(result => assert(result.migrationsExecuted === 0)) - } - - test("DatabaseMigrator must throw an exception if the database is not available") { - val migrator = new DatabaseMigrator[IO] - val test = migrator.migrate("jdbc:nodriver://", "", "") - test.attempt.map(r => assert(r.isLeft)) - } -} diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala --- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala 2025-01-16 05:02:15.359735379 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,241 +0,0 @@ -/* - * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -package de.smederee.hub - -import java.time.{ OffsetDateTime, ZoneOffset } -import java.util.UUID - -import cats.effect._ -import cats.syntax.all._ -import de.smederee.hub.Generators._ -import de.smederee.security._ -import de.smederee.ssh._ -import doobie._ - -final class DoobieAccountManagementRepositoryTest extends BaseSpec { - val sshKeyWithComment = ResourceSuiteLocalFixture( - "ssh-key-with-comment", - Resource.make(IO { - val input = scala.io.Source - .fromInputStream( - getClass().getClassLoader().getResourceAsStream("de/smederee/ssh/ssh-key-with-comment.pub"), - "UTF-8" - ) - .getLines() - .mkString - val keyString = SshPublicKeyString(input) - PublicSshKey.from(UUID.randomUUID())(UserId.randomUserId)(OffsetDateTime.now(ZoneOffset.UTC))(keyString) - })(_ => IO.unit) - ) - - val sshKeyWithoutComment = ResourceSuiteLocalFixture( - "ssh-key-without-comment", - Resource.make(IO { - val input = scala.io.Source - .fromInputStream( - getClass().getClassLoader().getResourceAsStream("de/smederee/ssh/ssh-key-without-comment.pub"), - "UTF-8" - ) - .getLines() - .mkString - val keyString = SshPublicKeyString(input) - PublicSshKey.from(UUID.randomUUID())(UserId.randomUserId)(OffsetDateTime.now(ZoneOffset.UTC))(keyString) - })(_ => IO.unit) - ) - - override def munitFixtures = List(sshKeyWithComment, sshKeyWithoutComment) - - test("addSshKey must save the key to the database") { - (genValidAccount.sample, sshKeyWithComment()) match { - case (Some(account), Some(sshKey)) => - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieAccountManagementRepository[IO](tx) - val attempts = scala.util.Random.nextInt(128) - val hash = PasswordHash("Yet another weak password!") - val test = for { - _ <- createAccount(account, hash, None, Option(attempts)) - w <- repo.addSshKey(sshKey.copy(ownerId = account.uid)) - keys <- repo.listSshKeys(account.uid).compile.toList - } yield (w, keys) - test.map { result => - val (written, keys) = result - assert(written === 1, "No database rows written!") - assert(keys.exists(_.id === sshKey.id), "Key must be in the key list of the user!") - } - case _ => fail("Could not generate data samples!") - } - } - - test("addSshKey must fail if a key with the same fingerprint already exists") { - (genValidAccount.sample, sshKeyWithoutComment()) match { - case (Some(account), Some(sshKey)) => - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieAccountManagementRepository[IO](tx) - val attempts = scala.util.Random.nextInt(128) - val hash = PasswordHash("Yet another weak password!") - val test = for { - _ <- createAccount(account, hash, None, Option(attempts)) - _ <- repo.addSshKey(sshKey.copy(ownerId = account.uid)) - _ <- repo.addSshKey(sshKey.copy(ownerId = account.uid)) - } yield () - test.attempt.map { result => - assert(result.isLeft, "Writing a key with a duplicate fingerprint must fail!") - } - case _ => fail("Could not generate data samples!") - } - } - - test("deleteAccount must remove the account from the database") { - genValidAccount.sample match { - case None => fail("Could not generate data samples!") - case Some(account) => - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieAccountManagementRepository[IO](tx) - val attempts = scala.util.Random.nextInt(128) - val hash = PasswordHash("Yet another weak password!") - val test = for { - _ <- createAccount(account, hash, None, Option(attempts)) - _ <- repo.deleteAccount(account.uid) - o <- loadAccount(account.uid) - } yield o - test.map(result => assert(result === None, "Account not deleted from database!")) - } - } - - test("findByValidationToken must return the matching account") { - genValidAccount.sample match { - case None => fail("Could not generate data samples!") - case Some(ac) => - val account = ac.copy(validatedEmail = true) - val token = ValidationToken.generate - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieAccountManagementRepository[IO](tx) - val hash = PasswordHash("Yet another weak password!") - val test = for { - _ <- createAccount(account, hash, validationToken = token.some) - o <- repo.findByValidationToken(token) - } yield o - test.map(result => assert(result === Some(account))) - } - } - - test("findPasswordHash must return correct hash") { - genValidAccount.sample match { - case None => fail("Could not generate data samples!") - case Some(account) => - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieAccountManagementRepository[IO](tx) - val attempts = scala.util.Random.nextInt(128) - val hash = PasswordHash("Yet another weak password!") - val test = for { - _ <- createAccount(account, hash, None, Option(attempts)) - o <- repo.findPasswordHash(account.uid) - } yield o - test.map(result => assert(result.exists(_ === hash), "Unexpected result from database!")) - } - } - - test("listSshKeys must return all keys for the user") { - (genValidAccount.sample, sshKeyWithComment(), sshKeyWithoutComment()) match { - case (Some(account), Some(sshKeyA), Some(sshKeyB)) => - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieAccountManagementRepository[IO](tx) - val attempts = scala.util.Random.nextInt(128) - val hash = PasswordHash("Yet another weak password!") - val test = for { - _ <- createAccount(account, hash, None, Option(attempts)) - _ <- repo.addSshKey(sshKeyA.copy(ownerId = account.uid)) - _ <- repo.addSshKey(sshKeyB.copy(ownerId = account.uid)) - keys <- repo.listSshKeys(account.uid).compile.toList - } yield keys - test.map { keys => - assertEquals(keys.length, 2, "Expected 2 keys in the key list!") - assert(keys.exists(_.id === sshKeyA.id), "Key A must be in the key list of the user!") - assert(keys.exists(_.id === sshKeyB.id), "Key B must be in the key list of the user!") - } - case _ => fail("Could not generate data samples!") - } - } - - test("markAsValidated must clear the validation token and set the validated column to true") { - genValidAccount.sample match { - case None => fail("Could not generate data samples!") - case Some(ac) => - val account = ac.copy(validatedEmail = true) - val token = ValidationToken.generate - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieAccountManagementRepository[IO](tx) - val hash = PasswordHash("Yet another weak password!") - val test = for { - _ <- createAccount(account, hash, validationToken = token.some) - _ <- repo.markAsValidated(account.uid) - cols <- loadValidationColumns(account.uid) - } yield cols - test.map { result => - assert(result === Some((true, None)), "Unexpected result from database!") - } - } - } - - test("setLanguage must set the language") { - genValidAccount.sample match { - case None => fail("Could not generate data samples!") - case Some(account) => - val language = genLanguageCode.sample - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieAccountManagementRepository[IO](tx) - val hash = PasswordHash("Yet another weak password!") - val test = for { - _ <- createAccount(account, hash) - _ <- repo.setLanguage(account.uid, language) - modifiedAccount <- loadAccount(account.uid) - } yield modifiedAccount - test.map { modifiedAccount => - assert(modifiedAccount.exists(_.language === language), "Written language field does not match!") - } - } - } - - test("setValidationToken must set the validation token") { - genValidAccount.sample match { - case None => fail("Could not generate data samples!") - case Some(account) => - val token = ValidationToken.generate - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieAccountManagementRepository[IO](tx) - val hash = PasswordHash("Yet another weak password!") - val test = for { - _ <- createAccount(account, hash) - _ <- repo.setValidationToken(account.uid, token) - cols <- loadValidationColumns(account.uid) - } yield cols - test.map { result => - assert(result === Some((account.validatedEmail, Some(token))), "Unexpected result from database!") - } - } - } -} diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala --- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala 2025-01-16 05:02:15.359735379 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,353 +0,0 @@ -/* - * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -package de.smederee.hub - -import cats.effect._ -import cats.syntax.all._ -import de.smederee.hub.Generators._ -import de.smederee.security._ -import doobie._ -import org.flywaydb.core.Flyway - -final class DoobieAuthenticationRepositoryTest extends BaseSpec { - override def beforeEach(context: BeforeEach): Unit = { - val dbConfig = configuration.database - val flyway: Flyway = - DatabaseMigrator.configureFlyway(dbConfig.url, dbConfig.user, dbConfig.pass).cleanDisabled(false).load() - val _ = flyway.migrate() - val _ = flyway.clean() - val _ = flyway.migrate() - } - - override def afterEach(context: AfterEach): Unit = { - val dbConfig = configuration.database - val flyway: Flyway = - DatabaseMigrator.configureFlyway(dbConfig.url, dbConfig.user, dbConfig.pass).cleanDisabled(false).load() - val _ = flyway.migrate() - val _ = flyway.clean() - } - - test("createUserSession must create the user session") { - (genValidSession.sample, genValidAccount.sample) match { - case (Some(s), Some(account)) => - val session = s.copy(uid = account.uid) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieAuthenticationRepository[IO](tx) - val test = for { - _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) - w <- repo.createUserSession(session) - o <- repo.findUserSession(session.id) - } yield (w, o) - test.map { result => - val (written, maybeSession) = result - assert(written === 1, "Creating user session must modify one database row!") - assert(clue(maybeSession) === clue(Option(session))) - } - case _ => fail("Could not generate data samples!") - } - } - - test("createUserSession must fail if the user does not exist") { - genValidSession.sample match { - case Some(session) => - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieAuthenticationRepository[IO](tx) - val test = for { - _ <- repo.createUserSession(session) - _ <- repo.findUserSession(session.id) - } yield () - test.attempt.map(result => assert(result.isLeft)) - case _ => fail("Could not generate data samples!") - } - } - - test("deleteUserSession must delete the session") { - (genValidSession.sample, genValidAccount.sample) match { - case (Some(s), Some(account)) => - val session = s.copy(uid = account.uid) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieAuthenticationRepository[IO](tx) - val test = for { - _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) - _ <- createUserSession(session) - before <- repo.findUserSession(session.id) - deleted <- repo.deleteUserSession(session.id) - after <- repo.findUserSession(session.id) - } yield (before, deleted, after) - test.map { result => - val (before, deleted, after) = result - assert(before.nonEmpty, "Session must exist before deleting it!") - assert(deleted === 1, "Deletion must affect one database row!") - assert(after.isEmpty, "Session must not exist after deletion!") - } - case _ => fail("Could not generate data samples!") - } - } - - test("findAccount must return an existing account") { - genValidAccount.sample match { - case None => fail("Could not generate data samples!") - case Some(account) => - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieAuthenticationRepository[IO](tx) - val test = for { - _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) - o <- repo.findAccount(account.uid) - } yield o - test.map { result => - assert(result === Option(account)) - } - } - } - - test("findAccount must not return a locked account") { - genValidAccount.sample match { - case None => fail("Could not generate data samples!") - case Some(account) => - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieAuthenticationRepository[IO](tx) - val test = for { - _ <- createAccount( - account, - PasswordHash("I am not a password hash!"), - Option(UnlockToken.generate), - None - ) - o <- repo.findAccount(account.uid) - } yield o - test.map { result => - assert(result.isEmpty, "The function must not return locked accounts!") - } - } - } - - test("findAccountByEmail must return an existing account") { - genValidAccount.sample match { - case None => fail("Could not generate data samples!") - case Some(account) => - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieAuthenticationRepository[IO](tx) - val test = for { - _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) - o <- repo.findAccountByEmail(account.email) - } yield o - test.map { result => - assert(result === Option(account)) - } - } - } - - test("findAccountByEmail must not return a locked account") { - genValidAccount.sample match { - case None => fail("Could not generate data samples!") - case Some(account) => - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieAuthenticationRepository[IO](tx) - val test = for { - _ <- createAccount( - account, - PasswordHash("I am not a password hash!"), - Option(UnlockToken.generate), - None - ) - o <- repo.findAccountByEmail(account.email) - } yield o - test.map { result => - assert(result.isEmpty, "The function must not return locked accounts!") - } - } - } - - test("findAccountByName must return an existing account") { - genValidAccount.sample match { - case None => fail("Could not generate data samples!") - case Some(account) => - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieAuthenticationRepository[IO](tx) - val test = for { - _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) - o <- repo.findAccountByName(account.name) - } yield o - test.map { result => - assert(result === Option(account)) - } - } - } - - test("findAccountByName must not return a locked account") { - genValidAccount.sample match { - case None => fail("Could not generate data samples!") - case Some(account) => - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieAuthenticationRepository[IO](tx) - val test = for { - _ <- createAccount( - account, - PasswordHash("I am not a password hash!"), - Option(UnlockToken.generate), - None - ) - o <- repo.findAccountByName(account.name) - } yield o - test.map { result => - assert(result.isEmpty, "The function must not return locked accounts!") - } - } - } - - test("findLockedAccount must return a locked account") { - genValidAccount.sample match { - case None => fail("Could not generate data samples!") - case Some(account) => - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieAuthenticationRepository[IO](tx) - val token = UnlockToken.generate - val test = for { - _ <- createAccount(account, PasswordHash("I am not a password hash!"), Option(token), None) - o <- repo.findLockedAccount(account.name)(token) - } yield o - test.map { result => - assert(result === Option(account)) - } - } - } - - test("findPasswordHashAndAttempts must return correct values") { - genValidAccount.sample match { - case None => fail("Could not generate data samples!") - case Some(account) => - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieAuthenticationRepository[IO](tx) - val attempts = scala.util.Random.nextInt(128) - val hash = PasswordHash("Yet another weak password!") - val test = for { - _ <- createAccount(account, hash, None, Option(attempts)) - o <- repo.findPasswordHashAndAttempts(account.uid) - } yield o - test.map { result => - result match { - case Some((readHash, readAttempts)) => - assert(readHash === hash) - assert(readAttempts === attempts) - case _ => fail("Unexpected result from database!") - } - } - } - } - - test("findPasswordHashAndAttempts must not return values for locked accounts") { - genValidAccount.sample match { - case None => fail("Could not generate data samples!") - case Some(account) => - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieAuthenticationRepository[IO](tx) - val attempts = scala.util.Random.nextInt(128) - val hash = PasswordHash("Yet another weak password!") - val test = for { - _ <- createAccount(account, hash, Option(UnlockToken.generate), Option(attempts)) - o <- repo.findPasswordHashAndAttempts(account.uid) - } yield o - test.map { result => - assert(result.isEmpty, "The function must not return locked accounts!") - } - } - } - - test("findUserSession must find an existing session") { - (genValidSession.sample, genValidAccount.sample) match { - case (Some(s), Some(account)) => - val session = s.copy(uid = account.uid) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieAuthenticationRepository[IO](tx) - val test = for { - _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) - _ <- createUserSession(session) - o <- repo.findUserSession(session.id) - } yield o - test.map { maybeSession => - assert(clue(maybeSession) === clue(Option(session))) - } - case _ => fail("Could not generate data samples!") - } - } - - test("incrementFailedAttempts must increment failed attempts by 1") { - genValidAccount.sample match { - case None => fail("Could not generate data samples!") - case Some(account) => - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieAuthenticationRepository[IO](tx) - val attempts = scala.util.Random.nextInt(128) - val hash = PasswordHash("Yet another weak password!") - val test = for { - _ <- createAccount(account, hash, None, Option(attempts)) - before <- repo.findPasswordHashAndAttempts(account.uid) - _ <- repo.incrementFailedAttempts(account.uid) - after <- repo.findPasswordHashAndAttempts(account.uid) - } yield (before, after) - test.map { result => - result match { - case (Some((_, before)), Some((_, after))) => - assert(after - before === 1, "Attempts must be incremented by one!") - case _ => fail("Unexpected result from database!") - } - } - } - } - - test("lockAccount must lock an account") { - genValidAccount.sample match { - case None => fail("Could not generate data samples!") - case Some(account) => - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieAuthenticationRepository[IO](tx) - val token = UnlockToken.generate - val test = for { - _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) - before <- repo.findAccount(account.uid) - _ <- repo.lockAccount(account.uid)(Option(token)) - after <- repo.findAccount(account.uid) - locked <- repo.findLockedAccount(account.name)(token) - } yield (before, after, locked) - test.map { result => - result match { - case (Some(before), after, Some(locked)) => - assert(after.isEmpty) - assert(before === locked) - case _ => fail("Unexpected result from database!") - } - } - } - } - -} diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieSignupRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieSignupRepositoryTest.scala --- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieSignupRepositoryTest.scala 2025-01-16 05:02:15.359735379 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieSignupRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,131 +0,0 @@ -/* - * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -package de.smederee.hub - -import cats.effect._ -import cats.syntax.all._ -import de.smederee.hub.Generators._ -import de.smederee.security._ -import doobie._ - -final class DoobieSignupRepositoryTest extends BaseSpec { - test("createAccount must create a new account") { - genValidAccount.sample match { - case None => fail("Could not generate data samples!") - case Some(account) => - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieSignupRepository[IO](tx) - val test = for { - w <- repo.createAccount(account, PasswordHash("I am not a password hash!")) - o <- loadAccount(account.uid) - } yield (w, o) - test.map { result => - val (written, loadedAccount) = result - assert(written === 1, "1 database row must have been written!") - assert(loadedAccount === Option(account), "Saved account differs from expected one!") - } - } - } - - test("createAccount must fail if the email already exists") { - (genValidAccount.sample, genValidAccount.sample) match { - case (Some(a), Some(b)) => - val existingAccount = a - val newAccount = b.copy(email = a.email) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieSignupRepository[IO](tx) - val test = for { - c <- createAccount(existingAccount, PasswordHash("I am not a password hash!"), None, None) - w <- repo.createAccount(newAccount, PasswordHash("I am not a password hash!")) - } yield (c, w) - test.attempt.map { - case Left(error) => - assert( - error.getMessage.contains("accounts_unique_email"), - "Error must be triggered by accounts_unique_email constraint!" - ) - case Right(_) => fail("Creating accounts with already used emails must fail!") - } - case _ => fail("Could not generate data samples!") - } - } - - test("createAccount must fail if the username already exists") { - (genValidAccount.sample, genValidAccount.sample) match { - case (Some(a), Some(b)) => - val existingAccount = a - val newAccount = b.copy(name = a.name) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieSignupRepository[IO](tx) - val test = for { - c <- createAccount(existingAccount, PasswordHash("I am not a password hash!"), None, None) - w <- repo.createAccount(newAccount, PasswordHash("I am not a password hash!")) - } yield (c, w) - test.attempt.map { - case Left(error) => - assert( - error.getMessage.contains("accounts_unique_name"), - "Error must be triggered by accounts_unique_name constraint!" - ) - case Right(_) => fail("Creating accounts with already used names must fail!") - } - case _ => fail("Could not generate data samples!") - } - } - - test("findEmail must return an existing email") { - genValidAccount.sample match { - case None => fail("Could not generate data samples!") - case Some(account) => - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieSignupRepository[IO](tx) - val test = for { - c <- repo.createAccount(account, PasswordHash("I am not a password hash!")) - e <- repo.findEmail(account.email) - } yield (c, e) - test.map { result => - val (created, email) = result - assert(created === 1, "Test account not created!") - assert(email === Option(account.email), "Expected email not found!") - } - } - } - - test("findUsername must return an existing name") { - genValidAccount.sample match { - case None => fail("Could not generate data samples!") - case Some(account) => - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieSignupRepository[IO](tx) - val test = for { - c <- repo.createAccount(account, PasswordHash("I am not a password hash!")) - n <- repo.findUsername(account.name) - } yield (c, n) - test.map { result => - val (created, name) = result - assert(created === 1, "Test account not created!") - assert(name === Option(account.name), "Expected name not found!") - } - } - } -} diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala --- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala 2025-01-16 05:02:15.359735379 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,542 +0,0 @@ -/* - * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -package de.smederee.hub - -import cats.effect._ -import cats.syntax.all._ -import de.smederee.email.EmailAddress -import de.smederee.hub.Generators._ -import de.smederee.hub.VcsMetadataRepositoriesOrdering._ -import de.smederee.security._ -import doobie._ -import org.http4s.implicits._ - -import scala.collection.immutable.Queue - -final class DoobieVcsMetadataRepositoryTest extends BaseSpec { - - /** Find all forks of the original repository with the given ID. - * - * @param originalRepoId - * The unique ID of the original repo from which was forked. - * @return - * A list of ID pairs (original repository id, forked repository id) which may be empty. - */ - @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") - protected def findForks(originalRepoId: VcsRepositoryId): IO[Seq[(VcsRepositoryId, VcsRepositoryId)]] = - connectToDb(configuration).use { con => - for { - statement <- IO.delay( - con.prepareStatement("""SELECT original_repo, forked_repo FROM "hub"."forks" WHERE original_repo = ?""") - ) - _ <- IO.delay(statement.setLong(1, originalRepoId.toLong)) - result <- IO.delay(statement.executeQuery) - forks <- IO.delay { - var queue = Queue.empty[(VcsRepositoryId, VcsRepositoryId)] - while (result.next()) - queue = queue :+ (VcsRepositoryId(result.getLong("original_repo")), VcsRepositoryId( - result.getLong("forked_repo") - )) - queue - } - _ <- IO.delay(statement.close()) - } yield forks.toList - } - - test("createFork must work correctly") { - (genValidAccounts.suchThat(_.size > 4).sample, genValidVcsRepositories.suchThat(_.size > 4).sample) match { - case (Some(accounts), Some(repositories)) => - val vcsRepositories = accounts.zip(repositories).map { tuple => - val (account, repo) = tuple - repo.copy(owner = account.toVcsRepositoryOwner) - } - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieVcsMetadataRepository[IO](tx) - val test = for { - _ <- accounts.traverse(account => - createAccount(account, PasswordHash("I am not a password hash!"), None, None) - ) - written <- vcsRepositories.traverse(repo.createVcsRepository) - original <- vcsRepositories.headOption.traverse(vcsRepository => - loadVcsRepositoryId(vcsRepository.owner.uid, vcsRepository.name) - ) - toFork <- vcsRepositories.drop(1).take(5).traverse { vcsRepository => - loadVcsRepositoryId(vcsRepository.owner.uid, vcsRepository.name) - } - forked <- (original, toFork) match { - case (Some(Some(originalId)), forkIds) => forkIds.flatten.traverse(id => repo.createFork(originalId, id)) - case _ => IO.pure(List.empty) - } - foundForks <- original match { - case Some(Some(originalId)) => findForks(originalId) - case _ => IO.pure(List.empty) - } - } yield (written, forked, foundForks) - test.map { result => - val (written, forked, foundForks) = result - assert(written.sum === vcsRepositories.size, "Test repository data was not written to database!") - assert(forked.sum === vcsRepositories.drop(1).take(5).size, "Number of created forks does not match!") - assert(foundForks.size === vcsRepositories.drop(1).take(5).size, "Number of found forks does not match!") - } - case _ => fail("Could not generate data samples!") - } - } - - test("createVcsRepository must create a repository entry") { - (genValidAccount.sample, genValidVcsRepository.sample) match { - case (Some(account), Some(repository)) => - val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieVcsMetadataRepository[IO](tx) - val test = for { - _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) - written <- repo.createVcsRepository(vcsRepository) - } yield written - test.map { written => - assert(written === 1, "Creating a vcs repository must modify one database row!") - } - case _ => fail("Could not generate data samples!") - } - } - - test("createVcsRepository must fail if the user does not exist") { - genValidVcsRepository.sample match { - case Some(repository) => - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieVcsMetadataRepository[IO](tx) - val test = for { - written <- repo.createVcsRepository(repository) - } yield written - test.attempt.map(result => assert(result.isLeft)) - case _ => fail("Could not generate data samples!") - } - } - - test("createVcsRepository must fail if a repository with the same name exists") { - (genValidAccount.sample, genValidVcsRepository.sample) match { - case (Some(account), Some(repository)) => - val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieVcsMetadataRepository[IO](tx) - val test = for { - _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) - _ <- repo.createVcsRepository(vcsRepository) - written <- repo.createVcsRepository(vcsRepository) - } yield written - test.attempt.map(result => assert(result.isLeft)) - case _ => fail("Could not generate data samples!") - } - } - - test("findVcsRepository must return an existing repository") { - (genValidAccount.sample, genValidVcsRepository.sample) match { - case (Some(account), Some(repository)) => - val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieVcsMetadataRepository[IO](tx) - val test = for { - _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) - written <- repo.createVcsRepository(vcsRepository) - foundRepo <- repo.findVcsRepository(vcsRepository.owner, vcsRepository.name) - } yield (written, foundRepo) - test.map { result => - val (written, foundRepo) = result - assert(written === 1, "Test repository data was not written to database!") - foundRepo match { - case None => fail("Repository was not found!") - case Some(repo) => assert(repo === vcsRepository) - } - } - case _ => fail("Could not generate data samples!") - } - } - - test("findVcsRepositoryBranches must return all branches") { - (genValidAccounts.suchThat(_.size > 4).sample, genValidVcsRepositories.suchThat(_.size > 4).sample) match { - case (Some(accounts), Some(repositories)) => - val vcsRepositories = accounts.zip(repositories).map { tuple => - val (account, repo) = tuple - repo.copy(owner = account.toVcsRepositoryOwner) - } - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieVcsMetadataRepository[IO](tx) - val test = for { - _ <- accounts.traverse(account => - createAccount(account, PasswordHash("I am not a password hash!"), None, None) - ) - written <- vcsRepositories.traverse(repo.createVcsRepository) - original <- vcsRepositories.headOption.traverse(vcsRepository => - loadVcsRepositoryId(vcsRepository.owner.uid, vcsRepository.name) - ) - toFork <- vcsRepositories.drop(1).take(5).traverse { vcsRepository => - loadVcsRepositoryId(vcsRepository.owner.uid, vcsRepository.name) - } - forked <- (original, toFork) match { - case (Some(Some(originalId)), forkIds) => forkIds.flatten.traverse(id => repo.createFork(originalId, id)) - case _ => IO.pure(List.empty) - } - foundForks <- original match { - case Some(Some(originalId)) => repo.findVcsRepositoryBranches(originalId).compile.toList - case _ => IO.pure(List.empty) - } - } yield (written, forked, foundForks) - test.map { result => - val (written, forked, foundForks) = result - assert(written.sum === vcsRepositories.size, "Test repository data was not written to database!") - assert(forked.sum === vcsRepositories.drop(1).take(5).size, "Number of created forks does not match!") - assert(foundForks.size === vcsRepositories.drop(1).take(5).size, "Number of found forks does not match!") - foundForks.zip(vcsRepositories.drop(1).take(5)).map { tuple => - val ((ownerName, repoName), repo) = tuple - assertEquals(ownerName, repo.owner.name) - assertEquals(repoName, repo.name) - } - } - case _ => fail("Could not generate data samples!") - } - } - - test("loadVcsRepositoryId must return the id of an existing repository") { - (genValidAccount.sample, genValidVcsRepository.sample) match { - case (Some(account), Some(repository)) => - val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieVcsMetadataRepository[IO](tx) - val test = for { - _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) - writtenRows <- repo.createVcsRepository(vcsRepository) - writtenId <- loadVcsRepositoryId(vcsRepository.owner.uid, vcsRepository.name) - foundId <- repo.findVcsRepositoryId(vcsRepository.owner, vcsRepository.name) - } yield (writtenRows, writtenId, foundId) - test.map { result => - val (written, writtenId, foundId) = result - assert(written === 1, "Test repository data was not written to database!") - assert(writtenId.nonEmpty) - assert(foundId.nonEmpty) - assert(writtenId === foundId) - } - case _ => fail("Could not generate data samples!") - } - } - - test("findVcsRepositoryOwner must return the correct account") { - genValidAccounts.sample match { - case Some(accounts) => - val expectedOwner = accounts(scala.util.Random.nextInt(accounts.size)).toVcsRepositoryOwner - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieVcsMetadataRepository[IO](tx) - val test = for { - written <- accounts.traverse(account => - createAccount(account, PasswordHash("I am not a password hash!"), None, None) - ) - foundOwner <- repo.findVcsRepositoryOwner(expectedOwner.name) - } yield (written, foundOwner) - test.map { result => - val (written, foundOwner) = result - assert( - written.filter(_ === 1).size === written.size, - "Not all test repository data was not written to database!" - ) - foundOwner match { - case None => fail("Vcs repository owner not found!") - case Some(owner) => assertEquals(owner, expectedOwner) - } - } - case _ => fail("Could not generate data samples!") - } - } - - test("findVcsRepositoryParentFork must return the parent repository if it exists") { - (genValidAccounts.suchThat(_.size > 4).sample, genValidVcsRepositories.suchThat(_.size > 4).sample) match { - case (Some(accounts), Some(repositories)) => - val vcsRepositories = accounts.zip(repositories).map { tuple => - val (account, repo) = tuple - repo.copy(owner = account.toVcsRepositoryOwner) - } - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieVcsMetadataRepository[IO](tx) - val test = for { - _ <- accounts.traverse(account => - createAccount(account, PasswordHash("I am not a password hash!"), None, None) - ) - written <- vcsRepositories.traverse(repo.createVcsRepository) - original <- vcsRepositories.headOption.traverse(vcsRepository => - loadVcsRepositoryId(vcsRepository.owner.uid, vcsRepository.name) - ) - toFork <- vcsRepositories.drop(1).take(5).traverse { vcsRepository => - loadVcsRepositoryId(vcsRepository.owner.uid, vcsRepository.name) - } - forked <- (original, toFork) match { - case (Some(Some(originalId)), forkIds) => forkIds.flatten.traverse(id => repo.createFork(originalId, id)) - case _ => IO.pure(List.empty) - } - foundParents <- vcsRepositories.drop(1).take(5).traverse { vcsRepository => - repo.findVcsRepositoryParentFork(vcsRepository.owner, vcsRepository.name) - } - } yield (written, forked, vcsRepositories.headOption, foundParents) - test.map { result => - val (written, forked, original, foundParents) = result - assert(written.sum === vcsRepositories.size, "Test repository data was not written to database!") - assert(forked.sum === vcsRepositories.drop(1).take(5).size, "Number of created forks does not match!") - assert(foundParents.forall(_ === original), "Parent vcs repository not matching!") - } - case _ => fail("Could not generate data samples!") - } - } - - test("listAllRepositories must return only public repositories for guest users") { - (genValidAccount.sample, genValidVcsRepositories.sample) match { - case (Some(account), Some(repositories)) => - val vcsRepositories = repositories - val accounts = vcsRepositories.map(repo => - Account( - repo.owner.uid, - repo.owner.name, - EmailAddress(s"${repo.owner.name}@example.com"), - validatedEmail = true, - None - ) - ) - val expectedRepoList = vcsRepositories.filter(_.isPrivate === false) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieVcsMetadataRepository[IO](tx) - val test = for { - _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) - _ <- accounts.traverse(account => - createAccount(account, PasswordHash("I am not a password hash!"), None, None) - ) - written <- vcsRepositories.traverse(vcsRepository => repo.createVcsRepository(vcsRepository)) - foundRepos <- repo.listAllRepositories(None)(NameAscending).compile.toList - } yield (written, foundRepos) - test.map { result => - val (written, foundRepos) = result - assert( - written.filter(_ === 1).size === written.size, - "Not all test repository data was written to database!" - ) - assertEquals(foundRepos.size, expectedRepoList.size) - } - - case _ => fail("Could not generate data samples!") - } - } - - test("listAllRepositories must return only public repositories of others for any user") { - (genValidAccount.sample, genValidVcsRepositories.sample) match { - case (Some(account), Some(repositories)) => - val vcsRepositories = repositories - val accounts = vcsRepositories.map(repo => - Account( - repo.owner.uid, - repo.owner.name, - EmailAddress(s"${repo.owner.name}@example.com"), - validatedEmail = true, - None - ) - ) - val expectedRepoList = vcsRepositories.filter(_.isPrivate === false) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieVcsMetadataRepository[IO](tx) - val test = for { - _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) - _ <- accounts.traverse(account => - createAccount(account, PasswordHash("I am not a password hash!"), None, None) - ) - written <- vcsRepositories.traverse(vcsRepository => repo.createVcsRepository(vcsRepository)) - foundRepos <- repo.listAllRepositories(account.some)(NameDescending).compile.toList - } yield (written, foundRepos) - test.map { result => - val (written, foundRepos) = result - assert( - written.filter(_ === 1).size === written.size, - "Not all test repository data was written to database!" - ) - assertEquals(foundRepos.size, expectedRepoList.size) - } - - case _ => fail("Could not generate data samples!") - } - } - - test("listAllRepositories must include all private repositories of the user") { - (genValidAccount.sample, genValidVcsRepositories.sample) match { - case (Some(account), Some(repositories)) => - val privateRepos = - repositories.filter(_.isPrivate === true).map(_.copy(owner = account.toVcsRepositoryOwner)) - val publicRepos = repositories.filter(_.isPrivate === false) - val vcsRepositories = privateRepos ::: publicRepos - val accounts = publicRepos.map(repo => - Account( - repo.owner.uid, - repo.owner.name, - EmailAddress(s"${repo.owner.name}@example.com"), - validatedEmail = true, - None - ) - ) - val expectedRepoList = vcsRepositories - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieVcsMetadataRepository[IO](tx) - val test = for { - _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) - _ <- accounts.traverse(account => - createAccount(account, PasswordHash("I am not a password hash!"), None, None) - ) - written <- vcsRepositories.traverse(vcsRepository => repo.createVcsRepository(vcsRepository)) - foundRepos <- repo.listAllRepositories(account.some)(NameDescending).compile.toList - } yield (written, foundRepos) - test.map { result => - val (written, foundRepos) = result - assert( - written.filter(_ === 1).size === written.size, - "Not all test repository data was written to database!" - ) - assertEquals(foundRepos.size, expectedRepoList.size) - } - - case _ => fail("Could not generate data samples!") - } - } - - test("listRepositories must return only public repositories for guest users") { - (genValidAccount.sample, genValidVcsRepositories.sample) match { - case (Some(account), Some(repositories)) => - val vcsRepositories = repositories.map(_.copy(owner = account.toVcsRepositoryOwner)) - val expectedRepoList = vcsRepositories.filter(_.isPrivate === false).sortBy(_.name) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieVcsMetadataRepository[IO](tx) - val test = for { - _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) - written <- vcsRepositories.traverse(vcsRepository => repo.createVcsRepository(vcsRepository)) - foundRepos <- repo.listRepositories(None)(account.toVcsRepositoryOwner).compile.toList - } yield (written, foundRepos) - test.map { result => - val (written, foundRepos) = result - assert( - written.filter(_ === 1).size === written.size, - "Not all test repository data was written to database!" - ) - assertEquals(foundRepos.size, expectedRepoList.size) - // We sort again because database sorting might differ slightly from code sorting. - assertEquals(foundRepos.sortBy(_.name), expectedRepoList) - } - case _ => fail("Could not generate data samples!") - } - } - - test("listRepositories must return all repositories for the owner") { - (genValidAccount.sample, genValidVcsRepositories.sample) match { - case (Some(account), Some(repositories)) => - val vcsRepositories = repositories.map(_.copy(owner = account.toVcsRepositoryOwner)) - val expectedRepoList = vcsRepositories.sortBy(_.name) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieVcsMetadataRepository[IO](tx) - val test = for { - _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) - written <- vcsRepositories.traverse(vcsRepository => repo.createVcsRepository(vcsRepository)) - foundRepos <- repo.listRepositories(account.some)(account.toVcsRepositoryOwner).compile.toList - } yield (written, foundRepos) - test.map { result => - val (written, foundRepos) = result - assert( - written.filter(_ === 1).size === written.size, - "Not all test repository data was written to database!" - ) - assertEquals(foundRepos.size, expectedRepoList.size) - // We sort again because database sorting might differ slightly from code sorting. - assertEquals(foundRepos.sortBy(_.name), expectedRepoList) - } - case _ => fail("Could not generate data samples!") - } - } - - test("listRepositories must return only public repositories for any user") { - (genValidAccount.sample, genValidVcsRepositories.sample, genValidAccount.sample) match { - case (Some(account), Some(repositories), Some(otherAccount)) => - val vcsRepositories = repositories.map(_.copy(owner = account.toVcsRepositoryOwner)) - val expectedRepoList = vcsRepositories.filter(_.isPrivate === false).sortBy(_.name) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieVcsMetadataRepository[IO](tx) - val test = for { - _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) - written <- vcsRepositories.traverse(vcsRepository => repo.createVcsRepository(vcsRepository)) - foundRepos <- repo.listRepositories(otherAccount.some)(account.toVcsRepositoryOwner).compile.toList - } yield (written, foundRepos) - test.map { result => - val (written, foundRepos) = result - assert( - written.filter(_ === 1).size === written.size, - "Not all test repository data was written to database!" - ) - assertEquals(foundRepos.size, expectedRepoList.size) - // We sort again because database sorting might differ slightly from code sorting. - assertEquals(foundRepos.sortBy(_.name), expectedRepoList) - } - case _ => fail("Could not generate data samples!") - } - } - - test("updateVcsRepository must update all columns correctly") { - (genValidAccount.sample, genValidVcsRepositories.sample) match { - case (Some(account), Some(repository :: repositories)) => - val vcsRepositories = repository.copy(owner = account.toVcsRepositoryOwner) :: repositories.map( - _.copy(owner = account.toVcsRepositoryOwner) - ) - val updatedRepo = repository - .copy(owner = account.toVcsRepositoryOwner) - .copy( - isPrivate = !repository.isPrivate, - description = Option(VcsRepositoryDescription("I am a description...")), - website = Option(uri"https://updated.example.com") - ) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieVcsMetadataRepository[IO](tx) - val test = for { - _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) - written <- vcsRepositories.traverse(vcsRepository => repo.createVcsRepository(vcsRepository)) - updated <- repo.updateVcsRepository(updatedRepo) - persisted <- repo.findVcsRepository(account.toVcsRepositoryOwner, repository.name) - } yield (written, updated, persisted) - test.map { result => - val (written, updated, persisted) = result - assert( - written.filter(_ === 1).size === written.size, - "Not all test repository data was written to database!" - ) - assert(updated === 1, "Repository was not updated in database!") - assert(persisted === Some(updatedRepo)) - } - case _ => fail("Could not generate data samples!") - } - } -} diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala --- old-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala 2025-01-16 05:02:15.359735379 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,202 +0,0 @@ -/* - * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -package de.smederee.hub - -import java.nio.charset.StandardCharsets -import java.time._ -import java.util.{ Locale, UUID } - -import cats.syntax.all._ -import de.smederee.email.EmailAddress -import de.smederee.i18n.LanguageCode -import de.smederee.security._ - -import org.scalacheck._ - -import scala.concurrent.duration._ -import scala.jdk.CollectionConverters._ - -object Generators { - val MinimumYear: Int = -4713 // Lowest year supported by PostgreSQL - val MaximumYear: Int = 294276 // Highest year supported by PostgreSQL - - val genLocale: Gen[Locale] = Gen.oneOf(Locale.getAvailableLocales.toList) - given Arbitrary[Locale] = Arbitrary(genLocale) - - val genLanguageCode: Gen[LanguageCode] = genLocale.map(_.getISO3Language).filter(_.nonEmpty).map(LanguageCode.apply) - - val genFiniteDuration: Gen[FiniteDuration] = - Gen.choose(0, Int.MaxValue).map(seconds => FiniteDuration(seconds, SECONDS)) - - given Arbitrary[FiniteDuration] = Arbitrary(genFiniteDuration) - given Arbitrary[Option[FiniteDuration]] = Arbitrary(Gen.option(genFiniteDuration)) - - val genOffsetDateTime: Gen[OffsetDateTime] = - for { - year <- Gen.choose(MinimumYear, MaximumYear) - month <- Gen.choose(1, 12) - day <- Gen.choose(1, Month.of(month).length(Year.of(year).isLeap)) - hour <- Gen.choose(0, 23) - minute <- Gen.choose(0, 59) - second <- Gen.choose(0, 59) - nanosecond <- Gen.const(0) // Avoid issues with loosing information during saving and loading. - offset <- Gen.oneOf( - ZoneId.getAvailableZoneIds.asScala.toList.map(ZoneId.of).map(ZonedDateTime.now).map(_.getOffset) - ) - } yield OffsetDateTime.of(year, month, day, hour, minute, second, nanosecond, offset) - - given Arbitrary[OffsetDateTime] = Arbitrary(genOffsetDateTime) - - val genZonedDateTime: Gen[ZonedDateTime] = - for { - year <- Gen.choose(MinimumYear, MaximumYear) - month <- Gen.choose(1, 12) - day <- Gen.choose(1, Month.of(month).length(Year.of(year).isLeap)) - hour <- Gen.choose(0, 23) - minute <- Gen.choose(0, 59) - second <- Gen.choose(0, 59) - nanosecond <- Gen.const(0) // Avoid issues with loosing information during saving and loading. - zone <- Gen.oneOf(ZoneId.getAvailableZoneIds.asScala.toList.map(ZoneId.of)) - } yield ZonedDateTime.of(year, month, day, hour, minute, second, nanosecond, zone) - - val genSessionId: Gen[SessionId] = Gen.delay(SessionId.generate) - - val genSignAndValidate: Gen[SignAndValidate] = Gen - .nonEmptyListOf(Gen.alphaNumChar) - .map(cs => PrivateKey(cs.mkString.getBytes(StandardCharsets.UTF_8))) - .map(key => new SignAndValidate(key)) - - given Arbitrary[SignAndValidate] = Arbitrary(genSignAndValidate) - - val genUserId: Gen[UserId] = Gen.delay(UserId.randomUserId) - - val genUUID: Gen[UUID] = Gen.delay(UUID.randomUUID) - - private val validEmailAddressPrefixChars = - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.!#$%&’'*+/=?^_`{|}~-".toList - private val validDomainNameChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-".toList - - val genValidEmail: Gen[EmailAddress] = for { - prefix <- Gen.nonEmptyListOf(Gen.oneOf(validEmailAddressPrefixChars)).map(_.take(64).mkString) - domain <- Gen.nonEmptyListOf(Gen.oneOf(validDomainNameChars)).map(_.take(32).mkString) - topLevelDomain <- Gen - .nonEmptyListOf(Gen.oneOf(validDomainNameChars)) - .suchThat(_.length >= 2) - .map(_.take(24).mkString) - suffix = s"$domain.$topLevelDomain" - } yield EmailAddress(s"$prefix@$suffix") - - val genValidUsername: Gen[Username] = for { - length <- Gen.choose(2, 30) - prefix <- Gen.alphaChar - chars <- Gen - .nonEmptyListOf(Gen.alphaNumChar) - .map(_.take(length).mkString.toLowerCase(Locale.ROOT)) - } yield Username(prefix.toString.toLowerCase(Locale.ROOT) + chars) - - val genValidAccount: Gen[Account] = for { - id <- genUserId - email <- genValidEmail - name <- genValidUsername - validatedEmail <- Gen.oneOf(List(false, true)) - language <- Gen.option(genLanguageCode) - } yield Account(uid = id, name = name, email = email, validatedEmail = validatedEmail, language = language) - - given Arbitrary[Account] = Arbitrary(genValidAccount) - - val genValidAccounts: Gen[List[Account]] = Gen - .nonEmptyListOf(genValidAccount) - .suchThat(accounts => accounts.size === accounts.map(_.name).distinct.size) // Ensure unique names. - - val genValidSession: Gen[Session] = - for { - id <- genSessionId - uid <- genUserId - cat <- genOffsetDateTime.map(_.withOffsetSameInstant(ZoneOffset.UTC)) - uat <- genOffsetDateTime.map(_.withOffsetSameInstant(ZoneOffset.UTC)) - } yield Session(id, uid, cat, uat) - - given Arbitrary[Session] = Arbitrary(genValidSession) - - val genValidVcsRepositoryName: Gen[VcsRepositoryName] = Gen - .nonEmptyListOf( - Gen.oneOf( - List( - "a", - "b", - "c", - "d", - "e", - "f", - "g", - "h", - "i", - "j", - "k", - "l", - "m", - "n", - "o", - "p", - "q", - "r", - "s", - "t", - "u", - "v", - "w", - "x", - "y", - "z", - "0", - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - "-", - "_" - ) - ) - ) - .map(cs => VcsRepositoryName(cs.take(64).mkString)) - - val genValidVcsRepositoryOwner = for { - uid <- genUserId - name <- genValidUsername - email <- genValidEmail - } yield VcsRepositoryOwner(uid, name, email) - - val genValidVcsType = Gen.oneOf(VcsType.values.toList) - - val genValidVcsRepository: Gen[VcsRepository] = for { - name <- genValidVcsRepositoryName - owner <- genValidVcsRepositoryOwner - isPrivate <- Gen.oneOf(List(false, true)) - description <- Gen.alphaNumStr.map(VcsRepositoryDescription.from) - ticketsEnabled <- Gen.oneOf(List(false, true)) - vcsType <- genValidVcsType - } yield VcsRepository(name, owner, isPrivate, description, ticketsEnabled, vcsType, None) - - val genValidVcsRepositories: Gen[List[VcsRepository]] = Gen.nonEmptyListOf(genValidVcsRepository) - -} diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/ssh/DoobieSshAuthenticationRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/ssh/DoobieSshAuthenticationRepositoryTest.scala --- old-smederee/modules/hub/src/it/scala/de/smederee/ssh/DoobieSshAuthenticationRepositoryTest.scala 2025-01-16 05:02:15.359735379 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/ssh/DoobieSshAuthenticationRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,42 +0,0 @@ -/* - * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -package de.smederee.ssh - -import cats.effect._ -import cats.syntax.all._ -import de.smederee.hub.BaseSpec -import de.smederee.hub.Generators._ -import de.smederee.security._ -import doobie._ - -final class DoobieSshAuthenticationRepositoryTest extends BaseSpec { - test("findVcsRepositoryOwner must return the correct owner") { - genValidAccount.sample match { - case Some(account) => - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val repo = new DoobieSshAuthenticationRepository[IO](tx) - val test = for { - _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) - owner <- repo.findVcsRepositoryOwner(account.name) - } yield owner - test.assertEquals(account.toVcsRepositoryOwner.some) - case _ => fail("Could not generate data samples!") - } - } -} diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/ssh/SshServerProviderTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/ssh/SshServerProviderTest.scala --- old-smederee/modules/hub/src/it/scala/de/smederee/ssh/SshServerProviderTest.scala 2025-01-16 05:02:15.363735377 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/ssh/SshServerProviderTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,70 +0,0 @@ -/* - * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -package de.smederee.ssh - -import java.nio.file._ - -import cats.effect._ -import com.comcast.ip4s._ -import de.smederee.hub.BaseSpec -import de.smederee.hub.config._ - -final class SshServerProviderTest extends BaseSpec { - - val repositoriesDirectory = ResourceSuiteLocalFixture( - "repositories-directory", - Resource.make(IO(Files.createTempDirectory("test-repo-dir")))(path => deleteDirectory(path) *> IO.unit) - ) - - val serverKeyFile = ResourceSuiteLocalFixture( - "server-key-file", - Resource.make( - for { - path <- IO(Files.createTempFile("test-server-", ".key")) - _ <- IO(Files.deleteIfExists(path)) - } yield path - )(_ => IO.unit) - ) - - val freePort = ResourceFixture(Resource.make(IO(findFreePort()))(_ => IO.unit)) - - override def munitFixtures = List(repositoriesDirectory, serverKeyFile) - - freePort.test("run() must create and start a server with the given configuration") { port => - port match { - case None => fail("Could not find a free port for testing!") - case Some(portNumber) => - val keyfile = serverKeyFile() - val darcsConfig = DarcsConfiguration(Paths.get("darcs"), DirectoryPath(repositoriesDirectory())) - val sshConfig = SshServerConfiguration( - enabled = true, - genericUser = SshUsername("darcs"), - host = host"localhost", - port = portNumber, - serverKeyFile = keyfile - ) - val provider = new SshServerProvider(darcsConfig, configuration.database, sshConfig) - provider.run().use { server => - assert(server.isStarted(), "Server not started!") - assertEquals(server.getPort(), portNumber.toString.toInt) - assertEquals(server.getHost(), null) - IO.unit - } - } - } -} diff -rN -u old-smederee/modules/hub/src/test/resources/application.conf new-smederee/modules/hub/src/test/resources/application.conf --- old-smederee/modules/hub/src/test/resources/application.conf 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/resources/application.conf 2025-01-16 05:02:15.367735375 +0000 @@ -0,0 +1,12 @@ +hub { + database { + host = localhost + host = ${?SMEDEREE_DB_HOST} + url = "jdbc:postgresql://"${hub.database.host}":5432/smederee_hub_it" + url = ${?SMEDEREE_HUB_TEST_DB_URL} + user = "smederee_hub" + user = ${?SMEDEREE_HUB_TEST_DB_USER} + pass = "secret" + pass = ${?SMEDEREE_HUB_TEST_DB_PASS} + } +} diff -rN -u old-smederee/modules/hub/src/test/resources/logback-test.xml new-smederee/modules/hub/src/test/resources/logback-test.xml --- old-smederee/modules/hub/src/test/resources/logback-test.xml 2025-01-16 05:02:15.363735377 +0000 +++ new-smederee/modules/hub/src/test/resources/logback-test.xml 2025-01-16 05:02:15.367735375 +0000 @@ -19,6 +19,10 @@ <appender-ref ref="async-console"/> </logger> + <logger name="org.flywaydb.core" level="ERROR" additivity="false"> + <appender-ref ref="async-console"/> + </logger> + <root> <appender-ref ref="async-console"/> </root> diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/AuthenticationMiddlewareTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/AuthenticationMiddlewareTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/hub/AuthenticationMiddlewareTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/AuthenticationMiddlewareTest.scala 2025-01-16 05:02:15.367735375 +0000 @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package de.smederee.hub + +import java.time._ + +import cats.effect._ +import cats.syntax.all._ +import de.smederee.TestTags._ +import de.smederee.hub.Generators._ +import de.smederee.hub.config._ +import de.smederee.security._ +import doobie._ +import org.http4s._ + +final class AuthenticationMiddlewareTest extends BaseSpec with AuthenticationMiddleware { + test("extractSessionId must return the session id".tag(NeedsDatabase)) { + (genSignAndValidate.sample, genSessionId.sample) match { + case (Some(signAndValidate), Some(sessionId)) => + val clock = java.time.Clock.systemUTC + val token = signAndValidate.signToken(sessionId.toString)(clock.millis.toString) + val request = Request[IO](method = Method.GET) + .addCookie(RequestCookie(Constants.authenticationCookieName.toString, token.toString)) + val test = for { + id <- extractSessionId[IO](request, signAndValidate) + } yield id + test.map { result => + assert(result === sessionId) + } + case _ => fail("Could not generate data samples!") + } + } + + test( + "resolveUser must return the account if session id and account exist and the session has not reached absolute timeout" + .tag(NeedsDatabase) + ) { + (genFiniteDuration.sample, genValidSession.sample, genValidAccount.sample) match { + case (Some(duration), Some(s), Some(account)) => + val createdAt = OffsetDateTime.now(ZoneOffset.UTC) + val session = s.copy(uid = account.uid, createdAt = createdAt) + val timeouts = AuthenticationTimeouts(duration, duration, duration) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAuthenticationRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + _ <- createUserSession(session) + user <- resolveUser[IO](repo)(timeouts).run(session.id) + } yield user + test.map { maybeUser => + assert(clue(maybeUser) === clue(Option(account))) + } + case _ => fail("Could not generate data samples!") + } + } + + test( + "resolveUser must return None if session id and account exist but the session has reached absolute timeout".tag( + NeedsDatabase + ) + ) { + (genFiniteDuration.sample, genValidSession.sample, genValidAccount.sample) match { + case (Some(duration), Some(s), Some(account)) => + val createdAt = OffsetDateTime.now(ZoneOffset.UTC).minusSeconds(duration.toSeconds + 5L) + val session = s.copy(uid = account.uid, createdAt = createdAt) + val timeouts = AuthenticationTimeouts(duration, duration, duration) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAuthenticationRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + _ <- createUserSession(session) + user <- resolveUser[IO](repo)(timeouts).run(session.id) + } yield user + test.map { maybeUser => + assert(clue(maybeUser) === clue(Option(account))) + } + case _ => fail("Could not generate data samples!") + } + } + + test("resolveUser must return None if no session exists".tag(NeedsDatabase)) { + (genFiniteDuration.sample, genValidSession.sample, genValidAccount.sample) match { + case (Some(duration), Some(s), Some(account)) => + val session = s.copy(uid = account.uid) + val timeouts = AuthenticationTimeouts(duration, duration, duration) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAuthenticationRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + user <- resolveUser[IO](repo)(timeouts).run(session.id) + } yield user + test.map { maybeUser => + assert(clue(maybeUser) === None) + } + case _ => fail("Could not generate data samples!") + } + } + +} diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/BaseSpec.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/BaseSpec.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/hub/BaseSpec.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/BaseSpec.scala 2025-01-16 05:02:15.367735375 +0000 @@ -0,0 +1,381 @@ +/* + * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package de.smederee.hub + +import java.io.IOException +import java.net.ServerSocket +import java.nio.file._ +import java.nio.file.attribute.BasicFileAttributes + +import cats.effect._ +import cats.syntax.all._ +import com.comcast.ip4s._ +import com.typesafe.config.ConfigFactory +import de.smederee.email.EmailAddress +import de.smederee.hub.config._ +import de.smederee.i18n.LanguageCode +import de.smederee.security._ +import org.flywaydb.core.Flyway +import pureconfig._ + +import munit._ + +import scala.annotation.nowarn + +/** Base class for our integration test suites. + * + * It loads and provides the configuration as a resource suit fixture (loaded once for all tests within a suite) and + * does initialise the test database for each suite. The latter means a possibly existing database with the name + * configured **will be deleted**! + */ +abstract class BaseSpec extends CatsEffectSuite { + + protected final val configuration: SmedereeHubConfig = + ConfigSource + .fromConfig(ConfigFactory.load(getClass.getClassLoader)) + .at(SmedereeHubConfig.location) + .loadOrThrow[SmedereeHubConfig] + + protected final val flyway: Flyway = + DatabaseMigrator + .configureFlyway(configuration.database.url, configuration.database.user, configuration.database.pass) + .cleanDisabled(false) + .load() + + /** Connect to the DBMS using the generic "template1" database which should always be present. + * + * @param dbConfig + * The database configuration. + * @return + * The connection to the database ("template1"). + */ + private def connect(dbConfig: DatabaseConfig): IO[java.sql.Connection] = + for { + _ <- IO(Class.forName(dbConfig.driver)) + database <- IO(dbConfig.url.split("/").reverse.take(1).mkString) + connection <- IO( + java.sql.DriverManager + .getConnection(dbConfig.url.replace(database, "template1"), dbConfig.user, dbConfig.pass) + ) + } yield connection + + @nowarn("msg=discarded non-Unit value.*") + override def beforeAll(): Unit = { + // Extract the database name from the URL. + val database = configuration.database.url.split("/").reverse.take(1).mkString + val db = Resource.make(connect(configuration.database))(con => IO(con.close())) + // Create the test database if it does not already exist. + db.use { connection => + for { + statement <- IO(connection.createStatement()) + exists <- IO( + statement.executeQuery( + s"""SELECT datname FROM pg_catalog.pg_database WHERE datname = '$database'""" + ) + ) + _ <- IO { + if (!exists.next()) + statement.execute(s"""CREATE DATABASE "$database"""") + } + _ <- IO(exists.close) + _ <- IO(statement.close) + } yield () + }.unsafeRunSync() + } + + override def beforeEach(context: BeforeEach): Unit = { + val _ = flyway.migrate() + val _ = flyway.clean() + val _ = flyway.migrate() + } + + override def afterEach(context: AfterEach): Unit = { + val _ = flyway.migrate() + val _ = flyway.clean() + } + + /** Find and return a free port on the local machine by starting a server socket and closing it. The port number used + * by the socket is marked to allow reuse, considered free and returned. + * + * @return + * An optional port number if a free one can be found. + */ + protected def findFreePort(): Option[Port] = { + val socket = new ServerSocket(0) + val port = socket.getLocalPort + socket.setReuseAddress(true) // Allow instant rebinding of the socket. + socket.close() // Free the socket for further use by closing it. + Port.fromInt(port) + } + + /** Provide a resource with a database connection to allow db operations and proper resource release later. + * + * @param cfg + * The application configuration. + * @return + * A cats resource encapsulation a database connection as defined within the given configuration. + */ + protected def connectToDb(cfg: SmedereeHubConfig): Resource[IO, java.sql.Connection] = + Resource.make( + IO.delay(java.sql.DriverManager.getConnection(cfg.database.url, cfg.database.user, cfg.database.pass)) + )(c => IO.delay(c.close())) + + /** Create the given account in the database. + * + * @param account + * The account to be created. + * @param hash + * A password hash to be stored. + * @param unlockToken + * An optional unlock token to be stored. + * @param attempts + * Optional number of failed login attempts. + * @param validationToken + * An optional validation token to be stored. + * @return + * The number of affected database rows. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + @SuppressWarnings(Array("DisableSyntax.defaultArgs")) + protected def createAccount( + account: Account, + hash: PasswordHash, + unlockToken: Option[UnlockToken] = None, + attempts: Option[Int] = None, + validationToken: Option[ValidationToken] = None + ): IO[Int] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay { + (unlockToken, validationToken) match { + case (None, None) => + con.prepareStatement( + """INSERT INTO "hub"."accounts" (uid, name, email, password, failed_attempts, created_at, updated_at, validated_email) VALUES(?, ?, ?, ?, ?, NOW(), NOW(), ?)""" + ) + case (Some(_), None) => + con.prepareStatement( + """INSERT INTO "hub"."accounts" (uid, name, email, password, failed_attempts, validated_email, locked_at, unlock_token, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, NOW(), ?, NOW(), NOW())""" + ) + case (None, Some(_)) => + con.prepareStatement( + """INSERT INTO "hub"."accounts" (uid, name, email, password, failed_attempts, validated_email, locked_at, validation_token, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, NOW(), ?, NOW(), NOW())""" + ) + case (Some(_), Some(_)) => + con.prepareStatement( + """INSERT INTO "hub"."accounts" (uid, name, email, password, failed_attempts, validated_email, locked_at, unlock_token, validation_token, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, NOW(), ?, NOW(), NOW())""" + ) + } + } + _ <- IO.delay(statement.setObject(1, account.uid)) + _ <- IO.delay(statement.setString(2, account.name.toString)) + _ <- IO.delay(statement.setString(3, account.email.toString)) + _ <- IO.delay(statement.setString(4, hash.toString)) + _ <- IO.delay(statement.setInt(5, attempts.getOrElse(1))) + _ <- IO.delay(statement.setBoolean(6, account.validatedEmail)) + _ <- (unlockToken, validationToken) match { + case (None, None) => IO.unit + case (Some(ut), None) => IO.delay(statement.setString(7, ut.toString)) + case (None, Some(vt)) => IO.delay(statement.setString(7, vt.toString)) + case (Some(ut), Some(vt)) => + IO.delay { + statement.setString(7, ut.toString) + statement.setString(8, vt.toString) + } + } + _ <- IO.delay { + unlockToken.foreach { token => + statement.setString(7, token.toString) + } + } + r <- IO.delay(statement.executeUpdate()) + _ <- IO.delay(statement.close()) + } yield r + } + + /** Create the given user session in the database. + * + * @param session + * The session that shall be created. The corresponding user account must exist! + * @return + * The number of affected database rows. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + protected def createUserSession(session: Session): IO[Int] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay( + con.prepareStatement( + """INSERT INTO "hub"."sessions" (id, uid, created_at, updated_at) VALUES(?, ?, ?, ?)""" + ) + ) + _ <- IO.delay(statement.setString(1, session.id.toString)) + _ <- IO.delay(statement.setObject(2, session.uid)) + _ <- IO.delay( + statement.setTimestamp(3, java.sql.Timestamp.from(session.createdAt.toInstant)) + ) + _ <- IO.delay( + statement.setTimestamp(4, java.sql.Timestamp.from(session.updatedAt.toInstant)) + ) + r <- IO.delay(statement.executeUpdate()) + _ <- IO.delay(statement.close()) + } yield r + } + + /** Load the account with the given uid from the database. + * + * @param uid + * The unique identifier for the account. + * @return + * An option to the account if it exists. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + @SuppressWarnings(Array("DisableSyntax.null")) + protected def loadAccount(uid: UserId): IO[Option[Account]] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay( + con.prepareStatement( + """SELECT uid, name, email, password, created_at, updated_at, validated_email, language FROM "hub"."accounts" WHERE uid = ? LIMIT 1""" + ) + ) + _ <- IO.delay(statement.setObject(1, uid)) + result <- IO.delay(statement.executeQuery) + account <- IO.delay { + if (result.next()) { + val language = + if (result.getString("language") =!= null) + LanguageCode.from(result.getString("language")) + else + None + Option( + Account( + uid = uid, + name = Username(result.getString("name")), + email = EmailAddress(result.getString("email")), + validatedEmail = result.getBoolean("validated_email"), + language = language + ) + ) + } else { + None + } + } + _ <- IO(statement.close()) + } yield account + } + + /** Load the validation related columns for the account with the given unique user id. + * + * @param uid + * The unique identifier for the account. + * @return + * An option of the columns if it exists. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + protected def loadValidationColumns(uid: UserId): IO[Option[(Boolean, Option[ValidationToken])]] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay( + con.prepareStatement( + """SELECT validated_email, validation_token FROM "hub"."accounts" WHERE uid = ? LIMIT 1""" + ) + ) + _ <- IO.delay(statement.setObject(1, uid)) + result <- IO.delay(statement.executeQuery) + columns <- IO.delay { + if (result.next()) { + Option( + ( + result.getBoolean("validated_email"), + ValidationToken + .from(result.getString("validation_token")) + ) + ) + } else { + None + } + } + _ <- IO.delay(statement.close()) + } yield columns + } + + /** Find the repository ID for the given owner and repository name. + * + * @param owner + * The unique ID of the user account that owns the repository. + * @param name + * The repository name which must be unique in regard to the owner. + * @return + * An option to the internal database ID. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + protected def loadVcsRepositoryId(owner: UserId, name: VcsRepositoryName): IO[Option[VcsRepositoryId]] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay( + con.prepareStatement( + """SELECT id FROM "hub"."repositories" WHERE owner = ? AND name = ? LIMIT 1""" + ) + ) + _ <- IO.delay(statement.setObject(1, owner)) + _ <- IO.delay(statement.setString(2, name.toString)) + result <- IO.delay(statement.executeQuery) + account <- IO.delay { + if (result.next()) { + VcsRepositoryId.from(result.getLong("id")) + } else { + None + } + } + _ <- IO(statement.close()) + } yield account + } + + /** Delete the given directory recursively. + * + * @param path + * The path on the filesystem to the directory that shall be deleted. + * @return + * `true` if the directory was deleted. + */ + protected def deleteDirectory(path: Path): IO[Boolean] = + IO.delay { + if (path.toString.trim =!= "/") { + Files.walkFileTree( + path, + new FileVisitor[Path] { + override def visitFileFailed(file: Path, exc: IOException): FileVisitResult = FileVisitResult.CONTINUE + + override def visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult = { + Files.delete(file) + FileVisitResult.CONTINUE + } + + override def preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult = + FileVisitResult.CONTINUE + + override def postVisitDirectory(dir: Path, exc: IOException): FileVisitResult = { + Files.delete(dir) + FileVisitResult.CONTINUE + } + } + ) + Files.deleteIfExists(path) + } else false + } +} diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/DatabaseMigratorTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/DatabaseMigratorTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/hub/DatabaseMigratorTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/DatabaseMigratorTest.scala 2025-01-16 05:02:15.367735375 +0000 @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package de.smederee.hub + +import cats.effect._ +import cats.syntax.all._ +import de.smederee.TestTags._ + +final class DatabaseMigratorTest extends BaseSpec { + override def beforeEach(context: BeforeEach): Unit = { + val _ = flyway.migrate() + val _ = flyway.clean() + } + + override def afterEach(context: AfterEach): Unit = { + val _ = flyway.migrate() + val _ = flyway.clean() + } + + test("DatabaseMigrator must update available outdated database".tag(NeedsDatabase)) { + val dbConfig = configuration.database + val migrator = new DatabaseMigrator[IO] + val test = migrator.migrate(dbConfig.url, dbConfig.user, dbConfig.pass) + test.map(result => assert(result.migrationsExecuted > 0)) + } + + test("DatabaseMigrator must not update an up to date database".tag(NeedsDatabase)) { + val dbConfig = configuration.database + val migrator = new DatabaseMigrator[IO] + val test = for { + _ <- migrator.migrate(dbConfig.url, dbConfig.user, dbConfig.pass) + r <- migrator.migrate(dbConfig.url, dbConfig.user, dbConfig.pass) + } yield r + test.map(result => assert(result.migrationsExecuted === 0)) + } + + test("DatabaseMigrator must throw an exception if the database is not available".tag(NeedsDatabase)) { + val migrator = new DatabaseMigrator[IO] + val test = migrator.migrate("jdbc:nodriver://", "", "") + test.attempt.map(r => assert(r.isLeft)) + } +} diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala 2025-01-16 05:02:15.367735375 +0000 @@ -0,0 +1,242 @@ +/* + * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package de.smederee.hub + +import java.time.{ OffsetDateTime, ZoneOffset } +import java.util.UUID + +import cats.effect._ +import cats.syntax.all._ +import de.smederee.TestTags._ +import de.smederee.hub.Generators._ +import de.smederee.security._ +import de.smederee.ssh._ +import doobie._ + +final class DoobieAccountManagementRepositoryTest extends BaseSpec { + val sshKeyWithComment = ResourceSuiteLocalFixture( + "ssh-key-with-comment", + Resource.make(IO { + val input = scala.io.Source + .fromInputStream( + getClass().getClassLoader().getResourceAsStream("de/smederee/ssh/ssh-key-with-comment.pub"), + "UTF-8" + ) + .getLines() + .mkString + val keyString = SshPublicKeyString(input) + PublicSshKey.from(UUID.randomUUID())(UserId.randomUserId)(OffsetDateTime.now(ZoneOffset.UTC))(keyString) + })(_ => IO.unit) + ) + + val sshKeyWithoutComment = ResourceSuiteLocalFixture( + "ssh-key-without-comment", + Resource.make(IO { + val input = scala.io.Source + .fromInputStream( + getClass().getClassLoader().getResourceAsStream("de/smederee/ssh/ssh-key-without-comment.pub"), + "UTF-8" + ) + .getLines() + .mkString + val keyString = SshPublicKeyString(input) + PublicSshKey.from(UUID.randomUUID())(UserId.randomUserId)(OffsetDateTime.now(ZoneOffset.UTC))(keyString) + })(_ => IO.unit) + ) + + override def munitFixtures = List(sshKeyWithComment, sshKeyWithoutComment) + + test("addSshKey must save the key to the database".tag(NeedsDatabase)) { + (genValidAccount.sample, sshKeyWithComment()) match { + case (Some(account), Some(sshKey)) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAccountManagementRepository[IO](tx) + val attempts = scala.util.Random.nextInt(128) + val hash = PasswordHash("Yet another weak password!") + val test = for { + _ <- createAccount(account, hash, None, Option(attempts)) + w <- repo.addSshKey(sshKey.copy(ownerId = account.uid)) + keys <- repo.listSshKeys(account.uid).compile.toList + } yield (w, keys) + test.map { result => + val (written, keys) = result + assert(written === 1, "No database rows written!") + assert(keys.exists(_.id === sshKey.id), "Key must be in the key list of the user!") + } + case _ => fail("Could not generate data samples!") + } + } + + test("addSshKey must fail if a key with the same fingerprint already exists".tag(NeedsDatabase)) { + (genValidAccount.sample, sshKeyWithoutComment()) match { + case (Some(account), Some(sshKey)) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAccountManagementRepository[IO](tx) + val attempts = scala.util.Random.nextInt(128) + val hash = PasswordHash("Yet another weak password!") + val test = for { + _ <- createAccount(account, hash, None, Option(attempts)) + _ <- repo.addSshKey(sshKey.copy(ownerId = account.uid)) + _ <- repo.addSshKey(sshKey.copy(ownerId = account.uid)) + } yield () + test.attempt.map { result => + assert(result.isLeft, "Writing a key with a duplicate fingerprint must fail!") + } + case _ => fail("Could not generate data samples!") + } + } + + test("deleteAccount must remove the account from the database".tag(NeedsDatabase)) { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(account) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAccountManagementRepository[IO](tx) + val attempts = scala.util.Random.nextInt(128) + val hash = PasswordHash("Yet another weak password!") + val test = for { + _ <- createAccount(account, hash, None, Option(attempts)) + _ <- repo.deleteAccount(account.uid) + o <- loadAccount(account.uid) + } yield o + test.map(result => assert(result === None, "Account not deleted from database!")) + } + } + + test("findByValidationToken must return the matching account".tag(NeedsDatabase)) { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(ac) => + val account = ac.copy(validatedEmail = true) + val token = ValidationToken.generate + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAccountManagementRepository[IO](tx) + val hash = PasswordHash("Yet another weak password!") + val test = for { + _ <- createAccount(account, hash, validationToken = token.some) + o <- repo.findByValidationToken(token) + } yield o + test.map(result => assert(result === Some(account))) + } + } + + test("findPasswordHash must return correct hash".tag(NeedsDatabase)) { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(account) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAccountManagementRepository[IO](tx) + val attempts = scala.util.Random.nextInt(128) + val hash = PasswordHash("Yet another weak password!") + val test = for { + _ <- createAccount(account, hash, None, Option(attempts)) + o <- repo.findPasswordHash(account.uid) + } yield o + test.map(result => assert(result.exists(_ === hash), "Unexpected result from database!")) + } + } + + test("listSshKeys must return all keys for the user".tag(NeedsDatabase)) { + (genValidAccount.sample, sshKeyWithComment(), sshKeyWithoutComment()) match { + case (Some(account), Some(sshKeyA), Some(sshKeyB)) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAccountManagementRepository[IO](tx) + val attempts = scala.util.Random.nextInt(128) + val hash = PasswordHash("Yet another weak password!") + val test = for { + _ <- createAccount(account, hash, None, Option(attempts)) + _ <- repo.addSshKey(sshKeyA.copy(ownerId = account.uid)) + _ <- repo.addSshKey(sshKeyB.copy(ownerId = account.uid)) + keys <- repo.listSshKeys(account.uid).compile.toList + } yield keys + test.map { keys => + assertEquals(keys.length, 2, "Expected 2 keys in the key list!") + assert(keys.exists(_.id === sshKeyA.id), "Key A must be in the key list of the user!") + assert(keys.exists(_.id === sshKeyB.id), "Key B must be in the key list of the user!") + } + case _ => fail("Could not generate data samples!") + } + } + + test("markAsValidated must clear the validation token and set the validated column to true".tag(NeedsDatabase)) { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(ac) => + val account = ac.copy(validatedEmail = true) + val token = ValidationToken.generate + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAccountManagementRepository[IO](tx) + val hash = PasswordHash("Yet another weak password!") + val test = for { + _ <- createAccount(account, hash, validationToken = token.some) + _ <- repo.markAsValidated(account.uid) + cols <- loadValidationColumns(account.uid) + } yield cols + test.map { result => + assert(result === Some((true, None)), "Unexpected result from database!") + } + } + } + + test("setLanguage must set the language".tag(NeedsDatabase)) { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(account) => + val language = genLanguageCode.sample + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAccountManagementRepository[IO](tx) + val hash = PasswordHash("Yet another weak password!") + val test = for { + _ <- createAccount(account, hash) + _ <- repo.setLanguage(account.uid, language) + modifiedAccount <- loadAccount(account.uid) + } yield modifiedAccount + test.map { modifiedAccount => + assert(modifiedAccount.exists(_.language === language), "Written language field does not match!") + } + } + } + + test("setValidationToken must set the validation token".tag(NeedsDatabase)) { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(account) => + val token = ValidationToken.generate + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAccountManagementRepository[IO](tx) + val hash = PasswordHash("Yet another weak password!") + val test = for { + _ <- createAccount(account, hash) + _ <- repo.setValidationToken(account.uid, token) + cols <- loadValidationColumns(account.uid) + } yield cols + test.map { result => + assert(result === Some((account.validatedEmail, Some(token))), "Unexpected result from database!") + } + } + } +} diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala 2025-01-16 05:02:15.367735375 +0000 @@ -0,0 +1,354 @@ +/* + * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package de.smederee.hub + +import cats.effect._ +import cats.syntax.all._ +import de.smederee.TestTags._ +import de.smederee.hub.Generators._ +import de.smederee.security._ +import doobie._ +import org.flywaydb.core.Flyway + +final class DoobieAuthenticationRepositoryTest extends BaseSpec { + override def beforeEach(context: BeforeEach): Unit = { + val dbConfig = configuration.database + val flyway: Flyway = + DatabaseMigrator.configureFlyway(dbConfig.url, dbConfig.user, dbConfig.pass).cleanDisabled(false).load() + val _ = flyway.migrate() + val _ = flyway.clean() + val _ = flyway.migrate() + } + + override def afterEach(context: AfterEach): Unit = { + val dbConfig = configuration.database + val flyway: Flyway = + DatabaseMigrator.configureFlyway(dbConfig.url, dbConfig.user, dbConfig.pass).cleanDisabled(false).load() + val _ = flyway.migrate() + val _ = flyway.clean() + } + + test("createUserSession must create the user session".tag(NeedsDatabase)) { + (genValidSession.sample, genValidAccount.sample) match { + case (Some(s), Some(account)) => + val session = s.copy(uid = account.uid) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAuthenticationRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + w <- repo.createUserSession(session) + o <- repo.findUserSession(session.id) + } yield (w, o) + test.map { result => + val (written, maybeSession) = result + assert(written === 1, "Creating user session must modify one database row!") + assert(clue(maybeSession) === clue(Option(session))) + } + case _ => fail("Could not generate data samples!") + } + } + + test("createUserSession must fail if the user does not exist".tag(NeedsDatabase)) { + genValidSession.sample match { + case Some(session) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAuthenticationRepository[IO](tx) + val test = for { + _ <- repo.createUserSession(session) + _ <- repo.findUserSession(session.id) + } yield () + test.attempt.map(result => assert(result.isLeft)) + case _ => fail("Could not generate data samples!") + } + } + + test("deleteUserSession must delete the session".tag(NeedsDatabase)) { + (genValidSession.sample, genValidAccount.sample) match { + case (Some(s), Some(account)) => + val session = s.copy(uid = account.uid) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAuthenticationRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + _ <- createUserSession(session) + before <- repo.findUserSession(session.id) + deleted <- repo.deleteUserSession(session.id) + after <- repo.findUserSession(session.id) + } yield (before, deleted, after) + test.map { result => + val (before, deleted, after) = result + assert(before.nonEmpty, "Session must exist before deleting it!") + assert(deleted === 1, "Deletion must affect one database row!") + assert(after.isEmpty, "Session must not exist after deletion!") + } + case _ => fail("Could not generate data samples!") + } + } + + test("findAccount must return an existing account".tag(NeedsDatabase)) { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(account) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAuthenticationRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + o <- repo.findAccount(account.uid) + } yield o + test.map { result => + assert(result === Option(account)) + } + } + } + + test("findAccount must not return a locked account".tag(NeedsDatabase)) { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(account) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAuthenticationRepository[IO](tx) + val test = for { + _ <- createAccount( + account, + PasswordHash("I am not a password hash!"), + Option(UnlockToken.generate), + None + ) + o <- repo.findAccount(account.uid) + } yield o + test.map { result => + assert(result.isEmpty, "The function must not return locked accounts!") + } + } + } + + test("findAccountByEmail must return an existing account".tag(NeedsDatabase)) { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(account) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAuthenticationRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + o <- repo.findAccountByEmail(account.email) + } yield o + test.map { result => + assert(result === Option(account)) + } + } + } + + test("findAccountByEmail must not return a locked account".tag(NeedsDatabase)) { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(account) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAuthenticationRepository[IO](tx) + val test = for { + _ <- createAccount( + account, + PasswordHash("I am not a password hash!"), + Option(UnlockToken.generate), + None + ) + o <- repo.findAccountByEmail(account.email) + } yield o + test.map { result => + assert(result.isEmpty, "The function must not return locked accounts!") + } + } + } + + test("findAccountByName must return an existing account".tag(NeedsDatabase)) { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(account) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAuthenticationRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + o <- repo.findAccountByName(account.name) + } yield o + test.map { result => + assert(result === Option(account)) + } + } + } + + test("findAccountByName must not return a locked account".tag(NeedsDatabase)) { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(account) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAuthenticationRepository[IO](tx) + val test = for { + _ <- createAccount( + account, + PasswordHash("I am not a password hash!"), + Option(UnlockToken.generate), + None + ) + o <- repo.findAccountByName(account.name) + } yield o + test.map { result => + assert(result.isEmpty, "The function must not return locked accounts!") + } + } + } + + test("findLockedAccount must return a locked account".tag(NeedsDatabase)) { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(account) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAuthenticationRepository[IO](tx) + val token = UnlockToken.generate + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), Option(token), None) + o <- repo.findLockedAccount(account.name)(token) + } yield o + test.map { result => + assert(result === Option(account)) + } + } + } + + test("findPasswordHashAndAttempts must return correct values".tag(NeedsDatabase)) { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(account) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAuthenticationRepository[IO](tx) + val attempts = scala.util.Random.nextInt(128) + val hash = PasswordHash("Yet another weak password!") + val test = for { + _ <- createAccount(account, hash, None, Option(attempts)) + o <- repo.findPasswordHashAndAttempts(account.uid) + } yield o + test.map { result => + result match { + case Some((readHash, readAttempts)) => + assert(readHash === hash) + assert(readAttempts === attempts) + case _ => fail("Unexpected result from database!") + } + } + } + } + + test("findPasswordHashAndAttempts must not return values for locked accounts".tag(NeedsDatabase)) { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(account) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAuthenticationRepository[IO](tx) + val attempts = scala.util.Random.nextInt(128) + val hash = PasswordHash("Yet another weak password!") + val test = for { + _ <- createAccount(account, hash, Option(UnlockToken.generate), Option(attempts)) + o <- repo.findPasswordHashAndAttempts(account.uid) + } yield o + test.map { result => + assert(result.isEmpty, "The function must not return locked accounts!") + } + } + } + + test("findUserSession must find an existing session".tag(NeedsDatabase)) { + (genValidSession.sample, genValidAccount.sample) match { + case (Some(s), Some(account)) => + val session = s.copy(uid = account.uid) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAuthenticationRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + _ <- createUserSession(session) + o <- repo.findUserSession(session.id) + } yield o + test.map { maybeSession => + assert(clue(maybeSession) === clue(Option(session))) + } + case _ => fail("Could not generate data samples!") + } + } + + test("incrementFailedAttempts must increment failed attempts by 1".tag(NeedsDatabase)) { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(account) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAuthenticationRepository[IO](tx) + val attempts = scala.util.Random.nextInt(128) + val hash = PasswordHash("Yet another weak password!") + val test = for { + _ <- createAccount(account, hash, None, Option(attempts)) + before <- repo.findPasswordHashAndAttempts(account.uid) + _ <- repo.incrementFailedAttempts(account.uid) + after <- repo.findPasswordHashAndAttempts(account.uid) + } yield (before, after) + test.map { result => + result match { + case (Some((_, before)), Some((_, after))) => + assert(after - before === 1, "Attempts must be incremented by one!") + case _ => fail("Unexpected result from database!") + } + } + } + } + + test("lockAccount must lock an account".tag(NeedsDatabase)) { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(account) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAuthenticationRepository[IO](tx) + val token = UnlockToken.generate + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + before <- repo.findAccount(account.uid) + _ <- repo.lockAccount(account.uid)(Option(token)) + after <- repo.findAccount(account.uid) + locked <- repo.findLockedAccount(account.name)(token) + } yield (before, after, locked) + test.map { result => + result match { + case (Some(before), after, Some(locked)) => + assert(after.isEmpty) + assert(before === locked) + case _ => fail("Unexpected result from database!") + } + } + } + } + +} diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieSignupRepositoryTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieSignupRepositoryTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieSignupRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieSignupRepositoryTest.scala 2025-01-16 05:02:15.367735375 +0000 @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package de.smederee.hub + +import cats.effect._ +import cats.syntax.all._ +import de.smederee.TestTags._ +import de.smederee.hub.Generators._ +import de.smederee.security._ +import doobie._ + +final class DoobieSignupRepositoryTest extends BaseSpec { + test("createAccount must create a new account".tag(NeedsDatabase)) { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(account) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieSignupRepository[IO](tx) + val test = for { + w <- repo.createAccount(account, PasswordHash("I am not a password hash!")) + o <- loadAccount(account.uid) + } yield (w, o) + test.map { result => + val (written, loadedAccount) = result + assert(written === 1, "1 database row must have been written!") + assert(loadedAccount === Option(account), "Saved account differs from expected one!") + } + } + } + + test("createAccount must fail if the email already exists".tag(NeedsDatabase)) { + (genValidAccount.sample, genValidAccount.sample) match { + case (Some(a), Some(b)) => + val existingAccount = a + val newAccount = b.copy(email = a.email) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieSignupRepository[IO](tx) + val test = for { + c <- createAccount(existingAccount, PasswordHash("I am not a password hash!"), None, None) + w <- repo.createAccount(newAccount, PasswordHash("I am not a password hash!")) + } yield (c, w) + test.attempt.map { + case Left(error) => + assert( + error.getMessage.contains("accounts_unique_email"), + "Error must be triggered by accounts_unique_email constraint!" + ) + case Right(_) => fail("Creating accounts with already used emails must fail!") + } + case _ => fail("Could not generate data samples!") + } + } + + test("createAccount must fail if the username already exists".tag(NeedsDatabase)) { + (genValidAccount.sample, genValidAccount.sample) match { + case (Some(a), Some(b)) => + val existingAccount = a + val newAccount = b.copy(name = a.name) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieSignupRepository[IO](tx) + val test = for { + c <- createAccount(existingAccount, PasswordHash("I am not a password hash!"), None, None) + w <- repo.createAccount(newAccount, PasswordHash("I am not a password hash!")) + } yield (c, w) + test.attempt.map { + case Left(error) => + assert( + error.getMessage.contains("accounts_unique_name"), + "Error must be triggered by accounts_unique_name constraint!" + ) + case Right(_) => fail("Creating accounts with already used names must fail!") + } + case _ => fail("Could not generate data samples!") + } + } + + test("findEmail must return an existing email".tag(NeedsDatabase)) { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(account) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieSignupRepository[IO](tx) + val test = for { + c <- repo.createAccount(account, PasswordHash("I am not a password hash!")) + e <- repo.findEmail(account.email) + } yield (c, e) + test.map { result => + val (created, email) = result + assert(created === 1, "Test account not created!") + assert(email === Option(account.email), "Expected email not found!") + } + } + } + + test("findUsername must return an existing name".tag(NeedsDatabase)) { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(account) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieSignupRepository[IO](tx) + val test = for { + c <- repo.createAccount(account, PasswordHash("I am not a password hash!")) + n <- repo.findUsername(account.name) + } yield (c, n) + test.map { result => + val (created, name) = result + assert(created === 1, "Test account not created!") + assert(name === Option(account.name), "Expected name not found!") + } + } + } +} diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala 2025-01-16 05:02:15.367735375 +0000 @@ -0,0 +1,544 @@ +/* + * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package de.smederee.hub + +import cats.effect._ +import cats.syntax.all._ +import de.smederee.TestTags._ +import de.smederee.email.EmailAddress +import de.smederee.hub.Generators._ +import de.smederee.hub.VcsMetadataRepositoriesOrdering._ +import de.smederee.security._ +import doobie._ +import org.http4s.implicits._ + +import scala.collection.immutable.Queue + +final class DoobieVcsMetadataRepositoryTest extends BaseSpec { + + /** Find all forks of the original repository with the given ID. + * + * @param originalRepoId + * The unique ID of the original repo from which was forked. + * @return + * A list of ID pairs (original repository id, forked repository id) which may be empty. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + @SuppressWarnings(Array("DisableSyntax.var", "DisableSyntax.while")) + protected def findForks(originalRepoId: VcsRepositoryId): IO[Seq[(VcsRepositoryId, VcsRepositoryId)]] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay( + con.prepareStatement("""SELECT original_repo, forked_repo FROM "hub"."forks" WHERE original_repo = ?""") + ) + _ <- IO.delay(statement.setLong(1, originalRepoId.toLong)) + result <- IO.delay(statement.executeQuery) + forks <- IO.delay { + var queue = Queue.empty[(VcsRepositoryId, VcsRepositoryId)] + while (result.next()) + queue = queue :+ (VcsRepositoryId(result.getLong("original_repo")), VcsRepositoryId( + result.getLong("forked_repo") + )) + queue + } + _ <- IO.delay(statement.close()) + } yield forks.toList + } + + test("createFork must work correctly".tag(NeedsDatabase)) { + (genValidAccounts.suchThat(_.size > 4).sample, genValidVcsRepositories.suchThat(_.size > 4).sample) match { + case (Some(accounts), Some(repositories)) => + val vcsRepositories = accounts.zip(repositories).map { tuple => + val (account, repo) = tuple + repo.copy(owner = account.toVcsRepositoryOwner) + } + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- accounts.traverse(account => + createAccount(account, PasswordHash("I am not a password hash!"), None, None) + ) + written <- vcsRepositories.traverse(repo.createVcsRepository) + original <- vcsRepositories.headOption.traverse(vcsRepository => + loadVcsRepositoryId(vcsRepository.owner.uid, vcsRepository.name) + ) + toFork <- vcsRepositories.drop(1).take(5).traverse { vcsRepository => + loadVcsRepositoryId(vcsRepository.owner.uid, vcsRepository.name) + } + forked <- (original, toFork) match { + case (Some(Some(originalId)), forkIds) => forkIds.flatten.traverse(id => repo.createFork(originalId, id)) + case _ => IO.pure(List.empty) + } + foundForks <- original match { + case Some(Some(originalId)) => findForks(originalId) + case _ => IO.pure(List.empty) + } + } yield (written, forked, foundForks) + test.map { result => + val (written, forked, foundForks) = result + assert(written.sum === vcsRepositories.size, "Test repository data was not written to database!") + assert(forked.sum === vcsRepositories.drop(1).take(5).size, "Number of created forks does not match!") + assert(foundForks.size === vcsRepositories.drop(1).take(5).size, "Number of found forks does not match!") + } + case _ => fail("Could not generate data samples!") + } + } + + test("createVcsRepository must create a repository entry".tag(NeedsDatabase)) { + (genValidAccount.sample, genValidVcsRepository.sample) match { + case (Some(account), Some(repository)) => + val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + written <- repo.createVcsRepository(vcsRepository) + } yield written + test.map { written => + assert(written === 1, "Creating a vcs repository must modify one database row!") + } + case _ => fail("Could not generate data samples!") + } + } + + test("createVcsRepository must fail if the user does not exist".tag(NeedsDatabase)) { + genValidVcsRepository.sample match { + case Some(repository) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + written <- repo.createVcsRepository(repository) + } yield written + test.attempt.map(result => assert(result.isLeft)) + case _ => fail("Could not generate data samples!") + } + } + + test("createVcsRepository must fail if a repository with the same name exists".tag(NeedsDatabase)) { + (genValidAccount.sample, genValidVcsRepository.sample) match { + case (Some(account), Some(repository)) => + val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + _ <- repo.createVcsRepository(vcsRepository) + written <- repo.createVcsRepository(vcsRepository) + } yield written + test.attempt.map(result => assert(result.isLeft)) + case _ => fail("Could not generate data samples!") + } + } + + test("findVcsRepository must return an existing repository".tag(NeedsDatabase)) { + (genValidAccount.sample, genValidVcsRepository.sample) match { + case (Some(account), Some(repository)) => + val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + written <- repo.createVcsRepository(vcsRepository) + foundRepo <- repo.findVcsRepository(vcsRepository.owner, vcsRepository.name) + } yield (written, foundRepo) + test.map { result => + val (written, foundRepo) = result + assert(written === 1, "Test repository data was not written to database!") + foundRepo match { + case None => fail("Repository was not found!") + case Some(repo) => assert(repo === vcsRepository) + } + } + case _ => fail("Could not generate data samples!") + } + } + + test("findVcsRepositoryBranches must return all branches".tag(NeedsDatabase)) { + (genValidAccounts.suchThat(_.size > 4).sample, genValidVcsRepositories.suchThat(_.size > 4).sample) match { + case (Some(accounts), Some(repositories)) => + val vcsRepositories = accounts.zip(repositories).map { tuple => + val (account, repo) = tuple + repo.copy(owner = account.toVcsRepositoryOwner) + } + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- accounts.traverse(account => + createAccount(account, PasswordHash("I am not a password hash!"), None, None) + ) + written <- vcsRepositories.traverse(repo.createVcsRepository) + original <- vcsRepositories.headOption.traverse(vcsRepository => + loadVcsRepositoryId(vcsRepository.owner.uid, vcsRepository.name) + ) + toFork <- vcsRepositories.drop(1).take(5).traverse { vcsRepository => + loadVcsRepositoryId(vcsRepository.owner.uid, vcsRepository.name) + } + forked <- (original, toFork) match { + case (Some(Some(originalId)), forkIds) => forkIds.flatten.traverse(id => repo.createFork(originalId, id)) + case _ => IO.pure(List.empty) + } + foundForks <- original match { + case Some(Some(originalId)) => repo.findVcsRepositoryBranches(originalId).compile.toList + case _ => IO.pure(List.empty) + } + } yield (written, forked, foundForks) + test.map { result => + val (written, forked, foundForks) = result + assert(written.sum === vcsRepositories.size, "Test repository data was not written to database!") + assert(forked.sum === vcsRepositories.drop(1).take(5).size, "Number of created forks does not match!") + assert(foundForks.size === vcsRepositories.drop(1).take(5).size, "Number of found forks does not match!") + foundForks.zip(vcsRepositories.drop(1).take(5)).map { tuple => + val ((ownerName, repoName), repo) = tuple + assertEquals(ownerName, repo.owner.name) + assertEquals(repoName, repo.name) + } + } + case _ => fail("Could not generate data samples!") + } + } + + test("loadVcsRepositoryId must return the id of an existing repository".tag(NeedsDatabase)) { + (genValidAccount.sample, genValidVcsRepository.sample) match { + case (Some(account), Some(repository)) => + val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + writtenRows <- repo.createVcsRepository(vcsRepository) + writtenId <- loadVcsRepositoryId(vcsRepository.owner.uid, vcsRepository.name) + foundId <- repo.findVcsRepositoryId(vcsRepository.owner, vcsRepository.name) + } yield (writtenRows, writtenId, foundId) + test.map { result => + val (written, writtenId, foundId) = result + assert(written === 1, "Test repository data was not written to database!") + assert(writtenId.nonEmpty) + assert(foundId.nonEmpty) + assert(writtenId === foundId) + } + case _ => fail("Could not generate data samples!") + } + } + + test("findVcsRepositoryOwner must return the correct account".tag(NeedsDatabase)) { + genValidAccounts.sample match { + case Some(accounts) => + val expectedOwner = accounts(scala.util.Random.nextInt(accounts.size)).toVcsRepositoryOwner + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + written <- accounts.traverse(account => + createAccount(account, PasswordHash("I am not a password hash!"), None, None) + ) + foundOwner <- repo.findVcsRepositoryOwner(expectedOwner.name) + } yield (written, foundOwner) + test.map { result => + val (written, foundOwner) = result + assert( + written.filter(_ === 1).size === written.size, + "Not all test repository data was not written to database!" + ) + foundOwner match { + case None => fail("Vcs repository owner not found!") + case Some(owner) => assertEquals(owner, expectedOwner) + } + } + case _ => fail("Could not generate data samples!") + } + } + + test("findVcsRepositoryParentFork must return the parent repository if it exists".tag(NeedsDatabase)) { + (genValidAccounts.suchThat(_.size > 4).sample, genValidVcsRepositories.suchThat(_.size > 4).sample) match { + case (Some(accounts), Some(repositories)) => + val vcsRepositories = accounts.zip(repositories).map { tuple => + val (account, repo) = tuple + repo.copy(owner = account.toVcsRepositoryOwner) + } + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- accounts.traverse(account => + createAccount(account, PasswordHash("I am not a password hash!"), None, None) + ) + written <- vcsRepositories.traverse(repo.createVcsRepository) + original <- vcsRepositories.headOption.traverse(vcsRepository => + loadVcsRepositoryId(vcsRepository.owner.uid, vcsRepository.name) + ) + toFork <- vcsRepositories.drop(1).take(5).traverse { vcsRepository => + loadVcsRepositoryId(vcsRepository.owner.uid, vcsRepository.name) + } + forked <- (original, toFork) match { + case (Some(Some(originalId)), forkIds) => forkIds.flatten.traverse(id => repo.createFork(originalId, id)) + case _ => IO.pure(List.empty) + } + foundParents <- vcsRepositories.drop(1).take(5).traverse { vcsRepository => + repo.findVcsRepositoryParentFork(vcsRepository.owner, vcsRepository.name) + } + } yield (written, forked, vcsRepositories.headOption, foundParents) + test.map { result => + val (written, forked, original, foundParents) = result + assert(written.sum === vcsRepositories.size, "Test repository data was not written to database!") + assert(forked.sum === vcsRepositories.drop(1).take(5).size, "Number of created forks does not match!") + assert(foundParents.forall(_ === original), "Parent vcs repository not matching!") + } + case _ => fail("Could not generate data samples!") + } + } + + test("listAllRepositories must return only public repositories for guest users".tag(NeedsDatabase)) { + (genValidAccount.sample, genValidVcsRepositories.sample) match { + case (Some(account), Some(repositories)) => + val vcsRepositories = repositories + val accounts = vcsRepositories.map(repo => + Account( + repo.owner.uid, + repo.owner.name, + EmailAddress(s"${repo.owner.name}@example.com"), + validatedEmail = true, + None + ) + ) + val expectedRepoList = vcsRepositories.filter(_.isPrivate === false) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + _ <- accounts.traverse(account => + createAccount(account, PasswordHash("I am not a password hash!"), None, None) + ) + written <- vcsRepositories.traverse(vcsRepository => repo.createVcsRepository(vcsRepository)) + foundRepos <- repo.listAllRepositories(None)(NameAscending).compile.toList + } yield (written, foundRepos) + test.map { result => + val (written, foundRepos) = result + assert( + written.filter(_ === 1).size === written.size, + "Not all test repository data was written to database!" + ) + assertEquals(foundRepos.size, expectedRepoList.size) + } + + case _ => fail("Could not generate data samples!") + } + } + + test("listAllRepositories must return only public repositories of others for any user".tag(NeedsDatabase)) { + (genValidAccount.sample, genValidVcsRepositories.sample) match { + case (Some(account), Some(repositories)) => + val vcsRepositories = repositories + val accounts = vcsRepositories.map(repo => + Account( + repo.owner.uid, + repo.owner.name, + EmailAddress(s"${repo.owner.name}@example.com"), + validatedEmail = true, + None + ) + ) + val expectedRepoList = vcsRepositories.filter(_.isPrivate === false) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + _ <- accounts.traverse(account => + createAccount(account, PasswordHash("I am not a password hash!"), None, None) + ) + written <- vcsRepositories.traverse(vcsRepository => repo.createVcsRepository(vcsRepository)) + foundRepos <- repo.listAllRepositories(account.some)(NameDescending).compile.toList + } yield (written, foundRepos) + test.map { result => + val (written, foundRepos) = result + assert( + written.filter(_ === 1).size === written.size, + "Not all test repository data was written to database!" + ) + assertEquals(foundRepos.size, expectedRepoList.size) + } + + case _ => fail("Could not generate data samples!") + } + } + + test("listAllRepositories must include all private repositories of the user".tag(NeedsDatabase)) { + (genValidAccount.sample, genValidVcsRepositories.sample) match { + case (Some(account), Some(repositories)) => + val privateRepos = + repositories.filter(_.isPrivate === true).map(_.copy(owner = account.toVcsRepositoryOwner)) + val publicRepos = repositories.filter(_.isPrivate === false) + val vcsRepositories = privateRepos ::: publicRepos + val accounts = publicRepos.map(repo => + Account( + repo.owner.uid, + repo.owner.name, + EmailAddress(s"${repo.owner.name}@example.com"), + validatedEmail = true, + None + ) + ) + val expectedRepoList = vcsRepositories + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + _ <- accounts.traverse(account => + createAccount(account, PasswordHash("I am not a password hash!"), None, None) + ) + written <- vcsRepositories.traverse(vcsRepository => repo.createVcsRepository(vcsRepository)) + foundRepos <- repo.listAllRepositories(account.some)(NameDescending).compile.toList + } yield (written, foundRepos) + test.map { result => + val (written, foundRepos) = result + assert( + written.filter(_ === 1).size === written.size, + "Not all test repository data was written to database!" + ) + assertEquals(foundRepos.size, expectedRepoList.size) + } + + case _ => fail("Could not generate data samples!") + } + } + + test("listRepositories must return only public repositories for guest users".tag(NeedsDatabase)) { + (genValidAccount.sample, genValidVcsRepositories.sample) match { + case (Some(account), Some(repositories)) => + val vcsRepositories = repositories.map(_.copy(owner = account.toVcsRepositoryOwner)) + val expectedRepoList = vcsRepositories.filter(_.isPrivate === false).sortBy(_.name) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + written <- vcsRepositories.traverse(vcsRepository => repo.createVcsRepository(vcsRepository)) + foundRepos <- repo.listRepositories(None)(account.toVcsRepositoryOwner).compile.toList + } yield (written, foundRepos) + test.map { result => + val (written, foundRepos) = result + assert( + written.filter(_ === 1).size === written.size, + "Not all test repository data was written to database!" + ) + assertEquals(foundRepos.size, expectedRepoList.size) + // We sort again because database sorting might differ slightly from code sorting. + assertEquals(foundRepos.sortBy(_.name), expectedRepoList) + } + case _ => fail("Could not generate data samples!") + } + } + + test("listRepositories must return all repositories for the owner".tag(NeedsDatabase)) { + (genValidAccount.sample, genValidVcsRepositories.sample) match { + case (Some(account), Some(repositories)) => + val vcsRepositories = repositories.map(_.copy(owner = account.toVcsRepositoryOwner)) + val expectedRepoList = vcsRepositories.sortBy(_.name) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + written <- vcsRepositories.traverse(vcsRepository => repo.createVcsRepository(vcsRepository)) + foundRepos <- repo.listRepositories(account.some)(account.toVcsRepositoryOwner).compile.toList + } yield (written, foundRepos) + test.map { result => + val (written, foundRepos) = result + assert( + written.filter(_ === 1).size === written.size, + "Not all test repository data was written to database!" + ) + assertEquals(foundRepos.size, expectedRepoList.size) + // We sort again because database sorting might differ slightly from code sorting. + assertEquals(foundRepos.sortBy(_.name), expectedRepoList) + } + case _ => fail("Could not generate data samples!") + } + } + + test("listRepositories must return only public repositories for any user".tag(NeedsDatabase)) { + (genValidAccount.sample, genValidVcsRepositories.sample, genValidAccount.sample) match { + case (Some(account), Some(repositories), Some(otherAccount)) => + val vcsRepositories = repositories.map(_.copy(owner = account.toVcsRepositoryOwner)) + val expectedRepoList = vcsRepositories.filter(_.isPrivate === false).sortBy(_.name) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + written <- vcsRepositories.traverse(vcsRepository => repo.createVcsRepository(vcsRepository)) + foundRepos <- repo.listRepositories(otherAccount.some)(account.toVcsRepositoryOwner).compile.toList + } yield (written, foundRepos) + test.map { result => + val (written, foundRepos) = result + assert( + written.filter(_ === 1).size === written.size, + "Not all test repository data was written to database!" + ) + assertEquals(foundRepos.size, expectedRepoList.size) + // We sort again because database sorting might differ slightly from code sorting. + assertEquals(foundRepos.sortBy(_.name), expectedRepoList) + } + case _ => fail("Could not generate data samples!") + } + } + + test("updateVcsRepository must update all columns correctly".tag(NeedsDatabase)) { + (genValidAccount.sample, genValidVcsRepositories.sample) match { + case (Some(account), Some(repository :: repositories)) => + val vcsRepositories = repository.copy(owner = account.toVcsRepositoryOwner) :: repositories.map( + _.copy(owner = account.toVcsRepositoryOwner) + ) + val updatedRepo = repository + .copy(owner = account.toVcsRepositoryOwner) + .copy( + isPrivate = !repository.isPrivate, + description = Option(VcsRepositoryDescription("I am a description...")), + website = Option(uri"https://updated.example.com") + ) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + written <- vcsRepositories.traverse(vcsRepository => repo.createVcsRepository(vcsRepository)) + updated <- repo.updateVcsRepository(updatedRepo) + persisted <- repo.findVcsRepository(account.toVcsRepositoryOwner, repository.name) + } yield (written, updated, persisted) + test.map { result => + val (written, updated, persisted) = result + assert( + written.filter(_ === 1).size === written.size, + "Not all test repository data was written to database!" + ) + assert(updated === 1, "Repository was not updated in database!") + assert(persisted === Some(updatedRepo)) + } + case _ => fail("Could not generate data samples!") + } + } +} diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/ssh/DoobieSshAuthenticationRepositoryTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/ssh/DoobieSshAuthenticationRepositoryTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/ssh/DoobieSshAuthenticationRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/ssh/DoobieSshAuthenticationRepositoryTest.scala 2025-01-16 05:02:15.367735375 +0000 @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package de.smederee.ssh + +import cats.effect._ +import cats.syntax.all._ +import de.smederee.TestTags._ +import de.smederee.hub.BaseSpec +import de.smederee.hub.Generators._ +import de.smederee.security._ +import doobie._ + +final class DoobieSshAuthenticationRepositoryTest extends BaseSpec { + test("findVcsRepositoryOwner must return the correct owner".tag(NeedsDatabase)) { + genValidAccount.sample match { + case Some(account) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieSshAuthenticationRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + owner <- repo.findVcsRepositoryOwner(account.name) + } yield owner + test.assertEquals(account.toVcsRepositoryOwner.some) + case _ => fail("Could not generate data samples!") + } + } +} diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/ssh/SshServerProviderTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/ssh/SshServerProviderTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/ssh/SshServerProviderTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/ssh/SshServerProviderTest.scala 2025-01-16 05:02:15.367735375 +0000 @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package de.smederee.ssh + +import java.nio.file._ + +import cats.effect._ +import com.comcast.ip4s._ +import de.smederee.hub.BaseSpec +import de.smederee.hub.config._ + +final class SshServerProviderTest extends BaseSpec { + + val repositoriesDirectory = ResourceSuiteLocalFixture( + "repositories-directory", + Resource.make(IO(Files.createTempDirectory("test-repo-dir")))(path => deleteDirectory(path) *> IO.unit) + ) + + val serverKeyFile = ResourceSuiteLocalFixture( + "server-key-file", + Resource.make( + for { + path <- IO(Files.createTempFile("test-server-", ".key")) + _ <- IO(Files.deleteIfExists(path)) + } yield path + )(_ => IO.unit) + ) + + val freePort = ResourceFixture(Resource.make(IO(findFreePort()))(_ => IO.unit)) + + override def munitFixtures = List(repositoriesDirectory, serverKeyFile) + + freePort.test("run() must create and start a server with the given configuration") { port => + port match { + case None => fail("Could not find a free port for testing!") + case Some(portNumber) => + val keyfile = serverKeyFile() + val darcsConfig = DarcsConfiguration(Paths.get("darcs"), DirectoryPath(repositoriesDirectory())) + val sshConfig = SshServerConfiguration( + enabled = true, + genericUser = SshUsername("darcs"), + host = host"localhost", + port = portNumber, + serverKeyFile = keyfile + ) + val provider = new SshServerProvider(darcsConfig, configuration.database, sshConfig) + provider.run().use { server => + assert(server.isStarted(), "Server not started!") + assertEquals(server.getPort(), portNumber.toString.toInt) + assertEquals(server.getHost(), null) // scalafix:ok + IO.unit + } + } + } +} diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/TestTags.scala new-smederee/modules/hub/src/test/scala/de/smederee/TestTags.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/TestTags.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/TestTags.scala 2025-01-16 05:02:15.367735375 +0000 @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package de.smederee + +/** A collection of tags that can be used to label tests which have certain requirements for example a database + * connection. + */ +object TestTags { + val NeedsDatabase = new munit.Tag("NeedsDatabase") +} diff -rN -u old-smederee/modules/tickets/src/it/resources/application.conf new-smederee/modules/tickets/src/it/resources/application.conf --- old-smederee/modules/tickets/src/it/resources/application.conf 2025-01-16 05:02:15.363735377 +0000 +++ new-smederee/modules/tickets/src/it/resources/application.conf 1970-01-01 00:00:00.000000000 +0000 @@ -1,12 +0,0 @@ -tickets { - database { - host = localhost - host = ${?SMEDEREE_DB_HOST} - url = "jdbc:postgresql://"${tickets.database.host}":5432/smederee_tickets_it" - url = ${?SMEDEREE_TICKETS_TEST_DB_URL} - user = "smederee_tickets" - user = ${?SMEDEREE_TICKETS_TEST_DB_USER} - pass = "secret" - pass = ${?SMEDEREE_TICKETS_TEST_DB_PASS} - } -} diff -rN -u old-smederee/modules/tickets/src/it/resources/logback-test.xml new-smederee/modules/tickets/src/it/resources/logback-test.xml --- old-smederee/modules/tickets/src/it/resources/logback-test.xml 2025-01-16 05:02:15.363735377 +0000 +++ new-smederee/modules/tickets/src/it/resources/logback-test.xml 1970-01-01 00:00:00.000000000 +0000 @@ -1,29 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<configuration debug="false"> - <appender name="console" class="ch.qos.logback.core.ConsoleAppender"> - <filter class="ch.qos.logback.classic.filter.ThresholdFilter"> - <level>WARN</level> - </filter> - <encoder> - <pattern>%date %highlight(%-5level) %cyan(%logger{0}) - %msg%n</pattern> - </encoder> - </appender> - - <appender name="async-console" class="ch.qos.logback.classic.AsyncAppender"> - <appender-ref ref="console"/> - <queueSize>5000</queueSize> - <discardingThreshold>0</discardingThreshold> - </appender> - - <logger name="de.smederee.tickets" level="DEBUG" additivity="false"> - <appender-ref ref="async-console"/> - </logger> - - <logger name="org.flywaydb.core" level="ERROR" additivity="false"> - <appender-ref ref="async-console"/> - </logger> - - <root> - <appender-ref ref="async-console"/> - </root> -</configuration> diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala --- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala 2025-01-16 05:02:15.363735377 +0000 +++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,364 +0,0 @@ -/* - * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -package de.smederee.tickets - -import java.net.ServerSocket - -import cats.effect._ -import cats.syntax.all._ -import com.comcast.ip4s._ -import com.typesafe.config.ConfigFactory -import de.smederee.email.EmailAddress -import de.smederee.i18n.LanguageCode -import de.smederee.security.{ UserId, Username } -import de.smederee.tickets.config._ -import org.flywaydb.core.Flyway -import pureconfig._ - -import munit._ - -import scala.annotation.nowarn - -/** Base class for our integration test suites. - * - * It loads and provides the configuration as a resource suit fixture (loaded once for all tests within a suite) and - * does initialise the test database for each suite. The latter means a possibly existing database with the name - * configured **will be deleted**! - */ -abstract class BaseSpec extends CatsEffectSuite { - protected final val configuration: SmedereeTicketsConfiguration = - ConfigSource - .fromConfig(ConfigFactory.load(getClass.getClassLoader)) - .at(SmedereeTicketsConfiguration.location) - .loadOrThrow[SmedereeTicketsConfiguration] - - protected final val flyway: Flyway = - DatabaseMigrator - .configureFlyway(configuration.database.url, configuration.database.user, configuration.database.pass) - .cleanDisabled(false) - .load() - - /** Connect to the DBMS using the generic "template1" database which should always be present. - * - * @param dbConfig - * The database configuration. - * @return - * The connection to the database ("template1"). - */ - private def connect(dbConfig: DatabaseConfig): IO[java.sql.Connection] = - for { - _ <- IO(Class.forName(dbConfig.driver)) - database <- IO(dbConfig.url.split("/").reverse.take(1).mkString) - connection <- IO( - java.sql.DriverManager - .getConnection(dbConfig.url.replace(database, "template1"), dbConfig.user, dbConfig.pass) - ) - } yield connection - - @nowarn("msg=discarded non-Unit value.*") - override def beforeAll(): Unit = { - // Extract the database name from the URL. - val database = configuration.database.url.split("/").reverse.take(1).mkString - val db = Resource.make(connect(configuration.database))(con => IO(con.close())) - // Create the test database if it does not already exist. - db.use { connection => - for { - statement <- IO(connection.createStatement()) - exists <- IO( - statement.executeQuery( - s"""SELECT datname FROM pg_catalog.pg_database WHERE datname = '$database'""" - ) - ) - _ <- IO { - if (!exists.next()) - statement.execute(s"""CREATE DATABASE "$database"""") - } - _ <- IO(exists.close) - _ <- IO(statement.close) - } yield () - }.unsafeRunSync() - } - - override def afterAll(): Unit = { - // Extract the database name from the URL. - val database = configuration.database.url.split("/").reverse.take(1).mkString - val db = Resource.make(connect(configuration.database))(con => IO(con.close())) - // Drop the test database after all tests have been run. - db.use { connection => - for { - statement <- IO(connection.createStatement()) - _ <- IO(statement.execute(s"""DROP DATABASE IF EXISTS $database""")) - _ <- IO(statement.close) - } yield () - }.unsafeRunSync() - } - - override def beforeEach(context: BeforeEach): Unit = { - val _ = flyway.migrate() - } - - override def afterEach(context: AfterEach): Unit = { - val _ = flyway.clean() - } - - /** Find and return a free port on the local machine by starting a server socket and closing it. The port number used - * by the socket is marked to allow reuse, considered free and returned. - * - * @return - * An optional port number if a free one can be found. - */ - protected def findFreePort(): Option[Port] = { - val socket = new ServerSocket(0) - val port = socket.getLocalPort - socket.setReuseAddress(true) // Allow instant rebinding of the socket. - socket.close() // Free the socket for further use by closing it. - Port.fromInt(port) - } - - /** Provide a resource with a database connection to allow db operations and proper resource release later. - * - * @param cfg - * The application configuration. - * @return - * A cats resource encapsulation a database connection as defined within the given configuration. - */ - protected def connectToDb(cfg: SmedereeTicketsConfiguration): Resource[IO, java.sql.Connection] = - Resource.make( - IO.delay(java.sql.DriverManager.getConnection(cfg.database.url, cfg.database.user, cfg.database.pass)) - )(c => IO.delay(c.close())) - - /** Create a project for ticket tracking in the database. - * - * @param project - * The project to be created. - * @return - * The number of affected database rows. - */ - @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") - protected def createTicketsProject(project: Project): IO[Int] = - connectToDb(configuration).use { con => - for { - statement <- IO.delay { - con.prepareStatement( - """INSERT INTO "tickets"."projects" (name, owner, is_private, description, created_at, updated_at) VALUES(?, ?, ?, ?, NOW(), NOW())""" - ) - } - _ <- IO.delay(statement.setString(1, project.name.toString)) - _ <- IO.delay(statement.setObject(2, project.owner.uid)) - _ <- IO.delay(statement.setBoolean(3, project.isPrivate)) - _ <- IO.delay( - project.description.fold(statement.setNull(4, java.sql.Types.VARCHAR))(descr => - statement.setString(4, descr.toString) - ) - ) - r <- IO.delay(statement.executeUpdate()) - _ <- IO.delay(statement.close()) - } yield r - } - - /** Create a user account from a ticket submitter in the database. - * - * @param submitter - * The submitter for which the account shall be created. - * @return - * The number of affected database rows. - */ - @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") - protected def createTicketsSubmitter(submitter: Submitter): IO[Int] = - connectToDb(configuration).use { con => - for { - statement <- IO.delay { - con.prepareStatement( - """INSERT INTO "tickets"."users" (uid, name, email, created_at, updated_at) VALUES(?, ?, ?, NOW(), NOW())""" - ) - } - _ <- IO.delay(statement.setObject(1, submitter.id)) - _ <- IO.delay(statement.setString(2, submitter.name.toString)) - _ <- IO.delay(statement.setString(3, s"email-${submitter.id.toString}@example.com")) - r <- IO.delay(statement.executeUpdate()) - _ <- IO.delay(statement.close()) - } yield r - } - - /** Create a tickets user account in the database. - * - * @param owner - * The user to be created. - * @return - * The number of affected database rows. - */ - @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") - protected def createProjectOwner(owner: ProjectOwner): IO[Int] = - connectToDb(configuration).use { con => - for { - statement <- IO.delay { - con.prepareStatement( - """INSERT INTO "tickets"."users" (uid, name, email, created_at, updated_at) VALUES(?, ?, ?, NOW(), NOW())""" - ) - } - _ <- IO.delay(statement.setObject(1, owner.uid)) - _ <- IO.delay(statement.setString(2, owner.name.toString)) - _ <- IO.delay(statement.setString(3, owner.email.toString)) - r <- IO.delay(statement.executeUpdate()) - _ <- IO.delay(statement.close()) - } yield r - } - - /** Create a tickets user account in the database. - * - * @param user - * The user to be created. - * @return - * The number of affected database rows. - */ - @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") - protected def createTicketsUser(user: TicketsUser): IO[Int] = - connectToDb(configuration).use { con => - for { - statement <- IO.delay { - con.prepareStatement( - """INSERT INTO "tickets"."users" (uid, name, email, created_at, updated_at) VALUES(?, ?, ?, NOW(), NOW())""" - ) - } - _ <- IO.delay(statement.setObject(1, user.uid)) - _ <- IO.delay(statement.setString(2, user.name.toString)) - _ <- IO.delay(statement.setString(3, user.email.toString)) - r <- IO.delay(statement.executeUpdate()) - _ <- IO.delay(statement.close()) - } yield r - } - - /** Return the next ticket number for the given project. - * - * @param projectId - * The internal database ID of the project. - * @return - * The next ticket number. - */ - @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") - protected def loadNextTicketNumber(projectId: ProjectId): IO[Int] = - connectToDb(configuration).use { con => - for { - statement <- IO.delay( - con.prepareStatement( - """SELECT next_ticket_number FROM "tickets"."projects" WHERE id = ?""" - ) - ) - _ <- IO.delay(statement.setLong(1, projectId.toLong)) - result <- IO.delay(statement.executeQuery) - number <- IO.delay { - result.next() - result.getInt("next_ticket_number") - } - _ <- IO(statement.close()) - } yield number - } - - /** Find the project ID for the given owner and project name. - * - * @param owner - * The unique ID of the user account that owns the project. - * @param name - * The project name which must be unique in regard to the owner. - * @return - * An option to the internal database ID. - */ - @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") - protected def loadProjectId(owner: ProjectOwnerId, name: ProjectName): IO[Option[ProjectId]] = - connectToDb(configuration).use { con => - for { - statement <- IO.delay( - con.prepareStatement( - """SELECT id FROM "tickets"."projects" WHERE owner = ? AND name = ? LIMIT 1""" - ) - ) - _ <- IO.delay(statement.setObject(1, owner)) - _ <- IO.delay(statement.setString(2, name.toString)) - result <- IO.delay(statement.executeQuery) - projectId <- IO.delay { - if (result.next()) { - ProjectId.from(result.getLong("id")) - } else { - None - } - } - _ <- IO(statement.close()) - } yield projectId - } - - /** Find the ticket ID for the given project ID and ticket number. - * - * @param projectId - * The unique internal project id. - * @param ticketNumber - * The ticket number. - * @return - * An option to the internal database ID of the ticket. - */ - @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") - protected def loadTicketId(project: ProjectId, number: TicketNumber): IO[Option[TicketId]] = - connectToDb(configuration).use { con => - for { - statement <- IO.delay( - con.prepareStatement( - """SELECT id FROM "tickets"."tickets" WHERE project = ? AND number = ? LIMIT 1""" - ) - ) - _ <- IO.delay(statement.setLong(1, project.toLong)) - _ <- IO.delay(statement.setInt(2, number.toInt)) - result <- IO.delay(statement.executeQuery) - ticketId <- IO.delay { - if (result.next()) { - TicketId.from(result.getLong("id")) - } else { - None - } - } - _ <- IO(statement.close()) - } yield ticketId - } - - /** Find the ticket service user with the given user id. - * - * @param uid - * The unique id of the user account. - * @return - * An option to the loaded user. - */ - @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") - protected def loadTicketsUser(uid: UserId): IO[Option[TicketsUser]] = - connectToDb(configuration).use { con => - for { - statement <- IO.delay( - con.prepareStatement("""SELECT uid, name, email, language FROM "tickets"."users" WHERE uid = ?""") - ) - _ <- IO.delay(statement.setObject(1, uid.toUUID)) - result <- IO.delay(statement.executeQuery()) - user <- IO.delay { - if (result.next()) { - val language = LanguageCode.from(result.getString("language")) - (uid.some, Username.from(result.getString("name")), EmailAddress.from(result.getString("email"))).mapN { - case (uid, name, email) => TicketsUser(uid, name, email, language) - } - } else { - None - } - } - } yield user - } -} diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala --- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala 2025-01-16 05:02:15.363735377 +0000 +++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,65 +0,0 @@ -/* - * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -package de.smederee.tickets.config - -import cats.effect._ -import cats.syntax.all._ -import org.flywaydb.core.Flyway - -import de.smederee.tickets.BaseSpec - -final class DatabaseMigratorTest extends BaseSpec { - override def beforeEach(context: BeforeEach): Unit = { - val dbConfig = configuration.database - val flyway: Flyway = - DatabaseMigrator.configureFlyway(dbConfig.url, dbConfig.user, dbConfig.pass).cleanDisabled(false).load() - val _ = flyway.migrate() - val _ = flyway.clean() - } - - override def afterEach(context: AfterEach): Unit = { - val dbConfig = configuration.database - val flyway: Flyway = - DatabaseMigrator.configureFlyway(dbConfig.url, dbConfig.user, dbConfig.pass).cleanDisabled(false).load() - val _ = flyway.migrate() - val _ = flyway.clean() - } - - test("DatabaseMigrator must update available outdated database") { - val dbConfig = configuration.database - val migrator = new DatabaseMigrator[IO] - val test = migrator.migrate(dbConfig.url, dbConfig.user, dbConfig.pass) - test.map(result => assert(result.migrationsExecuted > 0)) - } - - test("DatabaseMigrator must not update an up to date database") { - val dbConfig = configuration.database - val migrator = new DatabaseMigrator[IO] - val test = for { - _ <- migrator.migrate(dbConfig.url, dbConfig.user, dbConfig.pass) - r <- migrator.migrate(dbConfig.url, dbConfig.user, dbConfig.pass) - } yield r - test.map(result => assert(result.migrationsExecuted === 0)) - } - - test("DatabaseMigrator must throw an exception if the database is not available") { - val migrator = new DatabaseMigrator[IO] - val test = migrator.migrate("jdbc:nodriver://", "", "") - test.attempt.map(r => assert(r.isLeft)) - } -} diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala --- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala 2025-01-16 05:02:15.363735377 +0000 +++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,269 +0,0 @@ -/* - * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -package de.smederee.tickets - -import cats.effect._ -import cats.syntax.all._ -import de.smederee.tickets.Generators._ -import doobie._ - -final class DoobieLabelRepositoryTest extends BaseSpec { - - /** Find the label ID for the given project and label name. - * - * @param owner - * The unique ID of the user account that owns the project. - * @param vcsRepoName - * The project name which must be unique in regard to the owner. - * @param labelName - * The label name which must be unique in the project context. - * @return - * An option to the internal database ID. - */ - @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") - protected def findLabelId(owner: ProjectOwnerId, vcsRepoName: ProjectName, labelName: LabelName): IO[Option[Long]] = - connectToDb(configuration).use { con => - for { - statement <- IO.delay( - con.prepareStatement( - """SELECT "labels".id - |FROM "tickets"."labels" AS "labels" - |JOIN "tickets"."projects" AS "projects" - |ON "labels".project = "projects".id - |WHERE "projects".owner = ? - |AND "projects".name = ? - |AND "labels".name = ?""".stripMargin - ) - ) - _ <- IO.delay(statement.setObject(1, owner)) - _ <- IO.delay(statement.setString(2, vcsRepoName.toString)) - _ <- IO.delay(statement.setString(3, labelName.toString)) - result <- IO.delay(statement.executeQuery) - account <- IO.delay { - if (result.next()) { - Option(result.getLong("id")) - } else { - None - } - } - _ <- IO(statement.close()) - } yield account - } - - test("allLabels must return all labels") { - (genProjectOwner.sample, genProject.sample, genLabels.sample) match { - case (Some(owner), Some(generatedProject), Some(labels)) => - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val labelRepo = new DoobieLabelRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- createTicketsProject(project) - projectId <- loadProjectId(owner.uid, project.name) - createdLabels <- projectId match { - case None => IO.pure(List.empty) - case Some(projectId) => labels.traverse(label => labelRepo.createLabel(projectId)(label)) - } - foundLabels <- projectId match { - case None => IO.pure(List.empty) - case Some(projectId) => labelRepo.allLabels(projectId).compile.toList - } - } yield foundLabels - test.map { foundLabels => - assert(foundLabels.size === labels.size, "Different number of labels!") - foundLabels.sortBy(_.name).zip(labels.sortBy(_.name)).map { (found, expected) => - assertEquals(found.copy(id = expected.id), expected) - } - } - case _ => fail("Could not generate data samples!") - } - } - - test("createLabel must create the label") { - (genProjectOwner.sample, genProject.sample, genLabel.sample) match { - case (Some(owner), Some(generatedProject), Some(label)) => - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val labelRepo = new DoobieLabelRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - createdProjects <- createTicketsProject(project) - projectId <- loadProjectId(owner.uid, project.name) - createdLabels <- projectId.traverse(id => labelRepo.createLabel(id)(label)) - foundLabel <- projectId.traverse(id => labelRepo.findLabel(id)(label.name)) - } yield (createdProjects, projectId, createdLabels, foundLabel) - test.map { tuple => - val (createdProjects, projectId, createdLabels, foundLabel) = tuple - assert(createdProjects === 1, "Test project was not created!") - assert(projectId.nonEmpty, "No project id found!") - assert(createdLabels.exists(_ === 1), "Test label was not created!") - foundLabel.getOrElse(None) match { - case None => fail("Created label not found!") - case Some(foundLabel) => - assert(foundLabel.id.nonEmpty, "Label ID must not be empty!") - assertEquals(foundLabel.name, label.name) - assertEquals(foundLabel.description, label.description) - assertEquals(foundLabel.colour, label.colour) - } - } - case _ => fail("Could not generate data samples!") - } - } - - test("createLabel must fail if the label name already exists") { - (genProjectOwner.sample, genProject.sample, genLabel.sample) match { - case (Some(owner), Some(generatedProject), Some(label)) => - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val labelRepo = new DoobieLabelRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - createdProjects <- createTicketsProject(project) - projectId <- loadProjectId(owner.uid, project.name) - createdLabels <- projectId.traverse(id => labelRepo.createLabel(id)(label)) - _ <- projectId.traverse(id => labelRepo.createLabel(id)(label)) - } yield (createdProjects, projectId, createdLabels) - test.attempt.map(r => assert(r.isLeft, "Creating a label with a duplicate name must fail!")) - case _ => fail("Could not generate data samples!") - } - } - - test("deleteLabel must delete an existing label") { - (genProjectOwner.sample, genProject.sample, genLabel.sample) match { - case (Some(owner), Some(generatedProject), Some(label)) => - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val labelRepo = new DoobieLabelRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - createdProjects <- createTicketsProject(project) - projectId <- loadProjectId(owner.uid, project.name) - createdLabels <- projectId.traverse(id => labelRepo.createLabel(id)(label)) - labelId <- findLabelId(owner.uid, project.name, label.name) - deletedLabels <- labelRepo.deleteLabel(label.copy(id = labelId.flatMap(LabelId.from))) - foundLabel <- projectId.traverse(id => labelRepo.findLabel(id)(label.name)) - } yield (createdProjects, projectId, createdLabels, deletedLabels, foundLabel) - test.map { tuple => - val (createdProjects, projectId, createdLabels, deletedLabels, foundLabel) = tuple - assert(createdProjects === 1, "Test vcs project was not created!") - assert(projectId.nonEmpty, "No vcs project id found!") - assert(createdLabels.exists(_ === 1), "Test label was not created!") - assert(deletedLabels === 1, "Test label was not deleted!") - assert(foundLabel.getOrElse(None).isEmpty, "Test label was not deleted!") - } - case _ => fail("Could not generate data samples!") - } - } - - test("findLabel must find existing labels") { - (genProjectOwner.sample, genProject.sample, genLabels.sample) match { - case (Some(owner), Some(generatedProject), Some(labels)) => - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val expectedLabel = labels(scala.util.Random.nextInt(labels.size)) - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val labelRepo = new DoobieLabelRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - createdProjects <- createTicketsProject(project) - projectId <- loadProjectId(owner.uid, project.name) - createdLabels <- projectId match { - case None => IO.pure(List.empty) - case Some(projectId) => labels.traverse(label => labelRepo.createLabel(projectId)(label)) - } - foundLabel <- projectId.traverse(id => labelRepo.findLabel(id)(expectedLabel.name)) - } yield foundLabel.flatten - test.map { foundLabel => - assertEquals(foundLabel.map(_.copy(id = expectedLabel.id)), Option(expectedLabel)) - } - case _ => fail("Could not generate data samples!") - } - } - - test("updateLabel must update an existing label") { - (genProjectOwner.sample, genProject.sample, genLabel.sample) match { - case (Some(owner), Some(generatedProject), Some(label)) => - val updatedLabel = label.copy( - name = LabelName("updated label"), - description = Option(LabelDescription("I am an updated label description...")), - colour = ColourCode("#abcdef") - ) - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val labelRepo = new DoobieLabelRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - createdProjects <- createTicketsProject(project) - projectId <- loadProjectId(owner.uid, project.name) - createdLabels <- projectId.traverse(id => labelRepo.createLabel(id)(label)) - labelId <- findLabelId(owner.uid, project.name, label.name) - updatedLabels <- labelRepo.updateLabel(updatedLabel.copy(id = labelId.map(LabelId.apply))) - foundLabel <- projectId.traverse(id => labelRepo.findLabel(id)(updatedLabel.name)) - } yield (createdProjects, projectId, createdLabels, updatedLabels, foundLabel.flatten) - test.map { tuple => - val (createdProjects, projectId, createdLabels, updatedLabels, foundLabel) = tuple - assert(createdProjects === 1, "Test vcs project was not created!") - assert(projectId.nonEmpty, "No vcs project id found!") - assert(createdLabels.exists(_ === 1), "Test label was not created!") - assert(updatedLabels === 1, "Test label was not updated!") - assert(foundLabel.nonEmpty, "Updated label not found!") - foundLabel.map { label => - assertEquals(label, updatedLabel.copy(id = label.id)) - } - } - case _ => fail("Could not generate data samples!") - } - } - - test("updateLabel must do nothing if id attribute is empty") { - (genProjectOwner.sample, genProject.sample, genLabel.sample) match { - case (Some(owner), Some(generatedProject), Some(label)) => - val updatedLabel = label.copy( - id = None, - name = LabelName("updated label"), - description = Option(LabelDescription("I am an updated label description...")), - colour = ColourCode("#abcdef") - ) - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val labelRepo = new DoobieLabelRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - createdProjects <- createTicketsProject(project) - projectId <- loadProjectId(owner.uid, project.name) - createdLabels <- projectId.traverse(id => labelRepo.createLabel(id)(label)) - labelId <- findLabelId(owner.uid, project.name, label.name) - updatedLabels <- labelRepo.updateLabel(updatedLabel) - } yield (createdProjects, projectId, createdLabels, updatedLabels) - test.map { tuple => - val (createdProjects, projectId, createdLabels, updatedLabels) = tuple - assert(createdProjects === 1, "Test vcs project was not created!") - assert(projectId.nonEmpty, "No vcs project id found!") - assert(createdLabels.exists(_ === 1), "Test label was not created!") - assert(updatedLabels === 0, "Label with empty id must not be updated!") - } - case _ => fail("Could not generate data samples!") - } - } -} diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala --- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala 2025-01-16 05:02:15.363735377 +0000 +++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,267 +0,0 @@ -/* - * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -package de.smederee.tickets - -import java.time._ - -import cats.effect._ -import cats.syntax.all._ -import de.smederee.tickets.Generators._ -import doobie._ - -final class DoobieMilestoneRepositoryTest extends BaseSpec { - - /** Find the milestone ID for the given repository and milestone title. - * - * @param owner - * The unique ID of the user owner that owns the repository. - * @param projectName - * The project name which must be unique in regard to the owner. - * @param title - * The milestone title which must be unique in the repository context. - * @return - * An option to the internal database ID. - */ - @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") - protected def findMilestoneId( - owner: ProjectOwnerId, - projectName: ProjectName, - title: MilestoneTitle - ): IO[Option[Long]] = - connectToDb(configuration).use { con => - for { - statement <- IO.delay( - con.prepareStatement( - """SELECT "milestones".id FROM "tickets"."milestones" AS "milestones" JOIN "tickets"."projects" AS "projects" ON "milestones".project = "projects".id WHERE "projects".owner = ? AND "projects".name = ? AND "milestones".title = ?""" - ) - ) - _ <- IO.delay(statement.setObject(1, owner)) - _ <- IO.delay(statement.setString(2, projectName.toString)) - _ <- IO.delay(statement.setString(3, title.toString)) - result <- IO.delay(statement.executeQuery) - owner <- IO.delay { - if (result.next()) { - Option(result.getLong("id")) - } else { - None - } - } - _ <- IO(statement.close()) - } yield owner - } - - test("allMilestones must return all milestones") { - (genProjectOwner.sample, genProject.sample, genMilestones.sample) match { - case (Some(owner), Some(generatedProject), Some(milestones)) => - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val milestoneRepo = new DoobieMilestoneRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - createdRepos <- createTicketsProject(project) - repoId <- loadProjectId(owner.uid, project.name) - createdMilestones <- repoId match { - case None => IO.pure(List.empty) - case Some(repoId) => milestones.traverse(milestone => milestoneRepo.createMilestone(repoId)(milestone)) - } - foundMilestones <- repoId match { - case None => IO.pure(List.empty) - case Some(repoId) => milestoneRepo.allMilestones(repoId).compile.toList - } - } yield foundMilestones - test.map { foundMilestones => - assert(foundMilestones.size === milestones.size, "Different number of milestones!") - foundMilestones.sortBy(_.title).zip(milestones.sortBy(_.title)).map { (found, expected) => - assertEquals(found.copy(id = expected.id), expected) - } - } - case _ => fail("Could not generate data samples!") - } - } - - test("createMilestone must create the milestone") { - (genProjectOwner.sample, genProject.sample, genMilestone.sample) match { - case (Some(owner), Some(generatedProject), Some(milestone)) => - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val milestoneRepo = new DoobieMilestoneRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - createdRepos <- createTicketsProject(project) - repoId <- loadProjectId(owner.uid, project.name) - createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone)) - foundMilestone <- repoId.traverse(id => milestoneRepo.findMilestone(id)(milestone.title)) - } yield (createdRepos, repoId, createdMilestones, foundMilestone) - test.map { tuple => - val (createdRepos, repoId, createdMilestones, foundMilestone) = tuple - assert(createdRepos === 1, "Test vcs generatedProject was not created!") - assert(repoId.nonEmpty, "No vcs generatedProject id found!") - assert(createdMilestones.exists(_ === 1), "Test milestone was not created!") - foundMilestone.getOrElse(None) match { - case None => fail("Created milestone not found!") - case Some(foundMilestone) => assertEquals(foundMilestone, milestone.copy(id = foundMilestone.id)) - } - } - case _ => fail("Could not generate data samples!") - } - } - - test("createMilestone must fail if the milestone name already exists") { - (genProjectOwner.sample, genProject.sample, genMilestone.sample) match { - case (Some(owner), Some(generatedProject), Some(milestone)) => - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val milestoneRepo = new DoobieMilestoneRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - createdRepos <- createTicketsProject(project) - repoId <- loadProjectId(owner.uid, project.name) - createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone)) - _ <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone)) - } yield (createdRepos, repoId, createdMilestones) - test.attempt.map(r => assert(r.isLeft, "Creating a milestone with a duplicate name must fail!")) - case _ => fail("Could not generate data samples!") - } - } - - test("deleteMilestone must delete an existing milestone") { - (genProjectOwner.sample, genProject.sample, genMilestone.sample) match { - case (Some(owner), Some(generatedProject), Some(milestone)) => - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val milestoneRepo = new DoobieMilestoneRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - createdRepos <- createTicketsProject(project) - repoId <- loadProjectId(owner.uid, project.name) - createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone)) - milestoneId <- findMilestoneId(owner.uid, project.name, milestone.title) - deletedMilestones <- milestoneRepo.deleteMilestone(milestone.copy(id = milestoneId.flatMap(MilestoneId.from))) - foundMilestone <- repoId.traverse(id => milestoneRepo.findMilestone(id)(milestone.title)) - } yield (createdRepos, repoId, createdMilestones, deletedMilestones, foundMilestone) - test.map { tuple => - val (createdRepos, repoId, createdMilestones, deletedMilestones, foundMilestone) = tuple - assert(createdRepos === 1, "Test vcs generatedProject was not created!") - assert(repoId.nonEmpty, "No vcs generatedProject id found!") - assert(createdMilestones.exists(_ === 1), "Test milestone was not created!") - assert(deletedMilestones === 1, "Test milestone was not deleted!") - assert(foundMilestone.getOrElse(None).isEmpty, "Test milestone was not deleted!") - } - case _ => fail("Could not generate data samples!") - } - } - - test("findMilestone must find existing milestones") { - (genProjectOwner.sample, genProject.sample, genMilestones.sample) match { - case (Some(owner), Some(generatedProject), Some(milestones)) => - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val expectedMilestone = milestones(scala.util.Random.nextInt(milestones.size)) - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val milestoneRepo = new DoobieMilestoneRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - createdRepos <- createTicketsProject(project) - repoId <- loadProjectId(owner.uid, project.name) - createdMilestones <- repoId match { - case None => IO.pure(List.empty) - case Some(repoId) => milestones.traverse(milestone => milestoneRepo.createMilestone(repoId)(milestone)) - } - foundMilestone <- repoId.traverse(id => milestoneRepo.findMilestone(id)(expectedMilestone.title)) - } yield foundMilestone.flatten - test.map { foundMilestone => - assertEquals(foundMilestone.map(_.copy(id = expectedMilestone.id)), Option(expectedMilestone)) - } - case _ => fail("Could not generate data samples!") - } - } - - test("updateMilestone must update an existing milestone") { - (genProjectOwner.sample, genProject.sample, genMilestone.sample) match { - case (Some(owner), Some(generatedProject), Some(milestone)) => - val updatedMilestone = milestone.copy( - title = MilestoneTitle("updated milestone"), - description = Option(MilestoneDescription("I am an updated milestone description...")), - dueDate = Option(LocalDate.of(1879, 3, 14)) - ) - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val milestoneRepo = new DoobieMilestoneRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - createdRepos <- createTicketsProject(project) - repoId <- loadProjectId(owner.uid, project.name) - createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone)) - milestoneId <- findMilestoneId(owner.uid, project.name, milestone.title) - updatedMilestones <- milestoneRepo.updateMilestone( - updatedMilestone.copy(id = milestoneId.map(MilestoneId.apply)) - ) - foundMilestone <- repoId.traverse(id => milestoneRepo.findMilestone(id)(updatedMilestone.title)) - } yield (createdRepos, repoId, createdMilestones, updatedMilestones, foundMilestone.flatten) - test.map { tuple => - val (createdRepos, repoId, createdMilestones, updatedMilestones, foundMilestone) = tuple - assert(createdRepos === 1, "Test vcs generatedProject was not created!") - assert(repoId.nonEmpty, "No vcs generatedProject id found!") - assert(createdMilestones.exists(_ === 1), "Test milestone was not created!") - assert(updatedMilestones === 1, "Test milestone was not updated!") - assert(foundMilestone.nonEmpty, "Updated milestone not found!") - foundMilestone.map { milestone => - assertEquals(milestone, updatedMilestone.copy(id = milestone.id)) - } - } - case _ => fail("Could not generate data samples!") - } - } - - test("updateMilestone must do nothing if id attribute is empty") { - (genProjectOwner.sample, genProject.sample, genMilestone.sample) match { - case (Some(owner), Some(generatedProject), Some(milestone)) => - val updatedMilestone = milestone.copy( - id = None, - title = MilestoneTitle("updated milestone"), - description = Option(MilestoneDescription("I am an updated milestone description...")), - dueDate = Option(LocalDate.of(1879, 3, 14)) - ) - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val milestoneRepo = new DoobieMilestoneRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - createdRepos <- createTicketsProject(project) - repoId <- loadProjectId(owner.uid, project.name) - createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone)) - milestoneId <- findMilestoneId(owner.uid, project.name, milestone.title) - updatedMilestones <- milestoneRepo.updateMilestone(updatedMilestone) - } yield (createdRepos, repoId, createdMilestones, updatedMilestones) - test.map { tuple => - val (createdRepos, repoId, createdMilestones, updatedMilestones) = tuple - assert(createdRepos === 1, "Test vcs generatedProject was not created!") - assert(repoId.nonEmpty, "No vcs generatedProject id found!") - assert(createdMilestones.exists(_ === 1), "Test milestone was not created!") - assert(updatedMilestones === 0, "Milestone with empty id must not be updated!") - } - case _ => fail("Could not generate data samples!") - } - } -} diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala --- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala 2025-01-16 05:02:15.363735377 +0000 +++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,179 +0,0 @@ -/* - * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -package de.smederee.tickets - -import cats.effect._ -import cats.syntax.all._ -import de.smederee.tickets.Generators._ -import doobie._ - -final class DoobieProjectRepositoryTest extends BaseSpec { - test("createProject must create a project") { - (genProjectOwner.sample, genProject.sample) match { - case (Some(owner), Some(generatedProject)) => - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val projectRepo = new DoobieProjectRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- projectRepo.createProject(project) - foundProject <- projectRepo.findProject(owner, project.name) - } yield foundProject - test.map { foundProject => - assertEquals(foundProject, Some(project)) - } - case _ => fail("Could not generate data samples!") - } - } - - test("deleteProject must delete a project") { - (genProjectOwner.sample, genProject.sample) match { - case (Some(owner), Some(generatedProject)) => - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val projectRepo = new DoobieProjectRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- createTicketsProject(project) - deleted <- projectRepo.deleteProject(project) - foundProject <- projectRepo.findProject(owner, project.name) - } yield (deleted, foundProject) - test.map { result => - val (deleted, foundProject) = result - assert(deleted > 0, "Rows not deleted from database!") - assert(foundProject.isEmpty, "Project not deleted from database!") - } - case _ => fail("Could not generate data samples!") - } - } - - test("findProject must return the matching project") { - (genProjectOwner.sample, genProjects.sample) match { - case (Some(owner), Some(generatedProject :: projects)) => - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val projectRepo = new DoobieProjectRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- createTicketsProject(project) - _ <- projects.filterNot(_.name === project.name).traverse(p => createTicketsProject(p.copy(owner = owner))) - foundProject <- projectRepo.findProject(owner, project.name) - } yield foundProject - test.map { foundProject => - assertEquals(foundProject, Some(project)) - } - case _ => fail("Could not generate data samples!") - } - } - - test("findProjectId must return the matching id") { - (genProjectOwner.sample, genProjects.sample) match { - case (Some(owner), Some(generatedProject :: projects)) => - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val projectRepo = new DoobieProjectRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- createTicketsProject(project) - _ <- projects.filterNot(_.name === project.name).traverse(p => createTicketsProject(p.copy(owner = owner))) - foundProjectId <- projectRepo.findProjectId(owner, project.name) - projectId <- loadProjectId(owner.uid, project.name) - } yield (foundProjectId, projectId) - test.map { result => - val (foundProjectId, projectId) = result - assertEquals(foundProjectId, projectId) - } - case _ => fail("Could not generate data samples!") - } - } - - test("findProjectOwner must return the matching project owner") { - genProjectOwners.sample match { - case Some(owner :: owners) => - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val projectRepo = new DoobieProjectRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- owners.filterNot(_.name === owner.name).traverse(createProjectOwner) - foundOwner <- projectRepo.findProjectOwner(owner.name) - } yield foundOwner - test.map { foundOwner => - assert(foundOwner.exists(_ === owner)) - } - case _ => fail("Could not generate data samples!") - } - } - - test("incrementNextTicketNumber must return and increment the old value") { - (genProjectOwner.sample, genProject.sample) match { - case (Some(owner), Some(firstProject)) => - val project = firstProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val projectRepo = new DoobieProjectRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- projectRepo.createProject(project) - projectId <- loadProjectId(owner.uid, project.name) - result <- projectId match { - case None => fail("Project was not created!") - case Some(projectId) => - for { - before <- loadNextTicketNumber(projectId) - number <- projectRepo.incrementNextTicketNumber(projectId) - after <- loadNextTicketNumber(projectId) - } yield (TicketNumber(before), number, TicketNumber(after)) - } - } yield result - test.map { result => - val (before, number, after) = result - assertEquals(before, number) - assertEquals(after, TicketNumber(number.toInt + 1)) - } - case _ => fail("Could not generate data samples!") - } - } - - test("updateProject must update a project") { - (genProjectOwner.sample, genProject.sample, genProject.sample) match { - case (Some(owner), Some(firstProject), Some(secondProject)) => - val project = firstProject.copy(owner = owner) - val updatedProject = project.copy(description = secondProject.description, isPrivate = secondProject.isPrivate) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val projectRepo = new DoobieProjectRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- projectRepo.createProject(project) - written <- projectRepo.updateProject(updatedProject) - foundProject <- projectRepo.findProject(owner, project.name) - } yield (written, foundProject) - test.map { result => - val (written, foundProject) = result - assert(written > 0, "Rows not updated in database!") - assertEquals(foundProject, Some(updatedProject)) - } - case _ => fail("Could not generate data samples!") - } - } -} diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala --- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala 2025-01-16 05:02:15.363735377 +0000 +++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,742 +0,0 @@ -/* - * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -package de.smederee.tickets - -import java.time.OffsetDateTime - -import cats.effect._ -import cats.syntax.all._ -import de.smederee.tickets.Generators._ -import doobie._ - -import scala.collection.immutable.Queue - -final class DoobieTicketRepositoryTest extends BaseSpec { - - /** Return the internal ids of all lables associated with the given ticket number and project id. - * - * @param projectId - * The unique internal project id. - * @param ticketNumber - * The ticket number. - * @return - * A list of label ids that may be empty. - */ - @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") - protected def loadTicketLabelIds(projectId: ProjectId, ticketNumber: TicketNumber): IO[List[LabelId]] = - connectToDb(configuration).use { con => - for { - statement <- IO.delay( - con.prepareStatement( - """SELECT label FROM "tickets"."ticket_labels" AS "ticket_labels" JOIN "tickets"."tickets" ON "ticket_labels".ticket = "tickets".id WHERE "tickets".project = ? AND "tickets".number = ?""" - ) - ) - _ <- IO.delay(statement.setLong(1, projectId.toLong)) - _ <- IO.delay(statement.setInt(2, ticketNumber.toInt)) - result <- IO.delay(statement.executeQuery) - labelIds <- IO.delay { - var queue = Queue.empty[LabelId] - while (result.next()) - queue = queue :+ LabelId(result.getLong("label")) - queue.toList - } - _ <- IO(statement.close()) - } yield labelIds - } - - /** Return the internal ids of all milestones associated with the given ticket number and project id. - * - * @param projectId - * The unique internal project id. - * @param ticketNumber - * The ticket number. - * @return - * A list of milestone ids that may be empty. - */ - @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") - protected def loadTicketMilestoneIds(projectId: ProjectId, ticketNumber: TicketNumber): IO[List[MilestoneId]] = - connectToDb(configuration).use { con => - for { - statement <- IO.delay( - con.prepareStatement( - """SELECT milestone FROM "tickets"."milestone_tickets" AS "milestone_tickets" JOIN "tickets"."tickets" ON "milestone_tickets".ticket = "tickets".id WHERE "tickets".project = ? AND "tickets".number = ?""" - ) - ) - _ <- IO.delay(statement.setLong(1, projectId.toLong)) - _ <- IO.delay(statement.setInt(2, ticketNumber.toInt)) - result <- IO.delay(statement.executeQuery) - milestoneIds <- IO.delay { - var queue = Queue.empty[MilestoneId] - while (result.next()) - queue = queue :+ MilestoneId(result.getLong("milestone")) - queue.toList - } - _ <- IO(statement.close()) - } yield milestoneIds - } - - test("addAssignee must save the assignee relation to the database") { - (genProjectOwner.sample, genProject.sample, genTicket.sample, genTicketsUser.sample) match { - case (Some(owner), Some(generatedProject), Some(ticket), Some(user)) => - val assignee = Assignee(AssigneeId(user.uid.toUUID), AssigneeName(user.name.toString)) - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val ticketRepo = new DoobieTicketRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- createTicketsUser(user) - _ <- createTicketsProject(project) - projectId <- loadProjectId(owner.uid, project.name) - _ <- ticket.submitter.traverse(createTicketsSubmitter) - _ <- projectId.traverse(projectId => ticketRepo.createTicket(projectId)(ticket)) - _ <- projectId.traverse(projectId => ticketRepo.addAssignee(projectId)(ticket.number)(assignee)) - foundAssignees <- projectId.traverse(projectId => - ticketRepo.loadAssignees(projectId)(ticket.number).compile.toList - ) - } yield foundAssignees.getOrElse(Nil) - test.map { foundAssignees => - assertEquals(foundAssignees, List(assignee)) - } - case _ => fail("Could not generate data samples!") - } - } - - test("addLabel must save the label relation to the database") { - (genProjectOwner.sample, genProject.sample, genTicket.sample, genLabel.sample) match { - case (Some(owner), Some(generatedProject), Some(ticket), Some(label)) => - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val labelRepo = new DoobieLabelRepository[IO](tx) - val ticketRepo = new DoobieTicketRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- createTicketsProject(project) - projectId <- loadProjectId(owner.uid, project.name) - result <- projectId match { - case None => fail("Project ID not found in database!") - case Some(projectId) => - for { - _ <- ticket.submitter.traverse(createTicketsSubmitter) - _ <- labelRepo.createLabel(projectId)(label) - createdLabel <- labelRepo.findLabel(projectId)(label.name) - _ <- ticketRepo.createTicket(projectId)(ticket) - _ <- createdLabel.traverse(cl => ticketRepo.addLabel(projectId)(ticket.number)(cl)) - foundLabels <- loadTicketLabelIds(projectId, ticket.number) - } yield (createdLabel, foundLabels) - } - } yield result - test.map { result => - val (createdLabel, foundLabels) = result - assert(createdLabel.nonEmpty, "Test label not created!") - createdLabel.flatMap(_.id) match { - case None => fail("Test label has no ID!") - case Some(labelId) => assert(foundLabels.exists(_ === labelId)) - } - } - case _ => fail("Could not generate data samples!") - } - } - - test("addMilestone must save the milestone relation to the database") { - (genProjectOwner.sample, genProject.sample, genTicket.sample, genMilestone.sample) match { - case (Some(owner), Some(generatedProject), Some(ticket), Some(milestone)) => - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val milestoneRepo = new DoobieMilestoneRepository[IO](tx) - val ticketRepo = new DoobieTicketRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- createTicketsProject(project) - projectId <- loadProjectId(owner.uid, project.name) - result <- projectId match { - case None => fail("Project ID not found in database!") - case Some(projectId) => - for { - _ <- ticket.submitter.traverse(createTicketsSubmitter) - _ <- milestoneRepo.createMilestone(projectId)(milestone) - createdMilestone <- milestoneRepo.findMilestone(projectId)(milestone.title) - _ <- ticketRepo.createTicket(projectId)(ticket) - _ <- createdMilestone.traverse(cl => ticketRepo.addMilestone(projectId)(ticket.number)(cl)) - foundMilestones <- loadTicketMilestoneIds(projectId, ticket.number) - } yield (createdMilestone, foundMilestones) - } - } yield result - test.map { result => - val (createdMilestone, foundMilestones) = result - assert(createdMilestone.nonEmpty, "Test milestone not created!") - createdMilestone.flatMap(_.id) match { - case None => fail("Test milestone has no ID!") - case Some(milestoneId) => assert(foundMilestones.exists(_ === milestoneId)) - } - } - case _ => fail("Could not generate data samples!") - } - } - - test("allTickets must return all tickets for the project") { - (genProjectOwner.sample, genProject.sample, genTickets.sample) match { - case (Some(owner), Some(generatedProject), Some(generatedTickets)) => - val defaultTimestamp = OffsetDateTime.now() - val tickets = - generatedTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)) - val submitters = tickets.map(_.submitter).flatten - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val ticketRepo = new DoobieTicketRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- submitters.traverse(createTicketsSubmitter) - _ <- createTicketsProject(project) - projectId <- loadProjectId(owner.uid, project.name) - result <- projectId match { - case None => IO.pure((0, Nil)) - case Some(projectId) => - for { - writtenTickets <- tickets.traverse(ticket => ticketRepo.createTicket(projectId)(ticket)) - foundTickets <- ticketRepo.allTickets(filter = None)(projectId).compile.toList - } yield (writtenTickets.sum, foundTickets) - } - } yield result - test.map { result => - val (writtenTickets, foundTickets) = result - assertEquals(writtenTickets, tickets.size, "Wrong number of tickets created in the database!") - assertEquals( - foundTickets.size, - writtenTickets, - "Number of returned tickets differs from number of created tickets!" - ) - assertEquals( - foundTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)), - tickets.sortBy(_.number) - ) - } - case _ => fail("Could not generate data samples!") - } - } - - test("allTickets must respect given filters for numbers") { - (genProjectOwner.sample, genProject.sample, genTickets.sample) match { - case (Some(owner), Some(generatedProject), Some(generatedTickets)) => - val defaultTimestamp = OffsetDateTime.now() - val tickets = - generatedTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)) - val expectedTickets = tickets.take(tickets.size / 2) - val filter = TicketFilter(number = expectedTickets.map(_.number), Nil, Nil, Nil) - val submitters = tickets.map(_.submitter).flatten - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val ticketRepo = new DoobieTicketRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- submitters.traverse(createTicketsSubmitter) - _ <- createTicketsProject(project) - projectId <- loadProjectId(owner.uid, project.name) - result <- projectId match { - case None => IO.pure((0, Nil)) - case Some(projectId) => - for { - writtenTickets <- tickets.traverse(ticket => ticketRepo.createTicket(projectId)(ticket)) - foundTickets <- ticketRepo.allTickets(filter.some)(projectId).compile.toList - } yield (writtenTickets.sum, foundTickets) - } - } yield result - test.map { result => - val (writtenTickets, foundTickets) = result - assertEquals(writtenTickets, tickets.size, "Wrong number of tickets created in the database!") - assertEquals( - foundTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)), - expectedTickets.sortBy(_.number) - ) - } - case _ => fail("Could not generate data samples!") - } - } - - test("allTickets must respect given filters for status") { - (genProjectOwner.sample, genProject.sample, genTickets.sample) match { - case (Some(owner), Some(generatedProject), Some(generatedTickets)) => - val defaultTimestamp = OffsetDateTime.now() - val tickets = - generatedTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)) - val statusFlags = tickets.map(_.status).distinct.take(2) - val expectedTickets = tickets.filter(t => statusFlags.exists(_ === t.status)) - val filter = TicketFilter(Nil, status = statusFlags, Nil, Nil) - val submitters = tickets.map(_.submitter).flatten - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val ticketRepo = new DoobieTicketRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- submitters.traverse(createTicketsSubmitter) - _ <- createTicketsProject(project) - projectId <- loadProjectId(owner.uid, project.name) - result <- projectId match { - case None => IO.pure((0, Nil)) - case Some(projectId) => - for { - writtenTickets <- tickets.traverse(ticket => ticketRepo.createTicket(projectId)(ticket)) - foundTickets <- ticketRepo.allTickets(filter.some)(projectId).compile.toList - } yield (writtenTickets.sum, foundTickets) - } - } yield result - test.map { result => - val (writtenTickets, foundTickets) = result - assertEquals(writtenTickets, tickets.size, "Wrong number of tickets created in the database!") - assertEquals( - foundTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)), - expectedTickets.sortBy(_.number) - ) - } - case _ => fail("Could not generate data samples!") - } - } - - test("allTickets must respect given filters for resolution") { - (genProjectOwner.sample, genProject.sample, genTickets.sample) match { - case (Some(owner), Some(generatedProject), Some(generatedTickets)) => - val defaultTimestamp = OffsetDateTime.now() - val tickets = - generatedTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)) - val resolutions = tickets.map(_.resolution).flatten.distinct.take(2) - val expectedTickets = tickets.filter(t => t.resolution.exists(r => resolutions.exists(_ === r))) - val filter = TicketFilter(Nil, Nil, resolution = resolutions, Nil) - val submitters = tickets.map(_.submitter).flatten - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val ticketRepo = new DoobieTicketRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- submitters.traverse(createTicketsSubmitter) - _ <- createTicketsProject(project) - projectId <- loadProjectId(owner.uid, project.name) - result <- projectId match { - case None => IO.pure((0, Nil)) - case Some(projectId) => - for { - writtenTickets <- tickets.traverse(ticket => ticketRepo.createTicket(projectId)(ticket)) - foundTickets <- ticketRepo.allTickets(filter.some)(projectId).compile.toList - } yield (writtenTickets.sum, foundTickets) - } - } yield result - test.map { result => - val (writtenTickets, foundTickets) = result - assertEquals(writtenTickets, tickets.size, "Wrong number of tickets created in the database!") - assertEquals( - foundTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)), - expectedTickets.sortBy(_.number) - ) - } - case _ => fail("Could not generate data samples!") - } - } - - test("allTickets must respect given filters for submitter") { - (genProjectOwner.sample, genProject.sample, genTickets.sample) match { - case (Some(owner), Some(generatedProject), Some(generatedTickets)) => - val defaultTimestamp = OffsetDateTime.now() - val tickets = - generatedTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)) - val submitters = tickets.map(_.submitter).flatten - val wantedSubmitters = submitters.take(submitters.size / 2) - val expectedTickets = tickets.filter(t => t.submitter.exists(s => wantedSubmitters.exists(_ === s))) - val filter = TicketFilter(Nil, Nil, Nil, submitter = wantedSubmitters) - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val ticketRepo = new DoobieTicketRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- submitters.traverse(createTicketsSubmitter) - _ <- createTicketsProject(project) - projectId <- loadProjectId(owner.uid, project.name) - result <- projectId match { - case None => IO.pure((0, Nil)) - case Some(projectId) => - for { - writtenTickets <- tickets.traverse(ticket => ticketRepo.createTicket(projectId)(ticket)) - foundTickets <- ticketRepo.allTickets(filter.some)(projectId).compile.toList - } yield (writtenTickets.sum, foundTickets) - } - } yield result - test.map { result => - val (writtenTickets, foundTickets) = result - assertEquals(writtenTickets, tickets.size, "Wrong number of tickets created in the database!") - assertEquals( - foundTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)), - expectedTickets.sortBy(_.number) - ) - } - case _ => fail("Could not generate data samples!") - } - } - - test("createTicket must save the ticket to the database") { - (genProjectOwner.sample, genProject.sample, genTicket.sample) match { - case (Some(owner), Some(generatedProject), Some(ticket)) => - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val ticketRepo = new DoobieTicketRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- ticket.submitter.traverse(createTicketsSubmitter) - _ <- createTicketsProject(project) - projectId <- loadProjectId(owner.uid, project.name) - _ <- projectId.traverse(projectId => ticketRepo.createTicket(projectId)(ticket)) - foundTicket <- projectId.traverse(projectId => ticketRepo.findTicket(projectId)(ticket.number)) - } yield foundTicket.getOrElse(None) - test.map { foundTicket => - foundTicket match { - case None => fail("Created ticket not found!") - case Some(foundTicket) => - assertEquals( - foundTicket, - ticket.copy(createdAt = foundTicket.createdAt, updatedAt = foundTicket.updatedAt) - ) - } - } - case _ => fail("Could not generate data samples!") - } - } - - test("deleteTicket must remove the ticket from the database") { - (genProjectOwner.sample, genProject.sample, genTicket.sample) match { - case (Some(owner), Some(generatedProject), Some(ticket)) => - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val ticketRepo = new DoobieTicketRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- ticket.submitter.traverse(createTicketsSubmitter) - _ <- createTicketsProject(project) - projectId <- loadProjectId(owner.uid, project.name) - _ <- projectId.traverse(projectId => ticketRepo.createTicket(projectId)(ticket)) - _ <- projectId.traverse(projectId => ticketRepo.deleteTicket(projectId)(ticket)) - foundTicket <- projectId.traverse(projectId => ticketRepo.findTicket(projectId)(ticket.number)) - } yield foundTicket.getOrElse(None) - test.map { foundTicket => - assertEquals(foundTicket, None, "Ticket was not deleted from database!") - } - case _ => fail("Could not generate data samples!") - } - } - - test("findTicket must find existing tickets") { - (genProjectOwner.sample, genProject.sample, genTickets.sample) match { - case (Some(owner), Some(generatedProject), Some(tickets)) => - val expectedTicket = tickets(scala.util.Random.nextInt(tickets.size)) - val submitters = tickets.map(_.submitter).flatten - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val ticketRepo = new DoobieTicketRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- submitters.traverse(createTicketsSubmitter) - _ <- createTicketsProject(project) - projectId <- loadProjectId(owner.uid, project.name) - _ <- projectId match { - case None => IO.pure(Nil) - case Some(projectId) => tickets.traverse(ticket => ticketRepo.createTicket(projectId)(ticket)) - } - foundTicket <- projectId.traverse(projectId => ticketRepo.findTicket(projectId)(expectedTicket.number)) - } yield foundTicket.getOrElse(None) - test.map { foundTicket => - foundTicket match { - case None => fail("Ticket not found!") - case Some(foundTicket) => - assertEquals( - foundTicket, - expectedTicket.copy(createdAt = foundTicket.createdAt, updatedAt = foundTicket.updatedAt) - ) - } - } - case _ => fail("Could not generate data samples!") - } - } - - test("findTicketId must find the unique internal id of existing tickets") { - (genProjectOwner.sample, genProject.sample, genTickets.sample) match { - case (Some(owner), Some(generatedProject), Some(tickets)) => - val expectedTicket = tickets(scala.util.Random.nextInt(tickets.size)) - val submitters = tickets.map(_.submitter).flatten - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val ticketRepo = new DoobieTicketRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- submitters.traverse(createTicketsSubmitter) - _ <- createTicketsProject(project) - projectId <- loadProjectId(owner.uid, project.name) - result <- projectId match { - case None => IO.pure((None, None)) - case Some(projectId) => - for { - _ <- tickets.traverse(ticket => ticketRepo.createTicket(projectId)(ticket)) - expectedTicketId <- loadTicketId(projectId, expectedTicket.number) - foundTicketId <- ticketRepo.findTicketId(projectId)(expectedTicket.number) - } yield (expectedTicketId, foundTicketId) - } - } yield result - test.map { result => - val (expectedTicketId, foundTicketId) = result - assert(expectedTicketId.nonEmpty, "Expected ticket id not found!") - assertEquals(foundTicketId, expectedTicketId) - } - case _ => fail("Could not generate data samples!") - } - } - - test("loadAssignees must return all assignees of a ticket") { - (genProjectOwner.sample, genProject.sample, genTicket.sample, genTicketsUsers.sample) match { - case (Some(owner), Some(generatedProject), Some(ticket), Some(users)) => - val assignees = users.map(user => Assignee(AssigneeId(user.uid.toUUID), AssigneeName(user.name.toString))) - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val ticketRepo = new DoobieTicketRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- users.traverse(createTicketsUser) - _ <- createTicketsProject(project) - projectId <- loadProjectId(owner.uid, project.name) - _ <- ticket.submitter.traverse(createTicketsSubmitter) - foundAssignees <- projectId match { - case None => fail("Project ID not found in database!") - case Some(projectId) => - for { - _ <- ticketRepo.createTicket(projectId)(ticket) - _ <- assignees.traverse(assignee => ticketRepo.addAssignee(projectId)(ticket.number)(assignee)) - foundAssignees <- ticketRepo.loadAssignees(projectId)(ticket.number).compile.toList - } yield foundAssignees - } - } yield foundAssignees - test.map { foundAssignees => - assertEquals(foundAssignees.sortBy(_.name), assignees.sortBy(_.name)) - } - case _ => fail("Could not generate data samples!") - } - } - - test("loadLabels must return all labels of a ticket") { - (genProjectOwner.sample, genProject.sample, genTicket.sample, genLabels.sample) match { - case (Some(owner), Some(generatedProject), Some(ticket), Some(labels)) => - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val labelRepo = new DoobieLabelRepository[IO](tx) - val ticketRepo = new DoobieTicketRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- createTicketsProject(project) - projectId <- loadProjectId(owner.uid, project.name) - result <- projectId match { - case None => fail("Project ID not found in database!") - case Some(projectId) => - for { - _ <- ticket.submitter.traverse(createTicketsSubmitter) - _ <- ticketRepo.createTicket(projectId)(ticket) - _ <- labels.traverse(label => labelRepo.createLabel(projectId)(label)) - createdLabels <- labelRepo.allLabels(projectId).compile.toList - _ <- createdLabels.traverse(cl => ticketRepo.addLabel(projectId)(ticket.number)(cl)) - foundLabels <- ticketRepo.loadLabels(projectId)(ticket.number).compile.toList - } yield (createdLabels, foundLabels) - } - } yield result - test.map { result => - val (createdLabels, foundLabels) = result - assertEquals(foundLabels.sortBy(_.id), createdLabels.sortBy(_.id)) - } - case _ => fail("Could not generate data samples!") - } - } - - test("loadMilestones must return all milestones of a ticket") { - (genProjectOwner.sample, genProject.sample, genTicket.sample, genMilestones.sample) match { - case (Some(owner), Some(generatedProject), Some(ticket), Some(milestones)) => - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val milestoneRepo = new DoobieMilestoneRepository[IO](tx) - val ticketRepo = new DoobieTicketRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- createTicketsProject(project) - projectId <- loadProjectId(owner.uid, project.name) - result <- projectId match { - case None => fail("Project ID not found in database!") - case Some(projectId) => - for { - _ <- ticket.submitter.traverse(createTicketsSubmitter) - _ <- ticketRepo.createTicket(projectId)(ticket) - _ <- milestones.traverse(milestone => milestoneRepo.createMilestone(projectId)(milestone)) - createdMilestones <- milestoneRepo.allMilestones(projectId).compile.toList - _ <- createdMilestones.traverse(cm => ticketRepo.addMilestone(projectId)(ticket.number)(cm)) - foundMilestones <- ticketRepo.loadMilestones(projectId)(ticket.number).compile.toList - } yield (createdMilestones, foundMilestones) - } - } yield result - test.map { result => - val (createdMilestones, foundMilestones) = result - assertEquals(foundMilestones.sortBy(_.title), createdMilestones.sortBy(_.title)) - } - case _ => fail("Could not generate data samples!") - } - } - - test("removeAssignee must remove the assignees from the ticket") { - (genProjectOwner.sample, genProject.sample, genTicket.sample, genTicketsUser.sample) match { - case (Some(owner), Some(generatedProject), Some(ticket), Some(user)) => - val assignee = Assignee(AssigneeId(user.uid.toUUID), AssigneeName(user.name.toString)) - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val ticketRepo = new DoobieTicketRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- createTicketsUser(user) - _ <- createTicketsProject(project) - projectId <- loadProjectId(owner.uid, project.name) - _ <- ticket.submitter.traverse(createTicketsSubmitter) - foundAssignees <- projectId match { - case None => IO.pure(Nil) - case Some(projectId) => - for { - _ <- ticketRepo.createTicket(projectId)(ticket) - _ <- ticketRepo.addAssignee(projectId)(ticket.number)(assignee) - _ <- ticketRepo.removeAssignee(projectId)(ticket)(assignee) - foundAssignees <- ticketRepo.loadAssignees(projectId)(ticket.number).compile.toList - } yield foundAssignees - } - } yield foundAssignees - test.map { foundAssignees => - assertEquals(foundAssignees, Nil) - } - case _ => fail("Could not generate data samples!") - } - } - - test("removeLabel must remove the label from the ticket") { - (genProjectOwner.sample, genProject.sample, genTicket.sample, genLabel.sample) match { - case (Some(owner), Some(generatedProject), Some(ticket), Some(label)) => - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val labelRepo = new DoobieLabelRepository[IO](tx) - val ticketRepo = new DoobieTicketRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- createTicketsProject(project) - projectId <- loadProjectId(owner.uid, project.name) - foundLabels <- projectId match { - case None => fail("Project ID not found in database!") - case Some(projectId) => - for { - _ <- ticket.submitter.traverse(createTicketsSubmitter) - _ <- labelRepo.createLabel(projectId)(label) - createdLabel <- labelRepo.findLabel(projectId)(label.name) - _ <- ticketRepo.createTicket(projectId)(ticket) - _ <- createdLabel.traverse(cl => ticketRepo.addLabel(projectId)(ticket.number)(cl)) - _ <- createdLabel.traverse(cl => ticketRepo.removeLabel(projectId)(ticket)(cl)) - foundLabels <- loadTicketLabelIds(projectId, ticket.number) - } yield foundLabels - } - } yield foundLabels - test.map { foundLabels => - assertEquals(foundLabels, Nil) - } - case _ => fail("Could not generate data samples!") - } - } - - test("removeMilestone must remove the milestone from the ticket") { - (genProjectOwner.sample, genProject.sample, genTicket.sample, genMilestone.sample) match { - case (Some(owner), Some(generatedProject), Some(ticket), Some(milestone)) => - val project = generatedProject.copy(owner = owner) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val milestoneRepo = new DoobieMilestoneRepository[IO](tx) - val ticketRepo = new DoobieTicketRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- createTicketsProject(project) - projectId <- loadProjectId(owner.uid, project.name) - foundMilestones <- projectId match { - case None => fail("Project ID not found in database!") - case Some(projectId) => - for { - _ <- ticket.submitter.traverse(createTicketsSubmitter) - _ <- milestoneRepo.createMilestone(projectId)(milestone) - createdMilestone <- milestoneRepo.findMilestone(projectId)(milestone.title) - _ <- ticketRepo.createTicket(projectId)(ticket) - _ <- createdMilestone.traverse(ms => ticketRepo.addMilestone(projectId)(ticket.number)(ms)) - _ <- createdMilestone.traverse(ms => ticketRepo.removeMilestone(projectId)(ticket)(ms)) - foundMilestones <- loadTicketMilestoneIds(projectId, ticket.number) - } yield foundMilestones - } - } yield foundMilestones - test.map { foundMilestones => - assertEquals(foundMilestones, Nil) - } - case _ => fail("Could not generate data samples!") - } - } - - test("updateTicket must update the ticket in the database") { - (genProjectOwner.sample, genProject.sample, genTicket.sample, genTicket.sample) match { - case (Some(owner), Some(generatedProject), Some(ticket), Some(anotherTicket)) => - val project = generatedProject.copy(owner = owner) - val updatedTicket = - ticket.copy(title = anotherTicket.title, content = anotherTicket.content, submitter = anotherTicket.submitter) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val ticketRepo = new DoobieTicketRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- ticket.submitter.traverse(createTicketsSubmitter) - _ <- updatedTicket.submitter.traverse(createTicketsSubmitter) - _ <- createTicketsProject(project) - projectId <- loadProjectId(owner.uid, project.name) - _ <- projectId.traverse(projectId => ticketRepo.createTicket(projectId)(ticket)) - _ <- projectId.traverse(projectId => ticketRepo.updateTicket(projectId)(updatedTicket)) - foundTicket <- projectId.traverse(projectId => ticketRepo.findTicket(projectId)(ticket.number)) - } yield foundTicket.getOrElse(None) - test.map { foundTicket => - foundTicket match { - case None => fail("Created ticket not found!") - case Some(foundTicket) => - assertEquals( - foundTicket, - updatedTicket.copy(createdAt = foundTicket.createdAt, updatedAt = foundTicket.updatedAt) - ) - } - } - case _ => fail("Could not generate data samples!") - } - } - -} diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala --- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala 2025-01-16 05:02:15.363735377 +0000 +++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,88 +0,0 @@ -/* - * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -package de.smederee.tickets - -import cats.effect._ -import de.smederee.tickets.Generators._ -import doobie._ - -final class DoobieTicketServiceApiTest extends BaseSpec { - test("createOrUpdateUser must create new users") { - genTicketsUser.sample match { - case Some(user) => - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val api = new DoobieTicketServiceApi[IO](tx) - val test = for { - written <- api.createOrUpdateUser(user) - foundUser <- loadTicketsUser(user.uid) - } yield (written, foundUser) - test.map { result => - val (written, foundUser) = result - assert(written > 0, "No rows written to database!") - assertEquals(foundUser, Some(user)) - } - - case _ => fail("Could not generate data samples!") - } - } - - test("createOrUpdateUser must update existing users") { - (genTicketsUser.sample, genTicketsUser.sample) match { - case (Some(user), Some(anotherUser)) => - val updatedUser = anotherUser.copy(uid = user.uid) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val api = new DoobieTicketServiceApi[IO](tx) - val test = for { - created <- api.createOrUpdateUser(user) - updated <- api.createOrUpdateUser(updatedUser) - foundUser <- loadTicketsUser(user.uid) - } yield (created, updated, foundUser) - test.map { result => - val (created, updated, foundUser) = result - assert(created > 0, "No rows written to database!") - assert(updated > 0, "No rows updated in database!") - assertEquals(foundUser, Some(updatedUser)) - } - - case _ => fail("Could not generate data samples!") - } - } - - test("deleteUser must delete existing users") { - genTicketsUser.sample match { - case Some(user) => - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) - val api = new DoobieTicketServiceApi[IO](tx) - val test = for { - _ <- api.createOrUpdateUser(user) - deleted <- api.deleteUser(user.uid) - foundUser <- loadTicketsUser(user.uid) - } yield (deleted, foundUser) - test.map { result => - val (deleted, foundUser) = result - assert(deleted > 0, "No rows deleted from database!") - assert(foundUser.isEmpty, "User not deleted from database!") - } - - case _ => fail("Could not generate data samples!") - } - } -} diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/Generators.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/Generators.scala --- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/Generators.scala 2025-01-16 05:02:15.363735377 +0000 +++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/Generators.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,268 +0,0 @@ -/* - * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -package de.smederee.tickets - -import java.time._ -import java.util.{ Locale, UUID } - -import cats._ -import cats.syntax.all._ -import de.smederee.email.EmailAddress -import de.smederee.i18n.LanguageCode -import de.smederee.security._ - -import org.scalacheck.{ Arbitrary, Gen } - -import scala.jdk.CollectionConverters._ - -object Generators { - val MinimumYear: Int = -4713 // Lowest year supported by PostgreSQL - val MaximumYear: Int = 294276 // Highest year supported by PostgreSQL - - /** Prepend a zero to a single character hexadecimal code. - * - * @param hexCode - * A string supposed to contain a hexadecimal code between 0 and ff. - * @return - * Either the given code prepended with a leading zero if it had only a single character or the originally given - * code otherwise. - */ - private def hexPadding(hexCode: String): String = - if (hexCode.length < 2) - "0" + hexCode - else - hexCode - - val genLocalDate: Gen[LocalDate] = - for { - year <- Gen.choose(MinimumYear, MaximumYear) - month <- Gen.choose(1, 12) - day <- Gen.choose(1, Month.of(month).length(Year.of(year).isLeap)) - } yield LocalDate.of(year, month, day) - - given Arbitrary[LocalDate] = Arbitrary(genLocalDate) - - val genOffsetDateTime: Gen[OffsetDateTime] = - for { - year <- Gen.choose(MinimumYear, MaximumYear) - month <- Gen.choose(1, 12) - day <- Gen.choose(1, Month.of(month).length(Year.of(year).isLeap)) - hour <- Gen.choose(0, 23) - minute <- Gen.choose(0, 59) - second <- Gen.choose(0, 59) - nanosecond <- Gen.const(0) // Avoid issues with loosing information during saving and loading. - offset <- Gen.oneOf( - ZoneId.getAvailableZoneIds.asScala.toList.map(ZoneId.of).map(ZonedDateTime.now).map(_.getOffset) - ) - } yield OffsetDateTime.of(year, month, day, hour, minute, second, nanosecond, offset) - - given Arbitrary[OffsetDateTime] = Arbitrary(genOffsetDateTime) - - val genLocale: Gen[Locale] = Gen.oneOf(Locale.getAvailableLocales.toList) - val genLanguageCode: Gen[LanguageCode] = genLocale.map(_.getISO3Language).filter(_.nonEmpty).map(LanguageCode.apply) - - val genProjectOwnerId: Gen[ProjectOwnerId] = Gen.delay(ProjectOwnerId.randomProjectOwnerId) - - val genSubmitterId: Gen[SubmitterId] = Gen.delay(SubmitterId.randomSubmitterId) - - val genUUID: Gen[UUID] = Gen.delay(UUID.randomUUID) - - val genUserId: Gen[UserId] = Gen.delay(UserId.randomUserId) - - val genUsername: Gen[Username] = for { - length <- Gen.choose(2, 30) - prefix <- Gen.alphaChar - chars <- Gen - .nonEmptyListOf(Gen.alphaNumChar) - .map(_.take(length).mkString.toLowerCase(Locale.ROOT)) - } yield Username(prefix.toString.toLowerCase(Locale.ROOT) + chars) - - val genSubmitter: Gen[Submitter] = for { - id <- genSubmitterId - name <- genUsername.map(name => SubmitterName(name.toString)) - } yield Submitter(id, name) - - val genEmailAddress: Gen[EmailAddress] = - for { - length <- Gen.choose(4, 64) - chars <- Gen.nonEmptyListOf(Gen.alphaNumChar) - email = chars.take(length).mkString - } yield EmailAddress(email + "@example.com") - - val genTicketContent: Gen[Option[TicketContent]] = Gen.alphaStr.map(TicketContent.from) - - val genTicketStatus: Gen[TicketStatus] = Gen.oneOf(TicketStatus.values.toList) - - val genTicketResolution: Gen[TicketResolution] = Gen.oneOf(TicketResolution.values.toList) - - val genTicketNumber: Gen[TicketNumber] = Gen.choose(0, Int.MaxValue).map(TicketNumber.apply) - - val genTicketTitle: Gen[TicketTitle] = - Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(TicketTitle.MaxLength).mkString).map(TicketTitle.apply) - - val genTicketsUser: Gen[TicketsUser] = for { - uid <- genUserId - name <- genUsername - email <- genEmailAddress - language <- Gen.option(genLanguageCode) - } yield TicketsUser(uid, name, email, language) - - val genTicketsUsers: Gen[List[TicketsUser]] = Gen.nonEmptyListOf(genTicketsUser) - - val genTicket: Gen[Ticket] = for { - number <- genTicketNumber - title <- genTicketTitle - content <- genTicketContent - status <- genTicketStatus - resolution <- Gen.option(genTicketResolution) - submitter <- Gen.option(genSubmitter) - createdAt <- genOffsetDateTime - updatedAt <- genOffsetDateTime - } yield Ticket( - number, - title, - content, - status, - resolution, - submitter, - createdAt, - updatedAt - ) - - val genTickets: Gen[List[Ticket]] = - Gen.nonEmptyListOf(genTicket).map(_.zipWithIndex.map(tuple => tuple._1.copy(number = TicketNumber(tuple._2 + 1)))) - - val genProjectOwnerName: Gen[ProjectOwnerName] = for { - length <- Gen.choose(2, 30) - prefix <- Gen.alphaChar - chars <- Gen - .nonEmptyListOf(Gen.alphaNumChar) - .map(_.take(length).mkString.toLowerCase(Locale.ROOT)) - } yield ProjectOwnerName(prefix.toString.toLowerCase(Locale.ROOT) + chars) - - val genProjectOwner: Gen[ProjectOwner] = for { - id <- genProjectOwnerId - name <- genProjectOwnerName - email <- genEmailAddress - } yield ProjectOwner(uid = id, name = name, email = email) - - given Arbitrary[ProjectOwner] = Arbitrary(genProjectOwner) - - val genProjectOwners: Gen[List[ProjectOwner]] = Gen - .nonEmptyListOf(genProjectOwner) - .suchThat(owners => owners.size === owners.map(_.name).distinct.size) // Ensure unique names. - - val genLabelName: Gen[LabelName] = - Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(LabelName.MaxLength).mkString).map(LabelName.apply) - - val genLabelDescription: Gen[LabelDescription] = - Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(LabelDescription.MaxLength).mkString).map(LabelDescription.apply) - - val genColourCode: Gen[ColourCode] = for { - red <- Gen.choose(0, 255).map(_.toHexString).map(hexPadding) - green <- Gen.choose(0, 255).map(_.toHexString).map(hexPadding) - blue <- Gen.choose(0, 255).map(_.toHexString).map(hexPadding) - hexString = s"#$red$green$blue" - } yield ColourCode(hexString) - - val genLabel: Gen[Label] = for { - id <- Gen.option(Gen.choose(0L, Long.MaxValue).map(LabelId.apply)) - name <- genLabelName - description <- Gen.option(genLabelDescription) - colour <- genColourCode - } yield Label(id, name, description, colour) - - val genLabels: Gen[List[Label]] = Gen.nonEmptyListOf(genLabel).map(_.distinct) - - val genMilestoneTitle: Gen[MilestoneTitle] = - Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(MilestoneTitle.MaxLength).mkString).map(MilestoneTitle.apply) - - val genMilestoneDescription: Gen[MilestoneDescription] = - Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.mkString).map(MilestoneDescription.apply) - - val genMilestone: Gen[Milestone] = - for { - id <- Gen.option(Gen.choose(0L, Long.MaxValue).map(MilestoneId.apply)) - title <- genMilestoneTitle - due <- Gen.option(genLocalDate) - descr <- Gen.option(genMilestoneDescription) - } yield Milestone(id, title, descr, due) - - val genMilestones: Gen[List[Milestone]] = Gen.nonEmptyListOf(genMilestone).map(_.distinct) - - val genProjectName: Gen[ProjectName] = Gen - .nonEmptyListOf( - Gen.oneOf( - List( - "a", - "b", - "c", - "d", - "e", - "f", - "g", - "h", - "i", - "j", - "k", - "l", - "m", - "n", - "o", - "p", - "q", - "r", - "s", - "t", - "u", - "v", - "w", - "x", - "y", - "z", - "0", - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - "-", - "_" - ) - ) - ) - .map(cs => ProjectName(cs.take(64).mkString)) - - val genProjectDescription: Gen[Option[ProjectDescription]] = - Gen.alphaNumStr.map(_.take(ProjectDescription.MaximumLength)).map(ProjectDescription.from) - - val genProject: Gen[Project] = - for { - name <- genProjectName - description <- genProjectDescription - owner <- genProjectOwner - isPrivate <- Gen.oneOf(List(false, true)) - } yield Project(owner, name, description, isPrivate) - - val genProjects: Gen[List[Project]] = Gen.nonEmptyListOf(genProject) - -} diff -rN -u old-smederee/modules/tickets/src/test/resources/application.conf new-smederee/modules/tickets/src/test/resources/application.conf --- old-smederee/modules/tickets/src/test/resources/application.conf 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/test/resources/application.conf 2025-01-16 05:02:15.367735375 +0000 @@ -0,0 +1,12 @@ +tickets { + database { + host = localhost + host = ${?SMEDEREE_DB_HOST} + url = "jdbc:postgresql://"${tickets.database.host}":5432/smederee_tickets_it" + url = ${?SMEDEREE_TICKETS_TEST_DB_URL} + user = "smederee_tickets" + user = ${?SMEDEREE_TICKETS_TEST_DB_USER} + pass = "secret" + pass = ${?SMEDEREE_TICKETS_TEST_DB_PASS} + } +} diff -rN -u old-smederee/modules/tickets/src/test/resources/logback-test.xml new-smederee/modules/tickets/src/test/resources/logback-test.xml --- old-smederee/modules/tickets/src/test/resources/logback-test.xml 2025-01-16 05:02:15.363735377 +0000 +++ new-smederee/modules/tickets/src/test/resources/logback-test.xml 2025-01-16 05:02:15.367735375 +0000 @@ -19,6 +19,10 @@ <appender-ref ref="async-console"/> </logger> + <logger name="org.flywaydb.core" level="ERROR" additivity="false"> + <appender-ref ref="async-console"/> + </logger> + <root> <appender-ref ref="async-console"/> </root> diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/TestTags.scala new-smederee/modules/tickets/src/test/scala/de/smederee/TestTags.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/TestTags.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/TestTags.scala 2025-01-16 05:02:15.367735375 +0000 @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package de.smederee + +/** A collection of tags that can be used to label tests which have certain requirements for example a database + * connection. + */ +object TestTags { + val NeedsDatabase = new munit.Tag("NeedsDatabase") +} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/BaseSpec.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/BaseSpec.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/BaseSpec.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/BaseSpec.scala 2025-01-16 05:02:15.367735375 +0000 @@ -0,0 +1,364 @@ +/* + * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package de.smederee.tickets + +import java.net.ServerSocket + +import cats.effect._ +import cats.syntax.all._ +import com.comcast.ip4s._ +import com.typesafe.config.ConfigFactory +import de.smederee.email.EmailAddress +import de.smederee.i18n.LanguageCode +import de.smederee.security.{ UserId, Username } +import de.smederee.tickets.config._ +import org.flywaydb.core.Flyway +import pureconfig._ + +import munit._ + +import scala.annotation.nowarn + +/** Base class for our integration test suites. + * + * It loads and provides the configuration as a resource suit fixture (loaded once for all tests within a suite) and + * does initialise the test database for each suite. The latter means a possibly existing database with the name + * configured **will be deleted**! + */ +abstract class BaseSpec extends CatsEffectSuite { + protected final val configuration: SmedereeTicketsConfiguration = + ConfigSource + .fromConfig(ConfigFactory.load(getClass.getClassLoader)) + .at(SmedereeTicketsConfiguration.location) + .loadOrThrow[SmedereeTicketsConfiguration] + + protected final val flyway: Flyway = + DatabaseMigrator + .configureFlyway(configuration.database.url, configuration.database.user, configuration.database.pass) + .cleanDisabled(false) + .load() + + /** Connect to the DBMS using the generic "template1" database which should always be present. + * + * @param dbConfig + * The database configuration. + * @return + * The connection to the database ("template1"). + */ + private def connect(dbConfig: DatabaseConfig): IO[java.sql.Connection] = + for { + _ <- IO(Class.forName(dbConfig.driver)) + database <- IO(dbConfig.url.split("/").reverse.take(1).mkString) + connection <- IO( + java.sql.DriverManager + .getConnection(dbConfig.url.replace(database, "template1"), dbConfig.user, dbConfig.pass) + ) + } yield connection + + @nowarn("msg=discarded non-Unit value.*") + override def beforeAll(): Unit = { + // Extract the database name from the URL. + val database = configuration.database.url.split("/").reverse.take(1).mkString + val db = Resource.make(connect(configuration.database))(con => IO(con.close())) + // Create the test database if it does not already exist. + db.use { connection => + for { + statement <- IO(connection.createStatement()) + exists <- IO( + statement.executeQuery( + s"""SELECT datname FROM pg_catalog.pg_database WHERE datname = '$database'""" + ) + ) + _ <- IO { + if (!exists.next()) + statement.execute(s"""CREATE DATABASE "$database"""") + } + _ <- IO(exists.close) + _ <- IO(statement.close) + } yield () + }.unsafeRunSync() + } + + override def afterAll(): Unit = { + // Extract the database name from the URL. + val database = configuration.database.url.split("/").reverse.take(1).mkString + val db = Resource.make(connect(configuration.database))(con => IO(con.close())) + // Drop the test database after all tests have been run. + db.use { connection => + for { + statement <- IO(connection.createStatement()) + _ <- IO(statement.execute(s"""DROP DATABASE IF EXISTS $database""")) + _ <- IO(statement.close) + } yield () + }.unsafeRunSync() + } + + override def beforeEach(context: BeforeEach): Unit = { + val _ = flyway.migrate() + } + + override def afterEach(context: AfterEach): Unit = { + val _ = flyway.clean() + } + + /** Find and return a free port on the local machine by starting a server socket and closing it. The port number used + * by the socket is marked to allow reuse, considered free and returned. + * + * @return + * An optional port number if a free one can be found. + */ + protected def findFreePort(): Option[Port] = { + val socket = new ServerSocket(0) + val port = socket.getLocalPort + socket.setReuseAddress(true) // Allow instant rebinding of the socket. + socket.close() // Free the socket for further use by closing it. + Port.fromInt(port) + } + + /** Provide a resource with a database connection to allow db operations and proper resource release later. + * + * @param cfg + * The application configuration. + * @return + * A cats resource encapsulation a database connection as defined within the given configuration. + */ + protected def connectToDb(cfg: SmedereeTicketsConfiguration): Resource[IO, java.sql.Connection] = + Resource.make( + IO.delay(java.sql.DriverManager.getConnection(cfg.database.url, cfg.database.user, cfg.database.pass)) + )(c => IO.delay(c.close())) + + /** Create a project for ticket tracking in the database. + * + * @param project + * The project to be created. + * @return + * The number of affected database rows. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + protected def createTicketsProject(project: Project): IO[Int] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay { + con.prepareStatement( + """INSERT INTO "tickets"."projects" (name, owner, is_private, description, created_at, updated_at) VALUES(?, ?, ?, ?, NOW(), NOW())""" + ) + } + _ <- IO.delay(statement.setString(1, project.name.toString)) + _ <- IO.delay(statement.setObject(2, project.owner.uid)) + _ <- IO.delay(statement.setBoolean(3, project.isPrivate)) + _ <- IO.delay( + project.description.fold(statement.setNull(4, java.sql.Types.VARCHAR))(descr => + statement.setString(4, descr.toString) + ) + ) + r <- IO.delay(statement.executeUpdate()) + _ <- IO.delay(statement.close()) + } yield r + } + + /** Create a user account from a ticket submitter in the database. + * + * @param submitter + * The submitter for which the account shall be created. + * @return + * The number of affected database rows. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + protected def createTicketsSubmitter(submitter: Submitter): IO[Int] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay { + con.prepareStatement( + """INSERT INTO "tickets"."users" (uid, name, email, created_at, updated_at) VALUES(?, ?, ?, NOW(), NOW())""" + ) + } + _ <- IO.delay(statement.setObject(1, submitter.id)) + _ <- IO.delay(statement.setString(2, submitter.name.toString)) + _ <- IO.delay(statement.setString(3, s"email-${submitter.id.toString}@example.com")) + r <- IO.delay(statement.executeUpdate()) + _ <- IO.delay(statement.close()) + } yield r + } + + /** Create a tickets user account in the database. + * + * @param owner + * The user to be created. + * @return + * The number of affected database rows. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + protected def createProjectOwner(owner: ProjectOwner): IO[Int] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay { + con.prepareStatement( + """INSERT INTO "tickets"."users" (uid, name, email, created_at, updated_at) VALUES(?, ?, ?, NOW(), NOW())""" + ) + } + _ <- IO.delay(statement.setObject(1, owner.uid)) + _ <- IO.delay(statement.setString(2, owner.name.toString)) + _ <- IO.delay(statement.setString(3, owner.email.toString)) + r <- IO.delay(statement.executeUpdate()) + _ <- IO.delay(statement.close()) + } yield r + } + + /** Create a tickets user account in the database. + * + * @param user + * The user to be created. + * @return + * The number of affected database rows. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + protected def createTicketsUser(user: TicketsUser): IO[Int] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay { + con.prepareStatement( + """INSERT INTO "tickets"."users" (uid, name, email, created_at, updated_at) VALUES(?, ?, ?, NOW(), NOW())""" + ) + } + _ <- IO.delay(statement.setObject(1, user.uid)) + _ <- IO.delay(statement.setString(2, user.name.toString)) + _ <- IO.delay(statement.setString(3, user.email.toString)) + r <- IO.delay(statement.executeUpdate()) + _ <- IO.delay(statement.close()) + } yield r + } + + /** Return the next ticket number for the given project. + * + * @param projectId + * The internal database ID of the project. + * @return + * The next ticket number. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + protected def loadNextTicketNumber(projectId: ProjectId): IO[Int] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay( + con.prepareStatement( + """SELECT next_ticket_number FROM "tickets"."projects" WHERE id = ?""" + ) + ) + _ <- IO.delay(statement.setLong(1, projectId.toLong)) + result <- IO.delay(statement.executeQuery) + number <- IO.delay { + result.next() + result.getInt("next_ticket_number") + } + _ <- IO(statement.close()) + } yield number + } + + /** Find the project ID for the given owner and project name. + * + * @param owner + * The unique ID of the user account that owns the project. + * @param name + * The project name which must be unique in regard to the owner. + * @return + * An option to the internal database ID. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + protected def loadProjectId(owner: ProjectOwnerId, name: ProjectName): IO[Option[ProjectId]] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay( + con.prepareStatement( + """SELECT id FROM "tickets"."projects" WHERE owner = ? AND name = ? LIMIT 1""" + ) + ) + _ <- IO.delay(statement.setObject(1, owner)) + _ <- IO.delay(statement.setString(2, name.toString)) + result <- IO.delay(statement.executeQuery) + projectId <- IO.delay { + if (result.next()) { + ProjectId.from(result.getLong("id")) + } else { + None + } + } + _ <- IO(statement.close()) + } yield projectId + } + + /** Find the ticket ID for the given project ID and ticket number. + * + * @param projectId + * The unique internal project id. + * @param ticketNumber + * The ticket number. + * @return + * An option to the internal database ID of the ticket. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + protected def loadTicketId(project: ProjectId, number: TicketNumber): IO[Option[TicketId]] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay( + con.prepareStatement( + """SELECT id FROM "tickets"."tickets" WHERE project = ? AND number = ? LIMIT 1""" + ) + ) + _ <- IO.delay(statement.setLong(1, project.toLong)) + _ <- IO.delay(statement.setInt(2, number.toInt)) + result <- IO.delay(statement.executeQuery) + ticketId <- IO.delay { + if (result.next()) { + TicketId.from(result.getLong("id")) + } else { + None + } + } + _ <- IO(statement.close()) + } yield ticketId + } + + /** Find the ticket service user with the given user id. + * + * @param uid + * The unique id of the user account. + * @return + * An option to the loaded user. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + protected def loadTicketsUser(uid: UserId): IO[Option[TicketsUser]] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay( + con.prepareStatement("""SELECT uid, name, email, language FROM "tickets"."users" WHERE uid = ?""") + ) + _ <- IO.delay(statement.setObject(1, uid.toUUID)) + result <- IO.delay(statement.executeQuery()) + user <- IO.delay { + if (result.next()) { + val language = LanguageCode.from(result.getString("language")) + (uid.some, Username.from(result.getString("name")), EmailAddress.from(result.getString("email"))).mapN { + case (uid, name, email) => TicketsUser(uid, name, email, language) + } + } else { + None + } + } + } yield user + } +} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala 2025-01-16 05:02:15.371735374 +0000 @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package de.smederee.tickets.config + +import cats.effect._ +import cats.syntax.all._ +import de.smederee.TestTags._ +import org.flywaydb.core.Flyway + +import de.smederee.tickets.BaseSpec + +final class DatabaseMigratorTest extends BaseSpec { + override def beforeEach(context: BeforeEach): Unit = { + val dbConfig = configuration.database + val flyway: Flyway = + DatabaseMigrator.configureFlyway(dbConfig.url, dbConfig.user, dbConfig.pass).cleanDisabled(false).load() + val _ = flyway.migrate() + val _ = flyway.clean() + } + + override def afterEach(context: AfterEach): Unit = { + val dbConfig = configuration.database + val flyway: Flyway = + DatabaseMigrator.configureFlyway(dbConfig.url, dbConfig.user, dbConfig.pass).cleanDisabled(false).load() + val _ = flyway.migrate() + val _ = flyway.clean() + } + + test("DatabaseMigrator must update available outdated database".tag(NeedsDatabase)) { + val dbConfig = configuration.database + val migrator = new DatabaseMigrator[IO] + val test = migrator.migrate(dbConfig.url, dbConfig.user, dbConfig.pass) + test.map(result => assert(result.migrationsExecuted > 0)) + } + + test("DatabaseMigrator must not update an up to date database".tag(NeedsDatabase)) { + val dbConfig = configuration.database + val migrator = new DatabaseMigrator[IO] + val test = for { + _ <- migrator.migrate(dbConfig.url, dbConfig.user, dbConfig.pass) + r <- migrator.migrate(dbConfig.url, dbConfig.user, dbConfig.pass) + } yield r + test.map(result => assert(result.migrationsExecuted === 0)) + } + + test("DatabaseMigrator must throw an exception if the database is not available".tag(NeedsDatabase)) { + val migrator = new DatabaseMigrator[IO] + val test = migrator.migrate("jdbc:nodriver://", "", "") + test.attempt.map(r => assert(r.isLeft)) + } +} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala 2025-01-16 05:02:15.367735375 +0000 @@ -0,0 +1,270 @@ +/* + * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package de.smederee.tickets + +import cats.effect._ +import cats.syntax.all._ +import de.smederee.TestTags._ +import de.smederee.tickets.Generators._ +import doobie._ + +final class DoobieLabelRepositoryTest extends BaseSpec { + + /** Find the label ID for the given project and label name. + * + * @param owner + * The unique ID of the user account that owns the project. + * @param vcsRepoName + * The project name which must be unique in regard to the owner. + * @param labelName + * The label name which must be unique in the project context. + * @return + * An option to the internal database ID. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + protected def findLabelId(owner: ProjectOwnerId, vcsRepoName: ProjectName, labelName: LabelName): IO[Option[Long]] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay( + con.prepareStatement( + """SELECT "labels".id + |FROM "tickets"."labels" AS "labels" + |JOIN "tickets"."projects" AS "projects" + |ON "labels".project = "projects".id + |WHERE "projects".owner = ? + |AND "projects".name = ? + |AND "labels".name = ?""".stripMargin + ) + ) + _ <- IO.delay(statement.setObject(1, owner)) + _ <- IO.delay(statement.setString(2, vcsRepoName.toString)) + _ <- IO.delay(statement.setString(3, labelName.toString)) + result <- IO.delay(statement.executeQuery) + account <- IO.delay { + if (result.next()) { + Option(result.getLong("id")) + } else { + None + } + } + _ <- IO(statement.close()) + } yield account + } + + test("allLabels must return all labels".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genLabels.sample) match { + case (Some(owner), Some(generatedProject), Some(labels)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val labelRepo = new DoobieLabelRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + createdLabels <- projectId match { + case None => IO.pure(List.empty) + case Some(projectId) => labels.traverse(label => labelRepo.createLabel(projectId)(label)) + } + foundLabels <- projectId match { + case None => IO.pure(List.empty) + case Some(projectId) => labelRepo.allLabels(projectId).compile.toList + } + } yield foundLabels + test.map { foundLabels => + assert(foundLabels.size === labels.size, "Different number of labels!") + foundLabels.sortBy(_.name).zip(labels.sortBy(_.name)).map { (found, expected) => + assertEquals(found.copy(id = expected.id), expected) + } + } + case _ => fail("Could not generate data samples!") + } + } + + test("createLabel must create the label".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genLabel.sample) match { + case (Some(owner), Some(generatedProject), Some(label)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val labelRepo = new DoobieLabelRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + createdProjects <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + createdLabels <- projectId.traverse(id => labelRepo.createLabel(id)(label)) + foundLabel <- projectId.traverse(id => labelRepo.findLabel(id)(label.name)) + } yield (createdProjects, projectId, createdLabels, foundLabel) + test.map { tuple => + val (createdProjects, projectId, createdLabels, foundLabel) = tuple + assert(createdProjects === 1, "Test project was not created!") + assert(projectId.nonEmpty, "No project id found!") + assert(createdLabels.exists(_ === 1), "Test label was not created!") + foundLabel.getOrElse(None) match { + case None => fail("Created label not found!") + case Some(foundLabel) => + assert(foundLabel.id.nonEmpty, "Label ID must not be empty!") + assertEquals(foundLabel.name, label.name) + assertEquals(foundLabel.description, label.description) + assertEquals(foundLabel.colour, label.colour) + } + } + case _ => fail("Could not generate data samples!") + } + } + + test("createLabel must fail if the label name already exists".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genLabel.sample) match { + case (Some(owner), Some(generatedProject), Some(label)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val labelRepo = new DoobieLabelRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + createdProjects <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + createdLabels <- projectId.traverse(id => labelRepo.createLabel(id)(label)) + _ <- projectId.traverse(id => labelRepo.createLabel(id)(label)) + } yield (createdProjects, projectId, createdLabels) + test.attempt.map(r => assert(r.isLeft, "Creating a label with a duplicate name must fail!")) + case _ => fail("Could not generate data samples!") + } + } + + test("deleteLabel must delete an existing label".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genLabel.sample) match { + case (Some(owner), Some(generatedProject), Some(label)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val labelRepo = new DoobieLabelRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + createdProjects <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + createdLabels <- projectId.traverse(id => labelRepo.createLabel(id)(label)) + labelId <- findLabelId(owner.uid, project.name, label.name) + deletedLabels <- labelRepo.deleteLabel(label.copy(id = labelId.flatMap(LabelId.from))) + foundLabel <- projectId.traverse(id => labelRepo.findLabel(id)(label.name)) + } yield (createdProjects, projectId, createdLabels, deletedLabels, foundLabel) + test.map { tuple => + val (createdProjects, projectId, createdLabels, deletedLabels, foundLabel) = tuple + assert(createdProjects === 1, "Test vcs project was not created!") + assert(projectId.nonEmpty, "No vcs project id found!") + assert(createdLabels.exists(_ === 1), "Test label was not created!") + assert(deletedLabels === 1, "Test label was not deleted!") + assert(foundLabel.getOrElse(None).isEmpty, "Test label was not deleted!") + } + case _ => fail("Could not generate data samples!") + } + } + + test("findLabel must find existing labels".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genLabels.sample) match { + case (Some(owner), Some(generatedProject), Some(labels)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val expectedLabel = labels(scala.util.Random.nextInt(labels.size)) + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val labelRepo = new DoobieLabelRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + createdProjects <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + createdLabels <- projectId match { + case None => IO.pure(List.empty) + case Some(projectId) => labels.traverse(label => labelRepo.createLabel(projectId)(label)) + } + foundLabel <- projectId.traverse(id => labelRepo.findLabel(id)(expectedLabel.name)) + } yield foundLabel.flatten + test.map { foundLabel => + assertEquals(foundLabel.map(_.copy(id = expectedLabel.id)), Option(expectedLabel)) + } + case _ => fail("Could not generate data samples!") + } + } + + test("updateLabel must update an existing label".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genLabel.sample) match { + case (Some(owner), Some(generatedProject), Some(label)) => + val updatedLabel = label.copy( + name = LabelName("updated label"), + description = Option(LabelDescription("I am an updated label description...")), + colour = ColourCode("#abcdef") + ) + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val labelRepo = new DoobieLabelRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + createdProjects <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + createdLabels <- projectId.traverse(id => labelRepo.createLabel(id)(label)) + labelId <- findLabelId(owner.uid, project.name, label.name) + updatedLabels <- labelRepo.updateLabel(updatedLabel.copy(id = labelId.map(LabelId.apply))) + foundLabel <- projectId.traverse(id => labelRepo.findLabel(id)(updatedLabel.name)) + } yield (createdProjects, projectId, createdLabels, updatedLabels, foundLabel.flatten) + test.map { tuple => + val (createdProjects, projectId, createdLabels, updatedLabels, foundLabel) = tuple + assert(createdProjects === 1, "Test vcs project was not created!") + assert(projectId.nonEmpty, "No vcs project id found!") + assert(createdLabels.exists(_ === 1), "Test label was not created!") + assert(updatedLabels === 1, "Test label was not updated!") + assert(foundLabel.nonEmpty, "Updated label not found!") + foundLabel.map { label => + assertEquals(label, updatedLabel.copy(id = label.id)) + } + } + case _ => fail("Could not generate data samples!") + } + } + + test("updateLabel must do nothing if id attribute is empty".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genLabel.sample) match { + case (Some(owner), Some(generatedProject), Some(label)) => + val updatedLabel = label.copy( + id = None, + name = LabelName("updated label"), + description = Option(LabelDescription("I am an updated label description...")), + colour = ColourCode("#abcdef") + ) + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val labelRepo = new DoobieLabelRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + createdProjects <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + createdLabels <- projectId.traverse(id => labelRepo.createLabel(id)(label)) + labelId <- findLabelId(owner.uid, project.name, label.name) + updatedLabels <- labelRepo.updateLabel(updatedLabel) + } yield (createdProjects, projectId, createdLabels, updatedLabels) + test.map { tuple => + val (createdProjects, projectId, createdLabels, updatedLabels) = tuple + assert(createdProjects === 1, "Test vcs project was not created!") + assert(projectId.nonEmpty, "No vcs project id found!") + assert(createdLabels.exists(_ === 1), "Test label was not created!") + assert(updatedLabels === 0, "Label with empty id must not be updated!") + } + case _ => fail("Could not generate data samples!") + } + } +} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala 2025-01-16 05:02:15.367735375 +0000 @@ -0,0 +1,268 @@ +/* + * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package de.smederee.tickets + +import java.time._ + +import cats.effect._ +import cats.syntax.all._ +import de.smederee.TestTags._ +import de.smederee.tickets.Generators._ +import doobie._ + +final class DoobieMilestoneRepositoryTest extends BaseSpec { + + /** Find the milestone ID for the given repository and milestone title. + * + * @param owner + * The unique ID of the user owner that owns the repository. + * @param projectName + * The project name which must be unique in regard to the owner. + * @param title + * The milestone title which must be unique in the repository context. + * @return + * An option to the internal database ID. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + protected def findMilestoneId( + owner: ProjectOwnerId, + projectName: ProjectName, + title: MilestoneTitle + ): IO[Option[Long]] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay( + con.prepareStatement( + """SELECT "milestones".id FROM "tickets"."milestones" AS "milestones" JOIN "tickets"."projects" AS "projects" ON "milestones".project = "projects".id WHERE "projects".owner = ? AND "projects".name = ? AND "milestones".title = ?""" + ) + ) + _ <- IO.delay(statement.setObject(1, owner)) + _ <- IO.delay(statement.setString(2, projectName.toString)) + _ <- IO.delay(statement.setString(3, title.toString)) + result <- IO.delay(statement.executeQuery) + owner <- IO.delay { + if (result.next()) { + Option(result.getLong("id")) + } else { + None + } + } + _ <- IO(statement.close()) + } yield owner + } + + test("allMilestones must return all milestones".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genMilestones.sample) match { + case (Some(owner), Some(generatedProject), Some(milestones)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val milestoneRepo = new DoobieMilestoneRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + createdRepos <- createTicketsProject(project) + repoId <- loadProjectId(owner.uid, project.name) + createdMilestones <- repoId match { + case None => IO.pure(List.empty) + case Some(repoId) => milestones.traverse(milestone => milestoneRepo.createMilestone(repoId)(milestone)) + } + foundMilestones <- repoId match { + case None => IO.pure(List.empty) + case Some(repoId) => milestoneRepo.allMilestones(repoId).compile.toList + } + } yield foundMilestones + test.map { foundMilestones => + assert(foundMilestones.size === milestones.size, "Different number of milestones!") + foundMilestones.sortBy(_.title).zip(milestones.sortBy(_.title)).map { (found, expected) => + assertEquals(found.copy(id = expected.id), expected) + } + } + case _ => fail("Could not generate data samples!") + } + } + + test("createMilestone must create the milestone".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genMilestone.sample) match { + case (Some(owner), Some(generatedProject), Some(milestone)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val milestoneRepo = new DoobieMilestoneRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + createdRepos <- createTicketsProject(project) + repoId <- loadProjectId(owner.uid, project.name) + createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone)) + foundMilestone <- repoId.traverse(id => milestoneRepo.findMilestone(id)(milestone.title)) + } yield (createdRepos, repoId, createdMilestones, foundMilestone) + test.map { tuple => + val (createdRepos, repoId, createdMilestones, foundMilestone) = tuple + assert(createdRepos === 1, "Test vcs generatedProject was not created!") + assert(repoId.nonEmpty, "No vcs generatedProject id found!") + assert(createdMilestones.exists(_ === 1), "Test milestone was not created!") + foundMilestone.getOrElse(None) match { + case None => fail("Created milestone not found!") + case Some(foundMilestone) => assertEquals(foundMilestone, milestone.copy(id = foundMilestone.id)) + } + } + case _ => fail("Could not generate data samples!") + } + } + + test("createMilestone must fail if the milestone name already exists".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genMilestone.sample) match { + case (Some(owner), Some(generatedProject), Some(milestone)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val milestoneRepo = new DoobieMilestoneRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + createdRepos <- createTicketsProject(project) + repoId <- loadProjectId(owner.uid, project.name) + createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone)) + _ <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone)) + } yield (createdRepos, repoId, createdMilestones) + test.attempt.map(r => assert(r.isLeft, "Creating a milestone with a duplicate name must fail!")) + case _ => fail("Could not generate data samples!") + } + } + + test("deleteMilestone must delete an existing milestone".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genMilestone.sample) match { + case (Some(owner), Some(generatedProject), Some(milestone)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val milestoneRepo = new DoobieMilestoneRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + createdRepos <- createTicketsProject(project) + repoId <- loadProjectId(owner.uid, project.name) + createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone)) + milestoneId <- findMilestoneId(owner.uid, project.name, milestone.title) + deletedMilestones <- milestoneRepo.deleteMilestone(milestone.copy(id = milestoneId.flatMap(MilestoneId.from))) + foundMilestone <- repoId.traverse(id => milestoneRepo.findMilestone(id)(milestone.title)) + } yield (createdRepos, repoId, createdMilestones, deletedMilestones, foundMilestone) + test.map { tuple => + val (createdRepos, repoId, createdMilestones, deletedMilestones, foundMilestone) = tuple + assert(createdRepos === 1, "Test vcs generatedProject was not created!") + assert(repoId.nonEmpty, "No vcs generatedProject id found!") + assert(createdMilestones.exists(_ === 1), "Test milestone was not created!") + assert(deletedMilestones === 1, "Test milestone was not deleted!") + assert(foundMilestone.getOrElse(None).isEmpty, "Test milestone was not deleted!") + } + case _ => fail("Could not generate data samples!") + } + } + + test("findMilestone must find existing milestones".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genMilestones.sample) match { + case (Some(owner), Some(generatedProject), Some(milestones)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val expectedMilestone = milestones(scala.util.Random.nextInt(milestones.size)) + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val milestoneRepo = new DoobieMilestoneRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + createdRepos <- createTicketsProject(project) + repoId <- loadProjectId(owner.uid, project.name) + createdMilestones <- repoId match { + case None => IO.pure(List.empty) + case Some(repoId) => milestones.traverse(milestone => milestoneRepo.createMilestone(repoId)(milestone)) + } + foundMilestone <- repoId.traverse(id => milestoneRepo.findMilestone(id)(expectedMilestone.title)) + } yield foundMilestone.flatten + test.map { foundMilestone => + assertEquals(foundMilestone.map(_.copy(id = expectedMilestone.id)), Option(expectedMilestone)) + } + case _ => fail("Could not generate data samples!") + } + } + + test("updateMilestone must update an existing milestone".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genMilestone.sample) match { + case (Some(owner), Some(generatedProject), Some(milestone)) => + val updatedMilestone = milestone.copy( + title = MilestoneTitle("updated milestone"), + description = Option(MilestoneDescription("I am an updated milestone description...")), + dueDate = Option(LocalDate.of(1879, 3, 14)) + ) + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val milestoneRepo = new DoobieMilestoneRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + createdRepos <- createTicketsProject(project) + repoId <- loadProjectId(owner.uid, project.name) + createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone)) + milestoneId <- findMilestoneId(owner.uid, project.name, milestone.title) + updatedMilestones <- milestoneRepo.updateMilestone( + updatedMilestone.copy(id = milestoneId.map(MilestoneId.apply)) + ) + foundMilestone <- repoId.traverse(id => milestoneRepo.findMilestone(id)(updatedMilestone.title)) + } yield (createdRepos, repoId, createdMilestones, updatedMilestones, foundMilestone.flatten) + test.map { tuple => + val (createdRepos, repoId, createdMilestones, updatedMilestones, foundMilestone) = tuple + assert(createdRepos === 1, "Test vcs generatedProject was not created!") + assert(repoId.nonEmpty, "No vcs generatedProject id found!") + assert(createdMilestones.exists(_ === 1), "Test milestone was not created!") + assert(updatedMilestones === 1, "Test milestone was not updated!") + assert(foundMilestone.nonEmpty, "Updated milestone not found!") + foundMilestone.map { milestone => + assertEquals(milestone, updatedMilestone.copy(id = milestone.id)) + } + } + case _ => fail("Could not generate data samples!") + } + } + + test("updateMilestone must do nothing if id attribute is empty".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genMilestone.sample) match { + case (Some(owner), Some(generatedProject), Some(milestone)) => + val updatedMilestone = milestone.copy( + id = None, + title = MilestoneTitle("updated milestone"), + description = Option(MilestoneDescription("I am an updated milestone description...")), + dueDate = Option(LocalDate.of(1879, 3, 14)) + ) + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val milestoneRepo = new DoobieMilestoneRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + createdRepos <- createTicketsProject(project) + repoId <- loadProjectId(owner.uid, project.name) + createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone)) + milestoneId <- findMilestoneId(owner.uid, project.name, milestone.title) + updatedMilestones <- milestoneRepo.updateMilestone(updatedMilestone) + } yield (createdRepos, repoId, createdMilestones, updatedMilestones) + test.map { tuple => + val (createdRepos, repoId, createdMilestones, updatedMilestones) = tuple + assert(createdRepos === 1, "Test vcs generatedProject was not created!") + assert(repoId.nonEmpty, "No vcs generatedProject id found!") + assert(createdMilestones.exists(_ === 1), "Test milestone was not created!") + assert(updatedMilestones === 0, "Milestone with empty id must not be updated!") + } + case _ => fail("Could not generate data samples!") + } + } +} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala 2025-01-16 05:02:15.367735375 +0000 @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package de.smederee.tickets + +import cats.effect._ +import cats.syntax.all._ +import de.smederee.TestTags._ +import de.smederee.tickets.Generators._ +import doobie._ + +final class DoobieProjectRepositoryTest extends BaseSpec { + test("createProject must create a project".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample) match { + case (Some(owner), Some(generatedProject)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val projectRepo = new DoobieProjectRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- projectRepo.createProject(project) + foundProject <- projectRepo.findProject(owner, project.name) + } yield foundProject + test.map { foundProject => + assertEquals(foundProject, Some(project)) + } + case _ => fail("Could not generate data samples!") + } + } + + test("deleteProject must delete a project".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample) match { + case (Some(owner), Some(generatedProject)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val projectRepo = new DoobieProjectRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- createTicketsProject(project) + deleted <- projectRepo.deleteProject(project) + foundProject <- projectRepo.findProject(owner, project.name) + } yield (deleted, foundProject) + test.map { result => + val (deleted, foundProject) = result + assert(deleted > 0, "Rows not deleted from database!") + assert(foundProject.isEmpty, "Project not deleted from database!") + } + case _ => fail("Could not generate data samples!") + } + } + + test("findProject must return the matching project".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProjects.sample) match { + case (Some(owner), Some(generatedProject :: projects)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val projectRepo = new DoobieProjectRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- createTicketsProject(project) + _ <- projects.filterNot(_.name === project.name).traverse(p => createTicketsProject(p.copy(owner = owner))) + foundProject <- projectRepo.findProject(owner, project.name) + } yield foundProject + test.map { foundProject => + assertEquals(foundProject, Some(project)) + } + case _ => fail("Could not generate data samples!") + } + } + + test("findProjectId must return the matching id".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProjects.sample) match { + case (Some(owner), Some(generatedProject :: projects)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val projectRepo = new DoobieProjectRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- createTicketsProject(project) + _ <- projects.filterNot(_.name === project.name).traverse(p => createTicketsProject(p.copy(owner = owner))) + foundProjectId <- projectRepo.findProjectId(owner, project.name) + projectId <- loadProjectId(owner.uid, project.name) + } yield (foundProjectId, projectId) + test.map { result => + val (foundProjectId, projectId) = result + assertEquals(foundProjectId, projectId) + } + case _ => fail("Could not generate data samples!") + } + } + + test("findProjectOwner must return the matching project owner".tag(NeedsDatabase)) { + genProjectOwners.sample match { + case Some(owner :: owners) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val projectRepo = new DoobieProjectRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- owners.filterNot(_.name === owner.name).traverse(createProjectOwner) + foundOwner <- projectRepo.findProjectOwner(owner.name) + } yield foundOwner + test.map { foundOwner => + assert(foundOwner.exists(_ === owner)) + } + case _ => fail("Could not generate data samples!") + } + } + + test("incrementNextTicketNumber must return and increment the old value".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample) match { + case (Some(owner), Some(firstProject)) => + val project = firstProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val projectRepo = new DoobieProjectRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- projectRepo.createProject(project) + projectId <- loadProjectId(owner.uid, project.name) + result <- projectId match { + case None => fail("Project was not created!") + case Some(projectId) => + for { + before <- loadNextTicketNumber(projectId) + number <- projectRepo.incrementNextTicketNumber(projectId) + after <- loadNextTicketNumber(projectId) + } yield (TicketNumber(before), number, TicketNumber(after)) + } + } yield result + test.map { result => + val (before, number, after) = result + assertEquals(before, number) + assertEquals(after, TicketNumber(number.toInt + 1)) + } + case _ => fail("Could not generate data samples!") + } + } + + test("updateProject must update a project".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genProject.sample) match { + case (Some(owner), Some(firstProject), Some(secondProject)) => + val project = firstProject.copy(owner = owner) + val updatedProject = project.copy(description = secondProject.description, isPrivate = secondProject.isPrivate) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val projectRepo = new DoobieProjectRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- projectRepo.createProject(project) + written <- projectRepo.updateProject(updatedProject) + foundProject <- projectRepo.findProject(owner, project.name) + } yield (written, foundProject) + test.map { result => + val (written, foundProject) = result + assert(written > 0, "Rows not updated in database!") + assertEquals(foundProject, Some(updatedProject)) + } + case _ => fail("Could not generate data samples!") + } + } +} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala 2025-01-16 05:02:15.367735375 +0000 @@ -0,0 +1,745 @@ +/* + * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package de.smederee.tickets + +import java.time.OffsetDateTime + +import cats.effect._ +import cats.syntax.all._ +import de.smederee.TestTags._ +import de.smederee.tickets.Generators._ +import doobie._ + +import scala.collection.immutable.Queue + +final class DoobieTicketRepositoryTest extends BaseSpec { + + /** Return the internal ids of all lables associated with the given ticket number and project id. + * + * @param projectId + * The unique internal project id. + * @param ticketNumber + * The ticket number. + * @return + * A list of label ids that may be empty. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + @SuppressWarnings(Array("DisableSyntax.var", "DisableSyntax.while")) + protected def loadTicketLabelIds(projectId: ProjectId, ticketNumber: TicketNumber): IO[List[LabelId]] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay( + con.prepareStatement( + """SELECT label FROM "tickets"."ticket_labels" AS "ticket_labels" JOIN "tickets"."tickets" ON "ticket_labels".ticket = "tickets".id WHERE "tickets".project = ? AND "tickets".number = ?""" + ) + ) + _ <- IO.delay(statement.setLong(1, projectId.toLong)) + _ <- IO.delay(statement.setInt(2, ticketNumber.toInt)) + result <- IO.delay(statement.executeQuery) + labelIds <- IO.delay { + var queue = Queue.empty[LabelId] + while (result.next()) + queue = queue :+ LabelId(result.getLong("label")) + queue.toList + } + _ <- IO(statement.close()) + } yield labelIds + } + + /** Return the internal ids of all milestones associated with the given ticket number and project id. + * + * @param projectId + * The unique internal project id. + * @param ticketNumber + * The ticket number. + * @return + * A list of milestone ids that may be empty. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + @SuppressWarnings(Array("DisableSyntax.var", "DisableSyntax.while")) + protected def loadTicketMilestoneIds(projectId: ProjectId, ticketNumber: TicketNumber): IO[List[MilestoneId]] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay( + con.prepareStatement( + """SELECT milestone FROM "tickets"."milestone_tickets" AS "milestone_tickets" JOIN "tickets"."tickets" ON "milestone_tickets".ticket = "tickets".id WHERE "tickets".project = ? AND "tickets".number = ?""" + ) + ) + _ <- IO.delay(statement.setLong(1, projectId.toLong)) + _ <- IO.delay(statement.setInt(2, ticketNumber.toInt)) + result <- IO.delay(statement.executeQuery) + milestoneIds <- IO.delay { + var queue = Queue.empty[MilestoneId] + while (result.next()) + queue = queue :+ MilestoneId(result.getLong("milestone")) + queue.toList + } + _ <- IO(statement.close()) + } yield milestoneIds + } + + test("addAssignee must save the assignee relation to the database".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genTicket.sample, genTicketsUser.sample) match { + case (Some(owner), Some(generatedProject), Some(ticket), Some(user)) => + val assignee = Assignee(AssigneeId(user.uid.toUUID), AssigneeName(user.name.toString)) + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- createTicketsUser(user) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + _ <- ticket.submitter.traverse(createTicketsSubmitter) + _ <- projectId.traverse(projectId => ticketRepo.createTicket(projectId)(ticket)) + _ <- projectId.traverse(projectId => ticketRepo.addAssignee(projectId)(ticket.number)(assignee)) + foundAssignees <- projectId.traverse(projectId => + ticketRepo.loadAssignees(projectId)(ticket.number).compile.toList + ) + } yield foundAssignees.getOrElse(Nil) + test.map { foundAssignees => + assertEquals(foundAssignees, List(assignee)) + } + case _ => fail("Could not generate data samples!") + } + } + + test("addLabel must save the label relation to the database".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genTicket.sample, genLabel.sample) match { + case (Some(owner), Some(generatedProject), Some(ticket), Some(label)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val labelRepo = new DoobieLabelRepository[IO](tx) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + result <- projectId match { + case None => fail("Project ID not found in database!") + case Some(projectId) => + for { + _ <- ticket.submitter.traverse(createTicketsSubmitter) + _ <- labelRepo.createLabel(projectId)(label) + createdLabel <- labelRepo.findLabel(projectId)(label.name) + _ <- ticketRepo.createTicket(projectId)(ticket) + _ <- createdLabel.traverse(cl => ticketRepo.addLabel(projectId)(ticket.number)(cl)) + foundLabels <- loadTicketLabelIds(projectId, ticket.number) + } yield (createdLabel, foundLabels) + } + } yield result + test.map { result => + val (createdLabel, foundLabels) = result + assert(createdLabel.nonEmpty, "Test label not created!") + createdLabel.flatMap(_.id) match { + case None => fail("Test label has no ID!") + case Some(labelId) => assert(foundLabels.exists(_ === labelId)) + } + } + case _ => fail("Could not generate data samples!") + } + } + + test("addMilestone must save the milestone relation to the database".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genTicket.sample, genMilestone.sample) match { + case (Some(owner), Some(generatedProject), Some(ticket), Some(milestone)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val milestoneRepo = new DoobieMilestoneRepository[IO](tx) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + result <- projectId match { + case None => fail("Project ID not found in database!") + case Some(projectId) => + for { + _ <- ticket.submitter.traverse(createTicketsSubmitter) + _ <- milestoneRepo.createMilestone(projectId)(milestone) + createdMilestone <- milestoneRepo.findMilestone(projectId)(milestone.title) + _ <- ticketRepo.createTicket(projectId)(ticket) + _ <- createdMilestone.traverse(cl => ticketRepo.addMilestone(projectId)(ticket.number)(cl)) + foundMilestones <- loadTicketMilestoneIds(projectId, ticket.number) + } yield (createdMilestone, foundMilestones) + } + } yield result + test.map { result => + val (createdMilestone, foundMilestones) = result + assert(createdMilestone.nonEmpty, "Test milestone not created!") + createdMilestone.flatMap(_.id) match { + case None => fail("Test milestone has no ID!") + case Some(milestoneId) => assert(foundMilestones.exists(_ === milestoneId)) + } + } + case _ => fail("Could not generate data samples!") + } + } + + test("allTickets must return all tickets for the project".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genTickets.sample) match { + case (Some(owner), Some(generatedProject), Some(generatedTickets)) => + val defaultTimestamp = OffsetDateTime.now() + val tickets = + generatedTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)) + val submitters = tickets.map(_.submitter).flatten + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- submitters.traverse(createTicketsSubmitter) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + result <- projectId match { + case None => IO.pure((0, Nil)) + case Some(projectId) => + for { + writtenTickets <- tickets.traverse(ticket => ticketRepo.createTicket(projectId)(ticket)) + foundTickets <- ticketRepo.allTickets(filter = None)(projectId).compile.toList + } yield (writtenTickets.sum, foundTickets) + } + } yield result + test.map { result => + val (writtenTickets, foundTickets) = result + assertEquals(writtenTickets, tickets.size, "Wrong number of tickets created in the database!") + assertEquals( + foundTickets.size, + writtenTickets, + "Number of returned tickets differs from number of created tickets!" + ) + assertEquals( + foundTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)), + tickets.sortBy(_.number) + ) + } + case _ => fail("Could not generate data samples!") + } + } + + test("allTickets must respect given filters for numbers".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genTickets.sample) match { + case (Some(owner), Some(generatedProject), Some(generatedTickets)) => + val defaultTimestamp = OffsetDateTime.now() + val tickets = + generatedTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)) + val expectedTickets = tickets.take(tickets.size / 2) + val filter = TicketFilter(number = expectedTickets.map(_.number), Nil, Nil, Nil) + val submitters = tickets.map(_.submitter).flatten + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- submitters.traverse(createTicketsSubmitter) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + result <- projectId match { + case None => IO.pure((0, Nil)) + case Some(projectId) => + for { + writtenTickets <- tickets.traverse(ticket => ticketRepo.createTicket(projectId)(ticket)) + foundTickets <- ticketRepo.allTickets(filter.some)(projectId).compile.toList + } yield (writtenTickets.sum, foundTickets) + } + } yield result + test.map { result => + val (writtenTickets, foundTickets) = result + assertEquals(writtenTickets, tickets.size, "Wrong number of tickets created in the database!") + assertEquals( + foundTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)), + expectedTickets.sortBy(_.number) + ) + } + case _ => fail("Could not generate data samples!") + } + } + + test("allTickets must respect given filters for status".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genTickets.sample) match { + case (Some(owner), Some(generatedProject), Some(generatedTickets)) => + val defaultTimestamp = OffsetDateTime.now() + val tickets = + generatedTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)) + val statusFlags = tickets.map(_.status).distinct.take(2) + val expectedTickets = tickets.filter(t => statusFlags.exists(_ === t.status)) + val filter = TicketFilter(Nil, status = statusFlags, Nil, Nil) + val submitters = tickets.map(_.submitter).flatten + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- submitters.traverse(createTicketsSubmitter) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + result <- projectId match { + case None => IO.pure((0, Nil)) + case Some(projectId) => + for { + writtenTickets <- tickets.traverse(ticket => ticketRepo.createTicket(projectId)(ticket)) + foundTickets <- ticketRepo.allTickets(filter.some)(projectId).compile.toList + } yield (writtenTickets.sum, foundTickets) + } + } yield result + test.map { result => + val (writtenTickets, foundTickets) = result + assertEquals(writtenTickets, tickets.size, "Wrong number of tickets created in the database!") + assertEquals( + foundTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)), + expectedTickets.sortBy(_.number) + ) + } + case _ => fail("Could not generate data samples!") + } + } + + test("allTickets must respect given filters for resolution".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genTickets.sample) match { + case (Some(owner), Some(generatedProject), Some(generatedTickets)) => + val defaultTimestamp = OffsetDateTime.now() + val tickets = + generatedTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)) + val resolutions = tickets.map(_.resolution).flatten.distinct.take(2) + val expectedTickets = tickets.filter(t => t.resolution.exists(r => resolutions.exists(_ === r))) + val filter = TicketFilter(Nil, Nil, resolution = resolutions, Nil) + val submitters = tickets.map(_.submitter).flatten + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- submitters.traverse(createTicketsSubmitter) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + result <- projectId match { + case None => IO.pure((0, Nil)) + case Some(projectId) => + for { + writtenTickets <- tickets.traverse(ticket => ticketRepo.createTicket(projectId)(ticket)) + foundTickets <- ticketRepo.allTickets(filter.some)(projectId).compile.toList + } yield (writtenTickets.sum, foundTickets) + } + } yield result + test.map { result => + val (writtenTickets, foundTickets) = result + assertEquals(writtenTickets, tickets.size, "Wrong number of tickets created in the database!") + assertEquals( + foundTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)), + expectedTickets.sortBy(_.number) + ) + } + case _ => fail("Could not generate data samples!") + } + } + + test("allTickets must respect given filters for submitter".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genTickets.sample) match { + case (Some(owner), Some(generatedProject), Some(generatedTickets)) => + val defaultTimestamp = OffsetDateTime.now() + val tickets = + generatedTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)) + val submitters = tickets.map(_.submitter).flatten + val wantedSubmitters = submitters.take(submitters.size / 2) + val expectedTickets = tickets.filter(t => t.submitter.exists(s => wantedSubmitters.exists(_ === s))) + val filter = TicketFilter(Nil, Nil, Nil, submitter = wantedSubmitters) + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- submitters.traverse(createTicketsSubmitter) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + result <- projectId match { + case None => IO.pure((0, Nil)) + case Some(projectId) => + for { + writtenTickets <- tickets.traverse(ticket => ticketRepo.createTicket(projectId)(ticket)) + foundTickets <- ticketRepo.allTickets(filter.some)(projectId).compile.toList + } yield (writtenTickets.sum, foundTickets) + } + } yield result + test.map { result => + val (writtenTickets, foundTickets) = result + assertEquals(writtenTickets, tickets.size, "Wrong number of tickets created in the database!") + assertEquals( + foundTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)), + expectedTickets.sortBy(_.number) + ) + } + case _ => fail("Could not generate data samples!") + } + } + + test("createTicket must save the ticket to the database".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genTicket.sample) match { + case (Some(owner), Some(generatedProject), Some(ticket)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- ticket.submitter.traverse(createTicketsSubmitter) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + _ <- projectId.traverse(projectId => ticketRepo.createTicket(projectId)(ticket)) + foundTicket <- projectId.traverse(projectId => ticketRepo.findTicket(projectId)(ticket.number)) + } yield foundTicket.getOrElse(None) + test.map { foundTicket => + foundTicket match { + case None => fail("Created ticket not found!") + case Some(foundTicket) => + assertEquals( + foundTicket, + ticket.copy(createdAt = foundTicket.createdAt, updatedAt = foundTicket.updatedAt) + ) + } + } + case _ => fail("Could not generate data samples!") + } + } + + test("deleteTicket must remove the ticket from the database".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genTicket.sample) match { + case (Some(owner), Some(generatedProject), Some(ticket)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- ticket.submitter.traverse(createTicketsSubmitter) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + _ <- projectId.traverse(projectId => ticketRepo.createTicket(projectId)(ticket)) + _ <- projectId.traverse(projectId => ticketRepo.deleteTicket(projectId)(ticket)) + foundTicket <- projectId.traverse(projectId => ticketRepo.findTicket(projectId)(ticket.number)) + } yield foundTicket.getOrElse(None) + test.map { foundTicket => + assertEquals(foundTicket, None, "Ticket was not deleted from database!") + } + case _ => fail("Could not generate data samples!") + } + } + + test("findTicket must find existing tickets".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genTickets.sample) match { + case (Some(owner), Some(generatedProject), Some(tickets)) => + val expectedTicket = tickets(scala.util.Random.nextInt(tickets.size)) + val submitters = tickets.map(_.submitter).flatten + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- submitters.traverse(createTicketsSubmitter) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + _ <- projectId match { + case None => IO.pure(Nil) + case Some(projectId) => tickets.traverse(ticket => ticketRepo.createTicket(projectId)(ticket)) + } + foundTicket <- projectId.traverse(projectId => ticketRepo.findTicket(projectId)(expectedTicket.number)) + } yield foundTicket.getOrElse(None) + test.map { foundTicket => + foundTicket match { + case None => fail("Ticket not found!") + case Some(foundTicket) => + assertEquals( + foundTicket, + expectedTicket.copy(createdAt = foundTicket.createdAt, updatedAt = foundTicket.updatedAt) + ) + } + } + case _ => fail("Could not generate data samples!") + } + } + + test("findTicketId must find the unique internal id of existing tickets".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genTickets.sample) match { + case (Some(owner), Some(generatedProject), Some(tickets)) => + val expectedTicket = tickets(scala.util.Random.nextInt(tickets.size)) + val submitters = tickets.map(_.submitter).flatten + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- submitters.traverse(createTicketsSubmitter) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + result <- projectId match { + case None => IO.pure((None, None)) + case Some(projectId) => + for { + _ <- tickets.traverse(ticket => ticketRepo.createTicket(projectId)(ticket)) + expectedTicketId <- loadTicketId(projectId, expectedTicket.number) + foundTicketId <- ticketRepo.findTicketId(projectId)(expectedTicket.number) + } yield (expectedTicketId, foundTicketId) + } + } yield result + test.map { result => + val (expectedTicketId, foundTicketId) = result + assert(expectedTicketId.nonEmpty, "Expected ticket id not found!") + assertEquals(foundTicketId, expectedTicketId) + } + case _ => fail("Could not generate data samples!") + } + } + + test("loadAssignees must return all assignees of a ticket".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genTicket.sample, genTicketsUsers.sample) match { + case (Some(owner), Some(generatedProject), Some(ticket), Some(users)) => + val assignees = users.map(user => Assignee(AssigneeId(user.uid.toUUID), AssigneeName(user.name.toString))) + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- users.traverse(createTicketsUser) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + _ <- ticket.submitter.traverse(createTicketsSubmitter) + foundAssignees <- projectId match { + case None => fail("Project ID not found in database!") + case Some(projectId) => + for { + _ <- ticketRepo.createTicket(projectId)(ticket) + _ <- assignees.traverse(assignee => ticketRepo.addAssignee(projectId)(ticket.number)(assignee)) + foundAssignees <- ticketRepo.loadAssignees(projectId)(ticket.number).compile.toList + } yield foundAssignees + } + } yield foundAssignees + test.map { foundAssignees => + assertEquals(foundAssignees.sortBy(_.name), assignees.sortBy(_.name)) + } + case _ => fail("Could not generate data samples!") + } + } + + test("loadLabels must return all labels of a ticket".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genTicket.sample, genLabels.sample) match { + case (Some(owner), Some(generatedProject), Some(ticket), Some(labels)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val labelRepo = new DoobieLabelRepository[IO](tx) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + result <- projectId match { + case None => fail("Project ID not found in database!") + case Some(projectId) => + for { + _ <- ticket.submitter.traverse(createTicketsSubmitter) + _ <- ticketRepo.createTicket(projectId)(ticket) + _ <- labels.traverse(label => labelRepo.createLabel(projectId)(label)) + createdLabels <- labelRepo.allLabels(projectId).compile.toList + _ <- createdLabels.traverse(cl => ticketRepo.addLabel(projectId)(ticket.number)(cl)) + foundLabels <- ticketRepo.loadLabels(projectId)(ticket.number).compile.toList + } yield (createdLabels, foundLabels) + } + } yield result + test.map { result => + val (createdLabels, foundLabels) = result + assertEquals(foundLabels.sortBy(_.id), createdLabels.sortBy(_.id)) + } + case _ => fail("Could not generate data samples!") + } + } + + test("loadMilestones must return all milestones of a ticket".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genTicket.sample, genMilestones.sample) match { + case (Some(owner), Some(generatedProject), Some(ticket), Some(milestones)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val milestoneRepo = new DoobieMilestoneRepository[IO](tx) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + result <- projectId match { + case None => fail("Project ID not found in database!") + case Some(projectId) => + for { + _ <- ticket.submitter.traverse(createTicketsSubmitter) + _ <- ticketRepo.createTicket(projectId)(ticket) + _ <- milestones.traverse(milestone => milestoneRepo.createMilestone(projectId)(milestone)) + createdMilestones <- milestoneRepo.allMilestones(projectId).compile.toList + _ <- createdMilestones.traverse(cm => ticketRepo.addMilestone(projectId)(ticket.number)(cm)) + foundMilestones <- ticketRepo.loadMilestones(projectId)(ticket.number).compile.toList + } yield (createdMilestones, foundMilestones) + } + } yield result + test.map { result => + val (createdMilestones, foundMilestones) = result + assertEquals(foundMilestones.sortBy(_.title), createdMilestones.sortBy(_.title)) + } + case _ => fail("Could not generate data samples!") + } + } + + test("removeAssignee must remove the assignees from the ticket".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genTicket.sample, genTicketsUser.sample) match { + case (Some(owner), Some(generatedProject), Some(ticket), Some(user)) => + val assignee = Assignee(AssigneeId(user.uid.toUUID), AssigneeName(user.name.toString)) + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- createTicketsUser(user) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + _ <- ticket.submitter.traverse(createTicketsSubmitter) + foundAssignees <- projectId match { + case None => IO.pure(Nil) + case Some(projectId) => + for { + _ <- ticketRepo.createTicket(projectId)(ticket) + _ <- ticketRepo.addAssignee(projectId)(ticket.number)(assignee) + _ <- ticketRepo.removeAssignee(projectId)(ticket)(assignee) + foundAssignees <- ticketRepo.loadAssignees(projectId)(ticket.number).compile.toList + } yield foundAssignees + } + } yield foundAssignees + test.map { foundAssignees => + assertEquals(foundAssignees, Nil) + } + case _ => fail("Could not generate data samples!") + } + } + + test("removeLabel must remove the label from the ticket".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genTicket.sample, genLabel.sample) match { + case (Some(owner), Some(generatedProject), Some(ticket), Some(label)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val labelRepo = new DoobieLabelRepository[IO](tx) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + foundLabels <- projectId match { + case None => fail("Project ID not found in database!") + case Some(projectId) => + for { + _ <- ticket.submitter.traverse(createTicketsSubmitter) + _ <- labelRepo.createLabel(projectId)(label) + createdLabel <- labelRepo.findLabel(projectId)(label.name) + _ <- ticketRepo.createTicket(projectId)(ticket) + _ <- createdLabel.traverse(cl => ticketRepo.addLabel(projectId)(ticket.number)(cl)) + _ <- createdLabel.traverse(cl => ticketRepo.removeLabel(projectId)(ticket)(cl)) + foundLabels <- loadTicketLabelIds(projectId, ticket.number) + } yield foundLabels + } + } yield foundLabels + test.map { foundLabels => + assertEquals(foundLabels, Nil) + } + case _ => fail("Could not generate data samples!") + } + } + + test("removeMilestone must remove the milestone from the ticket".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genTicket.sample, genMilestone.sample) match { + case (Some(owner), Some(generatedProject), Some(ticket), Some(milestone)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val milestoneRepo = new DoobieMilestoneRepository[IO](tx) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + foundMilestones <- projectId match { + case None => fail("Project ID not found in database!") + case Some(projectId) => + for { + _ <- ticket.submitter.traverse(createTicketsSubmitter) + _ <- milestoneRepo.createMilestone(projectId)(milestone) + createdMilestone <- milestoneRepo.findMilestone(projectId)(milestone.title) + _ <- ticketRepo.createTicket(projectId)(ticket) + _ <- createdMilestone.traverse(ms => ticketRepo.addMilestone(projectId)(ticket.number)(ms)) + _ <- createdMilestone.traverse(ms => ticketRepo.removeMilestone(projectId)(ticket)(ms)) + foundMilestones <- loadTicketMilestoneIds(projectId, ticket.number) + } yield foundMilestones + } + } yield foundMilestones + test.map { foundMilestones => + assertEquals(foundMilestones, Nil) + } + case _ => fail("Could not generate data samples!") + } + } + + test("updateTicket must update the ticket in the database".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genTicket.sample, genTicket.sample) match { + case (Some(owner), Some(generatedProject), Some(ticket), Some(anotherTicket)) => + val project = generatedProject.copy(owner = owner) + val updatedTicket = + ticket.copy(title = anotherTicket.title, content = anotherTicket.content, submitter = anotherTicket.submitter) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- ticket.submitter.traverse(createTicketsSubmitter) + _ <- updatedTicket.submitter.traverse(createTicketsSubmitter) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + _ <- projectId.traverse(projectId => ticketRepo.createTicket(projectId)(ticket)) + _ <- projectId.traverse(projectId => ticketRepo.updateTicket(projectId)(updatedTicket)) + foundTicket <- projectId.traverse(projectId => ticketRepo.findTicket(projectId)(ticket.number)) + } yield foundTicket.getOrElse(None) + test.map { foundTicket => + foundTicket match { + case None => fail("Created ticket not found!") + case Some(foundTicket) => + assertEquals( + foundTicket, + updatedTicket.copy(createdAt = foundTicket.createdAt, updatedAt = foundTicket.updatedAt) + ) + } + } + case _ => fail("Could not generate data samples!") + } + } + +} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala 2025-01-16 05:02:15.367735375 +0000 @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package de.smederee.tickets + +import cats.effect._ +import de.smederee.TestTags._ +import de.smederee.tickets.Generators._ +import doobie._ + +final class DoobieTicketServiceApiTest extends BaseSpec { + test("createOrUpdateUser must create new users".tag(NeedsDatabase)) { + genTicketsUser.sample match { + case Some(user) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val api = new DoobieTicketServiceApi[IO](tx) + val test = for { + written <- api.createOrUpdateUser(user) + foundUser <- loadTicketsUser(user.uid) + } yield (written, foundUser) + test.map { result => + val (written, foundUser) = result + assert(written > 0, "No rows written to database!") + assertEquals(foundUser, Some(user)) + } + + case _ => fail("Could not generate data samples!") + } + } + + test("createOrUpdateUser must update existing users".tag(NeedsDatabase)) { + (genTicketsUser.sample, genTicketsUser.sample) match { + case (Some(user), Some(anotherUser)) => + val updatedUser = anotherUser.copy(uid = user.uid) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val api = new DoobieTicketServiceApi[IO](tx) + val test = for { + created <- api.createOrUpdateUser(user) + updated <- api.createOrUpdateUser(updatedUser) + foundUser <- loadTicketsUser(user.uid) + } yield (created, updated, foundUser) + test.map { result => + val (created, updated, foundUser) = result + assert(created > 0, "No rows written to database!") + assert(updated > 0, "No rows updated in database!") + assertEquals(foundUser, Some(updatedUser)) + } + + case _ => fail("Could not generate data samples!") + } + } + + test("deleteUser must delete existing users".tag(NeedsDatabase)) { + genTicketsUser.sample match { + case Some(user) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val api = new DoobieTicketServiceApi[IO](tx) + val test = for { + _ <- api.createOrUpdateUser(user) + deleted <- api.deleteUser(user.uid) + foundUser <- loadTicketsUser(user.uid) + } yield (deleted, foundUser) + test.map { result => + val (deleted, foundUser) = result + assert(deleted > 0, "No rows deleted from database!") + assert(foundUser.isEmpty, "User not deleted from database!") + } + + case _ => fail("Could not generate data samples!") + } + } +} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala 2025-01-16 05:02:15.363735377 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala 2025-01-16 05:02:15.367735375 +0000 @@ -122,6 +122,8 @@ language <- Gen.option(genLanguageCode) } yield TicketsUser(uid, name, email, language) + val genTicketsUsers: Gen[List[TicketsUser]] = Gen.nonEmptyListOf(genTicketsUser) + val genTicket: Gen[Ticket] = for { number <- genTicketNumber title <- genTicketTitle @@ -143,7 +145,7 @@ ) val genTickets: Gen[List[Ticket]] = - Gen.nonEmptyListOf(genTicket).map(_.zipWithIndex.map(tuple => tuple._1.copy(number = TicketNumber(tuple._2)))) + Gen.nonEmptyListOf(genTicket).map(_.zipWithIndex.map(tuple => tuple._1.copy(number = TicketNumber(tuple._2 + 1)))) val genProjectOwnerName: Gen[ProjectOwnerName] = for { length <- Gen.choose(2, 30) diff -rN -u old-smederee/README.md new-smederee/README.md --- old-smederee/README.md 2025-01-16 05:02:15.359735379 +0000 +++ new-smederee/README.md 2025-01-16 05:02:15.367735375 +0000 @@ -62,9 +62,20 @@ ### Tests ### -Tests are included in the project. You can run them via the appropriate sbt tasks -`test` and `IntegrationTest/test`. The latter will execute the integration tests. -Be aware that the integration tests might need a working database. +Tests are included in the project. You can run them via the sbt task `test`. +Be aware that the some tests might need a working database. These should be +tagged accordingly and can be excluded or included via the following +parameters for the test task: + +- `--exclude-tags=NAME` (do _not_ run any tests labelled with `NAME`) +- `--include-tags=NAME` (do _only_ run tests labelled with `NAME`) + +For example to not run any tests needing a database connection in the hub +module: + +``` +sbt:smederee> hub/testOnly -- --exclude-tags=NeedsDatabase +``` ## Deployment guide ##