diff --git a/backend/src/test/java/com/indie/config/RateLimitConfigTest.java b/backend/src/test/java/com/indie/config/RateLimitConfigTest.java new file mode 100644 index 0000000..df7091a --- /dev/null +++ b/backend/src/test/java/com/indie/config/RateLimitConfigTest.java @@ -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)); + } +} diff --git a/backend/src/test/java/com/indie/controller/LLMControllerTest.java b/backend/src/test/java/com/indie/controller/LLMControllerTest.java new file mode 100644 index 0000000..533c721 --- /dev/null +++ b/backend/src/test/java/com/indie/controller/LLMControllerTest.java @@ -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 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 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 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 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 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 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 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 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 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 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 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 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()); + } + } +} diff --git a/backend/src/test/java/com/indie/service/FakeReferenceService.java b/backend/src/test/java/com/indie/service/FakeReferenceService.java new file mode 100644 index 0000000..fa88d54 --- /dev/null +++ b/backend/src/test/java/com/indie/service/FakeReferenceService.java @@ -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 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(); + } +} diff --git a/backend/src/test/java/com/indie/service/LLMResponseParserTest.java b/backend/src/test/java/com/indie/service/LLMResponseParserTest.java new file mode 100644 index 0000000..9db6a12 --- /dev/null +++ b/backend/src/test/java/com/indie/service/LLMResponseParserTest.java @@ -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)); + } + } +} diff --git a/backend/src/test/java/com/indie/service/LLMServiceTest.java b/backend/src/test/java/com/indie/service/LLMServiceTest.java new file mode 100644 index 0000000..e7e8439 --- /dev/null +++ b/backend/src/test/java/com/indie/service/LLMServiceTest.java @@ -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 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 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 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 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 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 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()); + } +} diff --git a/backend/src/test/java/com/indie/service/ReferenceServiceTest.java b/backend/src/test/java/com/indie/service/ReferenceServiceTest.java new file mode 100644 index 0000000..1f8bf9a --- /dev/null +++ b/backend/src/test/java/com/indie/service/ReferenceServiceTest.java @@ -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("( )")); + } +} diff --git a/backend/src/test/java/com/indie/service/llm/LLMFactoryTest.java b/backend/src/test/java/com/indie/service/llm/LLMFactoryTest.java new file mode 100644 index 0000000..29206e3 --- /dev/null +++ b/backend/src/test/java/com/indie/service/llm/LLMFactoryTest.java @@ -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 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); + } +} diff --git a/backend/src/test/java/com/indie/support/FakeLLMProvider.java b/backend/src/test/java/com/indie/support/FakeLLMProvider.java new file mode 100644 index 0000000..d9915ed --- /dev/null +++ b/backend/src/test/java/com/indie/support/FakeLLMProvider.java @@ -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 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; + } +}