Compare commits

...
8 changed files with 378 additions and 6 deletions
@@ -0,0 +1,83 @@
# Test Evidence: development-profile attendance policy time hydration
- **Test type:** Integration
- **Requirement IDs:** `ATT-002`, `ATT-003`, `I1-ATT-01`
- **Scenario IDs:** `AC-ATT-001`
- **Test class/method:** `com.lab.labtimesheet.feature.attendance.controller.CalendarDevelopmentProfileWebIntegrationTest#v1SeededPolicyLetsFormAuthenticatedAdminOpenCalendarInAsiaHoChiMinhDevelopmentProfile`
- **Implementation commit:** pending
## Protected behavior
The unmodified V1 attendance policy must hydrate its `time` schedule as the configured local wall-clock values when the development profile runs in `Asia/Ho_Chi_Minh`. A form-authenticated Admin can therefore open the calendar without weakening the policy rule that requires the checkout cutoff to be before local midnight.
## Test method
The test starts the application with the real `dev` profile plus isolated test configuration, forces the JVM default zone to `Asia/Ho_Chi_Minh` before JPA starts, and uses PostgreSQL 18.4 Testcontainers with Flyway V1. It bootstraps an Admin through the form, logs in through the form, and requests `/attendance/calendar`, which resolves the current policy through `AttendanceApplicationService.currentBusinessDate`.
## Hand-derived expected result
V1 explicitly stores `scheduled_start = 08:30`, `scheduled_end = 15:30`, and `checkout_grace_minutes = 30`. The checkout cutoff is therefore `16:00`, which is strictly before local midnight, so the calendar request returns HTTP 200.
## RED
**Command**
```text
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=CalendarDevelopmentProfileWebIntegrationTest' test
```
**Observed result**
```text
PostgreSQL 18.4 Testcontainers applied Flyway V1, then the form-authenticated GET /attendance/calendar failed.
BUILD FAILURE: CalendarDevelopmentProfileWebIntegrationTest ... ServletException caused by
IllegalArgumentException: checkout cutoff must be before local midnight
at AttendancePolicy.java:58 via AttendancePolicyEntity.toDomain and AttendanceApplicationService.currentBusinessDate.
```
## GREEN
**Command**
```text
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=CalendarDevelopmentProfileWebIntegrationTest' test
```
**Observed result**
```text
PostgreSQL 18.4 Testcontainers applied Flyway V1.
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=CalendarAuthorizationWebIntegrationTest,CalendarDevelopmentProfileWebIntegrationTest,RoleDashboardWebIntegrationTest,AttendancePersistenceIntegrationTest,AttendancePolicyTest' test
Tests run: 13, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=*Attendance*Test,*Calendar*Test,Dashboard*Test,RoleDashboardWebIntegrationTest,AdminDashboardWebTest' test
Tests run: 60, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## Java 26 smoke
**Command and result**
```text
env JAVA_HOME=/Users/sechmachine/Library/Java/JavaVirtualMachines/corretto-26.0.2/Contents/Home PATH=/Users/sechmachine/Library/Java/JavaVirtualMachines/corretto-26.0.2/Contents/Home/bin:/opt/homebrew/bin:/usr/bin:/bin ./mvnw clean compile -DskipTests
Amazon Corretto 26.0.2 compiled 129 source files with release 25.
BUILD SUCCESS
```
## External-test boundaries
This integration test proves the fresh Flyway/JPA/real-login calendar path under the development profile and Vietnam JVM zone. It does not operate the already-running browser-gate application or exercise the Intern dashboard UI itself; both paths resolve the same policy timeline.
@@ -0,0 +1,82 @@
# Test Evidence: Windows legacy Vietnam timezone startup
- **Test type:** Integration
- **Requirement IDs:** `ARC-001`, `ARC-003`, `GOV-011`, `ATT-002`, `TST-001`, `TST-005`
- **Scenario IDs:** `N/A — user-reported cross-platform startup defect`
- **Test class/method:** `com.lab.labtimesheet.ApplicationTimeZoneIntegrationTest.mainCanonicalizesLegacyAliasBeforeStartingSpring`, `com.lab.labtimesheet.ApplicationTimeZoneIntegrationTest.canonicalizesLegacyVietnamAliasBeforePostgresConnects`, `com.lab.labtimesheet.ApplicationTimeZoneIntegrationTest.leavesSupportedSystemTimeZoneUnchanged`
- **Implementation commit:** `a9fb9487692e84f5a7e7923570cbded58c362a2f`
## Protected behavior
The executable entry point replaces the legacy Windows JVM timezone ID `Asia/Saigon` with the canonical business timezone ID `Asia/Ho_Chi_Minh` before pgJDBC opens a PostgreSQL connection. Other supported operating-system timezone IDs remain unchanged.
## Test method
The entry-point test replaces Spring startup with Mockito's existing static test seam, invokes the real `main` method with a legacy JVM default, and checks that normalization happens before Spring starts. The PostgreSQL test starts a real PostgreSQL 18.4 Testcontainer, proves pgJDBC 42.7.11 is rejected while the JVM default is `Asia/Saigon`, invokes the same startup normalization, and then opens a valid JDBC connection. A negative test verifies that an unrelated supported timezone is not overwritten.
## Hand-derived expected result
PostgreSQL does not accept `Asia/Saigon` as a startup `TimeZone`, while the approved business timezone is `Asia/Ho_Chi_Minh`. Therefore only the legacy alias is replaced, the following connection succeeds, and a supported non-Vietnam timezone remains unchanged.
## RED
**Command**
```text
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw -Dtest=ApplicationTimeZoneIntegrationTest test
```
**Observed result**
```text
BUILD FAILURE during test compilation.
ApplicationTimeZoneIntegrationTest.java: cannot find symbol normalizeDefaultTimeZone()
```
The failing test established that the application had no pre-Spring normalization boundary.
A second mutation check temporarily removed the new call from `main` and ran:
```text
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin ./mvnw '-Dtest=ApplicationTimeZoneIntegrationTest#mainCanonicalizesLegacyAliasBeforeStartingSpring' test
```
It failed `1/1` with `expected: "Asia/Ho_Chi_Minh" but was: "Asia/Saigon"`, proving the test protects the entry-point ordering rather than only the helper.
## GREEN
**Command**
```text
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw -Dtest=ApplicationTimeZoneIntegrationTest test
```
**Observed result**
```text
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=ApplicationTimeZoneIntegrationTest,LabtimesheetApplicationTests,PlatformFoundationTest,TimeConfigurationTest,CalendarDevelopmentProfileWebIntegrationTest' test
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw test
Tests run: 217, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin ./mvnw -DskipTests -Ddoclint=all javadoc:javadoc
BUILD SUCCESS
```
A local Java 25 process was also started with `-Duser.timezone=Asia/Saigon` against a disposable PostgreSQL 18.4 database on port `55439`. Hikari connected, Flyway migrated the fresh database, Tomcat started on port `18080`, and Spring reported `Started LabtimesheetApplication`. The process shut down cleanly and the disposable database container was removed.
## External-test boundaries
The regression executes the installed pgJDBC version against PostgreSQL 18.4 and reproduces the exact rejected timezone value from the Windows report. It does not run the Windows JVM itself; the supplied Windows log is the evidence that its OS/JDK mapping produced `Asia/Saigon`.
+4
View File
@@ -46,3 +46,7 @@ No browser loop or Impeccable detector is run on this branch; the root owner per
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. 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. 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.
The root-owned final full suite then exposed a branch-induced MVC-slice fixture RED: 213 tests ran with 0 failures and 3 errors, all `ProjectTaskFormAccessibilityWebTest` context errors because the slice did not provide the new ProjectController AccountService dependency. A focused reproduction ran the class at 3 tests, 0 failures, 3 errors and reported the same missing AccountService constructor dependency.
The smallest test-only correction supplies the controller's AccountService and Clock dependencies and the existing ProjectQueryService mock's authenticated Mentor response. The intermediate focused runs exposed each dependency in order; no production code changed. Final focused GREEN: `env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=ProjectTaskFormAccessibilityWebTest' test` passed 3/3 with no failures, errors, or skips. The root owner retains the broader rerun.
@@ -1,23 +1,43 @@
package com.lab.labtimesheet; package com.lab.labtimesheet;
import java.util.TimeZone;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import com.lab.labtimesheet.config.SecurityProperties; import com.lab.labtimesheet.config.SecurityProperties;
/** Application entry point and root component-scan boundary for Lab Timesheet. */ /**
* Application entry point and root component-scan boundary for Lab Timesheet.
*
* <p>The entry point also canonicalizes the legacy Windows Vietnam timezone alias before database
* drivers inspect the JVM default timezone.
*/
@SpringBootApplication @SpringBootApplication
@EnableConfigurationProperties(SecurityProperties.class) @EnableConfigurationProperties(SecurityProperties.class)
public class LabtimesheetApplication { public class LabtimesheetApplication {
private static final String LEGACY_VIETNAM_TIME_ZONE = "Asia/Saigon";
private static final String BUSINESS_TIME_ZONE = "Asia/Ho_Chi_Minh";
/** /**
* Starts the standalone Spring Boot process. * Canonicalizes the process timezone and starts the standalone Spring Boot process.
* *
* @param args command-line arguments forwarded to Spring Boot * @param args command-line arguments forwarded to Spring Boot
*/ */
public static void main(String[] args) { public static void main(String[] args) {
normalizeDefaultTimeZone();
SpringApplication.run(LabtimesheetApplication.class, args); SpringApplication.run(LabtimesheetApplication.class, args);
} }
/**
* Replaces the legacy Windows Vietnam alias before pgJDBC sends it to PostgreSQL as a startup
* parameter. Other supported system timezones remain unchanged.
*/
static void normalizeDefaultTimeZone() {
if (LEGACY_VIETNAM_TIME_ZONE.equals(TimeZone.getDefault().getID())) {
TimeZone.setDefault(TimeZone.getTimeZone(BUSINESS_TIME_ZONE));
}
}
} }
-4
View File
@@ -10,10 +10,6 @@ spring:
hibernate: hibernate:
ddl-auto: validate ddl-auto: validate
open-in-view: false open-in-view: false
properties:
hibernate:
jdbc:
time_zone: UTC
flyway: flyway:
enabled: true enabled: true
locations: classpath:db/migration locations: classpath:db/migration
@@ -0,0 +1,80 @@
package com.lab.labtimesheet;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mockStatic;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.TimeZone;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.springframework.boot.SpringApplication;
import org.testcontainers.postgresql.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;
class ApplicationTimeZoneIntegrationTest {
@Test
void mainCanonicalizesLegacyAliasBeforeStartingSpring() {
TimeZone originalTimeZone = TimeZone.getDefault();
String[] args = {"--spring.profiles.active=test"};
try (MockedStatic<SpringApplication> springApplication = mockStatic(SpringApplication.class)) {
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Saigon"));
LabtimesheetApplication.main(args);
assertThat(TimeZone.getDefault().getID()).isEqualTo("Asia/Ho_Chi_Minh");
springApplication.verify(() -> SpringApplication.run(LabtimesheetApplication.class, args));
} finally {
TimeZone.setDefault(originalTimeZone);
}
}
@Test
void canonicalizesLegacyVietnamAliasBeforePostgresConnects() throws SQLException {
TimeZone originalTimeZone = TimeZone.getDefault();
try (PostgreSQLContainer postgres =
new PostgreSQLContainer(DockerImageName.parse("postgres:18.4"))) {
postgres.start();
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Saigon"));
assertThat(TimeZone.getDefault().getID()).isEqualTo("Asia/Saigon");
assertThatThrownBy(() -> openConnection(postgres))
.isInstanceOf(SQLException.class)
.hasMessageContaining("Asia/Saigon");
LabtimesheetApplication.normalizeDefaultTimeZone();
assertThat(TimeZone.getDefault().getID()).isEqualTo("Asia/Ho_Chi_Minh");
try (Connection connection = openConnection(postgres)) {
assertThat(connection.isValid(1)).isTrue();
}
} finally {
TimeZone.setDefault(originalTimeZone);
}
}
@Test
void leavesSupportedSystemTimeZoneUnchanged() {
TimeZone originalTimeZone = TimeZone.getDefault();
try {
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Paris"));
LabtimesheetApplication.normalizeDefaultTimeZone();
assertThat(TimeZone.getDefault().getID()).isEqualTo("Europe/Paris");
} finally {
TimeZone.setDefault(originalTimeZone);
}
}
private static Connection openConnection(PostgreSQLContainer postgres) throws SQLException {
return DriverManager.getConnection(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
}
}
@@ -0,0 +1,87 @@
package com.lab.labtimesheet.feature.attendance.controller;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.lab.labtimesheet.config.TestcontainersConfiguration;
import java.util.TimeZone;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Import;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.web.servlet.MockMvc;
@Import(TestcontainersConfiguration.class)
@SpringBootTest(properties = {
"LAB_SMTP_HOST=localhost",
"LAB_SMTP_PORT=1025",
"LAB_SERVER_PORT=0",
"LAB_FORWARD_HEADERS_STRATEGY=none",
"LAB_PUBLIC_ORIGIN=http://localhost:8080",
"LAB_SECURITY_MASTER_KEY=AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
})
@AutoConfigureMockMvc
@ActiveProfiles({"dev", "test"})
@ContextConfiguration(initializers = CalendarDevelopmentProfileWebIntegrationTest.AsiaHoChiMinhTimeZoneInitializer.class)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class CalendarDevelopmentProfileWebIntegrationTest {
private static final String ADMIN_EMAIL = "admin@example.test";
private static final String PASSWORD = "correct horse battery staple";
@Autowired
private MockMvc mockMvc;
@Test
void v1SeededPolicyLetsFormAuthenticatedAdminOpenCalendarInAsiaHoChiMinhDevelopmentProfile() throws Exception {
mockMvc.perform(post("/bootstrap")
.with(csrf())
.param("email", ADMIN_EMAIL)
.param("displayName", "Admin")
.param("password", PASSWORD))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/admin/smtp?onboarding"));
var login = mockMvc.perform(post("/login")
.with(csrf())
.param("username", ADMIN_EMAIL)
.param("password", PASSWORD))
.andExpect(status().is3xxRedirection())
.andExpect(authenticated().withUsername(ADMIN_EMAIL))
.andReturn();
mockMvc.perform(get("/attendance/calendar")
.session((MockHttpSession) login.getRequest().getSession(false)))
.andExpect(status().isOk());
}
@AfterAll
static void restoreSystemDefaultTimeZone() {
TimeZone.setDefault(AsiaHoChiMinhTimeZoneInitializer.originalDefaultTimeZone());
}
static final class AsiaHoChiMinhTimeZoneInitializer
implements ApplicationContextInitializer<ConfigurableApplicationContext> {
private static final TimeZone ORIGINAL_DEFAULT_TIME_ZONE = TimeZone.getDefault();
@Override
public void initialize(ConfigurableApplicationContext applicationContext) {
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Ho_Chi_Minh"));
}
static TimeZone originalDefaultTimeZone() {
return ORIGINAL_DEFAULT_TIME_ZONE;
}
}
}
@@ -8,14 +8,20 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
import com.lab.labtimesheet.feature.project.controller.ProjectController; import com.lab.labtimesheet.feature.project.controller.ProjectController;
import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView;
import com.lab.labtimesheet.feature.project.service.ProjectQueryService; import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
import com.lab.labtimesheet.feature.project.service.ProjectService; import com.lab.labtimesheet.feature.project.service.ProjectService;
import com.lab.labtimesheet.feature.task.controller.TaskController; import com.lab.labtimesheet.feature.task.controller.TaskController;
import com.lab.labtimesheet.feature.task.model.dto.TaskAssigneeChoice; import com.lab.labtimesheet.feature.task.model.dto.TaskAssigneeChoice;
import com.lab.labtimesheet.feature.task.service.TaskService; import com.lab.labtimesheet.feature.task.service.TaskService;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.util.List; import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
@@ -34,12 +40,26 @@ class ProjectTaskFormAccessibilityWebTest {
@MockitoBean @MockitoBean
private ProjectService projects; private ProjectService projects;
@MockitoBean
private AccountService accounts;
@MockitoBean
private Clock clock;
@MockitoBean @MockitoBean
private TaskService tasks; private TaskService tasks;
@MockitoBean @MockitoBean
private SmtpConfigurationService smtpConfiguration; private SmtpConfigurationService smtpConfiguration;
@BeforeEach
void mentorActor() {
given(projectQueries.authenticatedActor("mentor@example.test"))
.willReturn(new ProjectActorView(10L, "MENTOR"));
given(clock.instant()).willReturn(Instant.parse("2026-08-15T01:00:00Z"));
given(clock.getZone()).willReturn(ZoneId.of("Asia/Ho_Chi_Minh"));
}
@Test @Test
void projectFieldErrorsHaveStableIdsAndInputAssociations() throws Exception { void projectFieldErrorsHaveStableIdsAndInputAssociations() throws Exception {
mvc.perform(post("/projects") mvc.perform(post("/projects")