diff --git a/.gitignore b/.gitignore index b63da45..da20993 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ build/ !**/src/test/**/build/ ### IntelliJ IDEA ### +.idea .idea/modules.xml .idea/jarRepositories.xml .idea/compiler.xml @@ -39,4 +40,4 @@ bin/ .vscode/ ### Mac OS ### -.DS_Store \ No newline at end of file +.DS_Store diff --git a/build.gradle.kts b/build.gradle.kts index 79bf52a..9e43dd7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,6 @@ plugins { id("java") + id("io.freefair.lombok") version "9.1.0" } group = "org.lab" @@ -9,7 +10,14 @@ repositories { mavenCentral() } +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(25)) + } +} + dependencies { + implementation("org.jetbrains:annotations:26.0.2") testImplementation(platform("org.junit:junit-bom:5.10.0")) testImplementation("org.junit.jupiter:junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher") @@ -17,4 +25,4 @@ dependencies { tasks.test { useJUnitPlatform() -} \ 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/auth/AuthRepository.java b/src/main/java/org/lab/auth/AuthRepository.java new file mode 100644 index 0000000..b9c7b59 --- /dev/null +++ b/src/main/java/org/lab/auth/AuthRepository.java @@ -0,0 +1,22 @@ +package org.lab.auth; + +import org.lab.auth.model.AccessBinding; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface AuthRepository { + AccessBinding save(AccessBinding accessBinding); + + Optional findByUserIdAndProjectId(UUID userId, UUID projectId); + + List findAll(); + + void deleteByUserIdAndProjectId(UUID userId, UUID projectId); + + default void delete(AccessBinding binding) { + deleteByUserIdAndProjectId(binding.userId(), binding.projectId()); + } +} + diff --git a/src/main/java/org/lab/auth/AuthService.java b/src/main/java/org/lab/auth/AuthService.java new file mode 100644 index 0000000..da5f077 --- /dev/null +++ b/src/main/java/org/lab/auth/AuthService.java @@ -0,0 +1,21 @@ +package org.lab.auth; + +import org.lab.auth.model.AccessBinding; +import org.lab.auth.model.Permission; +import org.lab.auth.model.Role; + +import java.util.List; +import java.util.UUID; +import java.util.function.Supplier; + +public interface AuthService { + void checkPermission(UUID projectId, Permission permission); + + void addBinding(UUID userId, UUID projectId, Role role); + + void removeBinding(UUID userId, UUID projectId, Role role); + + List findAllByUserId(UUID userId); + + void removeAllByProjectIdAndRole(UUID projectId, Role role); +} diff --git a/src/main/java/org/lab/auth/AuthServiceImpl.java b/src/main/java/org/lab/auth/AuthServiceImpl.java new file mode 100644 index 0000000..406b52c --- /dev/null +++ b/src/main/java/org/lab/auth/AuthServiceImpl.java @@ -0,0 +1,62 @@ +package org.lab.auth; + +import org.lab.auth.model.AccessBinding; +import org.lab.auth.model.Permission; +import org.lab.auth.model.Role; + +import java.util.List; +import java.util.UUID; +import java.util.function.Supplier; + +public class AuthServiceImpl implements AuthService { + private final AuthRepository authRepository; + + public AuthServiceImpl(AuthRepository authRepository) { + this.authRepository = authRepository; + } + + @Override + public void checkPermission(UUID projectId, Permission permission) { + var userId = AuthenticationContext.get(); + if (!hasPermission(userId, projectId, permission)) { + throw new PermissionDeniedException(userId, projectId, permission); + } + } + + private boolean hasPermission(UUID userId, UUID projectId, Permission permission) { + var binding = authRepository.findByUserIdAndProjectId(userId, projectId) + .orElse(null); + + return binding != null && binding.role().getPermissions().contains(permission.getName()); + } + + @Override + public void addBinding(UUID userId, UUID projectId, Role role) { + authRepository.save(new AccessBinding(userId, projectId, role)); + } + + @Override + public void removeBinding(UUID userId, UUID projectId, Role role) { + var binding = authRepository.findByUserIdAndProjectId(userId, projectId) + .orElse(null); + + if (binding != null && binding.role() == role) { + authRepository.deleteByUserIdAndProjectId(userId, projectId); + } + } + + @Override + public List findAllByUserId(UUID userId) { + return authRepository.findAll().stream() + .filter(binding -> binding.userId().equals(userId)) + .toList(); + } + + @Override + public void removeAllByProjectIdAndRole(UUID projectId, Role role) { + authRepository.findAll().stream() + .filter(binding -> binding.projectId().equals(projectId) && binding.role() == role) + .forEach(authRepository::delete); + } +} + diff --git a/src/main/java/org/lab/auth/AuthenticationContext.java b/src/main/java/org/lab/auth/AuthenticationContext.java new file mode 100644 index 0000000..b8bc4ea --- /dev/null +++ b/src/main/java/org/lab/auth/AuthenticationContext.java @@ -0,0 +1,21 @@ +package org.lab.auth; + +import java.util.UUID; + +public class AuthenticationContext { + + private static final ThreadLocal USER_ID = new ThreadLocal<>(); + + public static UUID get() { + return USER_ID.get(); + } + + public static void set(UUID userId) { + USER_ID.set(userId); + } + + public static void clear() { + USER_ID.remove(); + } + +} diff --git a/src/main/java/org/lab/auth/InMemoryAuthRepository.java b/src/main/java/org/lab/auth/InMemoryAuthRepository.java new file mode 100644 index 0000000..77c5b74 --- /dev/null +++ b/src/main/java/org/lab/auth/InMemoryAuthRepository.java @@ -0,0 +1,40 @@ +package org.lab.auth; + +import org.lab.auth.model.AccessBinding; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public class InMemoryAuthRepository implements AuthRepository { + private final Map storage = new ConcurrentHashMap<>(); + + private String key(UUID userId, UUID projectId) { + return userId + ":" + projectId; + } + + @Override + public AccessBinding save(AccessBinding accessBinding) { + storage.put(key(accessBinding.userId(), accessBinding.projectId()), accessBinding); + return accessBinding; + } + + @Override + public Optional findByUserIdAndProjectId(UUID userId, UUID projectId) { + return Optional.ofNullable(storage.get(key(userId, projectId))); + } + + @Override + public List findAll() { + return new ArrayList<>(storage.values()); + } + + @Override + public void deleteByUserIdAndProjectId(UUID userId, UUID projectId) { + storage.remove(key(userId, projectId)); + } +} + diff --git a/src/main/java/org/lab/auth/PermissionDeniedException.java b/src/main/java/org/lab/auth/PermissionDeniedException.java new file mode 100644 index 0000000..fdf2f8e --- /dev/null +++ b/src/main/java/org/lab/auth/PermissionDeniedException.java @@ -0,0 +1,12 @@ +package org.lab.auth; + +import org.lab.auth.model.Permission; + +import java.util.UUID; + +public class PermissionDeniedException extends RuntimeException { + public PermissionDeniedException(UUID userId, UUID projectId, Permission permission) { + super("Permission denied: userId=" + userId + ", projectId=" + projectId + ", permission=" + permission.getName()); + } +} + diff --git a/src/main/java/org/lab/auth/model/AccessBinding.java b/src/main/java/org/lab/auth/model/AccessBinding.java new file mode 100644 index 0000000..24f5f33 --- /dev/null +++ b/src/main/java/org/lab/auth/model/AccessBinding.java @@ -0,0 +1,11 @@ +package org.lab.auth.model; + +import java.util.UUID; + +public record AccessBinding( + UUID userId, + UUID projectId, + Role role +) { +} + diff --git a/src/main/java/org/lab/auth/model/Permission.java b/src/main/java/org/lab/auth/model/Permission.java new file mode 100644 index 0000000..8a49194 --- /dev/null +++ b/src/main/java/org/lab/auth/model/Permission.java @@ -0,0 +1,38 @@ +package org.lab.auth.model; + +public enum Permission { + PROJECT_SET_TEAM_LEAD("project.setTeamLead", "Set team lead for a project"), + PROJECT_ADD_DEVELOPER("project.addDeveloper", "Add developer to a project"), + PROJECT_ADD_TESTER("project.addTester", "Add tester to a project"), + PROJECT_TEST("project.test", "Test a project"), + + TICKET_CREATE("ticket.create", "Create a new ticket"), + TICKET_ASSIGN_DEVELOPER("ticket.assignDeveloper", "Assign developer to a ticket"), + TICKET_GET_STATUS("ticket.getStatus", "Get ticket status"), + TICKET_COMPLETE("ticket.complete", "Complete a ticket"), + + BUG_REPORT_CREATE("bugReport.create", "Create a new bug report"), + BUG_REPORT_FIX("bugReport.fix", "Mark bug report as fixed"), + BUG_REPORT_TEST("bugReport.test", "Mark bug report as tested"), + BUG_REPORT_CLOSE("bugReport.close", "Close a bug report"), + + MILESTONE_CREATE("milestone.create", "Create a new milestone"), + MILESTONE_SET_STATUS("milestone.setStatus", "Set milestone status"); + + private final String name; + private final String description; + + Permission(String name, String description) { + this.name = name; + this.description = description; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } +} + diff --git a/src/main/java/org/lab/auth/model/Role.java b/src/main/java/org/lab/auth/model/Role.java new file mode 100644 index 0000000..bb1ef4d --- /dev/null +++ b/src/main/java/org/lab/auth/model/Role.java @@ -0,0 +1,54 @@ +package org.lab.auth.model; + +import java.util.List; + +public enum Role { + MANAGER("manager", List.of( + Permission.PROJECT_SET_TEAM_LEAD.getName(), + Permission.PROJECT_ADD_DEVELOPER.getName(), + Permission.PROJECT_ADD_TESTER.getName(), + Permission.TICKET_CREATE.getName(), + Permission.TICKET_ASSIGN_DEVELOPER.getName(), + Permission.TICKET_GET_STATUS.getName(), + Permission.MILESTONE_CREATE.getName(), + Permission.MILESTONE_SET_STATUS.getName() + )), + + DEVELOPER("developer", List.of( + Permission.TICKET_COMPLETE.getName(), + Permission.BUG_REPORT_CREATE.getName(), + Permission.BUG_REPORT_FIX.getName(), + Permission.BUG_REPORT_CLOSE.getName() + )), + + TESTER("tester", List.of( + Permission.PROJECT_TEST.getName(), + Permission.BUG_REPORT_CREATE.getName(), + Permission.BUG_REPORT_TEST.getName(), + Permission.BUG_REPORT_CLOSE.getName() + )), + + TEAM_LEAD("teamLead", List.of( + Permission.TICKET_CREATE.getName(), + Permission.TICKET_ASSIGN_DEVELOPER.getName(), + Permission.TICKET_GET_STATUS.getName(), + Permission.TICKET_COMPLETE.getName() + )); + + private final String name; + private final List permissions; + + Role(String name, List permissions) { + this.name = name; + this.permissions = permissions; + } + + public String getName() { + return name; + } + + public List getPermissions() { + return permissions; + } +} + diff --git a/src/main/java/org/lab/exception/ActiveMilestoneExistsException.java b/src/main/java/org/lab/exception/ActiveMilestoneExistsException.java new file mode 100644 index 0000000..9b7bb2d --- /dev/null +++ b/src/main/java/org/lab/exception/ActiveMilestoneExistsException.java @@ -0,0 +1,10 @@ +package org.lab.exception; + +import java.util.UUID; + +public class ActiveMilestoneExistsException extends RuntimeException { + public ActiveMilestoneExistsException(UUID projectId) { + super("Project already has an active milestone: " + projectId); + } +} + diff --git a/src/main/java/org/lab/exception/BugReportNotFoundException.java b/src/main/java/org/lab/exception/BugReportNotFoundException.java new file mode 100644 index 0000000..c653897 --- /dev/null +++ b/src/main/java/org/lab/exception/BugReportNotFoundException.java @@ -0,0 +1,15 @@ +package org.lab.exception; + +import java.util.UUID; +import java.util.function.Supplier; + +public class BugReportNotFoundException extends RuntimeException { + public BugReportNotFoundException(UUID bugReportId) { + super("Bug report not found: " + bugReportId); + } + + public static Supplier supplier(UUID bugReportId) { + return () -> new BugReportNotFoundException(bugReportId); + } +} + diff --git a/src/main/java/org/lab/exception/MilestoneNotFoundException.java b/src/main/java/org/lab/exception/MilestoneNotFoundException.java new file mode 100644 index 0000000..4b1bf75 --- /dev/null +++ b/src/main/java/org/lab/exception/MilestoneNotFoundException.java @@ -0,0 +1,15 @@ +package org.lab.exception; + +import java.util.UUID; +import java.util.function.Supplier; + +public class MilestoneNotFoundException extends RuntimeException { + public MilestoneNotFoundException(UUID milestoneId) { + super("Milestone not found: " + milestoneId); + } + + public static Supplier supplier(UUID milestoneId) { + return () -> new MilestoneNotFoundException(milestoneId); + } +} + diff --git a/src/main/java/org/lab/exception/NotAllTicketsCompletedException.java b/src/main/java/org/lab/exception/NotAllTicketsCompletedException.java new file mode 100644 index 0000000..d8b16ce --- /dev/null +++ b/src/main/java/org/lab/exception/NotAllTicketsCompletedException.java @@ -0,0 +1,10 @@ +package org.lab.exception; + +import java.util.UUID; + +public class NotAllTicketsCompletedException extends RuntimeException { + public NotAllTicketsCompletedException(UUID milestoneId) { + super("Cannot close milestone: not all tickets are completed: " + milestoneId); + } +} + diff --git a/src/main/java/org/lab/exception/ProjectNotFoundException.java b/src/main/java/org/lab/exception/ProjectNotFoundException.java new file mode 100644 index 0000000..6a1d76e --- /dev/null +++ b/src/main/java/org/lab/exception/ProjectNotFoundException.java @@ -0,0 +1,15 @@ +package org.lab.exception; + +import java.util.UUID; +import java.util.function.Supplier; + +public class ProjectNotFoundException extends RuntimeException { + public ProjectNotFoundException(UUID projectId) { + super("Project not found: " + projectId); + } + + public static Supplier supplier(UUID projectId) { + return () -> new ProjectNotFoundException(projectId); + } +} + diff --git a/src/main/java/org/lab/exception/TicketNotFoundException.java b/src/main/java/org/lab/exception/TicketNotFoundException.java new file mode 100644 index 0000000..13d2cd4 --- /dev/null +++ b/src/main/java/org/lab/exception/TicketNotFoundException.java @@ -0,0 +1,15 @@ +package org.lab.exception; + +import java.util.UUID; +import java.util.function.Supplier; + +public class TicketNotFoundException extends RuntimeException { + public TicketNotFoundException(UUID ticketId) { + super("Ticket not found: " + ticketId); + } + + public static Supplier supplier(UUID ticketId) { + return () -> new TicketNotFoundException(ticketId); + } +} + diff --git a/src/main/java/org/lab/model/BugReport.java b/src/main/java/org/lab/model/BugReport.java new file mode 100644 index 0000000..237f737 --- /dev/null +++ b/src/main/java/org/lab/model/BugReport.java @@ -0,0 +1,13 @@ +package org.lab.model; + +import lombok.With; +import java.util.UUID; + +public record BugReport( + UUID id, + UUID projectId, + String description, + @With BugReportStatus status +) implements Entity { +} + diff --git a/src/main/java/org/lab/model/BugReportStatus.java b/src/main/java/org/lab/model/BugReportStatus.java new file mode 100644 index 0000000..e8f03a3 --- /dev/null +++ b/src/main/java/org/lab/model/BugReportStatus.java @@ -0,0 +1,9 @@ +package org.lab.model; + +public enum BugReportStatus { + NEW, + FIXED, + TESTED, + CLOSED +} + diff --git a/src/main/java/org/lab/model/Entity.java b/src/main/java/org/lab/model/Entity.java new file mode 100644 index 0000000..4e97979 --- /dev/null +++ b/src/main/java/org/lab/model/Entity.java @@ -0,0 +1,8 @@ +package org.lab.model; + +import java.util.UUID; + +public sealed interface Entity permits BugReport, Milestone, Project, Ticket, User { + UUID id(); +} + diff --git a/src/main/java/org/lab/model/Milestone.java b/src/main/java/org/lab/model/Milestone.java new file mode 100644 index 0000000..457a48c --- /dev/null +++ b/src/main/java/org/lab/model/Milestone.java @@ -0,0 +1,17 @@ +package org.lab.model; + +import lombok.With; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +public record Milestone( + UUID id, + UUID projectId, + LocalDate startDate, + LocalDate endDate, + @With List ticketIds, + @With MilestoneStatus status +) implements Entity { +} + diff --git a/src/main/java/org/lab/model/MilestoneStatus.java b/src/main/java/org/lab/model/MilestoneStatus.java new file mode 100644 index 0000000..8948a9a --- /dev/null +++ b/src/main/java/org/lab/model/MilestoneStatus.java @@ -0,0 +1,8 @@ +package org.lab.model; + +public enum MilestoneStatus { + OPENED, + ACTIVE, + CLOSED +} + diff --git a/src/main/java/org/lab/model/Project.java b/src/main/java/org/lab/model/Project.java new file mode 100644 index 0000000..a3640ff --- /dev/null +++ b/src/main/java/org/lab/model/Project.java @@ -0,0 +1,14 @@ +package org.lab.model; + +import java.util.List; +import java.util.UUID; + +// Can be value object +public record Project( + UUID id, + String title, + String description, + List milestoneIds, + List bugReportIds +) implements Entity { +} diff --git a/src/main/java/org/lab/model/Ticket.java b/src/main/java/org/lab/model/Ticket.java new file mode 100644 index 0000000..1969c1a --- /dev/null +++ b/src/main/java/org/lab/model/Ticket.java @@ -0,0 +1,19 @@ +package org.lab.model; + +import lombok.With; +import java.util.List; +import java.util.UUID; + +public record Ticket( + UUID id, + UUID projectId, + UUID milestoneId, + String description, + @With List assignedDevelopers, + @With TicketStatus status +) implements Entity { + public boolean isCompleted() { + return status == TicketStatus.COMPLETED; + } +} + diff --git a/src/main/java/org/lab/model/TicketStatus.java b/src/main/java/org/lab/model/TicketStatus.java new file mode 100644 index 0000000..c69665c --- /dev/null +++ b/src/main/java/org/lab/model/TicketStatus.java @@ -0,0 +1,9 @@ +package org.lab.model; + +public enum TicketStatus { + NEW, + ACCEPTED, + IN_PROGRESS, + COMPLETED +} + diff --git a/src/main/java/org/lab/model/User.java b/src/main/java/org/lab/model/User.java new file mode 100644 index 0000000..79a5e9c --- /dev/null +++ b/src/main/java/org/lab/model/User.java @@ -0,0 +1,12 @@ +package org.lab.model; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record User( + UUID id, + String name, + LocalDateTime createdAt +) implements Entity { +} + diff --git a/src/main/java/org/lab/repository/BugReportRepository.java b/src/main/java/org/lab/repository/BugReportRepository.java new file mode 100644 index 0000000..59edb3f --- /dev/null +++ b/src/main/java/org/lab/repository/BugReportRepository.java @@ -0,0 +1,18 @@ +package org.lab.repository; + +import org.lab.model.BugReport; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface BugReportRepository { + BugReport save(BugReport bugReport); + + Optional findById(UUID id); + + List findAll(); + + void deleteById(UUID id); +} + diff --git a/src/main/java/org/lab/repository/MilestoneRepository.java b/src/main/java/org/lab/repository/MilestoneRepository.java new file mode 100644 index 0000000..a9445e2 --- /dev/null +++ b/src/main/java/org/lab/repository/MilestoneRepository.java @@ -0,0 +1,18 @@ +package org.lab.repository; + +import org.lab.model.Milestone; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface MilestoneRepository { + Milestone save(Milestone milestone); + + Optional findById(UUID id); + + List findAll(); + + void deleteById(UUID id); +} + diff --git a/src/main/java/org/lab/repository/ProjectRepository.java b/src/main/java/org/lab/repository/ProjectRepository.java new file mode 100644 index 0000000..06c3a77 --- /dev/null +++ b/src/main/java/org/lab/repository/ProjectRepository.java @@ -0,0 +1,18 @@ +package org.lab.repository; + +import org.lab.model.Project; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface ProjectRepository { + Project save(Project project); + + Optional findById(UUID id); + + List findAll(); + + void deleteById(UUID id); +} + diff --git a/src/main/java/org/lab/repository/TicketRepository.java b/src/main/java/org/lab/repository/TicketRepository.java new file mode 100644 index 0000000..8f9e999 --- /dev/null +++ b/src/main/java/org/lab/repository/TicketRepository.java @@ -0,0 +1,18 @@ +package org.lab.repository; + +import org.lab.model.Ticket; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface TicketRepository { + Ticket save(Ticket ticket); + + Optional findById(UUID id); + + List findAll(); + + void deleteById(UUID id); +} + diff --git a/src/main/java/org/lab/repository/UserRepository.java b/src/main/java/org/lab/repository/UserRepository.java new file mode 100644 index 0000000..8b3cb73 --- /dev/null +++ b/src/main/java/org/lab/repository/UserRepository.java @@ -0,0 +1,18 @@ +package org.lab.repository; + +import org.lab.model.User; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface UserRepository { + User save(User user); + + Optional findById(UUID id); + + List findAll(); + + void deleteById(UUID id); +} + diff --git a/src/main/java/org/lab/repository/inmemory/InMemoryBugReportRepository.java b/src/main/java/org/lab/repository/inmemory/InMemoryBugReportRepository.java new file mode 100644 index 0000000..68b058a --- /dev/null +++ b/src/main/java/org/lab/repository/inmemory/InMemoryBugReportRepository.java @@ -0,0 +1,37 @@ +package org.lab.repository.inmemory; + +import org.lab.model.BugReport; +import org.lab.repository.BugReportRepository; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public class InMemoryBugReportRepository implements BugReportRepository { + private final Map storage = new ConcurrentHashMap<>(); + + @Override + public BugReport save(BugReport bugReport) { + storage.put(bugReport.id(), bugReport); + return bugReport; + } + + @Override + public Optional findById(UUID id) { + return Optional.ofNullable(storage.get(id)); + } + + @Override + public List findAll() { + return new ArrayList<>(storage.values()); + } + + @Override + public void deleteById(UUID id) { + storage.remove(id); + } +} + diff --git a/src/main/java/org/lab/repository/inmemory/InMemoryMilestoneRepository.java b/src/main/java/org/lab/repository/inmemory/InMemoryMilestoneRepository.java new file mode 100644 index 0000000..c0149e5 --- /dev/null +++ b/src/main/java/org/lab/repository/inmemory/InMemoryMilestoneRepository.java @@ -0,0 +1,37 @@ +package org.lab.repository.inmemory; + +import org.lab.model.Milestone; +import org.lab.repository.MilestoneRepository; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public class InMemoryMilestoneRepository implements MilestoneRepository { + private final Map storage = new ConcurrentHashMap<>(); + + @Override + public Milestone save(Milestone milestone) { + storage.put(milestone.id(), milestone); + return milestone; + } + + @Override + public Optional findById(UUID id) { + return Optional.ofNullable(storage.get(id)); + } + + @Override + public List findAll() { + return new ArrayList<>(storage.values()); + } + + @Override + public void deleteById(UUID id) { + storage.remove(id); + } +} + diff --git a/src/main/java/org/lab/repository/inmemory/InMemoryProjectRepository.java b/src/main/java/org/lab/repository/inmemory/InMemoryProjectRepository.java new file mode 100644 index 0000000..c3129fc --- /dev/null +++ b/src/main/java/org/lab/repository/inmemory/InMemoryProjectRepository.java @@ -0,0 +1,37 @@ +package org.lab.repository.inmemory; + +import org.lab.model.Project; +import org.lab.repository.ProjectRepository; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public class InMemoryProjectRepository implements ProjectRepository { + private final Map storage = new ConcurrentHashMap<>(); + + @Override + public Project save(Project project) { + storage.put(project.id(), project); + return project; + } + + @Override + public Optional findById(UUID id) { + return Optional.ofNullable(storage.get(id)); + } + + @Override + public List findAll() { + return new ArrayList<>(storage.values()); + } + + @Override + public void deleteById(UUID id) { + storage.remove(id); + } +} + diff --git a/src/main/java/org/lab/repository/inmemory/InMemoryTicketRepository.java b/src/main/java/org/lab/repository/inmemory/InMemoryTicketRepository.java new file mode 100644 index 0000000..1b7948c --- /dev/null +++ b/src/main/java/org/lab/repository/inmemory/InMemoryTicketRepository.java @@ -0,0 +1,37 @@ +package org.lab.repository.inmemory; + +import org.lab.model.Ticket; +import org.lab.repository.TicketRepository; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public class InMemoryTicketRepository implements TicketRepository { + private final Map storage = new ConcurrentHashMap<>(); + + @Override + public Ticket save(Ticket ticket) { + storage.put(ticket.id(), ticket); + return ticket; + } + + @Override + public Optional findById(UUID id) { + return Optional.ofNullable(storage.get(id)); + } + + @Override + public List findAll() { + return new ArrayList<>(storage.values()); + } + + @Override + public void deleteById(UUID id) { + storage.remove(id); + } +} + diff --git a/src/main/java/org/lab/repository/inmemory/InMemoryUserRepository.java b/src/main/java/org/lab/repository/inmemory/InMemoryUserRepository.java new file mode 100644 index 0000000..de34bf6 --- /dev/null +++ b/src/main/java/org/lab/repository/inmemory/InMemoryUserRepository.java @@ -0,0 +1,37 @@ +package org.lab.repository.inmemory; + +import org.lab.model.User; +import org.lab.repository.UserRepository; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public class InMemoryUserRepository implements UserRepository { + private final Map storage = new ConcurrentHashMap<>(); + + @Override + public User save(User user) { + storage.put(user.id(), user); + return user; + } + + @Override + public Optional findById(UUID id) { + return Optional.ofNullable(storage.get(id)); + } + + @Override + public List findAll() { + return new ArrayList<>(storage.values()); + } + + @Override + public void deleteById(UUID id) { + storage.remove(id); + } +} + diff --git a/src/main/java/org/lab/service/BugReportService.java b/src/main/java/org/lab/service/BugReportService.java new file mode 100644 index 0000000..1b8de48 --- /dev/null +++ b/src/main/java/org/lab/service/BugReportService.java @@ -0,0 +1,73 @@ +package org.lab.service; + +import org.lab.auth.AuthService; +import org.lab.auth.model.AccessBinding; +import org.lab.auth.model.Permission; +import org.lab.exception.BugReportNotFoundException; +import org.lab.model.BugReport; +import org.lab.model.BugReportStatus; +import org.lab.repository.BugReportRepository; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +public class BugReportService { + private final BugReportRepository bugReportRepository; + private final AuthService authService; + + public BugReportService(BugReportRepository bugReportRepository, AuthService authService) { + this.bugReportRepository = bugReportRepository; + this.authService = authService; + } + + public BugReport create(UUID projectId, String description) { + authService.checkPermission(projectId, Permission.BUG_REPORT_CREATE); + + var bugReport = new BugReport( + UUID.randomUUID(), + projectId, + description, + BugReportStatus.NEW + ); + return bugReportRepository.save(bugReport); + } + + public List listByUser(UUID userId) { + var userProjectIds = authService.findAllByUserId(userId).stream() + .map(AccessBinding::projectId) + .collect(Collectors.toSet()); + + return bugReportRepository.findAll().stream() + .filter(bugReport -> userProjectIds.contains(bugReport.projectId())) + .toList(); + } + + public void fix(UUID bugReportId) { + var bugReport = bugReportRepository.findById(bugReportId) + .orElseThrow(BugReportNotFoundException.supplier(bugReportId)); + + authService.checkPermission(bugReport.projectId(), Permission.BUG_REPORT_FIX); + + bugReportRepository.save(bugReport.withStatus(BugReportStatus.FIXED)); + } + + public void test(UUID bugReportId) { + var bugReport = bugReportRepository.findById(bugReportId) + .orElseThrow(BugReportNotFoundException.supplier(bugReportId)); + + authService.checkPermission(bugReport.projectId(), Permission.BUG_REPORT_TEST); + + bugReportRepository.save(bugReport.withStatus(BugReportStatus.TESTED)); + } + + public void close(UUID bugReportId) { + var bugReport = bugReportRepository.findById(bugReportId) + .orElseThrow(BugReportNotFoundException.supplier(bugReportId)); + + authService.checkPermission(bugReport.projectId(), Permission.BUG_REPORT_CLOSE); + + bugReportRepository.save(bugReport.withStatus(BugReportStatus.CLOSED)); + } +} + 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..f0a4512 --- /dev/null +++ b/src/main/java/org/lab/service/MilestoneService.java @@ -0,0 +1,77 @@ +package org.lab.service; + +import org.lab.auth.AuthService; +import org.lab.auth.model.Permission; +import org.lab.exception.ActiveMilestoneExistsException; +import org.lab.exception.MilestoneNotFoundException; +import org.lab.exception.NotAllTicketsCompletedException; +import org.lab.model.Milestone; +import org.lab.model.MilestoneStatus; +import org.lab.model.Ticket; +import org.lab.model.TicketStatus; +import org.lab.repository.MilestoneRepository; +import org.lab.repository.TicketRepository; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Optional; +import java.util.UUID; + +public class MilestoneService { + private final MilestoneRepository milestoneRepository; + private final TicketRepository ticketRepository; + private final AuthService authService; + + public MilestoneService(MilestoneRepository milestoneRepository, TicketRepository ticketRepository, AuthService authService) { + this.milestoneRepository = milestoneRepository; + this.ticketRepository = ticketRepository; + this.authService = authService; + } + + public Milestone create(UUID projectId, LocalDate startDate, LocalDate endDate) { + authService.checkPermission(projectId, Permission.MILESTONE_CREATE); + + var milestone = new Milestone( + UUID.randomUUID(), + projectId, + startDate, + endDate, + new ArrayList<>(), + MilestoneStatus.OPENED + ); + return milestoneRepository.save(milestone); + } + + public void setStatus(UUID milestoneId, MilestoneStatus status) { + var milestone = milestoneRepository.findById(milestoneId) + .orElseThrow(MilestoneNotFoundException.supplier(milestoneId)); + + authService.checkPermission(milestone.projectId(), Permission.MILESTONE_SET_STATUS); + + if (status == MilestoneStatus.CLOSED) { + var allTicketsCompleted = milestone.ticketIds().stream() + .map(ticketRepository::findById) + .filter(Optional::isPresent) + .map(Optional::get) + .allMatch(Ticket::isCompleted); + + if (!allTicketsCompleted) { + throw new NotAllTicketsCompletedException(milestoneId); + } + } + + if (status == MilestoneStatus.ACTIVE) { + var hasActiveMilestone = milestoneRepository.findAll().stream() + .anyMatch(m -> m.projectId().equals(milestone.projectId()) + && m.status() == MilestoneStatus.ACTIVE + && !m.id().equals(milestoneId)); + + if (hasActiveMilestone) { + throw new ActiveMilestoneExistsException(milestone.projectId()); + } + } + + milestoneRepository.save(milestone.withStatus(status)); + } +} + 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..e8d5b8a --- /dev/null +++ b/src/main/java/org/lab/service/ProjectService.java @@ -0,0 +1,91 @@ +package org.lab.service; + +import org.lab.auth.AuthService; +import org.lab.auth.model.AccessBinding; +import org.lab.auth.model.Permission; +import org.lab.auth.model.Role; +import org.lab.exception.ProjectNotFoundException; +import org.lab.model.Project; +import org.lab.repository.ProjectRepository; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * У человека может быть только одна роль в проекте. + * У проекта может быть только один teamLead. + * У проекта может быть только один manager, определяется создателем. + */ +public class ProjectService { + private final ProjectRepository projectRepository; + private final AuthService authService; + + public ProjectService(ProjectRepository projectRepository, AuthService authService) { + this.projectRepository = projectRepository; + this.authService = authService; + } + + public List list(UUID userId) { + var userProjectIds = authService.findAllByUserId(userId).stream() + .map(AccessBinding::projectId) + .collect(Collectors.toSet()); + + return projectRepository.findAll().stream() + .filter(project -> userProjectIds.contains(project.id())) + .toList(); + } + + public Project create(UUID userId, String title, String description) { + var project = new Project( + UUID.randomUUID(), + title, + description, + new ArrayList<>(), + new ArrayList<>() + ); + var savedProject = projectRepository.save(project); + authService.addBinding(userId, savedProject.id(), Role.MANAGER); + return savedProject; + } + + public void setTeamLead(UUID projectId, UUID teamLeadId) { + projectRepository.findById(projectId) + .orElseThrow(ProjectNotFoundException.supplier(projectId)); + + authService.checkPermission(projectId, Permission.PROJECT_SET_TEAM_LEAD); + + authService.removeAllByProjectIdAndRole(projectId, Role.TEAM_LEAD); + authService.addBinding(teamLeadId, projectId, Role.TEAM_LEAD); + } + + public void addDeveloper(UUID projectId, UUID developerId) { + projectRepository.findById(projectId) + .orElseThrow(ProjectNotFoundException.supplier(projectId)); + + authService.checkPermission(projectId, Permission.PROJECT_ADD_DEVELOPER); + + authService.addBinding(developerId, projectId, Role.DEVELOPER); + } + + public void addTester(UUID projectId, UUID testerId) { + projectRepository.findById(projectId) + .orElseThrow(ProjectNotFoundException.supplier(projectId)); + + authService.checkPermission(projectId, Permission.PROJECT_ADD_TESTER); + + authService.addBinding(testerId, projectId, Role.TESTER); + } + + public void test(UUID projectId) { + var project = projectRepository.findById(projectId) + .orElseThrow(ProjectNotFoundException.supplier(projectId)); + + authService.checkPermission(projectId, Permission.PROJECT_TEST); + + IO.println("Testing project " + project.title() + "#" + project.id() + "..."); + } +} + 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..fb2f582 --- /dev/null +++ b/src/main/java/org/lab/service/TicketService.java @@ -0,0 +1,75 @@ +package org.lab.service; + +import org.lab.auth.AuthService; +import org.lab.auth.model.Permission; +import org.lab.exception.TicketNotFoundException; +import org.lab.model.Ticket; +import org.lab.model.TicketStatus; +import org.lab.repository.TicketRepository; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public class TicketService { + private final TicketRepository ticketRepository; + private final AuthService authService; + + public TicketService(TicketRepository ticketRepository, AuthService authService) { + this.ticketRepository = ticketRepository; + this.authService = authService; + } + + public Ticket create(UUID projectId, UUID milestoneId, String description) { + authService.checkPermission(projectId, Permission.TICKET_CREATE); + + var ticket = new Ticket( + UUID.randomUUID(), + projectId, + milestoneId, + description, + new ArrayList<>(), + TicketStatus.NEW + ); + return ticketRepository.save(ticket); + } + + public List listByUser(UUID userId) { + return ticketRepository.findAll().stream() + .filter(ticket -> ticket.assignedDevelopers().contains(userId)) + .toList(); + } + + public void assignDeveloper(UUID ticketId, UUID developerId) { + var ticket = ticketRepository.findById(ticketId) + .orElseThrow(TicketNotFoundException.supplier(ticketId)); + + authService.checkPermission(ticket.projectId(), Permission.TICKET_ASSIGN_DEVELOPER); + + var developers = new ArrayList<>(ticket.assignedDevelopers()); + if (!developers.contains(developerId)) { + developers.add(developerId); + } + + ticketRepository.save(ticket.withAssignedDevelopers(developers)); + } + + public TicketStatus getStatus(UUID ticketId) { + var ticket = ticketRepository.findById(ticketId) + .orElseThrow(TicketNotFoundException.supplier(ticketId)); + + authService.checkPermission(ticket.projectId(), Permission.TICKET_GET_STATUS); + + return ticket.status(); + } + + public void complete(UUID ticketId) { + var ticket = ticketRepository.findById(ticketId) + .orElseThrow(TicketNotFoundException.supplier(ticketId)); + + authService.checkPermission(ticket.projectId(), Permission.TICKET_COMPLETE); + + ticketRepository.save(ticket.withStatus(TicketStatus.COMPLETED)); + } +} + diff --git a/src/main/java/org/lab/service/UserService.java b/src/main/java/org/lab/service/UserService.java new file mode 100644 index 0000000..a265f34 --- /dev/null +++ b/src/main/java/org/lab/service/UserService.java @@ -0,0 +1,25 @@ +package org.lab.service; + +import org.lab.model.User; +import org.lab.repository.UserRepository; + +import java.time.LocalDateTime; +import java.util.UUID; + +public class UserService { + private final UserRepository userRepository; + + public UserService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + public User register(String name) { + var user = new User( + UUID.randomUUID(), + name, + LocalDateTime.now() + ); + return userRepository.save(user); + } +} + diff --git a/src/test/java/org/lab/TestBase.java b/src/test/java/org/lab/TestBase.java new file mode 100644 index 0000000..eb30be0 --- /dev/null +++ b/src/test/java/org/lab/TestBase.java @@ -0,0 +1,65 @@ +package org.lab; + +import org.junit.jupiter.api.BeforeEach; +import org.lab.auth.AuthRepository; +import org.lab.auth.AuthService; +import org.lab.auth.AuthServiceImpl; +import org.lab.auth.AuthenticationContext; +import org.lab.auth.InMemoryAuthRepository; +import org.lab.repository.*; +import org.lab.repository.inmemory.*; +import org.lab.service.*; + +import java.util.UUID; + +public abstract class TestBase { + protected UserRepository userRepository; + protected ProjectRepository projectRepository; + protected TicketRepository ticketRepository; + protected MilestoneRepository milestoneRepository; + protected BugReportRepository bugReportRepository; + protected AuthRepository authRepository; + + protected UserService userService; + protected ProjectService projectService; + protected TicketService ticketService; + protected MilestoneService milestoneService; + protected BugReportService bugReportService; + protected AuthService authService; + + protected UUID managerId; + protected UUID teamLeadId; + protected UUID developerId; + protected UUID testerId; + + @BeforeEach + protected void baseSetUp() { + userRepository = new InMemoryUserRepository(); + projectRepository = new InMemoryProjectRepository(); + ticketRepository = new InMemoryTicketRepository(); + milestoneRepository = new InMemoryMilestoneRepository(); + bugReportRepository = new InMemoryBugReportRepository(); + authRepository = new InMemoryAuthRepository(); + + authService = new AuthServiceImpl(authRepository); + userService = new UserService(userRepository); + projectService = new ProjectService(projectRepository, authService); + ticketService = new TicketService(ticketRepository, authService); + milestoneService = new MilestoneService(milestoneRepository, ticketRepository, authService); + bugReportService = new BugReportService(bugReportRepository, authService); + + managerId = userService.register("Manager").id(); + teamLeadId = userService.register("TeamLead").id(); + developerId = userService.register("Developer").id(); + testerId = userService.register("Tester").id(); + } + + protected void setCurrentUser(UUID userId) { + AuthenticationContext.set(userId); + } + + protected void clearCurrentUser() { + AuthenticationContext.clear(); + } +} + diff --git a/src/test/java/org/lab/auth/AuthServiceTest.java b/src/test/java/org/lab/auth/AuthServiceTest.java new file mode 100644 index 0000000..6bad0e9 --- /dev/null +++ b/src/test/java/org/lab/auth/AuthServiceTest.java @@ -0,0 +1,82 @@ +package org.lab.auth; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lab.TestBase; +import org.lab.auth.PermissionDeniedException; +import org.lab.auth.model.Permission; +import org.lab.auth.model.Role; +import org.lab.model.Project; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class AuthServiceTest extends TestBase { + + private UUID projectId; + + @BeforeEach + void setUp() { + setCurrentUser(managerId); + var project = projectService.create(managerId, "Project", "Desc"); + projectId = project.id(); + } + + @Test + void testAddBinding() { + authService.addBinding(developerId, projectId, Role.DEVELOPER); + + var binding = authRepository.findByUserIdAndProjectId(developerId, projectId); + assertTrue(binding.isPresent()); + assertEquals(Role.DEVELOPER, binding.get().role()); + } + + @Test + void testRemoveBinding() { + authService.addBinding(developerId, projectId, Role.DEVELOPER); + authService.removeBinding(developerId, projectId, Role.DEVELOPER); + + var binding = authRepository.findByUserIdAndProjectId(developerId, projectId); + assertFalse(binding.isPresent()); + } + + @Test + void testRemoveBindingOnlyRemovesCorrectRole() { + authService.addBinding(developerId, projectId, Role.DEVELOPER); + authService.removeBinding(developerId, projectId, Role.TESTER); + + var binding = authRepository.findByUserIdAndProjectId(developerId, projectId); + assertTrue(binding.isPresent()); + } + + @Test + void testCheckPermissionSuccess() { + setCurrentUser(managerId); + authService.addBinding(managerId, projectId, Role.MANAGER); + + assertDoesNotThrow(() -> + authService.checkPermission(projectId, Permission.PROJECT_SET_TEAM_LEAD)); + } + + @Test + void testCheckPermissionDenied() { + setCurrentUser(developerId); + authService.addBinding(developerId, projectId, Role.DEVELOPER); + + assertThrows(PermissionDeniedException.class, + () -> authService.checkPermission(projectId, Permission.PROJECT_SET_TEAM_LEAD)); + } + + @Test + void testFindAllByUserId() { + var projectId2 = projectService.create(managerId, "Project 2", "Desc").id(); + + authService.addBinding(developerId, projectId, Role.DEVELOPER); + authService.addBinding(developerId, projectId2, Role.DEVELOPER); + + var bindings = authService.findAllByUserId(developerId); + assertEquals(2, bindings.size()); + } +} + diff --git a/src/test/java/org/lab/service/BugReportServiceTest.java b/src/test/java/org/lab/service/BugReportServiceTest.java new file mode 100644 index 0000000..afe167e --- /dev/null +++ b/src/test/java/org/lab/service/BugReportServiceTest.java @@ -0,0 +1,112 @@ +package org.lab.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lab.TestBase; +import org.lab.auth.model.Role; +import org.lab.exception.BugReportNotFoundException; +import org.lab.model.BugReport; +import org.lab.model.BugReportStatus; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class BugReportServiceTest extends TestBase { + + private UUID projectId; + + @BeforeEach + void setUp() { + setCurrentUser(managerId); + var project = projectService.create(managerId, "Project", "Desc"); + projectId = project.id(); + } + + @Test + void testCreateBugReport() { + setCurrentUser(managerId); + projectService.addDeveloper(projectId, developerId); + setCurrentUser(developerId); + + var bugReport = bugReportService.create(projectId, "Bug description"); + + assertNotNull(bugReport); + assertNotNull(bugReport.id()); + assertEquals(projectId, bugReport.projectId()); + assertEquals("Bug description", bugReport.description()); + assertEquals(BugReportStatus.NEW, bugReport.status()); + } + + @Test + void testFixBugReport() { + setCurrentUser(managerId); + projectService.addDeveloper(projectId, developerId); + setCurrentUser(developerId); + + var bugReport = bugReportService.create(projectId, "Bug"); + bugReportService.fix(bugReport.id()); + + var fixed = bugReportRepository.findById(bugReport.id()).orElseThrow(); + assertEquals(BugReportStatus.FIXED, fixed.status()); + } + + @Test + void testTestBugReport() { + setCurrentUser(managerId); + projectService.addTester(projectId, testerId); + projectService.addDeveloper(projectId, developerId); + setCurrentUser(developerId); + var bugReport = bugReportService.create(projectId, "Bug"); + bugReportService.fix(bugReport.id()); + setCurrentUser(testerId); + bugReportService.test(bugReport.id()); + + var tested = bugReportRepository.findById(bugReport.id()).orElseThrow(); + assertEquals(BugReportStatus.TESTED, tested.status()); + } + + @Test + void testCloseBugReport() { + setCurrentUser(managerId); + projectService.addTester(projectId, testerId); + projectService.addDeveloper(projectId, developerId); + setCurrentUser(developerId); + var bugReport = bugReportService.create(projectId, "Bug"); + bugReportService.fix(bugReport.id()); + setCurrentUser(testerId); + bugReportService.test(bugReport.id()); + bugReportService.close(bugReport.id()); + + var closed = bugReportRepository.findById(bugReport.id()).orElseThrow(); + assertEquals(BugReportStatus.CLOSED, closed.status()); + } + + @Test + void testListByUser() { + setCurrentUser(managerId); + projectService.addDeveloper(projectId, developerId); + setCurrentUser(developerId); + + var bugReport1 = bugReportService.create(projectId, "Bug 1"); + var bugReport2 = bugReportService.create(projectId, "Bug 2"); + + var userBugs = bugReportService.listByUser(developerId); + assertTrue(userBugs.size() >= 2); + assertTrue(userBugs.stream().anyMatch(b -> b.id().equals(bugReport1.id()))); + assertTrue(userBugs.stream().anyMatch(b -> b.id().equals(bugReport2.id()))); + } + + @Test + void testBugReportNotFound() { + setCurrentUser(managerId); + projectService.addDeveloper(projectId, developerId); + setCurrentUser(developerId); + var nonExistentId = UUID.randomUUID(); + + assertThrows(BugReportNotFoundException.class, + () -> bugReportService.fix(nonExistentId)); + } +} + 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..f921bd5 --- /dev/null +++ b/src/test/java/org/lab/service/MilestoneServiceTest.java @@ -0,0 +1,141 @@ +package org.lab.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lab.TestBase; +import org.lab.auth.model.Role; +import org.lab.exception.ActiveMilestoneExistsException; +import org.lab.exception.MilestoneNotFoundException; +import org.lab.exception.NotAllTicketsCompletedException; +import org.lab.model.Milestone; +import org.lab.model.MilestoneStatus; +import org.lab.model.Ticket; +import org.lab.model.TicketStatus; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class MilestoneServiceTest extends TestBase { + + private UUID projectId; + private UUID milestoneId; + + @BeforeEach + void setUp() { + setCurrentUser(managerId); + var project = projectService.create(managerId, "Project", "Desc"); + projectId = project.id(); + } + + @Test + void testCreateMilestone() { + setCurrentUser(managerId); + var milestone = milestoneService.create( + projectId, + LocalDate.now(), + LocalDate.now().plusDays(30) + ); + + assertNotNull(milestone); + assertNotNull(milestone.id()); + assertEquals(projectId, milestone.projectId()); + assertEquals(MilestoneStatus.OPENED, milestone.status()); + } + + @Test + void testSetStatusToActive() { + setCurrentUser(managerId); + var milestone = milestoneService.create( + projectId, + LocalDate.now(), + LocalDate.now().plusDays(30) + ); + + milestoneService.setStatus(milestone.id(), MilestoneStatus.ACTIVE); + + var updated = milestoneRepository.findById(milestone.id()).orElseThrow(); + assertEquals(MilestoneStatus.ACTIVE, updated.status()); + } + + @Test + void testOnlyOneActiveMilestonePerProject() { + setCurrentUser(managerId); + var milestone1 = milestoneService.create( + projectId, + LocalDate.now(), + LocalDate.now().plusDays(30) + ); + milestoneService.setStatus(milestone1.id(), MilestoneStatus.ACTIVE); + + var milestone2 = milestoneService.create( + projectId, + LocalDate.now().plusDays(31), + LocalDate.now().plusDays(60) + ); + + assertThrows(ActiveMilestoneExistsException.class, + () -> milestoneService.setStatus(milestone2.id(), MilestoneStatus.ACTIVE)); + } + + @Test + void testCannotCloseMilestoneWithIncompleteTickets() { + setCurrentUser(managerId); + var milestone = milestoneService.create( + projectId, + LocalDate.now(), + LocalDate.now().plusDays(30) + ); + + var ticket = ticketService.create(projectId, milestone.id(), "Test ticket"); + var updatedMilestone = milestoneRepository.findById(milestone.id()).orElseThrow(); + var milestoneWithTicket = updatedMilestone.withTicketIds( + List.of(ticket.id()) + ); + milestoneRepository.save(milestoneWithTicket); + + assertThrows(NotAllTicketsCompletedException.class, + () -> milestoneService.setStatus(milestone.id(), MilestoneStatus.CLOSED)); + } + + @Test + void testCanCloseMilestoneWhenAllTicketsCompleted() { + setCurrentUser(managerId); + projectService.addDeveloper(projectId, developerId); + var milestone = milestoneService.create( + projectId, + LocalDate.now(), + LocalDate.now().plusDays(30) + ); + + var ticket = ticketService.create(projectId, milestone.id(), "Test ticket"); + ticketService.assignDeveloper(ticket.id(), developerId); + setCurrentUser(developerId); + ticketService.complete(ticket.id()); + + var updatedMilestone = milestoneRepository.findById(milestone.id()).orElseThrow(); + var milestoneWithTicket = updatedMilestone.withTicketIds( + List.of(ticket.id()) + ); + milestoneRepository.save(milestoneWithTicket); + + setCurrentUser(managerId); + assertDoesNotThrow(() -> + milestoneService.setStatus(milestone.id(), MilestoneStatus.CLOSED)); + + var closed = milestoneRepository.findById(milestone.id()).orElseThrow(); + assertEquals(MilestoneStatus.CLOSED, closed.status()); + } + + @Test + void testMilestoneNotFound() { + setCurrentUser(managerId); + var nonExistentId = UUID.randomUUID(); + + assertThrows(MilestoneNotFoundException.class, + () -> milestoneService.setStatus(nonExistentId, MilestoneStatus.ACTIVE)); + } +} + 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..40b9b32 --- /dev/null +++ b/src/test/java/org/lab/service/ProjectServiceTest.java @@ -0,0 +1,143 @@ +package org.lab.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lab.TestBase; +import org.lab.auth.model.Role; +import org.lab.exception.ProjectNotFoundException; +import org.lab.model.Project; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class ProjectServiceTest extends TestBase { + + private UUID projectId; + + @BeforeEach + void setUp() { + setCurrentUser(managerId); + var project = projectService.create(managerId, "Test Project", "Description"); + projectId = project.id(); + } + + @Test + void testCreateProject() { + setCurrentUser(managerId); + var project = projectService.create(managerId, "New Project", "New Description"); + + assertNotNull(project); + assertNotNull(project.id()); + assertEquals("New Project", project.title()); + assertEquals("New Description", project.description()); + } + + @Test + void testCreateProjectSetsManagerRole() { + setCurrentUser(managerId); + var project = projectService.create(managerId, "Project", "Desc"); + + var binding = authRepository.findByUserIdAndProjectId(managerId, project.id()); + assertTrue(binding.isPresent()); + assertEquals(Role.MANAGER, binding.get().role()); + } + + @Test + void testListProjectsForUser() { + var testUserId = userService.register("Test User").id(); + + setCurrentUser(testUserId); + var managerProject = projectService.create(testUserId, "Manager Project", "Desc"); + + setCurrentUser(managerId); + var teamLeadProject = projectService.create(managerId, "TeamLead Project", "Desc"); + projectService.setTeamLead(teamLeadProject.id(), testUserId); + + var developerProject = projectService.create(managerId, "Developer Project", "Desc"); + projectService.addDeveloper(developerProject.id(), testUserId); + + var testerProject = projectService.create(managerId, "Tester Project", "Desc"); + projectService.addTester(testerProject.id(), testUserId); + + var otherUserId = userService.register("Other User").id(); + setCurrentUser(otherUserId); + var otherProject = projectService.create(otherUserId, "Other Project 1", "Desc"); + + var userProjects = projectService.list(testUserId); + + assertEquals(4, userProjects.size()); + assertTrue(userProjects.stream().anyMatch(p -> p.id().equals(managerProject.id()))); + assertTrue(userProjects.stream().anyMatch(p -> p.id().equals(teamLeadProject.id()))); + assertTrue(userProjects.stream().anyMatch(p -> p.id().equals(developerProject.id()))); + assertTrue(userProjects.stream().anyMatch(p -> p.id().equals(testerProject.id()))); + + assertFalse(userProjects.stream().anyMatch(p -> p.id().equals(otherProject.id()))); + } + + @Test + void testSetTeamLead() { + setCurrentUser(managerId); + projectService.setTeamLead(projectId, teamLeadId); + + var binding = authRepository.findByUserIdAndProjectId(teamLeadId, projectId); + assertTrue(binding.isPresent()); + assertEquals(Role.TEAM_LEAD, binding.get().role()); + } + + @Test + void testSetTeamLeadRemovesPreviousTeamLead() { + setCurrentUser(managerId); + var previousTeamLead = userService.register("Previous TeamLead").id(); + projectService.setTeamLead(projectId, previousTeamLead); + + projectService.setTeamLead(projectId, teamLeadId); + + var previousBinding = authRepository.findByUserIdAndProjectId(previousTeamLead, projectId); + var newBinding = authRepository.findByUserIdAndProjectId(teamLeadId, projectId); + + assertFalse(previousBinding.isPresent()); + assertTrue(newBinding.isPresent()); + assertEquals(Role.TEAM_LEAD, newBinding.get().role()); + } + + @Test + void testAddDeveloper() { + setCurrentUser(managerId); + projectService.addDeveloper(projectId, developerId); + + var binding = authRepository.findByUserIdAndProjectId(developerId, projectId); + assertTrue(binding.isPresent()); + assertEquals(Role.DEVELOPER, binding.get().role()); + } + + @Test + void testAddTester() { + setCurrentUser(managerId); + projectService.addTester(projectId, testerId); + + var binding = authRepository.findByUserIdAndProjectId(testerId, projectId); + assertTrue(binding.isPresent()); + assertEquals(Role.TESTER, binding.get().role()); + } + + @Test + void testTestProject() { + setCurrentUser(managerId); + projectService.addTester(projectId, testerId); + setCurrentUser(testerId); + + assertDoesNotThrow(() -> projectService.test(projectId)); + } + + @Test + void testProjectNotFound() { + setCurrentUser(managerId); + var nonExistentId = UUID.randomUUID(); + + assertThrows(ProjectNotFoundException.class, + () -> projectService.setTeamLead(nonExistentId, teamLeadId)); + } +} + diff --git a/src/test/java/org/lab/service/RoleBasedAccessTest.java b/src/test/java/org/lab/service/RoleBasedAccessTest.java new file mode 100644 index 0000000..3d4ed46 --- /dev/null +++ b/src/test/java/org/lab/service/RoleBasedAccessTest.java @@ -0,0 +1,165 @@ +package org.lab.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lab.TestBase; +import org.lab.auth.PermissionDeniedException; +import org.lab.auth.model.Permission; +import org.lab.auth.model.Role; +import org.lab.model.Project; + +import java.time.LocalDate; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class RoleBasedAccessTest extends TestBase { + + private UUID projectId; + private UUID milestoneId; + + @BeforeEach + void setUp() { + setCurrentUser(managerId); + var project = projectService.create(managerId, "Project", "Desc"); + projectId = project.id(); + + var milestone = milestoneService.create( + projectId, + LocalDate.now(), + LocalDate.now().plusDays(30) + ); + milestoneId = milestone.id(); + } + + @Test + void testManagerCanSetTeamLead() { + setCurrentUser(managerId); + assertDoesNotThrow(() -> + projectService.setTeamLead(projectId, teamLeadId)); + } + + @Test + void testManagerCanAddDeveloper() { + setCurrentUser(managerId); + assertDoesNotThrow(() -> + projectService.addDeveloper(projectId, developerId)); + } + + @Test + void testManagerCanAddTester() { + setCurrentUser(managerId); + assertDoesNotThrow(() -> + projectService.addTester(projectId, testerId)); + } + + @Test + void testManagerCanCreateMilestone() { + setCurrentUser(managerId); + assertDoesNotThrow(() -> + milestoneService.create(projectId, LocalDate.now(), LocalDate.now().plusDays(30))); + } + + @Test + void testManagerCanCreateTicket() { + setCurrentUser(managerId); + assertDoesNotThrow(() -> + ticketService.create(projectId, milestoneId, "Ticket")); + } + + @Test + void testTeamLeadCanCreateTicket() { + setCurrentUser(managerId); + projectService.setTeamLead(projectId, teamLeadId); + setCurrentUser(teamLeadId); + + assertDoesNotThrow(() -> + ticketService.create(projectId, milestoneId, "Ticket")); + } + + @Test + void testTeamLeadCanAssignDeveloper() { + setCurrentUser(managerId); + projectService.setTeamLead(projectId, teamLeadId); + projectService.addDeveloper(projectId, developerId); + + var ticket = ticketService.create(projectId, milestoneId, "Ticket"); + setCurrentUser(teamLeadId); + + assertDoesNotThrow(() -> + ticketService.assignDeveloper(ticket.id(), developerId)); + } + + @Test + void testDeveloperCannotSetTeamLead() { + setCurrentUser(managerId); + projectService.addDeveloper(projectId, developerId); + setCurrentUser(developerId); + + assertThrows(PermissionDeniedException.class, + () -> projectService.setTeamLead(projectId, teamLeadId)); + } + + @Test + void testDeveloperCanCompleteTicket() { + setCurrentUser(managerId); + projectService.addDeveloper(projectId, developerId); + var ticket = ticketService.create(projectId, milestoneId, "Ticket"); + ticketService.assignDeveloper(ticket.id(), developerId); + + setCurrentUser(developerId); + assertDoesNotThrow(() -> ticketService.complete(ticket.id())); + } + + @Test + void testDeveloperCanCreateBugReport() { + setCurrentUser(managerId); + projectService.addDeveloper(projectId, developerId); + setCurrentUser(developerId); + + assertDoesNotThrow(() -> + bugReportService.create(projectId, "Bug description")); + } + + @Test + void testDeveloperCanFixBugReport() { + setCurrentUser(managerId); + projectService.addDeveloper(projectId, developerId); + setCurrentUser(developerId); + + var bugReport = bugReportService.create(projectId, "Bug"); + assertDoesNotThrow(() -> bugReportService.fix(bugReport.id())); + } + + @Test + void testTesterCanTestProject() { + setCurrentUser(managerId); + projectService.addTester(projectId, testerId); + setCurrentUser(testerId); + + assertDoesNotThrow(() -> projectService.test(projectId)); + } + + @Test + void testTesterCanCreateBugReport() { + setCurrentUser(managerId); + projectService.addTester(projectId, testerId); + setCurrentUser(testerId); + + assertDoesNotThrow(() -> + bugReportService.create(projectId, "Bug description")); + } + + @Test + void testTesterCanTestBugReport() { + setCurrentUser(managerId); + projectService.addTester(projectId, testerId); + projectService.addDeveloper(projectId, developerId); + setCurrentUser(developerId); + var bugReport = bugReportService.create(projectId, "Bug"); + bugReportService.fix(bugReport.id()); + setCurrentUser(testerId); + assertDoesNotThrow(() -> bugReportService.test(bugReport.id())); + } +} + 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..dce654c --- /dev/null +++ b/src/test/java/org/lab/service/TicketServiceTest.java @@ -0,0 +1,124 @@ +package org.lab.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lab.TestBase; +import org.lab.auth.model.Role; +import org.lab.exception.TicketNotFoundException; +import org.lab.model.Milestone; +import org.lab.model.MilestoneStatus; +import org.lab.model.Ticket; +import org.lab.model.TicketStatus; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class TicketServiceTest extends TestBase { + + private UUID projectId; + private UUID milestoneId; + + @BeforeEach + void setUp() { + setCurrentUser(managerId); + var project = projectService.create(managerId, "Project", "Desc"); + projectId = project.id(); + + var milestone = milestoneService.create( + projectId, + LocalDate.now(), + LocalDate.now().plusDays(30) + ); + milestoneId = milestone.id(); + } + + @Test + void testCreateTicket() { + setCurrentUser(managerId); + var ticket = ticketService.create(projectId, milestoneId, "Test ticket"); + + assertNotNull(ticket); + assertNotNull(ticket.id()); + assertEquals(projectId, ticket.projectId()); + assertEquals(milestoneId, ticket.milestoneId()); + assertEquals("Test ticket", ticket.description()); + assertEquals(TicketStatus.NEW, ticket.status()); + } + + @Test + void testAssignDeveloper() { + setCurrentUser(managerId); + projectService.addDeveloper(projectId, developerId); + + var ticket = ticketService.create(projectId, milestoneId, "Ticket"); + ticketService.assignDeveloper(ticket.id(), developerId); + + var updated = ticketRepository.findById(ticket.id()).orElseThrow(); + assertTrue(updated.assignedDevelopers().contains(developerId)); + } + + @Test + void testAssignDeveloperDoesNotDuplicate() { + setCurrentUser(managerId); + projectService.addDeveloper(projectId, developerId); + + var ticket = ticketService.create(projectId, milestoneId, "Ticket"); + ticketService.assignDeveloper(ticket.id(), developerId); + ticketService.assignDeveloper(ticket.id(), developerId); + + var updated = ticketRepository.findById(ticket.id()).orElseThrow(); + assertEquals(1, updated.assignedDevelopers().size()); + } + + @Test + void testGetStatus() { + setCurrentUser(managerId); + projectService.addDeveloper(projectId, developerId); + + var ticket = ticketService.create(projectId, milestoneId, "Ticket"); + var status = ticketService.getStatus(ticket.id()); + + assertEquals(TicketStatus.NEW, status); + } + + @Test + void testCompleteTicket() { + setCurrentUser(managerId); + projectService.addDeveloper(projectId, developerId); + var ticket = ticketService.create(projectId, milestoneId, "Ticket"); + ticketService.assignDeveloper(ticket.id(), developerId); + setCurrentUser(developerId); + ticketService.complete(ticket.id()); + + var completed = ticketRepository.findById(ticket.id()).orElseThrow(); + assertEquals(TicketStatus.COMPLETED, completed.status()); + } + + @Test + void testListByUser() { + setCurrentUser(managerId); + projectService.addDeveloper(projectId, developerId); + + var ticket1 = ticketService.create(projectId, milestoneId, "Ticket 1"); + var ticket2 = ticketService.create(projectId, milestoneId, "Ticket 2"); + + ticketService.assignDeveloper(ticket1.id(), developerId); + ticketService.assignDeveloper(ticket2.id(), developerId); + + var userTickets = ticketService.listByUser(developerId); + assertEquals(2, userTickets.size()); + } + + @Test + void testTicketNotFound() { + setCurrentUser(managerId); + var nonExistentId = UUID.randomUUID(); + + assertThrows(TicketNotFoundException.class, + () -> ticketService.getStatus(nonExistentId)); + } +} + diff --git a/src/test/java/org/lab/service/UserServiceTest.java b/src/test/java/org/lab/service/UserServiceTest.java new file mode 100644 index 0000000..428d14a --- /dev/null +++ b/src/test/java/org/lab/service/UserServiceTest.java @@ -0,0 +1,41 @@ +package org.lab.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lab.TestBase; +import org.lab.model.User; +import org.lab.repository.inmemory.InMemoryUserRepository; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class UserServiceTest extends TestBase { + + @BeforeEach + void setUp() { + } + + @Test + void testRegister() { + var user = userService.register("John Doe"); + + assertNotNull(user); + assertNotNull(user.id()); + assertEquals("John Doe", user.name()); + assertNotNull(user.createdAt()); + assertTrue(user.createdAt().isBefore(LocalDateTime.now().plusSeconds(1))); + } + + @Test + void testRegisterMultipleUsers() { + var user1 = userService.register("User1"); + var user2 = userService.register("User2"); + + assertNotEquals(user1.id(), user2.id()); + assertEquals("User1", user1.name()); + assertEquals("User2", user2.name()); + } +} +