~jan0sch/smederee

Showing details for patch 97c550e848eb1f6ccfb9e2643bfe40ee290e3826.
2023-06-26 (Mon), 5:04 PM - Jens Grassel - 97c550e848eb1f6ccfb9e2643bfe40ee290e3826

Add simple ticket search capabilities for end users.

Enable end users to filter tickets by fields provided by the `TicketFilter`
class. This does not yet provide a "real" search for text contained within a
ticket title or description (_read_ full text search).

- add serialisation and deserialisation for TicketFilter via `toQueryParameter`
  and `fromQueryParameter` functions
- add helper functions `fromString` to `TicketStatus` and `TicketResolution`
- add tests
- change submitter field in `TicketFilter` to contain only submitter names
    - change related code in `DoobieTicketRepository`
- add search input field on show tickets page
- add links containing example search queries for open and resolved tickets
Summary of changes
3 files added
  • modules/tickets/src/test/scala/de/smederee/tickets/TicketFilterTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/TicketResolutionTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/TicketStatusTest.scala
7 files modified with 179 lines added and 15 lines removed
  • modules/hub/src/main/resources/messages.properties with 9 added and 2 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala with 5 added and 4 removed lines
  • modules/hub/src/main/twirl/de/smederee/tickets/views/showTickets.scala.html with 20 added and 0 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala with 1 added and 1 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala with 127 added and 2 removed lines
  • modules/tickets/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala with 1 added and 1 removed lines
  • modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala with 16 added and 5 removed lines
diff -rN -u old-smederee/modules/hub/src/main/resources/messages.properties new-smederee/modules/hub/src/main/resources/messages.properties
--- old-smederee/modules/hub/src/main/resources/messages.properties	2025-01-15 23:43:43.406836721 +0000
+++ new-smederee/modules/hub/src/main/resources/messages.properties	2025-01-15 23:43:43.410836725 +0000
@@ -330,9 +330,16 @@
 project.tickets.add.title=Create a new ticket for {0}.
 project.tickets.edit.link=Edit ticket
 project.tickets.edit.title=Edit the ticket {0}.
-project.tickets.view.title=Tickets
-project.tickets.list.empty=There are no tickets defined for this project.
+project.tickets.list.empty=No tickets were found.
 project.tickets.list.title={0} tickets found.
+project.tickets.query=Search tickets
+project.tickets.query.examples=Example queries:
+project.tickets.query.help=Enter a query to search for tickets e.g. "status: Resolved resolution: Duplicate"
+project.tickets.query.open=Open tickets
+project.tickets.query.open.help=Use a query that will show only open tickets.
+project.tickets.query.resolved=Resolved tickets
+project.tickets.query.resolved.help=Use a query that will show only resolved tickets.
+project.tickets.view.title=Tickets
 
 ticket.assigned=Assigned to
 ticket.reported=Reported by
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-15 23:43:43.410836725 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala	2025-01-15 23:43:43.410836725 +0000
@@ -204,6 +204,7 @@
                 csrf,
                 linkToHubService,
                 tickets,
+                filter,
                 projectBaseUri,
                 "Manage your project tickets.".some,
                 user,
@@ -528,11 +529,11 @@
   private val showTicketsPage: AuthedRoutes[Account, F] = AuthedRoutes.of {
     case ar @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter(
           projectName
-        ) / "tickets" as user =>
+        ) / "tickets" :? TicketFilter.OptionalUrlParameter(maybeFilter) as user =>
       val response =
         for {
           csrf <- Sync[F].delay(ar.req.getCsrfToken)
-          resp <- doShowTickets(None)(csrf)(user.some)(projectOwnerName)(projectName)
+          resp <- doShowTickets(maybeFilter)(csrf)(user.some)(projectOwnerName)(projectName)
         } yield resp
       response.recoverWith { error =>
         log.error("Internal Server Error", error)
@@ -547,10 +548,10 @@
   private val showTicketsForGuests: HttpRoutes[F] = HttpRoutes.of {
     case req @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter(
           projectName
-        ) / "tickets" =>
+        ) / "tickets" :? TicketFilter.OptionalUrlParameter(maybeFilter) =>
       for {
         csrf <- Sync[F].delay(req.getCsrfToken)
-        resp <- doShowTickets(None)(csrf)(None)(projectOwnerName)(projectName)
+        resp <- doShowTickets(maybeFilter)(csrf)(None)(projectOwnerName)(projectName)
       } yield resp
   }
 
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTickets.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTickets.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTickets.scala.html	2025-01-15 23:43:43.410836725 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTickets.scala.html	2025-01-15 23:43:43.410836725 +0000
@@ -8,6 +8,7 @@
   csrf: Option[CsrfToken] = None,
   linkToHubService: Uri,
   tickets: List[Ticket],
+  ticketFilter: Option[TicketFilter],
   projectBaseUri: Uri,
   title: Option[String] = None,
   user: Option[Account],
@@ -37,6 +38,25 @@
       </div>
     </div>
   </div>
+  <div class="pure-g">
+    <div class="pure-u-1-1 pure-u-md-1-1">
+      <div class="l-box">
+        <form action="@{projectBaseUri.addSegment("tickets")}" class="pure-form" method="GET" accept-charset="UTF-8">
+          <fieldset>
+            <div class="pure-control-group">
+              <input aria-label="@Messages("project.tickets.query.help")" class="pure-input-2-3" id="q" name="q" maxlength="128" placeholder="@Messages("project.tickets.query.help")" type="text" value="@{ticketFilter.map(_.toQueryParameter)}">
+              <button type="submit" class="pure-button">@Messages("project.tickets.query")</button>
+            </div>
+          </fieldset>
+        </form>
+        <div class="pure-form-message">
+          <span>@Messages("project.tickets.query.examples")</span>
+          <a href="@{projectBaseUri.addSegment("tickets").withQueryParam("q", TicketFilter.OpenTicketsOnly)}" title="@Messages("project.tickets.query.open.help")">@Messages("project.tickets.query.open")</a>
+          <a href="@{projectBaseUri.addSegment("tickets").withQueryParam("q", TicketFilter.ResolvedTicketsOnly)}" title="@Messages("project.tickets.query.resolved.help")">@Messages("project.tickets.query.resolved")</a>
+        </div>
+      </div>
+    </div>
+  </div>
   <div class="pure-g">
     <div class="pure-u-1-1 pure-u-md-1-1">
       <div class="l-box">
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-15 23:43:43.410836725 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala	2025-01-15 23:43:43.410836725 +0000
@@ -137,7 +137,7 @@
         val resolutionFilter =
           filter.resolution.toNel.map(resolutions => Fragments.in(fr""""tickets".resolution""", resolutions))
         val submitterFilter =
-          filter.submitter.toNel.map(submitters => Fragments.in(fr""""tickets".submitter""", submitters.map(_.id)))
+          filter.submitter.toNel.map(submitters => Fragments.in(fr""""submitters".name""", submitters))
         selectTicketColumns ++ whereAndOpt(
           projectFilter.some,
           numberFilter,
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-15 23:43:43.410836725 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala	2025-01-15 23:43:43.410836725 +0000
@@ -21,6 +21,8 @@
 
 import cats._
 import cats.syntax.all._
+import org.http4s.{ QueryParamDecoder, QueryParamEncoder }
+import org.http4s.dsl.impl.OptionalQueryParamDecoderMatcher
 
 import scala.util.matching.Regex
 
@@ -166,6 +168,16 @@
 
 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.
@@ -207,6 +219,16 @@
 
 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.
@@ -276,16 +298,25 @@
   * @param resolution
   *   A list of ticket resolution kinds that must be matched.
   * @param submitter
-  *   A list of submitters from whom the ticket must have been submitted.
+  *   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[Submitter]
+    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,
@@ -296,4 +327,98 @@
   // 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/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-15 23:43:43.410836725 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala	2025-01-15 23:43:43.410836725 +0000
@@ -406,7 +406,7 @@
         val submitters       = tickets.map(_.submitter).flatten
         val wantedSubmitters = submitters.take(submitters.size / 2)
         val expectedTickets  = tickets.filter(t => t.submitter.exists(s => wantedSubmitters.exists(_ === s)))
-        val filter           = TicketFilter(Nil, Nil, Nil, submitter = wantedSubmitters)
+        val filter           = TicketFilter(Nil, Nil, Nil, submitter = wantedSubmitters.map(_.name))
         val project          = generatedProject.copy(owner = owner)
         val dbConfig         = configuration.database
         val tx = Transactor.fromDriverManager[IO](
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-15 23:43:43.410836725 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala	2025-01-15 23:43:43.410836725 +0000
@@ -106,11 +106,14 @@
 
   val genTicketContent: Gen[Option[TicketContent]] = Gen.alphaStr.map(TicketContent.from)
 
-  val genTicketStatus: Gen[TicketStatus] = Gen.oneOf(TicketStatus.values.toList)
+  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 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 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)
@@ -147,6 +150,14 @@
   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
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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketFilterTest.scala	2025-01-15 23:43:43.410836725 +0000
@@ -0,0 +1,122 @@
+/*
+ * 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/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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketResolutionTest.scala	2025-01-15 23:43:43.410836725 +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 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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketStatusTest.scala	2025-01-15 23:43:43.410836725 +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 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))
+    }
+  }
+}