diff --git a/backend/lined/.beads/interactions.jsonl b/backend/lined/.beads/interactions.jsonl index 2a067b8..0e967a5 100644 --- a/backend/lined/.beads/interactions.jsonl +++ b/backend/lined/.beads/interactions.jsonl @@ -3,3 +3,4 @@ {"id":"int-00878b38","kind":"field_change","created_at":"2026-05-26T04:33:18.818645Z","actor":"Oleksii Makieiev","issue_id":"lined-byj","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed: added bounded k6 load-test baseline script, documented smoke/baseline workflows, Docker fallback, synthetic data behavior, and runtime metrics verification; Gradle test/check and static script checks passed."}} {"id":"int-4377e589","kind":"field_change","created_at":"2026-05-29T08:40:48.238866Z","actor":"Oleksii Makieiev","issue_id":"lined-vbf","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented runtime scenario summary docs and CLI with critic review, verification, and Notion write-back."}} {"id":"int-5e657a3c","kind":"field_change","created_at":"2026-05-30T16:21:49.590801Z","actor":"Oleksii Makieiev","issue_id":"lined-z7y","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented account provisioning policy seam and shared role resolver; focused tests and ./gradlew check pass."}} +{"id":"int-7ae524e7","kind":"field_change","created_at":"2026-06-01T14:19:13.525997Z","actor":"Oleksii Makieiev","issue_id":"lined-0zb","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed calendar time-window refactor with validated window seam, conflict analyzer, header-bound requester checks, critic review, and ./gradlew check."}} diff --git a/backend/lined/src/main/java/io/backend/lined/event/api/EventController.java b/backend/lined/src/main/java/io/backend/lined/event/api/EventController.java index 5d5a669..0fa94fd 100644 --- a/backend/lined/src/main/java/io/backend/lined/event/api/EventController.java +++ b/backend/lined/src/main/java/io/backend/lined/event/api/EventController.java @@ -1,5 +1,6 @@ package io.backend.lined.event.api; +import io.backend.lined.common.exception.ForbiddenException; import io.backend.lined.event.service.EventService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -10,6 +11,7 @@ import jakarta.validation.Valid; import java.time.OffsetDateTime; import java.util.List; +import java.util.Objects; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -94,9 +96,12 @@ public ResponseEntity> findConflicts( @RequestParam Long lobbyId, @RequestParam OffsetDateTime start, @RequestParam OffsetDateTime end, - @RequestParam Long requesterId) { + @RequestParam Long requesterId, + @Parameter(description = "Current user id (temporary for MVP)", example = "42") + @RequestHeader("X-User-Id") Long currentUserId) { + ensureRequesterMatchesCurrentUser(requesterId, currentUserId); return ResponseEntity.ok( - service.findConflicts(lobbyId, start, end, requesterId)); + service.findConflicts(lobbyId, start, end, currentUserId)); } @GetMapping("/user-conflict") @@ -104,9 +109,18 @@ public ResponseEntity hasConflict( @RequestParam Long userId, @RequestParam OffsetDateTime start, @RequestParam OffsetDateTime end, - @RequestParam Long requesterId) { + @RequestParam Long requesterId, + @Parameter(description = "Current user id (temporary for MVP)", example = "42") + @RequestHeader("X-User-Id") Long currentUserId) { + ensureRequesterMatchesCurrentUser(requesterId, currentUserId); return ResponseEntity.ok( - service.hasConflict(userId, start, end, requesterId)); + service.hasConflict(userId, start, end, currentUserId)); + } + + private void ensureRequesterMatchesCurrentUser(Long requesterId, Long currentUserId) { + if (!Objects.equals(requesterId, currentUserId)) { + throw new ForbiddenException("Requester id must match current user"); + } } } diff --git a/backend/lined/src/main/java/io/backend/lined/event/service/CalendarTimeWindow.java b/backend/lined/src/main/java/io/backend/lined/event/service/CalendarTimeWindow.java new file mode 100644 index 0000000..b210baf --- /dev/null +++ b/backend/lined/src/main/java/io/backend/lined/event/service/CalendarTimeWindow.java @@ -0,0 +1,76 @@ +package io.backend.lined.event.service; + +import io.backend.lined.common.exception.BadRequestException; +import java.time.OffsetDateTime; +import java.util.Optional; + +/** + * Validated half-open calendar time window where start is inclusive and end is exclusive. + * + * @param start inclusive start instant + * @param end exclusive end instant + */ +record CalendarTimeWindow(OffsetDateTime start, OffsetDateTime end) { + + /** + * Creates a validated time window. + * + * @param start inclusive start instant + * @param end exclusive end instant + * @param message error message used when the bounds are invalid + * @return validated calendar time window + * @throws BadRequestException when either bound is null or start is not before end + */ + static CalendarTimeWindow of(OffsetDateTime start, OffsetDateTime end, String message) { + if (start == null || end == null || !start.isBefore(end)) { + throw new BadRequestException(message); + } + return new CalendarTimeWindow(start, end); + } + + /** + * Checks whether this window overlaps another window using half-open interval semantics. + * + * @param other validated window to compare with + * @return true when the windows share a non-empty time range + */ + boolean overlaps(CalendarTimeWindow other) { + return start.isBefore(other.end) && end.isAfter(other.start); + } + + /** + * Calculates the shared bounds between this window and another window. + * + * @param other validated window to compare with + * @return overlap bounds, or empty when the windows do not overlap + */ + Optional overlapWith(CalendarTimeWindow other) { + if (!overlaps(other)) { + return Optional.empty(); + } + // The overlap of two validated windows is always valid, so this can skip re-validation. + return Optional.of(new CalendarTimeWindow(max(start, other.start), min(end, other.end))); + } + + /** + * Returns the later of two timestamps. + * + * @param first first timestamp + * @param second second timestamp + * @return later timestamp + */ + private static OffsetDateTime max(OffsetDateTime first, OffsetDateTime second) { + return first.isAfter(second) ? first : second; + } + + /** + * Returns the earlier of two timestamps. + * + * @param first first timestamp + * @param second second timestamp + * @return earlier timestamp + */ + private static OffsetDateTime min(OffsetDateTime first, OffsetDateTime second) { + return first.isBefore(second) ? first : second; + } +} diff --git a/backend/lined/src/main/java/io/backend/lined/event/service/EventConflictAnalyzer.java b/backend/lined/src/main/java/io/backend/lined/event/service/EventConflictAnalyzer.java new file mode 100644 index 0000000..08e733b --- /dev/null +++ b/backend/lined/src/main/java/io/backend/lined/event/service/EventConflictAnalyzer.java @@ -0,0 +1,53 @@ +package io.backend.lined.event.service; + +import io.backend.lined.event.api.EventConflictDto; +import io.backend.lined.event.api.EventMapper; +import io.backend.lined.event.domain.EventEntity; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * Finds overlapping event pairs and maps them into calendar conflict responses. + */ +@Component +@RequiredArgsConstructor +public class EventConflictAnalyzer { + + private final EventMapper mapper; + + /** + * Finds all pairwise conflicts in the supplied event order. + * + * @param events events that already match the scheduling search window + * @return conflict pairs with calculated overlap bounds + */ + public List findConflicts(List events) { + var windows = events.stream().map(this::windowOf).toList(); + List conflicts = new ArrayList<>(); + for (int i = 0; i < events.size(); i++) { + for (int j = i + 1; j < events.size(); j++) { + addConflict(events.get(i), windows.get(i), events.get(j), windows.get(j), conflicts); + } + } + return conflicts; + } + + private void addConflict(EventEntity first, CalendarTimeWindow firstWindow, + EventEntity second, CalendarTimeWindow secondWindow, + List conflicts) { + firstWindow.overlapWith(secondWindow).ifPresent(overlap -> + conflicts.add(new EventConflictDto( + mapper.toDto(first), mapper.toDto(second), overlap.start(), overlap.end()))); + } + + private CalendarTimeWindow windowOf(EventEntity event) { + if (event.getStartAt() == null || event.getEndAt() == null + || !event.getStartAt().isBefore(event.getEndAt())) { + throw new IllegalStateException( + "Stored event %d has invalid time window".formatted(event.getId())); + } + return new CalendarTimeWindow(event.getStartAt(), event.getEndAt()); + } +} diff --git a/backend/lined/src/main/java/io/backend/lined/event/service/EventServiceImpl.java b/backend/lined/src/main/java/io/backend/lined/event/service/EventServiceImpl.java index ca225ac..0007864 100644 --- a/backend/lined/src/main/java/io/backend/lined/event/service/EventServiceImpl.java +++ b/backend/lined/src/main/java/io/backend/lined/event/service/EventServiceImpl.java @@ -1,7 +1,7 @@ package io.backend.lined.event.service; import io.backend.lined.common.EntityFinder; -import io.backend.lined.common.exception.BadRequestException; +import io.backend.lined.common.exception.ForbiddenException; import io.backend.lined.common.exception.NotFoundException; import io.backend.lined.event.api.EventConflictDto; import io.backend.lined.event.api.EventCreateDto; @@ -18,8 +18,8 @@ import io.backend.lined.user.domain.UserRepository; import jakarta.transaction.Transactional; import java.time.OffsetDateTime; -import java.util.ArrayList; import java.util.List; +import java.util.Objects; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -33,22 +33,20 @@ public class EventServiceImpl implements EventService { private final UserRepository userRepo; private final EventMapper mapper; private final LobbyAccessPolicy accessPolicy; + private final EventConflictAnalyzer conflictAnalyzer; @Override public EventDto create(EventCreateDto dto, Long currentUserId) { var owner = mustUser(currentUserId); var lobby = mustLobby(dto.lobbyId()); accessPolicy.ensureMember(lobby, currentUserId); - - if (!dto.startAt().isBefore(dto.endAt())) { - throw new BadRequestException("startAt must be before endAt"); - } + var window = eventWindow(dto.startAt(), dto.endAt()); var entity = EventEntity.builder() .title(dto.title()) .shared(dto.shared()) - .startAt(dto.startAt()) - .endAt(dto.endAt()) + .startAt(window.start()) + .endAt(window.end()) .timezone(dto.timezone()) .lobby(lobby) .owner(owner) @@ -61,6 +59,9 @@ public EventDto create(EventCreateDto dto, Long currentUserId) { public EventDto update(Long id, EventUpdateDto dto, Long currentUserId) { var e = mustEvent(id); accessPolicy.ensureMember(e.getLobby(), currentUserId); + var window = eventWindow( + dto.startAt() == null ? e.getStartAt() : dto.startAt(), + dto.endAt() == null ? e.getEndAt() : dto.endAt()); if (dto.title() != null && !dto.title().isBlank()) { e.setTitle(dto.title()); @@ -68,20 +69,12 @@ public EventDto update(Long id, EventUpdateDto dto, Long currentUserId) { if (dto.shared() != null) { e.setShared(dto.shared()); } - if (dto.startAt() != null) { - e.setStartAt(dto.startAt()); - } - if (dto.endAt() != null) { - e.setEndAt(dto.endAt()); - } + e.setStartAt(window.start()); + e.setEndAt(window.end()); if (dto.timezone() != null && !dto.timezone().isBlank()) { e.setTimezone(dto.timezone()); } - if (!e.getStartAt().isBefore(e.getEndAt())) { - throw new BadRequestException("startAt must be before endAt"); - } - return mapper.toDto(e); } @@ -97,12 +90,11 @@ public List list(Long lobbyId, OffsetDateTime from, OffsetDateTime to, Long currentUserId) { var lobby = mustLobby(lobbyId); accessPolicy.ensureMember(lobby, currentUserId); + var window = queryWindow(from, to); - if (from == null || to == null || !from.isBefore(to)) { - throw new BadRequestException("Invalid time window: from < to is required"); - } - - return repo.findOverlapping(lobbyId, from, to).stream().map(mapper::toDto).toList(); + return repo.findOverlapping(lobbyId, window.start(), window.end()).stream() + .map(mapper::toDto) + .toList(); } @Override @@ -111,35 +103,17 @@ public List findConflicts(Long lobbyId, Long requesterId) { var lobby = mustLobby(lobbyId); accessPolicy.ensureMember(lobby, requesterId); - if (!start.isBefore(end)) { - throw new BadRequestException("start must be before end"); - } - var events = repo.findOverlapping(lobbyId, start, end); - List conflicts = new ArrayList<>(); - for (int i = 0; i < events.size(); i++) { - for (int j = i + 1; j < events.size(); j++) { - var a = events.get(i); - var b = events.get(j); - var overlapStart = a.getStartAt().isAfter(b.getStartAt()) - ? a.getStartAt() : b.getStartAt(); - var overlapEnd = a.getEndAt().isBefore(b.getEndAt()) - ? a.getEndAt() : b.getEndAt(); - if (overlapStart.isBefore(overlapEnd)) { - conflicts.add(new EventConflictDto( - mapper.toDto(a), mapper.toDto(b), overlapStart, overlapEnd)); - } - } - } - return conflicts; + var window = conflictWindow(start, end); + var events = repo.findOverlapping(lobbyId, window.start(), window.end()); + return conflictAnalyzer.findConflicts(events); } @Override public UserConflictDto hasConflict(Long userId, OffsetDateTime start, OffsetDateTime end, Long requesterId) { - if (!start.isBefore(end)) { - throw new BadRequestException("start must be before end"); - } - var overlapping = repo.findOverlappingByUser(userId, start, end); + ensureOwnCalendar(userId, requesterId); + var window = conflictWindow(start, end); + var overlapping = repo.findOverlappingByUser(userId, window.start(), window.end()); if (overlapping.isEmpty()) { return new UserConflictDto(userId, false, null); } @@ -161,4 +135,22 @@ private EventEntity mustEvent(Long id) { () -> new NotFoundException("Event %d not found".formatted(id))); } + private CalendarTimeWindow eventWindow(OffsetDateTime start, OffsetDateTime end) { + return CalendarTimeWindow.of(start, end, "startAt must be before endAt"); + } + + private CalendarTimeWindow queryWindow(OffsetDateTime start, OffsetDateTime end) { + return CalendarTimeWindow.of(start, end, "Invalid time window: from < to is required"); + } + + private CalendarTimeWindow conflictWindow(OffsetDateTime start, OffsetDateTime end) { + return CalendarTimeWindow.of(start, end, "start must be before end"); + } + + private void ensureOwnCalendar(Long userId, Long requesterId) { + if (!Objects.equals(userId, requesterId)) { + throw new ForbiddenException("Requester can only check their own calendar"); + } + } + } diff --git a/backend/lined/src/test/java/io/backend/lined/event/api/EventControllerTest.java b/backend/lined/src/test/java/io/backend/lined/event/api/EventControllerTest.java new file mode 100644 index 0000000..5af0552 --- /dev/null +++ b/backend/lined/src/test/java/io/backend/lined/event/api/EventControllerTest.java @@ -0,0 +1,74 @@ +package io.backend.lined.event.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.backend.lined.common.exception.ForbiddenException; +import io.backend.lined.event.service.EventService; +import java.time.OffsetDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class EventControllerTest { + + @Mock + private EventService service; + + private EventController controller; + private OffsetDateTime start; + private OffsetDateTime end; + + @BeforeEach + void setUp() { + controller = new EventController(service); + start = OffsetDateTime.parse("2026-01-01T10:00:00Z"); + end = start.plusHours(1); + } + + @Test + void findConflicts_usesCurrentUserHeaderAsRequester() { + when(service.findConflicts(101L, start, end, 1L)).thenReturn(List.of()); + + var response = controller.findConflicts(101L, start, end, 1L, 1L); + + assertThat(response.getBody()).isEmpty(); + verify(service).findConflicts(101L, start, end, 1L); + } + + @Test + void findConflicts_throwsForbidden_whenRequesterParamDoesNotMatchHeader() { + assertThatThrownBy(() -> controller.findConflicts(101L, start, end, 2L, 1L)) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("Requester id must match current user"); + + verify(service, never()).findConflicts(101L, start, end, 1L); + } + + @Test + void hasConflict_usesCurrentUserHeaderAsRequester() { + var result = new UserConflictDto(1L, false, null); + when(service.hasConflict(1L, start, end, 1L)).thenReturn(result); + + var response = controller.hasConflict(1L, start, end, 1L, 1L); + + assertThat(response.getBody()).isEqualTo(result); + verify(service).hasConflict(1L, start, end, 1L); + } + + @Test + void hasConflict_throwsForbidden_whenRequesterParamDoesNotMatchHeader() { + assertThatThrownBy(() -> controller.hasConflict(2L, start, end, 2L, 1L)) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("Requester id must match current user"); + + verify(service, never()).hasConflict(2L, start, end, 1L); + } +} diff --git a/backend/lined/src/test/java/io/backend/lined/event/service/CalendarTimeWindowTest.java b/backend/lined/src/test/java/io/backend/lined/event/service/CalendarTimeWindowTest.java new file mode 100644 index 0000000..d48c73d --- /dev/null +++ b/backend/lined/src/test/java/io/backend/lined/event/service/CalendarTimeWindowTest.java @@ -0,0 +1,91 @@ +package io.backend.lined.event.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.backend.lined.common.exception.BadRequestException; +import java.time.OffsetDateTime; +import org.junit.jupiter.api.Test; + +class CalendarTimeWindowTest { + + private static final String MESSAGE = "Invalid time window"; + private final OffsetDateTime base = OffsetDateTime.parse("2026-01-01T10:00:00Z"); + + @Test + void of_acceptsValidWindow() { + var window = CalendarTimeWindow.of(base, base.plusHours(1), MESSAGE); + + assertThat(window.start()).isEqualTo(base); + assertThat(window.end()).isEqualTo(base.plusHours(1)); + } + + @Test + void of_throwsBadRequest_whenStartIsNull() { + assertThatThrownBy(() -> CalendarTimeWindow.of(null, base.plusHours(1), MESSAGE)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining(MESSAGE); + } + + @Test + void of_throwsBadRequest_whenEndIsNull() { + assertThatThrownBy(() -> CalendarTimeWindow.of(base, null, MESSAGE)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining(MESSAGE); + } + + @Test + void of_throwsBadRequest_whenStartEqualsEnd() { + assertThatThrownBy(() -> CalendarTimeWindow.of(base, base, MESSAGE)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining(MESSAGE); + } + + @Test + void of_throwsBadRequest_whenStartIsAfterEnd() { + assertThatThrownBy(() -> CalendarTimeWindow.of(base.plusHours(1), base, MESSAGE)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining(MESSAGE); + } + + @Test + void overlaps_returnsFalse_whenWindowsAreAdjacent() { + var first = CalendarTimeWindow.of(base, base.plusHours(1), MESSAGE); + var second = CalendarTimeWindow.of(base.plusHours(1), base.plusHours(2), MESSAGE); + + assertThat(first.overlaps(second)).isFalse(); + assertThat(second.overlaps(first)).isFalse(); + assertThat(first.overlapWith(second)).isEmpty(); + } + + @Test + void overlapWith_returnsBoundsForPartialOverlap() { + var first = CalendarTimeWindow.of(base, base.plusHours(2), MESSAGE); + var second = CalendarTimeWindow.of(base.plusHours(1), base.plusHours(3), MESSAGE); + + var overlap = first.overlapWith(second).orElseThrow(); + + assertThat(overlap.start()).isEqualTo(base.plusHours(1)); + assertThat(overlap.end()).isEqualTo(base.plusHours(2)); + } + + @Test + void overlapWith_returnsContainedWindowBounds() { + var outer = CalendarTimeWindow.of(base, base.plusHours(4), MESSAGE); + var inner = CalendarTimeWindow.of(base.plusHours(1), base.plusHours(2), MESSAGE); + + var overlap = outer.overlapWith(inner).orElseThrow(); + + assertThat(overlap).isEqualTo(inner); + } + + @Test + void overlapWith_returnsSameWindowBounds() { + var first = CalendarTimeWindow.of(base, base.plusHours(1), MESSAGE); + var second = CalendarTimeWindow.of(base, base.plusHours(1), MESSAGE); + + var overlap = first.overlapWith(second).orElseThrow(); + + assertThat(overlap).isEqualTo(first); + } +} diff --git a/backend/lined/src/test/java/io/backend/lined/event/service/EventConflictAnalyzerTest.java b/backend/lined/src/test/java/io/backend/lined/event/service/EventConflictAnalyzerTest.java new file mode 100644 index 0000000..fb0eed7 --- /dev/null +++ b/backend/lined/src/test/java/io/backend/lined/event/service/EventConflictAnalyzerTest.java @@ -0,0 +1,142 @@ +package io.backend.lined.event.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.backend.lined.event.api.EventConflictDto; +import io.backend.lined.event.api.EventDto; +import io.backend.lined.event.api.EventMapper; +import io.backend.lined.event.domain.EventEntity; +import java.time.OffsetDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class EventConflictAnalyzerTest { + + @Mock + private EventMapper mapper; + + private EventConflictAnalyzer analyzer; + private OffsetDateTime base; + + @BeforeEach + void setUp() { + analyzer = new EventConflictAnalyzer(mapper); + base = OffsetDateTime.parse("2026-01-01T10:00:00Z"); + } + + @Test + void findConflicts_returnsEmpty_whenNoEvents() { + assertThat(analyzer.findConflicts(List.of())).isEmpty(); + } + + @Test + void findConflicts_returnsEmpty_whenSingleEvent() { + var event = event(1L, base, base.plusHours(1)); + + assertThat(analyzer.findConflicts(List.of(event))).isEmpty(); + verify(mapper, never()).toDto(event); + } + + @Test + void findConflicts_returnsEmpty_whenEventsAreAdjacent() { + var first = event(1L, base, base.plusHours(1)); + var second = event(2L, base.plusHours(1), base.plusHours(2)); + + assertThat(analyzer.findConflicts(List.of(first, second))).isEmpty(); + verify(mapper, never()).toDto(first); + verify(mapper, never()).toDto(second); + } + + @Test + void findConflicts_returnsChainPairsInInputOrder() { + var first = event(1L, base, base.plusHours(2)); + var second = event(2L, base.plusHours(1), base.plusHours(3)); + var third = event(3L, base.plusHours(2), base.plusHours(4)); + var dto1 = dto(1L); + var dto2 = dto(2L); + var dto3 = dto(3L); + when(mapper.toDto(first)).thenReturn(dto1); + when(mapper.toDto(second)).thenReturn(dto2); + when(mapper.toDto(third)).thenReturn(dto3); + + List result = analyzer.findConflicts(List.of(first, second, third)); + + assertThat(result).hasSize(2); + assertThat(result.get(0).first()).isEqualTo(dto1); + assertThat(result.get(0).second()).isEqualTo(dto2); + assertThat(result.get(1).first()).isEqualTo(dto2); + assertThat(result.get(1).second()).isEqualTo(dto3); + } + + @Test + void findConflicts_calculatesContainedOverlapBounds() { + var outer = event(1L, base, base.plusHours(4)); + var inner = event(2L, base.plusHours(1), base.plusHours(2)); + when(mapper.toDto(outer)).thenReturn(dto(1L)); + when(mapper.toDto(inner)).thenReturn(dto(2L)); + + List result = analyzer.findConflicts(List.of(outer, inner)); + + assertThat(result).hasSize(1); + assertThat(result.get(0).overlapStart()).isEqualTo(inner.getStartAt()); + assertThat(result.get(0).overlapEnd()).isEqualTo(inner.getEndAt()); + } + + @Test + void findConflicts_calculatesSameWindowOverlapBounds() { + var first = event(1L, base, base.plusHours(1)); + var second = event(2L, base, base.plusHours(1)); + when(mapper.toDto(first)).thenReturn(dto(1L)); + when(mapper.toDto(second)).thenReturn(dto(2L)); + + List result = analyzer.findConflicts(List.of(first, second)); + + assertThat(result).hasSize(1); + assertThat(result.get(0).overlapStart()).isEqualTo(base); + assertThat(result.get(0).overlapEnd()).isEqualTo(base.plusHours(1)); + } + + @Test + void findConflicts_throwsIllegalState_whenEntityHasInvalidWindow() { + var invalid = event(1L, base.plusHours(1), base); + var valid = event(2L, base, base.plusHours(2)); + + assertThatThrownBy(() -> analyzer.findConflicts(List.of(invalid, valid))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Stored event 1 has invalid time window"); + } + + @Test + void findConflicts_throwsIllegalState_whenSingleEntityHasInvalidWindow() { + var invalid = event(1L, base.plusHours(1), base); + + assertThatThrownBy(() -> analyzer.findConflicts(List.of(invalid))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Stored event 1 has invalid time window"); + } + + private EventEntity event(Long id, OffsetDateTime start, OffsetDateTime end) { + return EventEntity.builder() + .id(id) + .title("Event " + id) + .shared(true) + .startAt(start) + .endAt(end) + .timezone("Europe/Kyiv") + .build(); + } + + private EventDto dto(Long id) { + return new EventDto(id, "Event " + id, true, base, base.plusHours(1), + "Europe/Kyiv", 101L, 1L, base); + } +} diff --git a/backend/lined/src/test/java/io/backend/lined/event/service/EventServiceImplConflictTest.java b/backend/lined/src/test/java/io/backend/lined/event/service/EventServiceImplConflictTest.java index 5a66fdd..5a421dc 100644 --- a/backend/lined/src/test/java/io/backend/lined/event/service/EventServiceImplConflictTest.java +++ b/backend/lined/src/test/java/io/backend/lined/event/service/EventServiceImplConflictTest.java @@ -31,7 +31,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; @@ -54,7 +53,6 @@ class EventServiceImplConflictTest { @Spy private LobbyAccessPolicy accessPolicy; - @InjectMocks private EventServiceImpl eventService; private UserEntity owner; @@ -81,6 +79,8 @@ void setUp() { setupLobby(); setupEvents(); setupDtos(); + eventService = new EventServiceImpl( + repo, lobbyRepo, userRepo, mapper, accessPolicy, new EventConflictAnalyzer(mapper)); } private void setupDtos() { @@ -243,6 +243,18 @@ void findConflicts_throwsBadRequest_whenStartAfterEnd() { verify(repo, never()).findOverlapping(any(), any(), any()); } + @Test + void findConflicts_throwsBadRequest_whenStartIsNull() { + when(lobbyRepo.findById(101L)).thenReturn(Optional.of(lobby)); + + assertThatThrownBy(() -> + eventService.findConflicts(101L, null, windowEnd, 1L)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("start must be before end"); + + verify(repo, never()).findOverlapping(any(), any(), any()); + } + @Test void findConflicts_throwsForbidden_whenUserIsNotLobbyMember() { when(lobbyRepo.findById(101L)).thenReturn(Optional.of(lobby)); @@ -314,6 +326,16 @@ void hasConflict_throwsBadRequest_whenStartAfterEnd() { verify(repo, never()).findOverlappingByUser(any(), any(), any()); } + @Test + void hasConflict_throwsBadRequest_whenStartIsNull() { + assertThatThrownBy(() -> + eventService.hasConflict(1L, null, windowEnd, 1L)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("start must be before end"); + + verify(repo, never()).findOverlappingByUser(any(), any(), any()); + } + @Test void hasConflict_throwsBadRequest_whenStartEqualsEnd() { assertThatThrownBy(() -> @@ -322,6 +344,16 @@ void hasConflict_throwsBadRequest_whenStartEqualsEnd() { .hasMessageContaining("start must be before end"); } + @Test + void hasConflict_throwsForbidden_whenRequesterChecksAnotherUser() { + assertThatThrownBy(() -> + eventService.hasConflict(2L, windowStart, windowEnd, 1L)) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("Requester can only check their own calendar"); + + verify(repo, never()).findOverlappingByUser(any(), any(), any()); + } + @Test void hasConflict_includesUserIdInResponse() { when(repo.findOverlappingByUser(eq(2L), eq(windowStart), eq(windowEnd))) diff --git a/backend/lined/src/test/java/io/backend/lined/event/service/EventServiceImplTest.java b/backend/lined/src/test/java/io/backend/lined/event/service/EventServiceImplTest.java index a551d6c..782a551 100644 --- a/backend/lined/src/test/java/io/backend/lined/event/service/EventServiceImplTest.java +++ b/backend/lined/src/test/java/io/backend/lined/event/service/EventServiceImplTest.java @@ -23,9 +23,9 @@ import io.backend.lined.user.domain.UserEntity; import io.backend.lined.user.domain.UserRepository; import java.time.OffsetDateTime; +import java.util.HashSet; import java.util.List; import java.util.Optional; -import java.util.HashSet; import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -48,6 +48,8 @@ class EventServiceImplTest { private EventMapper mapper; @Spy private LobbyAccessPolicy accessPolicy; + @Mock + private EventConflictAnalyzer conflictAnalyzer; @InjectMocks private EventServiceImpl eventService; @@ -149,6 +151,21 @@ void create_throwsBadRequest_whenStartAtEqualsEndAt() { .hasMessageContaining("startAt must be before endAt"); } + @Test + void create_throwsBadRequest_whenStartAtIsNull() { + EventCreateDto dto = new EventCreateDto( + "Dinner together", true, null, endAt, "Europe/Kyiv", 101L); + + when(userRepo.findById(1L)).thenReturn(Optional.of(owner)); + when(lobbyRepo.findById(101L)).thenReturn(Optional.of(lobby)); + + assertThatThrownBy(() -> eventService.create(dto, 1L)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("startAt must be before endAt"); + + verify(repo, never()).save(any()); + } + @Test void create_throwsNotFound_whenUserNotFound() { EventCreateDto dto = new EventCreateDto( @@ -249,6 +266,40 @@ void update_throwsBadRequest_whenUpdatedDatesAreInvalid() { .hasMessageContaining("startAt must be before endAt"); } + @Test + void update_doesNotMutateEntity_whenUpdatedDatesAreInvalid() { + EventUpdateDto dto = new EventUpdateDto("Invalid update", false, endAt, startAt, "UTC"); + + when(repo.findById(9001L)).thenReturn(Optional.of(eventEntity)); + + assertThatThrownBy(() -> eventService.update(9001L, dto, 1L)) + .isInstanceOf(BadRequestException.class); + + assertThat(eventEntity.getTitle()).isEqualTo("Dinner together"); + assertThat(eventEntity.isShared()).isTrue(); + assertThat(eventEntity.getStartAt()).isEqualTo(startAt); + assertThat(eventEntity.getEndAt()).isEqualTo(endAt); + assertThat(eventEntity.getTimezone()).isEqualTo("Europe/Kyiv"); + } + + @Test + void update_throwsBadRequest_whenNewStartAtExceedsExistingEndAt() { + EventUpdateDto dto = new EventUpdateDto("Invalid update", false, endAt.plusHours(1), + null, "UTC"); + + when(repo.findById(9001L)).thenReturn(Optional.of(eventEntity)); + + assertThatThrownBy(() -> eventService.update(9001L, dto, 1L)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("startAt must be before endAt"); + + assertThat(eventEntity.getTitle()).isEqualTo("Dinner together"); + assertThat(eventEntity.isShared()).isTrue(); + assertThat(eventEntity.getStartAt()).isEqualTo(startAt); + assertThat(eventEntity.getEndAt()).isEqualTo(endAt); + assertThat(eventEntity.getTimezone()).isEqualTo("Europe/Kyiv"); + } + @Test void update_throwsNotFound_whenEventNotFound() { EventUpdateDto dto = new EventUpdateDto("Title", null, null, null, null);