~jan0sch/smederee
Showing details for patch 431fe77d201d8bbff8b3ac6e046cae501f5dfb3b.
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)