diff --git a/.github/workflows/test-build-push.yml b/.github/workflows/test-build-push.yml index bae2a28..f25daed 100644 --- a/.github/workflows/test-build-push.yml +++ b/.github/workflows/test-build-push.yml @@ -30,6 +30,9 @@ jobs: distribution: 'temurin' cache: maven + - name: Check formatting with Spotless + run: cd server/${{ matrix.service }} && ./mvnw -B spotless:check + - name: Run tests with Maven run: cd server/${{ matrix.service }} && ./mvnw -B test @@ -50,6 +53,9 @@ jobs: - name: Install dependencies run: cd client && npm ci + - name: Lint with ESLint + run: cd client && npm run lint + - name: Run tests run: cd client && npm test @@ -70,6 +76,9 @@ jobs: - name: Install dependencies run: pip install -r gen-ai/requirements-dev.txt + - name: Lint with Ruff + run: ruff check gen-ai + - name: Run tests with pytest run: pytest gen-ai diff --git a/README.md b/README.md index e831651..1c11394 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,22 @@ not rerun the Java tests. --- +### Linting / Static Analysis + +Every service is linted in CI (`Test, Build and Push Images`) as a **blocking gate** — +a lint failure fails the build and prevents images from being pushed or deployed. + +| Service | Tool | Run locally | +|---------|------|-------------| +| `client` | ESLint | `cd client && npm run lint` | +| `gen-ai` | [Ruff](https://docs.astral.sh/ruff/) | `cd gen-ai && ruff check .` | +| `api-gateway`, `user-service`, `grocery-service` | [Spotless](https://github.com/diffplug/spotless) (google-java-format) | `cd server/ && ./mvnw spotless:check` | + +For the Java services, auto-format any violations with `./mvnw spotless:apply`. +Ruff config lives in `gen-ai/pyproject.toml`; ESLint config in `client/eslint.config.js`. + +--- + ### Docker Requires Docker Desktop running. diff --git a/gen-ai/pyproject.toml b/gen-ai/pyproject.toml new file mode 100644 index 0000000..edc4f4f --- /dev/null +++ b/gen-ai/pyproject.toml @@ -0,0 +1,12 @@ +[tool.ruff] +target-version = "py312" +line-length = 100 + +[tool.ruff.lint] +# E = pycodestyle errors, F = pyflakes, I = import sorting. +# Kept intentionally small so it flags real problems (unused imports, undefined +# names, import order, obvious style errors) without drowning the service in noise. +select = ["E", "F", "I"] +# E501 (line-too-long) is not enforced: the LLM system prompts are long, intentional +# string literals where wrapping would change the text actually sent to the model. +ignore = ["E501"] diff --git a/gen-ai/requirements-dev.txt b/gen-ai/requirements-dev.txt index 602c941..4a24579 100644 --- a/gen-ai/requirements-dev.txt +++ b/gen-ai/requirements-dev.txt @@ -2,3 +2,4 @@ pytest pytest-cov httpx +ruff diff --git a/gen-ai/tests/test_endpoint_merge.py b/gen-ai/tests/test_endpoint_merge.py index 8f85b2c..b859cba 100644 --- a/gen-ai/tests/test_endpoint_merge.py +++ b/gen-ai/tests/test_endpoint_merge.py @@ -1,7 +1,6 @@ import json from main import LOGOS_BASE_URL, LOGOS_MODEL, NO_LLM_NOTE - from tests.conftest import _fake_response diff --git a/gen-ai/tests/test_endpoint_parse.py b/gen-ai/tests/test_endpoint_parse.py index 79c409e..7d5f2dc 100644 --- a/gen-ai/tests/test_endpoint_parse.py +++ b/gen-ai/tests/test_endpoint_parse.py @@ -1,7 +1,6 @@ import json from main import CANNED_INGREDIENTS, LOGOS_BASE_URL, LOGOS_MODEL, NO_LLM_NOTE, OPENAI_MODEL - from tests.conftest import _fake_response diff --git a/server/api-gateway/pom.xml b/server/api-gateway/pom.xml index 666cb98..f71d152 100644 --- a/server/api-gateway/pom.xml +++ b/server/api-gateway/pom.xml @@ -95,6 +95,16 @@ -Djdk.attach.allowAttachSelf=true + + com.diffplug.spotless + spotless-maven-plugin + 2.44.5 + + + + + + diff --git a/server/api-gateway/src/main/java/com/bytebite/server/JwtAuthenticationFilter.java b/server/api-gateway/src/main/java/com/bytebite/server/JwtAuthenticationFilter.java index 4b5c2e8..d8b2861 100644 --- a/server/api-gateway/src/main/java/com/bytebite/server/JwtAuthenticationFilter.java +++ b/server/api-gateway/src/main/java/com/bytebite/server/JwtAuthenticationFilter.java @@ -1,5 +1,12 @@ package com.bytebite.server; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; import org.springframework.beans.factory.annotation.Value; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GlobalFilter; @@ -11,124 +18,118 @@ import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; -import java.time.Instant; -import java.util.Base64; -import java.util.LinkedHashMap; -import java.util.Map; - @Component public class JwtAuthenticationFilter implements GlobalFilter, Ordered { - private static final Base64.Decoder BASE64_URL_DECODER = Base64.getUrlDecoder(); - private static final Base64.Encoder BASE64_URL_ENCODER = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder BASE64_URL_DECODER = Base64.getUrlDecoder(); + private static final Base64.Encoder BASE64_URL_ENCODER = Base64.getUrlEncoder().withoutPadding(); - private final byte[] secret; + private final byte[] secret; - public JwtAuthenticationFilter(@Value("${auth.jwt.secret}") String secret) { - if (secret == null || secret.length() < 32) { - throw new IllegalStateException("JWT secret must be at least 32 characters."); - } - this.secret = secret.getBytes(StandardCharsets.UTF_8); + public JwtAuthenticationFilter(@Value("${auth.jwt.secret}") String secret) { + if (secret == null || secret.length() < 32) { + throw new IllegalStateException("JWT secret must be at least 32 characters."); + } + this.secret = secret.getBytes(StandardCharsets.UTF_8); + } + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + String path = exchange.getRequest().getURI().getPath(); + if (!path.startsWith("/api/") || path.startsWith("/api/auth/")) { + return chain.filter(exchange); } - @Override - public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { - String path = exchange.getRequest().getURI().getPath(); - if (!path.startsWith("/api/") || path.startsWith("/api/auth/")) { - return chain.filter(exchange); - } - - String authorization = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION); - JwtPayload payload = verify(authorization); - if (payload == null) { - exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); - return exchange.getResponse().setComplete(); - } + String authorization = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION); + JwtPayload payload = verify(authorization); + if (payload == null) { + exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); + return exchange.getResponse().setComplete(); + } - // Use set() so any client-supplied X-User-* headers are overwritten, never trusted. - ServerHttpRequest request = exchange.getRequest().mutate() - .headers(headers -> { - headers.set("X-User-Id", payload.userId()); - headers.set("X-User-Email", payload.email()); + // Use set() so any client-supplied X-User-* headers are overwritten, never trusted. + ServerHttpRequest request = + exchange + .getRequest() + .mutate() + .headers( + headers -> { + headers.set("X-User-Id", payload.userId()); + headers.set("X-User-Email", payload.email()); }) - .build(); - return chain.filter(exchange.mutate().request(request).build()); + .build(); + return chain.filter(exchange.mutate().request(request).build()); + } + + @Override + public int getOrder() { + return -100; + } + + private JwtPayload verify(String authorizationHeader) { + if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { + return null; } - - @Override - public int getOrder() { - return -100; + String token = authorizationHeader.substring("Bearer ".length()).trim(); + String[] parts = token.split("\\."); + if (parts.length != 3) { + return null; } - - private JwtPayload verify(String authorizationHeader) { - if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { - return null; - } - String token = authorizationHeader.substring("Bearer ".length()).trim(); - String[] parts = token.split("\\."); - if (parts.length != 3) { - return null; - } - String unsigned = parts[0] + "." + parts[1]; - if (!constantTimeEquals(sign(unsigned), parts[2])) { - return null; - } - - Map payload = parseFlatJson(new String(BASE64_URL_DECODER.decode(parts[1]), StandardCharsets.UTF_8)); - long expiresAt = Long.parseLong(payload.getOrDefault("exp", "0")); - if (expiresAt <= Instant.now().getEpochSecond()) { - return null; - } - return new JwtPayload(payload.get("sub"), payload.get("email")); + String unsigned = parts[0] + "." + parts[1]; + if (!constantTimeEquals(sign(unsigned), parts[2])) { + return null; } - private String sign(String value) { - try { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(secret, "HmacSHA256")); - return BASE64_URL_ENCODER.encodeToString(mac.doFinal(value.getBytes(StandardCharsets.UTF_8))); - } catch (Exception exception) { - throw new IllegalStateException("Could not verify JWT.", exception); - } + Map payload = + parseFlatJson(new String(BASE64_URL_DECODER.decode(parts[1]), StandardCharsets.UTF_8)); + long expiresAt = Long.parseLong(payload.getOrDefault("exp", "0")); + if (expiresAt <= Instant.now().getEpochSecond()) { + return null; } - - private boolean constantTimeEquals(String expected, String actual) { - byte[] expectedBytes = expected.getBytes(StandardCharsets.UTF_8); - byte[] actualBytes = actual.getBytes(StandardCharsets.UTF_8); - if (expectedBytes.length != actualBytes.length) { - return false; - } - int result = 0; - for (int i = 0; i < expectedBytes.length; i++) { - result |= expectedBytes[i] ^ actualBytes[i]; - } - return result == 0; + return new JwtPayload(payload.get("sub"), payload.get("email")); + } + + private String sign(String value) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret, "HmacSHA256")); + return BASE64_URL_ENCODER.encodeToString(mac.doFinal(value.getBytes(StandardCharsets.UTF_8))); + } catch (Exception exception) { + throw new IllegalStateException("Could not verify JWT.", exception); } + } - private Map parseFlatJson(String json) { - Map values = new LinkedHashMap<>(); - String body = json.substring(1, json.length() - 1); - for (String pair : body.split(",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)")) { - String[] parts = pair.split(":", 2); - if (parts.length == 2) { - values.put(unquote(parts[0]), unquote(parts[1])); - } - } - return values; + private boolean constantTimeEquals(String expected, String actual) { + byte[] expectedBytes = expected.getBytes(StandardCharsets.UTF_8); + byte[] actualBytes = actual.getBytes(StandardCharsets.UTF_8); + if (expectedBytes.length != actualBytes.length) { + return false; } - - private String unquote(String value) { - String trimmed = value.trim(); - if (trimmed.startsWith("\"") && trimmed.endsWith("\"")) { - return trimmed.substring(1, trimmed.length() - 1) - .replace("\\\"", "\"") - .replace("\\\\", "\\"); - } - return trimmed; + int result = 0; + for (int i = 0; i < expectedBytes.length; i++) { + result |= expectedBytes[i] ^ actualBytes[i]; + } + return result == 0; + } + + private Map parseFlatJson(String json) { + Map values = new LinkedHashMap<>(); + String body = json.substring(1, json.length() - 1); + for (String pair : body.split(",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)")) { + String[] parts = pair.split(":", 2); + if (parts.length == 2) { + values.put(unquote(parts[0]), unquote(parts[1])); + } } + return values; + } - private record JwtPayload(String userId, String email) { + private String unquote(String value) { + String trimmed = value.trim(); + if (trimmed.startsWith("\"") && trimmed.endsWith("\"")) { + return trimmed.substring(1, trimmed.length() - 1).replace("\\\"", "\"").replace("\\\\", "\\"); } + return trimmed; + } + + private record JwtPayload(String userId, String email) {} } diff --git a/server/api-gateway/src/main/java/com/bytebite/server/ServerApplication.java b/server/api-gateway/src/main/java/com/bytebite/server/ServerApplication.java index 13d5438..6c431e6 100644 --- a/server/api-gateway/src/main/java/com/bytebite/server/ServerApplication.java +++ b/server/api-gateway/src/main/java/com/bytebite/server/ServerApplication.java @@ -6,8 +6,7 @@ @SpringBootApplication public class ServerApplication { - public static void main(String[] args) { - SpringApplication.run(ServerApplication.class, args); - } - + public static void main(String[] args) { + SpringApplication.run(ServerApplication.class, args); + } } diff --git a/server/api-gateway/src/test/java/com/bytebite/server/JwtAuthenticationFilterTest.java b/server/api-gateway/src/test/java/com/bytebite/server/JwtAuthenticationFilterTest.java index 256b612..8badae2 100644 --- a/server/api-gateway/src/test/java/com/bytebite/server/JwtAuthenticationFilterTest.java +++ b/server/api-gateway/src/test/java/com/bytebite/server/JwtAuthenticationFilterTest.java @@ -1,5 +1,14 @@ package com.bytebite.server; +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; import org.junit.jupiter.api.Test; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.http.HttpHeaders; @@ -9,103 +18,111 @@ import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; -import java.time.Instant; -import java.util.Base64; -import java.util.UUID; -import java.util.concurrent.atomic.AtomicReference; - -import static org.assertj.core.api.Assertions.assertThat; - class JwtAuthenticationFilterTest { - private static final String SECRET = "test-secret-with-at-least-32-chars"; - private final JwtAuthenticationFilter filter = new JwtAuthenticationFilter(SECRET); - - @Test - void publicAuthRoutesBypassJwtValidation() { - MockServerWebExchange exchange = MockServerWebExchange.from( - MockServerHttpRequest.post("/api/auth/login").build()); - CapturingChain chain = new CapturingChain(); - - filter.filter(exchange, chain).block(); - - assertThat(chain.exchange()).isSameAs(exchange); - assertThat(exchange.getResponse().getStatusCode()).isNull(); - } - - @Test - void protectedApiRejectsMissingBearerToken() { - MockServerWebExchange exchange = MockServerWebExchange.from( - MockServerHttpRequest.get("/api/grocery-list").build()); - - filter.filter(exchange, new CapturingChain()).block(); - - assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + private static final String SECRET = "test-secret-with-at-least-32-chars"; + private final JwtAuthenticationFilter filter = new JwtAuthenticationFilter(SECRET); + + @Test + void publicAuthRoutesBypassJwtValidation() { + MockServerWebExchange exchange = + MockServerWebExchange.from(MockServerHttpRequest.post("/api/auth/login").build()); + CapturingChain chain = new CapturingChain(); + + filter.filter(exchange, chain).block(); + + assertThat(chain.exchange()).isSameAs(exchange); + assertThat(exchange.getResponse().getStatusCode()).isNull(); + } + + @Test + void protectedApiRejectsMissingBearerToken() { + MockServerWebExchange exchange = + MockServerWebExchange.from(MockServerHttpRequest.get("/api/grocery-list").build()); + + filter.filter(exchange, new CapturingChain()).block(); + + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void protectedApiInjectsUserHeadersFromValidJwtAndOverwritesClientValues() { + UUID userId = UUID.randomUUID(); + MockServerWebExchange exchange = + MockServerWebExchange.from( + MockServerHttpRequest.get("/api/grocery-list") + .header( + HttpHeaders.AUTHORIZATION, "Bearer " + token(userId, "ada@example.com", 3600)) + .header("X-User-Id", "attacker") + .build()); + CapturingChain chain = new CapturingChain(); + + filter.filter(exchange, chain).block(); + + assertThat(chain.exchange().getRequest().getHeaders().getFirst("X-User-Id")) + .isEqualTo(userId.toString()); + assertThat(chain.exchange().getRequest().getHeaders().getFirst("X-User-Email")) + .isEqualTo("ada@example.com"); + } + + @Test + void expiredJwtIsRejected() { + MockServerWebExchange exchange = + MockServerWebExchange.from( + MockServerHttpRequest.get("/api/grocery-list") + .header( + HttpHeaders.AUTHORIZATION, + "Bearer " + token(UUID.randomUUID(), "ada@example.com", -1)) + .build()); + + filter.filter(exchange, new CapturingChain()).block(); + + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + private static String token(UUID userId, String email, long expiresInSeconds) { + long now = Instant.now().getEpochSecond(); + String header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"; + String payload = + "{\"sub\":\"" + + userId + + "\",\"email\":\"" + + email + + "\",\"exp\":" + + (now + expiresInSeconds) + + "}"; + String unsigned = encode(header) + "." + encode(payload); + return unsigned + "." + sign(unsigned); + } + + private static String encode(String value) { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(value.getBytes(StandardCharsets.UTF_8)); + } + + private static String sign(String value) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(mac.doFinal(value.getBytes(StandardCharsets.UTF_8))); + } catch (Exception exception) { + throw new IllegalStateException(exception); } + } - @Test - void protectedApiInjectsUserHeadersFromValidJwtAndOverwritesClientValues() { - UUID userId = UUID.randomUUID(); - MockServerWebExchange exchange = MockServerWebExchange.from( - MockServerHttpRequest.get("/api/grocery-list") - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token(userId, "ada@example.com", 3600)) - .header("X-User-Id", "attacker") - .build()); - CapturingChain chain = new CapturingChain(); - - filter.filter(exchange, chain).block(); + private static final class CapturingChain implements GatewayFilterChain { + private final AtomicReference exchange = new AtomicReference<>(); - assertThat(chain.exchange().getRequest().getHeaders().getFirst("X-User-Id")).isEqualTo(userId.toString()); - assertThat(chain.exchange().getRequest().getHeaders().getFirst("X-User-Email")).isEqualTo("ada@example.com"); + @Override + public Mono filter(ServerWebExchange exchange) { + this.exchange.set(exchange); + return Mono.empty(); } - @Test - void expiredJwtIsRejected() { - MockServerWebExchange exchange = MockServerWebExchange.from( - MockServerHttpRequest.get("/api/grocery-list") - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token(UUID.randomUUID(), "ada@example.com", -1)) - .build()); - - filter.filter(exchange, new CapturingChain()).block(); - - assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - } - - private static String token(UUID userId, String email, long expiresInSeconds) { - long now = Instant.now().getEpochSecond(); - String header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"; - String payload = "{\"sub\":\"" + userId + "\",\"email\":\"" + email + "\",\"exp\":" + (now + expiresInSeconds) + "}"; - String unsigned = encode(header) + "." + encode(payload); - return unsigned + "." + sign(unsigned); - } - - private static String encode(String value) { - return Base64.getUrlEncoder().withoutPadding().encodeToString(value.getBytes(StandardCharsets.UTF_8)); - } - - private static String sign(String value) { - try { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); - return Base64.getUrlEncoder().withoutPadding().encodeToString(mac.doFinal(value.getBytes(StandardCharsets.UTF_8))); - } catch (Exception exception) { - throw new IllegalStateException(exception); - } - } - - private static final class CapturingChain implements GatewayFilterChain { - private final AtomicReference exchange = new AtomicReference<>(); - - @Override - public Mono filter(ServerWebExchange exchange) { - this.exchange.set(exchange); - return Mono.empty(); - } - - private ServerWebExchange exchange() { - return exchange.get(); - } + private ServerWebExchange exchange() { + return exchange.get(); } + } } diff --git a/server/api-gateway/src/test/java/com/bytebite/server/ServerApplicationTests.java b/server/api-gateway/src/test/java/com/bytebite/server/ServerApplicationTests.java index 2d6d85b..e16ea95 100644 --- a/server/api-gateway/src/test/java/com/bytebite/server/ServerApplicationTests.java +++ b/server/api-gateway/src/test/java/com/bytebite/server/ServerApplicationTests.java @@ -6,8 +6,6 @@ @SpringBootTest class ServerApplicationTests { - @Test - void contextLoads() { - } - + @Test + void contextLoads() {} } diff --git a/server/grocery-service/pom.xml b/server/grocery-service/pom.xml index 6def0c1..9b4aea6 100644 --- a/server/grocery-service/pom.xml +++ b/server/grocery-service/pom.xml @@ -104,6 +104,16 @@ -Djdk.attach.allowAttachSelf=true + + com.diffplug.spotless + spotless-maven-plugin + 2.44.5 + + + + + + diff --git a/server/grocery-service/src/main/java/com/bytebite/server/GenAiClient.java b/server/grocery-service/src/main/java/com/bytebite/server/GenAiClient.java index d79fc41..d2a20d9 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/GenAiClient.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/GenAiClient.java @@ -9,10 +9,10 @@ @Configuration public class GenAiClient { - @Bean - public RestTemplate genAiRestTemplate(@Value("${genai.base-url}") String baseUrl) { - RestTemplate template = new RestTemplate(); - template.setUriTemplateHandler(new DefaultUriBuilderFactory(baseUrl)); - return template; - } + @Bean + public RestTemplate genAiRestTemplate(@Value("${genai.base-url}") String baseUrl) { + RestTemplate template = new RestTemplate(); + template.setUriTemplateHandler(new DefaultUriBuilderFactory(baseUrl)); + return template; + } } diff --git a/server/grocery-service/src/main/java/com/bytebite/server/GenerateController.java b/server/grocery-service/src/main/java/com/bytebite/server/GenerateController.java index 192f008..dd4f308 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/GenerateController.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/GenerateController.java @@ -9,6 +9,9 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -27,80 +30,96 @@ import org.springframework.web.client.RestTemplate; import org.springframework.web.server.ResponseStatusException; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - @RestController @Tag(name = "Recipes", description = "Recipe-to-grocery-list generation") public class GenerateController { - private final RestTemplate genAiRestTemplate; - - public GenerateController(RestTemplate genAiRestTemplate) { - this.genAiRestTemplate = genAiRestTemplate; - } + private final RestTemplate genAiRestTemplate; - @PostMapping(value = "/api/recipes/generate", - consumes = MediaType.APPLICATION_JSON_VALUE, - produces = MediaType.APPLICATION_JSON_VALUE) - @Operation( - summary = "Generate a grocery list from a dish or recipe", - description = "Delegates to the Gen AI service and returns categorized ingredients with dietary restriction flags.", - security = @SecurityRequirement(name = "bearerAuth"), - responses = { - @ApiResponse(responseCode = "200", description = "Generated grocery list"), - @ApiResponse(responseCode = "401", description = "Missing, expired, or invalid JWT", content = @Content), - @ApiResponse(responseCode = "422", description = "AI service returned no ingredients", content = @Content(schema = @Schema(implementation = Map.class))), - @ApiResponse(responseCode = "502", description = "AI service rejected or failed the request", content = @Content(schema = @Schema(implementation = Map.class))), - @ApiResponse(responseCode = "503", description = "AI service is unreachable", content = @Content(schema = @Schema(implementation = Map.class))) - } - ) - public RecipeResponseDTO generate(@RequestBody GenerateRequest request) { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - Map body = new HashMap<>(); - body.put("dish", request.dish()); - body.put("dietary_restrictions", request.dietaryRestrictions() != null ? request.dietaryRestrictions() : List.of()); - body.put("llm_provider", request.llmProvider() != null ? request.llmProvider() : "logos"); - HttpEntity> entity = new HttpEntity<>(body, headers); + public GenerateController(RestTemplate genAiRestTemplate) { + this.genAiRestTemplate = genAiRestTemplate; + } - RecipeResponseDTO response; - try { - response = genAiRestTemplate.postForObject("/api/ai/parse", entity, RecipeResponseDTO.class); - } catch (HttpClientErrorException e) { - throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, - "AI service rejected the request: " + e.getMessage()); - } catch (HttpServerErrorException e) { - throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, - "AI service encountered an error: " + e.getMessage()); - } catch (ResourceAccessException e) { - throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, - "AI service is unreachable"); - } + @PostMapping( + value = "/api/recipes/generate", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "Generate a grocery list from a dish or recipe", + description = + "Delegates to the Gen AI service and returns categorized ingredients with dietary restriction flags.", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "200", description = "Generated grocery list"), + @ApiResponse( + responseCode = "401", + description = "Missing, expired, or invalid JWT", + content = @Content), + @ApiResponse( + responseCode = "422", + description = "AI service returned no ingredients", + content = @Content(schema = @Schema(implementation = Map.class))), + @ApiResponse( + responseCode = "502", + description = "AI service rejected or failed the request", + content = @Content(schema = @Schema(implementation = Map.class))), + @ApiResponse( + responseCode = "503", + description = "AI service is unreachable", + content = @Content(schema = @Schema(implementation = Map.class))) + }) + public RecipeResponseDTO generate(@RequestBody GenerateRequest request) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + Map body = new HashMap<>(); + body.put("dish", request.dish()); + body.put( + "dietary_restrictions", + request.dietaryRestrictions() != null ? request.dietaryRestrictions() : List.of()); + body.put("llm_provider", request.llmProvider() != null ? request.llmProvider() : "logos"); + HttpEntity> entity = new HttpEntity<>(body, headers); - if (response == null || response.ingredients() == null || response.ingredients().isEmpty()) { - throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, - "AI service returned no ingredients for the given input"); - } + RecipeResponseDTO response; + try { + response = genAiRestTemplate.postForObject("/api/ai/parse", entity, RecipeResponseDTO.class); + } catch (HttpClientErrorException e) { + throw new ResponseStatusException( + HttpStatus.BAD_GATEWAY, "AI service rejected the request: " + e.getMessage()); + } catch (HttpServerErrorException e) { + throw new ResponseStatusException( + HttpStatus.BAD_GATEWAY, "AI service encountered an error: " + e.getMessage()); + } catch (ResourceAccessException e) { + throw new ResponseStatusException( + HttpStatus.SERVICE_UNAVAILABLE, "AI service is unreachable"); + } - return response; + if (response == null || response.ingredients() == null || response.ingredients().isEmpty()) { + throw new ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "AI service returned no ingredients for the given input"); } - @GetMapping(value = "/api/recipes/providers", produces = MediaType.APPLICATION_JSON_VALUE) - @Operation( - summary = "Check which LLM providers are available", - description = "Reports whether the OpenAI provider is configured on the AI service, so the client can hide the option otherwise. Logos is always required and assumed available." - ) - public ProviderAvailabilityDTO providers() { - try { - ResponseEntity> health = genAiRestTemplate.exchange( - "/health", HttpMethod.GET, null, new ParameterizedTypeReference>() {}); - Map body = health.getBody(); - Object openaiAvailable = body != null ? body.get("openai_available") : null; - return new ProviderAvailabilityDTO(Boolean.TRUE.equals(openaiAvailable)); - } catch (RestClientException e) { - return new ProviderAvailabilityDTO(false); - } + return response; + } + + @GetMapping(value = "/api/recipes/providers", produces = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "Check which LLM providers are available", + description = + "Reports whether the OpenAI provider is configured on the AI service, so the client can hide the option otherwise. Logos is always required and assumed available.") + public ProviderAvailabilityDTO providers() { + try { + ResponseEntity> health = + genAiRestTemplate.exchange( + "/health", + HttpMethod.GET, + null, + new ParameterizedTypeReference>() {}); + Map body = health.getBody(); + Object openaiAvailable = body != null ? body.get("openai_available") : null; + return new ProviderAvailabilityDTO(Boolean.TRUE.equals(openaiAvailable)); + } catch (RestClientException e) { + return new ProviderAvailabilityDTO(false); } + } } diff --git a/server/grocery-service/src/main/java/com/bytebite/server/GroceryListController.java b/server/grocery-service/src/main/java/com/bytebite/server/GroceryListController.java index c48c1d4..cc262d0 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/GroceryListController.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/GroceryListController.java @@ -15,10 +15,13 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.UUID; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.web.server.ResponseStatusException; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -29,177 +32,229 @@ import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; -import java.net.URI; -import java.util.List; -import java.util.Map; -import java.util.UUID; - @RestController @RequestMapping("/api/grocery-list") @Tag(name = "Grocery Lists", description = "Stored grocery-list history and details") public class GroceryListController { - private final GroceryListService service; - private final GroceryListMergeService mergeService; + private final GroceryListService service; + private final GroceryListMergeService mergeService; - public GroceryListController(GroceryListService service, GroceryListMergeService mergeService) { - this.service = service; - this.mergeService = mergeService; - } + public GroceryListController(GroceryListService service, GroceryListMergeService mergeService) { + this.service = service; + this.mergeService = mergeService; + } - @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) - @Operation( - summary = "List grocery lists", - description = "Returns a summary of all the caller's grocery lists, newest first.", - security = @SecurityRequirement(name = "bearerAuth"), - responses = { - @ApiResponse(responseCode = "200", description = "Grocery-list summaries"), - @ApiResponse(responseCode = "401", description = "Missing, expired, or invalid JWT", content = @Content) - } - ) - public List list( - @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId) { - return service.getAll(userId); - } + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "List grocery lists", + description = "Returns a summary of all the caller's grocery lists, newest first.", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "200", description = "Grocery-list summaries"), + @ApiResponse( + responseCode = "401", + description = "Missing, expired, or invalid JWT", + content = @Content) + }) + public List list( + @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId) { + return service.getAll(userId); + } - @GetMapping(value = "/{groceryListId}", produces = MediaType.APPLICATION_JSON_VALUE) - @Operation( - summary = "Get a grocery list by id", - description = "Returns a single grocery list including its items.", - security = @SecurityRequirement(name = "bearerAuth"), - responses = { - @ApiResponse(responseCode = "200", description = "Grocery list with items"), - @ApiResponse(responseCode = "401", description = "Missing, expired, or invalid JWT", content = @Content), - @ApiResponse(responseCode = "404", description = "Grocery list not found", content = @Content(schema = @Schema(implementation = Map.class))) - } - ) - public GroceryListDetailDTO getById( - @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, - @Parameter(description = "Identifier of the grocery list", required = true) - @PathVariable UUID groceryListId) { - return service.getById(groceryListId, userId); - } + @GetMapping(value = "/{groceryListId}", produces = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "Get a grocery list by id", + description = "Returns a single grocery list including its items.", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "200", description = "Grocery list with items"), + @ApiResponse( + responseCode = "401", + description = "Missing, expired, or invalid JWT", + content = @Content), + @ApiResponse( + responseCode = "404", + description = "Grocery list not found", + content = @Content(schema = @Schema(implementation = Map.class))) + }) + public GroceryListDetailDTO getById( + @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, + @Parameter(description = "Identifier of the grocery list", required = true) @PathVariable + UUID groceryListId) { + return service.getById(groceryListId, userId); + } - @PostMapping( - consumes = MediaType.APPLICATION_JSON_VALUE, - produces = MediaType.APPLICATION_JSON_VALUE) - @Operation( - summary = "Create a grocery list", - description = "Persists a new grocery list with its items and returns the created resource.", - security = @SecurityRequirement(name = "bearerAuth"), - responses = { - @ApiResponse(responseCode = "201", description = "Grocery list created"), - @ApiResponse(responseCode = "400", description = "Invalid request body", content = @Content(schema = @Schema(implementation = Map.class))), - @ApiResponse(responseCode = "401", description = "Missing, expired, or invalid JWT", content = @Content) - } - ) - public ResponseEntity create( - @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, - @RequestBody GroceryListCreateRequest request) { - GroceryListDetailDTO created = service.create(userId, request); - URI location = ServletUriComponentsBuilder.fromCurrentRequest() - .path("/{id}") - .buildAndExpand(created.groceryListId()) - .toUri(); - return ResponseEntity.created(location).body(created); - } + @PostMapping( + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "Create a grocery list", + description = "Persists a new grocery list with its items and returns the created resource.", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "201", description = "Grocery list created"), + @ApiResponse( + responseCode = "400", + description = "Invalid request body", + content = @Content(schema = @Schema(implementation = Map.class))), + @ApiResponse( + responseCode = "401", + description = "Missing, expired, or invalid JWT", + content = @Content) + }) + public ResponseEntity create( + @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, + @RequestBody GroceryListCreateRequest request) { + GroceryListDetailDTO created = service.create(userId, request); + URI location = + ServletUriComponentsBuilder.fromCurrentRequest() + .path("/{id}") + .buildAndExpand(created.groceryListId()) + .toUri(); + return ResponseEntity.created(location).body(created); + } - @PostMapping(value = "/merge", - consumes = MediaType.APPLICATION_JSON_VALUE, - produces = MediaType.APPLICATION_JSON_VALUE) - @Operation( - summary = "Merge recipes into a new grocery list", - description = "Reads the selected recipes, asks the Gen AI service to deduplicate and sum their " - + "ingredients, then persists the result as a new grocery list linked to those recipes.", - security = @SecurityRequirement(name = "bearerAuth"), - responses = { - @ApiResponse(responseCode = "201", description = "Merged grocery list created"), - @ApiResponse(responseCode = "400", description = "No recipes provided", content = @Content(schema = @Schema(implementation = Map.class))), - @ApiResponse(responseCode = "401", description = "Missing, expired, or invalid JWT", content = @Content), - @ApiResponse(responseCode = "404", description = "One or more recipes not found", content = @Content(schema = @Schema(implementation = Map.class))), - @ApiResponse(responseCode = "422", description = "AI service returned no merged ingredients", content = @Content(schema = @Schema(implementation = Map.class))), - @ApiResponse(responseCode = "502", description = "AI service rejected or failed the request", content = @Content(schema = @Schema(implementation = Map.class))), - @ApiResponse(responseCode = "503", description = "AI service is unreachable", content = @Content(schema = @Schema(implementation = Map.class))) - } - ) - public ResponseEntity merge( - @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, - @RequestBody MergeListRequest request) { - GroceryListDetailDTO created = mergeService.merge(userId, request); - URI location = ServletUriComponentsBuilder.fromCurrentRequestUri() - .replacePath("/api/grocery-list/{id}") - .buildAndExpand(created.groceryListId()) - .toUri(); - return ResponseEntity.created(location).body(created); - } + @PostMapping( + value = "/merge", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "Merge recipes into a new grocery list", + description = + "Reads the selected recipes, asks the Gen AI service to deduplicate and sum their " + + "ingredients, then persists the result as a new grocery list linked to those recipes.", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "201", description = "Merged grocery list created"), + @ApiResponse( + responseCode = "400", + description = "No recipes provided", + content = @Content(schema = @Schema(implementation = Map.class))), + @ApiResponse( + responseCode = "401", + description = "Missing, expired, or invalid JWT", + content = @Content), + @ApiResponse( + responseCode = "404", + description = "One or more recipes not found", + content = @Content(schema = @Schema(implementation = Map.class))), + @ApiResponse( + responseCode = "422", + description = "AI service returned no merged ingredients", + content = @Content(schema = @Schema(implementation = Map.class))), + @ApiResponse( + responseCode = "502", + description = "AI service rejected or failed the request", + content = @Content(schema = @Schema(implementation = Map.class))), + @ApiResponse( + responseCode = "503", + description = "AI service is unreachable", + content = @Content(schema = @Schema(implementation = Map.class))) + }) + public ResponseEntity merge( + @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, + @RequestBody MergeListRequest request) { + GroceryListDetailDTO created = mergeService.merge(userId, request); + URI location = + ServletUriComponentsBuilder.fromCurrentRequestUri() + .replacePath("/api/grocery-list/{id}") + .buildAndExpand(created.groceryListId()) + .toUri(); + return ResponseEntity.created(location).body(created); + } - @PutMapping(value = "/{groceryListId}", - consumes = MediaType.APPLICATION_JSON_VALUE, - produces = MediaType.APPLICATION_JSON_VALUE) - @Operation( - summary = "Replace a grocery list's name and items", - description = "Renames the grocery list and replaces all of its items.", - security = @SecurityRequirement(name = "bearerAuth"), - responses = { - @ApiResponse(responseCode = "200", description = "Grocery list updated"), - @ApiResponse(responseCode = "400", description = "Invalid request body", content = @Content(schema = @Schema(implementation = Map.class))), - @ApiResponse(responseCode = "401", description = "Missing, expired, or invalid JWT", content = @Content), - @ApiResponse(responseCode = "404", description = "Grocery list not found", content = @Content(schema = @Schema(implementation = Map.class))) - } - ) - public GroceryListDetailDTO update( - @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, - @Parameter(description = "Identifier of the grocery list", required = true) - @PathVariable UUID groceryListId, - @RequestBody GroceryListCreateRequest request) { - return service.update(groceryListId, userId, request); - } + @PutMapping( + value = "/{groceryListId}", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "Replace a grocery list's name and items", + description = "Renames the grocery list and replaces all of its items.", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "200", description = "Grocery list updated"), + @ApiResponse( + responseCode = "400", + description = "Invalid request body", + content = @Content(schema = @Schema(implementation = Map.class))), + @ApiResponse( + responseCode = "401", + description = "Missing, expired, or invalid JWT", + content = @Content), + @ApiResponse( + responseCode = "404", + description = "Grocery list not found", + content = @Content(schema = @Schema(implementation = Map.class))) + }) + public GroceryListDetailDTO update( + @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, + @Parameter(description = "Identifier of the grocery list", required = true) @PathVariable + UUID groceryListId, + @RequestBody GroceryListCreateRequest request) { + return service.update(groceryListId, userId, request); + } - @PatchMapping(value = "/{groceryListId}/items/{itemId}", - consumes = MediaType.APPLICATION_JSON_VALUE, - produces = MediaType.APPLICATION_JSON_VALUE) - @Operation( - summary = "Update a grocery item's purchased flag", - description = "Marks a single item in the list as picked up (or not) without resending the whole list.", - security = @SecurityRequirement(name = "bearerAuth"), - responses = { - @ApiResponse(responseCode = "200", description = "Item updated"), - @ApiResponse(responseCode = "400", description = "Missing purchased flag", content = @Content(schema = @Schema(implementation = Map.class))), - @ApiResponse(responseCode = "401", description = "Missing, expired, or invalid JWT", content = @Content), - @ApiResponse(responseCode = "404", description = "Grocery list or item not found", content = @Content(schema = @Schema(implementation = Map.class))) - } - ) - public GroceryItemResponseDTO updateItem( - @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, - @Parameter(description = "Identifier of the grocery list", required = true) - @PathVariable UUID groceryListId, - @Parameter(description = "Identifier of the item", required = true) - @PathVariable UUID itemId, - @RequestBody GroceryItemPatchRequest request) { - if (request == null || request.purchased() == null) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "purchased is required"); - } - return service.setItemPurchased(groceryListId, itemId, userId, request.purchased()); + @PatchMapping( + value = "/{groceryListId}/items/{itemId}", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "Update a grocery item's purchased flag", + description = + "Marks a single item in the list as picked up (or not) without resending the whole list.", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "200", description = "Item updated"), + @ApiResponse( + responseCode = "400", + description = "Missing purchased flag", + content = @Content(schema = @Schema(implementation = Map.class))), + @ApiResponse( + responseCode = "401", + description = "Missing, expired, or invalid JWT", + content = @Content), + @ApiResponse( + responseCode = "404", + description = "Grocery list or item not found", + content = @Content(schema = @Schema(implementation = Map.class))) + }) + public GroceryItemResponseDTO updateItem( + @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, + @Parameter(description = "Identifier of the grocery list", required = true) @PathVariable + UUID groceryListId, + @Parameter(description = "Identifier of the item", required = true) @PathVariable UUID itemId, + @RequestBody GroceryItemPatchRequest request) { + if (request == null || request.purchased() == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "purchased is required"); } + return service.setItemPurchased(groceryListId, itemId, userId, request.purchased()); + } - @DeleteMapping(value = "/{groceryListId}") - @Operation( - summary = "Delete a grocery list", - description = "Deletes the grocery list identified by the given id along with its items.", - security = @SecurityRequirement(name = "bearerAuth"), - responses = { - @ApiResponse(responseCode = "204", description = "Grocery list deleted"), - @ApiResponse(responseCode = "401", description = "Missing, expired, or invalid JWT", content = @Content), - @ApiResponse(responseCode = "404", description = "Grocery list not found", content = @Content(schema = @Schema(implementation = Map.class))) - } - ) - public ResponseEntity delete( - @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, - @Parameter(description = "Identifier of the grocery list", required = true) - @PathVariable UUID groceryListId) { - service.delete(groceryListId, userId); - return ResponseEntity.noContent().build(); - } + @DeleteMapping(value = "/{groceryListId}") + @Operation( + summary = "Delete a grocery list", + description = "Deletes the grocery list identified by the given id along with its items.", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "204", description = "Grocery list deleted"), + @ApiResponse( + responseCode = "401", + description = "Missing, expired, or invalid JWT", + content = @Content), + @ApiResponse( + responseCode = "404", + description = "Grocery list not found", + content = @Content(schema = @Schema(implementation = Map.class))) + }) + public ResponseEntity delete( + @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, + @Parameter(description = "Identifier of the grocery list", required = true) @PathVariable + UUID groceryListId) { + service.delete(groceryListId, userId); + return ResponseEntity.noContent().build(); + } } diff --git a/server/grocery-service/src/main/java/com/bytebite/server/HealthController.java b/server/grocery-service/src/main/java/com/bytebite/server/HealthController.java index 05f54ab..284893f 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/HealthController.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/HealthController.java @@ -2,18 +2,17 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.Map; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.Map; - @RestController @Tag(name = "Health", description = "Service health checks") public class HealthController { - @GetMapping("/health") - @Operation(summary = "Check grocery-service health") - public Map health() { - return Map.of("status", "ok"); - } + @GetMapping("/health") + @Operation(summary = "Check grocery-service health") + public Map health() { + return Map.of("status", "ok"); + } } diff --git a/server/grocery-service/src/main/java/com/bytebite/server/OpenApiConfig.java b/server/grocery-service/src/main/java/com/bytebite/server/OpenApiConfig.java index 99aa86f..6789473 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/OpenApiConfig.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/OpenApiConfig.java @@ -1,30 +1,33 @@ package com.bytebite.server; -import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; -import io.swagger.v3.oas.models.servers.Server; import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import java.util.List; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import java.util.List; - @Configuration public class OpenApiConfig { - @Bean - public OpenAPI groceryServiceOpenAPI() { - return new OpenAPI() - .info(new Info() - .title("ByteBite Grocery Service API") - .version("0.0.1") - .description("Recipe generation and grocery-list endpoints for ByteBite.")) - .servers(List.of(new Server().url("/"))) - .components(new Components() - .addSecuritySchemes("bearerAuth", new SecurityScheme() - .type(SecurityScheme.Type.HTTP) - .scheme("bearer") - .bearerFormat("JWT"))); - } + @Bean + public OpenAPI groceryServiceOpenAPI() { + return new OpenAPI() + .info( + new Info() + .title("ByteBite Grocery Service API") + .version("0.0.1") + .description("Recipe generation and grocery-list endpoints for ByteBite.")) + .servers(List.of(new Server().url("/"))) + .components( + new Components() + .addSecuritySchemes( + "bearerAuth", + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))); + } } diff --git a/server/grocery-service/src/main/java/com/bytebite/server/RecipeController.java b/server/grocery-service/src/main/java/com/bytebite/server/RecipeController.java index 3844e12..53811b9 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/RecipeController.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/RecipeController.java @@ -11,6 +11,10 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.UUID; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -24,109 +28,136 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; -import java.net.URI; -import java.util.List; -import java.util.Map; -import java.util.UUID; - @RestController @RequestMapping("/api/recipes") @Tag(name = "Recipes", description = "Stored recipes and their items") public class RecipeController { - private final RecipeService service; + private final RecipeService service; - public RecipeController(RecipeService service) { - this.service = service; - } + public RecipeController(RecipeService service) { + this.service = service; + } - @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) - @Operation( - summary = "List saved recipes", - description = "Returns the caller's recipes as summaries, newest first. Use GET /{recipeId} for items.", - security = @SecurityRequirement(name = "bearerAuth"), - responses = { - @ApiResponse(responseCode = "200", description = "Recipe summaries"), - @ApiResponse(responseCode = "401", description = "Missing, expired, or invalid JWT", content = @Content) - } - ) - public List list( - @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId) { - return service.getAll(userId); - } + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "List saved recipes", + description = + "Returns the caller's recipes as summaries, newest first. Use GET /{recipeId} for items.", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "200", description = "Recipe summaries"), + @ApiResponse( + responseCode = "401", + description = "Missing, expired, or invalid JWT", + content = @Content) + }) + public List list( + @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId) { + return service.getAll(userId); + } - @GetMapping(value = "/{recipeId}", produces = MediaType.APPLICATION_JSON_VALUE) - @Operation( - summary = "Get a recipe by id", - security = @SecurityRequirement(name = "bearerAuth"), - responses = { - @ApiResponse(responseCode = "200", description = "Recipe with items"), - @ApiResponse(responseCode = "401", description = "Missing, expired, or invalid JWT", content = @Content), - @ApiResponse(responseCode = "404", description = "Recipe not found", content = @Content(schema = @Schema(implementation = Map.class))) - } - ) - public RecipeDetailDTO getById( - @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, - @Parameter(description = "Identifier of the recipe", required = true) - @PathVariable UUID recipeId) { - return service.getById(recipeId, userId); - } + @GetMapping(value = "/{recipeId}", produces = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "Get a recipe by id", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "200", description = "Recipe with items"), + @ApiResponse( + responseCode = "401", + description = "Missing, expired, or invalid JWT", + content = @Content), + @ApiResponse( + responseCode = "404", + description = "Recipe not found", + content = @Content(schema = @Schema(implementation = Map.class))) + }) + public RecipeDetailDTO getById( + @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, + @Parameter(description = "Identifier of the recipe", required = true) @PathVariable + UUID recipeId) { + return service.getById(recipeId, userId); + } - @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) - @Operation( - summary = "Save a recipe", - security = @SecurityRequirement(name = "bearerAuth"), - responses = { - @ApiResponse(responseCode = "201", description = "Recipe created"), - @ApiResponse(responseCode = "400", description = "Invalid request body", content = @Content(schema = @Schema(implementation = Map.class))), - @ApiResponse(responseCode = "401", description = "Missing, expired, or invalid JWT", content = @Content) - } - ) - public ResponseEntity create( - @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, - @RequestBody RecipeCreateRequest request) { - RecipeDetailDTO created = service.create(userId, request); - URI location = ServletUriComponentsBuilder.fromCurrentRequest() - .path("/{id}") - .buildAndExpand(created.recipeId()) - .toUri(); - return ResponseEntity.created(location).body(created); - } + @PostMapping( + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "Save a recipe", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "201", description = "Recipe created"), + @ApiResponse( + responseCode = "400", + description = "Invalid request body", + content = @Content(schema = @Schema(implementation = Map.class))), + @ApiResponse( + responseCode = "401", + description = "Missing, expired, or invalid JWT", + content = @Content) + }) + public ResponseEntity create( + @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, + @RequestBody RecipeCreateRequest request) { + RecipeDetailDTO created = service.create(userId, request); + URI location = + ServletUriComponentsBuilder.fromCurrentRequest() + .path("/{id}") + .buildAndExpand(created.recipeId()) + .toUri(); + return ResponseEntity.created(location).body(created); + } - @PutMapping(value = "/{recipeId}", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) - @Operation( - summary = "Replace a recipe's name and items", - security = @SecurityRequirement(name = "bearerAuth"), - responses = { - @ApiResponse(responseCode = "200", description = "Recipe updated"), - @ApiResponse(responseCode = "400", description = "Invalid request body", content = @Content(schema = @Schema(implementation = Map.class))), - @ApiResponse(responseCode = "401", description = "Missing, expired, or invalid JWT", content = @Content), - @ApiResponse(responseCode = "404", description = "Recipe not found", content = @Content(schema = @Schema(implementation = Map.class))) - } - ) - public RecipeDetailDTO update( - @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, - @Parameter(description = "Identifier of the recipe", required = true) - @PathVariable UUID recipeId, - @RequestBody RecipeCreateRequest request) { - return service.update(recipeId, userId, request); - } + @PutMapping( + value = "/{recipeId}", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "Replace a recipe's name and items", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "200", description = "Recipe updated"), + @ApiResponse( + responseCode = "400", + description = "Invalid request body", + content = @Content(schema = @Schema(implementation = Map.class))), + @ApiResponse( + responseCode = "401", + description = "Missing, expired, or invalid JWT", + content = @Content), + @ApiResponse( + responseCode = "404", + description = "Recipe not found", + content = @Content(schema = @Schema(implementation = Map.class))) + }) + public RecipeDetailDTO update( + @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, + @Parameter(description = "Identifier of the recipe", required = true) @PathVariable + UUID recipeId, + @RequestBody RecipeCreateRequest request) { + return service.update(recipeId, userId, request); + } - @DeleteMapping("/{recipeId}") - @Operation( - summary = "Delete a recipe", - security = @SecurityRequirement(name = "bearerAuth"), - responses = { - @ApiResponse(responseCode = "204", description = "Recipe deleted"), - @ApiResponse(responseCode = "401", description = "Missing, expired, or invalid JWT", content = @Content), - @ApiResponse(responseCode = "404", description = "Recipe not found", content = @Content(schema = @Schema(implementation = Map.class))) - } - ) - public ResponseEntity delete( - @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, - @Parameter(description = "Identifier of the recipe", required = true) - @PathVariable UUID recipeId) { - service.delete(recipeId, userId); - return ResponseEntity.noContent().build(); - } -} \ No newline at end of file + @DeleteMapping("/{recipeId}") + @Operation( + summary = "Delete a recipe", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "204", description = "Recipe deleted"), + @ApiResponse( + responseCode = "401", + description = "Missing, expired, or invalid JWT", + content = @Content), + @ApiResponse( + responseCode = "404", + description = "Recipe not found", + content = @Content(schema = @Schema(implementation = Map.class))) + }) + public ResponseEntity delete( + @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, + @Parameter(description = "Identifier of the recipe", required = true) @PathVariable + UUID recipeId) { + service.delete(recipeId, userId); + return ResponseEntity.noContent().build(); + } +} diff --git a/server/grocery-service/src/main/java/com/bytebite/server/ServerApplication.java b/server/grocery-service/src/main/java/com/bytebite/server/ServerApplication.java index 13d5438..6c431e6 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/ServerApplication.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/ServerApplication.java @@ -6,8 +6,7 @@ @SpringBootApplication public class ServerApplication { - public static void main(String[] args) { - SpringApplication.run(ServerApplication.class, args); - } - + public static void main(String[] args) { + SpringApplication.run(ServerApplication.class, args); + } } diff --git a/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryItemPatchRequest.java b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryItemPatchRequest.java index 4b98030..7259a34 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryItemPatchRequest.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryItemPatchRequest.java @@ -1,4 +1,4 @@ package com.bytebite.server.dto; /** Partial update for a single grocery item — currently only the purchased flag. */ -public record GroceryItemPatchRequest(Boolean purchased) {} \ No newline at end of file +public record GroceryItemPatchRequest(Boolean purchased) {} diff --git a/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryItemRequestDTO.java b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryItemRequestDTO.java index 3349b57..7b9f180 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryItemRequestDTO.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryItemRequestDTO.java @@ -1,3 +1,5 @@ package com.bytebite.server.dto; -public record GroceryItemRequestDTO(String name, Double quantity, String unit, String category, boolean purchased) implements ItemRequest {} \ No newline at end of file +public record GroceryItemRequestDTO( + String name, Double quantity, String unit, String category, boolean purchased) + implements ItemRequest {} diff --git a/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryItemResponseDTO.java b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryItemResponseDTO.java index 38ef3b6..f04b67d 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryItemResponseDTO.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryItemResponseDTO.java @@ -2,4 +2,5 @@ import java.util.UUID; -public record GroceryItemResponseDTO(UUID itemId, String name, Double quantity, String unit, String category, boolean purchased) {} +public record GroceryItemResponseDTO( + UUID itemId, String name, Double quantity, String unit, String category, boolean purchased) {} diff --git a/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryListDetailDTO.java b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryListDetailDTO.java index e3a3d97..e80f008 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryListDetailDTO.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryListDetailDTO.java @@ -4,4 +4,5 @@ import java.util.List; import java.util.UUID; -public record GroceryListDetailDTO(UUID groceryListId, String name, Instant createdAt, List items) {} +public record GroceryListDetailDTO( + UUID groceryListId, String name, Instant createdAt, List items) {} diff --git a/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryListSummaryDTO.java b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryListSummaryDTO.java index 6fb16fd..9737ca7 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryListSummaryDTO.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryListSummaryDTO.java @@ -3,5 +3,5 @@ import java.time.Instant; import java.util.UUID; -public record GroceryListSummaryDTO(UUID groceryListId, String name, Instant createdAt, - long itemCount, long purchasedCount) {} \ No newline at end of file +public record GroceryListSummaryDTO( + UUID groceryListId, String name, Instant createdAt, long itemCount, long purchasedCount) {} diff --git a/server/grocery-service/src/main/java/com/bytebite/server/dto/IngredientDTO.java b/server/grocery-service/src/main/java/com/bytebite/server/dto/IngredientDTO.java index bda6808..87d2019 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/dto/IngredientDTO.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/dto/IngredientDTO.java @@ -1,3 +1,9 @@ package com.bytebite.server.dto; -public record IngredientDTO(String name, String quantity, String unit, String category, boolean restricted, String alternative) {} +public record IngredientDTO( + String name, + String quantity, + String unit, + String category, + boolean restricted, + String alternative) {} diff --git a/server/grocery-service/src/main/java/com/bytebite/server/dto/ItemRequest.java b/server/grocery-service/src/main/java/com/bytebite/server/dto/ItemRequest.java index 297c1e0..56971ce 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/dto/ItemRequest.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/dto/ItemRequest.java @@ -2,8 +2,11 @@ /** Common fields every item-create payload shares, regardless of its owning resource. */ public interface ItemRequest { - String name(); - Double quantity(); - String unit(); - String category(); -} \ No newline at end of file + String name(); + + Double quantity(); + + String unit(); + + String category(); +} diff --git a/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeCreateRequest.java b/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeCreateRequest.java index 77c1404..94a8adb 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeCreateRequest.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeCreateRequest.java @@ -2,4 +2,4 @@ import java.util.List; -public record RecipeCreateRequest(String name, List items) {} \ No newline at end of file +public record RecipeCreateRequest(String name, List items) {} diff --git a/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeDetailDTO.java b/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeDetailDTO.java index f8aec20..14d6815 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeDetailDTO.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeDetailDTO.java @@ -4,4 +4,5 @@ import java.util.List; import java.util.UUID; -public record RecipeDetailDTO(UUID recipeId, String name, Instant createdAt, List items) {} \ No newline at end of file +public record RecipeDetailDTO( + UUID recipeId, String name, Instant createdAt, List items) {} diff --git a/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeItemRequestDTO.java b/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeItemRequestDTO.java index 755bf84..52b0aca 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeItemRequestDTO.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeItemRequestDTO.java @@ -1,3 +1,4 @@ package com.bytebite.server.dto; -public record RecipeItemRequestDTO(String name, Double quantity, String unit, String category) implements ItemRequest {} \ No newline at end of file +public record RecipeItemRequestDTO(String name, Double quantity, String unit, String category) + implements ItemRequest {} diff --git a/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeItemResponseDTO.java b/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeItemResponseDTO.java index cfc0157..7f47fb9 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeItemResponseDTO.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeItemResponseDTO.java @@ -2,4 +2,5 @@ import java.util.UUID; -public record RecipeItemResponseDTO(UUID itemId, String name, Double quantity, String unit, String category) {} \ No newline at end of file +public record RecipeItemResponseDTO( + UUID itemId, String name, Double quantity, String unit, String category) {} diff --git a/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeSummaryDTO.java b/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeSummaryDTO.java index 053ec64..996f427 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeSummaryDTO.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeSummaryDTO.java @@ -3,4 +3,4 @@ import java.time.Instant; import java.util.UUID; -public record RecipeSummaryDTO(UUID recipeId, String name, Instant createdAt) {} \ No newline at end of file +public record RecipeSummaryDTO(UUID recipeId, String name, Instant createdAt) {} diff --git a/server/grocery-service/src/main/java/com/bytebite/server/entity/GroceryCategory.java b/server/grocery-service/src/main/java/com/bytebite/server/entity/GroceryCategory.java index 16f800d..e23ee45 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/entity/GroceryCategory.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/entity/GroceryCategory.java @@ -1,14 +1,14 @@ package com.bytebite.server.entity; public enum GroceryCategory { - PRODUCE, - DAIRY, - MEAT, - SEAFOOD, - BAKERY, - PANTRY, - FROZEN, - BEVERAGES, - SPICES, - OTHER + PRODUCE, + DAIRY, + MEAT, + SEAFOOD, + BAKERY, + PANTRY, + FROZEN, + BEVERAGES, + SPICES, + OTHER } diff --git a/server/grocery-service/src/main/java/com/bytebite/server/entity/GroceryItem.java b/server/grocery-service/src/main/java/com/bytebite/server/entity/GroceryItem.java index 5fcacf4..362f862 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/entity/GroceryItem.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/entity/GroceryItem.java @@ -1,60 +1,105 @@ package com.bytebite.server.entity; import jakarta.persistence.*; +import java.util.UUID; import org.hibernate.annotations.JdbcType; import org.hibernate.dialect.PostgreSQLEnumJdbcType; -import java.util.UUID; @Entity @Table(name = "grocery_items") public class GroceryItem { - @Id - @Column(name = "item_id") - private UUID id; - - @Column(nullable = false) - private String name; - - // Nullable: recipe ingredients may have an unspecified quantity (e.g. "N/A", "to taste"). - @Column - private Double quantity; - - @Column(nullable = false) - private String unit; - - @Enumerated(EnumType.STRING) - @JdbcType(PostgreSQLEnumJdbcType.class) - @Column(columnDefinition = "grocery_category", nullable = false) - private GroceryCategory category; - - @Column(name = "is_purchased", nullable = false) - private boolean purchased; - - // A grocery item belongs to either a grocery list or a recipe (see chk_grocery_items_owner). - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "grocery_list_id") - private GroceryList groceryList; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "recipe_id") - private Recipe recipe; - - public UUID getId() { return id; } - public String getName() { return name; } - public Double getQuantity() { return quantity; } - public String getUnit() { return unit; } - public GroceryCategory getCategory() { return category; } - public boolean isPurchased() { return purchased; } - public GroceryList getGroceryList() { return groceryList; } - public Recipe getRecipe() { return recipe; } - - public void setId(UUID id) { this.id = id; } - public void setName(String name) { this.name = name; } - public void setQuantity(Double quantity) { this.quantity = quantity; } - public void setUnit(String unit) { this.unit = unit; } - public void setCategory(GroceryCategory category) { this.category = category; } - public void setPurchased(boolean purchased) { this.purchased = purchased; } - public void setGroceryList(GroceryList groceryList) { this.groceryList = groceryList; } - public void setRecipe(Recipe recipe) { this.recipe = recipe; } + @Id + @Column(name = "item_id") + private UUID id; + + @Column(nullable = false) + private String name; + + // Nullable: recipe ingredients may have an unspecified quantity (e.g. "N/A", "to taste"). + @Column private Double quantity; + + @Column(nullable = false) + private String unit; + + @Enumerated(EnumType.STRING) + @JdbcType(PostgreSQLEnumJdbcType.class) + @Column(columnDefinition = "grocery_category", nullable = false) + private GroceryCategory category; + + @Column(name = "is_purchased", nullable = false) + private boolean purchased; + + // A grocery item belongs to either a grocery list or a recipe (see chk_grocery_items_owner). + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "grocery_list_id") + private GroceryList groceryList; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "recipe_id") + private Recipe recipe; + + public UUID getId() { + return id; + } + + public String getName() { + return name; + } + + public Double getQuantity() { + return quantity; + } + + public String getUnit() { + return unit; + } + + public GroceryCategory getCategory() { + return category; + } + + public boolean isPurchased() { + return purchased; + } + + public GroceryList getGroceryList() { + return groceryList; + } + + public Recipe getRecipe() { + return recipe; + } + + public void setId(UUID id) { + this.id = id; + } + + public void setName(String name) { + this.name = name; + } + + public void setQuantity(Double quantity) { + this.quantity = quantity; + } + + public void setUnit(String unit) { + this.unit = unit; + } + + public void setCategory(GroceryCategory category) { + this.category = category; + } + + public void setPurchased(boolean purchased) { + this.purchased = purchased; + } + + public void setGroceryList(GroceryList groceryList) { + this.groceryList = groceryList; + } + + public void setRecipe(Recipe recipe) { + this.recipe = recipe; + } } diff --git a/server/grocery-service/src/main/java/com/bytebite/server/entity/GroceryList.java b/server/grocery-service/src/main/java/com/bytebite/server/entity/GroceryList.java index c2cafd3..ff179f2 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/entity/GroceryList.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/entity/GroceryList.java @@ -10,46 +10,91 @@ @Table(name = "grocery_lists") public class GroceryList { - @Id - @Column(name = "grocery_list_id") - private UUID id; - - @Column(nullable = false) - private String name; - - @Column(nullable = false) - private boolean outdated; - - @Column(name = "user_id", nullable = false) - private UUID userId; - - @Column(name = "created_at", nullable = false, updatable = false) - private Instant createdAt; - - @OneToMany(mappedBy = "groceryList", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) - private List items; - - // The recipes this list was merged from. Unidirectional and without cascade: we only - // record links in grocery_list_recipes and must never create or delete Recipe rows. - @ManyToMany(fetch = FetchType.LAZY) - @JoinTable(name = "grocery_list_recipes", - joinColumns = @JoinColumn(name = "grocery_list_id"), - inverseJoinColumns = @JoinColumn(name = "recipe_id")) - private List recipes = new ArrayList<>(); - - public UUID getId() { return id; } - public String getName() { return name; } - public boolean isOutdated() { return outdated; } - public UUID getUserId() { return userId; } - public Instant getCreatedAt() { return createdAt; } - public List getItems() { return items; } - public List getRecipes() { return recipes; } - - public void setId(UUID id) { this.id = id; } - public void setName(String name) { this.name = name; } - public void setOutdated(boolean outdated) { this.outdated = outdated; } - public void setUserId(UUID userId) { this.userId = userId; } - public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } - public void setItems(List items) { this.items = items; } - public void setRecipes(List recipes) { this.recipes = recipes; } + @Id + @Column(name = "grocery_list_id") + private UUID id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private boolean outdated; + + @Column(name = "user_id", nullable = false) + private UUID userId; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @OneToMany( + mappedBy = "groceryList", + cascade = CascadeType.ALL, + orphanRemoval = true, + fetch = FetchType.LAZY) + private List items; + + // The recipes this list was merged from. Unidirectional and without cascade: we only + // record links in grocery_list_recipes and must never create or delete Recipe rows. + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable( + name = "grocery_list_recipes", + joinColumns = @JoinColumn(name = "grocery_list_id"), + inverseJoinColumns = @JoinColumn(name = "recipe_id")) + private List recipes = new ArrayList<>(); + + public UUID getId() { + return id; + } + + public String getName() { + return name; + } + + public boolean isOutdated() { + return outdated; + } + + public UUID getUserId() { + return userId; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public List getItems() { + return items; + } + + public List getRecipes() { + return recipes; + } + + public void setId(UUID id) { + this.id = id; + } + + public void setName(String name) { + this.name = name; + } + + public void setOutdated(boolean outdated) { + this.outdated = outdated; + } + + public void setUserId(UUID userId) { + this.userId = userId; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public void setItems(List items) { + this.items = items; + } + + public void setRecipes(List recipes) { + this.recipes = recipes; + } } diff --git a/server/grocery-service/src/main/java/com/bytebite/server/entity/Recipe.java b/server/grocery-service/src/main/java/com/bytebite/server/entity/Recipe.java index a5f0cc9..355c883 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/entity/Recipe.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/entity/Recipe.java @@ -10,31 +10,63 @@ @Table(name = "recipes") public class Recipe { - @Id - @Column(name = "recipe_id") - private UUID id; - - @Column(nullable = false) - private String name; - - @Column(name = "user_id", nullable = false) - private UUID userId; - - @Column(name = "created_at", nullable = false, updatable = false) - private Instant createdAt; - - @OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) - private List items = new ArrayList<>(); - - public UUID getId() { return id; } - public String getName() { return name; } - public UUID getUserId() { return userId; } - public Instant getCreatedAt() { return createdAt; } - public List getItems() { return items; } - - public void setId(UUID id) { this.id = id; } - public void setName(String name) { this.name = name; } - public void setUserId(UUID userId) { this.userId = userId; } - public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } - public void setItems(List items) { this.items = items; } -} \ No newline at end of file + @Id + @Column(name = "recipe_id") + private UUID id; + + @Column(nullable = false) + private String name; + + @Column(name = "user_id", nullable = false) + private UUID userId; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @OneToMany( + mappedBy = "recipe", + cascade = CascadeType.ALL, + orphanRemoval = true, + fetch = FetchType.LAZY) + private List items = new ArrayList<>(); + + public UUID getId() { + return id; + } + + public String getName() { + return name; + } + + public UUID getUserId() { + return userId; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public List getItems() { + return items; + } + + public void setId(UUID id) { + this.id = id; + } + + public void setName(String name) { + this.name = name; + } + + public void setUserId(UUID userId) { + this.userId = userId; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public void setItems(List items) { + this.items = items; + } +} diff --git a/server/grocery-service/src/main/java/com/bytebite/server/repository/GroceryListRepository.java b/server/grocery-service/src/main/java/com/bytebite/server/repository/GroceryListRepository.java index c6d911e..5c77f75 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/repository/GroceryListRepository.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/repository/GroceryListRepository.java @@ -2,26 +2,26 @@ import com.bytebite.server.dto.GroceryListSummaryDTO; import com.bytebite.server.entity.GroceryList; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.stereotype.Repository; - import java.util.List; import java.util.Optional; import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; @Repository public interface GroceryListRepository extends JpaRepository { - List findAllByUserIdOrderByCreatedAtDesc(UUID userId); + List findAllByUserIdOrderByCreatedAtDesc(UUID userId); - Optional findByIdAndUserId(UUID id, UUID userId); + Optional findByIdAndUserId(UUID id, UUID userId); - /** - * Returns each list as a summary with item totals computed in a single aggregate query, - * so the collapsed cards can show progress without loading every item. - */ - @Query(""" + /** + * Returns each list as a summary with item totals computed in a single aggregate query, so the + * collapsed cards can show progress without loading every item. + */ + @Query( + """ select new com.bytebite.server.dto.GroceryListSummaryDTO( gl.id, gl.name, gl.createdAt, count(i), @@ -32,5 +32,5 @@ public interface GroceryListRepository extends JpaRepository group by gl.id, gl.name, gl.createdAt order by gl.createdAt desc """) - List findSummariesByUserId(UUID userId); + List findSummariesByUserId(UUID userId); } diff --git a/server/grocery-service/src/main/java/com/bytebite/server/repository/RecipeRepository.java b/server/grocery-service/src/main/java/com/bytebite/server/repository/RecipeRepository.java index aea7f03..62e5f25 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/repository/RecipeRepository.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/repository/RecipeRepository.java @@ -1,17 +1,16 @@ package com.bytebite.server.repository; import com.bytebite.server.entity.Recipe; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - import java.util.List; import java.util.Optional; import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; @Repository public interface RecipeRepository extends JpaRepository { - List findAllByUserIdOrderByCreatedAtDesc(UUID userId); + List findAllByUserIdOrderByCreatedAtDesc(UUID userId); - Optional findByIdAndUserId(UUID id, UUID userId); -} \ No newline at end of file + Optional findByIdAndUserId(UUID id, UUID userId); +} diff --git a/server/grocery-service/src/main/java/com/bytebite/server/service/GroceryItemMapper.java b/server/grocery-service/src/main/java/com/bytebite/server/service/GroceryItemMapper.java index 09473d2..5f4a7b6 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/service/GroceryItemMapper.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/service/GroceryItemMapper.java @@ -3,90 +3,91 @@ import com.bytebite.server.dto.ItemRequest; import com.bytebite.server.entity.GroceryCategory; import com.bytebite.server.entity.GroceryItem; -import org.springframework.http.HttpStatus; -import org.springframework.web.server.ResponseStatusException; - import java.util.Map; import java.util.UUID; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; /** - * Builds {@link GroceryItem} entities from request payloads. Recipes and grocery lists - * share the same item table, so they share the same field handling here; callers set the - * owning association (and {@code purchased}) afterwards. + * Builds {@link GroceryItem} entities from request payloads. Recipes and grocery lists share the + * same item table, so they share the same field handling here; callers set the owning association + * (and {@code purchased}) afterwards. */ final class GroceryItemMapper { - private GroceryItemMapper() { - } + private GroceryItemMapper() {} - /** Creates an item with the common fields populated, validating name and normalizing unit/category. */ - static GroceryItem newItem(ItemRequest dto) { - if (dto.name() == null || dto.name().isBlank()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Item name is required"); - } - GroceryItem item = new GroceryItem(); - item.setId(UUID.randomUUID()); - item.setName(dto.name()); - item.setQuantity(dto.quantity()); - item.setUnit(dto.unit() == null ? "" : dto.unit()); - item.setCategory(parseCategory(dto.category())); - return item; + /** + * Creates an item with the common fields populated, validating name and normalizing + * unit/category. + */ + static GroceryItem newItem(ItemRequest dto) { + if (dto.name() == null || dto.name().isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Item name is required"); } + GroceryItem item = new GroceryItem(); + item.setId(UUID.randomUUID()); + item.setName(dto.name()); + item.setQuantity(dto.quantity()); + item.setUnit(dto.unit() == null ? "" : dto.unit()); + item.setCategory(parseCategory(dto.category())); + return item; + } - /** - * Aliases mapping the gen-ai taxonomy (and common variants) onto the coarser - * {@link GroceryCategory} enum. The gen-ai prompt is aligned to emit the enum tokens - * directly; this table is defence-in-depth so legacy or drifting labels (e.g. - * "Dairy & Eggs", "Dry Goods & Pasta", "Spices & Herbs") still resolve to a real - * category instead of silently collapsing to OTHER. - */ - private static final Map CATEGORY_ALIASES = Map.ofEntries( - Map.entry("PRODUCE", GroceryCategory.PRODUCE), - Map.entry("FRUIT", GroceryCategory.PRODUCE), - Map.entry("FRUITS", GroceryCategory.PRODUCE), - Map.entry("VEGETABLE", GroceryCategory.PRODUCE), - Map.entry("VEGETABLES", GroceryCategory.PRODUCE), - Map.entry("DAIRY", GroceryCategory.DAIRY), - Map.entry("DAIRY & EGGS", GroceryCategory.DAIRY), - Map.entry("EGGS", GroceryCategory.DAIRY), - Map.entry("MEAT", GroceryCategory.MEAT), - Map.entry("MEAT & SEAFOOD", GroceryCategory.MEAT), - Map.entry("POULTRY", GroceryCategory.MEAT), - Map.entry("SEAFOOD", GroceryCategory.SEAFOOD), - Map.entry("FISH", GroceryCategory.SEAFOOD), - Map.entry("BAKERY", GroceryCategory.BAKERY), - Map.entry("BAKERY & BREAD", GroceryCategory.BAKERY), - Map.entry("BREAD", GroceryCategory.BAKERY), - Map.entry("PANTRY", GroceryCategory.PANTRY), - Map.entry("DRY GOODS & PASTA", GroceryCategory.PANTRY), - Map.entry("DRY GOODS", GroceryCategory.PANTRY), - Map.entry("PASTA", GroceryCategory.PANTRY), - Map.entry("CANNED & JARRED GOODS", GroceryCategory.PANTRY), - Map.entry("CANNED GOODS", GroceryCategory.PANTRY), - Map.entry("CONDIMENTS & SAUCES", GroceryCategory.PANTRY), - Map.entry("CONDIMENTS", GroceryCategory.PANTRY), - Map.entry("SAUCES", GroceryCategory.PANTRY), - Map.entry("BAKING NEEDS", GroceryCategory.PANTRY), - Map.entry("BAKING", GroceryCategory.PANTRY), - Map.entry("SNACKS", GroceryCategory.PANTRY), - Map.entry("INTERNATIONAL FOODS", GroceryCategory.PANTRY), - Map.entry("INTERNATIONAL", GroceryCategory.PANTRY), - Map.entry("FROZEN", GroceryCategory.FROZEN), - Map.entry("FROZEN FOODS", GroceryCategory.FROZEN), - Map.entry("BEVERAGES", GroceryCategory.BEVERAGES), - Map.entry("BEVERAGE", GroceryCategory.BEVERAGES), - Map.entry("DRINKS", GroceryCategory.BEVERAGES), - Map.entry("SPICES", GroceryCategory.SPICES), - Map.entry("SPICES & HERBS", GroceryCategory.SPICES), - Map.entry("HERBS", GroceryCategory.SPICES), - Map.entry("OTHER", GroceryCategory.OTHER) - ); + /** + * Aliases mapping the gen-ai taxonomy (and common variants) onto the coarser {@link + * GroceryCategory} enum. The gen-ai prompt is aligned to emit the enum tokens directly; this + * table is defence-in-depth so legacy or drifting labels (e.g. "Dairy & Eggs", "Dry Goods + * & Pasta", "Spices & Herbs") still resolve to a real category instead of silently + * collapsing to OTHER. + */ + private static final Map CATEGORY_ALIASES = + Map.ofEntries( + Map.entry("PRODUCE", GroceryCategory.PRODUCE), + Map.entry("FRUIT", GroceryCategory.PRODUCE), + Map.entry("FRUITS", GroceryCategory.PRODUCE), + Map.entry("VEGETABLE", GroceryCategory.PRODUCE), + Map.entry("VEGETABLES", GroceryCategory.PRODUCE), + Map.entry("DAIRY", GroceryCategory.DAIRY), + Map.entry("DAIRY & EGGS", GroceryCategory.DAIRY), + Map.entry("EGGS", GroceryCategory.DAIRY), + Map.entry("MEAT", GroceryCategory.MEAT), + Map.entry("MEAT & SEAFOOD", GroceryCategory.MEAT), + Map.entry("POULTRY", GroceryCategory.MEAT), + Map.entry("SEAFOOD", GroceryCategory.SEAFOOD), + Map.entry("FISH", GroceryCategory.SEAFOOD), + Map.entry("BAKERY", GroceryCategory.BAKERY), + Map.entry("BAKERY & BREAD", GroceryCategory.BAKERY), + Map.entry("BREAD", GroceryCategory.BAKERY), + Map.entry("PANTRY", GroceryCategory.PANTRY), + Map.entry("DRY GOODS & PASTA", GroceryCategory.PANTRY), + Map.entry("DRY GOODS", GroceryCategory.PANTRY), + Map.entry("PASTA", GroceryCategory.PANTRY), + Map.entry("CANNED & JARRED GOODS", GroceryCategory.PANTRY), + Map.entry("CANNED GOODS", GroceryCategory.PANTRY), + Map.entry("CONDIMENTS & SAUCES", GroceryCategory.PANTRY), + Map.entry("CONDIMENTS", GroceryCategory.PANTRY), + Map.entry("SAUCES", GroceryCategory.PANTRY), + Map.entry("BAKING NEEDS", GroceryCategory.PANTRY), + Map.entry("BAKING", GroceryCategory.PANTRY), + Map.entry("SNACKS", GroceryCategory.PANTRY), + Map.entry("INTERNATIONAL FOODS", GroceryCategory.PANTRY), + Map.entry("INTERNATIONAL", GroceryCategory.PANTRY), + Map.entry("FROZEN", GroceryCategory.FROZEN), + Map.entry("FROZEN FOODS", GroceryCategory.FROZEN), + Map.entry("BEVERAGES", GroceryCategory.BEVERAGES), + Map.entry("BEVERAGE", GroceryCategory.BEVERAGES), + Map.entry("DRINKS", GroceryCategory.BEVERAGES), + Map.entry("SPICES", GroceryCategory.SPICES), + Map.entry("SPICES & HERBS", GroceryCategory.SPICES), + Map.entry("HERBS", GroceryCategory.SPICES), + Map.entry("OTHER", GroceryCategory.OTHER)); - /** Maps a free-form category onto a valid grocery_category enum, defaulting to OTHER. */ - static GroceryCategory parseCategory(String value) { - if (value == null || value.isBlank()) { - return GroceryCategory.OTHER; - } - return CATEGORY_ALIASES.getOrDefault(value.trim().toUpperCase(), GroceryCategory.OTHER); + /** Maps a free-form category onto a valid grocery_category enum, defaulting to OTHER. */ + static GroceryCategory parseCategory(String value) { + if (value == null || value.isBlank()) { + return GroceryCategory.OTHER; } -} \ No newline at end of file + return CATEGORY_ALIASES.getOrDefault(value.trim().toUpperCase(), GroceryCategory.OTHER); + } +} diff --git a/server/grocery-service/src/main/java/com/bytebite/server/service/GroceryListMergeService.java b/server/grocery-service/src/main/java/com/bytebite/server/service/GroceryListMergeService.java index 9fc198b..334b0db 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/service/GroceryListMergeService.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/service/GroceryListMergeService.java @@ -11,6 +11,12 @@ import com.bytebite.server.entity.Recipe; import com.bytebite.server.repository.GroceryListRepository; import com.bytebite.server.repository.RecipeRepository; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; @@ -23,163 +29,169 @@ import org.springframework.web.client.RestTemplate; import org.springframework.web.server.ResponseStatusException; -import java.time.Instant; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; - /** - * Merges several stored recipes into a new grocery list: reads the selected recipes from - * the DB, delegates deduplication/summing to the gen-ai service, then persists the result - * as a grocery list (linked back to its source recipes via grocery_list_recipes). + * Merges several stored recipes into a new grocery list: reads the selected recipes from the DB, + * delegates deduplication/summing to the gen-ai service, then persists the result as a grocery list + * (linked back to its source recipes via grocery_list_recipes). */ @Service public class GroceryListMergeService { - private static final String DEFAULT_LLM_PROVIDER = "logos"; - private static final int MAX_NAME_LENGTH = 255; - - private final RecipeRepository recipeRepository; - private final GroceryListRepository groceryListRepository; - private final RestTemplate genAiRestTemplate; - - public GroceryListMergeService(RecipeRepository recipeRepository, - GroceryListRepository groceryListRepository, - RestTemplate genAiRestTemplate) { - this.recipeRepository = recipeRepository; - this.groceryListRepository = groceryListRepository; - this.genAiRestTemplate = genAiRestTemplate; + private static final String DEFAULT_LLM_PROVIDER = "logos"; + private static final int MAX_NAME_LENGTH = 255; + + private final RecipeRepository recipeRepository; + private final GroceryListRepository groceryListRepository; + private final RestTemplate genAiRestTemplate; + + public GroceryListMergeService( + RecipeRepository recipeRepository, + GroceryListRepository groceryListRepository, + RestTemplate genAiRestTemplate) { + this.recipeRepository = recipeRepository; + this.groceryListRepository = groceryListRepository; + this.genAiRestTemplate = genAiRestTemplate; + } + + @Transactional + public GroceryListDetailDTO merge(UUID userId, MergeListRequest request) { + List recipeIds = request == null ? null : request.recipeIds(); + if (recipeIds == null || recipeIds.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one recipe is required"); } - @Transactional - public GroceryListDetailDTO merge(UUID userId, MergeListRequest request) { - List recipeIds = request == null ? null : request.recipeIds(); - if (recipeIds == null || recipeIds.isEmpty()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one recipe is required"); - } - - List recipes = new ArrayList<>(); - for (UUID recipeId : recipeIds) { - recipes.add(recipeRepository.findByIdAndUserId(recipeId, userId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, - "Recipe not found: " + recipeId))); - } - - List merged = callGenAiMerge(recipes, - request.llmProvider() != null ? request.llmProvider() : DEFAULT_LLM_PROVIDER); - - GroceryList groceryList = new GroceryList(); - groceryList.setId(UUID.randomUUID()); - groceryList.setUserId(userId); - groceryList.setName(deriveName(recipes)); - groceryList.setOutdated(false); - groceryList.setCreatedAt(Instant.now()); - groceryList.setItems(buildItems(merged, groceryList)); - groceryList.setRecipes(recipes); - - return toDetail(groceryListRepository.save(groceryList)); + List recipes = new ArrayList<>(); + for (UUID recipeId : recipeIds) { + recipes.add( + recipeRepository + .findByIdAndUserId(recipeId, userId) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, "Recipe not found: " + recipeId))); } - /** Sends each recipe's items to gen-ai for deduplication and returns the merged ingredients. */ - private List callGenAiMerge(List recipes, String llmProvider) { - List> recipePayloads = new ArrayList<>(); - for (Recipe recipe : recipes) { - List ingredients = recipe.getItems().stream() - .map(this::toIngredientDTO) - .toList(); - recipePayloads.add(ingredients); - } - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - Map body = new HashMap<>(); - body.put("recipes", recipePayloads); - body.put("llm_provider", llmProvider); - HttpEntity> entity = new HttpEntity<>(body, headers); - - MergeResponseDTO response; - try { - response = genAiRestTemplate.postForObject("/api/ai/merge", entity, MergeResponseDTO.class); - } catch (HttpClientErrorException e) { - throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, - "AI service rejected the request: " + e.getMessage()); - } catch (HttpServerErrorException e) { - throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, - "AI service encountered an error: " + e.getMessage()); - } catch (ResourceAccessException e) { - throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, - "AI service is unreachable"); - } - - if (response == null || response.ingredients() == null || response.ingredients().isEmpty()) { - throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, - "AI service returned no merged ingredients"); - } - return response.ingredients(); + List merged = + callGenAiMerge( + recipes, request.llmProvider() != null ? request.llmProvider() : DEFAULT_LLM_PROVIDER); + + GroceryList groceryList = new GroceryList(); + groceryList.setId(UUID.randomUUID()); + groceryList.setUserId(userId); + groceryList.setName(deriveName(recipes)); + groceryList.setOutdated(false); + groceryList.setCreatedAt(Instant.now()); + groceryList.setItems(buildItems(merged, groceryList)); + groceryList.setRecipes(recipes); + + return toDetail(groceryListRepository.save(groceryList)); + } + + /** Sends each recipe's items to gen-ai for deduplication and returns the merged ingredients. */ + private List callGenAiMerge(List recipes, String llmProvider) { + List> recipePayloads = new ArrayList<>(); + for (Recipe recipe : recipes) { + List ingredients = + recipe.getItems().stream().map(this::toIngredientDTO).toList(); + recipePayloads.add(ingredients); } - // Stored items use a numeric quantity and the GroceryCategory enum; gen-ai expects strings. - // We send the enum name so gen-ai (which preserves the input category) round-trips it cleanly. - private IngredientDTO toIngredientDTO(GroceryItem item) { - String quantity = item.getQuantity() == null ? "N/A" : String.valueOf(item.getQuantity()); - return new IngredientDTO(item.getName(), quantity, item.getUnit(), - item.getCategory().name(), false, null); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + Map body = new HashMap<>(); + body.put("recipes", recipePayloads); + body.put("llm_provider", llmProvider); + HttpEntity> entity = new HttpEntity<>(body, headers); + + MergeResponseDTO response; + try { + response = genAiRestTemplate.postForObject("/api/ai/merge", entity, MergeResponseDTO.class); + } catch (HttpClientErrorException e) { + throw new ResponseStatusException( + HttpStatus.BAD_GATEWAY, "AI service rejected the request: " + e.getMessage()); + } catch (HttpServerErrorException e) { + throw new ResponseStatusException( + HttpStatus.BAD_GATEWAY, "AI service encountered an error: " + e.getMessage()); + } catch (ResourceAccessException e) { + throw new ResponseStatusException( + HttpStatus.SERVICE_UNAVAILABLE, "AI service is unreachable"); } - private List buildItems(List ingredients, GroceryList groceryList) { - List items = new ArrayList<>(); - for (IngredientDTO ingredient : ingredients) { - GroceryItemRequestDTO dto = new GroceryItemRequestDTO( - ingredient.name(), - parseQuantity(ingredient.quantity()), - ingredient.unit(), - ingredient.category(), - false); - GroceryItem item = GroceryItemMapper.newItem(dto); - item.setPurchased(false); - item.setGroceryList(groceryList); - items.add(item); - } - return items; + if (response == null || response.ingredients() == null || response.ingredients().isEmpty()) { + throw new ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, "AI service returned no merged ingredients"); } - - /** Parses a free-form quantity string to a number, treating blanks/"N/A"/non-numeric as unspecified. */ - private static Double parseQuantity(String quantity) { - if (quantity == null) { - return null; - } - String trimmed = quantity.trim(); - if (trimmed.isEmpty() || trimmed.equalsIgnoreCase("N/A")) { - return null; - } - try { - return Double.valueOf(trimmed); - } catch (NumberFormatException exception) { - return null; - } + return response.ingredients(); + } + + // Stored items use a numeric quantity and the GroceryCategory enum; gen-ai expects strings. + // We send the enum name so gen-ai (which preserves the input category) round-trips it cleanly. + private IngredientDTO toIngredientDTO(GroceryItem item) { + String quantity = item.getQuantity() == null ? "N/A" : String.valueOf(item.getQuantity()); + return new IngredientDTO( + item.getName(), quantity, item.getUnit(), item.getCategory().name(), false, null); + } + + private List buildItems(List ingredients, GroceryList groceryList) { + List items = new ArrayList<>(); + for (IngredientDTO ingredient : ingredients) { + GroceryItemRequestDTO dto = + new GroceryItemRequestDTO( + ingredient.name(), + parseQuantity(ingredient.quantity()), + ingredient.unit(), + ingredient.category(), + false); + GroceryItem item = GroceryItemMapper.newItem(dto); + item.setPurchased(false); + item.setGroceryList(groceryList); + items.add(item); } - - private static String deriveName(List recipes) { - String name = recipes.stream() - .map(Recipe::getName) - .reduce((a, b) -> a + " + " + b) - .orElse("Merged grocery list"); - return name.length() > MAX_NAME_LENGTH ? name.substring(0, MAX_NAME_LENGTH) : name; + return items; + } + + /** + * Parses a free-form quantity string to a number, treating blanks/"N/A"/non-numeric as + * unspecified. + */ + private static Double parseQuantity(String quantity) { + if (quantity == null) { + return null; } - - private GroceryListDetailDTO toDetail(GroceryList groceryList) { - List items = groceryList.getItems().stream() - .map(item -> new GroceryItemResponseDTO( + String trimmed = quantity.trim(); + if (trimmed.isEmpty() || trimmed.equalsIgnoreCase("N/A")) { + return null; + } + try { + return Double.valueOf(trimmed); + } catch (NumberFormatException exception) { + return null; + } + } + + private static String deriveName(List recipes) { + String name = + recipes.stream() + .map(Recipe::getName) + .reduce((a, b) -> a + " + " + b) + .orElse("Merged grocery list"); + return name.length() > MAX_NAME_LENGTH ? name.substring(0, MAX_NAME_LENGTH) : name; + } + + private GroceryListDetailDTO toDetail(GroceryList groceryList) { + List items = + groceryList.getItems().stream() + .map( + item -> + new GroceryItemResponseDTO( item.getId(), item.getName(), item.getQuantity(), item.getUnit(), item.getCategory().name(), item.isPurchased())) - .toList(); - return new GroceryListDetailDTO(groceryList.getId(), groceryList.getName(), - groceryList.getCreatedAt(), items); - } + .toList(); + return new GroceryListDetailDTO( + groceryList.getId(), groceryList.getName(), groceryList.getCreatedAt(), items); + } } diff --git a/server/grocery-service/src/main/java/com/bytebite/server/service/GroceryListService.java b/server/grocery-service/src/main/java/com/bytebite/server/service/GroceryListService.java index 63de467..c0ac80f 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/service/GroceryListService.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/service/GroceryListService.java @@ -8,116 +8,121 @@ import com.bytebite.server.entity.GroceryItem; import com.bytebite.server.entity.GroceryList; import com.bytebite.server.repository.GroceryListRepository; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; - import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.UUID; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; @Service public class GroceryListService { - private final GroceryListRepository repository; - - public GroceryListService(GroceryListRepository repository) { - this.repository = repository; - } - - @Transactional(readOnly = true) - public List getAll(UUID userId) { - return repository.findSummariesByUserId(userId); + private final GroceryListRepository repository; + + public GroceryListService(GroceryListRepository repository) { + this.repository = repository; + } + + @Transactional(readOnly = true) + public List getAll(UUID userId) { + return repository.findSummariesByUserId(userId); + } + + @Transactional(readOnly = true) + public GroceryListDetailDTO getById(UUID id, UUID userId) { + return toDetail(requireOwned(id, userId)); + } + + @Transactional + public GroceryListDetailDTO create(UUID userId, GroceryListCreateRequest request) { + String name = requireName(request); + GroceryList groceryList = new GroceryList(); + groceryList.setId(UUID.randomUUID()); + groceryList.setUserId(userId); + groceryList.setName(name); + groceryList.setOutdated(false); + groceryList.setCreatedAt(Instant.now()); + groceryList.setItems(buildItems(request.items(), groceryList)); + return toDetail(repository.save(groceryList)); + } + + @Transactional + public GroceryListDetailDTO update(UUID id, UUID userId, GroceryListCreateRequest request) { + String name = requireName(request); + GroceryList groceryList = requireOwned(id, userId); + groceryList.setName(name); + groceryList.getItems().clear(); + groceryList.getItems().addAll(buildItems(request.items(), groceryList)); + return toDetail(repository.save(groceryList)); + } + + @Transactional + public void delete(UUID id, UUID userId) { + repository.delete(requireOwned(id, userId)); + } + + @Transactional + public GroceryItemResponseDTO setItemPurchased( + UUID id, UUID itemId, UUID userId, boolean purchased) { + GroceryList groceryList = requireOwned(id, userId); + GroceryItem item = + groceryList.getItems().stream() + .filter(candidate -> candidate.getId().equals(itemId)) + .findFirst() + .orElseThrow( + () -> + new ResponseStatusException(HttpStatus.NOT_FOUND, "Item not found: " + itemId)); + item.setPurchased(purchased); + return toItemDTO(item); + } + + private List buildItems( + List itemDtos, GroceryList groceryList) { + List items = new ArrayList<>(); + if (itemDtos == null) { + return items; } - - @Transactional(readOnly = true) - public GroceryListDetailDTO getById(UUID id, UUID userId) { - return toDetail(requireOwned(id, userId)); - } - - @Transactional - public GroceryListDetailDTO create(UUID userId, GroceryListCreateRequest request) { - String name = requireName(request); - GroceryList groceryList = new GroceryList(); - groceryList.setId(UUID.randomUUID()); - groceryList.setUserId(userId); - groceryList.setName(name); - groceryList.setOutdated(false); - groceryList.setCreatedAt(Instant.now()); - groceryList.setItems(buildItems(request.items(), groceryList)); - return toDetail(repository.save(groceryList)); - } - - @Transactional - public GroceryListDetailDTO update(UUID id, UUID userId, GroceryListCreateRequest request) { - String name = requireName(request); - GroceryList groceryList = requireOwned(id, userId); - groceryList.setName(name); - groceryList.getItems().clear(); - groceryList.getItems().addAll(buildItems(request.items(), groceryList)); - return toDetail(repository.save(groceryList)); - } - - @Transactional - public void delete(UUID id, UUID userId) { - repository.delete(requireOwned(id, userId)); - } - - @Transactional - public GroceryItemResponseDTO setItemPurchased(UUID id, UUID itemId, UUID userId, boolean purchased) { - GroceryList groceryList = requireOwned(id, userId); - GroceryItem item = groceryList.getItems().stream() - .filter(candidate -> candidate.getId().equals(itemId)) - .findFirst() - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, - "Item not found: " + itemId)); - item.setPurchased(purchased); - return toItemDTO(item); - } - - private List buildItems(List itemDtos, GroceryList groceryList) { - List items = new ArrayList<>(); - if (itemDtos == null) { - return items; - } - for (GroceryItemRequestDTO dto : itemDtos) { - GroceryItem item = GroceryItemMapper.newItem(dto); - item.setPurchased(dto.purchased()); - item.setGroceryList(groceryList); - items.add(item); - } - return items; - } - - private String requireName(GroceryListCreateRequest request) { - if (request == null || request.name() == null || request.name().isBlank()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Grocery list name is required"); - } - return request.name(); - } - - private GroceryList requireOwned(UUID id, UUID userId) { - return repository.findByIdAndUserId(id, userId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, - "Grocery list not found: " + id)); - } - - private GroceryListDetailDTO toDetail(GroceryList groceryList) { - List items = groceryList.getItems().stream() - .map(this::toItemDTO) - .toList(); - return new GroceryListDetailDTO(groceryList.getId(), groceryList.getName(), groceryList.getCreatedAt(), items); + for (GroceryItemRequestDTO dto : itemDtos) { + GroceryItem item = GroceryItemMapper.newItem(dto); + item.setPurchased(dto.purchased()); + item.setGroceryList(groceryList); + items.add(item); } + return items; + } - private GroceryItemResponseDTO toItemDTO(GroceryItem item) { - return new GroceryItemResponseDTO( - item.getId(), - item.getName(), - item.getQuantity(), - item.getUnit(), - item.getCategory().name(), - item.isPurchased()); + private String requireName(GroceryListCreateRequest request) { + if (request == null || request.name() == null || request.name().isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Grocery list name is required"); } -} \ No newline at end of file + return request.name(); + } + + private GroceryList requireOwned(UUID id, UUID userId) { + return repository + .findByIdAndUserId(id, userId) + .orElseThrow( + () -> + new ResponseStatusException(HttpStatus.NOT_FOUND, "Grocery list not found: " + id)); + } + + private GroceryListDetailDTO toDetail(GroceryList groceryList) { + List items = + groceryList.getItems().stream().map(this::toItemDTO).toList(); + return new GroceryListDetailDTO( + groceryList.getId(), groceryList.getName(), groceryList.getCreatedAt(), items); + } + + private GroceryItemResponseDTO toItemDTO(GroceryItem item) { + return new GroceryItemResponseDTO( + item.getId(), + item.getName(), + item.getQuantity(), + item.getUnit(), + item.getCategory().name(), + item.isPurchased()); + } +} diff --git a/server/grocery-service/src/main/java/com/bytebite/server/service/RecipeService.java b/server/grocery-service/src/main/java/com/bytebite/server/service/RecipeService.java index 19b6f84..18d06db 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/service/RecipeService.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/service/RecipeService.java @@ -8,99 +8,104 @@ import com.bytebite.server.entity.GroceryItem; import com.bytebite.server.entity.Recipe; import com.bytebite.server.repository.RecipeRepository; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; - import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.UUID; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; @Service public class RecipeService { - private final RecipeRepository repository; + private final RecipeRepository repository; - public RecipeService(RecipeRepository repository) { - this.repository = repository; - } + public RecipeService(RecipeRepository repository) { + this.repository = repository; + } - @Transactional(readOnly = true) - public List getAll(UUID userId) { - return repository.findAllByUserIdOrderByCreatedAtDesc(userId).stream() - .map(recipe -> new RecipeSummaryDTO(recipe.getId(), recipe.getName(), recipe.getCreatedAt())) - .toList(); - } + @Transactional(readOnly = true) + public List getAll(UUID userId) { + return repository.findAllByUserIdOrderByCreatedAtDesc(userId).stream() + .map( + recipe -> new RecipeSummaryDTO(recipe.getId(), recipe.getName(), recipe.getCreatedAt())) + .toList(); + } - @Transactional(readOnly = true) - public RecipeDetailDTO getById(UUID id, UUID userId) { - return toDetail(requireOwned(id, userId)); - } + @Transactional(readOnly = true) + public RecipeDetailDTO getById(UUID id, UUID userId) { + return toDetail(requireOwned(id, userId)); + } - @Transactional - public RecipeDetailDTO create(UUID userId, RecipeCreateRequest request) { - String name = requireName(request); - Recipe recipe = new Recipe(); - recipe.setId(UUID.randomUUID()); - recipe.setUserId(userId); - recipe.setName(name); - recipe.setCreatedAt(Instant.now()); - recipe.setItems(buildItems(request.items(), recipe)); - return toDetail(repository.save(recipe)); - } + @Transactional + public RecipeDetailDTO create(UUID userId, RecipeCreateRequest request) { + String name = requireName(request); + Recipe recipe = new Recipe(); + recipe.setId(UUID.randomUUID()); + recipe.setUserId(userId); + recipe.setName(name); + recipe.setCreatedAt(Instant.now()); + recipe.setItems(buildItems(request.items(), recipe)); + return toDetail(repository.save(recipe)); + } - @Transactional - public RecipeDetailDTO update(UUID id, UUID userId, RecipeCreateRequest request) { - String name = requireName(request); - Recipe recipe = requireOwned(id, userId); - recipe.setName(name); - recipe.getItems().clear(); - recipe.getItems().addAll(buildItems(request.items(), recipe)); - return toDetail(repository.save(recipe)); - } + @Transactional + public RecipeDetailDTO update(UUID id, UUID userId, RecipeCreateRequest request) { + String name = requireName(request); + Recipe recipe = requireOwned(id, userId); + recipe.setName(name); + recipe.getItems().clear(); + recipe.getItems().addAll(buildItems(request.items(), recipe)); + return toDetail(repository.save(recipe)); + } - @Transactional - public void delete(UUID id, UUID userId) { - repository.delete(requireOwned(id, userId)); - } + @Transactional + public void delete(UUID id, UUID userId) { + repository.delete(requireOwned(id, userId)); + } - private List buildItems(List itemDtos, Recipe recipe) { - List items = new ArrayList<>(); - if (itemDtos == null) { - return items; - } - for (RecipeItemRequestDTO dto : itemDtos) { - GroceryItem item = GroceryItemMapper.newItem(dto); - item.setPurchased(false); - item.setRecipe(recipe); - items.add(item); - } - return items; + private List buildItems(List itemDtos, Recipe recipe) { + List items = new ArrayList<>(); + if (itemDtos == null) { + return items; } - - private String requireName(RecipeCreateRequest request) { - if (request == null || request.name() == null || request.name().isBlank()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Recipe name is required"); - } - return request.name(); + for (RecipeItemRequestDTO dto : itemDtos) { + GroceryItem item = GroceryItemMapper.newItem(dto); + item.setPurchased(false); + item.setRecipe(recipe); + items.add(item); } + return items; + } - private Recipe requireOwned(UUID id, UUID userId) { - return repository.findByIdAndUserId(id, userId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Recipe not found: " + id)); + private String requireName(RecipeCreateRequest request) { + if (request == null || request.name() == null || request.name().isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Recipe name is required"); } + return request.name(); + } - private RecipeDetailDTO toDetail(Recipe recipe) { - List items = recipe.getItems().stream() - .map(item -> new RecipeItemResponseDTO( + private Recipe requireOwned(UUID id, UUID userId) { + return repository + .findByIdAndUserId(id, userId) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Recipe not found: " + id)); + } + + private RecipeDetailDTO toDetail(Recipe recipe) { + List items = + recipe.getItems().stream() + .map( + item -> + new RecipeItemResponseDTO( item.getId(), item.getName(), item.getQuantity(), item.getUnit(), item.getCategory().name())) - .toList(); - return new RecipeDetailDTO(recipe.getId(), recipe.getName(), recipe.getCreatedAt(), items); - } -} \ No newline at end of file + .toList(); + return new RecipeDetailDTO(recipe.getId(), recipe.getName(), recipe.getCreatedAt(), items); + } +} diff --git a/server/grocery-service/src/test/java/com/bytebite/server/GroceryListControllerTest.java b/server/grocery-service/src/test/java/com/bytebite/server/GroceryListControllerTest.java index 80c816d..f7d44ba 100644 --- a/server/grocery-service/src/test/java/com/bytebite/server/GroceryListControllerTest.java +++ b/server/grocery-service/src/test/java/com/bytebite/server/GroceryListControllerTest.java @@ -1,11 +1,24 @@ package com.bytebite.server; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import com.bytebite.server.dto.GroceryItemPatchRequest; import com.bytebite.server.dto.GroceryItemResponseDTO; import com.bytebite.server.dto.GroceryListDetailDTO; import com.bytebite.server.service.GroceryListMergeService; import com.bytebite.server.service.GroceryListService; import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Instant; +import java.util.List; +import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @@ -13,81 +26,69 @@ import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; -import java.time.Instant; -import java.util.List; -import java.util.UUID; - -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - @WebMvcTest(GroceryListController.class) class GroceryListControllerTest { - @Autowired - private MockMvc mockMvc; + @Autowired private MockMvc mockMvc; - @Autowired - private ObjectMapper objectMapper; + @Autowired private ObjectMapper objectMapper; - @MockBean - private GroceryListService groceryListService; + @MockBean private GroceryListService groceryListService; - @MockBean - private GroceryListMergeService mergeService; + @MockBean private GroceryListMergeService mergeService; - @Test - void updateItemRejectsMissingPurchasedFlagBeforeCallingService() throws Exception { - UUID userId = UUID.randomUUID(); - UUID listId = UUID.randomUUID(); - UUID itemId = UUID.randomUUID(); + @Test + void updateItemRejectsMissingPurchasedFlagBeforeCallingService() throws Exception { + UUID userId = UUID.randomUUID(); + UUID listId = UUID.randomUUID(); + UUID itemId = UUID.randomUUID(); - mockMvc.perform(patch("/api/grocery-list/{listId}/items/{itemId}", listId, itemId) - .header("X-User-Id", userId) - .contentType(MediaType.APPLICATION_JSON) - .content("{}")) - .andExpect(status().isBadRequest()); + mockMvc + .perform( + patch("/api/grocery-list/{listId}/items/{itemId}", listId, itemId) + .header("X-User-Id", userId) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); - verifyNoInteractions(groceryListService); - } + verifyNoInteractions(groceryListService); + } - @Test - void updateItemReturnsUpdatedItem() throws Exception { - UUID userId = UUID.randomUUID(); - UUID listId = UUID.randomUUID(); - UUID itemId = UUID.randomUUID(); - when(groceryListService.setItemPurchased(listId, itemId, userId, true)) - .thenReturn(new GroceryItemResponseDTO(itemId, "Milk", 1.0, "l", "DAIRY", true)); + @Test + void updateItemReturnsUpdatedItem() throws Exception { + UUID userId = UUID.randomUUID(); + UUID listId = UUID.randomUUID(); + UUID itemId = UUID.randomUUID(); + when(groceryListService.setItemPurchased(listId, itemId, userId, true)) + .thenReturn(new GroceryItemResponseDTO(itemId, "Milk", 1.0, "l", "DAIRY", true)); - mockMvc.perform(patch("/api/grocery-list/{listId}/items/{itemId}", listId, itemId) - .header("X-User-Id", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(new GroceryItemPatchRequest(true)))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.itemId").value(itemId.toString())) - .andExpect(jsonPath("$.purchased").value(true)); + mockMvc + .perform( + patch("/api/grocery-list/{listId}/items/{itemId}", listId, itemId) + .header("X-User-Id", userId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new GroceryItemPatchRequest(true)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.itemId").value(itemId.toString())) + .andExpect(jsonPath("$.purchased").value(true)); - verify(groceryListService).setItemPurchased(listId, itemId, userId, true); - } + verify(groceryListService).setItemPurchased(listId, itemId, userId, true); + } - @Test - void mergeReturnsCreatedLocationForNewGroceryList() throws Exception { - UUID userId = UUID.randomUUID(); - UUID listId = UUID.randomUUID(); - when(mergeService.merge(eq(userId), org.mockito.ArgumentMatchers.any())) - .thenReturn(new GroceryListDetailDTO(listId, "Merged", Instant.now(), List.of())); + @Test + void mergeReturnsCreatedLocationForNewGroceryList() throws Exception { + UUID userId = UUID.randomUUID(); + UUID listId = UUID.randomUUID(); + when(mergeService.merge(eq(userId), org.mockito.ArgumentMatchers.any())) + .thenReturn(new GroceryListDetailDTO(listId, "Merged", Instant.now(), List.of())); - mockMvc.perform(post("/api/grocery-list/merge") - .header("X-User-Id", userId) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"recipeIds\":[\"" + UUID.randomUUID() + "\"]}")) - .andExpect(status().isCreated()) - .andExpect(header().string("Location", "http://localhost/api/grocery-list/" + listId)) - .andExpect(jsonPath("$.groceryListId").value(listId.toString())); - } + mockMvc + .perform( + post("/api/grocery-list/merge") + .header("X-User-Id", userId) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"recipeIds\":[\"" + UUID.randomUUID() + "\"]}")) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "http://localhost/api/grocery-list/" + listId)) + .andExpect(jsonPath("$.groceryListId").value(listId.toString())); + } } diff --git a/server/grocery-service/src/test/java/com/bytebite/server/ServerApplicationTests.java b/server/grocery-service/src/test/java/com/bytebite/server/ServerApplicationTests.java index 62c77ea..b0b6682 100644 --- a/server/grocery-service/src/test/java/com/bytebite/server/ServerApplicationTests.java +++ b/server/grocery-service/src/test/java/com/bytebite/server/ServerApplicationTests.java @@ -8,11 +8,10 @@ // JPA/Hibernate would otherwise open a JDBC connection at startup to read DB metadata, // which fails in CI where no database is available. The dialect is configured explicitly // in application.properties, so Hibernate can bootstrap without probing the database. -@TestPropertySource(properties = "spring.jpa.properties.hibernate.boot.allow_jdbc_metadata_access=false") +@TestPropertySource( + properties = "spring.jpa.properties.hibernate.boot.allow_jdbc_metadata_access=false") class ServerApplicationTests { - @Test - void contextLoads() { - } - -} \ No newline at end of file + @Test + void contextLoads() {} +} diff --git a/server/grocery-service/src/test/java/com/bytebite/server/service/GroceryItemMapperTest.java b/server/grocery-service/src/test/java/com/bytebite/server/service/GroceryItemMapperTest.java index a360e2e..52354ae 100644 --- a/server/grocery-service/src/test/java/com/bytebite/server/service/GroceryItemMapperTest.java +++ b/server/grocery-service/src/test/java/com/bytebite/server/service/GroceryItemMapperTest.java @@ -1,5 +1,8 @@ package com.bytebite.server.service; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + import com.bytebite.server.dto.GroceryItemRequestDTO; import com.bytebite.server.entity.GroceryCategory; import com.bytebite.server.entity.GroceryItem; @@ -7,40 +10,42 @@ import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - class GroceryItemMapperTest { - @Test - void mapsKnownAiCategoryAliases() { - assertThat(GroceryItemMapper.parseCategory("Dairy & Eggs")).isEqualTo(GroceryCategory.DAIRY); - assertThat(GroceryItemMapper.parseCategory("dry goods & pasta")).isEqualTo(GroceryCategory.PANTRY); - assertThat(GroceryItemMapper.parseCategory("Spices & Herbs")).isEqualTo(GroceryCategory.SPICES); - } + @Test + void mapsKnownAiCategoryAliases() { + assertThat(GroceryItemMapper.parseCategory("Dairy & Eggs")).isEqualTo(GroceryCategory.DAIRY); + assertThat(GroceryItemMapper.parseCategory("dry goods & pasta")) + .isEqualTo(GroceryCategory.PANTRY); + assertThat(GroceryItemMapper.parseCategory("Spices & Herbs")).isEqualTo(GroceryCategory.SPICES); + } - @Test - void defaultsUnknownOrBlankCategoryToOther() { - assertThat(GroceryItemMapper.parseCategory(null)).isEqualTo(GroceryCategory.OTHER); - assertThat(GroceryItemMapper.parseCategory("")).isEqualTo(GroceryCategory.OTHER); - assertThat(GroceryItemMapper.parseCategory("Hardware")).isEqualTo(GroceryCategory.OTHER); - } + @Test + void defaultsUnknownOrBlankCategoryToOther() { + assertThat(GroceryItemMapper.parseCategory(null)).isEqualTo(GroceryCategory.OTHER); + assertThat(GroceryItemMapper.parseCategory("")).isEqualTo(GroceryCategory.OTHER); + assertThat(GroceryItemMapper.parseCategory("Hardware")).isEqualTo(GroceryCategory.OTHER); + } - @Test - void newItemValidatesNameAndNormalizesNullUnit() { - GroceryItem item = GroceryItemMapper.newItem( - new GroceryItemRequestDTO("Milk", 1.0, null, "Dairy & Eggs", false)); + @Test + void newItemValidatesNameAndNormalizesNullUnit() { + GroceryItem item = + GroceryItemMapper.newItem( + new GroceryItemRequestDTO("Milk", 1.0, null, "Dairy & Eggs", false)); - assertThat(item.getId()).isNotNull(); - assertThat(item.getName()).isEqualTo("Milk"); - assertThat(item.getUnit()).isEmpty(); - assertThat(item.getCategory()).isEqualTo(GroceryCategory.DAIRY); - } + assertThat(item.getId()).isNotNull(); + assertThat(item.getName()).isEqualTo("Milk"); + assertThat(item.getUnit()).isEmpty(); + assertThat(item.getCategory()).isEqualTo(GroceryCategory.DAIRY); + } - @Test - void newItemRejectsBlankName() { - assertThatThrownBy(() -> GroceryItemMapper.newItem( - new GroceryItemRequestDTO(" ", 1.0, "kg", "Produce", false))) - .isInstanceOfSatisfying(ResponseStatusException.class, exception -> - assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST)); - } + @Test + void newItemRejectsBlankName() { + assertThatThrownBy( + () -> + GroceryItemMapper.newItem( + new GroceryItemRequestDTO(" ", 1.0, "kg", "Produce", false))) + .isInstanceOfSatisfying( + ResponseStatusException.class, + exception -> assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST)); + } } diff --git a/server/grocery-service/src/test/java/com/bytebite/server/service/GroceryListMergeServiceTest.java b/server/grocery-service/src/test/java/com/bytebite/server/service/GroceryListMergeServiceTest.java index c8ac864..680e699 100644 --- a/server/grocery-service/src/test/java/com/bytebite/server/service/GroceryListMergeServiceTest.java +++ b/server/grocery-service/src/test/java/com/bytebite/server/service/GroceryListMergeServiceTest.java @@ -1,5 +1,12 @@ package com.bytebite.server.service; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + import com.bytebite.server.dto.IngredientDTO; import com.bytebite.server.dto.MergeListRequest; import com.bytebite.server.dto.MergeResponseDTO; @@ -9,6 +16,10 @@ import com.bytebite.server.entity.Recipe; import com.bytebite.server.repository.GroceryListRepository; import com.bytebite.server.repository.RecipeRepository; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -19,105 +30,99 @@ import org.springframework.web.client.RestTemplate; import org.springframework.web.server.ResponseStatusException; -import java.time.Instant; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - @ExtendWith(MockitoExtension.class) class GroceryListMergeServiceTest { - @Mock - private RecipeRepository recipeRepository; - - @Mock - private GroceryListRepository groceryListRepository; - - @Mock - private RestTemplate genAiRestTemplate; - - @Test - void mergePersistsAiMergedIngredientsAndLinksSourceRecipes() { - UUID userId = UUID.randomUUID(); - UUID recipeId = UUID.randomUUID(); - Recipe recipe = recipe(recipeId, userId, "Pasta", item("Tomato", 2.0, "pcs", GroceryCategory.PRODUCE)); - when(recipeRepository.findByIdAndUserId(recipeId, userId)).thenReturn(Optional.of(recipe)); - when(genAiRestTemplate.postForObject(eq("/api/ai/merge"), any(), eq(MergeResponseDTO.class))) - .thenReturn(new MergeResponseDTO(List.of( - new IngredientDTO("Tomato", "3", "pcs", "Produce", false, null), - new IngredientDTO("Salt", "N/A", "", "Spices & Herbs", false, null)))); - when(groceryListRepository.save(any(GroceryList.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - var merged = service().merge(userId, new MergeListRequest(List.of(recipeId), "openai")); - - ArgumentCaptor captor = ArgumentCaptor.forClass(GroceryList.class); - verify(groceryListRepository).save(captor.capture()); - GroceryList saved = captor.getValue(); - assertThat(saved.getName()).isEqualTo("Pasta"); - assertThat(saved.getUserId()).isEqualTo(userId); - assertThat(saved.getRecipes()).containsExactly(recipe); - assertThat(saved.getItems()).hasSize(2); - assertThat(saved.getItems().getFirst().getQuantity()).isEqualTo(3.0); - assertThat(saved.getItems().getFirst().getCategory()).isEqualTo(GroceryCategory.PRODUCE); - assertThat(saved.getItems().get(1).getQuantity()).isNull(); - assertThat(saved.getItems().get(1).getCategory()).isEqualTo(GroceryCategory.SPICES); - - assertThat(merged.name()).isEqualTo("Pasta"); - assertThat(merged.items()).extracting("name").containsExactly("Tomato", "Salt"); - } - - @Test - void mergeRejectsEmptyRecipeSelection() { - assertThatThrownBy(() -> service().merge(UUID.randomUUID(), new MergeListRequest(List.of(), null))) - .isInstanceOfSatisfying(ResponseStatusException.class, exception -> - assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST)); - } - - @Test - void mergeMapsAiConnectivityFailureToServiceUnavailable() { - UUID userId = UUID.randomUUID(); - UUID recipeId = UUID.randomUUID(); - when(recipeRepository.findByIdAndUserId(recipeId, userId)) - .thenReturn(Optional.of(recipe(recipeId, userId, "Pasta"))); - when(genAiRestTemplate.postForObject(eq("/api/ai/merge"), any(), eq(MergeResponseDTO.class))) - .thenThrow(new ResourceAccessException("connection refused")); - - assertThatThrownBy(() -> service().merge(userId, new MergeListRequest(List.of(recipeId), null))) - .isInstanceOfSatisfying(ResponseStatusException.class, exception -> - assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE)); - } - - private GroceryListMergeService service() { - return new GroceryListMergeService(recipeRepository, groceryListRepository, genAiRestTemplate); - } - - private static Recipe recipe(UUID id, UUID userId, String name, GroceryItem... items) { - Recipe recipe = new Recipe(); - recipe.setId(id); - recipe.setUserId(userId); - recipe.setName(name); - recipe.setCreatedAt(Instant.now()); - recipe.setItems(List.of(items)); - for (GroceryItem item : recipe.getItems()) { - item.setRecipe(recipe); - } - return recipe; - } - - private static GroceryItem item(String name, Double quantity, String unit, GroceryCategory category) { - GroceryItem item = new GroceryItem(); - item.setId(UUID.randomUUID()); - item.setName(name); - item.setQuantity(quantity); - item.setUnit(unit); - item.setCategory(category); - item.setPurchased(false); - return item; + @Mock private RecipeRepository recipeRepository; + + @Mock private GroceryListRepository groceryListRepository; + + @Mock private RestTemplate genAiRestTemplate; + + @Test + void mergePersistsAiMergedIngredientsAndLinksSourceRecipes() { + UUID userId = UUID.randomUUID(); + UUID recipeId = UUID.randomUUID(); + Recipe recipe = + recipe(recipeId, userId, "Pasta", item("Tomato", 2.0, "pcs", GroceryCategory.PRODUCE)); + when(recipeRepository.findByIdAndUserId(recipeId, userId)).thenReturn(Optional.of(recipe)); + when(genAiRestTemplate.postForObject(eq("/api/ai/merge"), any(), eq(MergeResponseDTO.class))) + .thenReturn( + new MergeResponseDTO( + List.of( + new IngredientDTO("Tomato", "3", "pcs", "Produce", false, null), + new IngredientDTO("Salt", "N/A", "", "Spices & Herbs", false, null)))); + when(groceryListRepository.save(any(GroceryList.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + var merged = service().merge(userId, new MergeListRequest(List.of(recipeId), "openai")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(GroceryList.class); + verify(groceryListRepository).save(captor.capture()); + GroceryList saved = captor.getValue(); + assertThat(saved.getName()).isEqualTo("Pasta"); + assertThat(saved.getUserId()).isEqualTo(userId); + assertThat(saved.getRecipes()).containsExactly(recipe); + assertThat(saved.getItems()).hasSize(2); + assertThat(saved.getItems().getFirst().getQuantity()).isEqualTo(3.0); + assertThat(saved.getItems().getFirst().getCategory()).isEqualTo(GroceryCategory.PRODUCE); + assertThat(saved.getItems().get(1).getQuantity()).isNull(); + assertThat(saved.getItems().get(1).getCategory()).isEqualTo(GroceryCategory.SPICES); + + assertThat(merged.name()).isEqualTo("Pasta"); + assertThat(merged.items()).extracting("name").containsExactly("Tomato", "Salt"); + } + + @Test + void mergeRejectsEmptyRecipeSelection() { + assertThatThrownBy( + () -> service().merge(UUID.randomUUID(), new MergeListRequest(List.of(), null))) + .isInstanceOfSatisfying( + ResponseStatusException.class, + exception -> assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST)); + } + + @Test + void mergeMapsAiConnectivityFailureToServiceUnavailable() { + UUID userId = UUID.randomUUID(); + UUID recipeId = UUID.randomUUID(); + when(recipeRepository.findByIdAndUserId(recipeId, userId)) + .thenReturn(Optional.of(recipe(recipeId, userId, "Pasta"))); + when(genAiRestTemplate.postForObject(eq("/api/ai/merge"), any(), eq(MergeResponseDTO.class))) + .thenThrow(new ResourceAccessException("connection refused")); + + assertThatThrownBy(() -> service().merge(userId, new MergeListRequest(List.of(recipeId), null))) + .isInstanceOfSatisfying( + ResponseStatusException.class, + exception -> + assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE)); + } + + private GroceryListMergeService service() { + return new GroceryListMergeService(recipeRepository, groceryListRepository, genAiRestTemplate); + } + + private static Recipe recipe(UUID id, UUID userId, String name, GroceryItem... items) { + Recipe recipe = new Recipe(); + recipe.setId(id); + recipe.setUserId(userId); + recipe.setName(name); + recipe.setCreatedAt(Instant.now()); + recipe.setItems(List.of(items)); + for (GroceryItem item : recipe.getItems()) { + item.setRecipe(recipe); } + return recipe; + } + + private static GroceryItem item( + String name, Double quantity, String unit, GroceryCategory category) { + GroceryItem item = new GroceryItem(); + item.setId(UUID.randomUUID()); + item.setName(name); + item.setQuantity(quantity); + item.setUnit(unit); + item.setCategory(category); + item.setPurchased(false); + return item; + } } diff --git a/server/grocery-service/src/test/java/com/bytebite/server/service/GroceryListServiceTest.java b/server/grocery-service/src/test/java/com/bytebite/server/service/GroceryListServiceTest.java index a51bbf9..ad05680 100644 --- a/server/grocery-service/src/test/java/com/bytebite/server/service/GroceryListServiceTest.java +++ b/server/grocery-service/src/test/java/com/bytebite/server/service/GroceryListServiceTest.java @@ -1,11 +1,22 @@ package com.bytebite.server.service; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + import com.bytebite.server.dto.GroceryItemRequestDTO; import com.bytebite.server.dto.GroceryListCreateRequest; import com.bytebite.server.entity.GroceryCategory; import com.bytebite.server.entity.GroceryItem; import com.bytebite.server.entity.GroceryList; import com.bytebite.server.repository.GroceryListRepository; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -15,109 +26,107 @@ import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - @ExtendWith(MockitoExtension.class) class GroceryListServiceTest { - @Mock - private GroceryListRepository repository; + @Mock private GroceryListRepository repository; - @InjectMocks - private GroceryListService service; + @InjectMocks private GroceryListService service; - @Test - void createBuildsOwnedListWithItemsAndReturnsSavedDto() { - UUID userId = UUID.randomUUID(); - when(repository.save(any(GroceryList.class))).thenAnswer(invocation -> invocation.getArgument(0)); + @Test + void createBuildsOwnedListWithItemsAndReturnsSavedDto() { + UUID userId = UUID.randomUUID(); + when(repository.save(any(GroceryList.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); - var request = new GroceryListCreateRequest("Weekend shop", List.of( + var request = + new GroceryListCreateRequest( + "Weekend shop", + List.of( new GroceryItemRequestDTO("Milk", 2.0, "l", "Dairy & Eggs", true), new GroceryItemRequestDTO("Basil", null, "bunch", "Spices & Herbs", false))); - var created = service.create(userId, request); - - ArgumentCaptor captor = ArgumentCaptor.forClass(GroceryList.class); - verify(repository).save(captor.capture()); - GroceryList saved = captor.getValue(); - assertThat(saved.getUserId()).isEqualTo(userId); - assertThat(saved.getName()).isEqualTo("Weekend shop"); - assertThat(saved.isOutdated()).isFalse(); - assertThat(saved.getCreatedAt()).isNotNull(); - assertThat(saved.getItems()).hasSize(2); - assertThat(saved.getItems().getFirst().getGroceryList()).isSameAs(saved); - assertThat(saved.getItems().getFirst().getCategory()).isEqualTo(GroceryCategory.DAIRY); - assertThat(saved.getItems().getFirst().isPurchased()).isTrue(); - - assertThat(created.groceryListId()).isEqualTo(saved.getId()); - assertThat(created.items()).extracting("name").containsExactly("Milk", "Basil"); - } - - @Test - void updateReplacesExistingItems() { - UUID userId = UUID.randomUUID(); - UUID listId = UUID.randomUUID(); - GroceryList existing = list(listId, userId, "Old", item("Old item")); - when(repository.findByIdAndUserId(listId, userId)).thenReturn(Optional.of(existing)); - when(repository.save(any(GroceryList.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - service.update(listId, userId, new GroceryListCreateRequest("New", List.of( - new GroceryItemRequestDTO("Tomato", 4.0, "pcs", "Produce", false)))); - - assertThat(existing.getName()).isEqualTo("New"); - assertThat(existing.getItems()).hasSize(1); - assertThat(existing.getItems().getFirst().getName()).isEqualTo("Tomato"); - assertThat(existing.getItems().getFirst().getGroceryList()).isSameAs(existing); - } - - @Test - void setItemPurchasedRejectsUnknownItem() { - UUID userId = UUID.randomUUID(); - UUID listId = UUID.randomUUID(); - when(repository.findByIdAndUserId(listId, userId)).thenReturn(Optional.of(list(listId, userId, "List"))); - - assertThatThrownBy(() -> service.setItemPurchased(listId, UUID.randomUUID(), userId, true)) - .isInstanceOfSatisfying(ResponseStatusException.class, exception -> - assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND)); - } - - @Test - void createRejectsBlankName() { - assertThatThrownBy(() -> service.create(UUID.randomUUID(), new GroceryListCreateRequest(" ", List.of()))) - .isInstanceOfSatisfying(ResponseStatusException.class, exception -> - assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST)); - } - - private static GroceryList list(UUID id, UUID userId, String name, GroceryItem... items) { - GroceryList list = new GroceryList(); - list.setId(id); - list.setUserId(userId); - list.setName(name); - list.setCreatedAt(Instant.now()); - list.setOutdated(false); - list.setItems(new ArrayList<>(List.of(items))); - for (GroceryItem item : list.getItems()) { - item.setGroceryList(list); - } - return list; - } - - private static GroceryItem item(String name) { - GroceryItem item = new GroceryItem(); - item.setId(UUID.randomUUID()); - item.setName(name); - item.setUnit(""); - item.setCategory(GroceryCategory.OTHER); - item.setPurchased(false); - return item; + var created = service.create(userId, request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(GroceryList.class); + verify(repository).save(captor.capture()); + GroceryList saved = captor.getValue(); + assertThat(saved.getUserId()).isEqualTo(userId); + assertThat(saved.getName()).isEqualTo("Weekend shop"); + assertThat(saved.isOutdated()).isFalse(); + assertThat(saved.getCreatedAt()).isNotNull(); + assertThat(saved.getItems()).hasSize(2); + assertThat(saved.getItems().getFirst().getGroceryList()).isSameAs(saved); + assertThat(saved.getItems().getFirst().getCategory()).isEqualTo(GroceryCategory.DAIRY); + assertThat(saved.getItems().getFirst().isPurchased()).isTrue(); + + assertThat(created.groceryListId()).isEqualTo(saved.getId()); + assertThat(created.items()).extracting("name").containsExactly("Milk", "Basil"); + } + + @Test + void updateReplacesExistingItems() { + UUID userId = UUID.randomUUID(); + UUID listId = UUID.randomUUID(); + GroceryList existing = list(listId, userId, "Old", item("Old item")); + when(repository.findByIdAndUserId(listId, userId)).thenReturn(Optional.of(existing)); + when(repository.save(any(GroceryList.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + service.update( + listId, + userId, + new GroceryListCreateRequest( + "New", List.of(new GroceryItemRequestDTO("Tomato", 4.0, "pcs", "Produce", false)))); + + assertThat(existing.getName()).isEqualTo("New"); + assertThat(existing.getItems()).hasSize(1); + assertThat(existing.getItems().getFirst().getName()).isEqualTo("Tomato"); + assertThat(existing.getItems().getFirst().getGroceryList()).isSameAs(existing); + } + + @Test + void setItemPurchasedRejectsUnknownItem() { + UUID userId = UUID.randomUUID(); + UUID listId = UUID.randomUUID(); + when(repository.findByIdAndUserId(listId, userId)) + .thenReturn(Optional.of(list(listId, userId, "List"))); + + assertThatThrownBy(() -> service.setItemPurchased(listId, UUID.randomUUID(), userId, true)) + .isInstanceOfSatisfying( + ResponseStatusException.class, + exception -> assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND)); + } + + @Test + void createRejectsBlankName() { + assertThatThrownBy( + () -> service.create(UUID.randomUUID(), new GroceryListCreateRequest(" ", List.of()))) + .isInstanceOfSatisfying( + ResponseStatusException.class, + exception -> assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST)); + } + + private static GroceryList list(UUID id, UUID userId, String name, GroceryItem... items) { + GroceryList list = new GroceryList(); + list.setId(id); + list.setUserId(userId); + list.setName(name); + list.setCreatedAt(Instant.now()); + list.setOutdated(false); + list.setItems(new ArrayList<>(List.of(items))); + for (GroceryItem item : list.getItems()) { + item.setGroceryList(list); } + return list; + } + + private static GroceryItem item(String name) { + GroceryItem item = new GroceryItem(); + item.setId(UUID.randomUUID()); + item.setName(name); + item.setUnit(""); + item.setCategory(GroceryCategory.OTHER); + item.setPurchased(false); + return item; + } } diff --git a/server/user-service/pom.xml b/server/user-service/pom.xml index 3e942ed..a6b822d 100644 --- a/server/user-service/pom.xml +++ b/server/user-service/pom.xml @@ -94,6 +94,16 @@ -Djdk.attach.allowAttachSelf=true + + com.diffplug.spotless + spotless-maven-plugin + 2.44.5 + + + + + + diff --git a/server/user-service/src/main/java/com/bytebite/server/HealthController.java b/server/user-service/src/main/java/com/bytebite/server/HealthController.java index c90fb35..0479468 100644 --- a/server/user-service/src/main/java/com/bytebite/server/HealthController.java +++ b/server/user-service/src/main/java/com/bytebite/server/HealthController.java @@ -2,18 +2,17 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.Map; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.Map; - @RestController @Tag(name = "Health", description = "Service health checks") public class HealthController { - @GetMapping("/health") - @Operation(summary = "Check user-service health") - public Map health() { - return Map.of("status", "ok"); - } + @GetMapping("/health") + @Operation(summary = "Check user-service health") + public Map health() { + return Map.of("status", "ok"); + } } diff --git a/server/user-service/src/main/java/com/bytebite/server/OpenApiConfig.java b/server/user-service/src/main/java/com/bytebite/server/OpenApiConfig.java index 6d2c703..11003eb 100644 --- a/server/user-service/src/main/java/com/bytebite/server/OpenApiConfig.java +++ b/server/user-service/src/main/java/com/bytebite/server/OpenApiConfig.java @@ -1,30 +1,33 @@ package com.bytebite.server; -import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; -import io.swagger.v3.oas.models.servers.Server; import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import java.util.List; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import java.util.List; - @Configuration public class OpenApiConfig { - @Bean - public OpenAPI userServiceOpenAPI() { - return new OpenAPI() - .info(new Info() - .title("ByteBite User Service API") - .version("0.0.1") - .description("Authentication and current-user endpoints for ByteBite.")) - .servers(List.of(new Server().url("/"))) - .components(new Components() - .addSecuritySchemes("bearerAuth", new SecurityScheme() - .type(SecurityScheme.Type.HTTP) - .scheme("bearer") - .bearerFormat("JWT"))); - } + @Bean + public OpenAPI userServiceOpenAPI() { + return new OpenAPI() + .info( + new Info() + .title("ByteBite User Service API") + .version("0.0.1") + .description("Authentication and current-user endpoints for ByteBite.")) + .servers(List.of(new Server().url("/"))) + .components( + new Components() + .addSecuritySchemes( + "bearerAuth", + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))); + } } diff --git a/server/user-service/src/main/java/com/bytebite/server/ServerApplication.java b/server/user-service/src/main/java/com/bytebite/server/ServerApplication.java index 13d5438..6c431e6 100644 --- a/server/user-service/src/main/java/com/bytebite/server/ServerApplication.java +++ b/server/user-service/src/main/java/com/bytebite/server/ServerApplication.java @@ -6,8 +6,7 @@ @SpringBootApplication public class ServerApplication { - public static void main(String[] args) { - SpringApplication.run(ServerApplication.class, args); - } - + public static void main(String[] args) { + SpringApplication.run(ServerApplication.class, args); + } } diff --git a/server/user-service/src/main/java/com/bytebite/server/auth/AuthController.java b/server/user-service/src/main/java/com/bytebite/server/auth/AuthController.java index 27791b9..b618743 100644 --- a/server/user-service/src/main/java/com/bytebite/server/auth/AuthController.java +++ b/server/user-service/src/main/java/com/bytebite/server/auth/AuthController.java @@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.Map; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -20,94 +21,113 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; -import java.util.Map; - @RestController @Tag(name = "Authentication", description = "Registration, login, and current user endpoints") public class AuthController { - private final AuthService authService; + private final AuthService authService; - public AuthController(AuthService authService) { - this.authService = authService; - } + public AuthController(AuthService authService) { + this.authService = authService; + } - @PostMapping("/api/auth/register") - @Operation( - summary = "Register a new user", - responses = { - @ApiResponse(responseCode = "200", description = "User registered and JWT issued"), - @ApiResponse(responseCode = "400", description = "Invalid registration data", content = @Content(schema = @Schema(implementation = Map.class))), - @ApiResponse(responseCode = "409", description = "Email address is already registered", content = @Content(schema = @Schema(implementation = Map.class))) - } - ) - public AuthResponse register(@RequestBody RegisterRequest request) { - return authService.register(request); - } + @PostMapping("/api/auth/register") + @Operation( + summary = "Register a new user", + responses = { + @ApiResponse(responseCode = "200", description = "User registered and JWT issued"), + @ApiResponse( + responseCode = "400", + description = "Invalid registration data", + content = @Content(schema = @Schema(implementation = Map.class))), + @ApiResponse( + responseCode = "409", + description = "Email address is already registered", + content = @Content(schema = @Schema(implementation = Map.class))) + }) + public AuthResponse register(@RequestBody RegisterRequest request) { + return authService.register(request); + } - @PostMapping("/api/auth/login") - @Operation( - summary = "Log in", - responses = { - @ApiResponse(responseCode = "200", description = "Credentials accepted and JWT issued"), - @ApiResponse(responseCode = "401", description = "Invalid credentials", content = @Content(schema = @Schema(implementation = Map.class))) - } - ) - public AuthResponse login(@RequestBody LoginRequest request) { - return authService.login(request); - } + @PostMapping("/api/auth/login") + @Operation( + summary = "Log in", + responses = { + @ApiResponse(responseCode = "200", description = "Credentials accepted and JWT issued"), + @ApiResponse( + responseCode = "401", + description = "Invalid credentials", + content = @Content(schema = @Schema(implementation = Map.class))) + }) + public AuthResponse login(@RequestBody LoginRequest request) { + return authService.login(request); + } - @GetMapping("/api/users/me") - @Operation( - summary = "Get the current user", - security = @SecurityRequirement(name = "bearerAuth"), - responses = { - @ApiResponse(responseCode = "200", description = "Current authenticated user"), - @ApiResponse(responseCode = "401", description = "Missing, expired, or invalid JWT", content = @Content(schema = @Schema(implementation = Map.class))) - } - ) - public AuthResponse me(@Parameter(hidden = true) @RequestHeader(HttpHeaders.AUTHORIZATION) String authorization) { - return authService.currentUser(authorization); - } + @GetMapping("/api/users/me") + @Operation( + summary = "Get the current user", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "200", description = "Current authenticated user"), + @ApiResponse( + responseCode = "401", + description = "Missing, expired, or invalid JWT", + content = @Content(schema = @Schema(implementation = Map.class))) + }) + public AuthResponse me( + @Parameter(hidden = true) @RequestHeader(HttpHeaders.AUTHORIZATION) String authorization) { + return authService.currentUser(authorization); + } - @PatchMapping("/api/users/me") - @Operation( - summary = "Update the current user's name and email", - security = @SecurityRequirement(name = "bearerAuth"), - responses = { - @ApiResponse(responseCode = "200", description = "Profile updated and a fresh JWT issued"), - @ApiResponse(responseCode = "400", description = "Invalid profile data", content = @Content(schema = @Schema(implementation = Map.class))), - @ApiResponse(responseCode = "401", description = "Missing, expired, or invalid JWT", content = @Content(schema = @Schema(implementation = Map.class))), - @ApiResponse(responseCode = "409", description = "Email address is already registered", content = @Content(schema = @Schema(implementation = Map.class))) - } - ) - public AuthResponse updateProfile( - @Parameter(hidden = true) @RequestHeader(HttpHeaders.AUTHORIZATION) String authorization, - @RequestBody UpdateProfileRequest request - ) { - return authService.updateProfile(authorization, request); - } + @PatchMapping("/api/users/me") + @Operation( + summary = "Update the current user's name and email", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "200", description = "Profile updated and a fresh JWT issued"), + @ApiResponse( + responseCode = "400", + description = "Invalid profile data", + content = @Content(schema = @Schema(implementation = Map.class))), + @ApiResponse( + responseCode = "401", + description = "Missing, expired, or invalid JWT", + content = @Content(schema = @Schema(implementation = Map.class))), + @ApiResponse( + responseCode = "409", + description = "Email address is already registered", + content = @Content(schema = @Schema(implementation = Map.class))) + }) + public AuthResponse updateProfile( + @Parameter(hidden = true) @RequestHeader(HttpHeaders.AUTHORIZATION) String authorization, + @RequestBody UpdateProfileRequest request) { + return authService.updateProfile(authorization, request); + } - @PutMapping("/api/users/me/password") - @Operation( - summary = "Change the current user's password", - security = @SecurityRequirement(name = "bearerAuth"), - responses = { - @ApiResponse(responseCode = "204", description = "Password changed"), - @ApiResponse(responseCode = "400", description = "Invalid password data", content = @Content(schema = @Schema(implementation = Map.class))), - @ApiResponse(responseCode = "401", description = "Missing/invalid JWT or incorrect current password", content = @Content(schema = @Schema(implementation = Map.class))) - } - ) - @ResponseStatus(HttpStatus.NO_CONTENT) - public void updatePassword( - @Parameter(hidden = true) @RequestHeader(HttpHeaders.AUTHORIZATION) String authorization, - @RequestBody UpdatePasswordRequest request - ) { - authService.updatePassword(authorization, request); - } + @PutMapping("/api/users/me/password") + @Operation( + summary = "Change the current user's password", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "204", description = "Password changed"), + @ApiResponse( + responseCode = "400", + description = "Invalid password data", + content = @Content(schema = @Schema(implementation = Map.class))), + @ApiResponse( + responseCode = "401", + description = "Missing/invalid JWT or incorrect current password", + content = @Content(schema = @Schema(implementation = Map.class))) + }) + @ResponseStatus(HttpStatus.NO_CONTENT) + public void updatePassword( + @Parameter(hidden = true) @RequestHeader(HttpHeaders.AUTHORIZATION) String authorization, + @RequestBody UpdatePasswordRequest request) { + authService.updatePassword(authorization, request); + } - @org.springframework.web.bind.annotation.ExceptionHandler(ResponseStatusException.class) - public ResponseEntity> handleStatus(ResponseStatusException exception) { - HttpStatus status = HttpStatus.valueOf(exception.getStatusCode().value()); - return ResponseEntity.status(status).body(Map.of("message", exception.getReason())); - } + @org.springframework.web.bind.annotation.ExceptionHandler(ResponseStatusException.class) + public ResponseEntity> handleStatus(ResponseStatusException exception) { + HttpStatus status = HttpStatus.valueOf(exception.getStatusCode().value()); + return ResponseEntity.status(status).body(Map.of("message", exception.getReason())); + } } diff --git a/server/user-service/src/main/java/com/bytebite/server/auth/AuthResponse.java b/server/user-service/src/main/java/com/bytebite/server/auth/AuthResponse.java index 6231d59..3afb407 100644 --- a/server/user-service/src/main/java/com/bytebite/server/auth/AuthResponse.java +++ b/server/user-service/src/main/java/com/bytebite/server/auth/AuthResponse.java @@ -1,4 +1,3 @@ package com.bytebite.server.auth; -public record AuthResponse(String token, UserResponse user) { -} +public record AuthResponse(String token, UserResponse user) {} diff --git a/server/user-service/src/main/java/com/bytebite/server/auth/AuthService.java b/server/user-service/src/main/java/com/bytebite/server/auth/AuthService.java index 4879f3f..2e32387 100644 --- a/server/user-service/src/main/java/com/bytebite/server/auth/AuthService.java +++ b/server/user-service/src/main/java/com/bytebite/server/auth/AuthService.java @@ -7,103 +7,125 @@ @Service public class AuthService { - private final UserRepository userRepository; - private final JwtTokenService jwtTokenService; - private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(12); - - public AuthService(UserRepository userRepository, JwtTokenService jwtTokenService) { - this.userRepository = userRepository; - this.jwtTokenService = jwtTokenService; - } - - public AuthResponse register(RegisterRequest request) { - String name = normalizeRequired(request.name(), "Name"); - String email = normalizeEmail(request.email()); - String password = requirePassword(request.password()); - - userRepository.findByEmail(email).ifPresent(user -> { - throw new ResponseStatusException(HttpStatus.CONFLICT, "Email is already registered."); - }); - - UserRecord user = userRepository.create(name, email, passwordEncoder.encode(password)); - return responseFor(user); + private final UserRepository userRepository; + private final JwtTokenService jwtTokenService; + private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(12); + + public AuthService(UserRepository userRepository, JwtTokenService jwtTokenService) { + this.userRepository = userRepository; + this.jwtTokenService = jwtTokenService; + } + + public AuthResponse register(RegisterRequest request) { + String name = normalizeRequired(request.name(), "Name"); + String email = normalizeEmail(request.email()); + String password = requirePassword(request.password()); + + userRepository + .findByEmail(email) + .ifPresent( + user -> { + throw new ResponseStatusException( + HttpStatus.CONFLICT, "Email is already registered."); + }); + + UserRecord user = userRepository.create(name, email, passwordEncoder.encode(password)); + return responseFor(user); + } + + public AuthResponse login(LoginRequest request) { + String email = normalizeEmail(request.email()); + String password = normalizeRequired(request.password(), "Password"); + UserRecord user = + userRepository + .findByEmail(email) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.UNAUTHORIZED, "Invalid email or password.")); + + if (!passwordEncoder.matches(password, user.passwordHash())) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid email or password."); } - public AuthResponse login(LoginRequest request) { - String email = normalizeEmail(request.email()); - String password = normalizeRequired(request.password(), "Password"); - UserRecord user = userRepository.findByEmail(email) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid email or password.")); - - if (!passwordEncoder.matches(password, user.passwordHash())) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid email or password."); - } - - return responseFor(user); + return responseFor(user); + } + + public AuthResponse currentUser(String authorizationHeader) { + JwtTokenService.JwtUser jwtUser = jwtTokenService.verify(authorizationHeader); + UserRecord user = + userRepository + .findById(jwtUser.userId()) + .orElseThrow( + () -> + new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User no longer exists.")); + return responseFor(user); + } + + public AuthResponse updateProfile(String authorizationHeader, UpdateProfileRequest request) { + JwtTokenService.JwtUser jwtUser = jwtTokenService.verify(authorizationHeader); + String name = normalizeRequired(request.name(), "Name"); + String email = normalizeEmail(request.email()); + + userRepository + .findByEmail(email) + .ifPresent( + existing -> { + if (!existing.userId().equals(jwtUser.userId())) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, "Email is already registered."); + } + }); + + UserRecord user = userRepository.updateProfile(jwtUser.userId(), name, email); + return responseFor(user); + } + + public void updatePassword(String authorizationHeader, UpdatePasswordRequest request) { + JwtTokenService.JwtUser jwtUser = jwtTokenService.verify(authorizationHeader); + String currentPassword = normalizeRequired(request.currentPassword(), "Current password"); + String newPassword = requirePassword(request.newPassword()); + + UserRecord user = + userRepository + .findById(jwtUser.userId()) + .orElseThrow( + () -> + new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User no longer exists.")); + + if (!passwordEncoder.matches(currentPassword, user.passwordHash())) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Current password is incorrect."); } - public AuthResponse currentUser(String authorizationHeader) { - JwtTokenService.JwtUser jwtUser = jwtTokenService.verify(authorizationHeader); - UserRecord user = userRepository.findById(jwtUser.userId()) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User no longer exists.")); - return responseFor(user); - } + userRepository.updatePassword(jwtUser.userId(), passwordEncoder.encode(newPassword)); + } - public AuthResponse updateProfile(String authorizationHeader, UpdateProfileRequest request) { - JwtTokenService.JwtUser jwtUser = jwtTokenService.verify(authorizationHeader); - String name = normalizeRequired(request.name(), "Name"); - String email = normalizeEmail(request.email()); + private AuthResponse responseFor(UserRecord user) { + UserResponse userResponse = UserResponse.from(user); + return new AuthResponse(jwtTokenService.createToken(userResponse), userResponse); + } - userRepository.findByEmail(email).ifPresent(existing -> { - if (!existing.userId().equals(jwtUser.userId())) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "Email is already registered."); - } - }); - - UserRecord user = userRepository.updateProfile(jwtUser.userId(), name, email); - return responseFor(user); + private String normalizeEmail(String value) { + String email = normalizeRequired(value, "Email").toLowerCase(); + if (!email.matches("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Email is not valid."); } - - public void updatePassword(String authorizationHeader, UpdatePasswordRequest request) { - JwtTokenService.JwtUser jwtUser = jwtTokenService.verify(authorizationHeader); - String currentPassword = normalizeRequired(request.currentPassword(), "Current password"); - String newPassword = requirePassword(request.newPassword()); - - UserRecord user = userRepository.findById(jwtUser.userId()) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User no longer exists.")); - - if (!passwordEncoder.matches(currentPassword, user.passwordHash())) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Current password is incorrect."); - } - - userRepository.updatePassword(jwtUser.userId(), passwordEncoder.encode(newPassword)); - } - - private AuthResponse responseFor(UserRecord user) { - UserResponse userResponse = UserResponse.from(user); - return new AuthResponse(jwtTokenService.createToken(userResponse), userResponse); - } - - private String normalizeEmail(String value) { - String email = normalizeRequired(value, "Email").toLowerCase(); - if (!email.matches("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Email is not valid."); - } - return email; - } - - private String requirePassword(String value) { - String password = normalizeRequired(value, "Password"); - if (password.length() < 8) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Password must be at least 8 characters."); - } - return password; + return email; + } + + private String requirePassword(String value) { + String password = normalizeRequired(value, "Password"); + if (password.length() < 8) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Password must be at least 8 characters."); } + return password; + } - private String normalizeRequired(String value, String label) { - if (value == null || value.trim().isEmpty()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, label + " is required."); - } - return value.trim(); + private String normalizeRequired(String value, String label) { + if (value == null || value.trim().isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, label + " is required."); } + return value.trim(); + } } diff --git a/server/user-service/src/main/java/com/bytebite/server/auth/JwtTokenService.java b/server/user-service/src/main/java/com/bytebite/server/auth/JwtTokenService.java index 56ed4e5..d44d3e8 100644 --- a/server/user-service/src/main/java/com/bytebite/server/auth/JwtTokenService.java +++ b/server/user-service/src/main/java/com/bytebite/server/auth/JwtTokenService.java @@ -1,133 +1,140 @@ package com.bytebite.server.auth; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.web.server.ResponseStatusException; - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.Base64; import java.util.LinkedHashMap; import java.util.Map; import java.util.UUID; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; @Service public class JwtTokenService { - private static final Base64.Encoder BASE64_URL_ENCODER = Base64.getUrlEncoder().withoutPadding(); - private static final Base64.Decoder BASE64_URL_DECODER = Base64.getUrlDecoder(); - - private final byte[] secret; - private final long expirationSeconds; - - public JwtTokenService( - @Value("${auth.jwt.secret}") String secret, - @Value("${auth.jwt.expiration-seconds}") long expirationSeconds - ) { - if (secret == null || secret.length() < 32) { - throw new IllegalStateException("JWT secret must be at least 32 characters."); - } - this.secret = secret.getBytes(StandardCharsets.UTF_8); - this.expirationSeconds = expirationSeconds; - } + private static final Base64.Encoder BASE64_URL_ENCODER = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder BASE64_URL_DECODER = Base64.getUrlDecoder(); - public String createToken(UserResponse user) { - long now = Instant.now().getEpochSecond(); - String headerJson = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"; - String payloadJson = "{" - + "\"sub\":\"" + escape(user.userId().toString()) + "\"," - + "\"email\":\"" + escape(user.email()) + "\"," - + "\"name\":\"" + escape(user.name()) + "\"," - + "\"iat\":" + now + "," - + "\"exp\":" + (now + expirationSeconds) - + "}"; - String unsigned = encode(headerJson) + "." + encode(payloadJson); - return unsigned + "." + sign(unsigned); - } + private final byte[] secret; + private final long expirationSeconds; - public JwtUser verify(String authorizationHeader) { - if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { - throw unauthorized(); - } - String token = authorizationHeader.substring("Bearer ".length()).trim(); - String[] parts = token.split("\\."); - if (parts.length != 3) { - throw unauthorized(); - } - - String unsigned = parts[0] + "." + parts[1]; - if (!constantTimeEquals(sign(unsigned), parts[2])) { - throw unauthorized(); - } - - Map payload = parseFlatJson(new String(BASE64_URL_DECODER.decode(parts[1]), StandardCharsets.UTF_8)); - long expiresAt = Long.parseLong(payload.getOrDefault("exp", "0")); - if (expiresAt <= Instant.now().getEpochSecond()) { - throw unauthorized(); - } - - return new JwtUser(UUID.fromString(payload.get("sub")), payload.get("email"), payload.get("name")); + public JwtTokenService( + @Value("${auth.jwt.secret}") String secret, + @Value("${auth.jwt.expiration-seconds}") long expirationSeconds) { + if (secret == null || secret.length() < 32) { + throw new IllegalStateException("JWT secret must be at least 32 characters."); } - - private String encode(String json) { - return BASE64_URL_ENCODER.encodeToString(json.getBytes(StandardCharsets.UTF_8)); + this.secret = secret.getBytes(StandardCharsets.UTF_8); + this.expirationSeconds = expirationSeconds; + } + + public String createToken(UserResponse user) { + long now = Instant.now().getEpochSecond(); + String headerJson = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"; + String payloadJson = + "{" + + "\"sub\":\"" + + escape(user.userId().toString()) + + "\"," + + "\"email\":\"" + + escape(user.email()) + + "\"," + + "\"name\":\"" + + escape(user.name()) + + "\"," + + "\"iat\":" + + now + + "," + + "\"exp\":" + + (now + expirationSeconds) + + "}"; + String unsigned = encode(headerJson) + "." + encode(payloadJson); + return unsigned + "." + sign(unsigned); + } + + public JwtUser verify(String authorizationHeader) { + if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { + throw unauthorized(); } - - private String sign(String value) { - try { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(secret, "HmacSHA256")); - return BASE64_URL_ENCODER.encodeToString(mac.doFinal(value.getBytes(StandardCharsets.UTF_8))); - } catch (Exception exception) { - throw new IllegalStateException("Could not sign JWT.", exception); - } + String token = authorizationHeader.substring("Bearer ".length()).trim(); + String[] parts = token.split("\\."); + if (parts.length != 3) { + throw unauthorized(); } - private boolean constantTimeEquals(String expected, String actual) { - byte[] expectedBytes = expected.getBytes(StandardCharsets.UTF_8); - byte[] actualBytes = actual.getBytes(StandardCharsets.UTF_8); - if (expectedBytes.length != actualBytes.length) { - return false; - } - int result = 0; - for (int i = 0; i < expectedBytes.length; i++) { - result |= expectedBytes[i] ^ actualBytes[i]; - } - return result == 0; + String unsigned = parts[0] + "." + parts[1]; + if (!constantTimeEquals(sign(unsigned), parts[2])) { + throw unauthorized(); } - private Map parseFlatJson(String json) { - Map values = new LinkedHashMap<>(); - String body = json.substring(1, json.length() - 1); - for (String pair : body.split(",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)")) { - String[] parts = pair.split(":", 2); - if (parts.length == 2) { - values.put(unquote(parts[0]), unquote(parts[1])); - } - } - return values; + Map payload = + parseFlatJson(new String(BASE64_URL_DECODER.decode(parts[1]), StandardCharsets.UTF_8)); + long expiresAt = Long.parseLong(payload.getOrDefault("exp", "0")); + if (expiresAt <= Instant.now().getEpochSecond()) { + throw unauthorized(); } - private String unquote(String value) { - String trimmed = value.trim(); - if (trimmed.startsWith("\"") && trimmed.endsWith("\"")) { - return trimmed.substring(1, trimmed.length() - 1) - .replace("\\\"", "\"") - .replace("\\\\", "\\"); - } - return trimmed; + return new JwtUser( + UUID.fromString(payload.get("sub")), payload.get("email"), payload.get("name")); + } + + private String encode(String json) { + return BASE64_URL_ENCODER.encodeToString(json.getBytes(StandardCharsets.UTF_8)); + } + + private String sign(String value) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret, "HmacSHA256")); + return BASE64_URL_ENCODER.encodeToString(mac.doFinal(value.getBytes(StandardCharsets.UTF_8))); + } catch (Exception exception) { + throw new IllegalStateException("Could not sign JWT.", exception); } + } - private String escape(String value) { - return value.replace("\\", "\\\\").replace("\"", "\\\""); + private boolean constantTimeEquals(String expected, String actual) { + byte[] expectedBytes = expected.getBytes(StandardCharsets.UTF_8); + byte[] actualBytes = actual.getBytes(StandardCharsets.UTF_8); + if (expectedBytes.length != actualBytes.length) { + return false; } - - private ResponseStatusException unauthorized() { - return new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication is required."); + int result = 0; + for (int i = 0; i < expectedBytes.length; i++) { + result |= expectedBytes[i] ^ actualBytes[i]; + } + return result == 0; + } + + private Map parseFlatJson(String json) { + Map values = new LinkedHashMap<>(); + String body = json.substring(1, json.length() - 1); + for (String pair : body.split(",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)")) { + String[] parts = pair.split(":", 2); + if (parts.length == 2) { + values.put(unquote(parts[0]), unquote(parts[1])); + } } + return values; + } - public record JwtUser(UUID userId, String email, String name) { + private String unquote(String value) { + String trimmed = value.trim(); + if (trimmed.startsWith("\"") && trimmed.endsWith("\"")) { + return trimmed.substring(1, trimmed.length() - 1).replace("\\\"", "\"").replace("\\\\", "\\"); } + return trimmed; + } + + private String escape(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } + + private ResponseStatusException unauthorized() { + return new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication is required."); + } + + public record JwtUser(UUID userId, String email, String name) {} } diff --git a/server/user-service/src/main/java/com/bytebite/server/auth/LoginRequest.java b/server/user-service/src/main/java/com/bytebite/server/auth/LoginRequest.java index f43ccb1..6a4180e 100644 --- a/server/user-service/src/main/java/com/bytebite/server/auth/LoginRequest.java +++ b/server/user-service/src/main/java/com/bytebite/server/auth/LoginRequest.java @@ -1,4 +1,3 @@ package com.bytebite.server.auth; -public record LoginRequest(String email, String password) { -} +public record LoginRequest(String email, String password) {} diff --git a/server/user-service/src/main/java/com/bytebite/server/auth/RegisterRequest.java b/server/user-service/src/main/java/com/bytebite/server/auth/RegisterRequest.java index df475f2..e62f37b 100644 --- a/server/user-service/src/main/java/com/bytebite/server/auth/RegisterRequest.java +++ b/server/user-service/src/main/java/com/bytebite/server/auth/RegisterRequest.java @@ -1,4 +1,3 @@ package com.bytebite.server.auth; -public record RegisterRequest(String name, String email, String password) { -} +public record RegisterRequest(String name, String email, String password) {} diff --git a/server/user-service/src/main/java/com/bytebite/server/auth/UpdatePasswordRequest.java b/server/user-service/src/main/java/com/bytebite/server/auth/UpdatePasswordRequest.java index ea1bd5c..4d59223 100644 --- a/server/user-service/src/main/java/com/bytebite/server/auth/UpdatePasswordRequest.java +++ b/server/user-service/src/main/java/com/bytebite/server/auth/UpdatePasswordRequest.java @@ -1,4 +1,3 @@ package com.bytebite.server.auth; -public record UpdatePasswordRequest(String currentPassword, String newPassword) { -} \ No newline at end of file +public record UpdatePasswordRequest(String currentPassword, String newPassword) {} diff --git a/server/user-service/src/main/java/com/bytebite/server/auth/UpdateProfileRequest.java b/server/user-service/src/main/java/com/bytebite/server/auth/UpdateProfileRequest.java index e5616af..e3da98c 100644 --- a/server/user-service/src/main/java/com/bytebite/server/auth/UpdateProfileRequest.java +++ b/server/user-service/src/main/java/com/bytebite/server/auth/UpdateProfileRequest.java @@ -1,4 +1,3 @@ package com.bytebite.server.auth; -public record UpdateProfileRequest(String name, String email) { -} \ No newline at end of file +public record UpdateProfileRequest(String name, String email) {} diff --git a/server/user-service/src/main/java/com/bytebite/server/auth/UserRecord.java b/server/user-service/src/main/java/com/bytebite/server/auth/UserRecord.java index 2cc0d4f..72f4ac1 100644 --- a/server/user-service/src/main/java/com/bytebite/server/auth/UserRecord.java +++ b/server/user-service/src/main/java/com/bytebite/server/auth/UserRecord.java @@ -4,10 +4,4 @@ import java.util.UUID; public record UserRecord( - UUID userId, - String name, - String email, - String passwordHash, - OffsetDateTime createdAt -) { -} + UUID userId, String name, String email, String passwordHash, OffsetDateTime createdAt) {} diff --git a/server/user-service/src/main/java/com/bytebite/server/auth/UserRepository.java b/server/user-service/src/main/java/com/bytebite/server/auth/UserRepository.java index d6bc68a..f073f75 100644 --- a/server/user-service/src/main/java/com/bytebite/server/auth/UserRepository.java +++ b/server/user-service/src/main/java/com/bytebite/server/auth/UserRepository.java @@ -1,10 +1,5 @@ package com.bytebite.server.auth; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Repository; -import org.springframework.web.server.ResponseStatusException; - import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; @@ -12,126 +7,135 @@ import java.sql.SQLException; import java.util.Optional; import java.util.UUID; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Repository; +import org.springframework.web.server.ResponseStatusException; @Repository public class UserRepository { - private final String datasourceUrl; - private final String datasourceUsername; - private final String datasourcePassword; + private final String datasourceUrl; + private final String datasourceUsername; + private final String datasourcePassword; - public UserRepository( - @Value("${spring.datasource.url}") String datasourceUrl, - @Value("${spring.datasource.username}") String datasourceUsername, - @Value("${spring.datasource.password}") String datasourcePassword - ) { - this.datasourceUrl = datasourceUrl; - this.datasourceUsername = datasourceUsername; - this.datasourcePassword = datasourcePassword; - } + public UserRepository( + @Value("${spring.datasource.url}") String datasourceUrl, + @Value("${spring.datasource.username}") String datasourceUsername, + @Value("${spring.datasource.password}") String datasourcePassword) { + this.datasourceUrl = datasourceUrl; + this.datasourceUsername = datasourceUsername; + this.datasourcePassword = datasourcePassword; + } - public UserRecord create(String name, String email, String passwordHash) { - String sql = """ + public UserRecord create(String name, String email, String passwordHash) { + String sql = + """ INSERT INTO users (name, email, password_hash) VALUES (?, ?, ?) RETURNING user_id, name, email, password_hash, created_at """; - try (Connection connection = connect(); - PreparedStatement statement = connection.prepareStatement(sql)) { - statement.setString(1, name); - statement.setString(2, email); - statement.setString(3, passwordHash); - try (ResultSet resultSet = statement.executeQuery()) { - resultSet.next(); - return map(resultSet); - } - } catch (SQLException exception) { - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not create user."); - } + try (Connection connection = connect(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, name); + statement.setString(2, email); + statement.setString(3, passwordHash); + try (ResultSet resultSet = statement.executeQuery()) { + resultSet.next(); + return map(resultSet); + } + } catch (SQLException exception) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not create user."); } + } - public UserRecord updateProfile(UUID userId, String name, String email) { - String sql = """ + public UserRecord updateProfile(UUID userId, String name, String email) { + String sql = + """ UPDATE users SET name = ?, email = ? WHERE user_id = ? RETURNING user_id, name, email, password_hash, created_at """; - try (Connection connection = connect(); - PreparedStatement statement = connection.prepareStatement(sql)) { - statement.setString(1, name); - statement.setString(2, email); - statement.setObject(3, userId); - try (ResultSet resultSet = statement.executeQuery()) { - if (!resultSet.next()) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User no longer exists."); - } - return map(resultSet); - } - } catch (SQLException exception) { - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not update user."); + try (Connection connection = connect(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, name); + statement.setString(2, email); + statement.setObject(3, userId); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User no longer exists."); } + return map(resultSet); + } + } catch (SQLException exception) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not update user."); } + } - public UserRecord updatePassword(UUID userId, String passwordHash) { - String sql = """ + public UserRecord updatePassword(UUID userId, String passwordHash) { + String sql = + """ UPDATE users SET password_hash = ? WHERE user_id = ? RETURNING user_id, name, email, password_hash, created_at """; - try (Connection connection = connect(); - PreparedStatement statement = connection.prepareStatement(sql)) { - statement.setString(1, passwordHash); - statement.setObject(2, userId); - try (ResultSet resultSet = statement.executeQuery()) { - if (!resultSet.next()) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User no longer exists."); - } - return map(resultSet); - } - } catch (SQLException exception) { - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not update password."); + try (Connection connection = connect(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, passwordHash); + statement.setObject(2, userId); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User no longer exists."); } + return map(resultSet); + } + } catch (SQLException exception) { + throw new ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, "Could not update password."); } + } - public Optional findByEmail(String email) { - return find("SELECT user_id, name, email, password_hash, created_at FROM users WHERE email = ?", email); - } + public Optional findByEmail(String email) { + return find( + "SELECT user_id, name, email, password_hash, created_at FROM users WHERE email = ?", email); + } - public Optional findById(UUID userId) { - return find("SELECT user_id, name, email, password_hash, created_at FROM users WHERE user_id = ?", userId); - } + public Optional findById(UUID userId) { + return find( + "SELECT user_id, name, email, password_hash, created_at FROM users WHERE user_id = ?", + userId); + } - private Optional find(String sql, Object value) { - try (Connection connection = connect(); - PreparedStatement statement = connection.prepareStatement(sql)) { - if (value instanceof UUID uuid) { - statement.setObject(1, uuid); - } else { - statement.setString(1, String.valueOf(value)); - } - try (ResultSet resultSet = statement.executeQuery()) { - if (!resultSet.next()) { - return Optional.empty(); - } - return Optional.of(map(resultSet)); - } - } catch (SQLException exception) { - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not read user."); + private Optional find(String sql, Object value) { + try (Connection connection = connect(); + PreparedStatement statement = connection.prepareStatement(sql)) { + if (value instanceof UUID uuid) { + statement.setObject(1, uuid); + } else { + statement.setString(1, String.valueOf(value)); + } + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + return Optional.empty(); } + return Optional.of(map(resultSet)); + } + } catch (SQLException exception) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not read user."); } + } - private Connection connect() throws SQLException { - return DriverManager.getConnection(datasourceUrl, datasourceUsername, datasourcePassword); - } + private Connection connect() throws SQLException { + return DriverManager.getConnection(datasourceUrl, datasourceUsername, datasourcePassword); + } - private UserRecord map(ResultSet resultSet) throws SQLException { - return new UserRecord( - resultSet.getObject("user_id", UUID.class), - resultSet.getString("name"), - resultSet.getString("email"), - resultSet.getString("password_hash"), - resultSet.getObject("created_at", java.time.OffsetDateTime.class) - ); - } + private UserRecord map(ResultSet resultSet) throws SQLException { + return new UserRecord( + resultSet.getObject("user_id", UUID.class), + resultSet.getString("name"), + resultSet.getString("email"), + resultSet.getString("password_hash"), + resultSet.getObject("created_at", java.time.OffsetDateTime.class)); + } } diff --git a/server/user-service/src/main/java/com/bytebite/server/auth/UserResponse.java b/server/user-service/src/main/java/com/bytebite/server/auth/UserResponse.java index c29ed0f..6fe1647 100644 --- a/server/user-service/src/main/java/com/bytebite/server/auth/UserResponse.java +++ b/server/user-service/src/main/java/com/bytebite/server/auth/UserResponse.java @@ -4,7 +4,7 @@ import java.util.UUID; public record UserResponse(UUID userId, String name, String email, OffsetDateTime createdAt) { - public static UserResponse from(UserRecord user) { - return new UserResponse(user.userId(), user.name(), user.email(), user.createdAt()); - } + public static UserResponse from(UserRecord user) { + return new UserResponse(user.userId(), user.name(), user.email(), user.createdAt()); + } } diff --git a/server/user-service/src/test/java/com/bytebite/server/ServerApplicationTests.java b/server/user-service/src/test/java/com/bytebite/server/ServerApplicationTests.java index 2d6d85b..e16ea95 100644 --- a/server/user-service/src/test/java/com/bytebite/server/ServerApplicationTests.java +++ b/server/user-service/src/test/java/com/bytebite/server/ServerApplicationTests.java @@ -6,8 +6,6 @@ @SpringBootTest class ServerApplicationTests { - @Test - void contextLoads() { - } - + @Test + void contextLoads() {} } diff --git a/server/user-service/src/test/java/com/bytebite/server/auth/AuthServiceTest.java b/server/user-service/src/test/java/com/bytebite/server/auth/AuthServiceTest.java index 0e280fe..05d9908 100644 --- a/server/user-service/src/test/java/com/bytebite/server/auth/AuthServiceTest.java +++ b/server/user-service/src/test/java/com/bytebite/server/auth/AuthServiceTest.java @@ -1,167 +1,198 @@ package com.bytebite.server.auth; -import org.junit.jupiter.api.Test; -import org.springframework.http.HttpStatus; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.web.server.ResponseStatusException; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.time.OffsetDateTime; import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.web.server.ResponseStatusException; class AuthServiceTest { - private final InMemoryUserRepository users = new InMemoryUserRepository(); - private final JwtTokenService tokens = new JwtTokenService("test-secret-with-at-least-32-chars", 3600); - private final AuthService service = new AuthService(users, tokens); - - @Test - void registerNormalizesEmailHashesPasswordAndReturnsToken() { - AuthResponse response = service.register(new RegisterRequest(" Ada Lovelace ", " ADA@Example.COM ", "correct horse")); - - assertThat(response.token()).isNotBlank(); - assertThat(response.user().name()).isEqualTo("Ada Lovelace"); - assertThat(response.user().email()).isEqualTo("ada@example.com"); - - UserRecord stored = users.findByEmail("ada@example.com").orElseThrow(); - assertThat(stored.name()).isEqualTo("Ada Lovelace"); - assertThat(stored.passwordHash()).isNotEqualTo("correct horse"); - assertThat(new BCryptPasswordEncoder().matches("correct horse", stored.passwordHash())).isTrue(); - } - - @Test - void registerRejectsDuplicateEmail() { + private final InMemoryUserRepository users = new InMemoryUserRepository(); + private final JwtTokenService tokens = + new JwtTokenService("test-secret-with-at-least-32-chars", 3600); + private final AuthService service = new AuthService(users, tokens); + + @Test + void registerNormalizesEmailHashesPasswordAndReturnsToken() { + AuthResponse response = + service.register( + new RegisterRequest(" Ada Lovelace ", " ADA@Example.COM ", "correct horse")); + + assertThat(response.token()).isNotBlank(); + assertThat(response.user().name()).isEqualTo("Ada Lovelace"); + assertThat(response.user().email()).isEqualTo("ada@example.com"); + + UserRecord stored = users.findByEmail("ada@example.com").orElseThrow(); + assertThat(stored.name()).isEqualTo("Ada Lovelace"); + assertThat(stored.passwordHash()).isNotEqualTo("correct horse"); + assertThat(new BCryptPasswordEncoder().matches("correct horse", stored.passwordHash())) + .isTrue(); + } + + @Test + void registerRejectsDuplicateEmail() { + service.register(new RegisterRequest("Ada", "ada@example.com", "correct horse")); + + assertThatThrownBy( + () -> + service.register( + new RegisterRequest("Ada Two", "ADA@example.com", "another password"))) + .isInstanceOfSatisfying( + ResponseStatusException.class, + exception -> assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.CONFLICT)); + } + + @Test + void loginRejectsWrongPassword() { + service.register(new RegisterRequest("Ada", "ada@example.com", "correct horse")); + + assertThatThrownBy(() -> service.login(new LoginRequest("ada@example.com", "wrong password"))) + .isInstanceOfSatisfying( + ResponseStatusException.class, + exception -> assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED)); + } + + @Test + void currentUserLoadsUserReferencedByJwt() { + AuthResponse registered = service.register(new RegisterRequest("Ada", "ada@example.com", "correct horse")); - assertThatThrownBy(() -> service.register(new RegisterRequest("Ada Two", "ADA@example.com", "another password"))) - .isInstanceOfSatisfying(ResponseStatusException.class, exception -> - assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.CONFLICT)); - } + AuthResponse current = service.currentUser("Bearer " + registered.token()); + + assertThat(current.user()).isEqualTo(registered.user()); + assertThat(tokens.verify("Bearer " + current.token()).userId()) + .isEqualTo(registered.user().userId()); + } - @Test - void loginRejectsWrongPassword() { + @Test + void updateProfileRejectsEmailWithoutAtSymbol() { + AuthResponse registered = + service.register(new RegisterRequest("Ada", "ada@example.com", "correct horse")); + String auth = "Bearer " + registered.token(); + + assertThatThrownBy( + () -> service.updateProfile(auth, new UpdateProfileRequest("Ada", "ada.example.com"))) + .isInstanceOfSatisfying( + ResponseStatusException.class, + exception -> assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST)); + + // The stored email is untouched, so the user can still log in. + assertThat(users.findByEmail("ada@example.com")).isPresent(); + assertThat(service.login(new LoginRequest("ada@example.com", "correct horse")).token()) + .isNotBlank(); + } + + @Test + void updateProfileRejectsEmailAlreadyUsedByAnotherUser() { + service.register(new RegisterRequest("Grace", "grace@example.com", "correct horse")); + AuthResponse ada = + service.register(new RegisterRequest("Ada", "ada@example.com", "correct horse")); + String auth = "Bearer " + ada.token(); + + assertThatThrownBy( + () -> service.updateProfile(auth, new UpdateProfileRequest("Ada", "grace@example.com"))) + .isInstanceOfSatisfying( + ResponseStatusException.class, + exception -> assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.CONFLICT)); + } + + @Test + void updateProfileAllowsKeepingYourOwnEmailAndReissuesToken() { + AuthResponse registered = service.register(new RegisterRequest("Ada", "ada@example.com", "correct horse")); - assertThatThrownBy(() -> service.login(new LoginRequest("ada@example.com", "wrong password"))) - .isInstanceOfSatisfying(ResponseStatusException.class, exception -> - assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED)); - } + AuthResponse updated = + service.updateProfile( + "Bearer " + registered.token(), + new UpdateProfileRequest("Ada Lovelace", "ada@example.com")); - @Test - void currentUserLoadsUserReferencedByJwt() { - AuthResponse registered = service.register(new RegisterRequest("Ada", "ada@example.com", "correct horse")); + assertThat(updated.user().name()).isEqualTo("Ada Lovelace"); + assertThat(tokens.verify("Bearer " + updated.token()).name()).isEqualTo("Ada Lovelace"); + } - AuthResponse current = service.currentUser("Bearer " + registered.token()); + @Test + void updatePasswordRejectsWrongCurrentPassword() { + AuthResponse registered = + service.register(new RegisterRequest("Ada", "ada@example.com", "correct horse")); + String auth = "Bearer " + registered.token(); + + assertThatThrownBy( + () -> service.updatePassword(auth, new UpdatePasswordRequest("wrong", "new password"))) + .isInstanceOfSatisfying( + ResponseStatusException.class, + exception -> assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED)); + } + + @Test + void updatePasswordReplacesHashSoOnlyTheNewPasswordLogsIn() { + AuthResponse registered = + service.register(new RegisterRequest("Ada", "ada@example.com", "correct horse")); - assertThat(current.user()).isEqualTo(registered.user()); - assertThat(tokens.verify("Bearer " + current.token()).userId()).isEqualTo(registered.user().userId()); - } + service.updatePassword( + "Bearer " + registered.token(), new UpdatePasswordRequest("correct horse", "new password")); - @Test - void updateProfileRejectsEmailWithoutAtSymbol() { - AuthResponse registered = service.register(new RegisterRequest("Ada", "ada@example.com", "correct horse")); - String auth = "Bearer " + registered.token(); + assertThat(service.login(new LoginRequest("ada@example.com", "new password")).token()) + .isNotBlank(); + assertThatThrownBy(() -> service.login(new LoginRequest("ada@example.com", "correct horse"))) + .isInstanceOfSatisfying( + ResponseStatusException.class, + exception -> assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED)); + } - assertThatThrownBy(() -> service.updateProfile(auth, new UpdateProfileRequest("Ada", "ada.example.com"))) - .isInstanceOfSatisfying(ResponseStatusException.class, exception -> - assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST)); + private static final class InMemoryUserRepository extends UserRepository { + private final Map byId = new LinkedHashMap<>(); + private final Map byEmail = new LinkedHashMap<>(); - // The stored email is untouched, so the user can still log in. - assertThat(users.findByEmail("ada@example.com")).isPresent(); - assertThat(service.login(new LoginRequest("ada@example.com", "correct horse")).token()).isNotBlank(); + private InMemoryUserRepository() { + super("jdbc:unused", "unused", "unused"); } - @Test - void updateProfileRejectsEmailAlreadyUsedByAnotherUser() { - service.register(new RegisterRequest("Grace", "grace@example.com", "correct horse")); - AuthResponse ada = service.register(new RegisterRequest("Ada", "ada@example.com", "correct horse")); - String auth = "Bearer " + ada.token(); - - assertThatThrownBy(() -> service.updateProfile(auth, new UpdateProfileRequest("Ada", "grace@example.com"))) - .isInstanceOfSatisfying(ResponseStatusException.class, exception -> - assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.CONFLICT)); + @Override + public UserRecord create(String name, String email, String passwordHash) { + UserRecord user = + new UserRecord(UUID.randomUUID(), name, email, passwordHash, OffsetDateTime.now()); + byId.put(user.userId(), user); + byEmail.put(user.email(), user); + return user; } - @Test - void updateProfileAllowsKeepingYourOwnEmailAndReissuesToken() { - AuthResponse registered = service.register(new RegisterRequest("Ada", "ada@example.com", "correct horse")); - - AuthResponse updated = service.updateProfile( - "Bearer " + registered.token(), new UpdateProfileRequest("Ada Lovelace", "ada@example.com")); - - assertThat(updated.user().name()).isEqualTo("Ada Lovelace"); - assertThat(tokens.verify("Bearer " + updated.token()).name()).isEqualTo("Ada Lovelace"); + @Override + public UserRecord updateProfile(UUID userId, String name, String email) { + UserRecord existing = byId.get(userId); + UserRecord updated = + new UserRecord(userId, name, email, existing.passwordHash(), existing.createdAt()); + byEmail.remove(existing.email()); + byId.put(userId, updated); + byEmail.put(email, updated); + return updated; } - @Test - void updatePasswordRejectsWrongCurrentPassword() { - AuthResponse registered = service.register(new RegisterRequest("Ada", "ada@example.com", "correct horse")); - String auth = "Bearer " + registered.token(); - - assertThatThrownBy(() -> service.updatePassword(auth, new UpdatePasswordRequest("wrong", "new password"))) - .isInstanceOfSatisfying(ResponseStatusException.class, exception -> - assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED)); + @Override + public UserRecord updatePassword(UUID userId, String passwordHash) { + UserRecord existing = byId.get(userId); + UserRecord updated = + new UserRecord( + userId, existing.name(), existing.email(), passwordHash, existing.createdAt()); + byId.put(userId, updated); + byEmail.put(updated.email(), updated); + return updated; } - @Test - void updatePasswordReplacesHashSoOnlyTheNewPasswordLogsIn() { - AuthResponse registered = service.register(new RegisterRequest("Ada", "ada@example.com", "correct horse")); - - service.updatePassword("Bearer " + registered.token(), new UpdatePasswordRequest("correct horse", "new password")); - - assertThat(service.login(new LoginRequest("ada@example.com", "new password")).token()).isNotBlank(); - assertThatThrownBy(() -> service.login(new LoginRequest("ada@example.com", "correct horse"))) - .isInstanceOfSatisfying(ResponseStatusException.class, exception -> - assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED)); + @Override + public Optional findByEmail(String email) { + return Optional.ofNullable(byEmail.get(email)); } - private static final class InMemoryUserRepository extends UserRepository { - private final Map byId = new LinkedHashMap<>(); - private final Map byEmail = new LinkedHashMap<>(); - - private InMemoryUserRepository() { - super("jdbc:unused", "unused", "unused"); - } - - @Override - public UserRecord create(String name, String email, String passwordHash) { - UserRecord user = new UserRecord(UUID.randomUUID(), name, email, passwordHash, OffsetDateTime.now()); - byId.put(user.userId(), user); - byEmail.put(user.email(), user); - return user; - } - - @Override - public UserRecord updateProfile(UUID userId, String name, String email) { - UserRecord existing = byId.get(userId); - UserRecord updated = new UserRecord(userId, name, email, existing.passwordHash(), existing.createdAt()); - byEmail.remove(existing.email()); - byId.put(userId, updated); - byEmail.put(email, updated); - return updated; - } - - @Override - public UserRecord updatePassword(UUID userId, String passwordHash) { - UserRecord existing = byId.get(userId); - UserRecord updated = new UserRecord(userId, existing.name(), existing.email(), passwordHash, existing.createdAt()); - byId.put(userId, updated); - byEmail.put(updated.email(), updated); - return updated; - } - - @Override - public Optional findByEmail(String email) { - return Optional.ofNullable(byEmail.get(email)); - } - - @Override - public Optional findById(UUID userId) { - return Optional.ofNullable(byId.get(userId)); - } + @Override + public Optional findById(UUID userId) { + return Optional.ofNullable(byId.get(userId)); } + } } diff --git a/server/user-service/src/test/java/com/bytebite/server/auth/JwtTokenServiceTest.java b/server/user-service/src/test/java/com/bytebite/server/auth/JwtTokenServiceTest.java index 58c0039..819768e 100644 --- a/server/user-service/src/test/java/com/bytebite/server/auth/JwtTokenServiceTest.java +++ b/server/user-service/src/test/java/com/bytebite/server/auth/JwtTokenServiceTest.java @@ -1,48 +1,53 @@ package com.bytebite.server.auth; -import org.junit.jupiter.api.Test; -import org.springframework.http.HttpStatus; -import org.springframework.web.server.ResponseStatusException; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.time.OffsetDateTime; import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; class JwtTokenServiceTest { - private final JwtTokenService service = new JwtTokenService("test-secret-with-at-least-32-chars", 3600); - - @Test - void createdTokenCanBeVerified() { - UUID userId = UUID.randomUUID(); - UserResponse user = new UserResponse(userId, "Ada \"Countess\"", "ada@example.com", OffsetDateTime.now()); - - JwtTokenService.JwtUser verified = service.verify("Bearer " + service.createToken(user)); - - assertThat(verified.userId()).isEqualTo(userId); - assertThat(verified.email()).isEqualTo("ada@example.com"); - assertThat(verified.name()).isEqualTo("Ada \"Countess\""); - } - - @Test - void tamperedSignatureIsRejected() { - UserResponse user = new UserResponse(UUID.randomUUID(), "Ada", "ada@example.com", OffsetDateTime.now()); - String token = service.createToken(user); - String tampered = token.substring(0, token.length() - 2) + "xx"; - - assertThatThrownBy(() -> service.verify("Bearer " + tampered)) - .isInstanceOfSatisfying(ResponseStatusException.class, exception -> - assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED)); - } - - @Test - void expiredTokenIsRejected() { - JwtTokenService expiringService = new JwtTokenService("test-secret-with-at-least-32-chars", -1); - UserResponse user = new UserResponse(UUID.randomUUID(), "Ada", "ada@example.com", OffsetDateTime.now()); - - assertThatThrownBy(() -> expiringService.verify("Bearer " + expiringService.createToken(user))) - .isInstanceOfSatisfying(ResponseStatusException.class, exception -> - assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED)); - } + private final JwtTokenService service = + new JwtTokenService("test-secret-with-at-least-32-chars", 3600); + + @Test + void createdTokenCanBeVerified() { + UUID userId = UUID.randomUUID(); + UserResponse user = + new UserResponse(userId, "Ada \"Countess\"", "ada@example.com", OffsetDateTime.now()); + + JwtTokenService.JwtUser verified = service.verify("Bearer " + service.createToken(user)); + + assertThat(verified.userId()).isEqualTo(userId); + assertThat(verified.email()).isEqualTo("ada@example.com"); + assertThat(verified.name()).isEqualTo("Ada \"Countess\""); + } + + @Test + void tamperedSignatureIsRejected() { + UserResponse user = + new UserResponse(UUID.randomUUID(), "Ada", "ada@example.com", OffsetDateTime.now()); + String token = service.createToken(user); + String tampered = token.substring(0, token.length() - 2) + "xx"; + + assertThatThrownBy(() -> service.verify("Bearer " + tampered)) + .isInstanceOfSatisfying( + ResponseStatusException.class, + exception -> assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED)); + } + + @Test + void expiredTokenIsRejected() { + JwtTokenService expiringService = new JwtTokenService("test-secret-with-at-least-32-chars", -1); + UserResponse user = + new UserResponse(UUID.randomUUID(), "Ada", "ada@example.com", OffsetDateTime.now()); + + assertThatThrownBy(() -> expiringService.verify("Bearer " + expiringService.createToken(user))) + .isInstanceOfSatisfying( + ResponseStatusException.class, + exception -> assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED)); + } }