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 @@
+[](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()));
+ }
+}
+
+
+
+