From d073d74a53b65c0224b82235fdc93fdf3b15b481 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Sun, 12 Jul 2026 22:07:50 +0200 Subject: [PATCH] fix: dav update --- .../dto/CustomerAppointmentKafkaDto.java | 1 + ...afkaCustomerAppointmentDtoTransformer.java | 1 + ...CustomerAppointmentDtoTransformerTest.java | 32 ++++ .../koku/dav/service/CalDavService.java | 6 +- .../dav/service/CalendarEventFactory.java | 7 +- .../koku/dav/service/CalDavServiceTest.java | 163 ++++++++++++++++++ .../dav/service/CalendarEventFactoryTest.java | 43 +++++ .../koku/dav/xml/DavXmlWriterTest.java | 19 ++ koku-frontend/nginx.conf | 4 +- 9 files changed, 271 insertions(+), 5 deletions(-) create mode 100644 koku-customer/koku-customer-service/src/test/java/de/domschmidt/koku/customer/kafka/customers/transformer/CustomerAppointmentToKafkaCustomerAppointmentDtoTransformerTest.java diff --git a/koku-customer/koku-customer-kafka-dto/src/main/java/de/domschmidt/koku/customer/kafka/dto/CustomerAppointmentKafkaDto.java b/koku-customer/koku-customer-kafka-dto/src/main/java/de/domschmidt/koku/customer/kafka/dto/CustomerAppointmentKafkaDto.java index f00d33c1..c4dfa43d 100644 --- a/koku-customer/koku-customer-kafka-dto/src/main/java/de/domschmidt/koku/customer/kafka/dto/CustomerAppointmentKafkaDto.java +++ b/koku-customer/koku-customer-kafka-dto/src/main/java/de/domschmidt/koku/customer/kafka/dto/CustomerAppointmentKafkaDto.java @@ -16,6 +16,7 @@ public class CustomerAppointmentKafkaDto { Boolean deleted; LocalDateTime start; + LocalDateTime end; String description; String additionalInfo; Long customerId; diff --git a/koku-customer/koku-customer-service/src/main/java/de/domschmidt/koku/customer/kafka/customers/transformer/CustomerAppointmentToKafkaCustomerAppointmentDtoTransformer.java b/koku-customer/koku-customer-service/src/main/java/de/domschmidt/koku/customer/kafka/customers/transformer/CustomerAppointmentToKafkaCustomerAppointmentDtoTransformer.java index d311ce86..222a1c3d 100644 --- a/koku-customer/koku-customer-service/src/main/java/de/domschmidt/koku/customer/kafka/customers/transformer/CustomerAppointmentToKafkaCustomerAppointmentDtoTransformer.java +++ b/koku-customer/koku-customer-service/src/main/java/de/domschmidt/koku/customer/kafka/customers/transformer/CustomerAppointmentToKafkaCustomerAppointmentDtoTransformer.java @@ -14,6 +14,7 @@ public CustomerAppointmentKafkaDto transformToDto(final CustomerAppointment mode .id(model.getId()) .deleted(model.isDeleted()) .start(model.getStart()) + .end(model.getCalculatedEndSnapshot()) .description(model.getDescription()) .additionalInfo(model.getAdditionalInfo()) .customerId(model.getCustomer().getId()) diff --git a/koku-customer/koku-customer-service/src/test/java/de/domschmidt/koku/customer/kafka/customers/transformer/CustomerAppointmentToKafkaCustomerAppointmentDtoTransformerTest.java b/koku-customer/koku-customer-service/src/test/java/de/domschmidt/koku/customer/kafka/customers/transformer/CustomerAppointmentToKafkaCustomerAppointmentDtoTransformerTest.java new file mode 100644 index 00000000..ebbee500 --- /dev/null +++ b/koku-customer/koku-customer-service/src/test/java/de/domschmidt/koku/customer/kafka/customers/transformer/CustomerAppointmentToKafkaCustomerAppointmentDtoTransformerTest.java @@ -0,0 +1,32 @@ +package de.domschmidt.koku.customer.kafka.customers.transformer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import de.domschmidt.koku.customer.persistence.Customer; +import de.domschmidt.koku.customer.persistence.CustomerAppointment; +import java.time.LocalDateTime; +import java.time.Month; +import java.util.List; +import org.junit.jupiter.api.Test; + +class CustomerAppointmentToKafkaCustomerAppointmentDtoTransformerTest { + + private final CustomerAppointmentToKafkaCustomerAppointmentDtoTransformer transformer = + new CustomerAppointmentToKafkaCustomerAppointmentDtoTransformer(); + + @Test + void mapsCalculatedEndSnapshot() { + final LocalDateTime calculatedEnd = LocalDateTime.of(2026, Month.JULY, 15, 12, 30); + final CustomerAppointment appointment = mock(CustomerAppointment.class); + final Customer customer = mock(Customer.class); + when(appointment.getCalculatedEndSnapshot()).thenReturn(calculatedEnd); + when(appointment.getCustomer()).thenReturn(customer); + when(appointment.getActivities()).thenReturn(List.of()); + when(appointment.getPromotions()).thenReturn(List.of()); + when(appointment.getSoldProducts()).thenReturn(List.of()); + + assertThat(transformer.transformToDto(appointment).getEnd()).isEqualTo(calculatedEnd); + } +} diff --git a/koku-dav/src/main/java/de/domschmidt/koku/dav/service/CalDavService.java b/koku-dav/src/main/java/de/domschmidt/koku/dav/service/CalDavService.java index af7a95bb..7da54c46 100644 --- a/koku-dav/src/main/java/de/domschmidt/koku/dav/service/CalDavService.java +++ b/koku-dav/src/main/java/de/domschmidt/koku/dav/service/CalDavService.java @@ -377,7 +377,9 @@ private boolean matchesTimeRange(final CustomerAppointmentKafkaDto appointment, return true; } final Instant startsAt = toInstant(appointment.getStart()); - final Instant endsAt = startsAt.plus(CalendarEventFactory.DEFAULT_APPOINTMENT_DURATION); + final Instant endsAt = appointment.getEnd() == null + ? startsAt.plus(CalendarEventFactory.DEFAULT_APPOINTMENT_DURATION) + : toInstant(appointment.getEnd()); return timeRange.overlaps(startsAt, endsAt); } @@ -415,6 +417,7 @@ private String customerAppointmentSyncToken(final List Objects.hash( appointment.getId(), appointment.getUpdated(), + appointment.getEnd(), appointment.getDeleted(), customer(appointment).map(CustomerKafkaDto::getUpdated).orElse(null), customer(appointment).map(CustomerKafkaDto::getDeleted).orElse(null))) @@ -437,6 +440,7 @@ private String customerAppointmentEtag( return DavResourceMetadata.etag( appointment.getId(), appointment.getUpdated(), + appointment.getEnd(), customer.map(CustomerKafkaDto::getUpdated).orElse(null), customer.map(CustomerKafkaDto::getDeleted).orElse(null)); } diff --git a/koku-dav/src/main/java/de/domschmidt/koku/dav/service/CalendarEventFactory.java b/koku-dav/src/main/java/de/domschmidt/koku/dav/service/CalendarEventFactory.java index f2ff55b9..efb7d27e 100644 --- a/koku-dav/src/main/java/de/domschmidt/koku/dav/service/CalendarEventFactory.java +++ b/koku-dav/src/main/java/de/domschmidt/koku/dav/service/CalendarEventFactory.java @@ -35,11 +35,14 @@ public String toICalendar(final CustomerAppointmentKafkaDto appointment) { public String toICalendar( final CustomerAppointmentKafkaDto appointment, final Optional customer) { final ZonedDateTime startsAt = toZonedDateTime(appointment.getStart()); + final ZonedDateTime endsAt = appointment.getEnd() == null + ? startsAt.plus(DEFAULT_APPOINTMENT_DURATION) + : toZonedDateTime(appointment.getEnd()); return toICalendar( "customer-appointment-" + appointment.getId() + "@koku", summary(appointment, customer), startsAt, - startsAt.plus(DEFAULT_APPOINTMENT_DURATION)); + endsAt); } public String toICalendar( @@ -50,7 +53,7 @@ public String toICalendar( calendar.setPropertyList( new PropertyList().add(new ProdId(PROD_ID)).add(version).add(new CalScale(CalScale.VALUE_GREGORIAN))); - final VEvent event = new VEvent(startsAt, endsAt, summary); + final VEvent event = new VEvent(startsAt.toInstant(), endsAt.toInstant(), summary); event.setPropertyList(event.getPropertyList().add(new Uid(uid))); calendar.setComponentList(new ComponentList<>(java.util.List.of(event))); diff --git a/koku-dav/src/test/java/de/domschmidt/koku/dav/service/CalDavServiceTest.java b/koku-dav/src/test/java/de/domschmidt/koku/dav/service/CalDavServiceTest.java index 92e9503d..db78593a 100644 --- a/koku-dav/src/test/java/de/domschmidt/koku/dav/service/CalDavServiceTest.java +++ b/koku-dav/src/test/java/de/domschmidt/koku/dav/service/CalDavServiceTest.java @@ -4,12 +4,16 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import de.domschmidt.koku.customer.kafka.dto.CustomerAppointmentKafkaDto; import de.domschmidt.koku.dav.model.DavMethod; import de.domschmidt.koku.dav.model.DavMultiStatus; import de.domschmidt.koku.dav.model.DavPropertyNames; import de.domschmidt.koku.dav.model.DavPropertyRequestType; import de.domschmidt.koku.dav.model.DavRequest; +import de.domschmidt.koku.dav.model.DavResponse; +import de.domschmidt.koku.dav.model.DavTimeRange; import de.domschmidt.koku.user.kafka.dto.UserAppointmentKafkaDto; +import java.time.Instant; import java.time.LocalDateTime; import java.time.Month; import java.util.List; @@ -18,6 +22,9 @@ class CalDavServiceTest { + private static final String USERNAME = "current-user"; + private static final String PRIVATE_APPOINTMENT_HREF = "/services/caldav/calendars/current-user/private/42.ics"; + private final CustomerAppointmentRepository customerAppointmentRepository = mock(CustomerAppointmentRepository.class); private final CustomerContactRepository customerContactRepository = mock(CustomerContactRepository.class); @@ -51,6 +58,162 @@ void returnsNotFoundForForeignPrivateAppointmentMultiget() { }); } + @Test + void reportsDeletedCustomerAppointmentExactlyOnceDuringIncrementalSync() { + final CustomerAppointmentKafkaDto activeAppointment = CustomerAppointmentKafkaDto.builder() + .id(42L) + .userId(USERNAME) + .start(LocalDateTime.of(2026, Month.JULY, 15, 10, 0)) + .updated(LocalDateTime.of(2026, Month.JULY, 1, 12, 0)) + .deleted(false) + .build(); + final CustomerAppointmentKafkaDto deletedAppointment = CustomerAppointmentKafkaDto.builder() + .id(42L) + .userId(USERNAME) + .start(activeAppointment.getStart()) + .updated(LocalDateTime.of(2026, Month.JULY, 2, 12, 0)) + .deleted(true) + .build(); + when(customerAppointmentRepository.findAllAppointments()) + .thenReturn(List.of(activeAppointment), List.of(deletedAppointment), List.of(deletedAppointment)); + + final DavMultiStatus initialSync = calDavService.handleAppointmentCalendar(syncRequest(null), USERNAME); + final DavMultiStatus deletionSync = + calDavService.handleAppointmentCalendar(syncRequest(initialSync.syncToken()), USERNAME); + final DavMultiStatus unchangedSync = + calDavService.handleAppointmentCalendar(syncRequest(deletionSync.syncToken()), USERNAME); + + assertThat(initialSync.responses()) + .extracting(DavResponse::href) + .containsExactly("/services/caldav/calendars/current-user/appointments/42.ics"); + assertThat(deletionSync.syncToken()).isNotEqualTo(initialSync.syncToken()); + assertThat(deletionSync.responses()).singleElement().satisfies(response -> { + assertThat(response.href()).isEqualTo("/services/caldav/calendars/current-user/appointments/42.ics"); + assertThat(response.status()).isEqualTo(404); + assertThat(response.propStats()).isEmpty(); + }); + assertThat(unchangedSync.syncToken()).isEqualTo(deletionSync.syncToken()); + assertThat(unchangedSync.responses()).isEmpty(); + } + + @Test + void reportsDeletedPrivateAppointmentExactlyOnceDuringIncrementalSync() { + final UserAppointmentKafkaDto activeAppointment = privateAppointment(false, 1); + final UserAppointmentKafkaDto deletedAppointment = privateAppointment(true, 2); + when(userAppointmentRepository.findAllAppointments()) + .thenReturn(List.of(activeAppointment), List.of(deletedAppointment), List.of(deletedAppointment)); + + final DavMultiStatus initialSync = calDavService.handlePrivateCalendar(privateSyncRequest(null), USERNAME); + final DavMultiStatus deletionSync = + calDavService.handlePrivateCalendar(privateSyncRequest(initialSync.syncToken()), USERNAME); + final DavMultiStatus unchangedSync = + calDavService.handlePrivateCalendar(privateSyncRequest(deletionSync.syncToken()), USERNAME); + + assertThat(initialSync.responses()).extracting(DavResponse::href).containsExactly(PRIVATE_APPOINTMENT_HREF); + assertThat(deletionSync.syncToken()).isNotEqualTo(initialSync.syncToken()); + assertThat(deletionSync.responses()).singleElement().satisfies(response -> { + assertThat(response.href()).isEqualTo(PRIVATE_APPOINTMENT_HREF); + assertThat(response.status()).isEqualTo(404); + assertThat(response.propStats()).isEmpty(); + }); + assertThat(unchangedSync.syncToken()).isEqualTo(deletionSync.syncToken()); + assertThat(unchangedSync.responses()).isEmpty(); + } + + @Test + void includesCustomerAppointmentWhenCalculatedEndOverlapsTimeRange() { + final CustomerAppointmentKafkaDto appointment = + customerAppointment(LocalDateTime.of(2026, Month.JULY, 15, 12, 30)); + when(customerAppointmentRepository.findAllAppointments()).thenReturn(List.of(appointment)); + + final DavMultiStatus result = calDavService.handleAppointmentCalendar( + customerQueryRequest( + new DavTimeRange(Instant.parse("2026-07-15T10:00:00Z"), Instant.parse("2026-07-15T11:00:00Z"))), + USERNAME); + + assertThat(result.responses()) + .extracting(DavResponse::href) + .containsExactly("/services/caldav/calendars/current-user/appointments/42.ics"); + } + + @Test + void excludesCustomerAppointmentWithoutCalculatedEndAfterDefaultDuration() { + final CustomerAppointmentKafkaDto appointment = customerAppointment(null); + when(customerAppointmentRepository.findAllAppointments()).thenReturn(List.of(appointment)); + + final DavMultiStatus result = calDavService.handleAppointmentCalendar( + customerQueryRequest( + new DavTimeRange(Instant.parse("2026-07-15T09:30:00Z"), Instant.parse("2026-07-15T10:30:00Z"))), + USERNAME); + + assertThat(result.responses()).isEmpty(); + } + + private CustomerAppointmentKafkaDto customerAppointment(final LocalDateTime end) { + return CustomerAppointmentKafkaDto.builder() + .id(42L) + .userId(USERNAME) + .start(LocalDateTime.of(2026, Month.JULY, 15, 10, 0)) + .end(end) + .updated(LocalDateTime.of(2026, Month.JULY, 1, 12, 0)) + .deleted(false) + .build(); + } + + private DavRequest customerQueryRequest(final DavTimeRange timeRange) { + return new DavRequest( + DavMethod.REPORT, + "/calendars/current-user/appointments/", + "/services/caldav", + 1, + DavPropertyRequestType.NAMED, + List.of(DavPropertyNames.GETETAG), + List.of(), + DavPropertyNames.CALENDAR_QUERY, + timeRange, + null); + } + + private UserAppointmentKafkaDto privateAppointment(final boolean deleted, final int updatedDay) { + return UserAppointmentKafkaDto.builder() + .id(42L) + .userId(USERNAME) + .description("iOS sync test") + .start(LocalDateTime.of(2026, Month.JULY, 15, 10, 0)) + .end(LocalDateTime.of(2026, Month.JULY, 15, 11, 0)) + .updated(LocalDateTime.of(2026, Month.JULY, updatedDay, 12, 0)) + .deleted(deleted) + .build(); + } + + private DavRequest syncRequest(final String syncToken) { + return new DavRequest( + DavMethod.REPORT, + "/calendars/current-user/appointments/", + "/services/caldav", + 1, + DavPropertyRequestType.NAMED, + List.of(DavPropertyNames.GETETAG), + List.of(), + DavPropertyNames.SYNC_COLLECTION, + null, + syncToken); + } + + private DavRequest privateSyncRequest(final String syncToken) { + return new DavRequest( + DavMethod.REPORT, + "/calendars/current-user/private/", + "/services/caldav", + 1, + DavPropertyRequestType.NAMED, + List.of(DavPropertyNames.GETETAG), + List.of(), + DavPropertyNames.SYNC_COLLECTION, + null, + syncToken); + } + private DavRequest request(final String href) { return new DavRequest( DavMethod.REPORT, diff --git a/koku-dav/src/test/java/de/domschmidt/koku/dav/service/CalendarEventFactoryTest.java b/koku-dav/src/test/java/de/domschmidt/koku/dav/service/CalendarEventFactoryTest.java index 9a597108..f9aeef60 100644 --- a/koku-dav/src/test/java/de/domschmidt/koku/dav/service/CalendarEventFactoryTest.java +++ b/koku-dav/src/test/java/de/domschmidt/koku/dav/service/CalendarEventFactoryTest.java @@ -52,6 +52,27 @@ void createsCustomerAppointmentSummaryFromCustomerDescriptionAndAdditionalInfo() assertThat(calendar).contains("SUMMARY:Kundentermin - Max Mustermann - Neuanlage - Patch Test beachten"); } + @Test + void usesCalculatedCustomerAppointmentEnd() { + final String calendar = factory.toICalendar(CustomerAppointmentKafkaDto.builder() + .id(42L) + .start(LocalDateTime.of(2026, Month.JULY, 15, 10, 0)) + .end(LocalDateTime.of(2026, Month.JULY, 15, 12, 30)) + .build()); + + assertThat(calendar).contains("DTSTART:20260715T080000Z", "DTEND:20260715T103000Z"); + } + + @Test + void defaultsCustomerAppointmentWithoutCalculatedEndToOneHour() { + final String calendar = factory.toICalendar(CustomerAppointmentKafkaDto.builder() + .id(42L) + .start(LocalDateTime.of(2026, Month.JULY, 15, 10, 0)) + .build()); + + assertThat(calendar).contains("DTSTART:20260715T080000Z", "DTEND:20260715T090000Z"); + } + @Test void createsPrivateAppointmentSummaryFromLabelAndDescription() { final String calendar = factory.toICalendar(UserAppointmentKafkaDto.builder() @@ -62,4 +83,26 @@ void createsPrivateAppointmentSummaryFromLabelAndDescription() { assertThat(calendar).contains("SUMMARY:Privater Termin - Arzt"); } + + @Test + void serializesSummerAppointmentAsUnambiguousUtcInstantsForIos() { + final String calendar = factory.toICalendar( + "summer-appointment@koku", + "Summer appointment", + ZonedDateTime.of(LocalDateTime.of(2026, Month.JULY, 15, 10, 0), ZoneId.of("Europe/Berlin")), + ZonedDateTime.of(LocalDateTime.of(2026, Month.JULY, 15, 11, 0), ZoneId.of("Europe/Berlin"))); + + assertThat(calendar).contains("DTSTART:20260715T080000Z", "DTEND:20260715T090000Z"); + } + + @Test + void serializesWinterAppointmentAsUnambiguousUtcInstantsForIos() { + final String calendar = factory.toICalendar( + "winter-appointment@koku", + "Winter appointment", + ZonedDateTime.of(LocalDateTime.of(2026, Month.JANUARY, 15, 10, 0), ZoneId.of("Europe/Berlin")), + ZonedDateTime.of(LocalDateTime.of(2026, Month.JANUARY, 15, 11, 0), ZoneId.of("Europe/Berlin"))); + + assertThat(calendar).contains("DTSTART:20260115T090000Z", "DTEND:20260115T100000Z"); + } } diff --git a/koku-dav/src/test/java/de/domschmidt/koku/dav/xml/DavXmlWriterTest.java b/koku-dav/src/test/java/de/domschmidt/koku/dav/xml/DavXmlWriterTest.java index 90c6b35e..45143422 100644 --- a/koku-dav/src/test/java/de/domschmidt/koku/dav/xml/DavXmlWriterTest.java +++ b/koku-dav/src/test/java/de/domschmidt/koku/dav/xml/DavXmlWriterTest.java @@ -99,6 +99,25 @@ void writesDeletedWebDavSyncResourcesAsResponseLevelNotFound() throws IOExceptio """, xml); } + @Test + void writesDeletedCalDavResourceWithNewSyncToken() throws IOException { + final DavMultiStatus multistatus = new DavMultiStatus( + List.of(DavResponse.notFound("/services/caldav/calendars/current-user/private/42.ics")), + "urn:koku:caldav:private:sync:2"); + + final String xml = writer.write(multistatus); + + assertXmlSimilar(""" + + + /services/caldav/calendars/current-user/private/42.ics + HTTP/1.1 404 Not Found + + urn:koku:caldav:private:sync:2 + + """, xml); + } + @Test void writesCardDavCompatibilityCapabilities() throws IOException { final DavMultiStatus multistatus = new DavMultiStatus(List.of(new DavResponse( diff --git a/koku-frontend/nginx.conf b/koku-frontend/nginx.conf index 6e13000e..a0710787 100644 --- a/koku-frontend/nginx.conf +++ b/koku-frontend/nginx.conf @@ -128,11 +128,11 @@ server { # -------- DAV Auto-Discovery -------- location = /.well-known/carddav { - return 301 /services/carddav/; + return 301 https://$host/services/carddav/; } location = /.well-known/caldav { - return 301 /services/caldav/; + return 301 https://$host/services/caldav/; } # -------- Logging --------