Compare commits

..

11 Commits

Author SHA1 Message Date
Jesse Brault
a38ac6b6f5 Add inference service. 2025-12-31 14:05:07 -06:00
Jesse Brault
0b62e06646 Move recipes ai-search to POST endpoint. 2025-12-29 12:53:20 -06:00
Jesse Brault
9f54c63c53 Add basic AI search. 2025-12-28 21:22:13 -06:00
Jesse Brault
120e6d90e1 Revert "Upgrade to Spring Boot 4.0.1."
This reverts commit b714593194.

# Conflicts:
#	build.gradle
#	src/integrationTest/java/app/mealsmadeeasy/api/auth/AuthControllerTests.java
2025-12-27 19:05:06 -06:00
Jesse Brault
dce0db0385 Fix integration tests, all passing. 2025-12-27 18:48:10 -06:00
Jesse Brault
cf8ebe984b Fix AuthControllerTests. 2025-12-26 23:32:28 -06:00
Jesse Brault
2642f6100e Fix app smokescreen and ImageController integration tests for use with Postgres. 2025-12-26 23:26:51 -06:00
Jesse Brault
b9e7ccedce Fix tests to compile. 2025-12-26 13:50:18 -06:00
Jesse Brault
1fefeaa1da Change to flyway migrations, many SQL/entity updates. 2025-12-26 13:45:27 -06:00
Jesse Brault
52136a34bd Turn off auto-commit in hikari so that LOBs may be retrieved easily. 2025-12-25 13:49:07 -06:00
Jesse Brault
fe6784e7fe Switch to Postgres. 2025-12-25 13:41:53 -06:00
71 changed files with 1315 additions and 755 deletions

View File

@ -1,6 +1,6 @@
plugins {
id 'java'
id 'org.springframework.boot' version '4.0.1'
id 'org.springframework.boot' version '3.5.9'
id 'io.spring.dependency-management' version '1.1.7'
}
@ -43,18 +43,28 @@ configurations {
}
}
ext {
set('springAiVersion', "1.1.2")
}
dependencies {
// From Spring Initalizr
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'org.springframework.boot:spring-boot-starter-jackson'
runtimeOnly 'com.mysql:mysql-connector-j'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testImplementation 'org.springframework.boot:spring-boot-starter-jackson-test'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.ai:spring-ai-advisors-vector-store'
implementation 'org.springframework.ai:spring-ai-starter-model-ollama'
implementation 'org.springframework.ai:spring-ai-starter-vector-store-pgvector'
implementation 'org.hibernate.orm:hibernate-vector:6.6.39.Final'
runtimeOnly 'org.postgresql:postgresql'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
// flyway
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-database-postgresql'
// Custom
implementation 'io.jsonwebtoken:jjwt-api:0.13.0'
implementation 'io.jsonwebtoken:jjwt-jackson:0.13.0'
@ -62,7 +72,7 @@ dependencies {
implementation 'org.commonmark:commonmark:0.27.0'
implementation 'org.jsoup:jsoup:1.21.2'
implementation 'tools.jackson.dataformat:jackson-dataformat-yaml:3.0.3'
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1'
implementation 'io.minio:minio:8.6.0'
@ -78,14 +88,20 @@ dependencies {
runtimeOnly 'org.apache.xmlgraphics:batik-all:1.19'
// Custom testing
testRuntimeOnly 'com.h2database:h2'
testImplementation 'org.testcontainers:testcontainers:1.21.4'
testImplementation 'org.testcontainers:junit-jupiter:1.21.4'
testImplementation "org.testcontainers:minio:1.21.4"
testImplementation 'org.testcontainers:postgresql:1.21.4'
testImplementation 'org.testcontainers:minio:1.21.4'
testFixturesImplementation 'org.hamcrest:hamcrest:3.0'
}
dependencyManagement {
imports {
mavenBom "org.springframework.ai:spring-ai-bom:${springAiVersion}"
}
}
tasks.register('integrationTest', Test) {
description = 'Run integration tests.'
group = 'verification'

View File

@ -1,26 +1,26 @@
name: meals-made-easy-api-dev
services:
db:
image: mysql:latest
image: pgvector/pgvector:pg18-trixie
ports:
- '55001:3306'
- '55000:33060'
- "5432:5432"
env_file: .env
environment:
MYSQL_DATABASE: meals_made_easy_api
MYSQL_USER: meals-made-easy-api-user
POSTGRES_DB: meals_made_easy_api
POSTGRES_USER: meals-made-easy-api-user
healthcheck:
test: mysqladmin ping -u $$MYSQL_USER --password=$$MYSQL_PASSWORD
test: pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB
interval: 5s
timeout: 10s
retries: 10
start_period: 10s
volumes:
- mysql-data:/var/lib/mysql
- postgres-data:/var/lib/postgresql
minio:
image: minio/minio:latest
ports:
- 9000:9000
- 9001:9001
- "9000:9000"
- "9001:9001"
env_file:
- .env
environment:
@ -32,8 +32,6 @@ services:
- /data
- --console-address
- :9001
profiles:
- deps
volumes:
mysql-data:
postgres-data:
minio-data:

View File

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View File

@ -0,0 +1,19 @@
package app.mealsmadeeasy.api;
import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.testcontainers.containers.PostgreSQLContainer;
public class IntegrationTestsExtension implements BeforeAllCallback {
@Override
public void beforeAll(ExtensionContext context) {
final PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("pgvector/pgvector:pg18-trixie")
.withDatabaseName("meals_made_easy_api");
postgres.start();
System.setProperty("spring.datasource.url", postgres.getJdbcUrl());
System.setProperty("spring.datasource.username", postgres.getUsername());
System.setProperty("spring.datasource.password", postgres.getPassword());
}
}

View File

@ -1,12 +1,14 @@
package app.mealsmadeeasy.api;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class MealsMadeEasyApiApplicationTests {
@ExtendWith(IntegrationTestsExtension.class)
public class MealsMadeEasyApiApplicationTests {
@Test
void contextLoads() {}
public void contextLoads() {}
}

View File

@ -1,22 +1,23 @@
package app.mealsmadeeasy.api.auth;
import app.mealsmadeeasy.api.IntegrationTestsExtension;
import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.user.UserCreateException;
import app.mealsmadeeasy.api.user.UserService;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import tools.jackson.databind.ObjectMapper;
import java.util.Map;
import java.util.UUID;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
@ -27,8 +28,11 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@SpringBootTest
@AutoConfigureMockMvc
@ExtendWith(IntegrationTestsExtension.class)
public class AuthControllerTests {
private static final String TEST_PASSWORD = "test";
@Autowired
private ObjectMapper objectMapper;
@ -38,43 +42,38 @@ public class AuthControllerTests {
@Autowired
private UserService userService;
private User createTestUser() {
private User seedUser() {
final String uuid = UUID.randomUUID().toString();
try {
return this.userService.createUser("test", "test@test.com", "test");
return this.userService.createUser(uuid, uuid + "@test.com", TEST_PASSWORD);
} catch (UserCreateException e) {
throw new RuntimeException(e);
}
}
private MockHttpServletRequestBuilder getLoginRequest() {
final Map<String, ?> body = Map.of(
"username", "test",
"password", "test"
private MockHttpServletRequestBuilder getLoginRequest(String username, String password) throws Exception {
final Map<String, Object> body = Map.of(
"username", username,
"password", password
);
return post("/auth/login")
.content(this.objectMapper.writeValueAsString(body))
.contentType(MediaType.APPLICATION_JSON)
.with(user("test").password("test"));
}
@BeforeEach
public void setup() {
final User testUser = this.createTestUser();
System.out.println("Created testUser: " + testUser);
.with(user(username).password(password));
}
@Test
@DirtiesContext
public void simpleLogin() throws Exception {
this.mockMvc.perform(this.getLoginRequest())
final User user = this.seedUser();
this.mockMvc.perform(this.getLoginRequest(user.getUsername(), TEST_PASSWORD))
.andExpect(status().isOk())
.andExpect(jsonPath("$.username").value("test"))
.andExpect(jsonPath("$.username").value(user.getUsername()))
.andExpect(jsonPath("$.accessToken").isString())
.andExpect(cookie().exists("refresh-token"));
}
private Cookie getRefreshTokenCookie() throws Exception {
final MvcResult loginResult = this.mockMvc.perform(this.getLoginRequest()).andReturn();
private Cookie getRefreshTokenCookie(String username, String password) throws Exception {
final MvcResult loginResult = this.mockMvc.perform(this.getLoginRequest(username, password)).andReturn();
final Cookie refreshTokenCookie = loginResult.getResponse().getCookie("refresh-token");
if (refreshTokenCookie == null) {
throw new NullPointerException("refreshTokenCookie is null");
@ -83,24 +82,24 @@ public class AuthControllerTests {
}
@Test
@DirtiesContext
public void simpleLogout() throws Exception {
final User user = this.seedUser();
final MockHttpServletRequestBuilder req = post("/auth/logout")
.cookie(this.getRefreshTokenCookie());
.cookie(this.getRefreshTokenCookie(user.getUsername(), TEST_PASSWORD));
this.mockMvc.perform(req)
.andExpect(status().isOk())
.andExpect(cookie().maxAge("refresh-token", 0));
}
@Test
@DirtiesContext
public void simpleRefresh() throws Exception {
final Cookie firstRefreshTokenCookie = this.getRefreshTokenCookie();
final User user = this.seedUser();
final Cookie firstRefreshTokenCookie = this.getRefreshTokenCookie(user.getUsername(), TEST_PASSWORD);
final MockHttpServletRequestBuilder req = post("/auth/refresh")
.cookie(firstRefreshTokenCookie);
final MvcResult res = this.mockMvc.perform(req)
.andExpect(status().isOk())
.andExpect(jsonPath("$.username").value("test"))
.andExpect(jsonPath("$.username").value(user.getUsername()))
.andExpect(jsonPath("$.accessToken").isString())
.andExpect(cookie().exists("refresh-token"))
.andReturn();

View File

@ -1,5 +1,6 @@
package app.mealsmadeeasy.api.image;
import app.mealsmadeeasy.api.IntegrationTestsExtension;
import app.mealsmadeeasy.api.auth.AuthService;
import app.mealsmadeeasy.api.auth.LoginException;
import app.mealsmadeeasy.api.image.body.ImageUpdateInfoBody;
@ -8,13 +9,14 @@ import app.mealsmadeeasy.api.image.spec.ImageUpdateInfoSpec;
import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.user.UserCreateException;
import app.mealsmadeeasy.api.user.UserService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
@ -22,12 +24,11 @@ import org.testcontainers.containers.MinIOContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import tools.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.io.InputStream;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.UUID;
import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.Matchers.empty;
@ -38,10 +39,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@Testcontainers
@SpringBootTest
@AutoConfigureMockMvc
@ExtendWith(IntegrationTestsExtension.class)
public class ImageControllerTests {
private static final String USER_FILENAME = "HAL9000.svg";
@Container
private static final MinIOContainer container = new MinIOContainer(
DockerImageName.parse("minio/minio:latest")
@ -54,7 +54,7 @@ public class ImageControllerTests {
registry.add("app.mealsmadeeasy.api.minio.secretKey", container::getPassword);
}
private static InputStream getHal9000() {
private static InputStream getHal9000InputStream() {
return ImageControllerTests.class.getResourceAsStream("HAL9000.svg");
}
@ -73,65 +73,73 @@ public class ImageControllerTests {
@Autowired
private ObjectMapper objectMapper;
private User createTestUser(String username) {
private static final String TEST_PASSWORD = "test";
private static final long HAL_SIZE = 27881L;
private static String makeUserFilename() {
return UUID.randomUUID() + ".svg";
}
private User seedUser() {
final String uuid = UUID.randomUUID().toString();
try {
return this.userService.createUser(username, username + "@test.com", "test");
return this.userService.createUser(uuid, uuid + "@test.com", TEST_PASSWORD);
} catch (UserCreateException e) {
throw new RuntimeException(e);
}
}
private Image createHal9000(User owner) throws ImageException, IOException {
try (final InputStream hal9000 = getHal9000()) {
private Image seedImage(User owner, ImageCreateInfoSpec spec) {
try {
return this.imageService.create(
owner,
USER_FILENAME,
hal9000,
27881L,
new ImageCreateInfoSpec()
makeUserFilename(),
getHal9000InputStream(),
HAL_SIZE,
spec
);
} catch (IOException | ImageException e) {
throw new RuntimeException(e);
}
}
private String getAccessToken(String username) {
private Image seedImage(User owner) {
return this.seedImage(owner, new ImageCreateInfoSpec());
}
private static String getImageUrl(User owner, Image image) {
return "/images/" + owner.getUsername() + "/" + image.getUserFilename();
}
private String getAccessToken(User user) {
try {
return this.authService.login(username, "test").getAccessToken().getToken();
return this.authService.login(user.getUsername(), TEST_PASSWORD).getAccessToken().getToken();
} catch (LoginException e) {
throw new RuntimeException(e);
}
}
private Image makePublic(Image image, User modifier) {
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
spec.setPublic(true);
return this.imageService.update(image, modifier, spec);
}
private Image addViewer(Image image, User modifier, User viewerToAdd) {
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
spec.setViewersToAdd(Set.of(viewerToAdd));
return this.imageService.update(image, modifier, spec);
}
@Test
@DirtiesContext
public void getImageNoPrincipal() throws Exception {
final User owner = this.createTestUser("imageOwner");
final Image image = this.createHal9000(owner);
this.makePublic(image, owner);
try (final InputStream hal9000 = getHal9000()) {
public void getPublicImageNoPrincipal() throws Exception {
final User owner = this.seedUser();
final ImageCreateInfoSpec spec = new ImageCreateInfoSpec();
spec.setPublic(true);
final Image image = this.seedImage(owner, spec);
// Assert bytes the same and proper mime type
try (final InputStream hal9000 = getHal9000InputStream()) {
final byte[] halBytes = hal9000.readAllBytes();
this.mockMvc.perform(get("/images/imageOwner/HAL9000.svg"))
this.mockMvc.perform(get(getImageUrl(owner, image)))
.andExpect(status().isOk())
.andExpect(content().contentType("image/svg+xml"))
.andExpect(content().bytes(halBytes));
}
}
private void doGetImageTestWithViewer(String accessToken) throws Exception {
try (final InputStream hal9000 = getHal9000()) {
private void doGetImageTestWithAccessToken(User owner, Image image, String accessToken) throws Exception {
try (final InputStream hal9000 = getHal9000InputStream()) {
final byte[] halBytes = hal9000.readAllBytes();
this.mockMvc.perform(get("/images/imageOwner/HAL9000.svg")
this.mockMvc.perform(get(getImageUrl(owner, image))
.header("Authorization", "Bearer " + accessToken))
.andExpect(status().isOk())
.andExpect(content().contentType("image/svg+xml"))
@ -140,88 +148,73 @@ public class ImageControllerTests {
}
@Test
@DirtiesContext
public void getImageWithOwner() throws Exception {
final User owner = this.createTestUser("imageOwner");
this.createHal9000(owner);
final String accessToken = this.getAccessToken(owner.getUsername());
this.doGetImageTestWithViewer(accessToken);
public void getImageForOwner() throws Exception {
final User owner = this.seedUser();
final Image image = this.seedImage(owner);
this.doGetImageTestWithAccessToken(owner, image, this.getAccessToken(owner));
}
@Test
@DirtiesContext
public void getImageWithViewer() throws Exception {
final User owner = this.createTestUser("imageOwner");
final User viewer = this.createTestUser("viewer");
final Image image = this.createHal9000(owner);
this.addViewer(image, owner, viewer);
final String accessToken = this.getAccessToken(viewer.getUsername());
this.doGetImageTestWithViewer(accessToken);
public void getImageForViewer() throws Exception {
final User owner = this.seedUser();
final User viewer = this.seedUser();
final Image image = this.seedImage(owner);
// add viewer
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
spec.setViewersToAdd(Set.of(viewer));
this.imageService.update(image, owner, spec);
this.doGetImageTestWithAccessToken(owner, image, this.getAccessToken(viewer));
}
@Test
@DirtiesContext
public void getNonPublicImageNoPrincipalForbidden() throws Exception {
final User owner = this.createTestUser("imageOwner");
this.createHal9000(owner);
this.mockMvc.perform(
get("/images/imageOwner/HAL9000.svg")
).andExpect(status().isForbidden());
final User owner = this.seedUser();
final Image image = this.seedImage(owner);
this.mockMvc.perform(get(getImageUrl(owner, image)))
.andExpect(status().isForbidden());
}
@Test
@DirtiesContext
public void getNonPublicImageWithPrincipalForbidden() throws Exception {
final User owner = this.createTestUser("imageOwner");
final User viewer = this.createTestUser("viewer");
this.createHal9000(owner);
final String accessToken = this.getAccessToken(viewer.getUsername());
final User owner = this.seedUser();
final Image image = this.seedImage(owner);
final User nonViewer = this.seedUser();
final String nonViewerToken = this.getAccessToken(nonViewer);
this.mockMvc.perform(
get("/images/imageOwner/HAL9000.svg")
.header("Authorization", "Bearer " + accessToken)
get(getImageUrl(owner, image))
.header("Authorization", "Bearer " + nonViewerToken)
).andExpect(status().isForbidden());
}
@Test
@DirtiesContext
public void getImageWithViewersNoPrincipalForbidden() throws Exception {
final User owner = this.createTestUser("imageOwner");
final User viewer = this.createTestUser("viewer");
final Image image = this.createHal9000(owner);
this.addViewer(image, owner, viewer);
this.mockMvc.perform(
get("/images/imageOwner/HAL9000.svg")
).andExpect(status().isForbidden());
final User owner = this.seedUser();
final Image image = this.seedImage(owner);
final User viewer = this.seedUser();
// add viewer
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
spec.setViewersToAdd(Set.of(viewer));
this.imageService.update(image, owner, spec);
this.mockMvc.perform(get(getImageUrl(owner, image))).andExpect(status().isForbidden());
}
@Test
@DirtiesContext
public void getImageWithViewersWrongViewerForbidden() throws Exception {
final User owner = this.createTestUser("imageOwner");
final User viewer = this.createTestUser("viewer");
final User wrongViewer = this.createTestUser("wrongViewer");
final Image image = this.createHal9000(owner);
this.addViewer(image, owner, viewer);
final String accessToken = this.getAccessToken(wrongViewer.getUsername());
this.mockMvc.perform(
get("/images/imageOwner/HAL9000.svg")
.header("Authorization", "Bearer " + accessToken)
).andExpect(status().isForbidden());
}
@Test
@DirtiesContext
public void putImage() throws Exception {
final User owner = this.createTestUser("imageOwner");
final String accessToken = this.getAccessToken(owner.getUsername());
try (final InputStream hal9000 = getHal9000()) {
final User owner = this.seedUser();
final String accessToken = this.getAccessToken(owner);
try (final InputStream hal9000 = getHal9000InputStream()) {
final String userFilename = makeUserFilename();
final MockMultipartFile mockMultipartFile = new MockMultipartFile(
"image", "HAL9000.svg", "image/svg+xml", hal9000
"image", userFilename, "image/svg+xml", hal9000
);
this.mockMvc.perform(
multipart("/images")
.file(mockMultipartFile)
.param("filename", "HAL9000.svg")
.param("filename", userFilename)
.param("alt", "HAL 9000")
.param("caption", "HAL 9000, from 2001: A Space Odyssey")
.param("isPublic", "true")
@ -234,31 +227,26 @@ public class ImageControllerTests {
.andExpect(status().isCreated())
.andExpect(jsonPath("$.created").exists())
.andExpect(jsonPath("$.modified").value(nullValue()))
.andExpect(jsonPath("$.filename").value(USER_FILENAME))
.andExpect(jsonPath("$.filename").value(userFilename))
.andExpect(jsonPath("$.mimeType").value("image/svg+xml"))
.andExpect(jsonPath("$.alt").value("HAL 9000"))
.andExpect(jsonPath("$.caption").value("HAL 9000, from 2001: A Space Odyssey"))
.andExpect(jsonPath("$.isPublic").value(true))
.andExpect(jsonPath("$.owner.username").value("imageOwner"))
.andExpect(jsonPath("$.owner.username").value(owner.getUsername()))
.andExpect(jsonPath("$.owner.id").value(owner.getId()))
.andExpect(jsonPath("$.viewers").value(empty()));
}
}
private String prepUpdate() throws ImageException, IOException {
final User owner = this.createTestUser("imageOwner");
this.createHal9000(owner);
return this.getAccessToken(owner.getUsername());
}
@Test
@DirtiesContext
public void updateAlt() throws Exception {
final String accessToken = this.prepUpdate();
final User owner = this.seedUser();
final Image image = this.seedImage(owner);
final String accessToken = this.getAccessToken(owner);
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
body.setAlt("HAL 9000");
this.mockMvc.perform(
post("/images/imageOwner/HAL9000.svg")
post(getImageUrl(owner, image))
.contentType(MediaType.APPLICATION_JSON)
.content(this.objectMapper.writeValueAsString(body))
.header("Authorization", "Bearer " + accessToken)
@ -269,13 +257,14 @@ public class ImageControllerTests {
}
@Test
@DirtiesContext
public void updateCaption() throws Exception {
final String accessToken = this.prepUpdate();
final User owner = this.seedUser();
final Image image = this.seedImage(owner);
final String accessToken = this.getAccessToken(owner);
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
body.setCaption("HAL 9000 from 2001: A Space Odyssey");
this.mockMvc.perform(
post("/images/imageOwner/HAL9000.svg")
post(getImageUrl(owner, image))
.contentType(MediaType.APPLICATION_JSON)
.content(this.objectMapper.writeValueAsString(body))
.header("Authorization", "Bearer " + accessToken)
@ -286,13 +275,14 @@ public class ImageControllerTests {
}
@Test
@DirtiesContext
public void updateIsPublic() throws Exception {
final String accessToken = this.prepUpdate();
final User owner = this.seedUser();
final Image image = this.seedImage(owner);
final String accessToken = this.getAccessToken(owner);
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
body.setPublic(true);
this.mockMvc.perform(
post("/images/imageOwner/HAL9000.svg")
post(getImageUrl(owner, image))
.contentType(MediaType.APPLICATION_JSON)
.content(this.objectMapper.writeValueAsString(body))
.header("Authorization", "Bearer " + accessToken)
@ -303,16 +293,18 @@ public class ImageControllerTests {
}
@Test
@DirtiesContext
public void addViewers() throws Exception {
final String accessToken = this.prepUpdate();
final User owner = this.seedUser();
final User viewerToAdd = this.seedUser();
final Image image = this.seedImage(owner);
final String accessToken = this.getAccessToken(owner);
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
final Set<String> viewerUsernames = Set.of(this.createTestUser("imageViewer")).stream()
.map(User::getUsername)
.collect(Collectors.toSet());
final Set<String> viewerUsernames = Set.of(viewerToAdd.getUsername());
body.setViewersToAdd(viewerUsernames);
this.mockMvc.perform(
post("/images/imageOwner/HAL9000.svg")
post(getImageUrl(owner, image))
.contentType(MediaType.APPLICATION_JSON)
.content(this.objectMapper.writeValueAsString(body))
.header("Authorization", "Bearer " + accessToken)
@ -320,15 +312,15 @@ public class ImageControllerTests {
.andExpect(status().isOk())
.andExpect(jsonPath("$.modified").value(notNullValue()))
.andExpect(jsonPath("$.viewers").value(not(empty())))
.andExpect(jsonPath("$.viewers[0].username").value("imageViewer"));
.andExpect(jsonPath("$.viewers[0].username").value(viewerToAdd.getUsername()));
}
private record OwnerViewerImage(User owner, User viewer, Image image) {}
private OwnerViewerImage prepOwnerViewerImage() throws ImageException, IOException {
final User owner = this.createTestUser("imageOwner");
final User viewer = this.createTestUser("imageViewer");
final Image image = this.createHal9000(owner);
private OwnerViewerImage prepOwnerViewerImage() {
final User owner = this.seedUser();
final User viewer = this.seedUser();
final Image image = this.seedImage(owner);
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
spec.setViewersToAdd(Set.of(viewer));
this.imageService.update(image, owner, spec);
@ -336,14 +328,15 @@ public class ImageControllerTests {
}
@Test
@DirtiesContext
public void removeViewers() throws Exception {
final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage();
final String accessToken = this.getAccessToken(ownerViewerImage.owner().getUsername());
final String accessToken = this.getAccessToken(ownerViewerImage.owner());
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
body.setViewersToRemove(Set.of(ownerViewerImage.viewer().getUsername()));
this.mockMvc.perform(
post("/images/imageOwner/HAL9000.svg")
post(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image()))
.contentType(MediaType.APPLICATION_JSON)
.content(this.objectMapper.writeValueAsString(body))
.header("Authorization", "Bearer " + accessToken)
@ -354,14 +347,13 @@ public class ImageControllerTests {
}
@Test
@DirtiesContext
public void clearAllViewers() throws Exception {
final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage();
final String accessToken = this.getAccessToken(ownerViewerImage.owner().getUsername());
final String accessToken = this.getAccessToken(ownerViewerImage.owner());
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
body.setClearAllViewers(true);
this.mockMvc.perform(
post("/images/imageOwner/HAL9000.svg")
post(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image()))
.contentType(MediaType.APPLICATION_JSON)
.content(this.objectMapper.writeValueAsString(body))
.header("Authorization", "Bearer " + accessToken)
@ -372,14 +364,13 @@ public class ImageControllerTests {
}
@Test
@DirtiesContext
public void updateInfoByViewerForbidden() throws Exception {
final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage();
final String accessToken = this.getAccessToken(ownerViewerImage.viewer().getUsername()); // viewer
final String accessToken = this.getAccessToken(ownerViewerImage.viewer()); // viewer
final ImageUpdateInfoBody body = new ImageUpdateInfoBody();
this.mockMvc.perform(
post("/images/imageOwner/HAL9000.svg")
.contentType(MediaType.APPLICATION_JSON )
post(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image()))
.contentType(MediaType.APPLICATION_JSON)
.content(this.objectMapper.writeValueAsString(body))
.header("Authorization", "Bearer " + accessToken)
)
@ -389,13 +380,12 @@ public class ImageControllerTests {
}
@Test
@DirtiesContext
public void deleteImageWithOwner() throws Exception {
final User owner = this.createTestUser("imageOwner");
final Image image = this.createHal9000(owner);
final String accessToken = this.getAccessToken(owner.getUsername());
final User owner = this.seedUser();
final Image image = this.seedImage(owner);
final String accessToken = this.getAccessToken(owner);
this.mockMvc.perform(
delete("/images/imageOwner/HAL9000.svg")
delete(getImageUrl(owner, image))
.header("Authorization", "Bearer " + accessToken)
)
.andExpect(status().isNoContent());
@ -403,12 +393,11 @@ public class ImageControllerTests {
}
@Test
@DirtiesContext
public void deleteImageByViewerForbidden() throws Exception {
final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage();
final String accessToken = this.getAccessToken(ownerViewerImage.viewer().getUsername());
final String accessToken = this.getAccessToken(ownerViewerImage.viewer());
this.mockMvc.perform(
delete("/images/imageOwner/HAL9000.svg")
delete(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image()))
.header("Authorization", "Bearer " + accessToken)
)
.andExpect(status().isForbidden());

View File

@ -1,14 +1,15 @@
package app.mealsmadeeasy.api.image;
import app.mealsmadeeasy.api.IntegrationTestsExtension;
import app.mealsmadeeasy.api.image.spec.ImageCreateInfoSpec;
import app.mealsmadeeasy.api.image.spec.ImageUpdateInfoSpec;
import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.user.UserCreateException;
import app.mealsmadeeasy.api.user.UserService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.MinIOContainer;
@ -20,6 +21,7 @@ import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static app.mealsmadeeasy.api.image.ContainsImagesMatcher.containsImages;
import static app.mealsmadeeasy.api.user.ContainsUsersMatcher.containsUsers;
@ -30,13 +32,13 @@ import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.notNullValue;
import static org.junit.Assert.assertThrows;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
@Testcontainers
@SpringBootTest
@ExtendWith(IntegrationTestsExtension.class)
public class S3ImageServiceTests {
private static final String USER_FILENAME = "HAL9000.svg";
@Container
private static final MinIOContainer container = new MinIOContainer(
DockerImageName.parse("minio/minio:latest")
@ -49,7 +51,7 @@ public class S3ImageServiceTests {
registry.add("app.mealsmadeeasy.api.minio.secretKey", container::getPassword);
}
private static InputStream getHal9000() {
private static InputStream getHal9000InputStream() {
return S3ImageServiceTests.class.getResourceAsStream("HAL9000.svg");
}
@ -59,26 +61,40 @@ public class S3ImageServiceTests {
@Autowired
private ImageService imageService;
private User createTestUser(String username) {
private static final String TEST_PASSWORD = "test";
private static final long HAL_LENGTH = 27881L;
private User seedUser() {
final String uuid = UUID.randomUUID().toString();
try {
return this.userService.createUser(username, username + "@test.com", "test");
return this.userService.createUser(uuid, uuid + "@test.com", TEST_PASSWORD);
} catch (UserCreateException e) {
throw new RuntimeException(e);
}
}
private Image createHal9000(User owner) throws ImageException, IOException {
try (final InputStream hal9000 = getHal9000()) {
private static String makeUserFilename() {
return UUID.randomUUID() + ".svg";
}
private Image seedImage(User owner, ImageCreateInfoSpec spec) {
try (final InputStream hal9000 = getHal9000InputStream()) {
return this.imageService.create(
owner,
USER_FILENAME,
makeUserFilename(),
hal9000,
27881L,
new ImageCreateInfoSpec()
HAL_LENGTH,
spec
);
} catch (ImageException | IOException e) {
throw new RuntimeException(e);
}
}
private Image seedImage(User owner) {
return this.seedImage(owner, new ImageCreateInfoSpec());
}
private Image makePublic(Image image, User modifier) {
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
spec.setPublic(true);
@ -89,14 +105,13 @@ public class S3ImageServiceTests {
public void smokeScreen() {}
@Test
@DirtiesContext
public void simpleCreate() throws ImageException, IOException {
final User owner = this.createTestUser("imageOwner");
final Image image = this.createHal9000(owner);
public void simpleCreate() {
final User owner = this.seedUser();
final Image image = this.seedImage(owner);
assertThat(image.getOwner(), isUser(owner));
assertThat(image.getCreated(), is(notNullValue()));
assertThat(image.getModified(), is(nullValue()));
assertThat(image.getUserFilename(), is("HAL9000.svg"));
assertThat(image.getUserFilename(), is(notNullValue()));
assertThat(image.getMimeType(), is("image/svg+xml"));
assertThat(image.getAlt(), is(nullValue()));
assertThat(image.getCaption(), is(nullValue()));
@ -105,54 +120,55 @@ public class S3ImageServiceTests {
}
@Test
@DirtiesContext
public void loadImageWithOwnerAsViewer() throws ImageException, IOException {
final User owner = this.createTestUser("imageOwner");
final Image image = this.createHal9000(owner);
try (final InputStream stored =
this.imageService.getImageContent(image, owner)) {
final byte[] storedBytes = stored.readAllBytes();
assertThat(storedBytes.length, is(27881));
}
public void properlyLoadsContent() throws IOException {
final User owner = this.seedUser();
final Image image = this.seedImage(owner);
final InputStream content = assertDoesNotThrow(() -> this.imageService.getImageContent(image, owner));
//noinspection DataFlowIssue
final byte[] contentBytes = content.readAllBytes();
assertThat(contentBytes.length, is((int) HAL_LENGTH));
content.close();
}
@Test
@DirtiesContext
public void loadPublicImage() throws ImageException, IOException {
final User owner = this.createTestUser("imageOwner");
Image image = this.createHal9000(owner);
image = this.makePublic(image, owner);
try (final InputStream stored =
this.imageService.getImageContent(image, null)) {
final byte[] storedBytes = stored.readAllBytes();
assertThat(storedBytes.length, is(27881));
}
public void loadImageWithOwnerAsViewer() throws IOException {
final User owner = this.seedUser();
final Image image = this.seedImage(owner);
final InputStream content = assertDoesNotThrow(() -> this.imageService.getImageContent(image, owner));
//noinspection DataFlowIssue
content.close();
}
@Test
@DirtiesContext
public void loadImageWithViewer() throws ImageException, IOException {
final User owner = this.createTestUser("imageOwner");
final User viewer = this.createTestUser("imageViewer");
Image image = this.createHal9000(owner);
public void loadPublicImage() throws IOException {
final User owner = this.seedUser();
final Image seedImage = this.seedImage(owner);
final Image publicImage = this.makePublic(seedImage, owner);
final InputStream content = assertDoesNotThrow(() -> this.imageService.getImageContent(publicImage, null));
//noinspection DataFlowIssue
content.close();
}
@Test
public void loadImageWithViewer() throws IOException {
final User owner = this.seedUser();
final User viewer = this.seedUser();
Image seedImage = this.seedImage(owner);
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
spec.setViewersToAdd(Set.of(viewer));
image = this.imageService.update(image, owner, spec);
try (final InputStream stored =
this.imageService.getImageContent(image, viewer)) {
final byte[] storedBytes = stored.readAllBytes();
assertThat(storedBytes.length, is(27881));
}
final Image imageWithViewer = this.imageService.update(seedImage, owner, spec);
final InputStream content = assertDoesNotThrow(() -> this.imageService.getImageContent(imageWithViewer, viewer));
//noinspection DataFlowIssue
content.close();
}
@Test
@DirtiesContext
public void getImagesOwnedBy() throws ImageException, IOException {
final User owner = this.createTestUser("imageOwner");
final User otherOwner = this.createTestUser("otherImageOwner");
final Image image0 = this.createHal9000(owner);
final Image image1 = this.createHal9000(owner);
final Image image2 = this.createHal9000(otherOwner);
public void getImagesOwnedBy() {
final User owner = this.seedUser();
final User otherOwner = this.seedUser();
final Image image0 = this.seedImage(owner);
final Image image1 = this.seedImage(owner);
final Image image2 = this.seedImage(otherOwner);
final List<Image> ownedImages = this.imageService.getImagesOwnedBy(owner);
assertThat(ownedImages.size(), is(2));
@ -164,10 +180,9 @@ public class S3ImageServiceTests {
}
@Test
@DirtiesContext
public void updateAlt() throws Exception {
final User owner = this.createTestUser("imageOwner");
Image image = this.createHal9000(owner);
public void updateAlt() {
final User owner = this.seedUser();
Image image = this.seedImage(owner);
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
spec.setAlt("HAL 9000");
image = this.imageService.update(image, owner, spec);
@ -175,10 +190,9 @@ public class S3ImageServiceTests {
}
@Test
@DirtiesContext
public void updateCaption() throws Exception {
final User owner = this.createTestUser("imageOwner");
Image image = this.createHal9000(owner);
public void updateCaption() {
final User owner = this.seedUser();
Image image = this.seedImage(owner);
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
spec.setCaption("HAL 9000 from 2001: A Space Odyssey");
image = this.imageService.update(image, owner, spec);
@ -186,10 +200,9 @@ public class S3ImageServiceTests {
}
@Test
@DirtiesContext
public void updateIsPublic() throws Exception {
final User owner = this.createTestUser("imageOwner");
Image image = this.createHal9000(owner);
public void updateIsPublic() {
final User owner = this.seedUser();
Image image = this.seedImage(owner);
final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec();
spec.setPublic(true);
image = this.imageService.update(image, owner, spec);
@ -203,21 +216,19 @@ public class S3ImageServiceTests {
}
@Test
@DirtiesContext
public void addViewers() throws Exception {
final User owner = this.createTestUser("imageOwner");
final User viewer = this.createTestUser("imageViewer");
Image image = this.createHal9000(owner);
public void addViewers() {
final User owner = this.seedUser();
final User viewer = this.seedUser();
Image image = this.seedImage(owner);
image = this.addViewer(image, owner, viewer);
assertThat(image.getViewers(), containsUsers(viewer));
}
@Test
@DirtiesContext
public void removeViewers() throws Exception {
final User owner = this.createTestUser("imageOwner");
final User viewer = this.createTestUser("imageViewer");
Image image = this.createHal9000(owner);
public void removeViewers() {
final User owner = this.seedUser();
final User viewer = this.seedUser();
Image image = this.seedImage(owner);
image = this.addViewer(image, owner, viewer);
assertThat(image.getViewers(), containsUsers(viewer));
@ -228,11 +239,10 @@ public class S3ImageServiceTests {
}
@Test
@DirtiesContext
public void clearAllViewers() throws Exception {
final User owner = this.createTestUser("imageOwner");
final User viewer = this.createTestUser("imageViewer");
Image image = this.createHal9000(owner);
public void clearAllViewers() {
final User owner = this.seedUser();
final User viewer = this.seedUser();
Image image = this.seedImage(owner);
image = this.addViewer(image, owner, viewer);
assertThat(image.getViewers(), containsUsers(viewer));
@ -243,10 +253,9 @@ public class S3ImageServiceTests {
}
@Test
@DirtiesContext
public void deleteImage() throws Exception {
final User owner = this.createTestUser("imageOwner");
final Image image = this.createHal9000(owner);
final User owner = this.seedUser();
final Image image = this.seedImage(owner);
this.imageService.deleteImage(image, owner);
assertThrows(ImageException.class, () -> this.imageService.getById(image.getId(), owner));
}

View File

@ -1,5 +1,6 @@
package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.IntegrationTestsExtension;
import app.mealsmadeeasy.api.auth.AuthService;
import app.mealsmadeeasy.api.auth.LoginDetails;
import app.mealsmadeeasy.api.auth.LoginException;
@ -13,12 +14,14 @@ import app.mealsmadeeasy.api.recipe.star.RecipeStarService;
import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.user.UserCreateException;
import app.mealsmadeeasy.api.user.UserService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
@ -26,12 +29,11 @@ import org.testcontainers.containers.MinIOContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import tools.jackson.databind.ObjectMapper;
import java.io.InputStream;
import java.util.UUID;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.nullValue;
import static org.hamcrest.Matchers.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ -39,6 +41,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@Testcontainers
@SpringBootTest
@AutoConfigureMockMvc
@ExtendWith(IntegrationTestsExtension.class)
public class RecipeControllerTests {
@Container
@ -78,17 +81,20 @@ public class RecipeControllerTests {
@Autowired
private ObjectMapper objectMapper;
private User createTestUser(String username) {
private static final String TEST_PASSWORD = "test";
private User seedUser() {
final String uuid = UUID.randomUUID().toString();
try {
return this.userService.createUser(username, username + "@test.com", "test");
return this.userService.createUser(uuid, uuid + "@test.com", TEST_PASSWORD);
} catch (UserCreateException e) {
throw new RuntimeException(e);
}
}
private Recipe createTestRecipe(User owner, boolean isPublic, String slug) {
private Recipe createTestRecipe(User owner, boolean isPublic) {
final RecipeCreateSpec spec = new RecipeCreateSpec();
spec.setSlug(slug);
spec.setSlug(UUID.randomUUID().toString());
spec.setTitle("Test Recipe");
spec.setPreparationTime(10);
spec.setCookingTime(20);
@ -98,12 +104,8 @@ public class RecipeControllerTests {
return this.recipeService.create(owner, spec);
}
private Recipe createTestRecipe(User owner, boolean isPublic) {
return this.createTestRecipe(owner, isPublic, "test-recipe");
}
private String getAccessToken(User user) throws LoginException {
return this.authService.login(user.getUsername(), "test")
return this.authService.login(user.getUsername(), TEST_PASSWORD)
.getAccessToken()
.getToken();
}
@ -112,7 +114,7 @@ public class RecipeControllerTests {
try (final InputStream hal9000 = getHal9000()) {
return this.imageService.create(
owner,
"HAL9000.svg",
UUID.randomUUID() + ".svg",
hal9000,
27881L,
new ImageCreateInfoSpec()
@ -123,15 +125,14 @@ public class RecipeControllerTests {
}
@Test
@DirtiesContext
public void getRecipePageViewByIdPublicRecipeNoPrincipal() throws Exception {
final User owner = this.createTestUser("owner");
final User owner = this.seedUser();
final Recipe recipe = this.createTestRecipe(owner, true);
this.mockMvc.perform(
get("/recipes/{username}/{slug}", recipe.getOwner().getUsername(), recipe.getSlug())
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.recipe.id").value(1))
.andExpect(jsonPath("$.recipe.id").value(recipe.getId()))
.andExpect(jsonPath("$.recipe.created").exists()) // TODO: better matching of exact LocalDateTime
.andExpect(jsonPath("$.recipe.modified").doesNotExist())
.andExpect(jsonPath("$.recipe.slug").value(recipe.getSlug()))
@ -152,9 +153,8 @@ public class RecipeControllerTests {
}
@Test
@DirtiesContext
public void getFullRecipeViewIncludeRawText() throws Exception {
final User owner = this.createTestUser("owner");
final User owner = this.seedUser();
final Recipe recipe = this.createTestRecipe(owner, true);
this.mockMvc.perform(
get(
@ -168,11 +168,10 @@ public class RecipeControllerTests {
}
@Test
@DirtiesContext
public void getFullRecipeViewPrincipalIsStarer() throws Exception {
final User owner = this.createTestUser("owner");
final User owner = this.seedUser();
final Recipe recipe = this.createTestRecipe(owner, false);
this.recipeStarService.create(recipe.getId(), owner.getUsername());
this.recipeStarService.create(recipe.getId(), owner.getId());
final String accessToken = this.getAccessToken(owner);
this.mockMvc.perform(
get("/recipes/{username}/{slug}", recipe.getOwner().getUsername(), recipe.getSlug())
@ -183,9 +182,8 @@ public class RecipeControllerTests {
}
@Test
@DirtiesContext
public void getFullRecipeViewPrincipalIsNotStarer() throws Exception {
final User owner = this.createTestUser("owner");
final User owner = this.seedUser();
final Recipe recipe = this.createTestRecipe(owner, false);
final String accessToken = this.getAccessToken(owner);
this.mockMvc.perform(
@ -197,38 +195,23 @@ public class RecipeControllerTests {
}
@Test
@DirtiesContext
public void getRecipeInfoViewsNoPrincipal() throws Exception {
final User owner = this.createTestUser("owner");
final User owner = this.seedUser();
final Recipe recipe = this.createTestRecipe(owner, true);
this.mockMvc.perform(get("/recipes"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.slice.number").value(0))
.andExpect(jsonPath("$.slice.size").value(20))
.andExpect(jsonPath("$.content").isArray())
.andExpect(jsonPath("$.content", hasSize(1)))
.andExpect(jsonPath("$.content[0].id").value(recipe.getId()))
.andExpect(jsonPath("$.content[0].created").exists()) // TODO: better matching of exact LocalDateTime
.andExpect(jsonPath("$.content[0].modified").doesNotExist())
.andExpect(jsonPath("$.content[0].slug").value(recipe.getSlug()))
.andExpect(jsonPath("$.content[0].title").value(recipe.getTitle()))
.andExpect(jsonPath("$.content[0].preparationTime").value(recipe.getPreparationTime()))
.andExpect(jsonPath("$.content[0].cookingTime").value(recipe.getCookingTime()))
.andExpect(jsonPath("$.content[0].totalTime").value(recipe.getTotalTime()))
.andExpect(jsonPath("$.content[0].owner.id").value(owner.getId()))
.andExpect(jsonPath("$.content[0].owner.username").value(owner.getUsername()))
.andExpect(jsonPath("$.content[0].isPublic").value(true))
.andExpect(jsonPath("$.content[0].starCount").value(0))
.andExpect(jsonPath("$.content[0].mainImage").value(nullValue()));
.andExpect(jsonPath("$.content[*].id").value(hasItem(recipe.getId())));
}
@Test
@DirtiesContext
public void getRecipeInfoViewsWithPrincipalIncludesPrivate() throws Exception {
final User owner = this.createTestUser("owner");
final Recipe r0 = this.createTestRecipe(owner, true, "r0");
final Recipe r1 = this.createTestRecipe(owner, true, "r1");
final Recipe r2 = this.createTestRecipe(owner, false, "r2");
final User owner = this.seedUser();
final Recipe r0 = this.createTestRecipe(owner, true);
final Recipe r1 = this.createTestRecipe(owner, true);
final Recipe r2 = this.createTestRecipe(owner, false);
final LoginDetails loginDetails = this.authService.login(owner.getUsername(), "test");
this.mockMvc.perform(
get("/recipes")
@ -238,10 +221,10 @@ public class RecipeControllerTests {
.andExpect(jsonPath("$.slice.number").value(0))
.andExpect(jsonPath("$.slice.size").value(20))
.andExpect(jsonPath("$.content").isArray())
.andExpect(jsonPath("$.content", hasSize(3)));
.andExpect(jsonPath("$.content[*].id").value(hasItems(r0.getId(), r1.getId(), r2.getId())));
}
private String getUpdateBody() {
private String getUpdateBody() throws JsonProcessingException {
final RecipeUpdateSpec spec = new RecipeUpdateSpec();
spec.setTitle("Updated Test Recipe");
spec.setPreparationTime(15);
@ -253,9 +236,8 @@ public class RecipeControllerTests {
}
@Test
@DirtiesContext
public void updateRecipe() throws Exception {
final User owner = this.createTestUser("owner");
final User owner = this.seedUser();
final Recipe recipe = this.createTestRecipe(owner, false);
final String accessToken = this.getAccessToken(owner);
final String body = this.getUpdateBody();
@ -284,9 +266,8 @@ public class RecipeControllerTests {
}
@Test
@DirtiesContext
public void updateRecipeReturnsViewWithMainImage() throws Exception {
final User owner = this.createTestUser("owner");
final User owner = this.seedUser();
final Image hal9000 = this.createHal9000(owner);
@ -319,26 +300,24 @@ public class RecipeControllerTests {
}
@Test
@DirtiesContext
public void addStarToRecipe() throws Exception {
final User owner = this.createTestUser("recipe-owner");
final User starer = this.createTestUser("recipe-starer");
final User owner = this.seedUser();
final User starer = this.seedUser();
final Recipe recipe = this.createTestRecipe(owner, true);
this.mockMvc.perform(
post("/recipes/{username}/{slug}/star", recipe.getOwner().getUsername(), recipe.getSlug())
.header("Authorization", "Bearer " + this.getAccessToken(starer))
)
.andExpect(status().isCreated())
.andExpect(jsonPath("$.date").exists());
.andExpect(jsonPath("$.timestamp").exists());
}
@Test
@DirtiesContext
public void getStarForRecipe() throws Exception {
final User owner = this.createTestUser("recipe-owner");
final User starer = this.createTestUser("recipe-starer");
final User owner = this.seedUser();
final User starer = this.seedUser();
final Recipe recipe = this.createTestRecipe(owner, true);
this.recipeStarService.create(recipe.getId(), starer.getUsername());
this.recipeStarService.create(recipe.getId(), starer.getId());
this.mockMvc.perform(
get("/recipes/{username}/{slug}/star", recipe.getOwner().getUsername(), recipe.getSlug())
.header("Authorization", "Bearer " + this.getAccessToken(starer))
@ -346,16 +325,15 @@ public class RecipeControllerTests {
.andExpect(status().isOk())
.andExpect(jsonPath("$.isStarred").value(true))
.andExpect(jsonPath("$.star").isMap())
.andExpect(jsonPath("$.star.date").exists());
.andExpect(jsonPath("$.star.timestamp").exists());
}
@Test
@DirtiesContext
public void deleteStarFromRecipe() throws Exception {
final User owner = this.createTestUser("recipe-owner");
final User starer = this.createTestUser("recipe-starer");
final User owner = this.seedUser();
final User starer = this.seedUser();
final Recipe recipe = this.createTestRecipe(owner, true);
this.recipeStarService.create(recipe.getId(), starer.getUsername());
this.recipeStarService.create(recipe.getId(), starer.getId());
this.mockMvc.perform(
delete("/recipes/{username}/{slug}/star", recipe.getOwner().getUsername(), recipe.getSlug())
.header("Authorization", "Bearer " + this.getAccessToken(starer))

View File

@ -1,20 +1,23 @@
package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.IntegrationTestsExtension;
import app.mealsmadeeasy.api.user.UserEntity;
import app.mealsmadeeasy.api.user.UserRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.DirtiesContext;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@ExtendWith(IntegrationTestsExtension.class)
public class RecipeRepositoryTests {
@Autowired
@ -23,65 +26,55 @@ public class RecipeRepositoryTests {
@Autowired
private UserRepository userRepository;
private UserEntity getOwnerUser() {
final UserEntity recipeUser = UserEntity.getDefaultDraft();
recipeUser.setUsername("recipeUser");
recipeUser.setEmail("recipe@user.com");
recipeUser.setPassword("test");
return this.userRepository.save(recipeUser);
}
private UserEntity getViewerUser() {
final UserEntity viewerUser = UserEntity.getDefaultDraft();
viewerUser.setUsername("recipeViewerUser");
viewerUser.setEmail("recipe-viewer@user.com");
viewerUser.setPassword("test");
return this.userRepository.save(viewerUser);
private UserEntity seedUser() {
final String uuid = UUID.randomUUID().toString();
final UserEntity draft = UserEntity.getDefaultDraft();
draft.setUsername(uuid);
draft.setEmail(uuid + "@test.com");
draft.setPassword("test");
return this.userRepository.save(draft);
}
@Test
@DirtiesContext
public void findsAllPublicRecipes() {
final RecipeEntity publicRecipe = new RecipeEntity();
publicRecipe.setCreated(LocalDateTime.now());
publicRecipe.setSlug("public-recipe");
publicRecipe.setCreated(OffsetDateTime.now());
publicRecipe.setSlug(UUID.randomUUID().toString());
publicRecipe.setPublic(true);
publicRecipe.setOwner(this.getOwnerUser());
publicRecipe.setOwner(this.seedUser());
publicRecipe.setTitle("Public Recipe");
publicRecipe.setRawText("Hello, World!");
this.recipeRepository.save(publicRecipe);
final RecipeEntity savedRecipe = this.recipeRepository.save(publicRecipe);
final List<RecipeEntity> publicRecipes = this.recipeRepository.findAllByIsPublicIsTrue();
assertThat(publicRecipes.size()).isEqualTo(1);
assertThat(publicRecipes).anyMatch(recipeEntity -> recipeEntity.getId().equals(savedRecipe.getId()));
}
@Test
@DirtiesContext
public void doesNotFindNonPublicRecipe() {
final RecipeEntity nonPublicRecipe = new RecipeEntity();
nonPublicRecipe.setCreated(LocalDateTime.now());
nonPublicRecipe.setSlug("non-public-recipe");
nonPublicRecipe.setOwner(this.getOwnerUser());
nonPublicRecipe.setCreated(OffsetDateTime.now());
nonPublicRecipe.setSlug(UUID.randomUUID().toString());
nonPublicRecipe.setOwner(this.seedUser());
nonPublicRecipe.setTitle("Non-Public Recipe");
nonPublicRecipe.setRawText("Hello, World!");
this.recipeRepository.save(nonPublicRecipe);
final RecipeEntity savedRecipe = this.recipeRepository.save(nonPublicRecipe);
final List<RecipeEntity> publicRecipes = this.recipeRepository.findAllByIsPublicIsTrue();
assertThat(publicRecipes.size()).isEqualTo(0);
assertThat(publicRecipes).noneMatch(recipeEntity -> recipeEntity.getId().equals(savedRecipe.getId()));
}
@Test
@DirtiesContext
public void findsAllForViewer() {
final RecipeEntity recipe = new RecipeEntity();
recipe.setCreated(LocalDateTime.now());
recipe.setSlug("test-recipe");
recipe.setOwner(this.getOwnerUser());
recipe.setCreated(OffsetDateTime.now());
recipe.setSlug(UUID.randomUUID().toString());
recipe.setOwner(this.seedUser());
recipe.setTitle("Test Recipe");
recipe.setRawText("Hello, World!");
final RecipeEntity saved = this.recipeRepository.save(recipe);
final UserEntity viewer = this.getViewerUser();
final UserEntity viewer = this.seedUser();
final Set<UserEntity> viewers = new HashSet<>(recipe.getViewerEntities());
viewers.add(viewer);
saved.setViewers(viewers);
@ -93,17 +86,16 @@ public class RecipeRepositoryTests {
}
@Test
@DirtiesContext
public void doesNotIncludeNonViewable() {
final RecipeEntity recipe = new RecipeEntity();
recipe.setCreated(LocalDateTime.now());
recipe.setSlug("test-recipe");
recipe.setOwner(this.getOwnerUser());
recipe.setCreated(OffsetDateTime.now());
recipe.setSlug(UUID.randomUUID().toString());
recipe.setOwner(this.seedUser());
recipe.setTitle("Test Recipe");
recipe.setRawText("Hello, World!");
this.recipeRepository.save(recipe);
final UserEntity viewer = this.getViewerUser();
final UserEntity viewer = this.seedUser();
final List<RecipeEntity> viewable = this.recipeRepository.findAllByViewersContaining(viewer);
assertThat(viewable.size()).isEqualTo(0);
}

View File

@ -1,5 +1,6 @@
package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.IntegrationTestsExtension;
import app.mealsmadeeasy.api.image.ImageException;
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
@ -11,25 +12,28 @@ import app.mealsmadeeasy.api.user.UserEntity;
import app.mealsmadeeasy.api.user.UserRepository;
import org.jetbrains.annotations.Nullable;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.test.annotation.DirtiesContext;
import java.util.List;
import java.util.UUID;
import static app.mealsmadeeasy.api.recipe.ContainsRecipeInfoViewsForRecipesMatcher.containsRecipeInfoViewsForRecipes;
import static app.mealsmadeeasy.api.recipe.ContainsRecipesMatcher.containsRecipes;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.not;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
// TODO: test mainImage included
// TODO: test prep/cooking/total times included
@SpringBootTest
@ExtendWith(IntegrationTestsExtension.class)
public class RecipeServiceTests {
@Autowired
@ -41,25 +45,22 @@ public class RecipeServiceTests {
@Autowired
private UserRepository userRepository;
private UserEntity createTestUser(String username) {
private UserEntity seedUser() {
final String uuid = UUID.randomUUID().toString();
final UserEntity draft = UserEntity.getDefaultDraft();
draft.setUsername(username);
draft.setEmail(username + "@test.com");
draft.setUsername(uuid);
draft.setEmail(uuid + "@test.com");
draft.setPassword("test");
return this.userRepository.save(draft);
}
private Recipe createTestRecipe(@Nullable User owner) {
return this.createTestRecipe(owner, false, null);
return this.createTestRecipe(owner, false);
}
private Recipe createTestRecipe(@Nullable User owner, boolean isPublic) {
return this.createTestRecipe(owner, isPublic, null);
}
private Recipe createTestRecipe(@Nullable User owner, boolean isPublic, @Nullable String slug) {
final RecipeCreateSpec spec = new RecipeCreateSpec();
spec.setSlug(slug != null ? slug : "my-recipe");
spec.setSlug(UUID.randomUUID().toString());
spec.setTitle("My Recipe");
spec.setRawText("Hello!");
spec.setPublic(isPublic);
@ -70,9 +71,8 @@ public class RecipeServiceTests {
public void smokeScreen() {}
@Test
@DirtiesContext
public void create() {
final User user = this.createTestUser("recipeOwner");
final User user = this.seedUser();
final Recipe recipe = this.createTestRecipe(user);
assertThat(recipe.getOwner().getUsername(), is(user.getUsername()));
assertThat(recipe.getTitle(), is("My Recipe"));
@ -80,23 +80,20 @@ public class RecipeServiceTests {
}
@Test
@DirtiesContext
public void createWithoutOwnerThrowsAccessDenied() {
assertThrows(AccessDeniedException.class, () -> this.recipeService.create(null, new RecipeCreateSpec()));
}
@Test
@DirtiesContext
public void getByIdPublicNoViewerDoesNotThrow() {
final User owner = this.createTestUser("recipeOwner");
final User owner = this.seedUser();
final Recipe recipe = this.createTestRecipe(owner, true);
assertDoesNotThrow(() -> this.recipeService.getById(recipe.getId(), null));
}
@Test
@DirtiesContext
public void getByIdHasCorrectProperties() throws RecipeException {
final User owner = this.createTestUser("recipeOwner");
final User owner = this.seedUser();
final Recipe recipe = this.createTestRecipe(owner, true);
final Recipe byId = this.recipeService.getById(recipe.getId(), null);
assertThat(byId.getId(), is(recipe.getId()));
@ -107,45 +104,40 @@ public class RecipeServiceTests {
}
@Test
@DirtiesContext
public void getByIdThrowsWhenNotPublicAndNoViewer() {
final User owner = this.createTestUser("recipeOwner");
final User owner = this.seedUser();
final Recipe recipe = this.createTestRecipe(owner, false); // not public
assertThrows(AccessDeniedException.class, () -> this.recipeService.getById(recipe.getId(), null));
}
@Test
@DirtiesContext
public void getByIdThrowsWhenNotViewer() {
final User owner = this.createTestUser("recipeOwner");
final User notViewer = this.createTestUser("notViewer");
final User owner = this.seedUser();
final User notViewer = this.seedUser();
final Recipe recipe = this.createTestRecipe(owner);
assertThrows(AccessDeniedException.class, () -> this.recipeService.getById(recipe.getId(), notViewer));
}
@Test
@DirtiesContext
public void getByIdOkayWhenPublicAndNoViewer() {
final User owner = this.createTestUser("recipeOwner");
final User owner = this.seedUser();
final Recipe recipe = this.createTestRecipe(owner, true);
assertDoesNotThrow(() -> this.recipeService.getById(recipe.getId(), null));
}
@Test
@DirtiesContext
public void getByIdOkayWhenPublicRecipeWithViewer() {
final User owner = this.createTestUser("recipeOwner");
final User viewer = this.createTestUser("viewer");
final User owner = this.seedUser();
final User viewer = this.seedUser();
final Recipe recipe = this.createTestRecipe(owner, true);
assertDoesNotThrow(() -> this.recipeService.getById(recipe.getId(), viewer));
}
@Test
@DirtiesContext
public void getByIdOkayWithStarsPublicAndNoViewer() {
final User owner = this.createTestUser("recipeOwner");
final User owner = this.seedUser();
final Recipe recipe = this.createTestRecipe(owner, true);
final RecipeStar star = this.recipeStarService.create(recipe.getId(), owner.getUsername());
final RecipeStar star = this.recipeStarService.create(recipe.getId(), owner.getId());
final Recipe byIdWithStars = assertDoesNotThrow(() -> this.recipeService.getByIdWithStars(
recipe.getId(), null
));
@ -153,19 +145,17 @@ public class RecipeServiceTests {
}
@Test
@DirtiesContext
public void getByIdOkayWithStarsThrowsWhenNotViewer() {
final User owner = this.createTestUser("recipeOwner");
final User notViewer = this.createTestUser("notViewer");
final User owner = this.seedUser();
final User notViewer = this.seedUser();
final Recipe recipe = this.createTestRecipe(owner);
assertThrows(AccessDeniedException.class, () -> this.recipeService.getByIdWithStars(recipe.getId(), notViewer));
}
@Test
@DirtiesContext
public void getByIdWithStarsOkayWhenPublicRecipeWithViewer() throws RecipeException, ImageException {
final User owner = this.createTestUser("recipeOwner");
final User viewer = this.createTestUser("viewer");
final User owner = this.seedUser();
final User viewer = this.seedUser();
final Recipe notYetPublicRecipe = this.createTestRecipe(owner);
final RecipeUpdateSpec updateSpec = new RecipeUpdateSpec(notYetPublicRecipe);
updateSpec.setIsPublic(true);
@ -179,45 +169,39 @@ public class RecipeServiceTests {
}
@Test
@DirtiesContext
public void getByMinimumStarsAllPublic() {
final User owner = this.createTestUser("recipeOwner");
final User u0 = this.createTestUser("u0");
final User u1 = this.createTestUser("u1");
final User owner = this.seedUser();
final User u0 = this.seedUser();
final User u1 = this.seedUser();
final Recipe r0 = this.createTestRecipe(owner, true, "r0");
final Recipe r1 = this.createTestRecipe(owner, true, "r1");
final Recipe r2 = this.createTestRecipe(owner, true, "r2");
final Recipe r0 = this.createTestRecipe(owner, true);
final Recipe r1 = this.createTestRecipe(owner, true);
final Recipe r2 = this.createTestRecipe(owner, true);
// r0.stars = 0, r1.stars = 1, r2.stars = 2
this.recipeStarService.create(r1.getId(), u0.getUsername());
this.recipeStarService.create(r2.getId(), u0.getUsername());
this.recipeStarService.create(r2.getId(), u1.getUsername());
this.recipeStarService.create(r1.getId(), u0.getId());
this.recipeStarService.create(r2.getId(), u0.getId());
this.recipeStarService.create(r2.getId(), u1.getId());
final List<Recipe> zeroStars = this.recipeService.getByMinimumStars(0, null);
final List<Recipe> oneStar = this.recipeService.getByMinimumStars(1, null);
final List<Recipe> twoStars = this.recipeService.getByMinimumStars(2, null);
assertThat(zeroStars.size(), is(3));
assertThat(oneStar.size(), is(2));
assertThat(twoStars.size(), is(1));
assertThat(zeroStars, containsRecipes(r0, r1, r2));
assertThat(oneStar, containsRecipes(r1, r2));
assertThat(twoStars, containsRecipes(r2));
}
@Test
@DirtiesContext
public void getByMinimumStarsOnlySomeViewable() throws RecipeException {
final User owner = this.createTestUser("recipeOwner");
final User u0 = this.createTestUser("u0");
final User u1 = this.createTestUser("u1");
final User viewer = this.createTestUser("recipeViewer");
final User owner = this.seedUser();
final User u0 = this.seedUser();
final User u1 = this.seedUser();
final User viewer = this.seedUser();
Recipe r0 = this.createTestRecipe(owner, false, "r0"); // not public
Recipe r1 = this.createTestRecipe(owner, false, "r1");
Recipe r2 = this.createTestRecipe(owner, false, "r2");
Recipe r0 = this.createTestRecipe(owner, false); // not public
Recipe r1 = this.createTestRecipe(owner, false);
Recipe r2 = this.createTestRecipe(owner, false);
for (final User starer : List.of(u0, u1)) {
r0 = this.recipeService.addViewer(r0.getId(), owner, starer);
@ -226,17 +210,17 @@ public class RecipeServiceTests {
}
// r0.stars = 0, r1.stars = 1, r2.stars = 2
this.recipeStarService.create(r1.getId(), u0.getUsername());
this.recipeStarService.create(r2.getId(), u0.getUsername());
this.recipeStarService.create(r2.getId(), u1.getUsername());
this.recipeStarService.create(r1.getId(), u0.getId());
this.recipeStarService.create(r2.getId(), u0.getId());
this.recipeStarService.create(r2.getId(), u1.getId());
final List<Recipe> zeroStarsNoneViewable = this.recipeService.getByMinimumStars(0, viewer);
final List<Recipe> oneStarNoneViewable = this.recipeService.getByMinimumStars(1, viewer);
final List<Recipe> twoStarsNoneViewable = this.recipeService.getByMinimumStars(2, viewer);
assertThat(zeroStarsNoneViewable.size(), is(0));
assertThat(oneStarNoneViewable.size(), is(0));
assertThat(twoStarsNoneViewable.size(), is(0));
assertThat(zeroStarsNoneViewable, not(containsRecipes(r0, r1, r2)));
assertThat(oneStarNoneViewable, not(containsRecipes(r1, r2)));
assertThat(twoStarsNoneViewable, not(containsRecipes(r2)));
// Now make them viewable
r0 = this.recipeService.addViewer(r0.getId(), owner, viewer);
@ -247,49 +231,40 @@ public class RecipeServiceTests {
final List<Recipe> oneStarViewable = this.recipeService.getByMinimumStars(1, viewer);
final List<Recipe> twoStarsViewable = this.recipeService.getByMinimumStars(2, viewer);
assertThat(zeroStarsViewable.size(), is(3));
assertThat(oneStarViewable.size(), is(2));
assertThat(twoStarsViewable.size(), is (1));
assertThat(zeroStarsViewable, containsRecipes(r0, r1, r2));
assertThat(oneStarViewable, containsRecipes(r1, r2));
assertThat(twoStarsViewable, containsRecipes(r2));
}
@Test
@DirtiesContext
public void getPublicRecipes() {
final User owner = this.createTestUser("recipeOwner");
final User owner = this.seedUser();
Recipe r0 = this.createTestRecipe(owner, true, "r0");
Recipe r1 = this.createTestRecipe(owner, true, "r1");
Recipe r0 = this.createTestRecipe(owner, true);
Recipe r1 = this.createTestRecipe(owner, true);
final List<Recipe> publicRecipes = this.recipeService.getPublicRecipes();
assertThat(publicRecipes.size(), is(2));
assertThat(publicRecipes, containsRecipes(r0, r1));
}
@Test
@DirtiesContext
public void getRecipeInfoViewsViewableByOwnerWhenPublicAndPrivate() {
final User owner = this.createTestUser("recipeOwner");
Recipe r0 = this.createTestRecipe(owner, true, "r0");
Recipe r1 = this.createTestRecipe(owner, false, "r1");
final User owner = this.seedUser();
Recipe r0 = this.createTestRecipe(owner, true);
Recipe r1 = this.createTestRecipe(owner, false);
final Slice<RecipeInfoView> viewableInfoViewsSlice = this.recipeService.getInfoViewsViewableBy(
Pageable.ofSize(20),
owner
);
final List<RecipeInfoView> viewableInfos = viewableInfoViewsSlice.getContent();
assertThat(viewableInfos.size(), is(2));
assertThat(viewableInfos, containsRecipeInfoViewsForRecipes(r0, r1));
}
@Test
@DirtiesContext
public void getRecipesViewableByUser() throws RecipeException {
final User owner = this.createTestUser("recipeOwner");
final User viewer = this.createTestUser("recipeViewer");
final User owner = this.seedUser();
final User viewer = this.seedUser();
Recipe r0 = this.createTestRecipe(owner);
r0 = this.recipeService.addViewer(r0.getId(), owner, viewer);
@ -299,9 +274,8 @@ public class RecipeServiceTests {
}
@Test
@DirtiesContext
public void getRecipesOwnedByUser() {
final User owner = this.createTestUser("recipeOwner");
final User owner = this.seedUser();
final Recipe r0 = this.createTestRecipe(owner);
final List<Recipe> ownedRecipes = this.recipeService.getRecipesOwnedBy(owner);
assertThat(ownedRecipes.size(), is(1));
@ -309,9 +283,8 @@ public class RecipeServiceTests {
}
@Test
@DirtiesContext
public void updateRawText() throws RecipeException, ImageException {
final User owner = this.createTestUser("recipeOwner");
final User owner = this.seedUser();
final RecipeCreateSpec createSpec = new RecipeCreateSpec();
createSpec.setSlug("my-recipe");
createSpec.setTitle("My Recipe");
@ -330,10 +303,9 @@ public class RecipeServiceTests {
}
@Test
@DirtiesContext
public void updateRawTextThrowsIfNotOwner() {
final User owner = this.createTestUser("recipeOwner");
final User notOwner = this.createTestUser("notOwner");
final User owner = this.seedUser();
final User notOwner = this.seedUser();
final Recipe recipe = this.createTestRecipe(owner);
final RecipeUpdateSpec updateSpec = new RecipeUpdateSpec();
updateSpec.setRawText("should fail");
@ -349,19 +321,17 @@ public class RecipeServiceTests {
}
@Test
@DirtiesContext
public void deleteRecipe() {
final User owner = this.createTestUser("recipeOwner");
final User owner = this.seedUser();
final Recipe toDelete = this.createTestRecipe(owner);
this.recipeService.deleteRecipe(toDelete.getId(), owner);
assertThrows(RecipeException.class, () -> this.recipeService.getById(toDelete.getId(), owner));
}
@Test
@DirtiesContext
public void deleteRecipeThrowsIfNotOwner() {
final User owner = this.createTestUser("recipeOwner");
final User notOwner = this.createTestUser("notOwner");
final User owner = this.seedUser();
final User notOwner = this.seedUser();
final Recipe toDelete = this.createTestRecipe(owner);
assertThrows(AccessDeniedException.class, () -> this.recipeService.deleteRecipe(toDelete.getId(), notOwner));
}

View File

@ -1,20 +1,23 @@
package app.mealsmadeeasy.api.recipe.star;
import app.mealsmadeeasy.api.IntegrationTestsExtension;
import app.mealsmadeeasy.api.recipe.RecipeEntity;
import app.mealsmadeeasy.api.recipe.RecipeRepository;
import app.mealsmadeeasy.api.user.UserEntity;
import app.mealsmadeeasy.api.user.UserRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.DirtiesContext;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.util.UUID;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
@SpringBootTest
@ExtendWith(IntegrationTestsExtension.class)
public class RecipeStarRepositoryTests {
@Autowired
@ -26,18 +29,19 @@ public class RecipeStarRepositoryTests {
@Autowired
private UserRepository userRepository;
private UserEntity getOwnerUser() {
private UserEntity seedUser() {
final UserEntity draft = UserEntity.getDefaultDraft();
draft.setUsername("test-user");
draft.setEmail("test-user@test.com");
final String uuid = UUID.randomUUID().toString();
draft.setUsername(uuid);
draft.setEmail(uuid + "@test.com");
draft.setPassword("test");
return this.userRepository.save(draft);
}
private RecipeEntity getTestRecipe(UserEntity owner) {
final RecipeEntity recipeDraft = new RecipeEntity();
recipeDraft.setCreated(LocalDateTime.now());
recipeDraft.setSlug("test-recipe");
recipeDraft.setCreated(OffsetDateTime.now());
recipeDraft.setSlug(UUID.randomUUID().toString());
recipeDraft.setOwner(owner);
recipeDraft.setTitle("Test Recipe");
recipeDraft.setRawText("Hello, World!");
@ -45,15 +49,14 @@ public class RecipeStarRepositoryTests {
}
@Test
@DirtiesContext
public void returnsTrueIfStarer() {
final UserEntity owner = this.getOwnerUser();
final UserEntity owner = this.seedUser();
final RecipeEntity recipe = this.getTestRecipe(owner);
final RecipeStarEntity starDraft = new RecipeStarEntity();
final RecipeStarId starId = new RecipeStarId();
starId.setRecipeId(recipe.getId());
starId.setOwnerUsername(owner.getUsername());
starId.getOwnerId(owner.getId());
starDraft.setId(starId);
this.recipeStarRepository.save(starDraft);
@ -61,22 +64,21 @@ public class RecipeStarRepositoryTests {
this.recipeStarRepository.isStarer(
recipe.getOwner().getUsername(),
recipe.getSlug(),
owner.getUsername()
owner.getId()
),
is(true)
);
}
@Test
@DirtiesContext
public void returnsFalseIfNotStarer() {
final UserEntity owner = this.getOwnerUser();
final UserEntity owner = this.seedUser();
final RecipeEntity recipe = this.getTestRecipe(owner);
assertThat(
this.recipeStarRepository.isStarer(
recipe.getOwner().getUsername(),
recipe.getSlug(),
owner.getUsername()
owner.getId()
),
is(false)
);

View File

@ -1,5 +1,6 @@
package app.mealsmadeeasy.api.recipe.star;
import app.mealsmadeeasy.api.IntegrationTestsExtension;
import app.mealsmadeeasy.api.recipe.Recipe;
import app.mealsmadeeasy.api.recipe.RecipeException;
import app.mealsmadeeasy.api.recipe.RecipeService;
@ -9,9 +10,11 @@ import app.mealsmadeeasy.api.user.UserCreateException;
import app.mealsmadeeasy.api.user.UserService;
import org.jetbrains.annotations.Nullable;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.DirtiesContext;
import java.util.UUID;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.notNullValue;
@ -19,6 +22,7 @@ import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
@SpringBootTest
@ExtendWith(IntegrationTestsExtension.class)
public class RecipeStarServiceTests {
@Autowired
@ -30,57 +34,57 @@ public class RecipeStarServiceTests {
@Autowired
private RecipeService recipeService;
private User getTestUser(String username) {
private User seedUser() {
final String uuid = UUID.randomUUID().toString();
try {
return this.userService.createUser(username, username + "@test.com", "test");
return this.userService.createUser(uuid, uuid + "@test.com", "test");
} catch (UserCreateException e) {
throw new RuntimeException(e);
}
}
private Recipe getTestRecipe(User owner, String slug, boolean isPublic) {
private Recipe seedRecipe(User owner) {
final RecipeCreateSpec spec = new RecipeCreateSpec();
spec.setSlug(slug);
spec.setSlug(UUID.randomUUID().toString());
spec.setTitle("Test Recipe");
spec.setRawText("My great recipe has five ingredients.");
spec.setPublic(isPublic);
spec.setPublic(true);
return this.recipeService.create(owner, spec);
}
@Test
@DirtiesContext
public void createViaUsernameAndSlug() {
final User owner = this.getTestUser("recipe-owner");
final User starer = this.getTestUser("recipe-starer");
final Recipe recipe = this.getTestRecipe(owner, "test-recipe", true);
final User owner = this.seedUser();
final User starer = this.seedUser();
final Recipe recipe = this.seedRecipe(owner);
final RecipeStar star = assertDoesNotThrow(() -> this.recipeStarService.create(
recipe.getOwner().getUsername(),
recipe.getSlug(),
starer
));
assertThat(star.getDate(), is(notNullValue()));
//noinspection DataFlowIssue
assertThat(star.getTimestamp(), is(notNullValue()));
}
@Test
@DirtiesContext
public void createViaId() {
final User owner = this.getTestUser("recipe-owner");
final User starer = this.getTestUser("recipe-starer");
final Recipe recipe = this.getTestRecipe(owner, "test-recipe", true);
final User owner = this.seedUser();
final User starer = this.seedUser();
final Recipe recipe = this.seedRecipe(owner);
final RecipeStar star = assertDoesNotThrow(() -> this.recipeStarService.create(
recipe.getId(),
starer.getUsername()
starer.getId()
));
assertThat(star.getDate(), is(notNullValue()));
//noinspection DataFlowIssue
assertThat(star.getTimestamp(), is(notNullValue()));
}
@Test
@DirtiesContext
public void find() throws RecipeException {
final User owner = this.getTestUser("recipe-owner");
final User starer = this.getTestUser("recipe-starer");
final Recipe recipe = this.getTestRecipe(owner, "test-recipe", true);
this.recipeStarService.create(recipe.getId(), starer.getUsername());
final User owner = this.seedUser();
final User starer = this.seedUser();
final Recipe recipe = this.seedRecipe(owner);
this.recipeStarService.create(recipe.getId(), starer.getId());
final @Nullable RecipeStar star = this.recipeStarService.find(
recipe.getOwner().getUsername(),
recipe.getSlug(),
@ -90,12 +94,11 @@ public class RecipeStarServiceTests {
}
@Test
@DirtiesContext
public void deleteViaUsernameAndSlug() {
final User owner = this.getTestUser("recipe-owner");
final User starer = this.getTestUser("recipe-starer");
final Recipe recipe = this.getTestRecipe(owner, "test-recipe", true);
this.recipeStarService.create(recipe.getId(), starer.getUsername());
final User owner = this.seedUser();
final User starer = this.seedUser();
final Recipe recipe = this.seedRecipe(owner);
this.recipeStarService.create(recipe.getId(), starer.getId());
assertDoesNotThrow(() -> this.recipeStarService.delete(
recipe.getOwner().getUsername(),
recipe.getSlug(),

View File

@ -1,19 +1,22 @@
package app.mealsmadeeasy.api.signup;
import app.mealsmadeeasy.api.IntegrationTestsExtension;
import app.mealsmadeeasy.api.user.UserCreateException.Type;
import app.mealsmadeeasy.api.user.UserService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import tools.jackson.databind.ObjectMapper;
import java.util.Map;
import java.util.UUID;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@ -22,6 +25,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@SpringBootTest
@AutoConfigureMockMvc
@ExtendWith(IntegrationTestsExtension.class)
public class SignUpControllerTests {
@Autowired
@ -33,7 +37,8 @@ public class SignUpControllerTests {
@Autowired
private UserService userService;
private MockHttpServletRequestBuilder getCheckUsernameRequest(String usernameToCheck) {
private MockHttpServletRequestBuilder getCheckUsernameRequest(String usernameToCheck)
throws JsonProcessingException {
final Map<String, Object> body = Map.of("username", usernameToCheck);
return MockMvcRequestBuilders.get("/sign-up/check-username")
.content(this.objectMapper.writeValueAsString(body))
@ -41,24 +46,24 @@ public class SignUpControllerTests {
}
@Test
@DirtiesContext
public void checkUsernameExpectAvailable() throws Exception {
this.mockMvc.perform(this.getCheckUsernameRequest("isAvailable"))
final String username = UUID.randomUUID().toString();
this.mockMvc.perform(this.getCheckUsernameRequest(username))
.andExpect(status().isOk())
.andExpect(jsonPath("$.isAvailable").value(true));
}
@Test
@DirtiesContext
public void checkUsernameExpectNotAvailable() throws Exception {
this.userService.createUser("notAvailable", "not-available@notavailable.com", "test");
this.mockMvc.perform(this.getCheckUsernameRequest("notAvailable"))
final String username = UUID.randomUUID().toString();
this.userService.createUser(username, username + "@notavailable.com", "test");
this.mockMvc.perform(this.getCheckUsernameRequest(username))
.andExpect(status().isOk())
.andExpect(jsonPath("$.isAvailable").value(false));
}
private MockHttpServletRequestBuilder getCheckEmailRequest(String emailToCheck) {
private MockHttpServletRequestBuilder getCheckEmailRequest(String emailToCheck) throws JsonProcessingException {
final Map<String, Object> body = Map.of("email", emailToCheck);
return MockMvcRequestBuilders.get("/sign-up/check-email")
.content(this.objectMapper.writeValueAsString(body))
@ -66,44 +71,43 @@ public class SignUpControllerTests {
}
@Test
@DirtiesContext
public void checkEmailExpectAvailable() throws Exception {
this.mockMvc.perform(this.getCheckEmailRequest("available@available.com"))
this.mockMvc.perform(this.getCheckEmailRequest(UUID.randomUUID() + "@available.com"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.isAvailable").value(true));
}
@Test
@DirtiesContext
public void checkEmailExpectNotAvailable() throws Exception {
this.userService.createUser("notAvailable", "not-available@notavailable.com", "test");
this.mockMvc.perform(this.getCheckEmailRequest("not-available@notavailable.com"))
final String notAvailable = UUID.randomUUID().toString();
this.userService.createUser(notAvailable, notAvailable + "@notavailable.com", "test");
this.mockMvc.perform(this.getCheckEmailRequest(notAvailable + "@notavailable.com"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.isAvailable").value(false));
}
@Test
@DirtiesContext
public void simpleSignUp() throws Exception {
final SignUpBody body = new SignUpBody();
body.setUsername("newUser");
body.setEmail("new@user.com");
final String username = UUID.randomUUID().toString();
body.setUsername(username);
body.setEmail(username + "@user.com");
body.setPassword("test");
final MockHttpServletRequestBuilder req = post("/sign-up")
.content(this.objectMapper.writeValueAsString(body))
.contentType(MediaType.APPLICATION_JSON);
this.mockMvc.perform(req)
.andExpect(status().isCreated())
.andExpect(jsonPath("$.username").value("newUser"));
.andExpect(jsonPath("$.username").value(username));
}
@Test
@DirtiesContext
public void signUpBadRequestWhenUsernameTaken() throws Exception {
this.userService.createUser("taken", "taken@taken.com", "test");
final String takenUsername = UUID.randomUUID().toString();
this.userService.createUser(takenUsername, takenUsername + "@taken.com", "test");
final SignUpBody body = new SignUpBody();
body.setUsername("taken");
body.setEmail("not-taken@taken.com"); // n.b.
body.setUsername(takenUsername);
body.setEmail(UUID.randomUUID() + "@taken.com"); // n.b.: not taken email
body.setPassword("test");
final MockHttpServletRequestBuilder req = post("/sign-up")
.content(this.objectMapper.writeValueAsString(body))
@ -111,16 +115,16 @@ public class SignUpControllerTests {
this.mockMvc.perform(req)
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error.type").value(Type.USERNAME_TAKEN.toString()))
.andExpect(jsonPath("$.error.message").value(containsString("taken")));
.andExpect(jsonPath("$.error.message").value(containsString(takenUsername)));
}
@Test
@DirtiesContext
public void signUpBadRequestWhenEmailTaken() throws Exception {
this.userService.createUser("taken", "taken@taken.com", "test");
final String takenEmail = UUID.randomUUID() + "@taken.com";
this.userService.createUser(UUID.randomUUID().toString(), takenEmail, "test");
final SignUpBody body = new SignUpBody();
body.setUsername("notTaken"); // n.b.
body.setEmail("taken@taken.com");
body.setUsername(UUID.randomUUID().toString()); // n.b.: random username
body.setEmail(takenEmail);
body.setPassword("test");
final MockHttpServletRequestBuilder req = post("/sign-up")
.content(this.objectMapper.writeValueAsString(body))
@ -128,7 +132,7 @@ public class SignUpControllerTests {
this.mockMvc.perform(req)
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error.type").value(Type.EMAIL_TAKEN.toString()))
.andExpect(jsonPath("$.error.message").value(containsString("taken@taken.com")));
.andExpect(jsonPath("$.error.message").value(containsString(takenEmail)));
}
}

View File

@ -1,7 +1,3 @@
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:mem:db;DB_CLOSE_DELAY=-1
spring.datasource.username=sa
spring.datasource.password=sa
app.mealsmadeeasy.api.baseUrl=http://localhost:8080
app.mealsmadeeasy.api.security.access-token-lifetime=60
app.mealsmadeeasy.api.security.refresh-token-lifetime=120
@ -9,3 +5,8 @@ app.mealsmadeeasy.api.minio.endpoint=http://localhost:9000
app.mealsmadeeasy.api.minio.accessKey=minio-root
app.mealsmadeeasy.api.minio.secretKey=test0123
app.mealsmadeeasy.api.images.bucketName=images
# Source - https://stackoverflow.com/questions/3164072/large-objects-may-not-be-used-in-auto-commit-mode
# Posted by Iogui, modified by community. See post 'Timeline' for change history
# Retrieved 2025-12-25, License - CC BY-SA 4.0
spring.datasource.hikari.auto-commit=false

View File

@ -0,0 +1,58 @@
package app.mealsmadeeasy.api;
import app.mealsmadeeasy.api.recipe.RecipeEmbeddingEntity;
import app.mealsmadeeasy.api.recipe.RecipeEntity;
import app.mealsmadeeasy.api.recipe.RecipeRepository;
import app.mealsmadeeasy.api.recipe.RecipeService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.time.OffsetDateTime;
import java.util.List;
@Component
@ConditionalOnProperty(name = "backfill.recipe-embeddings.enabled", havingValue = "true")
public class BackfillRecipeEmbeddings implements ApplicationRunner {
private static final Logger logger = LoggerFactory.getLogger(BackfillRecipeEmbeddings.class);
private final RecipeRepository recipeRepository;
private final RecipeService recipeService;
private final EmbeddingModel embeddingModel;
public BackfillRecipeEmbeddings(
RecipeRepository recipeRepository,
RecipeService recipeService,
EmbeddingModel embeddingModel
) {
this.recipeRepository = recipeRepository;
this.recipeService = recipeService;
this.embeddingModel = embeddingModel;
}
@Override
public void run(ApplicationArguments args) {
final List<RecipeEntity> recipeEntities = this.recipeRepository.findAllByEmbeddingIsNull();
for (final RecipeEntity recipeEntity : recipeEntities) {
logger.info("Calculating embedding for {}", recipeEntity);
final String renderedMarkdown = this.recipeService.getRenderedMarkdown(recipeEntity);
final String toEmbed = "<h1>" + recipeEntity.getTitle() + "</h1>" + renderedMarkdown;
final float[] embedding = this.embeddingModel.embed(toEmbed);
final RecipeEmbeddingEntity recipeEmbedding = new RecipeEmbeddingEntity();
recipeEmbedding.setRecipe(recipeEntity);
recipeEmbedding.setEmbedding(embedding);
recipeEmbedding.setTimestamp(OffsetDateTime.now());
recipeEntity.setEmbedding(recipeEmbedding);
this.recipeRepository.save(recipeEntity);
}
this.recipeRepository.flush();
}
}

View File

@ -8,14 +8,14 @@ import app.mealsmadeeasy.api.recipe.RecipeService;
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.user.UserService;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.dataformat.yaml.YAMLFactory;
import java.io.FileInputStream;
import java.io.InputStream;

View File

@ -1,6 +1,5 @@
package app.mealsmadeeasy.api.auth;
import app.mealsmadeeasy.api.security.AuthToken;
import org.jetbrains.annotations.Nullable;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
@ -8,6 +7,8 @@ import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
@RestController
@RequestMapping("/auth")
public final class AuthController {
@ -31,9 +32,9 @@ public final class AuthController {
}
private ResponseEntity<LoginView> getLoginViewResponseEntity(LoginDetails loginDetails) {
final AuthToken refreshToken = loginDetails.getRefreshToken();
final RefreshToken refreshToken = loginDetails.getRefreshToken();
final ResponseCookie refreshCookie = getRefreshTokenCookie(
refreshToken.getToken(),
refreshToken.getToken().toString(),
refreshToken.getLifetime()
);
final var loginView = new LoginView(
@ -60,7 +61,7 @@ public final class AuthController {
@PostMapping("/refresh")
public ResponseEntity<LoginView> refresh(
@CookieValue(value = "refresh-token", required = false) @Nullable String oldRefreshToken
@CookieValue(value = "refresh-token", required = false) @Nullable UUID oldRefreshToken
) throws LoginException {
final LoginDetails loginDetails = this.authService.refresh(oldRefreshToken);
return this.getLoginViewResponseEntity(loginDetails);
@ -68,7 +69,7 @@ public final class AuthController {
@PostMapping("/logout")
public ResponseEntity<?> logout(
@CookieValue(value = "refresh-token", required = false) @Nullable String refreshToken
@CookieValue(value = "refresh-token", required = false) @Nullable UUID refreshToken
) {
if (refreshToken != null) {
this.authService.logout(refreshToken);

View File

@ -2,8 +2,10 @@ package app.mealsmadeeasy.api.auth;
import org.jetbrains.annotations.Nullable;
import java.util.UUID;
public interface AuthService {
LoginDetails login(String username, String password) throws LoginException;
void logout(String refreshToken);
LoginDetails refresh(@Nullable String refreshToken) throws LoginException;
void logout(UUID refreshToken);
LoginDetails refresh(@Nullable UUID refreshToken) throws LoginException;
}

View File

@ -12,7 +12,7 @@ import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@ -38,9 +38,9 @@ public class AuthServiceImpl implements AuthService {
private RefreshToken createRefreshToken(UserEntity principal) {
final RefreshTokenEntity refreshTokenDraft = new RefreshTokenEntity();
refreshTokenDraft.setToken(UUID.randomUUID().toString());
refreshTokenDraft.setIssued(LocalDateTime.now());
refreshTokenDraft.setExpiration(LocalDateTime.now().plusSeconds(this.refreshTokenLifetime));
refreshTokenDraft.setToken(UUID.randomUUID());
refreshTokenDraft.setIssued(OffsetDateTime.now());
refreshTokenDraft.setExpiration(OffsetDateTime.now().plusSeconds(this.refreshTokenLifetime));
refreshTokenDraft.setOwner(principal);
return this.refreshTokenRepository.save(refreshTokenDraft);
}
@ -64,13 +64,13 @@ public class AuthServiceImpl implements AuthService {
@Override
@Transactional
public void logout(String refreshToken) {
public void logout(UUID refreshToken) {
this.refreshTokenRepository.findByToken(refreshToken).ifPresent(this.refreshTokenRepository::delete);
}
@Override
@Transactional
public LoginDetails refresh(@Nullable String refreshToken) throws LoginException {
public LoginDetails refresh(@Nullable UUID refreshToken) throws LoginException {
if (refreshToken == null) {
throw new LoginException(LoginExceptionReason.NO_REFRESH_TOKEN, "No refresh token provided.");
}
@ -83,7 +83,7 @@ public class AuthServiceImpl implements AuthService {
if (old.isRevoked() || old.isDeleted()) {
throw new LoginException(LoginExceptionReason.INVALID_REFRESH_TOKEN, "Invalid refresh token.");
}
if (old.getExpires().isBefore(LocalDateTime.now())) {
if (old.getExpires().isBefore(OffsetDateTime.now())) {
throw new LoginException(LoginExceptionReason.EXPIRED_REFRESH_TOKEN, "Refresh token is expired.");
}

View File

@ -6,9 +6,9 @@ public final class LoginDetails {
private final String username;
private final AuthToken accessToken;
private final AuthToken refreshToken;
private final RefreshToken refreshToken;
public LoginDetails(String username, AuthToken accessToken, AuthToken refreshToken) {
public LoginDetails(String username, AuthToken accessToken, RefreshToken refreshToken) {
this.username = username;
this.accessToken = accessToken;
this.refreshToken = refreshToken;
@ -22,7 +22,7 @@ public final class LoginDetails {
return this.accessToken;
}
public AuthToken getRefreshToken() {
public RefreshToken getRefreshToken() {
return this.refreshToken;
}

View File

@ -1,11 +1,13 @@
package app.mealsmadeeasy.api.auth;
import app.mealsmadeeasy.api.security.AuthToken;
import java.time.OffsetDateTime;
import java.util.UUID;
import java.time.LocalDateTime;
public interface RefreshToken extends AuthToken {
LocalDateTime getIssued();
public interface RefreshToken {
UUID getToken();
long getLifetime();
OffsetDateTime getExpires();
OffsetDateTime getIssued();
boolean isRevoked();
boolean isDeleted();
}

View File

@ -3,68 +3,58 @@ package app.mealsmadeeasy.api.auth;
import app.mealsmadeeasy.api.user.UserEntity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.temporal.ChronoUnit;
import java.util.UUID;
@Entity(name = "RefreshToken")
@Table(name = "refresh_token")
public class RefreshTokenEntity implements RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(nullable = false, updatable = false)
private Long id;
@Column(unique = true, nullable = false)
private String token;
@Column(nullable = false)
private UUID token;
@Column(nullable = false)
private LocalDateTime issued;
private OffsetDateTime issued;
@Column(nullable = false)
private LocalDateTime expiration;
private OffsetDateTime expiration;
@Column(nullable = false)
private Boolean revoked = false;
@JoinColumn(nullable = false)
@ManyToOne
@ManyToOne(optional = false)
@JoinColumn(name = "owner_id", nullable = false)
private UserEntity owner;
@Column(nullable = false)
private Boolean deleted = false;
public Long getId() {
return this.id;
}
public void setId(Long id) {
this.id = id;
}
@Column(nullable = false)
private Boolean revoked = false;
@Override
public String getToken() {
public UUID getToken() {
return this.token;
}
public void setToken(String token) {
public void setToken(UUID token) {
this.token = token;
}
@Override
public LocalDateTime getIssued() {
public OffsetDateTime getIssued() {
return this.issued;
}
public void setIssued(LocalDateTime issued) {
public void setIssued(OffsetDateTime issued) {
this.issued = issued;
}
@Override
public LocalDateTime getExpires() {
public OffsetDateTime getExpires() {
return this.expiration;
}
public void setExpiration(LocalDateTime expiration) {
public void setExpiration(OffsetDateTime expiration) {
this.expiration = expiration;
}

View File

@ -6,10 +6,11 @@ import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import java.util.Optional;
import java.util.UUID;
public interface RefreshTokenRepository extends JpaRepository<RefreshTokenEntity, Long> {
Optional<RefreshTokenEntity> findByToken(String token);
Optional<RefreshTokenEntity> findByToken(UUID token);
@Modifying
@Transactional

View File

@ -3,13 +3,13 @@ package app.mealsmadeeasy.api.image;
import app.mealsmadeeasy.api.user.User;
import org.jetbrains.annotations.Nullable;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.util.Set;
public interface Image {
Long getId();
LocalDateTime getCreated();
@Nullable LocalDateTime getModified();
Integer getId();
OffsetDateTime getCreated();
@Nullable OffsetDateTime getModified();
String getUserFilename();
String getMimeType();
@Nullable String getAlt();

View File

@ -5,22 +5,23 @@ import app.mealsmadeeasy.api.user.UserEntity;
import jakarta.persistence.*;
import org.jetbrains.annotations.Nullable;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.util.HashSet;
import java.util.Set;
@Entity(name = "Image")
@Table(name = "image")
public class S3ImageEntity implements Image {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(nullable = false, updatable = false)
private Long id;
private Integer id;
@Column(nullable = false)
private LocalDateTime created = LocalDateTime.now();
private OffsetDateTime created = OffsetDateTime.now();
private LocalDateTime modified;
private OffsetDateTime modified;
@Column(nullable = false)
private String userFilename;
@ -47,32 +48,37 @@ public class S3ImageEntity implements Image {
private Boolean isPublic = false;
@ManyToMany
@JoinTable(
name = "image_viewer",
joinColumns = @JoinColumn(name = "image_id"),
inverseJoinColumns = @JoinColumn(name = "viewer_id")
)
private Set<UserEntity> viewers = new HashSet<>();
@Override
public Long getId() {
public Integer getId() {
return this.id;
}
public void setId(Long id) {
public void setId(Integer id) {
this.id = id;
}
@Override
public LocalDateTime getCreated() {
public OffsetDateTime getCreated() {
return this.created;
}
public void setCreated(LocalDateTime created) {
public void setCreated(OffsetDateTime created) {
this.created = created;
}
@Override
public @Nullable LocalDateTime getModified() {
public @Nullable OffsetDateTime getModified() {
return this.modified;
}
public void setModified(LocalDateTime modified) {
public void setModified(OffsetDateTime modified) {
this.modified = modified;
}

View File

@ -18,7 +18,7 @@ import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@ -225,7 +225,7 @@ public class S3ImageService implements ImageService {
}
}
if (didUpdate) {
entity.setModified(LocalDateTime.now());
entity.setModified(OffsetDateTime.now());
}
return this.imageRepository.save(entity);
}

View File

@ -4,7 +4,7 @@ import app.mealsmadeeasy.api.image.Image;
import app.mealsmadeeasy.api.user.view.UserInfoView;
import org.jetbrains.annotations.Nullable;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.util.Set;
import java.util.stream.Collectors;
@ -33,8 +33,8 @@ public class ImageView {
}
private String url;
private LocalDateTime created;
private @Nullable LocalDateTime modified;
private OffsetDateTime created;
private @Nullable OffsetDateTime modified;
private String filename;
private String mimeType;
private @Nullable String alt;
@ -53,19 +53,19 @@ public class ImageView {
this.url = url;
}
public LocalDateTime getCreated() {
public OffsetDateTime getCreated() {
return this.created;
}
public void setCreated(LocalDateTime created) {
public void setCreated(OffsetDateTime created) {
this.created = created;
}
public @Nullable LocalDateTime getModified() {
public @Nullable OffsetDateTime getModified() {
return this.modified;
}
public void setModified(@Nullable LocalDateTime modified) {
public void setModified(@Nullable OffsetDateTime modified) {
this.modified = modified;
}

View File

@ -0,0 +1,37 @@
package app.mealsmadeeasy.api.inference;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import reactor.core.publisher.Flux;
import java.io.IOException;
import java.util.Map;
@RestController
@RequestMapping("/inferences")
public class InferenceController {
private final InferenceService inferenceService;
public InferenceController(InferenceService inferenceService) {
this.inferenceService = inferenceService;
}
@PutMapping("/recipe-extract")
public ResponseEntity<String> recipeExtract(@RequestParam MultipartFile recipeImageFile) throws IOException {
return ResponseEntity.ok(this.inferenceService.extractRecipe(recipeImageFile.getInputStream()));
}
@PutMapping(value = "/recipe-extract-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<Map<String, String>>> recipeExtractStream(@RequestParam MultipartFile recipeImageFile) throws IOException {
return this.inferenceService.extractRecipeStream(recipeImageFile.getInputStream())
.map(data -> ServerSentEvent.builder(Map.of("delta", data)).build());
}
}

View File

@ -0,0 +1,52 @@
package app.mealsmadeeasy.api.inference;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.content.Media;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.InputStreamResource;
import org.springframework.stereotype.Service;
import org.springframework.util.MimeType;
import reactor.core.publisher.Flux;
import java.io.InputStream;
@Service
public class InferenceService {
private final ChatClient chatClient;
public InferenceService(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
public String extractRecipe(InputStream recipeImageInputStream) {
final Media media = Media.builder()
.data(new InputStreamResource(recipeImageInputStream))
.mimeType(MimeType.valueOf("image/jpeg"))
.build();
final String markdownResponse = this.chatClient.prompt()
.user(u ->
u.text(new ClassPathResource("app/mealsmadeeasy/api/inference/recipe-extract-user-prompt.md"))
.media(media)
)
.call()
.content();
return markdownResponse;
}
public Flux<String> extractRecipeStream(InputStream recipeImageInputStream) {
final Media media = Media.builder()
.data(new InputStreamResource(recipeImageInputStream))
.mimeType(MimeType.valueOf("image/jpeg"))
.build();
return this.chatClient.prompt()
.user(u ->
u.text(new ClassPathResource("app/mealsmadeeasy/api/inference/recipe-extract-user-prompt.md"))
.media(media)
)
.stream().content();
}
}

View File

@ -0,0 +1,36 @@
package app.mealsmadeeasy.api.inference;
import jakarta.persistence.*;
import java.util.UUID;
@Entity
@Table(name = "recipe_extraction")
public class RecipeExtractionEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Lob
@Column(columnDefinition = "TEXT")
@Basic(fetch = FetchType.LAZY)
private String markdown;
public UUID getId() {
return this.id;
}
public void setId(UUID id) {
this.id = id;
}
public String getMarkdown() {
return this.markdown;
}
public void setMarkdown(String markdown) {
this.markdown = markdown;
}
}

View File

@ -0,0 +1,33 @@
package app.mealsmadeeasy.api.inference;
import java.util.UUID;
public class RecipeExtractionView {
public static RecipeExtractionView from(RecipeExtractionEntity entity) {
final var view = new RecipeExtractionView();
view.setId(entity.getId());
view.setMarkdown(entity.getMarkdown());
return view;
}
private UUID id;
private String markdown;
public UUID getId() {
return this.id;
}
public void setId(UUID id) {
this.id = id;
}
public String getMarkdown() {
return this.markdown;
}
public void setMarkdown(String markdown) {
this.markdown = markdown;
}
}

View File

@ -0,0 +1,21 @@
package app.mealsmadeeasy.api.job;
import jakarta.persistence.Column;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
public class JobEntity {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(nullable = false, updatable = false)
private Long id;
@Column(nullable = false)
private String key;
@Column(nullable = false, columnDefinition = "jsonb")
private String payload;
}

View File

@ -6,13 +6,13 @@ import app.mealsmadeeasy.api.recipe.star.RecipeStar;
import app.mealsmadeeasy.api.user.User;
import org.jetbrains.annotations.Nullable;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.util.Set;
public interface Recipe {
Long getId();
LocalDateTime getCreated();
@Nullable LocalDateTime getModified();
Integer getId();
OffsetDateTime getCreated();
@Nullable OffsetDateTime getModified();
String getSlug();
String getTitle();
@Nullable Integer getPreparationTime();

View File

@ -1,10 +1,12 @@
package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.image.ImageException;
import app.mealsmadeeasy.api.recipe.body.RecipeSearchBody;
import app.mealsmadeeasy.api.recipe.comment.RecipeComment;
import app.mealsmadeeasy.api.recipe.comment.RecipeCommentCreateBody;
import app.mealsmadeeasy.api.recipe.comment.RecipeCommentService;
import app.mealsmadeeasy.api.recipe.comment.RecipeCommentView;
import app.mealsmadeeasy.api.recipe.spec.RecipeAiSearchSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
import app.mealsmadeeasy.api.recipe.star.RecipeStar;
import app.mealsmadeeasy.api.recipe.star.RecipeStarService;
@ -13,6 +15,7 @@ import app.mealsmadeeasy.api.recipe.view.RecipeExceptionView;
import app.mealsmadeeasy.api.recipe.view.RecipeInfoView;
import app.mealsmadeeasy.api.sliceview.SliceViewService;
import app.mealsmadeeasy.api.user.User;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.jetbrains.annotations.Nullable;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
@ -23,6 +26,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@ -33,17 +37,20 @@ public class RecipeController {
private final RecipeStarService recipeStarService;
private final RecipeCommentService recipeCommentService;
private final SliceViewService sliceViewService;
private final ObjectMapper objectMapper;
public RecipeController(
RecipeService recipeService,
RecipeStarService recipeStarService,
RecipeCommentService recipeCommentService,
SliceViewService sliceViewService
SliceViewService sliceViewService,
ObjectMapper objectMapper
) {
this.recipeService = recipeService;
this.recipeStarService = recipeStarService;
this.recipeCommentService = recipeCommentService;
this.sliceViewService = sliceViewService;
this.objectMapper = objectMapper;
}
@ExceptionHandler(RecipeException.class)
@ -104,6 +111,23 @@ public class RecipeController {
return ResponseEntity.ok(this.sliceViewService.getSliceView(slice));
}
@PostMapping
public ResponseEntity<Map<String, Object>> searchRecipes(
@RequestBody(required = false) RecipeSearchBody recipeSearchBody,
@AuthenticationPrincipal User user
) {
if (recipeSearchBody.getType() == RecipeSearchBody.Type.AI_PROMPT) {
final RecipeAiSearchSpec spec = this.objectMapper.convertValue(
recipeSearchBody.getData(),
RecipeAiSearchSpec.class
);
final List<RecipeInfoView> results = this.recipeService.aiSearch(spec, user);
return ResponseEntity.ok(Map.of("results", results));
} else {
throw new IllegalArgumentException("Invalid recipeSearchBody type: " + recipeSearchBody.getType());
}
}
@PostMapping("/{username}/{slug}/star")
public ResponseEntity<RecipeStar> addStar(
@PathVariable String username,

View File

@ -0,0 +1,63 @@
package app.mealsmadeeasy.api.recipe;
import jakarta.persistence.*;
import org.hibernate.annotations.Array;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import org.jetbrains.annotations.Nullable;
import java.time.OffsetDateTime;
@Entity
@Table(name = "recipe_embedding")
public class RecipeEmbeddingEntity {
@Id
private Integer id;
@OneToOne(fetch = FetchType.LAZY, optional = false)
@MapsId
@JoinColumn(name = "recipe_id")
private RecipeEntity recipe;
@JdbcTypeCode(SqlTypes.VECTOR)
@Array(length = 1024)
@Nullable
private float[] embedding;
@Column(nullable = false)
private OffsetDateTime timestamp;
public Integer getId() {
return this.id;
}
public void setId(Integer id) {
this.id = id;
}
public RecipeEntity getRecipe() {
return this.recipe;
}
public void setRecipe(RecipeEntity recipe) {
this.recipe = recipe;
}
public float[] getEmbedding() {
return this.embedding;
}
public void setEmbedding(float[] embedding) {
this.embedding = embedding;
}
public OffsetDateTime getTimestamp() {
return this.timestamp;
}
public void setTimestamp(OffsetDateTime timestamp) {
this.timestamp = timestamp;
}
}

View File

@ -10,7 +10,7 @@ import app.mealsmadeeasy.api.user.UserEntity;
import jakarta.persistence.*;
import org.jetbrains.annotations.Nullable;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.util.HashSet;
import java.util.Set;
@ -18,14 +18,14 @@ import java.util.Set;
public final class RecipeEntity implements Recipe {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(nullable = false, updatable = false)
private Long id;
private Integer id;
@Column(nullable = false)
private LocalDateTime created;
private OffsetDateTime created;
private LocalDateTime modified;
private OffsetDateTime modified;
@Column(nullable = false, unique = true)
private String slug;
@ -57,7 +57,7 @@ public final class RecipeEntity implements Recipe {
private UserEntity owner;
@OneToMany
@JoinColumn(name = "recipeId")
@JoinColumn(name = "recipe_id")
private Set<RecipeStarEntity> stars = new HashSet<>();
@OneToMany(mappedBy = "recipe")
@ -67,35 +67,44 @@ public final class RecipeEntity implements Recipe {
private Boolean isPublic = false;
@ManyToMany
@JoinTable(
name = "recipe_viewer",
joinColumns = @JoinColumn(name = "recipe_id"),
inverseJoinColumns = @JoinColumn(name = "viewer_id")
)
private Set<UserEntity> viewers = new HashSet<>();
@ManyToOne
@JoinColumn(name = "main_image_id")
private S3ImageEntity mainImage;
@OneToOne(mappedBy = "recipe", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
private RecipeEmbeddingEntity embedding;
@Override
public Long getId() {
public Integer getId() {
return this.id;
}
public void setId(Long id) {
public void setId(Integer id) {
this.id = id;
}
@Override
public LocalDateTime getCreated() {
public OffsetDateTime getCreated() {
return this.created;
}
public void setCreated(LocalDateTime created) {
public void setCreated(OffsetDateTime created) {
this.created = created;
}
@Override
public @Nullable LocalDateTime getModified() {
public @Nullable OffsetDateTime getModified() {
return this.modified;
}
public void setModified(@Nullable LocalDateTime modified) {
public void setModified(@Nullable OffsetDateTime modified) {
this.modified = modified;
}
@ -232,4 +241,12 @@ public final class RecipeEntity implements Recipe {
this.mainImage = image;
}
public RecipeEmbeddingEntity getEmbedding() {
return this.embedding;
}
public void setEmbedding(RecipeEmbeddingEntity embedding) {
this.embedding = embedding;
}
}

View File

@ -41,4 +41,34 @@ public interface RecipeRepository extends JpaRepository<RecipeEntity, Long> {
@Query("SELECT r FROM Recipe r WHERE r.isPublic OR r.owner = ?1 OR ?1 MEMBER OF r.viewers")
Slice<RecipeEntity> findAllViewableBy(UserEntity viewer, Pageable pageable);
List<RecipeEntity> findAllByEmbeddingIsNull();
@Query(
nativeQuery = true,
value = """
WITH distances AS (SELECT recipe_id, embedding <=> cast(?1 AS vector) AS distance FROM recipe_embedding)
SELECT r.* FROM distances d
INNER JOIN recipe r ON r.id = d.recipe_id
WHERE d.distance < ?2 AND (
r.is_public = TRUE
OR r.owner_id = ?3
OR exists(SELECT 1 FROM recipe_viewer v WHERE v.recipe_id = r.id AND v.viewer_id = ?3)
)
ORDER BY d.distance;
"""
)
List<RecipeEntity> searchByEmbeddingAndViewableBy(float[] queryEmbedding, float similarity, Integer viewerId);
@Query(
nativeQuery = true,
value = """
WITH distances AS (SELECT recipe_id, embedding <=> cast(?1 AS vector) AS distance FROM recipe_embedding)
SELECT r.* FROM distances d
INNER JOIN recipe r ON r.id = d.recipe_id
WHERE d.distance < ?2 AND r.is_public = TRUE
ORDER BY d.distance;
"""
)
List<RecipeEntity> searchByEmbeddingAndIsPublic(float[] queryEmbedding, float similarity);
}

View File

@ -1,11 +1,13 @@
package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.image.ImageException;
import app.mealsmadeeasy.api.recipe.spec.RecipeAiSearchSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
import app.mealsmadeeasy.api.recipe.view.FullRecipeView;
import app.mealsmadeeasy.api.recipe.view.RecipeInfoView;
import app.mealsmadeeasy.api.user.User;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.Nullable;
import org.springframework.data.domain.Pageable;
@ -35,6 +37,8 @@ public interface RecipeService {
List<Recipe> getRecipesViewableBy(User viewer);
List<Recipe> getRecipesOwnedBy(User owner);
List<RecipeInfoView> aiSearch(RecipeAiSearchSpec searchSpec, @Nullable User viewer);
Recipe update(String username, String slug, RecipeUpdateSpec spec, User modifier)
throws RecipeException, ImageException;
@ -53,4 +57,7 @@ public interface RecipeService {
@Contract("_, _, null -> null")
@Nullable Boolean isOwner(String username, String slug, @Nullable User viewer);
@ApiStatus.Internal
String getRenderedMarkdown(RecipeEntity entity);
}

View File

@ -6,6 +6,7 @@ import app.mealsmadeeasy.api.image.ImageService;
import app.mealsmadeeasy.api.image.S3ImageEntity;
import app.mealsmadeeasy.api.image.view.ImageView;
import app.mealsmadeeasy.api.markdown.MarkdownService;
import app.mealsmadeeasy.api.recipe.spec.RecipeAiSearchSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
import app.mealsmadeeasy.api.recipe.star.RecipeStarRepository;
@ -13,8 +14,10 @@ import app.mealsmadeeasy.api.recipe.view.FullRecipeView;
import app.mealsmadeeasy.api.recipe.view.RecipeInfoView;
import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.user.UserEntity;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.Nullable;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.security.access.AccessDeniedException;
@ -22,7 +25,7 @@ import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@ -34,17 +37,20 @@ public class RecipeServiceImpl implements RecipeService {
private final RecipeStarRepository recipeStarRepository;
private final ImageService imageService;
private final MarkdownService markdownService;
private final EmbeddingModel embeddingModel;
public RecipeServiceImpl(
RecipeRepository recipeRepository,
RecipeStarRepository recipeStarRepository,
ImageService imageService,
MarkdownService markdownService
MarkdownService markdownService,
EmbeddingModel embeddingModel
) {
this.recipeRepository = recipeRepository;
this.recipeStarRepository = recipeStarRepository;
this.imageService = imageService;
this.markdownService = markdownService;
this.embeddingModel = embeddingModel;
}
@Override
@ -53,7 +59,7 @@ public class RecipeServiceImpl implements RecipeService {
throw new AccessDeniedException("Must be logged in.");
}
final RecipeEntity draft = new RecipeEntity();
draft.setCreated(LocalDateTime.now());
draft.setCreated(OffsetDateTime.now());
draft.setOwner((UserEntity) owner);
draft.setSlug(spec.getSlug());
draft.setTitle(spec.getTitle());
@ -93,7 +99,9 @@ public class RecipeServiceImpl implements RecipeService {
));
}
private String getRenderedMarkdown(RecipeEntity entity) {
@Override
@ApiStatus.Internal
public String getRenderedMarkdown(RecipeEntity entity) {
if (entity.getCachedRenderedText() == null) {
entity.setCachedRenderedText(this.markdownService.renderAndCleanMarkdown(entity.getRawText()));
entity = this.recipeRepository.save(entity);
@ -191,6 +199,20 @@ public class RecipeServiceImpl implements RecipeService {
return List.copyOf(this.recipeRepository.findAllByOwner((UserEntity) owner));
}
@Override
public List<RecipeInfoView> aiSearch(RecipeAiSearchSpec searchSpec, @Nullable User viewer) {
final float[] queryEmbedding = this.embeddingModel.embed(searchSpec.getPrompt());
final List<RecipeEntity> results;
if (viewer == null) {
results = this.recipeRepository.searchByEmbeddingAndIsPublic(queryEmbedding, 0.5f);
} else {
results = this.recipeRepository.searchByEmbeddingAndViewableBy(queryEmbedding, 0.5f, viewer.getId());
}
return results.stream()
.map(recipeEntity -> this.getInfoView(recipeEntity, viewer))
.toList();
}
@Override
@PreAuthorize("@recipeSecurity.isOwner(#username, #slug, #modifier)")
public Recipe update(String username, String slug, RecipeUpdateSpec spec, User modifier)
@ -222,7 +244,7 @@ public class RecipeServiceImpl implements RecipeService {
}
recipe.setMainImage(mainImage);
recipe.setModified(LocalDateTime.now());
recipe.setModified(OffsetDateTime.now());
return this.recipeRepository.save(recipe);
}
@ -279,7 +301,7 @@ public class RecipeServiceImpl implements RecipeService {
if (viewer == null) {
return null;
}
return this.recipeStarRepository.isStarer(username, slug, viewer.getUsername());
return this.recipeStarRepository.isStarer(username, slug, viewer.getId());
}
@Override

View File

@ -0,0 +1,30 @@
package app.mealsmadeeasy.api.recipe.body;
import java.util.Map;
public class RecipeSearchBody {
public enum Type {
AI_PROMPT
}
private Type type;
private Map<String, Object> data;
public Type getType() {
return this.type;
}
public void setType(Type type) {
this.type = type;
}
public Map<String, Object> getData() {
return this.data;
}
public void setData(Map<String, Object> data) {
this.data = data;
}
}

View File

@ -4,12 +4,12 @@ import app.mealsmadeeasy.api.recipe.Recipe;
import app.mealsmadeeasy.api.user.User;
import org.jetbrains.annotations.Nullable;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
public interface RecipeComment {
Long getId();
LocalDateTime getCreated();
@Nullable LocalDateTime getModified();
Integer getId();
OffsetDateTime getCreated();
@Nullable OffsetDateTime getModified();
String getRawText();
User getOwner();
Recipe getRecipe();

View File

@ -4,20 +4,21 @@ import app.mealsmadeeasy.api.recipe.RecipeEntity;
import app.mealsmadeeasy.api.user.UserEntity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
@Entity(name = "RecipeComment")
@Table(name = "recipe_comment")
public final class RecipeCommentEntity implements RecipeComment {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(nullable = false)
private Long id;
private Integer id;
@Column(nullable = false, updatable = false)
private LocalDateTime created = LocalDateTime.now();
private OffsetDateTime created = OffsetDateTime.now();
private LocalDateTime modified;
private OffsetDateTime modified;
@Lob
@Basic(fetch = FetchType.LAZY)
@ -35,29 +36,29 @@ public final class RecipeCommentEntity implements RecipeComment {
@JoinColumn(name = "recipe_id", nullable = false, updatable = false)
private RecipeEntity recipe;
public Long getId() {
public Integer getId() {
return this.id;
}
public void setId(Long id) {
public void setId(Integer id) {
this.id = id;
}
@Override
public LocalDateTime getCreated() {
public OffsetDateTime getCreated() {
return this.created;
}
public void setCreated(LocalDateTime created) {
public void setCreated(OffsetDateTime created) {
this.created = created;
}
@Override
public LocalDateTime getModified() {
public OffsetDateTime getModified() {
return this.modified;
}
public void setModified(LocalDateTime modified) {
public void setModified(OffsetDateTime modified) {
this.modified = modified;
}

View File

@ -12,7 +12,7 @@ import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import static java.util.Objects.requireNonNull;
@ -43,7 +43,7 @@ public class RecipeCommentServiceImpl implements RecipeCommentService {
) throws RecipeException {
requireNonNull(commenter);
final RecipeCommentEntity draft = new RecipeCommentEntity();
draft.setCreated(LocalDateTime.now());
draft.setCreated(OffsetDateTime.now());
draft.setRawText(body.getText());
draft.setCachedRenderedText(this.markdownService.renderAndCleanMarkdown(body.getText()));
draft.setOwner((UserEntity) commenter);

View File

@ -3,7 +3,7 @@ package app.mealsmadeeasy.api.recipe.comment;
import app.mealsmadeeasy.api.user.view.UserInfoView;
import org.jetbrains.annotations.Nullable;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
public class RecipeCommentView {
@ -21,35 +21,35 @@ public class RecipeCommentView {
return view;
}
private Long id;
private LocalDateTime created;
private @Nullable LocalDateTime modified;
private Integer id;
private OffsetDateTime created;
private @Nullable OffsetDateTime modified;
private String text;
private @Nullable String rawText;
private UserInfoView owner;
private Long recipeId;
private Integer recipeId;
public Long getId() {
public Integer getId() {
return this.id;
}
public void setId(Long id) {
public void setId(Integer id) {
this.id = id;
}
public LocalDateTime getCreated() {
public OffsetDateTime getCreated() {
return this.created;
}
public void setCreated(LocalDateTime created) {
public void setCreated(OffsetDateTime created) {
this.created = created;
}
public @Nullable LocalDateTime getModified() {
public @Nullable OffsetDateTime getModified() {
return this.modified;
}
public void setModified(@Nullable LocalDateTime modified) {
public void setModified(@Nullable OffsetDateTime modified) {
this.modified = modified;
}
@ -77,11 +77,11 @@ public class RecipeCommentView {
this.owner = owner;
}
public Long getRecipeId() {
public Integer getRecipeId() {
return this.recipeId;
}
public void setRecipeId(Long recipeId) {
public void setRecipeId(Integer recipeId) {
this.recipeId = recipeId;
}

View File

@ -0,0 +1,15 @@
package app.mealsmadeeasy.api.recipe.spec;
public class RecipeAiSearchSpec {
private String prompt;
public String getPrompt() {
return this.prompt;
}
public void setPrompt(String prompt) {
this.prompt = prompt;
}
}

View File

@ -1,7 +1,7 @@
package app.mealsmadeeasy.api.recipe.star;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
public interface RecipeStar {
LocalDateTime getDate();
OffsetDateTime getTimestamp();
}

View File

@ -3,17 +3,19 @@ package app.mealsmadeeasy.api.recipe.star;
import jakarta.persistence.Column;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
@Entity(name = "RecipeStar")
@Table(name = "recipe_star")
public final class RecipeStarEntity implements RecipeStar {
@EmbeddedId
private RecipeStarId id;
@Column(nullable = false, updatable = false)
private LocalDateTime date = LocalDateTime.now();
private OffsetDateTime timestamp = OffsetDateTime.now();
public RecipeStarId getId() {
return this.id;
@ -23,13 +25,12 @@ public final class RecipeStarEntity implements RecipeStar {
this.id = id;
}
@Override
public LocalDateTime getDate() {
return this.date;
public OffsetDateTime getTimestamp() {
return this.timestamp;
}
public void setDate(LocalDateTime date) {
this.date = date;
public void setTimestamp(OffsetDateTime date) {
this.timestamp = date;
}
@Override

View File

@ -8,25 +8,25 @@ import java.util.Objects;
@Embeddable
public class RecipeStarId {
@Column(nullable = false)
private String ownerUsername;
@Column(name = "owner_id", nullable = false)
private Integer ownerId;
@Column(nullable = false)
private Long recipeId;
@Column(name = "recipe_id", nullable = false)
private Integer recipeId;
public String getOwnerUsername() {
return this.ownerUsername;
public Integer getOwnerId() {
return this.ownerId;
}
public void setOwnerUsername(String ownerUsername) {
this.ownerUsername = ownerUsername;
public void getOwnerId(Integer ownerId) {
this.ownerId = ownerId;
}
public Long getRecipeId() {
public Integer getRecipeId() {
return this.recipeId;
}
public void setRecipeId(Long recipeId) {
public void setRecipeId(Integer recipeId) {
this.recipeId = recipeId;
}
@ -34,19 +34,19 @@ public class RecipeStarId {
public boolean equals(Object o) {
if (this == o) return true;
if (o instanceof RecipeStarId other) {
return this.recipeId.equals(other.recipeId) && this.ownerUsername.equals(other.ownerUsername);
return this.recipeId.equals(other.recipeId) && this.ownerId.equals(other.ownerId);
}
return false;
}
@Override
public int hashCode() {
return Objects.hash(this.recipeId, this.ownerUsername);
return Objects.hash(this.recipeId, this.ownerId);
}
@Override
public String toString() {
return "RecipeStarId(" + this.recipeId + ", " + this.ownerUsername + ")";
return "RecipeStarId(" + this.recipeId + ", " + this.ownerId + ")";
}
}

View File

@ -9,15 +9,15 @@ import java.util.Optional;
public interface RecipeStarRepository extends JpaRepository<RecipeStarEntity, Long> {
@Query("SELECT star FROM RecipeStar star WHERE star.id.recipeId = ?1 AND star.id.ownerUsername = ?2")
Optional<RecipeStarEntity> findByRecipeIdAndOwnerUsername(Long recipeId, String username);
@Query("SELECT star FROM RecipeStar star WHERE star.id.recipeId = ?1 AND star.id.ownerId = ?2")
Optional<RecipeStarEntity> findByRecipeIdAndOwnerId(Integer recipeId, Integer ownerId);
@Query("SELECT count(rs) > 0 FROM RecipeStar rs, Recipe r WHERE r.owner.username = ?1 AND r.slug = ?2 AND r.id = rs.id.recipeId AND rs.id.ownerUsername = ?3")
boolean isStarer(String ownerUsername, String slug, String viewerUsername);
@Query("SELECT count(rs) > 0 FROM RecipeStar rs, Recipe r WHERE r.owner.username = ?1 AND r.slug = ?2 AND r.id = rs.id.recipeId AND rs.id.ownerId = ?3")
boolean isStarer(String ownerUsername, String slug, Integer viewerId);
@Modifying
@Transactional
@Query("DELETE FROM RecipeStar star WHERE star.id.recipeId = ?1 AND star.id.ownerUsername = ?2")
void deleteByRecipeIdAndOwnerUsername(Long recipeId, String username);
@Query("DELETE FROM RecipeStar star WHERE star.id.recipeId = ?1 AND star.id.ownerId = ?2")
void deleteByRecipeIdAndOwnerId(Integer recipeId, Integer ownerId);
}

View File

@ -6,11 +6,11 @@ import app.mealsmadeeasy.api.user.User;
import java.util.Optional;
public interface RecipeStarService {
RecipeStar create(long recipeId, String ownerUsername);
RecipeStar create(Integer recipeId, Integer ownerId);
RecipeStar create(String recipeOwnerUsername, String recipeSlug, User starer) throws RecipeException;
Optional<RecipeStar> find(String recipeOwnerUsername, String recipeSlug, User starer) throws RecipeException;
void delete(long recipeId, String ownerUsername);
void delete(Integer recipeId, Integer ownerId);
void delete(String recipeOwnerUsername, String recipeSlug, User starer) throws RecipeException;
}

View File

@ -6,7 +6,7 @@ import app.mealsmadeeasy.api.recipe.RecipeService;
import app.mealsmadeeasy.api.user.User;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.util.Optional;
@Service
@ -21,45 +21,45 @@ public class RecipeStarServiceImpl implements RecipeStarService {
}
@Override
public RecipeStar create(long recipeId, String ownerUsername) {
public RecipeStar create(Integer recipeId, Integer ownerId) {
final RecipeStarEntity draft = new RecipeStarEntity();
final RecipeStarId id = new RecipeStarId();
id.setRecipeId(recipeId);
id.setOwnerUsername(ownerUsername);
id.getOwnerId(ownerId);
draft.setId(id);
draft.setDate(LocalDateTime.now());
draft.setTimestamp(OffsetDateTime.now());
return this.recipeStarRepository.save(draft);
}
@Override
public RecipeStar create(String recipeOwnerUsername, String recipeSlug, User starer) throws RecipeException {
final Recipe recipe = this.recipeService.getByUsernameAndSlug(recipeOwnerUsername, recipeSlug, starer);
final Optional<RecipeStarEntity> existing = this.recipeStarRepository.findByRecipeIdAndOwnerUsername(
final Optional<RecipeStarEntity> existing = this.recipeStarRepository.findByRecipeIdAndOwnerId(
recipe.getId(),
starer.getUsername()
starer.getId()
);
if (existing.isPresent()) {
return existing.get();
}
return this.create(recipe.getId(), starer.getUsername());
return this.create(recipe.getId(), starer.getId());
}
@Override
public Optional<RecipeStar> find(String recipeOwnerUsername, String recipeSlug, User starer) throws RecipeException {
final Recipe recipe = this.recipeService.getByUsernameAndSlug(recipeOwnerUsername, recipeSlug, starer);
return this.recipeStarRepository.findByRecipeIdAndOwnerUsername(recipe.getId(), starer.getUsername())
return this.recipeStarRepository.findByRecipeIdAndOwnerId(recipe.getId(), starer.getId())
.map(RecipeStar.class::cast);
}
@Override
public void delete(long recipeId, String ownerUsername) {
this.recipeStarRepository.deleteByRecipeIdAndOwnerUsername(recipeId, ownerUsername);
public void delete(Integer recipeId, Integer ownerId) {
this.recipeStarRepository.deleteByRecipeIdAndOwnerId(recipeId, ownerId);
}
@Override
public void delete(String recipeOwnerUsername, String recipeSlug, User starer) throws RecipeException {
final Recipe recipe = this.recipeService.getByUsernameAndSlug(recipeOwnerUsername, recipeSlug, starer);
this.delete(recipe.getId(), starer.getUsername());
this.delete(recipe.getId(), starer.getId());
}
}

View File

@ -7,7 +7,7 @@ import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import org.jetbrains.annotations.Nullable;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
public class FullRecipeView {
@ -41,8 +41,8 @@ public class FullRecipeView {
}
private long id;
private LocalDateTime created;
private @Nullable LocalDateTime modified;
private OffsetDateTime created;
private @Nullable OffsetDateTime modified;
private String slug;
private String title;
private @Nullable Integer preparationTime;
@ -64,19 +64,19 @@ public class FullRecipeView {
this.id = id;
}
public LocalDateTime getCreated() {
public OffsetDateTime getCreated() {
return this.created;
}
public void setCreated(LocalDateTime created) {
public void setCreated(OffsetDateTime created) {
this.created = created;
}
public @Nullable LocalDateTime getModified() {
public @Nullable OffsetDateTime getModified() {
return this.modified;
}
public void setModified(@Nullable LocalDateTime modified) {
public void setModified(@Nullable OffsetDateTime modified) {
this.modified = modified;
}

View File

@ -5,7 +5,7 @@ import app.mealsmadeeasy.api.recipe.Recipe;
import app.mealsmadeeasy.api.user.view.UserInfoView;
import org.jetbrains.annotations.Nullable;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
public final class RecipeInfoView {
@ -26,9 +26,9 @@ public final class RecipeInfoView {
return view;
}
private long id;
private LocalDateTime created;
private LocalDateTime modified;
private Integer id;
private OffsetDateTime created;
private OffsetDateTime modified;
private String slug;
private String title;
private @Nullable Integer preparationTime;
@ -39,27 +39,27 @@ public final class RecipeInfoView {
private int starCount;
private @Nullable ImageView mainImage;
public long getId() {
public Integer getId() {
return this.id;
}
public void setId(long id) {
public void setId(Integer id) {
this.id = id;
}
public LocalDateTime getCreated() {
public OffsetDateTime getCreated() {
return this.created;
}
public void setCreated(LocalDateTime created) {
public void setCreated(OffsetDateTime created) {
this.created = created;
}
public LocalDateTime getModified() {
public OffsetDateTime getModified() {
return this.modified;
}
public void setModified(LocalDateTime modified) {
public void setModified(OffsetDateTime modified) {
this.modified = modified;
}

View File

@ -1,6 +1,7 @@
package app.mealsmadeeasy.api.security;
import app.mealsmadeeasy.api.jwt.JwtService;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
@ -18,7 +19,6 @@ import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import tools.jackson.databind.ObjectMapper;
import java.io.IOException;

View File

@ -33,7 +33,7 @@ public class SecurityConfiguration {
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) {
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity.authorizeHttpRequests(requests -> requests.anyRequest().permitAll());
httpSecurity.csrf(AbstractHttpConfigurer::disable);
httpSecurity.cors(Customizer.withDefaults());

View File

@ -6,7 +6,7 @@ import java.util.Set;
public interface User extends UserDetails {
Long getId();
Integer getId();
String getEmail();
void setEmail(String email);

View File

@ -24,7 +24,7 @@ public final class UserEntity implements User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(nullable = false)
private Long id;
private Integer id;
@Column(unique = true, nullable = false)
private String username;
@ -51,11 +51,11 @@ public final class UserEntity implements User {
private Boolean credentialsExpired;
@Override
public Long getId() {
public Integer getId() {
return this.id;
}
public void setId(Long id) {
public void setId(Integer id) {
this.id = id;
}

View File

@ -2,18 +2,19 @@ package app.mealsmadeeasy.api.user;
import jakarta.persistence.*;
@Entity
@Entity(name = "UserGrantedAuthority")
@Table(name = "user_granted_authority")
public final class UserGrantedAuthorityEntity implements UserGrantedAuthority {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(nullable = false)
private Long id;
private Integer id;
private String authority;
@ManyToOne
@JoinColumn(name = "user_entity_id")
@JoinColumn(name = "user_id")
private UserEntity userEntity;
@Override

View File

@ -11,14 +11,14 @@ public class UserInfoView {
return userInfoView;
}
private long id;
private Integer id;
private String username;
public long getId() {
public Integer getId() {
return this.id;
}
public void setId(long id) {
public void setId(Integer id) {
this.id = id;
}

View File

@ -0,0 +1 @@
Convert the recipe in the image to Markdown.

View File

@ -1,9 +1,14 @@
spring.application.name=meals-made-easy-api
spring.jpa.hibernate.ddl-auto=create-drop
spring.datasource.url=jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:meals_made_easy_api}
spring.datasource.username=${MYSQL_USERNAME:meals-made-easy-api-user}
spring.datasource.password=${MYSQL_PASSWORD}
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.hibernate.ddl-auto=none
spring.datasource.url=jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:meals_made_easy_api}
spring.datasource.username=${POSTGRES_USER:meals-made-easy-api-user}
spring.datasource.password=${POSTGRES_PASSWORD}
# Source - https://stackoverflow.com/questions/3164072/large-objects-may-not-be-used-in-auto-commit-mode
# Posted by Iogui, modified by community. See post 'Timeline' for change history
# Retrieved 2025-12-25, License - CC BY-SA 4.0
spring.datasource.hikari.auto-commit=false
app.mealsmadeeasy.api.baseUrl=http://localhost:8080
app.mealsmadeeasy.api.security.access-token-lifetime=60
app.mealsmadeeasy.api.security.refresh-token-lifetime=3600
@ -11,3 +16,8 @@ app.mealsmadeeasy.api.minio.endpoint=http://${MINIO_HOST:localhost}:${MINIO_PORT
app.mealsmadeeasy.api.minio.accessKey=${MINIO_ROOT_USER}
app.mealsmadeeasy.api.minio.secretKey=${MINIO_ROOT_PASSWORD}
app.mealsmadeeasy.api.images.bucketName=images
# AI
spring.ai.vectorstore.pgvector.dimensions=1024
spring.ai.ollama.chat.options.model=deepseek-ocr:latest
spring.ai.ollama.init.pull-model-strategy=never

View File

@ -0,0 +1,108 @@
CREATE TABLE "user" (
id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
username VARCHAR(255) NOT NULL UNIQUE,
locked BOOLEAN NOT NULL,
expired BOOLEAN NOT NULL,
enabled BOOLEAN NOT NULL,
credentials_expired BOOLEAN NOT NULL
);
CREATE TABLE user_granted_authority (
id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
user_id INT NOT NULL,
authority VARCHAR(255),
FOREIGN KEY (user_id) REFERENCES "user"
);
CREATE TABLE refresh_token (
token UUID PRIMARY KEY,
issued TIMESTAMPTZ(6) NOT NULL,
expiration TIMESTAMPTZ(6) NOT NULL,
owner_id INT NOT NULL,
deleted BOOLEAN NOT NULL,
revoked BOOLEAN NOT NULL,
FOREIGN KEY (owner_id) REFERENCES "user"
);
CREATE TABLE image (
id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
created TIMESTAMPTZ(6) NOT NULL,
modified TIMESTAMPTZ(6),
owner_id INT NOT NULL,
is_public BOOLEAN NOT NULL,
object_name VARCHAR(255) NOT NULL,
user_filename VARCHAR(255) NOT NULL,
mime_type VARCHAR(255) NOT NULL,
alt VARCHAR(255),
caption VARCHAR(255),
height INTEGER,
width INTEGER,
FOREIGN KEY (owner_id) REFERENCES "user"
);
CREATE TABLE image_viewer (
image_id INT NOT NULL,
viewer_id INT NOT NULL,
PRIMARY KEY (image_id, viewer_id),
FOREIGN KEY (image_id) REFERENCES image,
FOREIGN KEY (viewer_id) REFERENCES "user"
);
CREATE TABLE recipe (
id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
created TIMESTAMPTZ(6) NOT NULL,
modified TIMESTAMPTZ(6),
owner_id INT NOT NULL,
slug VARCHAR(255) NOT NULL,
is_public BOOLEAN NOT NULL,
title VARCHAR(255) NOT NULL,
raw_text TEXT NOT NULL,
cached_rendered_text TEXT,
main_image_id INT,
preparation_time INT,
cooking_time INT,
total_time INT,
FOREIGN KEY (owner_id) REFERENCES "user",
FOREIGN KEY (main_image_id) REFERENCES image
);
CREATE INDEX recipe_username_slug ON recipe (owner_id, slug);
CREATE TABLE recipe_viewer (
recipe_id INT NOT NULL,
viewer_id INT NOT NULL,
PRIMARY KEY (recipe_id, viewer_id),
FOREIGN KEY (recipe_id) REFERENCES recipe,
FOREIGN KEY (viewer_id) REFERENCES "user"
);
CREATE TABLE recipe_comment (
id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
created TIMESTAMPTZ(6) NOT NULL,
modified TIMESTAMPTZ(6),
owner_id INT NOT NULL,
recipe_id INT NOT NULL,
raw_text TEXT NOT NULL,
cached_rendered_text TEXT,
FOREIGN KEY (owner_id) REFERENCES "user",
FOREIGN KEY (recipe_id) REFERENCES recipe
);
CREATE TABLE recipe_star (
timestamp TIMESTAMPTZ(6) NOT NULL,
recipe_id INT NOT NULL,
owner_id INT NOT NULL,
PRIMARY KEY (recipe_id, owner_id),
FOREIGN KEY (recipe_id) references recipe,
FOREIGN KEY (owner_id) references "user"
);

View File

@ -0,0 +1 @@
CREATE UNIQUE INDEX image_owner_id_user_filename ON image (owner_id, user_filename);

View File

@ -0,0 +1 @@
CREATE EXTENSION vector;

View File

@ -0,0 +1,7 @@
CREATE TABLE recipe_embedding (
recipe_id INT NOT NULL,
timestamp TIMESTAMPTZ(6) NOT NULL,
embedding VECTOR(1024),
PRIMARY KEY (recipe_id),
FOREIGN KEY (recipe_id) REFERENCES recipe ON DELETE CASCADE
);

View File

@ -4,7 +4,7 @@ import app.mealsmadeeasy.api.matchers.ContainsItemsMatcher;
import java.util.List;
public final class ContainsImagesMatcher extends ContainsItemsMatcher<Image, Image, Long> {
public final class ContainsImagesMatcher extends ContainsItemsMatcher<Image, Image, Integer> {
public static ContainsImagesMatcher containsImages(Image... expected) {
return new ContainsImagesMatcher(expected);

View File

@ -5,7 +5,7 @@ import app.mealsmadeeasy.api.recipe.view.RecipeInfoView;
import java.util.List;
public class ContainsRecipeInfoViewsForRecipesMatcher extends ContainsItemsMatcher<RecipeInfoView, Recipe, Long> {
public class ContainsRecipeInfoViewsForRecipesMatcher extends ContainsItemsMatcher<RecipeInfoView, Recipe, Integer> {
public static ContainsRecipeInfoViewsForRecipesMatcher containsRecipeInfoViewsForRecipes(Recipe... expected) {
return new ContainsRecipeInfoViewsForRecipesMatcher(List.of(expected));

View File

@ -21,7 +21,7 @@ public class ContainsRecipeStarsMatcher extends ContainsItemsMatcher<RecipeStar,
recipeStar -> ((RecipeStarEntity) recipeStar).getId(),
recipeStar -> ((RecipeStarEntity) recipeStar).getId(),
(id0, id1) -> Objects.equals(id0.getRecipeId(), id1.getRecipeId())
&& Objects.equals(id0.getOwnerUsername(), id1.getOwnerUsername())
&& Objects.equals(id0.getOwnerId(), id1.getOwnerId())
);
}

View File

@ -4,7 +4,7 @@ import app.mealsmadeeasy.api.matchers.ContainsItemsMatcher;
import java.util.List;
public final class ContainsRecipesMatcher extends ContainsItemsMatcher<Recipe, Recipe, Long> {
public final class ContainsRecipesMatcher extends ContainsItemsMatcher<Recipe, Recipe, Integer> {
public static ContainsRecipesMatcher containsRecipes(Recipe... expected) {
return new ContainsRecipesMatcher(expected);

View File

@ -4,7 +4,7 @@ import app.mealsmadeeasy.api.matchers.ContainsItemsMatcher;
import java.util.List;
public class ContainsUsersMatcher extends ContainsItemsMatcher<User, User, Long> {
public class ContainsUsersMatcher extends ContainsItemsMatcher<User, User, Integer> {
public static ContainsUsersMatcher containsUsers(User... allExpected) {
return new ContainsUsersMatcher(allExpected);