Service and data layer handling of jobs, files, and recipe inferences.
This commit is contained in:
parent
012bf743a1
commit
7f985f3434
@ -80,6 +80,9 @@ dependencies {
|
||||
implementation 'org.jsoup:jsoup:1.21.2'
|
||||
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1'
|
||||
|
||||
// Source: https://mvnrepository.com/artifact/io.hypersistence/hypersistence-utils-hibernate-63
|
||||
implementation 'io.hypersistence:hypersistence-utils-hibernate-63:3.14.1'
|
||||
|
||||
implementation 'io.minio:minio:8.6.0'
|
||||
|
||||
compileOnly 'org.jetbrains:annotations:26.0.2-1'
|
||||
@ -98,6 +101,10 @@ dependencies {
|
||||
testImplementation 'org.testcontainers:junit-jupiter:1.21.4'
|
||||
testImplementation 'org.testcontainers:postgresql:1.21.4'
|
||||
testImplementation 'org.testcontainers:minio:1.21.4'
|
||||
testImplementation 'org.testcontainers:testcontainers-ollama:2.0.3'
|
||||
|
||||
// Source: https://mvnrepository.com/artifact/org.awaitility/awaitility
|
||||
testImplementation("org.awaitility:awaitility:4.3.0")
|
||||
|
||||
testFixturesImplementation 'org.hamcrest:hamcrest:3.0'
|
||||
}
|
||||
|
||||
@ -0,0 +1,75 @@
|
||||
package app.mealsmadeeasy.api.job;
|
||||
|
||||
import app.mealsmadeeasy.api.IntegrationTestsExtension;
|
||||
import app.mealsmadeeasy.api.recipe.job.RecipeInferJobHandler;
|
||||
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.boot.test.context.TestConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.awaitility.Awaitility.await;
|
||||
|
||||
@SpringBootTest
|
||||
@ExtendWith(IntegrationTestsExtension.class)
|
||||
public class JobServiceIntegrationTests {
|
||||
|
||||
@Component
|
||||
private static final class TestJobHandler implements JobHandler<Object> {
|
||||
|
||||
@Override
|
||||
public Class<Object> getPayloadType() {
|
||||
return Object.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getJobKey() {
|
||||
return "TEST_JOB_TYPE";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(Job job, Object payload) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@TestConfiguration
|
||||
public static class JobServiceIntegrationTestsConfiguration {
|
||||
|
||||
@Bean
|
||||
public JobHandler<Object> testJobHandler() {
|
||||
return new TestJobHandler();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// If not mockito, it will try to load a ChatClient
|
||||
@MockitoBean
|
||||
private RecipeInferJobHandler recipeInferJobHandler;
|
||||
|
||||
@Autowired
|
||||
private JobService jobService;
|
||||
|
||||
@Autowired
|
||||
private JobRepository jobRepository;
|
||||
|
||||
@Test
|
||||
public void smokeScreen() {
|
||||
final Job job = this.jobService.create("TEST_JOB_TYPE", null);
|
||||
await().atMost(1, TimeUnit.SECONDS).until(() -> {
|
||||
final Job foundJob = this.jobRepository.findById(job.getId()).orElse(null);
|
||||
if (foundJob == null) {
|
||||
return false;
|
||||
} else {
|
||||
return foundJob.getState().equals(Job.State.DONE);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@ -24,8 +24,7 @@ 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.hamcrest.Matchers.*;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
@ -342,4 +341,13 @@ public class RecipeServiceTests {
|
||||
assertThrows(AccessDeniedException.class, () -> this.recipeService.deleteRecipe(toDelete.getId(), notOwner));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createDraftReturnsDefaultRecipeDraft() {
|
||||
final User owner = this.seedUser();
|
||||
final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
|
||||
assertThat(recipeDraft.getCreated(), is(notNullValue()));
|
||||
assertThat(recipeDraft.getState(), is(RecipeDraft.State.ENTER_DATA));
|
||||
assertThat(recipeDraft.getOwner(), is(owner));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,88 @@
|
||||
package app.mealsmadeeasy.api.recipe.job;
|
||||
|
||||
import app.mealsmadeeasy.api.IntegrationTestsExtension;
|
||||
import app.mealsmadeeasy.api.file.File;
|
||||
import app.mealsmadeeasy.api.file.FileService;
|
||||
import app.mealsmadeeasy.api.recipe.RecipeDraft;
|
||||
import app.mealsmadeeasy.api.recipe.RecipeService;
|
||||
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.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.testcontainers.containers.MinIOContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.awaitility.Awaitility.await;
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.Matchers.is;
|
||||
import static org.hamcrest.Matchers.notNullValue;
|
||||
|
||||
@SpringBootTest
|
||||
@ExtendWith(IntegrationTestsExtension.class)
|
||||
@Testcontainers
|
||||
public class RecipeInferJobIntegrationTests {
|
||||
|
||||
@Container
|
||||
private static final MinIOContainer minioContainer = new MinIOContainer("minio/minio:latest");
|
||||
|
||||
@DynamicPropertySource
|
||||
public static void minioProperties(DynamicPropertyRegistry registry) {
|
||||
registry.add("app.mealsmadeeasy.api.minio.endpoint", minioContainer::getS3URL);
|
||||
registry.add("app.mealsmadeeasy.api.minio.accessKey", minioContainer::getUserName);
|
||||
registry.add("app.mealsmadeeasy.api.minio.secretKey", minioContainer::getPassword);
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Autowired
|
||||
private RecipeService recipeService;
|
||||
|
||||
@Autowired
|
||||
private FileService fileService;
|
||||
|
||||
@Test
|
||||
public void enqueueJobAndWait() throws UserCreateException, IOException {
|
||||
final String ownerName = UUID.randomUUID().toString();
|
||||
final User owner = this.userService.createUser(
|
||||
ownerName,
|
||||
ownerName + "@test.com",
|
||||
"test-pass"
|
||||
);
|
||||
final File sourceFile = this.fileService.create(
|
||||
RecipeInferJobIntegrationTests.class.getResourceAsStream("recipe.jpeg"),
|
||||
"recipe.jpeg",
|
||||
127673L,
|
||||
owner
|
||||
);
|
||||
final RecipeDraft draft = this.recipeService.createAiDraft(sourceFile, owner);
|
||||
|
||||
await().atMost(60, TimeUnit.SECONDS).until(() -> {
|
||||
final RecipeDraft foundDraft = this.recipeService.getDraftById(draft.getId());
|
||||
return foundDraft.getState().equals(RecipeDraft.State.ENTER_DATA);
|
||||
});
|
||||
|
||||
final RecipeDraft draftWithInference = this.recipeService.getDraftById(draft.getId());
|
||||
|
||||
assertThat(draftWithInference.getInferences(), is(notNullValue()));
|
||||
final List<RecipeDraft.RecipeDraftInference> inferences = draftWithInference.getInferences();
|
||||
|
||||
assertThat(inferences.size(), is(1));
|
||||
final RecipeDraft.RecipeDraftInference inference = inferences.getFirst();
|
||||
assertThat(inference.getTitle(), is(notNullValue()));
|
||||
assertThat(inference.getRawText(), is(notNullValue()));
|
||||
assertThat(inference.getInferredAt(), is(notNullValue()));
|
||||
}
|
||||
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
@ -5,6 +5,7 @@ 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
|
||||
app.mealsmadeeasy.api.files.bucketName=files
|
||||
|
||||
# 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
|
||||
|
||||
36
src/main/java/app/mealsmadeeasy/api/file/File.java
Normal file
36
src/main/java/app/mealsmadeeasy/api/file/File.java
Normal file
@ -0,0 +1,36 @@
|
||||
package app.mealsmadeeasy.api.file;
|
||||
|
||||
import app.mealsmadeeasy.api.user.User;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "file")
|
||||
@Data
|
||||
public class File {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Column(nullable = false, updatable = false)
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private OffsetDateTime created;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String userFilename;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private String mimeType;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private String objectName;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "owner_id", nullable = false)
|
||||
private User owner;
|
||||
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package app.mealsmadeeasy.api.file;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public interface FileRepository extends JpaRepository<File, UUID> {}
|
||||
75
src/main/java/app/mealsmadeeasy/api/file/FileService.java
Normal file
75
src/main/java/app/mealsmadeeasy/api/file/FileService.java
Normal file
@ -0,0 +1,75 @@
|
||||
package app.mealsmadeeasy.api.file;
|
||||
|
||||
import app.mealsmadeeasy.api.s3.S3Manager;
|
||||
import app.mealsmadeeasy.api.user.User;
|
||||
import app.mealsmadeeasy.api.util.MimeTypeService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class FileService {
|
||||
|
||||
private final FileRepository fileRepository;
|
||||
private final S3Manager s3Manager;
|
||||
private final MimeTypeService mimeTypeService;
|
||||
private final String filesBucket;
|
||||
|
||||
public FileService(
|
||||
FileRepository fileRepository,
|
||||
S3Manager s3Manager,
|
||||
MimeTypeService mimeTypeService,
|
||||
@Value("${app.mealsmadeeasy.api.files.bucketName}") String filesBucket
|
||||
) {
|
||||
this.fileRepository = fileRepository;
|
||||
this.s3Manager = s3Manager;
|
||||
this.mimeTypeService = mimeTypeService;
|
||||
this.filesBucket = filesBucket;
|
||||
}
|
||||
|
||||
public File create(
|
||||
InputStream fileContent,
|
||||
String userFilename,
|
||||
long fileSize,
|
||||
User owner
|
||||
) throws IOException {
|
||||
final UUID uuid = UUID.randomUUID();
|
||||
final String mimeType = this.mimeTypeService.getMimeType(userFilename);
|
||||
final String filename = uuid + "." + this.mimeTypeService.getExtension(mimeType);
|
||||
|
||||
final String objectName = this.s3Manager.store(
|
||||
this.filesBucket,
|
||||
filename,
|
||||
mimeType,
|
||||
fileContent,
|
||||
fileSize
|
||||
);
|
||||
|
||||
final var file = new File();
|
||||
file.setCreated(OffsetDateTime.now());
|
||||
file.setUserFilename(userFilename);
|
||||
file.setMimeType(mimeType);
|
||||
file.setObjectName(objectName);
|
||||
file.setOwner(owner);
|
||||
|
||||
return this.fileRepository.save(file);
|
||||
}
|
||||
|
||||
public InputStream getFileContentById(UUID fileId) throws IOException {
|
||||
final File file = this.fileRepository.findById(fileId).orElseThrow(() -> new IllegalArgumentException(
|
||||
"File with id " + fileId + " not found"
|
||||
));
|
||||
return this.s3Manager.load(this.filesBucket, file.getObjectName());
|
||||
}
|
||||
|
||||
public File getById(UUID id) {
|
||||
return this.fileRepository.findById(id).orElseThrow(() -> new IllegalArgumentException(
|
||||
"File with id " + id + " not found"
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
@ -5,6 +5,7 @@ import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec;
|
||||
import app.mealsmadeeasy.api.image.view.ImageView;
|
||||
import app.mealsmadeeasy.api.s3.S3Manager;
|
||||
import app.mealsmadeeasy.api.user.User;
|
||||
import app.mealsmadeeasy.api.util.MimeTypeService;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.access.prepost.PostAuthorize;
|
||||
@ -19,63 +20,28 @@ import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Service
|
||||
public class S3ImageService implements ImageService {
|
||||
|
||||
private static final Pattern extensionPattern = Pattern.compile(".+\\.(.+)$");
|
||||
|
||||
private static final String IMAGE_JPEG = "image/jpeg";
|
||||
private static final String IMAGE_PNG = "image/png";
|
||||
private static final String IMAGE_SVG = "image/svg+xml";
|
||||
private static final String IMAGE_WEBP = "image/webp";
|
||||
|
||||
private final S3Manager s3Manager;
|
||||
private final ImageRepository imageRepository;
|
||||
private final String imageBucketName;
|
||||
private final String baseUrl;
|
||||
private final MimeTypeService mimeTypeService;
|
||||
|
||||
public S3ImageService(
|
||||
S3Manager s3Manager,
|
||||
ImageRepository imageRepository,
|
||||
@Value("${app.mealsmadeeasy.api.images.bucketName}") String imageBucketName,
|
||||
@Value("${app.mealsmadeeasy.api.baseUrl}") String baseUrl
|
||||
@Value("${app.mealsmadeeasy.api.baseUrl}") String baseUrl,
|
||||
MimeTypeService mimeTypeService
|
||||
) {
|
||||
this.s3Manager = s3Manager;
|
||||
this.imageRepository = imageRepository;
|
||||
this.imageBucketName = imageBucketName;
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
private String getMimeType(String userFilename) {
|
||||
final Matcher m = extensionPattern.matcher(userFilename);
|
||||
if (m.matches()) {
|
||||
final String extension = m.group(1);
|
||||
return switch (extension) {
|
||||
case "jpg", "jpeg" -> IMAGE_JPEG;
|
||||
case "png" -> IMAGE_PNG;
|
||||
case "svg" -> IMAGE_SVG;
|
||||
case "webp" -> IMAGE_WEBP;
|
||||
default -> throw new IllegalArgumentException("Cannot determine mime type for extension: " + extension);
|
||||
};
|
||||
} else {
|
||||
throw new IllegalArgumentException("Cannot determine mime type for filename: " + userFilename);
|
||||
}
|
||||
}
|
||||
|
||||
private String getExtension(String mimeType) throws ImageException {
|
||||
return switch (mimeType) {
|
||||
case IMAGE_JPEG -> "jpg";
|
||||
case IMAGE_PNG -> "png";
|
||||
case IMAGE_SVG -> "svg";
|
||||
case IMAGE_WEBP -> "webp";
|
||||
default -> throw new ImageException(
|
||||
ImageException.Type.UNSUPPORTED_IMAGE_TYPE,
|
||||
"Unsupported mime type: " + mimeType
|
||||
);
|
||||
};
|
||||
this.mimeTypeService = mimeTypeService;
|
||||
}
|
||||
|
||||
private boolean transferFromCreateSpec(Image entity, ImageCreateSpec spec) {
|
||||
@ -157,9 +123,9 @@ public class S3ImageService implements ImageService {
|
||||
long objectSize,
|
||||
ImageCreateSpec createSpec
|
||||
) throws IOException, ImageException {
|
||||
final String mimeType = this.getMimeType(userFilename);
|
||||
final String mimeType = this.mimeTypeService.getMimeType(userFilename);
|
||||
final String uuid = UUID.randomUUID().toString();
|
||||
final String extension = this.getExtension(mimeType);
|
||||
final String extension = this.mimeTypeService.getExtension(mimeType);
|
||||
final String filename = uuid + "." + extension;
|
||||
|
||||
final var baos = new ByteArrayOutputStream();
|
||||
|
||||
60
src/main/java/app/mealsmadeeasy/api/job/Job.java
Normal file
60
src/main/java/app/mealsmadeeasy/api/job/Job.java
Normal file
@ -0,0 +1,60 @@
|
||||
package app.mealsmadeeasy.api.job;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import io.hypersistence.utils.hibernate.type.json.JsonBinaryType;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Data;
|
||||
import org.hibernate.annotations.Type;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "job")
|
||||
@Data
|
||||
public class Job {
|
||||
|
||||
public enum State {
|
||||
QUEUED, RUNNING, DONE, FAILED, DEAD
|
||||
}
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Column(nullable = false, updatable = false)
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private OffsetDateTime created;
|
||||
|
||||
private OffsetDateTime modified;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Enumerated(EnumType.STRING)
|
||||
private State state;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String jobKey;
|
||||
|
||||
@Type(JsonBinaryType.class)
|
||||
@Column(nullable = false, columnDefinition = "jsonb")
|
||||
private JsonNode payload;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Integer attempts;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Integer maxAttempts;
|
||||
|
||||
@Column(nullable = false)
|
||||
private OffsetDateTime runAfter;
|
||||
|
||||
private String lockedBy;
|
||||
|
||||
private OffsetDateTime lockedAt;
|
||||
|
||||
@Lob
|
||||
@Column(columnDefinition = "TEXT")
|
||||
@Basic(fetch = FetchType.LAZY)
|
||||
private String lastError;
|
||||
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
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;
|
||||
|
||||
}
|
||||
7
src/main/java/app/mealsmadeeasy/api/job/JobHandler.java
Normal file
7
src/main/java/app/mealsmadeeasy/api/job/JobHandler.java
Normal file
@ -0,0 +1,7 @@
|
||||
package app.mealsmadeeasy.api.job;
|
||||
|
||||
public interface JobHandler<T> {
|
||||
Class<T> getPayloadType();
|
||||
String getJobKey();
|
||||
void handle(Job job, T payload);
|
||||
}
|
||||
27
src/main/java/app/mealsmadeeasy/api/job/JobRepository.java
Normal file
27
src/main/java/app/mealsmadeeasy/api/job/JobRepository.java
Normal file
@ -0,0 +1,27 @@
|
||||
package app.mealsmadeeasy.api.job;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface JobRepository extends JpaRepository<Job, UUID> {
|
||||
|
||||
@Query(value = """
|
||||
WITH cte AS (
|
||||
SELECT id FROM job WHERE state = 'QUEUED' AND run_after <= now() ORDER BY run_after, created
|
||||
FOR UPDATE SKIP LOCKED LIMIT 1
|
||||
)
|
||||
UPDATE job j
|
||||
SET state = 'RUNNING',
|
||||
locked_by = ?1,
|
||||
locked_at = now(),
|
||||
modified = now()
|
||||
FROM cte
|
||||
WHERE j.id = cte.id
|
||||
RETURNING j.*
|
||||
""", nativeQuery = true)
|
||||
Optional<Job> claimNext(String lockedBy);
|
||||
|
||||
}
|
||||
119
src/main/java/app/mealsmadeeasy/api/job/JobService.java
Normal file
119
src/main/java/app/mealsmadeeasy/api/job/JobService.java
Normal file
@ -0,0 +1,119 @@
|
||||
package app.mealsmadeeasy.api.job;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.transaction.Transactional;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
@Service
|
||||
public class JobService {
|
||||
|
||||
private final ApplicationContext applicationContext;
|
||||
private final JobRepository jobRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@SuppressWarnings("rawtypes")
|
||||
private Map<String, JobHandler> jobHandlers;
|
||||
|
||||
public JobService(ApplicationContext applicationContext, JobRepository jobRepository, ObjectMapper objectMapper) {
|
||||
this.applicationContext = applicationContext;
|
||||
this.jobRepository = jobRepository;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public Job create(String jobType, @Nullable Object payload) {
|
||||
return this.create(jobType, payload, 10, OffsetDateTime.now());
|
||||
}
|
||||
|
||||
public Job create(String jobType, @Nullable Object payload, int maxAttempts, OffsetDateTime runAfter) {
|
||||
final var job = new Job();
|
||||
job.setCreated(OffsetDateTime.now());
|
||||
job.setState(Job.State.QUEUED);
|
||||
job.setJobKey(jobType);
|
||||
job.setPayload(this.objectMapper.convertValue(payload, JsonNode.class));
|
||||
job.setAttempts(0);
|
||||
job.setMaxAttempts(maxAttempts);
|
||||
job.setRunAfter(runAfter);
|
||||
return this.jobRepository.save(job);
|
||||
}
|
||||
|
||||
@SuppressWarnings("rawtypes")
|
||||
private void initJobHandlers() {
|
||||
final Map<String, JobHandler> handlersByBeanName = this.applicationContext.getBeansOfType(JobHandler.class);
|
||||
this.jobHandlers = new HashMap<>(handlersByBeanName);
|
||||
for (final var jobHandler : handlersByBeanName.values()) {
|
||||
this.jobHandlers.put(jobHandler.getJobKey(), jobHandler);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked,rawtypes")
|
||||
@Scheduled(fixedDelay = 200)
|
||||
@Transactional
|
||||
public void runOneJob() {
|
||||
final Optional<Job> nextJob = this.jobRepository.claimNext(Thread.currentThread().getName());
|
||||
if (nextJob.isPresent()) {
|
||||
if (this.jobHandlers == null) {
|
||||
this.initJobHandlers();
|
||||
}
|
||||
final Job job = nextJob.get();
|
||||
final JobHandler jobHandler = this.jobHandlers.get(job.getJobKey());
|
||||
if (jobHandler == null) {
|
||||
throw new RuntimeException("There is no registered job handler for " + job.getJobKey());
|
||||
}
|
||||
final Object payload = this.objectMapper.convertValue(
|
||||
job.getPayload(),
|
||||
jobHandler.getPayloadType()
|
||||
);
|
||||
try {
|
||||
jobHandler.handle(job, payload);
|
||||
job.setState(Job.State.DONE);
|
||||
job.setModified(OffsetDateTime.now());
|
||||
job.setLockedBy(null);
|
||||
job.setLockedAt(null);
|
||||
this.jobRepository.save(job);
|
||||
} catch (Exception e) {
|
||||
final int attemptCount = job.getAttempts() + 1;
|
||||
final boolean isDead = attemptCount >= job.getMaxAttempts();
|
||||
final OffsetDateTime runAfter = isDead
|
||||
? OffsetDateTime.now()
|
||||
: OffsetDateTime.now().plusSeconds(getBackoffSeconds(attemptCount));
|
||||
final String lastError = formatException(e);
|
||||
|
||||
job.setState(isDead ? Job.State.DEAD : Job.State.QUEUED);
|
||||
job.setAttempts(attemptCount);
|
||||
job.setRunAfter(runAfter);
|
||||
job.setLastError(lastError);
|
||||
job.setModified(OffsetDateTime.now());
|
||||
job.setLockedBy(null);
|
||||
job.setLockedAt(null);
|
||||
|
||||
this.jobRepository.save(job);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long getBackoffSeconds(int attemptCount) {
|
||||
final long base = (long) Math.min(300, Math.pow(2, attemptCount));
|
||||
final long jitter = ThreadLocalRandom.current().nextLong(0, 5);
|
||||
return base + jitter;
|
||||
}
|
||||
|
||||
private static String formatException(Exception e) {
|
||||
final var sw = new StringWriter();
|
||||
e.printStackTrace(new PrintWriter(sw));
|
||||
final String s = sw.toString();
|
||||
return s.length() <= 8000 ? s : s.substring(0, 8000);
|
||||
}
|
||||
|
||||
}
|
||||
63
src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraft.java
Normal file
63
src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraft.java
Normal file
@ -0,0 +1,63 @@
|
||||
package app.mealsmadeeasy.api.recipe;
|
||||
|
||||
import app.mealsmadeeasy.api.image.Image;
|
||||
import app.mealsmadeeasy.api.user.User;
|
||||
import io.hypersistence.utils.hibernate.type.json.JsonBinaryType;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Data;
|
||||
import org.hibernate.annotations.Type;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "recipe_draft")
|
||||
@Data
|
||||
public class RecipeDraft {
|
||||
|
||||
public enum State {
|
||||
INFER,
|
||||
ENTER_DATA
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class RecipeDraftInference {
|
||||
private OffsetDateTime inferredAt;
|
||||
private String title;
|
||||
private String rawText;
|
||||
}
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Column(nullable = false, unique = true, updatable = false)
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private OffsetDateTime created;
|
||||
private @Nullable OffsetDateTime modified;
|
||||
|
||||
@Column(nullable = false)
|
||||
private State state;
|
||||
|
||||
private @Nullable String slug;
|
||||
private @Nullable String title;
|
||||
private @Nullable Integer preparationTime;
|
||||
private @Nullable Integer cookingTime;
|
||||
private @Nullable Integer totalTime;
|
||||
private @Nullable String rawText;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "owner_id", nullable = false)
|
||||
private User owner;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "main_image_id")
|
||||
private @Nullable Image mainImage;
|
||||
|
||||
@Type(JsonBinaryType.class)
|
||||
@Column(columnDefinition = "jsonb")
|
||||
private @Nullable List<RecipeDraftInference> inferences;
|
||||
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package app.mealsmadeeasy.api.recipe;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public interface RecipeDraftRepository extends JpaRepository<RecipeDraft, UUID> {}
|
||||
@ -1,17 +1,22 @@
|
||||
package app.mealsmadeeasy.api.recipe;
|
||||
|
||||
import app.mealsmadeeasy.api.file.File;
|
||||
import app.mealsmadeeasy.api.image.Image;
|
||||
import app.mealsmadeeasy.api.image.ImageException;
|
||||
import app.mealsmadeeasy.api.image.ImageService;
|
||||
import app.mealsmadeeasy.api.image.view.ImageView;
|
||||
import app.mealsmadeeasy.api.job.JobService;
|
||||
import app.mealsmadeeasy.api.markdown.MarkdownService;
|
||||
import app.mealsmadeeasy.api.recipe.body.RecipeAiSearchBody;
|
||||
import app.mealsmadeeasy.api.recipe.job.RecipeInferJobHandler;
|
||||
import app.mealsmadeeasy.api.recipe.job.RecipeInferJobPayload;
|
||||
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
|
||||
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
|
||||
import app.mealsmadeeasy.api.recipe.star.RecipeStarRepository;
|
||||
import app.mealsmadeeasy.api.recipe.view.FullRecipeView;
|
||||
import app.mealsmadeeasy.api.recipe.view.RecipeInfoView;
|
||||
import app.mealsmadeeasy.api.user.User;
|
||||
import jakarta.transaction.Transactional;
|
||||
import org.jetbrains.annotations.ApiStatus;
|
||||
import org.jetbrains.annotations.Contract;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
@ -27,6 +32,7 @@ import java.time.OffsetDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class RecipeService {
|
||||
@ -36,19 +42,25 @@ public class RecipeService {
|
||||
private final ImageService imageService;
|
||||
private final MarkdownService markdownService;
|
||||
private final EmbeddingModel embeddingModel;
|
||||
private final RecipeDraftRepository recipeDraftRepository;
|
||||
private final JobService jobService;
|
||||
|
||||
public RecipeService(
|
||||
RecipeRepository recipeRepository,
|
||||
RecipeStarRepository recipeStarRepository,
|
||||
ImageService imageService,
|
||||
MarkdownService markdownService,
|
||||
EmbeddingModel embeddingModel
|
||||
EmbeddingModel embeddingModel,
|
||||
RecipeDraftRepository recipeDraftRepository,
|
||||
JobService jobService
|
||||
) {
|
||||
this.recipeRepository = recipeRepository;
|
||||
this.recipeStarRepository = recipeStarRepository;
|
||||
this.imageService = imageService;
|
||||
this.markdownService = markdownService;
|
||||
this.embeddingModel = embeddingModel;
|
||||
this.recipeDraftRepository = recipeDraftRepository;
|
||||
this.jobService = jobService;
|
||||
}
|
||||
|
||||
public Recipe create(@Nullable User owner, RecipeCreateSpec spec) {
|
||||
@ -314,4 +326,41 @@ public class RecipeService {
|
||||
return viewer.getUsername().equals(username);
|
||||
}
|
||||
|
||||
public RecipeDraft createDraft(User owner) {
|
||||
final var recipeDraft = new RecipeDraft();
|
||||
recipeDraft.setState(RecipeDraft.State.ENTER_DATA);
|
||||
recipeDraft.setCreated(OffsetDateTime.now());
|
||||
recipeDraft.setOwner(owner);
|
||||
return this.recipeDraftRepository.save(recipeDraft);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public RecipeDraft createAiDraft(File sourceFile, User owner) {
|
||||
final var recipeDraft = new RecipeDraft();
|
||||
recipeDraft.setState(RecipeDraft.State.INFER);
|
||||
recipeDraft.setCreated(OffsetDateTime.now());
|
||||
recipeDraft.setOwner(owner);
|
||||
|
||||
final var saved = this.recipeDraftRepository.save(recipeDraft);
|
||||
|
||||
this.jobService.create(
|
||||
RecipeInferJobHandler.JOB_KEY,
|
||||
new RecipeInferJobPayload(saved.getId(), sourceFile.getId()),
|
||||
1,
|
||||
OffsetDateTime.now()
|
||||
);
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
public RecipeDraft getDraftById(UUID id) {
|
||||
return this.recipeDraftRepository.findById(id).orElseThrow(() -> new RuntimeException(
|
||||
"RecipeDraft with id " + id + " not found"
|
||||
));
|
||||
}
|
||||
|
||||
public RecipeDraft saveDraft(RecipeDraft recipeDraft) {
|
||||
return this.recipeDraftRepository.save(recipeDraft);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,95 @@
|
||||
package app.mealsmadeeasy.api.recipe.job;
|
||||
|
||||
import app.mealsmadeeasy.api.file.File;
|
||||
import app.mealsmadeeasy.api.file.FileService;
|
||||
import app.mealsmadeeasy.api.job.Job;
|
||||
import app.mealsmadeeasy.api.job.JobHandler;
|
||||
import app.mealsmadeeasy.api.recipe.RecipeDraft;
|
||||
import app.mealsmadeeasy.api.recipe.RecipeService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.content.Media;
|
||||
import org.springframework.ai.ollama.api.OllamaChatOptions;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.MimeType;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class RecipeInferJobHandler implements JobHandler<RecipeInferJobPayload> {
|
||||
|
||||
public static final String JOB_KEY = "RECIPE_INFER_JOB";
|
||||
|
||||
private final FileService fileService;
|
||||
private final RecipeService recipeService;
|
||||
private final ChatModel chatModel;
|
||||
|
||||
@Override
|
||||
public Class<RecipeInferJobPayload> getPayloadType() {
|
||||
return RecipeInferJobPayload.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getJobKey() {
|
||||
return "RECIPE_INFER_JOB";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(Job job, RecipeInferJobPayload payload) {
|
||||
final File sourceFile = this.fileService.getById(payload.fileId());
|
||||
final InputStream sourceFileContent;
|
||||
try {
|
||||
sourceFileContent = this.fileService.getFileContentById(payload.fileId());
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
final Media sourceFileMedia = Media.builder()
|
||||
.data(new InputStreamResource(sourceFileContent))
|
||||
.mimeType(MimeType.valueOf(sourceFile.getMimeType()))
|
||||
.build();
|
||||
|
||||
final Message ocrMessage = UserMessage.builder()
|
||||
.text("Convert the recipe in the image to Markdown.")
|
||||
.media(sourceFileMedia)
|
||||
.build();
|
||||
final Prompt ocrPrompt = Prompt.builder()
|
||||
.messages(ocrMessage)
|
||||
.chatOptions(OllamaChatOptions.builder().model("deepseek-ocr:latest").build())
|
||||
.build();
|
||||
final ChatResponse ocrResponse = this.chatModel.call(ocrPrompt);
|
||||
final String fullMarkdownText = ocrResponse.getResult().getOutput().getText();
|
||||
|
||||
// get recipe draft
|
||||
final @Nullable RecipeDraft recipeDraft = this.recipeService.getDraftById(payload.recipeDraftId());
|
||||
if (recipeDraft == null) {
|
||||
throw new RuntimeException("Recipe draft not found");
|
||||
}
|
||||
|
||||
// set props on draft from ai output, etc.
|
||||
recipeDraft.setRawText(fullMarkdownText);
|
||||
recipeDraft.setState(RecipeDraft.State.ENTER_DATA);
|
||||
|
||||
if (recipeDraft.getInferences() == null) {
|
||||
recipeDraft.setInferences(new ArrayList<>());
|
||||
}
|
||||
final RecipeDraft.RecipeDraftInference inference = new RecipeDraft.RecipeDraftInference();
|
||||
inference.setTitle("TODO: inferred title");
|
||||
inference.setRawText(fullMarkdownText);
|
||||
inference.setInferredAt(OffsetDateTime.now());
|
||||
recipeDraft.getInferences().add(inference);
|
||||
|
||||
this.recipeService.saveDraft(recipeDraft);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
package app.mealsmadeeasy.api.recipe.job;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record RecipeInferJobPayload(UUID recipeDraftId, UUID fileId) {}
|
||||
@ -0,0 +1,46 @@
|
||||
package app.mealsmadeeasy.api.util;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Service
|
||||
public class MimeTypeService {
|
||||
|
||||
private static final Pattern extensionPattern = Pattern.compile(".+\\.(.+)$");
|
||||
|
||||
private static final String IMAGE_JPEG = "image/jpeg";
|
||||
private static final String IMAGE_PNG = "image/png";
|
||||
private static final String IMAGE_SVG = "image/svg+xml";
|
||||
private static final String IMAGE_WEBP = "image/webp";
|
||||
|
||||
public String getMimeType(String userFilename) {
|
||||
final Matcher m = extensionPattern.matcher(userFilename);
|
||||
if (m.matches()) {
|
||||
final String extension = m.group(1);
|
||||
return switch (extension) {
|
||||
case "jpg", "jpeg" -> IMAGE_JPEG;
|
||||
case "png" -> IMAGE_PNG;
|
||||
case "svg" -> IMAGE_SVG;
|
||||
case "webp" -> IMAGE_WEBP;
|
||||
default -> throw new IllegalArgumentException("Cannot determine mime type for extension: " + extension);
|
||||
};
|
||||
} else {
|
||||
throw new IllegalArgumentException("Cannot determine mime type for filename: " + userFilename);
|
||||
}
|
||||
}
|
||||
|
||||
public String getExtension(String mimeType) {
|
||||
return switch (mimeType) {
|
||||
case IMAGE_JPEG -> "jpg";
|
||||
case IMAGE_PNG -> "png";
|
||||
case IMAGE_SVG -> "svg";
|
||||
case IMAGE_WEBP -> "webp";
|
||||
default -> throw new IllegalArgumentException(
|
||||
"Cannot determine extension from given mimetype: " + mimeType
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@ -16,6 +16,7 @@ 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
|
||||
app.mealsmadeeasy.api.files.bucketName=files
|
||||
|
||||
# AI
|
||||
spring.ai.vectorstore.pgvector.dimensions=1024
|
||||
|
||||
17
src/main/resources/db/migration/V5__create_recipe_draft.sql
Normal file
17
src/main/resources/db/migration/V5__create_recipe_draft.sql
Normal file
@ -0,0 +1,17 @@
|
||||
CREATE TABLE recipe_draft (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created TIMESTAMPTZ(6) NOT NULL,
|
||||
modified TIMESTAMPTZ(6),
|
||||
state INT NOT NULL,
|
||||
slug VARCHAR(255),
|
||||
title VARCHAR(255),
|
||||
preparation_time INT,
|
||||
cooking_time INT,
|
||||
total_time INT,
|
||||
raw_text TEXT,
|
||||
owner_id INT NOT NULL,
|
||||
main_image_id INT,
|
||||
inferences JSONB,
|
||||
FOREIGN KEY (owner_id) REFERENCES "user",
|
||||
FOREIGN KEY (main_image_id) REFERENCES image
|
||||
);
|
||||
14
src/main/resources/db/migration/V6__create_job.sql
Normal file
14
src/main/resources/db/migration/V6__create_job.sql
Normal file
@ -0,0 +1,14 @@
|
||||
CREATE TABLE job (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created TIMESTAMPTZ NOT NULL,
|
||||
modified TIMESTAMPTZ,
|
||||
state VARCHAR(64) NOT NULL,
|
||||
job_key VARCHAR(255) NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
attempts INT NOT NULL,
|
||||
max_attempts INT NOT NULL,
|
||||
run_after TIMESTAMPTZ NOT NULL,
|
||||
locked_by VARCHAR(255),
|
||||
locked_at TIMESTAMPTZ,
|
||||
last_error TEXT
|
||||
);
|
||||
9
src/main/resources/db/migration/V7__create_file.sql
Normal file
9
src/main/resources/db/migration/V7__create_file.sql
Normal file
@ -0,0 +1,9 @@
|
||||
CREATE TABLE file (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created TIMESTAMPTZ NOT NULL,
|
||||
user_filename VARCHAR(255) NOT NULL,
|
||||
mime_type VARCHAR(255) NOT NULL,
|
||||
object_name VARCHAR(255) NOT NULL,
|
||||
owner_id INT NOT NULL,
|
||||
FOREIGN KEY (owner_id) REFERENCES "user"
|
||||
);
|
||||
98
src/test/java/app/mealsmadeeasy/api/job/JobServiceTests.java
Normal file
98
src/test/java/app/mealsmadeeasy/api/job/JobServiceTests.java
Normal file
@ -0,0 +1,98 @@
|
||||
package app.mealsmadeeasy.api.job;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Spy;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
public class JobServiceTests {
|
||||
|
||||
@Mock
|
||||
private JobRepository jobRepository;
|
||||
|
||||
@Mock
|
||||
private ApplicationContext applicationContext;
|
||||
|
||||
@Spy
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@InjectMocks
|
||||
private JobService jobService;
|
||||
|
||||
@Test
|
||||
public void whenRunOneJob_callsClaimNext() {
|
||||
when(this.jobRepository.claimNext(anyString())).thenReturn(Optional.empty());
|
||||
this.jobService.runOneJob();
|
||||
verify(this.jobRepository).claimNext(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void findsJobHandlerAndSavesJobInfo() {
|
||||
final Job testJob = new Job();
|
||||
testJob.setJobKey("TEST_JOB_KEY");
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
final JobHandler<Object> testJobHandler = mock(JobHandler.class);
|
||||
when(testJobHandler.getPayloadType()).thenReturn(Object.class);
|
||||
when(testJobHandler.getJobKey()).thenReturn("TEST_JOB_KEY");
|
||||
|
||||
when(this.jobRepository.claimNext(anyString())).thenReturn(Optional.of(testJob));
|
||||
when(this.applicationContext.getBeansOfType(JobHandler.class)).thenReturn(
|
||||
Map.of("testJobHandler", testJobHandler)
|
||||
);
|
||||
|
||||
this.jobService.runOneJob();
|
||||
|
||||
verify(testJobHandler).handle(testJob, null);
|
||||
verify(this.jobRepository).save(testJob);
|
||||
|
||||
assertThat(testJob.getState(), is(Job.State.DONE));
|
||||
assertThat(testJob.getModified(), is(notNullValue()));
|
||||
assertThat(testJob.getLockedBy(), is(nullValue()));
|
||||
assertThat(testJob.getLockedAt(), is(nullValue()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retryJobAfterThrowingHandler() {
|
||||
final Job testJob = new Job();
|
||||
testJob.setAttempts(0);
|
||||
testJob.setMaxAttempts(3);
|
||||
testJob.setJobKey("TEST_JOB_KEY");
|
||||
|
||||
final JobHandler<Object> throwingHandler = mock(JobHandler.class);
|
||||
when(throwingHandler.getPayloadType()).thenReturn(Object.class);
|
||||
when(throwingHandler.getJobKey()).thenReturn("TEST_JOB_KEY");
|
||||
doThrow(new RuntimeException("TO THROW")).when(throwingHandler).handle(testJob, null);
|
||||
|
||||
when(this.jobRepository.claimNext(anyString())).thenReturn(Optional.of(testJob));
|
||||
when(this.applicationContext.getBeansOfType(JobHandler.class)).thenReturn(
|
||||
Map.of("testJobHandler", throwingHandler)
|
||||
);
|
||||
|
||||
this.jobService.runOneJob();
|
||||
|
||||
verify(throwingHandler).handle(testJob, null);
|
||||
verify(this.jobRepository).save(testJob);
|
||||
|
||||
assertThat(testJob.getState(), is(Job.State.QUEUED));
|
||||
assertThat(testJob.getAttempts(), is(1));
|
||||
assertThat(testJob.getRunAfter(), is(notNullValue()));
|
||||
assertThat(testJob.getLastError(), is(notNullValue()));
|
||||
assertThat(testJob.getModified(), is(notNullValue()));
|
||||
assertThat(testJob.getLockedBy(), is(nullValue()));
|
||||
assertThat(testJob.getLockedAt(), is(nullValue()));
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user