Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public class CustomerAppointmentKafkaDto {
Boolean deleted;

LocalDateTime start;
LocalDateTime end;
String description;
String additionalInfo;
Long customerId;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -415,6 +417,7 @@ private String customerAppointmentSyncToken(final List<CustomerAppointmentKafkaD
.map(appointment -> Objects.hash(
appointment.getId(),
appointment.getUpdated(),
appointment.getEnd(),
appointment.getDeleted(),
customer(appointment).map(CustomerKafkaDto::getUpdated).orElse(null),
customer(appointment).map(CustomerKafkaDto::getDeleted).orElse(null)))
Expand All @@ -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));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,14 @@ public String toICalendar(final CustomerAppointmentKafkaDto appointment) {
public String toICalendar(
final CustomerAppointmentKafkaDto appointment, final Optional<CustomerKafkaDto> 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(
Expand All @@ -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)));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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("""
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/services/caldav/calendars/current-user/private/42.ics</d:href>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:response>
<d:sync-token>urn:koku:caldav:private:sync:2</d:sync-token>
</d:multistatus>
""", xml);
}

@Test
void writesCardDavCompatibilityCapabilities() throws IOException {
final DavMultiStatus multistatus = new DavMultiStatus(List.of(new DavResponse(
Expand Down
4 changes: 2 additions & 2 deletions koku-frontend/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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 --------
Expand Down
Loading