~jan0sch/smederee

Showing details for patch 07a6440caec01be6e199b0618de8943cb08f413a.
2023-06-13 (Tue), 3:15 PM - Jens Grassel - 07a6440caec01be6e199b0618de8943cb08f413a

Testing: Remove IntegrationTest build axis and replace with tagged tests.

In preparation of upgrading sbt in the future the IntegrationTest build axis is
removed in favour of tests that need external ressources being tagged
accordingly.

This uses the tag feature of the munit test framework in use. See the following
url for details:

https://scalameta.org/munit/docs/filtering.html#include-and-exclude-tests-based-on-tags

The integration test code is moved over into the test directories and tagged.
Also the now obsolete `it` directories are removed and the build configuration
is adjusted.

Details and an example how to run tests are included in the README.
Summary of changes
20 files added
  • modules/hub/src/test/resources/application.conf
  • modules/hub/src/test/scala/de/smederee/TestTags.scala
  • modules/hub/src/test/scala/de/smederee/hub/AuthenticationMiddlewareTest.scala
  • modules/hub/src/test/scala/de/smederee/hub/BaseSpec.scala
  • modules/hub/src/test/scala/de/smederee/hub/DatabaseMigratorTest.scala
  • modules/hub/src/test/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala
  • modules/hub/src/test/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala
  • modules/hub/src/test/scala/de/smederee/hub/DoobieSignupRepositoryTest.scala
  • modules/hub/src/test/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala
  • modules/hub/src/test/scala/de/smederee/ssh/DoobieSshAuthenticationRepositoryTest.scala
  • modules/hub/src/test/scala/de/smederee/ssh/SshServerProviderTest.scala
  • modules/tickets/src/test/resources/application.conf
  • modules/tickets/src/test/scala/de/smederee/TestTags.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/BaseSpec.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala
16 files modified with 2,036 lines added and 82 lines removed
  • README.md with 14 added and 3 removed lines
  • build.sbt with 34 added and 78 removed lines
  • modules/hub/src/test/resources/application.conf with 12 added and 0 removed lines
  • modules/hub/src/test/resources/logback-test.xml with 4 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/TestTags.scala with 25 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/AuthenticationMiddlewareTest.scala with 117 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/BaseSpec.scala with 381 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/DatabaseMigratorTest.scala with 57 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala with 242 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala with 354 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/DoobieSignupRepositoryTest.scala with 132 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala with 544 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/ssh/DoobieSshAuthenticationRepositoryTest.scala with 43 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/ssh/SshServerProviderTest.scala with 70 added and 0 removed lines
  • modules/tickets/src/test/resources/logback-test.xml with 4 added and 0 removed lines
  • modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala with 3 added and 1 removed lines
25 files removed
  • modules/tickets/src/it/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala
  • modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala
  • modules/tickets/src/it/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala
  • modules/tickets/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala
  • modules/tickets/src/it/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala
  • modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala
  • modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala
  • modules/tickets/src/it/scala/de/smederee/tickets/Generators.scala
  • modules/tickets/src/it/resources/application.conf
  • modules/tickets/src/it/resources/logback-test.xml
  • modules/hub/src/it/scala/de/smederee/ssh/DoobieSshAuthenticationRepositoryTest.scala
  • modules/hub/src/it/scala/de/smederee/ssh/SshServerProviderTest.scala
  • modules/hub/src/it/scala/de/smederee/hub/AuthenticationMiddlewareTest.scala
  • modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala
  • modules/hub/src/it/scala/de/smederee/hub/DatabaseMigratorTest.scala
  • modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala
  • modules/hub/src/it/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala
  • modules/hub/src/it/scala/de/smederee/hub/DoobieSignupRepositoryTest.scala
  • modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala
  • modules/hub/src/it/scala/de/smederee/hub/Generators.scala
  • modules/hub/src/it/resources/de/smederee/ssh/ssh-key-with-comment.pub
  • modules/hub/src/it/resources/de/smederee/ssh/ssh-key-without-comment.pub
  • modules/hub/src/it/resources/application.conf
  • modules/hub/src/it/resources/logback-test.xml
  • modules/darcs/src/it/resources/logback-test.xml
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 ##