Merge commit '01b8095e9459417e2cf5bd1079c796d4f01ec549' into work/reports-ui
# Conflicts: # src/main/resources/templates/attendance/history.html
This commit is contained in:
+28
-1
@@ -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);
|
||||
|
||||
+32
@@ -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));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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)));
|
||||
|
||||
+36
@@ -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);
|
||||
}
|
||||
}
|
||||
+45
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+119
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -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);
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user