update site generation flow
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import io.github.bucket4j.Bandwidth;
|
||||
import io.github.bucket4j.Bucket;
|
||||
import io.github.bucket4j.Refill;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Component
|
||||
public class SuggestRateLimitConfig {
|
||||
|
||||
private final Map<UUID, Bucket> cache = new ConcurrentHashMap<>();
|
||||
private final int maxRequestsPerHour;
|
||||
|
||||
public SuggestRateLimitConfig(
|
||||
@Value("${indie.llm.suggest-rate-limit.max-requests-per-hour:30}") int maxRequestsPerHour) {
|
||||
this.maxRequestsPerHour = maxRequestsPerHour;
|
||||
}
|
||||
|
||||
public boolean tryConsume(UUID userId) {
|
||||
Bucket bucket = cache.computeIfAbsent(userId, this::createBucket);
|
||||
return bucket.tryConsume(1);
|
||||
}
|
||||
|
||||
public int getAvailableTokens(UUID userId) {
|
||||
Bucket bucket = cache.computeIfAbsent(userId, this::createBucket);
|
||||
return (int) bucket.getAvailableTokens();
|
||||
}
|
||||
|
||||
private Bucket createBucket(UUID userId) {
|
||||
Bandwidth limit = Bandwidth.classic(maxRequestsPerHour,
|
||||
Refill.greedy(maxRequestsPerHour, Duration.ofHours(1)));
|
||||
return Bucket.builder().addLimit(limit).build();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.krrishg.controller;
|
||||
|
||||
import com.krrishg.config.RateLimitConfig;
|
||||
import com.krrishg.config.SuggestRateLimitConfig;
|
||||
import com.krrishg.config.UserPrincipal;
|
||||
import com.krrishg.dto.Reference;
|
||||
import com.krrishg.dto.SiteStructure;
|
||||
import com.krrishg.dto.SuggestSectionsResponse;
|
||||
import com.krrishg.model.GenerationVersion;
|
||||
import com.krrishg.service.LLMService;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
@@ -23,11 +25,14 @@ public class LLMController {
|
||||
|
||||
private final LLMService llmService;
|
||||
private final RateLimitConfig rateLimitConfig;
|
||||
private final SuggestRateLimitConfig suggestRateLimitConfig;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public LLMController(LLMService llmService, RateLimitConfig rateLimitConfig, ObjectMapper objectMapper) {
|
||||
public LLMController(LLMService llmService, RateLimitConfig rateLimitConfig,
|
||||
SuggestRateLimitConfig suggestRateLimitConfig, ObjectMapper objectMapper) {
|
||||
this.llmService = llmService;
|
||||
this.rateLimitConfig = rateLimitConfig;
|
||||
this.suggestRateLimitConfig = suggestRateLimitConfig;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@@ -77,6 +82,40 @@ public class LLMController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/suggest-sections")
|
||||
public ResponseEntity<?> suggestSections(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@RequestBody Map<String, Object> request) {
|
||||
if (!suggestRateLimitConfig.tryConsume(principal.id())) {
|
||||
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.body(Map.of("error", "Suggest rate limit exceeded. Max " +
|
||||
suggestRateLimitConfig.getAvailableTokens(principal.id()) +
|
||||
" requests per hour."));
|
||||
}
|
||||
|
||||
String briefDescription = (String) request.get("briefDescription");
|
||||
if (briefDescription == null || briefDescription.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "briefDescription is required"));
|
||||
}
|
||||
|
||||
String provider = (String) request.get("provider");
|
||||
if (provider == null || provider.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "provider is required"));
|
||||
}
|
||||
|
||||
String apiKey = (String) request.get("apiKey");
|
||||
String baseUrl = (String) request.get("baseUrl");
|
||||
String model = (String) request.get("model");
|
||||
|
||||
try {
|
||||
SuggestSectionsResponse response = llmService.suggestSections(
|
||||
principal.id(), briefDescription, provider, apiKey, baseUrl, model);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (IllegalStateException | IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/refine")
|
||||
public ResponseEntity<?> refine(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
public record SuggestSectionsRequest(
|
||||
String briefDescription,
|
||||
String provider,
|
||||
String apiKey,
|
||||
String baseUrl,
|
||||
String model
|
||||
) {}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record SuggestSectionsResponse(
|
||||
List<SectionSuggestion> sections
|
||||
) {
|
||||
public record SectionSuggestion(
|
||||
String title,
|
||||
String description,
|
||||
String slug,
|
||||
List<PatternSuggestion> patterns
|
||||
) {}
|
||||
|
||||
public record PatternSuggestion(
|
||||
String title,
|
||||
String description
|
||||
) {}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import com.krrishg.dto.LLMOptions;
|
||||
import com.krrishg.dto.LLMResult;
|
||||
import com.krrishg.dto.Reference;
|
||||
import com.krrishg.dto.SiteStructure;
|
||||
import com.krrishg.dto.SuggestSectionsResponse;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import com.krrishg.model.GenerationVersion;
|
||||
@@ -12,6 +13,8 @@ import com.krrishg.model.User;
|
||||
import com.krrishg.repository.UserRepository;
|
||||
import com.krrishg.service.llm.LLMProvider;
|
||||
import com.krrishg.service.llm.ProviderFactory;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -51,6 +54,8 @@ public class LLMService {
|
||||
private final EncryptionService encryptionService;
|
||||
private final String systemPrompt;
|
||||
private final String refinerPrompt;
|
||||
private final String suggestSectionsPrompt;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final int maxVersionsPerSite;
|
||||
private final double refineTemperature;
|
||||
|
||||
@@ -63,6 +68,7 @@ public class LLMService {
|
||||
ResourceLoader resourceLoader,
|
||||
@Value("${indie.llm.system-prompt-path:classpath:prompts/site-generator.txt}") String promptPath,
|
||||
@Value("${indie.llm.refiner-prompt-path:classpath:prompts/site-refiner.txt}") String refinerPath,
|
||||
@Value("${indie.llm.suggest-sections-prompt-path:classpath:prompts/suggest-sections.txt}") String suggestSectionsPath,
|
||||
@Value("${indie.llm.max-versions-per-site:10}") int maxVersionsPerSite,
|
||||
@Value("${indie.llm.refine.temperature:0.3}") double refineTemperature) {
|
||||
this.providerFactory = providerFactory;
|
||||
@@ -73,6 +79,8 @@ public class LLMService {
|
||||
this.encryptionService = encryptionService;
|
||||
this.systemPrompt = loadSystemPrompt(resourceLoader, promptPath);
|
||||
this.refinerPrompt = loadSystemPrompt(resourceLoader, refinerPath);
|
||||
this.suggestSectionsPrompt = loadSystemPrompt(resourceLoader, suggestSectionsPath);
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.maxVersionsPerSite = maxVersionsPerSite;
|
||||
this.refineTemperature = refineTemperature;
|
||||
}
|
||||
@@ -107,6 +115,35 @@ public class LLMService {
|
||||
return structure;
|
||||
}
|
||||
|
||||
public SuggestSectionsResponse suggestSections(UUID userId, String briefDescription,
|
||||
String provider, String apiKey) {
|
||||
return suggestSections(userId, briefDescription, provider, apiKey, null, null);
|
||||
}
|
||||
|
||||
public SuggestSectionsResponse suggestSections(UUID userId, String briefDescription,
|
||||
String provider, String apiKey, String baseUrl) {
|
||||
return suggestSections(userId, briefDescription, provider, apiKey, baseUrl, null);
|
||||
}
|
||||
|
||||
public SuggestSectionsResponse suggestSections(UUID userId, String briefDescription,
|
||||
String provider, String apiKey, String baseUrl, String model) {
|
||||
String resolvedKey = resolveApiKey(userId, provider, apiKey);
|
||||
String resolvedBaseUrl = resolveBaseUrl(userId, provider, baseUrl);
|
||||
String resolvedModel = resolveModel(userId, provider, model);
|
||||
|
||||
LLMOptions options = LLMOptions.builder()
|
||||
.temperature(0.2)
|
||||
.maxTokens(2000)
|
||||
.build();
|
||||
LLMResult result = callLLM(provider, resolvedKey, resolvedBaseUrl, resolvedModel,
|
||||
suggestSectionsPrompt, briefDescription, null, options);
|
||||
log.info("Section suggestions completed: model={}, latency={}ms, tokens={}+{}",
|
||||
result.getModel(), result.getLatencyMs(),
|
||||
result.getPromptTokens(), result.getCompletionTokens());
|
||||
|
||||
return parseSectionSuggestions(result.getContent());
|
||||
}
|
||||
|
||||
public SiteStructure refineSite(UUID userId, UUID siteId, SiteStructure currentStructure,
|
||||
String refinementPrompt, List<Reference> references,
|
||||
String provider, String apiKey) {
|
||||
@@ -421,4 +458,65 @@ public class LLMService {
|
||||
throw new RuntimeException("Failed to serialize site structure", e);
|
||||
}
|
||||
}
|
||||
|
||||
private SuggestSectionsResponse parseSectionSuggestions(String llmOutput) {
|
||||
String json = extractJsonArray(llmOutput);
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(json);
|
||||
List<SuggestSectionsResponse.SectionSuggestion> sections = new ArrayList<>();
|
||||
if (root.isArray()) {
|
||||
for (JsonNode node : root) {
|
||||
String title = node.has("title") ? node.get("title").asText() : null;
|
||||
String description = node.has("description") ? node.get("description").asText() : null;
|
||||
String slug = node.has("slug") ? node.get("slug").asText() : null;
|
||||
if (title != null && slug != null) {
|
||||
List<SuggestSectionsResponse.PatternSuggestion> patterns = new ArrayList<>();
|
||||
if (node.has("patterns") && node.get("patterns").isArray()) {
|
||||
for (JsonNode pn : node.get("patterns")) {
|
||||
String pt = pn.has("title") ? pn.get("title").asText() : null;
|
||||
String pd = pn.has("description") ? pn.get("description").asText() : null;
|
||||
if (pt != null) {
|
||||
patterns.add(new SuggestSectionsResponse.PatternSuggestion(pt, pd));
|
||||
}
|
||||
}
|
||||
}
|
||||
sections.add(new SuggestSectionsResponse.SectionSuggestion(title, description, slug, patterns));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sections.isEmpty()) {
|
||||
throw new IllegalArgumentException("LLM output must contain at least one section suggestion");
|
||||
}
|
||||
return new SuggestSectionsResponse(sections);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalArgumentException("Failed to parse section suggestions as JSON: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String extractJsonArray(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
throw new IllegalArgumentException("LLM output is empty");
|
||||
}
|
||||
String trimmed = raw.trim();
|
||||
if (trimmed.startsWith("```")) {
|
||||
int start = trimmed.indexOf('\n');
|
||||
int end = trimmed.lastIndexOf("```");
|
||||
if (start > 0 && end > start) {
|
||||
trimmed = trimmed.substring(start, end).trim();
|
||||
}
|
||||
}
|
||||
if (!trimmed.startsWith("[")) {
|
||||
int bracket = trimmed.indexOf('[');
|
||||
if (bracket >= 0) {
|
||||
trimmed = trimmed.substring(bracket);
|
||||
}
|
||||
}
|
||||
if (!trimmed.endsWith("]")) {
|
||||
int bracket = trimmed.lastIndexOf(']');
|
||||
if (bracket >= 0) {
|
||||
trimmed = trimmed.substring(0, bracket + 1);
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import org.apache.catalina.connector.ClientAbortException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
@@ -13,6 +15,19 @@ class GlobalExceptionHandlerTest {
|
||||
|
||||
private final GlobalExceptionHandler handler = new GlobalExceptionHandler();
|
||||
|
||||
@Test
|
||||
void jwtExceptionReturns401() {
|
||||
ProblemDetail detail = handler.handleJwtException(new JwtException("token expired"));
|
||||
|
||||
assertEquals(HttpStatus.UNAUTHORIZED.value(), detail.getStatus());
|
||||
assertEquals("token expired", detail.getDetail());
|
||||
}
|
||||
|
||||
@Test
|
||||
void clientAbortDoesNotThrow() {
|
||||
handler.handleClientAbort(new ClientAbortException("client disconnected"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void illegalArgumentReturns400() {
|
||||
ProblemDetail detail = handler.handleBadRequest(new IllegalArgumentException("bad input"));
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.krrishg.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 SuggestRateLimitConfigTest {
|
||||
|
||||
private SuggestRateLimitConfig config;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
config = new SuggestRateLimitConfig(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();
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
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));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAvailableTokensReturnsZeroWhenExhausted() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
for (int i = 0; i < 3; i++) {
|
||||
config.tryConsume(userId);
|
||||
}
|
||||
assertEquals(0, config.getAvailableTokens(userId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructorWithDefaultRate() {
|
||||
SuggestRateLimitConfig defaultConfig = new SuggestRateLimitConfig(30);
|
||||
UUID userId = UUID.randomUUID();
|
||||
assertEquals(30, defaultConfig.getAvailableTokens(userId));
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@ package com.krrishg.controller;
|
||||
import com.krrishg.config.GlobalExceptionHandler;
|
||||
import com.krrishg.config.JwtAuthenticationToken;
|
||||
import com.krrishg.config.RateLimitConfig;
|
||||
import com.krrishg.config.SuggestRateLimitConfig;
|
||||
import com.krrishg.config.UserPrincipal;
|
||||
import com.krrishg.dto.Reference;
|
||||
import com.krrishg.dto.SiteStructure;
|
||||
import com.krrishg.dto.SuggestSectionsResponse;
|
||||
import com.krrishg.model.GenerationVersion;
|
||||
import com.krrishg.service.LLMService;
|
||||
import com.krrishg.support.TestSecurityConfig;
|
||||
@@ -45,6 +47,9 @@ class LLMControllerTest {
|
||||
@MockBean
|
||||
private RateLimitConfig rateLimitConfig;
|
||||
|
||||
@MockBean
|
||||
private SuggestRateLimitConfig suggestRateLimitConfig;
|
||||
|
||||
private UUID userId;
|
||||
private UserPrincipal principal;
|
||||
private JwtAuthenticationToken auth;
|
||||
@@ -219,6 +224,22 @@ class LLMControllerTest {
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("No API key configured for gemini"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenSiteIdMissing() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Build a site",
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/generate")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -296,6 +317,171 @@ class LLMControllerTest {
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenPromptMissing() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini",
|
||||
"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",
|
||||
"provider", "gemini",
|
||||
"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 returns400WhenCurrentStructureInvalid() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Refine",
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini",
|
||||
"currentStructure", "not-a-valid-structure"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/refine")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenCurrentStructureNull() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Refine",
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/refine")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class SuggestSections {
|
||||
|
||||
@Test
|
||||
void returns200WithSuggestions() throws Exception {
|
||||
when(suggestRateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
SuggestSectionsResponse response = new SuggestSectionsResponse(
|
||||
List.of(
|
||||
new SuggestSectionsResponse.SectionSuggestion("Home", "Hero section", "home", List.of()),
|
||||
new SuggestSectionsResponse.SectionSuggestion("About", "Bio", "about", List.of())
|
||||
)
|
||||
);
|
||||
when(llmService.suggestSections(any(), anyString(), anyString(), any(), any(), any())).thenReturn(response);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"briefDescription", "photography portfolio",
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/suggest-sections")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.sections[0].title").value("Home"))
|
||||
.andExpect(jsonPath("$.sections[1].slug").value("about"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns429WhenRateLimited() throws Exception {
|
||||
when(suggestRateLimitConfig.tryConsume(userId)).thenReturn(false);
|
||||
when(suggestRateLimitConfig.getAvailableTokens(userId)).thenReturn(0);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"briefDescription", "portfolio",
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/suggest-sections")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isTooManyRequests());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenBriefDescriptionMissing() throws Exception {
|
||||
when(suggestRateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/suggest-sections")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenProviderMissing() throws Exception {
|
||||
when(suggestRateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"briefDescription", "portfolio"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/suggest-sections")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenNoKeyConfigured() throws Exception {
|
||||
when(suggestRateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
when(llmService.suggestSections(any(), anyString(), anyString(), any(), any(), any()))
|
||||
.thenThrow(new IllegalStateException("No API key configured for gemini"));
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"briefDescription", "portfolio",
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/suggest-sections")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("No API key configured for gemini"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -383,4 +569,45 @@ class LLMControllerTest {
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class PruneVersions {
|
||||
|
||||
@Test
|
||||
void returns200WithDefaultKeep() throws Exception {
|
||||
when(llmService.pruneVersions(any(UUID.class), eq(10))).thenReturn(3);
|
||||
|
||||
UUID siteId = UUID.randomUUID();
|
||||
mockMvc.perform(post("/api/llm/versions/{siteId}/prune", siteId)
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.deleted").value(3));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns200WithCustomKeep() throws Exception {
|
||||
when(llmService.pruneVersions(any(UUID.class), eq(5))).thenReturn(2);
|
||||
|
||||
UUID siteId = UUID.randomUUID();
|
||||
mockMvc.perform(post("/api/llm/versions/{siteId}/prune", siteId)
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(Map.of("keep", 5))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.deleted").value(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns200WithNoRequestBody() throws Exception {
|
||||
when(llmService.pruneVersions(any(UUID.class), eq(10))).thenReturn(0);
|
||||
|
||||
UUID siteId = UUID.randomUUID();
|
||||
mockMvc.perform(post("/api/llm/versions/{siteId}/prune", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.deleted").value(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.krrishg.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class VerificationTokenTest {
|
||||
|
||||
@Test
|
||||
void prePersistSetsIdAndCreatedAt() {
|
||||
VerificationToken token = new VerificationToken();
|
||||
token.setUserId(UUID.randomUUID());
|
||||
token.setToken("verify-token");
|
||||
token.setExpiryDate(LocalDateTime.now().plusHours(1));
|
||||
|
||||
assertNull(token.getId());
|
||||
assertNull(token.getCreatedAt());
|
||||
|
||||
token.onCreate();
|
||||
|
||||
assertNotNull(token.getId());
|
||||
assertNotNull(token.getCreatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void prePersistDoesNotOverrideExistingId() {
|
||||
UUID existingId = UUID.randomUUID();
|
||||
VerificationToken token = VerificationToken.builder()
|
||||
.id(existingId)
|
||||
.userId(UUID.randomUUID())
|
||||
.token("test-token")
|
||||
.expiryDate(LocalDateTime.now().plusHours(1))
|
||||
.build();
|
||||
|
||||
token.onCreate();
|
||||
|
||||
assertEquals(existingId, token.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builderDefaults() {
|
||||
VerificationToken token = VerificationToken.builder()
|
||||
.userId(UUID.randomUUID())
|
||||
.token("test-token")
|
||||
.expiryDate(LocalDateTime.now().plusHours(1))
|
||||
.build();
|
||||
|
||||
assertFalse(token.isUsed());
|
||||
assertFalse(token.isSetupTokenUsed());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.krrishg.repository;
|
||||
|
||||
import com.krrishg.model.VerificationToken;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@DataJpaTest
|
||||
class VerificationTokenRepositoryTest {
|
||||
|
||||
private final VerificationTokenRepository repository;
|
||||
|
||||
VerificationTokenRepositoryTest(@Autowired VerificationTokenRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAndFindByToken() {
|
||||
VerificationToken token = VerificationToken.builder()
|
||||
.userId(UUID.randomUUID())
|
||||
.token("verify-token-123")
|
||||
.expiryDate(LocalDateTime.now().plusHours(1))
|
||||
.build();
|
||||
repository.save(token);
|
||||
|
||||
Optional<VerificationToken> found = repository.findByToken("verify-token-123");
|
||||
|
||||
assertTrue(found.isPresent());
|
||||
assertEquals(token.getUserId(), found.get().getUserId());
|
||||
assertFalse(found.get().isUsed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByTokenReturnsEmptyWhenNotFound() {
|
||||
Optional<VerificationToken> found = repository.findByToken("nonexistent");
|
||||
|
||||
assertTrue(found.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findBySetupToken() {
|
||||
VerificationToken token = VerificationToken.builder()
|
||||
.userId(UUID.randomUUID())
|
||||
.token("verify-token")
|
||||
.setupToken("setup-token-456")
|
||||
.expiryDate(LocalDateTime.now().plusHours(1))
|
||||
.build();
|
||||
repository.save(token);
|
||||
|
||||
Optional<VerificationToken> found = repository.findBySetupToken("setup-token-456");
|
||||
|
||||
assertTrue(found.isPresent());
|
||||
assertEquals("verify-token", found.get().getToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findBySetupTokenReturnsEmptyWhenNotFound() {
|
||||
Optional<VerificationToken> found = repository.findBySetupToken("nonexistent");
|
||||
|
||||
assertTrue(found.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteByUserIdRemovesToken() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
VerificationToken token = VerificationToken.builder()
|
||||
.userId(userId)
|
||||
.token("delete-me")
|
||||
.expiryDate(LocalDateTime.now().plusHours(1))
|
||||
.build();
|
||||
repository.save(token);
|
||||
|
||||
repository.deleteByUserId(userId);
|
||||
|
||||
assertTrue(repository.findByToken("delete-me").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByUsedFalseAndExpiryDateBeforeReturnsExpiredTokens() {
|
||||
VerificationToken expired = VerificationToken.builder()
|
||||
.userId(UUID.randomUUID())
|
||||
.token("expired-token")
|
||||
.expiryDate(LocalDateTime.now().minusHours(1))
|
||||
.used(false)
|
||||
.build();
|
||||
VerificationToken valid = VerificationToken.builder()
|
||||
.userId(UUID.randomUUID())
|
||||
.token("valid-token")
|
||||
.expiryDate(LocalDateTime.now().plusHours(1))
|
||||
.used(false)
|
||||
.build();
|
||||
repository.save(expired);
|
||||
repository.save(valid);
|
||||
|
||||
var expiredTokens = repository.findByUsedFalseAndExpiryDateBefore(LocalDateTime.now());
|
||||
|
||||
assertEquals(1, expiredTokens.size());
|
||||
assertEquals("expired-token", expiredTokens.get(0).getToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
void savedEntityHasIdAndCreatedAt() {
|
||||
VerificationToken token = VerificationToken.builder()
|
||||
.userId(UUID.randomUUID())
|
||||
.token("new-token")
|
||||
.expiryDate(LocalDateTime.now().plusHours(1))
|
||||
.build();
|
||||
VerificationToken saved = repository.save(token);
|
||||
|
||||
assertNotNull(saved.getId());
|
||||
assertNotNull(saved.getCreatedAt());
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.krrishg.service;
|
||||
|
||||
import com.krrishg.dto.Reference;
|
||||
import com.krrishg.dto.SiteStructure;
|
||||
import com.krrishg.dto.SuggestSectionsResponse;
|
||||
import com.krrishg.model.GenerationVersion;
|
||||
import com.krrishg.model.User;
|
||||
import com.krrishg.support.FakeEncryptionService;
|
||||
@@ -83,6 +84,7 @@ class LLMServiceTest {
|
||||
mongoTemplate, userRepository, encryptionService,
|
||||
resourceLoader, "classpath:prompts/site-generator.txt",
|
||||
"classpath:prompts/site-refiner.txt",
|
||||
"classpath:prompts/suggest-sections.txt",
|
||||
10, 0.3);
|
||||
}
|
||||
|
||||
@@ -493,4 +495,57 @@ class LLMServiceTest {
|
||||
assertThrows(IllegalArgumentException.class, () -> llmService.deleteVersion("nonexistent"));
|
||||
verify(mongoTemplate, never()).remove(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void suggestSectionsReturnsParsedSuggestions() {
|
||||
geminiProvider.withContent("""
|
||||
[
|
||||
{"title": "Home", "description": "Hero section", "slug": "home",
|
||||
"patterns": [
|
||||
{"title": "Full-screen hero", "description": "Large image with text overlay"},
|
||||
{"title": "Split layout", "description": "Text left, image right"}
|
||||
]},
|
||||
{"title": "About", "description": "Bio section", "slug": "about",
|
||||
"patterns": [
|
||||
{"title": "Timeline", "description": "Vertical story timeline"}
|
||||
]},
|
||||
{"title": "Gallery", "description": "Portfolio grid", "slug": "gallery", "patterns": []}
|
||||
]
|
||||
""");
|
||||
|
||||
SuggestSectionsResponse response = llmService.suggestSections(
|
||||
userId, "photography portfolio", "gemini", null);
|
||||
|
||||
assertEquals(3, response.sections().size());
|
||||
assertEquals("Home", response.sections().get(0).title());
|
||||
assertEquals("gallery", response.sections().get(2).slug());
|
||||
|
||||
assertEquals(2, response.sections().get(0).patterns().size());
|
||||
assertEquals("Full-screen hero", response.sections().get(0).patterns().get(0).title());
|
||||
assertEquals("Timeline", response.sections().get(1).patterns().get(0).title());
|
||||
assertEquals(0, response.sections().get(2).patterns().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void suggestSectionsThrowsWhenEmpty() {
|
||||
geminiProvider.withContent("[]");
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> llmService.suggestSections(userId, "nothing", "gemini", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void suggestSectionsUsesProvidedApiKey() {
|
||||
geminiProvider.withContent("""
|
||||
[
|
||||
{"title": "Home", "description": "Hero", "slug": "home", "patterns": []}
|
||||
]
|
||||
""");
|
||||
|
||||
SuggestSectionsResponse response = llmService.suggestSections(
|
||||
userId, "site", "gemini", "override-key");
|
||||
|
||||
assertEquals(1, response.sections().size());
|
||||
assertEquals("Home", response.sections().get(0).title());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user