Added Owntracks friend data support, including avatar and location data. (#617)

This commit is contained in:
Daniel Graf
2026-01-03 09:47:29 +01:00
committed by GitHub
parent 155713da53
commit d267b09329
12 changed files with 825 additions and 274 deletions

View File

@@ -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>

View File

@@ -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 -> {

View File

@@ -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();

View File

@@ -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; }
}

View File

@@ -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";
}
}

View File

@@ -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 {}
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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());
}
}

View File

@@ -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