tests for generate site using llm prompt

This commit is contained in:
Krrish Ghimire
2026-07-02 01:26:20 +05:45
parent 3ccbd2ebec
commit d29de6fdbc
8 changed files with 1313 additions and 0 deletions

View File

@@ -0,0 +1,80 @@
package com.indie.config;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class RateLimitConfigTest {
private RateLimitConfig config;
@BeforeEach
void setUp() {
config = new RateLimitConfig(3);
}
@Test
void tryConsumeReturnsTrueWithinLimit() {
UUID userId = UUID.randomUUID();
assertTrue(config.tryConsume(userId));
assertTrue(config.tryConsume(userId));
assertTrue(config.tryConsume(userId));
}
@Test
void tryConsumeReturnsFalseWhenExceeded() {
UUID userId = UUID.randomUUID();
assertTrue(config.tryConsume(userId));
assertTrue(config.tryConsume(userId));
assertTrue(config.tryConsume(userId));
assertFalse(config.tryConsume(userId));
}
@Test
void differentUsersHaveIndependentLimits() {
UUID userA = UUID.randomUUID();
UUID userB = UUID.randomUUID();
assertTrue(config.tryConsume(userA));
assertTrue(config.tryConsume(userA));
assertTrue(config.tryConsume(userA));
assertFalse(config.tryConsume(userA));
assertTrue(config.tryConsume(userB));
assertTrue(config.tryConsume(userB));
assertTrue(config.tryConsume(userB));
assertFalse(config.tryConsume(userB));
}
@Test
void getAvailableTokensReturnsRemaining() {
UUID userId = UUID.randomUUID();
assertEquals(3, config.getAvailableTokens(userId));
config.tryConsume(userId);
assertEquals(2, config.getAvailableTokens(userId));
config.tryConsume(userId);
assertEquals(1, config.getAvailableTokens(userId));
}
@Test
void getAvailableTokensReturnsZeroWhenExhausted() {
UUID userId = UUID.randomUUID();
config.tryConsume(userId);
config.tryConsume(userId);
config.tryConsume(userId);
assertEquals(0, config.getAvailableTokens(userId));
}
@Test
void constructorWithDefaultRate() {
RateLimitConfig defaultConfig = new RateLimitConfig(10);
UUID userId = UUID.randomUUID();
assertEquals(10, defaultConfig.getAvailableTokens(userId));
}
}

View File

@@ -0,0 +1,404 @@
package com.indie.controller;
import com.indie.config.GlobalExceptionHandler;
import com.indie.config.JwtAuthenticationToken;
import com.indie.config.RateLimitConfig;
import com.indie.config.UserPrincipal;
import com.indie.dto.Reference;
import com.indie.dto.SiteStructure;
import com.indie.model.GenerationVersion;
import com.indie.service.LLMService;
import com.indie.support.TestSecurityConfig;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Nested;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(LLMController.class)
@Import({GlobalExceptionHandler.class, TestSecurityConfig.class})
class LLMControllerTest {
private final MockMvc mockMvc;
private final ObjectMapper objectMapper;
@MockBean
private LLMService llmService;
@MockBean
private RateLimitConfig rateLimitConfig;
private UUID userId;
private UserPrincipal principal;
private JwtAuthenticationToken auth;
LLMControllerTest(@Autowired MockMvc mockMvc, @Autowired ObjectMapper objectMapper) {
this.mockMvc = mockMvc;
this.objectMapper = objectMapper;
}
@BeforeEach
void setUp() {
userId = UUID.randomUUID();
principal = new UserPrincipal(userId, "user@test.com", "Test User");
auth = new JwtAuthenticationToken(principal, "test-token");
}
@Nested
class Generate {
@Test
void returns200WithSiteStructure() throws Exception {
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
SiteStructure structure = SiteStructure.builder()
.pages(List.of(
SiteStructure.PageStructure.builder()
.title("Home").slug("home")
.components(List.of(
SiteStructure.ComponentStructure.builder().type("hero").build()
))
.build()
))
.build();
when(llmService.generateSite(any(), any(), anyString(), any())).thenReturn(structure);
Map<String, Object> request = Map.of(
"prompt", "Build a site",
"siteId", UUID.randomUUID().toString()
);
mockMvc.perform(post("/api/llm/generate")
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.pages[0].title").value("Home"));
}
@Test
void returns200WithReferences() throws Exception {
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
SiteStructure structure = SiteStructure.builder()
.pages(List.of(
SiteStructure.PageStructure.builder()
.title("Home").slug("home")
.components(List.of(
SiteStructure.ComponentStructure.builder().type("hero").build()
))
.build()
))
.build();
when(llmService.generateSite(any(), any(), anyString(), any())).thenReturn(structure);
Map<String, Object> request = Map.of(
"prompt", "Build a site",
"siteId", UUID.randomUUID().toString(),
"references", List.of(
Map.of("type", "IMAGE", "url", "data:img", "altText", "Photo")
)
);
mockMvc.perform(post("/api/llm/generate")
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk());
}
@Test
void returns429WhenRateLimited() throws Exception {
when(rateLimitConfig.tryConsume(userId)).thenReturn(false);
when(rateLimitConfig.getAvailableTokens(userId)).thenReturn(0);
Map<String, Object> request = Map.of(
"prompt", "Build a site",
"siteId", UUID.randomUUID().toString()
);
mockMvc.perform(post("/api/llm/generate")
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isTooManyRequests());
}
@Test
void returns400WhenPromptMissing() throws Exception {
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
Map<String, Object> request = Map.of(
"siteId", UUID.randomUUID().toString()
);
mockMvc.perform(post("/api/llm/generate")
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void returns400WhenPromptBlank() throws Exception {
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
Map<String, Object> request = Map.of(
"prompt", " ",
"siteId", UUID.randomUUID().toString()
);
mockMvc.perform(post("/api/llm/generate")
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void returns400WhenSiteIdMissing() throws Exception {
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
Map<String, Object> request = Map.of(
"prompt", "Build a site"
);
mockMvc.perform(post("/api/llm/generate")
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void returns400WhenSiteIdBlank() throws Exception {
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
Map<String, Object> request = Map.of(
"prompt", "Build a site",
"siteId", ""
);
mockMvc.perform(post("/api/llm/generate")
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
}
@Nested
class Refine {
@Test
void returns200WithRefinedStructure() throws Exception {
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
SiteStructure current = SiteStructure.builder()
.pages(List.of(
SiteStructure.PageStructure.builder()
.title("Old").slug("old")
.components(List.of(
SiteStructure.ComponentStructure.builder().type("text").build()
))
.build()
))
.build();
SiteStructure refined = SiteStructure.builder()
.pages(List.of(
SiteStructure.PageStructure.builder()
.title("Refined").slug("refined")
.components(List.of(
SiteStructure.ComponentStructure.builder().type("hero").build()
))
.build()
))
.build();
when(llmService.refineSite(any(), any(), any(), anyString(), any())).thenReturn(refined);
Map<String, Object> request = Map.of(
"prompt", "Make it better",
"siteId", UUID.randomUUID().toString(),
"currentStructure", current
);
mockMvc.perform(post("/api/llm/refine")
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.pages[0].title").value("Refined"));
}
@Test
void returns429WhenRateLimited() throws Exception {
when(rateLimitConfig.tryConsume(userId)).thenReturn(false);
when(rateLimitConfig.getAvailableTokens(userId)).thenReturn(0);
Map<String, Object> request = Map.of(
"prompt", "Refine",
"siteId", UUID.randomUUID().toString(),
"currentStructure", Map.of()
);
mockMvc.perform(post("/api/llm/refine")
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isTooManyRequests());
}
@Test
void returns400WhenPromptMissing() throws Exception {
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
Map<String, Object> request = Map.of(
"siteId", UUID.randomUUID().toString(),
"currentStructure", SiteStructure.builder().build()
);
mockMvc.perform(post("/api/llm/refine")
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void returns400WhenSiteIdMissing() throws Exception {
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
Map<String, Object> request = Map.of(
"prompt", "Refine",
"currentStructure", SiteStructure.builder().build()
);
mockMvc.perform(post("/api/llm/refine")
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void returns400WhenCurrentStructureMissing() throws Exception {
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
Map<String, Object> request = Map.of(
"prompt", "Refine",
"siteId", UUID.randomUUID().toString()
);
mockMvc.perform(post("/api/llm/refine")
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
}
@Nested
class Versions {
@Test
void getVersionsReturns200() throws Exception {
UUID siteId = UUID.randomUUID();
GenerationVersion v1 = GenerationVersion.builder()
.id("v1").siteId(siteId).versionNumber(1)
.prompt("First").createdAt(LocalDateTime.now())
.build();
when(llmService.getVersions(siteId)).thenReturn(List.of(v1));
mockMvc.perform(get("/api/llm/versions/{siteId}", siteId)
.with(authentication(auth)))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].versionNumber").value(1))
.andExpect(jsonPath("$[0].prompt").value("First"));
}
@Test
void getVersionsReturns200WithEmptyList() throws Exception {
UUID siteId = UUID.randomUUID();
when(llmService.getVersions(siteId)).thenReturn(List.of());
mockMvc.perform(get("/api/llm/versions/{siteId}", siteId)
.with(authentication(auth)))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isEmpty());
}
}
@Nested
class RestoreVersion {
@Test
void returns200WithRestoredStructure() throws Exception {
SiteStructure structure = SiteStructure.builder()
.pages(List.of(
SiteStructure.PageStructure.builder()
.title("Restored").slug("restored")
.components(List.of(
SiteStructure.ComponentStructure.builder().type("hero").build()
))
.build()
))
.build();
when(llmService.restoreVersion("v1")).thenReturn(structure);
mockMvc.perform(post("/api/llm/versions/v1/restore")
.with(authentication(auth)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.pages[0].title").value("Restored"));
}
@Test
void returns400WhenVersionNotFound() throws Exception {
when(llmService.restoreVersion("nonexistent"))
.thenThrow(new IllegalArgumentException("Version not found: nonexistent"));
mockMvc.perform(post("/api/llm/versions/nonexistent/restore")
.with(authentication(auth)))
.andExpect(status().isBadRequest());
}
}
@Nested
class DeleteVersion {
@Test
void returns204WhenDeleted() throws Exception {
doNothing().when(llmService).deleteVersion("v1");
mockMvc.perform(delete("/api/llm/versions/v1")
.with(authentication(auth)))
.andExpect(status().isNoContent());
}
@Test
void returns404WhenNotFound() throws Exception {
doThrow(new IllegalArgumentException("Version not found: nonexistent"))
.when(llmService).deleteVersion("nonexistent");
mockMvc.perform(delete("/api/llm/versions/nonexistent")
.with(authentication(auth)))
.andExpect(status().isNotFound());
}
}
}

View File

@@ -0,0 +1,31 @@
package com.indie.service;
import com.indie.dto.Reference;
import java.util.List;
public class FakeReferenceService extends ReferenceService {
@Override
String fetchUrlContent(String url) {
return "fake content for " + url;
}
public static String contextFor(List<Reference> references) {
if (references == null || references.isEmpty()) {
return "";
}
StringBuilder sb = new StringBuilder("\n\nReference materials provided by the user:\n");
for (int i = 0; i < references.size(); i++) {
Reference ref = references.get(i);
sb.append("[").append(i + 1).append("] ");
sb.append(ref.getType()).append(": ").append(ref.getUrl());
if (ref.getAltText() != null && !ref.getAltText().isBlank()) {
sb.append(" (").append(ref.getAltText()).append(")");
}
sb.append("\n");
sb.append(" Content: fake content for ").append(ref.getUrl()).append("\n");
}
return sb.toString();
}
}

View File

@@ -0,0 +1,272 @@
package com.indie.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.indie.dto.SiteStructure;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.api.Nested;
import static org.junit.jupiter.api.Assertions.*;
class LLMResponseParserTest {
private LLMResponseParser parser;
@BeforeEach
void setUp() {
parser = new LLMResponseParser(new ObjectMapper());
}
@Nested
class ExtractJson {
@Test
void extractsPlainJson() {
String json = "{\"pages\": []}";
assertEquals(json, parser.extractJson(json));
}
@Test
void extractsJsonFromMarkdownFence() {
String raw = "```json\n{\"pages\": [{\"title\": \"Home\", \"slug\": \"home\"}]}\n```";
String result = parser.extractJson(raw);
assertTrue(result.startsWith("{"));
assertTrue(result.endsWith("}"));
assertTrue(result.contains("\"title\": \"Home\""));
}
@Test
void extractsJsonFromMarkdownFenceWithoutLang() {
String raw = "```\n{\"pages\": []}\n```";
String result = parser.extractJson(raw);
assertTrue(result.startsWith("{"));
assertTrue(result.endsWith("}"));
}
@Test
void extractsJsonWithLeadingText() {
String raw = "Here is the JSON:\n{\"pages\": [{\"title\": \"About\", \"slug\": \"about\"}]}";
String result = parser.extractJson(raw);
assertTrue(result.startsWith("{"));
assertTrue(result.contains("\"title\": \"About\""));
}
@Test
void extractsJsonWithTrailingText() {
String raw = "{\"pages\": []}\nAnd that's all folks.";
String result = parser.extractJson(raw);
assertTrue(result.startsWith("{"));
assertTrue(result.endsWith("}"));
}
@Test
void trimsLeadingNonJsonContent() {
String raw = "Some explanation text before the actual JSON output.\n\n{\"pages\": []}";
String result = parser.extractJson(raw);
assertEquals("{\"pages\": []}", result);
}
@Test
void throwsOnNullInput() {
assertThrows(IllegalArgumentException.class, () -> parser.extractJson(null));
}
@Test
void throwsOnBlankInput() {
assertThrows(IllegalArgumentException.class, () -> parser.extractJson(" "));
}
}
@Nested
class Parse {
@Test
void parsesValidSiteStructure() {
String json = """
{
"theme": {
"colors": {"primary": "#000", "secondary": "#fff"},
"fonts": {"heading": "Arial"}
},
"pages": [
{
"title": "Home",
"slug": "home",
"components": [
{"type": "hero", "orderIndex": 0, "config": {"title": "Welcome"}}
]
}
]
}
""";
SiteStructure structure = parser.parse(json);
assertNotNull(structure.getTheme());
assertEquals("#000", structure.getTheme().getColors().get("primary"));
assertEquals("Arial", structure.getTheme().getFonts().get("heading"));
assertEquals(1, structure.getPages().size());
assertEquals("Home", structure.getPages().get(0).getTitle());
assertEquals("home", structure.getPages().get(0).getSlug());
assertEquals(1, structure.getPages().get(0).getComponents().size());
assertEquals("hero", structure.getPages().get(0).getComponents().get(0).getType());
}
@Test
void parsesStructureWithoutTheme() {
String json = """
{
"pages": [
{"title": "Home", "slug": "home", "components": [{"type": "text"}]}
]
}
""";
SiteStructure structure = parser.parse(json);
assertNull(structure.getTheme());
assertEquals(1, structure.getPages().size());
}
@Test
void throwsOnEmptyPages() {
String json = "{\"pages\": []}";
assertThrows(IllegalArgumentException.class, () -> parser.parse(json));
}
@Test
void throwsOnMissingPages() {
String json = "{\"theme\": {}}";
assertThrows(IllegalArgumentException.class, () -> parser.parse(json));
}
@Test
void throwsOnPageWithoutTitle() {
String json = """
{
"pages": [
{"slug": "home", "components": [{"type": "hero"}]}
]
}
""";
assertThrows(IllegalArgumentException.class, () -> parser.parse(json));
}
@Test
void throwsOnPageWithoutSlug() {
String json = """
{
"pages": [
{"title": "Home", "components": [{"type": "hero"}]}
]
}
""";
assertThrows(IllegalArgumentException.class, () -> parser.parse(json));
}
@Test
void throwsOnComponentWithoutType() {
String json = """
{
"pages": [
{"title": "Home", "slug": "home", "components": [{"orderIndex": 0}]}
]
}
""";
assertThrows(IllegalArgumentException.class, () -> parser.parse(json));
}
@Test
void assignsGeneratedIdWhenPageIdMissing() {
String json = """
{
"pages": [
{"title": "Home", "slug": "home", "components": [{"type": "hero"}]}
]
}
""";
SiteStructure structure = parser.parse(json);
assertNotNull(structure.getPages().get(0).getId());
}
@Test
void assignsGeneratedIdWhenComponentIdMissing() {
String json = """
{
"pages": [
{"title": "Home", "slug": "home", "components": [{"type": "hero"}]}
]
}
""";
SiteStructure structure = parser.parse(json);
assertNotNull(structure.getPages().get(0).getComponents().get(0).getId());
}
@Test
void parsesPageWithAllOptionalFields() {
String json = """
{
"pages": [
{
"id": "page-1",
"title": "About",
"slug": "about",
"seoTitle": "About Us",
"seoDescription": "Learn about our company",
"orderIndex": 1,
"components": [
{
"id": "comp-1",
"type": "text",
"orderIndex": 0,
"config": {"body": "Hello"},
"styles": {"color": "red"}
}
]
}
]
}
""";
SiteStructure structure = parser.parse(json);
SiteStructure.PageStructure page = structure.getPages().get(0);
assertEquals("page-1", page.getId());
assertEquals("About", page.getTitle());
assertEquals("about", page.getSlug());
assertEquals("About Us", page.getSeoTitle());
assertEquals("Learn about our company", page.getSeoDescription());
assertEquals(1, page.getOrderIndex());
SiteStructure.ComponentStructure comp = page.getComponents().get(0);
assertEquals("comp-1", comp.getId());
assertEquals("text", comp.getType());
assertEquals(0, comp.getOrderIndex());
assertEquals("Hello", comp.getConfig().get("body"));
assertEquals("red", comp.getStyles().get("color"));
}
@Test
void ignoresUnknownProperties() {
String json = """
{
"unknownField": true,
"pages": [
{"title": "Home", "slug": "home", "components": [{"type": "hero", "extra": 1}]}
]
}
""";
assertDoesNotThrow(() -> parser.parse(json));
}
@Test
void throwsOnInvalidJson() {
String json = "{invalid json here}";
assertThrows(IllegalArgumentException.class, () -> parser.parse(json));
}
}
}

View File

@@ -0,0 +1,297 @@
package com.indie.service;
import com.indie.dto.Reference;
import com.indie.dto.SiteStructure;
import com.indie.model.GenerationVersion;
import com.indie.service.llm.LLMFactory;
import com.indie.support.FakeLLMProvider;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Query;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.lang.reflect.Method;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class LLMServiceTest {
@Mock
private MongoTemplate mongoTemplate;
@Mock
private ResourceLoader resourceLoader;
private FakeLLMProvider activeProvider;
private FakeLLMProvider fallbackProvider;
private LLMFactory llmFactory;
private LLMResponseParser responseParser;
private FakeReferenceService referenceService;
private LLMService llmService;
private UUID userId;
private UUID siteId;
@BeforeEach
void setUp() throws Exception {
userId = UUID.randomUUID();
siteId = UUID.randomUUID();
activeProvider = new FakeLLMProvider("gemini");
fallbackProvider = new FakeLLMProvider("openai");
llmFactory = new LLMFactory(List.of(activeProvider, fallbackProvider), "gemini", "openai");
Method init = LLMFactory.class.getDeclaredMethod("init");
init.setAccessible(true);
init.invoke(llmFactory);
responseParser = new LLMResponseParser(new ObjectMapper());
referenceService = new FakeReferenceService();
Resource mockResource = mock(Resource.class);
when(mockResource.exists()).thenReturn(true);
when(mockResource.getInputStream()).thenReturn(
new ByteArrayInputStream("You are a site generator.".getBytes(StandardCharsets.UTF_8)));
when(resourceLoader.getResource(anyString())).thenReturn(mockResource);
llmService = new LLMService(llmFactory, responseParser, referenceService,
mongoTemplate, resourceLoader, "classpath:prompts/site-generator.txt");
}
@Test
void generateSiteReturnsParsedStructure() {
activeProvider.withContent("""
{
"pages": [
{"title": "Home", "slug": "home", "components": [{"type": "hero"}]}
]
}
""");
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
SiteStructure result = llmService.generateSite(userId, siteId, "Build a site", null);
assertEquals(1, result.getPages().size());
assertEquals("Home", result.getPages().get(0).getTitle());
verify(mongoTemplate).save(any(GenerationVersion.class));
}
@Test
void generateSiteSavesVersionWithCorrectData() {
activeProvider.withContent("""
{
"pages": [
{"title": "About", "slug": "about", "components": [{"type": "text"}]}
]
}
""");
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
llmService.generateSite(userId, siteId, "Build about page", null);
ArgumentCaptor<GenerationVersion> captor = ArgumentCaptor.forClass(GenerationVersion.class);
verify(mongoTemplate).save(captor.capture());
GenerationVersion saved = captor.getValue();
assertEquals(siteId, saved.getSiteId());
assertEquals(userId, saved.getUserId());
assertEquals(1, saved.getVersionNumber());
assertEquals("Build about page", saved.getPrompt());
assertNotNull(saved.getCreatedAt());
}
@Test
void generateSiteIncrementsVersionNumber() {
activeProvider.withContent("""
{
"pages": [
{"title": "Home", "slug": "home", "components": [{"type": "hero"}]}
]
}
""");
GenerationVersion existing = GenerationVersion.builder()
.id("v1")
.siteId(siteId)
.versionNumber(3)
.build();
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(existing);
llmService.generateSite(userId, siteId, "Build a site", null);
ArgumentCaptor<GenerationVersion> captor = ArgumentCaptor.forClass(GenerationVersion.class);
verify(mongoTemplate).save(captor.capture());
assertEquals(4, captor.getValue().getVersionNumber());
}
@Test
void generateSiteWithReferences() {
activeProvider.withContent("""
{
"pages": [
{"title": "Home", "slug": "home", "components": [{"type": "hero"}]}
]
}
""");
List<Reference> references = List.of(
Reference.builder().type(Reference.ReferenceType.IMAGE).url("data:img").altText("Ref").build()
);
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
llmService.generateSite(userId, siteId, "Build a site", references);
ArgumentCaptor<GenerationVersion> captor = ArgumentCaptor.forClass(GenerationVersion.class);
verify(mongoTemplate).save(captor.capture());
assertNotNull(captor.getValue().getReferences());
assertEquals(1, captor.getValue().getReferences().size());
assertEquals("IMAGE", captor.getValue().getReferences().get(0).getType());
}
@Test
void generateSiteFallsBackOnProviderFailure() {
activeProvider.setFailure(new RuntimeException("API error"));
fallbackProvider.withContent("""
{
"pages": [
{"title": "Fallback", "slug": "fallback", "components": [{"type": "hero"}]}
]
}
""");
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
SiteStructure result = llmService.generateSite(userId, siteId, "Build", null);
assertEquals("Fallback", result.getPages().get(0).getTitle());
verify(mongoTemplate).save(any(GenerationVersion.class));
}
@Test
void generateSiteThrowsWhenAllProvidersFail() {
activeProvider.setFailure(new RuntimeException("API error"));
fallbackProvider.setFailure(new RuntimeException("Fallback also failed"));
assertThrows(RuntimeException.class,
() -> llmService.generateSite(userId, siteId, "Build", null));
verify(mongoTemplate, never()).save(any());
}
@Test
void refineSiteReturnsRefinedStructure() {
SiteStructure current = SiteStructure.builder()
.pages(List.of(
SiteStructure.PageStructure.builder()
.title("Old")
.slug("old")
.components(List.of(
SiteStructure.ComponentStructure.builder().type("text").build()
))
.build()
))
.build();
activeProvider.withContent("""
{
"pages": [
{"title": "Refined", "slug": "refined", "components": [{"type": "hero"}]}
]
}
""");
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
SiteStructure result = llmService.refineSite(userId, siteId, current, "Make it better", null);
assertEquals("Refined", result.getPages().get(0).getTitle());
verify(mongoTemplate).save(any(GenerationVersion.class));
}
@Test
void getVersionsReturnsList() {
GenerationVersion v1 = GenerationVersion.builder()
.id("v1").siteId(siteId).versionNumber(1).build();
GenerationVersion v2 = GenerationVersion.builder()
.id("v2").siteId(siteId).versionNumber(2).build();
when(mongoTemplate.find(any(Query.class), eq(GenerationVersion.class)))
.thenReturn(List.of(v2, v1));
List<GenerationVersion> versions = llmService.getVersions(siteId);
assertEquals(2, versions.size());
assertEquals("v2", versions.get(0).getId());
}
@Test
void getVersionsReturnsEmptyWhenNone() {
when(mongoTemplate.find(any(Query.class), eq(GenerationVersion.class)))
.thenReturn(List.of());
List<GenerationVersion> versions = llmService.getVersions(siteId);
assertTrue(versions.isEmpty());
}
@Test
void restoreVersionReturnsParsedStructure() {
GenerationVersion version = GenerationVersion.builder()
.id("v1")
.fullStructure("""
{"pages": [{"title": "Restored", "slug": "restored", "components": [{"type": "hero"}]}]}
""")
.build();
when(mongoTemplate.findById("v1", GenerationVersion.class)).thenReturn(version);
SiteStructure result = llmService.restoreVersion("v1");
assertEquals("Restored", result.getPages().get(0).getTitle());
}
@Test
void restoreVersionThrowsWhenNotFound() {
when(mongoTemplate.findById("nonexistent", GenerationVersion.class)).thenReturn(null);
assertThrows(IllegalArgumentException.class, () -> llmService.restoreVersion("nonexistent"));
}
@Test
void deleteVersionRemovesVersion() {
GenerationVersion version = GenerationVersion.builder()
.id("v1").siteId(siteId).versionNumber(1).build();
when(mongoTemplate.findById("v1", GenerationVersion.class)).thenReturn(version);
llmService.deleteVersion("v1");
verify(mongoTemplate).remove(version);
}
@Test
void deleteVersionThrowsWhenNotFound() {
when(mongoTemplate.findById("nonexistent", GenerationVersion.class)).thenReturn(null);
assertThrows(IllegalArgumentException.class, () -> llmService.deleteVersion("nonexistent"));
verify(mongoTemplate, never()).remove(any());
}
}

View File

@@ -0,0 +1,92 @@
package com.indie.service;
import com.indie.dto.Reference;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class ReferenceServiceTest {
private ReferenceService referenceService;
@BeforeEach
void setUp() {
referenceService = new ReferenceService();
}
@Test
void buildReferenceContextReturnsEmptyForNull() {
assertEquals("", referenceService.buildReferenceContext(null));
}
@Test
void buildReferenceContextReturnsEmptyForEmptyList() {
assertEquals("", referenceService.buildReferenceContext(List.of()));
}
@Test
void buildReferenceContextIncludesImageReferences() {
Reference img = Reference.builder()
.type(Reference.ReferenceType.IMAGE)
.url("data:image/png;base64,abc123")
.altText("Screenshot")
.build();
String context = referenceService.buildReferenceContext(List.of(img));
assertTrue(context.contains("IMAGE"));
assertTrue(context.contains("data:image/png;base64,abc123"));
assertTrue(context.contains("Screenshot"));
}
@Test
void buildReferenceContextExcludesAltTextWhenNull() {
Reference img = Reference.builder()
.type(Reference.ReferenceType.IMAGE)
.url("data:image/png;base64,abc")
.build();
String context = referenceService.buildReferenceContext(List.of(img));
assertTrue(context.contains("IMAGE"));
assertFalse(context.contains("(null)"));
}
@Test
void buildReferenceContextIncludesMultipleReferences() {
Reference ref1 = Reference.builder()
.type(Reference.ReferenceType.IMAGE)
.url("data:img1")
.altText("First")
.build();
Reference ref2 = Reference.builder()
.type(Reference.ReferenceType.IMAGE)
.url("data:img2")
.altText("Second")
.build();
String context = referenceService.buildReferenceContext(List.of(ref1, ref2));
assertTrue(context.contains("[1]"));
assertTrue(context.contains("[2]"));
assertTrue(context.contains("First"));
assertTrue(context.contains("Second"));
}
@Test
void buildReferenceContextHandlesBlankAltText() {
Reference ref = Reference.builder()
.type(Reference.ReferenceType.IMAGE)
.url("data:img")
.altText(" ")
.build();
String context = referenceService.buildReferenceContext(List.of(ref));
assertTrue(context.contains("IMAGE"));
assertFalse(context.contains("( )"));
}
}

View File

@@ -0,0 +1,89 @@
package com.indie.service.llm;
import com.indie.support.FakeLLMProvider;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class LLMFactoryTest {
private FakeLLMProvider gemini;
private FakeLLMProvider openai;
private List<LLMProvider> providers;
@BeforeEach
void setUp() {
gemini = new FakeLLMProvider("gemini");
openai = new FakeLLMProvider("openai");
providers = List.of(gemini, openai);
}
@Test
void getActiveProviderReturnsConfiguredProvider() {
LLMFactory factory = new LLMFactory(providers, "gemini", "openai");
factory.init();
LLMProvider active = factory.getActiveProvider();
assertEquals("gemini", active.getName());
}
@Test
void getActiveProviderThrowsWhenNotFound() {
LLMFactory factory = new LLMFactory(providers, "nonexistent", "openai");
factory.init();
assertThrows(IllegalStateException.class, factory::getActiveProvider);
}
@Test
void getActiveProviderThrowsWhenNoProviders() {
LLMFactory factory = new LLMFactory(List.of(), "gemini", "openai");
factory.init();
assertThrows(IllegalStateException.class, factory::getActiveProvider);
}
@Test
void getFallbackProviderReturnsConfiguredFallback() {
LLMFactory factory = new LLMFactory(providers, "gemini", "openai");
factory.init();
assertTrue(factory.getFallbackProvider().isPresent());
assertEquals("openai", factory.getFallbackProvider().get().getName());
}
@Test
void getFallbackProviderReturnsEmptyWhenNotFound() {
LLMFactory factory = new LLMFactory(providers, "gemini", "nonexistent");
factory.init();
assertTrue(factory.getFallbackProvider().isEmpty());
}
@Test
void getFallbackProviderReturnsEmptyWhenNotConfigured() {
LLMFactory factory = new LLMFactory(providers, "gemini", "");
factory.init();
assertTrue(factory.getFallbackProvider().isEmpty());
}
@Test
void activeAndFallbackCanBeSameProvider() {
LLMFactory factory = new LLMFactory(providers, "gemini", "gemini");
factory.init();
assertEquals("gemini", factory.getActiveProvider().getName());
assertTrue(factory.getFallbackProvider().isPresent());
assertEquals("gemini", factory.getFallbackProvider().get().getName());
}
@Test
void initLogsAvailableProviders() {
LLMFactory factory = new LLMFactory(providers, "gemini", "openai");
assertDoesNotThrow(factory::init);
}
}

View File

@@ -0,0 +1,48 @@
package com.indie.support;
import com.indie.dto.LLMResult;
import com.indie.dto.LLMOptions;
import com.indie.dto.Reference;
import com.indie.service.llm.LLMProvider;
import java.util.List;
public class FakeLLMProvider implements LLMProvider {
private final String name;
private String content;
private RuntimeException failure;
public FakeLLMProvider(String name) {
this.name = name;
this.content = "{\"pages\": [{\"title\": \"Home\", \"slug\": \"home\", \"components\": [{\"type\": \"hero\"}]}]}";
}
public FakeLLMProvider withContent(String content) {
this.content = content;
return this;
}
public void setFailure(RuntimeException failure) {
this.failure = failure;
}
@Override
public LLMResult generate(String systemPrompt, String userPrompt, List<Reference> references, LLMOptions options) {
if (failure != null) {
throw failure;
}
return LLMResult.builder()
.content(content)
.promptTokens(10)
.completionTokens(20)
.model(name)
.latencyMs(100)
.build();
}
@Override
public String getName() {
return name;
}
}