Merge commit '8b48e281f7e860af435ae35b16c4edeb139286dc' into work/tasks
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package com.lab.labtimesheet.feature.account.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
|
||||
import com.lab.labtimesheet.config.TestcontainersConfiguration;
|
||||
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.TokenPurpose;
|
||||
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand;
|
||||
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
|
||||
import com.lab.labtimesheet.feature.account.repository.InternProfileRepository;
|
||||
import com.lab.labtimesheet.feature.account.repository.UserActionTokenRepository;
|
||||
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 org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
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.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
@Import({TestcontainersConfiguration.class, AccountActivationIntegrationTest.MailProbeConfiguration.class})
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class AccountActivationIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private BootstrapService bootstrap;
|
||||
|
||||
@Autowired
|
||||
private AccountService accounts;
|
||||
|
||||
@Autowired
|
||||
private SmtpConfigurationService smtp;
|
||||
|
||||
@Autowired
|
||||
private RecordingSmtpProbe mail;
|
||||
|
||||
@Autowired
|
||||
private AppUserRepository users;
|
||||
|
||||
@Autowired
|
||||
private InternProfileRepository internProfiles;
|
||||
|
||||
@Autowired
|
||||
private UserActionTokenRepository tokens;
|
||||
|
||||
@Autowired
|
||||
private PasswordEncoder passwords;
|
||||
|
||||
@Test
|
||||
void smtpGatedCreationHashesSingleUseActivationAndRetainsFailedDeliveryHistory() throws Exception {
|
||||
bootstrap.bootstrap("admin@example.com", "Admin", "correct horse battery staple");
|
||||
long adminId = accounts.requireActiveAdminId("admin@example.com");
|
||||
|
||||
var mentor = new CreateAccountCommand(
|
||||
" MENTOR@EXAMPLE.COM ", " Mentor One ", GlobalRole.MENTOR, null, null, null);
|
||||
assertThatThrownBy(() -> accounts.create(mentor, adminId))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("SMTP");
|
||||
assertThat(users.count()).isEqualTo(1);
|
||||
|
||||
activateSmtp(adminId);
|
||||
mail.messages.clear();
|
||||
|
||||
var mentorCreation = accounts.create(mentor, adminId);
|
||||
assertThat(mentorCreation.deliverySucceeded()).isTrue();
|
||||
var pendingMentor = users.findById(mentorCreation.userId()).orElseThrow();
|
||||
assertThat(pendingMentor.getEmail()).isEqualTo("mentor@example.com");
|
||||
assertThat(pendingMentor.getDisplayName()).isEqualTo("Mentor One");
|
||||
assertThat(pendingMentor.getGlobalRole()).isEqualTo(GlobalRole.MENTOR);
|
||||
assertThat(pendingMentor.getAccountStatus()).isEqualTo(AccountStatus.PENDING_ACTIVATION);
|
||||
assertThat(pendingMentor.getPasswordHash()).isNull();
|
||||
|
||||
String rawMentorToken = mail.onlyActivationToken();
|
||||
var mentorToken = tokens.findAll().stream()
|
||||
.filter(token -> token.getUserId().equals(mentorCreation.userId()))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
assertThat(mentorToken.getPurpose()).isEqualTo(TokenPurpose.ACTIVATION);
|
||||
assertThat(mentorToken.getTokenHash()).containsExactly(sha256(rawMentorToken));
|
||||
assertThat(mentorToken.getExpiresAt()).isEqualTo(Instant.parse("2026-08-15T00:00:00Z"));
|
||||
assertThat(mentorToken.isUsableAt(mentorToken.getExpiresAt())).isFalse();
|
||||
assertThat(HexFormat.of().formatHex(mentorToken.getTokenHash())).doesNotContain(rawMentorToken);
|
||||
|
||||
assertThat(accounts.activate("not-the-token", "new secure mentor password")).isFalse();
|
||||
assertThat(accounts.activate(rawMentorToken, "new secure mentor password")).isTrue();
|
||||
assertThat(accounts.activate(rawMentorToken, "another secure password")).isFalse();
|
||||
var activeMentor = users.findById(mentorCreation.userId()).orElseThrow();
|
||||
assertThat(activeMentor.getAccountStatus()).isEqualTo(AccountStatus.ACTIVE);
|
||||
assertThat(passwords.matches("new secure mentor password", activeMentor.getPasswordHash())).isTrue();
|
||||
assertThat(tokens.findById(mentorToken.getId()).orElseThrow().getUsedAt()).isNotNull();
|
||||
|
||||
mail.fail = true;
|
||||
var failedIntern = accounts.create(new CreateAccountCommand(
|
||||
"intern-failed@example.com", "Failed Intern", GlobalRole.INTERN, "STU-FAIL",
|
||||
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 12, 31)), adminId);
|
||||
assertThat(failedIntern.deliverySucceeded()).isFalse();
|
||||
assertThat(users.findById(failedIntern.userId()).orElseThrow().getAccountStatus())
|
||||
.isEqualTo(AccountStatus.PENDING_ACTIVATION);
|
||||
assertThat(tokens.findAll().stream()
|
||||
.filter(token -> token.getUserId().equals(failedIntern.userId()))
|
||||
.findFirst().orElseThrow().getInvalidatedAt()).isNotNull();
|
||||
|
||||
mail.fail = false;
|
||||
mail.messages.clear();
|
||||
var activeInternCreation = accounts.create(new CreateAccountCommand(
|
||||
"intern@example.com", "Active Intern", GlobalRole.INTERN, "STU-001",
|
||||
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 12, 31)), adminId);
|
||||
assertThat(accounts.activate(mail.onlyActivationToken(), "new secure intern password")).isTrue();
|
||||
accounts.activateInternship(activeInternCreation.userId(), adminId);
|
||||
|
||||
var profile = internProfiles.findById(activeInternCreation.userId()).orElseThrow();
|
||||
assertThat(profile.getInternshipStatus()).isEqualTo(InternshipStatus.ACTIVE);
|
||||
assertThat(accounts.isEligibleIntern(activeInternCreation.userId(), LocalDate.of(2026, 8, 1))).isTrue();
|
||||
assertThat(accounts.isEligibleIntern(activeInternCreation.userId(), LocalDate.of(2026, 12, 31))).isTrue();
|
||||
assertThat(accounts.isEligibleIntern(activeInternCreation.userId(), LocalDate.of(2027, 1, 1))).isFalse();
|
||||
|
||||
var summary = accounts.summary();
|
||||
assertThat(summary.activeAccounts()).isEqualTo(3);
|
||||
assertThat(summary.pendingActivations()).isEqualTo(1);
|
||||
assertThat(summary.activeInternships()).isEqualTo(1);
|
||||
}
|
||||
|
||||
private void activateSmtp(long adminId) {
|
||||
long draftId = smtp.saveDraft(adminId, new SmtpDraft(
|
||||
"mailpit", 1025, SecurityMode.NONE, null, null, "admin@example.com", "Lab Timesheet"));
|
||||
smtp.testDraft(draftId, adminId, "admin@example.com");
|
||||
smtp.activate(draftId, adminId);
|
||||
}
|
||||
|
||||
private static byte[] sha256(String value) throws Exception {
|
||||
return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@TestConfiguration(proxyBeanMethods = false)
|
||||
static class MailProbeConfiguration {
|
||||
@Bean
|
||||
@Primary
|
||||
RecordingSmtpProbe recordingSmtpProbe() {
|
||||
return new RecordingSmtpProbe();
|
||||
}
|
||||
}
|
||||
|
||||
static final class RecordingSmtpProbe implements SmtpProbe {
|
||||
private final List<Message> messages = new ArrayList<>();
|
||||
private boolean fail;
|
||||
|
||||
@Override
|
||||
public void send(SmtpConnection connection, String recipient, String subject, String body) {
|
||||
if (fail) {
|
||||
throw new IllegalStateException("simulated SMTP failure");
|
||||
}
|
||||
messages.add(new Message(recipient, subject, body));
|
||||
}
|
||||
|
||||
String onlyActivationToken() {
|
||||
assertThat(messages).hasSize(1);
|
||||
String body = messages.getFirst().body();
|
||||
int tokenStart = body.indexOf("token=");
|
||||
assertThat(tokenStart).isGreaterThanOrEqualTo(0);
|
||||
return body.substring(tokenStart + "token=".length()).trim();
|
||||
}
|
||||
}
|
||||
|
||||
record Message(String recipient, String subject, String body) {
|
||||
}
|
||||
}
|
||||
+161
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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;
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
+87
@@ -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;
|
||||
}
|
||||
}
|
||||
+289
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
package com.lab.labtimesheet.feature.attendance.service;
|
||||
|
||||
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.DayOfWeek;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.time.ZoneId;
|
||||
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);
|
||||
|
||||
AttendanceService service = new AttendanceService();
|
||||
AttendanceRecord existing = checkInAt("2026-08-14T01:30:00Z", activeDay(), seededPolicy());
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkoutIsInclusiveAtCutoffAndCannotBeOverwritten() {
|
||||
AttendanceService service = new AttendanceService();
|
||||
AttendanceRecord checkedIn = checkedInRecord(seededPolicy());
|
||||
|
||||
AttendanceRecord checkedOut = service.checkOut(
|
||||
Optional.of(checkedIn), at("2026-08-14T09:00:00Z"));
|
||||
|
||||
assertEquals(at("2026-08-14T09:00:00Z"), checkedOut.checkOutAt());
|
||||
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"), checkedOut.checkOutAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstInstantAfterCheckoutCutoffIsRejectedWithoutRawCheckout() {
|
||||
AttendanceRecord checkedIn = checkedInRecord(seededPolicy());
|
||||
AttendanceService service = new AttendanceService();
|
||||
|
||||
AttendanceException exception = assertThrows(
|
||||
AttendanceException.class,
|
||||
() -> service.checkOut(Optional.of(checkedIn), at("2026-08-14T09:00:00.001Z")));
|
||||
|
||||
assertEquals(CHECKOUT_CUTOFF_PASSED, exception.rejection());
|
||||
assertNull(checkedIn.checkOutAt());
|
||||
AttendanceViolations violations = checkedIn.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));
|
||||
AttendanceService service = new AttendanceService();
|
||||
AttendanceRecord checkedIn = checkedInRecord(zeroGrace);
|
||||
|
||||
AttendanceRecord checkedOut = service.checkOut(
|
||||
Optional.of(checkedIn), at("2026-08-14T08:30:00Z"));
|
||||
|
||||
assertEquals(at("2026-08-14T08:30:00Z"), checkedOut.checkOutAt());
|
||||
|
||||
AttendanceRecord lateRecord = checkedInRecord(zeroGrace);
|
||||
AttendanceException exception = assertThrows(
|
||||
AttendanceException.class,
|
||||
() -> service.checkOut(Optional.of(lateRecord), at("2026-08-14T08:30:00.001Z")));
|
||||
assertEquals(CHECKOUT_CUTOFF_PASSED, exception.rejection());
|
||||
assertNull(lateRecord.checkOutAt());
|
||||
}
|
||||
|
||||
private static AttendanceRecord checkInAt(
|
||||
String instant, AttendanceDayContext context, AttendancePolicy policy) {
|
||||
return new AttendanceService().checkIn(
|
||||
INTERN_ID, at(instant), policy, context, Optional.empty());
|
||||
}
|
||||
|
||||
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,
|
||||
() -> new AttendanceService().checkIn(
|
||||
INTERN_ID,
|
||||
at("2026-08-14T01:30:00Z"),
|
||||
policy,
|
||||
context,
|
||||
Optional.empty()));
|
||||
assertEquals(rejection, exception.rejection());
|
||||
}
|
||||
|
||||
private static AttendanceRecord checkedInRecord(AttendancePolicy policy) {
|
||||
return checkInAt("2026-08-14T01:30:00Z", activeDay(), policy);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,5 +3,6 @@ spring:
|
||||
compose:
|
||||
enabled: false
|
||||
lab:
|
||||
public-origin: http://localhost:8080
|
||||
security:
|
||||
master-key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
|
||||
|
||||
Reference in New Issue
Block a user