14 Commits

Author SHA1 Message Date
kasun ea866377bc test: add unit tests for HomeboxSearchProvider and SearchResponse
HomeboxSearchProviderTest verifies that ConnectionNotFoundException is
thrown when no matching connection exists in the repository.
SearchResponseTest covers the getExtra helper — null extra map, missing
key, and a present key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 17:36:31 +02:00
kasun a7b984ca84 refactor: consolidate test mock constants into shared Mockdata class
Moved Mockdata from the connection package to the root test package and
extended it with MOCK_USER, MOCK_PASS, MOCK_TITLE, and MOCK_DESCRIPTION
so all test modules share a single source of truth. Removed duplicate
inline constants from ConnectionControllerTest, HomeboxIntegrationTest,
and SearchControllerTest.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 17:36:22 +02:00
kasun 1ba85e129e added SearchControllerTest 2026-05-18 03:14:38 +02:00
kasun c75bf2ad71 changed hibernate schema creation 2026-05-17 19:52:40 +02:00
kasun d91f39d087 implemented basic search function with Homebox provider 2026-05-17 04:22:29 +02:00
kasun f39bf049a0 added RemoteApiException 2026-05-17 00:38:47 +02:00
kasun 5b2648d526 changed formatter 2026-05-17 00:31:10 +02:00
kasun a8ecf65180 changed formatter 2026-05-17 00:26:46 +02:00
kasun 4d96524adb reoargnized packages 2026-05-16 02:38:33 +02:00
kasun 1d5006fd7e reorganized packages 2026-05-16 01:48:36 +02:00
kasun 406a041ce9 reorganized packages 2026-05-16 01:47:12 +02:00
kasun d7233d817c reorganized packages 2026-05-16 01:14:34 +02:00
kasun 92aaf63c12 fixed Homebox typo 2026-05-16 00:16:28 +02:00
kasun ef09a3c84d revised CORS config for lalowed origins 2026-05-14 14:57:25 +02:00
41 changed files with 715 additions and 273 deletions
@@ -8,18 +8,13 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration @Configuration
public class CorsConfig implements WebMvcConfigurer { public class CorsConfig implements WebMvcConfigurer {
@Value("${vaessl.frontend-local-url}") @Value("${vaessl.allowed-origins}")
private String frontendLocalUrl; private String[] allowedOrigins;
@Value("${vaessl.frontend-public-url}")
private String frontendPublicUrl;
@Override @Override
public void addCorsMappings(CorsRegistry registry) { public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**") registry.addMapping("/**").allowedOrigins(allowedOrigins)
.allowedOrigins(frontendLocalUrl, frontendPublicUrl)
.allowedMethods("GET", "POST", "DELETE", "OPTIONS") .allowedMethods("GET", "POST", "DELETE", "OPTIONS")
.allowedHeaders("Content-Type", "Accept") .allowedHeaders("Content-Type", "Accept").allowCredentials(true);
.allowCredentials(true);
} }
} }
@@ -1,5 +1,6 @@
package com.vaessl.app.dto; package com.vaessl.app.connection;
import java.time.Instant; import java.time.Instant;
public record AuthResponse(String serviceType, Instant expiresAt) {} public record AuthResponse(String serviceType, Instant expiresAt) {
}
@@ -14,11 +14,6 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import com.vaessl.app.dto.AuthResponse;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionStatusResponse;
import com.vaessl.app.dto.LoginResult;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession; import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@@ -33,8 +28,7 @@ public class ConnectionController {
private final ConnectionService connectionService; private final ConnectionService connectionService;
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<AuthResponse> login( public ResponseEntity<AuthResponse> login(@Valid @RequestBody ConnectionRequest request,
@Valid @RequestBody ConnectionRequest request,
HttpServletRequest httpReq) { HttpServletRequest httpReq) {
LoginResult result = connectionService.login(request); LoginResult result = connectionService.login(request);
@@ -58,21 +52,21 @@ public class ConnectionController {
} }
List<ConnectionStatusResponse> statuses = new ArrayList<>(); List<ConnectionStatusResponse> statuses = new ArrayList<>();
Collections.list(session.getAttributeNames()).stream() Collections.list(session.getAttributeNames()).stream().filter(k -> k.endsWith(SUFFIX))
.filter(k -> k.endsWith(SUFFIX))
.forEach(k -> { .forEach(k -> {
String serviceType = k.replace(SUFFIX, ""); String serviceType = k.replace(SUFFIX, "");
Long id = (Long) session.getAttribute(k); Long id = (Long) session.getAttribute(k);
ConnectionStatusResponse status = connectionService.getConnectionStatus(serviceType, id); ConnectionStatusResponse status =
if (status != null) statuses.add(status); connectionService.getConnectionStatus(serviceType, id);
if (status != null)
statuses.add(status);
}); });
return ResponseEntity.ok(statuses); return ResponseEntity.ok(statuses);
} }
@DeleteMapping("/connections/{serviceType}") @DeleteMapping("/connections/{serviceType}")
public ResponseEntity<Void> logout( public ResponseEntity<Void> logout(@PathVariable("serviceType") String serviceType,
@PathVariable("serviceType") String serviceType,
HttpServletRequest httpReq) { HttpServletRequest httpReq) {
HttpSession session = httpReq.getSession(false); HttpSession session = httpReq.getSession(false);
@@ -14,7 +14,8 @@ import lombok.NoArgsConstructor;
import lombok.Setter; import lombok.Setter;
@Entity @Entity
@Table(name = "connections", uniqueConstraints = { @UniqueConstraint(columnNames = { "appUrl", "username" }) }) @Table(name = "connections",
uniqueConstraints = {@UniqueConstraint(columnNames = {"appUrl", "username"})})
@Inheritance(strategy = InheritanceType.SINGLE_TABLE) @Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "service_type") @DiscriminatorColumn(name = "service_type")
@Getter @Getter
@@ -2,15 +2,10 @@ package com.vaessl.app.connection;
import java.time.Instant; import java.time.Instant;
import com.vaessl.app.dto.ConnectionRequest; public interface ConnectionProvider extends ServiceProvider {
import com.vaessl.app.dto.ConnectionResponse;
public interface ConnectionProvider {
void checkCredentials(ConnectionRequest request); void checkCredentials(ConnectionRequest request);
String getServiceType();
ConnectionResponse authenticate(ConnectionRequest request); ConnectionResponse authenticate(ConnectionRequest request);
ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request); ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request);
@@ -1,15 +1,12 @@
package com.vaessl.app.dto; package com.vaessl.app.connection;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
public record ConnectionRequest( public record ConnectionRequest(@NotBlank(message = "App URL is mandatory") String appUrl,
@NotBlank(message = "App URL is mandatory") String appUrl,
@NotBlank(message = "Service type is mandatory") String serviceType, @NotBlank(message = "Service type is mandatory") String serviceType,
String username, String username, String password, String apiKey,
String password,
String apiKey,
@JsonProperty(defaultValue = "false") Boolean stayLoggedIn) { @JsonProperty(defaultValue = "false") Boolean stayLoggedIn) {
public ConnectionRequest { public ConnectionRequest {
@@ -18,8 +15,8 @@ public record ConnectionRequest(
} }
} }
public ConnectionRequest(String appUrl, String serviceType, String username, String password, public ConnectionRequest(String appUrl, String serviceType, String username,
Boolean stayLoggedIn) { String password, Boolean stayLoggedIn) {
this(appUrl, serviceType, username, password, null, stayLoggedIn); this(appUrl, serviceType, username, password, null, stayLoggedIn);
} }
} }
@@ -1,9 +1,10 @@
package com.vaessl.app.dto; package com.vaessl.app.connection;
import java.time.Instant; import java.time.Instant;
import java.util.Map; import java.util.Map;
public record ConnectionResponse(String token, Instant expiresAt, Map<String, Object> extraResponseData) { public record ConnectionResponse(String token, Instant expiresAt,
Map<String, Object> extraResponseData) {
public String getExtraVar(String key) { public String getExtraVar(String key) {
if (extraResponseData == null) { if (extraResponseData == null) {
@@ -7,10 +7,6 @@ import java.util.stream.Collectors;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
import com.vaessl.app.dto.ConnectionStatusResponse;
import com.vaessl.app.dto.LoginResult;
import com.vaessl.app.exception.WrongServiceTypeException; import com.vaessl.app.exception.WrongServiceTypeException;
@Service @Service
@@ -54,13 +50,14 @@ public class ConnectionService {
public ConnectionStatusResponse getConnectionStatus(String serviceType, Long connectionId) { public ConnectionStatusResponse getConnectionStatus(String serviceType, Long connectionId) {
ConnectionEntity entity = cRepository.findById(connectionId).orElse(null); ConnectionEntity entity = cRepository.findById(connectionId).orElse(null);
if (entity == null) return null; if (entity == null)
return null;
ConnectionProvider provider = providerRegistry.get(serviceType); ConnectionProvider provider = providerRegistry.get(serviceType);
Instant expiresAt = (provider != null) ? provider.getTokenExpiry(entity) : null; Instant expiresAt = (provider != null) ? provider.getTokenExpiry(entity) : null;
boolean connected = expiresAt == null || expiresAt.isAfter(Instant.now()); boolean connected = expiresAt == null || expiresAt.isAfter(Instant.now());
return new ConnectionStatusResponse(serviceType, entity.getAppUrl(), return new ConnectionStatusResponse(serviceType, entity.getAppUrl(), entity.getUsername(),
entity.getUsername(), expiresAt, connected); expiresAt, connected);
} }
} }
@@ -0,0 +1,7 @@
package com.vaessl.app.connection;
import java.time.Instant;
public record ConnectionStatusResponse(String serviceType, String appUrl, String username,
Instant expiresAt, boolean connected) {
}
@@ -1,9 +1,8 @@
package com.vaessl.app.connection; package com.vaessl.app.connection;
public enum Endpoint { public enum Endpoint {
HOMEBOX_LOGIN("/api/v1/users/login"), HOMEBOX_LOGIN("/api/v1/users/login"), LOGIN("/login"), CONNECTION_STATUS(
LOGIN("/login"), "/connections/status"), HOMEBOX_QUERY_ALL_ITEMS("/api/v1/items"), SEARCH("/search");
CONNECTION_STATUS("/connections/status");
private final String value; private final String value;
@@ -9,20 +9,20 @@ import java.util.Map;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClient;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
import com.vaessl.app.exception.EmptyCredentialsException; import com.vaessl.app.exception.EmptyCredentialsException;
import com.vaessl.app.exception.RemoteApiException;
import static com.vaessl.app.connection.Endpoint.*; import static com.vaessl.app.connection.Endpoint.*;
@Component @Component
public class HomeBoxConnectionProvider implements ConnectionProvider { public class HomeboxConnectionProvider implements ConnectionProvider {
private final RestClient.Builder restClientBuilder; private final RestClient.Builder restClientBuilder;
private final ConnectionRepository cRepository; private final ConnectionRepository cRepository;
public HomeBoxConnectionProvider(RestClient.Builder restClientBuilder, ConnectionRepository cRepository) { public HomeboxConnectionProvider(RestClient.Builder restClientBuilder,
ConnectionRepository cRepository) {
this.restClientBuilder = restClientBuilder; this.restClientBuilder = restClientBuilder;
this.cRepository = cRepository; this.cRepository = cRepository;
} }
@@ -50,27 +50,26 @@ public class HomeBoxConnectionProvider implements ConnectionProvider {
@Override @Override
public ConnectionResponse authenticate(ConnectionRequest request) { public ConnectionResponse authenticate(ConnectionRequest request) {
Map<String, Object> homeboxPayload = Map.of("username", request.username(), Map<String, Object> homeboxPayload = Map.of("username", request.username(), "password",
"password", request.password(), "stayLoggedIn", request.password(), "stayLoggedIn", request.stayLoggedIn());
request.stayLoggedIn());
HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl()) HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl()).build().post()
.build() .uri(HOMEBOX_LOGIN.getValue()).body(homeboxPayload).retrieve()
.post()
.uri(HOMEBOX_LOGIN.getValue())
.body(homeboxPayload)
.retrieve()
.body(HomeboxLoginResponse.class); .body(HomeboxLoginResponse.class);
if (hbResponse == null) { if (hbResponse == null) {
throw new IllegalStateException("Remote API returned an empty body for " + request.appUrl()); throw new RemoteApiException(request.appUrl());
} }
Map<String, Object> attachmentToken = new HashMap<>(); Map<String, Object> attachmentToken = new HashMap<>();
attachmentToken.put("attachmentToken", hbResponse.attachmentToken()); attachmentToken.put("attachmentToken", hbResponse.attachmentToken());
return new ConnectionResponse(hbResponse.token(), hbResponse.expiresAt(), attachmentToken); String hbRawToken = hbResponse.token();
String token = hbRawToken.startsWith("Bearer ") ? hbRawToken.substring(7) : hbRawToken;
return new ConnectionResponse(token, hbResponse.expiresAt(), attachmentToken);
} }
@Override @Override
@@ -80,7 +79,8 @@ public class HomeBoxConnectionProvider implements ConnectionProvider {
} }
@Override @Override
public ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response) { public ConnectionEntity connectionToEntity(ConnectionRequest request,
ConnectionResponse response) {
return HomeboxEntity.from(request, response); return HomeboxEntity.from(request, response);
} }
@@ -105,4 +105,5 @@ public class HomeBoxConnectionProvider implements ConnectionProvider {
private record HomeboxLoginResponse(String token, String attachmentToken, Instant expiresAt) { private record HomeboxLoginResponse(String token, String attachmentToken, Instant expiresAt) {
} }
} }
@@ -2,9 +2,6 @@ package com.vaessl.app.connection;
import java.time.Instant; import java.time.Instant;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.DiscriminatorValue;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import lombok.Getter; import lombok.Getter;
@@ -1,5 +1,6 @@
package com.vaessl.app.dto; package com.vaessl.app.connection;
import java.time.Instant; import java.time.Instant;
public record LoginResult(Long connectionId, Instant expiresAt) {} public record LoginResult(Long connectionId, Instant expiresAt) {
}
@@ -0,0 +1,10 @@
package com.vaessl.app.connection;
public interface ServiceProvider {
/**
* Returns the service type key used to look up this provider in a registry, e.g.
* {@code "HOMEBOX"}.
*/
String getServiceType();
}
@@ -1,10 +0,0 @@
package com.vaessl.app.dto;
import java.time.Instant;
public record ConnectionStatusResponse(
String serviceType,
String appUrl,
String username,
Instant expiresAt,
boolean connected) {}
@@ -0,0 +1,4 @@
package com.vaessl.app.exception;
public class ConnectionNotFoundException extends RuntimeException {
}
@@ -5,11 +5,18 @@ import org.springframework.http.HttpStatus;
import com.fasterxml.jackson.annotation.JsonValue; import com.fasterxml.jackson.annotation.JsonValue;
public enum ErrorMessage { public enum ErrorMessage {
BAD_REQUEST_EMPTY_FIELDS(HttpStatus.BAD_REQUEST, "Fields must not be empty."), UNAUTHORIZED_WRONG_LOGIN( BAD_REQUEST_EMPTY_FIELDS(HttpStatus.BAD_REQUEST,
HttpStatus.UNAUTHORIZED, "Invalid username or password."), SERVICE_UNAVAILABLE_UNREACHABLE_URL( "Fields must not be empty."), UNAUTHORIZED_WRONG_LOGIN(HttpStatus.UNAUTHORIZED,
HttpStatus.SERVICE_UNAVAILABLE, "The target URL is unreachable."), SERVER_ERROR_GENERAL( "Invalid username or password."), SERVICE_UNAVAILABLE_UNREACHABLE_URL(
"The external app returned a server error: "), WRONG_SERVICE_TYPE(HttpStatus.NOT_FOUND, HttpStatus.SERVICE_UNAVAILABLE,
"No such service type."); "The target URL is unreachable."), SERVER_ERROR_GENERAL(
"The external app returned a server error: "), WRONG_SERVICE_TYPE(
HttpStatus.NOT_FOUND,
"No such service type."), CONNECTION_NOT_FOUND(
HttpStatus.NOT_FOUND,
"No active connection found for this service."), REMOTE_API_EMPTY_RESPONSE(
HttpStatus.BAD_GATEWAY,
"Remote API returned empty response for ");
private final HttpStatus status; private final HttpStatus status;
private final String message; private final String message;
@@ -19,8 +19,8 @@ public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class) @ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleEmptyCredentialInput(MethodArgumentNotValidException e) { public ProblemDetail handleEmptyCredentialInput(MethodArgumentNotValidException e) {
String defaultMessages = e.getBindingResult().getFieldErrors().stream().map(FieldError::getDefaultMessage) String defaultMessages = e.getBindingResult().getFieldErrors().stream()
.collect(Collectors.joining(", ")); .map(FieldError::getDefaultMessage).collect(Collectors.joining(", "));
return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(), return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(),
BAD_REQUEST_EMPTY_FIELDS.getMessage() + " [" + defaultMessages + "]"); BAD_REQUEST_EMPTY_FIELDS.getMessage() + " [" + defaultMessages + "]");
@@ -43,14 +43,14 @@ public class GlobalExceptionHandler {
@ExceptionHandler(HttpServerErrorException.class) @ExceptionHandler(HttpServerErrorException.class)
public ProblemDetail handleTimeoutOrNotFound(HttpServerErrorException e) { public ProblemDetail handleTimeoutOrNotFound(HttpServerErrorException e) {
return ProblemDetail return ProblemDetail.forStatusAndDetail(e.getStatusCode(),
.forStatusAndDetail(e.getStatusCode(),
SERVER_ERROR_GENERAL.getMessage() + e.getStatusText()); SERVER_ERROR_GENERAL.getMessage() + e.getStatusText());
} }
@ExceptionHandler(WrongServiceTypeException.class) @ExceptionHandler(WrongServiceTypeException.class)
public ProblemDetail handleWrongServiceType(WrongServiceTypeException e) { public ProblemDetail handleWrongServiceType(WrongServiceTypeException e) {
return ProblemDetail.forStatusAndDetail(WRONG_SERVICE_TYPE.getStatus(), WRONG_SERVICE_TYPE.getMessage()); return ProblemDetail.forStatusAndDetail(WRONG_SERVICE_TYPE.getStatus(),
WRONG_SERVICE_TYPE.getMessage());
} }
@ExceptionHandler(EmptyCredentialsException.class) @ExceptionHandler(EmptyCredentialsException.class)
@@ -58,4 +58,15 @@ public class GlobalExceptionHandler {
return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(), return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(),
BAD_REQUEST_EMPTY_FIELDS.getMessage() + " " + e.getMissingFields()); BAD_REQUEST_EMPTY_FIELDS.getMessage() + " " + e.getMissingFields());
} }
@ExceptionHandler(ConnectionNotFoundException.class)
public ProblemDetail handleConnectionNotFound(ConnectionNotFoundException e) {
return ProblemDetail.forStatusAndDetail(CONNECTION_NOT_FOUND.getStatus(), CONNECTION_NOT_FOUND.getMessage());
}
@ExceptionHandler(RemoteApiException.class)
public ProblemDetail handleRemoteApiException(RemoteApiException e) {
return ProblemDetail.forStatusAndDetail(REMOTE_API_EMPTY_RESPONSE.getStatus(),
REMOTE_API_EMPTY_RESPONSE.getMessage() + e.getAppUrl());
}
} }
@@ -0,0 +1,11 @@
package com.vaessl.app.exception;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public class RemoteApiException extends RuntimeException {
private final String appUrl;
}
@@ -0,0 +1,79 @@
package com.vaessl.app.search;
import java.util.List;
import java.util.Map;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import com.vaessl.app.connection.ConnectionEntity;
import com.vaessl.app.connection.ConnectionRepository;
import com.vaessl.app.connection.HomeboxEntity;
import com.vaessl.app.exception.ConnectionNotFoundException;
import com.vaessl.app.exception.RemoteApiException;
import static com.vaessl.app.connection.Endpoint.*;
@Component
public class HomeboxSearchProvider implements SearchProvider {
private final RestClient.Builder restClientBuilder;
private final ConnectionRepository cRepository;
public HomeboxSearchProvider(RestClient.Builder restClientBuilder,
ConnectionRepository cRepository) {
this.restClientBuilder = restClientBuilder;
this.cRepository = cRepository;
}
@Override
public String getServiceType() {
return "HOMEBOX";
}
@Override
public Page<SearchResponse> getSearchResults(SearchRequest request, Pageable pageable) {
ConnectionEntity entity =
cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
if (!(entity instanceof HomeboxEntity hbEntity)) {
throw new ConnectionNotFoundException();
}
HomeboxSearchResponse hbResponse = restClientBuilder.baseUrl(request.appUrl()).build().get()
.uri(u -> u.path(HOMEBOX_QUERY_ALL_ITEMS.getValue())
.queryParam("q", request.query())
.queryParam("page", pageable.getPageNumber() + 1)
.queryParam("pageSize", pageable.getPageSize()).build())
.headers(h -> h.setBearerAuth(
hbEntity.getToken())).retrieve().body(HomeboxSearchResponse.class);
if (hbResponse == null) {
throw new RemoteApiException(request.appUrl());
}
List<SearchResponse> items = hbResponse.items().stream().map(i -> {
String title = i.name();
String description = i.description();
Map<String, Object> extraSearchResponseData = Map.of("location", i.location());
return new SearchResponse(title, description, extraSearchResponseData);
}).toList();
return new PageImpl<>(items, pageable, hbResponse.total());
}
private record HomeboxSearchResponse(int page, int pageSize, int total,
List<HomeboxItem> items) {
}
private record HomeboxItem(String name, String description, HomeboxLocation location) {
}
private record HomeboxLocation(String name, String description) {
}
}
@@ -0,0 +1,14 @@
package com.vaessl.app.search;
import java.util.List;
import org.springframework.data.domain.Page;
public record PagedSearchResponse<T>(List<T> content, int page, int pageSize, long totalElements,
boolean first, boolean last, String sort) {
public static <T> PagedSearchResponse<T> from(Page<T> pageResult) {
return new PagedSearchResponse<>(pageResult.getContent(), pageResult.getNumber(),
pageResult.getSize(), pageResult.getTotalElements(), pageResult.isFirst(),
pageResult.isLast(), pageResult.getSort().toString());
}
}
@@ -0,0 +1,41 @@
package com.vaessl.app.search;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@RestController
@RequiredArgsConstructor
public class SearchController {
private final SearchService searchService;
/**
* Executes a paged search against the requested service. Returns {@code 401 Unauthorized} if
* there is no active session.
*/
@PostMapping("/search")
public ResponseEntity<PagedSearchResponse<SearchResponse>> search(
@Valid @RequestBody SearchRequest request,
@PageableDefault(size = 20) Pageable pageable, HttpServletRequest httpReq) {
HttpSession session = httpReq.getSession(false);
if (session == null
|| session.getAttribute(request.serviceType() + "_CONNECTION_ID") == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
Page<SearchResponse> result = searchService.search(request, pageable);
return ResponseEntity.ok(PagedSearchResponse.from(result));
}
}
@@ -0,0 +1,20 @@
package com.vaessl.app.search;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import com.vaessl.app.connection.ServiceProvider;
/**
* Implemented by any service that supports querying its remote API for items.
*/
public interface SearchProvider extends ServiceProvider {
/**
* Executes a search query against the remote service and returns matching results.
*
* @param request the search request containing the query string, app URL, and user credentials
* @return a list of {@link SearchResponse} items matching the query
*/
Page<SearchResponse> getSearchResults(SearchRequest request, Pageable pageable);
}
@@ -0,0 +1,7 @@
package com.vaessl.app.search;
import jakarta.validation.constraints.NotBlank;
public record SearchRequest(@NotBlank String appUrl, @NotBlank String username, String query,
@NotBlank String serviceType) {
}
@@ -0,0 +1,16 @@
package com.vaessl.app.search;
import java.util.Map;
public record SearchResponse(String title, String description, Map<String, Object> extraData) {
public String getExtra(String key) {
if (extraData == null) {
return null;
} else {
Object value = extraData.get(key);
return value != null ? String.valueOf(value) : null;
}
}
}
@@ -0,0 +1,42 @@
package com.vaessl.app.search;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import com.vaessl.app.exception.WrongServiceTypeException;
@Service
public class SearchService {
private final Map<String, SearchProvider> providerRegistry;
public SearchService(List<SearchProvider> providers) {
this.providerRegistry = Map.copyOf(providers.stream()
.collect(Collectors.toMap(SearchProvider::getServiceType, p -> p)));
}
/**
* Dispatches the paged search request to the provider registered for
* {@link SearchRequest#serviceType()}.
*
* @param request the search request
* @param pageable the Pageable interface
* @return results returned by the matching provider
* @throws WrongServiceTypeException if no provider is registered for the given service type
*/
public Page<SearchResponse> search(SearchRequest request, Pageable pageable) {
SearchProvider provider = providerRegistry.get(request.serviceType());
if (provider == null) {
throw new WrongServiceTypeException();
}
return provider.getSearchResults(request, pageable);
}
}
@@ -5,13 +5,8 @@
"description": "A description for 'spring.session.store-type'" "description": "A description for 'spring.session.store-type'"
}, },
{ {
"name": "vaessl.frontend-local-url", "name": "vaessl.allowed-origins",
"type": "java.lang.String", "type": "java.lang.String",
"description": "A description for 'vaessl.frontend-local-url'" "description": "Comma-separated list of allowed CORS origins"
},
{
"name": "vaessl.frontend-public-url",
"type": "java.lang.String",
"description": "A description for 'vaessl.frontend-public-url'"
} }
]} ]}
+2 -3
View File
@@ -13,7 +13,7 @@ spring:
driver-class-name: ${PG_DRIVER_CLASS_NAME} driver-class-name: ${PG_DRIVER_CLASS_NAME}
jpa: jpa:
hibernate: hibernate:
ddl-auto: create-drop ddl-auto: update
show-sql: true show-sql: true
ai: ai:
openai: openai:
@@ -30,5 +30,4 @@ server:
servlet: servlet:
context-path: /api context-path: /api
vaessl: vaessl:
frontend-local-url: ${FRONTEND_LOCAL_URL} allowed-origins: ${ALLOWED_ORIGINS}
frontend-public-url: ${FRONTEND_PUBLIC_URL}
@@ -20,8 +20,7 @@ class ApplicationTests {
private DataSource dataSource; private DataSource dataSource;
@Test @Test
void contextLoads() { void contextLoads() {}
}
@Test @Test
void connectionToTestDbWorks() throws SQLException { void connectionToTestDbWorks() throws SQLException {
@@ -0,0 +1,13 @@
package com.vaessl.app;
public final class Mockdata {
private Mockdata() {}
public static final String MOCK_URL = "http://localhost:1234";
public static final String MOCK_SERVICE_TYPE = "SERVICE_TYPE";
public static final String MOCK_USER = "user";
public static final String MOCK_PASS = "pw";
public static final String MOCK_TITLE = "title";
public static final String MOCK_DESCRIPTION = "desc";
}
@@ -1,5 +1,7 @@
package com.vaessl.app.connection; package com.vaessl.app.connection;
import static com.vaessl.app.Mockdata.*;
import static com.vaessl.app.connection.Endpoint.*; import static com.vaessl.app.connection.Endpoint.*;
import static com.vaessl.app.connection.ServiceType.*; import static com.vaessl.app.connection.ServiceType.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
@@ -27,8 +29,6 @@ class ConnectionControllerTest {
@Autowired @Autowired
MockMvc mockMvc; MockMvc mockMvc;
private static final String TEST_USER = "admin";
private static final String TEST_PASS = "pw";
private static final String LOGIN_PATH = LOGIN.getValue(); private static final String LOGIN_PATH = LOGIN.getValue();
private static final String STATUS_PATH = CONNECTION_STATUS.getValue(); private static final String STATUS_PATH = CONNECTION_STATUS.getValue();
private static final String LOGOUT_PATH = "/connections/HOMEBOX"; private static final String LOGOUT_PATH = "/connections/HOMEBOX";
@@ -51,60 +51,60 @@ class ConnectionControllerTest {
@Test @Test
void shouldReturnEmptyListWhenNoActiveSession() throws Exception { void shouldReturnEmptyListWhenNoActiveSession() throws Exception {
mockMvc.perform(get(STATUS_PATH)) mockMvc.perform(get(STATUS_PATH)).andExpect(status().isOk())
.andExpect(status().isOk())
.andExpect(content().json("[]")); .andExpect(content().json("[]"));
} }
@Test @Test
void shouldReturnConnectionStatusWithConnectedTrueAfterLogin(WireMockRuntimeInfo wm) throws Exception { void shouldReturnConnectionStatusWithConnectedTrueAfterLogin(WireMockRuntimeInfo wm)
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE))); throws Exception {
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
.willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE)));
MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH) MvcResult loginResult = mockMvc
.contentType(MediaType.APPLICATION_JSON) .perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
.content(connectionRequestBody(wm))) .content(connectionRequestBody(wm)))
.andExpect(status().isOk()) .andExpect(status().isOk()).andReturn();
.andReturn();
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION"); Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)) mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)).andExpect(status().isOk())
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1)) .andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.getValue())) .andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.getValue()))
.andExpect(jsonPath("$[0].username").value(TEST_USER)) .andExpect(jsonPath("$[0].username").value(MOCK_USER))
.andExpect(jsonPath("$[0].appUrl").value(wm.getHttpBaseUrl())) .andExpect(jsonPath("$[0].appUrl").value(wm.getHttpBaseUrl()))
.andExpect(jsonPath("$[0].connected").value(true)); .andExpect(jsonPath("$[0].connected").value(true));
} }
@Test @Test
void shouldReturnConnectedFalseWhenStoredTokenIsExpired(WireMockRuntimeInfo wm) throws Exception { void shouldReturnConnectedFalseWhenStoredTokenIsExpired(WireMockRuntimeInfo wm)
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(EXPIRED_HOMEBOX_RESPONSE))); throws Exception {
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
.willReturn(WireMock.okJson(EXPIRED_HOMEBOX_RESPONSE)));
MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH) MvcResult loginResult = mockMvc
.contentType(MediaType.APPLICATION_JSON) .perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
.content(connectionRequestBody(wm))) .content(connectionRequestBody(wm)))
.andExpect(status().isOk()) .andExpect(status().isOk()).andReturn();
.andReturn();
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION"); Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)) mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)).andExpect(status().isOk())
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.getValue())) .andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.getValue()))
.andExpect(jsonPath("$[0].connected").value(false)) .andExpect(jsonPath("$[0].connected").value(false))
.andExpect(jsonPath("$[0].expiresAt").value("2000-01-01T00:00:00Z")); .andExpect(jsonPath("$[0].expiresAt")
.value("2000-01-01T00:00:00Z"));
} }
@Test @Test
void shouldReturn204NoContentOnLogout(WireMockRuntimeInfo wm) throws Exception { void shouldReturn204NoContentOnLogout(WireMockRuntimeInfo wm) throws Exception {
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE))); WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
.willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE)));
MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH) MvcResult loginResult = mockMvc
.contentType(MediaType.APPLICATION_JSON) .perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
.content(connectionRequestBody(wm))) .content(connectionRequestBody(wm)))
.andExpect(status().isOk()) .andExpect(status().isOk()).andReturn();
.andReturn();
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION"); Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
@@ -114,34 +114,31 @@ class ConnectionControllerTest {
@Test @Test
void shouldReturnEmptyStatusListAfterLogout(WireMockRuntimeInfo wm) throws Exception { void shouldReturnEmptyStatusListAfterLogout(WireMockRuntimeInfo wm) throws Exception {
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE))); WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
.willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE)));
MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH) MvcResult loginResult = mockMvc
.contentType(MediaType.APPLICATION_JSON) .perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
.content(connectionRequestBody(wm))) .content(connectionRequestBody(wm)))
.andExpect(status().isOk()) .andExpect(status().isOk()).andReturn();
.andReturn();
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION"); Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
// Verify connected before logout // Verify connected before logout
mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)) mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)).andExpect(status().isOk())
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1)); .andExpect(jsonPath("$.length()").value(1));
mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie)) mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
// A new request (no session cookie, as in a fresh browser) returns no connections // A new request (no session cookie, as in a fresh browser) returns no connections
mockMvc.perform(get(STATUS_PATH)) mockMvc.perform(get(STATUS_PATH)).andExpect(status().isOk())
.andExpect(status().isOk())
.andExpect(content().json("[]")); .andExpect(content().json("[]"));
} }
@Test @Test
void shouldReturn204WhenLogoutCalledWithNoActiveSession() throws Exception { void shouldReturn204WhenLogoutCalledWithNoActiveSession() throws Exception {
mockMvc.perform(delete(LOGOUT_PATH)) mockMvc.perform(delete(LOGOUT_PATH)).andExpect(status().isNoContent());
.andExpect(status().isNoContent());
} }
private String connectionRequestBody(WireMockRuntimeInfo wm) { private String connectionRequestBody(WireMockRuntimeInfo wm) {
@@ -152,6 +149,6 @@ class ConnectionControllerTest {
"username": "%s", "username": "%s",
"password": "%s" "password": "%s"
} }
""".formatted(wm.getHttpBaseUrl(), TEST_USER, TEST_PASS); """.formatted(wm.getHttpBaseUrl(), MOCK_USER, MOCK_PASS);
} }
} }
@@ -1,5 +1,6 @@
package com.vaessl.app.connection; package com.vaessl.app.connection;
import static com.vaessl.app.Mockdata.*;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.doThrow;
@@ -15,11 +16,8 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.exception.EmptyCredentialsException; import com.vaessl.app.exception.EmptyCredentialsException;
import static com.vaessl.app.connection.Mockdata.*;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class ConnectionServiceTest { class ConnectionServiceTest {
@@ -38,10 +36,11 @@ class ConnectionServiceTest {
@Test @Test
void login_ShouldAbort_WhenCheckCredentialsThrowsException() { void login_ShouldAbort_WhenCheckCredentialsThrowsException() {
ConnectionRequest request = new ConnectionRequest(MOCK_URL, MOCK_SERVICE_TYPE, null, null, false); ConnectionRequest request =
new ConnectionRequest(MOCK_URL, MOCK_SERVICE_TYPE, null, null, false);
doThrow(new EmptyCredentialsException(List.of("username"))) doThrow(new EmptyCredentialsException(List.of("username"))).when(mockProvider)
.when(mockProvider).checkCredentials(request); .checkCredentials(request);
assertThrows(EmptyCredentialsException.class, () -> connectionService.login(request)); assertThrows(EmptyCredentialsException.class, () -> connectionService.login(request));
@@ -4,15 +4,13 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.exception.EmptyCredentialsException; import com.vaessl.app.exception.EmptyCredentialsException;
import static com.vaessl.app.Mockdata.*;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static com.vaessl.app.connection.Mockdata.*;
class HomeBoxConnectionProviderTest { class HomeboxConnectionProviderTest {
private final HomeBoxConnectionProvider provider = new HomeBoxConnectionProvider(null, null); private final HomeboxConnectionProvider provider = new HomeboxConnectionProvider(null, null);
@Test @Test
void checkCredentials_ShouldThrowException_WhenFieldsAreMissing() { void checkCredentials_ShouldThrowException_WhenFieldsAreMissing() {
@@ -1,5 +1,6 @@
package com.vaessl.app.connection; package com.vaessl.app.connection;
import static com.vaessl.app.Mockdata.*;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.resttestclient.TestRestTemplate; import org.springframework.boot.resttestclient.TestRestTemplate;
@@ -14,7 +15,6 @@ import com.github.tomakehurst.wiremock.junit5.WireMockTest;
import com.jayway.jsonpath.DocumentContext; import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath; import com.jayway.jsonpath.JsonPath;
import com.vaessl.app.dto.ConnectionRequest;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -42,9 +42,6 @@ class HomeboxIntegrationTest {
} }
"""; """;
private static final String TEST_USER = "admin";
private static final String TEST_PASS = "pw";
/** /**
* Returns Token and status code OK when login is successful. * Returns Token and status code OK when login is successful.
* *
@@ -53,11 +50,10 @@ class HomeboxIntegrationTest {
@Test @Test
void shouldReturnStatusOkWhenHomeboxCredentialsAreValid(WireMockRuntimeInfo wm) { void shouldReturnStatusOkWhenHomeboxCredentialsAreValid(WireMockRuntimeInfo wm) {
stubFor(post(HOMEBOX_LOGIN.getValue()) stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(okJson(okJsonHomeboxResponse)));
.willReturn(okJson(okJsonHomeboxResponse)));
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), ResponseEntity<String> response =
String.class); restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), String.class);
DocumentContext documentContext = JsonPath.parse(response.getBody()); DocumentContext documentContext = JsonPath.parse(response.getBody());
@@ -79,8 +75,8 @@ class HomeboxIntegrationTest {
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(unauthorized())); stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(unauthorized()));
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), ResponseEntity<String> response =
String.class); restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(response.getBody()).contains(UNAUTHORIZED_WRONG_LOGIN.getMessage()); assertThat(response.getBody()).contains(UNAUTHORIZED_WRONG_LOGIN.getMessage());
@@ -96,8 +92,8 @@ class HomeboxIntegrationTest {
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(serviceUnavailable())); stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(serviceUnavailable()));
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), ResponseEntity<String> response =
String.class); restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
assertThat(response.getBody()).contains(SERVER_ERROR_GENERAL.getMessage()); assertThat(response.getBody()).contains(SERVER_ERROR_GENERAL.getMessage());
@@ -113,7 +109,8 @@ class HomeboxIntegrationTest {
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))); .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
ConnectionRequest badRequest = connectionRequest(wm); ConnectionRequest badRequest = connectionRequest(wm);
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), badRequest, String.class); ResponseEntity<String> response =
restTemplate.postForEntity(LOGIN.getValue(), badRequest, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
assertThat(response.getBody()).contains(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage()); assertThat(response.getBody()).contains(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage());
@@ -127,34 +124,30 @@ class HomeboxIntegrationTest {
ConnectionRequest emtpyRequest = new ConnectionRequest("", "", "", "", false); ConnectionRequest emtpyRequest = new ConnectionRequest("", "", "", "", false);
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), emtpyRequest, String.class); ResponseEntity<String> response =
restTemplate.postForEntity(LOGIN.getValue(), emtpyRequest, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage()); assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage());
} }
/** /**
* Test the exception when there is an input for serviceType but it's * Test the exception when there is an input for serviceType but it's unsupported.
* unsupported.
*/ */
@Test @Test
void shouldReturnWrongServiceTypeException(WireMockRuntimeInfo wm) { void shouldReturnWrongServiceTypeException(WireMockRuntimeInfo wm) {
ConnectionRequest wrongServiceTypeReq = new ConnectionRequest( ConnectionRequest wrongServiceTypeReq = new ConnectionRequest(wm.getHttpBaseUrl(),
wm.getHttpBaseUrl(), "wrong-service-type", MOCK_USER, MOCK_PASS, false);
"wrong-service-type",
TEST_USER, TEST_PASS,
false);
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), wrongServiceTypeReq, ResponseEntity<String> response =
String.class); restTemplate.postForEntity(LOGIN.getValue(), wrongServiceTypeReq, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
assertThat(response.getBody()).contains(WRONG_SERVICE_TYPE.getMessage()); assertThat(response.getBody()).contains(WRONG_SERVICE_TYPE.getMessage());
} }
/** /**
* Tests the succesfull persistance of Homebox credential response to the * Tests the succesfull persistance of Homebox credential response to the database.
* database.
* *
* @param wm the WiremockRuntimeInfo object * @param wm the WiremockRuntimeInfo object
*/ */
@@ -166,10 +159,12 @@ class HomeboxIntegrationTest {
ConnectionRequest request = connectionRequest(wm); ConnectionRequest request = connectionRequest(wm);
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), request, String.class); ResponseEntity<String> response =
restTemplate.postForEntity(LOGIN.getValue(), request, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
ConnectionEntity dbEntry = cRepository.findByAppUrlAndUsername(request.appUrl(), request.username()); ConnectionEntity dbEntry =
cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
assertThat(dbEntry).isNotNull(); assertThat(dbEntry).isNotNull();
assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl()); assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl());
@@ -184,26 +179,24 @@ class HomeboxIntegrationTest {
@Test @Test
void shouldReturnEmptyCredentialsExceptionWhenCredsAreMissing(WireMockRuntimeInfo wm) { void shouldReturnEmptyCredentialsExceptionWhenCredsAreMissing(WireMockRuntimeInfo wm) {
ConnectionRequest missingCredentials = new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX.getValue(), TEST_USER, ConnectionRequest missingCredentials = new ConnectionRequest(wm.getHttpBaseUrl(),
null, HOMEBOX.getValue(), MOCK_USER, null, false);
false);
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), missingCredentials, ResponseEntity<String> response =
String.class); restTemplate.postForEntity(LOGIN.getValue(), missingCredentials, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage()); assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage());
} }
/** /**
* Creates a valid connection request with a mock Api through * Creates a valid connection request with a mock Api through WireMockRuntimeInfo.
* WireMockRuntimeInfo.
* *
* @param wm the WiremockRuntimeInfo object * @param wm the WiremockRuntimeInfo object
* @return a mock api connection request. * @return a mock api connection request.
*/ */
private ConnectionRequest connectionRequest(WireMockRuntimeInfo wm) { private ConnectionRequest connectionRequest(WireMockRuntimeInfo wm) {
return new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX.getValue(), TEST_USER, TEST_PASS, return new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX.getValue(), MOCK_USER, MOCK_PASS,
null); null);
} }
} }
@@ -1,9 +0,0 @@
package com.vaessl.app.connection;
public final class Mockdata {
private Mockdata() {}
public static final String MOCK_URL = "http://localhost:1234";
public static final String MOCK_SERVICE_TYPE = "SERVICE_TYPE";
}
@@ -0,0 +1,35 @@
package com.vaessl.app.search;
import static com.vaessl.app.Mockdata.*;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import com.vaessl.app.connection.ConnectionRepository;
import com.vaessl.app.exception.ConnectionNotFoundException;
@ExtendWith(MockitoExtension.class)
class HomeboxSearchProviderTest {
@Mock
private ConnectionRepository mockRepo;
@InjectMocks
private HomeboxSearchProvider provider;
@Test
void shouldReturnConnectionNotFoundException() {
when(mockRepo.findByAppUrlAndUsername(MOCK_URL, MOCK_USER)).thenReturn(null);
SearchRequest request = new SearchRequest(MOCK_URL, MOCK_USER, "test query", "HOMEBOX");
Pageable pageable = PageRequest.of(0, 10);
assertThrows(ConnectionNotFoundException.class,
() -> provider.getSearchResults(request, pageable));
}
}
@@ -0,0 +1,152 @@
package com.vaessl.app.search;
import static com.vaessl.app.Mockdata.MOCK_PASS;
import static com.vaessl.app.Mockdata.MOCK_USER;
import static com.vaessl.app.connection.Endpoint.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import com.github.tomakehurst.wiremock.client.WireMock;
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
import jakarta.servlet.http.Cookie;
@SpringBootTest
@AutoConfigureMockMvc
@WireMockTest
class SearchControllerTest {
@Autowired
MockMvc mockMvc;
private static final String QUERY_ALL_ITEMS = HOMEBOX_QUERY_ALL_ITEMS.getValue();
private static final String LOGIN_PATH = LOGIN.getValue();
private static final String SEARCH_REQUEST = SEARCH.getValue();
private static final String VALID_HOMEBOX_LOGIN_RESPONSE = """
{
"token": "fake-bearer-token",
"attachmentToken": "fake-attach-token",
"expiresAt": "2099-01-01T00:00:00Z"
}
""";
private static final String VALID_HOMEBOX_ALL_ITEMS_QUERY_RESPONSE = """
{
"page": -1,
"pageSize": -1,
"total": 1,
"items": [
{
"id": "c643e7f9-93d0-4b5f-ae4d-e1c2d90389e0",
"assetId": "000-001",
"name": "MacBook Pro A1398",
"description": "Running Linux (Fedora)",
"quantity": 1,
"insured": false,
"archived": false,
"createdAt": "2026-05-13T19:52:20.016176Z",
"updatedAt": "2026-05-14T12:39:11.836403Z",
"purchasePrice": 0,
"location": {
"id": "b6f60ab8-3a2a-4a8d-a4bf-897d0555f636",
"name": "Server Schrank Ikea weiß",
"description": "Weißer Ikea Schrank, wo sich der Server befindet.",
"createdAt": "2026-05-13T19:55:55.817576Z",
"updatedAt": "2026-05-14T12:37:24.396651Z"
},
"tags": [],
"imageId": "cb3e44d5-ccd4-421e-9f5a-f52cd5f40ca6",
"thumbnailId": "2bfd53fa-1bf1-483c-8d76-7720464532fa",
"soldTime": "0001-01-01T00:00:00Z"
}
]
}
""";
@Test
void shouldReturnListOfQueriedHomeboxItems(WireMockRuntimeInfo wm) throws Exception {
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
.willReturn(WireMock.okJson(VALID_HOMEBOX_LOGIN_RESPONSE)));
WireMock.stubFor(WireMock.get(WireMock.urlPathEqualTo(QUERY_ALL_ITEMS))
.willReturn(WireMock.okJson(VALID_HOMEBOX_ALL_ITEMS_QUERY_RESPONSE)));
MvcResult loginResult =
mockMvc.perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
.content(connectionRequestBody(wm))).andExpect(status().isOk()).andReturn();
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
mockMvc.perform(post(SEARCH_REQUEST).cookie(sessionCookie)
.contentType(MediaType.APPLICATION_JSON).content(searchRequestBody(wm, "HOMEBOX")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].title").value("MacBook Pro A1398"))
.andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.content[0].extraData.location.name")
.value("Server Schrank Ikea weiß"));
}
@Test
void shouldReturnUnauthorizedWhenNoSession() throws Exception {
mockMvc.perform(post(SEARCH_REQUEST).contentType(MediaType.APPLICATION_JSON).content("""
{
"appUrl": "http://irrelevant",
"query": "Item",
"serviceType": "HOMEBOX",
"username": "irrelevant"
}
""")).andExpect(status().isUnauthorized());
}
@Test
void shouldReturnUnauthorizedWhenSessionHasNoConnectionForRequestedServiceType(
WireMockRuntimeInfo wm) throws Exception {
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
.willReturn(WireMock.okJson(VALID_HOMEBOX_LOGIN_RESPONSE)));
MvcResult loginResult =
mockMvc.perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
.content(connectionRequestBody(wm))).andExpect(status().isOk()).andReturn();
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
mockMvc.perform(
post(SEARCH_REQUEST).cookie(sessionCookie).contentType(MediaType.APPLICATION_JSON)
.content(searchRequestBody(wm, "OTHER_SERVICETYPE")))
.andExpect(status().isUnauthorized());
}
private String searchRequestBody(WireMockRuntimeInfo wm, String serviceType) {
return """
{
"appUrl": "%s",
"query": "Item",
"serviceType": "%s",
"username": "%s"
}
""".formatted(wm.getHttpBaseUrl(), serviceType, MOCK_USER);
}
private String connectionRequestBody(WireMockRuntimeInfo wm) {
return """
{
"appUrl": "%s",
"serviceType": "HOMEBOX",
"username": "%s",
"password": "%s"
}
""".formatted(wm.getHttpBaseUrl(), MOCK_USER, MOCK_PASS);
}
}
@@ -0,0 +1,33 @@
package com.vaessl.app.search;
import static com.vaessl.app.Mockdata.*;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Map;
import org.junit.jupiter.api.Test;
class SearchResponseTest {
@Test
void shouldReturnNullWhenExtraDataIsNull() {
SearchResponse response = new SearchResponse(MOCK_TITLE, MOCK_DESCRIPTION, null);
assertThat(response.getExtra(null)).isNull();
}
@Test
void shouldReturnNullWhenExtraDataKeyIsMissing() {
SearchResponse response = new SearchResponse(MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value"));
assertThat(response.getExtra("missing")).isNull();
}
@Test
void shouldReturnExtraDataValue() {
SearchResponse response = new SearchResponse(MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value"));
assertThat(response.getExtra("key")).contains("value");
}
}
@@ -94,7 +94,7 @@ The entire commit can be reviewed under following hash 43bbcece7a901e94021e10bca
- DELETE /connections/{serviceType} — removes the specific key from the session. If no connections remain, the session is invalidated entirely. - DELETE /connections/{serviceType} — removes the specific key from the session. If no connections remain, the session is invalidated entirely.
--- ---
**connection/HomeBoxConnectionProvider.java (modified)** **connection/HomeboxConnectionProvider.java (modified)**
Implements the new interface methods: Implements the new interface methods:
- checkCredentials validates that username and password are present before touching the network. - checkCredentials validates that username and password are present before touching the network.
@@ -94,11 +94,11 @@ public interface ConnectionProvider {
- `findUniqueConnectionEntry` — looks up an existing record to decide insert vs. update - `findUniqueConnectionEntry` — looks up an existing record to decide insert vs. update
- `getTokenExpiry` — default returns `null` (no expiry); token-based providers override this so `ConnectionService` can compute the `connected` flag - `getTokenExpiry` — default returns `null` (no expiry); token-based providers override this so `ConnectionService` can compute the `connected` flag
***HomeBoxConnectionProvider.java*** ***HomeboxConnectionProvider.java***
```java ```java
@Component @Component
public class HomeBoxConnectionProvider implements ConnectionProvider { public class HomeboxConnectionProvider implements ConnectionProvider {
@Override @Override
public String getServiceType() { return "HOMEBOX"; } public String getServiceType() { return "HOMEBOX"; }