feat: expose account identity and eligibility boundary

This commit is contained in:
sechmachine
2026-08-15 00:17:28 +07:00
parent 3fdfbb2bf2
commit 1235204bf1
8 changed files with 115 additions and 13 deletions
@@ -0,0 +1,74 @@
# Test Evidence: Cross-feature account boundary
- **Test type:** Integration
- **Requirement IDs:** `ACC-002, ACC-014, ACC-020ACC-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.
@@ -53,7 +53,7 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
**Observed result** **Observed result**
```text ```text
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS BUILD SUCCESS
``` ```
@@ -63,7 +63,7 @@ BUILD SUCCESS
```text ```text
./mvnw test ./mvnw test
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS BUILD SUCCESS
``` ```
@@ -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 export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw test ./mvnw test
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS BUILD SUCCESS
``` ```
+1 -1
View File
@@ -64,7 +64,7 @@ BUILD SUCCESS
```text ```text
./mvnw test ./mvnw test
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS BUILD SUCCESS
``` ```
@@ -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 export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw test ./mvnw test
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS BUILD SUCCESS
``` ```
@@ -1,9 +1,14 @@
package com.lab.labtimesheet.feature.account.repository; 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.InternshipStatus;
import com.lab.labtimesheet.feature.account.model.entity.InternProfile; import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
public interface InternProfileRepository extends JpaRepository<InternProfile, Long> { public interface InternProfileRepository extends JpaRepository<InternProfile, Long> {
boolean existsByUserIdAndInternshipStatus(Long userId, InternshipStatus status); boolean existsByUserIdAndInternshipStatus(Long userId, InternshipStatus status);
boolean existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual(
Long userId, InternshipStatus status, LocalDate latestStartDate, LocalDate earliestEndDate);
} }
@@ -1,5 +1,7 @@
package com.lab.labtimesheet.feature.account.service; 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.AccountStatus;
import com.lab.labtimesheet.feature.account.model.GlobalRole; import com.lab.labtimesheet.feature.account.model.GlobalRole;
import com.lab.labtimesheet.feature.account.model.InternshipStatus; import com.lab.labtimesheet.feature.account.model.InternshipStatus;
@@ -42,6 +44,20 @@ public class AccountService {
.isPresent(); .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) @Transactional(readOnly = true)
public AccountIdentity requireEligibleIntern(long userId) { public AccountIdentity requireEligibleIntern(long userId) {
if (!isEligibleIntern(userId)) { if (!isEligibleIntern(userId)) {
@@ -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.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
@@ -80,17 +81,23 @@ class BootstrapIntegrationTest {
BootstrapService.BootstrapOutcome.CREATED, BootstrapService.BootstrapOutcome.ALREADY_INITIALIZED); BootstrapService.BootstrapOutcome.CREATED, BootstrapService.BootstrapOutcome.ALREADY_INITIALIZED);
assertThat(users.count()).isEqualTo(1); assertThat(users.count()).isEqualTo(1);
assertThat(users.countByGlobalRoleAndAccountStatus(GlobalRole.ADMIN, AccountStatus.ACTIVE)).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( assertThat(bootstrapService.bootstrap(
"another@example.com", "Another", "correct horse battery staple")) "another@example.com", "Another", "correct horse battery staple"))
.isEqualTo(BootstrapService.BootstrapOutcome.ALREADY_INITIALIZED); .isEqualTo(BootstrapService.BootstrapOutcome.ALREADY_INITIALIZED);
assertThat(systemStates.findById((short) 1).orElseThrow().isInitialized()).isTrue(); 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();
}
} }