Feature/ implement basic search function #40

Merged
kasun merged 30 commits from feature/Implement-basic-search-function into main 2026-06-25 17:58:40 +02:00
24 changed files with 278 additions and 263 deletions
Showing only changes of commit fb64a7787f - Show all commits
@@ -2,5 +2,5 @@ package com.vaessl.app.connection;
import java.time.Instant; import java.time.Instant;
public record AuthResponse(String serviceType, Instant expiresAt) { public record AuthResponse(ServiceType serviceType, Instant expiresAt) {
} }
@@ -53,19 +53,24 @@ public class ConnectionController {
Collections.list(session.getAttributeNames()).stream() Collections.list(session.getAttributeNames()).stream()
.filter(k -> k.endsWith(SessionKeys.CONNECTION_ID_SUFFIX)) .filter(k -> k.endsWith(SessionKeys.CONNECTION_ID_SUFFIX))
.forEach(k -> { .forEach(k -> {
String serviceType = k.replace(SessionKeys.CONNECTION_ID_SUFFIX, ""); String name = k.replace(SessionKeys.CONNECTION_ID_SUFFIX, "");
try {
ServiceType serviceType = ServiceType.valueOf(name);
Long id = (Long) session.getAttribute(k); Long id = (Long) session.getAttribute(k);
ConnectionStatusResponse status = ConnectionStatusResponse status =
connectionService.getConnectionStatus(serviceType, id); connectionService.getConnectionStatus(serviceType, id);
if (status != null) if (status != null)
statuses.add(status); statuses.add(status);
} catch (IllegalArgumentException _) {
// stale session key from an old or removed service type — skip it
}
}); });
return ResponseEntity.ok(statuses); return ResponseEntity.ok(statuses);
} }
@DeleteMapping("/connections/{serviceType}") @DeleteMapping("/connections/{serviceType}")
public ResponseEntity<Void> logout(@PathVariable("serviceType") String serviceType, public ResponseEntity<Void> logout(@PathVariable("serviceType") ServiceType serviceType,
HttpServletRequest httpReq) { HttpServletRequest httpReq) {
HttpSession session = httpReq.getSession(false); HttpSession session = httpReq.getSession(false);
@@ -3,9 +3,10 @@ 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;
import jakarta.validation.constraints.NotNull;
public record ConnectionRequest(@NotBlank(message = "App URL is mandatory") String appUrl, public record ConnectionRequest(@NotBlank(message = "App URL is mandatory") String appUrl,
@NotBlank(message = "Service type is mandatory") String serviceType, @NotNull(message = "Service type is mandatory") ServiceType serviceType,
String username, String password, String apiKey, String username, String password, String apiKey,
@JsonProperty(defaultValue = "false") Boolean stayLoggedIn) { @JsonProperty(defaultValue = "false") Boolean stayLoggedIn) {
@@ -15,7 +16,7 @@ public record ConnectionRequest(@NotBlank(message = "App URL is mandatory") Stri
} }
} }
public ConnectionRequest(String appUrl, String serviceType, String username, public ConnectionRequest(String appUrl, ServiceType serviceType, String username,
String password, Boolean stayLoggedIn) { String password, Boolean stayLoggedIn) {
this(appUrl, serviceType, username, password, null, stayLoggedIn); this(appUrl, serviceType, username, password, null, stayLoggedIn);
} }
@@ -12,7 +12,7 @@ import com.vaessl.app.exception.WrongServiceTypeException;
@Service @Service
public class ConnectionService { public class ConnectionService {
private final Map<String, ConnectionProvider> providerRegistry; private final Map<ServiceType, ConnectionProvider> providerRegistry;
private final ConnectionRepository cRepository; private final ConnectionRepository cRepository;
@@ -48,7 +48,7 @@ public class ConnectionService {
return new LoginResult(saved.getId(), response.expiresAt()); return new LoginResult(saved.getId(), response.expiresAt());
} }
public ConnectionStatusResponse getConnectionStatus(String serviceType, Long connectionId) { public ConnectionStatusResponse getConnectionStatus(ServiceType serviceType, Long connectionId) {
ConnectionEntity entity = cRepository.findById(connectionId).orElse(null); ConnectionEntity entity = cRepository.findById(connectionId).orElse(null);
if (entity == null) if (entity == null)
return null; return null;
@@ -57,7 +57,7 @@ public class ConnectionService {
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(), entity.getUsername(), return new ConnectionStatusResponse(serviceType.name(), entity.getAppUrl(), entity.getUsername(),
expiresAt, connected); expiresAt, connected);
} }
} }
@@ -44,8 +44,8 @@ public class HomeboxConnectionProvider implements ConnectionProvider {
} }
@Override @Override
public String getServiceType() { public ServiceType getServiceType() {
return "HOMEBOX"; return ServiceType.HOMEBOX;
} }
@Override @Override
@@ -6,5 +6,5 @@ public interface ServiceProvider {
* Returns the service type key used to look up this provider in a registry, e.g. * Returns the service type key used to look up this provider in a registry, e.g.
* {@code "HOMEBOX"}. * {@code "HOMEBOX"}.
*/ */
String getServiceType(); ServiceType getServiceType();
} }
@@ -1,14 +1,5 @@
package com.vaessl.app.connection; package com.vaessl.app.connection;
import lombok.Getter;
@Getter
public enum ServiceType { public enum ServiceType {
HOMEBOX("HOMEBOX"); HOMEBOX;
private final String value;
private ServiceType(String value) {
this.value = value;
}
} }
@@ -6,7 +6,7 @@ public final class SessionKeys {
private SessionKeys() {} private SessionKeys() {}
public static String connectionId(String serviceType) { public static String connectionId(ServiceType serviceType) {
return serviceType + CONNECTION_ID_SUFFIX; return serviceType.name() + CONNECTION_ID_SUFFIX;
} }
} }
@@ -12,20 +12,15 @@ import org.springframework.web.client.RestClient;
import com.vaessl.app.connection.ConnectionEntity; import com.vaessl.app.connection.ConnectionEntity;
import com.vaessl.app.connection.ConnectionRepository; import com.vaessl.app.connection.ConnectionRepository;
import com.vaessl.app.connection.HomeboxEntity; import com.vaessl.app.connection.HomeboxEntity;
import com.vaessl.app.connection.ServiceType;
import com.vaessl.app.exception.ConnectionNotFoundException; import com.vaessl.app.exception.ConnectionNotFoundException;
import com.vaessl.app.exception.RemoteApiException; import com.vaessl.app.exception.RemoteApiException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static com.vaessl.app.connection.Endpoint.*; import static com.vaessl.app.connection.Endpoint.*;
@Component @Component
public class HomeboxSearchProvider implements SearchProvider { public class HomeboxSearchProvider implements SearchProvider {
// TODO: Remove logger before merge
private static final Logger log = LoggerFactory.getLogger(HomeboxSearchProvider.class);
private final RestClient.Builder restClientBuilder; private final RestClient.Builder restClientBuilder;
private final ConnectionRepository cRepository; private final ConnectionRepository cRepository;
@@ -37,8 +32,8 @@ public class HomeboxSearchProvider implements SearchProvider {
} }
@Override @Override
public String getServiceType() { public ServiceType getServiceType() {
return "HOMEBOX"; return ServiceType.HOMEBOX;
} }
@Override @Override
@@ -71,8 +66,6 @@ public class HomeboxSearchProvider implements SearchProvider {
return new SearchResponse(id, title, description, extraSearchResponseData); return new SearchResponse(id, title, description, extraSearchResponseData);
}).toList(); }).toList();
log.info("Search results for query '{}': {}", request.query(), items);
return new PageImpl<>(items, pageable, hbResponse.total()); return new PageImpl<>(items, pageable, hbResponse.total());
} }
@@ -1,7 +1,9 @@
package com.vaessl.app.search; package com.vaessl.app.search;
import com.vaessl.app.connection.ServiceType;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record SearchRequest(@NotBlank String appUrl, @NotBlank String username, String query, public record SearchRequest(@NotBlank String appUrl, @NotBlank String username, String query,
@NotBlank String serviceType) { @NotNull ServiceType serviceType) {
} }
@@ -7,13 +7,13 @@ import java.util.stream.Collectors;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.vaessl.app.connection.ServiceType;
import com.vaessl.app.exception.WrongServiceTypeException; import com.vaessl.app.exception.WrongServiceTypeException;
@Service @Service
public class SearchService { public class SearchService {
private final Map<String, SearchProvider> providerRegistry; private final Map<ServiceType, SearchProvider> providerRegistry;
public SearchService(List<SearchProvider> providers) { public SearchService(List<SearchProvider> providers) {
this.providerRegistry = Map.copyOf(providers.stream() this.providerRegistry = Map.copyOf(providers.stream()
@@ -1,11 +1,13 @@
package com.vaessl.app; package com.vaessl.app;
import com.vaessl.app.connection.ServiceType;
public final class Mockdata { public final class Mockdata {
private Mockdata() {} private Mockdata() {}
public static final String MOCK_URL = "http://localhost:1234"; public static final String MOCK_URL = "http://localhost:1234";
public static final String MOCK_SERVICE_TYPE = "SERVICE_TYPE"; public static final ServiceType MOCK_SERVICE_TYPE = ServiceType.HOMEBOX;
public static final String MOCK_USER = "user"; public static final String MOCK_USER = "user";
public static final String MOCK_PASS = "pw"; public static final String MOCK_PASS = "pw";
public static final String MOCK_ID = "item-1"; public static final String MOCK_ID = "item-1";
@@ -32,6 +32,7 @@ class ConnectionControllerTest {
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";
private static final String SESSION = "SESSION";
private static final String VALID_HOMEBOX_RESPONSE = """ private static final String VALID_HOMEBOX_RESPONSE = """
{ {
@@ -66,11 +67,11 @@ class ConnectionControllerTest {
.content(connectionRequestBody(wm))) .content(connectionRequestBody(wm)))
.andExpect(status().isOk()).andReturn(); .andExpect(status().isOk()).andReturn();
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION"); Cookie sessionCookie = loginResult.getResponse().getCookie(SESSION);
mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)).andExpect(status().isOk()) mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)).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.name()))
.andExpect(jsonPath("$[0].username").value(MOCK_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));
@@ -87,10 +88,10 @@ class ConnectionControllerTest {
.content(connectionRequestBody(wm))) .content(connectionRequestBody(wm)))
.andExpect(status().isOk()).andReturn(); .andExpect(status().isOk()).andReturn();
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION"); Cookie sessionCookie = loginResult.getResponse().getCookie(SESSION);
mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)).andExpect(status().isOk()) mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)).andExpect(status().isOk())
.andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.getValue())) .andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.name()))
.andExpect(jsonPath("$[0].connected").value(false)) .andExpect(jsonPath("$[0].connected").value(false))
.andExpect(jsonPath("$[0].expiresAt") .andExpect(jsonPath("$[0].expiresAt")
.value("2000-01-01T00:00:00Z")); .value("2000-01-01T00:00:00Z"));
@@ -106,7 +107,7 @@ class ConnectionControllerTest {
.content(connectionRequestBody(wm))) .content(connectionRequestBody(wm)))
.andExpect(status().isOk()).andReturn(); .andExpect(status().isOk()).andReturn();
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION"); Cookie sessionCookie = loginResult.getResponse().getCookie(SESSION);
mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie)) mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
@@ -122,7 +123,7 @@ class ConnectionControllerTest {
.content(connectionRequestBody(wm))) .content(connectionRequestBody(wm)))
.andExpect(status().isOk()).andReturn(); .andExpect(status().isOk()).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)).andExpect(status().isOk()) mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)).andExpect(status().isOk())
@@ -6,6 +6,7 @@ import org.junit.jupiter.api.Test;
import com.vaessl.app.exception.EmptyCredentialsException; import com.vaessl.app.exception.EmptyCredentialsException;
import static com.vaessl.app.Mockdata.*; import static com.vaessl.app.Mockdata.*;
import static com.vaessl.app.connection.ServiceType.HOMEBOX;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
class HomeboxConnectionProviderTest { class HomeboxConnectionProviderTest {
@@ -13,8 +14,9 @@ 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 checkCredentialsShouldThrowExceptionWhenFieldsAreMissing() {
ConnectionRequest request = new ConnectionRequest(MOCK_URL, "HOMEBOX", null, null, false); ConnectionRequest request =
new ConnectionRequest(MOCK_URL, HOMEBOX, null, null, false);
EmptyCredentialsException exception = assertThrows(EmptyCredentialsException.class, () -> { EmptyCredentialsException exception = assertThrows(EmptyCredentialsException.class, () -> {
provider.checkCredentials(request); provider.checkCredentials(request);
@@ -6,7 +6,11 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.resttestclient.TestRestTemplate; import org.springframework.boot.resttestclient.TestRestTemplate;
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import com.github.tomakehurst.wiremock.http.Fault; import com.github.tomakehurst.wiremock.http.Fault;
@@ -52,8 +56,8 @@ class HomeboxIntegrationTest {
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(okJson(okJsonHomeboxResponse))); stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(okJson(okJsonHomeboxResponse)));
ResponseEntity<String> response = ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(),
restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), String.class); connectionRequest(wm), String.class);
DocumentContext documentContext = JsonPath.parse(response.getBody()); DocumentContext documentContext = JsonPath.parse(response.getBody());
@@ -75,8 +79,8 @@ class HomeboxIntegrationTest {
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(unauthorized())); stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(unauthorized()));
ResponseEntity<String> response = ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(),
restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), String.class); 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());
@@ -92,8 +96,8 @@ class HomeboxIntegrationTest {
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(serviceUnavailable())); stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(serviceUnavailable()));
ResponseEntity<String> response = ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(),
restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), String.class); 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());
@@ -109,11 +113,12 @@ 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 = ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(),
restTemplate.postForEntity(LOGIN.getValue(), badRequest, String.class); 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());
} }
/** /**
@@ -122,10 +127,10 @@ class HomeboxIntegrationTest {
@Test @Test
void shouldReturnBadRequestWhenHomeboxFieldsAreEmpty() { void shouldReturnBadRequestWhenHomeboxFieldsAreEmpty() {
ConnectionRequest emtpyRequest = new ConnectionRequest("", "", "", "", false); ConnectionRequest emtpyRequest = new ConnectionRequest("", null, "", "", false);
ResponseEntity<String> response = ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(),
restTemplate.postForEntity(LOGIN.getValue(), emtpyRequest, String.class); 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());
@@ -135,15 +140,20 @@ class HomeboxIntegrationTest {
* Test the exception when there is an input for serviceType but it's unsupported. * Test the exception when there is an input for serviceType but it's unsupported.
*/ */
@Test @Test
void shouldReturnWrongServiceTypeException(WireMockRuntimeInfo wm) { void shouldReturnBadRequestWhenServiceTypeIsInvalid(WireMockRuntimeInfo wm) {
ConnectionRequest wrongServiceTypeReq = new ConnectionRequest(wm.getHttpBaseUrl(), String body = """
"wrong-service-type", MOCK_USER, MOCK_PASS, false); {"appUrl":"%s","serviceType":"wrong-service-type","username":"%s","password":"%s"}
"""
.formatted(wm.getHttpBaseUrl(), MOCK_USER, MOCK_PASS);
ResponseEntity<String> response = HttpHeaders headers = new HttpHeaders();
restTemplate.postForEntity(LOGIN.getValue(), wrongServiceTypeReq, String.class); headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> entity = new HttpEntity<>(body, headers);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); ResponseEntity<String> response = restTemplate.exchange(LOGIN.getValue(),
assertThat(response.getBody()).contains(WRONG_SERVICE_TYPE.getMessage()); HttpMethod.POST, entity, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
} }
/** /**
@@ -163,8 +173,8 @@ class HomeboxIntegrationTest {
restTemplate.postForEntity(LOGIN.getValue(), request, String.class); restTemplate.postForEntity(LOGIN.getValue(), request, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
ConnectionEntity dbEntry = ConnectionEntity dbEntry = cRepository.findByAppUrlAndUsername(request.appUrl(),
cRepository.findByAppUrlAndUsername(request.appUrl(), request.username()); request.username());
assertThat(dbEntry).isNotNull(); assertThat(dbEntry).isNotNull();
assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl()); assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl());
@@ -173,17 +183,18 @@ class HomeboxIntegrationTest {
if (dbEntry instanceof HomeboxEntity hbE) { if (dbEntry instanceof HomeboxEntity hbE) {
assertThat(hbE.getToken()).isEqualTo("fake-jwt-token"); assertThat(hbE.getToken()).isEqualTo("fake-jwt-token");
assertThat(hbE.getAttachmentToken()).isEqualTo("fake-attach"); assertThat(hbE.getAttachmentToken()).isEqualTo("fake-attach");
assertThat(hbE.getExpiresAt().toString()).hasToString("2026-04-26T02:23:13Z"); assertThat(hbE.getExpiresAt().toString())
.hasToString("2026-04-26T02:23:13Z");
} }
} }
@Test @Test
void shouldReturnEmptyCredentialsExceptionWhenCredsAreMissing(WireMockRuntimeInfo wm) { void shouldReturnEmptyCredentialsExceptionWhenCredsAreMissing(WireMockRuntimeInfo wm) {
ConnectionRequest missingCredentials = new ConnectionRequest(wm.getHttpBaseUrl(), ConnectionRequest missingCredentials = new ConnectionRequest(wm.getHttpBaseUrl(),
HOMEBOX.getValue(), MOCK_USER, null, false); HOMEBOX, MOCK_USER, null, false);
ResponseEntity<String> response = ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(),
restTemplate.postForEntity(LOGIN.getValue(), missingCredentials, String.class); 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());
@@ -196,7 +207,7 @@ class HomeboxIntegrationTest {
* @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(), MOCK_USER, MOCK_PASS, return new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX, MOCK_USER, MOCK_PASS,
null); null);
} }
} }
@@ -12,6 +12,8 @@ import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import com.vaessl.app.connection.ConnectionRepository; import com.vaessl.app.connection.ConnectionRepository;
import com.vaessl.app.exception.ConnectionNotFoundException; import com.vaessl.app.exception.ConnectionNotFoundException;
import static com.vaessl.app.connection.ServiceType.HOMEBOX;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class HomeboxSearchProviderTest { class HomeboxSearchProviderTest {
@@ -27,7 +29,7 @@ class HomeboxSearchProviderTest {
when(mockRepo.findByAppUrlAndUsername(MOCK_URL, MOCK_USER)).thenReturn(null); when(mockRepo.findByAppUrlAndUsername(MOCK_URL, MOCK_USER)).thenReturn(null);
SearchRequest request = new SearchRequest(MOCK_URL, MOCK_USER, "test query", "HOMEBOX"); SearchRequest request = new SearchRequest(MOCK_URL, MOCK_USER, "test query", HOMEBOX);
Pageable pageable = PageRequest.of(0, 10); Pageable pageable = PageRequest.of(0, 10);
assertThrows(ConnectionNotFoundException.class, assertThrows(ConnectionNotFoundException.class,
() -> provider.getSearchResults(request, pageable)); () -> provider.getSearchResults(request, pageable));
@@ -110,25 +110,21 @@ class SearchControllerTest {
} }
@Test @Test
void shouldReturnUnauthorizedWhenSessionHasNoConnectionForRequestedServiceType( void shouldReturnBadRequestWhenServiceTypeIsInvalid() throws Exception {
WireMockRuntimeInfo wm) throws Exception { mockMvc.perform(post(SEARCH_REQUEST).contentType(MediaType.APPLICATION_JSON)
.content(searchRequestBody("INVALID_SERVICETYPE")))
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()) .andExpect(status().isBadRequest());
.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) { private String searchRequestBody(WireMockRuntimeInfo wm, String serviceType) {
return searchRequestBody(wm.getHttpBaseUrl(), serviceType);
}
private String searchRequestBody(String serviceType) {
return searchRequestBody("http://irrelevant", serviceType);
}
private String searchRequestBody(String appUrl, String serviceType) {
return """ return """
{ {
"appUrl": "%s", "appUrl": "%s",
@@ -136,7 +132,7 @@ class SearchControllerTest {
"serviceType": "%s", "serviceType": "%s",
"username": "%s" "username": "%s"
} }
""".formatted(wm.getHttpBaseUrl(), serviceType, MOCK_USER); """.formatted(appUrl, serviceType, MOCK_USER);
} }
private String connectionRequestBody(WireMockRuntimeInfo wm) { private String connectionRequestBody(WireMockRuntimeInfo wm) {
+2 -2
View File
@@ -1,5 +1,5 @@
import { apiFetch } from './client' import { apiFetch } from './client'
import type { AuthResponse, ConnectionStatus, LoginRequest } from '../types/connection' import type { AuthResponse, ConnectionStatus, LoginRequest, ServiceType } from '../types/connection'
export const login = (req: LoginRequest) => export const login = (req: LoginRequest) =>
apiFetch<AuthResponse>('/login', { method: 'POST', body: JSON.stringify(req) }) apiFetch<AuthResponse>('/login', { method: 'POST', body: JSON.stringify(req) })
@@ -7,5 +7,5 @@ export const login = (req: LoginRequest) =>
export const getStatuses = () => export const getStatuses = () =>
apiFetch<ConnectionStatus[]>('/connections/status') apiFetch<ConnectionStatus[]>('/connections/status')
export const logout = (serviceType: string) => export const logout = (serviceType: ServiceType) =>
apiFetch<void>(`/connections/${serviceType}`, { method: 'DELETE' }) apiFetch<void>(`/connections/${serviceType}`, { method: 'DELETE' })
@@ -1,10 +1,10 @@
import { useEffect, useRef, useState, type SyntheticEvent } from 'react' import { useEffect, useRef, useState, type SyntheticEvent } from 'react'
import { login } from '../../api/connections' import { login } from '../../api/connections'
import type { LoginRequest } from '../../types/connection' import type { LoginRequest, ServiceType } from '../../types/connection'
import '../ui/Modal.scss' import '../ui/Modal.scss'
interface Props { interface Props {
serviceType: string serviceType: ServiceType
label: string label: string
onClose: () => void onClose: () => void
onSuccess: () => void onSuccess: () => void
@@ -1,13 +1,13 @@
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { getStatuses, logout } from "../../api/connections" import { getStatuses, logout } from "../../api/connections"
import type { ConnectionStatus } from "../../types/connection" import { ServiceType, type ConnectionStatus } from "../../types/connection"
import { ServiceCard } from "./ServiceCard" import { ServiceCard } from "./ServiceCard"
import { ConnectModal } from "./ConnectModal" import { ConnectModal } from "./ConnectModal"
import "./Dashboard.scss" import "./Dashboard.scss"
import { SearchModal } from "../search/SearchModal" import { SearchModal } from "../search/SearchModal"
const SERVICES = [ const SERVICES = [
{ serviceType: 'HOMEBOX', label: 'Homebox', icon: '📦' }, { serviceType: ServiceType.HOMEBOX, label: 'Homebox', icon: '📦' },
] as const ] as const
export function Dashboard() { export function Dashboard() {
@@ -21,7 +21,7 @@ export function Dashboard() {
useEffect(() => { refresh() }, []) useEffect(() => { refresh() }, [])
const handleDisconnect = (serviceType: string) => { const handleDisconnect = (serviceType: ServiceType) => {
logout(serviceType).then(refresh).catch(() => { }) logout(serviceType).then(refresh).catch(() => { })
} }
@@ -1,9 +1,9 @@
import type { ConnectionStatus } from '../../types/connection' import type { ConnectionStatus, ServiceType } from '../../types/connection'
import { ActionButton } from '../ui/ActionButton' import { ActionButton } from '../ui/ActionButton'
import './ServiceCard.scss' import './ServiceCard.scss'
interface Props { interface Props {
serviceType: string serviceType: ServiceType
label: string label: string
icon: string icon: string
status: ConnectionStatus | null status: ConnectionStatus | null
@@ -2,9 +2,10 @@ import { useEffect, useRef, useState, type SyntheticEvent } from 'react'
import '../ui/Modal.scss' import '../ui/Modal.scss'
import { type PagedSearchResponse, type SearchResponse, type SearchRequest } from '../../types/search' import { type PagedSearchResponse, type SearchResponse, type SearchRequest } from '../../types/search'
import { search } from '../../api/searches' import { search } from '../../api/searches'
import type { ServiceType } from '../../types/connection'
interface Props { interface Props {
serviceType: string serviceType: ServiceType
label: string label: string
appUrl: string appUrl: string
username: string username: string
+18 -12
View File
@@ -1,20 +1,26 @@
export const ServiceType = {
HOMEBOX: "HOMEBOX",
} as const;
export type ServiceType = (typeof ServiceType)[keyof typeof ServiceType];
export interface LoginRequest { export interface LoginRequest {
appUrl: string appUrl: string;
serviceType: string serviceType: ServiceType;
username: string username: string;
password: string password: string;
stayLoggedIn: boolean stayLoggedIn: boolean;
} }
export interface AuthResponse { export interface AuthResponse {
serviceType: string serviceType: ServiceType;
expiresAt: string expiresAt: string;
} }
export interface ConnectionStatus { export interface ConnectionStatus {
serviceType: string serviceType: ServiceType;
appUrl: string appUrl: string;
username: string username: string;
expiresAt: string | null expiresAt: string | null;
connected: boolean connected: boolean;
} }
+17 -15
View File
@@ -1,23 +1,25 @@
import type { ServiceType } from "./connection";
export interface SearchRequest { export interface SearchRequest {
appUrl: string; appUrl: string
serviceType: string; serviceType: ServiceType
username: string; username: string
query: string | null; query: string | null
} }
export interface SearchResponse { export interface SearchResponse {
id: string; id: string
title: string; title: string
description: string | null; description: string | null
extraData: Record<string, unknown>; extraData: Record<string, unknown>
} }
export interface PagedSearchResponse<T> { export interface PagedSearchResponse<T> {
content: T[]; content: T[]
page: number; page: number
pageSize: number; pageSize: number
totalElements: number; totalElements: number
first: boolean; first: boolean
last: boolean; last: boolean
sort: string; sort: string
} }