feat: add attendance policy and punch domain

This commit is contained in:
sechmachine
2026-08-14 23:34:12 +07:00
parent 5967f7f70d
commit 71901d1670
14 changed files with 697 additions and 0 deletions
@@ -0,0 +1,65 @@
package com.lab.labtimesheet.attendance;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.math.BigDecimal;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.util.Set;
import org.junit.jupiter.api.Test;
class AttendancePolicyTest {
@Test
void resolvesSeedPolicyForHistoricalAndCurrentDates() {
AttendancePolicy seeded = AttendancePolicy.seeded(1L);
AttendancePolicyTimeline timeline = new AttendancePolicyTimeline(Set.of(seeded));
assertEquals(seeded, timeline.resolve(LocalDate.of(1970, 1, 1)));
assertEquals(seeded, timeline.resolve(LocalDate.of(2026, 8, 14)));
assertEquals(ZoneId.of("Asia/Ho_Chi_Minh"), seeded.zoneId());
assertEquals(LocalTime.of(8, 30), seeded.scheduledStart());
assertEquals(LocalTime.of(15, 30), seeded.scheduledEnd());
assertEquals(30, seeded.checkInGraceMinutes());
assertEquals(30, seeded.checkoutGraceMinutes());
assertEquals(3, seeded.monthlyLeaveQuota());
assertEquals(new BigDecimal("0.25"), seeded.violationPenalty());
assertEquals(
Set.of(
DayOfWeek.MONDAY,
DayOfWeek.TUESDAY,
DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY,
DayOfWeek.FRIDAY),
seeded.workdays());
}
@Test
void rejectsGraceOutsideZeroThroughSevenHundredTwenty() {
assertThrows(IllegalArgumentException.class, () -> policy(-1, 30, LocalTime.of(15, 30)));
assertThrows(IllegalArgumentException.class, () -> policy(30, 721, LocalTime.of(15, 30)));
}
@Test
void rejectsCheckoutCutoffAtLocalMidnight() {
assertThrows(IllegalArgumentException.class, () -> policy(30, 30, LocalTime.of(23, 30)));
}
private static AttendancePolicy policy(
int checkInGraceMinutes, int checkoutGraceMinutes, LocalTime scheduledEnd) {
return new AttendancePolicy(
2L,
LocalDate.of(2026, 9, 1),
ZoneId.of("Asia/Ho_Chi_Minh"),
LocalTime.of(8, 30),
scheduledEnd,
checkInGraceMinutes,
checkoutGraceMinutes,
3,
new BigDecimal("0.25"),
Set.of(DayOfWeek.MONDAY));
}
}
@@ -0,0 +1,199 @@
package com.lab.labtimesheet.attendance;
import static com.lab.labtimesheet.attendance.AttendanceRejection.ALREADY_CHECKED_IN;
import static com.lab.labtimesheet.attendance.AttendanceRejection.ALREADY_CHECKED_OUT;
import static com.lab.labtimesheet.attendance.AttendanceRejection.APPROVED_LEAVE;
import static com.lab.labtimesheet.attendance.AttendanceRejection.CHECKOUT_CUTOFF_PASSED;
import static com.lab.labtimesheet.attendance.AttendanceRejection.GLOBAL_DAY_OFF;
import static com.lab.labtimesheet.attendance.AttendanceRejection.INACTIVE_INTERN;
import static com.lab.labtimesheet.attendance.AttendanceRejection.NON_WORKDAY;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.Test;
class AttendanceServiceTest {
private static final long INTERN_ID = 42L;
private static final LocalDate WORKDAY = LocalDate.of(2026, 8, 14);
@Test
void exactCheckInGraceBoundaryIsOnTimeAndFirstLaterInstantIsLate() {
AttendanceRecord exactBoundary = checkInAt("2026-08-14T02:00:00Z", activeDay(), seededPolicy());
AttendanceRecord firstLater = checkInAt("2026-08-14T02:00:00.001Z", activeDay(), seededPolicy());
assertFalse(exactBoundary.violations(at("2026-08-14T02:00:00Z")).late());
assertTrue(firstLater.violations(at("2026-08-14T02:00:00.001Z")).late());
assertEquals(WORKDAY, exactBoundary.workDate());
assertEquals(1L, exactBoundary.policy().id());
}
@Test
void rejectsIneligibleAndDuplicateCheckIns() {
assertCheckInRejected(INACTIVE_INTERN, new AttendanceDayContext(false, false, false));
assertCheckInRejected(GLOBAL_DAY_OFF, new AttendanceDayContext(true, true, false));
assertCheckInRejected(APPROVED_LEAVE, new AttendanceDayContext(true, false, true));
AttendancePolicy weekendOnly = policy(30, Set.of(DayOfWeek.SATURDAY));
assertCheckInRejected(NON_WORKDAY, activeDay(), weekendOnly);
InMemoryAttendanceRepository repository = new InMemoryAttendanceRepository();
AttendanceService service = serviceAt("2026-08-14T01:30:00Z", repository, activeDay(), seededPolicy());
service.checkIn(INTERN_ID);
AttendanceException exception = assertThrows(AttendanceException.class, () -> service.checkIn(INTERN_ID));
assertEquals(ALREADY_CHECKED_IN, exception.rejection());
assertEquals(1, repository.records.size());
}
@Test
void checkoutIsInclusiveAtCutoffAndCannotBeOverwritten() {
InMemoryAttendanceRepository repository = checkedInRepository(seededPolicy());
AttendanceService atCutoff = serviceAt("2026-08-14T09:00:00Z", repository, activeDay(), seededPolicy());
AttendanceRecord checkedOut = atCutoff.checkOut(INTERN_ID);
assertEquals(at("2026-08-14T09:00:00Z"), checkedOut.checkOutAt());
AttendanceService later = serviceAt("2026-08-14T09:00:00.001Z", repository, activeDay(), seededPolicy());
AttendanceException repeated = assertThrows(AttendanceException.class, () -> later.checkOut(INTERN_ID));
assertEquals(ALREADY_CHECKED_OUT, repeated.rejection());
assertEquals(at("2026-08-14T09:00:00Z"), repository.record().checkOutAt());
}
@Test
void firstInstantAfterCheckoutCutoffIsRejectedWithoutRawCheckout() {
InMemoryAttendanceRepository repository = checkedInRepository(seededPolicy());
AttendanceService service = serviceAt("2026-08-14T09:00:00.001Z", repository, activeDay(), seededPolicy());
AttendanceException exception = assertThrows(AttendanceException.class, () -> service.checkOut(INTERN_ID));
assertEquals(CHECKOUT_CUTOFF_PASSED, exception.rejection());
assertNull(repository.record().checkOutAt());
AttendanceViolations violations = repository.record().violations(at("2026-08-14T09:00:00.001Z"));
assertTrue(violations.missingCheckout());
assertFalse(violations.earlyDeparture());
}
@Test
void zeroGraceCheckoutUsesScheduledEndAsInclusiveCutoff() {
AttendancePolicy zeroGrace = policy(
0,
Set.of(
DayOfWeek.MONDAY,
DayOfWeek.TUESDAY,
DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY,
DayOfWeek.FRIDAY));
InMemoryAttendanceRepository repository = checkedInRepository(zeroGrace);
AttendanceRecord checkedOut = serviceAt("2026-08-14T08:30:00Z", repository, activeDay(), zeroGrace)
.checkOut(INTERN_ID);
assertEquals(at("2026-08-14T08:30:00Z"), checkedOut.checkOutAt());
InMemoryAttendanceRepository lateRepository = checkedInRepository(zeroGrace);
AttendanceException exception = assertThrows(
AttendanceException.class,
() -> serviceAt("2026-08-14T08:30:00.001Z", lateRepository, activeDay(), zeroGrace)
.checkOut(INTERN_ID));
assertEquals(CHECKOUT_CUTOFF_PASSED, exception.rejection());
assertNull(lateRepository.record().checkOutAt());
}
private static AttendanceRecord checkInAt(
String instant, AttendanceDayContext context, AttendancePolicy policy) {
return serviceAt(instant, new InMemoryAttendanceRepository(), context, policy).checkIn(INTERN_ID);
}
private static void assertCheckInRejected(AttendanceRejection rejection, AttendanceDayContext context) {
assertCheckInRejected(rejection, context, seededPolicy());
}
private static void assertCheckInRejected(
AttendanceRejection rejection, AttendanceDayContext context, AttendancePolicy policy) {
AttendanceException exception = assertThrows(
AttendanceException.class,
() -> serviceAt("2026-08-14T01:30:00Z", new InMemoryAttendanceRepository(), context, policy)
.checkIn(INTERN_ID));
assertEquals(rejection, exception.rejection());
}
private static AttendanceService serviceAt(
String instant,
InMemoryAttendanceRepository repository,
AttendanceDayContext context,
AttendancePolicy policy) {
return new AttendanceService(
Clock.fixed(at(instant), ZoneOffset.UTC),
new AttendancePolicyTimeline(Set.of(policy)),
repository,
(internId, date) -> context);
}
private static InMemoryAttendanceRepository checkedInRepository(AttendancePolicy policy) {
InMemoryAttendanceRepository repository = new InMemoryAttendanceRepository();
serviceAt("2026-08-14T01:30:00Z", repository, activeDay(), policy).checkIn(INTERN_ID);
return repository;
}
private static AttendanceDayContext activeDay() {
return new AttendanceDayContext(true, false, false);
}
private static AttendancePolicy seededPolicy() {
return AttendancePolicy.seeded(1L);
}
private static AttendancePolicy policy(int checkoutGraceMinutes, Set<DayOfWeek> workdays) {
return new AttendancePolicy(
2L,
LocalDate.of(1970, 1, 1),
ZoneId.of("Asia/Ho_Chi_Minh"),
LocalTime.of(8, 30),
LocalTime.of(15, 30),
30,
checkoutGraceMinutes,
3,
new BigDecimal("0.25"),
workdays);
}
private static Instant at(String instant) {
return Instant.parse(instant);
}
private static final class InMemoryAttendanceRepository implements AttendanceRepository {
private final Map<LocalDate, AttendanceRecord> records = new HashMap<>();
@Override
public Optional<AttendanceRecord> find(long internId, LocalDate workDate) {
return Optional.ofNullable(records.get(workDate));
}
@Override
public AttendanceRecord save(AttendanceRecord record) {
records.put(record.workDate(), record);
return record;
}
private AttendanceRecord record() {
return records.get(WORKDAY);
}
}
}