fix(attendance): preserve frozen attendance boundaries

This commit is contained in:
sechmachine
2026-08-15 02:25:13 +07:00
parent 8b48e281f7
commit 4c39df70e1
46 changed files with 1270 additions and 58 deletions
@@ -13,9 +13,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import com.lab.labtimesheet.feature.attendance.model.AttendanceActor;
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicyFixtures;
import com.lab.labtimesheet.feature.attendance.model.AttendanceRole;
import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations;
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem;
@@ -69,7 +72,7 @@ class AttendanceControllerTest {
LocalDate.of(2026, 8, 14),
Instant.parse("2026-08-14T02:00:00Z"),
Instant.parse("2026-08-14T09:00:00Z"),
AttendancePolicy.seeded(1L),
AttendancePolicyFixtures.seeded(1L),
new AttendanceViolations(false, false, false))));
mockMvc.perform(get("/attendance")
@@ -82,6 +85,30 @@ class AttendanceControllerTest {
.andExpect(content().string(org.hamcrest.Matchers.containsString("30 min")));
}
@Test
void historyRendersPolicyLocalDisplayValuesAndEveryViolation() throws Exception {
AttendanceActor actor = new AttendanceActor(42L, AttendanceRole.INTERN);
when(currentUsers.actor(any())).thenReturn(actor);
when(attendance.history(eq(actor), eq(42L), any(), any())).thenReturn(List.of(new AttendanceHistoryItem(
LocalDate.of(2026, 8, 14),
Instant.parse("2026-08-14T02:00:00.001Z"),
Instant.parse("2026-08-14T08:00:00Z"),
AttendancePolicyFixtures.seeded(1L),
new AttendanceViolations(true, true, false))));
mockMvc.perform(get("/attendance")
.with(user("intern@example.test").roles("INTERN"))
.param("from", "2026-08-01")
.param("to", "2026-08-31"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("14/08/2026")))
.andExpect(content().string(containsString("09:00")))
.andExpect(content().string(containsString("15:00")))
.andExpect(content().string(containsString("Late")))
.andExpect(content().string(containsString("Early departure")))
.andExpect(content().string(not(containsString("On time"))));
}
@Test
void mentorCanInspectInternHistory() throws Exception {
AttendanceActor mentor = new AttendanceActor(7L, AttendanceRole.MENTOR);
@@ -0,0 +1,32 @@
package com.lab.labtimesheet.feature.attendance.model;
import java.math.BigDecimal;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.util.Set;
public final class AttendancePolicyFixtures {
private AttendancePolicyFixtures() {}
public static AttendancePolicy seeded(long id) {
return new AttendancePolicy(
id,
LocalDate.of(1970, 1, 1),
ZoneId.of("Asia/Ho_Chi_Minh"),
LocalTime.of(8, 30),
LocalTime.of(15, 30),
30,
30,
3,
new BigDecimal("0.25"),
Set.of(
DayOfWeek.MONDAY,
DayOfWeek.TUESDAY,
DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY,
DayOfWeek.FRIDAY));
}
}
@@ -16,7 +16,7 @@ class AttendancePolicyTest {
@Test
void resolvesSeedPolicyForHistoricalAndCurrentDates() {
AttendancePolicy seeded = AttendancePolicy.seeded(1L);
AttendancePolicy seeded = AttendancePolicyFixtures.seeded(1L);
AttendancePolicyTimeline timeline = new AttendancePolicyTimeline(Set.of(seeded));
assertEquals(seeded, timeline.resolve(LocalDate.of(1970, 1, 1)));
@@ -0,0 +1,36 @@
package com.lab.labtimesheet.feature.attendance.model.entity;
import java.time.Instant;
import java.time.LocalDate;
public final class LeaveEntityFixtures {
private LeaveEntityFixtures() {}
public static LeaveRequestEntity approvedRequest(
long internUserId,
LocalDate startDate,
LocalDate endDate,
Instant submittedAt,
Instant firstCountedStartAt,
long decidedByMentorUserId,
Instant decidedAt) {
return new LeaveRequestEntity(
internUserId,
startDate,
endDate,
"Attendance integration fixture",
submittedAt,
firstCountedStartAt,
decidedByMentorUserId,
decidedAt);
}
public static LeaveRequestDayEntity allocatedDay(
LeaveRequestEntity request,
LocalDate leaveDate,
AttendancePolicyEntity policy,
int monthlyQuotaSnapshot) {
return new LeaveRequestDayEntity(request, leaveDate, policy, monthlyQuotaSnapshot);
}
}
@@ -2,6 +2,7 @@ package com.lab.labtimesheet.feature.attendance.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -9,6 +10,7 @@ import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.attendance.exception.AttendanceException;
import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection;
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicyFixtures;
import com.lab.labtimesheet.feature.attendance.model.AttendanceRecord;
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceCurrentState;
import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEntity;
@@ -24,6 +26,8 @@ import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
class AttendanceApplicationServiceTest {
@@ -39,7 +43,7 @@ class AttendanceApplicationServiceTest {
@BeforeEach
void setUp() {
AttendancePolicyEntity policyEntity = mock(AttendancePolicyEntity.class);
when(policyEntity.toDomain()).thenReturn(AttendancePolicy.seeded(1L));
when(policyEntity.toDomain()).thenReturn(AttendancePolicyFixtures.seeded(1L));
when(policies.findAllByOrderByEffectiveFromAsc()).thenReturn(List.of(policyEntity));
when(accounts.isEligibleIntern(INTERN_ID, WORK_DATE)).thenReturn(true);
attendance = new AttendanceApplicationService(
@@ -74,14 +78,53 @@ class AttendanceApplicationServiceTest {
.isEqualTo(AttendanceRejection.INACTIVE_INTERN));
}
@Test
void rejectsCheckoutWhenInternIsNoLongerEligibleForPersistedWorkDate() {
AttendanceRecordEntity entity = entityFor(null);
when(records.findByInternUserIdAndWorkDate(INTERN_ID, WORK_DATE))
.thenReturn(Optional.of(entity));
when(accounts.isEligibleIntern(INTERN_ID, WORK_DATE)).thenReturn(false);
assertThatThrownBy(() -> attendance.checkOut(INTERN_ID))
.isInstanceOfSatisfying(AttendanceException.class,
exception -> assertThat(exception.rejection())
.isEqualTo(AttendanceRejection.INACTIVE_INTERN));
}
@Test
void translatesConcurrentCheckInUniqueConflictToStableDuplicateRejection() {
when(records.findByInternUserIdAndWorkDate(INTERN_ID, WORK_DATE)).thenReturn(Optional.empty());
when(records.saveAndFlush(any(AttendanceRecordEntity.class)))
.thenThrow(new DataIntegrityViolationException("concurrent unique conflict"));
assertThatThrownBy(() -> attendance.checkIn(INTERN_ID))
.isInstanceOfSatisfying(AttendanceException.class,
exception -> assertThat(exception.rejection())
.isEqualTo(AttendanceRejection.ALREADY_CHECKED_IN));
}
@Test
void translatesConcurrentCheckoutVersionConflictToStableDuplicateRejection() {
AttendanceRecordEntity entity = entityFor(null);
when(records.findByInternUserIdAndWorkDate(INTERN_ID, WORK_DATE)).thenReturn(Optional.of(entity));
when(records.saveAndFlush(entity))
.thenThrow(new ObjectOptimisticLockingFailureException(AttendanceRecordEntity.class, 1L));
assertThatThrownBy(() -> attendance.checkOut(INTERN_ID))
.isInstanceOfSatisfying(AttendanceException.class,
exception -> assertThat(exception.rejection())
.isEqualTo(AttendanceRejection.ALREADY_CHECKED_OUT));
}
private static AttendanceRecordEntity entityFor(Instant checkOutAt) {
AttendanceRecordEntity entity = mock(AttendanceRecordEntity.class);
when(entity.toDomain()).thenReturn(new AttendanceRecord(
INTERN_ID,
WORK_DATE,
AttendancePolicy.seeded(1L),
AttendancePolicyFixtures.seeded(1L),
NOW,
checkOutAt));
when(entity.workDate()).thenReturn(WORK_DATE);
return entity;
}
}
@@ -0,0 +1,119 @@
package com.lab.labtimesheet.feature.attendance.service;
import static org.assertj.core.api.Assertions.assertThat;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand;
import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.account.service.BootstrapService;
import com.lab.labtimesheet.feature.attendance.exception.AttendanceException;
import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection;
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
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.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
@Import(AttendancePersistenceIntegrationTest.IntegrationConfiguration.class)
@SpringBootTest
@ActiveProfiles("test")
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class AttendanceConcurrencyIntegrationTest {
@Autowired
private AttendanceApplicationService attendance;
@Autowired
private BootstrapService bootstrap;
@Autowired
private AccountService accounts;
@Autowired
private SmtpConfigurationService smtp;
@Autowired
private AttendancePersistenceIntegrationTest.RecordingSmtpProbe mail;
@Autowired
private AttendancePersistenceIntegrationTest.MutableClock clock;
@Test
void concurrentDuplicatePunchesReturnStableDomainOutcomes() throws Exception {
long internId = createActiveIntern();
clock.set(Instant.parse("2026-08-14T02:00:00Z"));
assertThat(runConcurrently(() -> punchOutcome(() -> attendance.checkIn(internId))))
.containsExactlyInAnyOrder("SUCCESS", AttendanceRejection.ALREADY_CHECKED_IN.name());
clock.set(Instant.parse("2026-08-14T09:00:00Z"));
assertThat(runConcurrently(() -> punchOutcome(() -> attendance.checkOut(internId))))
.containsExactlyInAnyOrder("SUCCESS", AttendanceRejection.ALREADY_CHECKED_OUT.name());
}
private long createActiveIntern() {
bootstrap.bootstrap("concurrency-admin@example.test", "Admin", "correct horse battery staple");
long adminId = accounts.requireActiveAdminId("concurrency-admin@example.test");
long draftId = smtp.saveDraft(adminId, new SmtpDraft(
"mailpit",
1025,
SecurityMode.NONE,
null,
null,
"concurrency-admin@example.test",
"Lab Timesheet"));
smtp.testDraft(draftId, adminId, "concurrency-admin@example.test");
smtp.activate(draftId, adminId);
mail.clear();
var creation = accounts.create(new CreateAccountCommand(
"concurrency-intern@example.test",
"Concurrent Intern",
GlobalRole.INTERN,
"INT-CONCURRENT",
LocalDate.of(2026, 8, 1),
LocalDate.of(2026, 12, 31)), adminId);
assertThat(creation.deliverySucceeded()).isTrue();
assertThat(accounts.activate(mail.onlyActivationToken(), "new secure intern password")).isTrue();
accounts.activateInternship(creation.userId(), adminId);
return creation.userId();
}
private static String punchOutcome(Runnable punch) {
try {
punch.run();
return "SUCCESS";
} catch (AttendanceException rejection) {
return rejection.rejection().name();
}
}
private static List<String> runConcurrently(Callable<String> action) throws Exception {
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
Callable<String> synchronizedAction = () -> {
ready.countDown();
start.await();
return action.call();
};
Future<String> first = executor.submit(synchronizedAction);
Future<String> second = executor.submit(synchronizedAction);
ready.await();
start.countDown();
return List.of(first.get(), second.get());
}
}
}
@@ -13,6 +13,8 @@ import com.lab.labtimesheet.feature.attendance.model.AttendanceActor;
import com.lab.labtimesheet.feature.attendance.model.AttendanceRole;
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceCurrentState;
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem;
import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEntity;
import com.lab.labtimesheet.feature.attendance.model.entity.LeaveRequestEntity;
import com.lab.labtimesheet.feature.attendance.repository.AttendanceRecordRepository;
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
@@ -26,6 +28,7 @@ import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -40,6 +43,8 @@ import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import org.testcontainers.postgresql.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;
import static com.lab.labtimesheet.feature.attendance.model.entity.LeaveEntityFixtures.allocatedDay;
import static com.lab.labtimesheet.feature.attendance.model.entity.LeaveEntityFixtures.approvedRequest;
@Import(AttendancePersistenceIntegrationTest.IntegrationConfiguration.class)
@SpringBootTest
@@ -71,6 +76,9 @@ class AttendancePersistenceIntegrationTest {
@Autowired
private AttendanceRecordRepository records;
@Autowired
private EntityManager entityManager;
@Autowired
private MutableClock clock;
@@ -141,6 +149,38 @@ class AttendancePersistenceIntegrationTest {
assertThat(item.violations().missingCheckout()).isFalse();
}
@Test
void approvedLeaveBlocksOnlyItsFrozenAllocatedDates() {
LocalDate unallocatedDate = LocalDate.of(2026, 8, 14);
LocalDate allocatedDate = LocalDate.of(2026, 8, 17);
LeaveRequestEntity request = approvedRequest(
internId,
unallocatedDate,
allocatedDate,
Instant.parse("2026-08-13T00:00:00Z"),
Instant.parse("2026-08-14T01:30:00Z"),
adminId,
Instant.parse("2026-08-13T00:30:00Z"));
entityManager.persist(request);
entityManager.flush();
entityManager.persist(allocatedDay(
request,
allocatedDate,
entityManager.getReference(AttendancePolicyEntity.class, 1L),
3));
entityManager.flush();
clock.set(Instant.parse("2026-08-14T02:00:00Z"));
attendance.checkIn(internId);
assertThat(records.findByInternUserIdAndWorkDate(internId, unallocatedDate)).isPresent();
clock.set(Instant.parse("2026-08-17T02:00:00Z"));
assertThatThrownBy(() -> attendance.checkIn(internId))
.isInstanceOfSatisfying(AttendanceException.class,
exception -> assertThat(exception.rejection())
.isEqualTo(com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.APPROVED_LEAVE));
}
@Test
void calendarDayOffBlocksCheckInAndPastEventsAreImmutable() {
AttendanceActor admin = new AttendanceActor(adminId, AttendanceRole.ADMIN);
@@ -17,6 +17,7 @@ import com.lab.labtimesheet.feature.attendance.exception.AttendanceException;
import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection;
import com.lab.labtimesheet.feature.attendance.model.AttendanceDayContext;
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicyFixtures;
import com.lab.labtimesheet.feature.attendance.model.AttendanceRecord;
import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations;
import java.math.BigDecimal;
@@ -158,7 +159,7 @@ class AttendanceServiceTest {
}
private static AttendancePolicy seededPolicy() {
return AttendancePolicy.seeded(1L);
return AttendancePolicyFixtures.seeded(1L);
}
private static AttendancePolicy policy(int checkoutGraceMinutes, Set<DayOfWeek> workdays) {