feat: add attendance policy and punch domain
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user