Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3ffdab48e | ||
|
|
5df9eff21e | ||
|
|
e5ff128502 | ||
|
|
169da1a9f4 | ||
|
|
64c9370aa0 | ||
|
|
32c8a2d315 |
@@ -0,0 +1,45 @@
|
||||
# Integration Test Evidence
|
||||
|
||||
## Requirement and scenario IDs
|
||||
|
||||
- AUTH-001, AUTH-002, AUTH-011; PRJ-003, PRJ-004, PRJ-017; ERR-001, ERR-003; TST-001 through TST-010.
|
||||
- AC-AUTH-001, AC-AUTH-010, AC-PRJ-001, AC-TST-001.
|
||||
|
||||
## Behavior under test
|
||||
|
||||
The owning Mentor adds several eligible nonmembers under one Project lock and transaction. Null, empty, duplicate, current-member, invalid, or stale/noneligible selections reject the whole batch; no valid prefix becomes a membership.
|
||||
|
||||
## Expected result derivation
|
||||
|
||||
The fixture begins with one Leader. A successful two-Intern batch must yield three current memberships. Every rejected batch leaves the eligible and stale candidate membership count at zero.
|
||||
|
||||
## RED
|
||||
|
||||
`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=ProjectControllerTest,ProjectServiceIntegrationTest' test` failed during test compilation with eight `cannot find symbol` errors for the requested `ProjectService.addMembers(long,long,List<Long>)` API. Production compiled first; the failure was the missing behavior boundary rather than the environment or fixture.
|
||||
|
||||
## GREEN
|
||||
|
||||
The focused PostgreSQL command was:
|
||||
|
||||
`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=ProjectServiceIntegrationTest#ownerAddsSeveralEligibleMembersInOneLockedTransaction+memberBatchRejectsMissingDuplicateCurrentAndStaleSelectionsWithoutPartialMutation' test`
|
||||
|
||||
Result: 2 tests, 0 failures, 0 errors, 0 skipped against PostgreSQL 18.4. The
|
||||
successful case added two memberships; the rejection case covered null, empty, duplicate,
|
||||
invalid, current-member, and one-valid-plus-one-stale selections without partial persistence.
|
||||
|
||||
## Affected suite
|
||||
|
||||
`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=ProjectServiceIntegrationTest' test`
|
||||
passed 9/9 tests with no failures, errors, or skips.
|
||||
|
||||
The complete Project plus layer-architecture command was:
|
||||
|
||||
`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=ProjectControllerTest,ProjectEntityTest,ProjectPersistenceStructureTest,ProjectServiceIntegrationTest,ProjectTaskMutationContextTest,LayerStructureTest' test`
|
||||
|
||||
Result: 38 tests, 0 failures, 0 errors, 0 skipped.
|
||||
|
||||
## External boundaries
|
||||
|
||||
PostgreSQL 18.4 Testcontainers provides the real schema, constraints, JPA transaction, and Project pessimistic lock path. The test does not exercise concurrent requests; existing Project locking coverage remains unchanged.
|
||||
|
||||
After merging exact reviewed `main` `32c8a2d315d2175760c5d4792988cd0aa5ab6dd0`, the affected command was rerun with `UiContractWebTest` included. It passed 45/45 tests with no failures, errors, or skips; the Project service portion remained 9/9 against PostgreSQL 18.4.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Web Test Evidence
|
||||
|
||||
## Requirement and scenario IDs
|
||||
|
||||
- AUTH-001, AUTH-002, AUTH-011; PRJ-001, PRJ-004, PRJ-005, PRJ-006, PRJ-017; UI-001, UI-005, UI-014, UI-018; TST-001 through TST-010.
|
||||
- AC-AUTH-001, AC-AUTH-010, AC-PRJ-001, AC-PRJ-003, AC-PRJ-009, AC-UI-005, AC-TST-001.
|
||||
|
||||
## Behavior under test
|
||||
|
||||
Project creation, direct member addition, and leadership reassignment render only server-provided eligible Intern choices. The native dialog picker exposes name, student code, and internship dates while numeric identifiers remain form values rather than visible labels. Local search, selection summaries, focus, apply, cancel, empty results, and retained server errors remain usable without adding a client API.
|
||||
|
||||
## Expected result derivation
|
||||
|
||||
The expected options are literal fixtures from the Account public DTO. Project membership history independently determines which eligible users are valid nonmembers or current-member leadership candidates. Native dialog controls keep server forms and CSRF as the mutation boundary.
|
||||
|
||||
## RED
|
||||
|
||||
`env PATH=/opt/homebrew/opt/node@24/bin:$PATH npm run test:ui` executed the dependency-free interaction contract first: 1 test, 1 failure. Opening the picker left `dialog.open` undefined because no picker behavior existed.
|
||||
|
||||
The combined Java RED command was `env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=ProjectControllerTest,ProjectServiceIntegrationTest' test`. After correcting test-only assertion imports, test compilation failed only because the requested `ProjectService.addMembers(long,long,List<Long>)` API did not exist. Controller rendering RED will be rerun after that producer API compiles.
|
||||
|
||||
After the producer API compiled, `env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=ProjectControllerTest' test` ran 19 tests with 4 expected assertion failures for the missing eligible-option model, filtered multi-select markup, and retained selection rendering. A separate no-roster regression ran 1 test with 1 assertion failure because the disabled picker trigger had no reachable explanatory copy.
|
||||
|
||||
Independent review added rendered regressions before the correction. The same focused controller command ran 22 tests with exactly 3 failures and no errors: both closed-dialog radio contracts detected browser `required`, and stale batch recovery lacked the count-only replacement message. The new missing-selection POST contracts already passed through server Bean Validation.
|
||||
|
||||
## GREEN
|
||||
|
||||
`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=ProjectControllerTest' test` passed the initial rendered picker suite at 19/19. After adding the no-roster regression, the affected Project command below passed the expanded controller suite at 20/20.
|
||||
|
||||
`env PATH=/opt/homebrew/opt/node@24/bin:$PATH npm run test:ui` passed 1/1 executable tests with no failures, proving local name/student-code filtering, summary updates, initial search focus, apply retention, cancel rollback, and opener focus restoration.
|
||||
|
||||
`env PATH=/opt/homebrew/opt/node@24/bin:$PATH npm run build` succeeded with Tailwind CSS 4.3.3 and the existing local icon builder. No dependency was added.
|
||||
|
||||
After the review correction, the focused controller command passed 22/22. Creation and leadership radios no longer use closed-dialog browser constraint validation; missing selections re-render their server field errors. A failed member batch retains submitted option 21 when refreshed eligibility contains only 21, omits all rendered value/ID markup for stale option 22, and reports one unavailable selection without exposing its identifier.
|
||||
|
||||
## Affected suite
|
||||
|
||||
`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=ProjectControllerTest,ProjectEntityTest,ProjectPersistenceStructureTest,ProjectServiceIntegrationTest,ProjectTaskMutationContextTest,LayerStructureTest' test` passed 38/38 tests with no failures, errors, or skips.
|
||||
|
||||
`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw -DskipTests compile` succeeded. Project-scoped `javadoc:javadoc` with `-Ddoclint=all` succeeded; it retained four non-fatal default-constructor warnings, including pre-existing advice/query types. `git diff --check` passed.
|
||||
|
||||
## External boundaries
|
||||
|
||||
No browser loop or Impeccable detector is run on this branch; the root owner performs one integrated pass. MockMvc proves rendered semantics and a dependency-free Node test executes the dialog/search/selection behavior with controlled DOM boundaries.
|
||||
|
||||
After merging exact reviewed `main` `32c8a2d315d2175760c5d4792988cd0aa5ab6dd0`, `npm ci`, the 1/1 UI test, frontend build, compile, Project-scoped Javadoc/doclint, and diff check all succeeded. The first affected Java command added the updated shared `UiContractWebTest` and passed 45/45 tests with no failures, errors, or skips.
|
||||
|
||||
The bounded post-review affected command reran `ProjectControllerTest,ProjectEntityTest,ProjectPersistenceStructureTest,ProjectServiceIntegrationTest,ProjectTaskMutationContextTest,LayerStructureTest,UiContractWebTest` and passed 47/47 with no failures, errors, or skips, including 9/9 Project service tests against PostgreSQL 18.4. The UI test remained 1/1; frontend build, compile, Project-scoped Javadoc/doclint, and `git diff --check` also succeeded.
|
||||
+2
-1
@@ -8,7 +8,8 @@
|
||||
"scripts": {
|
||||
"build": "npm run build:css && npm run build:icons",
|
||||
"build:css": "tailwindcss -i src/main/frontend/app.css -o src/main/resources/static/assets/app.css --minify",
|
||||
"build:icons": "node src/main/frontend/build-icons.mjs"
|
||||
"build:icons": "node src/main/frontend/build-icons.mjs",
|
||||
"test:ui": "node --test src/test/js/*.test.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "4.3.3",
|
||||
|
||||
@@ -120,6 +120,7 @@
|
||||
.primary-action { margin-left: auto; }
|
||||
.button { display: inline-flex; min-height: 2.35rem; align-items: center; justify-content: center; gap: .45rem; border: 1px solid var(--border-strong); border-radius: .5rem; padding: .5rem .8rem; background: var(--panel); color: var(--ink); font-weight: 650; text-decoration: none; cursor: pointer; }
|
||||
.button-primary { border-color: var(--ink); background: var(--ink); color: var(--panel); }
|
||||
.button:disabled { cursor: not-allowed; opacity: .55; }
|
||||
.button-danger { border-color: color-mix(in srgb, var(--danger), transparent 65%); background: color-mix(in srgb, var(--danger), transparent 90%); color: var(--danger); }
|
||||
.panel { border: 1px solid var(--border); border-radius: .75rem; background: var(--panel); box-shadow: 0 10px 28px rgb(20 25 35 / .06); }
|
||||
.panel-header { padding: .9rem 1rem; border-bottom: 1px solid var(--border); }
|
||||
@@ -170,6 +171,21 @@
|
||||
.notification-menu { min-width: 18rem; padding: .75rem; }
|
||||
dialog { max-width: 30rem; border: 1px solid var(--border); border-radius: .9rem; background: var(--panel); color: var(--ink); padding: 1.25rem; }
|
||||
dialog::backdrop { background: rgb(0 0 0 / .45); }
|
||||
.picker-trigger { justify-content: flex-start; }
|
||||
.picker-summary { margin: 0; color: var(--muted); font-size: .78rem; }
|
||||
.picker-drawer { width: min(32rem, 100%); max-width: 32rem; height: 100dvh; max-height: 100dvh; margin: 0 0 0 auto; border-radius: .9rem 0 0 .9rem; padding: 0; }
|
||||
.picker-header, .picker-footer { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; }
|
||||
.picker-header { border-bottom: 1px solid var(--border); }
|
||||
.picker-header .field-help { margin: .2rem 0 0; }
|
||||
.picker-body { display: grid; gap: .5rem; padding: 1rem; }
|
||||
.picker-options { display: grid; gap: .5rem; margin-top: .5rem; }
|
||||
.picker-option { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: .75rem; border: 1px solid var(--border); border-radius: .65rem; padding: .75rem; cursor: pointer; }
|
||||
.picker-option:hover { border-color: var(--border-strong); background: var(--panel-muted); }
|
||||
.picker-option input { margin-top: .2rem; }
|
||||
.picker-option span { display: grid; gap: .18rem; min-width: 0; }
|
||||
.picker-option small, .picker-empty { color: var(--muted); }
|
||||
.picker-empty { margin: 1rem 0; text-align: center; }
|
||||
.picker-footer { border-top: 1px solid var(--border); justify-content: flex-end; }
|
||||
@keyframes pulse { 50% { opacity: .45; } }
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; } }
|
||||
}
|
||||
|
||||
+83
-18
@@ -1,13 +1,21 @@
|
||||
package com.lab.labtimesheet.feature.project.controller;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption;
|
||||
import com.lab.labtimesheet.feature.account.service.AccountService;
|
||||
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
|
||||
import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateForm;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberForm;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectMembersForm;
|
||||
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
|
||||
import com.lab.labtimesheet.feature.project.service.ProjectService;
|
||||
import jakarta.validation.Valid;
|
||||
import java.security.Principal;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
@@ -33,6 +41,8 @@ public class ProjectController {
|
||||
|
||||
private final ProjectQueryService pages;
|
||||
private final ProjectService projects;
|
||||
private final AccountService accounts;
|
||||
private final Clock clock;
|
||||
|
||||
/**
|
||||
* Lists only Projects visible to the authenticated actor and exposes Project creation only
|
||||
@@ -64,6 +74,7 @@ public class ProjectController {
|
||||
throw new ProjectAccessDeniedException();
|
||||
}
|
||||
model.addAttribute("projectForm", new ProjectCreateForm());
|
||||
model.addAttribute("eligibleInternOptions", eligibleInternOptions());
|
||||
return "projects/form";
|
||||
}
|
||||
|
||||
@@ -73,22 +84,30 @@ public class ProjectController {
|
||||
* @param principal authenticated user
|
||||
* @param projectForm validated browser input
|
||||
* @param bindingResult binding and domain validation results
|
||||
* @param model response model used when validation fails
|
||||
* @return a redirect to the created Project, or the creation form on validation failure
|
||||
*/
|
||||
@PostMapping
|
||||
public String create(
|
||||
Principal principal,
|
||||
@Valid @ModelAttribute("projectForm") ProjectCreateForm projectForm,
|
||||
BindingResult bindingResult) {
|
||||
BindingResult bindingResult,
|
||||
Model model) {
|
||||
var actor = pages.authenticatedActor(principal.getName());
|
||||
if (!"MENTOR".equals(actor.role())) {
|
||||
throw new ProjectAccessDeniedException();
|
||||
}
|
||||
if (bindingResult.hasErrors()) {
|
||||
model.addAttribute("eligibleInternOptions", eligibleInternOptions());
|
||||
return "projects/form";
|
||||
}
|
||||
try {
|
||||
long projectId = projects.create(actorId(principal), projectForm.toCommand());
|
||||
long projectId = projects.create(actor.userId(), projectForm.toCommand());
|
||||
return "redirect:/projects/" + projectId;
|
||||
} catch (ProjectRuleViolationException exception) {
|
||||
bindingResult.rejectValue(
|
||||
"initialLeaderUserId", "project.initialLeader.ineligible", exception.getMessage());
|
||||
model.addAttribute("eligibleInternOptions", eligibleInternOptions());
|
||||
return "projects/form";
|
||||
}
|
||||
}
|
||||
@@ -139,41 +158,51 @@ public class ProjectController {
|
||||
@GetMapping("/{projectId}/members")
|
||||
public String members(Principal principal, @PathVariable long projectId, Model model) {
|
||||
long actorId = actorId(principal);
|
||||
model.addAttribute("project", pages.detail(actorId, projectId));
|
||||
model.addAttribute("members", pages.members(actorId, projectId));
|
||||
model.addAttribute("projectMemberForm", new ProjectMemberForm(null));
|
||||
populateMembersModel(actorId, projectId, model);
|
||||
model.addAttribute("projectMembersForm", new ProjectMembersForm());
|
||||
return "projects/members";
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an eligible Intern or re-renders membership history with the submitted identifier
|
||||
* and a safe validation message.
|
||||
* Adds all selected eligible Interns atomically or re-renders membership history with every
|
||||
* still-eligible selection retained and a count of unavailable choices.
|
||||
*
|
||||
* @param principal authenticated user
|
||||
* @param projectId owning Project identifier
|
||||
* @param memberForm validated Intern selection
|
||||
* @param membersForm validated Intern selection
|
||||
* @param bindingResult binding and domain validation results
|
||||
* @param model response model used on failure
|
||||
* @return a membership redirect after success, or the membership view on validation failure
|
||||
*/
|
||||
@PostMapping("/{projectId}/members")
|
||||
public String addMember(
|
||||
public String addMembers(
|
||||
Principal principal,
|
||||
@PathVariable long projectId,
|
||||
@Valid @ModelAttribute("projectMemberForm") ProjectMemberForm memberForm,
|
||||
@Valid @ModelAttribute("projectMembersForm") ProjectMembersForm membersForm,
|
||||
BindingResult bindingResult,
|
||||
Model model) {
|
||||
long actorId = actorId(principal);
|
||||
boolean rejectedByService = false;
|
||||
if (!bindingResult.hasErrors()) {
|
||||
try {
|
||||
projects.addMember(actorId, projectId, memberForm.internUserId());
|
||||
projects.addMembers(actorId, projectId, membersForm.internUserIds());
|
||||
return "redirect:/projects/" + projectId + "/members";
|
||||
} catch (ProjectRuleViolationException exception) {
|
||||
bindingResult.rejectValue("internUserId", "project.member.ineligible", exception.getMessage());
|
||||
rejectedByService = true;
|
||||
bindingResult.rejectValue(
|
||||
"internUserIds", "project.members.ineligible", exception.getMessage());
|
||||
}
|
||||
}
|
||||
model.addAttribute("project", pages.detail(actorId, projectId));
|
||||
model.addAttribute("members", pages.members(actorId, projectId));
|
||||
var refreshedOptions = populateMembersModel(actorId, projectId, model);
|
||||
if (rejectedByService) {
|
||||
Set<Long> refreshedIds = refreshedOptions.stream()
|
||||
.map(EligibleInternOption::userId)
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
long unavailableSelectionCount = membersForm.internUserIds().stream()
|
||||
.filter(userId -> !refreshedIds.contains(userId))
|
||||
.count();
|
||||
model.addAttribute("unavailableSelectionCount", unavailableSelectionCount);
|
||||
}
|
||||
return "projects/members";
|
||||
}
|
||||
|
||||
@@ -189,8 +218,7 @@ public class ProjectController {
|
||||
@GetMapping("/{projectId}/leadership")
|
||||
public String leadership(Principal principal, @PathVariable long projectId, Model model) {
|
||||
long actorId = actorId(principal);
|
||||
model.addAttribute("project", pages.detail(actorId, projectId));
|
||||
model.addAttribute("leadership", pages.leadership(actorId, projectId));
|
||||
populateLeadershipModel(actorId, projectId, model);
|
||||
model.addAttribute("projectMemberForm", new ProjectMemberForm(null));
|
||||
return "projects/leadership";
|
||||
}
|
||||
@@ -221,11 +249,48 @@ public class ProjectController {
|
||||
bindingResult.rejectValue("internUserId", "project.leader.ineligible", exception.getMessage());
|
||||
}
|
||||
}
|
||||
model.addAttribute("project", pages.detail(actorId, projectId));
|
||||
model.addAttribute("leadership", pages.leadership(actorId, projectId));
|
||||
populateLeadershipModel(actorId, projectId, model);
|
||||
return "projects/leadership";
|
||||
}
|
||||
|
||||
private List<EligibleInternOption> populateMembersModel(long actorId, long projectId, Model model) {
|
||||
var project = pages.detail(actorId, projectId);
|
||||
var members = pages.members(actorId, projectId);
|
||||
model.addAttribute("project", project);
|
||||
model.addAttribute("members", members);
|
||||
if (project.canManage()) {
|
||||
Set<Long> currentMemberIds = members.stream()
|
||||
.filter(member -> member.leftAt() == null)
|
||||
.map(member -> member.internUserId())
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
var options = eligibleInternOptions().stream()
|
||||
.filter(option -> !currentMemberIds.contains(option.userId()))
|
||||
.toList();
|
||||
model.addAttribute("eligibleInternOptions", options);
|
||||
return options;
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private void populateLeadershipModel(long actorId, long projectId, Model model) {
|
||||
var project = pages.detail(actorId, projectId);
|
||||
model.addAttribute("project", project);
|
||||
model.addAttribute("leadership", pages.leadership(actorId, projectId));
|
||||
if (project.canManage()) {
|
||||
Set<Long> replacementIds = pages.members(actorId, projectId).stream()
|
||||
.filter(member -> member.leftAt() == null && !member.currentLeader())
|
||||
.map(member -> member.internUserId())
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
model.addAttribute("eligibleInternOptions", eligibleInternOptions().stream()
|
||||
.filter(option -> replacementIds.contains(option.userId()))
|
||||
.toList());
|
||||
}
|
||||
}
|
||||
|
||||
private List<EligibleInternOption> eligibleInternOptions() {
|
||||
return accounts.eligibleInternOptions(LocalDate.now(clock));
|
||||
}
|
||||
|
||||
private long actorId(Principal principal) {
|
||||
return pages.authenticatedUserId(principal.getName());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.lab.labtimesheet.feature.project.model.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Browser form for one atomic owning-Mentor direct-add selection.
|
||||
*
|
||||
* @param internUserIds distinct positive Intern account identifiers selected in the picker
|
||||
*/
|
||||
public record ProjectMembersForm(@NotEmpty List<@Positive Long> internUserIds) {
|
||||
|
||||
/** Creates an empty form for the initial membership page. */
|
||||
public ProjectMembersForm() {
|
||||
this(List.of());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.lab.labtimesheet.feature.project.service;
|
||||
|
||||
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
|
||||
import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException;
|
||||
import com.lab.labtimesheet.feature.account.service.AccountService;
|
||||
import com.lab.labtimesheet.feature.project.model.ProjectInternEligibility;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand;
|
||||
@@ -9,6 +10,9 @@ import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity;
|
||||
import com.lab.labtimesheet.feature.project.repository.ProjectRepository;
|
||||
import com.lab.labtimesheet.feature.task.service.TaskQueryService;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -68,9 +72,42 @@ public class ProjectService {
|
||||
*/
|
||||
@Transactional
|
||||
public void addMember(long actorUserId, long projectId, long internUserId) {
|
||||
addMembers(actorUserId, projectId, List.of(internUserId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a complete selection of eligible nonmembers while holding one Project write lock.
|
||||
* Every identifier is revalidated after owner authorization and before the aggregate changes,
|
||||
* so missing, duplicate, stale, ineligible, or current-member selections leave membership
|
||||
* unchanged.
|
||||
*
|
||||
* @param actorUserId authenticated owning Mentor
|
||||
* @param projectId Project to update
|
||||
* @param internUserIds distinct eligible Intern account identifiers
|
||||
* @throws com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException when
|
||||
* the selection is null, empty, malformed, duplicate, stale, ineligible, or already
|
||||
* contains a current member
|
||||
*/
|
||||
@Transactional
|
||||
public void addMembers(long actorUserId, long projectId, List<Long> internUserIds) {
|
||||
var project = lockedProject(projectId);
|
||||
project.authorizeOwner(actorUserId);
|
||||
project.addMember(actorUserId, eligibleIntern(internUserId), clock.instant());
|
||||
if (internUserIds == null || internUserIds.isEmpty()) {
|
||||
throw new ProjectRuleViolationException("Select at least one Intern");
|
||||
}
|
||||
if (internUserIds.stream().anyMatch(userId -> userId == null || userId <= 0)
|
||||
|| new HashSet<>(internUserIds).size() != internUserIds.size()) {
|
||||
throw new ProjectRuleViolationException("Intern selection is invalid");
|
||||
}
|
||||
|
||||
var selectedInterns = internUserIds.stream().map(this::eligibleIntern).toList();
|
||||
if (selectedInterns.stream().anyMatch(intern -> !intern.isEligible())
|
||||
|| selectedInterns.stream().anyMatch(intern -> project.hasCurrentMember(intern.userId()))) {
|
||||
throw new ProjectRuleViolationException("One or more selected Interns are no longer eligible");
|
||||
}
|
||||
|
||||
var addedAt = clock.instant();
|
||||
selectedInterns.forEach(intern -> project.addMember(actorUserId, intern, addedAt));
|
||||
projects.flush();
|
||||
}
|
||||
|
||||
@@ -145,7 +182,7 @@ public class ProjectService {
|
||||
}
|
||||
|
||||
private ProjectInternEligibility eligibleIntern(long userId) {
|
||||
return new ProjectInternEligibility(userId, accounts.isEligibleIntern(userId));
|
||||
return new ProjectInternEligibility(userId, accounts.isEligibleIntern(userId, LocalDate.now(clock)));
|
||||
}
|
||||
|
||||
private void requireActiveMentor(long userId) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -37,4 +37,57 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
try { localStorage.setItem('labtimesheet-sidebar', collapsed ? 'collapsed' : 'expanded'); }
|
||||
catch (_) { /* Collapse still works for this page. */ }
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-intern-picker]').forEach((picker) => {
|
||||
const open = picker.querySelector('[data-picker-open]');
|
||||
const dialog = picker.querySelector('[data-picker-dialog]');
|
||||
const search = picker.querySelector('[data-picker-search]');
|
||||
const summary = picker.querySelector('[data-picker-summary]');
|
||||
const empty = picker.querySelector('[data-picker-empty]');
|
||||
const cancel = picker.querySelector('[data-picker-cancel]');
|
||||
const apply = picker.querySelector('[data-picker-apply]');
|
||||
const options = [...picker.querySelectorAll('[data-picker-option]')];
|
||||
let initialSelection = [];
|
||||
|
||||
const inputs = () => options.map((option) => option.querySelector('input'));
|
||||
const updateSummary = () => {
|
||||
const selected = options
|
||||
.filter((option) => option.querySelector('input').checked)
|
||||
.map((option) => option.querySelector('[data-picker-label]').textContent.trim());
|
||||
summary.textContent = selected.length === 0
|
||||
? `No Intern${inputs()[0]?.type === 'radio' ? '' : 's'} selected`
|
||||
: `${selected.length} Intern${selected.length === 1 ? '' : 's'} selected: ${selected.join(', ')}`;
|
||||
};
|
||||
const filter = () => {
|
||||
const query = search.value.trim().toLocaleLowerCase();
|
||||
let visible = 0;
|
||||
options.forEach((option) => {
|
||||
option.hidden = !option.dataset.pickerSearch.toLocaleLowerCase().includes(query);
|
||||
if (!option.hidden) visible += 1;
|
||||
});
|
||||
empty.hidden = visible !== 0;
|
||||
};
|
||||
const restore = () => {
|
||||
inputs().forEach((input, index) => { input.checked = initialSelection[index]; });
|
||||
updateSummary();
|
||||
};
|
||||
|
||||
inputs().forEach((input) => input.addEventListener('change', updateSummary));
|
||||
search.addEventListener('input', filter);
|
||||
open.addEventListener('click', () => {
|
||||
initialSelection = inputs().map((input) => input.checked);
|
||||
search.value = '';
|
||||
filter();
|
||||
dialog.showModal();
|
||||
search.focus();
|
||||
});
|
||||
cancel.addEventListener('click', () => {
|
||||
restore();
|
||||
dialog.close();
|
||||
});
|
||||
dialog.addEventListener('cancel', restore);
|
||||
dialog.addEventListener('close', () => open.focus());
|
||||
apply.addEventListener('click', () => dialog.close());
|
||||
updateSummary();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,10 +26,29 @@
|
||||
<div class="form-grid form-grid-three">
|
||||
<div class="field"><label class="field-label" for="startDate">Start date</label><input class="control" id="startDate" type="date" th:field="*{startDate}" required th:attr="aria-invalid=${#fields.hasErrors('startDate')},aria-describedby=${#fields.hasErrors('startDate') ? 'startDate-error' : null}"><p class="field-error" id="startDate-error" role="alert" th:if="${#fields.hasErrors('startDate')}" th:errors="*{startDate}">Start date error</p></div>
|
||||
<div class="field"><label class="field-label" for="endDate">End date</label><input class="control" id="endDate" type="date" th:field="*{endDate}" required th:attr="aria-invalid=${#fields.hasErrors('endDate') or #fields.hasErrors('dateRangeValid')},aria-describedby=${#fields.hasErrors('endDate') ? 'endDate-error' : (#fields.hasErrors('dateRangeValid') ? 'dateRangeValid-error' : null)}"><p class="field-error" id="endDate-error" role="alert" th:if="${#fields.hasErrors('endDate')}" th:errors="*{endDate}">End date error</p><p class="field-error" id="dateRangeValid-error" role="alert" th:if="${#fields.hasErrors('dateRangeValid')}" th:errors="*{dateRangeValid}">Date range error</p></div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="leader">Initial Leader user ID</label>
|
||||
<input class="control" id="leader" type="number" min="1" th:field="*{initialLeaderUserId}" required th:attr="aria-invalid=${#fields.hasErrors('initialLeaderUserId')},aria-describedby=${#fields.hasErrors('initialLeaderUserId') ? 'initialLeaderUserId-error' : null}">
|
||||
<div class="field" data-intern-picker>
|
||||
<span class="field-label">Initial Leader</span>
|
||||
<button class="button picker-trigger" type="button" data-picker-open
|
||||
th:disabled="${#lists.isEmpty(eligibleInternOptions)}"
|
||||
th:attr="aria-invalid=${#fields.hasErrors('initialLeaderUserId')},aria-describedby=${#fields.hasErrors('initialLeaderUserId') ? 'initialLeaderUserId-error' : null}">Choose an eligible Intern</button>
|
||||
<p class="picker-summary" data-picker-summary aria-live="polite">No Intern selected</p>
|
||||
<p class="field-help" th:if="${#lists.isEmpty(eligibleInternOptions)}">No eligible Interns are available.</p>
|
||||
<p class="field-error" id="initialLeaderUserId-error" role="alert" th:if="${#fields.hasErrors('initialLeaderUserId')}" th:errors="*{initialLeaderUserId}">Leader error</p>
|
||||
<dialog class="picker-drawer" data-picker-dialog aria-labelledby="leader-picker-title">
|
||||
<div class="picker-header"><div><h2 class="panel-title" id="leader-picker-title">Choose initial Leader</h2><p class="field-help">Only currently eligible Interns are available.</p></div><button class="button" type="button" data-picker-cancel>Cancel</button></div>
|
||||
<div class="picker-body">
|
||||
<label class="field-label" for="leader-search">Search by name or Student Code</label>
|
||||
<input class="control" id="leader-search" type="search" autocomplete="off" data-picker-search>
|
||||
<div class="picker-options">
|
||||
<label class="picker-option" data-picker-option th:each="option : ${eligibleInternOptions}" th:attr="data-picker-search=${option.displayName + ' ' + option.studentCode}">
|
||||
<input type="radio" th:field="*{initialLeaderUserId}" th:value="${option.userId}">
|
||||
<span><strong data-picker-label th:text="|${option.displayName} (${option.studentCode})|">Intern (Code)</strong><small th:text="|${#temporals.format(option.internshipStart, 'dd/MM/yyyy')} – ${#temporals.format(option.internshipEnd, 'dd/MM/yyyy')}|">Dates</small></span>
|
||||
</label>
|
||||
<p class="picker-empty" data-picker-empty th:hidden="${!#lists.isEmpty(eligibleInternOptions)}">No matching eligible Interns.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="picker-footer"><button class="button button-primary" type="button" data-picker-apply>Use selection</button></div>
|
||||
</dialog>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions"><a class="button" th:href="@{/projects}">Cancel</a><button class="button button-primary" type="submit">Create Project</button></div>
|
||||
|
||||
@@ -20,10 +20,32 @@
|
||||
<tbody><tr th:each="term : ${leadership}"><td th:text="${term.leaderName}">Leader</td><td th:text="${#temporals.format(term.startedAt, 'dd/MM/yyyy HH:mm')}">Started</td><td th:text="${term.endedAt == null ? 'Current' : #temporals.format(term.endedAt, 'dd/MM/yyyy HH:mm')}">Current</td></tr></tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
<form class="panel form-panel filter-form" th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/leadership(id=${project.id})}" th:object="${projectMemberForm}">
|
||||
<form class="panel form-panel form-grid" th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/leadership(id=${project.id})}" th:object="${projectMemberForm}">
|
||||
<div class="alert alert-error" role="alert" th:if="${#fields.hasAnyErrors()}">Please correct the Leader selection.</div>
|
||||
<div class="field"><label class="field-label" for="leader">New Leader user ID</label><input class="control" id="leader" th:field="*{internUserId}" type="number" min="1" required th:attr="aria-invalid=${#fields.hasErrors('internUserId')},aria-describedby=${#fields.hasErrors('internUserId') ? 'leadership-intern-user-error' : null}"><p class="field-error" id="leadership-intern-user-error" role="alert" th:if="${#fields.hasErrors('internUserId')}" th:errors="*{internUserId}"></p></div>
|
||||
<span></span><button class="button button-primary" type="submit">Change Leader</button>
|
||||
<div class="field" data-intern-picker>
|
||||
<span class="field-label">New Leader</span>
|
||||
<button class="button picker-trigger" type="button" data-picker-open th:disabled="${#lists.isEmpty(eligibleInternOptions)}"
|
||||
th:attr="aria-invalid=${#fields.hasErrors('internUserId')},aria-describedby=${#fields.hasErrors('internUserId') ? 'leadership-intern-user-error' : null}">Choose a current member</button>
|
||||
<p class="picker-summary" data-picker-summary aria-live="polite">No Intern selected</p>
|
||||
<p class="field-help" th:if="${#lists.isEmpty(eligibleInternOptions)}">No eligible current members are available.</p>
|
||||
<p class="field-error" id="leadership-intern-user-error" role="alert" th:if="${#fields.hasErrors('internUserId')}" th:errors="*{internUserId}"></p>
|
||||
<dialog class="picker-drawer" data-picker-dialog aria-labelledby="leadership-picker-title">
|
||||
<div class="picker-header"><div><h2 class="panel-title" id="leadership-picker-title">Choose new Leader</h2><p class="field-help">Only eligible current members other than the current Leader are available.</p></div><button class="button" type="button" data-picker-cancel>Cancel</button></div>
|
||||
<div class="picker-body">
|
||||
<label class="field-label" for="leadership-search">Search by name or Student Code</label>
|
||||
<input class="control" id="leadership-search" type="search" autocomplete="off" data-picker-search>
|
||||
<div class="picker-options">
|
||||
<label class="picker-option" data-picker-option th:each="option : ${eligibleInternOptions}" th:attr="data-picker-search=${option.displayName + ' ' + option.studentCode}">
|
||||
<input type="radio" th:field="*{internUserId}" th:value="${option.userId}">
|
||||
<span><strong data-picker-label th:text="|${option.displayName} (${option.studentCode})|">Intern (Code)</strong><small th:text="|${#temporals.format(option.internshipStart, 'dd/MM/yyyy')} – ${#temporals.format(option.internshipEnd, 'dd/MM/yyyy')}|">Dates</small></span>
|
||||
</label>
|
||||
<p class="picker-empty" data-picker-empty th:hidden="${!#lists.isEmpty(eligibleInternOptions)}">No matching eligible current members.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="picker-footer"><button class="button button-primary" type="button" data-picker-apply>Use selection</button></div>
|
||||
</dialog>
|
||||
</div>
|
||||
<div class="form-actions"><button class="button button-primary" type="submit">Change Leader</button></div>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -20,10 +20,34 @@
|
||||
<tbody><tr th:each="member : ${members}"><td th:text="${member.displayName}">Intern</td><td th:text="${#temporals.format(member.joinedAt, 'dd/MM/yyyy HH:mm')}">Joined</td><td th:text="${member.leftAt == null ? 'Current' : #temporals.format(member.leftAt, 'dd/MM/yyyy HH:mm')}">Current</td><td><span class="badge" th:classappend="${member.currentLeader ? ' badge-success' : ''}" th:text="${member.currentLeader ? 'Leader' : 'Member'}">Member</span></td></tr></tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
<form class="panel form-panel filter-form" th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/members(id=${project.id})}" th:object="${projectMemberForm}">
|
||||
<div class="alert alert-error" role="alert" th:if="${#fields.hasAnyErrors()}">Please correct the member selection.</div>
|
||||
<div class="field"><label class="field-label" for="intern">Intern user ID</label><input class="control" id="intern" th:field="*{internUserId}" type="number" min="1" required th:attr="aria-invalid=${#fields.hasErrors('internUserId')},aria-describedby=${#fields.hasErrors('internUserId') ? 'member-intern-user-error' : null}"><p class="field-error" id="member-intern-user-error" role="alert" th:if="${#fields.hasErrors('internUserId')}" th:errors="*{internUserId}"></p></div>
|
||||
<span></span><button class="button button-primary" type="submit">Add member</button>
|
||||
<form class="panel form-panel form-grid" th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/members(id=${project.id})}" th:object="${projectMembersForm}">
|
||||
<div class="alert alert-error" role="alert" th:if="${#fields.hasAnyErrors()}"><strong>Member selection could not be saved.</strong><ul><li th:each="fieldError : ${#fields.allErrors()}" th:text="${fieldError}">Selection error</li></ul></div>
|
||||
<div class="field" data-intern-picker>
|
||||
<span class="field-label">Interns to add</span>
|
||||
<button class="button picker-trigger" type="button" data-picker-open th:disabled="${#lists.isEmpty(eligibleInternOptions)}"
|
||||
th:attr="aria-invalid=${#fields.hasErrors('internUserIds')},aria-describedby=${#fields.hasErrors('internUserIds') ? 'member-intern-user-error' : null}">Choose eligible Interns</button>
|
||||
<p class="picker-summary" data-picker-summary aria-live="polite">No Interns selected</p>
|
||||
<p class="field-help" th:if="${#lists.isEmpty(eligibleInternOptions)}">No eligible nonmembers are available.</p>
|
||||
<p class="field-error" id="member-intern-user-error" role="alert" th:if="${#fields.hasErrors('internUserIds')}" th:errors="*{internUserIds}"></p>
|
||||
<p class="field-help" role="status" th:if="${unavailableSelectionCount != null and unavailableSelectionCount > 0}"
|
||||
th:text="${unavailableSelectionCount == 1 ? '1 previously selected Intern is no longer eligible; choose a replacement.' : unavailableSelectionCount + ' previously selected Interns are no longer eligible; choose replacements.'}">Unavailable selection recovery</p>
|
||||
<dialog class="picker-drawer" data-picker-dialog aria-labelledby="member-picker-title">
|
||||
<div class="picker-header"><div><h2 class="panel-title" id="member-picker-title">Add Project members</h2><p class="field-help">Select one or more eligible Interns who are not current members.</p></div><button class="button" type="button" data-picker-cancel>Cancel</button></div>
|
||||
<div class="picker-body">
|
||||
<label class="field-label" for="member-search">Search by name or Student Code</label>
|
||||
<input class="control" id="member-search" type="search" autocomplete="off" data-picker-search>
|
||||
<div class="picker-options">
|
||||
<label class="picker-option" data-picker-option th:each="option : ${eligibleInternOptions}" th:attr="data-picker-search=${option.displayName + ' ' + option.studentCode}">
|
||||
<input type="checkbox" th:field="*{internUserIds}" th:value="${option.userId}">
|
||||
<span><strong data-picker-label th:text="|${option.displayName} (${option.studentCode})|">Intern (Code)</strong><small th:text="|${#temporals.format(option.internshipStart, 'dd/MM/yyyy')} – ${#temporals.format(option.internshipEnd, 'dd/MM/yyyy')}|">Dates</small></span>
|
||||
</label>
|
||||
<p class="picker-empty" data-picker-empty th:hidden="${!#lists.isEmpty(eligibleInternOptions)}">No matching eligible Interns.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="picker-footer"><button class="button button-primary" type="button" data-picker-apply>Use selection</button></div>
|
||||
</dialog>
|
||||
</div>
|
||||
<div class="form-actions"><button class="button button-primary" type="submit">Add selected members</button></div>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
+194
-6
@@ -1,5 +1,7 @@
|
||||
package com.lab.labtimesheet.feature.project.controller;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -17,6 +19,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
|
||||
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
|
||||
import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException;
|
||||
import com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption;
|
||||
import com.lab.labtimesheet.feature.account.service.AccountService;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectDetail;
|
||||
@@ -27,9 +31,13 @@ import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
|
||||
import com.lab.labtimesheet.feature.project.service.ProjectService;
|
||||
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
|
||||
import java.time.Instant;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -50,9 +58,120 @@ class ProjectControllerTest {
|
||||
@MockitoBean
|
||||
private ProjectService projects;
|
||||
|
||||
@MockitoBean
|
||||
private AccountService accounts;
|
||||
|
||||
@MockitoBean
|
||||
private Clock clock;
|
||||
|
||||
@MockitoBean
|
||||
private SmtpConfigurationService smtpConfiguration;
|
||||
|
||||
@BeforeEach
|
||||
void serverBusinessDate() {
|
||||
when(clock.instant()).thenReturn(Instant.parse("2026-08-15T01:00:00Z"));
|
||||
when(clock.getZone()).thenReturn(ZoneId.of("Asia/Ho_Chi_Minh"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "mentor@example.test")
|
||||
void projectCreationRendersSearchableEligibleLeaderOptionsWithoutVisibleNumericIds() throws Exception {
|
||||
when(pages.authenticatedActor("mentor@example.test"))
|
||||
.thenReturn(new ProjectActorView(10L, "MENTOR"));
|
||||
when(accounts.eligibleInternOptions(LocalDate.of(2026, 8, 15))).thenReturn(List.of(
|
||||
option(20L, "Nguyen An", "STU-020"),
|
||||
option(21L, "Tran Binh", "STU-021")));
|
||||
|
||||
String html = mvc.perform(get("/projects/new"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(model().attributeExists("eligibleInternOptions"))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(containsString("data-intern-picker")))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(containsString("type=\"radio\"")))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(containsString("Nguyen An")))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(containsString("STU-020")))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(containsString("01/08/2026 – 31/12/2026")))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(not(containsString("Initial Leader user ID"))))
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
assertFalse(containsRequiredRadio(html));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "mentor@example.test")
|
||||
void projectCreationExplainsWhenNoEligibleLeaderIsAvailable() throws Exception {
|
||||
when(pages.authenticatedActor("mentor@example.test"))
|
||||
.thenReturn(new ProjectActorView(10L, "MENTOR"));
|
||||
when(accounts.eligibleInternOptions(LocalDate.of(2026, 8, 15))).thenReturn(List.of());
|
||||
|
||||
mvc.perform(get("/projects/new"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(containsString("No eligible Interns are available.")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "mentor@example.test")
|
||||
void memberAndLeadershipPickersExposeOnlyValidServerFilteredOptions() throws Exception {
|
||||
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
|
||||
when(pages.detail(10L, 30L)).thenReturn(plannedOwnerDetail());
|
||||
when(pages.members(10L, 30L)).thenReturn(List.of(
|
||||
new ProjectMemberView(40L, 20L, "Current Leader", Instant.parse("2026-08-15T00:00:00Z"), null, true),
|
||||
new ProjectMemberView(41L, 21L, "Current Member", Instant.parse("2026-08-15T00:00:00Z"), null, false)));
|
||||
when(pages.leadership(10L, 30L)).thenReturn(List.of());
|
||||
when(accounts.eligibleInternOptions(LocalDate.of(2026, 8, 15))).thenReturn(List.of(
|
||||
option(20L, "Current Leader", "STU-020"),
|
||||
option(21L, "Current Member", "STU-021"),
|
||||
option(22L, "Eligible Nonmember", "STU-022")));
|
||||
|
||||
String membersHtml = mvc.perform(get("/projects/30/members"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
assertTrue(membersHtml.contains("name=\"internUserIds\""));
|
||||
assertTrue(membersHtml.contains("Eligible Nonmember"));
|
||||
assertFalse(membersHtml.contains("data-picker-label>Current Member"));
|
||||
|
||||
String leadershipHtml = mvc.perform(get("/projects/30/leadership"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
assertTrue(leadershipHtml.contains("type=\"radio\""));
|
||||
assertTrue(leadershipHtml.contains("Current Member"));
|
||||
assertFalse(leadershipHtml.contains("Eligible Nonmember"));
|
||||
assertFalse(leadershipHtml.contains("data-picker-label>Current Leader"));
|
||||
assertFalse(containsRequiredRadio(leadershipHtml));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "mentor@example.test")
|
||||
void rejectedMemberBatchRetainsEligibleSelectionsAndExplainsUnavailableCountWithoutIds() throws Exception {
|
||||
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
|
||||
when(pages.detail(10L, 30L)).thenReturn(plannedOwnerDetail());
|
||||
when(pages.members(10L, 30L)).thenReturn(List.of());
|
||||
when(accounts.eligibleInternOptions(LocalDate.of(2026, 8, 15))).thenReturn(List.of(
|
||||
option(21L, "First Intern", "STU-021")));
|
||||
doThrow(new ProjectRuleViolationException("One or more selected Interns are no longer eligible"))
|
||||
.when(projects).addMembers(10L, 30L, List.of(21L, 22L));
|
||||
|
||||
mvc.perform(post("/projects/30/members")
|
||||
.with(csrf())
|
||||
.param("internUserIds", "21", "22"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("projects/members"))
|
||||
.andExpect(model().attributeHasFieldErrors("projectMembersForm", "internUserIds"))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(containsString("One or more selected Interns are no longer eligible")))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(containsString("1 previously selected Intern is no longer eligible; choose a replacement.")))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(containsString("value=\"21\" id=\"internUserIds1\" name=\"internUserIds\" checked=\"checked\"")))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(not(containsString("value=\"22\""))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "mentor@example.test")
|
||||
void listsOnlyTheAuthenticatedUsersAuthorizedProjects() throws Exception {
|
||||
@@ -144,7 +263,8 @@ class ProjectControllerTest {
|
||||
@Test
|
||||
@WithMockUser(username = "mentor@example.test")
|
||||
void validCreateSubmissionUsesAuthenticatedMentorAndRedirectsToDetail() throws Exception {
|
||||
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
|
||||
when(pages.authenticatedActor("mentor@example.test"))
|
||||
.thenReturn(new ProjectActorView(10L, "MENTOR"));
|
||||
when(projects.create(
|
||||
10L,
|
||||
new ProjectCreateCommand(
|
||||
@@ -166,6 +286,50 @@ class ProjectControllerTest {
|
||||
.andExpect(redirectedUrl("/projects/30"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "mentor@example.test")
|
||||
void missingInitialLeaderReRendersServerFieldError() throws Exception {
|
||||
when(pages.authenticatedActor("mentor@example.test"))
|
||||
.thenReturn(new ProjectActorView(10L, "MENTOR"));
|
||||
|
||||
mvc.perform(post("/projects")
|
||||
.with(csrf())
|
||||
.param("name", "Intern Portal Refresh")
|
||||
.param("startDate", "2026-08-15")
|
||||
.param("endDate", "2026-09-30"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("projects/form"))
|
||||
.andExpect(model().attributeHasFieldErrors("projectForm", "initialLeaderUserId"))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(containsString("id=\"initialLeaderUserId-error\"")));
|
||||
|
||||
verify(projects, never()).create(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "mentor@example.test")
|
||||
void missingReplacementLeaderReRendersServerFieldError() throws Exception {
|
||||
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
|
||||
when(pages.detail(10L, 30L)).thenReturn(plannedOwnerDetail());
|
||||
when(pages.leadership(10L, 30L)).thenReturn(List.of());
|
||||
when(pages.members(10L, 30L)).thenReturn(List.of(new ProjectMemberView(
|
||||
41L, 21L, "Current Member", Instant.parse("2026-08-15T00:00:00Z"), null, false)));
|
||||
when(accounts.eligibleInternOptions(LocalDate.of(2026, 8, 15))).thenReturn(List.of(
|
||||
option(21L, "Current Member", "STU-021")));
|
||||
|
||||
mvc.perform(post("/projects/30/leadership").with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("projects/leadership"))
|
||||
.andExpect(model().attributeHasFieldErrors("projectMemberForm", "internUserId"))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(containsString("id=\"leadership-intern-user-error\"")));
|
||||
|
||||
verify(projects, never()).changeLeader(
|
||||
org.mockito.ArgumentMatchers.anyLong(),
|
||||
org.mockito.ArgumentMatchers.anyLong(),
|
||||
org.mockito.ArgumentMatchers.anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "mentor@example.test")
|
||||
void owningMentorCanActivateAPlannedProject() throws Exception {
|
||||
@@ -217,6 +381,9 @@ class ProjectControllerTest {
|
||||
@Test
|
||||
@WithMockUser(username = "mentor@example.test")
|
||||
void invalidCreateSubmissionStaysOnSafeFormWithoutMutation() throws Exception {
|
||||
when(pages.authenticatedActor("mentor@example.test"))
|
||||
.thenReturn(new ProjectActorView(10L, "MENTOR"));
|
||||
|
||||
mvc.perform(post("/projects")
|
||||
.with(csrf())
|
||||
.param("name", " ")
|
||||
@@ -239,6 +406,8 @@ class ProjectControllerTest {
|
||||
@Test
|
||||
@WithMockUser(username = "mentor@example.test")
|
||||
void domainValidationErrorsStayOnTheirSafeFormsWithRetainedInput() throws Exception {
|
||||
when(pages.authenticatedActor("mentor@example.test"))
|
||||
.thenReturn(new ProjectActorView(10L, "MENTOR"));
|
||||
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
|
||||
when(pages.detail(10L, 30L)).thenReturn(plannedOwnerDetail());
|
||||
when(pages.members(10L, 30L)).thenReturn(List.of(new ProjectMemberView(
|
||||
@@ -255,7 +424,7 @@ class ProjectControllerTest {
|
||||
99L)))
|
||||
.thenThrow(new ProjectRuleViolationException("Intern must have an active account and internship"));
|
||||
doThrow(new ProjectRuleViolationException("Intern is already a current Project member"))
|
||||
.when(projects).addMember(10L, 30L, 20L);
|
||||
.when(projects).addMembers(10L, 30L, List.of(20L));
|
||||
doThrow(new ProjectRuleViolationException("Selected Intern is already the current Leader"))
|
||||
.when(projects).changeLeader(10L, 30L, 20L);
|
||||
|
||||
@@ -274,12 +443,14 @@ class ProjectControllerTest {
|
||||
|
||||
mvc.perform(post("/projects/30/members")
|
||||
.with(csrf())
|
||||
.param("internUserId", "20"))
|
||||
.param("internUserIds", "20"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("projects/members"))
|
||||
.andExpect(model().attributeHasFieldErrors("projectMemberForm", "internUserId"))
|
||||
.andExpect(model().attributeHasFieldErrors("projectMembersForm", "internUserIds"))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(containsString("value=\"20\"")));
|
||||
.string(containsString("Intern is already a current Project member")))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(not(containsString("value=\"20\""))));
|
||||
|
||||
mvc.perform(post("/projects/30/leadership")
|
||||
.with(csrf())
|
||||
@@ -288,7 +459,9 @@ class ProjectControllerTest {
|
||||
.andExpect(view().name("projects/leadership"))
|
||||
.andExpect(model().attributeHasFieldErrors("projectMemberForm", "internUserId"))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(containsString("value=\"20\"")));
|
||||
.string(containsString("Selected Intern is already the current Leader")))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(not(containsString("value=\"20\""))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -388,4 +561,19 @@ class ProjectControllerTest {
|
||||
"Current Leader",
|
||||
true);
|
||||
}
|
||||
|
||||
private static EligibleInternOption option(long userId, String name, String studentCode) {
|
||||
return new EligibleInternOption(
|
||||
userId,
|
||||
name,
|
||||
studentCode,
|
||||
LocalDate.of(2026, 8, 1),
|
||||
LocalDate.of(2026, 12, 31));
|
||||
}
|
||||
|
||||
private static boolean containsRequiredRadio(String html) {
|
||||
return Pattern.compile("<input(?=[^>]*type=\\\"radio\\\")(?=[^>]*required(?:=|\\s|>))[^>]*>")
|
||||
.matcher(html)
|
||||
.find();
|
||||
}
|
||||
}
|
||||
|
||||
+47
@@ -106,6 +106,53 @@ class ProjectServiceIntegrationTest {
|
||||
user("other-mentor@example.test", "MENTOR"), projectId, Long.MAX_VALUE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerAddsSeveralEligibleMembersInOneLockedTransaction() {
|
||||
long mentorId = user("mentor-batch-add@example.test", "MENTOR");
|
||||
long leaderId = intern("leader-batch-add@example.test", "I017");
|
||||
long firstMemberId = intern("first-batch-add@example.test", "I018");
|
||||
long secondMemberId = intern("second-batch-add@example.test", "I019");
|
||||
long projectId = createProject(mentorId, leaderId, "Batch membership");
|
||||
|
||||
projectService.addMembers(mentorId, projectId, List.of(firstMemberId, secondMemberId));
|
||||
|
||||
assertEquals(3, count("""
|
||||
select count(*) from project_memberships
|
||||
where project_id = ? and left_at is null
|
||||
""", projectId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void memberBatchRejectsMissingDuplicateCurrentAndStaleSelectionsWithoutPartialMutation() {
|
||||
long mentorId = user("mentor-batch-guard@example.test", "MENTOR");
|
||||
long leaderId = intern("leader-batch-guard@example.test", "I020");
|
||||
long eligibleId = intern("eligible-batch-guard@example.test", "I021");
|
||||
long staleId = intern("stale-batch-guard@example.test", "I022");
|
||||
long projectId = createProject(mentorId, leaderId, "Batch guard");
|
||||
jdbc.update("update intern_profiles set internship_end_date = date '2026-08-13' where user_id = ?", staleId);
|
||||
entityManager.clear();
|
||||
|
||||
assertThrows(ProjectRuleViolationException.class,
|
||||
() -> projectService.addMembers(mentorId, projectId, null));
|
||||
assertThrows(ProjectRuleViolationException.class,
|
||||
() -> projectService.addMembers(mentorId, projectId, List.of()));
|
||||
assertThrows(ProjectRuleViolationException.class,
|
||||
() -> projectService.addMembers(mentorId, projectId, List.of(eligibleId, eligibleId)));
|
||||
assertThrows(ProjectRuleViolationException.class,
|
||||
() -> projectService.addMembers(mentorId, projectId, List.of(Long.MAX_VALUE)));
|
||||
assertThrows(ProjectRuleViolationException.class,
|
||||
() -> projectService.addMembers(mentorId, projectId, List.of(mentorId)));
|
||||
assertThrows(ProjectRuleViolationException.class,
|
||||
() -> projectService.addMembers(mentorId, projectId, List.of(leaderId)));
|
||||
assertThrows(ProjectRuleViolationException.class,
|
||||
() -> projectService.addMembers(mentorId, projectId, List.of(eligibleId, staleId)));
|
||||
|
||||
assertEquals(0, count("""
|
||||
select count(*) from project_memberships
|
||||
where project_id = ? and intern_user_id in (?, ?) and left_at is null
|
||||
""", projectId, eligibleId, staleId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaderChangeClosesOneTermAndDoesNotMoveTaskAssignments() {
|
||||
long mentorId = user("mentor-leader@example.test", "MENTOR");
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
|
||||
class Target {
|
||||
listeners = new Map();
|
||||
|
||||
addEventListener(type, listener) {
|
||||
this.listeners.set(type, listener);
|
||||
}
|
||||
|
||||
dispatch(type) {
|
||||
this.listeners.get(type)?.({preventDefault() {}, target: this});
|
||||
}
|
||||
}
|
||||
|
||||
test('picker searches name and student code, summarizes selection, and cancels safely', () => {
|
||||
const open = Object.assign(new Target(), {focus() { this.focused = true; }});
|
||||
const cancel = new Target();
|
||||
const apply = new Target();
|
||||
const search = Object.assign(new Target(), {value: '', focus() { this.focused = true; }});
|
||||
const summary = {textContent: ''};
|
||||
const empty = {hidden: true};
|
||||
const firstInput = Object.assign(new Target(), {checked: false, type: 'checkbox'});
|
||||
const secondInput = Object.assign(new Target(), {checked: false, type: 'checkbox'});
|
||||
const options = [
|
||||
option('Nguyen An STU-020', 'Nguyen An (STU-020)', firstInput),
|
||||
option('Tran Binh STU-021', 'Tran Binh (STU-021)', secondInput),
|
||||
];
|
||||
const dialog = Object.assign(new Target(), {
|
||||
showModal() { this.open = true; },
|
||||
close() { this.open = false; this.dispatch('close'); },
|
||||
});
|
||||
const picker = {
|
||||
querySelector(selector) {
|
||||
return new Map([
|
||||
['[data-picker-open]', open], ['[data-picker-dialog]', dialog],
|
||||
['[data-picker-search]', search], ['[data-picker-summary]', summary],
|
||||
['[data-picker-empty]', empty], ['[data-picker-cancel]', cancel],
|
||||
['[data-picker-apply]', apply],
|
||||
]).get(selector) ?? null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
return selector === '[data-picker-option]' ? options : [];
|
||||
},
|
||||
};
|
||||
let ready;
|
||||
const document = {
|
||||
documentElement: {dataset: {}, style: {}},
|
||||
addEventListener(type, listener) { if (type === 'DOMContentLoaded') ready = listener; },
|
||||
querySelector() { return null; },
|
||||
querySelectorAll(selector) { return selector === '[data-intern-picker]' ? [picker] : []; },
|
||||
};
|
||||
vm.runInNewContext(readFileSync('src/main/resources/static/assets/app.js', 'utf8'), {
|
||||
document,
|
||||
localStorage: {getItem() { return null; }, setItem() {}, removeItem() {}},
|
||||
matchMedia() { return {matches: false}; },
|
||||
});
|
||||
ready();
|
||||
|
||||
open.dispatch('click');
|
||||
assert.equal(dialog.open, true);
|
||||
assert.equal(search.focused, true);
|
||||
|
||||
search.value = 'stu-021';
|
||||
search.dispatch('input');
|
||||
assert.equal(options[0].hidden, true);
|
||||
assert.equal(options[1].hidden, false);
|
||||
assert.equal(empty.hidden, true);
|
||||
|
||||
secondInput.checked = true;
|
||||
secondInput.dispatch('change');
|
||||
assert.equal(summary.textContent, '1 Intern selected: Tran Binh (STU-021)');
|
||||
|
||||
cancel.dispatch('click');
|
||||
assert.equal(secondInput.checked, false);
|
||||
assert.equal(summary.textContent, 'No Interns selected');
|
||||
assert.equal(open.focused, true);
|
||||
|
||||
open.dispatch('click');
|
||||
secondInput.checked = true;
|
||||
secondInput.dispatch('change');
|
||||
apply.dispatch('click');
|
||||
assert.equal(secondInput.checked, true);
|
||||
});
|
||||
|
||||
function option(searchValue, label, input) {
|
||||
return {
|
||||
hidden: false,
|
||||
dataset: {pickerSearch: searchValue},
|
||||
querySelector(selector) {
|
||||
if (selector === 'input') return input;
|
||||
if (selector === '[data-picker-label]') return {textContent: label};
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user