From 8da3b14e40808567a282e6ee86b9008026b83bc3 Mon Sep 17 00:00:00 2001 From: kasun Date: Mon, 30 Mar 2026 05:05:24 +0200 Subject: [PATCH 01/32] added wiremock dependency for remote api testing --- backend/build.gradle.kts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index c1738af..b9781ce 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -40,8 +40,7 @@ dependencies { testImplementation("org.springframework.boot:spring-boot-starter-validation-test") testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") - testImplementation("org.springframework.boot:spring-boot-starter-test") -} + testImplementation("org.wiremock:wiremock-standalone:3.12.0")} dependencyManagement { imports { -- 2.52.0 From 8128ab829fceb608a1c16082a53bbbdbee1a7418 Mon Sep 17 00:00:00 2001 From: kasun Date: Mon, 30 Mar 2026 05:05:57 +0200 Subject: [PATCH 02/32] added additional .env.local file import pathes --- backend/src/main/resources/application.yaml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index 3aca02f..e75cdeb 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -2,7 +2,10 @@ spring: application: name: vaessl config: - import: "optional:file:.env.local[.properties]" + import: + - "optional:file:.env.local[.properties]" + - "optional:file:backend/.env.local[.properties]" + - "optional:file:vaessl/backend/.env.local[.properties]" datasource: url : ${DB_URL} username: ${DB_USERNAME} @@ -21,7 +24,3 @@ spring: chat: options: model: gpt-4o-mini - - logging: - level: - org.springframework.boot.context.config: TRACE -- 2.52.0 From 75b6995b949fa5424d6cc9146ec321f3680e5d45 Mon Sep 17 00:00:00 2001 From: kasun Date: Mon, 30 Mar 2026 05:07:51 +0200 Subject: [PATCH 03/32] added post request to achieve login response with tokens --- .../app/connection/ConnectionController.java | 27 ++++ .../app/connection/ConnectionService.java | 28 ++++ .../com/vaessl/app/dto/ConnectionRequest.java | 9 ++ .../vaessl/app/dto/ConnectionResponse.java | 7 + .../app/exception/GlobalExceptionHandler.java | 40 ++++++ .../connection/ConnectionIntegrationTest.java | 120 ++++++++++++++++++ 6 files changed, 231 insertions(+) create mode 100644 backend/src/main/java/com/vaessl/app/connection/ConnectionController.java create mode 100644 backend/src/main/java/com/vaessl/app/connection/ConnectionService.java create mode 100644 backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java create mode 100644 backend/src/main/java/com/vaessl/app/dto/ConnectionResponse.java create mode 100644 backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java create mode 100644 backend/src/test/java/com/vaessl/app/connection/ConnectionIntegrationTest.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 new file mode 100644 index 0000000..3918311 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java @@ -0,0 +1,27 @@ +package com.vaessl.app.connection; + +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 com.vaessl.app.dto.ConnectionRequest; +import com.vaessl.app.dto.ConnectionResponse; + +import jakarta.validation.Valid; + +@RestController +public class ConnectionController { + + private final ConnectionService connectionService; + + public ConnectionController(ConnectionService connectionService) { + this.connectionService = connectionService; + } + + @PostMapping("/login") + public ResponseEntity loginResponse(@Valid @RequestBody ConnectionRequest request) { + ConnectionResponse connectionResponse = connectionService.login(request); + return ResponseEntity.ok(connectionResponse); + } +} diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java new file mode 100644 index 0000000..3cae0c9 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java @@ -0,0 +1,28 @@ +package com.vaessl.app.connection; + +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import com.vaessl.app.dto.ConnectionRequest; +import com.vaessl.app.dto.ConnectionResponse; + +@Service +public class ConnectionService { + + private final RestClient.Builder restClientBuilder; + + public ConnectionService(RestClient.Builder restClientBuilder) { + this.restClientBuilder = restClientBuilder; + } + + public ConnectionResponse login(ConnectionRequest request) { + //TODO: Look into Map to cache restclient requests. + return restClientBuilder.baseUrl(request.appUrl()) + .build() + .post() + .uri("/api/v1/users/login") + .body(request) + .retrieve() + .body(ConnectionResponse.class); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java b/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java new file mode 100644 index 0000000..8b6d513 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java @@ -0,0 +1,9 @@ +package com.vaessl.app.dto; + +import jakarta.validation.constraints.NotBlank; + +public record ConnectionRequest( + @NotBlank(message = "App URL is mandatory") String appUrl, + @NotBlank(message = "Username is mandatory") String username, + @NotBlank(message = "Password is mandatory") String password) { +} diff --git a/backend/src/main/java/com/vaessl/app/dto/ConnectionResponse.java b/backend/src/main/java/com/vaessl/app/dto/ConnectionResponse.java new file mode 100644 index 0000000..521407b --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/dto/ConnectionResponse.java @@ -0,0 +1,7 @@ +package com.vaessl.app.dto; + +import java.time.Instant; + +public record ConnectionResponse(String token, String attachmentToken, Instant expiresAt) { + +} diff --git a/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..0062628 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java @@ -0,0 +1,40 @@ +package com.vaessl.app.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ProblemDetail handleEmptyCredentialInput(MethodArgumentNotValidException e) { + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Fields must not be empty."); + } + + @ExceptionHandler(HttpClientErrorException.Unauthorized.class) + public ProblemDetail handleUnauthorizedAccess(HttpClientErrorException e) { + + return ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, "Invalid username or password."); + } + + @ExceptionHandler(ResourceAccessException.class) + public ProblemDetail handleNoConnection(ResourceAccessException e) { + + return ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE, "The target URL is unreachable."); + } + + @ExceptionHandler(HttpServerErrorException.class) + public ProblemDetail handleTimeoutOrNotFound(HttpServerErrorException e) { + + return ProblemDetail + .forStatusAndDetail(e.getStatusCode(), + "The external app returned a server error: " + e.getStatusText()); + } + +} diff --git a/backend/src/test/java/com/vaessl/app/connection/ConnectionIntegrationTest.java b/backend/src/test/java/com/vaessl/app/connection/ConnectionIntegrationTest.java new file mode 100644 index 0000000..08452e2 --- /dev/null +++ b/backend/src/test/java/com/vaessl/app/connection/ConnectionIntegrationTest.java @@ -0,0 +1,120 @@ +package com.vaessl.app.connection; + +import org.junit.jupiter.api.Test; +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.HttpStatus; +import org.springframework.http.ResponseEntity; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +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; +import static com.github.tomakehurst.wiremock.client.WireMock.*; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureTestRestTemplate +@WireMockTest +public class ConnectionIntegrationTest { + + @Autowired + TestRestTemplate restTemplate; + + private static final String API_LOGIN = "/api/v1/users/login"; + + /** + * Returns Token and status code OK when login is successful. + */ + @Test + void shouldReturnTokenAndStatusOkWhenCredentialsAreValid(WireMockRuntimeInfo wm) { + + stubFor(post(API_LOGIN) + .willReturn(okJson(""" + { + "token": "fake-jwt-token", + "attachmentToken": "fake-attach", + "expiresAt": "2026-04-26T02:23:13Z" + } + """))); + + ResponseEntity response = restTemplate.postForEntity("/login", connectionRequest(wm), String.class); + + DocumentContext documentContext = JsonPath.parse(response.getBody()); + + String token = documentContext.read("$.token"); + assertThat(token).isEqualTo("fake-jwt-token"); + + String attachmentToken = documentContext.read("$.attachmentToken"); + assertThat(attachmentToken).isEqualTo("fake-attach"); + + String expiresAt = documentContext.read("$.expiresAt", String.class); + assertThat(expiresAt).isEqualTo("2026-04-26T02:23:13Z"); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + /** + * Test if login request fails with 401 unauthorized. + */ + @Test + void shouldFailToConnectWhenHomeboxReturnsUnauthorized(WireMockRuntimeInfo wm) { + + stubFor(post(API_LOGIN).willReturn(unauthorized())); + + ResponseEntity response = restTemplate.postForEntity("/login", connectionRequest(wm), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(response.getBody()).contains("Invalid username or password."); + } + + /** + * Tests a server error from the external api. + */ + @Test + void shouldFailToConnectWhenHomeboxReturnsServiceUnavailable(WireMockRuntimeInfo wm) { + + stubFor(post(API_LOGIN).willReturn(serviceUnavailable())); + + ResponseEntity response = restTemplate.postForEntity("/login", connectionRequest(wm), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(response.getBody()).contains("The external app returned a server error"); + } + + /** + * Checks when the service is unavailable or the app URL is wrong. + */ + @Test + void shouldReturnServiceUnavailableWhenUrlIsCompletelyWrong() { + + ConnectionRequest badRequest = new ConnectionRequest( + "http://localhost:1234", + "user", + "pass"); + + ResponseEntity response = restTemplate.postForEntity("/login", badRequest, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(response.getBody()).contains("The target URL is unreachable."); + } + + @Test + void shouldReturnBadRequestWhenUrlIsMissing() { + + ConnectionRequest emtpyRequest = new ConnectionRequest("", "", ""); + + ResponseEntity response = restTemplate.postForEntity("/login", emtpyRequest, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).contains("Fields must not be empty."); + } + + private ConnectionRequest connectionRequest(WireMockRuntimeInfo wireMockRuntimeInfo) { + return new ConnectionRequest(wireMockRuntimeInfo.getHttpBaseUrl(), "username", "password"); + } +} -- 2.52.0 From 79379b238a6901960e8dfebd5e34db5bc1d70c86 Mon Sep 17 00:00:00 2001 From: kasun Date: Mon, 30 Mar 2026 20:42:39 +0200 Subject: [PATCH 04/32] changes test name added comment --- .../com/vaessl/app/connection/ConnectionIntegrationTest.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/src/test/java/com/vaessl/app/connection/ConnectionIntegrationTest.java b/backend/src/test/java/com/vaessl/app/connection/ConnectionIntegrationTest.java index 08452e2..64d5186 100644 --- a/backend/src/test/java/com/vaessl/app/connection/ConnectionIntegrationTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/ConnectionIntegrationTest.java @@ -103,8 +103,11 @@ public class ConnectionIntegrationTest { assertThat(response.getBody()).contains("The target URL is unreachable."); } + /** + * Checks if any login fields are empty since all of them are mandatory. + */ @Test - void shouldReturnBadRequestWhenUrlIsMissing() { + void shouldReturnBadRequestWhenFieldsAreEmpty() { ConnectionRequest emtpyRequest = new ConnectionRequest("", "", ""); -- 2.52.0 From 6267e18478fdaaac7c51b5ab7ede7f2ff0fc7872 Mon Sep 17 00:00:00 2001 From: kasun Date: Mon, 30 Mar 2026 20:42:48 +0200 Subject: [PATCH 05/32] changed format --- backend/src/main/java/com/vaessl/app/Application.java | 1 - .../java/com/vaessl/app/connection/ConnectionService.java | 2 +- .../src/main/java/com/vaessl/app/dto/ConnectionRequest.java | 6 +++--- .../main/java/com/vaessl/app/dto/ConnectionResponse.java | 1 - .../com/vaessl/app/exception/GlobalExceptionHandler.java | 1 - backend/src/test/java/com/vaessl/app/ApplicationTests.java | 3 +-- 6 files changed, 5 insertions(+), 9 deletions(-) diff --git a/backend/src/main/java/com/vaessl/app/Application.java b/backend/src/main/java/com/vaessl/app/Application.java index 7075c45..28b1853 100644 --- a/backend/src/main/java/com/vaessl/app/Application.java +++ b/backend/src/main/java/com/vaessl/app/Application.java @@ -9,5 +9,4 @@ public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } - } 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 3cae0c9..882f369 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java @@ -16,7 +16,7 @@ public class ConnectionService { } public ConnectionResponse login(ConnectionRequest request) { - //TODO: Look into Map to cache restclient requests. + // TODO: Look into Map to cache restclient requests. return restClientBuilder.baseUrl(request.appUrl()) .build() .post() diff --git a/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java b/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java index 8b6d513..e9981a8 100644 --- a/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java +++ b/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java @@ -3,7 +3,7 @@ package com.vaessl.app.dto; import jakarta.validation.constraints.NotBlank; public record ConnectionRequest( - @NotBlank(message = "App URL is mandatory") String appUrl, - @NotBlank(message = "Username is mandatory") String username, - @NotBlank(message = "Password is mandatory") String password) { + @NotBlank(message = "App URL is mandatory") String appUrl, + @NotBlank(message = "Username is mandatory") String username, + @NotBlank(message = "Password is mandatory") String password) { } diff --git a/backend/src/main/java/com/vaessl/app/dto/ConnectionResponse.java b/backend/src/main/java/com/vaessl/app/dto/ConnectionResponse.java index 521407b..bc9f370 100644 --- a/backend/src/main/java/com/vaessl/app/dto/ConnectionResponse.java +++ b/backend/src/main/java/com/vaessl/app/dto/ConnectionResponse.java @@ -3,5 +3,4 @@ package com.vaessl.app.dto; import java.time.Instant; public record ConnectionResponse(String token, String attachmentToken, Instant expiresAt) { - } 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 0062628..d451854 100644 --- a/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java @@ -36,5 +36,4 @@ public class GlobalExceptionHandler { .forStatusAndDetail(e.getStatusCode(), "The external app returned a server error: " + e.getStatusText()); } - } diff --git a/backend/src/test/java/com/vaessl/app/ApplicationTests.java b/backend/src/test/java/com/vaessl/app/ApplicationTests.java index 2e60da2..c33349c 100644 --- a/backend/src/test/java/com/vaessl/app/ApplicationTests.java +++ b/backend/src/test/java/com/vaessl/app/ApplicationTests.java @@ -16,7 +16,7 @@ import static org.assertj.core.api.Assertions.assertThat; @ActiveProfiles("test") class ApplicationTests { - @Autowired + @Autowired private DataSource dataSource; @Test @@ -24,6 +24,5 @@ class ApplicationTests { try (Connection connection = dataSource.getConnection()) { assertThat(connection.getMetaData().getURL()).contains("vaessl_test"); } - } } -- 2.52.0 From bda9391c75a24b5a0f8e85b2cfbc56a3f85935c8 Mon Sep 17 00:00:00 2001 From: kasun Date: Mon, 30 Mar 2026 21:30:19 +0200 Subject: [PATCH 06/32] changed format --- backend/src/main/resources/application.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index e75cdeb..da53c38 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -11,12 +11,10 @@ spring: username: ${DB_USERNAME} password: ${DB_PASSWORD} driver-class-name: ${PG_DRIVER_CLASS_NAME} - jpa: hibernate: ddl-auto: create-drop show-sql: true - ai: openai: base-url: ${OPENAI_BASE_URL} -- 2.52.0 From c15c7a0d619b0030affb24a9f676c1c88f140c80 Mon Sep 17 00:00:00 2001 From: kasun Date: Mon, 30 Mar 2026 22:41:41 +0200 Subject: [PATCH 07/32] added ErrorMessages enum --- .../vaessl/app/exception/ErrorMessages.java | 29 +++++++++++++++++++ .../app/exception/GlobalExceptionHandler.java | 14 +++++---- 2 files changed, 38 insertions(+), 5 deletions(-) create mode 100644 backend/src/main/java/com/vaessl/app/exception/ErrorMessages.java diff --git a/backend/src/main/java/com/vaessl/app/exception/ErrorMessages.java b/backend/src/main/java/com/vaessl/app/exception/ErrorMessages.java new file mode 100644 index 0000000..9aab15b --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/exception/ErrorMessages.java @@ -0,0 +1,29 @@ +package com.vaessl.app.exception; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; + +@Getter +public enum ErrorMessages { + + 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: "); + + private final HttpStatus status; + private final String message; + + ErrorMessages(HttpStatus status, String message) { + this.status = status; + this.message = message; + } + + ErrorMessages(String message) { + this.status = null; + this.message = 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 d451854..60e2e9e 100644 --- a/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java @@ -1,6 +1,5 @@ package com.vaessl.app.exception; -import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -9,24 +8,29 @@ import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.HttpServerErrorException; import org.springframework.web.client.ResourceAccessException; +import static com.vaessl.app.exception.ErrorMessages.*; + @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) public ProblemDetail handleEmptyCredentialInput(MethodArgumentNotValidException e) { - return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Fields must not be empty."); + return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(), + BAD_REQUEST_EMPTY_FIELDS.getMessage()); } @ExceptionHandler(HttpClientErrorException.Unauthorized.class) public ProblemDetail handleUnauthorizedAccess(HttpClientErrorException e) { - return ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, "Invalid username or password."); + return ProblemDetail.forStatusAndDetail(UNAUTHORIZED_WRONG_LOGIN.getStatus(), + UNAUTHORIZED_WRONG_LOGIN.getMessage()); } @ExceptionHandler(ResourceAccessException.class) public ProblemDetail handleNoConnection(ResourceAccessException e) { - return ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE, "The target URL is unreachable."); + return ProblemDetail.forStatusAndDetail(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getStatus(), + SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage()); } @ExceptionHandler(HttpServerErrorException.class) @@ -34,6 +38,6 @@ public class GlobalExceptionHandler { return ProblemDetail .forStatusAndDetail(e.getStatusCode(), - "The external app returned a server error: " + e.getStatusText()); + SERVER_ERROR_GENERAL.getMessage() + e.getStatusText()); } } -- 2.52.0 From 2387d41ebb051001720628f66864373fc810f28b Mon Sep 17 00:00:00 2001 From: kasun Date: Mon, 30 Mar 2026 22:43:07 +0200 Subject: [PATCH 08/32] changed format --- .../com/vaessl/app/exception/ErrorMessages.java | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/backend/src/main/java/com/vaessl/app/exception/ErrorMessages.java b/backend/src/main/java/com/vaessl/app/exception/ErrorMessages.java index 9aab15b..7e39eb7 100644 --- a/backend/src/main/java/com/vaessl/app/exception/ErrorMessages.java +++ b/backend/src/main/java/com/vaessl/app/exception/ErrorMessages.java @@ -7,10 +7,10 @@ import lombok.Getter; @Getter public enum ErrorMessages { - 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: "); + 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: "); private final HttpStatus status; private final String message; @@ -19,11 +19,9 @@ public enum ErrorMessages { this.status = status; this.message = message; } - + ErrorMessages(String message) { this.status = null; this.message = message; } - - -} +} -- 2.52.0 From ab1d7e68f5063115dbb6ae9d981c3f56b4abd358 Mon Sep 17 00:00:00 2001 From: kasun Date: Thu, 2 Apr 2026 20:28:34 +0200 Subject: [PATCH 09/32] added rest path --- backend/src/main/resources/application.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index da53c38..f4b136c 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -22,3 +22,6 @@ spring: chat: options: model: gpt-4o-mini +server: + servlet: + context-path: /api -- 2.52.0 From 0169cf04b68b81c452d7e7c4897bb15d31575405 Mon Sep 17 00:00:00 2001 From: kasun Date: Fri, 3 Apr 2026 02:58:34 +0200 Subject: [PATCH 10/32] refactored connection classes to be more generic and accept credentials of different apps. --- .../app/connection/ConnectionProvider.java | 11 +++++ .../app/connection/ConnectionService.java | 27 ++++++------ .../com/vaessl/app/connection/Endpoints.java | 14 +++++++ .../connection/HomeBoxConnectionProvider.java | 42 +++++++++++++++++++ .../com/vaessl/app/dto/ConnectionRequest.java | 10 +++-- .../vaessl/app/exception/ErrorMessages.java | 1 - ...nTest.java => HomeboxIntegrationTest.java} | 41 +++++++++++------- 7 files changed, 116 insertions(+), 30 deletions(-) create mode 100644 backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java create mode 100644 backend/src/main/java/com/vaessl/app/connection/Endpoints.java create mode 100644 backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java rename backend/src/test/java/com/vaessl/app/connection/{ConnectionIntegrationTest.java => HomeboxIntegrationTest.java} (78%) diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java new file mode 100644 index 0000000..80b4e8e --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java @@ -0,0 +1,11 @@ +package com.vaessl.app.connection; + +import com.vaessl.app.dto.ConnectionRequest; +import com.vaessl.app.dto.ConnectionResponse; + +public interface ConnectionProvider { + + String getServiceType(); + + ConnectionResponse authenticate (ConnectionRequest connectionRequest); +} 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 882f369..7a98f9f 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java @@ -1,7 +1,10 @@ package com.vaessl.app.connection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + import org.springframework.stereotype.Service; -import org.springframework.web.client.RestClient; import com.vaessl.app.dto.ConnectionRequest; import com.vaessl.app.dto.ConnectionResponse; @@ -9,20 +12,20 @@ import com.vaessl.app.dto.ConnectionResponse; @Service public class ConnectionService { - private final RestClient.Builder restClientBuilder; + private final Map providerRegistry; - public ConnectionService(RestClient.Builder restClientBuilder) { - this.restClientBuilder = restClientBuilder; + public ConnectionService(List providers) { + this.providerRegistry = providers.stream() + .collect(Collectors.toMap(ConnectionProvider::getServiceType, p -> p)); } public ConnectionResponse login(ConnectionRequest request) { - // TODO: Look into Map to cache restclient requests. - return restClientBuilder.baseUrl(request.appUrl()) - .build() - .post() - .uri("/api/v1/users/login") - .body(request) - .retrieve() - .body(ConnectionResponse.class); + ConnectionProvider provider = providerRegistry.get(request.serviceType().toUpperCase()); + + if (provider == null) { + throw new IllegalArgumentException("Unknown provider: " + request.serviceType()); + } + + return provider.authenticate(request); } } \ No newline at end of file diff --git a/backend/src/main/java/com/vaessl/app/connection/Endpoints.java b/backend/src/main/java/com/vaessl/app/connection/Endpoints.java new file mode 100644 index 0000000..9f9711c --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/Endpoints.java @@ -0,0 +1,14 @@ +package com.vaessl.app.connection; + +import lombok.Getter; + +@Getter +public enum Endpoints { + HOMEBOX_LOGIN("/api/v1/users/login"); + + private final String endpoint; + + Endpoints(String endpoint){ + this.endpoint = endpoint; + } +} diff --git a/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java b/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java new file mode 100644 index 0000000..aff1466 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java @@ -0,0 +1,42 @@ +package com.vaessl.app.connection; + +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 static com.vaessl.app.connection.Endpoints.*; + +@Component +public class HomeBoxConnectionProvider implements ConnectionProvider { + + private final RestClient.Builder restClientBuilder; + + public HomeBoxConnectionProvider(RestClient.Builder restClientBuilder) { + this.restClientBuilder = restClientBuilder; + } + + @Override + public String getServiceType() { + return "HOMEBOX"; + } + + @Override + public ConnectionResponse authenticate(ConnectionRequest connectionRequest) { + Map homeboxPayload = Map.of("username", connectionRequest.credentials().get("username"), + "password", connectionRequest.credentials().get("password"), "stayLoggedIn", + connectionRequest.stayLoggedIn()); + + return restClientBuilder.baseUrl(connectionRequest.appUrl()) + .build() + .post() + .uri(HOMEBOX_LOGIN.getEndpoint()) + .body(homeboxPayload) + .retrieve() + .body(ConnectionResponse.class); + } + +} diff --git a/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java b/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java index e9981a8..d01b63f 100644 --- a/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java +++ b/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java @@ -1,9 +1,13 @@ package com.vaessl.app.dto; +import java.util.Map; + import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; public record ConnectionRequest( - @NotBlank(message = "App URL is mandatory") String appUrl, - @NotBlank(message = "Username is mandatory") String username, - @NotBlank(message = "Password is mandatory") String password) { + @NotBlank(message = "App URL is mandatory") String appUrl, + @NotBlank String serviceType, + @NotEmpty Map credentials, + boolean stayLoggedIn) { } diff --git a/backend/src/main/java/com/vaessl/app/exception/ErrorMessages.java b/backend/src/main/java/com/vaessl/app/exception/ErrorMessages.java index 7e39eb7..7c77dab 100644 --- a/backend/src/main/java/com/vaessl/app/exception/ErrorMessages.java +++ b/backend/src/main/java/com/vaessl/app/exception/ErrorMessages.java @@ -6,7 +6,6 @@ import lombok.Getter; @Getter public enum ErrorMessages { - 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( diff --git a/backend/src/test/java/com/vaessl/app/connection/ConnectionIntegrationTest.java b/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java similarity index 78% rename from backend/src/test/java/com/vaessl/app/connection/ConnectionIntegrationTest.java rename to backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java index 64d5186..f205159 100644 --- a/backend/src/test/java/com/vaessl/app/connection/ConnectionIntegrationTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java @@ -16,25 +16,27 @@ import com.jayway.jsonpath.JsonPath; import com.vaessl.app.dto.ConnectionRequest; import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; + import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static com.vaessl.app.connection.Endpoints.*; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureTestRestTemplate @WireMockTest -public class ConnectionIntegrationTest { +public class HomeboxIntegrationTest { @Autowired TestRestTemplate restTemplate; - private static final String API_LOGIN = "/api/v1/users/login"; - /** * Returns Token and status code OK when login is successful. */ @Test - void shouldReturnTokenAndStatusOkWhenCredentialsAreValid(WireMockRuntimeInfo wm) { + void shouldReturnTokenAndStatusOkWhenHomeboxCredentialsAreValid(WireMockRuntimeInfo wm) { - stubFor(post(API_LOGIN) + stubFor(post(HOMEBOX_LOGIN.getEndpoint()) .willReturn(okJson(""" { "token": "fake-jwt-token", @@ -64,7 +66,7 @@ public class ConnectionIntegrationTest { @Test void shouldFailToConnectWhenHomeboxReturnsUnauthorized(WireMockRuntimeInfo wm) { - stubFor(post(API_LOGIN).willReturn(unauthorized())); + stubFor(post(HOMEBOX_LOGIN.getEndpoint()).willReturn(unauthorized())); ResponseEntity response = restTemplate.postForEntity("/login", connectionRequest(wm), String.class); @@ -78,7 +80,7 @@ public class ConnectionIntegrationTest { @Test void shouldFailToConnectWhenHomeboxReturnsServiceUnavailable(WireMockRuntimeInfo wm) { - stubFor(post(API_LOGIN).willReturn(serviceUnavailable())); + stubFor(post(HOMEBOX_LOGIN.getEndpoint()).willReturn(serviceUnavailable())); ResponseEntity response = restTemplate.postForEntity("/login", connectionRequest(wm), String.class); @@ -90,15 +92,18 @@ public class ConnectionIntegrationTest { * Checks when the service is unavailable or the app URL is wrong. */ @Test - void shouldReturnServiceUnavailableWhenUrlIsCompletelyWrong() { + void shouldReturnServiceUnavailableWhenHomeboxUrlIsWrong() { ConnectionRequest badRequest = new ConnectionRequest( "http://localhost:1234", - "user", - "pass"); + "HOMEBOX", + Map.of("username", "myUser", "password", "myPass"), + false); ResponseEntity response = restTemplate.postForEntity("/login", badRequest, String.class); + System.out.println("RESPONSE: " + response.getBody()); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); assertThat(response.getBody()).contains("The target URL is unreachable."); } @@ -107,9 +112,9 @@ public class ConnectionIntegrationTest { * Checks if any login fields are empty since all of them are mandatory. */ @Test - void shouldReturnBadRequestWhenFieldsAreEmpty() { + void shouldReturnBadRequestWhenHomeboxFieldsAreEmpty() { - ConnectionRequest emtpyRequest = new ConnectionRequest("", "", ""); + ConnectionRequest emtpyRequest = new ConnectionRequest("", "", Map.of(), false); ResponseEntity response = restTemplate.postForEntity("/login", emtpyRequest, String.class); @@ -117,7 +122,15 @@ public class ConnectionIntegrationTest { assertThat(response.getBody()).contains("Fields must not be empty."); } - private ConnectionRequest connectionRequest(WireMockRuntimeInfo wireMockRuntimeInfo) { - return new ConnectionRequest(wireMockRuntimeInfo.getHttpBaseUrl(), "username", "password"); + /** + * Creates a valid connection request with a mock Api throuh + * WireMockRuntimeInfo. + * + * @param wm + * @return a mock api connection request. + */ + private ConnectionRequest connectionRequest(WireMockRuntimeInfo wm) { + return new ConnectionRequest(wm.getHttpBaseUrl(), "HOMEBOX", Map.of("username", "admin", "password", "pw"), + false); } } -- 2.52.0 From 913f3c75f189af842e7c3ec92d43bf899ef84947 Mon Sep 17 00:00:00 2001 From: kasun Date: Fri, 3 Apr 2026 21:28:00 +0200 Subject: [PATCH 11/32] made improvements --- .../com/vaessl/app/connection/ConnectionController.java | 3 +-- .../vaessl/app/connection/HomeBoxConnectionProvider.java | 1 - .../main/java/com/vaessl/app/dto/ConnectionRequest.java | 8 ++++---- 3 files changed, 5 insertions(+), 7 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 3918311..d94835c 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java @@ -21,7 +21,6 @@ public class ConnectionController { @PostMapping("/login") public ResponseEntity loginResponse(@Valid @RequestBody ConnectionRequest request) { - ConnectionResponse connectionResponse = connectionService.login(request); - return ResponseEntity.ok(connectionResponse); + return ResponseEntity.ok(connectionService.login(request)); } } 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 aff1466..c7f9181 100644 --- a/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java @@ -38,5 +38,4 @@ public class HomeBoxConnectionProvider implements ConnectionProvider { .retrieve() .body(ConnectionResponse.class); } - } diff --git a/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java b/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java index d01b63f..72a7f26 100644 --- a/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java +++ b/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java @@ -6,8 +6,8 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; public record ConnectionRequest( - @NotBlank(message = "App URL is mandatory") String appUrl, - @NotBlank String serviceType, - @NotEmpty Map credentials, - boolean stayLoggedIn) { + @NotBlank(message = "App URL is mandatory") String appUrl, + @NotBlank(message = "Service type is mandatory") String serviceType, + @NotEmpty(message = "Credentials are mandatory") Map credentials, + boolean stayLoggedIn) { } -- 2.52.0 From be0821b0bee7076611852948277b46d986216772 Mon Sep 17 00:00:00 2001 From: kasun Date: Sat, 4 Apr 2026 23:15:38 +0200 Subject: [PATCH 12/32] replaced constructor with requiredArgsConstructor annotation --- .../com/vaessl/app/connection/ConnectionController.java | 6 ++---- 1 file changed, 2 insertions(+), 4 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 d94835c..ae9409c 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java @@ -9,16 +9,14 @@ import com.vaessl.app.dto.ConnectionRequest; import com.vaessl.app.dto.ConnectionResponse; import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; @RestController +@RequiredArgsConstructor public class ConnectionController { private final ConnectionService connectionService; - public ConnectionController(ConnectionService connectionService) { - this.connectionService = connectionService; - } - @PostMapping("/login") public ResponseEntity loginResponse(@Valid @RequestBody ConnectionRequest request) { return ResponseEntity.ok(connectionService.login(request)); -- 2.52.0 From 8b1a604dc27168fa41ee377bdf0a76fd0ded8da4 Mon Sep 17 00:00:00 2001 From: kasun Date: Mon, 6 Apr 2026 01:28:31 +0200 Subject: [PATCH 13/32] renamed enum classes --- .../com/vaessl/app/connection/Endpoint.java | 16 ++++++++++++++ .../com/vaessl/app/connection/Endpoints.java | 14 ------------- .../{ErrorMessages.java => ErrorMessage.java} | 21 +++++++++++++------ 3 files changed, 31 insertions(+), 20 deletions(-) create mode 100644 backend/src/main/java/com/vaessl/app/connection/Endpoint.java delete mode 100644 backend/src/main/java/com/vaessl/app/connection/Endpoints.java rename backend/src/main/java/com/vaessl/app/exception/{ErrorMessages.java => ErrorMessage.java} (60%) diff --git a/backend/src/main/java/com/vaessl/app/connection/Endpoint.java b/backend/src/main/java/com/vaessl/app/connection/Endpoint.java new file mode 100644 index 0000000..6aec4e5 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/Endpoint.java @@ -0,0 +1,16 @@ +package com.vaessl.app.connection; + + +public enum Endpoint { + HOMEBOX_LOGIN("/api/v1/users/login"), LOGIN("/login"); + + private final String value; + + private Endpoint(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/backend/src/main/java/com/vaessl/app/connection/Endpoints.java b/backend/src/main/java/com/vaessl/app/connection/Endpoints.java deleted file mode 100644 index 9f9711c..0000000 --- a/backend/src/main/java/com/vaessl/app/connection/Endpoints.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.vaessl.app.connection; - -import lombok.Getter; - -@Getter -public enum Endpoints { - HOMEBOX_LOGIN("/api/v1/users/login"); - - private final String endpoint; - - Endpoints(String endpoint){ - this.endpoint = endpoint; - } -} diff --git a/backend/src/main/java/com/vaessl/app/exception/ErrorMessages.java b/backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java similarity index 60% rename from backend/src/main/java/com/vaessl/app/exception/ErrorMessages.java rename to backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java index 7c77dab..2d6f693 100644 --- a/backend/src/main/java/com/vaessl/app/exception/ErrorMessages.java +++ b/backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java @@ -2,25 +2,34 @@ package com.vaessl.app.exception; import org.springframework.http.HttpStatus; -import lombok.Getter; +import com.fasterxml.jackson.annotation.JsonValue; -@Getter -public enum ErrorMessages { +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: "); + "The external app returned a server error: "), WRONG_SERVICE_TYPE(HttpStatus.BAD_REQUEST, + "No such service type."); private final HttpStatus status; private final String message; - ErrorMessages(HttpStatus status, String message) { + private ErrorMessage(HttpStatus status, String message) { this.status = status; this.message = message; } - ErrorMessages(String message) { + private ErrorMessage(String message) { this.status = null; this.message = message; } + + public HttpStatus getStatus() { + return status; + } + + @JsonValue + public String getMessage() { + return message; + } } -- 2.52.0 From ba7887f6b23e3b3d85cd937a9d62696d72a69c4f Mon Sep 17 00:00:00 2001 From: kasun Date: Mon, 6 Apr 2026 02:09:58 +0200 Subject: [PATCH 14/32] updated spring boot version --- backend/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index b9781ce..05bcab7 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -1,6 +1,6 @@ plugins { java - id("org.springframework.boot") version "4.0.4" + id("org.springframework.boot") version "4.0.5" id("io.spring.dependency-management") version "1.1.7" } -- 2.52.0 From 9c3e1469c7dc0f6f4a92c0b67868853439ca280b Mon Sep 17 00:00:00 2001 From: kasun Date: Mon, 6 Apr 2026 03:51:36 +0200 Subject: [PATCH 15/32] added ApplicationTests --- .../java/com/vaessl/app/ApplicationTests.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/backend/src/test/java/com/vaessl/app/ApplicationTests.java b/backend/src/test/java/com/vaessl/app/ApplicationTests.java index c33349c..c608989 100644 --- a/backend/src/test/java/com/vaessl/app/ApplicationTests.java +++ b/backend/src/test/java/com/vaessl/app/ApplicationTests.java @@ -9,8 +9,10 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; +import org.springframework.context.ApplicationContext; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertNotNull; @SpringBootTest @ActiveProfiles("test") @@ -19,10 +21,23 @@ class ApplicationTests { @Autowired private DataSource dataSource; + @Autowired + private ApplicationContext context; + + @Test + void contextLoads() { + } + @Test void connectionToTestDbWorks() throws SQLException { try (Connection connection = dataSource.getConnection()) { assertThat(connection.getMetaData().getURL()).contains("vaessl_test"); } } + + @Test + void main() { + Application.main(new String[] {}); + assertNotNull(context, "The Spring context should not be null"); + } } -- 2.52.0 From 240a366ce84f7d586c01f9ee1eae8c761c8d4081 Mon Sep 17 00:00:00 2001 From: kasun Date: Mon, 6 Apr 2026 05:02:46 +0200 Subject: [PATCH 16/32] added login logic excl refresh call --- .../app/connection/ConnectionEntity.java | 28 ++ .../app/connection/ConnectionProvider.java | 8 +- .../app/connection/ConnectionRepository.java | 10 + .../app/connection/ConnectionService.java | 24 +- .../connection/HomeBoxConnectionProvider.java | 56 +++- .../vaessl/app/connection/HomeboxEntity.java | 34 ++ .../vaessl/app/connection/ServiceType.java | 14 + .../com/vaessl/app/dto/ConnectionRequest.java | 10 +- .../vaessl/app/dto/ConnectionResponse.java | 13 +- .../app/exception/GlobalExceptionHandler.java | 7 +- .../exception/ProviderNotFoundException.java | 5 + .../connection/HomeboxIntegrationTest.java | 124 +++++-- docs/03-Architecture/01-Login-Architecture.md | 302 ++++++++++++++++++ 13 files changed, 589 insertions(+), 46 deletions(-) create mode 100644 backend/src/main/java/com/vaessl/app/connection/ConnectionEntity.java create mode 100644 backend/src/main/java/com/vaessl/app/connection/ConnectionRepository.java create mode 100644 backend/src/main/java/com/vaessl/app/connection/HomeboxEntity.java create mode 100644 backend/src/main/java/com/vaessl/app/connection/ServiceType.java create mode 100644 backend/src/main/java/com/vaessl/app/exception/ProviderNotFoundException.java create mode 100644 docs/03-Architecture/01-Login-Architecture.md diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionEntity.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionEntity.java new file mode 100644 index 0000000..df06a47 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionEntity.java @@ -0,0 +1,28 @@ +package com.vaessl.app.connection; + +import jakarta.persistence.DiscriminatorColumn; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Inheritance; +import jakarta.persistence.InheritanceType; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "connections") +@Inheritance(strategy = InheritanceType.SINGLE_TABLE) +@DiscriminatorColumn(name = "service_type") +@Getter +@Setter +@NoArgsConstructor +public abstract class ConnectionEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String appUrl; +} 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 80b4e8e..ac05ca7 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java @@ -6,6 +6,10 @@ import com.vaessl.app.dto.ConnectionResponse; public interface ConnectionProvider { String getServiceType(); - - ConnectionResponse authenticate (ConnectionRequest connectionRequest); + + ConnectionResponse authenticate(ConnectionRequest request); + + ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response); + + void updateToRepository(ConnectionEntity existing, ConnectionResponse response); } diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionRepository.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionRepository.java new file mode 100644 index 0000000..d02823b --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionRepository.java @@ -0,0 +1,10 @@ +package com.vaessl.app.connection; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ConnectionRepository extends JpaRepository { + + ConnectionEntity findByAppUrl(String appUrl); +} 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 7a98f9f..ea5a526 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java @@ -8,24 +8,40 @@ import org.springframework.stereotype.Service; import com.vaessl.app.dto.ConnectionRequest; import com.vaessl.app.dto.ConnectionResponse; +import com.vaessl.app.exception.ProviderNotFoundException; @Service public class ConnectionService { private final Map providerRegistry; - public ConnectionService(List providers) { + private final ConnectionRepository cRepository; + + public ConnectionService(List providers, ConnectionRepository cRepository) { this.providerRegistry = providers.stream() .collect(Collectors.toMap(ConnectionProvider::getServiceType, p -> p)); + this.cRepository = cRepository; } public ConnectionResponse login(ConnectionRequest request) { - ConnectionProvider provider = providerRegistry.get(request.serviceType().toUpperCase()); + + ConnectionProvider provider = providerRegistry.get(request.serviceType()); if (provider == null) { - throw new IllegalArgumentException("Unknown provider: " + request.serviceType()); + throw new ProviderNotFoundException(); } - return provider.authenticate(request); + ConnectionResponse response = provider.authenticate(request); + + ConnectionEntity existing = cRepository.findByAppUrl(request.appUrl()); + + if (existing != null) { + provider.updateToRepository(existing, response); + } else { + ConnectionEntity newEntity = provider.connectionToEntity(request, response); + cRepository.save(newEntity); + } + + return response; } } \ No newline at end of file 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 c7f9181..fd3fcb1 100644 --- a/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java @@ -1,5 +1,7 @@ package com.vaessl.app.connection; +import java.time.Instant; +import java.util.HashMap; import java.util.Map; import org.springframework.stereotype.Component; @@ -8,15 +10,18 @@ import org.springframework.web.client.RestClient; import com.vaessl.app.dto.ConnectionRequest; import com.vaessl.app.dto.ConnectionResponse; -import static com.vaessl.app.connection.Endpoints.*; +import static com.vaessl.app.connection.Endpoint.*; @Component public class HomeBoxConnectionProvider implements ConnectionProvider { private final RestClient.Builder restClientBuilder; - public HomeBoxConnectionProvider(RestClient.Builder restClientBuilder) { + private final ConnectionRepository cRepository; + + public HomeBoxConnectionProvider(RestClient.Builder restClientBuilder, ConnectionRepository cRepository) { this.restClientBuilder = restClientBuilder; + this.cRepository = cRepository; } @Override @@ -25,17 +30,50 @@ public class HomeBoxConnectionProvider implements ConnectionProvider { } @Override - public ConnectionResponse authenticate(ConnectionRequest connectionRequest) { - Map homeboxPayload = Map.of("username", connectionRequest.credentials().get("username"), - "password", connectionRequest.credentials().get("password"), "stayLoggedIn", - connectionRequest.stayLoggedIn()); + public ConnectionResponse authenticate(ConnectionRequest request) { + Map homeboxPayload = Map.of("username", request.credentials().get("username"), + "password", request.credentials().get("password"), "stayLoggedIn", + request.stayLoggedIn()); - return restClientBuilder.baseUrl(connectionRequest.appUrl()) + HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl()) .build() .post() - .uri(HOMEBOX_LOGIN.getEndpoint()) + .uri(HOMEBOX_LOGIN.getValue()) .body(homeboxPayload) .retrieve() - .body(ConnectionResponse.class); + .body(HomeboxLoginResponse.class); + + if (hbResponse == null) { + throw new IllegalStateException("Remote API returned an empty body for " + request.appUrl()); + } + + Map attachmentToken = new HashMap<>(); + + attachmentToken.put("attachmentToken", hbResponse.attachmentToken()); + + return new ConnectionResponse(hbResponse.token(), hbResponse.expiresAt(), attachmentToken); } + + @Override + public ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response) { + return HomeboxEntity.from(request, response); + } + + @Override + public void updateToRepository(ConnectionEntity existing, ConnectionResponse response) { + + + if (existing instanceof HomeboxEntity hbE) { + + hbE.setToken(response.token()); + hbE.setExpiresAt(response.expiresAt()); + hbE.setAttachmentToken(response.getExtraVar("attachmentToken")); + + cRepository.save(hbE); + } + } + + private record HomeboxLoginResponse(String token, String attachmentToken, Instant expiresAt) { + } + } diff --git a/backend/src/main/java/com/vaessl/app/connection/HomeboxEntity.java b/backend/src/main/java/com/vaessl/app/connection/HomeboxEntity.java new file mode 100644 index 0000000..f23521d --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/HomeboxEntity.java @@ -0,0 +1,34 @@ +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; +import lombok.Setter; + +@Entity +@DiscriminatorValue("HOMEBOX") +@Getter +@Setter +public class HomeboxEntity extends ConnectionEntity { + + private String token; + private String attachmentToken; + private Instant expiresAt; + + public static HomeboxEntity from(ConnectionRequest request, ConnectionResponse response) { + + HomeboxEntity he = new HomeboxEntity(); + + he.setAppUrl(request.appUrl()); + he.setToken(response.token()); + he.setAttachmentToken(response.getExtraVar("attachmentToken")); + he.setExpiresAt(response.expiresAt()); + + return he; + } +} diff --git a/backend/src/main/java/com/vaessl/app/connection/ServiceType.java b/backend/src/main/java/com/vaessl/app/connection/ServiceType.java new file mode 100644 index 0000000..d579690 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/ServiceType.java @@ -0,0 +1,14 @@ +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; + } +} diff --git a/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java b/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java index 72a7f26..587a94b 100644 --- a/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java +++ b/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java @@ -2,6 +2,8 @@ package com.vaessl.app.dto; import java.util.Map; +import com.fasterxml.jackson.annotation.JsonProperty; + import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; @@ -9,5 +11,11 @@ public record ConnectionRequest( @NotBlank(message = "App URL is mandatory") String appUrl, @NotBlank(message = "Service type is mandatory") String serviceType, @NotEmpty(message = "Credentials are mandatory") Map credentials, - boolean stayLoggedIn) { + @JsonProperty(defaultValue = "false") Boolean stayLoggedIn) { + + public ConnectionRequest { + if (stayLoggedIn == null) { + stayLoggedIn = false; + } + } } diff --git a/backend/src/main/java/com/vaessl/app/dto/ConnectionResponse.java b/backend/src/main/java/com/vaessl/app/dto/ConnectionResponse.java index bc9f370..9422e7d 100644 --- a/backend/src/main/java/com/vaessl/app/dto/ConnectionResponse.java +++ b/backend/src/main/java/com/vaessl/app/dto/ConnectionResponse.java @@ -1,6 +1,17 @@ package com.vaessl.app.dto; import java.time.Instant; +import java.util.Map; -public record ConnectionResponse(String token, String attachmentToken, Instant expiresAt) { +public record ConnectionResponse(String token, Instant expiresAt, Map extraResponseData) { + + public String getExtraVar(String key) { + if(extraResponseData == null) { + return null; + } else { + Object value = extraResponseData.get(key); + + return value != null ? String.valueOf(value) : null; + } + } } 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 60e2e9e..8bf462b 100644 --- a/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java @@ -8,7 +8,7 @@ import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.HttpServerErrorException; import org.springframework.web.client.ResourceAccessException; -import static com.vaessl.app.exception.ErrorMessages.*; +import static com.vaessl.app.exception.ErrorMessage.*; @RestControllerAdvice public class GlobalExceptionHandler { @@ -40,4 +40,9 @@ public class GlobalExceptionHandler { .forStatusAndDetail(e.getStatusCode(), SERVER_ERROR_GENERAL.getMessage() + e.getStatusText()); } + + @ExceptionHandler(ProviderNotFoundException.class) + public ProblemDetail handleWrongServiceType(ProviderNotFoundException e) { + return ProblemDetail.forStatusAndDetail(WRONG_SERVICE_TYPE.getStatus(), WRONG_SERVICE_TYPE.getMessage()); + } } diff --git a/backend/src/main/java/com/vaessl/app/exception/ProviderNotFoundException.java b/backend/src/main/java/com/vaessl/app/exception/ProviderNotFoundException.java new file mode 100644 index 0000000..a5d8200 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/exception/ProviderNotFoundException.java @@ -0,0 +1,5 @@ +package com.vaessl.app.exception; + +public class ProviderNotFoundException extends RuntimeException { + +} 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 f205159..be6fa09 100644 --- a/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java @@ -20,39 +20,50 @@ import static org.assertj.core.api.Assertions.assertThat; import java.util.Map; import static com.github.tomakehurst.wiremock.client.WireMock.*; -import static com.vaessl.app.connection.Endpoints.*; +import static com.vaessl.app.connection.Endpoint.*; +import static com.vaessl.app.exception.ErrorMessage.*; +import static com.vaessl.app.connection.ServiceType.*; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureTestRestTemplate @WireMockTest -public class HomeboxIntegrationTest { +class HomeboxIntegrationTest { @Autowired TestRestTemplate restTemplate; + @Autowired + ConnectionRepository cRepository; + + 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 shouldReturnTokenAndStatusOkWhenHomeboxCredentialsAreValid(WireMockRuntimeInfo wm) { - stubFor(post(HOMEBOX_LOGIN.getEndpoint()) - .willReturn(okJson(""" - { - "token": "fake-jwt-token", - "attachmentToken": "fake-attach", - "expiresAt": "2026-04-26T02:23:13Z" - } - """))); + stubFor(post(HOMEBOX_LOGIN.getValue()) + .willReturn(okJson(okJsonHomeboxResponse))); - ResponseEntity response = restTemplate.postForEntity("/login", connectionRequest(wm), String.class); + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), + String.class); DocumentContext documentContext = JsonPath.parse(response.getBody()); String token = documentContext.read("$.token"); assertThat(token).isEqualTo("fake-jwt-token"); - String attachmentToken = documentContext.read("$.attachmentToken"); + String attachmentToken = documentContext.read("$.extraResponseData.attachmentToken"); assertThat(attachmentToken).isEqualTo("fake-attach"); String expiresAt = documentContext.read("$.expiresAt", String.class); @@ -61,31 +72,38 @@ public class HomeboxIntegrationTest { } /** - * Test if login request fails with 401 unauthorized. + * Tests the Unauthorized custom exception. + * + * @param wm + * the WiremockRuntimeInfo object */ @Test void shouldFailToConnectWhenHomeboxReturnsUnauthorized(WireMockRuntimeInfo wm) { - stubFor(post(HOMEBOX_LOGIN.getEndpoint()).willReturn(unauthorized())); + stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(unauthorized())); - ResponseEntity response = restTemplate.postForEntity("/login", connectionRequest(wm), String.class); + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), + String.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - assertThat(response.getBody()).contains("Invalid username or password."); + assertThat(response.getBody()).contains(UNAUTHORIZED_WRONG_LOGIN.getMessage()); } /** * Tests a server error from the external api. + * + * @param wm */ @Test void shouldFailToConnectWhenHomeboxReturnsServiceUnavailable(WireMockRuntimeInfo wm) { - stubFor(post(HOMEBOX_LOGIN.getEndpoint()).willReturn(serviceUnavailable())); + stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(serviceUnavailable())); - ResponseEntity response = restTemplate.postForEntity("/login", 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("The external app returned a server error"); + assertThat(response.getBody()).contains(SERVER_ERROR_GENERAL.getMessage()); } /** @@ -96,16 +114,14 @@ public class HomeboxIntegrationTest { ConnectionRequest badRequest = new ConnectionRequest( "http://localhost:1234", - "HOMEBOX", + HOMEBOX.getValue(), Map.of("username", "myUser", "password", "myPass"), false); - ResponseEntity response = restTemplate.postForEntity("/login", badRequest, String.class); - - System.out.println("RESPONSE: " + response.getBody()); + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), badRequest, String.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); - assertThat(response.getBody()).contains("The target URL is unreachable."); + assertThat(response.getBody()).contains(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage()); } /** @@ -116,10 +132,60 @@ public class HomeboxIntegrationTest { ConnectionRequest emtpyRequest = new ConnectionRequest("", "", Map.of(), false); - ResponseEntity response = restTemplate.postForEntity("/login", emtpyRequest, String.class); + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), emtpyRequest, String.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(response.getBody()).contains("Fields must not be empty."); + assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage()); + } + + /** + * Test the custom ProviderNotFound exception. + */ + @Test + void shouldReturnProviderNotFound() { + ConnectionRequest wrongServiceTypeReq = new ConnectionRequest( + "http://localhost:1234", + "wrong-service-type", + Map.of("username", "myUser", "password", "myPass"), + false); + + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), wrongServiceTypeReq, + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + 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); + + DocumentContext responseContext = JsonPath.parse(response.getBody()); + + ConnectionEntity dbEntry = cRepository.findByAppUrl(request.appUrl()); + + assertThat(dbEntry).isNotNull(); + + assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl()); + + if (dbEntry instanceof HomeboxEntity hbE) { + assertThat(hbE.getToken()).isEqualTo(responseContext.read("$.token")); + assertThat(hbE.getAttachmentToken()).isEqualTo(responseContext.read("$.extraResponseData.attachmentToken")); + assertThat(hbE.getExpiresAt()).isEqualTo(responseContext.read("$.expiresAt")); + } } /** @@ -127,10 +193,12 @@ public class HomeboxIntegrationTest { * WireMockRuntimeInfo. * * @param wm + * the WiremockRuntimeInfo object * @return a mock api connection request. */ private ConnectionRequest connectionRequest(WireMockRuntimeInfo wm) { - return new ConnectionRequest(wm.getHttpBaseUrl(), "HOMEBOX", Map.of("username", "admin", "password", "pw"), - false); + return new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX.getValue(), + Map.of("username", "admin", "password", "pw"), + null); } } diff --git a/docs/03-Architecture/01-Login-Architecture.md b/docs/03-Architecture/01-Login-Architecture.md new file mode 100644 index 0000000..8d40aa6 --- /dev/null +++ b/docs/03-Architecture/01-Login-Architecture.md @@ -0,0 +1,302 @@ +**vaessl: Login Architecture** + +# Backend +The login architecture is designed to make future additions to this bridging app as frictionless as possible. Abstraction and inheritance will be used as good as possible to keep refactorings to a minimum. The first app to bridge will be Homebox which uses a classic username, password and Bearer token login proces to authenticate calls to its api. The second hypothetic app will be WikiJs which simply uses a user generated api key. The abstraction of the Java classes will try to cater to both authentication methods. + +## Single Table Inheritance +The database entities will follow the Single Table Inheritance concept. + +The databse will have one "connections" table that has all the columnns of every supported app. So in this case the table will have username, password and attachment token which are specific to Homebox. Both Homebox and WikiJs will share the token field. +The entities will be organized with an abstract ConnectionEntitiy class containing the id and appUrl and the app specific entities like HomeboxEntitiy: + +***ConnectionEntitiy.java*** + +``` +package com.vaessl.app.connection; + +import jakarta.persistence.DiscriminatorColumn; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Inheritance; +import jakarta.persistence.InheritanceType; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "connections") +@Inheritance(strategy = InheritanceType.SINGLE_TABLE) +@DiscriminatorColumn(name = "service_type") +@Getter +@Setter +@NoArgsConstructor +public abstract class ConnectionEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + Long id; + + private String appUrl; +} + +``` + +***HomeboxEntitiy.java*** + +``` +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; +import lombok.Setter; + +@Entity +@DiscriminatorValue("HOMEBOX") +@Getter +@Setter +public class HomeboxEntity extends ConnectionEntity { + +private String token; +private String attachmentToken; +private Instant expiresAt; + +public static HomeboxEntity from(ConnectionRequest request, ConnectionResponse response) { + + HomeboxEntity he = new HomeboxEntity(); + + he.setAppUrl(request.appUrl()); + he.setToken(response.token()); + he.setAttachmentToken(response.getExtraVar("attachmentToken")); + he.setExpiresAt(response.expiresAt()); + + return he; + } +} + +``` + +## The Provider Pattern (Logic Layer) +To keep the core business logic clean, the app uses a Provider Pattern. This separates how the app authenticates (the Specific) from what the system does with that info (the General). + +- ConnectionProvider Interface: Defines the contract. Every new app (Homebox, WikiJS, etc.) must implement this interface to tell the system how to authenticate and how to map its specific API response into a database entity. + +***ConnectionProvider.java*** +``` +package com.vaessl.app.connection; + +import com.vaessl.app.dto.ConnectionRequest; +import com.vaessl.app.dto.ConnectionResponse; + +public interface ConnectionProvider { + + String getServiceType(); + + ConnectionResponse authenticate(ConnectionRequest request); + + ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response); + + void updateToRepository(ConnectionEntity existing, ConnectionResponse response); +} + +``` +***HomeboxConnectionProvider.java*** +``` +package com.vaessl.app.connection; + +import java.time.Instant; +import java.util.HashMap; +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 static com.vaessl.app.connection.Endpoint.*; + +@Component +public class HomeBoxConnectionProvider implements ConnectionProvider { + + private final RestClient.Builder restClientBuilder; + + private final ConnectionRepository cRepository; + + public HomeBoxConnectionProvider(RestClient.Builder restClientBuilder, ConnectionRepository cRepository) { + this.restClientBuilder = restClientBuilder; + this.cRepository = cRepository; + } + + @Override + public String getServiceType() { + return "HOMEBOX"; + } + + @Override + public ConnectionResponse authenticate(ConnectionRequest request) { + Map homeboxPayload = Map.of("username", request.credentials().get("username"), + "password", request.credentials().get("password"), "stayLoggedIn", + request.stayLoggedIn()); + + 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()); + } + + Map attachmentToken = new HashMap<>(); + + attachmentToken.put("attachmentToken", hbResponse.attachmentToken()); + + return new ConnectionResponse(hbResponse.token(), hbResponse.expiresAt(), attachmentToken); + } + + @Override + public ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response) { + return HomeboxEntity.from(request, response); + } + + @Override + public void updateToRepository(ConnectionEntity existing, ConnectionResponse response) { + + + if (existing instanceof HomeboxEntity hbE) { + + hbE.setToken(response.token()); + hbE.setExpiresAt(response.expiresAt()); + hbE.setAttachmentToken(response.getExtraVar("attachmentToken")); + + cRepository.save(hbE); + } + } + + private record HomeboxLoginResponse(String token, String attachmentToken, Instant expiresAt) { + } + +} +``` + +- Provider Registry: The ConnectionService automatically detects all implementations of the provider interface and stores them in a map. When a login request comes in, the service simply looks up the correct provider by its "Service Type" string. + +***ConnectionService.java*** +``` +package com.vaessl.app.connection; + +import java.util.List; +import java.util.Map; +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.exception.ProviderNotFoundException; + +@Service +public class ConnectionService { + + private final Map providerRegistry; + + private final ConnectionRepository cRepository; + + public ConnectionService(List providers, ConnectionRepository cRepository) { + this.providerRegistry = providers.stream() + .collect(Collectors.toMap(ConnectionProvider::getServiceType, p -> p)); + this.cRepository = cRepository; + } + + public ConnectionResponse login(ConnectionRequest request) { + + ConnectionProvider provider = providerRegistry.get(request.serviceType()); + + if (provider == null) { + throw new ProviderNotFoundException(); + } + + ConnectionResponse response = provider.authenticate(request); + + ConnectionEntity existing = cRepository.findByAppUrl(request.appUrl()); + + if (existing != null) { + provider.updateToRepository(existing, response); + } else { + ConnectionEntity newEntity = provider.connectionToEntity(request, response); + cRepository.save(newEntity); + } + + return response; + } +} +``` + +## Generic Data Exchange (The DTOs) +Since different apps return different types of data (e.g., Homebox returns an attachmentToken, but WikiJS might return a something else), I use a Generic Data Bridge to move information between the API and the Database. + +- ConnectionRequest: A universal "envelope" containing common fields (appUrl, serviceType) and a flexible Map for credentials. This allows one DTO to handle both username/password logins and API-key-only logins. + +***ConnectionRequest.java*** + +``` +package com.vaessl.app.dto; + +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; + +public record ConnectionRequest( + @NotBlank(message = "App URL is mandatory") String appUrl, + @NotBlank(message = "Service type is mandatory") String serviceType, + @NotEmpty(message = "Credentials are mandatory") Map credentials, + @JsonProperty(defaultValue = "false") Boolean stayLoggedIn) { + + public ConnectionRequest { + if (stayLoggedIn == null) { + stayLoggedIn = false; + } + } +} +``` + +- ConnectionResponse: A "Smart" DTO that holds the core authentication data (the token and expiresAt) and a Map called extraResponseData. + + - The "Smart" Getter: The response object contains helper methods to safely extract app-specific variables from this map. This allows the system to be "Generic" while still giving specific entities (like HomeboxEntity) access to the unique data they need. + +***ConnectionResponse.java*** + +``` +package com.vaessl.app.dto; + +import java.time.Instant; +import java.util.Map; + +public record ConnectionResponse(String token, Instant expiresAt, Map extraResponseData) { + + public String getExtraVar(String key) { + if(extraResponseData == null) { + return null; + } else { + Object value = extraResponseData.get(key); + + return value != null ? String.valueOf(value) : null; + } + } +} + +``` -- 2.52.0 From da0411f5d171918c5be4264c8f750ec350333e94 Mon Sep 17 00:00:00 2001 From: kasun Date: Mon, 6 Apr 2026 11:51:03 +0200 Subject: [PATCH 17/32] corrected typo --- docs/03-Architecture/01-Login-Architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/03-Architecture/01-Login-Architecture.md b/docs/03-Architecture/01-Login-Architecture.md index 8d40aa6..3de9f40 100644 --- a/docs/03-Architecture/01-Login-Architecture.md +++ b/docs/03-Architecture/01-Login-Architecture.md @@ -6,7 +6,7 @@ The login architecture is designed to make future additions to this bridging app ## Single Table Inheritance The database entities will follow the Single Table Inheritance concept. -The databse will have one "connections" table that has all the columnns of every supported app. So in this case the table will have username, password and attachment token which are specific to Homebox. Both Homebox and WikiJs will share the token field. +The database will have one "connections" table that has all the columnns of every supported app. So in this case the table will have username, password and attachment token which are specific to Homebox. Both Homebox and WikiJs will share the token field. The entities will be organized with an abstract ConnectionEntitiy class containing the id and appUrl and the app specific entities like HomeboxEntitiy: ***ConnectionEntitiy.java*** -- 2.52.0 From 30784fa756b19c66b8ea66265eae7a70c264c717 Mon Sep 17 00:00:00 2001 From: kasun Date: Wed, 8 Apr 2026 02:21:55 +0200 Subject: [PATCH 18/32] format --- .../com/vaessl/app/connection/HomeBoxConnectionProvider.java | 5 ++--- 1 file changed, 2 insertions(+), 3 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 fd3fcb1..3294cd7 100644 --- a/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java @@ -62,13 +62,12 @@ public class HomeBoxConnectionProvider implements ConnectionProvider { @Override public void updateToRepository(ConnectionEntity existing, ConnectionResponse response) { - if (existing instanceof HomeboxEntity hbE) { - + hbE.setToken(response.token()); hbE.setExpiresAt(response.expiresAt()); hbE.setAttachmentToken(response.getExtraVar("attachmentToken")); - + cRepository.save(hbE); } } -- 2.52.0 From c59f2598b01a3b0c8764f6f3e9bfdfaac1422c7a Mon Sep 17 00:00:00 2001 From: kasun Date: Wed, 8 Apr 2026 02:22:07 +0200 Subject: [PATCH 19/32] added final fileds --- .../app/connection/HomeboxIntegrationTest.java | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) 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 be6fa09..2f7cf3c 100644 --- a/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java @@ -43,6 +43,10 @@ class HomeboxIntegrationTest { } """; + private static final String MOCK_URL = "http://localhost:1234"; + private static final String TEST_USER = "admin"; + private static final String TEST_PASS = "pw"; + /** * Returns Token and status code OK when login is successful. * @@ -113,9 +117,9 @@ class HomeboxIntegrationTest { void shouldReturnServiceUnavailableWhenHomeboxUrlIsWrong() { ConnectionRequest badRequest = new ConnectionRequest( - "http://localhost:1234", + MOCK_URL, HOMEBOX.getValue(), - Map.of("username", "myUser", "password", "myPass"), + Map.of("username", TEST_USER, "password", TEST_PASS), false); ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), badRequest, String.class); @@ -144,9 +148,9 @@ class HomeboxIntegrationTest { @Test void shouldReturnProviderNotFound() { ConnectionRequest wrongServiceTypeReq = new ConnectionRequest( - "http://localhost:1234", + MOCK_URL, "wrong-service-type", - Map.of("username", "myUser", "password", "myPass"), + Map.of("username", TEST_USER, "password", TEST_PASS), false); ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), wrongServiceTypeReq, @@ -198,7 +202,7 @@ class HomeboxIntegrationTest { */ private ConnectionRequest connectionRequest(WireMockRuntimeInfo wm) { return new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX.getValue(), - Map.of("username", "admin", "password", "pw"), + Map.of("username", TEST_USER, "password", TEST_PASS), null); } } -- 2.52.0 From 3e4a1f92b1280ff964ba52296bec174d406ed243 Mon Sep 17 00:00:00 2001 From: kasun Date: Wed, 8 Apr 2026 17:38:42 +0200 Subject: [PATCH 20/32] added username for uniqueness of connection db entry --- .../java/com/vaessl/app/connection/ConnectionEntity.java | 6 ++++-- .../java/com/vaessl/app/connection/ConnectionProvider.java | 2 ++ .../com/vaessl/app/connection/ConnectionRepository.java | 2 +- .../java/com/vaessl/app/connection/ConnectionService.java | 2 +- .../vaessl/app/connection/HomeBoxConnectionProvider.java | 6 ++++++ .../main/java/com/vaessl/app/connection/HomeboxEntity.java | 1 + .../com/vaessl/app/connection/HomeboxIntegrationTest.java | 3 ++- 7 files changed, 17 insertions(+), 5 deletions(-) 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 df06a47..96b769f 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionEntity.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionEntity.java @@ -8,12 +8,13 @@ import jakarta.persistence.Id; import jakarta.persistence.Inheritance; import jakarta.persistence.InheritanceType; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @Entity -@Table(name = "connections") +@Table(name = "connections", uniqueConstraints = { @UniqueConstraint(columnNames = { "appUrl", "username" }) }) @Inheritance(strategy = InheritanceType.SINGLE_TABLE) @DiscriminatorColumn(name = "service_type") @Getter @@ -23,6 +24,7 @@ public abstract class ConnectionEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - + private String appUrl; + private String username; } 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 ac05ca7..0b6e893 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java @@ -9,6 +9,8 @@ public interface ConnectionProvider { ConnectionResponse authenticate(ConnectionRequest request); + ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request); + ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response); void updateToRepository(ConnectionEntity existing, ConnectionResponse response); diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionRepository.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionRepository.java index d02823b..880f567 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionRepository.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionRepository.java @@ -6,5 +6,5 @@ import org.springframework.stereotype.Repository; @Repository public interface ConnectionRepository extends JpaRepository { - ConnectionEntity findByAppUrl(String appUrl); + ConnectionEntity findByAppUrlAndUsername(String appUrl, String username); } 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 ea5a526..86c3b28 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java @@ -33,7 +33,7 @@ public class ConnectionService { ConnectionResponse response = provider.authenticate(request); - ConnectionEntity existing = cRepository.findByAppUrl(request.appUrl()); + ConnectionEntity existing = provider.findUniqueConnectionEntry(request); if (existing != null) { provider.updateToRepository(existing, response); 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 3294cd7..c8fc2d3 100644 --- a/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java @@ -54,6 +54,12 @@ public class HomeBoxConnectionProvider implements ConnectionProvider { return new ConnectionResponse(hbResponse.token(), hbResponse.expiresAt(), attachmentToken); } + @Override + public ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request) { + + return cRepository.findByAppUrlAndUsername(request.appUrl(), request.credentials().get("username")); + } + @Override public ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response) { return HomeboxEntity.from(request, response); 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 f23521d..c134af0 100644 --- a/backend/src/main/java/com/vaessl/app/connection/HomeboxEntity.java +++ b/backend/src/main/java/com/vaessl/app/connection/HomeboxEntity.java @@ -25,6 +25,7 @@ public class HomeboxEntity extends ConnectionEntity { HomeboxEntity he = new HomeboxEntity(); he.setAppUrl(request.appUrl()); + he.setUsername(request.credentials().get("username")); he.setToken(response.token()); he.setAttachmentToken(response.getExtraVar("attachmentToken")); he.setExpiresAt(response.expiresAt()); 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 2f7cf3c..4ea7388 100644 --- a/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java @@ -179,11 +179,12 @@ class HomeboxIntegrationTest { DocumentContext responseContext = JsonPath.parse(response.getBody()); - ConnectionEntity dbEntry = cRepository.findByAppUrl(request.appUrl()); + ConnectionEntity dbEntry = cRepository.findByAppUrlAndUsername(request.appUrl(), request.credentials().get("username")); assertThat(dbEntry).isNotNull(); assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl()); + assertThat(dbEntry.getUsername()).isEqualTo(request.credentials().get("username")); if (dbEntry instanceof HomeboxEntity hbE) { assertThat(hbE.getToken()).isEqualTo(responseContext.read("$.token")); -- 2.52.0 From 8a5f7c2bf88765596da15bf2ea4cca0f48a7bddc Mon Sep 17 00:00:00 2001 From: kasun Date: Wed, 8 Apr 2026 20:48:21 +0200 Subject: [PATCH 21/32] added username to ConnectionEntity --- .../src/main/java/com/vaessl/app/connection/HomeboxEntity.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c134af0..e965269 100644 --- a/backend/src/main/java/com/vaessl/app/connection/HomeboxEntity.java +++ b/backend/src/main/java/com/vaessl/app/connection/HomeboxEntity.java @@ -25,7 +25,7 @@ public class HomeboxEntity extends ConnectionEntity { HomeboxEntity he = new HomeboxEntity(); he.setAppUrl(request.appUrl()); - he.setUsername(request.credentials().get("username")); + he.setUsername(request.username()); he.setToken(response.token()); he.setAttachmentToken(response.getExtraVar("attachmentToken")); he.setExpiresAt(response.expiresAt()); -- 2.52.0 From 702e6bb9738b72466af1ffba657e409a19402769 Mon Sep 17 00:00:00 2001 From: kasun Date: Wed, 8 Apr 2026 20:49:28 +0200 Subject: [PATCH 22/32] revised HBTest to match new request body --- .../app/connection/HomeboxIntegrationTest.java | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) 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 4ea7388..1ba9122 100644 --- a/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java @@ -17,8 +17,6 @@ import com.vaessl.app.dto.ConnectionRequest; import static org.assertj.core.api.Assertions.assertThat; -import java.util.Map; - import static com.github.tomakehurst.wiremock.client.WireMock.*; import static com.vaessl.app.connection.Endpoint.*; import static com.vaessl.app.exception.ErrorMessage.*; @@ -97,6 +95,7 @@ class HomeboxIntegrationTest { * Tests a server error from the external api. * * @param wm + * the WiremockRuntimeInfo object */ @Test void shouldFailToConnectWhenHomeboxReturnsServiceUnavailable(WireMockRuntimeInfo wm) { @@ -119,7 +118,7 @@ class HomeboxIntegrationTest { ConnectionRequest badRequest = new ConnectionRequest( MOCK_URL, HOMEBOX.getValue(), - Map.of("username", TEST_USER, "password", TEST_PASS), + TEST_USER, TEST_PASS, null, false); ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), badRequest, String.class); @@ -134,7 +133,7 @@ class HomeboxIntegrationTest { @Test void shouldReturnBadRequestWhenHomeboxFieldsAreEmpty() { - ConnectionRequest emtpyRequest = new ConnectionRequest("", "", Map.of(), false); + ConnectionRequest emtpyRequest = new ConnectionRequest("", "", "", "", false); ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), emtpyRequest, String.class); @@ -150,7 +149,7 @@ class HomeboxIntegrationTest { ConnectionRequest wrongServiceTypeReq = new ConnectionRequest( MOCK_URL, "wrong-service-type", - Map.of("username", TEST_USER, "password", TEST_PASS), + TEST_USER, TEST_PASS, false); ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), wrongServiceTypeReq, @@ -179,12 +178,12 @@ class HomeboxIntegrationTest { DocumentContext responseContext = JsonPath.parse(response.getBody()); - ConnectionEntity dbEntry = cRepository.findByAppUrlAndUsername(request.appUrl(), request.credentials().get("username")); + ConnectionEntity dbEntry = cRepository.findByAppUrlAndUsername(request.appUrl(), request.username()); assertThat(dbEntry).isNotNull(); assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl()); - assertThat(dbEntry.getUsername()).isEqualTo(request.credentials().get("username")); + assertThat(dbEntry.getUsername()).isEqualTo(request.username()); if (dbEntry instanceof HomeboxEntity hbE) { assertThat(hbE.getToken()).isEqualTo(responseContext.read("$.token")); @@ -202,8 +201,7 @@ class HomeboxIntegrationTest { * @return a mock api connection request. */ private ConnectionRequest connectionRequest(WireMockRuntimeInfo wm) { - return new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX.getValue(), - Map.of("username", TEST_USER, "password", TEST_PASS), + return new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX.getValue(), TEST_USER, TEST_PASS, null); } } -- 2.52.0 From 2000278f1a1471312d608bbaa1ce932b287f40c8 Mon Sep 17 00:00:00 2001 From: kasun Date: Wed, 8 Apr 2026 20:51:14 +0200 Subject: [PATCH 23/32] got rid if Map and added username, password and apikey to request body --- .../app/connection/HomeBoxConnectionProvider.java | 6 +++--- .../java/com/vaessl/app/dto/ConnectionRequest.java | 12 ++++++++---- 2 files changed, 11 insertions(+), 7 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 c8fc2d3..fa7117f 100644 --- a/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java @@ -31,8 +31,8 @@ public class HomeBoxConnectionProvider implements ConnectionProvider { @Override public ConnectionResponse authenticate(ConnectionRequest request) { - Map homeboxPayload = Map.of("username", request.credentials().get("username"), - "password", request.credentials().get("password"), "stayLoggedIn", + Map homeboxPayload = Map.of("username", request.username(), + "password", request.password(), "stayLoggedIn", request.stayLoggedIn()); HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl()) @@ -57,7 +57,7 @@ public class HomeBoxConnectionProvider implements ConnectionProvider { @Override public ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request) { - return cRepository.findByAppUrlAndUsername(request.appUrl(), request.credentials().get("username")); + return cRepository.findByAppUrlAndUsername(request.appUrl(), request.username()); } @Override diff --git a/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java b/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java index 587a94b..3c6272d 100644 --- a/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java +++ b/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java @@ -1,16 +1,15 @@ package com.vaessl.app.dto; -import java.util.Map; - import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotEmpty; public record ConnectionRequest( @NotBlank(message = "App URL is mandatory") String appUrl, @NotBlank(message = "Service type is mandatory") String serviceType, - @NotEmpty(message = "Credentials are mandatory") Map credentials, + String username, + String password, + String apiKey, @JsonProperty(defaultValue = "false") Boolean stayLoggedIn) { public ConnectionRequest { @@ -18,4 +17,9 @@ public record ConnectionRequest( stayLoggedIn = false; } } + + public ConnectionRequest(String appUrl, String serviceType, String username, String password, + Boolean stayLoggedIn) { + this(appUrl, serviceType, username, password, null, stayLoggedIn); + } } -- 2.52.0 From 680e5f0abd01833b1bb298ec3be72860ff55b7fe Mon Sep 17 00:00:00 2001 From: kasun Date: Wed, 8 Apr 2026 20:57:27 +0200 Subject: [PATCH 24/32] revised exception handling for empty fields. --- .../app/connection/ConnectionProvider.java | 2 ++ .../app/connection/ConnectionService.java | 5 +---- .../connection/HomeBoxConnectionProvider.java | 19 +++++++++++++++++++ .../exception/EmptyCredentialsException.java | 13 +++++++++++++ .../app/exception/GlobalExceptionHandler.java | 15 +++++++++++---- .../exception/ProviderNotFoundException.java | 5 ----- 6 files changed, 46 insertions(+), 13 deletions(-) create mode 100644 backend/src/main/java/com/vaessl/app/exception/EmptyCredentialsException.java delete mode 100644 backend/src/main/java/com/vaessl/app/exception/ProviderNotFoundException.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 0b6e893..bd2c7d1 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java @@ -5,6 +5,8 @@ import com.vaessl.app.dto.ConnectionResponse; public interface ConnectionProvider { + void checkCredentials(ConnectionRequest request); + String getServiceType(); ConnectionResponse authenticate(ConnectionRequest request); 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 86c3b28..bfd2ace 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java @@ -8,7 +8,6 @@ import org.springframework.stereotype.Service; import com.vaessl.app.dto.ConnectionRequest; import com.vaessl.app.dto.ConnectionResponse; -import com.vaessl.app.exception.ProviderNotFoundException; @Service public class ConnectionService { @@ -27,9 +26,7 @@ public class ConnectionService { ConnectionProvider provider = providerRegistry.get(request.serviceType()); - if (provider == null) { - throw new ProviderNotFoundException(); - } + provider.checkCredentials(request); ConnectionResponse response = provider.authenticate(request); 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 fa7117f..051ea85 100644 --- a/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java @@ -1,7 +1,9 @@ package com.vaessl.app.connection; import java.time.Instant; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.springframework.stereotype.Component; @@ -9,6 +11,7 @@ import org.springframework.web.client.RestClient; import com.vaessl.app.dto.ConnectionRequest; import com.vaessl.app.dto.ConnectionResponse; +import com.vaessl.app.exception.EmptyCredentialsException; import static com.vaessl.app.connection.Endpoint.*; @@ -24,6 +27,22 @@ public class HomeBoxConnectionProvider implements ConnectionProvider { this.cRepository = cRepository; } + @Override + public void checkCredentials(ConnectionRequest request) { + if (request.username() == null || request.password() == null) { + List missingFields = new ArrayList<>(); + + if (request.username() == null) { + missingFields.add("username"); + } + if (request.password() == null) { + missingFields.add("password"); + + } + throw new EmptyCredentialsException(List.copyOf(missingFields)); + } + } + @Override public String getServiceType() { return "HOMEBOX"; diff --git a/backend/src/main/java/com/vaessl/app/exception/EmptyCredentialsException.java b/backend/src/main/java/com/vaessl/app/exception/EmptyCredentialsException.java new file mode 100644 index 0000000..62acdc6 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/exception/EmptyCredentialsException.java @@ -0,0 +1,13 @@ +package com.vaessl.app.exception; + +import java.util.List; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class EmptyCredentialsException extends RuntimeException { + + private final List missingFields; +} 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 8bf462b..4e8dd15 100644 --- a/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ package com.vaessl.app.exception; import org.springframework.http.ProblemDetail; +import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -10,13 +11,18 @@ import org.springframework.web.client.ResourceAccessException; import static com.vaessl.app.exception.ErrorMessage.*; +import java.util.stream.Collectors; + @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) public ProblemDetail handleEmptyCredentialInput(MethodArgumentNotValidException e) { + + 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()); + BAD_REQUEST_EMPTY_FIELDS.getMessage() + " [" + defaultMessages + "]"); } @ExceptionHandler(HttpClientErrorException.Unauthorized.class) @@ -41,8 +47,9 @@ public class GlobalExceptionHandler { SERVER_ERROR_GENERAL.getMessage() + e.getStatusText()); } - @ExceptionHandler(ProviderNotFoundException.class) - public ProblemDetail handleWrongServiceType(ProviderNotFoundException e) { - return ProblemDetail.forStatusAndDetail(WRONG_SERVICE_TYPE.getStatus(), WRONG_SERVICE_TYPE.getMessage()); + @ExceptionHandler(EmptyCredentialsException.class) + public ProblemDetail handleEmptyCredentials(EmptyCredentialsException e) { + return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(), + BAD_REQUEST_EMPTY_FIELDS.getMessage() + " " + e.getMissingFields()); } } diff --git a/backend/src/main/java/com/vaessl/app/exception/ProviderNotFoundException.java b/backend/src/main/java/com/vaessl/app/exception/ProviderNotFoundException.java deleted file mode 100644 index a5d8200..0000000 --- a/backend/src/main/java/com/vaessl/app/exception/ProviderNotFoundException.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.vaessl.app.exception; - -public class ProviderNotFoundException extends RuntimeException { - -} -- 2.52.0 From c553735653884ebba30318cfdc24643f1fcee431 Mon Sep 17 00:00:00 2001 From: kasun Date: Wed, 8 Apr 2026 22:31:36 +0200 Subject: [PATCH 25/32] removed main test --- .../test/java/com/vaessl/app/ApplicationTests.java | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/backend/src/test/java/com/vaessl/app/ApplicationTests.java b/backend/src/test/java/com/vaessl/app/ApplicationTests.java index c608989..b2954e9 100644 --- a/backend/src/test/java/com/vaessl/app/ApplicationTests.java +++ b/backend/src/test/java/com/vaessl/app/ApplicationTests.java @@ -9,10 +9,8 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; -import org.springframework.context.ApplicationContext; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertNotNull; @SpringBootTest @ActiveProfiles("test") @@ -21,9 +19,6 @@ class ApplicationTests { @Autowired private DataSource dataSource; - @Autowired - private ApplicationContext context; - @Test void contextLoads() { } @@ -34,10 +29,4 @@ class ApplicationTests { assertThat(connection.getMetaData().getURL()).contains("vaessl_test"); } } - - @Test - void main() { - Application.main(new String[] {}); - assertNotNull(context, "The Spring context should not be null"); - } } -- 2.52.0 From 00bb929f22259908fccc307f14a07bd0d2da4414 Mon Sep 17 00:00:00 2001 From: kasun Date: Wed, 8 Apr 2026 22:32:06 +0200 Subject: [PATCH 26/32] format --- .../java/com/vaessl/app/exception/GlobalExceptionHandler.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 4e8dd15..254e6bf 100644 --- a/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java @@ -19,7 +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 + "]"); -- 2.52.0 From ef4ee70aac965cb9225f81500651e71b02c7721f Mon Sep 17 00:00:00 2001 From: kasun Date: Wed, 8 Apr 2026 22:33:04 +0200 Subject: [PATCH 27/32] added WrongServiceTypeException --- .../java/com/vaessl/app/connection/ConnectionService.java | 5 +++++ .../src/main/java/com/vaessl/app/exception/ErrorMessage.java | 2 +- .../com/vaessl/app/exception/GlobalExceptionHandler.java | 5 +++++ .../com/vaessl/app/exception/WrongServiceTypeException.java | 4 ++++ .../com/vaessl/app/connection/HomeboxIntegrationTest.java | 4 ++-- 5 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 backend/src/main/java/com/vaessl/app/exception/WrongServiceTypeException.java 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 bfd2ace..f68edf7 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 org.springframework.stereotype.Service; import com.vaessl.app.dto.ConnectionRequest; import com.vaessl.app.dto.ConnectionResponse; +import com.vaessl.app.exception.WrongServiceTypeException; @Service public class ConnectionService { @@ -26,6 +27,10 @@ public class ConnectionService { ConnectionProvider provider = providerRegistry.get(request.serviceType()); + if (provider == null) { + throw new WrongServiceTypeException(); + } + provider.checkCredentials(request); ConnectionResponse response = provider.authenticate(request); 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 2d6f693..ae14081 100644 --- a/backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java +++ b/backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java @@ -8,7 +8,7 @@ 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.BAD_REQUEST, + "The external app returned a server error: "), WRONG_SERVICE_TYPE(HttpStatus.NOT_FOUND, "No such service type."); private final HttpStatus status; 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 254e6bf..08994c0 100644 --- a/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java @@ -48,6 +48,11 @@ public class GlobalExceptionHandler { 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()); + } + @ExceptionHandler(EmptyCredentialsException.class) public ProblemDetail handleEmptyCredentials(EmptyCredentialsException e) { return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(), diff --git a/backend/src/main/java/com/vaessl/app/exception/WrongServiceTypeException.java b/backend/src/main/java/com/vaessl/app/exception/WrongServiceTypeException.java new file mode 100644 index 0000000..c2f72fa --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/exception/WrongServiceTypeException.java @@ -0,0 +1,4 @@ +package com.vaessl.app.exception; + +public class WrongServiceTypeException extends RuntimeException { +} 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 1ba9122..b22037a 100644 --- a/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java @@ -145,7 +145,7 @@ class HomeboxIntegrationTest { * Test the custom ProviderNotFound exception. */ @Test - void shouldReturnProviderNotFound() { + void shouldReturnWrongServiceTypeException() { ConnectionRequest wrongServiceTypeReq = new ConnectionRequest( MOCK_URL, "wrong-service-type", @@ -155,7 +155,7 @@ class HomeboxIntegrationTest { ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), wrongServiceTypeReq, String.class); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); assertThat(response.getBody()).contains(WRONG_SERVICE_TYPE.getMessage()); } -- 2.52.0 From 3e80b81e07b21aa3c4e382cd4421ba919dbec5f0 Mon Sep 17 00:00:00 2001 From: kasun Date: Wed, 8 Apr 2026 22:35:59 +0200 Subject: [PATCH 28/32] added comment --- .../java/com/vaessl/app/connection/HomeboxIntegrationTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 b22037a..c5cc576 100644 --- a/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java @@ -142,7 +142,8 @@ class HomeboxIntegrationTest { } /** - * Test the custom ProviderNotFound exception. + * Test the exception when there is an input for serviceType but it's + * unsupported. */ @Test void shouldReturnWrongServiceTypeException() { -- 2.52.0 From a5c1931a2aaabaa7564753680c2f3a9319d532ac Mon Sep 17 00:00:00 2001 From: kasun Date: Thu, 9 Apr 2026 21:09:27 +0200 Subject: [PATCH 29/32] removed apikey variable from homebox test --- .../java/com/vaessl/app/connection/HomeboxIntegrationTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c5cc576..4efd7ac 100644 --- a/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java @@ -118,7 +118,7 @@ class HomeboxIntegrationTest { ConnectionRequest badRequest = new ConnectionRequest( MOCK_URL, HOMEBOX.getValue(), - TEST_USER, TEST_PASS, null, + TEST_USER, TEST_PASS, false); ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), badRequest, String.class); -- 2.52.0 From 7de38afec27e1c715945be320c1d05edfabdf8b8 Mon Sep 17 00:00:00 2001 From: kasun Date: Thu, 9 Apr 2026 21:11:17 +0200 Subject: [PATCH 30/32] added ConnectionService and HomeboxProvider tests --- .../app/connection/ConnectionServiceTest.java | 50 +++++++++++++++++++ .../HomeBoxConnectionProviderTest.java | 27 ++++++++++ .../com/vaessl/app/connection/Mockdata.java | 9 ++++ 3 files changed, 86 insertions(+) create mode 100644 backend/src/test/java/com/vaessl/app/connection/ConnectionServiceTest.java create mode 100644 backend/src/test/java/com/vaessl/app/connection/HomeBoxConnectionProviderTest.java create mode 100644 backend/src/test/java/com/vaessl/app/connection/Mockdata.java diff --git a/backend/src/test/java/com/vaessl/app/connection/ConnectionServiceTest.java b/backend/src/test/java/com/vaessl/app/connection/ConnectionServiceTest.java new file mode 100644 index 0000000..2c626f9 --- /dev/null +++ b/backend/src/test/java/com/vaessl/app/connection/ConnectionServiceTest.java @@ -0,0 +1,50 @@ +package com.vaessl.app.connection; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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.*; + +@ExtendWith(MockitoExtension.class) +class ConnectionServiceTest { + + @Mock + private ConnectionProvider mockProvider; + @Mock + private ConnectionRepository mockRepo; + + private ConnectionService connectionService; + + @BeforeEach + void setup() { + when(mockProvider.getServiceType()).thenReturn(MOCK_SERVICE_TYPE); + connectionService = new ConnectionService(List.of(mockProvider), mockRepo); + } + + @Test + void login_ShouldAbort_WhenCheckCredentialsThrowsException() { + ConnectionRequest request = new ConnectionRequest(MOCK_URL, MOCK_SERVICE_TYPE, null, null, false); + + 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/HomeBoxConnectionProviderTest.java b/backend/src/test/java/com/vaessl/app/connection/HomeBoxConnectionProviderTest.java new file mode 100644 index 0000000..eaaca00 --- /dev/null +++ b/backend/src/test/java/com/vaessl/app/connection/HomeBoxConnectionProviderTest.java @@ -0,0 +1,27 @@ +package com.vaessl.app.connection; + +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; +import static com.vaessl.app.connection.Mockdata.*; + +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); + + EmptyCredentialsException exception = assertThrows(EmptyCredentialsException.class, () -> { + provider.checkCredentials(request); + }); + + assertThat(exception.getMissingFields()).containsExactly("username", "password"); + } +} diff --git a/backend/src/test/java/com/vaessl/app/connection/Mockdata.java b/backend/src/test/java/com/vaessl/app/connection/Mockdata.java new file mode 100644 index 0000000..657e15c --- /dev/null +++ b/backend/src/test/java/com/vaessl/app/connection/Mockdata.java @@ -0,0 +1,9 @@ +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"; +} -- 2.52.0 From a1ebb1be21d71f2592b36965aec88b75c2f2127f Mon Sep 17 00:00:00 2001 From: kasun Date: Thu, 9 Apr 2026 21:18:40 +0200 Subject: [PATCH 31/32] format --- backend/src/main/java/com/vaessl/app/connection/Endpoint.java | 1 - 1 file changed, 1 deletion(-) 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 6aec4e5..1056771 100644 --- a/backend/src/main/java/com/vaessl/app/connection/Endpoint.java +++ b/backend/src/main/java/com/vaessl/app/connection/Endpoint.java @@ -1,6 +1,5 @@ package com.vaessl.app.connection; - public enum Endpoint { HOMEBOX_LOGIN("/api/v1/users/login"), LOGIN("/login"); -- 2.52.0 From 01fa3e4267324543edf133dfaef0b0296fcaf3a4 Mon Sep 17 00:00:00 2001 From: kasun Date: Thu, 9 Apr 2026 21:18:52 +0200 Subject: [PATCH 32/32] added integration test for missing creds --- .../app/connection/HomeboxIntegrationTest.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 4efd7ac..8646120 100644 --- a/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java @@ -193,6 +193,18 @@ class HomeboxIntegrationTest { } } + @Test + void shouldReturnEmptyCredentialsExceptionWhenCredsAreMissing() { + ConnectionRequest missingCredentials = new ConnectionRequest(MOCK_URL, HOMEBOX.getValue(), TEST_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 throuh * WireMockRuntimeInfo. -- 2.52.0