~jan0sch/smederee
Showing details for patch 115c9f0f01e9f7c103de0e9190e0359afc4920c5.
diff -rN -u old-smederee/build.sbt new-smederee/build.sbt --- old-smederee/build.sbt 2025-01-12 05:25:38.799741485 +0000 +++ new-smederee/build.sbt 2025-01-12 05:25:38.811741504 +0000 @@ -64,7 +64,7 @@ publish := {}, publishLocal := {} ) - .aggregate(darcs, email, htmlUtils, hub, i18n, security, tickets, twirl) + .aggregate(darcs, email, htmlUtils, hub, i18n, security, twirl) lazy val darcs = project @@ -135,7 +135,7 @@ lazy val hub = project .in(file("modules/hub")) - .dependsOn(darcs, email, htmlUtils, i18n, security, tickets, twirl) + .dependsOn(darcs, email, htmlUtils, i18n, security, twirl) .enablePlugins( AutomateHeaderPlugin, BuildInfoPlugin, @@ -270,65 +270,6 @@ ) ) -lazy val tickets = - project - .in(file("modules/tickets")) - .dependsOn(email, htmlUtils, i18n, security, twirl) - .enablePlugins( - AutomateHeaderPlugin, - BuildInfoPlugin, - SbtTwirl, - ) - .settings(commonSettings) - .settings( - name := "smederee-tickets", - buildInfoKeys := Seq[BuildInfoKey](name, version, scalaVersion), - buildInfoPackage := "de.smederee.tickets", - libraryDependencies ++= Seq( - library.catsCore, - library.circeCore, - library.circeGeneric, - library.circeParser, - library.doobieCore, - library.doobieHikari, - library.doobiePostgres, - library.flywayCore, - library.flywayPostgreSQL, - library.http4sCirce, - library.http4sCore, - library.http4sDsl, - library.http4sEmberClient, - library.http4sEmberServer, - // library.http4sTwirl, - library.jclOverSlf4j, // Bridge Java Commons Logging to SLF4J. - library.log4catsSlf4j, - library.logback, - library.postgresql, - library.pureConfig, - library.springSecurityCrypto, - library.munit % Test, - library.munitCatsEffect % Test, - library.munitScalaCheck % Test, - library.scalaCheck % Test - ) - ) - .settings( - libraryDependencies := libraryDependencies.value.map { - case module if module.name == "twirl-api" => - module.cross(CrossVersion.for3Use2_13).exclude("org.scala-lang.modules", "scala-xml_2.13") - case module => module - } ++ Seq("org.scala-lang.modules" %% "scala-xml" % "2.2.0"), - TwirlKeys.templateImports ++= Seq( - "cats.*", - "cats.data.*", - "cats.syntax.all.*", - "de.smederee.html.*", - "de.smederee.i18n.*", - "de.smederee.security.{ CsrfToken, UserId, Username }", - "org.http4s.Uri" - ) - ) - // FIXME: This is a workaround until http4s-twirl gets published properly for Scala 3! lazy val twirl = project diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/tickets/V1__create_schema.sql new-smederee/modules/hub/src/main/resources/db/migration/tickets/V1__create_schema.sql --- old-smederee/modules/hub/src/main/resources/db/migration/tickets/V1__create_schema.sql 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/resources/db/migration/tickets/V1__create_schema.sql 2025-01-12 05:25:38.811741504 +0000 @@ -0,0 +1,3 @@ +CREATE SCHEMA IF NOT EXISTS tickets; + +COMMENT ON SCHEMA tickets IS 'Data related to ticket tracking.'; diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/tickets/V2__base_tables.sql new-smederee/modules/hub/src/main/resources/db/migration/tickets/V2__base_tables.sql --- old-smederee/modules/hub/src/main/resources/db/migration/tickets/V2__base_tables.sql 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/resources/db/migration/tickets/V2__base_tables.sql 2025-01-12 05:25:38.811741504 +0000 @@ -0,0 +1,200 @@ +CREATE TABLE tickets.users +( + uid UUID NOT NULL, + name CHARACTER VARYING(32) NOT NULL, + email CHARACTER VARYING(128) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT users_pk PRIMARY KEY (uid), + CONSTRAINT users_unique_name UNIQUE (name), + CONSTRAINT users_unique_email UNIQUE (email) +) +WITH ( + OIDS=FALSE +); + +COMMENT ON TABLE tickets.users IS 'All users for the ticket system live within this table.'; +COMMENT ON COLUMN tickets.users.uid IS 'A globally unique ID for the related user account. It must match the user ID from the hub account.'; +COMMENT ON COLUMN tickets.users.name IS 'A username between 2 and 32 characters which must be globally unique, contain only lowercase alphanumeric characters and start with a character.'; +COMMENT ON COLUMN tickets.users.email IS 'A globally unique email address associated with the account.'; +COMMENT ON COLUMN tickets.users.created_at IS 'The timestamp of when the account was created.'; +COMMENT ON COLUMN tickets.users.updated_at IS 'A timestamp when the account was last changed.'; + +CREATE TABLE tickets.sessions +( + id VARCHAR(32) NOT NULL, + uid UUID NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT sessions_pk PRIMARY KEY (id), + CONSTRAINT sessions_fk_uid FOREIGN KEY (uid) + REFERENCES tickets.users (uid) ON UPDATE CASCADE ON DELETE CASCADE +) +WITH ( + OIDS=FALSE +); + +COMMENT ON TABLE tickets.sessions IS 'Keeps the sessions of users.'; +COMMENT ON COLUMN tickets.sessions.id IS 'A globally unique session ID.'; +COMMENT ON COLUMN tickets.sessions.uid IS 'The unique ID of the user account to whom the session belongs.'; +COMMENT ON COLUMN tickets.sessions.created_at IS 'The timestamp of when the session was created.'; +COMMENT ON COLUMN tickets.sessions.updated_at IS 'The session ID should be re-generated in regular intervals resulting in a copy of the old session entry with a new ID and the corresponding timestamp in this column.'; + +CREATE TABLE tickets.projects +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name CHARACTER VARYING(64) NOT NULL, + owner UUID NOT NULL, + is_private BOOLEAN NOT NULL DEFAULT FALSE, + description CHARACTER VARYING(254), + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, + next_ticket_number INTEGER NOT NULL DEFAULT 1, + CONSTRAINT projects_unique_owner_name UNIQUE (owner, name), + CONSTRAINT projects_fk_uid FOREIGN KEY (owner) + REFERENCES tickets.users (uid) ON UPDATE CASCADE ON DELETE CASCADE +) +WITH ( + OIDS=FALSE +); + +COMMENT ON TABLE tickets.projects IS 'All projects which are basically mirrored repositories from the hub are stored within this table.'; +COMMENT ON COLUMN tickets.projects.id IS 'An auto generated primary key.'; +COMMENT ON COLUMN tickets.projects.name IS 'The name of the project. A project name must start with a letter or number and must contain only alphanumeric ASCII characters as well as minus or underscore signs. It must be between 2 and 64 characters long.'; +COMMENT ON COLUMN tickets.projects.owner IS 'The unique ID of the user account that owns the project.'; +COMMENT ON COLUMN tickets.projects.is_private IS 'A flag indicating if this project is private i.e. only visible / accessible for users with appropriate permissions.'; +COMMENT ON COLUMN tickets.projects.description IS 'An optional short text description of the project.'; +COMMENT ON COLUMN tickets.projects.created_at IS 'The timestamp of when the project was created.'; +COMMENT ON COLUMN tickets.projects.updated_at IS 'A timestamp when the project was last changed.'; +COMMENT ON COLUMN tickets.projects.next_ticket_number IS 'Tickets are numbered ascending per project and this field holds the next logical ticket number to be used and must be incremented upon creation of a new ticket.'; + +CREATE TABLE tickets.labels +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + project BIGINT NOT NULL, + name CHARACTER VARYING(40) NOT NULL, + description CHARACTER VARYING(254) DEFAULT NULL, + colour CHARACTER VARYING(7) NOT NULL, + CONSTRAINT labels_unique_project_label UNIQUE (project, name), + CONSTRAINT labels_fk_project FOREIGN KEY (project) + REFERENCES tickets.projects (id) ON UPDATE CASCADE ON DELETE CASCADE +) +WITH ( + OIDS=FALSE +); + +COMMENT ON TABLE tickets.labels IS 'Labels used to add information to tickets.'; +COMMENT ON COLUMN tickets.labels.id IS 'An auto generated primary key.'; +COMMENT ON COLUMN tickets.labels.project IS 'The project to which this label belongs.'; +COMMENT ON COLUMN tickets.labels.name IS 'A short descriptive name for the label which is supposed to be unique in a project context.'; +COMMENT ON COLUMN tickets.labels.description IS 'An optional description if needed.'; +COMMENT ON COLUMN tickets.labels.colour IS 'A hexadecimal HTML colour code which can be used to mark the label on a rendered website.'; + +CREATE TABLE tickets.milestones +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + project BIGINT NOT NULL, + title CHARACTER VARYING(64) NOT NULL, + due_date DATE DEFAULT NULL, + description TEXT DEFAULT NULL, + CONSTRAINT milestones_unique_project_title UNIQUE (project, title), + CONSTRAINT milestones_fk_project FOREIGN KEY (project) + REFERENCES tickets.projects (id) ON UPDATE CASCADE ON DELETE CASCADE +) +WITH ( + OIDS=FALSE +); + +COMMENT ON TABLE tickets.milestones IS 'Milestones used to organise tickets'; +COMMENT ON COLUMN tickets.milestones.project IS 'The project to which this milestone belongs.'; +COMMENT ON COLUMN tickets.milestones.title IS 'A title for the milestone, usually a version number, a word or a short phrase that is supposed to be unique within a project context.'; +COMMENT ON COLUMN tickets.milestones.due_date IS 'An optional date on which the milestone is supposed to be reached.'; +COMMENT ON COLUMN tickets.milestones.description IS 'An optional longer description of the milestone.'; + +CREATE TABLE tickets.tickets +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + project BIGINT NOT NULL, + number INT NOT NULL, + title CHARACTER VARYING(72) NOT NULL, + content TEXT DEFAULT NULL, + status CHARACTER VARYING(16) NOT NULL, + submitter UUID DEFAULT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT tickets_unique_project_ticket UNIQUE (project, number), + CONSTRAINT tickets_fk_project FOREIGN KEY (project) + REFERENCES tickets.projects (id) ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT tickets_fk_submitter FOREIGN KEY (submitter) + REFERENCES tickets.users (uid) ON UPDATE CASCADE ON DELETE SET NULL +) +WITH ( + OIDS=FALSE +); + +CREATE INDEX tickets_status ON tickets.tickets (status); + +COMMENT ON TABLE tickets.tickets IS 'Information about tickets for projects.'; +COMMENT ON COLUMN tickets.tickets.id IS 'An auto generated primary key.'; +COMMENT ON COLUMN tickets.tickets.project IS 'The unique ID of the project which is associated with the ticket.'; +COMMENT ON COLUMN tickets.tickets.number IS 'The number of the ticket which must be unique within the scope of the project.'; +COMMENT ON COLUMN tickets.tickets.title IS 'A concise and short description of the ticket which should not exceed 72 characters.'; +COMMENT ON COLUMN tickets.tickets.content IS 'An optional field to describe the ticket in great detail if needed.'; +COMMENT ON COLUMN tickets.tickets.status IS 'The current status of the ticket describing its life cycle.'; +COMMENT ON COLUMN tickets.tickets.submitter IS 'The person who submitted (created) this ticket which is optional because of possible account deletion or other reasons.'; +COMMENT ON COLUMN tickets.tickets.created_at IS 'The timestamp when the ticket was created / submitted.'; +COMMENT ON COLUMN tickets.tickets.updated_at IS 'The timestamp when the ticket was last updated. Upon creation the update time equals the creation time.'; + +CREATE TABLE tickets.milestone_tickets +( + milestone BIGINT NOT NULL, + ticket BIGINT NOT NULL, + CONSTRAINT milestone_tickets_pk PRIMARY KEY (milestone, ticket), + CONSTRAINT milestone_tickets_fk_milestone FOREIGN KEY (milestone) + REFERENCES tickets.milestones (id) ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT milestone_tickets_fk_ticket FOREIGN KEY (ticket) + REFERENCES tickets.tickets (id) ON UPDATE CASCADE ON DELETE CASCADE +) +WITH ( + OIDS=FALSE +); + +COMMENT ON TABLE tickets.milestone_tickets IS 'This table stores the relation between milestones and their tickets.'; +COMMENT ON COLUMN tickets.milestone_tickets.milestone IS 'The unique ID of the milestone.'; +COMMENT ON COLUMN tickets.milestone_tickets.ticket IS 'The unique ID of the ticket that is attached to the milestone.'; + +CREATE TABLE tickets.ticket_assignees +( + ticket BIGINT NOT NULL, + assignee UUID NOT NULL, + CONSTRAINT ticket_assignees_pk PRIMARY KEY (ticket, assignee), + CONSTRAINT ticket_assignees_fk_ticket FOREIGN KEY (ticket) + REFERENCES tickets.tickets (id) ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT ticket_assignees_fk_assignee FOREIGN KEY (assignee) + REFERENCES tickets.users (uid) ON UPDATE CASCADE ON DELETE CASCADE +) +WITH ( + OIDS=FALSE +); + +COMMENT ON TABLE tickets.ticket_assignees IS 'This table stores the relation between tickets and their assignees.'; +COMMENT ON COLUMN tickets.ticket_assignees.ticket IS 'The unqiue ID of the ticket.'; +COMMENT ON COLUMN tickets.ticket_assignees.assignee IS 'The unique ID of the user account that is assigned to the ticket.'; + +CREATE TABLE tickets.ticket_labels +( + ticket BIGINT NOT NULL, + label BIGINT NOT NULL, + CONSTRAINT ticket_labels_pk PRIMARY KEY (ticket, label), + CONSTRAINT ticket_labels_fk_ticket FOREIGN KEY (ticket) + REFERENCES tickets.tickets (id) ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT ticket_labels_fk_label FOREIGN KEY (label) + REFERENCES tickets.labels (id) ON UPDATE CASCADE ON DELETE CASCADE +) +WITH ( + OIDS=FALSE +); + +COMMENT ON TABLE tickets.ticket_labels IS 'This table stores the relation between tickets and their labels.'; +COMMENT ON COLUMN tickets.ticket_labels.ticket IS 'The unqiue ID of the ticket.'; +COMMENT ON COLUMN tickets.ticket_labels.label IS 'The unique ID of the label that is attached to the ticket.'; diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/tickets/V3__add_language.sql new-smederee/modules/hub/src/main/resources/db/migration/tickets/V3__add_language.sql --- old-smederee/modules/hub/src/main/resources/db/migration/tickets/V3__add_language.sql 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/resources/db/migration/tickets/V3__add_language.sql 2025-01-12 05:25:38.811741504 +0000 @@ -0,0 +1,4 @@ +ALTER TABLE tickets.users + ADD COLUMN language CHARACTER VARYING(3) DEFAULT NULL; + +COMMENT ON COLUMN tickets.users.language IS 'The ISO-639 language code of the preferred language of the user.'; diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/tickets/V4__add_resolution.sql new-smederee/modules/hub/src/main/resources/db/migration/tickets/V4__add_resolution.sql --- old-smederee/modules/hub/src/main/resources/db/migration/tickets/V4__add_resolution.sql 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/resources/db/migration/tickets/V4__add_resolution.sql 2025-01-12 05:25:38.811741504 +0000 @@ -0,0 +1,4 @@ +ALTER TABLE tickets.tickets + ADD COLUMN resolution CHARACTER VARYING(16) DEFAULT NULL; + +COMMENT ON COLUMN tickets.tickets.resolution IS 'An optional resolution state of the ticket that should be set if it is closed.'; diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/tickets/V5__add_closeable_milestones.sql new-smederee/modules/hub/src/main/resources/db/migration/tickets/V5__add_closeable_milestones.sql --- old-smederee/modules/hub/src/main/resources/db/migration/tickets/V5__add_closeable_milestones.sql 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/resources/db/migration/tickets/V5__add_closeable_milestones.sql 2025-01-12 05:25:38.811741504 +0000 @@ -0,0 +1,4 @@ +ALTER TABLE tickets.milestones + ADD COLUMN closed BOOLEAN DEFAULT FALSE; + +COMMENT ON COLUMN tickets.milestones.closed IS 'This flag indicates if the milestone is closed e.g. considered done or obsolete.'; diff -rN -u old-smederee/modules/hub/src/main/resources/reference.conf new-smederee/modules/hub/src/main/resources/reference.conf --- old-smederee/modules/hub/src/main/resources/reference.conf 2025-01-12 05:25:38.799741485 +0000 +++ new-smederee/modules/hub/src/main/resources/reference.conf 2025-01-12 05:25:38.811741504 +0000 @@ -170,8 +170,39 @@ ticket-integration { enabled = false # The base URI used to build links to the ticket service. - base-uri = "http://localhost:8081" + base-uri = "http://localhost:8080" base-uri = ${?SMEDEREE_TICKET_BASE_URI} } } } + +tickets { + # Configuration of the database. + # Defaults are given except for password and can also be overridden via + # environment variables. + database { + # The class name of the JDBC driver to be used. + driver = "org.postgresql.Driver" + driver = ${?SMEDEREE_TICKETS_DB_DRIVER} + # The JDBC connection URL **without** username and password. + url = "jdbc:postgresql://localhost:5432/smederee" + url = ${?SMEDEREE_TICKETS_DB_URL} + # The username (login) needed to authenticate against the database. + user = "smederee_tickets" + user = ${?SMEDEREE_TICKETS_DB_USER} + # The password needed to authenticate against the database. + pass = "secret" + pass = ${?SMEDEREE_TICKETS_DB_PASS} + } + + # Settings affecting how the service will communicate several information to + # the "outside world" e.g. if it runs behind a reverse proxy. + external = ${hub.service.external} + + # Configuration regarding the integration with the hub service. + hub-integration { + # The base URI used to build links to the hub service. + base-uri = "http://localhost:8080" + base-uri = ${?SMEDEREE_HUB_BASE_URI} + } +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala 2025-01-12 05:25:38.803741491 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala 2025-01-12 05:25:38.811741504 +0000 @@ -473,7 +473,10 @@ csrf <- Sync[F].delay(req.getCsrfToken) account <- accountManagementRepo.findByValidationToken(token) _ <- account.traverse(user => accountManagementRepo.markAsValidated(user.uid)) - resp <- SeeOther(Location(configuration.external.createFullUri(uri"/"))) + _ <- account.traverse(user => + ticketServiceApi.createOrUpdateUser(TicketsUser(user.uid, user.name, user.email, user.language)) + ) + resp <- SeeOther(Location(configuration.external.createFullUri(uri"/"))) } yield resp } diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala 2025-01-12 05:25:38.803741491 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala 2025-01-12 05:25:38.811741504 +0000 @@ -441,7 +441,7 @@ cryptoClock = java.time.Clock.systemUTC csrfKey <- loadOrCreateCsrfKey(hubConfiguration.service.csrfKeyFile) csrfOriginCheck = createCsrfOriginCheck( - NonEmptyList(hubConfiguration.service.external, List(ticketsConfiguration.externalUrl)) + NonEmptyList(hubConfiguration.service.external, List(ticketsConfiguration.external)) ) csrfBuilder = CSRF[IO, IO](csrfKey, csrfOriginCheck) // Present an error page to the user in case of CSRF check failure that recommends deleting the diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Assignee.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Assignee.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Assignee.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Assignee.scala 2025-01-12 05:25:38.815741510 +0000 @@ -0,0 +1,165 @@ +/* + * 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.util.UUID + +import cats.* +import cats.data.* +import cats.syntax.all.* + +/** A submitter id is supposed to be globally unique and maps to the [[java.util.UUID]] type beneath. + */ +opaque type AssigneeId = UUID +object AssigneeId { + val Format = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$".r + + given Eq[AssigneeId] = Eq.fromUniversalEquals + + /** Create an instance of AssigneeId from the given UUID type. + * + * @param source + * An instance of type UUID which will be returned as a AssigneeId. + * @return + * The appropriate instance of AssigneeId. + */ + def apply(source: UUID): AssigneeId = source + + /** Try to create an instance of AssigneeId from the given UUID. + * + * @param source + * A UUID that should fulfil the requirements to be converted into a AssigneeId. + * @return + * An option to the successfully converted AssigneeId. + */ + def from(source: UUID): Option[AssigneeId] = Option(source) + + /** Try to create an instance of AssigneeId from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a AssigneeId. + * @return + * An option to the successfully converted AssigneeId. + */ + def fromString(source: String): Either[String, AssigneeId] = + Option(source) + .filter(s => Format.matches(s)) + .flatMap { uuidString => + Either.catchNonFatal(UUID.fromString(uuidString)).toOption + } + .toRight("Illegal value for AssigneeId!") + + /** Generate a new random user id. + * + * @return + * A user id which is pseudo randomly generated. + */ + def randomAssigneeId: AssigneeId = UUID.randomUUID + + extension (uid: AssigneeId) { + def toUUID: UUID = uid + } +} + +/** A submitter name has to obey several restrictions which are similiar to the ones found for Unix usernames. It must + * start with a letter, contain only alphanumeric characters, be at least 2 and at most 31 characters long and be all + * lowercase. + */ +opaque type AssigneeName = String +object AssigneeName { + given Eq[AssigneeName] = Eq.fromUniversalEquals + given Order[AssigneeName] = Order.from((x: AssigneeName, y: AssigneeName) => x.compareTo(y)) + given Ordering[AssigneeName] = implicitly[Order[AssigneeName]].toOrdering + + val isAlphanumeric = "^[a-z][a-z0-9]+$".r + + /** Create an instance of AssigneeName from the given String type. + * + * @param source + * An instance of type String which will be returned as a AssigneeName. + * @return + * The appropriate instance of AssigneeName. + */ + def apply(source: String): AssigneeName = source + + /** Try to create an instance of AssigneeName from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a AssigneeName. + * @return + * An option to the successfully converted AssigneeName. + */ + def from(s: String): Option[AssigneeName] = validate(s).toOption + + /** Validate the given string and return either the validated username or a list of errors. + * + * @param s + * An arbitrary string which should be a username. + * @return + * Either a list of errors or the validated username. + */ + def validate(s: String): ValidatedNec[String, AssigneeName] = + Option(s).map(_.trim.nonEmpty) match { + case Some(true) => + val input = s.trim + val miniumLength = + if (input.length >= 2) + input.validNec + else + "AssigneeName too short (min. 2 characters)!".invalidNec + val maximumLength = + if (input.length < 32) + input.validNec + else + "AssigneeName too long (max. 31 characters)!".invalidNec + val alphanumeric = + if (isAlphanumeric.matches(input)) + input.validNec + else + "AssigneeName must be all lowercase alphanumeric characters and start with a character.".invalidNec + (miniumLength, maximumLength, alphanumeric).mapN { case (_, _, name) => + name + } + case _ => "AssigneeName must not be empty!".invalidNec + } +} + +/** Extractor to retrieve an AssigneeName from a path parameter. + */ +object AssigneeNamePathParameter { + def unapply(str: String): Option[AssigneeName] = + Option(str).flatMap { string => + if (string.startsWith("~")) + AssigneeName.from(string.drop(1)) + else + None + } +} + +/** The assignee for a ticket i.e. the person supposed to be working on it. + * + * @param id + * A globally unique ID identifying the assignee. + * @param name + * The name associated with the assignee which is supposed to be unique. + */ +final case class Assignee(id: AssigneeId, name: AssigneeName) + +object Assignee { + given Eq[Assignee] = Eq.fromUniversalEquals +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala 2025-01-12 05:25:38.815741510 +0000 @@ -0,0 +1,45 @@ +/* + * 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 + +/** A configuration path describes a path within a configuration file and is used to determine locations of certain + * configurations within a combined configuration file. + */ +opaque type ConfigurationPath = String +object ConfigurationPath { + + given Conversion[ConfigurationPath, String] = _.toString + + /** Create an instance of ConfigurationPath from the given String type. + * + * @param source + * An instance of type String which will be returned as a ConfigurationPath. + * @return + * The appropriate instance of ConfigurationPath. + */ + def apply(source: String): ConfigurationPath = source + + /** Try to create an instance of ConfigurationPath from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a ConfigurationPath. + * @return + * An option to the successfully converted ConfigurationPath. + */ + def from(source: String): Option[ConfigurationPath] = Option(source).map(_.trim).filter(_.nonEmpty) +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala 2025-01-12 05:25:38.815741510 +0000 @@ -0,0 +1,37 @@ +/* + * 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 pureconfig.* + +/** Configuration specifying the database access. + * + * @param driver + * The class name of the JDBC driver to be used. + * @param url + * The JDBC connection URL **without** username and password. + * @param user + * The username (login) needed to authenticate against the database. + * @param pass + * The password needed to authenticate against the database. + */ +final case class DatabaseConfig(driver: String, url: String, user: String, pass: String) + +object DatabaseConfig { + given ConfigReader[DatabaseConfig] = ConfigReader.forProduct4("driver", "url", "user", "pass")(DatabaseConfig.apply) +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.scala 2025-01-12 05:25:38.815741510 +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.tickets.config + +import cats.effect.* +import cats.syntax.all.* +import org.flywaydb.core.Flyway +import org.flywaydb.core.api.configuration.FluentConfiguration +import org.flywaydb.core.api.output.MigrateResult + +/** Provide functionality to migrate the database used by the service. + */ +final class DatabaseMigrator[F[_]: Sync] { + + /** Apply pending migrations to the database if needed using the underlying Flyway library. + * + * @param url + * The JDBC connection URL **without** username and password. + * @param user + * The username (login) needed to authenticate against the database. + * @param pass + * The password needed to authenticate against the database. + * @return + * A migrate result object holding information about executed migrations and the schema. See the Java-Doc of + * Flyway for details. + */ + def migrate(url: String, user: String, pass: String): F[MigrateResult] = + for { + flyway <- Sync[F].delay(DatabaseMigrator.configureFlyway(url, user, pass).load()) + result <- Sync[F].delay(flyway.migrate()) + } yield result +} + +object DatabaseMigrator { + + /** Configure an instance of flyway with our defaults and the given credentials for a database connection. The + * returned instance must be activated by calling the `.load()` method. + * + * @param url + * The JDBC connection URL **without** username and password. + * @param user + * The username (login) needed to authenticate against the database. + * @param pass + * The password needed to authenticate against the database. + * @return + * An instance configuration which can be turned into a flyway database migrator by calling the `.load()` method. + */ + def configureFlyway(url: String, user: String, pass: String): FluentConfiguration = + Flyway + .configure() + .defaultSchema("tickets") + .locations("classpath:db/migration/tickets") + .dataSource(url, user, pass) + +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala 2025-01-12 05:25:38.815741510 +0000 @@ -0,0 +1,69 @@ +/* + * 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 com.comcast.ip4s.* +import de.smederee.html.ExternalUrlConfiguration +import org.http4s.Uri +import pureconfig.* + +/** Configuration regarding the integration with the hub service. + * + * @param baseUri + * The base URI used to build links to the hub service. + */ +final case class HubIntegrationConfiguration(baseUri: Uri) + +object HubIntegrationConfiguration { + given ConfigReader[Uri] = ConfigReader.fromStringOpt(s => Uri.fromString(s).toOption) + given ConfigReader[HubIntegrationConfiguration] = + ConfigReader.forProduct1("base-uri")(HubIntegrationConfiguration.apply) +} + +/** Wrapper class for the confiuration of the Smederee tickets module. + * + * @param database + * The configuration needed to access the database. + * @param external + * Configuration regarding support for generating "external urls" which is usually needed if the service runs behind + * a reverse proxy. + * @param hub + * Configuration regarding the integration with the hub service. + */ +final case class SmedereeTicketsConfiguration( + database: DatabaseConfig, + external: ExternalUrlConfiguration, + hub: HubIntegrationConfiguration +) + +object SmedereeTicketsConfiguration { + val location: ConfigurationPath = ConfigurationPath("tickets") + + given ConfigReader[Host] = ConfigReader.fromStringOpt[Host](Host.fromString) + given ConfigReader[Port] = ConfigReader.fromStringOpt[Port](Port.fromString) + given ConfigReader[Uri] = ConfigReader.fromStringOpt(s => Uri.fromString(s).toOption) + given ConfigReader[Uri.Scheme] = ConfigReader.fromStringOpt(s => Uri.Scheme.fromString(s).toOption) + + given ConfigReader[ExternalUrlConfiguration] = + ConfigReader.forProduct4("host", "path", "port", "scheme")(ExternalUrlConfiguration.apply) + + given ConfigReader[SmedereeTicketsConfiguration] = + ConfigReader.forProduct3("database", "external", "hub-integration")( + SmedereeTicketsConfiguration.apply + ) +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala 2025-01-12 05:25:38.815741510 +0000 @@ -0,0 +1,81 @@ +/* + * 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 doobie.* +import doobie.implicits.* +import fs2.Stream +import org.slf4j.LoggerFactory + +final class DoobieLabelRepository[F[_]: Sync](tx: Transactor[F]) extends LabelRepository[F] { + private val log = LoggerFactory.getLogger(getClass) + + given LogHandler[F] = Slf4jLogHandler.createLogHandler[F](log) + + given Meta[ColourCode] = Meta[String].timap(ColourCode.apply)(_.toString) + given Meta[LabelDescription] = Meta[String].timap(LabelDescription.apply)(_.toString) + given Meta[LabelId] = Meta[Long].timap(LabelId.apply)(_.toLong) + given Meta[LabelName] = Meta[String].timap(LabelName.apply)(_.toString) + given Meta[ProjectId] = Meta[Long].timap(ProjectId.apply)(_.toLong) + + override def allLabels(projectId: ProjectId): Stream[F, Label] = + sql"""SELECT id, name, description, colour FROM tickets.labels WHERE project = $projectId ORDER BY name ASC""" + .query[Label] + .stream + .transact(tx) + + override def createLabel(projectId: ProjectId)(label: Label): F[Int] = + sql"""INSERT INTO tickets.labels + ( + project, + name, + description, + colour + ) + VALUES ( + $projectId, + ${label.name}, + ${label.description}, + ${label.colour} + )""".update.run.transact(tx) + + override def deleteLabel(label: Label): F[Int] = + label.id match { + case None => Sync[F].pure(0) + case Some(id) => + sql"""DELETE FROM tickets.labels WHERE id = $id""".update.run.transact(tx) + } + + override def findLabel(projectId: ProjectId)(name: LabelName): F[Option[Label]] = + sql"""SELECT id, name, description, colour FROM tickets.labels WHERE project = $projectId AND name = $name LIMIT 1""" + .query[Label] + .option + .transact(tx) + + override def updateLabel(label: Label): F[Int] = + label.id match { + case None => Sync[F].pure(0) + case Some(id) => + sql"""UPDATE tickets.labels + SET name = ${label.name}, + description = ${label.description}, + colour = ${label.colour} + WHERE id = $id""".update.run.transact(tx) + } +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala 2025-01-12 05:25:38.815741510 +0000 @@ -0,0 +1,145 @@ +/* + * 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.util.UUID + +import cats.effect.* +import cats.syntax.all.* +import doobie.* +import doobie.Fragments.* +import doobie.implicits.* +import doobie.postgres.implicits.* +import fs2.Stream +import org.slf4j.LoggerFactory + +final class DoobieMilestoneRepository[F[_]: Sync](tx: Transactor[F]) extends MilestoneRepository[F] { + private val log = LoggerFactory.getLogger(getClass) + + given LogHandler[F] = Slf4jLogHandler.createLogHandler[F](log) + + given Meta[AssigneeId] = Meta[UUID].timap(AssigneeId.apply)(_.toUUID) + given Meta[AssigneeName] = Meta[String].timap(AssigneeName.apply)(_.toString) + given Meta[ColourCode] = Meta[String].timap(ColourCode.apply)(_.toString) + given Meta[LabelDescription] = Meta[String].timap(LabelDescription.apply)(_.toString) + given Meta[LabelId] = Meta[Long].timap(LabelId.apply)(_.toLong) + given Meta[LabelName] = Meta[String].timap(LabelName.apply)(_.toString) + given Meta[MilestoneDescription] = Meta[String].timap(MilestoneDescription.apply)(_.toString) + given Meta[MilestoneId] = Meta[Long].timap(MilestoneId.apply)(_.toLong) + given Meta[MilestoneTitle] = Meta[String].timap(MilestoneTitle.apply)(_.toString) + 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[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) + given Meta[TicketResolution] = Meta[String].timap(TicketResolution.valueOf)(_.toString) + given Meta[TicketStatus] = Meta[String].timap(TicketStatus.valueOf)(_.toString) + given Meta[TicketTitle] = Meta[String].timap(TicketTitle.apply)(_.toString) + + private val selectTicketColumns = + fr"""SELECT + "tickets".number AS number, + "tickets".title AS title, + "tickets".content AS content, + "tickets".status AS status, + "tickets".resolution AS resolution, + "submitters".uid AS submitter_uid, + "submitters".name AS submitter_name, + "tickets".created_at AS created_at, + "tickets".updated_at AS updated_at + FROM "tickets"."tickets" AS "tickets" + LEFT OUTER JOIN "tickets"."users" AS "submitters" + ON "tickets".submitter = "submitters".uid""" + + override def allMilestones(projectId: ProjectId): Stream[F, Milestone] = + sql"""SELECT id, title, description, due_date, closed FROM "tickets"."milestones" WHERE project = $projectId ORDER BY due_date ASC, title ASC""" + .query[Milestone] + .stream + .transact(tx) + + override def allTickets(filter: Option[TicketFilter])(milestoneId: MilestoneId): Stream[F, Ticket] = { + val milestoneFilter = + fr""""tickets".id IN (SELECT ticket FROM "tickets".milestone_tickets AS "milestone_tickets" WHERE milestone = $milestoneId)""" + val tickets = filter match { + case None => selectTicketColumns ++ whereAnd(milestoneFilter) + case Some(filter) => + val numberFilter = filter.number.toNel.map(numbers => Fragments.in(fr""""tickets".number""", numbers)) + val statusFilter = filter.status.toNel.map(status => Fragments.in(fr""""tickets".status""", status)) + val resolutionFilter = + filter.resolution.toNel.map(resolutions => Fragments.in(fr""""tickets".resolution""", resolutions)) + val submitterFilter = + filter.submitter.toNel.map(submitters => Fragments.in(fr""""submitters".name""", submitters)) + selectTicketColumns ++ whereAndOpt( + milestoneFilter.some, + numberFilter, + statusFilter, + resolutionFilter, + submitterFilter + ) + } + tickets.query[Ticket].stream.transact(tx) + } + + override def closeMilestone(milestoneId: MilestoneId): F[Int] = + sql"""UPDATE "tickets"."milestones" SET closed = TRUE WHERE id = $milestoneId""".update.run.transact(tx) + + override def createMilestone(projectId: ProjectId)(milestone: Milestone): F[Int] = + sql"""INSERT INTO "tickets"."milestones" + ( + project, + title, + due_date, + description, + closed + ) + VALUES ( + $projectId, + ${milestone.title}, + ${milestone.dueDate}, + ${milestone.description}, + ${milestone.closed} + )""".update.run.transact(tx) + + override def deleteMilestone(milestone: Milestone): F[Int] = + milestone.id match { + case None => Sync[F].pure(0) + case Some(id) => sql"""DELETE FROM "tickets"."milestones" WHERE id = $id""".update.run.transact(tx) + } + + override def findMilestone(projectId: ProjectId)(title: MilestoneTitle): F[Option[Milestone]] = + sql"""SELECT id, title, description, due_date, closed FROM "tickets"."milestones" WHERE project = $projectId AND title = $title LIMIT 1""" + .query[Milestone] + .option + .transact(tx) + + override def openMilestone(milestoneId: MilestoneId): F[Int] = + sql"""UPDATE "tickets"."milestones" SET closed = FALSE WHERE id = $milestoneId""".update.run.transact(tx) + + override def updateMilestone(milestone: Milestone): F[Int] = + milestone.id match { + case None => Sync[F].pure(0) + case Some(id) => + sql"""UPDATE "tickets"."milestones" + SET title = ${milestone.title}, + due_date = ${milestone.dueDate}, + description = ${milestone.description} + WHERE id = $id""".update.run.transact(tx) + } + +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala 2025-01-12 05:25:38.815741510 +0000 @@ -0,0 +1,127 @@ +/* + * 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.util.UUID + +import cats.effect.* +import de.smederee.email.EmailAddress +import doobie.* +import doobie.implicits.* +import doobie.postgres.implicits.* +import org.slf4j.LoggerFactory + +final class DoobieProjectRepository[F[_]: Sync](tx: Transactor[F]) extends ProjectRepository[F] { + private val log = LoggerFactory.getLogger(getClass) + + given LogHandler[F] = Slf4jLogHandler.createLogHandler[F](log) + + given Meta[EmailAddress] = Meta[String].timap(EmailAddress.apply)(_.toString) + given Meta[ProjectDescription] = Meta[String].timap(ProjectDescription.apply)(_.toString) + given Meta[ProjectId] = Meta[Long].timap(ProjectId.apply)(_.toLong) + given Meta[ProjectName] = Meta[String].timap(ProjectName.apply)(_.toString) + given Meta[ProjectOwnerId] = Meta[UUID].timap(ProjectOwnerId.apply)(_.toUUID) + given Meta[ProjectOwnerName] = Meta[String].timap(ProjectOwnerName.apply)(_.toString) + given Meta[TicketNumber] = Meta[Int].timap(TicketNumber.apply)(_.toInt) + + override def createProject(project: Project): F[Int] = + sql"""INSERT INTO tickets.projects (name, owner, is_private, description, created_at, updated_at) + VALUES ( + ${project.name}, + ${project.owner.uid}, + ${project.isPrivate}, + ${project.description}, + NOW(), + NOW() + )""".update.run.transact(tx) + + override def deleteProject(project: Project): F[Int] = + sql"""DELETE FROM tickets.projects WHERE owner = ${project.owner.uid} AND name = ${project.name}""".update.run + .transact(tx) + + override def findProject(owner: ProjectOwner, name: ProjectName): F[Option[Project]] = + sql"""SELECT + users.uid AS owner_id, + users.name AS owner_name, + users.email AS owner_email, + projects.name, + projects.description, + projects.is_private + FROM tickets.projects AS projects + JOIN tickets.users AS users + ON projects.owner = users.uid + WHERE + projects.owner = ${owner.uid} + AND + projects.name = $name""".query[Project].option.transact(tx) + + override def findProjectId(owner: ProjectOwner, name: ProjectName): F[Option[ProjectId]] = + sql"""SELECT + projects.id + FROM tickets.projects AS projects + JOIN tickets.users AS users + ON projects.owner = users.uid + WHERE + projects.owner = ${owner.uid} + AND + projects.name = $name""".query[ProjectId].option.transact(tx) + + override def findProjectOwner(name: ProjectOwnerName): F[Option[ProjectOwner]] = + sql"""SELECT + users.uid, + users.name, + users.email + FROM tickets.users + WHERE name = $name""".query[ProjectOwner].option.transact(tx) + + override def incrementNextTicketNumber(projectId: ProjectId): F[TicketNumber] = { + // TODO: Find out which of the queries is more reliable and more performant. + /* + val sqlQuery1 = sql"""UPDATE tickets.projects AS alias1 + SET next_ticket_number = alias2.next_ticket_number + 1 + FROM ( + SELECT + id, + next_ticket_number + FROM tickets.projects + WHERE id = $projectId + ) AS alias2 + WHERE alias1.id = alias2.id + RETURNING alias2.next_ticket_number AS next_ticket_number""" + */ + val sqlQuery2 = sql"""WITH old_number AS ( + SELECT next_ticket_number FROM tickets.projects WHERE id = $projectId + ) + UPDATE tickets.projects + SET next_ticket_number = next_ticket_number + 1 + WHERE id = $projectId + RETURNING ( + SELECT next_ticket_number FROM old_number + )""" + sqlQuery2.query[TicketNumber].unique.transact(tx) + } + + override def updateProject(project: Project): F[Int] = + sql"""UPDATE tickets.projects SET + is_private = ${project.isPrivate}, + description = ${project.description}, + updated_at = NOW() + WHERE + owner = ${project.owner.uid} + AND name = ${project.name}""".update.run.transact(tx) +} 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 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala 2025-01-12 05:25:38.815741510 +0000 @@ -0,0 +1,275 @@ +/* + * 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.util.UUID + +import cats.* +import cats.data.* +import cats.effect.* +import cats.syntax.all.* +import doobie.* +import doobie.Fragments.* +import doobie.implicits.* +import doobie.postgres.implicits.* +import fs2.Stream +import org.slf4j.LoggerFactory + +final class DoobieTicketRepository[F[_]: Sync](tx: Transactor[F]) extends TicketRepository[F] { + private val log = LoggerFactory.getLogger(getClass) + + given LogHandler[F] = Slf4jLogHandler.createLogHandler[F](log) + + given Meta[AssigneeId] = Meta[UUID].timap(AssigneeId.apply)(_.toUUID) + given Meta[AssigneeName] = Meta[String].timap(AssigneeName.apply)(_.toString) + given Meta[ColourCode] = Meta[String].timap(ColourCode.apply)(_.toString) + given Meta[LabelDescription] = Meta[String].timap(LabelDescription.apply)(_.toString) + given Meta[LabelId] = Meta[Long].timap(LabelId.apply)(_.toLong) + given Meta[LabelName] = Meta[String].timap(LabelName.apply)(_.toString) + given Meta[MilestoneDescription] = Meta[String].timap(MilestoneDescription.apply)(_.toString) + given Meta[MilestoneId] = Meta[Long].timap(MilestoneId.apply)(_.toLong) + given Meta[MilestoneTitle] = Meta[String].timap(MilestoneTitle.apply)(_.toString) + 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[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) + given Meta[TicketResolution] = Meta[String].timap(TicketResolution.valueOf)(_.toString) + given Meta[TicketStatus] = Meta[String].timap(TicketStatus.valueOf)(_.toString) + given Meta[TicketTitle] = Meta[String].timap(TicketTitle.apply)(_.toString) + + private val selectTicketColumns = + fr"""SELECT + tickets.number AS number, + tickets.title AS title, + tickets.content AS content, + tickets.status AS status, + tickets.resolution AS resolution, + submitters.uid AS submitter_uid, + submitters.name AS submitter_name, + tickets.created_at AS created_at, + tickets.updated_at AS updated_at + FROM tickets.tickets AS tickets + LEFT OUTER JOIN tickets.users AS submitters + ON tickets.submitter = submitters.uid""" + + /** Construct a query fragment that fetches the internal unique ticket id from the tickets table via the given + * project id and ticket number. The fetched id can be referenced like this `SELECT id FROM ticket_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 query fragment useable within other queries which defines a common table expression using the `WITH` clause. + */ + private def withTicketId(projectId: ProjectId, ticketNumber: TicketNumber): Fragment = + fr"""WITH ticket_id AS ( + SELECT id AS id + FROM tickets.tickets AS tickets + WHERE tickets.project = $projectId + AND tickets.number = $ticketNumber)""" + + override def addAssignee(projectId: ProjectId)(ticketNumber: TicketNumber)(assignee: Assignee): F[Int] = + sql"""INSERT INTO tickets.ticket_assignees ( + ticket, + assignee + ) SELECT + id, + ${assignee.id} + 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) + case Some(labelId) => + sql"""INSERT INTO tickets.ticket_labels ( + ticket, + label + ) SELECT + id, + $labelId + FROM tickets.tickets + WHERE project = $projectId + AND number = $ticketNumber""".update.run.transact(tx) + } + + override def addMilestone(projectId: ProjectId)(ticketNumber: TicketNumber)(milestone: Milestone): F[Int] = + milestone.id match { + case None => Sync[F].pure(0) + case Some(milestoneId) => + sql"""INSERT INTO tickets.milestone_tickets ( + ticket, + milestone + ) SELECT + id, + $milestoneId + FROM tickets.tickets + WHERE project = $projectId + AND number = $ticketNumber""".update.run.transact(tx) + } + + override def allTickets(filter: Option[TicketFilter])(projectId: ProjectId): Stream[F, Ticket] = { + val projectFilter = fr"""tickets.project = $projectId""" + val tickets = filter match { + case None => selectTicketColumns ++ whereAnd(projectFilter) + case Some(filter) => + val numberFilter = filter.number.toNel.map(numbers => Fragments.in(fr"""tickets.number""", numbers)) + val statusFilter = filter.status.toNel.map(status => Fragments.in(fr"""tickets.status""", status)) + val resolutionFilter = + filter.resolution.toNel.map(resolutions => Fragments.in(fr"""tickets.resolution""", resolutions)) + val submitterFilter = + filter.submitter.toNel.map(submitters => Fragments.in(fr"""submitters.name""", submitters)) + selectTicketColumns ++ whereAndOpt( + projectFilter.some, + numberFilter, + statusFilter, + resolutionFilter, + submitterFilter + ) + } + tickets.query[Ticket].stream.transact(tx) + } + + override def createTicket(projectId: ProjectId)(ticket: Ticket): F[Int] = + sql"""INSERT INTO tickets.tickets ( + project, + number, + title, + content, + status, + resolution, + submitter, + created_at, + updated_at + ) VALUES ( + $projectId, + ${ticket.number}, + ${ticket.title}, + ${ticket.content}, + ${ticket.status}, + ${ticket.resolution}, + ${ticket.submitter.map(_.id)}, + NOW(), + NOW() + )""".update.run.transact(tx) + + override def deleteTicket(projectId: ProjectId)(ticket: Ticket): F[Int] = + sql"""DELETE FROM tickets.tickets + WHERE project = $projectId + AND number = ${ticket.number}""".update.run.transact(tx) + + override def findTicket(projectId: ProjectId)(ticketNumber: TicketNumber): F[Option[Ticket]] = { + val projectFilter = fr"""project = $projectId""" + val numberFilter = fr"""number = $ticketNumber""" + val ticket = selectTicketColumns ++ whereAnd(projectFilter, numberFilter) ++ fr"""LIMIT 1""" + ticket.query[Ticket].option.transact(tx) + } + + override def findTicketId(projectId: ProjectId)(ticketNumber: TicketNumber): F[Option[TicketId]] = + sql"""SELECT id FROM tickets.tickets WHERE project = $projectId AND number = $ticketNumber""" + .query[TicketId] + .option + .transact(tx) + + override def loadAssignees(projectId: ProjectId)(ticketNumber: TicketNumber): Stream[F, Assignee] = { + val sqlQuery = withTicketId(projectId, ticketNumber) ++ + fr"""SELECT + users.uid AS uid, + users.name AS name + FROM tickets.ticket_assignees AS assignees + JOIN tickets.users AS users + ON assignees.assignee = users.uid + WHERE assignees.ticket = (SELECT id FROM ticket_id)""" + sqlQuery.query[Assignee].stream.transact(tx) + } + + override def loadLabels(projectId: ProjectId)(ticketNumber: TicketNumber): Stream[F, Label] = { + val sqlQuery = withTicketId(projectId, ticketNumber) ++ + fr"""SELECT + labels.id AS id, + labels.name AS name, + labels.description AS description, + labels.colour AS colour + FROM tickets.labels AS labels + JOIN tickets.ticket_labels AS ticket_labels + ON labels.id = ticket_labels.label + WHERE ticket_labels.ticket = (SELECT id FROM ticket_id)""" + sqlQuery.query[Label].stream.transact(tx) + } + + override def loadMilestones(projectId: ProjectId)(ticketNumber: TicketNumber): Stream[F, Milestone] = { + val sqlQuery = withTicketId(projectId, ticketNumber) ++ + fr"""SELECT + milestones.id AS id, + milestones.title AS title, + milestones.description AS description, + milestones.due_date AS due_date, + milestones.closed AS closed + FROM tickets.milestones AS milestones + JOIN tickets.milestone_tickets AS milestone_tickets + ON milestones.id = milestone_tickets.milestone + WHERE milestone_tickets.ticket = (SELECT id FROM ticket_id) + ORDER BY milestones.due_date ASC, milestones.title ASC""" + sqlQuery.query[Milestone].stream.transact(tx) + } + + override def removeAssignee(projectId: ProjectId)(ticket: Ticket)(assignee: Assignee): F[Int] = { + val sqlQuery = withTicketId(projectId, ticket.number) ++ + fr"""DELETE FROM tickets.ticket_assignees AS ticket_assignees + WHERE ticket_assignees.ticket = (SELECT id FROM ticket_id) + AND ticket_assignees.assignee = ${assignee.id}""" + sqlQuery.update.run.transact(tx) + } + + override def removeLabel(projectId: ProjectId)(ticket: Ticket)(label: Label): F[Int] = + label.id match { + case None => Sync[F].pure(0) + case Some(labelId) => + val sqlQuery = withTicketId(projectId, ticket.number) ++ + fr"""DELETE FROM tickets.ticket_labels AS labels + WHERE labels.ticket = (SELECT id FROM ticket_id) + AND labels.label = $labelId""" + sqlQuery.update.run.transact(tx) + } + + override def removeMilestone(projectId: ProjectId)(ticket: Ticket)(milestone: Milestone): F[Int] = + milestone.id match { + case None => Sync[F].pure(0) + case Some(milestoneId) => + val sqlQuery = withTicketId(projectId, ticket.number) ++ + fr"""DELETE FROM tickets.milestone_tickets AS milestone_tickets + WHERE milestone_tickets.ticket = (SELECT id FROM ticket_id) + AND milestone_tickets.milestone = $milestoneId""" + sqlQuery.update.run.transact(tx) + } + + override def updateTicket(projectId: ProjectId)(ticket: Ticket): F[Int] = + sql"""UPDATE tickets.tickets SET + title = ${ticket.title}, + content = ${ticket.content}, + status = ${ticket.status}, + resolution = ${ticket.resolution}, + submitter = ${ticket.submitter.map(_.id)}, + updated_at = NOW() + WHERE project = $projectId + AND number = ${ticket.number}""".update.run.transact(tx) +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieTicketServiceApi.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieTicketServiceApi.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieTicketServiceApi.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieTicketServiceApi.scala 2025-01-12 05:25:38.815741510 +0000 @@ -0,0 +1,59 @@ +/* + * 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.util.UUID + +import cats.effect.* +import de.smederee.email.EmailAddress +import de.smederee.i18n.LanguageCode +import de.smederee.security.UserId +import de.smederee.security.Username +import doobie.* +import doobie.implicits.* +import doobie.postgres.implicits.* +import org.slf4j.LoggerFactory + +final class DoobieTicketServiceApi[F[_]: Sync](tx: Transactor[F]) extends TicketServiceApi[F] { + private val log = LoggerFactory.getLogger(getClass) + + given LogHandler[F] = Slf4jLogHandler.createLogHandler[F](log) + + given Meta[EmailAddress] = Meta[String].timap(EmailAddress.apply)(_.toString) + given Meta[LanguageCode] = Meta[String].timap(LanguageCode.apply)(_.toString) + given Meta[UserId] = Meta[UUID].timap(UserId.apply)(_.toUUID) + given Meta[Username] = Meta[String].timap(Username.apply)(_.toString) + + override def createOrUpdateUser(user: TicketsUser): F[Int] = + sql"""INSERT INTO tickets.users (uid, name, email, language, created_at, updated_at) + VALUES ( + ${user.uid}, + ${user.name}, + ${user.email}, + ${user.language}, + NOW(), + NOW() + ) ON CONFLICT (uid) DO UPDATE SET + name = EXCLUDED.name, + email = EXCLUDED.email, + language = EXCLUDED.language, + updated_at = EXCLUDED.updated_at""".update.run.transact(tx) + + override def deleteUser(uid: UserId): F[Int] = + sql"""DELETE FROM tickets.users WHERE uid = $uid""".update.run.transact(tx) +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/forms/FormValidator.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/forms/FormValidator.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/forms/FormValidator.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/forms/FormValidator.scala 2025-01-12 05:25:38.815741510 +0000 @@ -0,0 +1,54 @@ +/* + * 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.forms + +import cats.data.* +import de.smederee.tickets.forms.types.* + +/** A base class for form validators. + * + * <p>It is intended to extend this class if you want to provide a more sophisticated validation for a form which gets + * submitted as raw stringified map.</p> + * + * <p>Please note that you can achieve auto validation if you use proper models (with refined types) in your tapir + * endpoints.</p> + * + * <p>However, sometimes you want to have more fine grained control...</p> + * + * @tparam T + * The concrete type of the validated form output. + */ +abstract class FormValidator[T] { + final val fieldGlobal: FormField = FormValidator.fieldGlobal + + /** Validate the submitted form data which is received as stringified map and return either a validated `T` or a + * list of [[de.smederee.tickets.forms.types.FormErrors]]. + * + * @param data + * The stringified map which was submitted. + * @return + * Either the validated form as concrete type T or a list of form errors. + */ + def validate(data: Map[String, Chain[String]]): ValidatedNec[FormErrors, T] + +} + +object FormValidator { + // A constant for the field name used for global errors. + val fieldGlobal: FormField = FormField("global") +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/forms/types.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/forms/types.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/forms/types.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/forms/types.scala 2025-01-12 05:25:38.815741510 +0000 @@ -0,0 +1,106 @@ +/* + * 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.forms + +import cats.data.* +import cats.syntax.all.* + +object types { + + type FormErrors = Map[FormField, List[FormFieldError]] + object FormErrors { + val empty: FormErrors = Map.empty[FormField, List[FormFieldError]] + + /** Create a single FormErrors instance from a given non empty chain of FormErrors which is usually returned + * from validators. + * + * @param errors + * A non empty chain of FormErrors. + * @return + * A single FormErrors instance containing all the errors. + */ + def fromNec(errors: NonEmptyChain[FormErrors]): FormErrors = errors.toList.fold(FormErrors.empty)(_ combine _) + + /** Create a single FormErrors instance from a given non empty list of FormErrors which is usually returned from + * validators. + * + * @param errors + * A non empty list of FormErrors. + * @return + * A single FormErrors instance containing all the errors. + */ + def fromNel(errors: NonEmptyList[FormErrors]): FormErrors = errors.toList.fold(FormErrors.empty)(_ combine _) + } + + opaque type FormField = String + object FormField { + + /** Create an instance of FormField from the given String type. + * + * @param source + * An instance of type String which will be returned as a FormField. + * @return + * The appropriate instance of FormField. + */ + def apply(source: String): FormField = source + + /** Try to create an instance of FormField from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a FormField. + * @return + * An option to the successfully converted FormField. + */ + def from(source: String): Option[FormField] = + Option(source).map(_.trim.nonEmpty) match { + case Some(true) => Option(source.trim) + case _ => None + } + } + + given Conversion[FormField, String] = _.toString + + opaque type FormFieldError = String + object FormFieldError { + + /** Create an instance of FormFieldError from the given String type. + * + * @param source + * An instance of type String which will be returned as a FormFieldError. + * @return + * The appropriate instance of FormFieldError. + */ + def apply(source: String): FormFieldError = source + + /** Try to create an instance of FormFieldError from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a FormFieldError. + * @return + * An option to the successfully converted FormFieldError. + */ + def from(source: String): Option[FormFieldError] = + Option(source).map(_.trim.nonEmpty) match { + case Some(true) => Option(source.trim) + case _ => None + } + } + + given Conversion[FormFieldError, String] = _.toString + +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRepository.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRepository.scala 2025-01-12 05:25:38.815741510 +0000 @@ -0,0 +1,78 @@ +/* + * 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 fs2.Stream + +/** The base class that defines the needed functionality to handle labels within a database. + * + * @tparam F + * A higher kinded type which wraps the actual return values. + */ +abstract class LabelRepository[F[_]] { + + /** Return all labels associated with the given repository. + * + * @param projectId + * The unique internal ID of a vcs repository metadata entry for which all labels shall be returned. + * @return + * A stream of labels associated with the vcs repository which may be empty. + */ + def allLabels(projectId: ProjectId): Stream[F, Label] + + /** Create a database entry for the given label definition. + * + * @param projectId + * The unique internal ID of a vcs repository metadata entry to which the label belongs. + * @param label + * The label definition that shall be written to the database. + * @return + * The number of affected database rows. + */ + def createLabel(projectId: ProjectId)(label: Label): F[Int] + + /** Delete the label from the database. + * + * @param label + * The label definition that shall be deleted from the database. + * @return + * The number of affected database rows. + */ + def deleteLabel(label: Label): F[Int] + + /** Find the label with the given name for the given vcs repository. + * + * @param projectId + * The unique internal ID of a vcs repository metadata entry to which the label belongs. + * @param name + * The name of the label which is must be unique in the context of the repository. + * @return + * An option to the found label. + */ + def findLabel(projectId: ProjectId)(name: LabelName): F[Option[Label]] + + /** Update the database entry for the given label. + * + * @param label + * The label definition that shall be updated within the database. + * @return + * The number of affected database rows. + */ + def updateLabel(label: Label): F[Int] + +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala 2025-01-12 05:25:38.803741491 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala 2025-01-12 05:25:38.815741510 +0000 @@ -54,10 +54,8 @@ ) extends Http4sDsl[F] { private val log = LoggerFactory.getLogger(getClass) - given CsrfProtectionConfiguration = configuration.csrfProtection - private val linkToHubService = configuration.hub.baseUri - private val linkConfig = configuration.externalUrl + private val linkConfig = configuration.external /** Logic for rendering a list of all labels for a project and optionally management functionality. * diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Label.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Label.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Label.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Label.scala 2025-01-12 05:25:38.815741510 +0000 @@ -0,0 +1,187 @@ +/* + * 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.* +import cats.syntax.all.* + +import scala.util.matching.Regex + +opaque type LabelId = Long +object LabelId { + given Eq[LabelId] = Eq.fromUniversalEquals + given Ordering[LabelId] = (x: LabelId, y: LabelId) => x.compareTo(y) + given Order[LabelId] = Order.fromOrdering + + val Format: Regex = "^-?\\d+$".r + + /** Create an instance of LabelId from the given Long type. + * + * @param source + * An instance of type Long which will be returned as a LabelId. + * @return + * The appropriate instance of LabelId. + */ + def apply(source: Long): LabelId = source + + /** Try to create an instance of LabelId from the given Long. + * + * @param source + * A Long that should fulfil the requirements to be converted into a LabelId. + * @return + * An option to the successfully converted LabelId. + */ + def from(source: Long): Option[LabelId] = Option(source) + + /** Try to create an instance of LabelId from the given String. + * + * @param source + * A string that should fulfil the requirements to be converted into a LabelId. + * @return + * An option to the successfully converted LabelId. + */ + def fromString(source: String): Option[LabelId] = Option(source).filter(Format.matches).map(_.toLong).flatMap(from) + + extension (id: LabelId) { + def toLong: Long = id + } +} + +/** Extractor to retrieve an LabelId from a path parameter. + */ +object LabelIdPathParameter { + def unapply(str: String): Option[LabelId] = Option(str).flatMap(LabelId.fromString) +} + +/** A short descriptive name for the label which is supposed to be unique in a project context. It must not be empty and + * not exceed 40 characters in length. + */ +opaque type LabelName = String +object LabelName { + given Eq[LabelName] = Eq.fromUniversalEquals + given Ordering[LabelName] = (x: LabelName, y: LabelName) => x.compareTo(y) + given Order[LabelName] = Order.fromOrdering[LabelName] + + val MaxLength: Int = 40 + + /** Create an instance of LabelName from the given String type. + * + * @param source + * An instance of type String which will be returned as a LabelName. + * @return + * The appropriate instance of LabelName. + */ + def apply(source: String): LabelName = source + + /** Try to create an instance of LabelName from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a LabelName. + * @return + * An option to the successfully converted LabelName. + */ + def from(source: String): Option[LabelName] = + Option(source).filter(string => string.nonEmpty && string.length <= MaxLength) + +} + +/** Extractor to retrieve an LabelName from a path parameter. + */ +object LabelNamePathParameter { + def unapply(str: String): Option[LabelName] = Option(str).flatMap(LabelName.from) +} + +/** A maybe needed description of a label which must not be empty and not exceed 254 characters in length. + */ +opaque type LabelDescription = String +object LabelDescription { + given Eq[LabelDescription] = Eq.fromUniversalEquals + + val MaxLength: Int = 254 + + /** Create an instance of LabelDescription from the given String type. + * + * @param source + * An instance of type String which will be returned as a LabelDescription. + * @return + * The appropriate instance of LabelDescription. + */ + def apply(source: String): LabelDescription = source + + /** Try to create an instance of LabelDescription from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a LabelDescription. + * @return + * An option to the successfully converted LabelDescription. + */ + def from(source: String): Option[LabelDescription] = + Option(source).filter(string => string.nonEmpty && string.length <= MaxLength) +} + +/** An HTML colour code in hexadecimal notation e.g. `#12ab3f`. It must match the format of starting with a hashtag + * followed by three 2-digit hexadecimal codes (`00-ff`). + */ +opaque type ColourCode = String +object ColourCode { + given Eq[ColourCode] = Eq.instance((a, b) => a.equalsIgnoreCase(b)) + + val Format: Regex = "^#[0-9a-fA-F]{6}$".r + + /** Create an instance of ColourCode from the given String type. + * + * @param source + * An instance of type String which will be returned as a ColourCode. + * @return + * The appropriate instance of ColourCode. + */ + def apply(source: String): ColourCode = source + + /** Try to create an instance of ColourCode from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a ColourCode. + * @return + * An option to the successfully converted ColourCode. + */ + def from(source: String): Option[ColourCode] = Option(source).filter(string => Format.matches(string)) + +} + +/** A label is intended to mark tickets with keywords and colours to allow filtering on them. + * + * @param id + * An optional attribute containing the unique internal database ID for the label. + * @param name + * A short descriptive name for the label which is supposed to be unique in a project context. + * @param description + * An optional description if needed. + * @param colour + * A hexadecimal HTML colour code which can be used to mark the label on a rendered website. + */ +final case class Label(id: Option[LabelId], name: LabelName, description: Option[LabelDescription], colour: ColourCode) + +object Label { + given Eq[Label] = + Eq.instance((thisLabel, thatLabel) => + thisLabel.id === thatLabel.id && + thisLabel.name === thatLabel.name && + thisLabel.description === thatLabel.description && + thisLabel.colour === thatLabel.colour + ) +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRepository.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRepository.scala 2025-01-12 05:25:38.815741510 +0000 @@ -0,0 +1,107 @@ +/* + * 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 fs2.Stream + +/** The base class that defines the needed functionality to handle milestones within a database. + * + * @tparam F + * A higher kinded type which wraps the actual return values. + */ +abstract class MilestoneRepository[F[_]] { + + /** Return all milestones associated with the given repository. + * + * @param projectId + * The unique internal ID of a vcs repository metadata entry for which all milestones shall be returned. + * @return + * A stream of milestones associated with the vcs repository which may be empty. + */ + def allMilestones(projectId: ProjectId): Stream[F, Milestone] + + /** Return all tickets associated with the given milestone. + * + * @param filter + * A ticket filter containing possible values which will be used to filter the list of tickets. + * @param milestoneId + * The unique internal ID of a milestone for which all tickets shall be returned. + * @return + * A stream of tickets associated with the vcs repository which may be empty. + */ + def allTickets(filter: Option[TicketFilter])(milestoneId: MilestoneId): Stream[F, Ticket] + + /** Change the milestone status with the given id to closed. + * + * @param milestoneId + * The unique internal ID of a milestone for which all tickets shall be returned. + * @return + * The number of affected database rows. + */ + def closeMilestone(milestoneId: MilestoneId): F[Int] + + /** Create a database entry for the given milestone definition. + * + * @param projectId + * The unique internal ID of a vcs repository metadata entry to which the milestone belongs. + * @param milestone + * The milestone definition that shall be written to the database. + * @return + * The number of affected database rows. + */ + def createMilestone(projectId: ProjectId)(milestone: Milestone): F[Int] + + /** Delete the milestone from the database. + * + * @param milestone + * The milestone definition that shall be deleted from the database. + * @return + * The number of affected database rows. + */ + def deleteMilestone(milestone: Milestone): F[Int] + + /** Find the milestone with the given title for the given vcs repository. + * + * @param projectId + * The unique internal ID of a vcs repository metadata entry to which the milestone belongs. + * @param title + * The title of the milestone which is must be unique in the context of the repository. + * @return + * An option to the found milestone. + */ + def findMilestone(projectId: ProjectId)(title: MilestoneTitle): F[Option[Milestone]] + + /** Change the milestone status with the given id to open. + * + * @param milestoneId + * The unique internal ID of a milestone for which all tickets shall be returned. + * @return + * The number of affected database rows. + */ + def openMilestone(milestoneId: MilestoneId): F[Int] + + /** Update the database entry for the given milestone. + * + * @param milestone + * The milestone definition that shall be updated within the database. + * @return + * The number of affected database rows. + */ + def updateMilestone(milestone: Milestone): F[Int] + +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala 2025-01-12 05:25:38.803741491 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala 2025-01-12 05:25:38.815741510 +0000 @@ -54,10 +54,8 @@ ) extends Http4sDsl[F] { private val log = LoggerFactory.getLogger(getClass) - given CsrfProtectionConfiguration = configuration.csrfProtection - private val linkToHubService = configuration.hub.baseUri - private val linkConfig = configuration.externalUrl + private val linkConfig = configuration.external /** Logic for rendering a detail page for a single milestone. * diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Milestone.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Milestone.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Milestone.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Milestone.scala 2025-01-12 05:25:38.815741510 +0000 @@ -0,0 +1,167 @@ +/* + * 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.LocalDate + +import cats.* +import cats.syntax.all.* + +import scala.util.matching.Regex + +opaque type MilestoneId = Long +object MilestoneId { + given Eq[MilestoneId] = Eq.fromUniversalEquals + + val Format: Regex = "^-?\\d+$".r + + /** Create an instance of MilestoneId from the given Long type. + * + * @param source + * An instance of type Long which will be returned as a MilestoneId. + * @return + * The appropriate instance of MilestoneId. + */ + def apply(source: Long): MilestoneId = source + + /** Try to create an instance of MilestoneId from the given Long. + * + * @param source + * A Long that should fulfil the requirements to be converted into a MilestoneId. + * @return + * An option to the successfully converted MilestoneId. + */ + def from(source: Long): Option[MilestoneId] = Option(source) + + /** Try to create an instance of MilestoneId from the given String. + * + * @param source + * A string that should fulfil the requirements to be converted into a MilestoneId. + * @return + * An option to the successfully converted MilestoneId. + */ + def fromString(source: String): Option[MilestoneId] = + Option(source).filter(Format.matches).map(_.toLong).flatMap(from) + + extension (id: MilestoneId) { + def toLong: Long = id + } +} + +/** Extractor to retrieve an MilestoneId from a path parameter. + */ +object MilestoneIdPathParameter { + def unapply(str: String): Option[MilestoneId] = Option(str).flatMap(MilestoneId.fromString) +} + +/** A title for a milestone, usually a version number, a word or a short phrase that is supposed to be unique within a + * project context. It must not be empty and not exceed 64 characters in length. + */ +opaque type MilestoneTitle = String +object MilestoneTitle { + given Eq[MilestoneTitle] = Eq.fromUniversalEquals + given Ordering[MilestoneTitle] = (x: MilestoneTitle, y: MilestoneTitle) => x.compareTo(y) + given Order[MilestoneTitle] = Order.fromOrdering[MilestoneTitle] + + val MaxLength: Int = 64 + + /** Create an instance of MilestoneTitle from the given String type. + * + * @param source + * An instance of type String which will be returned as a MilestoneTitle. + * @return + * The appropriate instance of MilestoneTitle. + */ + def apply(source: String): MilestoneTitle = source + + /** Try to create an instance of MilestoneTitle from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a MilestoneTitle. + * @return + * An option to the successfully converted MilestoneTitle. + */ + def from(source: String): Option[MilestoneTitle] = + Option(source).filter(string => string.nonEmpty && string.length <= MaxLength) + +} + +/** Extractor to retrieve an MilestoneTitle from a path parameter. + */ +object MilestoneTitlePathParameter { + def unapply(str: String): Option[MilestoneTitle] = Option(str).flatMap(MilestoneTitle.from) +} + +/** A longer detailed description of a project milestone which must not be empty. + */ +opaque type MilestoneDescription = String +object MilestoneDescription { + given Eq[MilestoneDescription] = Eq.fromUniversalEquals + + /** Create an instance of MilestoneDescription from the given String type. + * + * @param source + * An instance of type String which will be returned as a MilestoneDescription. + * @return + * The appropriate instance of MilestoneDescription. + */ + def apply(source: String): MilestoneDescription = source + + /** Try to create an instance of MilestoneDescription from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a MilestoneDescription. + * @return + * An option to the successfully converted MilestoneDescription. + */ + def from(source: String): Option[MilestoneDescription] = Option(source).map(_.trim).filter(_.nonEmpty) + +} + +/** A milestone can be used to organise tickets and progress inside a project. + * + * @param id + * An optional attribute containing the unique internal database ID for the milestone. + * @param title + * A title for the milestone, usually a version number, a word or a short phrase that is supposed to be unique within + * a project context. + * @param description + * An optional longer description of the milestone. + * @param dueDate + * An optional date on which the milestone is supposed to be reached. + * @param closed + * This flag indicates if the milestone is closed e.g. considered done or obsolete. + */ +final case class Milestone( + id: Option[MilestoneId], + title: MilestoneTitle, + description: Option[MilestoneDescription], + dueDate: Option[LocalDate], + closed: Boolean +) + +object Milestone { + + given Eq[LocalDate] = Eq.instance((a, b) => a.compareTo(b) === 0) + + given Eq[Milestone] = + Eq.instance((a, b) => + a.id === b.id && a.title === b.title && a.dueDate === b.dueDate && a.description === b.description + ) + +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/ProjectRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/ProjectRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/ProjectRepository.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/ProjectRepository.scala 2025-01-12 05:25:38.815741510 +0000 @@ -0,0 +1,96 @@ +/* + * 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 + +/** A base class for a database repository that should handle all functionality regarding projects in the database. + * + * @tparam F + * A higher kinded type which wraps the actual return values. + */ +abstract class ProjectRepository[F[_]] { + + /** Create the given project within the database. + * + * @param project + * The project that shall be created. + * @return + * The number of affected database rows. + */ + def createProject(project: Project): F[Int] + + /** Delete the given project from the database. + * + * @param project + * The project that shall be deleted. + * @return + * The number of affected database rows. + */ + def deleteProject(project: Project): F[Int] + + /** Search for the project entry with the given owner and name. + * + * @param owner + * Data about the owner of the project containing information needed to query the database. + * @param name + * The project name which must be unique in regard to the owner. + * @return + * An option to the successfully found project entry. + */ + def findProject(owner: ProjectOwner, name: ProjectName): F[Option[Project]] + + /** Search for the internal database specific (auto generated) ID of the given owner / project combination which + * serves as a primary key for the database table. + * + * @param owner + * Data about the owner of the project containing information needed to query the database. + * @param name + * The project name which must be unique in regard to the owner. + * @return + * An option to the internal database ID. + */ + def findProjectId(owner: ProjectOwner, name: ProjectName): F[Option[ProjectId]] + + /** Search for a project owner of whom we only know the name. + * + * @param name + * The name of the project owner which is the username of the actual owners account. + * @return + * An option to successfully found project owner. + */ + def findProjectOwner(name: ProjectOwnerName): F[Option[ProjectOwner]] + + /** Increment the counter column for the next ticket number and return the old value (i.e. the value _before_ it was + * incremented). + * + * @param projectId + * The internal database id of the project. + * @return + * The ticket number _before_ it was incremented. + */ + def incrementNextTicketNumber(projectId: ProjectId): F[TicketNumber] + + /** Update the database entry for the given project. + * + * @param project + * The project that shall be updated within the database. + * @return + * The number of affected database rows. + */ + def updateProject(project: Project): F[Int] + +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Project.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Project.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Project.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Project.scala 2025-01-12 05:25:38.815741510 +0000 @@ -0,0 +1,355 @@ +/* + * 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.util.UUID + +import cats.* +import cats.data.* +import cats.syntax.all.* +import de.smederee.email.EmailAddress +import de.smederee.security.UserId +import de.smederee.security.Username + +import scala.util.Try +import scala.util.matching.Regex + +opaque type ProjectDescription = String +object ProjectDescription { + val MaximumLength: Int = 8192 + + /** Create an instance of ProjectDescription from the given String type. + * + * @param source + * An instance of type String which will be returned as a ProjectDescription. + * @return + * The appropriate instance of ProjectDescription. + */ + def apply(source: String): ProjectDescription = source + + /** Try to create an instance of ProjectDescription from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a ProjectDescription. + * @return + * An option to the successfully converted ProjectDescription. + */ + def from(source: String): Option[ProjectDescription] = Option(source).map(_.take(MaximumLength)) + +} + +opaque type ProjectId = Long +object ProjectId { + given Eq[ProjectId] = Eq.fromUniversalEquals + + val Format: Regex = "^-?\\d+$".r + + /** Create an instance of ProjectId from the given Long type. + * + * @param source + * An instance of type Long which will be returned as a ProjectId. + * @return + * The appropriate instance of ProjectId. + */ + def apply(source: Long): ProjectId = source + + /** Try to create an instance of ProjectId from the given Long. + * + * @param source + * A Long that should fulfil the requirements to be converted into a ProjectId. + * @return + * An option to the successfully converted ProjectId. + */ + def from(source: Long): Option[ProjectId] = Option(source) + + /** Try to create an instance of ProjectId from the given String. + * + * @param source + * A string that should fulfil the requirements to be converted into a ProjectId. + * @return + * An option to the successfully converted ProjectId. + */ + def fromString(source: String): Option[ProjectId] = + Option(source).filter(Format.matches).flatMap(string => Try(string.toLong).toOption).flatMap(from) + + extension (id: ProjectId) { + def toLong: Long = id + } +} + +opaque type ProjectName = String +object ProjectName { + + given Eq[ProjectName] = Eq.fromUniversalEquals + + given Order[ProjectName] = Order.from((a, b) => a.toString.compareTo(b.toString)) + + // TODO: Can we rewrite this in a Scala-3 way (i.e. without implicitly)? + given Ordering[ProjectName] = implicitly[Order[ProjectName]].toOrdering + + val Format: Regex = "^[a-zA-Z0-9][a-zA-Z0-9\\-_]{1,63}$".r + + /** Create an instance of ProjectName from the given String type. + * + * @param source + * An instance of type String which will be returned as a ProjectName. + * @return + * The appropriate instance of ProjectName. + */ + def apply(source: String): ProjectName = source + + /** Try to create an instance of ProjectName from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a ProjectName. + * @return + * An option to the successfully converted ProjectName. + */ + def from(source: String): Option[ProjectName] = validate(source).toOption + + /** Validate the given string and return either the validated repository name or a list of errors. + * + * @param s + * An arbitrary string which should be a repository name. + * @return + * Either a list of errors or the validated repository name. + */ + def validate(s: String): ValidatedNec[String, ProjectName] = + Option(s).map(_.trim.nonEmpty) match { + case Some(true) => + val input = s.trim + val miniumLength = + if (input.length > 1) + input.validNec + else + "Repository name too short (min. 2 characters)!".invalidNec + val maximumLength = + if (input.length < 65) + input.validNec + else + "Repository name too long (max. 64 characters)!".invalidNec + val validFormat = + if (Format.matches(input)) + input.validNec + else + "Repository name must start with a letter or number and is only allowed to contain alphanumeric characters plus .".invalidNec + (miniumLength, maximumLength, validFormat).mapN { case (_, _, name) => + name + } + case _ => "Repository name must not be empty!".invalidNec + } +} + +/** Extractor to retrieve a ProjectName from a path parameter. + */ +object ProjectNamePathParameter { + def unapply(str: String): Option[ProjectName] = Option(str).flatMap(ProjectName.from) +} + +/** A project owner id is supposed to be globally unique and maps to the [[java.util.UUID]] type beneath. + */ +opaque type ProjectOwnerId = UUID +object ProjectOwnerId { + val Format = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$".r + + given Eq[ProjectOwnerId] = Eq.fromUniversalEquals + + // given Conversion[UserId, ProjectOwnerId] = ProjectOwnerId.fromUserId + + /** Create an instance of ProjectOwnerId from the given UUID type. + * + * @param source + * An instance of type UUID which will be returned as a ProjectOwnerId. + * @return + * The appropriate instance of ProjectOwnerId. + */ + def apply(source: UUID): ProjectOwnerId = source + + /** Try to create an instance of ProjectOwnerId from the given UUID. + * + * @param source + * A UUID that should fulfil the requirements to be converted into a ProjectOwnerId. + * @return + * An option to the successfully converted ProjectOwnerId. + */ + def from(source: UUID): Option[ProjectOwnerId] = Option(source) + + /** Try to create an instance of ProjectOwnerId from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a ProjectOwnerId. + * @return + * An option to the successfully converted ProjectOwnerId. + */ + def fromString(source: String): Either[String, ProjectOwnerId] = + Option(source) + .filter(s => Format.matches(s)) + .flatMap { uuidString => + Either.catchNonFatal(UUID.fromString(uuidString)).toOption + } + .toRight("Illegal value for ProjectOwnerId!") + + /** Create an instance of ProjectOwnerId from the given UserId type. + * + * @param uid + * An instance of type UserId which will be returned as a ProjectOwnerId. + * @return + * The appropriate instance of ProjectOwnerId. + */ + def fromUserId(uid: UserId): ProjectOwnerId = uid.toUUID + + /** Generate a new random project owner id. + * + * @return + * A project owner id which is pseudo randomly generated. + */ + def randomProjectOwnerId: ProjectOwnerId = UUID.randomUUID + + extension (uid: ProjectOwnerId) { + def toUUID: UUID = uid + } +} + +/** A project owner name for an account has to obey several restrictions which are similiar to the ones found for Unix + * usernames. It must start with a letter, contain only alphanumeric characters, be at least 2 and at most 31 + * characters long and be all lowercase. + */ +opaque type ProjectOwnerName = String +object ProjectOwnerName { + given Eq[ProjectOwnerName] = Eq.fromUniversalEquals + + given Conversion[Username, ProjectOwnerName] = ProjectOwnerName.fromUsername + + val isAlphanumeric = "^[a-z][a-z0-9]+$".r + + /** Create an instance of ProjectOwnerName from the given String type. + * + * @param source + * An instance of type String which will be returned as a ProjectOwnerName. + * @return + * The appropriate instance of ProjectOwnerName. + */ + def apply(source: String): ProjectOwnerName = source + + /** Try to create an instance of ProjectOwnerName from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a ProjectOwnerName. + * @return + * An option to the successfully converted ProjectOwnerName. + */ + def from(s: String): Option[ProjectOwnerName] = validate(s).toOption + + /** Create an instance of ProjectOwnerName from the given Username type. + * + * @param username + * An instance of the type Username which will be returned as a ProjectOwnerName. + * @return + * The appropriate instance of ProjectOwnerName. + */ + def fromUsername(username: Username): ProjectOwnerName = username.toString + + /** Validate the given string and return either the validated project owner name or a list of errors. + * + * @param s + * An arbitrary string which should be a project owner name. + * @return + * Either a list of errors or the validated project owner name. + */ + def validate(s: String): ValidatedNec[String, ProjectOwnerName] = + Option(s).map(_.trim.nonEmpty) match { + case Some(true) => + val input = s.trim + val miniumLength = + if (input.length >= 2) + input.validNec + else + "ProjectOwnerName too short (min. 2 characters)!".invalidNec + val maximumLength = + if (input.length < 32) + input.validNec + else + "ProjectOwnerName too long (max. 31 characters)!".invalidNec + val alphanumeric = + if (isAlphanumeric.matches(input)) + input.validNec + else + "ProjectOwnerName must be all lowercase alphanumeric characters and start with a character.".invalidNec + (miniumLength, maximumLength, alphanumeric).mapN { case (_, _, name) => + name + } + case _ => "ProjectOwnerName must not be empty!".invalidNec + } + + extension (ownername: ProjectOwnerName) { + + /** Convert this project owner name into a username. + * + * @return + * A syntactically valid username. + */ + def toUsername: Username = Username(ownername.toString) + } +} + +/** Extractor to retrieve an ProjectOwnerName from a path parameter. + */ +object ProjectOwnerNamePathParameter { + def unapply(str: String): Option[ProjectOwnerName] = + Option(str).flatMap { string => + if (string.startsWith("~")) + ProjectOwnerName.from(string.drop(1)) + else + None + } +} + +/** Descriptive information about the owner of a project. + * + * @param owner + * The unique ID of the project owner. + * @param name + * The name of the project owner which is supposed to be unique. + * @param email + * The email address of the project owner. + */ +final case class ProjectOwner(uid: ProjectOwnerId, name: ProjectOwnerName, email: EmailAddress) + +object ProjectOwner { + given Eq[ProjectOwner] = Eq.fromUniversalEquals +} + +/** A project is the base entity for tracking tickets. + * + * @param owner + * The owner of the project. + * @param name + * The name of the project. A project name must start with a letter or number and must contain only alphanumeric + * ASCII characters as well as minus or underscore signs. It must be between 2 and 64 characters long. + * @param description + * An optional short text description of the project. + * @param isPrivate + * A flag indicating if this project is private i.e. only visible / accessible for accounts with appropriate + * permissions. + */ +final case class Project( + owner: ProjectOwner, + name: ProjectName, + description: Option[ProjectDescription], + isPrivate: Boolean +) diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Slf4jLogHandler.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Slf4jLogHandler.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Slf4jLogHandler.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Slf4jLogHandler.scala 2025-01-12 05:25:38.815741510 +0000 @@ -0,0 +1,91 @@ +/* + * 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 doobie.util.log.* +import org.slf4j.Logger + +object Slf4jLogHandler { + private val RedactArguments: Boolean = true // This SHALL only be set to `false` when debugging issues! + + private val sqlArgumentsToLogString: List[Any] => String = arguments => + if (RedactArguments) + arguments.map(_ => "redacted").mkString(", ") + else + arguments.mkString(", ") + + private val sqlQueryToLogString: String => String = _.linesIterator.dropWhile(_.trim.isEmpty).mkString("\n ") + + /** Create a [[doobie.util.log.LogHandler]] for logging doobie queries and errors. For convenience it is best to + * simply return the return value of this method to a given (implicit) instance. + * + * @param log + * A logger which provides an slf4j interface. + * @return + * A log handler as expected by doobie. + */ + def createLogHandler[F[_]: Sync](log: Logger): LogHandler[F] = + new LogHandler[F] { + def run(logEvent: LogEvent): F[Unit] = + Sync[F].delay { + logEvent match { + case Success(sqlQuery, arguments, label, executionTime, processingTime) => + log.debug(s"""SQL command successful: + | + | ${sqlQueryToLogString(sqlQuery)} + | + | arguments: [${sqlArgumentsToLogString(arguments)}] + | label: $label + | + | execution time : ${executionTime.toMillis} ms + | processing time: ${processingTime.toMillis} ms + | total time : ${(executionTime + processingTime).toMillis} ms + |""".stripMargin) + case ProcessingFailure(sqlQuery, arguments, label, executionTime, processingTime, failure) => + log.error( + s"""SQL PROCESSING FAILURE: + | + | ${sqlQueryToLogString(sqlQuery)} + | + | arguments: [${sqlArgumentsToLogString(arguments)}] + | label: $label + | + | execution time : ${executionTime.toMillis} ms + | processing time: ${processingTime.toMillis} ms + | total time : ${(executionTime + processingTime).toMillis} ms + |""".stripMargin, + failure + ) + case ExecFailure(sqlQuery, arguments, label, executionTime, failure) => + log.error( + s"""SQL EXECUTION FAILURE: + | + | ${sqlQueryToLogString(sqlQuery)} + | + | arguments: [${sqlArgumentsToLogString(arguments)}] + | label: $label + | + | execution time : ${executionTime.toMillis} ms + |""".stripMargin, + failure + ) + } + } + } +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Submitter.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Submitter.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Submitter.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Submitter.scala 2025-01-12 05:25:38.815741510 +0000 @@ -0,0 +1,163 @@ +/* + * 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.util.UUID + +import cats.* +import cats.data.* +import cats.syntax.all.* + +/** A submitter id is supposed to be globally unique and maps to the [[java.util.UUID]] type beneath. + */ +opaque type SubmitterId = UUID +object SubmitterId { + val Format = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$".r + + given Eq[SubmitterId] = Eq.fromUniversalEquals + + /** Create an instance of SubmitterId from the given UUID type. + * + * @param source + * An instance of type UUID which will be returned as a SubmitterId. + * @return + * The appropriate instance of SubmitterId. + */ + def apply(source: UUID): SubmitterId = source + + /** Try to create an instance of SubmitterId from the given UUID. + * + * @param source + * A UUID that should fulfil the requirements to be converted into a SubmitterId. + * @return + * An option to the successfully converted SubmitterId. + */ + def from(source: UUID): Option[SubmitterId] = Option(source) + + /** Try to create an instance of SubmitterId from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a SubmitterId. + * @return + * An option to the successfully converted SubmitterId. + */ + def fromString(source: String): Either[String, SubmitterId] = + Option(source) + .filter(s => Format.matches(s)) + .flatMap { uuidString => + Either.catchNonFatal(UUID.fromString(uuidString)).toOption + } + .toRight("Illegal value for SubmitterId!") + + /** Generate a new random user id. + * + * @return + * A user id which is pseudo randomly generated. + */ + def randomSubmitterId: SubmitterId = UUID.randomUUID + + extension (uid: SubmitterId) { + def toUUID: UUID = uid + } +} + +/** A submitter name has to obey several restrictions which are similiar to the ones found for Unix usernames. It must + * start with a letter, contain only alphanumeric characters, be at least 2 and at most 31 characters long and be all + * lowercase. + */ +opaque type SubmitterName = String +object SubmitterName { + given Eq[SubmitterName] = Eq.fromUniversalEquals + + val isAlphanumeric = "^[a-z][a-z0-9]+$".r + + /** Create an instance of SubmitterName from the given String type. + * + * @param source + * An instance of type String which will be returned as a SubmitterName. + * @return + * The appropriate instance of SubmitterName. + */ + def apply(source: String): SubmitterName = source + + /** Try to create an instance of SubmitterName from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a SubmitterName. + * @return + * An option to the successfully converted SubmitterName. + */ + def from(s: String): Option[SubmitterName] = validate(s).toOption + + /** Validate the given string and return either the validated username or a list of errors. + * + * @param s + * An arbitrary string which should be a username. + * @return + * Either a list of errors or the validated username. + */ + def validate(s: String): ValidatedNec[String, SubmitterName] = + Option(s).map(_.trim.nonEmpty) match { + case Some(true) => + val input = s.trim + val miniumLength = + if (input.length >= 2) + input.validNec + else + "SubmitterName too short (min. 2 characters)!".invalidNec + val maximumLength = + if (input.length < 32) + input.validNec + else + "SubmitterName too long (max. 31 characters)!".invalidNec + val alphanumeric = + if (isAlphanumeric.matches(input)) + input.validNec + else + "SubmitterName must be all lowercase alphanumeric characters and start with a character.".invalidNec + (miniumLength, maximumLength, alphanumeric).mapN { case (_, _, name) => + name + } + case _ => "SubmitterName must not be empty!".invalidNec + } +} + +/** Extractor to retrieve an SubmitterName from a path parameter. + */ +object SubmitterNamePathParameter { + def unapply(str: String): Option[SubmitterName] = + Option(str).flatMap { string => + if (string.startsWith("~")) + SubmitterName.from(string.drop(1)) + else + None + } +} + +/** The submitter for a ticket i.e. the person supposed to be working on it. + * + * @param id + * A globally unique ID identifying the submitter. + * @param name + * The name associated with the submitter which is supposed to be unique. + */ +final case class Submitter(id: SubmitterId, name: SubmitterName) + +object Submitter { + given Eq[Submitter] = Eq.fromUniversalEquals +} 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 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRepository.scala 2025-01-12 05:25:38.815741510 +0000 @@ -0,0 +1,207 @@ +/* + * 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 fs2.Stream + +/** The base class that defines the needed functionality to handle tickets and related data types within a database. + * + * @tparam F + * A higher kinded type which wraps the actual return values. + */ +abstract class TicketRepository[F[_]] { + + /** Add the given assignee to the ticket of the given repository 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 assignee + * The assignee to be added to the ticket. + * @return + * The number of affected database rows. + */ + def addAssignee(projectId: ProjectId)(ticketNumber: TicketNumber)(assignee: Assignee): F[Int] + + /** Add the given label to the ticket of the given repository 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 label + * The label to be added to the ticket. + * @return + * The number of affected database rows. + */ + def addLabel(projectId: ProjectId)(ticketNumber: TicketNumber)(label: Label): F[Int] + + /** Add the given milestone to the ticket of the given repository 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 milestone + * The milestone to be added to the ticket. + * @return + * The number of affected database rows. + */ + def addMilestone(projectId: ProjectId)(ticketNumber: TicketNumber)(milestone: Milestone): F[Int] + + /** Return all tickets associated with the given repository. + * + * @param filter + * A ticket filter containing possible values which will be used to filter the list of tickets. + * @param projectId + * The unique internal ID of a ticket tracking project for which all tickets shall be returned. + * @return + * A stream of tickets associated with the vcs repository which may be empty. + */ + def allTickets(filter: Option[TicketFilter])(projectId: ProjectId): Stream[F, Ticket] + + /** Create a database entry for the given ticket definition within the scope of the repository with the given id. + * + * @param projectId + * The unique internal ID of a ticket tracking project. + * @param ticket + * The ticket definition that shall be written to the database. + * @return + * The number of affected database rows. + */ + def createTicket(projectId: ProjectId)(ticket: Ticket): F[Int] + + /** Delete the ticket of the repository with the given id from the database. + * + * @param projectId + * The unique internal ID of a ticket tracking project. + * @param ticket + * The ticket definition that shall be deleted from the database. + * @return + * The number of affected database rows. + */ + def deleteTicket(projectId: ProjectId)(ticket: Ticket): F[Int] + + /** Find the ticket with the given number of the repository with the given 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 + * An option to the found ticket. + */ + def findTicket(projectId: ProjectId)(ticketNumber: TicketNumber): F[Option[Ticket]] + + /** Find the ticket with the given number of the project with the given id and return the internal unique id of the + * ticket. + * + * @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 + * An option to the found ticket. + */ + def findTicketId(projectId: ProjectId)(ticketNumber: TicketNumber): F[Option[TicketId]] + + /** Load all assignees that are assigned to the ticket with the given number and repository 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 assigness that may be empty. + */ + def loadAssignees(projectId: ProjectId)(ticketNumber: TicketNumber): Stream[F, Assignee] + + /** Load all labels that are attached to the ticket with the given number and repository 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 labels that may be empty. + */ + def loadLabels(projectId: ProjectId)(ticketNumber: TicketNumber): Stream[F, Label] + + /** Load all milestones that are attached to the ticket with the given number and repository 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 milestones that may be empty. + */ + def loadMilestones(projectId: ProjectId)(ticketNumber: TicketNumber): Stream[F, Milestone] + + /** Remove the given assignee from the ticket of the given repository 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 assignee + * The assignee to be removed from the ticket. + * @return + * The number of affected database rows. + */ + def removeAssignee(projectId: ProjectId)(ticket: Ticket)(assignee: Assignee): F[Int] + + /** Remove the given label from the ticket of the given repository 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 label + * The label to be removed from the ticket. + * @return + * The number of affected database rows. + */ + def removeLabel(projectId: ProjectId)(ticket: Ticket)(label: Label): F[Int] + + /** Remove the given milestone from the ticket of the given repository 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 milestone + * The milestone to be removed from the ticket. + * @return + * The number of affected database rows. + */ + def removeMilestone(projectId: ProjectId)(ticket: Ticket)(milestone: Milestone): F[Int] + + /** Update the database entry for the given ticket within the scope of the repository with the given id. + * + * @param projectId + * The unique internal ID of a ticket tracking project. + * @param ticket + * The ticket definition that shall be updated within the database. + * @return + * The number of affected database rows. + */ + def updateTicket(projectId: ProjectId)(ticket: Ticket): F[Int] + +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala 2025-01-12 05:25:38.803741491 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala 2025-01-12 05:25:38.815741510 +0000 @@ -63,10 +63,8 @@ ) extends Http4sDsl[F] { private val log = LoggerFactory.getLogger(getClass) - given CsrfProtectionConfiguration = configuration.csrfProtection - private val linkToHubService = configuration.hub.baseUri - private val linkConfig = configuration.externalUrl + private val linkConfig = configuration.external /** Load the project metadata with the given owner and name from the database and return it and its primary key id * if the project exists and is readable by the given user account. 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 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Ticket.scala 2025-01-12 05:25:38.815741510 +0000 @@ -0,0 +1,426 @@ +/* + * 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.* +import cats.syntax.all.* +import org.http4s.QueryParamDecoder +import org.http4s.QueryParamEncoder +import org.http4s.dsl.impl.OptionalQueryParamDecoderMatcher + +import scala.util.matching.Regex + +/** An unlimited text field which must be not empty to describe the ticket in great detail if needed. + */ +opaque type TicketContent = String +object TicketContent { + + /** Create an instance of TicketContent from the given String type. + * + * @param source + * An instance of type String which will be returned as a TicketContent. + * @return + * The appropriate instance of TicketContent. + */ + def apply(source: String): TicketContent = source + + /** Try to create an instance of TicketContent from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a TicketContent. + * @return + * An option to the successfully converted TicketContent. + */ + def from(source: String): Option[TicketContent] = Option(source).filter(_.nonEmpty) + +} + +opaque type TicketId = Long +object TicketId { + given Eq[TicketId] = Eq.fromUniversalEquals + + val Format: Regex = "^-?\\d+$".r + + /** Create an instance of TicketId from the given Long type. + * + * @param source + * An instance of type Long which will be returned as a TicketId. + * @return + * The appropriate instance of TicketId. + */ + def apply(source: Long): TicketId = source + + /** Try to create an instance of TicketId from the given Long. + * + * @param source + * A Long that should fulfil the requirements to be converted into a TicketId. + * @return + * An option to the successfully converted TicketId. + */ + def from(source: Long): Option[TicketId] = Option(source) + + /** Try to create an instance of TicketId from the given String. + * + * @param source + * A string that should fulfil the requirements to be converted into a TicketId. + * @return + * An option to the successfully converted TicketId. + */ + def fromString(source: String): Option[TicketId] = Option(source).filter(Format.matches).map(_.toLong).flatMap(from) + + extension (id: TicketId) { + def toLong: Long = id + } +} + +/** A ticket number maps to an integer beneath and has the requirement to be greater than zero. + */ +opaque type TicketNumber = Int +object TicketNumber { + given Eq[TicketNumber] = Eq.fromUniversalEquals + given Ordering[TicketNumber] = (x: TicketNumber, y: TicketNumber) => x.compareTo(y) + given Order[TicketNumber] = Order.fromOrdering + + val Format: Regex = "^-?\\d+$".r + + /** Create an instance of TicketNumber from the given Int type. + * + * @param source + * An instance of type Int which will be returned as a TicketNumber. + * @return + * The appropriate instance of TicketNumber. + */ + def apply(source: Int): TicketNumber = source + + /** Try to create an instance of TicketNumber from the given Int. + * + * @param source + * A Int that should fulfil the requirements to be converted into a TicketNumber. + * @return + * An option to the successfully converted TicketNumber. + */ + def from(source: Int): Option[TicketNumber] = Option(source).filter(_ > 0) + + /** Try to create an instance of TicketNumber from the given String. + * + * @param source + * A string that should fulfil the requirements to be converted into a TicketNumber. + * @return + * An option to the successfully converted TicketNumber. + */ + def fromString(source: String): Option[TicketNumber] = + Option(source).filter(Format.matches).map(_.toInt).flatMap(from) + + extension (number: TicketNumber) { + def toInt: Int = number.toInt + } +} + +/** Extractor to retrieve a TicketNumber from a path parameter. + */ +object TicketNumberPathParameter { + def unapply(str: String): Option[TicketNumber] = Option(str).flatMap(TicketNumber.fromString) +} + +/** Possible states of a ticket which shall model the life cycle from ticket creation until it is closed. To keep things + * simple we do not model the kind of a resolution (e.g. fixed, won't fix or so) for a ticket. + */ +enum TicketStatus { + + /** The ticket is considered to be a valid ticket / ready to be worked on e.g. a bug has been confirmed to be + * present. + */ + case Confirmed + + /** The ticket is being worked on i.e. it is in progress. + */ + case InProgress + + /** The ticket is pending and cannot be processed right now. It may be moved to another state or closed depending on + * the circumstances. This could be used to model the "blocked" state of Kanban. + */ + case Pending + + /** The ticket is resolved (i.e. closed) and considered done. + */ + case Resolved + + /** The ticket was reported and is open for further investigation. This might map to what is often called "Backlog" + * nowadays. + */ + case Submitted +} + +object TicketStatus { + given Eq[TicketStatus] = Eq.fromUniversalEquals + + /** Try to parse a ticket status instance from the given string without throwin an exception like `valueOf`. + * + * @param source + * A string that should contain the name of a ticket status. + * @return + * An option to the successfully deserialised instance. + */ + def fromString(source: String): Option[TicketStatus] = + TicketStatus.values.map(_.toString).find(_ === source).map(TicketStatus.valueOf) +} + +/** Possible types of "resolved states" of a ticket. + */ +enum TicketResolution { + + /** The behaviour / scenario described in the ticket is caused by the design of the application and not considered + * to be a bug. + */ + case ByDesign + + /** The ticket is finally closed and considered done. + * + * This state can be used to model a review process e.g. a developer can move a ticket to `Fixed` and reviewer and + * tester can later move the ticket to `Closed`. + */ + case Closed + + /** The ticket is a duplicate of an already existing one. + */ + case Duplicate + + /** The bug described in the ticket was fixed. + */ + case Fixed + + /** The feature described in the ticket was implemented. + */ + case Implemented + + /** The ticket is considered to be invalid. + */ + case Invalid + + /** The issue described in the ticket will not be fixed. + */ + case WontFix +} + +object TicketResolution { + given Eq[TicketResolution] = Eq.fromUniversalEquals + + /** Try to parse a ticket resolution instance from the given string without throwin an exception like `valueOf`. + * + * @param source + * A string that should contain the name of a ticket resolution. + * @return + * An option to the successfully deserialised instance. + */ + def fromString(source: String): Option[TicketResolution] = + TicketResolution.values.map(_.toString).find(_ === source).map(TicketResolution.valueOf) +} + +/** A concise and short description of the ticket which should not exceed 80 characters. + */ +opaque type TicketTitle = String +object TicketTitle { + + val MaxLength: Int = 72 + + /** Create an instance of TicketTitle from the given String type. + * + * @param source + * An instance of type String which will be returned as a TicketTitle. + * @return + * The appropriate instance of TicketTitle. + */ + def apply(source: String): TicketTitle = source + + /** Try to create an instance of TicketTitle from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a TicketTitle. + * @return + * An option to the successfully converted TicketTitle. + */ + def from(source: String): Option[TicketTitle] = + Option(source).filter(string => string.nonEmpty && string.length <= MaxLength) +} + +/** An ticket used to describe a problem or a task (e.g. implement a concrete feature) within the scope of a project. + * + * @param number + * The unique identifier of a ticket within the project scope is its number. + * @param title + * A concise and short description of the ticket which should not exceed 72 characters. + * @param content + * An optional field to describe the ticket in great detail if needed. + * @param status + * The current status of the ticket describing its life cycle. + * @param resolution + * An optional resolution state of the ticket that should be set if it is closed. + * @param submitter + * The person who submitted (created) this ticket 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 Ticket( + number: TicketNumber, + title: TicketTitle, + content: Option[TicketContent], + status: TicketStatus, + resolution: Option[TicketResolution], + submitter: Option[Submitter], + createdAt: OffsetDateTime, + updatedAt: OffsetDateTime +) + +/** A data container for values that can be used to filter a list of tickets by. + * + * @param number + * A list of ticket numbers that must be matched. + * @param status + * A list of ticket status flags that must be matched. + * @param resolution + * A list of ticket resolution kinds that must be matched. + * @param submitter + * A list of usernames from whom the ticket must have been submitted. + */ +final case class TicketFilter( + number: List[TicketNumber], + status: List[TicketStatus], + resolution: List[TicketResolution], + submitter: List[SubmitterName] +) + +object TicketFilter { + given QueryParamDecoder[TicketFilter] = QueryParamDecoder[String].map(TicketFilter.fromQueryParameter) + given QueryParamEncoder[TicketFilter] = QueryParamEncoder[String].contramap(_.toQueryParameter) + + /** Decode an optional possibly existing query parameter into a `TicketFilter`. + * + * Usage: `case GET -> Root / "..." :? OptionalUrlParamter(maybeFilter) => ...` + */ + object OptionalUrlParameter extends OptionalQueryParamDecoderMatcher[TicketFilter]("q") + + // Only "open" tickets. + val OpenTicketsOnly = TicketFilter( + number = Nil, + status = TicketStatus.values.filterNot(_ === TicketStatus.Resolved).toList, + resolution = Nil, + submitter = Nil + ) + // Only resolved (closed) tickets. + val ResolvedTicketsOnly = + TicketFilter(number = Nil, status = List(TicketStatus.Resolved), resolution = Nil, submitter = Nil) + + /** Parse the given query string which must contain a serialised ticket filter instance and return a ticket filter + * with the successfully parsed filters. + * + * @param queryString + * A query string parameter passed via an URL. + * @return + * A ticket filter instance which may be empty. + */ + def fromQueryParameter(queryString: String): TicketFilter = { + val number = + if (queryString.contains("numbers: ")) + queryString + .drop(queryString.indexOf("numbers: ") + 9) + .takeWhile(char => !char.isWhitespace) + .split(",") + .map(TicketNumber.fromString) + .flatten + .toList + else + Nil + val status = + if (queryString.contains("status: ")) + queryString + .drop(queryString.indexOf("status: ") + 8) + .takeWhile(char => !char.isWhitespace) + .split(",") + .map(TicketStatus.fromString) + .flatten + .toList + else + Nil + val resolution = + if (queryString.contains("resolution: ")) + queryString + .drop(queryString.indexOf("resolution: ") + 12) + .takeWhile(char => !char.isWhitespace) + .split(",") + .map(TicketResolution.fromString) + .flatten + .toList + else + Nil + val submitter = + if (queryString.contains("by: ")) + queryString + .drop(queryString.indexOf("by: ") + 4) + .takeWhile(char => !char.isWhitespace) + .split(",") + .map(SubmitterName.from) + .flatten + .toList + else + Nil + TicketFilter(number, status, resolution, submitter) + } + + extension (filter: TicketFilter) { + + /** Convert this ticket filter instance into a query string representation that can be passed as query parameter + * in a URL and parsed back again. + * + * @return + * A string containing a serialised form of the ticket filter that can be used as a URL query parameter. + */ + def toQueryParameter: String = { + val numbers = + if (filter.number.isEmpty) + None + else + filter.number.map(_.toString).mkString(",").some + val status = + if (filter.status.isEmpty) + None + else + filter.status.map(_.toString).mkString(",").some + val resolution = + if (filter.resolution.isEmpty) + None + else + filter.resolution.map(_.toString).mkString(",").some + val submitter = + if (filter.submitter.isEmpty) + None + else + filter.submitter.map(_.toString).mkString(",").some + List( + numbers.map(string => s"numbers: $string"), + status.map(string => s"status: $string"), + resolution.map(string => s"resolution: $string"), + submitter.map(string => s"by: $string") + ).flatten.mkString(" ") + } + } +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketServiceApi.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketServiceApi.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketServiceApi.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketServiceApi.scala 2025-01-12 05:25:38.815741510 +0000 @@ -0,0 +1,48 @@ +/* + * 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 de.smederee.security.UserId + +/** Definition of a programmatic API for the ticket service which can be used to initialise and synchronise data with + * the hub service. + * + * @tparam F + * A higher kinded type which wraps the actual return values. + */ +abstract class TicketServiceApi[F[_]] { + + /** Create a user in the ticket service or update an existing one if an account with the unique id already exists. + * + * @param user + * The user account that shall be created. + * @return + * The number of affected database rows. + */ + def createOrUpdateUser(user: TicketsUser): F[Int] + + /** Delete the given user from the ticket service. + * + * @param uid + * The unique id of the user account. + * @return + * The number of affected database rows. + */ + def deleteUser(uid: UserId): F[Int] + +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketsUser.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketsUser.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketsUser.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketsUser.scala 2025-01-12 05:25:38.815741510 +0000 @@ -0,0 +1,41 @@ +/* + * 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.* +import de.smederee.email.EmailAddress +import de.smederee.i18n.LanguageCode +import de.smederee.security.UserId +import de.smederee.security.Username + +/** A user of the tickets service. + * + * @param uid + * The unique ID of the user. + * @param name + * A unique name which can be used for login and to identify the user. + * @param email + * The email address of the user which must also be unique. + * @param language + * The language code of the users preferred language. + */ +final case class TicketsUser(uid: UserId, name: Username, email: EmailAddress, language: Option[LanguageCode]) + +object TicketsUser { + given Eq[TicketsUser] = Eq.fromUniversalEquals +} 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 2025-01-12 05:25:38.803741491 +0000 +++ new-smederee/modules/hub/src/test/resources/application.conf 2025-01-12 05:25:38.815741510 +0000 @@ -10,3 +10,16 @@ pass = ${?SMEDEREE_HUB_TEST_DB_PASS} } } + +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/hub/src/test/scala/de/smederee/tickets/BaseSpec.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/BaseSpec.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/BaseSpec.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/BaseSpec.scala 2025-01-12 05:25:38.815741510 +0000 @@ -0,0 +1,369 @@ +/* + * 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 +import de.smederee.security.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/hub/src/test/scala/de/smederee/tickets/ColourCodeTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/ColourCodeTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/ColourCodeTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/ColourCodeTest.scala 2025-01-12 05:25:38.815741510 +0000 @@ -0,0 +1,42 @@ +/* + * 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 de.smederee.tickets.Generators.* + +import munit.* + +import org.scalacheck.* +import org.scalacheck.Prop.* + +final class ColourCodeTest extends ScalaCheckSuite { + given Arbitrary[ColourCode] = Arbitrary(genColourCode) + + property("ColourCode.from must fail on invalid input") { + forAll { (input: String) => + assertEquals(ColourCode.from(input), None) + } + } + + property("ColourCode.from must succeed on valid input") { + forAll { (colourCode: ColourCode) => + val input = colourCode.toString + assertEquals(ColourCode.from(input), Option(colourCode)) + } + } +} diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala 2025-01-12 05:25:38.819741516 +0000 @@ -0,0 +1,65 @@ +/* + * 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 de.smederee.tickets.BaseSpec +import org.flywaydb.core.Flyway + +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/hub/src/test/scala/de/smederee/tickets/config/SmedereeTicketsConfigurationTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/config/SmedereeTicketsConfigurationTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/config/SmedereeTicketsConfigurationTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/config/SmedereeTicketsConfigurationTest.scala 2025-01-12 05:25:38.819741516 +0000 @@ -0,0 +1,71 @@ +/* + * 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 com.comcast.ip4s.* +import com.typesafe.config.* +import org.http4s.Uri +import org.http4s.implicits.* +import pureconfig.* + +import munit.* + +final class SmedereeTicketsConfigurationTest extends FunSuite { + val rawDefaultConfig = new Fixture[Config]("defaultConfig") { + def apply() = ConfigFactory.load(getClass.getClassLoader) + } + + override def munitFixtures = List(rawDefaultConfig) + + test("must load from the default configuration successfully") { + ConfigSource + .fromConfig(rawDefaultConfig()) + .at(s"${SmedereeTicketsConfiguration.location.toString}") + .load[SmedereeTicketsConfiguration] match { + case Left(errors) => fail(errors.toList.mkString(", ")) + case Right(_) => assert(true) + } + } + + test("default values for external linking must be setup for local development") { + ConfigSource + .fromConfig(rawDefaultConfig()) + .at(s"${SmedereeTicketsConfiguration.location.toString}") + .load[SmedereeTicketsConfiguration] match { + case Left(errors) => fail(errors.toList.mkString(", ")) + case Right(cfg) => + val externalCfg = cfg.external + assertEquals(externalCfg.host, host"localhost") + assertEquals(externalCfg.port, Option(port"8080")) + assert(externalCfg.path.isEmpty) + assertEquals(externalCfg.scheme, Uri.Scheme.http) + } + } + + test("default values for hub service integration must be setup for local development") { + ConfigSource + .fromConfig(rawDefaultConfig()) + .at(s"${SmedereeTicketsConfiguration.location.toString}") + .load[SmedereeTicketsConfiguration] match { + case Left(errors) => fail(errors.toList.mkString(", ")) + case Right(cfg) => + val expectedUri = uri"http://localhost:8080" + assertEquals(cfg.hub.baseUri, expectedUri) + } + } +} diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala 2025-01-12 05:25:38.815741510 +0000 @@ -0,0 +1,312 @@ +/* + * 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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/hub/src/test/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala 2025-01-12 05:25:38.815741510 +0000 @@ -0,0 +1,446 @@ +/* + * 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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("allTickets must return all tickets associated with the milestone".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genMilestone.sample, genTickets.sample) match { + case (Some(owner), Some(generatedProject), Some(milestone), Some(rawTickets)) => + val project = generatedProject.copy(owner = owner) + val tickets = rawTickets.map(_.copy(submitter = None)) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + val milestoneRepo = new DoobieMilestoneRepository[IO](tx) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- createTicketsProject(project) + repoId <- loadProjectId(owner.uid, project.name) + foundTickets <- repoId match { + case None => IO.pure(List.empty) + case Some(projectId) => + for { + _ <- milestoneRepo.createMilestone(projectId)(milestone) + _ <- tickets.traverse(ticket => ticketRepo.createTicket(projectId)(ticket)) + createdMilestone <- milestoneRepo.findMilestone(projectId)(milestone.title) + _ <- createdMilestone match { + case None => IO.pure(List.empty) + case Some(milestone) => + tickets.traverse(ticket => + ticketRepo.addMilestone(projectId)(ticket.number)(milestone) + ) + } + foundTickets <- createdMilestone.map(_.id).getOrElse(None) match { + case None => IO.pure(List.empty) + case Some(milestoneId) => milestoneRepo.allTickets(None)(milestoneId).compile.toList + } + } yield foundTickets + } + } yield foundTickets + test.map { foundTickets => + assertEquals(foundTickets.size, tickets.size, "Different number of tickets!") + foundTickets.sortBy(_.number).zip(tickets.sortBy(_.number)).map { (found, expected) => + assertEquals( + found.copy(createdAt = expected.createdAt, updatedAt = expected.updatedAt), + expected + ) + } + } + case _ => fail("Could not generate data samples!") + } + } + + test("closeMilestone must set the closed flag correctly".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genMilestone.sample) match { + case (Some(owner), Some(generatedProject), Some(generatedMilestone)) => + val milestone = generatedMilestone.copy(closed = false) + 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 milestoneRepo = new DoobieMilestoneRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + createdRepos <- createTicketsProject(project) + repoId <- loadProjectId(owner.uid, project.name) + milestones <- repoId match { + case None => IO.pure((None, None)) + case Some(projectId) => + for { + _ <- milestoneRepo.createMilestone(projectId)(milestone) + before <- milestoneRepo.findMilestone(projectId)(milestone.title) + _ <- before.flatMap(_.id).traverse(milestoneRepo.closeMilestone) + after <- milestoneRepo.findMilestone(projectId)(milestone.title) + } yield (before, after) + } + } yield milestones + test.map { result => + val (before, after) = result + val expected = before.map(m => milestone.copy(id = m.id)) + assertEquals(before, expected, "Test milestone not properly initialised!") + assertEquals(after, before.map(_.copy(closed = true)), "Test milestone not properly closed!") + } + 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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("openMilestone must reset the closed flag correctly".tag(NeedsDatabase)) { + (genProjectOwner.sample, genProject.sample, genMilestone.sample) match { + case (Some(owner), Some(generatedProject), Some(generatedMilestone)) => + val milestone = generatedMilestone.copy(closed = true) + 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 milestoneRepo = new DoobieMilestoneRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + createdRepos <- createTicketsProject(project) + repoId <- loadProjectId(owner.uid, project.name) + milestones <- repoId match { + case None => IO.pure((None, None)) + case Some(projectId) => + for { + _ <- milestoneRepo.createMilestone(projectId)(milestone) + before <- milestoneRepo.findMilestone(projectId)(milestone.title) + _ <- before.flatMap(_.id).traverse(milestoneRepo.openMilestone) + after <- milestoneRepo.findMilestone(projectId)(milestone.title) + } yield (before, after) + } + } yield milestones + test.map { result => + val (before, after) = result + val expected = before.map(m => milestone.copy(id = m.id)) + assertEquals(before, expected, "Test milestone not properly initialised!") + assertEquals(after, before.map(_.copy(closed = false)), "Test milestone not properly opened!") + } + 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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/hub/src/test/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala 2025-01-12 05:25:38.819741516 +0000 @@ -0,0 +1,227 @@ +/* + * 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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/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 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala 2025-01-12 05:25:38.819741516 +0000 @@ -0,0 +1,899 @@ +/* + * 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]( + 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)) + _ <- 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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]( + 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) + _ <- 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]( + 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) + _ <- 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]( + 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) + _ <- 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]( + 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) + _ <- 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.map(_.name)) + 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) + _ <- 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]( + 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) + _ <- 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]( + 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) + _ <- 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]( + 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) + _ <- 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]( + 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) + _ <- 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]( + 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) + _ <- 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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]( + 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) + 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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]( + 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) + _ <- 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/hub/src/test/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala 2025-01-12 05:25:38.819741516 +0000 @@ -0,0 +1,107 @@ +/* + * 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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]( + driver = dbConfig.driver, + url = dbConfig.url, + user = dbConfig.user, + password = dbConfig.pass, + logHandler = None + ) + 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/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 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/Generators.scala 2025-01-12 05:25:38.819741516 +0000 @@ -0,0 +1,290 @@ +/* + * 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 +import java.util.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 +import org.scalacheck.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 genTicketStatusList: Gen[List[TicketStatus]] = Gen.nonEmptyListOf(genTicketStatus).map(_.distinct) + + val genTicketResolution: Gen[TicketResolution] = Gen.oneOf(TicketResolution.values.toList) + val genTicketResolutions: Gen[List[TicketResolution]] = Gen.nonEmptyListOf(genTicketResolution).map(_.distinct) + + val genTicketNumber: Gen[TicketNumber] = Gen.choose(0, Int.MaxValue).map(TicketNumber.apply) + val genTicketNumbers: Gen[List[TicketNumber]] = Gen.nonEmptyListOf(genTicketNumber).map(_.distinct) + + 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 genTicketFilter: Gen[TicketFilter] = + for { + number <- Gen.listOf(genTicketNumber) + status <- Gen.listOf(genTicketStatus) + resolution <- Gen.listOf(genTicketResolution) + submitter <- Gen.listOf(genSubmitter) + } yield TicketFilter(number, status, resolution, submitter.map(_.name).distinct) + + 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) + .map(_.foldLeft(List.empty[ProjectOwner]) { (acc, a) => + if (acc.exists(_.name === a.name)) + acc + else + a :: acc + }) // Ensure distinct user 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) + closed <- Gen.oneOf(List(false, true)) + } yield Milestone(id, title, descr, due, closed) + + 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/hub/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala 2025-01-12 05:25:38.819741516 +0000 @@ -0,0 +1,47 @@ +/* + * 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 de.smederee.tickets.Generators.* + +import munit.* + +import org.scalacheck.* +import org.scalacheck.Prop.* + +final class LabelDescriptionTest extends ScalaCheckSuite { + given Arbitrary[LabelDescription] = Arbitrary(genLabelDescription) + + test("LabelDescription.from must fail on empty input") { + assertEquals(LabelDescription.from(""), None) + } + + property("LabelDescription.from must fail on too long input") { + forAll { (input: String) => + if (input.length > LabelDescription.MaxLength) + assertEquals(LabelDescription.from(input), None) + } + } + + property("LabelDescription.from must succeed on valid input") { + forAll { (label: LabelDescription) => + val input = label.toString + assertEquals(LabelDescription.from(input), Option(label)) + } + } +} diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelNameTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelNameTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelNameTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelNameTest.scala 2025-01-12 05:25:38.819741516 +0000 @@ -0,0 +1,47 @@ +/* + * 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 de.smederee.tickets.Generators.* + +import munit.* + +import org.scalacheck.* +import org.scalacheck.Prop.* + +final class LabelNameTest extends ScalaCheckSuite { + given Arbitrary[LabelName] = Arbitrary(genLabelName) + + test("LabelName.from must fail on empty input") { + assertEquals(LabelName.from(""), None) + } + + property("LabelName.from must fail on too long input") { + forAll { (input: String) => + if (input.length > LabelName.MaxLength) + assertEquals(LabelName.from(input), None) + } + } + + property("LabelName.from must succeed on valid input") { + forAll { (label: LabelName) => + val input = label.toString + assertEquals(LabelName.from(input), Option(label)) + } + } +} diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelTest.scala 2025-01-12 05:25:38.819741516 +0000 @@ -0,0 +1,36 @@ +/* + * 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.syntax.all.* +import de.smederee.tickets.Generators.* + +import munit.* + +import org.scalacheck.* +import org.scalacheck.Prop.* + +final class LabelTest extends ScalaCheckSuite { + given Arbitrary[Label] = Arbitrary(genLabel) + + property("Eq must hold") { + forAll { (label: Label) => + assert(label === label, "Identical labels must be considered equal!") + } + } +} diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala 2025-01-12 05:25:38.819741516 +0000 @@ -0,0 +1,40 @@ +/* + * 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 de.smederee.tickets.Generators.* + +import munit.* + +import org.scalacheck.* +import org.scalacheck.Prop.* + +final class MilestoneDescriptionTest extends ScalaCheckSuite { + given Arbitrary[MilestoneDescription] = Arbitrary(genMilestoneDescription) + + test("MilestoneDescription.from must fail on empty input") { + assertEquals(MilestoneDescription.from(""), None) + } + + property("MilestoneDescription.from must succeed on valid input") { + forAll { (label: MilestoneDescription) => + val input = label.toString + assertEquals(MilestoneDescription.from(input), Option(label)) + } + } +} diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneTest.scala 2025-01-12 05:25:38.819741516 +0000 @@ -0,0 +1,36 @@ +/* + * 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.syntax.all.* +import de.smederee.tickets.Generators.* + +import munit.* + +import org.scalacheck.* +import org.scalacheck.Prop.* + +final class MilestoneTest extends ScalaCheckSuite { + given Arbitrary[Milestone] = Arbitrary(genMilestone) + + property("Eq must hold") { + forAll { (label: Milestone) => + assert(label === label, "Identical milestones must be considered equal!") + } + } +} diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala 2025-01-12 05:25:38.819741516 +0000 @@ -0,0 +1,47 @@ +/* + * 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 de.smederee.tickets.Generators.* + +import munit.* + +import org.scalacheck.* +import org.scalacheck.Prop.* + +final class MilestoneTitleTest extends ScalaCheckSuite { + given Arbitrary[MilestoneTitle] = Arbitrary(genMilestoneTitle) + + test("MilestoneTitle.from must fail on empty input") { + assertEquals(MilestoneTitle.from(""), None) + } + + property("MilestoneTitle.from must fail on too long input") { + forAll { (input: String) => + if (input.length > MilestoneTitle.MaxLength) + assertEquals(MilestoneTitle.from(input), None) + } + } + + property("MilestoneTitle.from must succeed on valid input") { + forAll { (label: MilestoneTitle) => + val input = label.toString + assertEquals(MilestoneTitle.from(input), Option(label)) + } + } +} diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketContentTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketContentTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketContentTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketContentTest.scala 2025-01-12 05:25:38.819741516 +0000 @@ -0,0 +1,36 @@ +/* + * 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 munit.* + +import org.scalacheck.* +import org.scalacheck.Prop.* + +final class TicketContentTest extends ScalaCheckSuite { + + property("TicketContent.from must only accept valid input") { + forAll { (input: String) => + if (input.nonEmpty) + assertEquals(TicketContent.from(input), Some(TicketContent(input))) + else + assertEquals(TicketContent.from(input), None) + } + } + +} diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketFilterTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketFilterTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketFilterTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketFilterTest.scala 2025-01-12 05:25:38.819741516 +0000 @@ -0,0 +1,136 @@ +/* + * 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 de.smederee.tickets.Generators.* + +import munit.* + +import org.scalacheck.* +import org.scalacheck.Prop.* + +final class TicketFilterTest extends ScalaCheckSuite { + given Arbitrary[Submitter] = Arbitrary(genSubmitter) + given Arbitrary[TicketFilter] = Arbitrary(genTicketFilter) + given Arbitrary[TicketNumber] = Arbitrary(genTicketNumber) + given Arbitrary[TicketResolution] = Arbitrary(genTicketResolution) + given Arbitrary[TicketStatus] = Arbitrary(genTicketStatus) + + property("fromQueryParameter must produce empty filters for invalid input") { + forAll { (randomInput: String) => + assertEquals( + TicketFilter.fromQueryParameter(randomInput), + TicketFilter(number = Nil, status = Nil, resolution = Nil, submitter = Nil) + ) + } + } + + property("fromQueryParameter must work for numbers only") { + forAll { (numbers: List[TicketNumber]) => + val filter = TicketFilter(number = numbers, status = Nil, resolution = Nil, submitter = Nil) + assertEquals(TicketFilter.fromQueryParameter(s"numbers: ${numbers.map(_.toString).mkString(",")}"), filter) + } + } + + property("fromQueryParameter must work for status only") { + forAll { (status: List[TicketStatus]) => + val filter = TicketFilter(number = Nil, status = status, resolution = Nil, submitter = Nil) + assertEquals(TicketFilter.fromQueryParameter(s"status: ${status.map(_.toString).mkString(",")}"), filter) + } + } + + property("fromQueryParameter must work for resolution only") { + forAll { (resolution: List[TicketResolution]) => + val filter = TicketFilter(number = Nil, status = Nil, resolution = resolution, submitter = Nil) + assertEquals( + TicketFilter.fromQueryParameter(s"resolution: ${resolution.map(_.toString).mkString(",")}"), + filter + ) + } + } + + property("fromQueryParameter must work for submitter only") { + forAll { (submitters: List[Submitter]) => + if (submitters.nonEmpty) { + val filter = + TicketFilter(number = Nil, status = Nil, resolution = Nil, submitter = submitters.map(_.name)) + assertEquals( + TicketFilter.fromQueryParameter(s"by: ${submitters.map(_.name.toString).mkString(",")}"), + filter + ) + } + } + } + + property("toQueryParameter must include numbers") { + forAll { (numbers: List[TicketNumber]) => + if (numbers.nonEmpty) { + val filter = TicketFilter(number = numbers, status = Nil, resolution = Nil, submitter = Nil) + assert( + TicketFilter.toQueryParameter(filter).contains(s"numbers: ${numbers.map(_.toString).mkString(",")}") + ) + } + } + } + + property("toQueryParameter must include status") { + forAll { (status: List[TicketStatus]) => + if (status.nonEmpty) { + val filter = TicketFilter(number = Nil, status = status, resolution = Nil, submitter = Nil) + assert( + TicketFilter.toQueryParameter(filter).contains(s"status: ${status.map(_.toString).mkString(",")}") + ) + } + } + } + + property("toQueryParameter must include resolution") { + forAll { (resolution: List[TicketResolution]) => + if (resolution.nonEmpty) { + val filter = TicketFilter(number = Nil, status = Nil, resolution = resolution, submitter = Nil) + assert( + TicketFilter + .toQueryParameter(filter) + .contains(s"resolution: ${resolution.map(_.toString).mkString(",")}"), + TicketFilter.toQueryParameter(filter) + ) + } + } + } + + property("toQueryParameter must include submitter") { + forAll { (submitters: List[Submitter]) => + if (submitters.nonEmpty) { + val filter = + TicketFilter(number = Nil, status = Nil, resolution = Nil, submitter = submitters.map(_.name)) + assert( + TicketFilter + .toQueryParameter(filter) + .contains(s"by: ${submitters.map(_.name.toString).mkString(",")}"), + TicketFilter.toQueryParameter(filter) + ) + } + } + } + + property("toQueryParameter must be the dual of fromQueryParameter") { + forAll { (filter: TicketFilter) => + assertEquals(TicketFilter.fromQueryParameter(filter.toQueryParameter), filter) + } + } +} diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketNumberTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketNumberTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketNumberTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketNumberTest.scala 2025-01-12 05:25:38.819741516 +0000 @@ -0,0 +1,36 @@ +/* + * 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 munit.* + +import org.scalacheck.* +import org.scalacheck.Prop.* + +final class TicketNumberTest extends ScalaCheckSuite { + + property("TicketNumber.from must only accept valid input") { + forAll { (integer: Int) => + if (integer > 0) + assertEquals(TicketNumber.from(integer), Some(TicketNumber(integer))) + else + assertEquals(TicketNumber.from(integer), None) + } + } + +} diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketResolutionTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketResolutionTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketResolutionTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketResolutionTest.scala 2025-01-12 05:25:38.819741516 +0000 @@ -0,0 +1,41 @@ +/* + * 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 de.smederee.tickets.Generators.* + +import munit.* + +import org.scalacheck.* +import org.scalacheck.Prop.* + +final class TicketResolutionTest extends ScalaCheckSuite { + given Arbitrary[TicketResolution] = Arbitrary(genTicketResolution) + + property("valueOf must work for all known instances") { + forAll { (status: TicketResolution) => + assertEquals(TicketResolution.valueOf(status.toString), status) + } + } + + property("fromString must work for all known instances") { + forAll { (status: TicketResolution) => + assertEquals(TicketResolution.fromString(status.toString), Option(status)) + } + } +} diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketStatusTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketStatusTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketStatusTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketStatusTest.scala 2025-01-12 05:25:38.819741516 +0000 @@ -0,0 +1,41 @@ +/* + * 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 de.smederee.tickets.Generators.* + +import munit.* + +import org.scalacheck.* +import org.scalacheck.Prop.* + +final class TicketStatusTest extends ScalaCheckSuite { + given Arbitrary[TicketStatus] = Arbitrary(genTicketStatus) + + property("valueOf must work for all known instances") { + forAll { (status: TicketStatus) => + assertEquals(TicketStatus.valueOf(status.toString), status) + } + } + + property("fromString must work for all known instances") { + forAll { (status: TicketStatus) => + assertEquals(TicketStatus.fromString(status.toString), Option(status)) + } + } +} diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketTitleTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketTitleTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketTitleTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketTitleTest.scala 2025-01-12 05:25:38.819741516 +0000 @@ -0,0 +1,36 @@ +/* + * 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 munit.* + +import org.scalacheck.* +import org.scalacheck.Prop.* + +final class TicketTitleTest extends ScalaCheckSuite { + + property("TicketTitle.from must only accept valid input") { + forAll { (input: String) => + if (input.nonEmpty && input.length <= TicketTitle.MaxLength) + assertEquals(TicketTitle.from(input), Some(TicketTitle(input))) + else + assertEquals(TicketTitle.from(input), None) + } + } + +} diff -rN -u old-smederee/modules/hub/src/universal/conf/application.conf.sample new-smederee/modules/hub/src/universal/conf/application.conf.sample --- old-smederee/modules/hub/src/universal/conf/application.conf.sample 2025-01-12 05:25:38.803741491 +0000 +++ new-smederee/modules/hub/src/universal/conf/application.conf.sample 2025-01-12 05:25:38.819741516 +0000 @@ -1,5 +1,5 @@ ############################################################################### -### Example configuration for running hub and ticket service on localhost. ### +## Example configuration for running the Smederee hub service on localhost. ## ############################################################################### hub { @@ -171,57 +171,6 @@ } tickets { - # Authentication / login settings - authentication { - enabled = true - - # The name used for the authentication cookie. - cookie-name = "sloetel" - - # The secret used for the cookie encryption and validation. - # Using the default should produce a warning message on startup. - cookie-secret = "CHANGEME" - - # Determines after how many failed login attempts an account gets locked. - lock-after = 10 - - # Timeouts for the authentication session. - timeouts { - # The maximum allowed age an authentication session. This setting will - # affect the invalidation of a session on the server side. - # This timeout MUST be triggered regardless of session activity. - absolute-timeout = 3 days - - # This timeout defines how long after the last activity a session will - # remain valid. - idle-timeout = 30 minutes - - # The time after which a session will be renewed (a new session ID will be - # generated). - renewal-timeout = 20 minutes - } - } - - # Configuration of the CSRF protection middleware. - csrf-protection { - # The official hostname of the service which will be used for the CSRF - # protection. - host = ${tickets.service.host} - - # The port number which defaults to the port the service is listening on. - # If the service is running behind a reverse proxy on a standard port e.g. - # 80 or 443 (http or https) then you MUST set this either to `port = null` - # or comment it out! - port = ${tickets.service.port} - - # The URL scheme which is used for links and will also determine if cookies - # will have the secure flag enabled. - # Valid options are: - # - http - # - https - scheme = "http" - } - # Configuration of the database. # Defaults are given except for password and can also be overridden via # environment variables. @@ -242,29 +191,7 @@ # Settings affecting how the service will communicate several information to # the "outside world" e.g. if it runs behind a reverse proxy. - external-url { - # The official hostname of the service which will be used for the generation - # of links. - host = ${tickets.service.host} - - # A possible path prefix that will be prepended to any paths used in link - # generation. If no path prefix is used then you MUST either comment it out - # or set it to `path = null`! - #path = null - - # The port number which defaults to the port the service is listening on. - # If the service is running behind a reverse proxy on a standard port e.g. - # 80 or 443 (http or https) then you MUST set this either to `port = null` - # or comment it out! - port = ${tickets.service.port} - - # The URL scheme which is used for links and will also determine if cookies - # will have the secure flag enabled. - # Valid options are: - # - http - # - https - scheme = "http" - } + external = ${hub.service.external} # Configuration regarding the integration with the hub service. hub-integration { @@ -272,12 +199,4 @@ base-uri = "http://localhost:8080" base-uri = ${?SMEDEREE_HUB_BASE_URI} } - - # Generic service configuration. - service { - # The hostname on which the service shall listen for requests. - host = "localhost" - # The TCP port number on which the service shall listen for requests. - port = 8080 - } } diff -rN -u old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V1__create_schema.sql new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V1__create_schema.sql --- old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V1__create_schema.sql 2025-01-12 05:25:38.803741491 +0000 +++ new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V1__create_schema.sql 1970-01-01 00:00:00.000000000 +0000 @@ -1,3 +0,0 @@ -CREATE SCHEMA IF NOT EXISTS tickets; - -COMMENT ON SCHEMA tickets IS 'Data related to ticket tracking.'; diff -rN -u old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V2__base_tables.sql new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V2__base_tables.sql --- old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V2__base_tables.sql 2025-01-12 05:25:38.803741491 +0000 +++ new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V2__base_tables.sql 1970-01-01 00:00:00.000000000 +0000 @@ -1,200 +0,0 @@ -CREATE TABLE tickets.users -( - uid UUID NOT NULL, - name CHARACTER VARYING(32) NOT NULL, - email CHARACTER VARYING(128) NOT NULL, - created_at TIMESTAMP WITH TIME ZONE NOT NULL, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL, - CONSTRAINT users_pk PRIMARY KEY (uid), - CONSTRAINT users_unique_name UNIQUE (name), - CONSTRAINT users_unique_email UNIQUE (email) -) -WITH ( - OIDS=FALSE -); - -COMMENT ON TABLE tickets.users IS 'All users for the ticket system live within this table.'; -COMMENT ON COLUMN tickets.users.uid IS 'A globally unique ID for the related user account. It must match the user ID from the hub account.'; -COMMENT ON COLUMN tickets.users.name IS 'A username between 2 and 32 characters which must be globally unique, contain only lowercase alphanumeric characters and start with a character.'; -COMMENT ON COLUMN tickets.users.email IS 'A globally unique email address associated with the account.'; -COMMENT ON COLUMN tickets.users.created_at IS 'The timestamp of when the account was created.'; -COMMENT ON COLUMN tickets.users.updated_at IS 'A timestamp when the account was last changed.'; - -CREATE TABLE tickets.sessions -( - id VARCHAR(32) NOT NULL, - uid UUID NOT NULL, - created_at TIMESTAMP WITH TIME ZONE NOT NULL, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL, - CONSTRAINT sessions_pk PRIMARY KEY (id), - CONSTRAINT sessions_fk_uid FOREIGN KEY (uid) - REFERENCES tickets.users (uid) ON UPDATE CASCADE ON DELETE CASCADE -) -WITH ( - OIDS=FALSE -); - -COMMENT ON TABLE tickets.sessions IS 'Keeps the sessions of users.'; -COMMENT ON COLUMN tickets.sessions.id IS 'A globally unique session ID.'; -COMMENT ON COLUMN tickets.sessions.uid IS 'The unique ID of the user account to whom the session belongs.'; -COMMENT ON COLUMN tickets.sessions.created_at IS 'The timestamp of when the session was created.'; -COMMENT ON COLUMN tickets.sessions.updated_at IS 'The session ID should be re-generated in regular intervals resulting in a copy of the old session entry with a new ID and the corresponding timestamp in this column.'; - -CREATE TABLE tickets.projects -( - id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - name CHARACTER VARYING(64) NOT NULL, - owner UUID NOT NULL, - is_private BOOLEAN NOT NULL DEFAULT FALSE, - description CHARACTER VARYING(254), - created_at TIMESTAMP WITH TIME ZONE NOT NULL, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL, - next_ticket_number INTEGER NOT NULL DEFAULT 1, - CONSTRAINT projects_unique_owner_name UNIQUE (owner, name), - CONSTRAINT projects_fk_uid FOREIGN KEY (owner) - REFERENCES tickets.users (uid) ON UPDATE CASCADE ON DELETE CASCADE -) -WITH ( - OIDS=FALSE -); - -COMMENT ON TABLE tickets.projects IS 'All projects which are basically mirrored repositories from the hub are stored within this table.'; -COMMENT ON COLUMN tickets.projects.id IS 'An auto generated primary key.'; -COMMENT ON COLUMN tickets.projects.name IS 'The name of the project. A project name must start with a letter or number and must contain only alphanumeric ASCII characters as well as minus or underscore signs. It must be between 2 and 64 characters long.'; -COMMENT ON COLUMN tickets.projects.owner IS 'The unique ID of the user account that owns the project.'; -COMMENT ON COLUMN tickets.projects.is_private IS 'A flag indicating if this project is private i.e. only visible / accessible for users with appropriate permissions.'; -COMMENT ON COLUMN tickets.projects.description IS 'An optional short text description of the project.'; -COMMENT ON COLUMN tickets.projects.created_at IS 'The timestamp of when the project was created.'; -COMMENT ON COLUMN tickets.projects.updated_at IS 'A timestamp when the project was last changed.'; -COMMENT ON COLUMN tickets.projects.next_ticket_number IS 'Tickets are numbered ascending per project and this field holds the next logical ticket number to be used and must be incremented upon creation of a new ticket.'; - -CREATE TABLE tickets.labels -( - id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - project BIGINT NOT NULL, - name CHARACTER VARYING(40) NOT NULL, - description CHARACTER VARYING(254) DEFAULT NULL, - colour CHARACTER VARYING(7) NOT NULL, - CONSTRAINT labels_unique_project_label UNIQUE (project, name), - CONSTRAINT labels_fk_project FOREIGN KEY (project) - REFERENCES tickets.projects (id) ON UPDATE CASCADE ON DELETE CASCADE -) -WITH ( - OIDS=FALSE -); - -COMMENT ON TABLE tickets.labels IS 'Labels used to add information to tickets.'; -COMMENT ON COLUMN tickets.labels.id IS 'An auto generated primary key.'; -COMMENT ON COLUMN tickets.labels.project IS 'The project to which this label belongs.'; -COMMENT ON COLUMN tickets.labels.name IS 'A short descriptive name for the label which is supposed to be unique in a project context.'; -COMMENT ON COLUMN tickets.labels.description IS 'An optional description if needed.'; -COMMENT ON COLUMN tickets.labels.colour IS 'A hexadecimal HTML colour code which can be used to mark the label on a rendered website.'; - -CREATE TABLE tickets.milestones -( - id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - project BIGINT NOT NULL, - title CHARACTER VARYING(64) NOT NULL, - due_date DATE DEFAULT NULL, - description TEXT DEFAULT NULL, - CONSTRAINT milestones_unique_project_title UNIQUE (project, title), - CONSTRAINT milestones_fk_project FOREIGN KEY (project) - REFERENCES tickets.projects (id) ON UPDATE CASCADE ON DELETE CASCADE -) -WITH ( - OIDS=FALSE -); - -COMMENT ON TABLE tickets.milestones IS 'Milestones used to organise tickets'; -COMMENT ON COLUMN tickets.milestones.project IS 'The project to which this milestone belongs.'; -COMMENT ON COLUMN tickets.milestones.title IS 'A title for the milestone, usually a version number, a word or a short phrase that is supposed to be unique within a project context.'; -COMMENT ON COLUMN tickets.milestones.due_date IS 'An optional date on which the milestone is supposed to be reached.'; -COMMENT ON COLUMN tickets.milestones.description IS 'An optional longer description of the milestone.'; - -CREATE TABLE tickets.tickets -( - id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - project BIGINT NOT NULL, - number INT NOT NULL, - title CHARACTER VARYING(72) NOT NULL, - content TEXT DEFAULT NULL, - status CHARACTER VARYING(16) NOT NULL, - submitter UUID DEFAULT NULL, - created_at TIMESTAMP WITH TIME ZONE NOT NULL, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL, - CONSTRAINT tickets_unique_project_ticket UNIQUE (project, number), - CONSTRAINT tickets_fk_project FOREIGN KEY (project) - REFERENCES tickets.projects (id) ON UPDATE CASCADE ON DELETE CASCADE, - CONSTRAINT tickets_fk_submitter FOREIGN KEY (submitter) - REFERENCES tickets.users (uid) ON UPDATE CASCADE ON DELETE SET NULL -) -WITH ( - OIDS=FALSE -); - -CREATE INDEX tickets_status ON tickets.tickets (status); - -COMMENT ON TABLE tickets.tickets IS 'Information about tickets for projects.'; -COMMENT ON COLUMN tickets.tickets.id IS 'An auto generated primary key.'; -COMMENT ON COLUMN tickets.tickets.project IS 'The unique ID of the project which is associated with the ticket.'; -COMMENT ON COLUMN tickets.tickets.number IS 'The number of the ticket which must be unique within the scope of the project.'; -COMMENT ON COLUMN tickets.tickets.title IS 'A concise and short description of the ticket which should not exceed 72 characters.'; -COMMENT ON COLUMN tickets.tickets.content IS 'An optional field to describe the ticket in great detail if needed.'; -COMMENT ON COLUMN tickets.tickets.status IS 'The current status of the ticket describing its life cycle.'; -COMMENT ON COLUMN tickets.tickets.submitter IS 'The person who submitted (created) this ticket which is optional because of possible account deletion or other reasons.'; -COMMENT ON COLUMN tickets.tickets.created_at IS 'The timestamp when the ticket was created / submitted.'; -COMMENT ON COLUMN tickets.tickets.updated_at IS 'The timestamp when the ticket was last updated. Upon creation the update time equals the creation time.'; - -CREATE TABLE tickets.milestone_tickets -( - milestone BIGINT NOT NULL, - ticket BIGINT NOT NULL, - CONSTRAINT milestone_tickets_pk PRIMARY KEY (milestone, ticket), - CONSTRAINT milestone_tickets_fk_milestone FOREIGN KEY (milestone) - REFERENCES tickets.milestones (id) ON UPDATE CASCADE ON DELETE CASCADE, - CONSTRAINT milestone_tickets_fk_ticket FOREIGN KEY (ticket) - REFERENCES tickets.tickets (id) ON UPDATE CASCADE ON DELETE CASCADE -) -WITH ( - OIDS=FALSE -); - -COMMENT ON TABLE tickets.milestone_tickets IS 'This table stores the relation between milestones and their tickets.'; -COMMENT ON COLUMN tickets.milestone_tickets.milestone IS 'The unique ID of the milestone.'; -COMMENT ON COLUMN tickets.milestone_tickets.ticket IS 'The unique ID of the ticket that is attached to the milestone.'; - -CREATE TABLE tickets.ticket_assignees -( - ticket BIGINT NOT NULL, - assignee UUID NOT NULL, - CONSTRAINT ticket_assignees_pk PRIMARY KEY (ticket, assignee), - CONSTRAINT ticket_assignees_fk_ticket FOREIGN KEY (ticket) - REFERENCES tickets.tickets (id) ON UPDATE CASCADE ON DELETE CASCADE, - CONSTRAINT ticket_assignees_fk_assignee FOREIGN KEY (assignee) - REFERENCES tickets.users (uid) ON UPDATE CASCADE ON DELETE CASCADE -) -WITH ( - OIDS=FALSE -); - -COMMENT ON TABLE tickets.ticket_assignees IS 'This table stores the relation between tickets and their assignees.'; -COMMENT ON COLUMN tickets.ticket_assignees.ticket IS 'The unqiue ID of the ticket.'; -COMMENT ON COLUMN tickets.ticket_assignees.assignee IS 'The unique ID of the user account that is assigned to the ticket.'; - -CREATE TABLE tickets.ticket_labels -( - ticket BIGINT NOT NULL, - label BIGINT NOT NULL, - CONSTRAINT ticket_labels_pk PRIMARY KEY (ticket, label), - CONSTRAINT ticket_labels_fk_ticket FOREIGN KEY (ticket) - REFERENCES tickets.tickets (id) ON UPDATE CASCADE ON DELETE CASCADE, - CONSTRAINT ticket_labels_fk_label FOREIGN KEY (label) - REFERENCES tickets.labels (id) ON UPDATE CASCADE ON DELETE CASCADE -) -WITH ( - OIDS=FALSE -); - -COMMENT ON TABLE tickets.ticket_labels IS 'This table stores the relation between tickets and their labels.'; -COMMENT ON COLUMN tickets.ticket_labels.ticket IS 'The unqiue ID of the ticket.'; -COMMENT ON COLUMN tickets.ticket_labels.label IS 'The unique ID of the label that is attached to the ticket.'; diff -rN -u old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V3__add_language.sql new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V3__add_language.sql --- old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V3__add_language.sql 2025-01-12 05:25:38.803741491 +0000 +++ new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V3__add_language.sql 1970-01-01 00:00:00.000000000 +0000 @@ -1,4 +0,0 @@ -ALTER TABLE tickets.users - ADD COLUMN language CHARACTER VARYING(3) DEFAULT NULL; - -COMMENT ON COLUMN tickets.users.language IS 'The ISO-639 language code of the preferred language of the user.'; diff -rN -u old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V4__add_resolution.sql new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V4__add_resolution.sql --- old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V4__add_resolution.sql 2025-01-12 05:25:38.803741491 +0000 +++ new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V4__add_resolution.sql 1970-01-01 00:00:00.000000000 +0000 @@ -1,4 +0,0 @@ -ALTER TABLE tickets.tickets - ADD COLUMN resolution CHARACTER VARYING(16) DEFAULT NULL; - -COMMENT ON COLUMN tickets.tickets.resolution IS 'An optional resolution state of the ticket that should be set if it is closed.'; diff -rN -u old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V5__add_closeable_milestones.sql new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V5__add_closeable_milestones.sql --- old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V5__add_closeable_milestones.sql 2025-01-12 05:25:38.803741491 +0000 +++ new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V5__add_closeable_milestones.sql 1970-01-01 00:00:00.000000000 +0000 @@ -1,4 +0,0 @@ -ALTER TABLE tickets.milestones - ADD COLUMN closed BOOLEAN DEFAULT FALSE; - -COMMENT ON COLUMN tickets.milestones.closed IS 'This flag indicates if the milestone is closed e.g. considered done or obsolete.'; diff -rN -u old-smederee/modules/tickets/src/main/resources/logback.xml new-smederee/modules/tickets/src/main/resources/logback.xml --- old-smederee/modules/tickets/src/main/resources/logback.xml 2025-01-12 05:25:38.803741491 +0000 +++ new-smederee/modules/tickets/src/main/resources/logback.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"> - <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> - - <!-- Suppress logback status output! --> - <statusListener class="ch.qos.logback.core.status.NopStatusListener"/> - - <logger name="de.smederee.tickets" level="${smederee.tickets.loglevel:-INFO}" additivity="false"> - <appender-ref ref="async-console"/> - </logger> - - <root level="INFO"> - <appender-ref ref="async-console"/> - </root> -</configuration> diff -rN -u old-smederee/modules/tickets/src/main/resources/reference.conf new-smederee/modules/tickets/src/main/resources/reference.conf --- old-smederee/modules/tickets/src/main/resources/reference.conf 2025-01-12 05:25:38.803741491 +0000 +++ new-smederee/modules/tickets/src/main/resources/reference.conf 1970-01-01 00:00:00.000000000 +0000 @@ -1,115 +0,0 @@ -############################################################################### -### Reference configuration file for the Smederee tickets service. ### -############################################################################### - -tickets { - # Authentication / login settings - authentication { - enabled = true - - # The name used for the authentication cookie. - cookie-name = "sloetel" - - # The secret used for the cookie encryption and validation. - # Using the default should produce a warning message on startup. - cookie-secret = "CHANGEME" - - # Determines after how many failed login attempts an account gets locked. - lock-after = 10 - - # Timeouts for the authentication session. - timeouts { - # The maximum allowed age an authentication session. This setting will - # affect the invalidation of a session on the server side. - # This timeout MUST be triggered regardless of session activity. - absolute-timeout = 3 days - - # This timeout defines how long after the last activity a session will - # remain valid. - idle-timeout = 30 minutes - - # The time after which a session will be renewed (a new session ID will be - # generated). - renewal-timeout = 20 minutes - } - } - - # Configuration of the CSRF protection middleware. - csrf-protection { - # The official hostname of the service which will be used for the CSRF - # protection. - host = ${tickets.service.host} - - # The port number which defaults to the port the service is listening on. - # If the service is running behind a reverse proxy on a standard port e.g. - # 80 or 443 (http or https) then you MUST set this either to `port = null` - # or comment it out! - port = ${tickets.service.port} - - # The URL scheme which is used for links and will also determine if cookies - # will have the secure flag enabled. - # Valid options are: - # - http - # - https - scheme = "http" - } - - # Configuration of the database. - # Defaults are given except for password and can also be overridden via - # environment variables. - database { - # The class name of the JDBC driver to be used. - driver = "org.postgresql.Driver" - driver = ${?SMEDEREE_TICKETS_DB_DRIVER} - # The JDBC connection URL **without** username and password. - url = "jdbc:postgresql://localhost:5432/smederee" - url = ${?SMEDEREE_TICKETS_DB_URL} - # The username (login) needed to authenticate against the database. - user = "smederee_tickets" - user = ${?SMEDEREE_TICKETS_DB_USER} - # The password needed to authenticate against the database. - pass = "secret" - pass = ${?SMEDEREE_TICKETS_DB_PASS} - } - - # Settings affecting how the service will communicate several information to - # the "outside world" e.g. if it runs behind a reverse proxy. - external-url { - # The official hostname of the service which will be used for the generation - # of links. - host = ${tickets.service.host} - - # A possible path prefix that will be prepended to any paths used in link - # generation. If no path prefix is used then you MUST either comment it out - # or set it to `path = null`! - #path = null - - # The port number which defaults to the port the service is listening on. - # If the service is running behind a reverse proxy on a standard port e.g. - # 80 or 443 (http or https) then you MUST set this either to `port = null` - # or comment it out! - port = ${tickets.service.port} - - # The URL scheme which is used for links and will also determine if cookies - # will have the secure flag enabled. - # Valid options are: - # - http - # - https - scheme = "http" - } - - # Configuration regarding the integration with the hub service. - hub-integration { - # The base URI used to build links to the hub service. - base-uri = "http://localhost:8080" - base-uri = ${?SMEDEREE_HUB_BASE_URI} - } - - # Generic service configuration. - service { - # The hostname on which the service shall listen for requests. - host = "localhost" - # The TCP port number on which the service shall listen for requests. - port = 8081 - } -} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Assignee.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Assignee.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Assignee.scala 2025-01-12 05:25:38.803741491 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Assignee.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,165 +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.util.UUID - -import cats.* -import cats.data.* -import cats.syntax.all.* - -/** A submitter id is supposed to be globally unique and maps to the [[java.util.UUID]] type beneath. - */ -opaque type AssigneeId = UUID -object AssigneeId { - val Format = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$".r - - given Eq[AssigneeId] = Eq.fromUniversalEquals - - /** Create an instance of AssigneeId from the given UUID type. - * - * @param source - * An instance of type UUID which will be returned as a AssigneeId. - * @return - * The appropriate instance of AssigneeId. - */ - def apply(source: UUID): AssigneeId = source - - /** Try to create an instance of AssigneeId from the given UUID. - * - * @param source - * A UUID that should fulfil the requirements to be converted into a AssigneeId. - * @return - * An option to the successfully converted AssigneeId. - */ - def from(source: UUID): Option[AssigneeId] = Option(source) - - /** Try to create an instance of AssigneeId from the given String. - * - * @param source - * A String that should fulfil the requirements to be converted into a AssigneeId. - * @return - * An option to the successfully converted AssigneeId. - */ - def fromString(source: String): Either[String, AssigneeId] = - Option(source) - .filter(s => Format.matches(s)) - .flatMap { uuidString => - Either.catchNonFatal(UUID.fromString(uuidString)).toOption - } - .toRight("Illegal value for AssigneeId!") - - /** Generate a new random user id. - * - * @return - * A user id which is pseudo randomly generated. - */ - def randomAssigneeId: AssigneeId = UUID.randomUUID - - extension (uid: AssigneeId) { - def toUUID: UUID = uid - } -} - -/** A submitter name has to obey several restrictions which are similiar to the ones found for Unix usernames. It must - * start with a letter, contain only alphanumeric characters, be at least 2 and at most 31 characters long and be all - * lowercase. - */ -opaque type AssigneeName = String -object AssigneeName { - given Eq[AssigneeName] = Eq.fromUniversalEquals - given Order[AssigneeName] = Order.from((x: AssigneeName, y: AssigneeName) => x.compareTo(y)) - given Ordering[AssigneeName] = implicitly[Order[AssigneeName]].toOrdering - - val isAlphanumeric = "^[a-z][a-z0-9]+$".r - - /** Create an instance of AssigneeName from the given String type. - * - * @param source - * An instance of type String which will be returned as a AssigneeName. - * @return - * The appropriate instance of AssigneeName. - */ - def apply(source: String): AssigneeName = source - - /** Try to create an instance of AssigneeName from the given String. - * - * @param source - * A String that should fulfil the requirements to be converted into a AssigneeName. - * @return - * An option to the successfully converted AssigneeName. - */ - def from(s: String): Option[AssigneeName] = validate(s).toOption - - /** Validate the given string and return either the validated username or a list of errors. - * - * @param s - * An arbitrary string which should be a username. - * @return - * Either a list of errors or the validated username. - */ - def validate(s: String): ValidatedNec[String, AssigneeName] = - Option(s).map(_.trim.nonEmpty) match { - case Some(true) => - val input = s.trim - val miniumLength = - if (input.length >= 2) - input.validNec - else - "AssigneeName too short (min. 2 characters)!".invalidNec - val maximumLength = - if (input.length < 32) - input.validNec - else - "AssigneeName too long (max. 31 characters)!".invalidNec - val alphanumeric = - if (isAlphanumeric.matches(input)) - input.validNec - else - "AssigneeName must be all lowercase alphanumeric characters and start with a character.".invalidNec - (miniumLength, maximumLength, alphanumeric).mapN { case (_, _, name) => - name - } - case _ => "AssigneeName must not be empty!".invalidNec - } -} - -/** Extractor to retrieve an AssigneeName from a path parameter. - */ -object AssigneeNamePathParameter { - def unapply(str: String): Option[AssigneeName] = - Option(str).flatMap { string => - if (string.startsWith("~")) - AssigneeName.from(string.drop(1)) - else - None - } -} - -/** The assignee for a ticket i.e. the person supposed to be working on it. - * - * @param id - * A globally unique ID identifying the assignee. - * @param name - * The name associated with the assignee which is supposed to be unique. - */ -final case class Assignee(id: AssigneeId, name: AssigneeName) - -object Assignee { - given Eq[Assignee] = Eq.fromUniversalEquals -} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,45 +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 - -/** A configuration path describes a path within a configuration file and is used to determine locations of certain - * configurations within a combined configuration file. - */ -opaque type ConfigurationPath = String -object ConfigurationPath { - - given Conversion[ConfigurationPath, String] = _.toString - - /** Create an instance of ConfigurationPath from the given String type. - * - * @param source - * An instance of type String which will be returned as a ConfigurationPath. - * @return - * The appropriate instance of ConfigurationPath. - */ - def apply(source: String): ConfigurationPath = source - - /** Try to create an instance of ConfigurationPath from the given String. - * - * @param source - * A String that should fulfil the requirements to be converted into a ConfigurationPath. - * @return - * An option to the successfully converted ConfigurationPath. - */ - def from(source: String): Option[ConfigurationPath] = Option(source).map(_.trim).filter(_.nonEmpty) -} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,37 +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 pureconfig.* - -/** Configuration specifying the database access. - * - * @param driver - * The class name of the JDBC driver to be used. - * @param url - * The JDBC connection URL **without** username and password. - * @param user - * The username (login) needed to authenticate against the database. - * @param pass - * The password needed to authenticate against the database. - */ -final case class DatabaseConfig(driver: String, url: String, user: String, pass: String) - -object DatabaseConfig { - given ConfigReader[DatabaseConfig] = ConfigReader.forProduct4("driver", "url", "user", "pass")(DatabaseConfig.apply) -} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.scala 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.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.tickets.config - -import cats.effect.* -import cats.syntax.all.* -import org.flywaydb.core.Flyway -import org.flywaydb.core.api.configuration.FluentConfiguration -import org.flywaydb.core.api.output.MigrateResult - -/** Provide functionality to migrate the database used by the service. - */ -final class DatabaseMigrator[F[_]: Sync] { - - /** Apply pending migrations to the database if needed using the underlying Flyway library. - * - * @param url - * The JDBC connection URL **without** username and password. - * @param user - * The username (login) needed to authenticate against the database. - * @param pass - * The password needed to authenticate against the database. - * @return - * A migrate result object holding information about executed migrations and the schema. See the Java-Doc of - * Flyway for details. - */ - def migrate(url: String, user: String, pass: String): F[MigrateResult] = - for { - flyway <- Sync[F].delay(DatabaseMigrator.configureFlyway(url, user, pass).load()) - result <- Sync[F].delay(flyway.migrate()) - } yield result -} - -object DatabaseMigrator { - - /** Configure an instance of flyway with our defaults and the given credentials for a database connection. The - * returned instance must be activated by calling the `.load()` method. - * - * @param url - * The JDBC connection URL **without** username and password. - * @param user - * The username (login) needed to authenticate against the database. - * @param pass - * The password needed to authenticate against the database. - * @return - * An instance configuration which can be turned into a flyway database migrator by calling the `.load()` method. - */ - def configureFlyway(url: String, user: String, pass: String): FluentConfiguration = - Flyway - .configure() - .defaultSchema("tickets") - .locations("classpath:db/migration/tickets") - .dataSource(url, user, pass) - -} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.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.tickets.config - -import cats.* -import com.comcast.ip4s.* -import de.smederee.html.ExternalUrlConfiguration -import org.http4s.Uri -import pureconfig.* - -/** Configuration for a CSRF protection middleware. - * - * @param host - * The hostname which will be expected and must be matched. - * @param port - * An optional portnumber which will be expected if set. - * @param scheme - * The URL scheme which is either HTTP or HTTPS. - */ -final case class CsrfProtectionConfiguration(host: Host, port: Option[Port], scheme: Uri.Scheme) - -object CsrfProtectionConfiguration { - given Eq[CsrfProtectionConfiguration] = Eq.fromUniversalEquals - - given ConfigReader[Host] = ConfigReader.fromStringOpt[Host](Host.fromString) - given ConfigReader[Port] = ConfigReader.fromStringOpt[Port](Port.fromString) - given ConfigReader[Uri.Scheme] = ConfigReader.fromStringOpt(s => Uri.Scheme.fromString(s).toOption) - - given ConfigReader[CsrfProtectionConfiguration] = - ConfigReader.forProduct3("host", "port", "scheme")(CsrfProtectionConfiguration.apply) -} - -/** Configuration regarding the integration with the hub service. - * - * @param baseUri - * The base URI used to build links to the hub service. - */ -final case class HubIntegrationConfiguration(baseUri: Uri) - -object HubIntegrationConfiguration { - given ConfigReader[Uri] = ConfigReader.fromStringOpt(s => Uri.fromString(s).toOption) - given ConfigReader[HubIntegrationConfiguration] = - ConfigReader.forProduct1("base-uri")(HubIntegrationConfiguration.apply) -} - -/** Generic service configuration determining how the service will be run. - * - * @param host - * The hostname on which the service shall listen for requests. - * @param port - * The TCP port number on which the service shall listen for requests. - */ -final case class ServiceConfiguration(host: Host, port: Port) - -object ServiceConfiguration { - given ConfigReader[Host] = ConfigReader.fromStringOpt[Host](Host.fromString) - given ConfigReader[Port] = ConfigReader.fromStringOpt[Port](Port.fromString) - given ConfigReader[ServiceConfiguration] = ConfigReader.forProduct2("host", "port")(ServiceConfiguration.apply) -} - -/** Wrapper class for the confiuration of the Smederee tickets module. - * - * @param csrfProtection - * The CSRF protection configuration. - * @param database - * The configuration needed to access the database. - * @param externalUrl - * Configuration regarding support for generating "external urls" which is usually needed if the service runs behind - * a reverse proxy. - * @param hub - * Configuration regarding the integration with the hub service. - * @param service - * Generic service configuration determining how the service will be run. - */ -final case class SmedereeTicketsConfiguration( - csrfProtection: CsrfProtectionConfiguration, - database: DatabaseConfig, - externalUrl: ExternalUrlConfiguration, - hub: HubIntegrationConfiguration, - service: ServiceConfiguration -) - -object SmedereeTicketsConfiguration { - val location: ConfigurationPath = ConfigurationPath("tickets") - - given ConfigReader[Host] = ConfigReader.fromStringOpt[Host](Host.fromString) - given ConfigReader[Port] = ConfigReader.fromStringOpt[Port](Port.fromString) - given ConfigReader[Uri] = ConfigReader.fromStringOpt(s => Uri.fromString(s).toOption) - given ConfigReader[Uri.Scheme] = ConfigReader.fromStringOpt(s => Uri.Scheme.fromString(s).toOption) - - given ConfigReader[ExternalUrlConfiguration] = - ConfigReader.forProduct4("host", "path", "port", "scheme")(ExternalUrlConfiguration.apply) - - given ConfigReader[SmedereeTicketsConfiguration] = - ConfigReader.forProduct5("csrf-protection", "database", "external-url", "hub-integration", "service")( - SmedereeTicketsConfiguration.apply - ) -} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala 2025-01-12 05:25:38.803741491 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,81 +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 doobie.* -import doobie.implicits.* -import fs2.Stream -import org.slf4j.LoggerFactory - -final class DoobieLabelRepository[F[_]: Sync](tx: Transactor[F]) extends LabelRepository[F] { - private val log = LoggerFactory.getLogger(getClass) - - given LogHandler[F] = Slf4jLogHandler.createLogHandler[F](log) - - given Meta[ColourCode] = Meta[String].timap(ColourCode.apply)(_.toString) - given Meta[LabelDescription] = Meta[String].timap(LabelDescription.apply)(_.toString) - given Meta[LabelId] = Meta[Long].timap(LabelId.apply)(_.toLong) - given Meta[LabelName] = Meta[String].timap(LabelName.apply)(_.toString) - given Meta[ProjectId] = Meta[Long].timap(ProjectId.apply)(_.toLong) - - override def allLabels(projectId: ProjectId): Stream[F, Label] = - sql"""SELECT id, name, description, colour FROM tickets.labels WHERE project = $projectId ORDER BY name ASC""" - .query[Label] - .stream - .transact(tx) - - override def createLabel(projectId: ProjectId)(label: Label): F[Int] = - sql"""INSERT INTO tickets.labels - ( - project, - name, - description, - colour - ) - VALUES ( - $projectId, - ${label.name}, - ${label.description}, - ${label.colour} - )""".update.run.transact(tx) - - override def deleteLabel(label: Label): F[Int] = - label.id match { - case None => Sync[F].pure(0) - case Some(id) => - sql"""DELETE FROM tickets.labels WHERE id = $id""".update.run.transact(tx) - } - - override def findLabel(projectId: ProjectId)(name: LabelName): F[Option[Label]] = - sql"""SELECT id, name, description, colour FROM tickets.labels WHERE project = $projectId AND name = $name LIMIT 1""" - .query[Label] - .option - .transact(tx) - - override def updateLabel(label: Label): F[Int] = - label.id match { - case None => Sync[F].pure(0) - case Some(id) => - sql"""UPDATE tickets.labels - SET name = ${label.name}, - description = ${label.description}, - colour = ${label.colour} - WHERE id = $id""".update.run.transact(tx) - } -} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala 2025-01-12 05:25:38.803741491 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,145 +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.util.UUID - -import cats.effect.* -import cats.syntax.all.* -import doobie.* -import doobie.Fragments.* -import doobie.implicits.* -import doobie.postgres.implicits.* -import fs2.Stream -import org.slf4j.LoggerFactory - -final class DoobieMilestoneRepository[F[_]: Sync](tx: Transactor[F]) extends MilestoneRepository[F] { - private val log = LoggerFactory.getLogger(getClass) - - given LogHandler[F] = Slf4jLogHandler.createLogHandler[F](log) - - given Meta[AssigneeId] = Meta[UUID].timap(AssigneeId.apply)(_.toUUID) - given Meta[AssigneeName] = Meta[String].timap(AssigneeName.apply)(_.toString) - given Meta[ColourCode] = Meta[String].timap(ColourCode.apply)(_.toString) - given Meta[LabelDescription] = Meta[String].timap(LabelDescription.apply)(_.toString) - given Meta[LabelId] = Meta[Long].timap(LabelId.apply)(_.toLong) - given Meta[LabelName] = Meta[String].timap(LabelName.apply)(_.toString) - given Meta[MilestoneDescription] = Meta[String].timap(MilestoneDescription.apply)(_.toString) - given Meta[MilestoneId] = Meta[Long].timap(MilestoneId.apply)(_.toLong) - given Meta[MilestoneTitle] = Meta[String].timap(MilestoneTitle.apply)(_.toString) - 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[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) - given Meta[TicketResolution] = Meta[String].timap(TicketResolution.valueOf)(_.toString) - given Meta[TicketStatus] = Meta[String].timap(TicketStatus.valueOf)(_.toString) - given Meta[TicketTitle] = Meta[String].timap(TicketTitle.apply)(_.toString) - - private val selectTicketColumns = - fr"""SELECT - "tickets".number AS number, - "tickets".title AS title, - "tickets".content AS content, - "tickets".status AS status, - "tickets".resolution AS resolution, - "submitters".uid AS submitter_uid, - "submitters".name AS submitter_name, - "tickets".created_at AS created_at, - "tickets".updated_at AS updated_at - FROM "tickets"."tickets" AS "tickets" - LEFT OUTER JOIN "tickets"."users" AS "submitters" - ON "tickets".submitter = "submitters".uid""" - - override def allMilestones(projectId: ProjectId): Stream[F, Milestone] = - sql"""SELECT id, title, description, due_date, closed FROM "tickets"."milestones" WHERE project = $projectId ORDER BY due_date ASC, title ASC""" - .query[Milestone] - .stream - .transact(tx) - - override def allTickets(filter: Option[TicketFilter])(milestoneId: MilestoneId): Stream[F, Ticket] = { - val milestoneFilter = - fr""""tickets".id IN (SELECT ticket FROM "tickets".milestone_tickets AS "milestone_tickets" WHERE milestone = $milestoneId)""" - val tickets = filter match { - case None => selectTicketColumns ++ whereAnd(milestoneFilter) - case Some(filter) => - val numberFilter = filter.number.toNel.map(numbers => Fragments.in(fr""""tickets".number""", numbers)) - val statusFilter = filter.status.toNel.map(status => Fragments.in(fr""""tickets".status""", status)) - val resolutionFilter = - filter.resolution.toNel.map(resolutions => Fragments.in(fr""""tickets".resolution""", resolutions)) - val submitterFilter = - filter.submitter.toNel.map(submitters => Fragments.in(fr""""submitters".name""", submitters)) - selectTicketColumns ++ whereAndOpt( - milestoneFilter.some, - numberFilter, - statusFilter, - resolutionFilter, - submitterFilter - ) - } - tickets.query[Ticket].stream.transact(tx) - } - - override def closeMilestone(milestoneId: MilestoneId): F[Int] = - sql"""UPDATE "tickets"."milestones" SET closed = TRUE WHERE id = $milestoneId""".update.run.transact(tx) - - override def createMilestone(projectId: ProjectId)(milestone: Milestone): F[Int] = - sql"""INSERT INTO "tickets"."milestones" - ( - project, - title, - due_date, - description, - closed - ) - VALUES ( - $projectId, - ${milestone.title}, - ${milestone.dueDate}, - ${milestone.description}, - ${milestone.closed} - )""".update.run.transact(tx) - - override def deleteMilestone(milestone: Milestone): F[Int] = - milestone.id match { - case None => Sync[F].pure(0) - case Some(id) => sql"""DELETE FROM "tickets"."milestones" WHERE id = $id""".update.run.transact(tx) - } - - override def findMilestone(projectId: ProjectId)(title: MilestoneTitle): F[Option[Milestone]] = - sql"""SELECT id, title, description, due_date, closed FROM "tickets"."milestones" WHERE project = $projectId AND title = $title LIMIT 1""" - .query[Milestone] - .option - .transact(tx) - - override def openMilestone(milestoneId: MilestoneId): F[Int] = - sql"""UPDATE "tickets"."milestones" SET closed = FALSE WHERE id = $milestoneId""".update.run.transact(tx) - - override def updateMilestone(milestone: Milestone): F[Int] = - milestone.id match { - case None => Sync[F].pure(0) - case Some(id) => - sql"""UPDATE "tickets"."milestones" - SET title = ${milestone.title}, - due_date = ${milestone.dueDate}, - description = ${milestone.description} - WHERE id = $id""".update.run.transact(tx) - } - -} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala 2025-01-12 05:25:38.803741491 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,127 +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.util.UUID - -import cats.effect.* -import de.smederee.email.EmailAddress -import doobie.* -import doobie.implicits.* -import doobie.postgres.implicits.* -import org.slf4j.LoggerFactory - -final class DoobieProjectRepository[F[_]: Sync](tx: Transactor[F]) extends ProjectRepository[F] { - private val log = LoggerFactory.getLogger(getClass) - - given LogHandler[F] = Slf4jLogHandler.createLogHandler[F](log) - - given Meta[EmailAddress] = Meta[String].timap(EmailAddress.apply)(_.toString) - given Meta[ProjectDescription] = Meta[String].timap(ProjectDescription.apply)(_.toString) - given Meta[ProjectId] = Meta[Long].timap(ProjectId.apply)(_.toLong) - given Meta[ProjectName] = Meta[String].timap(ProjectName.apply)(_.toString) - given Meta[ProjectOwnerId] = Meta[UUID].timap(ProjectOwnerId.apply)(_.toUUID) - given Meta[ProjectOwnerName] = Meta[String].timap(ProjectOwnerName.apply)(_.toString) - given Meta[TicketNumber] = Meta[Int].timap(TicketNumber.apply)(_.toInt) - - override def createProject(project: Project): F[Int] = - sql"""INSERT INTO tickets.projects (name, owner, is_private, description, created_at, updated_at) - VALUES ( - ${project.name}, - ${project.owner.uid}, - ${project.isPrivate}, - ${project.description}, - NOW(), - NOW() - )""".update.run.transact(tx) - - override def deleteProject(project: Project): F[Int] = - sql"""DELETE FROM tickets.projects WHERE owner = ${project.owner.uid} AND name = ${project.name}""".update.run - .transact(tx) - - override def findProject(owner: ProjectOwner, name: ProjectName): F[Option[Project]] = - sql"""SELECT - users.uid AS owner_id, - users.name AS owner_name, - users.email AS owner_email, - projects.name, - projects.description, - projects.is_private - FROM tickets.projects AS projects - JOIN tickets.users AS users - ON projects.owner = users.uid - WHERE - projects.owner = ${owner.uid} - AND - projects.name = $name""".query[Project].option.transact(tx) - - override def findProjectId(owner: ProjectOwner, name: ProjectName): F[Option[ProjectId]] = - sql"""SELECT - projects.id - FROM tickets.projects AS projects - JOIN tickets.users AS users - ON projects.owner = users.uid - WHERE - projects.owner = ${owner.uid} - AND - projects.name = $name""".query[ProjectId].option.transact(tx) - - override def findProjectOwner(name: ProjectOwnerName): F[Option[ProjectOwner]] = - sql"""SELECT - users.uid, - users.name, - users.email - FROM tickets.users - WHERE name = $name""".query[ProjectOwner].option.transact(tx) - - override def incrementNextTicketNumber(projectId: ProjectId): F[TicketNumber] = { - // TODO: Find out which of the queries is more reliable and more performant. - /* - val sqlQuery1 = sql"""UPDATE tickets.projects AS alias1 - SET next_ticket_number = alias2.next_ticket_number + 1 - FROM ( - SELECT - id, - next_ticket_number - FROM tickets.projects - WHERE id = $projectId - ) AS alias2 - WHERE alias1.id = alias2.id - RETURNING alias2.next_ticket_number AS next_ticket_number""" - */ - val sqlQuery2 = sql"""WITH old_number AS ( - SELECT next_ticket_number FROM tickets.projects WHERE id = $projectId - ) - UPDATE tickets.projects - SET next_ticket_number = next_ticket_number + 1 - WHERE id = $projectId - RETURNING ( - SELECT next_ticket_number FROM old_number - )""" - sqlQuery2.query[TicketNumber].unique.transact(tx) - } - - override def updateProject(project: Project): F[Int] = - sql"""UPDATE tickets.projects SET - is_private = ${project.isPrivate}, - description = ${project.description}, - updated_at = NOW() - WHERE - owner = ${project.owner.uid} - AND name = ${project.name}""".update.run.transact(tx) -} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala 2025-01-12 05:25:38.803741491 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,275 +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.util.UUID - -import cats.* -import cats.data.* -import cats.effect.* -import cats.syntax.all.* -import doobie.* -import doobie.Fragments.* -import doobie.implicits.* -import doobie.postgres.implicits.* -import fs2.Stream -import org.slf4j.LoggerFactory - -final class DoobieTicketRepository[F[_]: Sync](tx: Transactor[F]) extends TicketRepository[F] { - private val log = LoggerFactory.getLogger(getClass) - - given LogHandler[F] = Slf4jLogHandler.createLogHandler[F](log) - - given Meta[AssigneeId] = Meta[UUID].timap(AssigneeId.apply)(_.toUUID) - given Meta[AssigneeName] = Meta[String].timap(AssigneeName.apply)(_.toString) - given Meta[ColourCode] = Meta[String].timap(ColourCode.apply)(_.toString) - given Meta[LabelDescription] = Meta[String].timap(LabelDescription.apply)(_.toString) - given Meta[LabelId] = Meta[Long].timap(LabelId.apply)(_.toLong) - given Meta[LabelName] = Meta[String].timap(LabelName.apply)(_.toString) - given Meta[MilestoneDescription] = Meta[String].timap(MilestoneDescription.apply)(_.toString) - given Meta[MilestoneId] = Meta[Long].timap(MilestoneId.apply)(_.toLong) - given Meta[MilestoneTitle] = Meta[String].timap(MilestoneTitle.apply)(_.toString) - 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[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) - given Meta[TicketResolution] = Meta[String].timap(TicketResolution.valueOf)(_.toString) - given Meta[TicketStatus] = Meta[String].timap(TicketStatus.valueOf)(_.toString) - given Meta[TicketTitle] = Meta[String].timap(TicketTitle.apply)(_.toString) - - private val selectTicketColumns = - fr"""SELECT - tickets.number AS number, - tickets.title AS title, - tickets.content AS content, - tickets.status AS status, - tickets.resolution AS resolution, - submitters.uid AS submitter_uid, - submitters.name AS submitter_name, - tickets.created_at AS created_at, - tickets.updated_at AS updated_at - FROM tickets.tickets AS tickets - LEFT OUTER JOIN tickets.users AS submitters - ON tickets.submitter = submitters.uid""" - - /** Construct a query fragment that fetches the internal unique ticket id from the tickets table via the given - * project id and ticket number. The fetched id can be referenced like this `SELECT id FROM ticket_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 query fragment useable within other queries which defines a common table expression using the `WITH` clause. - */ - private def withTicketId(projectId: ProjectId, ticketNumber: TicketNumber): Fragment = - fr"""WITH ticket_id AS ( - SELECT id AS id - FROM tickets.tickets AS tickets - WHERE tickets.project = $projectId - AND tickets.number = $ticketNumber)""" - - override def addAssignee(projectId: ProjectId)(ticketNumber: TicketNumber)(assignee: Assignee): F[Int] = - sql"""INSERT INTO tickets.ticket_assignees ( - ticket, - assignee - ) SELECT - id, - ${assignee.id} - 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) - case Some(labelId) => - sql"""INSERT INTO tickets.ticket_labels ( - ticket, - label - ) SELECT - id, - $labelId - FROM tickets.tickets - WHERE project = $projectId - AND number = $ticketNumber""".update.run.transact(tx) - } - - override def addMilestone(projectId: ProjectId)(ticketNumber: TicketNumber)(milestone: Milestone): F[Int] = - milestone.id match { - case None => Sync[F].pure(0) - case Some(milestoneId) => - sql"""INSERT INTO tickets.milestone_tickets ( - ticket, - milestone - ) SELECT - id, - $milestoneId - FROM tickets.tickets - WHERE project = $projectId - AND number = $ticketNumber""".update.run.transact(tx) - } - - override def allTickets(filter: Option[TicketFilter])(projectId: ProjectId): Stream[F, Ticket] = { - val projectFilter = fr"""tickets.project = $projectId""" - val tickets = filter match { - case None => selectTicketColumns ++ whereAnd(projectFilter) - case Some(filter) => - val numberFilter = filter.number.toNel.map(numbers => Fragments.in(fr"""tickets.number""", numbers)) - val statusFilter = filter.status.toNel.map(status => Fragments.in(fr"""tickets.status""", status)) - val resolutionFilter = - filter.resolution.toNel.map(resolutions => Fragments.in(fr"""tickets.resolution""", resolutions)) - val submitterFilter = - filter.submitter.toNel.map(submitters => Fragments.in(fr"""submitters.name""", submitters)) - selectTicketColumns ++ whereAndOpt( - projectFilter.some, - numberFilter, - statusFilter, - resolutionFilter, - submitterFilter - ) - } - tickets.query[Ticket].stream.transact(tx) - } - - override def createTicket(projectId: ProjectId)(ticket: Ticket): F[Int] = - sql"""INSERT INTO tickets.tickets ( - project, - number, - title, - content, - status, - resolution, - submitter, - created_at, - updated_at - ) VALUES ( - $projectId, - ${ticket.number}, - ${ticket.title}, - ${ticket.content}, - ${ticket.status}, - ${ticket.resolution}, - ${ticket.submitter.map(_.id)}, - NOW(), - NOW() - )""".update.run.transact(tx) - - override def deleteTicket(projectId: ProjectId)(ticket: Ticket): F[Int] = - sql"""DELETE FROM tickets.tickets - WHERE project = $projectId - AND number = ${ticket.number}""".update.run.transact(tx) - - override def findTicket(projectId: ProjectId)(ticketNumber: TicketNumber): F[Option[Ticket]] = { - val projectFilter = fr"""project = $projectId""" - val numberFilter = fr"""number = $ticketNumber""" - val ticket = selectTicketColumns ++ whereAnd(projectFilter, numberFilter) ++ fr"""LIMIT 1""" - ticket.query[Ticket].option.transact(tx) - } - - override def findTicketId(projectId: ProjectId)(ticketNumber: TicketNumber): F[Option[TicketId]] = - sql"""SELECT id FROM tickets.tickets WHERE project = $projectId AND number = $ticketNumber""" - .query[TicketId] - .option - .transact(tx) - - override def loadAssignees(projectId: ProjectId)(ticketNumber: TicketNumber): Stream[F, Assignee] = { - val sqlQuery = withTicketId(projectId, ticketNumber) ++ - fr"""SELECT - users.uid AS uid, - users.name AS name - FROM tickets.ticket_assignees AS assignees - JOIN tickets.users AS users - ON assignees.assignee = users.uid - WHERE assignees.ticket = (SELECT id FROM ticket_id)""" - sqlQuery.query[Assignee].stream.transact(tx) - } - - override def loadLabels(projectId: ProjectId)(ticketNumber: TicketNumber): Stream[F, Label] = { - val sqlQuery = withTicketId(projectId, ticketNumber) ++ - fr"""SELECT - labels.id AS id, - labels.name AS name, - labels.description AS description, - labels.colour AS colour - FROM tickets.labels AS labels - JOIN tickets.ticket_labels AS ticket_labels - ON labels.id = ticket_labels.label - WHERE ticket_labels.ticket = (SELECT id FROM ticket_id)""" - sqlQuery.query[Label].stream.transact(tx) - } - - override def loadMilestones(projectId: ProjectId)(ticketNumber: TicketNumber): Stream[F, Milestone] = { - val sqlQuery = withTicketId(projectId, ticketNumber) ++ - fr"""SELECT - milestones.id AS id, - milestones.title AS title, - milestones.description AS description, - milestones.due_date AS due_date, - milestones.closed AS closed - FROM tickets.milestones AS milestones - JOIN tickets.milestone_tickets AS milestone_tickets - ON milestones.id = milestone_tickets.milestone - WHERE milestone_tickets.ticket = (SELECT id FROM ticket_id) - ORDER BY milestones.due_date ASC, milestones.title ASC""" - sqlQuery.query[Milestone].stream.transact(tx) - } - - override def removeAssignee(projectId: ProjectId)(ticket: Ticket)(assignee: Assignee): F[Int] = { - val sqlQuery = withTicketId(projectId, ticket.number) ++ - fr"""DELETE FROM tickets.ticket_assignees AS ticket_assignees - WHERE ticket_assignees.ticket = (SELECT id FROM ticket_id) - AND ticket_assignees.assignee = ${assignee.id}""" - sqlQuery.update.run.transact(tx) - } - - override def removeLabel(projectId: ProjectId)(ticket: Ticket)(label: Label): F[Int] = - label.id match { - case None => Sync[F].pure(0) - case Some(labelId) => - val sqlQuery = withTicketId(projectId, ticket.number) ++ - fr"""DELETE FROM tickets.ticket_labels AS labels - WHERE labels.ticket = (SELECT id FROM ticket_id) - AND labels.label = $labelId""" - sqlQuery.update.run.transact(tx) - } - - override def removeMilestone(projectId: ProjectId)(ticket: Ticket)(milestone: Milestone): F[Int] = - milestone.id match { - case None => Sync[F].pure(0) - case Some(milestoneId) => - val sqlQuery = withTicketId(projectId, ticket.number) ++ - fr"""DELETE FROM tickets.milestone_tickets AS milestone_tickets - WHERE milestone_tickets.ticket = (SELECT id FROM ticket_id) - AND milestone_tickets.milestone = $milestoneId""" - sqlQuery.update.run.transact(tx) - } - - override def updateTicket(projectId: ProjectId)(ticket: Ticket): F[Int] = - sql"""UPDATE tickets.tickets SET - title = ${ticket.title}, - content = ${ticket.content}, - status = ${ticket.status}, - resolution = ${ticket.resolution}, - submitter = ${ticket.submitter.map(_.id)}, - updated_at = NOW() - WHERE project = $projectId - AND number = ${ticket.number}""".update.run.transact(tx) -} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketServiceApi.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketServiceApi.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketServiceApi.scala 2025-01-12 05:25:38.803741491 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketServiceApi.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,59 +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.util.UUID - -import cats.effect.* -import de.smederee.email.EmailAddress -import de.smederee.i18n.LanguageCode -import de.smederee.security.UserId -import de.smederee.security.Username -import doobie.* -import doobie.implicits.* -import doobie.postgres.implicits.* -import org.slf4j.LoggerFactory - -final class DoobieTicketServiceApi[F[_]: Sync](tx: Transactor[F]) extends TicketServiceApi[F] { - private val log = LoggerFactory.getLogger(getClass) - - given LogHandler[F] = Slf4jLogHandler.createLogHandler[F](log) - - given Meta[EmailAddress] = Meta[String].timap(EmailAddress.apply)(_.toString) - given Meta[LanguageCode] = Meta[String].timap(LanguageCode.apply)(_.toString) - given Meta[UserId] = Meta[UUID].timap(UserId.apply)(_.toUUID) - given Meta[Username] = Meta[String].timap(Username.apply)(_.toString) - - override def createOrUpdateUser(user: TicketsUser): F[Int] = - sql"""INSERT INTO tickets.users (uid, name, email, language, created_at, updated_at) - VALUES ( - ${user.uid}, - ${user.name}, - ${user.email}, - ${user.language}, - NOW(), - NOW() - ) ON CONFLICT (uid) DO UPDATE SET - name = EXCLUDED.name, - email = EXCLUDED.email, - language = EXCLUDED.language, - updated_at = EXCLUDED.updated_at""".update.run.transact(tx) - - override def deleteUser(uid: UserId): F[Int] = - sql"""DELETE FROM tickets.users WHERE uid = $uid""".update.run.transact(tx) -} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/FormValidator.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/FormValidator.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/FormValidator.scala 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/FormValidator.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,54 +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.forms - -import cats.data.* -import de.smederee.tickets.forms.types.* - -/** A base class for form validators. - * - * <p>It is intended to extend this class if you want to provide a more sophisticated validation for a form which gets - * submitted as raw stringified map.</p> - * - * <p>Please note that you can achieve auto validation if you use proper models (with refined types) in your tapir - * endpoints.</p> - * - * <p>However, sometimes you want to have more fine grained control...</p> - * - * @tparam T - * The concrete type of the validated form output. - */ -abstract class FormValidator[T] { - final val fieldGlobal: FormField = FormValidator.fieldGlobal - - /** Validate the submitted form data which is received as stringified map and return either a validated `T` or a - * list of [[de.smederee.tickets.forms.types.FormErrors]]. - * - * @param data - * The stringified map which was submitted. - * @return - * Either the validated form as concrete type T or a list of form errors. - */ - def validate(data: Map[String, Chain[String]]): ValidatedNec[FormErrors, T] - -} - -object FormValidator { - // A constant for the field name used for global errors. - val fieldGlobal: FormField = FormField("global") -} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/types.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/types.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/types.scala 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/types.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,106 +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.forms - -import cats.data.* -import cats.syntax.all.* - -object types { - - type FormErrors = Map[FormField, List[FormFieldError]] - object FormErrors { - val empty: FormErrors = Map.empty[FormField, List[FormFieldError]] - - /** Create a single FormErrors instance from a given non empty chain of FormErrors which is usually returned - * from validators. - * - * @param errors - * A non empty chain of FormErrors. - * @return - * A single FormErrors instance containing all the errors. - */ - def fromNec(errors: NonEmptyChain[FormErrors]): FormErrors = errors.toList.fold(FormErrors.empty)(_ combine _) - - /** Create a single FormErrors instance from a given non empty list of FormErrors which is usually returned from - * validators. - * - * @param errors - * A non empty list of FormErrors. - * @return - * A single FormErrors instance containing all the errors. - */ - def fromNel(errors: NonEmptyList[FormErrors]): FormErrors = errors.toList.fold(FormErrors.empty)(_ combine _) - } - - opaque type FormField = String - object FormField { - - /** Create an instance of FormField from the given String type. - * - * @param source - * An instance of type String which will be returned as a FormField. - * @return - * The appropriate instance of FormField. - */ - def apply(source: String): FormField = source - - /** Try to create an instance of FormField from the given String. - * - * @param source - * A String that should fulfil the requirements to be converted into a FormField. - * @return - * An option to the successfully converted FormField. - */ - def from(source: String): Option[FormField] = - Option(source).map(_.trim.nonEmpty) match { - case Some(true) => Option(source.trim) - case _ => None - } - } - - given Conversion[FormField, String] = _.toString - - opaque type FormFieldError = String - object FormFieldError { - - /** Create an instance of FormFieldError from the given String type. - * - * @param source - * An instance of type String which will be returned as a FormFieldError. - * @return - * The appropriate instance of FormFieldError. - */ - def apply(source: String): FormFieldError = source - - /** Try to create an instance of FormFieldError from the given String. - * - * @param source - * A String that should fulfil the requirements to be converted into a FormFieldError. - * @return - * An option to the successfully converted FormFieldError. - */ - def from(source: String): Option[FormFieldError] = - Option(source).map(_.trim.nonEmpty) match { - case Some(true) => Option(source.trim) - case _ => None - } - } - - given Conversion[FormFieldError, String] = _.toString - -} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/LabelRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/LabelRepository.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/LabelRepository.scala 2025-01-12 05:25:38.803741491 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/LabelRepository.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,78 +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 fs2.Stream - -/** The base class that defines the needed functionality to handle labels within a database. - * - * @tparam F - * A higher kinded type which wraps the actual return values. - */ -abstract class LabelRepository[F[_]] { - - /** Return all labels associated with the given repository. - * - * @param projectId - * The unique internal ID of a vcs repository metadata entry for which all labels shall be returned. - * @return - * A stream of labels associated with the vcs repository which may be empty. - */ - def allLabels(projectId: ProjectId): Stream[F, Label] - - /** Create a database entry for the given label definition. - * - * @param projectId - * The unique internal ID of a vcs repository metadata entry to which the label belongs. - * @param label - * The label definition that shall be written to the database. - * @return - * The number of affected database rows. - */ - def createLabel(projectId: ProjectId)(label: Label): F[Int] - - /** Delete the label from the database. - * - * @param label - * The label definition that shall be deleted from the database. - * @return - * The number of affected database rows. - */ - def deleteLabel(label: Label): F[Int] - - /** Find the label with the given name for the given vcs repository. - * - * @param projectId - * The unique internal ID of a vcs repository metadata entry to which the label belongs. - * @param name - * The name of the label which is must be unique in the context of the repository. - * @return - * An option to the found label. - */ - def findLabel(projectId: ProjectId)(name: LabelName): F[Option[Label]] - - /** Update the database entry for the given label. - * - * @param label - * The label definition that shall be updated within the database. - * @return - * The number of affected database rows. - */ - def updateLabel(label: Label): F[Int] - -} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Label.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Label.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Label.scala 2025-01-12 05:25:38.803741491 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Label.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,187 +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.* -import cats.syntax.all.* - -import scala.util.matching.Regex - -opaque type LabelId = Long -object LabelId { - given Eq[LabelId] = Eq.fromUniversalEquals - given Ordering[LabelId] = (x: LabelId, y: LabelId) => x.compareTo(y) - given Order[LabelId] = Order.fromOrdering - - val Format: Regex = "^-?\\d+$".r - - /** Create an instance of LabelId from the given Long type. - * - * @param source - * An instance of type Long which will be returned as a LabelId. - * @return - * The appropriate instance of LabelId. - */ - def apply(source: Long): LabelId = source - - /** Try to create an instance of LabelId from the given Long. - * - * @param source - * A Long that should fulfil the requirements to be converted into a LabelId. - * @return - * An option to the successfully converted LabelId. - */ - def from(source: Long): Option[LabelId] = Option(source) - - /** Try to create an instance of LabelId from the given String. - * - * @param source - * A string that should fulfil the requirements to be converted into a LabelId. - * @return - * An option to the successfully converted LabelId. - */ - def fromString(source: String): Option[LabelId] = Option(source).filter(Format.matches).map(_.toLong).flatMap(from) - - extension (id: LabelId) { - def toLong: Long = id - } -} - -/** Extractor to retrieve an LabelId from a path parameter. - */ -object LabelIdPathParameter { - def unapply(str: String): Option[LabelId] = Option(str).flatMap(LabelId.fromString) -} - -/** A short descriptive name for the label which is supposed to be unique in a project context. It must not be empty and - * not exceed 40 characters in length. - */ -opaque type LabelName = String -object LabelName { - given Eq[LabelName] = Eq.fromUniversalEquals - given Ordering[LabelName] = (x: LabelName, y: LabelName) => x.compareTo(y) - given Order[LabelName] = Order.fromOrdering[LabelName] - - val MaxLength: Int = 40 - - /** Create an instance of LabelName from the given String type. - * - * @param source - * An instance of type String which will be returned as a LabelName. - * @return - * The appropriate instance of LabelName. - */ - def apply(source: String): LabelName = source - - /** Try to create an instance of LabelName from the given String. - * - * @param source - * A String that should fulfil the requirements to be converted into a LabelName. - * @return - * An option to the successfully converted LabelName. - */ - def from(source: String): Option[LabelName] = - Option(source).filter(string => string.nonEmpty && string.length <= MaxLength) - -} - -/** Extractor to retrieve an LabelName from a path parameter. - */ -object LabelNamePathParameter { - def unapply(str: String): Option[LabelName] = Option(str).flatMap(LabelName.from) -} - -/** A maybe needed description of a label which must not be empty and not exceed 254 characters in length. - */ -opaque type LabelDescription = String -object LabelDescription { - given Eq[LabelDescription] = Eq.fromUniversalEquals - - val MaxLength: Int = 254 - - /** Create an instance of LabelDescription from the given String type. - * - * @param source - * An instance of type String which will be returned as a LabelDescription. - * @return - * The appropriate instance of LabelDescription. - */ - def apply(source: String): LabelDescription = source - - /** Try to create an instance of LabelDescription from the given String. - * - * @param source - * A String that should fulfil the requirements to be converted into a LabelDescription. - * @return - * An option to the successfully converted LabelDescription. - */ - def from(source: String): Option[LabelDescription] = - Option(source).filter(string => string.nonEmpty && string.length <= MaxLength) -} - -/** An HTML colour code in hexadecimal notation e.g. `#12ab3f`. It must match the format of starting with a hashtag - * followed by three 2-digit hexadecimal codes (`00-ff`). - */ -opaque type ColourCode = String -object ColourCode { - given Eq[ColourCode] = Eq.instance((a, b) => a.equalsIgnoreCase(b)) - - val Format: Regex = "^#[0-9a-fA-F]{6}$".r - - /** Create an instance of ColourCode from the given String type. - * - * @param source - * An instance of type String which will be returned as a ColourCode. - * @return - * The appropriate instance of ColourCode. - */ - def apply(source: String): ColourCode = source - - /** Try to create an instance of ColourCode from the given String. - * - * @param source - * A String that should fulfil the requirements to be converted into a ColourCode. - * @return - * An option to the successfully converted ColourCode. - */ - def from(source: String): Option[ColourCode] = Option(source).filter(string => Format.matches(string)) - -} - -/** A label is intended to mark tickets with keywords and colours to allow filtering on them. - * - * @param id - * An optional attribute containing the unique internal database ID for the label. - * @param name - * A short descriptive name for the label which is supposed to be unique in a project context. - * @param description - * An optional description if needed. - * @param colour - * A hexadecimal HTML colour code which can be used to mark the label on a rendered website. - */ -final case class Label(id: Option[LabelId], name: LabelName, description: Option[LabelDescription], colour: ColourCode) - -object Label { - given Eq[Label] = - Eq.instance((thisLabel, thatLabel) => - thisLabel.id === thatLabel.id && - thisLabel.name === thatLabel.name && - thisLabel.description === thatLabel.description && - thisLabel.colour === thatLabel.colour - ) -} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/MilestoneRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/MilestoneRepository.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/MilestoneRepository.scala 2025-01-12 05:25:38.803741491 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/MilestoneRepository.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,107 +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 fs2.Stream - -/** The base class that defines the needed functionality to handle milestones within a database. - * - * @tparam F - * A higher kinded type which wraps the actual return values. - */ -abstract class MilestoneRepository[F[_]] { - - /** Return all milestones associated with the given repository. - * - * @param projectId - * The unique internal ID of a vcs repository metadata entry for which all milestones shall be returned. - * @return - * A stream of milestones associated with the vcs repository which may be empty. - */ - def allMilestones(projectId: ProjectId): Stream[F, Milestone] - - /** Return all tickets associated with the given milestone. - * - * @param filter - * A ticket filter containing possible values which will be used to filter the list of tickets. - * @param milestoneId - * The unique internal ID of a milestone for which all tickets shall be returned. - * @return - * A stream of tickets associated with the vcs repository which may be empty. - */ - def allTickets(filter: Option[TicketFilter])(milestoneId: MilestoneId): Stream[F, Ticket] - - /** Change the milestone status with the given id to closed. - * - * @param milestoneId - * The unique internal ID of a milestone for which all tickets shall be returned. - * @return - * The number of affected database rows. - */ - def closeMilestone(milestoneId: MilestoneId): F[Int] - - /** Create a database entry for the given milestone definition. - * - * @param projectId - * The unique internal ID of a vcs repository metadata entry to which the milestone belongs. - * @param milestone - * The milestone definition that shall be written to the database. - * @return - * The number of affected database rows. - */ - def createMilestone(projectId: ProjectId)(milestone: Milestone): F[Int] - - /** Delete the milestone from the database. - * - * @param milestone - * The milestone definition that shall be deleted from the database. - * @return - * The number of affected database rows. - */ - def deleteMilestone(milestone: Milestone): F[Int] - - /** Find the milestone with the given title for the given vcs repository. - * - * @param projectId - * The unique internal ID of a vcs repository metadata entry to which the milestone belongs. - * @param title - * The title of the milestone which is must be unique in the context of the repository. - * @return - * An option to the found milestone. - */ - def findMilestone(projectId: ProjectId)(title: MilestoneTitle): F[Option[Milestone]] - - /** Change the milestone status with the given id to open. - * - * @param milestoneId - * The unique internal ID of a milestone for which all tickets shall be returned. - * @return - * The number of affected database rows. - */ - def openMilestone(milestoneId: MilestoneId): F[Int] - - /** Update the database entry for the given milestone. - * - * @param milestone - * The milestone definition that shall be updated within the database. - * @return - * The number of affected database rows. - */ - def updateMilestone(milestone: Milestone): F[Int] - -} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Milestone.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Milestone.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Milestone.scala 2025-01-12 05:25:38.803741491 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Milestone.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,167 +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.LocalDate - -import cats.* -import cats.syntax.all.* - -import scala.util.matching.Regex - -opaque type MilestoneId = Long -object MilestoneId { - given Eq[MilestoneId] = Eq.fromUniversalEquals - - val Format: Regex = "^-?\\d+$".r - - /** Create an instance of MilestoneId from the given Long type. - * - * @param source - * An instance of type Long which will be returned as a MilestoneId. - * @return - * The appropriate instance of MilestoneId. - */ - def apply(source: Long): MilestoneId = source - - /** Try to create an instance of MilestoneId from the given Long. - * - * @param source - * A Long that should fulfil the requirements to be converted into a MilestoneId. - * @return - * An option to the successfully converted MilestoneId. - */ - def from(source: Long): Option[MilestoneId] = Option(source) - - /** Try to create an instance of MilestoneId from the given String. - * - * @param source - * A string that should fulfil the requirements to be converted into a MilestoneId. - * @return - * An option to the successfully converted MilestoneId. - */ - def fromString(source: String): Option[MilestoneId] = - Option(source).filter(Format.matches).map(_.toLong).flatMap(from) - - extension (id: MilestoneId) { - def toLong: Long = id - } -} - -/** Extractor to retrieve an MilestoneId from a path parameter. - */ -object MilestoneIdPathParameter { - def unapply(str: String): Option[MilestoneId] = Option(str).flatMap(MilestoneId.fromString) -} - -/** A title for a milestone, usually a version number, a word or a short phrase that is supposed to be unique within a - * project context. It must not be empty and not exceed 64 characters in length. - */ -opaque type MilestoneTitle = String -object MilestoneTitle { - given Eq[MilestoneTitle] = Eq.fromUniversalEquals - given Ordering[MilestoneTitle] = (x: MilestoneTitle, y: MilestoneTitle) => x.compareTo(y) - given Order[MilestoneTitle] = Order.fromOrdering[MilestoneTitle] - - val MaxLength: Int = 64 - - /** Create an instance of MilestoneTitle from the given String type. - * - * @param source - * An instance of type String which will be returned as a MilestoneTitle. - * @return - * The appropriate instance of MilestoneTitle. - */ - def apply(source: String): MilestoneTitle = source - - /** Try to create an instance of MilestoneTitle from the given String. - * - * @param source - * A String that should fulfil the requirements to be converted into a MilestoneTitle. - * @return - * An option to the successfully converted MilestoneTitle. - */ - def from(source: String): Option[MilestoneTitle] = - Option(source).filter(string => string.nonEmpty && string.length <= MaxLength) - -} - -/** Extractor to retrieve an MilestoneTitle from a path parameter. - */ -object MilestoneTitlePathParameter { - def unapply(str: String): Option[MilestoneTitle] = Option(str).flatMap(MilestoneTitle.from) -} - -/** A longer detailed description of a project milestone which must not be empty. - */ -opaque type MilestoneDescription = String -object MilestoneDescription { - given Eq[MilestoneDescription] = Eq.fromUniversalEquals - - /** Create an instance of MilestoneDescription from the given String type. - * - * @param source - * An instance of type String which will be returned as a MilestoneDescription. - * @return - * The appropriate instance of MilestoneDescription. - */ - def apply(source: String): MilestoneDescription = source - - /** Try to create an instance of MilestoneDescription from the given String. - * - * @param source - * A String that should fulfil the requirements to be converted into a MilestoneDescription. - * @return - * An option to the successfully converted MilestoneDescription. - */ - def from(source: String): Option[MilestoneDescription] = Option(source).map(_.trim).filter(_.nonEmpty) - -} - -/** A milestone can be used to organise tickets and progress inside a project. - * - * @param id - * An optional attribute containing the unique internal database ID for the milestone. - * @param title - * A title for the milestone, usually a version number, a word or a short phrase that is supposed to be unique within - * a project context. - * @param description - * An optional longer description of the milestone. - * @param dueDate - * An optional date on which the milestone is supposed to be reached. - * @param closed - * This flag indicates if the milestone is closed e.g. considered done or obsolete. - */ -final case class Milestone( - id: Option[MilestoneId], - title: MilestoneTitle, - description: Option[MilestoneDescription], - dueDate: Option[LocalDate], - closed: Boolean -) - -object Milestone { - - given Eq[LocalDate] = Eq.instance((a, b) => a.compareTo(b) === 0) - - given Eq[Milestone] = - Eq.instance((a, b) => - a.id === b.id && a.title === b.title && a.dueDate === b.dueDate && a.description === b.description - ) - -} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,96 +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 - -/** A base class for a database repository that should handle all functionality regarding projects in the database. - * - * @tparam F - * A higher kinded type which wraps the actual return values. - */ -abstract class ProjectRepository[F[_]] { - - /** Create the given project within the database. - * - * @param project - * The project that shall be created. - * @return - * The number of affected database rows. - */ - def createProject(project: Project): F[Int] - - /** Delete the given project from the database. - * - * @param project - * The project that shall be deleted. - * @return - * The number of affected database rows. - */ - def deleteProject(project: Project): F[Int] - - /** Search for the project entry with the given owner and name. - * - * @param owner - * Data about the owner of the project containing information needed to query the database. - * @param name - * The project name which must be unique in regard to the owner. - * @return - * An option to the successfully found project entry. - */ - def findProject(owner: ProjectOwner, name: ProjectName): F[Option[Project]] - - /** Search for the internal database specific (auto generated) ID of the given owner / project combination which - * serves as a primary key for the database table. - * - * @param owner - * Data about the owner of the project containing information needed to query the database. - * @param name - * The project name which must be unique in regard to the owner. - * @return - * An option to the internal database ID. - */ - def findProjectId(owner: ProjectOwner, name: ProjectName): F[Option[ProjectId]] - - /** Search for a project owner of whom we only know the name. - * - * @param name - * The name of the project owner which is the username of the actual owners account. - * @return - * An option to successfully found project owner. - */ - def findProjectOwner(name: ProjectOwnerName): F[Option[ProjectOwner]] - - /** Increment the counter column for the next ticket number and return the old value (i.e. the value _before_ it was - * incremented). - * - * @param projectId - * The internal database id of the project. - * @return - * The ticket number _before_ it was incremented. - */ - def incrementNextTicketNumber(projectId: ProjectId): F[TicketNumber] - - /** Update the database entry for the given project. - * - * @param project - * The project that shall be updated within the database. - * @return - * The number of affected database rows. - */ - def updateProject(project: Project): F[Int] - -} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Project.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Project.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Project.scala 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Project.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,355 +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.util.UUID - -import cats.* -import cats.data.* -import cats.syntax.all.* -import de.smederee.email.EmailAddress -import de.smederee.security.UserId -import de.smederee.security.Username - -import scala.util.Try -import scala.util.matching.Regex - -opaque type ProjectDescription = String -object ProjectDescription { - val MaximumLength: Int = 8192 - - /** Create an instance of ProjectDescription from the given String type. - * - * @param source - * An instance of type String which will be returned as a ProjectDescription. - * @return - * The appropriate instance of ProjectDescription. - */ - def apply(source: String): ProjectDescription = source - - /** Try to create an instance of ProjectDescription from the given String. - * - * @param source - * A String that should fulfil the requirements to be converted into a ProjectDescription. - * @return - * An option to the successfully converted ProjectDescription. - */ - def from(source: String): Option[ProjectDescription] = Option(source).map(_.take(MaximumLength)) - -} - -opaque type ProjectId = Long -object ProjectId { - given Eq[ProjectId] = Eq.fromUniversalEquals - - val Format: Regex = "^-?\\d+$".r - - /** Create an instance of ProjectId from the given Long type. - * - * @param source - * An instance of type Long which will be returned as a ProjectId. - * @return - * The appropriate instance of ProjectId. - */ - def apply(source: Long): ProjectId = source - - /** Try to create an instance of ProjectId from the given Long. - * - * @param source - * A Long that should fulfil the requirements to be converted into a ProjectId. - * @return - * An option to the successfully converted ProjectId. - */ - def from(source: Long): Option[ProjectId] = Option(source) - - /** Try to create an instance of ProjectId from the given String. - * - * @param source - * A string that should fulfil the requirements to be converted into a ProjectId. - * @return - * An option to the successfully converted ProjectId. - */ - def fromString(source: String): Option[ProjectId] = - Option(source).filter(Format.matches).flatMap(string => Try(string.toLong).toOption).flatMap(from) - - extension (id: ProjectId) { - def toLong: Long = id - } -} - -opaque type ProjectName = String -object ProjectName { - - given Eq[ProjectName] = Eq.fromUniversalEquals - - given Order[ProjectName] = Order.from((a, b) => a.toString.compareTo(b.toString)) - - // TODO: Can we rewrite this in a Scala-3 way (i.e. without implicitly)? - given Ordering[ProjectName] = implicitly[Order[ProjectName]].toOrdering - - val Format: Regex = "^[a-zA-Z0-9][a-zA-Z0-9\\-_]{1,63}$".r - - /** Create an instance of ProjectName from the given String type. - * - * @param source - * An instance of type String which will be returned as a ProjectName. - * @return - * The appropriate instance of ProjectName. - */ - def apply(source: String): ProjectName = source - - /** Try to create an instance of ProjectName from the given String. - * - * @param source - * A String that should fulfil the requirements to be converted into a ProjectName. - * @return - * An option to the successfully converted ProjectName. - */ - def from(source: String): Option[ProjectName] = validate(source).toOption - - /** Validate the given string and return either the validated repository name or a list of errors. - * - * @param s - * An arbitrary string which should be a repository name. - * @return - * Either a list of errors or the validated repository name. - */ - def validate(s: String): ValidatedNec[String, ProjectName] = - Option(s).map(_.trim.nonEmpty) match { - case Some(true) => - val input = s.trim - val miniumLength = - if (input.length > 1) - input.validNec - else - "Repository name too short (min. 2 characters)!".invalidNec - val maximumLength = - if (input.length < 65) - input.validNec - else - "Repository name too long (max. 64 characters)!".invalidNec - val validFormat = - if (Format.matches(input)) - input.validNec - else - "Repository name must start with a letter or number and is only allowed to contain alphanumeric characters plus .".invalidNec - (miniumLength, maximumLength, validFormat).mapN { case (_, _, name) => - name - } - case _ => "Repository name must not be empty!".invalidNec - } -} - -/** Extractor to retrieve a ProjectName from a path parameter. - */ -object ProjectNamePathParameter { - def unapply(str: String): Option[ProjectName] = Option(str).flatMap(ProjectName.from) -} - -/** A project owner id is supposed to be globally unique and maps to the [[java.util.UUID]] type beneath. - */ -opaque type ProjectOwnerId = UUID -object ProjectOwnerId { - val Format = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$".r - - given Eq[ProjectOwnerId] = Eq.fromUniversalEquals - - // given Conversion[UserId, ProjectOwnerId] = ProjectOwnerId.fromUserId - - /** Create an instance of ProjectOwnerId from the given UUID type. - * - * @param source - * An instance of type UUID which will be returned as a ProjectOwnerId. - * @return - * The appropriate instance of ProjectOwnerId. - */ - def apply(source: UUID): ProjectOwnerId = source - - /** Try to create an instance of ProjectOwnerId from the given UUID. - * - * @param source - * A UUID that should fulfil the requirements to be converted into a ProjectOwnerId. - * @return - * An option to the successfully converted ProjectOwnerId. - */ - def from(source: UUID): Option[ProjectOwnerId] = Option(source) - - /** Try to create an instance of ProjectOwnerId from the given String. - * - * @param source - * A String that should fulfil the requirements to be converted into a ProjectOwnerId. - * @return - * An option to the successfully converted ProjectOwnerId. - */ - def fromString(source: String): Either[String, ProjectOwnerId] = - Option(source) - .filter(s => Format.matches(s)) - .flatMap { uuidString => - Either.catchNonFatal(UUID.fromString(uuidString)).toOption - } - .toRight("Illegal value for ProjectOwnerId!") - - /** Create an instance of ProjectOwnerId from the given UserId type. - * - * @param uid - * An instance of type UserId which will be returned as a ProjectOwnerId. - * @return - * The appropriate instance of ProjectOwnerId. - */ - def fromUserId(uid: UserId): ProjectOwnerId = uid.toUUID - - /** Generate a new random project owner id. - * - * @return - * A project owner id which is pseudo randomly generated. - */ - def randomProjectOwnerId: ProjectOwnerId = UUID.randomUUID - - extension (uid: ProjectOwnerId) { - def toUUID: UUID = uid - } -} - -/** A project owner name for an account has to obey several restrictions which are similiar to the ones found for Unix - * usernames. It must start with a letter, contain only alphanumeric characters, be at least 2 and at most 31 - * characters long and be all lowercase. - */ -opaque type ProjectOwnerName = String -object ProjectOwnerName { - given Eq[ProjectOwnerName] = Eq.fromUniversalEquals - - given Conversion[Username, ProjectOwnerName] = ProjectOwnerName.fromUsername - - val isAlphanumeric = "^[a-z][a-z0-9]+$".r - - /** Create an instance of ProjectOwnerName from the given String type. - * - * @param source - * An instance of type String which will be returned as a ProjectOwnerName. - * @return - * The appropriate instance of ProjectOwnerName. - */ - def apply(source: String): ProjectOwnerName = source - - /** Try to create an instance of ProjectOwnerName from the given String. - * - * @param source - * A String that should fulfil the requirements to be converted into a ProjectOwnerName. - * @return - * An option to the successfully converted ProjectOwnerName. - */ - def from(s: String): Option[ProjectOwnerName] = validate(s).toOption - - /** Create an instance of ProjectOwnerName from the given Username type. - * - * @param username - * An instance of the type Username which will be returned as a ProjectOwnerName. - * @return - * The appropriate instance of ProjectOwnerName. - */ - def fromUsername(username: Username): ProjectOwnerName = username.toString - - /** Validate the given string and return either the validated project owner name or a list of errors. - * - * @param s - * An arbitrary string which should be a project owner name. - * @return - * Either a list of errors or the validated project owner name. - */ - def validate(s: String): ValidatedNec[String, ProjectOwnerName] = - Option(s).map(_.trim.nonEmpty) match { - case Some(true) => - val input = s.trim - val miniumLength = - if (input.length >= 2) - input.validNec - else - "ProjectOwnerName too short (min. 2 characters)!".invalidNec - val maximumLength = - if (input.length < 32) - input.validNec - else - "ProjectOwnerName too long (max. 31 characters)!".invalidNec - val alphanumeric = - if (isAlphanumeric.matches(input)) - input.validNec - else - "ProjectOwnerName must be all lowercase alphanumeric characters and start with a character.".invalidNec - (miniumLength, maximumLength, alphanumeric).mapN { case (_, _, name) => - name - } - case _ => "ProjectOwnerName must not be empty!".invalidNec - } - - extension (ownername: ProjectOwnerName) { - - /** Convert this project owner name into a username. - * - * @return - * A syntactically valid username. - */ - def toUsername: Username = Username(ownername.toString) - } -} - -/** Extractor to retrieve an ProjectOwnerName from a path parameter. - */ -object ProjectOwnerNamePathParameter { - def unapply(str: String): Option[ProjectOwnerName] = - Option(str).flatMap { string => - if (string.startsWith("~")) - ProjectOwnerName.from(string.drop(1)) - else - None - } -} - -/** Descriptive information about the owner of a project. - * - * @param owner - * The unique ID of the project owner. - * @param name - * The name of the project owner which is supposed to be unique. - * @param email - * The email address of the project owner. - */ -final case class ProjectOwner(uid: ProjectOwnerId, name: ProjectOwnerName, email: EmailAddress) - -object ProjectOwner { - given Eq[ProjectOwner] = Eq.fromUniversalEquals -} - -/** A project is the base entity for tracking tickets. - * - * @param owner - * The owner of the project. - * @param name - * The name of the project. A project name must start with a letter or number and must contain only alphanumeric - * ASCII characters as well as minus or underscore signs. It must be between 2 and 64 characters long. - * @param description - * An optional short text description of the project. - * @param isPrivate - * A flag indicating if this project is private i.e. only visible / accessible for accounts with appropriate - * permissions. - */ -final case class Project( - owner: ProjectOwner, - name: ProjectName, - description: Option[ProjectDescription], - isPrivate: Boolean -) diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Slf4jLogHandler.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Slf4jLogHandler.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Slf4jLogHandler.scala 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Slf4jLogHandler.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,91 +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 doobie.util.log.* -import org.slf4j.Logger - -object Slf4jLogHandler { - private val RedactArguments: Boolean = true // This SHALL only be set to `false` when debugging issues! - - private val sqlArgumentsToLogString: List[Any] => String = arguments => - if (RedactArguments) - arguments.map(_ => "redacted").mkString(", ") - else - arguments.mkString(", ") - - private val sqlQueryToLogString: String => String = _.linesIterator.dropWhile(_.trim.isEmpty).mkString("\n ") - - /** Create a [[doobie.util.log.LogHandler]] for logging doobie queries and errors. For convenience it is best to - * simply return the return value of this method to a given (implicit) instance. - * - * @param log - * A logger which provides an slf4j interface. - * @return - * A log handler as expected by doobie. - */ - def createLogHandler[F[_]: Sync](log: Logger): LogHandler[F] = - new LogHandler[F] { - def run(logEvent: LogEvent): F[Unit] = - Sync[F].delay { - logEvent match { - case Success(sqlQuery, arguments, label, executionTime, processingTime) => - log.debug(s"""SQL command successful: - | - | ${sqlQueryToLogString(sqlQuery)} - | - | arguments: [${sqlArgumentsToLogString(arguments)}] - | label: $label - | - | execution time : ${executionTime.toMillis} ms - | processing time: ${processingTime.toMillis} ms - | total time : ${(executionTime + processingTime).toMillis} ms - |""".stripMargin) - case ProcessingFailure(sqlQuery, arguments, label, executionTime, processingTime, failure) => - log.error( - s"""SQL PROCESSING FAILURE: - | - | ${sqlQueryToLogString(sqlQuery)} - | - | arguments: [${sqlArgumentsToLogString(arguments)}] - | label: $label - | - | execution time : ${executionTime.toMillis} ms - | processing time: ${processingTime.toMillis} ms - | total time : ${(executionTime + processingTime).toMillis} ms - |""".stripMargin, - failure - ) - case ExecFailure(sqlQuery, arguments, label, executionTime, failure) => - log.error( - s"""SQL EXECUTION FAILURE: - | - | ${sqlQueryToLogString(sqlQuery)} - | - | arguments: [${sqlArgumentsToLogString(arguments)}] - | label: $label - | - | execution time : ${executionTime.toMillis} ms - |""".stripMargin, - failure - ) - } - } - } -} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Submitter.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Submitter.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Submitter.scala 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Submitter.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,163 +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.util.UUID - -import cats.* -import cats.data.* -import cats.syntax.all.* - -/** A submitter id is supposed to be globally unique and maps to the [[java.util.UUID]] type beneath. - */ -opaque type SubmitterId = UUID -object SubmitterId { - val Format = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$".r - - given Eq[SubmitterId] = Eq.fromUniversalEquals - - /** Create an instance of SubmitterId from the given UUID type. - * - * @param source - * An instance of type UUID which will be returned as a SubmitterId. - * @return - * The appropriate instance of SubmitterId. - */ - def apply(source: UUID): SubmitterId = source - - /** Try to create an instance of SubmitterId from the given UUID. - * - * @param source - * A UUID that should fulfil the requirements to be converted into a SubmitterId. - * @return - * An option to the successfully converted SubmitterId. - */ - def from(source: UUID): Option[SubmitterId] = Option(source) - - /** Try to create an instance of SubmitterId from the given String. - * - * @param source - * A String that should fulfil the requirements to be converted into a SubmitterId. - * @return - * An option to the successfully converted SubmitterId. - */ - def fromString(source: String): Either[String, SubmitterId] = - Option(source) - .filter(s => Format.matches(s)) - .flatMap { uuidString => - Either.catchNonFatal(UUID.fromString(uuidString)).toOption - } - .toRight("Illegal value for SubmitterId!") - - /** Generate a new random user id. - * - * @return - * A user id which is pseudo randomly generated. - */ - def randomSubmitterId: SubmitterId = UUID.randomUUID - - extension (uid: SubmitterId) { - def toUUID: UUID = uid - } -} - -/** A submitter name has to obey several restrictions which are similiar to the ones found for Unix usernames. It must - * start with a letter, contain only alphanumeric characters, be at least 2 and at most 31 characters long and be all - * lowercase. - */ -opaque type SubmitterName = String -object SubmitterName { - given Eq[SubmitterName] = Eq.fromUniversalEquals - - val isAlphanumeric = "^[a-z][a-z0-9]+$".r - - /** Create an instance of SubmitterName from the given String type. - * - * @param source - * An instance of type String which will be returned as a SubmitterName. - * @return - * The appropriate instance of SubmitterName. - */ - def apply(source: String): SubmitterName = source - - /** Try to create an instance of SubmitterName from the given String. - * - * @param source - * A String that should fulfil the requirements to be converted into a SubmitterName. - * @return - * An option to the successfully converted SubmitterName. - */ - def from(s: String): Option[SubmitterName] = validate(s).toOption - - /** Validate the given string and return either the validated username or a list of errors. - * - * @param s - * An arbitrary string which should be a username. - * @return - * Either a list of errors or the validated username. - */ - def validate(s: String): ValidatedNec[String, SubmitterName] = - Option(s).map(_.trim.nonEmpty) match { - case Some(true) => - val input = s.trim - val miniumLength = - if (input.length >= 2) - input.validNec - else - "SubmitterName too short (min. 2 characters)!".invalidNec - val maximumLength = - if (input.length < 32) - input.validNec - else - "SubmitterName too long (max. 31 characters)!".invalidNec - val alphanumeric = - if (isAlphanumeric.matches(input)) - input.validNec - else - "SubmitterName must be all lowercase alphanumeric characters and start with a character.".invalidNec - (miniumLength, maximumLength, alphanumeric).mapN { case (_, _, name) => - name - } - case _ => "SubmitterName must not be empty!".invalidNec - } -} - -/** Extractor to retrieve an SubmitterName from a path parameter. - */ -object SubmitterNamePathParameter { - def unapply(str: String): Option[SubmitterName] = - Option(str).flatMap { string => - if (string.startsWith("~")) - SubmitterName.from(string.drop(1)) - else - None - } -} - -/** The submitter for a ticket i.e. the person supposed to be working on it. - * - * @param id - * A globally unique ID identifying the submitter. - * @param name - * The name associated with the submitter which is supposed to be unique. - */ -final case class Submitter(id: SubmitterId, name: SubmitterName) - -object Submitter { - given Eq[Submitter] = Eq.fromUniversalEquals -} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketRepository.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketRepository.scala 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketRepository.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,207 +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 fs2.Stream - -/** The base class that defines the needed functionality to handle tickets and related data types within a database. - * - * @tparam F - * A higher kinded type which wraps the actual return values. - */ -abstract class TicketRepository[F[_]] { - - /** Add the given assignee to the ticket of the given repository 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 assignee - * The assignee to be added to the ticket. - * @return - * The number of affected database rows. - */ - def addAssignee(projectId: ProjectId)(ticketNumber: TicketNumber)(assignee: Assignee): F[Int] - - /** Add the given label to the ticket of the given repository 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 label - * The label to be added to the ticket. - * @return - * The number of affected database rows. - */ - def addLabel(projectId: ProjectId)(ticketNumber: TicketNumber)(label: Label): F[Int] - - /** Add the given milestone to the ticket of the given repository 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 milestone - * The milestone to be added to the ticket. - * @return - * The number of affected database rows. - */ - def addMilestone(projectId: ProjectId)(ticketNumber: TicketNumber)(milestone: Milestone): F[Int] - - /** Return all tickets associated with the given repository. - * - * @param filter - * A ticket filter containing possible values which will be used to filter the list of tickets. - * @param projectId - * The unique internal ID of a ticket tracking project for which all tickets shall be returned. - * @return - * A stream of tickets associated with the vcs repository which may be empty. - */ - def allTickets(filter: Option[TicketFilter])(projectId: ProjectId): Stream[F, Ticket] - - /** Create a database entry for the given ticket definition within the scope of the repository with the given id. - * - * @param projectId - * The unique internal ID of a ticket tracking project. - * @param ticket - * The ticket definition that shall be written to the database. - * @return - * The number of affected database rows. - */ - def createTicket(projectId: ProjectId)(ticket: Ticket): F[Int] - - /** Delete the ticket of the repository with the given id from the database. - * - * @param projectId - * The unique internal ID of a ticket tracking project. - * @param ticket - * The ticket definition that shall be deleted from the database. - * @return - * The number of affected database rows. - */ - def deleteTicket(projectId: ProjectId)(ticket: Ticket): F[Int] - - /** Find the ticket with the given number of the repository with the given 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 - * An option to the found ticket. - */ - def findTicket(projectId: ProjectId)(ticketNumber: TicketNumber): F[Option[Ticket]] - - /** Find the ticket with the given number of the project with the given id and return the internal unique id of the - * ticket. - * - * @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 - * An option to the found ticket. - */ - def findTicketId(projectId: ProjectId)(ticketNumber: TicketNumber): F[Option[TicketId]] - - /** Load all assignees that are assigned to the ticket with the given number and repository 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 assigness that may be empty. - */ - def loadAssignees(projectId: ProjectId)(ticketNumber: TicketNumber): Stream[F, Assignee] - - /** Load all labels that are attached to the ticket with the given number and repository 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 labels that may be empty. - */ - def loadLabels(projectId: ProjectId)(ticketNumber: TicketNumber): Stream[F, Label] - - /** Load all milestones that are attached to the ticket with the given number and repository 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 milestones that may be empty. - */ - def loadMilestones(projectId: ProjectId)(ticketNumber: TicketNumber): Stream[F, Milestone] - - /** Remove the given assignee from the ticket of the given repository 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 assignee - * The assignee to be removed from the ticket. - * @return - * The number of affected database rows. - */ - def removeAssignee(projectId: ProjectId)(ticket: Ticket)(assignee: Assignee): F[Int] - - /** Remove the given label from the ticket of the given repository 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 label - * The label to be removed from the ticket. - * @return - * The number of affected database rows. - */ - def removeLabel(projectId: ProjectId)(ticket: Ticket)(label: Label): F[Int] - - /** Remove the given milestone from the ticket of the given repository 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 milestone - * The milestone to be removed from the ticket. - * @return - * The number of affected database rows. - */ - def removeMilestone(projectId: ProjectId)(ticket: Ticket)(milestone: Milestone): F[Int] - - /** Update the database entry for the given ticket within the scope of the repository with the given id. - * - * @param projectId - * The unique internal ID of a ticket tracking project. - * @param ticket - * The ticket definition that shall be updated within the database. - * @return - * The number of affected database rows. - */ - def updateTicket(projectId: ProjectId)(ticket: Ticket): F[Int] - -} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,426 +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.* -import cats.syntax.all.* -import org.http4s.QueryParamDecoder -import org.http4s.QueryParamEncoder -import org.http4s.dsl.impl.OptionalQueryParamDecoderMatcher - -import scala.util.matching.Regex - -/** An unlimited text field which must be not empty to describe the ticket in great detail if needed. - */ -opaque type TicketContent = String -object TicketContent { - - /** Create an instance of TicketContent from the given String type. - * - * @param source - * An instance of type String which will be returned as a TicketContent. - * @return - * The appropriate instance of TicketContent. - */ - def apply(source: String): TicketContent = source - - /** Try to create an instance of TicketContent from the given String. - * - * @param source - * A String that should fulfil the requirements to be converted into a TicketContent. - * @return - * An option to the successfully converted TicketContent. - */ - def from(source: String): Option[TicketContent] = Option(source).filter(_.nonEmpty) - -} - -opaque type TicketId = Long -object TicketId { - given Eq[TicketId] = Eq.fromUniversalEquals - - val Format: Regex = "^-?\\d+$".r - - /** Create an instance of TicketId from the given Long type. - * - * @param source - * An instance of type Long which will be returned as a TicketId. - * @return - * The appropriate instance of TicketId. - */ - def apply(source: Long): TicketId = source - - /** Try to create an instance of TicketId from the given Long. - * - * @param source - * A Long that should fulfil the requirements to be converted into a TicketId. - * @return - * An option to the successfully converted TicketId. - */ - def from(source: Long): Option[TicketId] = Option(source) - - /** Try to create an instance of TicketId from the given String. - * - * @param source - * A string that should fulfil the requirements to be converted into a TicketId. - * @return - * An option to the successfully converted TicketId. - */ - def fromString(source: String): Option[TicketId] = Option(source).filter(Format.matches).map(_.toLong).flatMap(from) - - extension (id: TicketId) { - def toLong: Long = id - } -} - -/** A ticket number maps to an integer beneath and has the requirement to be greater than zero. - */ -opaque type TicketNumber = Int -object TicketNumber { - given Eq[TicketNumber] = Eq.fromUniversalEquals - given Ordering[TicketNumber] = (x: TicketNumber, y: TicketNumber) => x.compareTo(y) - given Order[TicketNumber] = Order.fromOrdering - - val Format: Regex = "^-?\\d+$".r - - /** Create an instance of TicketNumber from the given Int type. - * - * @param source - * An instance of type Int which will be returned as a TicketNumber. - * @return - * The appropriate instance of TicketNumber. - */ - def apply(source: Int): TicketNumber = source - - /** Try to create an instance of TicketNumber from the given Int. - * - * @param source - * A Int that should fulfil the requirements to be converted into a TicketNumber. - * @return - * An option to the successfully converted TicketNumber. - */ - def from(source: Int): Option[TicketNumber] = Option(source).filter(_ > 0) - - /** Try to create an instance of TicketNumber from the given String. - * - * @param source - * A string that should fulfil the requirements to be converted into a TicketNumber. - * @return - * An option to the successfully converted TicketNumber. - */ - def fromString(source: String): Option[TicketNumber] = - Option(source).filter(Format.matches).map(_.toInt).flatMap(from) - - extension (number: TicketNumber) { - def toInt: Int = number.toInt - } -} - -/** Extractor to retrieve a TicketNumber from a path parameter. - */ -object TicketNumberPathParameter { - def unapply(str: String): Option[TicketNumber] = Option(str).flatMap(TicketNumber.fromString) -} - -/** Possible states of a ticket which shall model the life cycle from ticket creation until it is closed. To keep things - * simple we do not model the kind of a resolution (e.g. fixed, won't fix or so) for a ticket. - */ -enum TicketStatus { - - /** The ticket is considered to be a valid ticket / ready to be worked on e.g. a bug has been confirmed to be - * present. - */ - case Confirmed - - /** The ticket is being worked on i.e. it is in progress. - */ - case InProgress - - /** The ticket is pending and cannot be processed right now. It may be moved to another state or closed depending on - * the circumstances. This could be used to model the "blocked" state of Kanban. - */ - case Pending - - /** The ticket is resolved (i.e. closed) and considered done. - */ - case Resolved - - /** The ticket was reported and is open for further investigation. This might map to what is often called "Backlog" - * nowadays. - */ - case Submitted -} - -object TicketStatus { - given Eq[TicketStatus] = Eq.fromUniversalEquals - - /** Try to parse a ticket status instance from the given string without throwin an exception like `valueOf`. - * - * @param source - * A string that should contain the name of a ticket status. - * @return - * An option to the successfully deserialised instance. - */ - def fromString(source: String): Option[TicketStatus] = - TicketStatus.values.map(_.toString).find(_ === source).map(TicketStatus.valueOf) -} - -/** Possible types of "resolved states" of a ticket. - */ -enum TicketResolution { - - /** The behaviour / scenario described in the ticket is caused by the design of the application and not considered - * to be a bug. - */ - case ByDesign - - /** The ticket is finally closed and considered done. - * - * This state can be used to model a review process e.g. a developer can move a ticket to `Fixed` and reviewer and - * tester can later move the ticket to `Closed`. - */ - case Closed - - /** The ticket is a duplicate of an already existing one. - */ - case Duplicate - - /** The bug described in the ticket was fixed. - */ - case Fixed - - /** The feature described in the ticket was implemented. - */ - case Implemented - - /** The ticket is considered to be invalid. - */ - case Invalid - - /** The issue described in the ticket will not be fixed. - */ - case WontFix -} - -object TicketResolution { - given Eq[TicketResolution] = Eq.fromUniversalEquals - - /** Try to parse a ticket resolution instance from the given string without throwin an exception like `valueOf`. - * - * @param source - * A string that should contain the name of a ticket resolution. - * @return - * An option to the successfully deserialised instance. - */ - def fromString(source: String): Option[TicketResolution] = - TicketResolution.values.map(_.toString).find(_ === source).map(TicketResolution.valueOf) -} - -/** A concise and short description of the ticket which should not exceed 80 characters. - */ -opaque type TicketTitle = String -object TicketTitle { - - val MaxLength: Int = 72 - - /** Create an instance of TicketTitle from the given String type. - * - * @param source - * An instance of type String which will be returned as a TicketTitle. - * @return - * The appropriate instance of TicketTitle. - */ - def apply(source: String): TicketTitle = source - - /** Try to create an instance of TicketTitle from the given String. - * - * @param source - * A String that should fulfil the requirements to be converted into a TicketTitle. - * @return - * An option to the successfully converted TicketTitle. - */ - def from(source: String): Option[TicketTitle] = - Option(source).filter(string => string.nonEmpty && string.length <= MaxLength) -} - -/** An ticket used to describe a problem or a task (e.g. implement a concrete feature) within the scope of a project. - * - * @param number - * The unique identifier of a ticket within the project scope is its number. - * @param title - * A concise and short description of the ticket which should not exceed 72 characters. - * @param content - * An optional field to describe the ticket in great detail if needed. - * @param status - * The current status of the ticket describing its life cycle. - * @param resolution - * An optional resolution state of the ticket that should be set if it is closed. - * @param submitter - * The person who submitted (created) this ticket 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 Ticket( - number: TicketNumber, - title: TicketTitle, - content: Option[TicketContent], - status: TicketStatus, - resolution: Option[TicketResolution], - submitter: Option[Submitter], - createdAt: OffsetDateTime, - updatedAt: OffsetDateTime -) - -/** A data container for values that can be used to filter a list of tickets by. - * - * @param number - * A list of ticket numbers that must be matched. - * @param status - * A list of ticket status flags that must be matched. - * @param resolution - * A list of ticket resolution kinds that must be matched. - * @param submitter - * A list of usernames from whom the ticket must have been submitted. - */ -final case class TicketFilter( - number: List[TicketNumber], - status: List[TicketStatus], - resolution: List[TicketResolution], - submitter: List[SubmitterName] -) - -object TicketFilter { - given QueryParamDecoder[TicketFilter] = QueryParamDecoder[String].map(TicketFilter.fromQueryParameter) - given QueryParamEncoder[TicketFilter] = QueryParamEncoder[String].contramap(_.toQueryParameter) - - /** Decode an optional possibly existing query parameter into a `TicketFilter`. - * - * Usage: `case GET -> Root / "..." :? OptionalUrlParamter(maybeFilter) => ...` - */ - object OptionalUrlParameter extends OptionalQueryParamDecoderMatcher[TicketFilter]("q") - - // Only "open" tickets. - val OpenTicketsOnly = TicketFilter( - number = Nil, - status = TicketStatus.values.filterNot(_ === TicketStatus.Resolved).toList, - resolution = Nil, - submitter = Nil - ) - // Only resolved (closed) tickets. - val ResolvedTicketsOnly = - TicketFilter(number = Nil, status = List(TicketStatus.Resolved), resolution = Nil, submitter = Nil) - - /** Parse the given query string which must contain a serialised ticket filter instance and return a ticket filter - * with the successfully parsed filters. - * - * @param queryString - * A query string parameter passed via an URL. - * @return - * A ticket filter instance which may be empty. - */ - def fromQueryParameter(queryString: String): TicketFilter = { - val number = - if (queryString.contains("numbers: ")) - queryString - .drop(queryString.indexOf("numbers: ") + 9) - .takeWhile(char => !char.isWhitespace) - .split(",") - .map(TicketNumber.fromString) - .flatten - .toList - else - Nil - val status = - if (queryString.contains("status: ")) - queryString - .drop(queryString.indexOf("status: ") + 8) - .takeWhile(char => !char.isWhitespace) - .split(",") - .map(TicketStatus.fromString) - .flatten - .toList - else - Nil - val resolution = - if (queryString.contains("resolution: ")) - queryString - .drop(queryString.indexOf("resolution: ") + 12) - .takeWhile(char => !char.isWhitespace) - .split(",") - .map(TicketResolution.fromString) - .flatten - .toList - else - Nil - val submitter = - if (queryString.contains("by: ")) - queryString - .drop(queryString.indexOf("by: ") + 4) - .takeWhile(char => !char.isWhitespace) - .split(",") - .map(SubmitterName.from) - .flatten - .toList - else - Nil - TicketFilter(number, status, resolution, submitter) - } - - extension (filter: TicketFilter) { - - /** Convert this ticket filter instance into a query string representation that can be passed as query parameter - * in a URL and parsed back again. - * - * @return - * A string containing a serialised form of the ticket filter that can be used as a URL query parameter. - */ - def toQueryParameter: String = { - val numbers = - if (filter.number.isEmpty) - None - else - filter.number.map(_.toString).mkString(",").some - val status = - if (filter.status.isEmpty) - None - else - filter.status.map(_.toString).mkString(",").some - val resolution = - if (filter.resolution.isEmpty) - None - else - filter.resolution.map(_.toString).mkString(",").some - val submitter = - if (filter.submitter.isEmpty) - None - else - filter.submitter.map(_.toString).mkString(",").some - List( - numbers.map(string => s"numbers: $string"), - status.map(string => s"status: $string"), - resolution.map(string => s"resolution: $string"), - submitter.map(string => s"by: $string") - ).flatten.mkString(" ") - } - } -} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketServiceApi.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketServiceApi.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketServiceApi.scala 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketServiceApi.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,48 +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 de.smederee.security.UserId - -/** Definition of a programmatic API for the ticket service which can be used to initialise and synchronise data with - * the hub service. - * - * @tparam F - * A higher kinded type which wraps the actual return values. - */ -abstract class TicketServiceApi[F[_]] { - - /** Create a user in the ticket service or update an existing one if an account with the unique id already exists. - * - * @param user - * The user account that shall be created. - * @return - * The number of affected database rows. - */ - def createOrUpdateUser(user: TicketsUser): F[Int] - - /** Delete the given user from the ticket service. - * - * @param uid - * The unique id of the user account. - * @return - * The number of affected database rows. - */ - def deleteUser(uid: UserId): F[Int] - -} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketsUser.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketsUser.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketsUser.scala 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketsUser.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,41 +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.* -import de.smederee.email.EmailAddress -import de.smederee.i18n.LanguageCode -import de.smederee.security.UserId -import de.smederee.security.Username - -/** A user of the tickets service. - * - * @param uid - * The unique ID of the user. - * @param name - * A unique name which can be used for login and to identify the user. - * @param email - * The email address of the user which must also be unique. - * @param language - * The language code of the users preferred language. - */ -final case class TicketsUser(uid: UserId, name: Username, email: EmailAddress, language: Option[LanguageCode]) - -object TicketsUser { - given Eq[TicketsUser] = Eq.fromUniversalEquals -} 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 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/test/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/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-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/test/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/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 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/TestTags.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,25 +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 - -/** 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 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/BaseSpec.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,369 +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 -import de.smederee.security.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/ColourCodeTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/ColourCodeTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/ColourCodeTest.scala 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/ColourCodeTest.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.tickets - -import de.smederee.tickets.Generators.* - -import munit.* - -import org.scalacheck.* -import org.scalacheck.Prop.* - -final class ColourCodeTest extends ScalaCheckSuite { - given Arbitrary[ColourCode] = Arbitrary(genColourCode) - - property("ColourCode.from must fail on invalid input") { - forAll { (input: String) => - assertEquals(ColourCode.from(input), None) - } - } - - property("ColourCode.from must succeed on valid input") { - forAll { (colourCode: ColourCode) => - val input = colourCode.toString - assertEquals(ColourCode.from(input), Option(colourCode)) - } - } -} 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 2025-01-12 05:25:38.811741504 +0000 +++ new-smederee/modules/tickets/src/test/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 de.smederee.TestTags.* -import de.smederee.tickets.BaseSpec -import org.flywaydb.core.Flyway - -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/config/SmedereeTicketsConfigurationTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/config/SmedereeTicketsConfigurationTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/config/SmedereeTicketsConfigurationTest.scala 2025-01-12 05:25:38.811741504 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/config/SmedereeTicketsConfigurationTest.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.tickets.config - -import com.typesafe.config.* -import org.http4s.Uri -import org.http4s.implicits.* -import pureconfig.* - -import munit.* - -final class SmedereeTicketsConfigurationTest extends FunSuite { - val rawDefaultConfig = new Fixture[Config]("defaultConfig") { - def apply() = ConfigFactory.load(getClass.getClassLoader) - } - - override def munitFixtures = List(rawDefaultConfig) - - test("must load from the default configuration successfully") { - ConfigSource - .fromConfig(rawDefaultConfig()) - .at(s"${SmedereeTicketsConfiguration.location.toString}") - .load[SmedereeTicketsConfiguration] match { - case Left(errors) => fail(errors.toList.mkString(", ")) - case Right(_) => assert(true) - } - } - - test("default values for external linking must be setup for local development") { - ConfigSource - .fromConfig(rawDefaultConfig()) - .at(s"${SmedereeTicketsConfiguration.location.toString}") - .load[SmedereeTicketsConfiguration] match { - case Left(errors) => fail(errors.toList.mkString(", ")) - case Right(cfg) => - val externalCfg = cfg.externalUrl - assertEquals(externalCfg.host, cfg.service.host) - assertEquals(externalCfg.port, Option(cfg.service.port)) - assert(externalCfg.path.isEmpty) - assertEquals(externalCfg.scheme, Uri.Scheme.http) - } - } - - test("default values for hub service integration must be setup for local development") { - ConfigSource - .fromConfig(rawDefaultConfig()) - .at(s"${SmedereeTicketsConfiguration.location.toString}") - .load[SmedereeTicketsConfiguration] match { - case Left(errors) => fail(errors.toList.mkString(", ")) - case Right(cfg) => - val expectedUri = uri"http://localhost:8080" - assertEquals(cfg.hub.baseUri, expectedUri) - } - } -} 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 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,312 +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.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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,446 +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.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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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("allTickets must return all tickets associated with the milestone".tag(NeedsDatabase)) { - (genProjectOwner.sample, genProject.sample, genMilestone.sample, genTickets.sample) match { - case (Some(owner), Some(generatedProject), Some(milestone), Some(rawTickets)) => - val project = generatedProject.copy(owner = owner) - val tickets = rawTickets.map(_.copy(submitter = None)) - val dbConfig = configuration.database - val tx = Transactor.fromDriverManager[IO]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - val milestoneRepo = new DoobieMilestoneRepository[IO](tx) - val ticketRepo = new DoobieTicketRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - _ <- createTicketsProject(project) - repoId <- loadProjectId(owner.uid, project.name) - foundTickets <- repoId match { - case None => IO.pure(List.empty) - case Some(projectId) => - for { - _ <- milestoneRepo.createMilestone(projectId)(milestone) - _ <- tickets.traverse(ticket => ticketRepo.createTicket(projectId)(ticket)) - createdMilestone <- milestoneRepo.findMilestone(projectId)(milestone.title) - _ <- createdMilestone match { - case None => IO.pure(List.empty) - case Some(milestone) => - tickets.traverse(ticket => - ticketRepo.addMilestone(projectId)(ticket.number)(milestone) - ) - } - foundTickets <- createdMilestone.map(_.id).getOrElse(None) match { - case None => IO.pure(List.empty) - case Some(milestoneId) => milestoneRepo.allTickets(None)(milestoneId).compile.toList - } - } yield foundTickets - } - } yield foundTickets - test.map { foundTickets => - assertEquals(foundTickets.size, tickets.size, "Different number of tickets!") - foundTickets.sortBy(_.number).zip(tickets.sortBy(_.number)).map { (found, expected) => - assertEquals( - found.copy(createdAt = expected.createdAt, updatedAt = expected.updatedAt), - expected - ) - } - } - case _ => fail("Could not generate data samples!") - } - } - - test("closeMilestone must set the closed flag correctly".tag(NeedsDatabase)) { - (genProjectOwner.sample, genProject.sample, genMilestone.sample) match { - case (Some(owner), Some(generatedProject), Some(generatedMilestone)) => - val milestone = generatedMilestone.copy(closed = false) - 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 milestoneRepo = new DoobieMilestoneRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - createdRepos <- createTicketsProject(project) - repoId <- loadProjectId(owner.uid, project.name) - milestones <- repoId match { - case None => IO.pure((None, None)) - case Some(projectId) => - for { - _ <- milestoneRepo.createMilestone(projectId)(milestone) - before <- milestoneRepo.findMilestone(projectId)(milestone.title) - _ <- before.flatMap(_.id).traverse(milestoneRepo.closeMilestone) - after <- milestoneRepo.findMilestone(projectId)(milestone.title) - } yield (before, after) - } - } yield milestones - test.map { result => - val (before, after) = result - val expected = before.map(m => milestone.copy(id = m.id)) - assertEquals(before, expected, "Test milestone not properly initialised!") - assertEquals(after, before.map(_.copy(closed = true)), "Test milestone not properly closed!") - } - 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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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("openMilestone must reset the closed flag correctly".tag(NeedsDatabase)) { - (genProjectOwner.sample, genProject.sample, genMilestone.sample) match { - case (Some(owner), Some(generatedProject), Some(generatedMilestone)) => - val milestone = generatedMilestone.copy(closed = true) - 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 milestoneRepo = new DoobieMilestoneRepository[IO](tx) - val test = for { - _ <- createProjectOwner(owner) - createdRepos <- createTicketsProject(project) - repoId <- loadProjectId(owner.uid, project.name) - milestones <- repoId match { - case None => IO.pure((None, None)) - case Some(projectId) => - for { - _ <- milestoneRepo.createMilestone(projectId)(milestone) - before <- milestoneRepo.findMilestone(projectId)(milestone.title) - _ <- before.flatMap(_.id).traverse(milestoneRepo.openMilestone) - after <- milestoneRepo.findMilestone(projectId)(milestone.title) - } yield (before, after) - } - } yield milestones - test.map { result => - val (before, after) = result - val expected = before.map(m => milestone.copy(id = m.id)) - assertEquals(before, expected, "Test milestone not properly initialised!") - assertEquals(after, before.map(_.copy(closed = false)), "Test milestone not properly opened!") - } - 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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,227 +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.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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,899 +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.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]( - 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)) - _ <- 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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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]( - 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) - _ <- 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]( - 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) - _ <- 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]( - 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) - _ <- 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]( - 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) - _ <- 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.map(_.name)) - 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) - _ <- 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]( - 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) - _ <- 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]( - 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) - _ <- 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]( - 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) - _ <- 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]( - 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) - _ <- 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]( - 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) - _ <- 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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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]( - 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) - 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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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]( - 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) - _ <- 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 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,107 +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.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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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]( - driver = dbConfig.driver, - url = dbConfig.url, - user = dbConfig.user, - password = dbConfig.pass, - logHandler = None - ) - 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-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,290 +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 -import java.util.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 -import org.scalacheck.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 genTicketStatusList: Gen[List[TicketStatus]] = Gen.nonEmptyListOf(genTicketStatus).map(_.distinct) - - val genTicketResolution: Gen[TicketResolution] = Gen.oneOf(TicketResolution.values.toList) - val genTicketResolutions: Gen[List[TicketResolution]] = Gen.nonEmptyListOf(genTicketResolution).map(_.distinct) - - val genTicketNumber: Gen[TicketNumber] = Gen.choose(0, Int.MaxValue).map(TicketNumber.apply) - val genTicketNumbers: Gen[List[TicketNumber]] = Gen.nonEmptyListOf(genTicketNumber).map(_.distinct) - - 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 genTicketFilter: Gen[TicketFilter] = - for { - number <- Gen.listOf(genTicketNumber) - status <- Gen.listOf(genTicketStatus) - resolution <- Gen.listOf(genTicketResolution) - submitter <- Gen.listOf(genSubmitter) - } yield TicketFilter(number, status, resolution, submitter.map(_.name).distinct) - - 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) - .map(_.foldLeft(List.empty[ProjectOwner]) { (acc, a) => - if (acc.exists(_.name === a.name)) - acc - else - a :: acc - }) // Ensure distinct user 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) - closed <- Gen.oneOf(List(false, true)) - } yield Milestone(id, title, descr, due, closed) - - 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/scala/de/smederee/tickets/LabelDescriptionTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,47 +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 de.smederee.tickets.Generators.* - -import munit.* - -import org.scalacheck.* -import org.scalacheck.Prop.* - -final class LabelDescriptionTest extends ScalaCheckSuite { - given Arbitrary[LabelDescription] = Arbitrary(genLabelDescription) - - test("LabelDescription.from must fail on empty input") { - assertEquals(LabelDescription.from(""), None) - } - - property("LabelDescription.from must fail on too long input") { - forAll { (input: String) => - if (input.length > LabelDescription.MaxLength) - assertEquals(LabelDescription.from(input), None) - } - } - - property("LabelDescription.from must succeed on valid input") { - forAll { (label: LabelDescription) => - val input = label.toString - assertEquals(LabelDescription.from(input), Option(label)) - } - } -} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelNameTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelNameTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelNameTest.scala 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelNameTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,47 +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 de.smederee.tickets.Generators.* - -import munit.* - -import org.scalacheck.* -import org.scalacheck.Prop.* - -final class LabelNameTest extends ScalaCheckSuite { - given Arbitrary[LabelName] = Arbitrary(genLabelName) - - test("LabelName.from must fail on empty input") { - assertEquals(LabelName.from(""), None) - } - - property("LabelName.from must fail on too long input") { - forAll { (input: String) => - if (input.length > LabelName.MaxLength) - assertEquals(LabelName.from(input), None) - } - } - - property("LabelName.from must succeed on valid input") { - forAll { (label: LabelName) => - val input = label.toString - assertEquals(LabelName.from(input), Option(label)) - } - } -} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelTest.scala 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,36 +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.syntax.all.* -import de.smederee.tickets.Generators.* - -import munit.* - -import org.scalacheck.* -import org.scalacheck.Prop.* - -final class LabelTest extends ScalaCheckSuite { - given Arbitrary[Label] = Arbitrary(genLabel) - - property("Eq must hold") { - forAll { (label: Label) => - assert(label === label, "Identical labels must be considered equal!") - } - } -} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,40 +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 de.smederee.tickets.Generators.* - -import munit.* - -import org.scalacheck.* -import org.scalacheck.Prop.* - -final class MilestoneDescriptionTest extends ScalaCheckSuite { - given Arbitrary[MilestoneDescription] = Arbitrary(genMilestoneDescription) - - test("MilestoneDescription.from must fail on empty input") { - assertEquals(MilestoneDescription.from(""), None) - } - - property("MilestoneDescription.from must succeed on valid input") { - forAll { (label: MilestoneDescription) => - val input = label.toString - assertEquals(MilestoneDescription.from(input), Option(label)) - } - } -} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTest.scala 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,36 +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.syntax.all.* -import de.smederee.tickets.Generators.* - -import munit.* - -import org.scalacheck.* -import org.scalacheck.Prop.* - -final class MilestoneTest extends ScalaCheckSuite { - given Arbitrary[Milestone] = Arbitrary(genMilestone) - - property("Eq must hold") { - forAll { (label: Milestone) => - assert(label === label, "Identical milestones must be considered equal!") - } - } -} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,47 +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 de.smederee.tickets.Generators.* - -import munit.* - -import org.scalacheck.* -import org.scalacheck.Prop.* - -final class MilestoneTitleTest extends ScalaCheckSuite { - given Arbitrary[MilestoneTitle] = Arbitrary(genMilestoneTitle) - - test("MilestoneTitle.from must fail on empty input") { - assertEquals(MilestoneTitle.from(""), None) - } - - property("MilestoneTitle.from must fail on too long input") { - forAll { (input: String) => - if (input.length > MilestoneTitle.MaxLength) - assertEquals(MilestoneTitle.from(input), None) - } - } - - property("MilestoneTitle.from must succeed on valid input") { - forAll { (label: MilestoneTitle) => - val input = label.toString - assertEquals(MilestoneTitle.from(input), Option(label)) - } - } -} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketContentTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketContentTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketContentTest.scala 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketContentTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,36 +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 munit.* - -import org.scalacheck.* -import org.scalacheck.Prop.* - -final class TicketContentTest extends ScalaCheckSuite { - - property("TicketContent.from must only accept valid input") { - forAll { (input: String) => - if (input.nonEmpty) - assertEquals(TicketContent.from(input), Some(TicketContent(input))) - else - assertEquals(TicketContent.from(input), None) - } - } - -} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketFilterTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketFilterTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketFilterTest.scala 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketFilterTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,136 +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 de.smederee.tickets.Generators.* - -import munit.* - -import org.scalacheck.* -import org.scalacheck.Prop.* - -final class TicketFilterTest extends ScalaCheckSuite { - given Arbitrary[Submitter] = Arbitrary(genSubmitter) - given Arbitrary[TicketFilter] = Arbitrary(genTicketFilter) - given Arbitrary[TicketNumber] = Arbitrary(genTicketNumber) - given Arbitrary[TicketResolution] = Arbitrary(genTicketResolution) - given Arbitrary[TicketStatus] = Arbitrary(genTicketStatus) - - property("fromQueryParameter must produce empty filters for invalid input") { - forAll { (randomInput: String) => - assertEquals( - TicketFilter.fromQueryParameter(randomInput), - TicketFilter(number = Nil, status = Nil, resolution = Nil, submitter = Nil) - ) - } - } - - property("fromQueryParameter must work for numbers only") { - forAll { (numbers: List[TicketNumber]) => - val filter = TicketFilter(number = numbers, status = Nil, resolution = Nil, submitter = Nil) - assertEquals(TicketFilter.fromQueryParameter(s"numbers: ${numbers.map(_.toString).mkString(",")}"), filter) - } - } - - property("fromQueryParameter must work for status only") { - forAll { (status: List[TicketStatus]) => - val filter = TicketFilter(number = Nil, status = status, resolution = Nil, submitter = Nil) - assertEquals(TicketFilter.fromQueryParameter(s"status: ${status.map(_.toString).mkString(",")}"), filter) - } - } - - property("fromQueryParameter must work for resolution only") { - forAll { (resolution: List[TicketResolution]) => - val filter = TicketFilter(number = Nil, status = Nil, resolution = resolution, submitter = Nil) - assertEquals( - TicketFilter.fromQueryParameter(s"resolution: ${resolution.map(_.toString).mkString(",")}"), - filter - ) - } - } - - property("fromQueryParameter must work for submitter only") { - forAll { (submitters: List[Submitter]) => - if (submitters.nonEmpty) { - val filter = - TicketFilter(number = Nil, status = Nil, resolution = Nil, submitter = submitters.map(_.name)) - assertEquals( - TicketFilter.fromQueryParameter(s"by: ${submitters.map(_.name.toString).mkString(",")}"), - filter - ) - } - } - } - - property("toQueryParameter must include numbers") { - forAll { (numbers: List[TicketNumber]) => - if (numbers.nonEmpty) { - val filter = TicketFilter(number = numbers, status = Nil, resolution = Nil, submitter = Nil) - assert( - TicketFilter.toQueryParameter(filter).contains(s"numbers: ${numbers.map(_.toString).mkString(",")}") - ) - } - } - } - - property("toQueryParameter must include status") { - forAll { (status: List[TicketStatus]) => - if (status.nonEmpty) { - val filter = TicketFilter(number = Nil, status = status, resolution = Nil, submitter = Nil) - assert( - TicketFilter.toQueryParameter(filter).contains(s"status: ${status.map(_.toString).mkString(",")}") - ) - } - } - } - - property("toQueryParameter must include resolution") { - forAll { (resolution: List[TicketResolution]) => - if (resolution.nonEmpty) { - val filter = TicketFilter(number = Nil, status = Nil, resolution = resolution, submitter = Nil) - assert( - TicketFilter - .toQueryParameter(filter) - .contains(s"resolution: ${resolution.map(_.toString).mkString(",")}"), - TicketFilter.toQueryParameter(filter) - ) - } - } - } - - property("toQueryParameter must include submitter") { - forAll { (submitters: List[Submitter]) => - if (submitters.nonEmpty) { - val filter = - TicketFilter(number = Nil, status = Nil, resolution = Nil, submitter = submitters.map(_.name)) - assert( - TicketFilter - .toQueryParameter(filter) - .contains(s"by: ${submitters.map(_.name.toString).mkString(",")}"), - TicketFilter.toQueryParameter(filter) - ) - } - } - } - - property("toQueryParameter must be the dual of fromQueryParameter") { - forAll { (filter: TicketFilter) => - assertEquals(TicketFilter.fromQueryParameter(filter.toQueryParameter), filter) - } - } -} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketNumberTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketNumberTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketNumberTest.scala 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketNumberTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,36 +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 munit.* - -import org.scalacheck.* -import org.scalacheck.Prop.* - -final class TicketNumberTest extends ScalaCheckSuite { - - property("TicketNumber.from must only accept valid input") { - forAll { (integer: Int) => - if (integer > 0) - assertEquals(TicketNumber.from(integer), Some(TicketNumber(integer))) - else - assertEquals(TicketNumber.from(integer), None) - } - } - -} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketResolutionTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketResolutionTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketResolutionTest.scala 2025-01-12 05:25:38.807741497 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketResolutionTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,41 +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 de.smederee.tickets.Generators.* - -import munit.* - -import org.scalacheck.* -import org.scalacheck.Prop.* - -final class TicketResolutionTest extends ScalaCheckSuite { - given Arbitrary[TicketResolution] = Arbitrary(genTicketResolution) - - property("valueOf must work for all known instances") { - forAll { (status: TicketResolution) => - assertEquals(TicketResolution.valueOf(status.toString), status) - } - } - - property("fromString must work for all known instances") { - forAll { (status: TicketResolution) => - assertEquals(TicketResolution.fromString(status.toString), Option(status)) - } - } -} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketStatusTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketStatusTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketStatusTest.scala 2025-01-12 05:25:38.811741504 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketStatusTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,41 +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 de.smederee.tickets.Generators.* - -import munit.* - -import org.scalacheck.* -import org.scalacheck.Prop.* - -final class TicketStatusTest extends ScalaCheckSuite { - given Arbitrary[TicketStatus] = Arbitrary(genTicketStatus) - - property("valueOf must work for all known instances") { - forAll { (status: TicketStatus) => - assertEquals(TicketStatus.valueOf(status.toString), status) - } - } - - property("fromString must work for all known instances") { - forAll { (status: TicketStatus) => - assertEquals(TicketStatus.fromString(status.toString), Option(status)) - } - } -} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketTitleTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketTitleTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketTitleTest.scala 2025-01-12 05:25:38.811741504 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketTitleTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,36 +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 munit.* - -import org.scalacheck.* -import org.scalacheck.Prop.* - -final class TicketTitleTest extends ScalaCheckSuite { - - property("TicketTitle.from must only accept valid input") { - forAll { (input: String) => - if (input.nonEmpty && input.length <= TicketTitle.MaxLength) - assertEquals(TicketTitle.from(input), Some(TicketTitle(input))) - else - assertEquals(TicketTitle.from(input), None) - } - } - -} diff -rN -u old-smederee/README.md new-smederee/README.md --- old-smederee/README.md 2025-01-12 05:25:38.799741485 +0000 +++ new-smederee/README.md 2025-01-12 05:25:38.811741504 +0000 @@ -91,12 +91,16 @@ ### Creating the configuration file ### -The easiest way is to simply concatenate the both default configurations for -the hub and ticket module: +The easiest way is to use the provided sample configuration file: ``` -% cat modules/hub/src/main/resources/reference.conf \ - modules/tickets/src/main/resources/reference.conf > application.conf +% cat application.conf.sample > application.conf +``` + +Another option is to copy the default configuration: + +``` +% cat modules/hub/src/main/resources/reference.conf > application.conf ``` ### Important configuration settings ### @@ -106,18 +110,20 @@ values. Secondly take a look at the `external` configuration settings for _both_ -services and configure them accordingly to be able to reach them depending -on your setup. We assume that you will be running them behind a reverse -proxy which also does take care of encryption (https). Depending on your -setup you can either decide to run both endpoints under one domain or on +hub _and_ tickets and configure them accordingly to be able to reach them +depending on your setup. We assume that you will be running them behind a +reverse proxy which also does take care of encryption (https). Depending on +your setup you can either decide to run both endpoints under one domain or on different subdomains e.g. hub on "example.com" and tickets on "tickets.example.com". +The default case is that the tickets simply use the configuration from the hub +section which means you only have to care for the setup in the hub section. -The hub service has a `ticket-integration` section into which you have to +The hub section has a `ticket-integration` section into which you have to enter the base uri under which the ticket endpoints are reachable. The -ticket service has a `hub-integration` section with the analogue settings. +ticket section has a `hub-integration` section with the analogue settings. -Both services have a `cookie-secret` setting that MUST be set to a randomly +The hub section has a `cookie-secret` setting that MUST be set to a randomly generated sequence of at least 64 alphanumeric characters. Next you might want to check the pre-configured paths and make adjustments