mirror of
https://github.com/dedicatedcode/reitti.git
synced 2026-01-07 00:23:52 -05:00
Added Owntracks friend data support, including avatar and location data. (#617)
This commit is contained in:
7
pom.xml
7
pom.xml
@@ -100,7 +100,7 @@
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
|
||||
<!-- Testcontainers -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
@@ -142,6 +142,11 @@
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.coobird</groupId>
|
||||
<artifactId>thumbnailator</artifactId>
|
||||
<version>0.4.21</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<dependencyManagement>
|
||||
|
||||
@@ -3,12 +3,14 @@ package com.dedicatedcode.reitti.controller;
|
||||
import com.dedicatedcode.reitti.dto.TimelineData;
|
||||
import com.dedicatedcode.reitti.dto.TimelineEntry;
|
||||
import com.dedicatedcode.reitti.dto.UserTimelineData;
|
||||
import com.dedicatedcode.reitti.model.geo.SignificantPlace;
|
||||
import com.dedicatedcode.reitti.model.geo.TransportMode;
|
||||
import com.dedicatedcode.reitti.model.geo.Trip;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import com.dedicatedcode.reitti.model.security.UserSettings;
|
||||
import com.dedicatedcode.reitti.repository.*;
|
||||
import com.dedicatedcode.reitti.repository.TripJdbcService;
|
||||
import com.dedicatedcode.reitti.repository.UserJdbcService;
|
||||
import com.dedicatedcode.reitti.repository.UserSettingsJdbcService;
|
||||
import com.dedicatedcode.reitti.repository.UserSharingJdbcService;
|
||||
import com.dedicatedcode.reitti.service.AvatarService;
|
||||
import com.dedicatedcode.reitti.service.TimelineService;
|
||||
import com.dedicatedcode.reitti.service.integration.ReittiIntegrationService;
|
||||
@@ -31,8 +33,6 @@ import java.util.*;
|
||||
@RequestMapping("/timeline")
|
||||
public class TimelineController {
|
||||
|
||||
private final SignificantPlaceJdbcService placeService;
|
||||
private final SignificantPlaceOverrideJdbcService placeOverrideJdbcService;
|
||||
private final UserJdbcService userJdbcService;
|
||||
|
||||
private final AvatarService avatarService;
|
||||
@@ -44,16 +44,14 @@ public class TimelineController {
|
||||
private final TripJdbcService tripJdbcService;
|
||||
|
||||
@Autowired
|
||||
public TimelineController(SignificantPlaceJdbcService placeService, SignificantPlaceOverrideJdbcService placeOverrideJdbcService,
|
||||
UserJdbcService userJdbcService,
|
||||
public TimelineController(UserJdbcService userJdbcService,
|
||||
AvatarService avatarService,
|
||||
ReittiIntegrationService reittiIntegrationService, UserSharingJdbcService userSharingJdbcService,
|
||||
ReittiIntegrationService reittiIntegrationService,
|
||||
UserSharingJdbcService userSharingJdbcService,
|
||||
TimelineService timelineService,
|
||||
UserSettingsJdbcService userSettingsJdbcService,
|
||||
TransportModeService transportModeService,
|
||||
TripJdbcService tripJdbcService) {
|
||||
this.placeService = placeService;
|
||||
this.placeOverrideJdbcService = placeOverrideJdbcService;
|
||||
this.userJdbcService = userJdbcService;
|
||||
this.avatarService = avatarService;
|
||||
this.reittiIntegrationService = reittiIntegrationService;
|
||||
@@ -69,7 +67,61 @@ public class TimelineController {
|
||||
@RequestParam LocalDate endDate,
|
||||
@RequestParam(required = false, defaultValue = "UTC") String timezone,
|
||||
Authentication principal, Model model) {
|
||||
return getTimelineContentRange(startDate, endDate, timezone, principal, model, null);
|
||||
|
||||
List<String> authorities = principal.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList();
|
||||
|
||||
ZoneId userTimezone = ZoneId.of(timezone);
|
||||
LocalDate now = LocalDate.now(userTimezone);
|
||||
|
||||
// Check if any date in the range is not today
|
||||
if (!startDate.isEqual(now) || !endDate.isEqual(now)) {
|
||||
if (!authorities.contains("ROLE_USER") && !authorities.contains("ROLE_ADMIN") && !authorities.contains("ROLE_MAGIC_LINK_FULL_ACCESS")) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
// Find the user by username
|
||||
User user = userJdbcService.findByUsername(principal.getName())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found"));
|
||||
|
||||
UserSettings userSettings = userSettingsJdbcService.getOrCreateDefaultSettings(user.getId());
|
||||
|
||||
// Convert LocalDate to start and end Instant for the date range in user's timezone
|
||||
Instant startOfRange = startDate.atStartOfDay(userTimezone).toInstant();
|
||||
Instant endOfRange = endDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1);
|
||||
|
||||
// Build timeline data for current user and connected users
|
||||
List<UserTimelineData> allUsersData = new ArrayList<>();
|
||||
|
||||
// Add current user data first - for range, we'll use the start date for the timeline service
|
||||
List<TimelineEntry> currentUserEntries;
|
||||
if (authorities.contains("ROLE_USER") || authorities.contains("ROLE_ADMIN") || authorities.contains("ROLE_MAGIC_LINK_FULL_ACCESS")) {
|
||||
currentUserEntries = this.timelineService.buildTimelineEntries(user, userTimezone, startDate, startOfRange, endOfRange);
|
||||
} else {
|
||||
currentUserEntries = Collections.emptyList();
|
||||
}
|
||||
|
||||
String currentUserRawLocationPointsUrl = String.format("/api/v1/raw-location-points/%d?startDate=%s&endDate=%s&timezone=%s", user.getId(), startDate, endDate, timezone);
|
||||
String currentUserProcessedVisitsUrl = String.format("/api/v1/visits/%d?startDate=%s&endDate=%s&timezone=%s", user.getId(), startDate, endDate, timezone);
|
||||
String currentUserAvatarUrl = this.avatarService.getInfo(user.getId()).map(avatarInfo -> String.format("/avatars/%d?ts=%s", user.getId(), avatarInfo.updatedAt())).orElse(String.format("/avatars/%d", user.getId()));
|
||||
String currentUserInitials = this.avatarService.generateInitials(user.getDisplayName());
|
||||
allUsersData.add(new UserTimelineData(user.getId() + "", user.getDisplayName(), currentUserInitials, currentUserAvatarUrl, userSettings.getColor(), currentUserEntries, currentUserRawLocationPointsUrl, currentUserProcessedVisitsUrl));
|
||||
|
||||
if (authorities.contains("ROLE_USER") || authorities.contains("ROLE_ADMIN")) {
|
||||
allUsersData.addAll(this.reittiIntegrationService.getTimelineDataRange(user, startDate, endDate, userTimezone));
|
||||
allUsersData.addAll(handleSharedUserDataRange(user, startDate, endDate, userTimezone));
|
||||
}
|
||||
|
||||
TimelineData timelineData = new TimelineData(allUsersData.stream().filter(Objects::nonNull).toList());
|
||||
|
||||
model.addAttribute("timelineData", timelineData);
|
||||
model.addAttribute("selectedPlaceId", null);
|
||||
model.addAttribute("startDate", startDate);
|
||||
model.addAttribute("endDate", endDate);
|
||||
model.addAttribute("timezone", timezone);
|
||||
model.addAttribute("isRange", true);
|
||||
model.addAttribute("timeDisplayMode", userSettingsJdbcService.getOrCreateDefaultSettings(user.getId()).getTimeDisplayMode());
|
||||
return "fragments/timeline :: timeline-content";
|
||||
}
|
||||
|
||||
@GetMapping("/trips/edit-form/{id}")
|
||||
@@ -115,148 +167,6 @@ public class TimelineController {
|
||||
return "fragments/trip-edit :: view-mode";
|
||||
}
|
||||
|
||||
private String getTimelineContent(String date,
|
||||
String timezone,
|
||||
Authentication principal, Model model,
|
||||
Long selectedPlaceId) {
|
||||
|
||||
List<String> authorities = principal.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList();
|
||||
|
||||
LocalDate selectedDate = LocalDate.parse(date);
|
||||
ZoneId userTimezone = ZoneId.of(timezone);
|
||||
LocalDate now = LocalDate.now(userTimezone);
|
||||
|
||||
if (!selectedDate.isEqual(now)) {
|
||||
if (!authorities.contains("ROLE_USER") && !authorities.contains("ROLE_ADMIN") && !authorities.contains("ROLE_MAGIC_LINK_FULL_ACCESS")) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
// Find the user by username
|
||||
User user = userJdbcService.findByUsername(principal.getName())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found"));
|
||||
|
||||
// Convert LocalDate to start and end Instant for the selected date in user's timezone
|
||||
Instant startOfDay = selectedDate.atStartOfDay(userTimezone).toInstant();
|
||||
Instant endOfDay = selectedDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1);
|
||||
|
||||
// Build timeline data for current user and connected users
|
||||
List<UserTimelineData> allUsersData = new ArrayList<>();
|
||||
|
||||
// Add current user data first
|
||||
List<TimelineEntry> currentUserEntries = this.timelineService.buildTimelineEntries(user, userTimezone, selectedDate, startOfDay, endOfDay);
|
||||
|
||||
String currentUserRawLocationPointsUrl = String.format("/api/v1/raw-location-points/%d?date=%s&timezone=%s", user.getId(), date, timezone);
|
||||
String currentUserProcessedVisitsUrl = String.format("/api/v1/visits/%d?date=%s&timezone=%s", user.getId(), date, timezone);
|
||||
String currentUserAvatarUrl = this.avatarService.getInfo(user.getId()).map(avatarInfo -> String.format("/avatars/%d?ts=%s", user.getId(), avatarInfo.updatedAt())).orElse(String.format("/avatars/%d", user.getId()));
|
||||
String currentUserInitials = this.avatarService.generateInitials(user.getDisplayName());
|
||||
allUsersData.add(new UserTimelineData(user.getId() + "", user.getDisplayName(), currentUserInitials, currentUserAvatarUrl, null, currentUserEntries, currentUserRawLocationPointsUrl, currentUserProcessedVisitsUrl));
|
||||
|
||||
if (authorities.contains("ROLE_USER") || authorities.contains("ROLE_ADMIN")) {
|
||||
allUsersData.addAll(this.reittiIntegrationService.getTimelineData(user, selectedDate, userTimezone));
|
||||
allUsersData.addAll(handleSharedUserData(user, selectedDate, userTimezone, startOfDay, endOfDay));
|
||||
}
|
||||
TimelineData timelineData = new TimelineData(allUsersData.stream().filter(Objects::nonNull).toList());
|
||||
|
||||
model.addAttribute("timelineData", timelineData);
|
||||
model.addAttribute("selectedPlaceId", selectedPlaceId);
|
||||
model.addAttribute("data", selectedDate);
|
||||
model.addAttribute("timezone", timezone);
|
||||
model.addAttribute("timeDisplayMode", userSettingsJdbcService.getOrCreateDefaultSettings(user.getId()).getTimeDisplayMode());
|
||||
return "fragments/timeline :: timeline-content";
|
||||
}
|
||||
|
||||
private String getTimelineContentRange(LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
String timezone,
|
||||
Authentication principal, Model model,
|
||||
Long selectedPlaceId) {
|
||||
|
||||
List<String> authorities = principal.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList();
|
||||
|
||||
LocalDate selectedStartDate = startDate;
|
||||
LocalDate selectedEndDate = endDate;
|
||||
ZoneId userTimezone = ZoneId.of(timezone);
|
||||
LocalDate now = LocalDate.now(userTimezone);
|
||||
|
||||
// Check if any date in the range is not today
|
||||
if (!selectedStartDate.isEqual(now) || !selectedEndDate.isEqual(now)) {
|
||||
if (!authorities.contains("ROLE_USER") && !authorities.contains("ROLE_ADMIN") && !authorities.contains("ROLE_MAGIC_LINK_FULL_ACCESS")) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
// Find the user by username
|
||||
User user = userJdbcService.findByUsername(principal.getName())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found"));
|
||||
|
||||
UserSettings userSettings = userSettingsJdbcService.getOrCreateDefaultSettings(user.getId());
|
||||
|
||||
// Convert LocalDate to start and end Instant for the date range in user's timezone
|
||||
Instant startOfRange = selectedStartDate.atStartOfDay(userTimezone).toInstant();
|
||||
Instant endOfRange = selectedEndDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1);
|
||||
|
||||
// Build timeline data for current user and connected users
|
||||
List<UserTimelineData> allUsersData = new ArrayList<>();
|
||||
|
||||
// Add current user data first - for range, we'll use the start date for the timeline service
|
||||
List<TimelineEntry> currentUserEntries;
|
||||
if (authorities.contains("ROLE_USER") || authorities.contains("ROLE_ADMIN") || authorities.contains("ROLE_MAGIC_LINK_FULL_ACCESS")) {
|
||||
currentUserEntries = this.timelineService.buildTimelineEntries(user, userTimezone, selectedStartDate, startOfRange, endOfRange);
|
||||
} else {
|
||||
currentUserEntries = Collections.emptyList();
|
||||
}
|
||||
|
||||
String currentUserRawLocationPointsUrl = String.format("/api/v1/raw-location-points/%d?startDate=%s&endDate=%s&timezone=%s", user.getId(), selectedStartDate, selectedEndDate, timezone);
|
||||
String currentUserProcessedVisitsUrl = String.format("/api/v1/visits/%d?startDate=%s&endDate=%s&timezone=%s", user.getId(), selectedStartDate, selectedEndDate, timezone);
|
||||
String currentUserAvatarUrl = this.avatarService.getInfo(user.getId()).map(avatarInfo -> String.format("/avatars/%d?ts=%s", user.getId(), avatarInfo.updatedAt())).orElse(String.format("/avatars/%d", user.getId()));
|
||||
String currentUserInitials = this.avatarService.generateInitials(user.getDisplayName());
|
||||
allUsersData.add(new UserTimelineData(user.getId() + "", user.getDisplayName(), currentUserInitials, currentUserAvatarUrl, userSettings.getColor(), currentUserEntries, currentUserRawLocationPointsUrl, currentUserProcessedVisitsUrl));
|
||||
|
||||
if (authorities.contains("ROLE_USER") || authorities.contains("ROLE_ADMIN")) {
|
||||
allUsersData.addAll(this.reittiIntegrationService.getTimelineDataRange(user, selectedStartDate, selectedEndDate, userTimezone));
|
||||
allUsersData.addAll(handleSharedUserDataRange(user, selectedStartDate, selectedEndDate, userTimezone));
|
||||
}
|
||||
|
||||
TimelineData timelineData = new TimelineData(allUsersData.stream().filter(Objects::nonNull).toList());
|
||||
|
||||
model.addAttribute("timelineData", timelineData);
|
||||
model.addAttribute("selectedPlaceId", selectedPlaceId);
|
||||
model.addAttribute("startDate", selectedStartDate);
|
||||
model.addAttribute("endDate", selectedEndDate);
|
||||
model.addAttribute("timezone", timezone);
|
||||
model.addAttribute("isRange", true);
|
||||
model.addAttribute("timeDisplayMode", userSettingsJdbcService.getOrCreateDefaultSettings(user.getId()).getTimeDisplayMode());
|
||||
return "fragments/timeline :: timeline-content";
|
||||
}
|
||||
|
||||
private List<UserTimelineData> handleSharedUserData(User user, LocalDate selectedDate, ZoneId userTimezone, Instant startOfDay, Instant endOfDay) {
|
||||
return this.userSharingJdbcService.findBySharedWithUser(user.getId()).stream()
|
||||
.map(u -> {
|
||||
Optional<User> sharedWithUserOpt = this.userJdbcService.findById(u.getSharingUserId());
|
||||
return sharedWithUserOpt.map(sharedWithUser -> {
|
||||
List<TimelineEntry> userTimelineEntries = this.timelineService.buildTimelineEntries(sharedWithUser, userTimezone, selectedDate, startOfDay, endOfDay);
|
||||
String currentUserRawLocationPointsUrl = String.format("/api/v1/raw-location-points/%d?date=%s&timezone=%s", sharedWithUser.getId(), selectedDate, userTimezone.getId());
|
||||
String currentUserProcessedVisitsUrl = String.format("/api/v1/visits/%d?date=%s&timezone=%s", sharedWithUser.getId(), selectedDate, userTimezone.getId());
|
||||
String currentUserAvatarUrl = this.avatarService.getInfo(sharedWithUser.getId()).map(avatarInfo -> String.format("/avatars/%d?ts=%s", sharedWithUser.getId(), avatarInfo.updatedAt())).orElse(String.format("/avatars/%d", sharedWithUser.getId()));
|
||||
String currentUserInitials = this.avatarService.generateInitials(sharedWithUser.getDisplayName());
|
||||
|
||||
return new UserTimelineData(sharedWithUser.getId() + "",
|
||||
sharedWithUser.getDisplayName(),
|
||||
currentUserInitials,
|
||||
currentUserAvatarUrl,
|
||||
u.getColor(),
|
||||
userTimelineEntries,
|
||||
currentUserRawLocationPointsUrl,
|
||||
currentUserProcessedVisitsUrl);
|
||||
});
|
||||
})
|
||||
.filter(Optional::isPresent)
|
||||
.map(Optional::get)
|
||||
.sorted(Comparator.comparing(UserTimelineData::displayName))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<UserTimelineData> handleSharedUserDataRange(User user, LocalDate startDate, LocalDate endDate, ZoneId userTimezone) {
|
||||
return this.userSharingJdbcService.findBySharedWithUser(user.getId()).stream()
|
||||
.map(u -> {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package com.dedicatedcode.reitti.controller.api;
|
||||
package com.dedicatedcode.reitti.controller.api.ingestion.overland;
|
||||
|
||||
import com.dedicatedcode.reitti.dto.LocationPoint;
|
||||
import com.dedicatedcode.reitti.dto.OverlandLocationRequest;
|
||||
import com.dedicatedcode.reitti.dto.OwntracksLocationRequest;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import com.dedicatedcode.reitti.repository.UserJdbcService;
|
||||
import com.dedicatedcode.reitti.service.ImportProcessor;
|
||||
import com.dedicatedcode.reitti.service.LocationBatchingService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -21,68 +19,25 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/ingest")
|
||||
public class IngestApiController {
|
||||
public class OverlandIngestionApiController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(IngestApiController.class);
|
||||
private static final Map<String, ? extends Serializable> SUCCESS = Map.of(
|
||||
"success", true,
|
||||
"message", "Successfully queued Owntracks location point for processing"
|
||||
);
|
||||
private static final Logger logger = LoggerFactory.getLogger(OverlandIngestionApiController.class);
|
||||
|
||||
private final ImportProcessor batchProcessor;
|
||||
private final UserJdbcService userJdbcService;
|
||||
private final LocationBatchingService locationBatchingService;
|
||||
|
||||
@Autowired
|
||||
public IngestApiController(ImportProcessor batchProcessor, UserJdbcService userJdbcService, LocationBatchingService locationBatchingService) {
|
||||
public OverlandIngestionApiController(UserJdbcService userJdbcService,
|
||||
LocationBatchingService locationBatchingService) {
|
||||
this.userJdbcService = userJdbcService;
|
||||
this.batchProcessor = batchProcessor;
|
||||
this.locationBatchingService = locationBatchingService;
|
||||
}
|
||||
|
||||
@PostMapping("/owntracks")
|
||||
public ResponseEntity<?> receiveOwntracksData(@RequestBody OwntracksLocationRequest request) {
|
||||
if (!request.isLocationUpdate()) {
|
||||
logger.debug("Ignoring non-location Owntracks message of type: {}", request.getType());
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"message", "Non-location update ignored"
|
||||
));
|
||||
}
|
||||
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
|
||||
User user = this.userJdbcService.findByUsername(userDetails.getUsername()).orElseThrow(() -> new UsernameNotFoundException(userDetails.getUsername()));
|
||||
|
||||
try {
|
||||
// Convert an Owntracks format to our LocationPoint format
|
||||
LocationPoint locationPoint = request.toLocationPoint();
|
||||
|
||||
if (locationPoint.getTimestamp() == null) {
|
||||
logger.warn("Ignoring location point [{}] because timestamp is null", locationPoint);
|
||||
return ResponseEntity.ok(Map.of());
|
||||
}
|
||||
|
||||
this.locationBatchingService.addLocationPoint(user, locationPoint);
|
||||
logger.debug("Successfully received and queued Owntracks location point for user {}",
|
||||
user.getUsername());
|
||||
|
||||
return ResponseEntity.accepted().body(SUCCESS);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Error processing Owntracks data", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Error processing Owntracks data: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/overland")
|
||||
public ResponseEntity<?> receiveOverlandData(@RequestBody OverlandLocationRequest request) {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.dedicatedcode.reitti.controller.api.ingestion.owntracks;
|
||||
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class OwntracksFriendResponse {
|
||||
@JsonProperty("_type")
|
||||
private final String type;
|
||||
@JsonProperty("tid")
|
||||
private final String tid;
|
||||
@JsonProperty("name")
|
||||
private final String name;
|
||||
@JsonProperty("lat")
|
||||
private final Double lat;
|
||||
@JsonProperty("lon")
|
||||
private final Double lon;
|
||||
@JsonProperty("tst")
|
||||
private final Long tst;
|
||||
@JsonProperty("face")
|
||||
private final String face;
|
||||
|
||||
// Card constructor with avatar
|
||||
public OwntracksFriendResponse(String tid, String name, byte[] avatarData, String mimeType) {
|
||||
this.type = "card";
|
||||
this.tid = tid;
|
||||
this.name = name;
|
||||
this.lat = null;
|
||||
this.lon = null;
|
||||
this.tst = null;
|
||||
this.face = avatarData != null ? createFaceData(avatarData, mimeType) : null;
|
||||
}
|
||||
|
||||
// Location constructor
|
||||
public OwntracksFriendResponse(String tid, String name, double lat, double lon, long tst) {
|
||||
this.type = "location";
|
||||
this.tid = tid;
|
||||
this.name = name;
|
||||
this.lat = lat;
|
||||
this.lon = lon;
|
||||
this.tst = tst;
|
||||
this.face = null;
|
||||
}
|
||||
|
||||
private String createFaceData(byte[] avatarData, String mimeType) {
|
||||
if (avatarData == null || mimeType == null) {
|
||||
return null;
|
||||
}
|
||||
return Base64.getEncoder().encodeToString(avatarData);
|
||||
}
|
||||
|
||||
// Getters
|
||||
public String getType() { return type; }
|
||||
public String getTid() { return tid; }
|
||||
public String getName() { return name; }
|
||||
public Double getLat() { return lat; }
|
||||
public Double getLon() { return lon; }
|
||||
public Long getTst() { return tst; }
|
||||
public String getFace() { return face; }
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.dedicatedcode.reitti.controller.api.ingestion.owntracks;
|
||||
|
||||
import com.dedicatedcode.reitti.dto.LocationPoint;
|
||||
import com.dedicatedcode.reitti.dto.OwntracksLocationRequest;
|
||||
import com.dedicatedcode.reitti.dto.ReittiRemoteInfo;
|
||||
import com.dedicatedcode.reitti.model.geo.RawLocationPoint;
|
||||
import com.dedicatedcode.reitti.model.integration.ReittiIntegration;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import com.dedicatedcode.reitti.model.security.UserSharing;
|
||||
import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService;
|
||||
import com.dedicatedcode.reitti.repository.UserJdbcService;
|
||||
import com.dedicatedcode.reitti.repository.UserSharingJdbcService;
|
||||
import com.dedicatedcode.reitti.service.AvatarService;
|
||||
import com.dedicatedcode.reitti.service.LocationBatchingService;
|
||||
import com.dedicatedcode.reitti.service.RequestFailedException;
|
||||
import com.dedicatedcode.reitti.service.RequestTemporaryFailedException;
|
||||
import com.dedicatedcode.reitti.service.integration.ReittiIntegrationService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/ingest")
|
||||
public class OwntracksIngestionApiController {
|
||||
private static final Logger logger = LoggerFactory.getLogger(OwntracksIngestionApiController.class);
|
||||
private final UserJdbcService userJdbcService;
|
||||
private final LocationBatchingService locationBatchingService;
|
||||
private final RawLocationPointJdbcService rawLocationPointJdbcService;
|
||||
private final AvatarService avatarService;
|
||||
private final UserSharingJdbcService userSharingJdbcService;
|
||||
private final ReittiIntegrationService reittiIntegrationService;
|
||||
|
||||
public OwntracksIngestionApiController(UserJdbcService userJdbcService,
|
||||
LocationBatchingService locationBatchingService,
|
||||
RawLocationPointJdbcService rawLocationPointJdbcService,
|
||||
AvatarService avatarService,
|
||||
UserSharingJdbcService userSharingJdbcService, ReittiIntegrationService reittiIntegrationService) {
|
||||
this.userJdbcService = userJdbcService;
|
||||
this.locationBatchingService = locationBatchingService;
|
||||
this.rawLocationPointJdbcService = rawLocationPointJdbcService;
|
||||
this.avatarService = avatarService;
|
||||
this.userSharingJdbcService = userSharingJdbcService;
|
||||
this.reittiIntegrationService = reittiIntegrationService;
|
||||
}
|
||||
|
||||
@PostMapping("/owntracks")
|
||||
public ResponseEntity<?> receiveOwntracksData(@RequestBody OwntracksLocationRequest request) {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
|
||||
User user = this.userJdbcService.findByUsername(userDetails.getUsername()).orElseThrow(() -> new UsernameNotFoundException(userDetails.getUsername()));
|
||||
|
||||
try {
|
||||
if (!request.isLocationUpdate()) {
|
||||
logger.debug("Ignoring non-location Owntracks message of type: {}", request.getType());
|
||||
// Return empty array for non-location messages
|
||||
return ResponseEntity.ok(new ArrayList<OwntracksFriendResponse>());
|
||||
}
|
||||
|
||||
// Convert an Owntracks format to our LocationPoint format
|
||||
LocationPoint locationPoint = request.toLocationPoint();
|
||||
|
||||
if (locationPoint.getTimestamp() == null) {
|
||||
logger.warn("Ignoring location point [{}] because timestamp is null", locationPoint);
|
||||
// Return empty array when timestamp is null
|
||||
return ResponseEntity.ok(new ArrayList<OwntracksFriendResponse>());
|
||||
}
|
||||
|
||||
this.locationBatchingService.addLocationPoint(user, locationPoint);
|
||||
logger.debug("Successfully received and queued Owntracks location point for user {}",
|
||||
user.getUsername());
|
||||
|
||||
// Return friends data
|
||||
List<OwntracksFriendResponse> friendsData = buildFriendsData(user);
|
||||
return ResponseEntity.ok(friendsData);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Error processing Owntracks data", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Error processing Owntracks data: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private List<OwntracksFriendResponse> buildFriendsData(User user) {
|
||||
List<OwntracksFriendResponse> friendsData = new ArrayList<>();
|
||||
|
||||
// Local shared users
|
||||
List<UserSharing> sharedUsers = userSharingJdbcService.findBySharedWithUser(user.getId());
|
||||
for (UserSharing sharing : sharedUsers) {
|
||||
Optional<User> sharedUserOpt = userJdbcService.findById(sharing.getSharingUserId());
|
||||
sharedUserOpt.ifPresent(sharedUser -> {
|
||||
String tid = generateTid(sharedUser.getUsername());
|
||||
|
||||
// Get processed avatar thumbnail (192x192)
|
||||
Optional<byte[]> avatarThumbnail = avatarService.getAvatarThumbnail(sharedUser.getId(), 192, 192);
|
||||
|
||||
// Add card with processed avatar
|
||||
friendsData.add(new OwntracksFriendResponse(
|
||||
tid,
|
||||
sharedUser.getDisplayName(),
|
||||
avatarThumbnail.orElse(null),
|
||||
"image/jpeg" // Default to JPEG for OwnTracks
|
||||
));
|
||||
|
||||
// Add location if available
|
||||
Optional<RawLocationPoint> latestLocation = rawLocationPointJdbcService.findLatest(sharedUser);
|
||||
latestLocation.ifPresent(location -> {
|
||||
friendsData.add(new OwntracksFriendResponse(
|
||||
tid,
|
||||
sharedUser.getDisplayName(),
|
||||
location.getLatitude(),
|
||||
location.getLongitude(),
|
||||
location.getTimestamp().getEpochSecond()
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Remote integrations
|
||||
List<ReittiIntegration> integrations = reittiIntegrationService.getActiveIntegrationsForUser(user);
|
||||
for (ReittiIntegration integration : integrations) {
|
||||
try {
|
||||
ReittiRemoteInfo info = reittiIntegrationService.getInfo(integration);
|
||||
String tid = generateTid(info.userInfo().username());
|
||||
friendsData.add(new OwntracksFriendResponse(tid, info.userInfo().displayName(), null, null));
|
||||
} catch (RequestFailedException | RequestTemporaryFailedException e) {
|
||||
logger.warn("Couldn't fetch info for integration {}", integration.getId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
return friendsData;
|
||||
}
|
||||
|
||||
private String generateTid(String username) {
|
||||
return username != null && username.length() >= 2 ?
|
||||
username.substring(0, 2).toUpperCase() : "UN";
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,32 @@
|
||||
package com.dedicatedcode.reitti.service;
|
||||
|
||||
import net.coobird.thumbnailator.Thumbnails;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.Serializable;
|
||||
import java.sql.Timestamp;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class AvatarService {
|
||||
|
||||
private final static Logger log = LoggerFactory.getLogger(AvatarService.class);
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public AvatarService(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
@Cacheable(value = "avatarData", key = "{#userId}")
|
||||
public Optional<AvatarData> getAvatarByUserId(Long userId) {
|
||||
try {
|
||||
Map<String, Object> result = jdbcTemplate.queryForMap(
|
||||
@@ -51,6 +61,7 @@ public class AvatarService {
|
||||
}
|
||||
}
|
||||
|
||||
@CacheEvict(value = {"avatarThumbnails", "avatarData"}, key = "{#userId}")
|
||||
public void updateAvatar(Long userId, String contentType, byte[] imageData) {
|
||||
|
||||
jdbcTemplate.update("DELETE FROM user_avatars WHERE user_id = ?", userId);
|
||||
@@ -63,6 +74,7 @@ public class AvatarService {
|
||||
);
|
||||
}
|
||||
|
||||
@CacheEvict(value = {"avatarThumbnails", "avatarData"}, key = "{#userId}")
|
||||
public void deleteAvatar(Long userId) {
|
||||
this.jdbcTemplate.update("DELETE FROM user_avatars WHERE user_id = ?", userId);
|
||||
}
|
||||
@@ -74,7 +86,6 @@ public class AvatarService {
|
||||
|
||||
String trimmed = displayName.trim();
|
||||
|
||||
|
||||
if (trimmed.contains(" ")) {
|
||||
StringBuilder initials = new StringBuilder();
|
||||
String[] words = trimmed.split("\\s+");
|
||||
@@ -94,7 +105,25 @@ public class AvatarService {
|
||||
}
|
||||
}
|
||||
}
|
||||
public record AvatarData(String mimeType, byte[] imageData, long updatedAt) {}
|
||||
|
||||
public record AvatarInfo(long updatedAt) {}
|
||||
@Cacheable(value = "avatarThumbnails", key = "{#userId, #width, #height}")
|
||||
public Optional<byte[]> getAvatarThumbnail(Long userId, int width, int height) {
|
||||
return getAvatarByUserId(userId).map(avatarData -> {
|
||||
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
||||
Thumbnails.of(new ByteArrayInputStream(avatarData.imageData()))
|
||||
.size(width, height)
|
||||
.outputFormat(avatarData.mimeType().contains("png") ? "png" : "jpg")
|
||||
.outputQuality(0.75)
|
||||
.toOutputStream(output);
|
||||
return output.toByteArray();
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to generate thumbnail for avatar of user [{}]", userId, e);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public record AvatarData(String mimeType, byte[] imageData, long updatedAt) implements Serializable {}
|
||||
|
||||
public record AvatarInfo(long updatedAt) implements Serializable {}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ spring.rabbitmq.listener.simple.prefetch=10
|
||||
# to RabbitMQ, which then triggers the dead-letter-exchange (DLX) policy on the queue.
|
||||
spring.rabbitmq.listener.simple.default-requeue-rejected=false
|
||||
|
||||
spring.cache.cache-names=processed-visits,significant-places,users,magic-links,configurations,transport-mode-configs
|
||||
spring.cache.cache-names=processed-visits,significant-places,users,magic-links,configurations,transport-mode-configs,avatarThumbnails,avatarData
|
||||
spring.cache.redis.time-to-live=1d
|
||||
|
||||
# Upload configuration
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package com.dedicatedcode.reitti.controller.api;
|
||||
package com.dedicatedcode.reitti.controller.api.ingestion.overland;
|
||||
|
||||
import com.dedicatedcode.reitti.IntegrationTest;
|
||||
import com.dedicatedcode.reitti.TestingService;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import com.dedicatedcode.reitti.repository.UserJdbcService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -19,7 +17,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
|
||||
@IntegrationTest
|
||||
@AutoConfigureWebMvc
|
||||
class IngestApiControllerTest {
|
||||
class OverlandIngestionApiControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
@@ -33,48 +31,6 @@ class IngestApiControllerTest {
|
||||
testUser = testingService.randomUser();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOwntracksIngestWithoutElevation() throws Exception {
|
||||
String owntracksPayload = """
|
||||
{
|
||||
"_type": "location",
|
||||
"lat": 53.863149,
|
||||
"lon": 10.700927,
|
||||
"tst": 1699545600,
|
||||
"acc": 10.5
|
||||
}
|
||||
""";
|
||||
|
||||
mockMvc.perform(post("/api/v1/ingest/owntracks")
|
||||
.with(user(testUser))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(owntracksPayload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.message").value("Successfully queued Owntracks location point for processing"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOwntracksIngestWithElevation() throws Exception {
|
||||
String owntracksPayload = """
|
||||
{
|
||||
"_type": "location",
|
||||
"lat": 53.863149,
|
||||
"lon": 10.700927,
|
||||
"tst": 1699545600,
|
||||
"acc": 10.5,
|
||||
"alt": 42.5
|
||||
}
|
||||
""";
|
||||
|
||||
mockMvc.perform(post("/api/v1/ingest/owntracks")
|
||||
.with(user(testUser))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(owntracksPayload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.message").value("Successfully queued Owntracks location point for processing"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOverlandIngestWithoutElevation() throws Exception {
|
||||
@@ -134,26 +90,6 @@ class IngestApiControllerTest {
|
||||
.andExpect(jsonPath("$.result").value("ok"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOwntracksIngestIgnoresNonLocationMessages() throws Exception {
|
||||
String owntracksPayload = """
|
||||
{
|
||||
"_type": "waypoint",
|
||||
"lat": 53.863149,
|
||||
"lon": 10.700927,
|
||||
"tst": 1699545600
|
||||
}
|
||||
""";
|
||||
|
||||
mockMvc.perform(post("/api/v1/ingest/owntracks")
|
||||
.with(user(testUser))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(owntracksPayload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.message").value("Non-location update ignored"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOverlandIngestWithEmptyLocations() throws Exception {
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package com.dedicatedcode.reitti.controller.api.ingestion.owntracks;
|
||||
|
||||
import com.dedicatedcode.reitti.IntegrationTest;
|
||||
import com.dedicatedcode.reitti.TestingService;
|
||||
import com.dedicatedcode.reitti.dto.LocationPoint;
|
||||
import com.dedicatedcode.reitti.model.geo.GeoPoint;
|
||||
import com.dedicatedcode.reitti.model.geo.RawLocationPoint;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import com.dedicatedcode.reitti.model.security.UserSharing;
|
||||
import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService;
|
||||
import com.dedicatedcode.reitti.repository.UserSharingJdbcService;
|
||||
import com.dedicatedcode.reitti.service.AvatarService;
|
||||
import com.dedicatedcode.reitti.service.LocationBatchingService;
|
||||
import com.dedicatedcode.reitti.service.integration.ReittiIntegrationService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureWebMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@IntegrationTest
|
||||
@AutoConfigureWebMvc
|
||||
class OwntracksIngestionApiControllerIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private TestingService testingService;
|
||||
|
||||
@Autowired
|
||||
private UserSharingJdbcService userSharingJdbcService;
|
||||
|
||||
@Autowired
|
||||
private RawLocationPointJdbcService rawLocationPointJdbcService;
|
||||
|
||||
@Autowired
|
||||
private AvatarService avatarService;
|
||||
|
||||
@MockitoBean
|
||||
private LocationBatchingService locationBatchingService;
|
||||
|
||||
@MockitoBean
|
||||
private ReittiIntegrationService reittiIntegrationService;
|
||||
|
||||
private User testUser;
|
||||
private User sharedUser;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
testUser = testingService.randomUser();
|
||||
sharedUser = testingService.randomUser();
|
||||
|
||||
// Create sharing relationship - sharedUser shares with testUser
|
||||
userSharingJdbcService.create(sharedUser, Set.of(new UserSharing(null, null, testUser.getId(), null, "#FF0000", null)));
|
||||
|
||||
rawLocationPointJdbcService.create(sharedUser, new RawLocationPoint(Instant.now(), new GeoPoint(60.1699, 24.9384), 10.0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOwntracksIngestWithLocation() throws Exception {
|
||||
String owntracksPayload = """
|
||||
{
|
||||
"_type": "location",
|
||||
"lat": 53.863149,
|
||||
"lon": 10.700927,
|
||||
"tst": 1699545600,
|
||||
"acc": 10.5,
|
||||
"alt": 42.5
|
||||
}
|
||||
""";
|
||||
|
||||
mockMvc.perform(post("/api/v1/ingest/owntracks")
|
||||
.with(user(testUser))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(owntracksPayload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOwntracksIngestIgnoresNonLocationMessages() throws Exception {
|
||||
String owntracksPayload = """
|
||||
{
|
||||
"_type": "waypoint",
|
||||
"lat": 53.863149,
|
||||
"lon": 10.700927,
|
||||
"tst": 1699545600
|
||||
}
|
||||
""";
|
||||
|
||||
mockMvc.perform(post("/api/v1/ingest/owntracks")
|
||||
.with(user(testUser))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(owntracksPayload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray())
|
||||
.andExpect(jsonPath("$").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOwntracksIngestReturnsFriendsData() throws Exception {
|
||||
String owntracksPayload = """
|
||||
{
|
||||
"_type": "location",
|
||||
"lat": 53.863149,
|
||||
"lon": 10.700927,
|
||||
"tst": 1699545600,
|
||||
"acc": 10.5
|
||||
}
|
||||
""";
|
||||
|
||||
// Mock the location batching service to avoid actual processing
|
||||
doNothing().when(locationBatchingService).addLocationPoint(any(User.class), any(LocationPoint.class));
|
||||
|
||||
mockMvc.perform(post("/api/v1/ingest/owntracks")
|
||||
.with(user(testUser))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(owntracksPayload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray())
|
||||
.andExpect(jsonPath("$[0]._type").value("card"))
|
||||
.andExpect(jsonPath("$[0].tid").exists())
|
||||
.andExpect(jsonPath("$[0].name").exists())
|
||||
.andExpect(jsonPath("$[1]._type").value("location"))
|
||||
.andExpect(jsonPath("$[1].tid").exists())
|
||||
.andExpect(jsonPath("$[1].name").exists())
|
||||
.andExpect(jsonPath("$[1].lat").exists())
|
||||
.andExpect(jsonPath("$[1].lon").exists())
|
||||
.andExpect(jsonPath("$[1].tst").exists());
|
||||
|
||||
// Verify location batching was called
|
||||
verify(locationBatchingService, times(1)).addLocationPoint(any(User.class), any(LocationPoint.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOwntracksIngestWithSharedUserLocation() throws Exception {
|
||||
String owntracksPayload = """
|
||||
{
|
||||
"_type": "location",
|
||||
"lat": 53.863149,
|
||||
"lon": 10.700927,
|
||||
"tst": 1699545600,
|
||||
"acc": 10.5
|
||||
}
|
||||
""";
|
||||
|
||||
// Mock the location batching service
|
||||
doNothing().when(locationBatchingService).addLocationPoint(any(User.class), any(LocationPoint.class));
|
||||
|
||||
mockMvc.perform(post("/api/v1/ingest/owntracks")
|
||||
.with(user(testUser))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(owntracksPayload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray())
|
||||
.andExpect(jsonPath("$[0]._type").value("card"))
|
||||
.andExpect(jsonPath("$[0].name").value(sharedUser.getDisplayName()))
|
||||
.andExpect(jsonPath("$[1]._type").value("location"))
|
||||
.andExpect(jsonPath("$[1].name").value(sharedUser.getDisplayName()))
|
||||
.andExpect(jsonPath("$[1].lat").value(60.1699))
|
||||
.andExpect(jsonPath("$[1].lon").value(24.9384));
|
||||
|
||||
// Verify location batching was called
|
||||
verify(locationBatchingService, times(1)).addLocationPoint(any(User.class), any(LocationPoint.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOwntracksIngestWithInvalidPayload() throws Exception {
|
||||
String invalidPayload = """
|
||||
{
|
||||
"invalid": "payload"
|
||||
}
|
||||
""";
|
||||
|
||||
mockMvc.perform(post("/api/v1/ingest/owntracks")
|
||||
.with(user(testUser))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(invalidPayload))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package com.dedicatedcode.reitti.service;
|
||||
|
||||
import com.dedicatedcode.reitti.IntegrationTest;
|
||||
import com.dedicatedcode.reitti.TestingService;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.cache.Cache;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@IntegrationTest
|
||||
class AvatarServiceIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private AvatarService avatarService;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Autowired
|
||||
private CacheManager cacheManager;
|
||||
|
||||
@Autowired
|
||||
private TestingService testingService;
|
||||
|
||||
@Autowired
|
||||
private ResourceLoader resourceLoader;
|
||||
|
||||
private User testUser;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// Create a real user using TestingService
|
||||
testUser = testingService.randomUser();
|
||||
|
||||
// Clear caches before each test
|
||||
clearCaches();
|
||||
}
|
||||
|
||||
private void clearCaches() {
|
||||
Cache avatarDataCache = cacheManager.getCache("avatarData");
|
||||
Cache avatarThumbnailsCache = cacheManager.getCache("avatarThumbnails");
|
||||
|
||||
if (avatarDataCache != null) {
|
||||
avatarDataCache.clear();
|
||||
}
|
||||
if (avatarThumbnailsCache != null) {
|
||||
avatarThumbnailsCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetAvatarByUserId_WhenNoAvatarExists() {
|
||||
Optional<AvatarService.AvatarData> result = avatarService.getAvatarByUserId(testUser.getId());
|
||||
assertTrue(result.isEmpty(), "Should return empty when no avatar exists");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetAvatarByUserId_WhenAvatarExists() throws IOException {
|
||||
// Load test image from resources
|
||||
Resource imageResource = resourceLoader.getResource("classpath:data/images/pexels-waldemar-nowak-305041-910625.jpg");
|
||||
byte[] testImageData = loadImageData(imageResource);
|
||||
String testContentType = "image/jpeg";
|
||||
|
||||
// Insert test avatar data
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO user_avatars (user_id, mime_type, binary_data) VALUES (?, ?, ?)",
|
||||
testUser.getId(), testContentType, testImageData);
|
||||
|
||||
Optional<AvatarService.AvatarData> result = avatarService.getAvatarByUserId(testUser.getId());
|
||||
|
||||
assertTrue(result.isPresent(), "Should return avatar data when it exists");
|
||||
assertEquals(testContentType, result.get().mimeType());
|
||||
assertArrayEquals(testImageData, result.get().imageData());
|
||||
assertTrue(result.get().updatedAt() > 0, "Updated timestamp should be set");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetInfo_WhenNoAvatarExists() {
|
||||
Optional<AvatarService.AvatarInfo> result = avatarService.getInfo(testUser.getId());
|
||||
assertTrue(result.isEmpty(), "Should return empty when no avatar exists");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetInfo_WhenAvatarExists() throws IOException {
|
||||
// Load test image from resources
|
||||
Resource imageResource = resourceLoader.getResource("classpath:data/images/pexels-waldemar-nowak-305041-910625.jpg");
|
||||
byte[] testImageData = loadImageData(imageResource);
|
||||
|
||||
// Insert test avatar data
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO user_avatars (user_id, mime_type, binary_data) VALUES (?, ?, ?)",
|
||||
testUser.getId(), "image/jpeg", testImageData);
|
||||
|
||||
Optional<AvatarService.AvatarInfo> result = avatarService.getInfo(testUser.getId());
|
||||
|
||||
assertTrue(result.isPresent(), "Should return avatar info when it exists");
|
||||
assertTrue(result.get().updatedAt() > 0, "Updated timestamp should be set");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateAvatar() throws IOException {
|
||||
// Load test image from resources
|
||||
Resource imageResource = resourceLoader.getResource("classpath:data/images/pexels-waldemar-nowak-305041-910625.jpg");
|
||||
byte[] imageData = loadImageData(imageResource);
|
||||
String contentType = "image/jpeg";
|
||||
|
||||
// Update avatar
|
||||
avatarService.updateAvatar(testUser.getId(), contentType, imageData);
|
||||
|
||||
// Verify it was stored
|
||||
Optional<AvatarService.AvatarData> result = avatarService.getAvatarByUserId(testUser.getId());
|
||||
assertTrue(result.isPresent());
|
||||
assertEquals(contentType, result.get().mimeType());
|
||||
assertArrayEquals(imageData, result.get().imageData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteAvatar() throws IOException {
|
||||
// Load test image from resources
|
||||
Resource imageResource = resourceLoader.getResource("classpath:data/images/pexels-waldemar-nowak-305041-910625.jpg");
|
||||
byte[] imageData = loadImageData(imageResource);
|
||||
|
||||
// First insert an avatar
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO user_avatars (user_id, mime_type, binary_data) VALUES (?, ?, ?)",
|
||||
testUser.getId(), "image/jpeg", imageData);
|
||||
|
||||
// Verify it exists
|
||||
Optional<AvatarService.AvatarData> beforeDelete = avatarService.getAvatarByUserId(testUser.getId());
|
||||
assertTrue(beforeDelete.isPresent());
|
||||
|
||||
// Delete the avatar
|
||||
avatarService.deleteAvatar(testUser.getId());
|
||||
|
||||
// Verify it's gone
|
||||
Optional<AvatarService.AvatarData> afterDelete = avatarService.getAvatarByUserId(testUser.getId());
|
||||
assertTrue(afterDelete.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateInitials() {
|
||||
assertEquals("", avatarService.generateInitials(null));
|
||||
assertEquals("", avatarService.generateInitials(""));
|
||||
assertEquals("", avatarService.generateInitials(" "));
|
||||
|
||||
assertEquals("JD", avatarService.generateInitials("John Doe"));
|
||||
assertEquals("JS", avatarService.generateInitials("John Smith"));
|
||||
assertEquals("JO", avatarService.generateInitials("John"));
|
||||
assertEquals("JO", avatarService.generateInitials("John"));
|
||||
assertEquals("A", avatarService.generateInitials("A"));
|
||||
assertEquals("AB", avatarService.generateInitials("AB"));
|
||||
assertEquals("AE", avatarService.generateInitials("ABCD EFGH")); // Only first 2 words
|
||||
assertEquals("JD", avatarService.generateInitials(" John Doe ")); // Trimmed
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetAvatarThumbnail() throws IOException {
|
||||
// Load test image from resources
|
||||
Resource imageResource = resourceLoader.getResource("classpath:data/images/pexels-waldemar-nowak-305041-910625.jpg");
|
||||
byte[] originalImage = loadImageData(imageResource);
|
||||
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO user_avatars (user_id, mime_type, binary_data) VALUES (?, ?, ?)",
|
||||
testUser.getId(), "image/jpeg", originalImage);
|
||||
|
||||
// Get thumbnail
|
||||
Optional<byte[]> thumbnail = avatarService.getAvatarThumbnail(testUser.getId(), 100, 100);
|
||||
|
||||
assertTrue(thumbnail.isPresent());
|
||||
assertTrue(thumbnail.get().length < originalImage.length, "Thumbnail should be smaller than original");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAvatarDataCaching() throws IOException {
|
||||
// Load test image from resources
|
||||
Resource imageResource = resourceLoader.getResource("classpath:data/images/pexels-waldemar-nowak-305041-910625.jpg");
|
||||
byte[] imageData = loadImageData(imageResource);
|
||||
|
||||
// Insert test data
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO user_avatars (user_id, mime_type, binary_data) VALUES (?, ?, ?)",
|
||||
testUser.getId(), "image/jpeg", imageData);
|
||||
|
||||
// First call - should hit database
|
||||
Optional<AvatarService.AvatarData> firstCall = avatarService.getAvatarByUserId(testUser.getId());
|
||||
assertTrue(firstCall.isPresent());
|
||||
|
||||
// Second call - should hit cache
|
||||
Optional<AvatarService.AvatarData> secondCall = avatarService.getAvatarByUserId(testUser.getId());
|
||||
assertTrue(secondCall.isPresent());
|
||||
|
||||
// Verify both calls return the same data
|
||||
assertEquals(firstCall.get().mimeType(), secondCall.get().mimeType());
|
||||
assertArrayEquals(firstCall.get().imageData(), secondCall.get().imageData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAvatarThumbnailCaching() throws IOException {
|
||||
// Load test image from resources
|
||||
Resource imageResource = resourceLoader.getResource("classpath:data/images/pexels-waldemar-nowak-305041-910625.jpg");
|
||||
byte[] imageData = loadImageData(imageResource);
|
||||
|
||||
// Insert test data
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO user_avatars (user_id, mime_type, binary_data) VALUES (?, ?, ?)",
|
||||
testUser.getId(), "image/jpeg", imageData);
|
||||
|
||||
// First call - should process thumbnail
|
||||
Optional<byte[]> firstThumbnail = avatarService.getAvatarThumbnail(testUser.getId(), 50, 50);
|
||||
assertTrue(firstThumbnail.isPresent());
|
||||
|
||||
// Second call - should return cached thumbnail
|
||||
Optional<byte[]> secondThumbnail = avatarService.getAvatarThumbnail(testUser.getId(), 50, 50);
|
||||
assertTrue(secondThumbnail.isPresent());
|
||||
|
||||
// Verify both calls return the same thumbnail data
|
||||
assertArrayEquals(firstThumbnail.get(), secondThumbnail.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCacheEvictionOnUpdate() throws IOException {
|
||||
// Load test images from resources
|
||||
Resource imageResource1 = resourceLoader.getResource("classpath:data/images/pexels-waldemar-nowak-305041-910625.jpg");
|
||||
Resource imageResource2 = resourceLoader.getResource("classpath:data/images/pexels-jan-van-der-wolf-11680885-32207751.jpg");
|
||||
byte[] imageData1 = loadImageData(imageResource1);
|
||||
byte[] imageData2 = loadImageData(imageResource2);
|
||||
|
||||
// Insert initial avatar
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO user_avatars (user_id, mime_type, binary_data) VALUES (?, ?, ?)",
|
||||
testUser.getId(), "image/jpeg", imageData1);
|
||||
|
||||
// Get avatar data (will be cached)
|
||||
Optional<AvatarService.AvatarData> beforeUpdate = avatarService.getAvatarByUserId(testUser.getId());
|
||||
assertTrue(beforeUpdate.isPresent());
|
||||
|
||||
// Update avatar
|
||||
avatarService.updateAvatar(testUser.getId(), "image/png", imageData2);
|
||||
|
||||
// Get avatar data again - should get new data, not cached old data
|
||||
Optional<AvatarService.AvatarData> afterUpdate = avatarService.getAvatarByUserId(testUser.getId());
|
||||
assertTrue(afterUpdate.isPresent());
|
||||
assertEquals("image/png", afterUpdate.get().mimeType());
|
||||
assertArrayEquals(imageData2, afterUpdate.get().imageData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCacheEvictionOnDelete() throws IOException {
|
||||
// Load test image from resources
|
||||
Resource imageResource = resourceLoader.getResource("classpath:data/images/pexels-waldemar-nowak-305041-910625.jpg");
|
||||
byte[] imageData = loadImageData(imageResource);
|
||||
|
||||
// Insert avatar
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO user_avatars (user_id, mime_type, binary_data) VALUES (?, ?, ?)",
|
||||
testUser.getId(), "image/jpeg", imageData);
|
||||
|
||||
// Get avatar data (will be cached)
|
||||
Optional<AvatarService.AvatarData> beforeDelete = avatarService.getAvatarByUserId(testUser.getId());
|
||||
assertTrue(beforeDelete.isPresent());
|
||||
|
||||
// Delete avatar
|
||||
avatarService.deleteAvatar(testUser.getId());
|
||||
|
||||
// Get avatar data again - should get empty, not cached data
|
||||
Optional<AvatarService.AvatarData> afterDelete = avatarService.getAvatarByUserId(testUser.getId());
|
||||
assertTrue(afterDelete.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDifferentThumbnailSizesAreCachedSeparately() throws IOException {
|
||||
// Load test image from resources
|
||||
Resource imageResource = resourceLoader.getResource("classpath:data/images/pexels-waldemar-nowak-305041-910625.jpg");
|
||||
byte[] imageData = loadImageData(imageResource);
|
||||
|
||||
// Insert test image
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO user_avatars (user_id, mime_type, binary_data) VALUES (?, ?, ?)",
|
||||
testUser.getId(), "image/jpeg", imageData);
|
||||
|
||||
// Get different sized thumbnails
|
||||
Optional<byte[]> smallThumbnail = avatarService.getAvatarThumbnail(testUser.getId(), 50, 50);
|
||||
Optional<byte[]> largeThumbnail = avatarService.getAvatarThumbnail(testUser.getId(), 200, 200);
|
||||
|
||||
assertTrue(smallThumbnail.isPresent());
|
||||
assertTrue(largeThumbnail.isPresent());
|
||||
|
||||
// They should be different sizes
|
||||
assertNotEquals(smallThumbnail.get().length, largeThumbnail.get().length);
|
||||
}
|
||||
|
||||
private byte[] loadImageData(Resource resource) throws IOException {
|
||||
try (InputStream inputStream = resource.getInputStream()) {
|
||||
return inputStream.readAllBytes();
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
Reference in New Issue
Block a user