package de.stadtbote.votian; import de.stadtbote.votian.database.DatabaseService; import org.junit.jupiter.api.*; import org.springframework.security.access.AccessDeniedException; import java.time.*; import java.util.*; import java.util.function.Supplier; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; import static org.mockito.ArgumentMatchers.*; import static de.stadtbote.votian.OrderEntryService.fields; import static de.stadtbote.votian.CalendarAdministration.*; class CalendarAdministrationTest { final DatabaseService db=mock(DatabaseService.class); final LegacyUser user=new LegacyUser(11,12,7,"test","1".repeat(70)); final CalendarAdministration service=new CalendarAdministration(db,Clock.fixed(Instant.parse("2026-10-12T09:00:00Z"),ZoneId.of("Europe/Berlin"))); final List> holidays=new ArrayList<>(); final Map travel=fields("id",1L,"postalCode","10115","country","DE","globalMinutes",30,"localMinutes",null); @BeforeEach void setup(){ when(db.readTransaction(any())).thenAnswer(c->((Supplier)c.getArgument(0)).get()); when(db.writeTransaction(any())).thenAnswer(c->((Supplier)c.getArgument(0)).get()); when(db.lockRows(anyString(),any(Object[].class))).thenAnswer(c->((String)c.getArgument(0)).startsWith("SELECT ph_id")?holidays:List.of(fields("hq_id",7))); when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(c->{String sql=c.getArgument(0);if(sql.startsWith("SELECT ph_id"))return holidays;if(sql.startsWith("SELECT p.srvp_id"))return List.of(travel);return List.of();}); when(db.update(anyString(),any(Object[].class))).thenReturn(1); } @Test void rightsAreCheckedBeforeAnyDatabaseAccess(){ var denied=new LegacyUser(11,12,7,"test","0".repeat(70)); assertThatThrownBy(()->service.holidays(denied,2026)).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(()->service.saveTravelTime(denied,TravelMode.BWV,1,new TravelSave("x",20))).isInstanceOf(AccessDeniedException.class); verifyNoInteractions(db); } @Test void historicalRowsAndTimeWindowsSurviveAnEmptyFutureCalendar(){ holidays.add(fields("ph_id",1,"ph_month",1,"ph_day",1,"ph_name","Neujahr","ph_time_from","10:00","ph_time_to","12:00")); holidays.add(fields("ph_id",2,"ph_month",12,"ph_day",25,"ph_name","Weihnachten","ph_time_from","00:00","ph_time_to","00:00")); service.saveHolidays(user,2026,new HolidaySave(AdminSupport.revision(holidays),List.of())); verify(db).update("DELETE FROM publicholiday WHERE ph_id=? AND hq_id=?",2L,7L); verify(db,never()).update(eq("DELETE FROM publicholiday WHERE ph_id=? AND hq_id=?"),eq(1L),any()); } @Test void staleCalendarCannotOverwriteAnotherEditorsChanges(){ assertThatThrownBy(()->service.saveHolidays(user,2026,new HolidaySave("outdated",List.of()))).hasMessageContaining("409"); verify(db,never()).update(anyString(),any(Object[].class));verify(db,never()).insert(anyString(),anyMap()); } @Test void validatesDateAndRejectsModificationOfToday(){ assertThatThrownBy(()->service.saveHolidays(user,2026,new HolidaySave("x",List.of(new Holiday(2,29,"x"))))).hasMessageContaining("Feiertagsdatum"); assertThatThrownBy(()->service.saveHolidays(user,2026,new HolidaySave(AdminSupport.revision(holidays),List.of(new Holiday(10,12,"x"))))).hasMessageContaining("heute"); verify(db,never()).insert(anyString(),anyMap()); } @Test void futureNamesAreUpdatedWithoutReplacingTimeWindows(){ holidays.add(fields("ph_id",2,"ph_month",12,"ph_day",25,"ph_name","alt","ph_time_from","10:00","ph_time_to","12:00")); service.saveHolidays(user,2026,new HolidaySave(AdminSupport.revision(holidays),List.of(new Holiday(12,25,"neu")))); verify(db).update("UPDATE publicholiday SET ph_name=? WHERE ph_id=? AND hq_id=?","neu",2L,7L); verify(db,never()).insert(anyString(),anyMap()); } @Test void multipleTimeWindowsRepresentOneDayAndSurviveAnUnchangedSave(){ holidays.add(fields("ph_id",2,"ph_month",12,"ph_day",25,"ph_name","vormittags","ph_time_from","10:00","ph_time_to","12:00")); holidays.add(fields("ph_id",3,"ph_month",12,"ph_day",25,"ph_name","nachmittags","ph_time_from","14:00","ph_time_to","16:00")); assertThat((List)service.holidays(user,2026).get("days")).hasSize(1); service.saveHolidays(user,2026,new HolidaySave(AdminSupport.revision(holidays),List.of(new Holiday(12,25,"vormittags")))); verify(db,never()).update(anyString(),any(Object[].class)); } @Test void classicCreatesOnlyThisHeadquartersOverride(){ service.saveTravelTime(user,TravelMode.CLASSIC,1,new TravelSave(AdminSupport.revision(Arrays.asList(1L,TravelMode.CLASSIC,null)),45)); verify(db).update("INSERT INTO serviceplztraveltime (srvp_id,hq_id,srvpt_traveltime) VALUES (?,?,?)",1L,7L,45); verify(db,never()).update(startsWith("UPDATE serviceplz SET"),any(Object[].class)); } @Test void bwvIsOneConditionalWriteAndReportsConcurrentUpdates(){ when(db.update(startsWith("UPDATE serviceplz SET"),any(Object[].class))).thenReturn(0); assertThatThrownBy(()->service.saveTravelTime(user,TravelMode.BWV,1,new TravelSave(AdminSupport.revision(Arrays.asList(1L,TravelMode.BWV,30)),45))).hasMessageContaining("409"); verify(db).update("UPDATE serviceplz SET srvp_traveltime=? WHERE srvp_id=? AND srvp_valid=1 AND srvp_traveltime <=> ?",45,1L,30); verify(db,never()).insert(anyString(),anyMap()); } @Test void zeroAndEmptyTravelTimeUseTheConfiguredHeadquartersDefault() { when(db.queryForList(startsWith("SELECT par_value FROM parameter"),eq(7L),eq("LATEST_TAKETIME_IN_MINUTES"))).thenReturn(List.of(fields("par_value","35"))); service.saveTravelTime(user,TravelMode.CLASSIC,1,new TravelSave(AdminSupport.revision(Arrays.asList(1L,TravelMode.CLASSIC,null)),0)); verify(db).update("INSERT INTO serviceplztraveltime (srvp_id,hq_id,srvpt_traveltime) VALUES (?,?,?)",1L,7L,35); service.saveTravelTime(user,TravelMode.BWV,1,new TravelSave(AdminSupport.revision(Arrays.asList(1L,TravelMode.BWV,30)),null)); verify(db).update(startsWith("UPDATE serviceplz SET"),eq(35),eq(1L),eq(30)); } @Test void missingDefaultNeverWritesAnInventedTravelTime() { assertThatThrownBy(()->service.saveTravelTime(user,TravelMode.CLASSIC,1,new TravelSave("",0))).hasMessageContaining("LATEST_TAKETIME_IN_MINUTES"); verify(db,never()).update(anyString(),any(Object[].class)); } }