feat: add iteration one attendance workflows

This commit is contained in:
sechmachine
2026-08-15 00:43:21 +07:00
parent c01c0089ac
commit 8b48e281f7
44 changed files with 2019 additions and 169 deletions
@@ -0,0 +1,57 @@
package com.lab.labtimesheet.architecture;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.data.repository.Repository;
import org.springframework.jdbc.core.JdbcTemplate;
class AttendanceLayerStructureTest {
@Test
void attendanceUsesAuthoritativeLayerPackagesWithoutLegacyFeaturePackage() throws Exception {
for (String className : List.of(
"com.lab.labtimesheet.feature.attendance.controller.AttendanceController",
"com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem",
"com.lab.labtimesheet.feature.attendance.exception.AttendanceException",
"com.lab.labtimesheet.feature.attendance.model.AttendancePolicy",
"com.lab.labtimesheet.feature.attendance.model.entity.AttendanceRecordEntity",
"com.lab.labtimesheet.feature.attendance.repository.AttendanceRecordRepository",
"com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService")) {
assertThat(Class.forName(className)).isNotNull();
}
assertThatThrownBy(() -> Class.forName("com.lab.labtimesheet.attendance.AttendanceService"))
.isInstanceOf(ClassNotFoundException.class);
assertThatThrownBy(() -> Class.forName("com.lab.labtimesheet.controller.AttendanceController"))
.isInstanceOf(ClassNotFoundException.class);
}
@Test
void attendanceQueriesUseSpringDataJpaRatherThanJdbcTemplate() throws Exception {
Class<?> queryRepository = Class.forName(
"com.lab.labtimesheet.feature.attendance.repository.AttendanceQueryRepository");
assertThat(Repository.class).isAssignableFrom(queryRepository);
for (String serviceName : List.of(
"com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService",
"com.lab.labtimesheet.feature.attendance.service.AttendanceCurrentUserService")) {
assertThat(Class.forName(serviceName).getDeclaredFields())
.allSatisfy(field -> assertThat(field.getType()).isNotEqualTo(JdbcTemplate.class));
}
}
@Test
void attendanceDoesNotMapOrExposeAccountFeatureTables() {
for (String className : List.of(
"com.lab.labtimesheet.feature.attendance.model.entity.AppUserEntity",
"com.lab.labtimesheet.feature.attendance.model.entity.InternProfileEntity",
"com.lab.labtimesheet.feature.attendance.repository.AppUserRepository",
"com.lab.labtimesheet.feature.attendance.repository.InternProfileRepository")) {
assertThatThrownBy(() -> Class.forName(className))
.isInstanceOf(ClassNotFoundException.class);
}
}
}
@@ -0,0 +1,161 @@
package com.lab.labtimesheet.feature.attendance.controller;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
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.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
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 com.lab.labtimesheet.feature.attendance.model.AttendanceActor;
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
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;
import com.lab.labtimesheet.feature.attendance.model.dto.GlobalCalendarEvent;
import com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService;
import com.lab.labtimesheet.feature.attendance.service.AttendanceCurrentUserService;
import com.lab.labtimesheet.feature.attendance.service.CalendarApplicationService;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest({AttendanceController.class, CalendarController.class})
class AttendanceControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private AttendanceApplicationService attendance;
@MockitoBean
private CalendarApplicationService calendar;
@MockitoBean
private AttendanceCurrentUserService currentUsers;
@Test
void internPunchesOnlyForAuthenticatedSelf() throws Exception {
AttendanceActor actor = new AttendanceActor(42L, AttendanceRole.INTERN);
when(currentUsers.actor(any())).thenReturn(actor);
mockMvc.perform(post("/attendance/check-in")
.with(user("intern@example.test").roles("INTERN"))
.with(csrf()))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/attendance"));
verify(attendance).checkIn(42L);
}
@Test
void ownHistoryRendersAttachedHistoricalPolicy() 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:00Z"),
Instant.parse("2026-08-14T09:00:00Z"),
AttendancePolicy.seeded(1L),
new AttendanceViolations(false, false, 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(view().name("attendance/history"))
.andExpect(model().attribute("targetInternId", 42L))
.andExpect(content().string(org.hamcrest.Matchers.containsString("30 min")));
}
@Test
void mentorCanInspectInternHistory() throws Exception {
AttendanceActor mentor = new AttendanceActor(7L, AttendanceRole.MENTOR);
when(currentUsers.actor(any())).thenReturn(mentor);
when(attendance.currentBusinessDate()).thenReturn(LocalDate.of(2026, 8, 14));
when(attendance.history(eq(mentor), eq(42L), any(), any())).thenReturn(List.of());
mockMvc.perform(get("/attendance/interns/42")
.with(user("mentor@example.test").roles("MENTOR")))
.andExpect(status().isOk())
.andExpect(view().name("attendance/history"));
verify(attendance).history(
mentor, 42L, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 14));
}
@Test
void onlyAdminCanOpenCalendarManagement() throws Exception {
when(currentUsers.actor(any())).thenReturn(new AttendanceActor(7L, AttendanceRole.MENTOR));
mockMvc.perform(get("/attendance/calendar")
.with(user("mentor@example.test").roles("MENTOR")))
.andExpect(status().isForbidden());
}
@Test
void adminCreatesManualDayOffFromServerAuthorizedIdentity() throws Exception {
AttendanceActor admin = new AttendanceActor(1L, AttendanceRole.ADMIN);
when(currentUsers.actor(any())).thenReturn(admin);
mockMvc.perform(post("/attendance/calendar")
.with(user("admin@example.test").roles("ADMIN"))
.with(csrf())
.param("date", "2026-08-20")
.param("name", "Lab closure")
.param("dayOff", "true"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/attendance/calendar"));
verify(calendar).createManual(admin, LocalDate.of(2026, 8, 20), "Lab closure", true);
}
@Test
void adminCalendarRendersEditableVersionedEvents() throws Exception {
AttendanceActor admin = new AttendanceActor(1L, AttendanceRole.ADMIN);
when(currentUsers.actor(any())).thenReturn(admin);
when(attendance.currentBusinessDate()).thenReturn(LocalDate.of(2026, 8, 14));
when(calendar.list(LocalDate.of(2026, 8, 14), LocalDate.of(2027, 8, 14)))
.thenReturn(List.of(new GlobalCalendarEvent(
9L, LocalDate.of(2026, 8, 20), "Lab closure", true, 3L)));
mockMvc.perform(get("/attendance/calendar")
.with(user("admin@example.test").roles("ADMIN")))
.andExpect(status().isOk())
.andExpect(view().name("attendance/calendar"))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Lab closure")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("value=\"3\"")));
}
@Test
void adminUpdateCarriesOptimisticVersion() throws Exception {
AttendanceActor admin = new AttendanceActor(1L, AttendanceRole.ADMIN);
when(currentUsers.actor(any())).thenReturn(admin);
mockMvc.perform(post("/attendance/calendar/9")
.with(user("admin@example.test").roles("ADMIN"))
.with(csrf())
.param("version", "3")
.param("date", "2026-08-20")
.param("name", "Lab closure")
.param("dayOff", "true"))
.andExpect(status().is3xxRedirection());
verify(calendar).updateManual(
admin, 9L, 3L, LocalDate.of(2026, 8, 20), "Lab closure", true);
}
}
@@ -1,5 +1,6 @@
package com.lab.labtimesheet.attendance;
package com.lab.labtimesheet.feature.attendance.model;
import com.lab.labtimesheet.feature.attendance.service.AttendancePolicyTimeline;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -0,0 +1,87 @@
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.Mockito.mock;
import static org.mockito.Mockito.when;
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.AttendanceRecord;
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceCurrentState;
import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEntity;
import com.lab.labtimesheet.feature.attendance.model.entity.AttendanceRecordEntity;
import com.lab.labtimesheet.feature.attendance.repository.AttendancePolicyRepository;
import com.lab.labtimesheet.feature.attendance.repository.AttendanceQueryRepository;
import com.lab.labtimesheet.feature.attendance.repository.AttendanceRecordRepository;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class AttendanceApplicationServiceTest {
private static final long INTERN_ID = 42L;
private static final Instant NOW = Instant.parse("2026-08-14T02:00:00Z");
private static final LocalDate WORK_DATE = LocalDate.of(2026, 8, 14);
private final AttendancePolicyRepository policies = mock(AttendancePolicyRepository.class);
private final AttendanceRecordRepository records = mock(AttendanceRecordRepository.class);
private final AccountService accounts = mock(AccountService.class);
private AttendanceApplicationService attendance;
@BeforeEach
void setUp() {
AttendancePolicyEntity policyEntity = mock(AttendancePolicyEntity.class);
when(policyEntity.toDomain()).thenReturn(AttendancePolicy.seeded(1L));
when(policies.findAllByOrderByEffectiveFromAsc()).thenReturn(List.of(policyEntity));
when(accounts.isEligibleIntern(INTERN_ID, WORK_DATE)).thenReturn(true);
attendance = new AttendanceApplicationService(
Clock.fixed(NOW, ZoneOffset.UTC),
policies,
records,
mock(AttendanceQueryRepository.class),
accounts,
mock(CalendarApplicationService.class),
new AttendanceService());
}
@Test
void reportsCurrentBusinessDatePunchStateWithoutExposingPersistenceTypes() {
when(records.findByInternUserIdAndWorkDate(INTERN_ID, WORK_DATE))
.thenReturn(Optional.empty())
.thenReturn(Optional.of(entityFor(null)))
.thenReturn(Optional.of(entityFor(NOW.plusSeconds(60))));
assertThat(attendance.currentState(INTERN_ID)).isEqualTo(AttendanceCurrentState.NOT_CHECKED_IN);
assertThat(attendance.currentState(INTERN_ID)).isEqualTo(AttendanceCurrentState.CHECKED_IN);
assertThat(attendance.currentState(INTERN_ID)).isEqualTo(AttendanceCurrentState.CHECKED_OUT);
}
@Test
void rejectsCurrentStateLookupForIneligibleIntern() {
when(accounts.isEligibleIntern(INTERN_ID, WORK_DATE)).thenReturn(false);
assertThatThrownBy(() -> attendance.currentState(INTERN_ID))
.isInstanceOfSatisfying(AttendanceException.class,
exception -> assertThat(exception.rejection())
.isEqualTo(AttendanceRejection.INACTIVE_INTERN));
}
private static AttendanceRecordEntity entityFor(Instant checkOutAt) {
AttendanceRecordEntity entity = mock(AttendanceRecordEntity.class);
when(entity.toDomain()).thenReturn(new AttendanceRecord(
INTERN_ID,
WORK_DATE,
AttendancePolicy.seeded(1L),
NOW,
checkOutAt));
return entity;
}
}
@@ -0,0 +1,289 @@
package com.lab.labtimesheet.feature.attendance.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
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.CalendarException;
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.repository.AttendanceRecordRepository;
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
import com.lab.labtimesheet.feature.integration.service.SmtpProbe;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Primary;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import org.testcontainers.postgresql.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;
@Import(AttendancePersistenceIntegrationTest.IntegrationConfiguration.class)
@SpringBootTest
@ActiveProfiles("test")
@Transactional
class AttendancePersistenceIntegrationTest {
@Autowired
private AttendanceApplicationService attendance;
@Autowired
private CalendarApplicationService calendar;
@Autowired
private AttendanceCurrentUserService currentUsers;
@Autowired
private BootstrapService bootstrap;
@Autowired
private AccountService accounts;
@Autowired
private SmtpConfigurationService smtp;
@Autowired
private RecordingSmtpProbe mail;
@Autowired
private AttendanceRecordRepository records;
@Autowired
private MutableClock clock;
private long internId;
private long adminId;
private long mentorId;
@BeforeEach
void seedUsers() {
clock.set(Instant.parse("2026-08-14T00:00:00Z"));
bootstrap.bootstrap("admin@example.test", "Admin", "correct horse battery staple");
adminId = accounts.requireActiveAdminId("admin@example.test");
long draftId = smtp.saveDraft(adminId, new SmtpDraft(
"mailpit",
1025,
SecurityMode.NONE,
null,
null,
"admin@example.test",
"Lab Timesheet"));
smtp.testDraft(draftId, adminId, "admin@example.test");
smtp.activate(draftId, adminId);
mail.clear();
var creation = accounts.create(new CreateAccountCommand(
"intern@example.test",
"Intern",
GlobalRole.INTERN,
"INT-001",
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);
mentorId = adminId + 1;
internId = creation.userId();
}
@Test
void storesServerPunchesWithSeededPolicyAndHistoricalPolicyDetails() {
clock.set(Instant.parse("2026-08-14T02:00:00Z"));
assertThat(attendance.currentState(internId)).isEqualTo(AttendanceCurrentState.NOT_CHECKED_IN);
attendance.checkIn(internId);
assertThat(attendance.currentState(internId)).isEqualTo(AttendanceCurrentState.CHECKED_IN);
var persisted = records.findByInternUserIdAndWorkDate(internId, LocalDate.of(2026, 8, 14))
.orElseThrow()
.toDomain();
assertThat(persisted.checkInAt()).isEqualTo(clock.instant());
assertThat(persisted.policy().id()).isEqualTo(1L);
assertThatThrownBy(() -> attendance.checkIn(internId)).isInstanceOf(AttendanceException.class);
clock.set(Instant.parse("2026-08-14T09:00:00Z"));
attendance.checkOut(internId);
assertThat(attendance.currentState(internId)).isEqualTo(AttendanceCurrentState.CHECKED_OUT);
AttendanceHistoryItem item = attendance.history(
new AttendanceActor(internId, AttendanceRole.INTERN),
internId,
LocalDate.of(2026, 8, 14),
LocalDate.of(2026, 8, 14))
.getFirst();
assertThat(item.policy().id()).isEqualTo(1L);
assertThat(item.policy().checkoutGraceMinutes()).isEqualTo(30);
assertThat(item.checkOutAt()).isEqualTo(clock.instant());
assertThat(item.violations().missingCheckout()).isFalse();
}
@Test
void calendarDayOffBlocksCheckInAndPastEventsAreImmutable() {
AttendanceActor admin = new AttendanceActor(adminId, AttendanceRole.ADMIN);
AttendanceActor intern = new AttendanceActor(internId, AttendanceRole.INTERN);
LocalDate workDate = LocalDate.of(2026, 8, 14);
clock.set(Instant.parse("2026-08-13T02:00:00Z"));
assertThatThrownBy(() -> calendar.createManual(intern, workDate, "Blocked", true))
.isInstanceOf(AccessDeniedException.class);
var event = calendar.createManual(admin, workDate, "Team holiday", true);
clock.set(Instant.parse("2026-08-14T02:00:00Z"));
assertThatThrownBy(() -> attendance.checkIn(internId)).isInstanceOf(AttendanceException.class);
clock.set(Instant.parse("2026-08-15T02:00:00Z"));
assertThatThrownBy(() -> calendar.updateManual(
admin, event.id(), event.version(), workDate, "Changed", false))
.isInstanceOf(CalendarException.class);
}
@Test
void calendarRejectsStaleOptimisticVersion() {
AttendanceActor admin = new AttendanceActor(adminId, AttendanceRole.ADMIN);
LocalDate date = LocalDate.of(2026, 8, 20);
var event = calendar.createManual(admin, date, "Lab closure", true);
calendar.updateManual(admin, event.id(), event.version(), date, "Lab open", false);
assertThatThrownBy(() -> calendar.updateManual(
admin, event.id(), event.version(), date, "Stale edit", true))
.isInstanceOf(CalendarException.class);
}
@Test
void publicCalendarServiceReportsAuthoritativeDayOff() {
AttendanceActor admin = new AttendanceActor(adminId, AttendanceRole.ADMIN);
LocalDate date = LocalDate.of(2026, 8, 20);
var event = calendar.createManual(admin, date, "Observance", false);
assertThat(calendar.isGlobalDayOff(date)).isFalse();
calendar.updateManual(admin, event.id(), event.version(), date, "Lab closure", true);
assertThat(calendar.isGlobalDayOff(date)).isTrue();
}
@Test
void ownHistoryAndMentorAdminInspectionAreAuthorized() {
clock.set(Instant.parse("2026-08-14T02:00:00Z"));
attendance.checkIn(internId);
LocalDate date = LocalDate.of(2026, 8, 14);
assertThat(attendance.history(
new AttendanceActor(internId, AttendanceRole.INTERN), internId, date, date))
.hasSize(1);
assertThat(attendance.history(
new AttendanceActor(mentorId, AttendanceRole.MENTOR), internId, date, date))
.hasSize(1);
assertThat(attendance.history(
new AttendanceActor(adminId, AttendanceRole.ADMIN), internId, date, date))
.hasSize(1);
assertThatThrownBy(() -> attendance.history(
new AttendanceActor(internId + 100, AttendanceRole.INTERN), internId, date, date))
.isInstanceOf(AccessDeniedException.class);
}
@Test
void currentActorComesFromActiveNormalizedAccountServiceIdentity() {
assertThat(currentUsers.actor(() -> " INTERN@EXAMPLE.TEST "))
.isEqualTo(new AttendanceActor(internId, AttendanceRole.INTERN));
assertThatThrownBy(() -> currentUsers.actor(() -> "missing@example.test"))
.isInstanceOf(AccessDeniedException.class);
}
@TestConfiguration(proxyBeanMethods = false)
static class IntegrationConfiguration {
@Bean
@ServiceConnection
PostgreSQLContainer postgresContainer() {
return new PostgreSQLContainer(DockerImageName.parse("postgres:18.4"));
}
@Bean
@Primary
MutableClock mutableClock() {
return new MutableClock(Instant.parse("2026-08-14T00:00:00Z"));
}
@Bean
@Primary
RecordingSmtpProbe recordingSmtpProbe() {
return new RecordingSmtpProbe();
}
}
static final class RecordingSmtpProbe implements SmtpProbe {
private final List<String> messages = new ArrayList<>();
@Override
public void send(SmtpConnection connection, String recipient, String subject, String body) {
messages.add(body);
}
void clear() {
messages.clear();
}
String onlyActivationToken() {
assertThat(messages).hasSize(1);
String body = messages.getFirst();
int tokenStart = body.indexOf("token=");
assertThat(tokenStart).isGreaterThanOrEqualTo(0);
return body.substring(tokenStart + "token=".length()).trim();
}
}
static final class MutableClock extends Clock {
private Instant instant;
MutableClock(Instant instant) {
this.instant = instant;
}
void set(Instant instant) {
this.instant = instant;
}
@Override
public ZoneId getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(ZoneId zone) {
return this;
}
@Override
public Instant instant() {
return instant;
}
}
}
@@ -1,28 +1,30 @@
package com.lab.labtimesheet.attendance;
package com.lab.labtimesheet.feature.attendance.service;
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 com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.ALREADY_CHECKED_IN;
import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.ALREADY_CHECKED_OUT;
import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.APPROVED_LEAVE;
import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.CHECKOUT_CUTOFF_PASSED;
import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.GLOBAL_DAY_OFF;
import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.INACTIVE_INTERN;
import static com.lab.labtimesheet.feature.attendance.exception.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 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.AttendanceRecord;
import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations;
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;
@@ -52,39 +54,48 @@ class AttendanceServiceTest {
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);
AttendanceService service = new AttendanceService();
AttendanceRecord existing = checkInAt("2026-08-14T01:30:00Z", activeDay(), seededPolicy());
AttendanceException exception = assertThrows(AttendanceException.class, () -> service.checkIn(INTERN_ID));
AttendanceException exception = assertThrows(
AttendanceException.class,
() -> service.checkIn(
INTERN_ID,
at("2026-08-14T01:30:00Z"),
seededPolicy(),
activeDay(),
Optional.of(existing)));
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());
AttendanceService service = new AttendanceService();
AttendanceRecord checkedIn = checkedInRecord(seededPolicy());
AttendanceRecord checkedOut = atCutoff.checkOut(INTERN_ID);
AttendanceRecord checkedOut = service.checkOut(
Optional.of(checkedIn), at("2026-08-14T09:00:00Z"));
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));
AttendanceException repeated = assertThrows(
AttendanceException.class,
() -> service.checkOut(Optional.of(checkedOut), at("2026-08-14T09:00:00.001Z")));
assertEquals(ALREADY_CHECKED_OUT, repeated.rejection());
assertEquals(at("2026-08-14T09:00:00Z"), repository.record().checkOutAt());
assertEquals(at("2026-08-14T09:00:00Z"), checkedOut.checkOutAt());
}
@Test
void firstInstantAfterCheckoutCutoffIsRejectedWithoutRawCheckout() {
InMemoryAttendanceRepository repository = checkedInRepository(seededPolicy());
AttendanceService service = serviceAt("2026-08-14T09:00:00.001Z", repository, activeDay(), seededPolicy());
AttendanceRecord checkedIn = checkedInRecord(seededPolicy());
AttendanceService service = new AttendanceService();
AttendanceException exception = assertThrows(AttendanceException.class, () -> service.checkOut(INTERN_ID));
AttendanceException exception = assertThrows(
AttendanceException.class,
() -> service.checkOut(Optional.of(checkedIn), at("2026-08-14T09:00:00.001Z")));
assertEquals(CHECKOUT_CUTOFF_PASSED, exception.rejection());
assertNull(repository.record().checkOutAt());
AttendanceViolations violations = repository.record().violations(at("2026-08-14T09:00:00.001Z"));
assertNull(checkedIn.checkOutAt());
AttendanceViolations violations = checkedIn.violations(at("2026-08-14T09:00:00.001Z"));
assertTrue(violations.missingCheckout());
assertFalse(violations.earlyDeparture());
}
@@ -99,25 +110,26 @@ class AttendanceServiceTest {
DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY,
DayOfWeek.FRIDAY));
InMemoryAttendanceRepository repository = checkedInRepository(zeroGrace);
AttendanceService service = new AttendanceService();
AttendanceRecord checkedIn = checkedInRecord(zeroGrace);
AttendanceRecord checkedOut = serviceAt("2026-08-14T08:30:00Z", repository, activeDay(), zeroGrace)
.checkOut(INTERN_ID);
AttendanceRecord checkedOut = service.checkOut(
Optional.of(checkedIn), at("2026-08-14T08:30:00Z"));
assertEquals(at("2026-08-14T08:30:00Z"), checkedOut.checkOutAt());
InMemoryAttendanceRepository lateRepository = checkedInRepository(zeroGrace);
AttendanceRecord lateRecord = checkedInRecord(zeroGrace);
AttendanceException exception = assertThrows(
AttendanceException.class,
() -> serviceAt("2026-08-14T08:30:00.001Z", lateRepository, activeDay(), zeroGrace)
.checkOut(INTERN_ID));
() -> service.checkOut(Optional.of(lateRecord), at("2026-08-14T08:30:00.001Z")));
assertEquals(CHECKOUT_CUTOFF_PASSED, exception.rejection());
assertNull(lateRepository.record().checkOutAt());
assertNull(lateRecord.checkOutAt());
}
private static AttendanceRecord checkInAt(
String instant, AttendanceDayContext context, AttendancePolicy policy) {
return serviceAt(instant, new InMemoryAttendanceRepository(), context, policy).checkIn(INTERN_ID);
return new AttendanceService().checkIn(
INTERN_ID, at(instant), policy, context, Optional.empty());
}
private static void assertCheckInRejected(AttendanceRejection rejection, AttendanceDayContext context) {
@@ -128,27 +140,17 @@ class AttendanceServiceTest {
AttendanceRejection rejection, AttendanceDayContext context, AttendancePolicy policy) {
AttendanceException exception = assertThrows(
AttendanceException.class,
() -> serviceAt("2026-08-14T01:30:00Z", new InMemoryAttendanceRepository(), context, policy)
.checkIn(INTERN_ID));
() -> new AttendanceService().checkIn(
INTERN_ID,
at("2026-08-14T01:30:00Z"),
policy,
context,
Optional.empty()));
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 AttendanceRecord checkedInRecord(AttendancePolicy policy) {
return checkInAt("2026-08-14T01:30:00Z", activeDay(), policy);
}
private static AttendanceDayContext activeDay() {
@@ -177,23 +179,4 @@ class AttendanceServiceTest {
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);
}
}
}