Skip to content

Commit b1a2c4c

Browse files
authored
Merge pull request #112 from volodymyrlp/feature/TP-128-trip-cloning
TP-128: Implement trip cloning endpoint with deep copy of days, itine…
2 parents 1239045 + 6fbcad9 commit b1a2c4c

4 files changed

Lines changed: 288 additions & 0 deletions

File tree

backend/src/main/java/travelplanner/controller/TripController.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,15 @@ public ResponseEntity<List<PhotoResponse>> getTripPhotos(@PathVariable UUID trip
100100
return ResponseEntity.ok(response);
101101
}
102102

103+
@PostMapping("/{tripId}/clone")
104+
public ResponseEntity<TripResponse> cloneTrip(
105+
@PathVariable UUID tripId,
106+
@AuthenticationPrincipal User currentUser
107+
) {
108+
TripResponse response = tripService.cloneTrip(tripId, currentUser);
109+
return ResponseEntity.status(HttpStatus.CREATED).body(response);
110+
}
111+
103112
@ExceptionHandler(EntityNotFoundException.class)
104113
public ResponseEntity<String> handleNotFound(EntityNotFoundException ex) {
105114
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage());

backend/src/main/java/travelplanner/service/TripService.java

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@
55
import lombok.RequiredArgsConstructor;
66
import org.springframework.data.domain.Page;
77
import org.springframework.data.domain.Pageable;
8+
import org.springframework.security.access.AccessDeniedException;
89
import org.springframework.stereotype.Service;
910
import org.springframework.transaction.annotation.Transactional;
1011
import org.springframework.web.multipart.MultipartFile;
1112
import travelplanner.dto.trip.PhotoResponse;
1213
import travelplanner.dto.trip.TripCreateRequest;
1314
import travelplanner.dto.trip.TripResponse;
15+
import travelplanner.entity.Itinerary;
1416
import travelplanner.entity.Photo;
1517
import travelplanner.entity.Trip;
18+
import travelplanner.entity.TripDay;
1619
import travelplanner.entity.User;
1720
import travelplanner.exception.EntityNotFoundException;
1821
import travelplanner.exception.ForbiddenException;
@@ -118,4 +121,48 @@ public List<PhotoResponse> getTripPhotos(UUID tripId) {
118121
List<Photo> photos = photoRepository.findAllByTripTripId(tripId);
119122
return tripMapper.toPhotoResponseList(photos);
120123
}
124+
125+
@Transactional
126+
public TripResponse cloneTrip(UUID originalTripId, User currentUser) {
127+
Trip originalTrip = tripRepository.findById(originalTripId)
128+
.orElseThrow(() -> new EntityNotFoundException(
129+
"Trip not found with id: " + originalTripId));
130+
131+
if (!originalTrip.getIsPublic()
132+
&& !originalTrip.getOwner().getUserId().equals(currentUser.getUserId())) {
133+
throw new AccessDeniedException("You do not have access to this trip");
134+
}
135+
136+
Trip clonedTrip = new Trip();
137+
clonedTrip.setTitle(originalTrip.getTitle() + " (Copy)");
138+
clonedTrip.setDescription(originalTrip.getDescription());
139+
clonedTrip.setBudget(originalTrip.getBudget());
140+
clonedTrip.setCurrency(originalTrip.getCurrency());
141+
clonedTrip.setCoverUrl(originalTrip.getCoverUrl());
142+
clonedTrip.setOwner(currentUser);
143+
clonedTrip.setIsPublic(false);
144+
clonedTrip.setTags(new java.util.HashSet<>(originalTrip.getTags()));
145+
146+
for (TripDay originalDay : originalTrip.getTripDays()) {
147+
TripDay clonedDay = new TripDay();
148+
clonedDay.setTrip(clonedTrip);
149+
clonedDay.setDayNumber(originalDay.getDayNumber());
150+
clonedDay.setDate(originalDay.getDate());
151+
152+
for (Itinerary originalItinerary : originalDay.getItineraries()) {
153+
Itinerary clonedItinerary = new Itinerary();
154+
clonedItinerary.setTripDay(clonedDay);
155+
clonedItinerary.setVisitOrder(originalItinerary.getVisitOrder());
156+
clonedItinerary.setNotes(originalItinerary.getNotes());
157+
clonedItinerary.setTimeSpentMinutes(originalItinerary.getTimeSpentMinutes());
158+
clonedItinerary.setPlace(originalItinerary.getPlace());
159+
clonedDay.getItineraries().add(clonedItinerary);
160+
}
161+
162+
clonedTrip.getTripDays().add(clonedDay);
163+
}
164+
165+
Trip savedTrip = tripRepository.save(clonedTrip);
166+
return tripMapper.toResponse(savedTrip);
167+
}
121168
}

backend/src/test/java/travelplanner/controller/TripControllerIntegrationTest.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
88
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
99
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
10+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
1011
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
1112
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
1213

@@ -126,4 +127,32 @@ void getPhotos_Success() throws Exception {
126127
.andExpect(jsonPath("$[0].url").value("/uploads/photo.jpg"))
127128
.andExpect(jsonPath("$[0].uploaderId").value(currentUser.getUserId().toString()));
128129
}
130+
131+
@Test
132+
void cloneTrip_Success() throws Exception {
133+
UUID tripId = UUID.randomUUID();
134+
User currentUser = new User();
135+
currentUser.setUserId(UUID.randomUUID());
136+
currentUser.setEmail("test@example.com");
137+
138+
TripResponse response = new TripResponse(
139+
UUID.randomUUID(),
140+
"My Trip (Copy)",
141+
"Description",
142+
BigDecimal.valueOf(1000),
143+
"USD",
144+
"/uploads/test.jpg",
145+
false,
146+
LocalDateTime.now()
147+
);
148+
149+
when(tripService.cloneTrip(eq(tripId), any())).thenReturn(response);
150+
151+
mockMvc.perform(post("/api/v1/trips/{tripId}/clone", tripId)
152+
.with(user(currentUser))
153+
.with(csrf()))
154+
.andExpect(status().isCreated())
155+
.andExpect(jsonPath("$.title").value("My Trip (Copy)"))
156+
.andExpect(jsonPath("$.isPublic").value(false));
157+
}
129158
}
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
package travelplanner.service;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertFalse;
5+
import static org.junit.jupiter.api.Assertions.assertNotNull;
6+
import static org.junit.jupiter.api.Assertions.assertNull;
7+
import static org.junit.jupiter.api.Assertions.assertSame;
8+
import static org.junit.jupiter.api.Assertions.assertThrows;
9+
import static org.mockito.ArgumentMatchers.any;
10+
import static org.mockito.Mockito.times;
11+
import static org.mockito.Mockito.verify;
12+
import static org.mockito.Mockito.when;
13+
14+
import java.math.BigDecimal;
15+
import java.time.LocalDate;
16+
import java.time.LocalDateTime;
17+
import java.util.Optional;
18+
import java.util.UUID;
19+
import org.junit.jupiter.api.Test;
20+
import org.junit.jupiter.api.extension.ExtendWith;
21+
import org.mockito.ArgumentCaptor;
22+
import org.mockito.InjectMocks;
23+
import org.mockito.Mock;
24+
import org.mockito.junit.jupiter.MockitoExtension;
25+
import org.springframework.security.access.AccessDeniedException;
26+
import travelplanner.dto.trip.TripResponse;
27+
import travelplanner.entity.Itinerary;
28+
import travelplanner.entity.Place;
29+
import travelplanner.entity.Tag;
30+
import travelplanner.entity.Trip;
31+
import travelplanner.entity.TripDay;
32+
import travelplanner.entity.User;
33+
import travelplanner.exception.EntityNotFoundException;
34+
import travelplanner.mapper.TripMapper;
35+
import travelplanner.repository.TripRepository;
36+
37+
@ExtendWith(MockitoExtension.class)
38+
class TripServiceTest {
39+
40+
@Mock
41+
private TripRepository tripRepository;
42+
43+
@Mock
44+
private TripMapper tripMapper;
45+
46+
@InjectMocks
47+
private TripService tripService;
48+
49+
@Test
50+
void cloneTrip_Success_PublicTripOfAnotherUser() {
51+
User originalOwner = new User();
52+
originalOwner.setUserId(UUID.randomUUID());
53+
54+
User currentUser = new User();
55+
currentUser.setUserId(UUID.randomUUID());
56+
57+
Place place = new Place();
58+
place.setPlaceId(UUID.randomUUID());
59+
place.setName("Eiffel Tower");
60+
61+
UUID originalTripId = UUID.randomUUID();
62+
Trip originalTrip = new Trip();
63+
originalTrip.setTripId(originalTripId);
64+
originalTrip.setTitle("Paris Tour");
65+
originalTrip.setDescription("A wonderful tour");
66+
originalTrip.setBudget(BigDecimal.valueOf(1500));
67+
originalTrip.setCurrency("EUR");
68+
originalTrip.setCoverUrl("http://example.com/cover.jpg");
69+
originalTrip.setIsPublic(true);
70+
originalTrip.setOwner(originalOwner);
71+
72+
Tag tag = new Tag();
73+
tag.setTagId(UUID.randomUUID());
74+
tag.setName("Adventure");
75+
originalTrip.getTags().add(tag);
76+
77+
TripDay originalDay = new TripDay();
78+
originalDay.setDayId(UUID.randomUUID());
79+
originalDay.setDayNumber(1);
80+
originalDay.setDate(LocalDate.of(2026, 9, 1));
81+
originalDay.setTrip(originalTrip);
82+
originalTrip.getTripDays().add(originalDay);
83+
84+
Itinerary originalItinerary = new Itinerary();
85+
originalItinerary.setItineraryId(UUID.randomUUID());
86+
originalItinerary.setVisitOrder(1);
87+
originalItinerary.setNotes("First stop");
88+
originalItinerary.setTimeSpentMinutes(120);
89+
originalItinerary.setPlace(place);
90+
originalItinerary.setTripDay(originalDay);
91+
originalDay.getItineraries().add(originalItinerary);
92+
93+
TripResponse expectedResponse = new TripResponse(
94+
UUID.randomUUID(),
95+
"Paris Tour (Copy)",
96+
"A wonderful tour",
97+
BigDecimal.valueOf(1500),
98+
"EUR",
99+
"http://example.com/cover.jpg",
100+
false,
101+
LocalDateTime.now()
102+
);
103+
104+
when(tripRepository.findById(originalTripId)).thenReturn(Optional.of(originalTrip));
105+
when(tripRepository.save(any(Trip.class)))
106+
.thenAnswer(invocation -> invocation.getArgument(0));
107+
when(tripMapper.toResponse(any(Trip.class))).thenReturn(expectedResponse);
108+
109+
TripResponse result = tripService.cloneTrip(originalTripId, currentUser);
110+
111+
assertNotNull(result);
112+
assertEquals(expectedResponse.title(), result.title());
113+
assertFalse(result.isPublic());
114+
115+
ArgumentCaptor<Trip> tripCaptor = ArgumentCaptor.forClass(Trip.class);
116+
verify(tripRepository, times(1)).save(tripCaptor.capture());
117+
Trip clonedTrip = tripCaptor.getValue();
118+
119+
assertNull(clonedTrip.getTripId());
120+
assertEquals("Paris Tour (Copy)", clonedTrip.getTitle());
121+
assertEquals("A wonderful tour", clonedTrip.getDescription());
122+
assertEquals(BigDecimal.valueOf(1500), clonedTrip.getBudget());
123+
assertEquals("EUR", clonedTrip.getCurrency());
124+
assertEquals("http://example.com/cover.jpg", clonedTrip.getCoverUrl());
125+
assertSame(currentUser, clonedTrip.getOwner());
126+
assertFalse(clonedTrip.getIsPublic());
127+
128+
assertEquals(1, clonedTrip.getTags().size());
129+
org.junit.jupiter.api.Assertions.assertTrue(
130+
clonedTrip.getTags().contains(tag));
131+
org.junit.jupiter.api.Assertions.assertNotSame(
132+
originalTrip.getTags(), clonedTrip.getTags());
133+
134+
assertEquals(1, clonedTrip.getTripDays().size());
135+
TripDay clonedDay = clonedTrip.getTripDays().get(0);
136+
assertNull(clonedDay.getDayId());
137+
assertSame(clonedTrip, clonedDay.getTrip());
138+
assertEquals(1, clonedDay.getDayNumber());
139+
assertEquals(LocalDate.of(2026, 9, 1), clonedDay.getDate());
140+
141+
assertEquals(1, clonedDay.getItineraries().size());
142+
Itinerary clonedItinerary = clonedDay.getItineraries().get(0);
143+
assertNull(clonedItinerary.getItineraryId());
144+
assertSame(clonedDay, clonedItinerary.getTripDay());
145+
assertEquals(1, clonedItinerary.getVisitOrder());
146+
assertEquals("First stop", clonedItinerary.getNotes());
147+
assertEquals(120, clonedItinerary.getTimeSpentMinutes());
148+
assertSame(place, clonedItinerary.getPlace());
149+
}
150+
151+
@Test
152+
void cloneTrip_Success_OwnPrivateTrip() {
153+
User currentUser = new User();
154+
currentUser.setUserId(UUID.randomUUID());
155+
156+
UUID originalTripId = UUID.randomUUID();
157+
Trip originalTrip = new Trip();
158+
originalTrip.setTripId(originalTripId);
159+
originalTrip.setTitle("My Trip");
160+
originalTrip.setIsPublic(false);
161+
originalTrip.setOwner(currentUser);
162+
163+
when(tripRepository.findById(originalTripId)).thenReturn(Optional.of(originalTrip));
164+
when(tripRepository.save(any(Trip.class)))
165+
.thenAnswer(invocation -> invocation.getArgument(0));
166+
167+
tripService.cloneTrip(originalTripId, currentUser);
168+
169+
verify(tripRepository, times(1)).save(any(Trip.class));
170+
}
171+
172+
@Test
173+
void cloneTrip_Failure_PrivateTripOfAnotherUser() {
174+
User originalOwner = new User();
175+
originalOwner.setUserId(UUID.randomUUID());
176+
177+
User currentUser = new User();
178+
currentUser.setUserId(UUID.randomUUID());
179+
180+
UUID originalTripId = UUID.randomUUID();
181+
Trip originalTrip = new Trip();
182+
originalTrip.setTripId(originalTripId);
183+
originalTrip.setTitle("Secret Trip");
184+
originalTrip.setIsPublic(false);
185+
originalTrip.setOwner(originalOwner);
186+
187+
when(tripRepository.findById(originalTripId)).thenReturn(Optional.of(originalTrip));
188+
189+
assertThrows(AccessDeniedException.class,
190+
() -> tripService.cloneTrip(originalTripId, currentUser));
191+
}
192+
193+
@Test
194+
void cloneTrip_Failure_TripNotFound() {
195+
User currentUser = new User();
196+
197+
UUID originalTripId = UUID.randomUUID();
198+
when(tripRepository.findById(originalTripId)).thenReturn(Optional.empty());
199+
200+
assertThrows(EntityNotFoundException.class,
201+
() -> tripService.cloneTrip(originalTripId, currentUser));
202+
}
203+
}

0 commit comments

Comments
 (0)