diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..0194542 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +features \ No newline at end of file diff --git a/.idea/copilot.data.migration.agent.xml b/.idea/copilot.data.migration.agent.xml new file mode 100644 index 0000000..4ea72a9 --- /dev/null +++ b/.idea/copilot.data.migration.agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask.xml b/.idea/copilot.data.migration.ask.xml new file mode 100644 index 0000000..7ef04e2 --- /dev/null +++ b/.idea/copilot.data.migration.ask.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml new file mode 100644 index 0000000..1f2ea11 --- /dev/null +++ b/.idea/copilot.data.migration.ask2agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.edit.xml b/.idea/copilot.data.migration.edit.xml new file mode 100644 index 0000000..8648f94 --- /dev/null +++ b/.idea/copilot.data.migration.edit.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..b131b8d --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..e31cf3f --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 4a80115..19aec56 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +[![Review Assignment Due Date](https://classroom.github.com/assets/deadline-readme-button-22041afd0340ce965d47ae6ef1cefeee28c7c493a6346c4f15d667ab976d596c.svg)](https://classroom.github.com/a/TvkQWWs6) # Features of modern Java # Цели и задачи л/р: diff --git a/build.gradle.kts b/build.gradle.kts index 79bf52a..1d6eb67 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,10 +1,17 @@ plugins { id("java") + id("application") } group = "org.lab" version = "1.0-SNAPSHOT" +java { + toolchain { + languageVersion = JavaLanguageVersion.of(26) + } +} + repositories { mavenCentral() } @@ -15,6 +22,20 @@ dependencies { testRuntimeOnly("org.junit.platform:junit-platform-launcher") } +tasks.withType().configureEach { + options.compilerArgs.add("--enable-preview") + options.release = 26 +} + tasks.test { useJUnitPlatform() + jvmArgs("--enable-preview") +} + +application { + mainClass = "org.lab.Main" +} + +tasks.withType().configureEach { + jvmArgs("--enable-preview") } \ No newline at end of file diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/org/lab/IO.java b/src/main/java/org/lab/IO.java new file mode 100644 index 0000000..899c6f7 --- /dev/null +++ b/src/main/java/org/lab/IO.java @@ -0,0 +1,13 @@ +package org.lab; + +public final class IO { + private IO() {} + + public static void println(Object o) { + System.out.println(o); + } +} + + + + diff --git a/src/main/java/org/lab/Main.java b/src/main/java/org/lab/Main.java index 22028ef..5ece792 100644 --- a/src/main/java/org/lab/Main.java +++ b/src/main/java/org/lab/Main.java @@ -1,4 +1,116 @@ -void main() { - IO.println("Hello and welcome!"); +package org.lab; + +import org.lab.domain.CommandResult; +import org.lab.domain.DomainException; +import org.lab.domain.Ticket; +import org.lab.repo.BugRepo; +import org.lab.repo.MilestoneRepo; +import org.lab.repo.ProjectRepo; +import org.lab.repo.TicketRepo; +import org.lab.repo.UserRepo; +import org.lab.service.AuthService; +import org.lab.service.BugService; +import org.lab.service.Context; +import org.lab.service.DashboardService; +import org.lab.service.MilestoneService; +import org.lab.service.ProjectService; +import org.lab.service.TicketService; + +import java.time.LocalDate; +import java.util.stream.Gatherers; + +public class Main { + public static void main(String[] args) { + var userRepo = new UserRepo(); + var projectRepo = new ProjectRepo(); + var milestoneRepo = new MilestoneRepo(); + var ticketRepo = new TicketRepo(); + var bugRepo = new BugRepo(); + + + var authService = new AuthService(userRepo); + var projectService = new ProjectService(projectRepo); + var milestoneService = new MilestoneService(projectRepo, milestoneRepo, ticketRepo); + var ticketService = new TicketService(projectRepo, ticketRepo, milestoneService); + var bugService = new BugService(projectRepo, bugRepo); + var dashboardService = new DashboardService(projectService, ticketService, bugService); + + + var manager = authService.register("manager"); + var teamlead = authService.register("teamlead"); + var dev = authService.register("dev"); + var tester = authService.register("tester"); + + + var project = projectService.createProject(manager.id(), "Modern Java Project"); + var assignLead = projectService.assignTeamLead(manager.id(), project.id(), teamlead.id()); + + + // Pattern matching for switch + var assignLeadMsg = switch (assignLead) { + case CommandResult.Ok(var updatedProject) -> "teamlead=" + updatedProject.teamLeadId(); + case CommandResult.Error(var message) -> "error=" + message; + }; + IO.println(assignLeadMsg); + // IO + + projectService.addDeveloper(manager.id(), project.id(), dev.id()); + projectService.addTester(manager.id(), project.id(), tester.id()); + + var milestone = milestoneService.createMilestone( + manager.id(), + project.id(), + "M1", + LocalDate.now(), + LocalDate.now().plusDays(14) + ); + milestoneService.activateMilestone(manager.id(), milestone.id()); + + var ticket = ticketService.createTicket(teamlead.id(), project.id(), milestone.id(), "Implement ticket flow"); + ticketService.assignDeveloper(teamlead.id(), ticket.id(), dev.id()); + ticketService.startWork(dev.id(), ticket.id()); + ticketService.complete(dev.id(), ticket.id()); + + var bug = bugService.createBug(tester.id(), project.id(), "NullPointerException in report"); + bugService.assignFixer(teamlead.id(), bug.id(), dev.id()); + bugService.markFixed(dev.id(), bug.id()); + bugService.markTested(tester.id(), bug.id()); + bugService.close(manager.id(), bug.id()); + + try { + // Scoped Values + ScopedValue.where(Context.CURRENT_USER_ID, dev.id()).run(() -> { + var overview = dashboardService.overviewStructured(Context.CURRENT_USER_ID.get()); + + + // Stream Gatherers + var windows = overview.assignedTickets().stream() + .map(Ticket::title) + .gather(Gatherers.windowFixed(2)) + .toList(); + + // Pattern Matching for instanceof + if (windows instanceof java.util.List list) { + var report = """ + user=%s + projects=%d + assignedTickets=%d + bugsToFix=%d + ticketTitleWindows=%s + """.formatted( + dev.name(), + overview.projects().size(), + overview.assignedTickets().size(), + overview.bugsToFix().size(), + list + ); + // Text Blocks + IO.println(report); + } + }); + } catch (DomainException e) { + IO.println("Domain error: " + e.getMessage()); + } + } } diff --git a/src/main/java/org/lab/domain/BugReport.java b/src/main/java/org/lab/domain/BugReport.java new file mode 100644 index 0000000..54dcae8 --- /dev/null +++ b/src/main/java/org/lab/domain/BugReport.java @@ -0,0 +1,18 @@ +package org.lab.domain; + +import java.util.UUID; + +public record BugReport( + UUID id, + UUID projectId, + String title, + BugStatus status, + UUID reporterId, + UUID fixerId +) { + // Records +} + + + + diff --git a/src/main/java/org/lab/domain/BugStatus.java b/src/main/java/org/lab/domain/BugStatus.java new file mode 100644 index 0000000..0bf3579 --- /dev/null +++ b/src/main/java/org/lab/domain/BugStatus.java @@ -0,0 +1,12 @@ +package org.lab.domain; + +public enum BugStatus { + NEW, + FIXED, + TESTED, + CLOSED +} + + + + diff --git a/src/main/java/org/lab/domain/CommandResult.java b/src/main/java/org/lab/domain/CommandResult.java new file mode 100644 index 0000000..00af8d7 --- /dev/null +++ b/src/main/java/org/lab/domain/CommandResult.java @@ -0,0 +1,16 @@ +package org.lab.domain; + +public sealed interface CommandResult permits CommandResult.Ok, CommandResult.Error { + // Sealed Classes + + record Ok(T value) implements CommandResult { + // Records + } + + record Error(String message) implements CommandResult { + // Records + } +} + + + diff --git a/src/main/java/org/lab/domain/DomainException.java b/src/main/java/org/lab/domain/DomainException.java new file mode 100644 index 0000000..cb48a16 --- /dev/null +++ b/src/main/java/org/lab/domain/DomainException.java @@ -0,0 +1,15 @@ +package org.lab.domain; + +public final class DomainException extends RuntimeException { + public DomainException(String message) { + // Flexible Constructor Bodies + if (message == null || message.isBlank()) { + throw new IllegalArgumentException("message is blank"); + } + super(message); + } +} + + + + diff --git a/src/main/java/org/lab/domain/Milestone.java b/src/main/java/org/lab/domain/Milestone.java new file mode 100644 index 0000000..404bc97 --- /dev/null +++ b/src/main/java/org/lab/domain/Milestone.java @@ -0,0 +1,21 @@ +package org.lab.domain; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +public record Milestone( + UUID id, + UUID projectId, + String name, + LocalDate startDate, + LocalDate endDate, + MilestoneStatus status, + List ticketIds +) { + // Records +} + + + + diff --git a/src/main/java/org/lab/domain/MilestoneStatus.java b/src/main/java/org/lab/domain/MilestoneStatus.java new file mode 100644 index 0000000..2de9d08 --- /dev/null +++ b/src/main/java/org/lab/domain/MilestoneStatus.java @@ -0,0 +1,11 @@ +package org.lab.domain; + +public enum MilestoneStatus { + OPEN, + ACTIVE, + CLOSED +} + + + + diff --git a/src/main/java/org/lab/domain/Project.java b/src/main/java/org/lab/domain/Project.java new file mode 100644 index 0000000..053e20e --- /dev/null +++ b/src/main/java/org/lab/domain/Project.java @@ -0,0 +1,34 @@ +package org.lab.domain; + +import java.util.Set; +import java.util.UUID; + +public record Project( + ProjectId id, + String name, + UUID managerId, + UUID teamLeadId, + Set developerIds, + Set testerIds, + UUID activeMilestoneId +) { + // Records + + public ProjectRole roleFor(UUID userId) { + // Pattern Matching for switch + return switch (userId) { + case UUID id when id.equals(managerId) -> ProjectRole.MANAGER; + case UUID id when teamLeadId != null && id.equals(teamLeadId) -> ProjectRole.TEAMLEAD; + case UUID id when developerIds.contains(id) -> ProjectRole.DEVELOPER; + case UUID id when testerIds.contains(id) -> ProjectRole.TESTER; + default -> null; + }; + } + + public boolean isParticipant(UUID userId) { + return roleFor(userId) != null; + } +} + + + diff --git a/src/main/java/org/lab/domain/ProjectId.java b/src/main/java/org/lab/domain/ProjectId.java new file mode 100644 index 0000000..d638909 --- /dev/null +++ b/src/main/java/org/lab/domain/ProjectId.java @@ -0,0 +1,24 @@ +package org.lab.domain; + +import java.util.UUID; + +public value class ProjectId { + // Valhalla: Value Classes + + private final UUID value; + + public ProjectId(UUID value) { + this.value = value; + } + + public static ProjectId random() { + return new ProjectId(UUID.randomUUID()); + } + + public UUID value() { + return value; + } +} + + + diff --git a/src/main/java/org/lab/domain/ProjectRole.java b/src/main/java/org/lab/domain/ProjectRole.java new file mode 100644 index 0000000..f53addb --- /dev/null +++ b/src/main/java/org/lab/domain/ProjectRole.java @@ -0,0 +1,12 @@ +package org.lab.domain; + +public enum ProjectRole { + MANAGER, + TEAMLEAD, + DEVELOPER, + TESTER +} + + + + diff --git a/src/main/java/org/lab/domain/Ticket.java b/src/main/java/org/lab/domain/Ticket.java new file mode 100644 index 0000000..f88e64c --- /dev/null +++ b/src/main/java/org/lab/domain/Ticket.java @@ -0,0 +1,18 @@ +package org.lab.domain; + +import java.util.UUID; + +public record Ticket( + UUID id, + UUID projectId, + UUID milestoneId, + String title, + TicketStatus status, + UUID assigneeId +) { + // Records +} + + + + diff --git a/src/main/java/org/lab/domain/TicketStatus.java b/src/main/java/org/lab/domain/TicketStatus.java new file mode 100644 index 0000000..89b113a --- /dev/null +++ b/src/main/java/org/lab/domain/TicketStatus.java @@ -0,0 +1,12 @@ +package org.lab.domain; + +public enum TicketStatus { + NEW, + ACCEPTED, + IN_PROGRESS, + DONE +} + + + + diff --git a/src/main/java/org/lab/domain/User.java b/src/main/java/org/lab/domain/User.java new file mode 100644 index 0000000..f34d6c1 --- /dev/null +++ b/src/main/java/org/lab/domain/User.java @@ -0,0 +1,11 @@ +package org.lab.domain; + +import java.util.UUID; + +public record User(UUID id, String name) { + // Records +} + + + + diff --git a/src/main/java/org/lab/repo/BugRepo.java b/src/main/java/org/lab/repo/BugRepo.java new file mode 100644 index 0000000..ce415bc --- /dev/null +++ b/src/main/java/org/lab/repo/BugRepo.java @@ -0,0 +1,29 @@ +package org.lab.repo; + +import org.lab.domain.BugReport; + +import java.util.Collection; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public final class BugRepo { + private final ConcurrentHashMap bugsById = new ConcurrentHashMap<>(); + + public BugReport save(BugReport bug) { + bugsById.put(bug.id(), bug); + return bug; + } + + public Optional findById(UUID id) { + return Optional.ofNullable(bugsById.get(id)); + } + + public Collection findAll() { + return bugsById.values(); + } +} + + + + diff --git a/src/main/java/org/lab/repo/MilestoneRepo.java b/src/main/java/org/lab/repo/MilestoneRepo.java new file mode 100644 index 0000000..e3f9624 --- /dev/null +++ b/src/main/java/org/lab/repo/MilestoneRepo.java @@ -0,0 +1,29 @@ +package org.lab.repo; + +import org.lab.domain.Milestone; + +import java.util.Collection; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public final class MilestoneRepo { + private final ConcurrentHashMap milestonesById = new ConcurrentHashMap<>(); + + public Milestone save(Milestone milestone) { + milestonesById.put(milestone.id(), milestone); + return milestone; + } + + public Optional findById(UUID id) { + return Optional.ofNullable(milestonesById.get(id)); + } + + public Collection findAll() { + return milestonesById.values(); + } +} + + + + diff --git a/src/main/java/org/lab/repo/ProjectRepo.java b/src/main/java/org/lab/repo/ProjectRepo.java new file mode 100644 index 0000000..407bf00 --- /dev/null +++ b/src/main/java/org/lab/repo/ProjectRepo.java @@ -0,0 +1,28 @@ +package org.lab.repo; + +import org.lab.domain.Project; +import org.lab.domain.ProjectId; + +import java.util.Collection; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +public final class ProjectRepo { + private final ConcurrentHashMap projectsById = new ConcurrentHashMap<>(); + + public Project save(Project project) { + projectsById.put(project.id(), project); + return project; + } + + public Optional findById(ProjectId id) { + return Optional.ofNullable(projectsById.get(id)); + } + + public Collection findAll() { + return projectsById.values(); + } +} + + + diff --git a/src/main/java/org/lab/repo/TicketRepo.java b/src/main/java/org/lab/repo/TicketRepo.java new file mode 100644 index 0000000..7dfcbe0 --- /dev/null +++ b/src/main/java/org/lab/repo/TicketRepo.java @@ -0,0 +1,29 @@ +package org.lab.repo; + +import org.lab.domain.Ticket; + +import java.util.Collection; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public final class TicketRepo { + private final ConcurrentHashMap ticketsById = new ConcurrentHashMap<>(); + + public Ticket save(Ticket ticket) { + ticketsById.put(ticket.id(), ticket); + return ticket; + } + + public Optional findById(UUID id) { + return Optional.ofNullable(ticketsById.get(id)); + } + + public Collection findAll() { + return ticketsById.values(); + } +} + + + + diff --git a/src/main/java/org/lab/repo/UserRepo.java b/src/main/java/org/lab/repo/UserRepo.java new file mode 100644 index 0000000..251434b --- /dev/null +++ b/src/main/java/org/lab/repo/UserRepo.java @@ -0,0 +1,29 @@ +package org.lab.repo; + +import org.lab.domain.User; + +import java.util.Collection; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public final class UserRepo { + private final ConcurrentHashMap usersById = new ConcurrentHashMap<>(); + + public User save(User user) { + usersById.put(user.id(), user); + return user; + } + + public Optional findById(UUID id) { + return Optional.ofNullable(usersById.get(id)); + } + + public Collection findAll() { + return usersById.values(); + } +} + + + + diff --git a/src/main/java/org/lab/service/AuthService.java b/src/main/java/org/lab/service/AuthService.java new file mode 100644 index 0000000..4449ab6 --- /dev/null +++ b/src/main/java/org/lab/service/AuthService.java @@ -0,0 +1,24 @@ +package org.lab.service; + +import org.lab.domain.User; +import org.lab.repo.UserRepo; + +import java.util.UUID; + +public final class AuthService { + private final UserRepo userRepo; + + public AuthService(UserRepo userRepo) { + this.userRepo = userRepo; + } + + public User register(String name) { + var user = new User(UUID.randomUUID(), name); + + return userRepo.save(user); + } +} + + + + diff --git a/src/main/java/org/lab/service/BugService.java b/src/main/java/org/lab/service/BugService.java new file mode 100644 index 0000000..8167289 --- /dev/null +++ b/src/main/java/org/lab/service/BugService.java @@ -0,0 +1,137 @@ +package org.lab.service; + +import org.lab.domain.BugReport; +import org.lab.domain.BugStatus; +import org.lab.domain.DomainException; +import org.lab.domain.ProjectId; +import org.lab.domain.ProjectRole; +import org.lab.repo.BugRepo; +import org.lab.repo.ProjectRepo; + +import java.util.List; +import java.util.UUID; + +public final class BugService { + private final ProjectRepo projectRepo; + private final BugRepo bugRepo; + + public BugService(ProjectRepo projectRepo, BugRepo bugRepo) { + this.projectRepo = projectRepo; + this.bugRepo = bugRepo; + } + + public BugReport createBug(UUID actorId, ProjectId projectId, String title) { + var project = projectRepo.findById(projectId).orElseThrow(() -> new DomainException("Project not found")); + + var role = project.roleFor(actorId); + + if (role != ProjectRole.DEVELOPER && role != ProjectRole.TESTER) { + throw new DomainException("Only developer/tester can create bug report"); + } + var bug = new BugReport(UUID.randomUUID(), projectId.value(), title, BugStatus.NEW, actorId, null); + + return bugRepo.save(bug); + } + + public BugReport assignFixer(UUID actorId, UUID bugId, UUID developerId) { + var bug = requireBug(bugId); + + var project = projectRepo.findById(new ProjectId(bug.projectId())).orElseThrow(() -> new DomainException("Project not found")); + + var role = project.roleFor(actorId); + + if (role != ProjectRole.MANAGER && role != ProjectRole.TEAMLEAD) { + throw new DomainException("Only manager/teamlead can assign fixer"); + } + if (!project.developerIds().contains(developerId)) { + throw new DomainException("Fixer must be a developer in this project"); + } + if (bug.status() != BugStatus.NEW) { + throw new DomainException("Bug is not NEW"); + } + return bugRepo.save(new BugReport( + bug.id(), + bug.projectId(), + bug.title(), + bug.status(), + bug.reporterId(), + developerId + )); + } + + public BugReport markFixed(UUID actorId, UUID bugId) { + var bug = requireBug(bugId); + + if (bug.fixerId() == null || !bug.fixerId().equals(actorId)) { + throw new DomainException("Only fixer can mark bug fixed"); + } + // Pattern Matching for switch + return switch (bug.status()) { + case NEW -> bugRepo.save(new BugReport( + bug.id(), + bug.projectId(), + bug.title(), + BugStatus.FIXED, + bug.reporterId(), + bug.fixerId() + )); + case FIXED, TESTED, CLOSED -> throw new DomainException("Cannot fix from status " + bug.status()); + }; + } + + public BugReport markTested(UUID actorId, UUID bugId) { + var bug = requireBug(bugId); + + var project = projectRepo.findById(new ProjectId(bug.projectId())).orElseThrow(() -> new DomainException("Project not found")); + + if (project.roleFor(actorId) != ProjectRole.TESTER) { + throw new DomainException("Only tester can mark bug tested"); + } + if (bug.status() != BugStatus.FIXED) { + throw new DomainException("Bug must be FIXED to be TESTED"); + } + return bugRepo.save(new BugReport( + bug.id(), + bug.projectId(), + bug.title(), + BugStatus.TESTED, + bug.reporterId(), + bug.fixerId() + )); + } + + public BugReport close(UUID actorId, UUID bugId) { + var bug = requireBug(bugId); + + var project = projectRepo.findById(new ProjectId(bug.projectId())).orElseThrow(() -> new DomainException("Project not found")); + + if (project.roleFor(actorId) != ProjectRole.MANAGER) { + throw new DomainException("Only manager can close bug"); + } + if (bug.status() != BugStatus.TESTED) { + throw new DomainException("Bug must be TESTED to be CLOSED"); + } + return bugRepo.save(new BugReport( + bug.id(), + bug.projectId(), + bug.title(), + BugStatus.CLOSED, + bug.reporterId(), + bug.fixerId() + )); + } + + public List bugsToFix(UUID userId) { + // Streams + return bugRepo.findAll().stream() + .filter(b -> b.fixerId() != null && b.fixerId().equals(userId)) + .filter(b -> b.status() != BugStatus.CLOSED) + .toList(); + } + + public BugReport requireBug(UUID id) { + return bugRepo.findById(id).orElseThrow(() -> new DomainException("Bug not found")); + } +} + + diff --git a/src/main/java/org/lab/service/Context.java b/src/main/java/org/lab/service/Context.java new file mode 100644 index 0000000..ffd452f --- /dev/null +++ b/src/main/java/org/lab/service/Context.java @@ -0,0 +1,14 @@ +package org.lab.service; + +import java.util.UUID; + +public final class Context { + private Context() {} + + public static final ScopedValue CURRENT_USER_ID = ScopedValue.newInstance(); + // Scoped Values +} + + + + diff --git a/src/main/java/org/lab/service/DashboardService.java b/src/main/java/org/lab/service/DashboardService.java new file mode 100644 index 0000000..16090c2 --- /dev/null +++ b/src/main/java/org/lab/service/DashboardService.java @@ -0,0 +1,42 @@ +package org.lab.service; + +import org.lab.domain.DomainException; +import org.lab.domain.BugReport; +import org.lab.domain.Project; +import org.lab.domain.Ticket; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.StructuredTaskScope; +import java.util.concurrent.Future; +import java.util.List; +import java.util.UUID; + +public final class DashboardService { + private final ProjectService projectService; + private final TicketService ticketService; + private final BugService bugService; + + public DashboardService(ProjectService projectService, TicketService ticketService, BugService bugService) { + this.projectService = projectService; + this.ticketService = ticketService; + this.bugService = bugService; + } + + public UserOverview overviewStructured(UUID userId) { + // Structured Concurrency + try (var scope = StructuredTaskScope.open()) { + var projects = scope.fork(() -> projectService.projectsForUser(userId)); + var tickets = scope.fork(() -> ticketService.ticketsForAssignee(userId)); + var bugs = scope.fork(() -> bugService.bugsToFix(userId)); + scope.join(); + return new UserOverview(projects.get(), tickets.get(), bugs.get()); + } catch (InterruptedException _) { + // Unnamed Variables and Patterns + Thread.currentThread().interrupt(); + throw new DomainException("Interrupted"); + } + } +} + + diff --git a/src/main/java/org/lab/service/MilestoneService.java b/src/main/java/org/lab/service/MilestoneService.java new file mode 100644 index 0000000..209f7cb --- /dev/null +++ b/src/main/java/org/lab/service/MilestoneService.java @@ -0,0 +1,137 @@ +package org.lab.service; + +import org.lab.domain.DomainException; +import org.lab.domain.Milestone; +import org.lab.domain.MilestoneStatus; +import org.lab.domain.ProjectId; +import org.lab.domain.ProjectRole; +import org.lab.repo.MilestoneRepo; +import org.lab.repo.ProjectRepo; +import org.lab.repo.TicketRepo; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.UUID; + +public final class MilestoneService { + private final ProjectRepo projectRepo; + private final MilestoneRepo milestoneRepo; + private final TicketRepo ticketRepo; + + public MilestoneService(ProjectRepo projectRepo, MilestoneRepo milestoneRepo, TicketRepo ticketRepo) { + this.projectRepo = projectRepo; + this.milestoneRepo = milestoneRepo; + this.ticketRepo = ticketRepo; + } + + public Milestone createMilestone(UUID actorId, ProjectId projectId, String name, LocalDate start, LocalDate end) { + var project = projectRepo.findById(projectId).orElseThrow(() -> new DomainException("Project not found")); + + if (project.roleFor(actorId) != ProjectRole.MANAGER) { + throw new DomainException("Only manager can create milestone"); + } + if (project.activeMilestoneId() != null) { + throw new DomainException("Active milestone already exists"); + } + var milestone = new Milestone(UUID.randomUUID(), projectId.value(), name, start, end, MilestoneStatus.OPEN, new ArrayList<>()); + + return milestoneRepo.save(milestone); + } + + public void activateMilestone(UUID actorId, UUID milestoneId) { + var milestone = requireMilestone(milestoneId); + + var project = projectRepo.findById(new ProjectId(milestone.projectId())).orElseThrow(() -> new DomainException("Project not found")); + + if (project.roleFor(actorId) != ProjectRole.MANAGER) { + throw new DomainException("Only manager can activate milestone"); + } + if (project.activeMilestoneId() != null && !project.activeMilestoneId().equals(milestoneId)) { + throw new DomainException("Another active milestone already exists"); + } + if (milestone.status() == MilestoneStatus.CLOSED) { + throw new DomainException("Milestone already closed"); + } + projectRepo.save(new org.lab.domain.Project( + project.id(), + project.name(), + project.managerId(), + project.teamLeadId(), + project.developerIds(), + project.testerIds(), + milestoneId + )); + milestoneRepo.save(new Milestone( + milestone.id(), + milestone.projectId(), + milestone.name(), + milestone.startDate(), + milestone.endDate(), + MilestoneStatus.ACTIVE, + milestone.ticketIds() + )); + } + + public Milestone closeMilestone(UUID actorId, UUID milestoneId) { + var milestone = requireMilestone(milestoneId); + + var project = projectRepo.findById(new ProjectId(milestone.projectId())).orElseThrow(() -> new DomainException("Project not found")); + + if (project.roleFor(actorId) != ProjectRole.MANAGER) { + throw new DomainException("Only manager can close milestone"); + } + + var allDone = milestone.ticketIds().stream() + .map(id -> ticketRepo.findById(id).orElseThrow(() -> new DomainException("Ticket not found"))) + .allMatch(t -> t.status() == org.lab.domain.TicketStatus.DONE); + // Streams + + if (!allDone) { + throw new DomainException("Cannot close milestone with unfinished tickets"); + } + + projectRepo.save(new org.lab.domain.Project( + project.id(), + project.name(), + project.managerId(), + project.teamLeadId(), + project.developerIds(), + project.testerIds(), + null + )); + + return milestoneRepo.save(new Milestone( + milestone.id(), + milestone.projectId(), + milestone.name(), + milestone.startDate(), + milestone.endDate(), + MilestoneStatus.CLOSED, + milestone.ticketIds() + )); + } + + public void addTicket(UUID milestoneId, UUID ticketId) { + var milestone = requireMilestone(milestoneId); + + var updated = new ArrayList<>(milestone.ticketIds()); + + updated.add(ticketId); + milestoneRepo.save(new Milestone( + milestone.id(), + milestone.projectId(), + milestone.name(), + milestone.startDate(), + milestone.endDate(), + milestone.status(), + java.util.List.copyOf(updated) + )); + } + + public Milestone requireMilestone(UUID id) { + return milestoneRepo.findById(id).orElseThrow(() -> new DomainException("Milestone not found")); + } +} + + + diff --git a/src/main/java/org/lab/service/ProjectService.java b/src/main/java/org/lab/service/ProjectService.java new file mode 100644 index 0000000..99b3ac0 --- /dev/null +++ b/src/main/java/org/lab/service/ProjectService.java @@ -0,0 +1,107 @@ +package org.lab.service; + +import org.lab.domain.CommandResult; +import org.lab.domain.DomainException; +import org.lab.domain.Project; +import org.lab.domain.ProjectId; +import org.lab.domain.ProjectRole; +import org.lab.repo.ProjectRepo; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +public final class ProjectService { + private final ProjectRepo projectRepo; + + public ProjectService(ProjectRepo projectRepo) { + this.projectRepo = projectRepo; + } + + public Project createProject(UUID managerId, String name) { + var project = new Project( + ProjectId.random(), + name, + managerId, + null, + new HashSet<>(), + new HashSet<>(), + null + ); + + return projectRepo.save(project); + } + + public CommandResult assignTeamLead(UUID actorId, ProjectId projectId, UUID teamLeadId) { + var project = requireProject(projectId); + + if (project.roleFor(actorId) != ProjectRole.MANAGER) { + return new CommandResult.Error<>("Only manager can assign teamlead"); + } + return new CommandResult.Ok<>(projectRepo.save(new Project( + project.id(), + project.name(), + project.managerId(), + teamLeadId, + project.developerIds(), + project.testerIds(), + project.activeMilestoneId() + ))); + } + + public Project addDeveloper(UUID actorId, ProjectId projectId, UUID developerId) { + var project = requireProject(projectId); + + if (project.roleFor(actorId) != ProjectRole.MANAGER) { + throw new DomainException("Only manager can add developer"); + } + var updatedDevs = new HashSet<>(project.developerIds()); + + updatedDevs.add(developerId); + return projectRepo.save(new Project( + project.id(), + project.name(), + project.managerId(), + project.teamLeadId(), + Set.copyOf(updatedDevs), + project.testerIds(), + project.activeMilestoneId() + )); + } + + public Project addTester(UUID actorId, ProjectId projectId, UUID testerId) { + var project = requireProject(projectId); + + if (project.roleFor(actorId) != ProjectRole.MANAGER) { + throw new DomainException("Only manager can add tester"); + } + var updatedTesters = new HashSet<>(project.testerIds()); + + updatedTesters.add(testerId); + return projectRepo.save(new Project( + project.id(), + project.name(), + project.managerId(), + project.teamLeadId(), + project.developerIds(), + Set.copyOf(updatedTesters), + project.activeMilestoneId() + )); + } + + public List projectsForUser(UUID userId) { + // Streams + return projectRepo.findAll().stream() + .filter(p -> p.isParticipant(userId) || p.managerId().equals(userId)) + .toList(); + } + + public Project requireProject(ProjectId projectId) { + return projectRepo.findById(projectId) + .orElseThrow(() -> new DomainException("Project not found")); + } +} + + + diff --git a/src/main/java/org/lab/service/TicketService.java b/src/main/java/org/lab/service/TicketService.java new file mode 100644 index 0000000..d99a987 --- /dev/null +++ b/src/main/java/org/lab/service/TicketService.java @@ -0,0 +1,117 @@ +package org.lab.service; + +import org.lab.domain.DomainException; +import org.lab.domain.ProjectRole; +import org.lab.domain.Ticket; +import org.lab.domain.TicketStatus; +import org.lab.domain.ProjectId; +import org.lab.repo.ProjectRepo; +import org.lab.repo.TicketRepo; + +import java.util.List; +import java.util.UUID; + +public final class TicketService { + private final ProjectRepo projectRepo; + private final TicketRepo ticketRepo; + private final MilestoneService milestoneService; + + public TicketService(ProjectRepo projectRepo, TicketRepo ticketRepo, MilestoneService milestoneService) { + this.projectRepo = projectRepo; + this.ticketRepo = ticketRepo; + this.milestoneService = milestoneService; + } + + public Ticket createTicket(UUID actorId, ProjectId projectId, UUID milestoneId, String title) { + var project = projectRepo.findById(projectId).orElseThrow(() -> new DomainException("Project not found")); + + var role = project.roleFor(actorId); + + if (role != ProjectRole.MANAGER && role != ProjectRole.TEAMLEAD) { + throw new DomainException("Only manager/teamlead can create ticket"); + } + var ticket = new Ticket(UUID.randomUUID(), projectId.value(), milestoneId, title, TicketStatus.NEW, null); + + ticketRepo.save(ticket); + milestoneService.addTicket(milestoneId, ticket.id()); + return ticket; + } + + public Ticket assignDeveloper(UUID actorId, UUID ticketId, UUID developerId) { + var ticket = requireTicket(ticketId); + + var project = projectRepo.findById(new ProjectId(ticket.projectId())).orElseThrow(() -> new DomainException("Project not found")); + + var role = project.roleFor(actorId); + + if (role != ProjectRole.MANAGER && role != ProjectRole.TEAMLEAD) { + throw new DomainException("Only manager/teamlead can assign developer"); + } + if (!project.developerIds().contains(developerId)) { + throw new DomainException("Assignee is not a developer in this project"); + } + return ticketRepo.save(new Ticket( + ticket.id(), + ticket.projectId(), + ticket.milestoneId(), + ticket.title(), + TicketStatus.ACCEPTED, + developerId + )); + } + + public Ticket startWork(UUID actorId, UUID ticketId) { + var ticket = requireTicket(ticketId); + + if (ticket.assigneeId() == null || !ticket.assigneeId().equals(actorId)) { + throw new DomainException("Only assignee can start work"); + } + // Pattern Matching for switch + return switch (ticket.status()) { + case NEW -> throw new DomainException("Ticket must be assigned before starting"); + case ACCEPTED -> ticketRepo.save(new Ticket( + ticket.id(), + ticket.projectId(), + ticket.milestoneId(), + ticket.title(), + TicketStatus.IN_PROGRESS, + ticket.assigneeId() + )); + case IN_PROGRESS, DONE -> throw new DomainException("Cannot start from status " + ticket.status()); + }; + } + + public Ticket complete(UUID actorId, UUID ticketId) { + var ticket = requireTicket(ticketId); + + if (ticket.assigneeId() == null || !ticket.assigneeId().equals(actorId)) { + throw new DomainException("Only assignee can complete ticket"); + } + // Pattern Matching for switch + return switch (ticket.status()) { + case IN_PROGRESS -> ticketRepo.save(new Ticket( + ticket.id(), + ticket.projectId(), + ticket.milestoneId(), + ticket.title(), + TicketStatus.DONE, + ticket.assigneeId() + )); + case NEW, ACCEPTED, DONE -> throw new DomainException("Cannot complete from status " + ticket.status()); + }; + } + + public List ticketsForAssignee(UUID userId) { + // Streams + return ticketRepo.findAll().stream() + .filter(t -> t.assigneeId() != null && t.assigneeId().equals(userId)) + .toList(); + } + + public Ticket requireTicket(UUID id) { + return ticketRepo.findById(id).orElseThrow(() -> new DomainException("Ticket not found")); + } +} + + + diff --git a/src/main/java/org/lab/service/UserOverview.java b/src/main/java/org/lab/service/UserOverview.java new file mode 100644 index 0000000..b328f4d --- /dev/null +++ b/src/main/java/org/lab/service/UserOverview.java @@ -0,0 +1,19 @@ +package org.lab.service; + +import org.lab.domain.BugReport; +import org.lab.domain.Project; +import org.lab.domain.Ticket; + +import java.util.List; + +public record UserOverview( + List projects, + List assignedTickets, + List bugsToFix +) { + // Records +} + + + + diff --git a/src/test/java/org/lab/service/BugServiceTest.java b/src/test/java/org/lab/service/BugServiceTest.java new file mode 100644 index 0000000..24c9449 --- /dev/null +++ b/src/test/java/org/lab/service/BugServiceTest.java @@ -0,0 +1,74 @@ +package org.lab.service; + +import org.junit.jupiter.api.Test; +import org.lab.domain.BugStatus; +import org.lab.domain.DomainException; +import org.lab.repo.BugRepo; +import org.lab.repo.ProjectRepo; + +import static org.junit.jupiter.api.Assertions.*; + +class BugServiceTest { + @Test + void bugHappyPath() { + var projectRepo = new ProjectRepo(); + var bugRepo = new BugRepo(); + + var projectService = new ProjectService(projectRepo); + var bugService = new BugService(projectRepo, bugRepo); + + var manager = java.util.UUID.randomUUID(); + var lead = java.util.UUID.randomUUID(); + var dev = java.util.UUID.randomUUID(); + var tester = java.util.UUID.randomUUID(); + + var project = projectService.createProject(manager, "P"); + projectService.assignTeamLead(manager, project.id(), lead); + projectService.addDeveloper(manager, project.id(), dev); + projectService.addTester(manager, project.id(), tester); + + var bug = bugService.createBug(tester, project.id(), "B"); + assertEquals(BugStatus.NEW, bug.status()); + + var assigned = bugService.assignFixer(lead, bug.id(), dev); + assertEquals(dev, assigned.fixerId()); + + var fixed = bugService.markFixed(dev, bug.id()); + assertEquals(BugStatus.FIXED, fixed.status()); + + var tested = bugService.markTested(tester, bug.id()); + assertEquals(BugStatus.TESTED, tested.status()); + + var closed = bugService.close(manager, bug.id()); + assertEquals(BugStatus.CLOSED, closed.status()); + } + + @Test + void onlyTesterCanMarkTested() { + var projectRepo = new ProjectRepo(); + var bugRepo = new BugRepo(); + + var projectService = new ProjectService(projectRepo); + var bugService = new BugService(projectRepo, bugRepo); + + var manager = java.util.UUID.randomUUID(); + var lead = java.util.UUID.randomUUID(); + var dev = java.util.UUID.randomUUID(); + var tester = java.util.UUID.randomUUID(); + + var project = projectService.createProject(manager, "P"); + projectService.assignTeamLead(manager, project.id(), lead); + projectService.addDeveloper(manager, project.id(), dev); + projectService.addTester(manager, project.id(), tester); + + var bug = bugService.createBug(tester, project.id(), "B"); + bugService.assignFixer(lead, bug.id(), dev); + bugService.markFixed(dev, bug.id()); + + assertThrows(DomainException.class, () -> bugService.markTested(dev, bug.id())); + } +} + + + + diff --git a/src/test/java/org/lab/service/DashboardServiceTest.java b/src/test/java/org/lab/service/DashboardServiceTest.java new file mode 100644 index 0000000..37fac9c --- /dev/null +++ b/src/test/java/org/lab/service/DashboardServiceTest.java @@ -0,0 +1,50 @@ +package org.lab.service; + +import org.junit.jupiter.api.Test; +import org.lab.repo.BugRepo; +import org.lab.repo.MilestoneRepo; +import org.lab.repo.ProjectRepo; +import org.lab.repo.TicketRepo; + +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.*; + +class DashboardServiceTest { + @Test + void overviewContainsAssignedTickets() { + var projectRepo = new ProjectRepo(); + var milestoneRepo = new MilestoneRepo(); + var ticketRepo = new TicketRepo(); + var bugRepo = new BugRepo(); + + var projectService = new ProjectService(projectRepo); + var milestoneService = new MilestoneService(projectRepo, milestoneRepo, ticketRepo); + var ticketService = new TicketService(projectRepo, ticketRepo, milestoneService); + var bugService = new BugService(projectRepo, bugRepo); + var dashboard = new DashboardService(projectService, ticketService, bugService); + + var manager = java.util.UUID.randomUUID(); + var lead = java.util.UUID.randomUUID(); + var dev = java.util.UUID.randomUUID(); + var tester = java.util.UUID.randomUUID(); + + var project = projectService.createProject(manager, "P"); + projectService.assignTeamLead(manager, project.id(), lead); + projectService.addDeveloper(manager, project.id(), dev); + projectService.addTester(manager, project.id(), tester); + + var ms = milestoneService.createMilestone(manager, project.id(), "M", LocalDate.now(), LocalDate.now().plusDays(1)); + milestoneService.activateMilestone(manager, ms.id()); + + var ticket = ticketService.createTicket(lead, project.id(), ms.id(), "T"); + ticketService.assignDeveloper(lead, ticket.id(), dev); + + var overview = dashboard.overviewStructured(dev); + assertEquals(1, overview.assignedTickets().size()); + assertEquals(ticket.id(), overview.assignedTickets().getFirst().id()); + } +} + + + diff --git a/src/test/java/org/lab/service/MilestoneServiceTest.java b/src/test/java/org/lab/service/MilestoneServiceTest.java new file mode 100644 index 0000000..65e882c --- /dev/null +++ b/src/test/java/org/lab/service/MilestoneServiceTest.java @@ -0,0 +1,75 @@ +package org.lab.service; + +import org.junit.jupiter.api.Test; +import org.lab.domain.DomainException; +import org.lab.domain.MilestoneStatus; +import org.lab.repo.MilestoneRepo; +import org.lab.repo.ProjectRepo; +import org.lab.repo.TicketRepo; + +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.*; + +class MilestoneServiceTest { + @Test + void cannotCloseMilestoneWithUnfinishedTickets() { + var projectRepo = new ProjectRepo(); + var milestoneRepo = new MilestoneRepo(); + var ticketRepo = new TicketRepo(); + + var projectService = new ProjectService(projectRepo); + var milestoneService = new MilestoneService(projectRepo, milestoneRepo, ticketRepo); + var ticketService = new TicketService(projectRepo, ticketRepo, milestoneService); + + var manager = java.util.UUID.randomUUID(); + var lead = java.util.UUID.randomUUID(); + var dev = java.util.UUID.randomUUID(); + + var project = projectService.createProject(manager, "P"); + projectService.assignTeamLead(manager, project.id(), lead); + projectService.addDeveloper(manager, project.id(), dev); + + var ms = milestoneService.createMilestone(manager, project.id(), "M", LocalDate.now(), LocalDate.now().plusDays(1)); + milestoneService.activateMilestone(manager, ms.id()); + + var ticket = ticketService.createTicket(lead, project.id(), ms.id(), "T"); + ticketService.assignDeveloper(lead, ticket.id(), dev); + + assertThrows(DomainException.class, () -> milestoneService.closeMilestone(manager, ms.id())); + } + + @Test + void milestoneClosesWhenAllTicketsDone() { + var projectRepo = new ProjectRepo(); + var milestoneRepo = new MilestoneRepo(); + var ticketRepo = new TicketRepo(); + + var projectService = new ProjectService(projectRepo); + var milestoneService = new MilestoneService(projectRepo, milestoneRepo, ticketRepo); + var ticketService = new TicketService(projectRepo, ticketRepo, milestoneService); + + var manager = java.util.UUID.randomUUID(); + var lead = java.util.UUID.randomUUID(); + var dev = java.util.UUID.randomUUID(); + + var project = projectService.createProject(manager, "P"); + projectService.assignTeamLead(manager, project.id(), lead); + projectService.addDeveloper(manager, project.id(), dev); + + var ms = milestoneService.createMilestone(manager, project.id(), "M", LocalDate.now(), LocalDate.now().plusDays(1)); + milestoneService.activateMilestone(manager, ms.id()); + + var ticket = ticketService.createTicket(lead, project.id(), ms.id(), "T"); + ticketService.assignDeveloper(lead, ticket.id(), dev); + ticketService.startWork(dev, ticket.id()); + ticketService.complete(dev, ticket.id()); + + var closed = milestoneService.closeMilestone(manager, ms.id()); + assertEquals(MilestoneStatus.CLOSED, closed.status()); + } +} + + + + diff --git a/src/test/java/org/lab/service/ProjectServiceTest.java b/src/test/java/org/lab/service/ProjectServiceTest.java new file mode 100644 index 0000000..f7e1c5a --- /dev/null +++ b/src/test/java/org/lab/service/ProjectServiceTest.java @@ -0,0 +1,50 @@ +package org.lab.service; + +import org.junit.jupiter.api.Test; +import org.lab.domain.CommandResult; +import org.lab.domain.ProjectRole; +import org.lab.repo.ProjectRepo; + +import static org.junit.jupiter.api.Assertions.*; + +class ProjectServiceTest { + @Test + void managerCanAssignTeamlead() { + var projectRepo = new ProjectRepo(); + var projectService = new ProjectService(projectRepo); + + var managerId = java.util.UUID.randomUUID(); + var teamleadId = java.util.UUID.randomUUID(); + + var project = projectService.createProject(managerId, "P1"); + var res = projectService.assignTeamLead(managerId, project.id(), teamleadId); + + var msg = switch (res) { + case CommandResult.Ok(var updated) -> updated.teamLeadId().toString(); + case CommandResult.Error(var m) -> m; + }; + assertEquals(teamleadId.toString(), msg); + + assertEquals(ProjectRole.MANAGER, projectService.requireProject(project.id()).roleFor(managerId)); + assertEquals(ProjectRole.TEAMLEAD, projectService.requireProject(project.id()).roleFor(teamleadId)); + } + + @Test + void nonManagerCannotAssignTeamlead() { + var projectRepo = new ProjectRepo(); + var projectService = new ProjectService(projectRepo); + + var managerId = java.util.UUID.randomUUID(); + var actorId = java.util.UUID.randomUUID(); + var teamleadId = java.util.UUID.randomUUID(); + + var project = projectService.createProject(managerId, "P1"); + var res = projectService.assignTeamLead(actorId, project.id(), teamleadId); + + assertTrue(res instanceof CommandResult.Error); + } +} + + + + diff --git a/src/test/java/org/lab/service/TicketServiceTest.java b/src/test/java/org/lab/service/TicketServiceTest.java new file mode 100644 index 0000000..47d9898 --- /dev/null +++ b/src/test/java/org/lab/service/TicketServiceTest.java @@ -0,0 +1,78 @@ +package org.lab.service; + +import org.junit.jupiter.api.Test; +import org.lab.domain.DomainException; +import org.lab.domain.TicketStatus; +import org.lab.repo.MilestoneRepo; +import org.lab.repo.ProjectRepo; +import org.lab.repo.TicketRepo; + +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.*; + +class TicketServiceTest { + @Test + void ticketHappyPath() { + var projectRepo = new ProjectRepo(); + var milestoneRepo = new MilestoneRepo(); + var ticketRepo = new TicketRepo(); + + var projectService = new ProjectService(projectRepo); + var milestoneService = new MilestoneService(projectRepo, milestoneRepo, ticketRepo); + var ticketService = new TicketService(projectRepo, ticketRepo, milestoneService); + + var manager = java.util.UUID.randomUUID(); + var lead = java.util.UUID.randomUUID(); + var dev = java.util.UUID.randomUUID(); + + var project = projectService.createProject(manager, "P"); + projectService.assignTeamLead(manager, project.id(), lead); + projectService.addDeveloper(manager, project.id(), dev); + + var ms = milestoneService.createMilestone(manager, project.id(), "M", LocalDate.now(), LocalDate.now().plusDays(1)); + milestoneService.activateMilestone(manager, ms.id()); + + var ticket = ticketService.createTicket(lead, project.id(), ms.id(), "T"); + var assigned = ticketService.assignDeveloper(lead, ticket.id(), dev); + assertEquals(TicketStatus.ACCEPTED, assigned.status()); + + var started = ticketService.startWork(dev, ticket.id()); + assertEquals(TicketStatus.IN_PROGRESS, started.status()); + + var done = ticketService.complete(dev, ticket.id()); + assertEquals(TicketStatus.DONE, done.status()); + } + + @Test + void onlyAssigneeCanStart() { + var projectRepo = new ProjectRepo(); + var milestoneRepo = new MilestoneRepo(); + var ticketRepo = new TicketRepo(); + + var projectService = new ProjectService(projectRepo); + var milestoneService = new MilestoneService(projectRepo, milestoneRepo, ticketRepo); + var ticketService = new TicketService(projectRepo, ticketRepo, milestoneService); + + var manager = java.util.UUID.randomUUID(); + var lead = java.util.UUID.randomUUID(); + var dev = java.util.UUID.randomUUID(); + var other = java.util.UUID.randomUUID(); + + var project = projectService.createProject(manager, "P"); + projectService.assignTeamLead(manager, project.id(), lead); + projectService.addDeveloper(manager, project.id(), dev); + + var ms = milestoneService.createMilestone(manager, project.id(), "M", LocalDate.now(), LocalDate.now().plusDays(1)); + milestoneService.activateMilestone(manager, ms.id()); + + var ticket = ticketService.createTicket(lead, project.id(), ms.id(), "T"); + ticketService.assignDeveloper(lead, ticket.id(), dev); + + assertThrows(DomainException.class, () -> ticketService.startWork(other, ticket.id())); + } +} + + + +