From e70159a81b6445825f6d5f912ecf3c4aa3c1aa85 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:23:45 +0700 Subject: [PATCH 1/2] feat(account): add eligible Intern picker query --- .../integration/eligible-intern-picker.md | 97 +++++++++++ .../model/dto/EligibleInternOption.java | 21 +++ .../repository/InternProfileRepository.java | 32 ++++ .../account/service/AccountService.java | 20 +++ .../EligibleInternOptionIntegrationTest.java | 157 ++++++++++++++++++ 5 files changed, 327 insertions(+) create mode 100644 docs/tests/integration/eligible-intern-picker.md create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/model/dto/EligibleInternOption.java create mode 100644 src/test/java/com/lab/labtimesheet/feature/account/service/EligibleInternOptionIntegrationTest.java diff --git a/docs/tests/integration/eligible-intern-picker.md b/docs/tests/integration/eligible-intern-picker.md new file mode 100644 index 0000000..8ab8077 --- /dev/null +++ b/docs/tests/integration/eligible-intern-picker.md @@ -0,0 +1,97 @@ +# Test Evidence: Eligible Intern picker query + +- **Test type:** Integration +- **Requirement IDs:** ACC-014, ACC-019–ACC-021, AUTH-001, PRJ-017, TST-001–TST-010 +- **Scenario IDs:** AC-ACC-009, AC-ACC-010, AC-PRJ-010 (selection-eligibility support) +- **Test class/method:** com.lab.labtimesheet.feature.account.service.EligibleInternOptionIntegrationTest#listsOnlyActiveInternsWithActiveInclusiveInternshipsInPickerOrder +- **Implementation commit:** Pending + +## Protected behavior + +Pending, locked, deactivated, non-Intern, not-started, completed, and date-expired records must not appear in the +Account-owned Intern picker. An option is selectable only when both account and internship are ACTIVE and the +explicit business date lies within the inclusive internship range. The returned numeric user ID is the internal +submission identity, and options sort by display name then student code. + +## Test method + +The PostgreSQL 18.4 integration test persists valid account/profile combinations through the account feature's JPA +entities and repositories. It uses SQL only as a test fixture for future lock, deactivation, and completion states +whose production transitions are outside this change. It calls the public Account service query and compares the +complete immutable DTO sequence, including both inclusive date boundaries and unique user IDs. + +## Hand-derived expected result + +For business date 2026-08-14, profiles starting on that date and ending on that date remain eligible. The only +expected options are Alpha / STU-100, Alpha / STU-200, and Zeta / STU-300, in that order. Every other seeded +row fails at least one account role/state, internship state, or inclusive date condition. + +## RED + +**Command** + +~~~text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=EligibleInternOptionIntegrationTest test +~~~ + +**Observed result** + +~~~text +[ERROR] EligibleInternOptionIntegrationTest.java:[11,54] cannot find symbol + symbol: class EligibleInternOption + location: package com.lab.labtimesheet.feature.account.model.dto +BUILD FAILURE +~~~ + +## GREEN + +**Command** + +~~~text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=EligibleInternOptionIntegrationTest test +~~~ + +**Observed result** + +~~~text +PostgreSQL 18.4 Testcontainers started and Flyway applied V1 baseline. +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +~~~ + +## Affected suite + +**Command and result** + +~~~text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=EligibleInternOptionIntegrationTest,AccountActivationIntegrationTest,BootstrapIntegrationTest,AccountWebIntegrationTest,AuthenticationWebIntegrationTest,BootstrapOnboardingWebIntegrationTest test + +Selected account reports: 13 tests, 0 failures, 0 errors, 0 skipped. + +./mvnw -Dtest=AccountActivationIntegrationTest test +Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS + +./mvnw -Dtest=LayerStructureTest test +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS + +./mvnw test +Tests run: 105, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +~~~ + +## External-test boundaries + +This query does not authorize Project membership itself; the consuming Project transaction must still recheck +membership and ownership invariants. It does not test the later lifecycle mutation workflows that produce locked, +deactivated, or completed rows. diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/EligibleInternOption.java b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/EligibleInternOption.java new file mode 100644 index 0000000..15c5f60 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/EligibleInternOption.java @@ -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) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java b/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java index 781e259..51316d5 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java @@ -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= :businessDate + order by u.displayName asc, p.studentCode asc, u.id asc + """) + List 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); diff --git a/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java b/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java index 81b7ba8..8a20797 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java @@ -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 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. * diff --git a/src/test/java/com/lab/labtimesheet/feature/account/service/EligibleInternOptionIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/account/service/EligibleInternOptionIntegrationTest.java new file mode 100644 index 0000000..36311b6 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/account/service/EligibleInternOptionIntegrationTest.java @@ -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 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"; + } +} From 432389220f53e06df88971f8a7b711a42cb85fee Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:36:11 +0700 Subject: [PATCH 2/2] test(account): cover missing picker business date --- .../integration/eligible-intern-picker.md | 68 ++++++++++++++++++- .../EligibleInternOptionIntegrationTest.java | 8 +++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/docs/tests/integration/eligible-intern-picker.md b/docs/tests/integration/eligible-intern-picker.md index 8ab8077..4ed0b66 100644 --- a/docs/tests/integration/eligible-intern-picker.md +++ b/docs/tests/integration/eligible-intern-picker.md @@ -3,8 +3,8 @@ - **Test type:** Integration - **Requirement IDs:** ACC-014, ACC-019–ACC-021, AUTH-001, PRJ-017, TST-001–TST-010 - **Scenario IDs:** AC-ACC-009, AC-ACC-010, AC-PRJ-010 (selection-eligibility support) -- **Test class/method:** com.lab.labtimesheet.feature.account.service.EligibleInternOptionIntegrationTest#listsOnlyActiveInternsWithActiveInclusiveInternshipsInPickerOrder -- **Implementation commit:** Pending +- **Test class/method:** com.lab.labtimesheet.feature.account.service.EligibleInternOptionIntegrationTest#listsOnlyActiveInternsWithActiveInclusiveInternshipsInPickerOrder; #rejectsMissingBusinessDate +- **Implementation commit:** e70159a81b6445825f6d5f912ecf3c4aa3c1aa85 ## Protected behavior @@ -12,6 +12,8 @@ Pending, locked, deactivated, non-Intern, not-started, completed, and date-expir Account-owned Intern picker. An option is selectable only when both account and internship are ACTIVE and the explicit business date lies within the inclusive internship range. The returned numeric user ID is the internal submission identity, and options sort by display name then student code. +The public query rejects a missing business date with the documented actionable message instead of issuing an +ambiguous null-bound database query. ## Test method @@ -19,12 +21,16 @@ The PostgreSQL 18.4 integration test persists valid account/profile combinations entities and repositories. It uses SQL only as a test fixture for future lock, deactivation, and completion states whose production transitions are outside this change. It calls the public Account service query and compares the complete immutable DTO sequence, including both inclusive date boundaries and unique user IDs. +Its separate null-date regression calls the same public service method and asserts the exact +IllegalArgumentException message documented by that method. ## Hand-derived expected result For business date 2026-08-14, profiles starting on that date and ending on that date remain eligible. The only expected options are Alpha / STU-100, Alpha / STU-200, and Zeta / STU-300, in that order. Every other seeded row fails at least one account role/state, internship state, or inclusive date condition. +For a missing business date, the service must immediately throw +IllegalArgumentException("Business date is required"). ## RED @@ -65,6 +71,49 @@ Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ~~~ +## Review follow-up: missing business date + +The public guard was temporarily removed solely to prove the new regression fails for the intended reason, then +restored exactly before the GREEN checks. The follow-up commit contains only the regression test and evidence. + +### RED + +**Command** + +~~~text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw '-Dtest=EligibleInternOptionIntegrationTest#rejectsMissingBusinessDate' test +~~~ + +**Observed result** + +~~~text +Tests run: 1, Failures: 1, Errors: 0, Skipped: 0 +java.lang.AssertionError: Expecting code to raise a throwable. +BUILD FAILURE +~~~ + +### GREEN + +**Command** + +~~~text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw '-Dtest=EligibleInternOptionIntegrationTest#rejectsMissingBusinessDate' test +~~~ + +**Observed result** + +~~~text +PostgreSQL 18.4 Testcontainers started and Flyway applied V1 baseline. +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +~~~ + ## Affected suite **Command and result** @@ -90,6 +139,21 @@ Tests run: 105, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ~~~ +### Review follow-up affected account-service checks + +~~~text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw '-Dtest=EligibleInternOptionIntegrationTest,AccountActivationIntegrationTest,BootstrapIntegrationTest' test + +EligibleInternOptionIntegrationTest: 2 tests, 0 failures, 0 errors, 0 skipped +BootstrapIntegrationTest: 4 tests, 0 failures, 0 errors, 0 skipped +AccountActivationIntegrationTest: 2 tests, 0 failures, 0 errors, 0 skipped +Selected account-service reports: 8 tests, 0 failures, 0 errors, 0 skipped. +BUILD SUCCESS +~~~ + ## External-test boundaries This query does not authorize Project membership itself; the consuming Project transaction must still recheck diff --git a/src/test/java/com/lab/labtimesheet/feature/account/service/EligibleInternOptionIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/account/service/EligibleInternOptionIntegrationTest.java index 36311b6..b527f9f 100644 --- a/src/test/java/com/lab/labtimesheet/feature/account/service/EligibleInternOptionIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/account/service/EligibleInternOptionIntegrationTest.java @@ -1,6 +1,7 @@ package com.lab.labtimesheet.feature.account.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.sql.Timestamp; import java.time.Instant; @@ -83,6 +84,13 @@ class EligibleInternOptionIntegrationTest { assertThat(options).extracting(EligibleInternOption::userId).doesNotHaveDuplicates(); } + @Test + void rejectsMissingBusinessDate() { + assertThatThrownBy(() -> accounts.eligibleInternOptions(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Business date is required"); + } + private long activeIntern(String displayName, String studentCode, LocalDate startDate, LocalDate endDate) { long userId = activeUser(GlobalRole.INTERN, displayName); activeProfile(userId, studentCode, startDate, endDate);