~jan0sch/smederee

Showing details for patch 431fe77d201d8bbff8b3ac6e046cae501f5dfb3b.
2024-07-09 (Tue), 11:58 AM - Jens Grassel - 431fe77d201d8bbff8b3ac6e046cae501f5dfb3b

Tickets: Prepare adding of comments to tickets

- add database table for ticket comments
- add `TicketComment`
- add functions to add and load comments to `TicketRepository`
- implement functions in `DoobieTicketRepository`
- add generators for testing
- add Quicklens library for easy optics
Summary of changes
1 files added
  • modules/hub/src/main/resources/db/migration/tickets/V6__ticket_comments.sql
6 files modified with 256 lines added and 38 lines removed
  • build.sbt with 41 added and 38 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala with 33 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/Ticket.scala with 49 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/TicketRepository.scala with 24 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala with 96 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/tickets/Generators.scala with 13 added and 0 removed lines
diff -rN -u old-smederee/build.sbt new-smederee/build.sbt
--- old-smederee/build.sbt	2025-01-11 05:59:14.355781914 +0000
+++ new-smederee/build.sbt	2025-01-11 05:59:14.359781919 +0000
@@ -167,6 +167,7 @@
                 library.osLib,
                 library.postgresql,
                 library.pureConfig,
+                library.quickLens,
                 library.springSecurityCrypto,
                 library.munit           % Test,
                 library.munitCatsEffect % Test,
@@ -292,48 +293,50 @@
             val osLib           = "0.10.2"
             val postgresql      = "42.7.3"
             val pureConfig      = "0.17.7"
+            val quickLens       = "1.9.7"
             val scalaCheck      = "1.18.0"
             val simpleJavaMail  = "8.11.2"
             val springSecurity  = "6.3.1"
         }
-        val apacheSshdCore       = "org.apache.sshd"        % "sshd-core"                  % Version.apacheSshd
-        val apacheSshdSftp       = "org.apache.sshd"        % "sshd-sftp"                  % Version.apacheSshd
-        val apacheSshdScp        = "org.apache.sshd"        % "sshd-scp"                   % Version.apacheSshd
-        val bouncyCastleProvider = "org.bouncycastle"       % "bcprov-jdk18on"             % Version.bouncyCastle
-        val catsCore             = "org.typelevel"         %% "cats-core"                  % Version.cats
-        val catsEffect           = "org.typelevel"         %% "cats-effect"                % Version.catsEffect
-        val circeCore            = "io.circe"              %% "circe-core"                 % Version.circe
-        val circeGeneric         = "io.circe"              %% "circe-generic"              % Version.circe
-        val circeParser          = "io.circe"              %% "circe-parser"               % Version.circe
-        val doobieCore           = "org.tpolecat"          %% "doobie-core"                % Version.doobie
-        val doobieHikari         = "org.tpolecat"          %% "doobie-hikari"              % Version.doobie
-        val doobiePostgres       = "org.tpolecat"          %% "doobie-postgres"            % Version.doobie
-        val doobieScalaTest      = "org.tpolecat"          %% "doobie-scalatest"           % Version.doobie
-        val ed25519Java          = "net.i2p.crypto"         % "eddsa"                      % "0.3.0"
-        val flywayCore           = "org.flywaydb"           % "flyway-core"                % Version.flyway
-        val flywayPostgreSQL     = "org.flywaydb"           % "flyway-database-postgresql" % Version.flyway
-        val fs2Core              = "co.fs2"                %% "fs2-core"                   % Version.fs2
-        val fs2IO                = "co.fs2"                %% "fs2-io"                     % Version.fs2
-        val http4sCirce          = "org.http4s"            %% "http4s-circe"               % Version.http4s
-        val http4sCore           = "org.http4s"            %% "http4s-core"                % Version.http4s
-        val http4sDsl            = "org.http4s"            %% "http4s-dsl"                 % Version.http4s
-        val http4sEmberServer    = "org.http4s"            %% "http4s-ember-server"        % Version.http4s
-        val http4sEmberClient    = "org.http4s"            %% "http4s-ember-client"        % Version.http4s
-        val ip4sCore             = "com.comcast"           %% "ip4s-core"                  % Version.ip4s
-        val jansi                = "com.github.Osiris-Team" % "jansi"                      % Version.jansi
-        val jclOverSlf4j         = "org.slf4j"              % "jcl-over-slf4j"             % Version.jclOverSlf4j
-        val laikaCore            = "org.typelevel"         %% "laika-core"                 % Version.laika
-        val log4catsSlf4j        = "org.typelevel"         %% "log4cats-slf4j"             % Version.log4cats
-        val logback              = "ch.qos.logback"         % "logback-classic"            % Version.logback
-        val munit                = "org.scalameta"         %% "munit"                      % Version.munit
-        val munitCatsEffect      = "org.typelevel"         %% "munit-cats-effect"          % Version.munitCatsEffect
-        val munitScalaCheck      = "org.scalameta"         %% "munit-scalacheck"           % Version.munit
-        val osLib                = "com.lihaoyi"           %% "os-lib"                     % Version.osLib
-        val postgresql           = "org.postgresql"         % "postgresql"                 % Version.postgresql
-        val pureConfig           = "com.github.pureconfig" %% "pureconfig-core"            % Version.pureConfig
-        val scalaCheck           = "org.scalacheck"        %% "scalacheck"                 % Version.scalaCheck
-        val simpleJavaMail       = "org.simplejavamail"     % "simple-java-mail"           % Version.simpleJavaMail
-        val springSecurityCrypto = "org.springframework.security" % "spring-security-crypto" % Version.springSecurity
+        val apacheSshdCore       = "org.apache.sshd"              % "sshd-core"                  % Version.apacheSshd
+        val apacheSshdSftp       = "org.apache.sshd"              % "sshd-sftp"                  % Version.apacheSshd
+        val apacheSshdScp        = "org.apache.sshd"              % "sshd-scp"                   % Version.apacheSshd
+        val bouncyCastleProvider = "org.bouncycastle"             % "bcprov-jdk18on"             % Version.bouncyCastle
+        val catsCore             = "org.typelevel"               %% "cats-core"                  % Version.cats
+        val catsEffect           = "org.typelevel"               %% "cats-effect"                % Version.catsEffect
+        val circeCore            = "io.circe"                    %% "circe-core"                 % Version.circe
+        val circeGeneric         = "io.circe"                    %% "circe-generic"              % Version.circe
+        val circeParser          = "io.circe"                    %% "circe-parser"               % Version.circe
+        val doobieCore           = "org.tpolecat"                %% "doobie-core"                % Version.doobie
+        val doobieHikari         = "org.tpolecat"                %% "doobie-hikari"              % Version.doobie
+        val doobiePostgres       = "org.tpolecat"                %% "doobie-postgres"            % Version.doobie
+        val doobieScalaTest      = "org.tpolecat"                %% "doobie-scalatest"           % Version.doobie
+        val ed25519Java          = "net.i2p.crypto"               % "eddsa"                      % "0.3.0"
+        val flywayCore           = "org.flywaydb"                 % "flyway-core"                % Version.flyway
+        val flywayPostgreSQL     = "org.flywaydb"                 % "flyway-database-postgresql" % Version.flyway
+        val fs2Core              = "co.fs2"                      %% "fs2-core"                   % Version.fs2
+        val fs2IO                = "co.fs2"                      %% "fs2-io"                     % Version.fs2
+        val http4sCirce          = "org.http4s"                  %% "http4s-circe"               % Version.http4s
+        val http4sCore           = "org.http4s"                  %% "http4s-core"                % Version.http4s
+        val http4sDsl            = "org.http4s"                  %% "http4s-dsl"                 % Version.http4s
+        val http4sEmberServer    = "org.http4s"                  %% "http4s-ember-server"        % Version.http4s
+        val http4sEmberClient    = "org.http4s"                  %% "http4s-ember-client"        % Version.http4s
+        val ip4sCore             = "com.comcast"                 %% "ip4s-core"                  % Version.ip4s
+        val jansi                = "com.github.Osiris-Team"       % "jansi"                      % Version.jansi
+        val jclOverSlf4j         = "org.slf4j"                    % "jcl-over-slf4j"             % Version.jclOverSlf4j
+        val laikaCore            = "org.typelevel"               %% "laika-core"                 % Version.laika
+        val log4catsSlf4j        = "org.typelevel"               %% "log4cats-slf4j"             % Version.log4cats
+        val logback              = "ch.qos.logback"               % "logback-classic"            % Version.logback
+        val munit                = "org.scalameta"               %% "munit"                      % Version.munit
+        val munitCatsEffect      = "org.typelevel"               %% "munit-cats-effect"          % Version.munitCatsEffect
+        val munitScalaCheck      = "org.scalameta"               %% "munit-scalacheck"           % Version.munit
+        val osLib                = "com.lihaoyi"                 %% "os-lib"                     % Version.osLib
+        val postgresql           = "org.postgresql"               % "postgresql"                 % Version.postgresql
+        val pureConfig           = "com.github.pureconfig"       %% "pureconfig-core"            % Version.pureConfig
+        val quickLens            = "com.softwaremill.quicklens"  %% "quicklens"                  % Version.quickLens
+        val scalaCheck           = "org.scalacheck"              %% "scalacheck"                 % Version.scalaCheck
+        val simpleJavaMail       = "org.simplejavamail"           % "simple-java-mail"           % Version.simpleJavaMail
+        val springSecurityCrypto = "org.springframework.security" % "spring-security-crypto"     % Version.springSecurity
     }
 
 // *****************************************************************************
diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/tickets/V6__ticket_comments.sql new-smederee/modules/hub/src/main/resources/db/migration/tickets/V6__ticket_comments.sql
--- old-smederee/modules/hub/src/main/resources/db/migration/tickets/V6__ticket_comments.sql	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/tickets/V6__ticket_comments.sql	2025-01-11 05:59:14.359781919 +0000
@@ -0,0 +1,24 @@
+CREATE TABLE tickets.ticket_comments
+(
+  id         BIGINT                   GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+  ticket     BIGINT                   NOT NULL,
+  content    TEXT                     NOT NULL,
+  submitter  UUID                     DEFAULT NULL,
+  created_at TIMESTAMP WITH TIME ZONE NOT NULL,
+  updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
+  CONSTRAINT ticket_comments_fk_ticket FOREIGN KEY (ticket)
+    REFERENCES tickets.tickets (id) ON UPDATE CASCADE ON DELETE CASCADE,
+  CONSTRAINT ticket_comments_fk_submitter FOREIGN KEY (submitter)
+    REFERENCES tickets.users (uid) ON UPDATE CASCADE ON DELETE SET NULL
+)
+WITH (
+  OIDS=FALSE
+);
+
+COMMENT ON TABLE tickets.ticket_comments IS 'Comments on tickets.';
+COMMENT ON COLUMN tickets.ticket_comments.id IS 'An auto generated primary key.';
+COMMENT ON COLUMN tickets.ticket_comments.ticket IS 'The unique ID of the ticket to which the comment belongs.';
+COMMENT ON COLUMN tickets.ticket_comments.content IS 'Required field to hold the content of the comment.';
+COMMENT ON COLUMN tickets.ticket_comments.submitter IS 'The unique ID of the user account that created the comment.';
+COMMENT ON COLUMN tickets.ticket_comments.created_at IS 'The timestamp of when the comment was created.';
+COMMENT ON COLUMN tickets.ticket_comments.updated_at IS 'A timestamp when the comment was last changed.';
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala	2025-01-11 05:59:14.359781919 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala	2025-01-11 05:59:14.359781919 +0000
@@ -36,6 +36,7 @@
     given Meta[ProjectId]            = Meta[Long].timap(ProjectId.apply)(_.toLong)
     given Meta[SubmitterId]          = Meta[UUID].timap(SubmitterId.apply)(_.toUUID)
     given Meta[SubmitterName]        = Meta[String].timap(SubmitterName.apply)(_.toString)
+    given Meta[TicketCommentContent] = Meta[String].timap(TicketCommentContent.apply)(_.toString)
     given Meta[TicketContent]        = Meta[String].timap(TicketContent.apply)(_.toString)
     given Meta[TicketId]             = Meta[Long].timap(TicketId.apply)(_.toLong)
     given Meta[TicketNumber]         = Meta[Int].timap(TicketNumber.apply)(_.toInt)
@@ -86,6 +87,23 @@
           WHERE project = $projectId
           AND number = $ticketNumber""".update.run.transact(tx)
 
+    override def addComment(projectId: ProjectId)(ticketNumber: TicketNumber)(comment: TicketComment): F[Int] =
+        sql"""INSERT INTO tickets.ticket_comments (
+            ticket,
+            content,
+            submitter,
+            created_at,
+            updated_at
+          ) SELECT
+            id,
+            ${comment.content},
+            ${comment.submitter.map(_.id)},
+            ${comment.createdAt},
+            ${comment.updatedAt}
+          FROM tickets.tickets
+          WHERE project = $projectId
+          AND number = $ticketNumber""".update.run.transact(tx)
+
     override def addLabel(projectId: ProjectId)(ticketNumber: TicketNumber)(label: Label): F[Int] =
         label.id match {
             case None => Sync[F].pure(0)
@@ -191,6 +209,21 @@
         sqlQuery.query[Assignee].stream.transact(tx)
     }
 
+    override def loadComments(projectId: ProjectId)(ticketNumber: TicketNumber): Stream[F, TicketComment] = {
+        val sqlQuery = withTicketId(projectId, ticketNumber) ++
+            fr"""SELECT
+                comments.content AS content,
+                submitters.uid AS submitter_uid,
+                submitters.name AS submitter_name,
+                comments.created_at AS created_at,
+                comments.updated_at AS updated_at
+            FROM tickets.ticket_comments AS comments
+            LEFT OUTER JOIN tickets.users AS submitters
+            ON comments.submitter = submitters.uid
+            WHERE comments.ticket = (SELECT id FROM ticket_id)"""
+        sqlQuery.query[TicketComment].stream.transact(tx)
+    }
+
     override def loadLabels(projectId: ProjectId)(ticketNumber: TicketNumber): Stream[F, Label] = {
         val sqlQuery = withTicketId(projectId, ticketNumber) ++
             fr"""SELECT
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRepository.scala	2025-01-11 05:59:14.359781919 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRepository.scala	2025-01-11 05:59:14.359781919 +0000
@@ -28,6 +28,19 @@
       */
     def addAssignee(projectId: ProjectId)(ticketNumber: TicketNumber)(assignee: Assignee): F[Int]
 
+    /** Add the given comment to the ticket of the given project id.
+      *
+      * @param projectId
+      *   The unique internal ID of a ticket tracking project.
+      * @param ticketNumber
+      *   The unique identifier of a ticket within the project scope is its number.
+      * @param comment
+      *   The comment to be added to the ticket.
+      * @return
+      *   The number of affected database rows.
+      */
+    def addComment(projectId: ProjectId)(ticketNumber: TicketNumber)(comment: TicketComment): F[Int]
+
     /** Add the given label to the ticket of the given repository id.
       *
       * @param projectId
@@ -121,6 +134,17 @@
       */
     def loadAssignees(projectId: ProjectId)(ticketNumber: TicketNumber): Stream[F, Assignee]
 
+    /** Load all comments that are attached to the ticket with the given number and project id.
+      *
+      * @param projectId
+      *   The unique internal ID of a ticket tracking project.
+      * @param ticketNumber
+      *   The unique identifier of a ticket within the project scope is its number.
+      * @return
+      *   A stream of comments that may be empty.
+      */
+    def loadComments(projectId: ProjectId)(ticketNumber: TicketNumber): Stream[F, TicketComment]
+
     /** Load all labels that are attached to the ticket with the given number and repository id.
       *
       * @param projectId
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Ticket.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Ticket.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Ticket.scala	2025-01-11 05:59:14.359781919 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Ticket.scala	2025-01-11 05:59:14.359781919 +0000
@@ -16,6 +16,31 @@
 
 import scala.util.matching.Regex
 
+/** A non empty unlimited string for comments on tickets.
+  */
+opaque type TicketCommentContent = String
+object TicketCommentContent {
+
+    /** Create an instance of TicketCommentContent from the given String type.
+      *
+      * @param source
+      *   An instance of type String which will be returned as a TicketCommentContent.
+      * @return
+      *   The appropriate instance of TicketCommentContent.
+      */
+    def apply(source: String): TicketCommentContent = source
+
+    /** Try to create an instance of TicketCommentContent from the given String.
+      *
+      * @param source
+      *   A String that should fulfil the requirements to be converted into a TicketCommentContent.
+      * @return
+      *   An option to the successfully converted TicketCommentContent.
+      */
+    def from(source: String): Option[TicketCommentContent] = Option(source).filter(_.nonEmpty)
+
+}
+
 /** An unlimited text field which must be not empty to describe the ticket in great detail if needed.
   */
 opaque type TicketContent = String
@@ -280,6 +305,30 @@
     updatedAt: OffsetDateTime
 )
 
+/** A comment on a ticket.
+  *
+  * @param content
+  *   The content of the comment which must be non empty.
+  * @param submitter
+  *   The person who submitted (created) the comment which is optional because of possible account deletion or other
+  *   reasons.
+  * @param createdAt
+  *   The timestamp when the ticket was created / submitted.
+  * @param updatedAt
+  *   The timestamp when the ticket was last updated. Upon creation the update time equals the creation time.
+  */
+final case class TicketComment(
+    content: TicketCommentContent,
+    submitter: Option[Submitter],
+    createdAt: OffsetDateTime,
+    updatedAt: OffsetDateTime
+)
+
+object TicketComment {
+    given Ordering[TicketComment] = (x: TicketComment, y: TicketComment) => x.createdAt.compareTo(y.createdAt)
+    given Order[TicketComment]    = Order.fromOrdering
+}
+
 /** A data container for values that can be used to filter a list of tickets by.
   *
   * @param number
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala	2025-01-11 05:59:14.359781919 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala	2025-01-11 05:59:14.359781919 +0000
@@ -7,9 +7,11 @@
 package de.smederee.tickets
 
 import java.time.OffsetDateTime
+import java.time.ZoneOffset
 
 import cats.effect.*
 import cats.syntax.all.*
+import com.softwaremill.quicklens.*
 import de.smederee.TestTags.*
 import de.smederee.tickets.Generators.*
 import doobie.*
@@ -115,6 +117,50 @@
         }
     }
 
+    test("addComment must save comment to the database".tag(NeedsDatabase)) {
+        (
+            genProjectOwner.sample,
+            genProject.sample,
+            genTicket.sample,
+            genTicketsUser.sample,
+            genTicketComment.sample
+        ) match {
+            case (Some(owner), Some(generatedProject), Some(ticket), Some(user), Some(comment)) =>
+                val project  = generatedProject.copy(owner = owner)
+                val dbConfig = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                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)
+                    _         <- comment.submitter.traverse(createTicketsSubmitter)
+                    _         <- projectId.traverse(projectId => ticketRepo.createTicket(projectId)(ticket))
+                    written <- projectId.traverse(projectId => ticketRepo.addComment(projectId)(ticket.number)(comment))
+                    foundComments <- projectId.traverse(projectId =>
+                        ticketRepo.loadComments(projectId)(ticket.number).compile.toList
+                    )
+                } yield (written.getOrElse(0), foundComments.getOrElse(Nil))
+                test.map { result =>
+                    val (written, foundComments) = result
+                    assert(written > 0, "No rows written to database!")
+                    assertEquals(
+                        foundComments.map(_.copy(createdAt = comment.createdAt, updatedAt = comment.updatedAt)),
+                        List(comment)
+                    )
+                }
+            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)) =>
@@ -640,6 +686,56 @@
                 }
             case _ => fail("Could not generate data samples!")
         }
+    }
+
+    test("loadComments must return all comments of a ticket".tag(NeedsDatabase)) {
+        (
+            genProjectOwner.sample,
+            genProject.sample,
+            genTicket.sample,
+            genTicketsUser.sample,
+            genTicketComments.sample
+        ) match {
+            case (Some(owner), Some(generatedProject), Some(ticket), Some(user), Some(genComments)) =>
+                // Avoid time zone trouble.
+                val comments = genComments
+                    .modifyAll(_.each.createdAt, _.each.updatedAt)
+                    .using(_.withOffsetSameInstant(ZoneOffset.UTC))
+                val project  = generatedProject.copy(owner = owner)
+                val dbConfig = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                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))
+                    foundComments <- projectId match {
+                        case None => IO.pure(Nil)
+                        case Some(projectId) =>
+                            for {
+                                submitters <- IO(comments.map(_.submitter).flatten)
+                                _          <- submitters.traverse(createTicketsSubmitter)
+                                _ <- comments.traverse(comment =>
+                                    ticketRepo.addComment(projectId)(ticket.number)(comment)
+                                )
+                                foundComments <- ticketRepo.loadComments(projectId)(ticket.number).compile.toList
+                            } yield foundComments
+                    }
+                } yield foundComments
+                test.map { foundComments =>
+                    assertEquals(foundComments.sorted, comments.sorted)
+                }
+            case _ => fail("Could not generate data samples!")
+        }
     }
 
     test("loadLabels must return all labels of a ticket".tag(NeedsDatabase)) {
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/Generators.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/Generators.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/Generators.scala	2025-01-11 05:59:14.359781919 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/Generators.scala	2025-01-11 05:59:14.359781919 +0000
@@ -95,6 +95,9 @@
             email = chars.take(length).mkString
         } yield EmailAddress(email + "@example.com")
 
+    val genTicketCommentContent: Gen[TicketCommentContent] =
+        Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.mkString).map(TicketCommentContent.apply)
+
     val genTicketContent: Gen[Option[TicketContent]] = Gen.alphaStr.map(TicketContent.from)
 
     val genTicketStatus: Gen[TicketStatus]           = Gen.oneOf(TicketStatus.values.toList)
@@ -142,6 +145,16 @@
         Gen.nonEmptyListOf(genTicket)
             .map(_.zipWithIndex.map(tuple => tuple._1.copy(number = TicketNumber(tuple._2 + 1))))
 
+    val genTicketComment: Gen[TicketComment] =
+        for {
+            content   <- genTicketCommentContent
+            submitter <- Gen.option(genSubmitter)
+            createdAt <- genOffsetDateTime
+            updatedAt <- genOffsetDateTime
+        } yield TicketComment(content, submitter, createdAt, updatedAt)
+
+    val genTicketComments: Gen[List[TicketComment]] = Gen.nonEmptyListOf(genTicketComment)
+
     val genTicketFilter: Gen[TicketFilter] =
         for {
             number     <- Gen.listOf(genTicketNumber)