feat(account): add eligible Intern picker query

This commit is contained in:
sechmachine
2026-08-15 14:23:45 +07:00
parent 8be1b754e1
commit e70159a81b
5 changed files with 327 additions and 0 deletions
@@ -0,0 +1,21 @@
package com.lab.labtimesheet.feature.account.model.dto;
import java.time.LocalDate;
/**
* Immutable non-secret selection data for an eligible Intern.
* The numeric user ID is the internal form submission identity; displayed fields are not authorization identifiers.
*
* @param userId persistent account identifier submitted by a consuming form
* @param displayName user-facing Intern name
* @param studentCode university student code shown to distinguish Interns
* @param internshipStart inclusive internship eligibility start date
* @param internshipEnd inclusive internship eligibility end date
*/
public record EligibleInternOption(
long userId,
String displayName,
String studentCode,
LocalDate internshipStart,
LocalDate internshipEnd) {
}
@@ -1,8 +1,12 @@
package com.lab.labtimesheet.feature.account.repository;
import java.time.LocalDate;
import java.util.List;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
import com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption;
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -19,6 +23,34 @@ public interface InternProfileRepository extends JpaRepository<InternProfile, Lo
boolean existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual(
Long userId, InternshipStatus status, LocalDate latestStartDate, LocalDate earliestEndDate);
/**
* Projects account-owned non-secret selection data for Interns eligible on one inclusive business date.
* Results are ordered by display name, student code, then user ID for deterministic form rendering.
*
* @param globalRole required immutable Intern role
* @param accountStatus required active account state
* @param internshipStatus required active internship state
* @param businessDate date that must fall within the inclusive internship range
* @return eligible Intern selection projections without duplicate profile rows
*/
@Query("""
select new com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption(
u.id, u.displayName, p.studentCode, p.internshipStartDate, p.internshipEndDate)
from InternProfile p
join AppUser u on u.id = p.userId
where u.globalRole = :globalRole
and u.accountStatus = :accountStatus
and p.internshipStatus = :internshipStatus
and p.internshipStartDate <= :businessDate
and p.internshipEndDate >= :businessDate
order by u.displayName asc, p.studentCode asc, u.id asc
""")
List<EligibleInternOption> findEligibleInternOptions(
@Param("globalRole") GlobalRole globalRole,
@Param("accountStatus") AccountStatus accountStatus,
@Param("internshipStatus") InternshipStatus internshipStatus,
@Param("businessDate") LocalDate businessDate);
/** Counts Intern profiles in a lifecycle state. */
long countByInternshipStatus(InternshipStatus status);
@@ -8,6 +8,7 @@ import java.time.Clock;
import java.time.Duration;
import java.time.LocalDate;
import java.util.Base64;
import java.util.List;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
@@ -17,6 +18,7 @@ import com.lab.labtimesheet.feature.account.model.dto.AccountCreation;
import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity;
import com.lab.labtimesheet.feature.account.model.dto.AccountSummary;
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand;
import com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption;
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
import com.lab.labtimesheet.feature.account.model.entity.UserActionToken;
@@ -238,6 +240,24 @@ public class AccountService {
.isPresent();
}
/**
* Lists non-secret Intern selection options eligible on an explicit business date.
* The result requires active account and internship states plus inclusive internship dates, but it does not
* authorize a consuming Project operation; that operation must recheck its own ownership and membership rules.
*
* @param businessDate server-derived business date to evaluate inclusively
* @return deterministic options ordered by display name, student code, then account ID
* @throws IllegalArgumentException when {@code businessDate} is {@code null}
*/
@Transactional(readOnly = true)
public List<EligibleInternOption> eligibleInternOptions(LocalDate businessDate) {
if (businessDate == null) {
throw new IllegalArgumentException("Business date is required");
}
return internProfiles.findEligibleInternOptions(
GlobalRole.INTERN, AccountStatus.ACTIVE, InternshipStatus.ACTIVE, businessDate);
}
/**
* Resolves the cross-feature identity of a currently eligible Intern.
*
@@ -0,0 +1,157 @@
package com.lab.labtimesheet.feature.account.service;
import static org.assertj.core.api.Assertions.assertThat;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import com.lab.labtimesheet.config.TestcontainersConfiguration;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
import com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption;
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
import com.lab.labtimesheet.feature.account.repository.InternProfileRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
@Import(TestcontainersConfiguration.class)
@SpringBootTest
@ActiveProfiles("test")
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class EligibleInternOptionIntegrationTest {
private static final Instant NOW = Instant.parse("2026-08-14T00:00:00Z");
private static final LocalDate BUSINESS_DATE = LocalDate.of(2026, 8, 14);
@Autowired
private AccountService accounts;
@Autowired
private AppUserRepository users;
@Autowired
private InternProfileRepository internProfiles;
@Autowired
private JdbcTemplate jdbc;
private AppUser admin;
private int userSequence;
@BeforeEach
void setUp() {
admin = users.saveAndFlush(AppUser.bootstrapAdmin(
"picker-admin@example.com", "Picker Admin", "encoded-password", NOW));
}
@Test
void listsOnlyActiveInternsWithActiveInclusiveInternshipsInPickerOrder() {
long lowerBoundary = activeIntern("Alpha", "STU-100", BUSINESS_DATE, BUSINESS_DATE.plusDays(10));
long upperBoundary = activeIntern("Alpha", "STU-200", BUSINESS_DATE.minusDays(10), BUSINESS_DATE);
long laterName = activeIntern("Zeta", "STU-300", BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1));
pendingInternWithActiveProfile("Ignored Pending", "STU-400");
long locked = activeIntern("Ignored Locked", "STU-500", BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1));
lock(locked);
long deactivated = activeIntern(
"Ignored Deactivated", "STU-600", BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1));
deactivate(deactivated);
activeMentorWithActiveProfile("Ignored Mentor", "STU-700");
activeInternWithNotStartedProfile("Ignored Not Started", "STU-800");
activeIntern("Ignored Ended", "STU-900", BUSINESS_DATE.minusDays(10), BUSINESS_DATE.minusDays(1));
long completed = activeIntern(
"Ignored Completed", "STU-1000", BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1));
completeInternship(completed);
List<EligibleInternOption> options = accounts.eligibleInternOptions(BUSINESS_DATE);
assertThat(options).containsExactly(
new EligibleInternOption(
lowerBoundary, "Alpha", "STU-100", BUSINESS_DATE, BUSINESS_DATE.plusDays(10)),
new EligibleInternOption(
upperBoundary, "Alpha", "STU-200", BUSINESS_DATE.minusDays(10), BUSINESS_DATE),
new EligibleInternOption(
laterName, "Zeta", "STU-300", BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1)));
assertThat(options).extracting(EligibleInternOption::userId).doesNotHaveDuplicates();
}
private long activeIntern(String displayName, String studentCode, LocalDate startDate, LocalDate endDate) {
long userId = activeUser(GlobalRole.INTERN, displayName);
activeProfile(userId, studentCode, startDate, endDate);
return userId;
}
private void pendingInternWithActiveProfile(String displayName, String studentCode) {
long userId = pendingUser(GlobalRole.INTERN, displayName);
activeProfile(userId, studentCode, BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1));
}
private void activeMentorWithActiveProfile(String displayName, String studentCode) {
long userId = activeUser(GlobalRole.MENTOR, displayName);
activeProfile(userId, studentCode, BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1));
}
private void activeInternWithNotStartedProfile(String displayName, String studentCode) {
long userId = activeUser(GlobalRole.INTERN, displayName);
internProfiles.saveAndFlush(InternProfile.notStarted(
userId, studentCode, BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1), NOW));
}
private long activeUser(GlobalRole role, String displayName) {
AppUser user = AppUser.pending(nextEmail(), displayName, role, admin, NOW);
user.activate("encoded-password", NOW);
return users.saveAndFlush(user).getId();
}
private long pendingUser(GlobalRole role, String displayName) {
return users.saveAndFlush(AppUser.pending(nextEmail(), displayName, role, admin, NOW)).getId();
}
private void activeProfile(long userId, String studentCode, LocalDate startDate, LocalDate endDate) {
InternProfile profile = InternProfile.notStarted(userId, studentCode, startDate, endDate, NOW);
profile.activate(NOW);
internProfiles.saveAndFlush(profile);
}
private void lock(long userId) {
assertThat(jdbc.update(
"""
update app_users
set account_status = 'LOCKED', locked_at = ?, updated_at = ?
where id = ?
""",
Timestamp.from(NOW), Timestamp.from(NOW), userId)).isOne();
}
private void deactivate(long userId) {
assertThat(jdbc.update(
"""
update app_users
set account_status = 'DEACTIVATED', deactivated_at = ?, updated_at = ?
where id = ?
""",
Timestamp.from(NOW), Timestamp.from(NOW), userId)).isOne();
}
private void completeInternship(long userId) {
assertThat(jdbc.update(
"""
update intern_profiles
set internship_status = 'COMPLETED', completed_at = ?, updated_at = ?
where user_id = ?
""",
Timestamp.from(NOW), Timestamp.from(NOW), userId)).isOne();
}
private String nextEmail() {
return "picker-" + ++userSequence + "@example.com";
}
}