~jan0sch/smederee
Showing details for patch 43f5137c62426c5ab616c5616669e9795c9c24e1.
diff -rN -u old-smederee/modules/hub/src/main/resources/assets/css/main.css new-smederee/modules/hub/src/main/resources/assets/css/main.css --- old-smederee/modules/hub/src/main/resources/assets/css/main.css 2025-01-15 05:53:54.394587504 +0000 +++ new-smederee/modules/hub/src/main/resources/assets/css/main.css 2025-01-15 05:53:54.398587511 +0000 @@ -216,6 +216,15 @@ vertical-align: middle; } +.milestone-closed { + color: var(--nord3); + text-decoration: line-through; +} + +.milestone-closed a { + color: var(--nord3); +} + .milestone-description { margin: auto; overflow: overlay; 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 05:53:54.394587504 +0000 +++ new-smederee/modules/hub/src/main/resources/messages.properties 2025-01-15 05:53:54.398587511 +0000 @@ -292,6 +292,8 @@ form.label.name.help=A label name can be up to 40 characters long, must not be empty and must be unique in the scope of a project. form.label.name.placeholder=label name form.label.name=Name +form.milestone.close.button.submit=Close +form.milestone.close.button.submit.help=Mark this milestone as closed e.g. completed or obsolete. form.milestone.create.button.submit=Create milestone form.milestone.delete.button.submit=Delete form.milestone.delete.i-am-sure=Yes, I'm sure! @@ -301,6 +303,8 @@ form.milestone.due-date.help=You may pick an optional date to indicate until when the milestone is planned to be reached. form.milestone.due-date=Due date form.milestone.edit.button.submit=Save milestone +form.milestone.open.button.submit=Open +form.milestone.open.button.submit.help=Re-open this milestone. form.milestone.title.help=A milestone title can be up to 64 characters long, must not be empty and must be unique in the scope of a project. form.milestone.title.placeholder=milestone title form.milestone.title=Title @@ -336,6 +340,7 @@ project.menu.tickets=Tickets project.milestone.edit.link=Edit project.milestone.edit.title=Edit milestone ''{0}''. +project.milestone.status.tickets={0} tickets closed from {1} total. project.milestone.title=Milestone {0} project.milestones.add.title=Add a new milestone. project.milestones.edit.title=Edit project milestones. 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-15 05:53:54.394587504 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala 2025-01-15 05:53:54.398587511 +0000 @@ -248,7 +248,13 @@ ) case Validated.Valid(milestoneData) => val milestone = - Milestone(None, milestoneData.title, milestoneData.description, milestoneData.dueDate) + Milestone( + None, + milestoneData.title, + milestoneData.description, + milestoneData.dueDate, + closed = false + ) for { checkDuplicate <- milestoneRepo.findMilestone(projectId)(milestoneData.title) resp <- checkDuplicate match { @@ -286,6 +292,71 @@ } } + private val closeMilestone: AuthedRoutes[Account, F] = AuthedRoutes.of { + case ar @ POST -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter( + projectName + ) / "milestones" / MilestoneTitlePathParameter(milestoneTitle) / "close" as user => + ar.req.decodeStrict[F, UrlForm] { urlForm => + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + projectAndId <- loadProject(user.some)(projectOwnerName, projectName) + resp <- projectAndId match { + case Some(project, projectId) => + for { + _ <- Sync[F].raiseUnless(project.owner.uid === ProjectOwnerId.fromUserId(user.uid))( + new Error("Only maintainers may close milestones!") + ) + milestone <- milestoneRepo.findMilestone(projectId)(milestoneTitle) + resp <- milestone match { + case Some(milestone) => + for { + formData <- Sync[F].delay { + urlForm.values.map { t => + val (key, values) = t + ( + key, + values.headOption.getOrElse("") + ) // Pick the first value (a field might get submitted multiple times)! + } + } + projectBaseUri <- Sync[F].delay( + linkConfig.createFullUri( + Uri(path = + Uri.Path( + Vector( + Uri.Path.Segment(s"~$projectOwnerName"), + Uri.Path.Segment(projectName.toString) + ) + ) + ) + ) + ) + milestoneIdMatches <- Sync[F].delay( + formData + .get(MilestoneForm.fieldId) + .flatMap(MilestoneId.fromString) + .exists(id => milestone.id.exists(_ === id)) + ) + milestoneTitleMatches <- Sync[F].delay( + formData.get(MilestoneForm.fieldTitle).flatMap(MilestoneTitle.from).exists(_ === milestoneTitle) + ) + resp <- (milestoneIdMatches && milestoneTitleMatches) match { + case false => BadRequest("Invalid form data!") + case true => + milestone.id.traverse(milestoneRepo.closeMilestone) *> SeeOther( + Location(projectBaseUri.addSegment("milestones").addSegment(milestone.title.toString)) + ) + } + } yield resp + case _ => NotFound("Milestone not found!") + } + } yield resp + case _ => NotFound("Repository not found!") + } + } yield resp + } + } + private val deleteMilestone: AuthedRoutes[Account, F] = AuthedRoutes.of { case ar @ POST -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter( projectName @@ -457,6 +528,71 @@ } } + private val openMilestone: AuthedRoutes[Account, F] = AuthedRoutes.of { + case ar @ POST -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter( + projectName + ) / "milestones" / MilestoneTitlePathParameter(milestoneTitle) / "open" as user => + ar.req.decodeStrict[F, UrlForm] { urlForm => + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + projectAndId <- loadProject(user.some)(projectOwnerName, projectName) + resp <- projectAndId match { + case Some(project, projectId) => + for { + _ <- Sync[F].raiseUnless(project.owner.uid === ProjectOwnerId.fromUserId(user.uid))( + new Error("Only maintainers may open milestones!") + ) + milestone <- milestoneRepo.findMilestone(projectId)(milestoneTitle) + resp <- milestone match { + case Some(milestone) => + for { + formData <- Sync[F].delay { + urlForm.values.map { t => + val (key, values) = t + ( + key, + values.headOption.getOrElse("") + ) // Pick the first value (a field might get submitted multiple times)! + } + } + projectBaseUri <- Sync[F].delay( + linkConfig.createFullUri( + Uri(path = + Uri.Path( + Vector( + Uri.Path.Segment(s"~$projectOwnerName"), + Uri.Path.Segment(projectName.toString) + ) + ) + ) + ) + ) + milestoneIdMatches <- Sync[F].delay( + formData + .get(MilestoneForm.fieldId) + .flatMap(MilestoneId.fromString) + .exists(id => milestone.id.exists(_ === id)) + ) + milestoneTitleMatches <- Sync[F].delay( + formData.get(MilestoneForm.fieldTitle).flatMap(MilestoneTitle.from).exists(_ === milestoneTitle) + ) + resp <- (milestoneIdMatches && milestoneTitleMatches) match { + case false => BadRequest("Invalid form data!") + case true => + milestone.id.traverse(milestoneRepo.openMilestone) *> SeeOther( + Location(projectBaseUri.addSegment("milestones").addSegment(milestone.title.toString)) + ) + } + } yield resp + case _ => NotFound("Milestone not found!") + } + } yield resp + case _ => NotFound("Repository not found!") + } + } yield resp + } + } + private val showEditMilestoneForm: AuthedRoutes[Account, F] = AuthedRoutes.of { case ar @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter( projectName @@ -552,7 +688,7 @@ } val protectedRoutes = - addMilestone <+> deleteMilestone <+> editMilestone <+> showEditMilestoneForm <+> showEditMilestonesPage <+> showMilestone + addMilestone <+> closeMilestone <+> deleteMilestone <+> editMilestone <+> openMilestone <+> showEditMilestoneForm <+> showEditMilestonesPage <+> showMilestone val routes = showMilestoneForGuests <+> showMilestonesForGuests diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestone.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestone.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestone.scala.html 2025-01-15 05:53:54.394587504 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestone.scala.html 2025-01-15 05:53:54.398587511 +0000 @@ -55,19 +55,19 @@ <input type="hidden" id="@fieldId" name="@fieldId" readonly="" value="@milestone.id"> <div class="pure-control-group"> <label for="@{fieldTitle}">@Messages("form.milestone.title")</label> - <input class="pure-input-3-4" id="@{fieldTitle}" name="@{fieldTitle}" maxlength="40" placeholder="@Messages("form.milestone.title.placeholder")" required="" type="text" value="@{formData.get(fieldTitle).headOption}"> + <input class="pure-input-3-4" id="@{fieldTitle}" name="@{fieldTitle}" maxlength="40" placeholder="@Messages("form.milestone.title.placeholder")" required="" type="text" value="@{formData(fieldTitle).headOption}"> <span class="pure-form-message" id="@{fieldTitle}.help">@Messages("form.milestone.title.help")</span> @renderFormErrors(fieldTitle, formErrors) </div> <div class="pure-control-group"> <label for="@{fieldDescription}">@Messages("form.milestone.description")</label> - <textarea class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" placeholder="@Messages("form.milestone.description.placeholder")" rows="4" value="@{formData.get(fieldDescription).headOption}"></textarea> + <textarea class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" placeholder="@Messages("form.milestone.description.placeholder")" rows="4" value="@{formData(fieldDescription).headOption}"></textarea> <span class="pure-form-message" id="@{fieldDescription}.help">@Messages("form.milestone.description.help")</span> @renderFormErrors(fieldDescription, formErrors) </div> <div class="pure-control-group"> <label for="@{fieldDueDate}">@Messages("form.milestone.due-date")</label> - <input id="@{fieldDueDate}" name="@{fieldDueDate}" type="date" value="@{formData.get(fieldDueDate).headOption}"> + <input id="@{fieldDueDate}" name="@{fieldDueDate}" type="date" value="@{formData(fieldDueDate).headOption}"> <span class="pure-form-message" id="@{fieldDueDate}.help">@Messages("form.milestone.due-date.help")</span> @renderFormErrors(fieldDueDate, formErrors) </div> diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestones.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestones.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestones.scala.html 2025-01-15 05:53:54.394587504 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestones.scala.html 2025-01-15 05:53:54.398587511 +0000 @@ -99,7 +99,7 @@ <div class="pure-u-1-24 milestone-icon"> @icon(baseUri)("flag", lineHeight.some) </div> - <div class="pure-u-5-24 milestone-title" style="height: @{lineHeight}px; line-height: @{lineHeight}px;"><a href="@projectBaseUri.addSegment("milestones").addSegment(milestone.title.toString)">@milestone.title @for(dueDate <- milestone.dueDate) { @formatDate(dueDate) }</a></div> + <div class="pure-u-5-24 milestone-title @if(milestone.closed){milestone-closed}else{}" style="height: @{lineHeight}px; line-height: @{lineHeight}px;"><a href="@projectBaseUri.addSegment("milestones").addSegment(milestone.title.toString)">@milestone.title @for(dueDate <- milestone.dueDate) { @formatDate(dueDate) }</a></div> <div class="pure-u-8-24 milestone-description" style="height: @{lineHeight}px; line-height: @{lineHeight}px;"> @for(description <- milestone.description) { @if(description.toString.length < 180) { @description } else { @description.toString.take(176)) ...} diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showMilestone.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showMilestone.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showMilestone.scala.html 2025-01-15 05:53:54.394587504 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showMilestone.scala.html 2025-01-15 05:53:54.398587511 +0000 @@ -39,11 +39,37 @@ <div class="l-box"> @if(user.nonEmpty) { <div class="milestone-buttons"> + @if(milestone.closed) { + <form action="@{linkToHubService.addPath(projectBaseUri.path.toString).addSegment("milestones").addSegment(milestone.title.toString).addSegment("open")}" accept-charset="UTF-8" class="pure-form" method="POST" style="display: inline-flex;"> + <fieldset> + <input type="hidden" id="@fieldId-@milestone.title" name="@fieldId" readonly="" value="@milestone.id"> + <input type="hidden" id="@fieldTitle-@milestone.title" name="@fieldTitle" readonly="" value="@milestone.title"> + @csrfToken(csrf) + <button type="submit" class="pure-button" title="@Messages("form.milestone.open.button.submit.help")">@Messages("form.milestone.open.button.submit")</button> + </fieldset> + </form> + } else { + <form action="@{linkToHubService.addPath(projectBaseUri.path.toString).addSegment("milestones").addSegment(milestone.title.toString).addSegment("close")}" accept-charset="UTF-8" class="pure-form" method="POST" style="display: inline-flex;"> + <fieldset> + <input type="hidden" id="@fieldId-@milestone.title" name="@fieldId" readonly="" value="@milestone.id"> + <input type="hidden" id="@fieldTitle-@milestone.title" name="@fieldTitle" readonly="" value="@milestone.title"> + @csrfToken(csrf) + <button type="submit" class="pure-button" title="@Messages("form.milestone.close.button.submit.help")">@Messages("form.milestone.close.button.submit")</button> + </fieldset> + </form> + } <a class="pure-button" href="@projectBaseUri.addSegment("milestones").addSegment(milestone.title.toString).addSegment("edit")" title="@Messages("project.milestone.edit.title", milestone.title)">@Messages("project.milestone.edit.link")</a> </div> } else {} - <h1>@milestone.title</h1> + <h1@if(milestone.closed){ class="milestone-closed"}else{}>@milestone.title</h1> <div class="milestone-description">@Html(renderedMilestoneDescription)</div> + <div class="milestone-status"> + @if(tickets.nonEmpty) { + @defining(tickets.filter(_.status =!= TicketStatus.Resolved).size) { openTickets => + @Messages("project.milestone.status.tickets", (tickets.size - openTickets), tickets.size) + } + } else {} + </div> </div> </div> <div class="pure-u-8-24 pure-u-md-8-24"> 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 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V5__add_closeable_milestones.sql 2025-01-15 05:53:54.398587511 +0000 @@ -0,0 +1,5 @@ +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/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-15 05:53:54.394587504 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala 2025-01-15 05:53:54.398587511 +0000 @@ -68,7 +68,7 @@ ON "tickets".submitter = "submitters".uid""" override def allMilestones(projectId: ProjectId): Stream[F, Milestone] = - sql"""SELECT id, title, description, due_date FROM "tickets"."milestones" WHERE project = $projectId ORDER BY due_date ASC, title ASC""" + 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) @@ -96,19 +96,24 @@ 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 + description, + closed ) VALUES ( $projectId, ${milestone.title}, ${milestone.dueDate}, - ${milestone.description} + ${milestone.description}, + ${milestone.closed} )""".update.run.transact(tx) override def deleteMilestone(milestone: Milestone): F[Int] = @@ -118,11 +123,14 @@ } override def findMilestone(projectId: ProjectId)(title: MilestoneTitle): F[Option[Milestone]] = - sql"""SELECT id, title, description, due_date FROM "tickets"."milestones" WHERE project = $projectId AND title = $title LIMIT 1""" + 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) 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 05:53:54.394587504 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala 2025-01-15 05:53:54.398587511 +0000 @@ -222,7 +222,8 @@ "milestones".id AS id, "milestones".title AS title, "milestones".description AS description, - "milestones".due_date AS due_date + "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" 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-15 05:53:54.394587504 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/MilestoneRepository.scala 2025-01-15 05:53:54.398587511 +0000 @@ -46,6 +46,15 @@ */ 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 @@ -77,6 +86,15 @@ */ 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 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-15 05:53:54.394587504 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Milestone.scala 2025-01-15 05:53:54.398587511 +0000 @@ -144,12 +144,15 @@ * 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] + dueDate: Option[LocalDate], + closed: Boolean ) object Milestone { 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-15 05:53:54.394587504 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala 2025-01-15 05:53:54.398587511 +0000 @@ -150,6 +150,45 @@ } } + 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)) => @@ -272,6 +311,45 @@ } 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)) { 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 05:53:54.394587504 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala 2025-01-15 05:53:54.398587511 +0000 @@ -213,11 +213,12 @@ val genMilestone: Gen[Milestone] = for { - id <- Gen.option(Gen.choose(0L, Long.MaxValue).map(MilestoneId.apply)) - title <- genMilestoneTitle - due <- Gen.option(genLocalDate) - descr <- Gen.option(genMilestoneDescription) - } yield Milestone(id, title, descr, due) + 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)