From fb64a7787f40f7d791e05d0cfb2a8294f8c7b115 Mon Sep 17 00:00:00 2001 From: kasun Date: Wed, 24 Jun 2026 23:25:18 +0200 Subject: [PATCH] refactored to make use of the ServiceType enum throughout backend and frontend for typesafety --- .../vaessl/app/connection/AuthResponse.java | 2 +- .../app/connection/ConnectionController.java | 19 +- .../app/connection/ConnectionRequest.java | 5 +- .../app/connection/ConnectionService.java | 6 +- .../connection/HomeboxConnectionProvider.java | 4 +- .../app/connection/ServiceProvider.java | 2 +- .../vaessl/app/connection/ServiceType.java | 11 +- .../vaessl/app/connection/SessionKeys.java | 4 +- .../app/search/HomeboxSearchProvider.java | 13 +- .../com/vaessl/app/search/SearchRequest.java | 4 +- .../com/vaessl/app/search/SearchService.java | 4 +- .../test/java/com/vaessl/app/Mockdata.java | 4 +- .../connection/ConnectionControllerTest.java | 13 +- .../HomeboxConnectionProviderTest.java | 6 +- .../connection/HomeboxIntegrationTest.java | 327 +++++++++--------- .../app/search/HomeboxSearchProviderTest.java | 4 +- .../app/search/SearchControllerTest.java | 30 +- frontend/src/api/connections.ts | 4 +- .../components/connections/ConnectModal.tsx | 4 +- .../src/components/connections/Dashboard.tsx | 6 +- .../components/connections/ServiceCard.tsx | 4 +- .../src/components/search/SearchModal.tsx | 3 +- frontend/src/types/connection.ts | 30 +- frontend/src/types/search.ts | 32 +- 24 files changed, 278 insertions(+), 263 deletions(-) diff --git a/backend/src/main/java/com/vaessl/app/connection/AuthResponse.java b/backend/src/main/java/com/vaessl/app/connection/AuthResponse.java index 0bdda64..bbdbcab 100644 --- a/backend/src/main/java/com/vaessl/app/connection/AuthResponse.java +++ b/backend/src/main/java/com/vaessl/app/connection/AuthResponse.java @@ -2,5 +2,5 @@ package com.vaessl.app.connection; import java.time.Instant; -public record AuthResponse(String serviceType, Instant expiresAt) { +public record AuthResponse(ServiceType serviceType, Instant expiresAt) { } diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java index e639db2..28219c4 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java @@ -53,19 +53,24 @@ public class ConnectionController { Collections.list(session.getAttributeNames()).stream() .filter(k -> k.endsWith(SessionKeys.CONNECTION_ID_SUFFIX)) .forEach(k -> { - String serviceType = k.replace(SessionKeys.CONNECTION_ID_SUFFIX, ""); - Long id = (Long) session.getAttribute(k); - ConnectionStatusResponse status = - connectionService.getConnectionStatus(serviceType, id); - if (status != null) - statuses.add(status); + String name = k.replace(SessionKeys.CONNECTION_ID_SUFFIX, ""); + try { + ServiceType serviceType = ServiceType.valueOf(name); + Long id = (Long) session.getAttribute(k); + ConnectionStatusResponse status = + connectionService.getConnectionStatus(serviceType, id); + if (status != null) + statuses.add(status); + } catch (IllegalArgumentException _) { + // stale session key from an old or removed service type — skip it + } }); return ResponseEntity.ok(statuses); } @DeleteMapping("/connections/{serviceType}") - public ResponseEntity logout(@PathVariable("serviceType") String serviceType, + public ResponseEntity logout(@PathVariable("serviceType") ServiceType serviceType, HttpServletRequest httpReq) { HttpSession session = httpReq.getSession(false); diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionRequest.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionRequest.java index 9edfa5b..ec265ba 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionRequest.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionRequest.java @@ -3,9 +3,10 @@ package com.vaessl.app.connection; import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; 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, @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) { this(appUrl, serviceType, username, password, null, stayLoggedIn); } diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java index dab76b2..f4ecccc 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java @@ -12,7 +12,7 @@ import com.vaessl.app.exception.WrongServiceTypeException; @Service public class ConnectionService { - private final Map providerRegistry; + private final Map providerRegistry; private final ConnectionRepository cRepository; @@ -48,7 +48,7 @@ public class ConnectionService { 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); if (entity == null) return null; @@ -57,7 +57,7 @@ public class ConnectionService { Instant expiresAt = (provider != null) ? provider.getTokenExpiry(entity) : null; 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); } } diff --git a/backend/src/main/java/com/vaessl/app/connection/HomeboxConnectionProvider.java b/backend/src/main/java/com/vaessl/app/connection/HomeboxConnectionProvider.java index 6f664ff..6467eb2 100644 --- a/backend/src/main/java/com/vaessl/app/connection/HomeboxConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/HomeboxConnectionProvider.java @@ -44,8 +44,8 @@ public class HomeboxConnectionProvider implements ConnectionProvider { } @Override - public String getServiceType() { - return "HOMEBOX"; + public ServiceType getServiceType() { + return ServiceType.HOMEBOX; } @Override diff --git a/backend/src/main/java/com/vaessl/app/connection/ServiceProvider.java b/backend/src/main/java/com/vaessl/app/connection/ServiceProvider.java index b5af34f..ad63c42 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ServiceProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/ServiceProvider.java @@ -6,5 +6,5 @@ public interface ServiceProvider { * Returns the service type key used to look up this provider in a registry, e.g. * {@code "HOMEBOX"}. */ - String getServiceType(); + ServiceType getServiceType(); } diff --git a/backend/src/main/java/com/vaessl/app/connection/ServiceType.java b/backend/src/main/java/com/vaessl/app/connection/ServiceType.java index 4c3fc5f..1d6c99a 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ServiceType.java +++ b/backend/src/main/java/com/vaessl/app/connection/ServiceType.java @@ -1,14 +1,5 @@ package com.vaessl.app.connection; -import lombok.Getter; - -@Getter public enum ServiceType { - HOMEBOX("HOMEBOX"); - - private final String value; - - private ServiceType(String value) { - this.value = value; - } + HOMEBOX; } diff --git a/backend/src/main/java/com/vaessl/app/connection/SessionKeys.java b/backend/src/main/java/com/vaessl/app/connection/SessionKeys.java index 2566df2..1760be5 100644 --- a/backend/src/main/java/com/vaessl/app/connection/SessionKeys.java +++ b/backend/src/main/java/com/vaessl/app/connection/SessionKeys.java @@ -6,7 +6,7 @@ public final class SessionKeys { private SessionKeys() {} - public static String connectionId(String serviceType) { - return serviceType + CONNECTION_ID_SUFFIX; + public static String connectionId(ServiceType serviceType) { + return serviceType.name() + CONNECTION_ID_SUFFIX; } } diff --git a/backend/src/main/java/com/vaessl/app/search/HomeboxSearchProvider.java b/backend/src/main/java/com/vaessl/app/search/HomeboxSearchProvider.java index b494f36..7be5851 100644 --- a/backend/src/main/java/com/vaessl/app/search/HomeboxSearchProvider.java +++ b/backend/src/main/java/com/vaessl/app/search/HomeboxSearchProvider.java @@ -12,20 +12,15 @@ 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.connection.ServiceType; import com.vaessl.app.exception.ConnectionNotFoundException; import com.vaessl.app.exception.RemoteApiException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import static com.vaessl.app.connection.Endpoint.*; @Component 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 ConnectionRepository cRepository; @@ -37,8 +32,8 @@ public class HomeboxSearchProvider implements SearchProvider { } @Override - public String getServiceType() { - return "HOMEBOX"; + public ServiceType getServiceType() { + return ServiceType.HOMEBOX; } @Override @@ -71,8 +66,6 @@ public class HomeboxSearchProvider implements SearchProvider { return new SearchResponse(id, title, description, extraSearchResponseData); }).toList(); - log.info("Search results for query '{}': {}", request.query(), items); - return new PageImpl<>(items, pageable, hbResponse.total()); } diff --git a/backend/src/main/java/com/vaessl/app/search/SearchRequest.java b/backend/src/main/java/com/vaessl/app/search/SearchRequest.java index 5019db7..0e7a505 100644 --- a/backend/src/main/java/com/vaessl/app/search/SearchRequest.java +++ b/backend/src/main/java/com/vaessl/app/search/SearchRequest.java @@ -1,7 +1,9 @@ package com.vaessl.app.search; +import com.vaessl.app.connection.ServiceType; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; public record SearchRequest(@NotBlank String appUrl, @NotBlank String username, String query, - @NotBlank String serviceType) { + @NotNull ServiceType serviceType) { } diff --git a/backend/src/main/java/com/vaessl/app/search/SearchService.java b/backend/src/main/java/com/vaessl/app/search/SearchService.java index f195f39..f92efde 100644 --- a/backend/src/main/java/com/vaessl/app/search/SearchService.java +++ b/backend/src/main/java/com/vaessl/app/search/SearchService.java @@ -7,13 +7,13 @@ 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.connection.ServiceType; import com.vaessl.app.exception.WrongServiceTypeException; @Service public class SearchService { - private final Map providerRegistry; + private final Map providerRegistry; public SearchService(List providers) { this.providerRegistry = Map.copyOf(providers.stream() diff --git a/backend/src/test/java/com/vaessl/app/Mockdata.java b/backend/src/test/java/com/vaessl/app/Mockdata.java index 6e0c411..eefb159 100644 --- a/backend/src/test/java/com/vaessl/app/Mockdata.java +++ b/backend/src/test/java/com/vaessl/app/Mockdata.java @@ -1,11 +1,13 @@ package com.vaessl.app; +import com.vaessl.app.connection.ServiceType; + 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 ServiceType MOCK_SERVICE_TYPE = ServiceType.HOMEBOX; public static final String MOCK_USER = "user"; public static final String MOCK_PASS = "pw"; public static final String MOCK_ID = "item-1"; diff --git a/backend/src/test/java/com/vaessl/app/connection/ConnectionControllerTest.java b/backend/src/test/java/com/vaessl/app/connection/ConnectionControllerTest.java index 95b2a5d..4a12c92 100644 --- a/backend/src/test/java/com/vaessl/app/connection/ConnectionControllerTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/ConnectionControllerTest.java @@ -32,6 +32,7 @@ class ConnectionControllerTest { private static final String LOGIN_PATH = LOGIN.getValue(); private static final String STATUS_PATH = CONNECTION_STATUS.getValue(); private static final String LOGOUT_PATH = "/connections/HOMEBOX"; + private static final String SESSION = "SESSION"; private static final String VALID_HOMEBOX_RESPONSE = """ { @@ -66,11 +67,11 @@ class ConnectionControllerTest { .content(connectionRequestBody(wm))) .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()) .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].appUrl").value(wm.getHttpBaseUrl())) .andExpect(jsonPath("$[0].connected").value(true)); @@ -87,10 +88,10 @@ class ConnectionControllerTest { .content(connectionRequestBody(wm))) .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()) - .andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.getValue())) + .andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.name())) .andExpect(jsonPath("$[0].connected").value(false)) .andExpect(jsonPath("$[0].expiresAt") .value("2000-01-01T00:00:00Z")); @@ -106,7 +107,7 @@ class ConnectionControllerTest { .content(connectionRequestBody(wm))) .andExpect(status().isOk()).andReturn(); - Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION"); + Cookie sessionCookie = loginResult.getResponse().getCookie(SESSION); mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie)) .andExpect(status().isNoContent()); @@ -122,7 +123,7 @@ class ConnectionControllerTest { .content(connectionRequestBody(wm))) .andExpect(status().isOk()).andReturn(); - Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION"); + Cookie sessionCookie = loginResult.getResponse().getCookie(SESSION); // Verify connected before logout mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)).andExpect(status().isOk()) diff --git a/backend/src/test/java/com/vaessl/app/connection/HomeboxConnectionProviderTest.java b/backend/src/test/java/com/vaessl/app/connection/HomeboxConnectionProviderTest.java index e9e08b4..4fa9ece 100644 --- a/backend/src/test/java/com/vaessl/app/connection/HomeboxConnectionProviderTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/HomeboxConnectionProviderTest.java @@ -6,6 +6,7 @@ import org.junit.jupiter.api.Test; import com.vaessl.app.exception.EmptyCredentialsException; import static com.vaessl.app.Mockdata.*; +import static com.vaessl.app.connection.ServiceType.HOMEBOX; import static org.assertj.core.api.Assertions.assertThat; class HomeboxConnectionProviderTest { @@ -13,8 +14,9 @@ class HomeboxConnectionProviderTest { private final HomeboxConnectionProvider provider = new HomeboxConnectionProvider(null, null); @Test - void checkCredentials_ShouldThrowException_WhenFieldsAreMissing() { - ConnectionRequest request = new ConnectionRequest(MOCK_URL, "HOMEBOX", null, null, false); + void checkCredentialsShouldThrowExceptionWhenFieldsAreMissing() { + ConnectionRequest request = + new ConnectionRequest(MOCK_URL, HOMEBOX, null, null, false); EmptyCredentialsException exception = assertThrows(EmptyCredentialsException.class, () -> { provider.checkCredentials(request); diff --git a/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java b/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java index 7f1c2ed..e0e7bcf 100644 --- a/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java @@ -6,7 +6,11 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.resttestclient.TestRestTemplate; import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; 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.MediaType; import org.springframework.http.ResponseEntity; import com.github.tomakehurst.wiremock.http.Fault; @@ -28,175 +32,182 @@ import static com.vaessl.app.connection.ServiceType.*; @WireMockTest class HomeboxIntegrationTest { - @Autowired - TestRestTemplate restTemplate; + @Autowired + TestRestTemplate restTemplate; - @Autowired - ConnectionRepository cRepository; + @Autowired + ConnectionRepository cRepository; - String okJsonHomeboxResponse = """ - { - "token": "fake-jwt-token", - "attachmentToken": "fake-attach", - "expiresAt": "2026-04-26T02:23:13Z" - } - """; + String okJsonHomeboxResponse = """ + { + "token": "fake-jwt-token", + "attachmentToken": "fake-attach", + "expiresAt": "2026-04-26T02:23:13Z" + } + """; - /** - * Returns Token and status code OK when login is successful. - * - * @param wm the WiremockRuntimeInfo object - */ - @Test - void shouldReturnStatusOkWhenHomeboxCredentialsAreValid(WireMockRuntimeInfo wm) { + /** + * Returns Token and status code OK when login is successful. + * + * @param wm the WiremockRuntimeInfo object + */ + @Test + void shouldReturnStatusOkWhenHomeboxCredentialsAreValid(WireMockRuntimeInfo wm) { - stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(okJson(okJsonHomeboxResponse))); + stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(okJson(okJsonHomeboxResponse))); - ResponseEntity response = - restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), String.class); + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), + connectionRequest(wm), String.class); - DocumentContext documentContext = JsonPath.parse(response.getBody()); + DocumentContext documentContext = JsonPath.parse(response.getBody()); - String serviceType = documentContext.read("$.serviceType"); - assertThat(serviceType).isEqualTo("HOMEBOX"); + String serviceType = documentContext.read("$.serviceType"); + assertThat(serviceType).isEqualTo("HOMEBOX"); - String expiresAt = documentContext.read("$.expiresAt", String.class); - assertThat(expiresAt).isEqualTo("2026-04-26T02:23:13Z"); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - } - - /** - * Tests the Unauthorized custom exception. - * - * @param wm the WiremockRuntimeInfo object - */ - @Test - void shouldFailToConnectWhenHomeboxReturnsUnauthorized(WireMockRuntimeInfo wm) { - - stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(unauthorized())); - - ResponseEntity response = - restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), String.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - assertThat(response.getBody()).contains(UNAUTHORIZED_WRONG_LOGIN.getMessage()); - } - - /** - * Tests a server error from the external api. - * - * @param wm the WiremockRuntimeInfo object - */ - @Test - void shouldFailToConnectWhenHomeboxReturnsServiceUnavailable(WireMockRuntimeInfo wm) { - - stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(serviceUnavailable())); - - ResponseEntity response = - restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), String.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); - assertThat(response.getBody()).contains(SERVER_ERROR_GENERAL.getMessage()); - } - - /** - * Checks when the service is unavailable or the app URL is wrong. - */ - @Test - void shouldReturnServiceUnavailableWhenHomeboxUrlIsWrong(WireMockRuntimeInfo wm) { - - stubFor(post(HOMEBOX_LOGIN.getValue()) - .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))); - - ConnectionRequest badRequest = connectionRequest(wm); - ResponseEntity response = - restTemplate.postForEntity(LOGIN.getValue(), badRequest, String.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); - assertThat(response.getBody()).contains(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage()); - } - - /** - * Checks if any login fields are empty since all of them are mandatory. - */ - @Test - void shouldReturnBadRequestWhenHomeboxFieldsAreEmpty() { - - ConnectionRequest emtpyRequest = new ConnectionRequest("", "", "", "", false); - - ResponseEntity response = - restTemplate.postForEntity(LOGIN.getValue(), emtpyRequest, String.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage()); - } - - /** - * Test the exception when there is an input for serviceType but it's unsupported. - */ - @Test - void shouldReturnWrongServiceTypeException(WireMockRuntimeInfo wm) { - ConnectionRequest wrongServiceTypeReq = new ConnectionRequest(wm.getHttpBaseUrl(), - "wrong-service-type", MOCK_USER, MOCK_PASS, false); - - ResponseEntity response = - restTemplate.postForEntity(LOGIN.getValue(), wrongServiceTypeReq, String.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); - assertThat(response.getBody()).contains(WRONG_SERVICE_TYPE.getMessage()); - } - - /** - * Tests the succesfull persistance of Homebox credential response to the database. - * - * @param wm the WiremockRuntimeInfo object - */ - @Test - void shouldSaveHomeboxConnectionResponseToDb(WireMockRuntimeInfo wm) { - - stubFor(post(urlEqualTo(HOMEBOX_LOGIN.getValue())) - .willReturn(okJson(okJsonHomeboxResponse))); - - ConnectionRequest request = connectionRequest(wm); - - ResponseEntity response = - restTemplate.postForEntity(LOGIN.getValue(), request, String.class); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - - ConnectionEntity dbEntry = - cRepository.findByAppUrlAndUsername(request.appUrl(), request.username()); - - assertThat(dbEntry).isNotNull(); - assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl()); - assertThat(dbEntry.getUsername()).isEqualTo(request.username()); - - if (dbEntry instanceof HomeboxEntity hbE) { - assertThat(hbE.getToken()).isEqualTo("fake-jwt-token"); - assertThat(hbE.getAttachmentToken()).isEqualTo("fake-attach"); - assertThat(hbE.getExpiresAt().toString()).hasToString("2026-04-26T02:23:13Z"); + String expiresAt = documentContext.read("$.expiresAt", String.class); + assertThat(expiresAt).isEqualTo("2026-04-26T02:23:13Z"); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); } - } - @Test - void shouldReturnEmptyCredentialsExceptionWhenCredsAreMissing(WireMockRuntimeInfo wm) { - ConnectionRequest missingCredentials = new ConnectionRequest(wm.getHttpBaseUrl(), - HOMEBOX.getValue(), MOCK_USER, null, false); + /** + * Tests the Unauthorized custom exception. + * + * @param wm the WiremockRuntimeInfo object + */ + @Test + void shouldFailToConnectWhenHomeboxReturnsUnauthorized(WireMockRuntimeInfo wm) { - ResponseEntity response = - restTemplate.postForEntity(LOGIN.getValue(), missingCredentials, String.class); + stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(unauthorized())); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage()); - } + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), + connectionRequest(wm), String.class); - /** - * Creates a valid connection request with a mock Api through WireMockRuntimeInfo. - * - * @param wm the WiremockRuntimeInfo object - * @return a mock api connection request. - */ - private ConnectionRequest connectionRequest(WireMockRuntimeInfo wm) { - return new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX.getValue(), MOCK_USER, MOCK_PASS, - null); - } + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(response.getBody()).contains(UNAUTHORIZED_WRONG_LOGIN.getMessage()); + } + + /** + * Tests a server error from the external api. + * + * @param wm the WiremockRuntimeInfo object + */ + @Test + void shouldFailToConnectWhenHomeboxReturnsServiceUnavailable(WireMockRuntimeInfo wm) { + + stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(serviceUnavailable())); + + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), + connectionRequest(wm), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(response.getBody()).contains(SERVER_ERROR_GENERAL.getMessage()); + } + + /** + * Checks when the service is unavailable or the app URL is wrong. + */ + @Test + void shouldReturnServiceUnavailableWhenHomeboxUrlIsWrong(WireMockRuntimeInfo wm) { + + stubFor(post(HOMEBOX_LOGIN.getValue()) + .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))); + + ConnectionRequest badRequest = connectionRequest(wm); + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), + badRequest, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(response.getBody()) + .contains(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage()); + } + + /** + * Checks if any login fields are empty since all of them are mandatory. + */ + @Test + void shouldReturnBadRequestWhenHomeboxFieldsAreEmpty() { + + ConnectionRequest emtpyRequest = new ConnectionRequest("", null, "", "", false); + + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), + emtpyRequest, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage()); + } + + /** + * Test the exception when there is an input for serviceType but it's unsupported. + */ + @Test + void shouldReturnBadRequestWhenServiceTypeIsInvalid(WireMockRuntimeInfo wm) { + String body = """ + {"appUrl":"%s","serviceType":"wrong-service-type","username":"%s","password":"%s"} + """ + .formatted(wm.getHttpBaseUrl(), MOCK_USER, MOCK_PASS); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity entity = new HttpEntity<>(body, headers); + + ResponseEntity response = restTemplate.exchange(LOGIN.getValue(), + HttpMethod.POST, entity, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + /** + * Tests the succesfull persistance of Homebox credential response to the database. + * + * @param wm the WiremockRuntimeInfo object + */ + @Test + void shouldSaveHomeboxConnectionResponseToDb(WireMockRuntimeInfo wm) { + + stubFor(post(urlEqualTo(HOMEBOX_LOGIN.getValue())) + .willReturn(okJson(okJsonHomeboxResponse))); + + ConnectionRequest request = connectionRequest(wm); + + ResponseEntity response = + restTemplate.postForEntity(LOGIN.getValue(), request, String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + ConnectionEntity dbEntry = cRepository.findByAppUrlAndUsername(request.appUrl(), + request.username()); + + assertThat(dbEntry).isNotNull(); + assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl()); + assertThat(dbEntry.getUsername()).isEqualTo(request.username()); + + if (dbEntry instanceof HomeboxEntity hbE) { + assertThat(hbE.getToken()).isEqualTo("fake-jwt-token"); + assertThat(hbE.getAttachmentToken()).isEqualTo("fake-attach"); + assertThat(hbE.getExpiresAt().toString()) + .hasToString("2026-04-26T02:23:13Z"); + } + } + + @Test + void shouldReturnEmptyCredentialsExceptionWhenCredsAreMissing(WireMockRuntimeInfo wm) { + ConnectionRequest missingCredentials = new ConnectionRequest(wm.getHttpBaseUrl(), + HOMEBOX, MOCK_USER, null, false); + + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), + missingCredentials, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage()); + } + + /** + * Creates a valid connection request with a mock Api through WireMockRuntimeInfo. + * + * @param wm the WiremockRuntimeInfo object + * @return a mock api connection request. + */ + private ConnectionRequest connectionRequest(WireMockRuntimeInfo wm) { + return new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX, MOCK_USER, MOCK_PASS, + null); + } } diff --git a/backend/src/test/java/com/vaessl/app/search/HomeboxSearchProviderTest.java b/backend/src/test/java/com/vaessl/app/search/HomeboxSearchProviderTest.java index cdd8dac..5952882 100644 --- a/backend/src/test/java/com/vaessl/app/search/HomeboxSearchProviderTest.java +++ b/backend/src/test/java/com/vaessl/app/search/HomeboxSearchProviderTest.java @@ -12,6 +12,8 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import com.vaessl.app.connection.ConnectionRepository; import com.vaessl.app.exception.ConnectionNotFoundException; +import static com.vaessl.app.connection.ServiceType.HOMEBOX; + @ExtendWith(MockitoExtension.class) class HomeboxSearchProviderTest { @@ -27,7 +29,7 @@ class HomeboxSearchProviderTest { 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); assertThrows(ConnectionNotFoundException.class, () -> provider.getSearchResults(request, pageable)); diff --git a/backend/src/test/java/com/vaessl/app/search/SearchControllerTest.java b/backend/src/test/java/com/vaessl/app/search/SearchControllerTest.java index eb66eee..0917254 100644 --- a/backend/src/test/java/com/vaessl/app/search/SearchControllerTest.java +++ b/backend/src/test/java/com/vaessl/app/search/SearchControllerTest.java @@ -110,25 +110,21 @@ class SearchControllerTest { } @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()); + void shouldReturnBadRequestWhenServiceTypeIsInvalid() throws Exception { + mockMvc.perform(post(SEARCH_REQUEST).contentType(MediaType.APPLICATION_JSON) + .content(searchRequestBody("INVALID_SERVICETYPE"))) + .andExpect(status().isBadRequest()); } 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 """ { "appUrl": "%s", @@ -136,7 +132,7 @@ class SearchControllerTest { "serviceType": "%s", "username": "%s" } - """.formatted(wm.getHttpBaseUrl(), serviceType, MOCK_USER); + """.formatted(appUrl, serviceType, MOCK_USER); } private String connectionRequestBody(WireMockRuntimeInfo wm) { diff --git a/frontend/src/api/connections.ts b/frontend/src/api/connections.ts index 633e063..a341377 100644 --- a/frontend/src/api/connections.ts +++ b/frontend/src/api/connections.ts @@ -1,5 +1,5 @@ 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) => apiFetch('/login', { method: 'POST', body: JSON.stringify(req) }) @@ -7,5 +7,5 @@ export const login = (req: LoginRequest) => export const getStatuses = () => apiFetch('/connections/status') -export const logout = (serviceType: string) => +export const logout = (serviceType: ServiceType) => apiFetch(`/connections/${serviceType}`, { method: 'DELETE' }) diff --git a/frontend/src/components/connections/ConnectModal.tsx b/frontend/src/components/connections/ConnectModal.tsx index ebce9d0..c6b26f0 100644 --- a/frontend/src/components/connections/ConnectModal.tsx +++ b/frontend/src/components/connections/ConnectModal.tsx @@ -1,10 +1,10 @@ import { useEffect, useRef, useState, type SyntheticEvent } from 'react' import { login } from '../../api/connections' -import type { LoginRequest } from '../../types/connection' +import type { LoginRequest, ServiceType } from '../../types/connection' import '../ui/Modal.scss' interface Props { - serviceType: string + serviceType: ServiceType label: string onClose: () => void onSuccess: () => void diff --git a/frontend/src/components/connections/Dashboard.tsx b/frontend/src/components/connections/Dashboard.tsx index b7feed9..0d2832e 100644 --- a/frontend/src/components/connections/Dashboard.tsx +++ b/frontend/src/components/connections/Dashboard.tsx @@ -1,13 +1,13 @@ import { useState, useEffect } from "react" import { getStatuses, logout } from "../../api/connections" -import type { ConnectionStatus } from "../../types/connection" +import { ServiceType, type ConnectionStatus } from "../../types/connection" import { ServiceCard } from "./ServiceCard" import { ConnectModal } from "./ConnectModal" import "./Dashboard.scss" import { SearchModal } from "../search/SearchModal" const SERVICES = [ - { serviceType: 'HOMEBOX', label: 'Homebox', icon: '📦' }, + { serviceType: ServiceType.HOMEBOX, label: 'Homebox', icon: '📦' }, ] as const export function Dashboard() { @@ -21,7 +21,7 @@ export function Dashboard() { useEffect(() => { refresh() }, []) - const handleDisconnect = (serviceType: string) => { + const handleDisconnect = (serviceType: ServiceType) => { logout(serviceType).then(refresh).catch(() => { }) } diff --git a/frontend/src/components/connections/ServiceCard.tsx b/frontend/src/components/connections/ServiceCard.tsx index cfcbd91..e71b9fd 100644 --- a/frontend/src/components/connections/ServiceCard.tsx +++ b/frontend/src/components/connections/ServiceCard.tsx @@ -1,9 +1,9 @@ -import type { ConnectionStatus } from '../../types/connection' +import type { ConnectionStatus, ServiceType } from '../../types/connection' import { ActionButton } from '../ui/ActionButton' import './ServiceCard.scss' interface Props { - serviceType: string + serviceType: ServiceType label: string icon: string status: ConnectionStatus | null diff --git a/frontend/src/components/search/SearchModal.tsx b/frontend/src/components/search/SearchModal.tsx index 9b1fdbe..8d22c4e 100644 --- a/frontend/src/components/search/SearchModal.tsx +++ b/frontend/src/components/search/SearchModal.tsx @@ -2,9 +2,10 @@ import { useEffect, useRef, useState, type SyntheticEvent } from 'react' import '../ui/Modal.scss' import { type PagedSearchResponse, type SearchResponse, type SearchRequest } from '../../types/search' import { search } from '../../api/searches' +import type { ServiceType } from '../../types/connection' interface Props { - serviceType: string + serviceType: ServiceType label: string appUrl: string username: string diff --git a/frontend/src/types/connection.ts b/frontend/src/types/connection.ts index 1e77aab..808440d 100644 --- a/frontend/src/types/connection.ts +++ b/frontend/src/types/connection.ts @@ -1,20 +1,26 @@ +export const ServiceType = { + HOMEBOX: "HOMEBOX", +} as const; + +export type ServiceType = (typeof ServiceType)[keyof typeof ServiceType]; + export interface LoginRequest { - appUrl: string - serviceType: string - username: string - password: string - stayLoggedIn: boolean + appUrl: string; + serviceType: ServiceType; + username: string; + password: string; + stayLoggedIn: boolean; } export interface AuthResponse { - serviceType: string - expiresAt: string + serviceType: ServiceType; + expiresAt: string; } export interface ConnectionStatus { - serviceType: string - appUrl: string - username: string - expiresAt: string | null - connected: boolean + serviceType: ServiceType; + appUrl: string; + username: string; + expiresAt: string | null; + connected: boolean; } diff --git a/frontend/src/types/search.ts b/frontend/src/types/search.ts index 5c1b8c7..6b1888e 100644 --- a/frontend/src/types/search.ts +++ b/frontend/src/types/search.ts @@ -1,23 +1,25 @@ +import type { ServiceType } from "./connection"; + export interface SearchRequest { - appUrl: string; - serviceType: string; - username: string; - query: string | null; + appUrl: string + serviceType: ServiceType + username: string + query: string | null } export interface SearchResponse { - id: string; - title: string; - description: string | null; - extraData: Record; + id: string + title: string + description: string | null + extraData: Record } export interface PagedSearchResponse { - content: T[]; - page: number; - pageSize: number; - totalElements: number; - first: boolean; - last: boolean; - sort: string; + content: T[] + page: number + pageSize: number + totalElements: number + first: boolean + last: boolean + sort: string }