From ef09a3c84d82392609db513297f6c457c86c4ddb Mon Sep 17 00:00:00 2001 From: kasun Date: Thu, 14 May 2026 14:57:25 +0200 Subject: [PATCH 01/29] revised CORS config for lalowed origins --- .../src/main/java/com/vaessl/app/config/CorsConfig.java | 9 +++------ .../additional-spring-configuration-metadata.json | 9 ++------- backend/src/main/resources/application.yaml | 3 +-- 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/backend/src/main/java/com/vaessl/app/config/CorsConfig.java b/backend/src/main/java/com/vaessl/app/config/CorsConfig.java index 945686a..baa8836 100644 --- a/backend/src/main/java/com/vaessl/app/config/CorsConfig.java +++ b/backend/src/main/java/com/vaessl/app/config/CorsConfig.java @@ -8,16 +8,13 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class CorsConfig implements WebMvcConfigurer { - @Value("${vaessl.frontend-local-url}") - private String frontendLocalUrl; - - @Value("${vaessl.frontend-public-url}") - private String frontendPublicUrl; + @Value("${vaessl.allowed-origins}") + private String[] allowedOrigins; @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") - .allowedOrigins(frontendLocalUrl, frontendPublicUrl) + .allowedOrigins(allowedOrigins) .allowedMethods("GET", "POST", "DELETE", "OPTIONS") .allowedHeaders("Content-Type", "Accept") .allowCredentials(true); diff --git a/backend/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/backend/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 57fd745..67507e3 100644 --- a/backend/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/backend/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -5,13 +5,8 @@ "description": "A description for 'spring.session.store-type'" }, { - "name": "vaessl.frontend-local-url", + "name": "vaessl.allowed-origins", "type": "java.lang.String", - "description": "A description for 'vaessl.frontend-local-url'" - }, - { - "name": "vaessl.frontend-public-url", - "type": "java.lang.String", - "description": "A description for 'vaessl.frontend-public-url'" + "description": "Comma-separated list of allowed CORS origins" } ]} \ No newline at end of file diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index f9c6a95..409f0ec 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -30,5 +30,4 @@ server: servlet: context-path: /api vaessl: - frontend-local-url: ${FRONTEND_LOCAL_URL} - frontend-public-url: ${FRONTEND_PUBLIC_URL} + allowed-origins: ${ALLOWED_ORIGINS} -- 2.54.0 From 92aaf63c122b8ce86816842b8e00a0afade63746 Mon Sep 17 00:00:00 2001 From: kasun Date: Sat, 16 May 2026 00:16:28 +0200 Subject: [PATCH 02/29] fixed Homebox typo --- ...ectionProvider.java => HomeboxConnectionProvider.java} | 8 ++++++-- ...oviderTest.java => HomeboxConnectionProviderTest.java} | 4 ++-- docs/02-Preparation/07-claude-code-feasibility.md | 2 +- docs/03-Architecture/01-Login-Architecture.md | 4 ++-- 4 files changed, 11 insertions(+), 7 deletions(-) rename backend/src/main/java/com/vaessl/app/connection/{HomeBoxConnectionProvider.java => HomeboxConnectionProvider.java} (92%) rename backend/src/test/java/com/vaessl/app/connection/{HomeBoxConnectionProviderTest.java => HomeboxConnectionProviderTest.java} (89%) diff --git a/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java b/backend/src/main/java/com/vaessl/app/connection/HomeboxConnectionProvider.java similarity index 92% rename from backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java rename to backend/src/main/java/com/vaessl/app/connection/HomeboxConnectionProvider.java index 1c50baa..522b77d 100644 --- a/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/HomeboxConnectionProvider.java @@ -11,18 +11,21 @@ import org.springframework.web.client.RestClient; import com.vaessl.app.dto.ConnectionRequest; import com.vaessl.app.dto.ConnectionResponse; +import com.vaessl.app.exception.ConnectionNotFoundException; import com.vaessl.app.exception.EmptyCredentialsException; +import com.vaessl.app.search.SearchRequest; +import com.vaessl.app.search.SearchResponse; import static com.vaessl.app.connection.Endpoint.*; @Component -public class HomeBoxConnectionProvider implements ConnectionProvider { +public class HomeboxConnectionProvider implements ConnectionProvider { private final RestClient.Builder restClientBuilder; private final ConnectionRepository cRepository; - public HomeBoxConnectionProvider(RestClient.Builder restClientBuilder, ConnectionRepository cRepository) { + public HomeboxConnectionProvider(RestClient.Builder restClientBuilder, ConnectionRepository cRepository) { this.restClientBuilder = restClientBuilder; this.cRepository = cRepository; } @@ -105,4 +108,5 @@ public class HomeBoxConnectionProvider implements ConnectionProvider { private record HomeboxLoginResponse(String token, String attachmentToken, Instant expiresAt) { } + } diff --git a/backend/src/test/java/com/vaessl/app/connection/HomeBoxConnectionProviderTest.java b/backend/src/test/java/com/vaessl/app/connection/HomeboxConnectionProviderTest.java similarity index 89% rename from backend/src/test/java/com/vaessl/app/connection/HomeBoxConnectionProviderTest.java rename to backend/src/test/java/com/vaessl/app/connection/HomeboxConnectionProviderTest.java index eaaca00..667f543 100644 --- a/backend/src/test/java/com/vaessl/app/connection/HomeBoxConnectionProviderTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/HomeboxConnectionProviderTest.java @@ -10,9 +10,9 @@ import com.vaessl.app.exception.EmptyCredentialsException; 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 void checkCredentials_ShouldThrowException_WhenFieldsAreMissing() { diff --git a/docs/02-Preparation/07-claude-code-feasibility.md b/docs/02-Preparation/07-claude-code-feasibility.md index 5a093cb..b6e142d 100644 --- a/docs/02-Preparation/07-claude-code-feasibility.md +++ b/docs/02-Preparation/07-claude-code-feasibility.md @@ -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. --- - **connection/HomeBoxConnectionProvider.java (modified)** + **connection/HomeboxConnectionProvider.java (modified)** Implements the new interface methods: - checkCredentials validates that username and password are present before touching the network. diff --git a/docs/03-Architecture/01-Login-Architecture.md b/docs/03-Architecture/01-Login-Architecture.md index fcf3dc5..93f583e 100644 --- a/docs/03-Architecture/01-Login-Architecture.md +++ b/docs/03-Architecture/01-Login-Architecture.md @@ -94,11 +94,11 @@ public interface ConnectionProvider { - `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 -***HomeBoxConnectionProvider.java*** +***HomeboxConnectionProvider.java*** ```java @Component -public class HomeBoxConnectionProvider implements ConnectionProvider { +public class HomeboxConnectionProvider implements ConnectionProvider { @Override public String getServiceType() { return "HOMEBOX"; } -- 2.54.0 From d7233d817c344394d331b0b77464f4eb0862e48c Mon Sep 17 00:00:00 2001 From: kasun Date: Sat, 16 May 2026 01:14:34 +0200 Subject: [PATCH 03/29] reorganized packages --- .../java/com/vaessl/app/{dto => connection}/AuthResponse.java | 2 +- .../com/vaessl/app/{dto => connection}/ConnectionRequest.java | 2 +- .../com/vaessl/app/{dto => connection}/ConnectionResponse.java | 2 +- .../app/{dto => connection}/ConnectionStatusResponse.java | 2 +- .../java/com/vaessl/app/{dto => connection}/LoginResult.java | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) rename backend/src/main/java/com/vaessl/app/{dto => connection}/AuthResponse.java (73%) rename backend/src/main/java/com/vaessl/app/{dto => connection}/ConnectionRequest.java (96%) rename backend/src/main/java/com/vaessl/app/{dto => connection}/ConnectionResponse.java (92%) rename backend/src/main/java/com/vaessl/app/{dto => connection}/ConnectionStatusResponse.java (85%) rename backend/src/main/java/com/vaessl/app/{dto => connection}/LoginResult.java (73%) diff --git a/backend/src/main/java/com/vaessl/app/dto/AuthResponse.java b/backend/src/main/java/com/vaessl/app/connection/AuthResponse.java similarity index 73% rename from backend/src/main/java/com/vaessl/app/dto/AuthResponse.java rename to backend/src/main/java/com/vaessl/app/connection/AuthResponse.java index a16a725..992c23a 100644 --- a/backend/src/main/java/com/vaessl/app/dto/AuthResponse.java +++ b/backend/src/main/java/com/vaessl/app/connection/AuthResponse.java @@ -1,4 +1,4 @@ -package com.vaessl.app.dto; +package com.vaessl.app.connection; import java.time.Instant; diff --git a/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionRequest.java similarity index 96% rename from backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java rename to backend/src/main/java/com/vaessl/app/connection/ConnectionRequest.java index 3c6272d..3df55d7 100644 --- a/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionRequest.java @@ -1,4 +1,4 @@ -package com.vaessl.app.dto; +package com.vaessl.app.connection; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/backend/src/main/java/com/vaessl/app/dto/ConnectionResponse.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionResponse.java similarity index 92% rename from backend/src/main/java/com/vaessl/app/dto/ConnectionResponse.java rename to backend/src/main/java/com/vaessl/app/connection/ConnectionResponse.java index 9422e7d..9ba9b87 100644 --- a/backend/src/main/java/com/vaessl/app/dto/ConnectionResponse.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionResponse.java @@ -1,4 +1,4 @@ -package com.vaessl.app.dto; +package com.vaessl.app.connection; import java.time.Instant; import java.util.Map; diff --git a/backend/src/main/java/com/vaessl/app/dto/ConnectionStatusResponse.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionStatusResponse.java similarity index 85% rename from backend/src/main/java/com/vaessl/app/dto/ConnectionStatusResponse.java rename to backend/src/main/java/com/vaessl/app/connection/ConnectionStatusResponse.java index 77f73a6..1d1fc62 100644 --- a/backend/src/main/java/com/vaessl/app/dto/ConnectionStatusResponse.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionStatusResponse.java @@ -1,4 +1,4 @@ -package com.vaessl.app.dto; +package com.vaessl.app.connection; import java.time.Instant; diff --git a/backend/src/main/java/com/vaessl/app/dto/LoginResult.java b/backend/src/main/java/com/vaessl/app/connection/LoginResult.java similarity index 73% rename from backend/src/main/java/com/vaessl/app/dto/LoginResult.java rename to backend/src/main/java/com/vaessl/app/connection/LoginResult.java index 8e54eea..61e7b43 100644 --- a/backend/src/main/java/com/vaessl/app/dto/LoginResult.java +++ b/backend/src/main/java/com/vaessl/app/connection/LoginResult.java @@ -1,4 +1,4 @@ -package com.vaessl.app.dto; +package com.vaessl.app.connection; import java.time.Instant; -- 2.54.0 From 406a041ce9a25ea5f6e2d4c3e5c6e6b71be64fb0 Mon Sep 17 00:00:00 2001 From: kasun Date: Sat, 16 May 2026 01:47:12 +0200 Subject: [PATCH 04/29] reorganized packages --- .../java/com/vaessl/app/connection/ConnectionProvider.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java index 91a6adb..a20d8bf 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java @@ -2,9 +2,6 @@ package com.vaessl.app.connection; import java.time.Instant; -import com.vaessl.app.dto.ConnectionRequest; -import com.vaessl.app.dto.ConnectionResponse; - public interface ConnectionProvider { void checkCredentials(ConnectionRequest request); -- 2.54.0 From 1d5006fd7e1bf782193cc5a1467c90f0e8ff3e3a Mon Sep 17 00:00:00 2001 From: kasun Date: Sat, 16 May 2026 01:48:36 +0200 Subject: [PATCH 05/29] reorganized packages --- .../java/com/vaessl/app/connection/ConnectionController.java | 5 ----- .../java/com/vaessl/app/connection/ConnectionService.java | 4 ---- .../com/vaessl/app/connection/HomeboxConnectionProvider.java | 2 -- .../main/java/com/vaessl/app/connection/HomeboxEntity.java | 3 --- .../com/vaessl/app/connection/ConnectionServiceTest.java | 1 - .../com/vaessl/app/connection/HomeboxIntegrationTest.java | 1 - 6 files changed, 16 deletions(-) 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 3dfa944..09f4bd9 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java @@ -14,11 +14,6 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; 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.HttpSession; import jakarta.validation.Valid; 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 fed0b7d..864f366 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java @@ -7,10 +7,6 @@ import java.util.stream.Collectors; 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; @Service 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 522b77d..6f87522 100644 --- a/backend/src/main/java/com/vaessl/app/connection/HomeboxConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/HomeboxConnectionProvider.java @@ -9,8 +9,6 @@ import java.util.Map; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; -import com.vaessl.app.dto.ConnectionRequest; -import com.vaessl.app.dto.ConnectionResponse; import com.vaessl.app.exception.ConnectionNotFoundException; import com.vaessl.app.exception.EmptyCredentialsException; import com.vaessl.app.search.SearchRequest; diff --git a/backend/src/main/java/com/vaessl/app/connection/HomeboxEntity.java b/backend/src/main/java/com/vaessl/app/connection/HomeboxEntity.java index e965269..e15ea57 100644 --- a/backend/src/main/java/com/vaessl/app/connection/HomeboxEntity.java +++ b/backend/src/main/java/com/vaessl/app/connection/HomeboxEntity.java @@ -2,9 +2,6 @@ package com.vaessl.app.connection; import java.time.Instant; -import com.vaessl.app.dto.ConnectionRequest; -import com.vaessl.app.dto.ConnectionResponse; - import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.Entity; import lombok.Getter; diff --git a/backend/src/test/java/com/vaessl/app/connection/ConnectionServiceTest.java b/backend/src/test/java/com/vaessl/app/connection/ConnectionServiceTest.java index 2c626f9..67c59e9 100644 --- a/backend/src/test/java/com/vaessl/app/connection/ConnectionServiceTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/ConnectionServiceTest.java @@ -15,7 +15,6 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.vaessl.app.dto.ConnectionRequest; import com.vaessl.app.exception.EmptyCredentialsException; import static com.vaessl.app.connection.Mockdata.*; 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 6003cbb..16f340e 100644 --- a/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java @@ -14,7 +14,6 @@ import com.github.tomakehurst.wiremock.junit5.WireMockTest; import com.jayway.jsonpath.DocumentContext; import com.jayway.jsonpath.JsonPath; -import com.vaessl.app.dto.ConnectionRequest; import static org.assertj.core.api.Assertions.assertThat; -- 2.54.0 From 4d96524adb04aaa4d3c5525aa0ec2ef9e7b556d4 Mon Sep 17 00:00:00 2001 From: kasun Date: Sat, 16 May 2026 02:38:33 +0200 Subject: [PATCH 06/29] reoargnized packages --- .../com/vaessl/app/connection/HomeboxConnectionProviderTest.java | 1 - 1 file changed, 1 deletion(-) 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 667f543..9a9dfe5 100644 --- a/backend/src/test/java/com/vaessl/app/connection/HomeboxConnectionProviderTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/HomeboxConnectionProviderTest.java @@ -4,7 +4,6 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import org.junit.jupiter.api.Test; -import com.vaessl.app.dto.ConnectionRequest; import com.vaessl.app.exception.EmptyCredentialsException; import static org.assertj.core.api.Assertions.assertThat; -- 2.54.0 From a8ecf65180aafdb274fef045b52289341856378b Mon Sep 17 00:00:00 2001 From: kasun Date: Sun, 17 May 2026 00:26:46 +0200 Subject: [PATCH 07/29] changed formatter --- .../com/vaessl/app/config/CorsConfig.java | 6 +- .../vaessl/app/connection/AuthResponse.java | 3 +- .../app/connection/ConnectionController.java | 15 +- .../app/connection/ConnectionEntity.java | 3 +- .../app/connection/ConnectionRequest.java | 11 +- .../app/connection/ConnectionResponse.java | 7 +- .../app/connection/ConnectionService.java | 7 +- .../connection/ConnectionStatusResponse.java | 9 +- .../com/vaessl/app/connection/Endpoint.java | 4 +- .../vaessl/app/connection/LoginResult.java | 3 +- .../vaessl/app/connection/ServiceType.java | 2 +- .../vaessl/app/exception/ErrorMessage.java | 12 +- .../app/exception/GlobalExceptionHandler.java | 12 +- .../java/com/vaessl/app/ApplicationTests.java | 3 +- .../connection/ConnectionControllerTest.java | 209 +++++++++--------- .../app/connection/ConnectionServiceTest.java | 9 +- .../connection/HomeboxIntegrationTest.java | 56 +++-- 17 files changed, 180 insertions(+), 191 deletions(-) diff --git a/backend/src/main/java/com/vaessl/app/config/CorsConfig.java b/backend/src/main/java/com/vaessl/app/config/CorsConfig.java index baa8836..f9ce4f8 100644 --- a/backend/src/main/java/com/vaessl/app/config/CorsConfig.java +++ b/backend/src/main/java/com/vaessl/app/config/CorsConfig.java @@ -13,10 +13,8 @@ public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { - registry.addMapping("/**") - .allowedOrigins(allowedOrigins) + registry.addMapping("/**").allowedOrigins(allowedOrigins) .allowedMethods("GET", "POST", "DELETE", "OPTIONS") - .allowedHeaders("Content-Type", "Accept") - .allowCredentials(true); + .allowedHeaders("Content-Type", "Accept").allowCredentials(true); } } 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 992c23a..0bdda64 100644 --- a/backend/src/main/java/com/vaessl/app/connection/AuthResponse.java +++ b/backend/src/main/java/com/vaessl/app/connection/AuthResponse.java @@ -2,4 +2,5 @@ package com.vaessl.app.connection; import java.time.Instant; -public record AuthResponse(String serviceType, Instant expiresAt) {} +public record AuthResponse(String 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 09f4bd9..9222b63 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java @@ -28,8 +28,7 @@ public class ConnectionController { private final ConnectionService connectionService; @PostMapping("/login") - public ResponseEntity login( - @Valid @RequestBody ConnectionRequest request, + public ResponseEntity login(@Valid @RequestBody ConnectionRequest request, HttpServletRequest httpReq) { LoginResult result = connectionService.login(request); @@ -53,21 +52,21 @@ public class ConnectionController { } List statuses = new ArrayList<>(); - Collections.list(session.getAttributeNames()).stream() - .filter(k -> k.endsWith(SUFFIX)) + Collections.list(session.getAttributeNames()).stream().filter(k -> k.endsWith(SUFFIX)) .forEach(k -> { String serviceType = k.replace(SUFFIX, ""); Long id = (Long) session.getAttribute(k); - ConnectionStatusResponse status = connectionService.getConnectionStatus(serviceType, id); - if (status != null) statuses.add(status); + ConnectionStatusResponse status = + connectionService.getConnectionStatus(serviceType, id); + if (status != null) + statuses.add(status); }); return ResponseEntity.ok(statuses); } @DeleteMapping("/connections/{serviceType}") - public ResponseEntity logout( - @PathVariable("serviceType") String serviceType, + public ResponseEntity logout(@PathVariable("serviceType") String serviceType, HttpServletRequest httpReq) { HttpSession session = httpReq.getSession(false); diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionEntity.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionEntity.java index 96b769f..586921a 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionEntity.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionEntity.java @@ -14,7 +14,8 @@ import lombok.NoArgsConstructor; import lombok.Setter; @Entity -@Table(name = "connections", uniqueConstraints = { @UniqueConstraint(columnNames = { "appUrl", "username" }) }) +@Table(name = "connections", + uniqueConstraints = {@UniqueConstraint(columnNames = {"appUrl", "username"})}) @Inheritance(strategy = InheritanceType.SINGLE_TABLE) @DiscriminatorColumn(name = "service_type") @Getter 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 3df55d7..9edfa5b 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionRequest.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionRequest.java @@ -4,12 +4,9 @@ import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.constraints.NotBlank; -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, - String username, - String password, - String apiKey, + String username, String password, String apiKey, @JsonProperty(defaultValue = "false") Boolean stayLoggedIn) { public ConnectionRequest { @@ -18,8 +15,8 @@ public record ConnectionRequest( } } - public ConnectionRequest(String appUrl, String serviceType, String username, String password, - Boolean stayLoggedIn) { + public ConnectionRequest(String appUrl, String 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/ConnectionResponse.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionResponse.java index 9ba9b87..41b278c 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionResponse.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionResponse.java @@ -3,10 +3,11 @@ package com.vaessl.app.connection; import java.time.Instant; import java.util.Map; -public record ConnectionResponse(String token, Instant expiresAt, Map extraResponseData) { - +public record ConnectionResponse(String token, Instant expiresAt, + Map extraResponseData) { + public String getExtraVar(String key) { - if(extraResponseData == null) { + if (extraResponseData == null) { return null; } else { Object value = extraResponseData.get(key); 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 864f366..dab76b2 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java @@ -50,13 +50,14 @@ public class ConnectionService { public ConnectionStatusResponse getConnectionStatus(String serviceType, Long connectionId) { ConnectionEntity entity = cRepository.findById(connectionId).orElse(null); - if (entity == null) return null; + if (entity == null) + return null; ConnectionProvider provider = providerRegistry.get(serviceType); Instant expiresAt = (provider != null) ? provider.getTokenExpiry(entity) : null; boolean connected = expiresAt == null || expiresAt.isAfter(Instant.now()); - return new ConnectionStatusResponse(serviceType, entity.getAppUrl(), - entity.getUsername(), expiresAt, connected); + return new ConnectionStatusResponse(serviceType, entity.getAppUrl(), entity.getUsername(), + expiresAt, connected); } } diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionStatusResponse.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionStatusResponse.java index 1d1fc62..be5ee04 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionStatusResponse.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionStatusResponse.java @@ -2,9 +2,6 @@ package com.vaessl.app.connection; import java.time.Instant; -public record ConnectionStatusResponse( - String serviceType, - String appUrl, - String username, - Instant expiresAt, - boolean connected) {} +public record ConnectionStatusResponse(String serviceType, String appUrl, String username, + Instant expiresAt, boolean connected) { +} diff --git a/backend/src/main/java/com/vaessl/app/connection/Endpoint.java b/backend/src/main/java/com/vaessl/app/connection/Endpoint.java index d8b7426..0960c26 100644 --- a/backend/src/main/java/com/vaessl/app/connection/Endpoint.java +++ b/backend/src/main/java/com/vaessl/app/connection/Endpoint.java @@ -1,9 +1,7 @@ package com.vaessl.app.connection; public enum Endpoint { - HOMEBOX_LOGIN("/api/v1/users/login"), - LOGIN("/login"), - CONNECTION_STATUS("/connections/status"); + HOMEBOX_LOGIN("/api/v1/users/login"), LOGIN("/login"), CONNECTION_STATUS("/connections/status"); private final String value; diff --git a/backend/src/main/java/com/vaessl/app/connection/LoginResult.java b/backend/src/main/java/com/vaessl/app/connection/LoginResult.java index 61e7b43..49349ec 100644 --- a/backend/src/main/java/com/vaessl/app/connection/LoginResult.java +++ b/backend/src/main/java/com/vaessl/app/connection/LoginResult.java @@ -2,4 +2,5 @@ package com.vaessl.app.connection; import java.time.Instant; -public record LoginResult(Long connectionId, Instant expiresAt) {} +public record LoginResult(Long connectionId, Instant expiresAt) { +} 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 d579690..4c3fc5f 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ServiceType.java +++ b/backend/src/main/java/com/vaessl/app/connection/ServiceType.java @@ -8,7 +8,7 @@ public enum ServiceType { private final String value; - private ServiceType(String value){ + private ServiceType(String value) { this.value = value; } } diff --git a/backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java b/backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java index ae14081..17b27ff 100644 --- a/backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java +++ b/backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java @@ -5,11 +5,13 @@ import org.springframework.http.HttpStatus; import com.fasterxml.jackson.annotation.JsonValue; public enum ErrorMessage { - BAD_REQUEST_EMPTY_FIELDS(HttpStatus.BAD_REQUEST, "Fields must not be empty."), UNAUTHORIZED_WRONG_LOGIN( - HttpStatus.UNAUTHORIZED, "Invalid username or password."), SERVICE_UNAVAILABLE_UNREACHABLE_URL( - HttpStatus.SERVICE_UNAVAILABLE, "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."); + BAD_REQUEST_EMPTY_FIELDS(HttpStatus.BAD_REQUEST, + "Fields must not be empty."), UNAUTHORIZED_WRONG_LOGIN(HttpStatus.UNAUTHORIZED, + "Invalid username or password."), SERVICE_UNAVAILABLE_UNREACHABLE_URL( + HttpStatus.SERVICE_UNAVAILABLE, + "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."); private final HttpStatus status; private final String message; diff --git a/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java index 08994c0..db29162 100644 --- a/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java @@ -19,8 +19,8 @@ public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) public ProblemDetail handleEmptyCredentialInput(MethodArgumentNotValidException e) { - String defaultMessages = e.getBindingResult().getFieldErrors().stream().map(FieldError::getDefaultMessage) - .collect(Collectors.joining(", ")); + String defaultMessages = e.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage).collect(Collectors.joining(", ")); return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(), BAD_REQUEST_EMPTY_FIELDS.getMessage() + " [" + defaultMessages + "]"); @@ -43,14 +43,14 @@ public class GlobalExceptionHandler { @ExceptionHandler(HttpServerErrorException.class) public ProblemDetail handleTimeoutOrNotFound(HttpServerErrorException e) { - return ProblemDetail - .forStatusAndDetail(e.getStatusCode(), - SERVER_ERROR_GENERAL.getMessage() + e.getStatusText()); + return ProblemDetail.forStatusAndDetail(e.getStatusCode(), + SERVER_ERROR_GENERAL.getMessage() + e.getStatusText()); } @ExceptionHandler(WrongServiceTypeException.class) 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) diff --git a/backend/src/test/java/com/vaessl/app/ApplicationTests.java b/backend/src/test/java/com/vaessl/app/ApplicationTests.java index b2954e9..40849b1 100644 --- a/backend/src/test/java/com/vaessl/app/ApplicationTests.java +++ b/backend/src/test/java/com/vaessl/app/ApplicationTests.java @@ -20,8 +20,7 @@ class ApplicationTests { private DataSource dataSource; @Test - void contextLoads() { - } + void contextLoads() {} @Test void connectionToTestDbWorks() throws SQLException { 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 906fd42..30331e9 100644 --- a/backend/src/test/java/com/vaessl/app/connection/ConnectionControllerTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/ConnectionControllerTest.java @@ -24,134 +24,131 @@ import org.springframework.test.web.servlet.MvcResult; @WireMockTest class ConnectionControllerTest { - @Autowired - MockMvc mockMvc; + @Autowired + 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 STATUS_PATH = CONNECTION_STATUS.getValue(); - private static final String LOGOUT_PATH = "/connections/HOMEBOX"; + 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 STATUS_PATH = CONNECTION_STATUS.getValue(); + private static final String LOGOUT_PATH = "/connections/HOMEBOX"; - private static final String VALID_HOMEBOX_RESPONSE = """ - { - "token": "fake-jwt-token", - "attachmentToken": "fake-attach", - "expiresAt": "2099-01-01T00:00:00Z" - } - """; + private static final String VALID_HOMEBOX_RESPONSE = """ + { + "token": "fake-jwt-token", + "attachmentToken": "fake-attach", + "expiresAt": "2099-01-01T00:00:00Z" + } + """; - private static final String EXPIRED_HOMEBOX_RESPONSE = """ - { - "token": "expired-token", - "attachmentToken": "fake-attach", - "expiresAt": "2000-01-01T00:00:00Z" - } - """; + private static final String EXPIRED_HOMEBOX_RESPONSE = """ + { + "token": "expired-token", + "attachmentToken": "fake-attach", + "expiresAt": "2000-01-01T00:00:00Z" + } + """; - @Test - void shouldReturnEmptyListWhenNoActiveSession() throws Exception { - mockMvc.perform(get(STATUS_PATH)) - .andExpect(status().isOk()) - .andExpect(content().json("[]")); - } + @Test + void shouldReturnEmptyListWhenNoActiveSession() throws Exception { + mockMvc.perform(get(STATUS_PATH)).andExpect(status().isOk()) + .andExpect(content().json("[]")); + } - @Test - void shouldReturnConnectionStatusWithConnectedTrueAfterLogin(WireMockRuntimeInfo wm) throws Exception { - WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE))); + @Test + void shouldReturnConnectionStatusWithConnectedTrueAfterLogin(WireMockRuntimeInfo wm) + throws Exception { + WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()) + .willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE))); - MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH) - .contentType(MediaType.APPLICATION_JSON) - .content(connectionRequestBody(wm))) - .andExpect(status().isOk()) - .andReturn(); + MvcResult loginResult = mockMvc + .perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON) + .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].username").value(TEST_USER)) - .andExpect(jsonPath("$[0].appUrl").value(wm.getHttpBaseUrl())) - .andExpect(jsonPath("$[0].connected").value(true)); - } + 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].username").value(TEST_USER)) + .andExpect(jsonPath("$[0].appUrl").value(wm.getHttpBaseUrl())) + .andExpect(jsonPath("$[0].connected").value(true)); + } - @Test - void shouldReturnConnectedFalseWhenStoredTokenIsExpired(WireMockRuntimeInfo wm) throws Exception { - WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(EXPIRED_HOMEBOX_RESPONSE))); + @Test + void shouldReturnConnectedFalseWhenStoredTokenIsExpired(WireMockRuntimeInfo wm) + throws Exception { + WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()) + .willReturn(WireMock.okJson(EXPIRED_HOMEBOX_RESPONSE))); - MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH) - .contentType(MediaType.APPLICATION_JSON) - .content(connectionRequestBody(wm))) - .andExpect(status().isOk()) - .andReturn(); + MvcResult loginResult = mockMvc + .perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON) + .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].connected").value(false)) - .andExpect(jsonPath("$[0].expiresAt").value("2000-01-01T00:00:00Z")); - } + mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)).andExpect(status().isOk()) + .andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.getValue())) + .andExpect(jsonPath("$[0].connected").value(false)) + .andExpect(jsonPath("$[0].expiresAt") + .value("2000-01-01T00:00:00Z")); + } - @Test - void shouldReturn204NoContentOnLogout(WireMockRuntimeInfo wm) throws Exception { - WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE))); + @Test + void shouldReturn204NoContentOnLogout(WireMockRuntimeInfo wm) throws Exception { + WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()) + .willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE))); - MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH) - .contentType(MediaType.APPLICATION_JSON) - .content(connectionRequestBody(wm))) - .andExpect(status().isOk()) - .andReturn(); + MvcResult loginResult = mockMvc + .perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON) + .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()); - } + mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie)) + .andExpect(status().isNoContent()); + } - @Test - void shouldReturnEmptyStatusListAfterLogout(WireMockRuntimeInfo wm) throws Exception { - WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE))); + @Test + void shouldReturnEmptyStatusListAfterLogout(WireMockRuntimeInfo wm) throws Exception { + WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()) + .willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE))); - MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH) - .contentType(MediaType.APPLICATION_JSON) - .content(connectionRequestBody(wm))) - .andExpect(status().isOk()) - .andReturn(); + MvcResult loginResult = mockMvc + .perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON) + .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()) - .andExpect(jsonPath("$.length()").value(1)); + // Verify connected before logout + mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)).andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)); - mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie)) - .andExpect(status().isNoContent()); + mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie)) + .andExpect(status().isNoContent()); - // A new request (no session cookie, as in a fresh browser) returns no connections - mockMvc.perform(get(STATUS_PATH)) - .andExpect(status().isOk()) - .andExpect(content().json("[]")); - } + // A new request (no session cookie, as in a fresh browser) returns no connections + mockMvc.perform(get(STATUS_PATH)).andExpect(status().isOk()) + .andExpect(content().json("[]")); + } - @Test - void shouldReturn204WhenLogoutCalledWithNoActiveSession() throws Exception { - mockMvc.perform(delete(LOGOUT_PATH)) - .andExpect(status().isNoContent()); - } + @Test + void shouldReturn204WhenLogoutCalledWithNoActiveSession() throws Exception { + mockMvc.perform(delete(LOGOUT_PATH)).andExpect(status().isNoContent()); + } - private String connectionRequestBody(WireMockRuntimeInfo wm) { - return """ - { - "appUrl": "%s", - "serviceType": "HOMEBOX", - "username": "%s", - "password": "%s" - } - """.formatted(wm.getHttpBaseUrl(), TEST_USER, TEST_PASS); - } + private String connectionRequestBody(WireMockRuntimeInfo wm) { + return """ + { + "appUrl": "%s", + "serviceType": "HOMEBOX", + "username": "%s", + "password": "%s" + } + """.formatted(wm.getHttpBaseUrl(), TEST_USER, TEST_PASS); + } } diff --git a/backend/src/test/java/com/vaessl/app/connection/ConnectionServiceTest.java b/backend/src/test/java/com/vaessl/app/connection/ConnectionServiceTest.java index 67c59e9..2543436 100644 --- a/backend/src/test/java/com/vaessl/app/connection/ConnectionServiceTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/ConnectionServiceTest.java @@ -37,13 +37,14 @@ class ConnectionServiceTest { @Test 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"))) - .when(mockProvider).checkCredentials(request); + doThrow(new EmptyCredentialsException(List.of("username"))).when(mockProvider) + .checkCredentials(request); assertThrows(EmptyCredentialsException.class, () -> connectionService.login(request)); verify(mockProvider, never()).authenticate(any()); } -} \ No newline at end of file +} 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 16f340e..fbcd477 100644 --- a/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java @@ -52,11 +52,10 @@ class HomeboxIntegrationTest { @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()); @@ -78,8 +77,8 @@ class HomeboxIntegrationTest { stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(unauthorized())); - ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), - String.class); + ResponseEntity response = + restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), String.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); assertThat(response.getBody()).contains(UNAUTHORIZED_WRONG_LOGIN.getMessage()); @@ -95,8 +94,8 @@ class HomeboxIntegrationTest { stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(serviceUnavailable())); - ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), - String.class); + 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()); @@ -112,7 +111,8 @@ class HomeboxIntegrationTest { .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))); ConnectionRequest badRequest = connectionRequest(wm); - ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), badRequest, String.class); + 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()); @@ -126,34 +126,30 @@ class HomeboxIntegrationTest { ConnectionRequest emtpyRequest = new ConnectionRequest("", "", "", "", false); - ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), emtpyRequest, String.class); + 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 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", - TEST_USER, TEST_PASS, - false); + ConnectionRequest wrongServiceTypeReq = new ConnectionRequest(wm.getHttpBaseUrl(), + "wrong-service-type", TEST_USER, TEST_PASS, false); - ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), wrongServiceTypeReq, - String.class); + 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. + * Tests the succesfull persistance of Homebox credential response to the database. * * @param wm the WiremockRuntimeInfo object */ @@ -165,10 +161,12 @@ class HomeboxIntegrationTest { ConnectionRequest request = connectionRequest(wm); - ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), request, String.class); + ResponseEntity response = + restTemplate.postForEntity(LOGIN.getValue(), request, String.class); 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.getAppUrl()).isEqualTo(request.appUrl()); @@ -183,20 +181,18 @@ class HomeboxIntegrationTest { @Test void shouldReturnEmptyCredentialsExceptionWhenCredsAreMissing(WireMockRuntimeInfo wm) { - ConnectionRequest missingCredentials = new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX.getValue(), TEST_USER, - null, - false); + ConnectionRequest missingCredentials = new ConnectionRequest(wm.getHttpBaseUrl(), + HOMEBOX.getValue(), TEST_USER, null, false); - ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), missingCredentials, - String.class); + 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. + * Creates a valid connection request with a mock Api through WireMockRuntimeInfo. * * @param wm the WiremockRuntimeInfo object * @return a mock api connection request. -- 2.54.0 From 5b2648d5263c5c4fc199c032847a0205118a2c5f Mon Sep 17 00:00:00 2001 From: kasun Date: Sun, 17 May 2026 00:31:10 +0200 Subject: [PATCH 08/29] changed formatter --- .../connection/HomeboxConnectionProvider.java | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) 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 6f87522..812c920 100644 --- a/backend/src/main/java/com/vaessl/app/connection/HomeboxConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/HomeboxConnectionProvider.java @@ -23,7 +23,8 @@ public class HomeboxConnectionProvider implements ConnectionProvider { private final ConnectionRepository cRepository; - public HomeboxConnectionProvider(RestClient.Builder restClientBuilder, ConnectionRepository cRepository) { + public HomeboxConnectionProvider(RestClient.Builder restClientBuilder, + ConnectionRepository cRepository) { this.restClientBuilder = restClientBuilder; this.cRepository = cRepository; } @@ -51,20 +52,16 @@ public class HomeboxConnectionProvider implements ConnectionProvider { @Override public ConnectionResponse authenticate(ConnectionRequest request) { - Map homeboxPayload = Map.of("username", request.username(), - "password", request.password(), "stayLoggedIn", - request.stayLoggedIn()); + Map homeboxPayload = Map.of("username", request.username(), "password", + request.password(), "stayLoggedIn", request.stayLoggedIn()); - HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl()) - .build() - .post() - .uri(HOMEBOX_LOGIN.getValue()) - .body(homeboxPayload) - .retrieve() + HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl()).build().post() + .uri(HOMEBOX_LOGIN.getValue()).body(homeboxPayload).retrieve() .body(HomeboxLoginResponse.class); if (hbResponse == null) { - throw new IllegalStateException("Remote API returned an empty body for " + request.appUrl()); + throw new IllegalStateException( + "Remote API returned an empty body for " + request.appUrl()); } Map attachmentToken = new HashMap<>(); @@ -81,7 +78,8 @@ public class HomeboxConnectionProvider implements ConnectionProvider { } @Override - public ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response) { + public ConnectionEntity connectionToEntity(ConnectionRequest request, + ConnectionResponse response) { return HomeboxEntity.from(request, response); } @@ -106,5 +104,5 @@ public class HomeboxConnectionProvider implements ConnectionProvider { private record HomeboxLoginResponse(String token, String attachmentToken, Instant expiresAt) { } - + } -- 2.54.0 From f39bf049a04c1a1c354c692b1570b97d2814b1f6 Mon Sep 17 00:00:00 2001 From: kasun Date: Sun, 17 May 2026 00:38:47 +0200 Subject: [PATCH 09/29] added RemoteApiException --- .../app/connection/HomeboxConnectionProvider.java | 7 ++----- .../vaessl/app/exception/GlobalExceptionHandler.java | 6 ++++++ .../com/vaessl/app/exception/RemoteApiException.java | 11 +++++++++++ 3 files changed, 19 insertions(+), 5 deletions(-) create mode 100644 backend/src/main/java/com/vaessl/app/exception/RemoteApiException.java 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 812c920..6e86b8d 100644 --- a/backend/src/main/java/com/vaessl/app/connection/HomeboxConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/HomeboxConnectionProvider.java @@ -9,10 +9,8 @@ import java.util.Map; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; -import com.vaessl.app.exception.ConnectionNotFoundException; import com.vaessl.app.exception.EmptyCredentialsException; -import com.vaessl.app.search.SearchRequest; -import com.vaessl.app.search.SearchResponse; +import com.vaessl.app.exception.RemoteApiException; import static com.vaessl.app.connection.Endpoint.*; @@ -60,8 +58,7 @@ public class HomeboxConnectionProvider implements ConnectionProvider { .body(HomeboxLoginResponse.class); if (hbResponse == null) { - throw new IllegalStateException( - "Remote API returned an empty body for " + request.appUrl()); + throw new RemoteApiException(request.appUrl()); } Map attachmentToken = new HashMap<>(); diff --git a/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java index db29162..f7fa664 100644 --- a/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java @@ -58,4 +58,10 @@ public class GlobalExceptionHandler { return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(), BAD_REQUEST_EMPTY_FIELDS.getMessage() + " " + e.getMissingFields()); } + + @ExceptionHandler(RemoteApiException.class) + public ProblemDetail handleRemoteApiException(RemoteApiException e) { + return ProblemDetail.forStatusAndDetail(REMOTE_API_EMPTY_RESPONSE.getStatus(), + REMOTE_API_EMPTY_RESPONSE.getMessage() + e.getAppUrl()); + } } diff --git a/backend/src/main/java/com/vaessl/app/exception/RemoteApiException.java b/backend/src/main/java/com/vaessl/app/exception/RemoteApiException.java new file mode 100644 index 0000000..e35e40c --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/exception/RemoteApiException.java @@ -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; +} -- 2.54.0 From d91f39d0873979885c1ba5afda114c5d6e860db6 Mon Sep 17 00:00:00 2001 From: kasun Date: Sun, 17 May 2026 04:22:29 +0200 Subject: [PATCH 10/29] implemented basic search function with Homebox provider --- .../app/connection/ConnectionProvider.java | 4 +- .../com/vaessl/app/connection/Endpoint.java | 3 +- .../connection/HomeboxConnectionProvider.java | 6 +- .../app/connection/ServiceProvider.java | 10 +++ .../ConnectionNotFoundException.java | 4 + .../vaessl/app/exception/ErrorMessage.java | 7 +- .../app/exception/GlobalExceptionHandler.java | 5 ++ .../app/search/HomeboxSearchProvider.java | 79 +++++++++++++++++++ .../app/search/PagedSearchResponse.java | 14 ++++ .../vaessl/app/search/SearchController.java | 41 ++++++++++ .../com/vaessl/app/search/SearchProvider.java | 20 +++++ .../com/vaessl/app/search/SearchRequest.java | 7 ++ .../com/vaessl/app/search/SearchResponse.java | 16 ++++ .../com/vaessl/app/search/SearchService.java | 42 ++++++++++ 14 files changed, 252 insertions(+), 6 deletions(-) create mode 100644 backend/src/main/java/com/vaessl/app/connection/ServiceProvider.java create mode 100644 backend/src/main/java/com/vaessl/app/exception/ConnectionNotFoundException.java create mode 100644 backend/src/main/java/com/vaessl/app/search/HomeboxSearchProvider.java create mode 100644 backend/src/main/java/com/vaessl/app/search/PagedSearchResponse.java create mode 100644 backend/src/main/java/com/vaessl/app/search/SearchController.java create mode 100644 backend/src/main/java/com/vaessl/app/search/SearchProvider.java create mode 100644 backend/src/main/java/com/vaessl/app/search/SearchRequest.java create mode 100644 backend/src/main/java/com/vaessl/app/search/SearchResponse.java create mode 100644 backend/src/main/java/com/vaessl/app/search/SearchService.java diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java index a20d8bf..4815b39 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java @@ -2,12 +2,10 @@ package com.vaessl.app.connection; import java.time.Instant; -public interface ConnectionProvider { +public interface ConnectionProvider extends ServiceProvider { void checkCredentials(ConnectionRequest request); - String getServiceType(); - ConnectionResponse authenticate(ConnectionRequest request); ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request); diff --git a/backend/src/main/java/com/vaessl/app/connection/Endpoint.java b/backend/src/main/java/com/vaessl/app/connection/Endpoint.java index 0960c26..f52535a 100644 --- a/backend/src/main/java/com/vaessl/app/connection/Endpoint.java +++ b/backend/src/main/java/com/vaessl/app/connection/Endpoint.java @@ -1,7 +1,8 @@ package com.vaessl.app.connection; public enum Endpoint { - HOMEBOX_LOGIN("/api/v1/users/login"), LOGIN("/login"), CONNECTION_STATUS("/connections/status"); + HOMEBOX_LOGIN("/api/v1/users/login"), LOGIN("/login"), CONNECTION_STATUS( + "/connections/status"), HOMEBOX_QUERY_ALL_ITEMS("/api/v1/items"); private final String value; 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 6e86b8d..7685008 100644 --- a/backend/src/main/java/com/vaessl/app/connection/HomeboxConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/HomeboxConnectionProvider.java @@ -65,7 +65,11 @@ public class HomeboxConnectionProvider implements ConnectionProvider { 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 diff --git a/backend/src/main/java/com/vaessl/app/connection/ServiceProvider.java b/backend/src/main/java/com/vaessl/app/connection/ServiceProvider.java new file mode 100644 index 0000000..b5af34f --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/ServiceProvider.java @@ -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(); +} diff --git a/backend/src/main/java/com/vaessl/app/exception/ConnectionNotFoundException.java b/backend/src/main/java/com/vaessl/app/exception/ConnectionNotFoundException.java new file mode 100644 index 0000000..d02d713 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/exception/ConnectionNotFoundException.java @@ -0,0 +1,4 @@ +package com.vaessl.app.exception; + +public class ConnectionNotFoundException extends RuntimeException { +} diff --git a/backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java b/backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java index 17b27ff..f4013b1 100644 --- a/backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java +++ b/backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java @@ -11,7 +11,12 @@ public enum ErrorMessage { HttpStatus.SERVICE_UNAVAILABLE, "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."); + 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 String message; diff --git a/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java index f7fa664..3adbd51 100644 --- a/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java @@ -59,6 +59,11 @@ public class GlobalExceptionHandler { 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(), diff --git a/backend/src/main/java/com/vaessl/app/search/HomeboxSearchProvider.java b/backend/src/main/java/com/vaessl/app/search/HomeboxSearchProvider.java new file mode 100644 index 0000000..ee490ae --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/search/HomeboxSearchProvider.java @@ -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 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 items = hbResponse.items().stream().map(i -> { + String title = i.name(); + String description = i.description(); + Map 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 items) { + } + + private record HomeboxItem(String name, String description, HomeboxLocation location) { + } + + private record HomeboxLocation(String name, String description) { + } +} diff --git a/backend/src/main/java/com/vaessl/app/search/PagedSearchResponse.java b/backend/src/main/java/com/vaessl/app/search/PagedSearchResponse.java new file mode 100644 index 0000000..9da5d55 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/search/PagedSearchResponse.java @@ -0,0 +1,14 @@ +package com.vaessl.app.search; + +import java.util.List; +import org.springframework.data.domain.Page; + +public record PagedSearchResponse(List content, int page, int pageSize, long totalElements, + boolean first, boolean last, String sort) { + + public static PagedSearchResponse from(Page pageResult) { + return new PagedSearchResponse<>(pageResult.getContent(), pageResult.getNumber(), + pageResult.getSize(), pageResult.getTotalElements(), pageResult.isFirst(), + pageResult.isLast(), pageResult.getSort().toString()); + } +} diff --git a/backend/src/main/java/com/vaessl/app/search/SearchController.java b/backend/src/main/java/com/vaessl/app/search/SearchController.java new file mode 100644 index 0000000..e7d4ef1 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/search/SearchController.java @@ -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> 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 result = searchService.search(request, pageable); + + return ResponseEntity.ok(PagedSearchResponse.from(result)); + } +} diff --git a/backend/src/main/java/com/vaessl/app/search/SearchProvider.java b/backend/src/main/java/com/vaessl/app/search/SearchProvider.java new file mode 100644 index 0000000..906a544 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/search/SearchProvider.java @@ -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 getSearchResults(SearchRequest request, Pageable pageable); +} diff --git a/backend/src/main/java/com/vaessl/app/search/SearchRequest.java b/backend/src/main/java/com/vaessl/app/search/SearchRequest.java new file mode 100644 index 0000000..5019db7 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/search/SearchRequest.java @@ -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) { +} diff --git a/backend/src/main/java/com/vaessl/app/search/SearchResponse.java b/backend/src/main/java/com/vaessl/app/search/SearchResponse.java new file mode 100644 index 0000000..9257fd8 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/search/SearchResponse.java @@ -0,0 +1,16 @@ +package com.vaessl.app.search; + +import java.util.Map; + +public record SearchResponse(String title, String description, Map extraData) { + + public String getExtra(String key) { + if (extraData == null) { + return null; + } else { + Object value = extraData.get(key); + + return value != null ? String.valueOf(value) : null; + } + } +} diff --git a/backend/src/main/java/com/vaessl/app/search/SearchService.java b/backend/src/main/java/com/vaessl/app/search/SearchService.java new file mode 100644 index 0000000..f195f39 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/search/SearchService.java @@ -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 providerRegistry; + + public SearchService(List 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 search(SearchRequest request, Pageable pageable) { + + SearchProvider provider = providerRegistry.get(request.serviceType()); + + if (provider == null) { + throw new WrongServiceTypeException(); + } + + return provider.getSearchResults(request, pageable); + } +} -- 2.54.0 From c75bf2ad7134c5029d575d55d418d6681357bbbc Mon Sep 17 00:00:00 2001 From: kasun Date: Sun, 17 May 2026 19:52:40 +0200 Subject: [PATCH 11/29] changed hibernate schema creation --- backend/src/main/resources/application.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index 409f0ec..ec71453 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -13,7 +13,7 @@ spring: driver-class-name: ${PG_DRIVER_CLASS_NAME} jpa: hibernate: - ddl-auto: create-drop + ddl-auto: update show-sql: true ai: openai: -- 2.54.0 From 1ba85e129e61b58e758cead48e6a9ee303508b82 Mon Sep 17 00:00:00 2001 From: kasun Date: Mon, 18 May 2026 03:14:38 +0200 Subject: [PATCH 12/29] added SearchControllerTest --- .../com/vaessl/app/connection/Endpoint.java | 2 +- .../app/search/SearchControllerTest.java | 154 ++++++++++++++++++ 2 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 backend/src/test/java/com/vaessl/app/search/SearchControllerTest.java diff --git a/backend/src/main/java/com/vaessl/app/connection/Endpoint.java b/backend/src/main/java/com/vaessl/app/connection/Endpoint.java index f52535a..619e914 100644 --- a/backend/src/main/java/com/vaessl/app/connection/Endpoint.java +++ b/backend/src/main/java/com/vaessl/app/connection/Endpoint.java @@ -2,7 +2,7 @@ package com.vaessl.app.connection; public enum Endpoint { HOMEBOX_LOGIN("/api/v1/users/login"), LOGIN("/login"), CONNECTION_STATUS( - "/connections/status"), HOMEBOX_QUERY_ALL_ITEMS("/api/v1/items"); + "/connections/status"), HOMEBOX_QUERY_ALL_ITEMS("/api/v1/items"), SEARCH("/search"); private final String value; diff --git a/backend/src/test/java/com/vaessl/app/search/SearchControllerTest.java b/backend/src/test/java/com/vaessl/app/search/SearchControllerTest.java new file mode 100644 index 0000000..a04f7b5 --- /dev/null +++ b/backend/src/test/java/com/vaessl/app/search/SearchControllerTest.java @@ -0,0 +1,154 @@ +package com.vaessl.app.search; + +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 TEST_USER = "admin"; + + private static final String TEST_PASS = "pw"; + + 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, TEST_USER); + } + + private String connectionRequestBody(WireMockRuntimeInfo wm) { + return """ + { + "appUrl": "%s", + "serviceType": "HOMEBOX", + "username": "%s", + "password": "%s" + } + """.formatted(wm.getHttpBaseUrl(), TEST_USER, TEST_PASS); + } +} -- 2.54.0 From a7b984ca845106876874395a39e89144bb7994a3 Mon Sep 17 00:00:00 2001 From: kasun Date: Wed, 20 May 2026 17:36:22 +0200 Subject: [PATCH 13/29] 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 --- backend/src/test/java/com/vaessl/app/Mockdata.java | 13 +++++++++++++ .../app/connection/ConnectionControllerTest.java | 8 ++++---- .../app/connection/ConnectionServiceTest.java | 3 +-- .../connection/HomeboxConnectionProviderTest.java | 3 +-- .../app/connection/HomeboxIntegrationTest.java | 10 ++++------ .../java/com/vaessl/app/connection/Mockdata.java | 9 --------- .../com/vaessl/app/search/SearchControllerTest.java | 10 ++++------ 7 files changed, 27 insertions(+), 29 deletions(-) create mode 100644 backend/src/test/java/com/vaessl/app/Mockdata.java delete mode 100644 backend/src/test/java/com/vaessl/app/connection/Mockdata.java diff --git a/backend/src/test/java/com/vaessl/app/Mockdata.java b/backend/src/test/java/com/vaessl/app/Mockdata.java new file mode 100644 index 0000000..9d435e3 --- /dev/null +++ b/backend/src/test/java/com/vaessl/app/Mockdata.java @@ -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"; +} 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 30331e9..95b2a5d 100644 --- a/backend/src/test/java/com/vaessl/app/connection/ConnectionControllerTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/ConnectionControllerTest.java @@ -1,5 +1,7 @@ package com.vaessl.app.connection; +import static com.vaessl.app.Mockdata.*; + import static com.vaessl.app.connection.Endpoint.*; import static com.vaessl.app.connection.ServiceType.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; @@ -27,8 +29,6 @@ class ConnectionControllerTest { @Autowired 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 STATUS_PATH = CONNECTION_STATUS.getValue(); private static final String LOGOUT_PATH = "/connections/HOMEBOX"; @@ -71,7 +71,7 @@ class ConnectionControllerTest { 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].username").value(TEST_USER)) + .andExpect(jsonPath("$[0].username").value(MOCK_USER)) .andExpect(jsonPath("$[0].appUrl").value(wm.getHttpBaseUrl())) .andExpect(jsonPath("$[0].connected").value(true)); } @@ -149,6 +149,6 @@ class ConnectionControllerTest { "username": "%s", "password": "%s" } - """.formatted(wm.getHttpBaseUrl(), TEST_USER, TEST_PASS); + """.formatted(wm.getHttpBaseUrl(), MOCK_USER, MOCK_PASS); } } diff --git a/backend/src/test/java/com/vaessl/app/connection/ConnectionServiceTest.java b/backend/src/test/java/com/vaessl/app/connection/ConnectionServiceTest.java index 2543436..410a977 100644 --- a/backend/src/test/java/com/vaessl/app/connection/ConnectionServiceTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/ConnectionServiceTest.java @@ -1,5 +1,6 @@ package com.vaessl.app.connection; +import static com.vaessl.app.Mockdata.*; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doThrow; @@ -17,8 +18,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import com.vaessl.app.exception.EmptyCredentialsException; -import static com.vaessl.app.connection.Mockdata.*; - @ExtendWith(MockitoExtension.class) class ConnectionServiceTest { 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 9a9dfe5..e9e08b4 100644 --- a/backend/src/test/java/com/vaessl/app/connection/HomeboxConnectionProviderTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/HomeboxConnectionProviderTest.java @@ -5,9 +5,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import org.junit.jupiter.api.Test; import com.vaessl.app.exception.EmptyCredentialsException; - +import static com.vaessl.app.Mockdata.*; import static org.assertj.core.api.Assertions.assertThat; -import static com.vaessl.app.connection.Mockdata.*; class HomeboxConnectionProviderTest { 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 fbcd477..7f1c2ed 100644 --- a/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java @@ -1,5 +1,6 @@ package com.vaessl.app.connection; +import static com.vaessl.app.Mockdata.*; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.resttestclient.TestRestTemplate; @@ -41,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. * @@ -139,7 +137,7 @@ class HomeboxIntegrationTest { @Test void shouldReturnWrongServiceTypeException(WireMockRuntimeInfo wm) { ConnectionRequest wrongServiceTypeReq = new ConnectionRequest(wm.getHttpBaseUrl(), - "wrong-service-type", TEST_USER, TEST_PASS, false); + "wrong-service-type", MOCK_USER, MOCK_PASS, false); ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), wrongServiceTypeReq, String.class); @@ -182,7 +180,7 @@ class HomeboxIntegrationTest { @Test void shouldReturnEmptyCredentialsExceptionWhenCredsAreMissing(WireMockRuntimeInfo wm) { ConnectionRequest missingCredentials = new ConnectionRequest(wm.getHttpBaseUrl(), - HOMEBOX.getValue(), TEST_USER, null, false); + HOMEBOX.getValue(), MOCK_USER, null, false); ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), missingCredentials, String.class); @@ -198,7 +196,7 @@ class HomeboxIntegrationTest { * @return a mock api connection request. */ 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); } } diff --git a/backend/src/test/java/com/vaessl/app/connection/Mockdata.java b/backend/src/test/java/com/vaessl/app/connection/Mockdata.java deleted file mode 100644 index 657e15c..0000000 --- a/backend/src/test/java/com/vaessl/app/connection/Mockdata.java +++ /dev/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"; -} 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 a04f7b5..cfded59 100644 --- a/backend/src/test/java/com/vaessl/app/search/SearchControllerTest.java +++ b/backend/src/test/java/com/vaessl/app/search/SearchControllerTest.java @@ -1,5 +1,7 @@ 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.*; @@ -24,10 +26,6 @@ class SearchControllerTest { @Autowired MockMvc mockMvc; - private static final String TEST_USER = "admin"; - - private static final String TEST_PASS = "pw"; - private static final String QUERY_ALL_ITEMS = HOMEBOX_QUERY_ALL_ITEMS.getValue(); private static final String LOGIN_PATH = LOGIN.getValue(); @@ -138,7 +136,7 @@ class SearchControllerTest { "serviceType": "%s", "username": "%s" } - """.formatted(wm.getHttpBaseUrl(), serviceType, TEST_USER); + """.formatted(wm.getHttpBaseUrl(), serviceType, MOCK_USER); } private String connectionRequestBody(WireMockRuntimeInfo wm) { @@ -149,6 +147,6 @@ class SearchControllerTest { "username": "%s", "password": "%s" } - """.formatted(wm.getHttpBaseUrl(), TEST_USER, TEST_PASS); + """.formatted(wm.getHttpBaseUrl(), MOCK_USER, MOCK_PASS); } } -- 2.54.0 From ea866377bc637356de7dba4fd02561466764b8b9 Mon Sep 17 00:00:00 2001 From: kasun Date: Wed, 20 May 2026 17:36:31 +0200 Subject: [PATCH 14/29] test: add unit tests for HomeboxSearchProvider and SearchResponse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../app/search/HomeboxSearchProviderTest.java | 35 +++++++++++++++++++ .../vaessl/app/search/SearchResponseTest.java | 33 +++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 backend/src/test/java/com/vaessl/app/search/HomeboxSearchProviderTest.java create mode 100644 backend/src/test/java/com/vaessl/app/search/SearchResponseTest.java diff --git a/backend/src/test/java/com/vaessl/app/search/HomeboxSearchProviderTest.java b/backend/src/test/java/com/vaessl/app/search/HomeboxSearchProviderTest.java new file mode 100644 index 0000000..cdd8dac --- /dev/null +++ b/backend/src/test/java/com/vaessl/app/search/HomeboxSearchProviderTest.java @@ -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)); + } +} diff --git a/backend/src/test/java/com/vaessl/app/search/SearchResponseTest.java b/backend/src/test/java/com/vaessl/app/search/SearchResponseTest.java new file mode 100644 index 0000000..265abcd --- /dev/null +++ b/backend/src/test/java/com/vaessl/app/search/SearchResponseTest.java @@ -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"); + + } +} -- 2.54.0 From 1a86c23565b6aca408631770ca2a89f273579e8a Mon Sep 17 00:00:00 2001 From: kasun Date: Thu, 21 May 2026 20:42:14 +0200 Subject: [PATCH 15/29] corrected javadoc --- backend/src/main/java/com/vaessl/app/search/SearchProvider.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/java/com/vaessl/app/search/SearchProvider.java b/backend/src/main/java/com/vaessl/app/search/SearchProvider.java index 906a544..a5fca4d 100644 --- a/backend/src/main/java/com/vaessl/app/search/SearchProvider.java +++ b/backend/src/main/java/com/vaessl/app/search/SearchProvider.java @@ -14,7 +14,7 @@ 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 + * @return a list of Page items matching the query */ Page getSearchResults(SearchRequest request, Pageable pageable); } -- 2.54.0 From 5776676eeb8005315d8ea4541d866812c85ff7b7 Mon Sep 17 00:00:00 2001 From: kasun Date: Thu, 21 May 2026 20:57:32 +0200 Subject: [PATCH 16/29] refactor: centralise session key format in SessionKeys utility --- .../vaessl/app/connection/ConnectionController.java | 13 ++++++------- .../java/com/vaessl/app/connection/SessionKeys.java | 12 ++++++++++++ .../com/vaessl/app/search/SearchController.java | 3 ++- 3 files changed, 20 insertions(+), 8 deletions(-) create mode 100644 backend/src/main/java/com/vaessl/app/connection/SessionKeys.java 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 9222b63..e639db2 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java @@ -23,8 +23,6 @@ import lombok.RequiredArgsConstructor; @RequiredArgsConstructor public class ConnectionController { - private static final String SUFFIX = "_CONNECTION_ID"; - private final ConnectionService connectionService; @PostMapping("/login") @@ -34,7 +32,7 @@ public class ConnectionController { LoginResult result = connectionService.login(request); HttpSession session = httpReq.getSession(true); - session.setAttribute(request.serviceType() + SUFFIX, result.connectionId()); + session.setAttribute(SessionKeys.connectionId(request.serviceType()), result.connectionId()); if (result.expiresAt() != null) { long secs = Instant.now().until(result.expiresAt(), ChronoUnit.SECONDS); @@ -52,9 +50,10 @@ public class ConnectionController { } List statuses = new ArrayList<>(); - Collections.list(session.getAttributeNames()).stream().filter(k -> k.endsWith(SUFFIX)) + Collections.list(session.getAttributeNames()).stream() + .filter(k -> k.endsWith(SessionKeys.CONNECTION_ID_SUFFIX)) .forEach(k -> { - String serviceType = k.replace(SUFFIX, ""); + String serviceType = k.replace(SessionKeys.CONNECTION_ID_SUFFIX, ""); Long id = (Long) session.getAttribute(k); ConnectionStatusResponse status = connectionService.getConnectionStatus(serviceType, id); @@ -71,9 +70,9 @@ public class ConnectionController { HttpSession session = httpReq.getSession(false); if (session != null) { - session.removeAttribute(serviceType + SUFFIX); + session.removeAttribute(SessionKeys.connectionId(serviceType)); boolean hasMore = Collections.list(session.getAttributeNames()).stream() - .anyMatch(k -> k.endsWith(SUFFIX)); + .anyMatch(k -> k.endsWith(SessionKeys.CONNECTION_ID_SUFFIX)); if (!hasMore) { session.invalidate(); } diff --git a/backend/src/main/java/com/vaessl/app/connection/SessionKeys.java b/backend/src/main/java/com/vaessl/app/connection/SessionKeys.java new file mode 100644 index 0000000..2566df2 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/SessionKeys.java @@ -0,0 +1,12 @@ +package com.vaessl.app.connection; + +public final class SessionKeys { + + static final String CONNECTION_ID_SUFFIX = "_CONNECTION_ID"; + + private SessionKeys() {} + + public static String connectionId(String serviceType) { + return serviceType + CONNECTION_ID_SUFFIX; + } +} diff --git a/backend/src/main/java/com/vaessl/app/search/SearchController.java b/backend/src/main/java/com/vaessl/app/search/SearchController.java index e7d4ef1..fce1905 100644 --- a/backend/src/main/java/com/vaessl/app/search/SearchController.java +++ b/backend/src/main/java/com/vaessl/app/search/SearchController.java @@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; +import com.vaessl.app.connection.SessionKeys; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; @@ -30,7 +31,7 @@ public class SearchController { @PageableDefault(size = 20) Pageable pageable, HttpServletRequest httpReq) { HttpSession session = httpReq.getSession(false); if (session == null - || session.getAttribute(request.serviceType() + "_CONNECTION_ID") == null) { + || session.getAttribute(SessionKeys.connectionId(request.serviceType())) == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } -- 2.54.0 From f0d536d8f415c874491185bd0f1d474117d7b07d Mon Sep 17 00:00:00 2001 From: kasun Date: Tue, 23 Jun 2026 19:08:35 +0200 Subject: [PATCH 17/29] bug: fixed changed Homebox endpoint and added better exception handling. --- .../com/vaessl/app/connection/Endpoint.java | 2 +- .../connection/HomeboxConnectionProvider.java | 2 +- .../vaessl/app/exception/ErrorMessage.java | 4 +++- .../app/exception/GlobalExceptionHandler.java | 21 ++++++++++++++++--- .../app/exception/RemoteApiException.java | 9 ++++++-- .../app/search/HomeboxSearchProvider.java | 10 ++++----- .../app/search/SearchControllerTest.java | 2 +- 7 files changed, 36 insertions(+), 14 deletions(-) diff --git a/backend/src/main/java/com/vaessl/app/connection/Endpoint.java b/backend/src/main/java/com/vaessl/app/connection/Endpoint.java index 619e914..da8d35a 100644 --- a/backend/src/main/java/com/vaessl/app/connection/Endpoint.java +++ b/backend/src/main/java/com/vaessl/app/connection/Endpoint.java @@ -2,7 +2,7 @@ package com.vaessl.app.connection; public enum Endpoint { HOMEBOX_LOGIN("/api/v1/users/login"), LOGIN("/login"), CONNECTION_STATUS( - "/connections/status"), HOMEBOX_QUERY_ALL_ITEMS("/api/v1/items"), SEARCH("/search"); + "/connections/status"), HOMEBOX_QUERY_ALL_ITEMS("/api/v1/entities"), SEARCH("/search"); private final String value; 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 7685008..6f664ff 100644 --- a/backend/src/main/java/com/vaessl/app/connection/HomeboxConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/HomeboxConnectionProvider.java @@ -58,7 +58,7 @@ public class HomeboxConnectionProvider implements ConnectionProvider { .body(HomeboxLoginResponse.class); if (hbResponse == null) { - throw new RemoteApiException(request.appUrl()); + throw new RemoteApiException(request.appUrl(), HOMEBOX_LOGIN.getValue()); } Map attachmentToken = new HashMap<>(); diff --git a/backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java b/backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java index f4013b1..7e4fc96 100644 --- a/backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java +++ b/backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java @@ -16,7 +16,9 @@ public enum ErrorMessage { HttpStatus.NOT_FOUND, "No active connection found for this service."), REMOTE_API_EMPTY_RESPONSE( HttpStatus.BAD_GATEWAY, - "Remote API returned empty response for "); + "Remote API returned empty response for "), REMOTE_API_CLIENT_ERROR( + HttpStatus.BAD_GATEWAY, + "Remote API returned an unexpected error: "); private final HttpStatus status; private final String message; diff --git a/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java index 3adbd51..8019f6a 100644 --- a/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java @@ -1,5 +1,7 @@ package com.vaessl.app.exception; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.ProblemDetail; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -16,6 +18,8 @@ import java.util.stream.Collectors; @RestControllerAdvice public class GlobalExceptionHandler { + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + @ExceptionHandler(MethodArgumentNotValidException.class) public ProblemDetail handleEmptyCredentialInput(MethodArgumentNotValidException e) { @@ -33,6 +37,15 @@ public class GlobalExceptionHandler { UNAUTHORIZED_WRONG_LOGIN.getMessage()); } + // Catches any other 4xx from a remote API (e.g. 404 caused by a changed endpoint). + // Must come after the Unauthorized handler so Spring matches 401 there first. + @ExceptionHandler(HttpClientErrorException.class) + public ProblemDetail handleRemoteClientError(HttpClientErrorException e) { + log.error("Remote API returned {}: {}", e.getStatusCode(), e.getResponseBodyAsString()); + return ProblemDetail.forStatusAndDetail(REMOTE_API_CLIENT_ERROR.getStatus(), + REMOTE_API_CLIENT_ERROR.getMessage() + e.getStatusCode()); + } + @ExceptionHandler(ResourceAccessException.class) public ProblemDetail handleNoConnection(ResourceAccessException e) { @@ -42,7 +55,7 @@ public class GlobalExceptionHandler { @ExceptionHandler(HttpServerErrorException.class) public ProblemDetail handleTimeoutOrNotFound(HttpServerErrorException e) { - + log.error("Remote API server error {}: {}", e.getStatusCode(), e.getResponseBodyAsString()); return ProblemDetail.forStatusAndDetail(e.getStatusCode(), SERVER_ERROR_GENERAL.getMessage() + e.getStatusText()); } @@ -61,12 +74,14 @@ public class GlobalExceptionHandler { @ExceptionHandler(ConnectionNotFoundException.class) public ProblemDetail handleConnectionNotFound(ConnectionNotFoundException e) { - return ProblemDetail.forStatusAndDetail(CONNECTION_NOT_FOUND.getStatus(), CONNECTION_NOT_FOUND.getMessage()); + return ProblemDetail.forStatusAndDetail(CONNECTION_NOT_FOUND.getStatus(), + CONNECTION_NOT_FOUND.getMessage()); } @ExceptionHandler(RemoteApiException.class) public ProblemDetail handleRemoteApiException(RemoteApiException e) { + log.error("Remote API empty body: {}{}", e.getAppUrl(), e.getPath()); return ProblemDetail.forStatusAndDetail(REMOTE_API_EMPTY_RESPONSE.getStatus(), - REMOTE_API_EMPTY_RESPONSE.getMessage() + e.getAppUrl()); + REMOTE_API_EMPTY_RESPONSE.getMessage() + e.getAppUrl() + e.getPath()); } } diff --git a/backend/src/main/java/com/vaessl/app/exception/RemoteApiException.java b/backend/src/main/java/com/vaessl/app/exception/RemoteApiException.java index e35e40c..35ac62c 100644 --- a/backend/src/main/java/com/vaessl/app/exception/RemoteApiException.java +++ b/backend/src/main/java/com/vaessl/app/exception/RemoteApiException.java @@ -1,11 +1,16 @@ package com.vaessl.app.exception; import lombok.Getter; -import lombok.RequiredArgsConstructor; @Getter -@RequiredArgsConstructor public class RemoteApiException extends RuntimeException { private final String appUrl; + private final String path; + + public RemoteApiException(String appUrl, String path) { + super("Remote API returned empty body: " + appUrl + path); + this.appUrl = appUrl; + this.path = path; + } } 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 ee490ae..e2b7f57 100644 --- a/backend/src/main/java/com/vaessl/app/search/HomeboxSearchProvider.java +++ b/backend/src/main/java/com/vaessl/app/search/HomeboxSearchProvider.java @@ -50,17 +50,17 @@ public class HomeboxSearchProvider implements SearchProvider { .queryParam("q", request.query()) .queryParam("page", pageable.getPageNumber() + 1) .queryParam("pageSize", pageable.getPageSize()).build()) - .headers(h -> h.setBearerAuth( - hbEntity.getToken())).retrieve().body(HomeboxSearchResponse.class); + .headers(h -> h.setBearerAuth(hbEntity.getToken())).retrieve() + .body(HomeboxSearchResponse.class); if (hbResponse == null) { - throw new RemoteApiException(request.appUrl()); + throw new RemoteApiException(request.appUrl(), HOMEBOX_QUERY_ALL_ITEMS.getValue()); } List items = hbResponse.items().stream().map(i -> { String title = i.name(); String description = i.description(); - Map extraSearchResponseData = Map.of("location", i.location()); + Map extraSearchResponseData = Map.of("location", i.parent()); return new SearchResponse(title, description, extraSearchResponseData); }).toList(); @@ -71,7 +71,7 @@ public class HomeboxSearchProvider implements SearchProvider { List items) { } - private record HomeboxItem(String name, String description, HomeboxLocation location) { + private record HomeboxItem(String name, String description, HomeboxLocation parent) { } private record HomeboxLocation(String name, String description) { 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 cfded59..eb66eee 100644 --- a/backend/src/test/java/com/vaessl/app/search/SearchControllerTest.java +++ b/backend/src/test/java/com/vaessl/app/search/SearchControllerTest.java @@ -57,7 +57,7 @@ class SearchControllerTest { "createdAt": "2026-05-13T19:52:20.016176Z", "updatedAt": "2026-05-14T12:39:11.836403Z", "purchasePrice": 0, - "location": { + "parent": { "id": "b6f60ab8-3a2a-4a8d-a4bf-897d0555f636", "name": "Server Schrank Ikea weiß", "description": "Weißer Ikea Schrank, wo sich der Server befindet.", -- 2.54.0 From b7bc8f552503fa121497bd237fe8f663c582e768 Mon Sep 17 00:00:00 2001 From: kasun Date: Wed, 24 Jun 2026 00:58:45 +0200 Subject: [PATCH 18/29] added first searchmodal draft --- .../app/search/HomeboxSearchProvider.java | 8 +++ frontend/src/api/searches.ts | 9 +++ frontend/src/components/ActionButton.scss | 35 ++++++++++ frontend/src/components/ActionButton.tsx | 17 +++++ frontend/src/components/Dashboard.tsx | 32 ++++++--- frontend/src/components/SearchModal.tsx | 69 +++++++++++++++++++ frontend/src/components/ServiceCard.scss | 35 ---------- frontend/src/components/ServiceCard.tsx | 17 ++--- frontend/src/types/search.ts | 6 ++ 9 files changed, 175 insertions(+), 53 deletions(-) create mode 100644 frontend/src/api/searches.ts create mode 100644 frontend/src/components/ActionButton.scss create mode 100644 frontend/src/components/ActionButton.tsx create mode 100644 frontend/src/components/SearchModal.tsx create mode 100644 frontend/src/types/search.ts 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 e2b7f57..d21f9c6 100644 --- a/backend/src/main/java/com/vaessl/app/search/HomeboxSearchProvider.java +++ b/backend/src/main/java/com/vaessl/app/search/HomeboxSearchProvider.java @@ -15,11 +15,17 @@ import com.vaessl.app.connection.HomeboxEntity; 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; @@ -64,6 +70,8 @@ public class HomeboxSearchProvider implements SearchProvider { return new SearchResponse(title, description, extraSearchResponseData); }).toList(); + log.info("Search results for query '{}': {}", request.query(), items); + return new PageImpl<>(items, pageable, hbResponse.total()); } diff --git a/frontend/src/api/searches.ts b/frontend/src/api/searches.ts new file mode 100644 index 0000000..ad471f7 --- /dev/null +++ b/frontend/src/api/searches.ts @@ -0,0 +1,9 @@ +import { apiFetch } from "./client"; +import type { AuthResponse } from "../types/connection"; +import type { SearchRequest } from "../types/search"; + +export const search = (req: SearchRequest) => + apiFetch("/search", { + method: "POST", + body: JSON.stringify(req), + }); diff --git a/frontend/src/components/ActionButton.scss b/frontend/src/components/ActionButton.scss new file mode 100644 index 0000000..6a71384 --- /dev/null +++ b/frontend/src/components/ActionButton.scss @@ -0,0 +1,35 @@ +.action-btn { + font-size: 14px; + font-weight: 500; + padding: 7px 16px; + border-radius: 6px; + border: 1px solid transparent; + cursor: pointer; + transition: box-shadow 0.2s, opacity 0.2s; + + &:hover { + opacity: 0.85; + } + + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + + &--connect, + &--search { + background: var(--accent); + color: #fff; + } + + &--disconnect { + background: transparent; + color: var(--text); + border-color: var(--border); + + &:hover { + border-color: #ef4444; + color: #ef4444; + } + } +} diff --git a/frontend/src/components/ActionButton.tsx b/frontend/src/components/ActionButton.tsx new file mode 100644 index 0000000..37c54b8 --- /dev/null +++ b/frontend/src/components/ActionButton.tsx @@ -0,0 +1,17 @@ +import './ActionButton.scss' + +type Variant = 'connect' | 'disconnect' | 'search' + +interface Props { + variant: Variant + onClick: () => void + children: React.ReactNode +} + +export function ActionButton({ variant, onClick, children }: Readonly) { + return ( + + ) +} diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index b75a625..35c1f76 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -4,6 +4,7 @@ import type { ConnectionStatus } from "../types/connection" import { ServiceCard } from "./ServiceCard" import { ConnectModal } from "./ConnectModal" import "./Dashboard.scss" +import { SearchModal } from "./SearchModal" const SERVICES = [ { serviceType: 'HOMEBOX', label: 'Homebox', icon: '📦' }, @@ -11,7 +12,8 @@ const SERVICES = [ export function Dashboard() { const [statuses, setStatuses] = useState([]) - const [openModal, setOpenModal] = useState(null) + const [openConnectionModal, setOpenConnectionModal] = useState(null) + const [openSearchModal, setOpenSearchModal] = useState(null) const refresh = () => { getStatuses().then(setStatuses).catch(() => { }) @@ -23,7 +25,9 @@ export function Dashboard() { logout(serviceType).then(refresh).catch(() => { }) } - const activeModal = SERVICES.find(s => s.serviceType === openModal) + const activeConnectionModal = SERVICES.find(s => s.serviceType === openConnectionModal) + const activeSearchModal = SERVICES.find(s => s.serviceType === openSearchModal) + const activeSearchStatus = statuses.find(s => s.serviceType === openSearchModal) return (
@@ -40,18 +44,30 @@ export function Dashboard() { label={label} icon={icon} status={statuses.find(s => s.serviceType === serviceType) ?? null} - onConnect={() => setOpenModal(serviceType)} + onConnect={() => setOpenConnectionModal(serviceType)} onDisconnect={() => handleDisconnect(serviceType)} + onSearch={() => setOpenSearchModal(serviceType)} /> ))}
- {activeModal && ( + {activeConnectionModal && ( setOpenModal(null)} - onSuccess={() => { setOpenModal(null); refresh() }} + serviceType={activeConnectionModal.serviceType} + label={activeConnectionModal.label} + onClose={() => setOpenSearchModal(null)} + onSuccess={() => { setOpenSearchModal(null); refresh() }} + /> + )} + + {activeSearchModal && ( + setOpenSearchModal(null) } + /> )} diff --git a/frontend/src/components/SearchModal.tsx b/frontend/src/components/SearchModal.tsx new file mode 100644 index 0000000..1677d00 --- /dev/null +++ b/frontend/src/components/SearchModal.tsx @@ -0,0 +1,69 @@ +import { useEffect, useRef, useState, type SyntheticEvent } from 'react' +import './ConnectModal.scss' +import type { SearchRequest } from '../types/search' +import { search } from '../api/searches' + +interface Props { + serviceType: string + label: string + appUrl: string + username: string + onClose: () => void +} + +export function SearchModal({ serviceType, label, appUrl, username, onClose }: Readonly) { + const [query, setQuery] = useState(''); + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const firstInputRef = useRef(null) + + const dialogRef = useRef(null) + + useEffect(() => { + const dialog = dialogRef.current + if (!dialog) return + + dialog.showModal() + firstInputRef.current?.focus() + + const handleCancel = (e: Event) => { + e.preventDefault() + onClose() + } + dialog.addEventListener('cancel', handleCancel) + return () => dialog.removeEventListener('cancel', handleCancel) + }, [onClose]) + + const handleSubmit = async (e: SyntheticEvent) => { + e.preventDefault() + setError(null) + setLoading(true) + try { + const req: SearchRequest = { appUrl, serviceType, username, query} + await search(req) + } catch (err) { + setError(err instanceof Error ? err.message : 'Search failed') + } finally { + setLoading(false) + } + } + + return ( + +
+ + +
+
+
+ setQuery(e.target.value)} /> +
+ {error &&

{error}

} + +
+
+ ) +} diff --git a/frontend/src/components/ServiceCard.scss b/frontend/src/components/ServiceCard.scss index b35c55c..bab31b0 100644 --- a/frontend/src/components/ServiceCard.scss +++ b/frontend/src/components/ServiceCard.scss @@ -81,39 +81,4 @@ display: flex; gap: 8px; } - - &__btn { - font-size: 14px; - font-weight: 500; - padding: 7px 16px; - border-radius: 6px; - border: 1px solid transparent; - cursor: pointer; - transition: box-shadow 0.2s, opacity 0.2s; - - &:hover { - opacity: 0.85; - } - - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } - - &--connect { - background: var(--accent); - color: #fff; - } - - &--disconnect { - background: transparent; - color: var(--text); - border-color: var(--border); - - &:hover { - border-color: #ef4444; - color: #ef4444; - } - } - } } \ No newline at end of file diff --git a/frontend/src/components/ServiceCard.tsx b/frontend/src/components/ServiceCard.tsx index 3b1bc5d..5a5bb3c 100644 --- a/frontend/src/components/ServiceCard.tsx +++ b/frontend/src/components/ServiceCard.tsx @@ -1,4 +1,5 @@ import type { ConnectionStatus } from '../types/connection' +import { ActionButton } from './ActionButton' import './ServiceCard.scss' interface Props { @@ -8,9 +9,10 @@ interface Props { status: ConnectionStatus | null onConnect: () => void onDisconnect: () => void + onSearch: () => void } -export function ServiceCard({ serviceType: _serviceType, label, icon, status, onConnect, onDisconnect }: Readonly) { +export function ServiceCard({ serviceType: _serviceType, label, icon, status, onConnect, onDisconnect, onSearch }: Readonly) { const connected = status?.connected ?? false const formatExpiry = (iso: string | null) => { @@ -37,15 +39,10 @@ export function ServiceCard({ serviceType: _serviceType, label, icon, status, on
- {connected ? ( - - ) : ( - - )} + + {connected ? 'Disconnect' : 'Connect'} + + {connected ? (Search) : null}
) diff --git a/frontend/src/types/search.ts b/frontend/src/types/search.ts new file mode 100644 index 0000000..c195a92 --- /dev/null +++ b/frontend/src/types/search.ts @@ -0,0 +1,6 @@ +export interface SearchRequest { + appUrl: string; + serviceType: string; + username: string; + query: string; +} -- 2.54.0 From 17a959e7a70b3eeabf340416b3e60b9b03c986b5 Mon Sep 17 00:00:00 2001 From: kasun Date: Wed, 24 Jun 2026 13:24:50 +0200 Subject: [PATCH 19/29] removed wrong operator --- frontend/src/components/ServiceCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/ServiceCard.tsx b/frontend/src/components/ServiceCard.tsx index 5a5bb3c..6ed8517 100644 --- a/frontend/src/components/ServiceCard.tsx +++ b/frontend/src/components/ServiceCard.tsx @@ -42,7 +42,7 @@ export function ServiceCard({ serviceType: _serviceType, label, icon, status, on {connected ? 'Disconnect' : 'Connect'} - {connected ? (Search) : null} + {connected && (Search)} ) -- 2.54.0 From 61d399761da0073beb42773ef7718631345a6238 Mon Sep 17 00:00:00 2001 From: kasun Date: Wed, 24 Jun 2026 13:25:13 +0200 Subject: [PATCH 20/29] fixed wrong modal reference --- frontend/src/components/Dashboard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index 35c1f76..c2c6827 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -55,8 +55,8 @@ export function Dashboard() { setOpenSearchModal(null)} - onSuccess={() => { setOpenSearchModal(null); refresh() }} + onClose={() => setOpenConnectionModal(null)} + onSuccess={() => { setOpenConnectionModal(null); refresh() }} /> )} -- 2.54.0 From a1ce08512321294dedb249cc00dbe7894000fd3f Mon Sep 17 00:00:00 2001 From: kasun Date: Wed, 24 Jun 2026 13:25:29 +0200 Subject: [PATCH 21/29] fixed format --- frontend/src/components/SearchModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/SearchModal.tsx b/frontend/src/components/SearchModal.tsx index 1677d00..904f05c 100644 --- a/frontend/src/components/SearchModal.tsx +++ b/frontend/src/components/SearchModal.tsx @@ -61,7 +61,7 @@ export function SearchModal({ serviceType, label, appUrl, username, onClose }: R {error &&

{error}

} -- 2.54.0 From 6e3d3347e20b95acbd8d2489065cb85a95fa6737 Mon Sep 17 00:00:00 2001 From: kasun Date: Wed, 24 Jun 2026 15:57:10 +0200 Subject: [PATCH 22/29] added search response into searchmodal ui, restructured folders --- .../app/search/HomeboxSearchProvider.java | 5 ++- .../com/vaessl/app/search/SearchResponse.java | 3 +- .../test/java/com/vaessl/app/Mockdata.java | 1 + .../vaessl/app/search/SearchResponseTest.java | 7 +-- frontend/src/App.tsx | 2 +- frontend/src/api/searches.ts | 5 +-- .../{ => connections}/ConnectModal.tsx | 6 +-- .../{ => connections}/Dashboard.scss | 0 .../{ => connections}/Dashboard.tsx | 6 +-- .../{ => connections}/ServiceCard.scss | 0 .../{ => connections}/ServiceCard.tsx | 4 +- .../components/{ => search}/SearchModal.tsx | 42 ++++++++++++------ .../src/components/{ => ui}/ActionButton.scss | 0 .../src/components/{ => ui}/ActionButton.tsx | 0 .../{ConnectModal.scss => ui/Modal.scss} | 44 ++++++++++++++++++- frontend/src/types/search.ts | 19 +++++++- 16 files changed, 110 insertions(+), 34 deletions(-) rename frontend/src/components/{ => connections}/ConnectModal.tsx (95%) rename frontend/src/components/{ => connections}/Dashboard.scss (100%) rename frontend/src/components/{ => connections}/Dashboard.tsx (94%) rename frontend/src/components/{ => connections}/ServiceCard.scss (100%) rename frontend/src/components/{ => connections}/ServiceCard.tsx (93%) rename frontend/src/components/{ => search}/SearchModal.tsx (51%) rename frontend/src/components/{ => ui}/ActionButton.scss (100%) rename frontend/src/components/{ => ui}/ActionButton.tsx (100%) rename frontend/src/components/{ConnectModal.scss => ui/Modal.scss} (72%) 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 d21f9c6..b494f36 100644 --- a/backend/src/main/java/com/vaessl/app/search/HomeboxSearchProvider.java +++ b/backend/src/main/java/com/vaessl/app/search/HomeboxSearchProvider.java @@ -64,10 +64,11 @@ public class HomeboxSearchProvider implements SearchProvider { } List items = hbResponse.items().stream().map(i -> { + String id = i.id(); String title = i.name(); String description = i.description(); Map extraSearchResponseData = Map.of("location", i.parent()); - return new SearchResponse(title, description, extraSearchResponseData); + return new SearchResponse(id, title, description, extraSearchResponseData); }).toList(); log.info("Search results for query '{}': {}", request.query(), items); @@ -79,7 +80,7 @@ public class HomeboxSearchProvider implements SearchProvider { List items) { } - private record HomeboxItem(String name, String description, HomeboxLocation parent) { + private record HomeboxItem(String id, String name, String description, HomeboxLocation parent) { } private record HomeboxLocation(String name, String description) { diff --git a/backend/src/main/java/com/vaessl/app/search/SearchResponse.java b/backend/src/main/java/com/vaessl/app/search/SearchResponse.java index 9257fd8..5327334 100644 --- a/backend/src/main/java/com/vaessl/app/search/SearchResponse.java +++ b/backend/src/main/java/com/vaessl/app/search/SearchResponse.java @@ -2,7 +2,8 @@ package com.vaessl.app.search; import java.util.Map; -public record SearchResponse(String title, String description, Map extraData) { +public record SearchResponse(String id, String title, String description, + Map extraData) { public String getExtra(String key) { if (extraData == null) { diff --git a/backend/src/test/java/com/vaessl/app/Mockdata.java b/backend/src/test/java/com/vaessl/app/Mockdata.java index 9d435e3..6e0c411 100644 --- a/backend/src/test/java/com/vaessl/app/Mockdata.java +++ b/backend/src/test/java/com/vaessl/app/Mockdata.java @@ -8,6 +8,7 @@ public final class Mockdata { 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_ID = "item-1"; public static final String MOCK_TITLE = "title"; public static final String MOCK_DESCRIPTION = "desc"; } diff --git a/backend/src/test/java/com/vaessl/app/search/SearchResponseTest.java b/backend/src/test/java/com/vaessl/app/search/SearchResponseTest.java index 265abcd..0970e25 100644 --- a/backend/src/test/java/com/vaessl/app/search/SearchResponseTest.java +++ b/backend/src/test/java/com/vaessl/app/search/SearchResponseTest.java @@ -9,7 +9,7 @@ class SearchResponseTest { @Test void shouldReturnNullWhenExtraDataIsNull() { - SearchResponse response = new SearchResponse(MOCK_TITLE, MOCK_DESCRIPTION, null); + SearchResponse response = new SearchResponse(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, null); assertThat(response.getExtra(null)).isNull(); } @@ -17,7 +17,7 @@ class SearchResponseTest { @Test void shouldReturnNullWhenExtraDataKeyIsMissing() { - SearchResponse response = new SearchResponse(MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value")); + SearchResponse response = new SearchResponse(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value")); assertThat(response.getExtra("missing")).isNull(); } @@ -25,8 +25,9 @@ class SearchResponseTest { @Test void shouldReturnExtraDataValue() { - SearchResponse response = new SearchResponse(MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value")); + SearchResponse response = new SearchResponse(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value")); + assertThat(response.id()).isEqualTo(MOCK_ID); assertThat(response.getExtra("key")).contains("value"); } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c30b522..44a492a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ -import { Dashboard } from './components/Dashboard' +import { Dashboard } from './components/connections/Dashboard' function App() { return ( diff --git a/frontend/src/api/searches.ts b/frontend/src/api/searches.ts index ad471f7..18aaa08 100644 --- a/frontend/src/api/searches.ts +++ b/frontend/src/api/searches.ts @@ -1,9 +1,8 @@ import { apiFetch } from "./client"; -import type { AuthResponse } from "../types/connection"; -import type { SearchRequest } from "../types/search"; +import type { PagedSearchResponse, SearchRequest, SearchResponse } from "../types/search"; export const search = (req: SearchRequest) => - apiFetch("/search", { + apiFetch>("/search", { method: "POST", body: JSON.stringify(req), }); diff --git a/frontend/src/components/ConnectModal.tsx b/frontend/src/components/connections/ConnectModal.tsx similarity index 95% rename from frontend/src/components/ConnectModal.tsx rename to frontend/src/components/connections/ConnectModal.tsx index 692d74b..ebce9d0 100644 --- a/frontend/src/components/ConnectModal.tsx +++ b/frontend/src/components/connections/ConnectModal.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState, type SyntheticEvent } from 'react' -import { login } from '../api/connections' -import type { LoginRequest } from '../types/connection' -import './ConnectModal.scss' +import { login } from '../../api/connections' +import type { LoginRequest } from '../../types/connection' +import '../ui/Modal.scss' interface Props { serviceType: string diff --git a/frontend/src/components/Dashboard.scss b/frontend/src/components/connections/Dashboard.scss similarity index 100% rename from frontend/src/components/Dashboard.scss rename to frontend/src/components/connections/Dashboard.scss diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/connections/Dashboard.tsx similarity index 94% rename from frontend/src/components/Dashboard.tsx rename to frontend/src/components/connections/Dashboard.tsx index c2c6827..b7feed9 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/connections/Dashboard.tsx @@ -1,10 +1,10 @@ import { useState, useEffect } from "react" -import { getStatuses, logout } from "../api/connections" -import type { ConnectionStatus } from "../types/connection" +import { getStatuses, logout } from "../../api/connections" +import type { ConnectionStatus } from "../../types/connection" import { ServiceCard } from "./ServiceCard" import { ConnectModal } from "./ConnectModal" import "./Dashboard.scss" -import { SearchModal } from "./SearchModal" +import { SearchModal } from "../search/SearchModal" const SERVICES = [ { serviceType: 'HOMEBOX', label: 'Homebox', icon: '📦' }, diff --git a/frontend/src/components/ServiceCard.scss b/frontend/src/components/connections/ServiceCard.scss similarity index 100% rename from frontend/src/components/ServiceCard.scss rename to frontend/src/components/connections/ServiceCard.scss diff --git a/frontend/src/components/ServiceCard.tsx b/frontend/src/components/connections/ServiceCard.tsx similarity index 93% rename from frontend/src/components/ServiceCard.tsx rename to frontend/src/components/connections/ServiceCard.tsx index 6ed8517..cfcbd91 100644 --- a/frontend/src/components/ServiceCard.tsx +++ b/frontend/src/components/connections/ServiceCard.tsx @@ -1,5 +1,5 @@ -import type { ConnectionStatus } from '../types/connection' -import { ActionButton } from './ActionButton' +import type { ConnectionStatus } from '../../types/connection' +import { ActionButton } from '../ui/ActionButton' import './ServiceCard.scss' interface Props { diff --git a/frontend/src/components/SearchModal.tsx b/frontend/src/components/search/SearchModal.tsx similarity index 51% rename from frontend/src/components/SearchModal.tsx rename to frontend/src/components/search/SearchModal.tsx index 904f05c..9b1fdbe 100644 --- a/frontend/src/components/SearchModal.tsx +++ b/frontend/src/components/search/SearchModal.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState, type SyntheticEvent } from 'react' -import './ConnectModal.scss' -import type { SearchRequest } from '../types/search' -import { search } from '../api/searches' +import '../ui/Modal.scss' +import { type PagedSearchResponse, type SearchResponse, type SearchRequest } from '../../types/search' +import { search } from '../../api/searches' interface Props { serviceType: string @@ -12,7 +12,8 @@ interface Props { } export function SearchModal({ serviceType, label, appUrl, username, onClose }: Readonly) { - const [query, setQuery] = useState(''); + const [query, setQuery] = useState('') + const [results, setResults] = useState | null>(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const firstInputRef = useRef(null) @@ -40,7 +41,10 @@ export function SearchModal({ serviceType, label, appUrl, username, onClose }: R setLoading(true) try { const req: SearchRequest = { appUrl, serviceType, username, query} - await search(req) + const res = await search(req) + console.log(req) + setResults(res) + console.log('results', results) } catch (err) { setError(err instanceof Error ? err.message : 'Search failed') } finally { @@ -49,21 +53,31 @@ export function SearchModal({ serviceType, label, appUrl, username, onClose }: R } return ( - -
- - + +
+ +
-
-
- +
+ setQuery(e.target.value)} />
- {error &&

{error}

} - + {results && ( +
+

{results.totalElements}

+ {results.content.map((item) => (
+

{item.title}

+ {item.description &&

{item.description}

} + +
))} +
+ )}
) } diff --git a/frontend/src/components/ActionButton.scss b/frontend/src/components/ui/ActionButton.scss similarity index 100% rename from frontend/src/components/ActionButton.scss rename to frontend/src/components/ui/ActionButton.scss diff --git a/frontend/src/components/ActionButton.tsx b/frontend/src/components/ui/ActionButton.tsx similarity index 100% rename from frontend/src/components/ActionButton.tsx rename to frontend/src/components/ui/ActionButton.tsx diff --git a/frontend/src/components/ConnectModal.scss b/frontend/src/components/ui/Modal.scss similarity index 72% rename from frontend/src/components/ConnectModal.scss rename to frontend/src/components/ui/Modal.scss index b816391..0bdd995 100644 --- a/frontend/src/components/ConnectModal.scss +++ b/frontend/src/components/ui/Modal.scss @@ -17,6 +17,8 @@ width: 100%; max-width: 420px; box-shadow: var(--shadow); + display: flex; + flex-direction: column; &__header { display: flex; @@ -87,7 +89,7 @@ font-size: 13px; color: #ef4444; padding: 8px 12px; - background: rgba(239, 68, 68, 0.08); + background: rgba(11, 0, 0, 0.499); border-radius: 6px; border: 1px solid rgba(239, 68, 68, 0.2); } @@ -110,4 +112,44 @@ &:disabled { opacity: 0.6; cursor: not-allowed; } &:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } } + + &--expanded { + max-height: 70vh; + } + + &__results { + overflow-y: auto; + flex: 1; + min-height: 0; // required: flex children won't shrink without this + margin-top: 20px; + display: flex; + flex-direction: column; + gap: 8px; + } + + &__results-count { + font-size: 12px; + color: var(--text); + margin: 0 0 8px 0; + } + + &__result-item { + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + } + + &__result-title { + font-weight: 500; + font-size: 14px; + color: var(--text-h); + margin: 0; + } + + &__result-desc { + font-size: 13px; + color: var(--text); + margin: 4px 0 0 0; + } } diff --git a/frontend/src/types/search.ts b/frontend/src/types/search.ts index c195a92..5c1b8c7 100644 --- a/frontend/src/types/search.ts +++ b/frontend/src/types/search.ts @@ -2,5 +2,22 @@ export interface SearchRequest { appUrl: string; serviceType: string; username: string; - query: string; + query: string | null; +} + +export interface SearchResponse { + 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; } -- 2.54.0 From 077308b547f23c4cdd4b56f4e18239491cae976c Mon Sep 17 00:00:00 2001 From: kasun Date: Wed, 24 Jun 2026 15:57:24 +0200 Subject: [PATCH 23/29] removed unsused assets --- frontend/src/assets/react.svg | 1 - frontend/src/assets/vite.svg | 1 - 2 files changed, 2 deletions(-) delete mode 100644 frontend/src/assets/react.svg delete mode 100644 frontend/src/assets/vite.svg diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/frontend/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -Vite -- 2.54.0 From 38c72d1bb88f9dabc072cda0376eabe35fb3a659 Mon Sep 17 00:00:00 2001 From: kasun Date: Wed, 24 Jun 2026 16:00:43 +0200 Subject: [PATCH 24/29] changed app title --- frontend/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/index.html b/frontend/index.html index 0fca6f0..cf33cf6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - frontend + Vaessl ~ AI Bridge
-- 2.54.0 From fb64a7787f40f7d791e05d0cfb2a8294f8c7b115 Mon Sep 17 00:00:00 2001 From: kasun Date: Wed, 24 Jun 2026 23:25:18 +0200 Subject: [PATCH 25/29] 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 } -- 2.54.0 From 40cbe7103fc0272fb1d53c20aeb02ac1204cdf71 Mon Sep 17 00:00:00 2001 From: kasun Date: Thu, 25 Jun 2026 17:17:10 +0200 Subject: [PATCH 26/29] restructured folders and files --- .../java/com/vaessl/app/connection/AuthResponse.java | 2 ++ .../vaessl/app/connection/ConnectionController.java | 3 +++ .../com/vaessl/app/connection/ConnectionProvider.java | 2 ++ .../com/vaessl/app/connection/ConnectionRequest.java | 1 + .../com/vaessl/app/connection/ConnectionService.java | 1 + .../app/connection/HomeboxConnectionProvider.java | 3 ++- .../com/vaessl/app/connection/ServiceProvider.java | 10 ---------- .../com/vaessl/app/search/HomeboxSearchProvider.java | 4 ++-- .../java/com/vaessl/app/search/SearchController.java | 2 +- .../java/com/vaessl/app/search/SearchProvider.java | 2 +- .../main/java/com/vaessl/app/search/SearchRequest.java | 2 +- .../main/java/com/vaessl/app/search/SearchService.java | 2 +- .../vaessl/app/{connection => shared}/Endpoint.java | 2 +- .../java/com/vaessl/app/shared/ServiceProvider.java | 6 ++++++ .../vaessl/app/{connection => shared}/ServiceType.java | 2 +- .../vaessl/app/{connection => shared}/SessionKeys.java | 4 ++-- backend/src/test/java/com/vaessl/app/Mockdata.java | 2 +- .../app/connection/ConnectionControllerTest.java | 4 ++-- .../app/connection/HomeboxConnectionProviderTest.java | 5 ++--- .../vaessl/app/connection/HomeboxIntegrationTest.java | 4 ++-- .../vaessl/app/search/HomeboxSearchProviderTest.java | 2 +- .../com/vaessl/app/search/SearchControllerTest.java | 2 +- 22 files changed, 36 insertions(+), 31 deletions(-) delete mode 100644 backend/src/main/java/com/vaessl/app/connection/ServiceProvider.java rename backend/src/main/java/com/vaessl/app/{connection => shared}/Endpoint.java (91%) create mode 100644 backend/src/main/java/com/vaessl/app/shared/ServiceProvider.java rename backend/src/main/java/com/vaessl/app/{connection => shared}/ServiceType.java (54%) rename backend/src/main/java/com/vaessl/app/{connection => shared}/SessionKeys.java (65%) 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 bbdbcab..42a0a51 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,7 @@ package com.vaessl.app.connection; import java.time.Instant; +import com.vaessl.app.shared.ServiceType; + 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 28219c4..1c8536a 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java @@ -19,6 +19,9 @@ import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import com.vaessl.app.shared.ServiceType; +import com.vaessl.app.shared.SessionKeys; + @RestController @RequiredArgsConstructor public class ConnectionController { diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java index 4815b39..33ee47d 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java @@ -2,6 +2,8 @@ package com.vaessl.app.connection; import java.time.Instant; +import com.vaessl.app.shared.ServiceProvider; + public interface ConnectionProvider extends ServiceProvider { void checkCredentials(ConnectionRequest request); 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 ec265ba..4cc5d91 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionRequest.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionRequest.java @@ -1,6 +1,7 @@ package com.vaessl.app.connection; import com.fasterxml.jackson.annotation.JsonProperty; +import com.vaessl.app.shared.ServiceType; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; 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 f4ecccc..518e0f9 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java @@ -8,6 +8,7 @@ import java.util.stream.Collectors; import org.springframework.stereotype.Service; import com.vaessl.app.exception.WrongServiceTypeException; +import com.vaessl.app.shared.ServiceType; @Service public class ConnectionService { 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 6467eb2..faffb79 100644 --- a/backend/src/main/java/com/vaessl/app/connection/HomeboxConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/HomeboxConnectionProvider.java @@ -11,8 +11,9 @@ import org.springframework.web.client.RestClient; import com.vaessl.app.exception.EmptyCredentialsException; import com.vaessl.app.exception.RemoteApiException; +import com.vaessl.app.shared.ServiceType; -import static com.vaessl.app.connection.Endpoint.*; +import static com.vaessl.app.shared.Endpoint.*; @Component public class HomeboxConnectionProvider implements ConnectionProvider { diff --git a/backend/src/main/java/com/vaessl/app/connection/ServiceProvider.java b/backend/src/main/java/com/vaessl/app/connection/ServiceProvider.java deleted file mode 100644 index ad63c42..0000000 --- a/backend/src/main/java/com/vaessl/app/connection/ServiceProvider.java +++ /dev/null @@ -1,10 +0,0 @@ -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"}. - */ - ServiceType getServiceType(); -} 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 7be5851..5131092 100644 --- a/backend/src/main/java/com/vaessl/app/search/HomeboxSearchProvider.java +++ b/backend/src/main/java/com/vaessl/app/search/HomeboxSearchProvider.java @@ -12,11 +12,11 @@ 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 com.vaessl.app.shared.ServiceType; -import static com.vaessl.app.connection.Endpoint.*; +import static com.vaessl.app.shared.Endpoint.*; @Component public class HomeboxSearchProvider implements SearchProvider { diff --git a/backend/src/main/java/com/vaessl/app/search/SearchController.java b/backend/src/main/java/com/vaessl/app/search/SearchController.java index fce1905..a96f47f 100644 --- a/backend/src/main/java/com/vaessl/app/search/SearchController.java +++ b/backend/src/main/java/com/vaessl/app/search/SearchController.java @@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; -import com.vaessl.app.connection.SessionKeys; +import com.vaessl.app.shared.SessionKeys; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; diff --git a/backend/src/main/java/com/vaessl/app/search/SearchProvider.java b/backend/src/main/java/com/vaessl/app/search/SearchProvider.java index a5fca4d..ef6196c 100644 --- a/backend/src/main/java/com/vaessl/app/search/SearchProvider.java +++ b/backend/src/main/java/com/vaessl/app/search/SearchProvider.java @@ -3,7 +3,7 @@ package com.vaessl.app.search; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import com.vaessl.app.connection.ServiceProvider; +import com.vaessl.app.shared.ServiceProvider; /** * Implemented by any service that supports querying its remote API for items. 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 0e7a505..bc618e9 100644 --- a/backend/src/main/java/com/vaessl/app/search/SearchRequest.java +++ b/backend/src/main/java/com/vaessl/app/search/SearchRequest.java @@ -1,6 +1,6 @@ package com.vaessl.app.search; -import com.vaessl.app.connection.ServiceType; +import com.vaessl.app.shared.ServiceType; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; 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 f92efde..a5314c7 100644 --- a/backend/src/main/java/com/vaessl/app/search/SearchService.java +++ b/backend/src/main/java/com/vaessl/app/search/SearchService.java @@ -7,7 +7,7 @@ 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.shared.ServiceType; import com.vaessl.app.exception.WrongServiceTypeException; @Service diff --git a/backend/src/main/java/com/vaessl/app/connection/Endpoint.java b/backend/src/main/java/com/vaessl/app/shared/Endpoint.java similarity index 91% rename from backend/src/main/java/com/vaessl/app/connection/Endpoint.java rename to backend/src/main/java/com/vaessl/app/shared/Endpoint.java index da8d35a..26682e4 100644 --- a/backend/src/main/java/com/vaessl/app/connection/Endpoint.java +++ b/backend/src/main/java/com/vaessl/app/shared/Endpoint.java @@ -1,4 +1,4 @@ -package com.vaessl.app.connection; +package com.vaessl.app.shared; public enum Endpoint { HOMEBOX_LOGIN("/api/v1/users/login"), LOGIN("/login"), CONNECTION_STATUS( diff --git a/backend/src/main/java/com/vaessl/app/shared/ServiceProvider.java b/backend/src/main/java/com/vaessl/app/shared/ServiceProvider.java new file mode 100644 index 0000000..d2ccc6a --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/shared/ServiceProvider.java @@ -0,0 +1,6 @@ +package com.vaessl.app.shared; + +public interface ServiceProvider { + + ServiceType getServiceType(); +} diff --git a/backend/src/main/java/com/vaessl/app/connection/ServiceType.java b/backend/src/main/java/com/vaessl/app/shared/ServiceType.java similarity index 54% rename from backend/src/main/java/com/vaessl/app/connection/ServiceType.java rename to backend/src/main/java/com/vaessl/app/shared/ServiceType.java index 1d6c99a..1f060c6 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ServiceType.java +++ b/backend/src/main/java/com/vaessl/app/shared/ServiceType.java @@ -1,4 +1,4 @@ -package com.vaessl.app.connection; +package com.vaessl.app.shared; public enum ServiceType { HOMEBOX; diff --git a/backend/src/main/java/com/vaessl/app/connection/SessionKeys.java b/backend/src/main/java/com/vaessl/app/shared/SessionKeys.java similarity index 65% rename from backend/src/main/java/com/vaessl/app/connection/SessionKeys.java rename to backend/src/main/java/com/vaessl/app/shared/SessionKeys.java index 1760be5..5985fee 100644 --- a/backend/src/main/java/com/vaessl/app/connection/SessionKeys.java +++ b/backend/src/main/java/com/vaessl/app/shared/SessionKeys.java @@ -1,8 +1,8 @@ -package com.vaessl.app.connection; +package com.vaessl.app.shared; public final class SessionKeys { - static final String CONNECTION_ID_SUFFIX = "_CONNECTION_ID"; + public static final String CONNECTION_ID_SUFFIX = "_CONNECTION_ID"; private SessionKeys() {} diff --git a/backend/src/test/java/com/vaessl/app/Mockdata.java b/backend/src/test/java/com/vaessl/app/Mockdata.java index eefb159..4ebf98b 100644 --- a/backend/src/test/java/com/vaessl/app/Mockdata.java +++ b/backend/src/test/java/com/vaessl/app/Mockdata.java @@ -1,6 +1,6 @@ package com.vaessl.app; -import com.vaessl.app.connection.ServiceType; +import com.vaessl.app.shared.ServiceType; public final class Mockdata { 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 4a12c92..79f90cb 100644 --- a/backend/src/test/java/com/vaessl/app/connection/ConnectionControllerTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/ConnectionControllerTest.java @@ -2,8 +2,8 @@ package com.vaessl.app.connection; import static com.vaessl.app.Mockdata.*; -import static com.vaessl.app.connection.Endpoint.*; -import static com.vaessl.app.connection.ServiceType.*; +import static com.vaessl.app.shared.Endpoint.*; +import static com.vaessl.app.shared.ServiceType.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; 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 4fa9ece..8b5816e 100644 --- a/backend/src/test/java/com/vaessl/app/connection/HomeboxConnectionProviderTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/HomeboxConnectionProviderTest.java @@ -6,7 +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 com.vaessl.app.shared.ServiceType.HOMEBOX; import static org.assertj.core.api.Assertions.assertThat; class HomeboxConnectionProviderTest { @@ -15,8 +15,7 @@ class HomeboxConnectionProviderTest { @Test 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, () -> { 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 e0e7bcf..582bd2c 100644 --- a/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java @@ -23,9 +23,9 @@ import com.jayway.jsonpath.JsonPath; import static org.assertj.core.api.Assertions.assertThat; import static com.github.tomakehurst.wiremock.client.WireMock.*; -import static com.vaessl.app.connection.Endpoint.*; +import static com.vaessl.app.shared.Endpoint.*; import static com.vaessl.app.exception.ErrorMessage.*; -import static com.vaessl.app.connection.ServiceType.*; +import static com.vaessl.app.shared.ServiceType.*; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureTestRestTemplate 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 5952882..63dd067 100644 --- a/backend/src/test/java/com/vaessl/app/search/HomeboxSearchProviderTest.java +++ b/backend/src/test/java/com/vaessl/app/search/HomeboxSearchProviderTest.java @@ -12,7 +12,7 @@ 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; +import static com.vaessl.app.shared.ServiceType.HOMEBOX; @ExtendWith(MockitoExtension.class) 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 0917254..6ba4d18 100644 --- a/backend/src/test/java/com/vaessl/app/search/SearchControllerTest.java +++ b/backend/src/test/java/com/vaessl/app/search/SearchControllerTest.java @@ -2,7 +2,7 @@ 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 com.vaessl.app.shared.Endpoint.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; -- 2.54.0 From 17e15279c5bdccfcc99c986a42eeb6bc64ee15be Mon Sep 17 00:00:00 2001 From: kasun Date: Thu, 25 Jun 2026 17:17:30 +0200 Subject: [PATCH 27/29] updated claude.md to new progress --- CLAUDE.md | 40 ++++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f2d149e..341e4a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,40 +43,56 @@ Frontend (optional, defaults to `/api`): ### Backend (`backend/src/main/java/com/vaessl/app/`) -Server context path is `/api`. Main endpoints: +Package-by-feature layout. Server context path is `/api`. Main endpoints: - `POST /api/login` — authenticates a service, stores connection ID in session - `GET /api/connections/status` — lists connected services for the current session - `DELETE /api/connections/{serviceType}` — removes a service from the session; invalidates the session if no connections remain +- `POST /api/search` — paged search against the requested service; returns 401 if no active session -Four main modules: +Five packages: + +**`shared/`** — cross-cutting types used by more than one feature package + +- `ServiceType` (enum): identifies each integrated app (e.g. `HOMEBOX`); used in both `connection/` and `search/` +- `ServiceProvider` (interface): base for `ConnectionProvider` and `SearchProvider`; declares `getServiceType()` +- `Endpoint` (enum): API path constants for all external service calls +- `SessionKeys`: builds session attribute names of the form `{SERVICE_TYPE}_CONNECTION_ID` **`config/`** - `CorsConfig`: env-driven allowed origins (`FRONTEND_LOCAL_URL`, `FRONTEND_PUBLIC_URL`); `allowCredentials(true)` is required for session cookies to work cross-origin - `SessionConfig`: JDBC-backed Spring Session with a persistent cookie (`SameSite=Lax`, `HttpOnly`) -**`connection/`** — core business logic +**`connection/`** — connecting to and persisting service credentials -- `ConnectionProvider` interface: each integrated app (Homebox, WikiJS) implements `login()` and declares its `ServiceType` +- `ConnectionProvider` interface: extends `ServiceProvider`; each integrated app implements `login()` and credential checking - `ConnectionService`: auto-discovers providers via Spring injection, dispatches login by `ServiceType` - `ConnectionController`: stores `{serviceType}_CONNECTION_ID` in `HttpSession` after login; reads session attributes to build status responses -- Entity (`Connection`) uses **Single Table Inheritance** — one `connections` table with app-specific nullable columns +- Entity (`ConnectionEntity`) uses **Single Table Inheritance** — one `connections` table with app-specific nullable columns +- `HomeboxConnectionProvider` / `HomeboxEntity`: Homebox-specific implementation -**`dto/`** — `ConnectionRequest`, `LoginResult`, `AuthResponse`, `ConnectionStatusResponse` +**`search/`** — querying connected services + +- `SearchProvider` interface: extends `ServiceProvider`; each integrated app implements `getSearchResults()` +- `SearchService`: auto-discovers providers via Spring injection, dispatches by `ServiceType` +- `SearchController`: guards with session check before delegating to `SearchService` +- `HomeboxSearchProvider`: Homebox-specific search implementation using bearer token from session **`exception/`** — `GlobalExceptionHandler` via `@ControllerAdvice` ### Frontend (`frontend/src/`) -React 19 + TypeScript + SCSS, Vite 8 build. +React 19 + TypeScript + SCSS, Vite 6 build. Package-by-feature under `components/`. - `api/client.ts` — typed `apiFetch` wrapper; always sends `credentials: 'include'` for session cookies; base URL from `VITE_API_URL` (defaults to `/api`) -- `api/connections.ts` — connection-specific API calls -- `types/connection.ts` — shared types: `LoginRequest`, `AuthResponse`, `ConnectionStatus` -- `components/Dashboard` — main view listing connected services -- `components/ConnectModal` — login form for adding a service connection -- `components/ServiceCard` — per-service status display +- `api/connections.ts` — connection-specific API calls (`getStatuses`, `login`, `logout`) +- `api/searches.ts` — search API call (`search`) +- `types/connection.ts` — `ServiceType`, `LoginRequest`, `AuthResponse`, `ConnectionStatus` +- `types/search.ts` — `SearchRequest`, `SearchResponse`, `PagedSearchResponse` +- `components/connections/` — `Dashboard`, `ConnectModal`, `ServiceCard` (connection management UI) +- `components/search/SearchModal` — search dialog; posts to `/api/search` and renders paged results +- `components/ui/` — shared UI primitives (`ActionButton`, `Modal`) ### Data & AI -- 2.54.0 From ff9cfe2b3cb9cb5fb8a8ca44fd9627aff14111db Mon Sep 17 00:00:00 2001 From: kasun Date: Thu, 25 Jun 2026 17:44:42 +0200 Subject: [PATCH 28/29] updated deprecated yaml option --- backend/src/main/resources/application.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index ec71453..398cf02 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -20,8 +20,7 @@ spring: base-url: ${OPENAI_BASE_URL} api-key: ${OPENAI_KEY} chat: - options: - model: gpt-4o-mini + model: gpt-4o-mini session: store-type: jdbc jdbc: -- 2.54.0 From 33c560555e748eb0d4368e465688b989f68c3741 Mon Sep 17 00:00:00 2001 From: kasun Date: Thu, 25 Jun 2026 17:56:44 +0200 Subject: [PATCH 29/29] updated readme.md --- README.md | 111 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 87 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 0609850..c041ff3 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,12 @@ This project serves as a transparent portfolio of my take on: --- ## What is Vaessl? -Vaessl acts as translator between user inputs via text or image and digital management systems. It performs image and semantic search analysis before serving results from applications (e.g., Homebox, WikiJS) via REST API. +Vaessl acts as a translator between user inputs via text or image and digital management systems. It performs image and semantic search analysis before serving results from applications (e.g., Homebox, WikiJS) via REST API. ### Core Functionality -* **Staging:** Raw images and metadata are stored in a PostgreSQL staging table. -* **AI-Powered Analysis:** The system utilizes **LiteLLM** as a unified gateway to process inputs via LLM of choice. -* **Semantic Discovery:** By utilizing **Spring AI** and **pgvector**, Vaessl stores description embeddings. This allows for intent-based search (e.g., finding a "Wrench" by searching for "tool to fix a leaky pipe"). +* **Connection Management:** Users connect to supported services (e.g. Homebox) via a credential login flow. Tokens are stored in a JDBC-backed HTTP session, enabling secure multi-service connections per browser session. +* **AI-Powered Analysis:** The system utilises **LiteLLM** as a unified gateway to process inputs via LLM of choice *(pipeline in progress)*. +* **Semantic Discovery:** By utilising **Spring AI** and **pgvector**, Vaessl will store description embeddings to enable intent-based search (e.g., finding a "Wrench" by searching for "tool to fix a leaky pipe") *(planned)*. --- @@ -22,8 +22,8 @@ Vaessl acts as translator between user inputs via text or image and digital mana | Layer | Technology | | :--- | :--- | -| **Backend** | Spring Boot 4.0.x, Java 25 (LTS), Spring AI | -| **Frontend** | React (Vite), Tailwind CSS, SCSS | +| **Backend** | Spring Boot 4.1.0, Java 25 (LTS), Spring AI | +| **Frontend** | React 19, Vite 8, SCSS | | **Database** | PostgreSQL + `pgvector` | | **AI Gateway** | LiteLLM (Unified API proxy) | | **DevOps** | Docker, Portainer, Hoppscotch (API testing), Gitea Actions, Jenkins | @@ -42,12 +42,50 @@ To maintain a zero-footprint local setup, I develop within a custom **code-serve ### 2. Abstraction & Scalability A core requirement was to prevent vendor or application lock-in: -* **API Abstraction:** While the initial integration targets **Homebox**, the export logic is decoupled. This allows the bridge to support other platforms like **WikiJS** by simply implementing new mapping profiles. +* **Provider Pattern:** Both connection and search logic are fully abstracted behind `ConnectionProvider` and `SearchProvider` interfaces. Adding a new service (e.g. WikiJS) means implementing those interfaces — no changes to core dispatch logic required. * **AI Agnostic:** LiteLLM abstracts the AI provider, allowing the backend to switch between cloud APIs and local inference engines (Ollama) without code changes. ### 3. Testing Strategy -* **Database Parity:** I utilize mirrored containers for development and testing (`vaessl-db` vs `vassal-test-db`). This ensures that JPA operations are tested against the real PostgreSQL engine and `pgvector` extensions rather than mocks. -* **Frontend TDD:** A Vitest-based stack provides a fast feedback loop for the UI, ensuring component reliability before integration with the Spring Boot backend. +* **Database Parity:** Mirrored PostgreSQL containers are used for development and testing (`vaessl-db` vs `vaessl-test-db`). This ensures JPA operations are tested against the real PostgreSQL engine and `pgvector` extension, not mocks. +* **WireMock:** External HTTP APIs (Homebox, WikiJS) are stubbed in integration tests using WireMock, isolating tests from live service availability. +* **Frontend:** A Vitest-based stack provides a fast feedback loop for UI components. + +--- + +## Architecture Overview + +### Backend (`backend/src/main/java/com/vaessl/app/`) + +Package-by-feature layout. Server context path is `/api`. + +| Package | Purpose | +| :--- | :--- | +| `shared/` | Cross-cutting types: `ServiceType` enum, `ServiceProvider` base interface, `Endpoint` enum, `SessionKeys` utility | +| `config/` | CORS (env-driven allowed origins) and JDBC-backed Spring Session | +| `connection/` | Login, session management, credential persistence (Single Table Inheritance) | +| `search/` | Paged search against connected services, guarded by active session | +| `exception/` | `GlobalExceptionHandler` via `@ControllerAdvice`; domain exceptions mapped to typed HTTP responses | + +**REST Endpoints:** + +| Method | Path | Description | +| :--- | :--- | :--- | +| `POST` | `/api/login` | Authenticates a service and stores its connection ID in the session | +| `GET` | `/api/connections/status` | Lists all connected services for the current session | +| `DELETE` | `/api/connections/{serviceType}` | Removes a service from the session; invalidates the session if none remain | +| `POST` | `/api/search` | Paged search against a connected service; returns 401 if no active session | + +### Frontend (`frontend/src/`) + +React 19 + TypeScript + SCSS, Vite 8 build. Package-by-feature under `components/`. + +| Module | Description | +| :--- | :--- | +| `api/` | Typed `apiFetch` wrapper + per-feature API call modules (`connections.ts`, `searches.ts`) | +| `types/` | Shared TS types aligned with backend records (`ServiceType`, `LoginRequest`, `SearchResponse`, etc.) | +| `components/connections/` | `Dashboard`, `ConnectModal`, `ServiceCard` — full connection management UI | +| `components/search/` | `SearchModal` — search dialog with paged result rendering | +| `components/ui/` | Shared primitives (`ActionButton`, `Modal`) | --- @@ -58,32 +96,57 @@ A core requirement was to prevent vendor or application lock-in: * [x] Custom Code-Server image build and deployment. * [x] Spring Boot skeleton with Java 25 and Gradle Kotlin DSL. -### Phase 2: The Processing Pipeline (Current) -* [ ] Implementation of Image/Semantic Search $\rightarrow$ AI $\rightarrow$ Staging Table workflow. -* [ ] Development of the data refinement UI in React. -* [ ] Initial API connector for Homebox. +### Phase 2: Connection & Basic Search (Current) +* [x] Provider-pattern abstraction for connections and search. +* [x] Homebox authentication — login, session-tracked token, expiry checking. +* [x] Paged search against Homebox entities via REST. +* [x] React connection management dashboard (connect, disconnect, status). +* [x] React search modal with paged results. +* [x] Integration test suite (WireMock + mirrored PostgreSQL container). +* [ ] Image/text input → LLM inference → staging table workflow. +* [ ] Data refinement UI for reviewing and approving LLM-processed results. ### Phase 3: Semantic Search & Expansion -* [ ] Integration of Spring AI vector embeddings. -* [ ] Implementation of additional API targets (WikiJS). -* [ ] Launch of a public-facing demo. +* [ ] Spring AI vector embeddings with pgvector for intent-based search. +* [ ] Additional API targets (WikiJS). +* [ ] Public-facing demo. --- ## Setup & Deployment -The project is orchestrated via Docker Compose for high portability. -1. Clone the Repository. -2. Environment Setup: Configure `.env.local` with your database credentials and AI API keys. +1. Clone the repository. +2. Copy `.env.local` into `backend/` with: -``` +```env DB_URL=jdbc:postgresql://192.168.1.{subnet}:{port}/vaessl DB_TEST_URL=jdbc:postgresql://192.168.1.{subnet}:{port}/vaessl_test DB_USERNAME={username} DB_PASSWORD={password} -OPENAI_KEY={api-key} # or LLM provider of choice -OPENAI_BASE_URL=https://api.openai.com/v1 PG_DRIVER_CLASS_NAME=org.postgresql.Driver +OPENAI_KEY={api-key} +OPENAI_BASE_URL=https://{litellm-host}/v1 +FRONTEND_LOCAL_URL=http://localhost:5173 +FRONTEND_PUBLIC_URL=https://{your-public-domain} +``` + +3. Start the pgvector-enabled PostgreSQL instances (production + test) — use the official `pgvector/pgvector` Docker image or install the extension into your existing PostgreSQL instance. +4. Install frontend dependencies and build the backend: + +```bash +# Frontend +cd frontend && npm install + +# Backend +cd backend && ./gradlew build +``` + +5. Run the backend and frontend dev servers: + +```bash +# Backend +cd backend && ./gradlew bootRun + +# Frontend +cd frontend && npm run dev ``` -3. Setup both production and test pgvector databases using the official pgvector docker image or installing the pgvector extension to you existing PostGreSQL database. -4. Execute "npm i" and build gradle. \ No newline at end of file -- 2.54.0