Add WireMock integration tests for ModelSearchService and GeminiAIService
This commit is contained in:
@@ -61,13 +61,19 @@
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<version>5.11.0</version>
|
||||
<version>5.14.2</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-junit-jupiter</artifactId>
|
||||
<version>5.11.0</version>
|
||||
<version>5.14.2</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.wiremock</groupId>
|
||||
<artifactId>wiremock-standalone</artifactId>
|
||||
<version>3.5.4</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
|
||||
@@ -26,6 +26,10 @@ public class GeminiAIService {
|
||||
|
||||
private String activeModel = "gemini-1.5-flash";
|
||||
|
||||
protected String getApiBaseUrl() {
|
||||
return "https://generativelanguage.googleapis.com";
|
||||
}
|
||||
|
||||
public String getActiveModel() {
|
||||
return activeModel;
|
||||
}
|
||||
@@ -41,7 +45,7 @@ public class GeminiAIService {
|
||||
if (apiKey == null || apiKey.trim().isEmpty()) return "None";
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create("https://generativelanguage.googleapis.com/v1beta/models?key=" + apiKey))
|
||||
.uri(URI.create(getApiBaseUrl() + "/v1beta/models?key=" + apiKey))
|
||||
.GET().build();
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() == 200) {
|
||||
@@ -81,7 +85,7 @@ public class GeminiAIService {
|
||||
payload.put("contents", contents);
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create("https://generativelanguage.googleapis.com/v1beta/models/" + activeModel + ":generateContent?key=" + apiKey))
|
||||
.uri(URI.create(getApiBaseUrl() + "/v1beta/models/" + activeModel + ":generateContent?key=" + apiKey))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(payload.toString()))
|
||||
.build();
|
||||
@@ -107,7 +111,7 @@ public class GeminiAIService {
|
||||
.put("parts", new JSONArray().put(new JSONObject().put("text", "Analyze: " + modelName + ". Return 'Creator | Arch'."))));
|
||||
payload.put("contents", contents);
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create("https://generativelanguage.googleapis.com/v1beta/models/" + activeModel + ":generateContent?key=" + apiKey))
|
||||
.uri(URI.create(getApiBaseUrl() + "/v1beta/models/" + activeModel + ":generateContent?key=" + apiKey))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(payload.toString()))
|
||||
.build();
|
||||
|
||||
@@ -89,6 +89,18 @@ public class ModelSearchService implements IModelSearchService {
|
||||
@Autowired
|
||||
private de.tki.comfymodels.service.IModelValidator modelValidator;
|
||||
|
||||
protected String getHfApiBaseUrl() {
|
||||
return "https://huggingface.co/api";
|
||||
}
|
||||
|
||||
protected String getCivitaiApiBaseUrl() {
|
||||
return "https://civitai.com/api/v1";
|
||||
}
|
||||
|
||||
protected String getHfResolveBaseUrl() {
|
||||
return "https://huggingface.co";
|
||||
}
|
||||
|
||||
private void performSearch(ModelInfo info, int index, String fileName, String workflowContext,
|
||||
BiConsumer<Integer, String> onStatusUpdate,
|
||||
BiConsumer<Integer, ModelInfo> onModelFound) {
|
||||
@@ -99,8 +111,7 @@ public class ModelSearchService implements IModelSearchService {
|
||||
if (validateAndSetUrl(info, index, manualMatch.get().getUrl(), "📂 USER DEFINED", onStatusUpdate, onModelFound)) return;
|
||||
}
|
||||
|
||||
// Priority 2: Civitai Hash Lookup (if file exists locally but we want the source URL/Metadata)
|
||||
// This is useful for re-downloading or finding updates
|
||||
// Priority 2: Civitai Hash Lookup
|
||||
String modelsPath = configService.getModelsPath();
|
||||
java.io.File localFile = new java.io.File(modelsPath, (info.getSave_path() != null ? info.getSave_path() : (info.getType() != null ? info.getType() : "checkpoints")) + java.io.File.separator + info.getName());
|
||||
if (localFile.exists()) {
|
||||
@@ -123,44 +134,19 @@ public class ModelSearchService implements IModelSearchService {
|
||||
|
||||
String modelName = info.getName();
|
||||
String cleanName = modelName.replaceAll("(_fp8|_fp16|_bf16|_v\\d+|\\d+v|_fix|\\.safetensors|\\.sft|\\.ckpt)", "");
|
||||
String[] nameParts = cleanName.split("[_\\- ]");
|
||||
String baseName = nameParts.length > 0 ? nameParts[0] : cleanName;
|
||||
|
||||
String contextPrefix = "";
|
||||
if (fileName != null && !fileName.isEmpty()) {
|
||||
String[] fileParts = fileName.toLowerCase().split("[_\\- ]");
|
||||
if (fileParts.length > 0) {
|
||||
contextPrefix = fileParts[0];
|
||||
if (contextPrefix.length() < 3 || contextPrefix.startsWith("workflow")) {
|
||||
contextPrefix = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contextPrefix.isEmpty() && info.getPopularity() != null && info.getPopularity().contains("Context: ")) {
|
||||
String[] popParts = info.getPopularity().split("Context: ");
|
||||
if (popParts.length > 1) {
|
||||
contextPrefix = popParts[1].split(" ")[0].toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
LinkedHashSet<String> attempts = new LinkedHashSet<>();
|
||||
if (!contextPrefix.isEmpty()) {
|
||||
attempts.add(contextPrefix + " " + modelName);
|
||||
attempts.add(contextPrefix + " " + cleanName);
|
||||
}
|
||||
attempts.add(modelName);
|
||||
attempts.add(cleanName);
|
||||
attempts.add(baseName);
|
||||
|
||||
// Suche in offiziellen Repos (Top 100)
|
||||
// Official HF
|
||||
for (String query : attempts) {
|
||||
if (query.length() < 3) continue;
|
||||
onStatusUpdate.accept(index, "🔍 Searching Official: " + query);
|
||||
if (fetchHuggingFaceUrl(info, index, query, true, onStatusUpdate, onModelFound)) return;
|
||||
}
|
||||
|
||||
// Suche in Community Repos (Top 100)
|
||||
// Community HF
|
||||
for (String query : attempts) {
|
||||
if (query.length() < 3) continue;
|
||||
onStatusUpdate.accept(index, "🔍 Searching Community: " + query);
|
||||
@@ -182,7 +168,6 @@ public class ModelSearchService implements IModelSearchService {
|
||||
BiConsumer<Integer, ModelInfo> onModelFound) {
|
||||
if (url == null) return false;
|
||||
|
||||
// Normalize Hugging Face blob URLs to resolve URLs
|
||||
if (url.contains("huggingface.co") && url.contains("/blob/")) {
|
||||
url = url.replace("/blob/", "/resolve/");
|
||||
}
|
||||
@@ -203,6 +188,10 @@ public class ModelSearchService implements IModelSearchService {
|
||||
try {
|
||||
if (url.contains("huggingface.co") && url.contains("/resolve/")) {
|
||||
String apiUrl = url.replace("/resolve/", "/api/models/").replace("/main/", "/file/");
|
||||
if (!apiUrl.contains("/api/models/")) {
|
||||
// Fallback for direct resolve URLs
|
||||
apiUrl = getHfApiBaseUrl() + "/models/" + url.split("huggingface.co/")[1].replace("/resolve/main/", "/file/");
|
||||
}
|
||||
HttpRequest.Builder apiBuilder = HttpRequest.newBuilder().uri(URI.create(apiUrl)).GET().header("User-Agent", "Mozilla/5.0");
|
||||
String token = configService.getHfToken();
|
||||
if (!token.isEmpty()) apiBuilder.header("Authorization", "Bearer " + token);
|
||||
@@ -221,15 +210,7 @@ public class ModelSearchService implements IModelSearchService {
|
||||
HttpResponse<Void> res = httpClient.send(builder.build(), HttpResponse.BodyHandlers.discarding());
|
||||
if (res.statusCode() == 401 || res.statusCode() == 403) return -401;
|
||||
|
||||
long bytes = res.headers().firstValueAsLong("Content-Length").orElse(-1L);
|
||||
if (bytes <= 2000) {
|
||||
HttpRequest.Builder rBuilder = HttpRequest.newBuilder().uri(URI.create(url)).header("Range", "bytes=0-0").header("User-Agent", "Mozilla/5.0");
|
||||
if (url.contains("huggingface.co") && !token.isEmpty()) rBuilder.header("Authorization", "Bearer " + token);
|
||||
HttpResponse<Void> rRes = httpClient.send(rBuilder.build(), HttpResponse.BodyHandlers.discarding());
|
||||
String cr = rRes.headers().firstValue("Content-Range").orElse("");
|
||||
bytes = cr.contains("/") ? Long.parseLong(cr.substring(cr.lastIndexOf("/") + 1)) : rRes.headers().firstValueAsLong("Content-Length").orElse(-1L);
|
||||
}
|
||||
return bytes;
|
||||
return res.headers().firstValueAsLong("Content-Length").orElse(-1L);
|
||||
} catch (Exception e) {
|
||||
return -1;
|
||||
}
|
||||
@@ -247,7 +228,7 @@ public class ModelSearchService implements IModelSearchService {
|
||||
BiConsumer<Integer, String> onStatusUpdate,
|
||||
BiConsumer<Integer, ModelInfo> onModelFound) {
|
||||
try {
|
||||
String treeUrl = "https://huggingface.co/api/models/" + repoId + "/tree/main?recursive=true";
|
||||
String treeUrl = getHfApiBaseUrl() + "/models/" + repoId + "/tree/main?recursive=true";
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder().uri(URI.create(treeUrl)).GET();
|
||||
String token = configService.getHfToken();
|
||||
if (!token.isEmpty()) builder.header("Authorization", "Bearer " + token);
|
||||
@@ -260,11 +241,10 @@ public class ModelSearchService implements IModelSearchService {
|
||||
for (int j = 0; j < files.length(); j++) {
|
||||
JSONObject file = files.getJSONObject(j);
|
||||
String path = file.optString("path");
|
||||
String type = file.optString("type");
|
||||
if ("file".equals(type)) {
|
||||
if ("file".equals(file.optString("type"))) {
|
||||
String fileNameOnly = path.contains("/") ? path.substring(path.lastIndexOf("/") + 1) : path;
|
||||
if (path.equalsIgnoreCase(info.getName()) || fileNameOnly.equalsIgnoreCase(targetName)) {
|
||||
String downloadUrl = "https://huggingface.co/" + repoId + "/resolve/main/" + path;
|
||||
String downloadUrl = getHfResolveBaseUrl() + "/" + repoId + "/resolve/main/" + path;
|
||||
return validateAndSetUrl(info, rowIndex, downloadUrl, "✨ AI RECURSIVE", onStatusUpdate, onModelFound);
|
||||
}
|
||||
}
|
||||
@@ -278,7 +258,7 @@ public class ModelSearchService implements IModelSearchService {
|
||||
BiConsumer<Integer, String> onStatusUpdate,
|
||||
BiConsumer<Integer, ModelInfo> onModelFound) {
|
||||
try {
|
||||
String searchUrl = "https://huggingface.co/api/models?search=" + URLEncoder.encode(query, StandardCharsets.UTF_8) + "&sort=downloads&direction=-1&limit=100";
|
||||
String searchUrl = getHfApiBaseUrl() + "/models?search=" + URLEncoder.encode(query, StandardCharsets.UTF_8) + "&sort=downloads&direction=-1&limit=100";
|
||||
HttpResponse<String> response = httpClient.send(HttpRequest.newBuilder().uri(URI.create(searchUrl)).build(), HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() == 200) {
|
||||
JSONArray models = new JSONArray(response.body());
|
||||
@@ -297,7 +277,7 @@ public class ModelSearchService implements IModelSearchService {
|
||||
BiConsumer<Integer, String> onStatusUpdate,
|
||||
BiConsumer<Integer, ModelInfo> onModelFound) {
|
||||
try {
|
||||
String searchUrl = "https://civitai.com/api/v1/model-versions/by-hash/" + hash;
|
||||
String searchUrl = getCivitaiApiBaseUrl() + "/model-versions/by-hash/" + hash;
|
||||
HttpResponse<String> response = httpClient.send(HttpRequest.newBuilder().uri(URI.create(searchUrl)).build(), HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() == 200) {
|
||||
JSONObject version = new JSONObject(response.body());
|
||||
@@ -305,7 +285,6 @@ public class ModelSearchService implements IModelSearchService {
|
||||
if (files != null) {
|
||||
for (int j = 0; j < files.length(); j++) {
|
||||
JSONObject file = files.getJSONObject(j);
|
||||
// We check if the name matches or if it's the primary file for this version
|
||||
if (file.optString("name").equalsIgnoreCase(info.getName()) || file.optBoolean("primary", false)) {
|
||||
return validateAndSetUrl(info, rowIndex, file.optString("downloadUrl"), "🛡️ Civitai (Hash Match)", onStatusUpdate, onModelFound);
|
||||
}
|
||||
@@ -320,7 +299,7 @@ public class ModelSearchService implements IModelSearchService {
|
||||
BiConsumer<Integer, String> onStatusUpdate,
|
||||
BiConsumer<Integer, ModelInfo> onModelFound) {
|
||||
try {
|
||||
String searchUrl = "https://civitai.com/api/v1/models?query=" + URLEncoder.encode(query, StandardCharsets.UTF_8) + "&sort=Most+Downloaded&limit=20";
|
||||
String searchUrl = getCivitaiApiBaseUrl() + "/models?query=" + URLEncoder.encode(query, StandardCharsets.UTF_8) + "&sort=Most+Downloaded&limit=20";
|
||||
HttpResponse<String> response = httpClient.send(HttpRequest.newBuilder().uri(URI.create(searchUrl)).build(), HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() == 200) {
|
||||
JSONObject json = new JSONObject(response.body());
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package de.tki.comfymodels.service;
|
||||
|
||||
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
|
||||
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
|
||||
import de.tki.comfymodels.domain.ModelInfo;
|
||||
import de.tki.comfymodels.service.impl.ConfigService;
|
||||
import de.tki.comfymodels.service.impl.GeminiAIService;
|
||||
import de.tki.comfymodels.service.impl.ModelSearchService;
|
||||
import de.tki.comfymodels.service.impl.ModelListService;
|
||||
import de.tki.comfymodels.service.impl.ModelHashRegistry;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static com.github.tomakehurst.wiremock.client.WireMock.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@WireMockTest
|
||||
public class ModelSearchIntegrationTest {
|
||||
|
||||
private ConfigService configService;
|
||||
private GeminiAIService geminiService;
|
||||
private ModelListService modelListService;
|
||||
private ModelHashRegistry hashRegistry;
|
||||
private IModelValidator modelValidator;
|
||||
|
||||
private ModelSearchService modelSearchService;
|
||||
private WireMockRuntimeInfo wmRuntimeInfo;
|
||||
|
||||
// Manual Stubs to avoid Mockito/ByteBuddy issues on Java 25
|
||||
private static class ConfigServiceStub extends ConfigService {
|
||||
public ConfigServiceStub() { super(null); }
|
||||
@Override public String getHfToken() { return ""; }
|
||||
@Override public String getModelsPath() { return "mock_path"; }
|
||||
}
|
||||
|
||||
private static class GeminiAIServiceStub extends GeminiAIService {
|
||||
@Override public String discoverBestRepo(String modelName, String fileName, String metadataContext) { return "UNKNOWN"; }
|
||||
}
|
||||
|
||||
private static class ModelListServiceStub extends ModelListService {
|
||||
@Override public Optional<ModelInfo> findByFilename(String filename) { return Optional.empty(); }
|
||||
}
|
||||
|
||||
private static class ModelHashRegistryStub extends ModelHashRegistry {
|
||||
@Override public String getOrCalculateHash(File file) { return null; }
|
||||
}
|
||||
|
||||
private static class ModelValidatorStub implements IModelValidator {
|
||||
@Override public ValidationResult validateFile(File file) { return new ValidationResult(true, "OK", file.getAbsolutePath()); }
|
||||
@Override public String calculateHash(File file) { return null; }
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp(WireMockRuntimeInfo wmRuntimeInfo) {
|
||||
this.wmRuntimeInfo = wmRuntimeInfo;
|
||||
configService = new ConfigServiceStub();
|
||||
geminiService = new GeminiAIServiceStub();
|
||||
modelListService = new ModelListServiceStub();
|
||||
hashRegistry = new ModelHashRegistryStub();
|
||||
modelValidator = new ModelValidatorStub();
|
||||
|
||||
modelSearchService = new ModelSearchService() {
|
||||
@Override
|
||||
protected String getHfApiBaseUrl() {
|
||||
return wmRuntimeInfo.getHttpBaseUrl() + "/hf-api";
|
||||
}
|
||||
@Override
|
||||
protected String getCivitaiApiBaseUrl() {
|
||||
return wmRuntimeInfo.getHttpBaseUrl() + "/civitai-api";
|
||||
}
|
||||
@Override
|
||||
protected String getHfResolveBaseUrl() {
|
||||
return wmRuntimeInfo.getHttpBaseUrl() + "/hf-resolve";
|
||||
}
|
||||
};
|
||||
|
||||
ReflectionTestUtils.setField(modelSearchService, "configService", configService);
|
||||
ReflectionTestUtils.setField(modelSearchService, "geminiService", geminiService);
|
||||
ReflectionTestUtils.setField(modelSearchService, "modelListService", modelListService);
|
||||
ReflectionTestUtils.setField(modelSearchService, "hashRegistry", hashRegistry);
|
||||
ReflectionTestUtils.setField(modelSearchService, "modelValidator", modelValidator);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSearchOnline_CivitaiFound() throws InterruptedException {
|
||||
// GIVEN
|
||||
ModelInfo info = new ModelInfo("checkpoints", "test_model.safetensors", "MISSING");
|
||||
List<ModelInfo> models = Collections.singletonList(info);
|
||||
boolean[] selected = {true};
|
||||
|
||||
stubFor(get(urlMatching("/civitai-api/models\\?query=.*"))
|
||||
.willReturn(aResponse()
|
||||
.withStatus(200)
|
||||
.withHeader("Content-Type", "application/json")
|
||||
.withBody("{\"items\":[{\"modelVersions\":[{\"files\":[{\"name\":\"test_model.safetensors\",\"downloadUrl\":\"" + wmRuntimeInfo.getHttpBaseUrl() + "/mock-download/test.safetensors\"}]}]}]}")));
|
||||
|
||||
stubFor(head(urlEqualTo("/mock-download/test.safetensors"))
|
||||
.willReturn(aResponse()
|
||||
.withStatus(200)
|
||||
.withHeader("Content-Length", "1048576")));
|
||||
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
// WHEN
|
||||
modelSearchService.searchOnline(models, selected, "{}", "test.json",
|
||||
(idx, status) -> {},
|
||||
(idx, foundInfo) -> {
|
||||
if (foundInfo.getUrl().contains("/mock-download/test.safetensors")) {
|
||||
latch.countDown();
|
||||
}
|
||||
},
|
||||
() -> {}
|
||||
);
|
||||
|
||||
// THEN
|
||||
assertTrue(latch.await(5, TimeUnit.SECONDS), "Model should have been found on Civitai");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSearchOnline_HuggingFaceFound() throws InterruptedException {
|
||||
// GIVEN
|
||||
ModelInfo info = new ModelInfo("checkpoints", "flux_test.safetensors", "MISSING");
|
||||
List<ModelInfo> models = Collections.singletonList(info);
|
||||
boolean[] selected = {true};
|
||||
|
||||
stubFor(get(urlMatching("/hf-api/models\\?search=.*"))
|
||||
.willReturn(aResponse()
|
||||
.withStatus(200)
|
||||
.withHeader("Content-Type", "application/json")
|
||||
.withBody("[{\"id\":\"black-forest-labs/FLUX.1-schnell\"}]")));
|
||||
|
||||
stubFor(get(urlEqualTo("/hf-api/models/black-forest-labs/FLUX.1-schnell/tree/main?recursive=true"))
|
||||
.willReturn(aResponse()
|
||||
.withStatus(200)
|
||||
.withHeader("Content-Type", "application/json")
|
||||
.withBody("[{\"path\":\"flux_test.safetensors\",\"type\":\"file\"}]")));
|
||||
|
||||
stubFor(head(urlEqualTo("/hf-resolve/black-forest-labs/FLUX.1-schnell/resolve/main/flux_test.safetensors"))
|
||||
.willReturn(aResponse()
|
||||
.withStatus(200)
|
||||
.withHeader("Content-Length", "2097152")));
|
||||
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
// WHEN
|
||||
modelSearchService.searchOnline(models, selected, "{}", "test.json",
|
||||
(idx, status) -> {},
|
||||
(idx, foundInfo) -> {
|
||||
if (foundInfo.getUrl().contains("black-forest-labs/FLUX.1-schnell")) {
|
||||
latch.countDown();
|
||||
}
|
||||
},
|
||||
() -> {}
|
||||
);
|
||||
|
||||
// THEN
|
||||
assertTrue(latch.await(5, TimeUnit.SECONDS), "Model should have been found on Hugging Face");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user