From 1235204bf1298599264a07943ca1167432556bd2 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:17:28 +0700 Subject: [PATCH] feat: expose account identity and eligibility boundary --- docs/tests/integration/account-boundary.md | 74 +++++++++++++++++++ .../integration/first-admin-bootstrap.md | 4 +- docs/tests/integration/platform-foundation.md | 2 +- docs/tests/integration/smtp-onboarding.md | 2 +- .../unit/package-by-feature-structure.md | 2 +- .../repository/InternProfileRepository.java | 5 ++ .../account/service/AccountService.java | 16 ++++ .../service/BootstrapIntegrationTest.java | 23 ++++-- 8 files changed, 115 insertions(+), 13 deletions(-) create mode 100644 docs/tests/integration/account-boundary.md diff --git a/docs/tests/integration/account-boundary.md b/docs/tests/integration/account-boundary.md new file mode 100644 index 0000000..8fe5c41 --- /dev/null +++ b/docs/tests/integration/account-boundary.md @@ -0,0 +1,74 @@ +# Test Evidence: Cross-feature account boundary + +- **Test type:** Integration +- **Requirement IDs:** `ACC-002, ACC-014, ACC-020–ACC-021, PRJ-017, ATT-007` +- **Scenario IDs:** `AC-ACC-002, AC-ATT-001` +- **Test class/method:** `com.lab.labtimesheet.feature.account.service.BootstrapIntegrationTest.exposesIdentityAndDateAwareInternEligibilityWithoutPersistenceTypes` +- **Implementation commit:** `this milestone commit` + +## Protected behavior + +Other features can resolve an account by normalized email or ID through an immutable identity DTO and can ask whether an Intern is active and within an inclusive internship interval for a supplied work date. They do not need access to account repositories or JPA entities. + +## Test method + +The PostgreSQL 18.4 integration test creates the initial Admin through the production bootstrap transaction, resolves the resulting identity through `AccountService`, and verifies ID/email equivalence, normalized lookup, role, status, and rejection by both current and date-aware Intern eligibility gates. Starting the context also parses the Spring Data derived interval query against the mapped `intern_profiles` entity. + +## Hand-derived expected result + +` ADMIN@EXAMPLE.COM ` resolves to the persisted `admin@example.com` identity. An active Admin is not an eligible Intern on `2026-08-14`. The date-aware gate requires an active Intern account, an `ACTIVE` internship, and `start_date <= workDate <= end_date`. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" +./mvnw -Dtest=BootstrapIntegrationTest test +``` + +**Observed result** + +```text +BootstrapIntegrationTest.java: method isEligibleIntern in class AccountService +cannot be applied to given types; required: long; found: long, java.time.LocalDate +Tests did not run; test compilation failed +BUILD FAILURE +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=BootstrapIntegrationTest test +``` + +**Observed result** + +```text +Tests run: 3, 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:/opt/homebrew/opt/node@24/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw test + +Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +## External-test boundaries + +The test proves identity lookup and rejection of a non-Intern plus successful repository-query initialization. The positive active-Intern and interval-edge cases remain part of I1-PLAT-06 activation/account lifecycle work; dependent features must still enforce their own authorization and transaction invariants. diff --git a/docs/tests/integration/first-admin-bootstrap.md b/docs/tests/integration/first-admin-bootstrap.md index 156e350..a94a6bf 100644 --- a/docs/tests/integration/first-admin-bootstrap.md +++ b/docs/tests/integration/first-admin-bootstrap.md @@ -53,7 +53,7 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock **Observed result** ```text -Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` @@ -63,7 +63,7 @@ BUILD SUCCESS ```text ./mvnw test -Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` diff --git a/docs/tests/integration/platform-foundation.md b/docs/tests/integration/platform-foundation.md index 104382f..ec1b8cc 100644 --- a/docs/tests/integration/platform-foundation.md +++ b/docs/tests/integration/platform-foundation.md @@ -69,7 +69,7 @@ export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw test -Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` diff --git a/docs/tests/integration/smtp-onboarding.md b/docs/tests/integration/smtp-onboarding.md index 7bc8955..8b7fe4b 100644 --- a/docs/tests/integration/smtp-onboarding.md +++ b/docs/tests/integration/smtp-onboarding.md @@ -64,7 +64,7 @@ BUILD SUCCESS ```text ./mvnw test -Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` diff --git a/docs/tests/unit/package-by-feature-structure.md b/docs/tests/unit/package-by-feature-structure.md index bca5c91..b8acc9a 100644 --- a/docs/tests/unit/package-by-feature-structure.md +++ b/docs/tests/unit/package-by-feature-structure.md @@ -67,7 +67,7 @@ export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw test -Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` 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 ed79914..b6e11e6 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,9 +1,14 @@ package com.lab.labtimesheet.feature.account.repository; +import java.time.LocalDate; + import com.lab.labtimesheet.feature.account.model.InternshipStatus; import com.lab.labtimesheet.feature.account.model.entity.InternProfile; import org.springframework.data.jpa.repository.JpaRepository; public interface InternProfileRepository extends JpaRepository { boolean existsByUserIdAndInternshipStatus(Long userId, InternshipStatus status); + + boolean existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual( + Long userId, InternshipStatus status, LocalDate latestStartDate, LocalDate earliestEndDate); } 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 c7c84c5..1227365 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 @@ -1,5 +1,7 @@ package com.lab.labtimesheet.feature.account.service; +import java.time.LocalDate; + import com.lab.labtimesheet.feature.account.model.AccountStatus; import com.lab.labtimesheet.feature.account.model.GlobalRole; import com.lab.labtimesheet.feature.account.model.InternshipStatus; @@ -42,6 +44,20 @@ public class AccountService { .isPresent(); } + @Transactional(readOnly = true) + public boolean isEligibleIntern(long userId, LocalDate workDate) { + if (workDate == null) { + throw new IllegalArgumentException("Work date is required"); + } + return users.findById(userId) + .filter(user -> user.getGlobalRole() == GlobalRole.INTERN) + .filter(user -> user.getAccountStatus() == AccountStatus.ACTIVE) + .filter(user -> internProfiles + .existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual( + user.getId(), InternshipStatus.ACTIVE, workDate, workDate)) + .isPresent(); + } + @Transactional(readOnly = true) public AccountIdentity requireEligibleIntern(long userId) { if (!isEligibleIntern(userId)) { diff --git a/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java index e2dbfef..d2edb50 100644 --- a/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; @@ -80,17 +81,23 @@ class BootstrapIntegrationTest { BootstrapService.BootstrapOutcome.CREATED, BootstrapService.BootstrapOutcome.ALREADY_INITIALIZED); assertThat(users.count()).isEqualTo(1); assertThat(users.countByGlobalRoleAndAccountStatus(GlobalRole.ADMIN, AccountStatus.ACTIVE)).isEqualTo(1); - var createdUser = users.findAll().getFirst(); - var identityByEmail = accountService.requireIdentityByEmail(" " + createdUser.getEmail().toUpperCase() + " "); - assertThat(identityByEmail.email()).isEqualTo(createdUser.getEmail()); - assertThat(identityByEmail.displayName()).isEqualTo("First Admin"); - assertThat(identityByEmail.role()).isEqualTo(GlobalRole.ADMIN); - assertThat(identityByEmail.status()).isEqualTo(AccountStatus.ACTIVE); - assertThat(accountService.requireIdentityById(identityByEmail.id())).isEqualTo(identityByEmail); - assertThat(accountService.isEligibleIntern(identityByEmail.id())).isFalse(); assertThat(bootstrapService.bootstrap( "another@example.com", "Another", "correct horse battery staple")) .isEqualTo(BootstrapService.BootstrapOutcome.ALREADY_INITIALIZED); assertThat(systemStates.findById((short) 1).orElseThrow().isInitialized()).isTrue(); } + + @Test + void exposesIdentityAndDateAwareInternEligibilityWithoutPersistenceTypes() { + bootstrapService.bootstrap("admin@example.com", "First Admin", "correct horse battery staple"); + + var identityByEmail = accountService.requireIdentityByEmail(" ADMIN@EXAMPLE.COM "); + assertThat(identityByEmail.email()).isEqualTo("admin@example.com"); + assertThat(identityByEmail.displayName()).isEqualTo("First Admin"); + assertThat(identityByEmail.role()).isEqualTo(GlobalRole.ADMIN); + assertThat(identityByEmail.status()).isEqualTo(AccountStatus.ACTIVE); + assertThat(accountService.requireIdentityById(identityByEmail.id())).isEqualTo(identityByEmail); + assertThat(accountService.isEligibleIntern(identityByEmail.id())).isFalse(); + assertThat(accountService.isEligibleIntern(identityByEmail.id(), LocalDate.of(2026, 8, 14))).isFalse(); + } }