update site generation flow

This commit is contained in:
Krrish Ghimire
2026-07-12 00:10:15 +05:45
parent c362219a40
commit ec4d030c0a
15 changed files with 1285 additions and 22 deletions

View File

@@ -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();
}
}

View File

@@ -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,

View File

@@ -0,0 +1,9 @@
package com.krrishg.dto;
public record SuggestSectionsRequest(
String briefDescription,
String provider,
String apiKey,
String baseUrl,
String model
) {}

View File

@@ -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
) {}
}

View File

@@ -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;
}
}

View File

@@ -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"));

View File

@@ -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));
}
}

View File

@@ -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));
}
}
}

View File

@@ -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());
}
}

View File

@@ -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());
}
}

View File

@@ -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());
}
}