add ssl certificates and tests
This commit is contained in:
@@ -26,5 +26,19 @@ public class SchemaMigration {
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not drop encrypted_ssh_key column: {}", e.getMessage());
|
||||
}
|
||||
try {
|
||||
jdbcTemplate.execute("ALTER TABLE self_hosted_configs ADD COLUMN IF NOT EXISTS ssl_enabled BOOLEAN DEFAULT FALSE");
|
||||
jdbcTemplate.execute("UPDATE self_hosted_configs SET ssl_enabled = FALSE WHERE ssl_enabled IS NULL");
|
||||
jdbcTemplate.execute("ALTER TABLE self_hosted_configs ALTER COLUMN ssl_enabled SET NOT NULL");
|
||||
log.info("Added column ssl_enabled to self_hosted_configs");
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not add ssl_enabled column: {}", e.getMessage());
|
||||
}
|
||||
try {
|
||||
jdbcTemplate.execute("ALTER TABLE self_hosted_configs ADD COLUMN IF NOT EXISTS ssl_email VARCHAR(255)");
|
||||
log.info("Added column ssl_email to self_hosted_configs");
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not add ssl_email column: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,8 +59,10 @@ public class SelfHostedController {
|
||||
@Valid @RequestBody SelfHostedConfigRequest request) {
|
||||
Site site = findSiteForUser(id, principal.id());
|
||||
|
||||
selfHostedConfigRepository.findBySiteId(id).ifPresent(existing ->
|
||||
selfHostedConfigRepository.delete(existing));
|
||||
selfHostedConfigRepository.findBySiteId(id).ifPresent(existing -> {
|
||||
selfHostedConfigRepository.delete(existing);
|
||||
selfHostedConfigRepository.flush();
|
||||
});
|
||||
|
||||
String deployPath = request.getDeployPath() != null && !request.getDeployPath().isBlank()
|
||||
? request.getDeployPath()
|
||||
@@ -78,6 +80,8 @@ public class SelfHostedController {
|
||||
.sshUser(request.getSshUser())
|
||||
.deployPath(deployPath)
|
||||
.nginxSitesPath(nginxSitesPath)
|
||||
.sslEnabled(request.isSslEnabled())
|
||||
.sslEmail(request.getSslEmail())
|
||||
.build();
|
||||
|
||||
config = selfHostedConfigRepository.save(config);
|
||||
|
||||
@@ -20,4 +20,8 @@ public class SelfHostedConfigRequest {
|
||||
private String deployPath;
|
||||
|
||||
private String nginxSitesPath;
|
||||
|
||||
private boolean sslEnabled;
|
||||
|
||||
private String sslEmail;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ public class SelfHostedConfigResponse {
|
||||
private String sshUser;
|
||||
private String deployPath;
|
||||
private String nginxSitesPath;
|
||||
private boolean sslEnabled;
|
||||
private String sslEmail;
|
||||
private String publicKey;
|
||||
private LocalDateTime deployedAt;
|
||||
private LocalDateTime createdAt;
|
||||
@@ -36,6 +38,8 @@ public class SelfHostedConfigResponse {
|
||||
.sshUser(config.getSshUser())
|
||||
.deployPath(config.getDeployPath())
|
||||
.nginxSitesPath(config.getNginxSitesPath())
|
||||
.sslEnabled(config.isSslEnabled())
|
||||
.sslEmail(config.getSslEmail())
|
||||
.publicKey(publicKey)
|
||||
.deployedAt(config.getDeployedAt())
|
||||
.createdAt(config.getCreatedAt())
|
||||
|
||||
@@ -18,29 +18,30 @@ import java.util.UUID;
|
||||
public class GitRemote {
|
||||
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "site_id", nullable = false)
|
||||
private UUID siteId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
@Column(name = "provider", nullable = false)
|
||||
private GitProvider provider;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "remote_url", nullable = false)
|
||||
private String remoteUrl;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "encrypted_token", nullable = false)
|
||||
private String encryptedToken;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "default_branch", nullable = false)
|
||||
@Builder.Default
|
||||
private String defaultBranch = "main";
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
|
||||
@@ -15,38 +15,47 @@ import java.util.UUID;
|
||||
public class SelfHostedConfig {
|
||||
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
@Column(name = "site_id", nullable = false, unique = true)
|
||||
private UUID siteId;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "domain", nullable = false)
|
||||
private String domain;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "ssh_host", nullable = false)
|
||||
private String sshHost;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "ssh_port", nullable = false)
|
||||
@Builder.Default
|
||||
private int sshPort = 22;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "ssh_user", nullable = false)
|
||||
private String sshUser;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "deploy_path", nullable = false)
|
||||
@Builder.Default
|
||||
private String deployPath = "/var/www/{domain}/public";
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "nginx_sites_path", nullable = false)
|
||||
@Builder.Default
|
||||
private String nginxSitesPath = "/etc/nginx/sites-available";
|
||||
|
||||
@Column(name = "ssl_enabled")
|
||||
@Builder.Default
|
||||
private boolean sslEnabled = false;
|
||||
|
||||
@Column(name = "ssl_email")
|
||||
private String sslEmail;
|
||||
|
||||
@Column(name = "deployed_at")
|
||||
private LocalDateTime deployedAt;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
|
||||
@@ -18,43 +18,47 @@ import java.util.UUID;
|
||||
public class Site {
|
||||
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private UUID userId;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "name", nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(length = 1000)
|
||||
@Column(name = "description", length = 1000)
|
||||
private String description;
|
||||
|
||||
@Column(unique = true)
|
||||
@Column(name = "subdomain", unique = true)
|
||||
private String subdomain;
|
||||
|
||||
@Column(name = "published_url")
|
||||
private String publishedUrl;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
@Column(name = "status", nullable = false)
|
||||
@Builder.Default
|
||||
private SiteStatus status = SiteStatus.DRAFT;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "deployment_target")
|
||||
@Builder.Default
|
||||
private DeploymentTarget deploymentTarget = DeploymentTarget.NONE;
|
||||
|
||||
@Column(name = "published_at")
|
||||
private LocalDateTime publishedAt;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public DeploymentTarget getDeploymentTarget() {
|
||||
return deploymentTarget != null ? deploymentTarget : DeploymentTarget.NONE;
|
||||
}
|
||||
|
||||
private LocalDateTime publishedAt;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public enum DeploymentTarget {
|
||||
GIT_PAGES, SELF_HOSTED, NONE
|
||||
}
|
||||
|
||||
@@ -18,31 +18,36 @@ import java.util.UUID;
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
@Column(name = "email", nullable = false, unique = true)
|
||||
private String email;
|
||||
|
||||
@Column(name = "password")
|
||||
private String password;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "name", nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(name = "avatar_url")
|
||||
private String avatarUrl;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
@Column(name = "provider", nullable = false)
|
||||
private AuthProvider provider;
|
||||
|
||||
@Column(name = "provider_id")
|
||||
private String providerId;
|
||||
|
||||
@Column(name = "email_verified")
|
||||
@Builder.Default
|
||||
private boolean emailVerified = false;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
|
||||
@@ -38,7 +38,11 @@ public class SshRsyncDeployer implements Deployer {
|
||||
|
||||
rsync(tempDir.resolve("site"), keyFile, config, resolvedDeployPath);
|
||||
|
||||
String nginxConfig = buildNginxConfig(config.getDomain(), resolvedDeployPath);
|
||||
if (config.isSslEnabled()) {
|
||||
provisionSsl(keyFile, config, resolvedDeployPath);
|
||||
}
|
||||
|
||||
String nginxConfig = buildNginxConfig(config.getDomain(), resolvedDeployPath, config.isSslEnabled());
|
||||
Path nginxConfPath = tempDir.resolve(config.getDomain());
|
||||
Files.writeString(nginxConfPath, nginxConfig);
|
||||
|
||||
@@ -73,6 +77,10 @@ public class SshRsyncDeployer implements Deployer {
|
||||
+ " /etc/nginx/sites-enabled/" + config.getDomain()
|
||||
+ " && sudo nginx -s reload");
|
||||
|
||||
if (config.isSslEnabled()) {
|
||||
removeSsl(keyFile, config);
|
||||
}
|
||||
|
||||
log.info("Removed deployment for site {} ({})", config.getSiteId(), config.getDomain());
|
||||
} finally {
|
||||
deleteDirectory(tempDir);
|
||||
@@ -81,7 +89,8 @@ public class SshRsyncDeployer implements Deployer {
|
||||
|
||||
@Override
|
||||
public String buildUrl(SelfHostedConfig config) {
|
||||
return "https://" + config.getDomain() + "/";
|
||||
String scheme = config.isSslEnabled() ? "https" : "http";
|
||||
return scheme + "://" + config.getDomain() + "/";
|
||||
}
|
||||
|
||||
private void writeSiteFiles(Path tempDir, SiteOutput output) throws IOException {
|
||||
@@ -122,7 +131,25 @@ public class SshRsyncDeployer implements Deployer {
|
||||
execute(rsyncCmd);
|
||||
}
|
||||
|
||||
private String buildNginxConfig(String domain, String rootPath) {
|
||||
private String buildNginxConfig(String domain, String rootPath, boolean sslEnabled) {
|
||||
if (sslEnabled) {
|
||||
return "server {\n"
|
||||
+ " listen 443 ssl http2;\n"
|
||||
+ " server_name " + domain + ";\n"
|
||||
+ " root " + rootPath + ";\n"
|
||||
+ " index index.html;\n"
|
||||
+ " ssl_certificate /etc/letsencrypt/live/" + domain + "/fullchain.pem;\n"
|
||||
+ " ssl_certificate_key /etc/letsencrypt/live/" + domain + "/privkey.pem;\n"
|
||||
+ " location / {\n"
|
||||
+ " try_files $uri $uri/ /index.html;\n"
|
||||
+ " }\n"
|
||||
+ "}\n"
|
||||
+ "server {\n"
|
||||
+ " listen 80;\n"
|
||||
+ " server_name " + domain + ";\n"
|
||||
+ " return 301 https://$host$request_uri;\n"
|
||||
+ "}\n";
|
||||
}
|
||||
return "server {\n"
|
||||
+ " listen 80;\n"
|
||||
+ " server_name " + domain + ";\n"
|
||||
@@ -134,6 +161,61 @@ public class SshRsyncDeployer implements Deployer {
|
||||
+ "}\n";
|
||||
}
|
||||
|
||||
private void provisionSsl(String keyFile, SelfHostedConfig config, String deployPath) throws Exception {
|
||||
String domain = config.getDomain();
|
||||
String email = config.getSslEmail();
|
||||
if (email == null || email.isBlank()) {
|
||||
throw new RuntimeException("SSL email is required when SSL is enabled");
|
||||
}
|
||||
|
||||
String checkCerts = "sudo test -d /etc/letsencrypt/live/" + domain + " && echo EXISTS || echo MISSING";
|
||||
String certStatus = sshWithOutput(keyFile, config, checkCerts);
|
||||
|
||||
if (certStatus.contains("EXISTS")) {
|
||||
log.info("SSL certificates already exist for {}", domain);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Provisioning SSL certificate for {} via Let's Encrypt", domain);
|
||||
|
||||
String certbotCmd = String.format(
|
||||
"sudo certbot certonly --webroot -w %s -d %s --non-interactive --agree-tos -m %s",
|
||||
deployPath, domain, email
|
||||
);
|
||||
ssh(keyFile, config, certbotCmd);
|
||||
log.info("SSL certificate obtained for {}", domain);
|
||||
}
|
||||
|
||||
private void removeSsl(String keyFile, SelfHostedConfig config) throws Exception {
|
||||
String domain = config.getDomain();
|
||||
|
||||
String checkCerts = "sudo test -d /etc/letsencrypt/live/" + domain + " && echo EXISTS || echo MISSING";
|
||||
String certStatus = sshWithOutput(keyFile, config, checkCerts);
|
||||
|
||||
if (!certStatus.contains("EXISTS")) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Removing SSL certificates for {}", domain);
|
||||
ssh(keyFile, config, "sudo certbot delete --non-interactive -d " + domain);
|
||||
}
|
||||
|
||||
private String sshWithOutput(String keyFile, SelfHostedConfig config, String command) throws Exception {
|
||||
String sshCmd = String.format(
|
||||
"ssh -i %s -p %d -o StrictHostKeyChecking=accept-new %s@%s \"%s\"",
|
||||
keyFile, config.getSshPort(), config.getSshUser(), config.getSshHost(), command
|
||||
);
|
||||
ProcessBuilder pb = new ProcessBuilder("sh", "-c", sshCmd);
|
||||
pb.redirectErrorStream(true);
|
||||
Process process = pb.start();
|
||||
String output = new String(process.getInputStream().readAllBytes());
|
||||
int exitCode = process.waitFor();
|
||||
if (exitCode != 0) {
|
||||
throw new RuntimeException("Command failed (exit " + exitCode + "): " + sshCmd + "\n" + output);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
private void execute(String command) throws Exception {
|
||||
ProcessBuilder pb = new ProcessBuilder("sh", "-c", command);
|
||||
pb.redirectErrorStream(true);
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
package com.krrishg.controller;
|
||||
|
||||
import com.krrishg.config.GlobalExceptionHandler;
|
||||
import com.krrishg.config.JwtAuthenticationToken;
|
||||
import com.krrishg.config.UserPrincipal;
|
||||
import com.krrishg.dto.SelfHostedConfigRequest;
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
import com.krrishg.model.Site;
|
||||
import com.krrishg.repository.SelfHostedConfigRepository;
|
||||
import com.krrishg.repository.SiteRepository;
|
||||
import com.krrishg.service.Deployer;
|
||||
import com.krrishg.service.KeyManager;
|
||||
import com.krrishg.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.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
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(SelfHostedController.class)
|
||||
@Import({GlobalExceptionHandler.class, TestSecurityConfig.class})
|
||||
class SelfHostedControllerTest {
|
||||
|
||||
private final MockMvc mockMvc;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@MockBean
|
||||
private SiteRepository siteRepository;
|
||||
|
||||
@MockBean
|
||||
private SelfHostedConfigRepository selfHostedConfigRepository;
|
||||
|
||||
@MockBean
|
||||
private Deployer deployer;
|
||||
|
||||
@MockBean
|
||||
private KeyManager keyManager;
|
||||
|
||||
private UUID userId;
|
||||
private UUID siteId;
|
||||
private UserPrincipal principal;
|
||||
private JwtAuthenticationToken auth;
|
||||
|
||||
SelfHostedControllerTest(@Autowired MockMvc mockMvc, @Autowired ObjectMapper objectMapper) {
|
||||
this.mockMvc = mockMvc;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
siteId = UUID.randomUUID();
|
||||
userId = UUID.randomUUID();
|
||||
principal = new UserPrincipal(userId, "user@test.com", "Test User");
|
||||
auth = new JwtAuthenticationToken(principal, "test-token");
|
||||
}
|
||||
|
||||
void givenSaveReturnsInput() {
|
||||
when(selfHostedConfigRepository.save(any())).thenAnswer(invocation -> {
|
||||
SelfHostedConfig input = invocation.getArgument(0);
|
||||
if (input.getId() == null) {
|
||||
input.setId(UUID.randomUUID());
|
||||
}
|
||||
if (input.getCreatedAt() == null) {
|
||||
input.setCreatedAt(LocalDateTime.now());
|
||||
input.setUpdatedAt(LocalDateTime.now());
|
||||
}
|
||||
return input;
|
||||
});
|
||||
}
|
||||
|
||||
private Site givenSiteOwnedByUser() {
|
||||
Site site = Site.builder()
|
||||
.id(siteId)
|
||||
.userId(userId)
|
||||
.name("Test Site")
|
||||
.build();
|
||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
|
||||
return site;
|
||||
}
|
||||
|
||||
@Test
|
||||
void getConfigReturnsConfigWithPublicKey() throws Exception {
|
||||
givenSiteOwnedByUser();
|
||||
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.siteId(siteId)
|
||||
.domain("example.com")
|
||||
.sshHost("192.168.1.1")
|
||||
.sshPort(22)
|
||||
.sshUser("deploy")
|
||||
.deployPath("/var/www/example/public")
|
||||
.nginxSitesPath("/etc/nginx/sites-available")
|
||||
.sslEnabled(true)
|
||||
.sslEmail("admin@example.com")
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
when(selfHostedConfigRepository.findBySiteId(siteId)).thenReturn(Optional.of(config));
|
||||
when(keyManager.getPublicKey(siteId.toString())).thenReturn("ssh-ed25519 AAA...");
|
||||
|
||||
mockMvc.perform(get("/api/sites/{id}/self-hosted", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.domain").value("example.com"))
|
||||
.andExpect(jsonPath("$.sshHost").value("192.168.1.1"))
|
||||
.andExpect(jsonPath("$.publicKey").value("ssh-ed25519 AAA..."))
|
||||
.andExpect(jsonPath("$.sslEnabled").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getConfigReturnsNotConfiguredWhenNoConfig() throws Exception {
|
||||
givenSiteOwnedByUser();
|
||||
when(selfHostedConfigRepository.findBySiteId(siteId)).thenReturn(Optional.empty());
|
||||
|
||||
mockMvc.perform(get("/api/sites/{id}/self-hosted", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.configured").value(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void putSaveConfigCreatesWithDefaults() throws Exception {
|
||||
givenSiteOwnedByUser();
|
||||
givenSaveReturnsInput();
|
||||
when(selfHostedConfigRepository.findBySiteId(siteId)).thenReturn(Optional.empty());
|
||||
when(keyManager.getPublicKey(siteId.toString())).thenReturn("ssh-ed25519 BBB...");
|
||||
|
||||
SelfHostedConfigRequest request = new SelfHostedConfigRequest();
|
||||
request.setDomain("mysite.com");
|
||||
request.setSshHost("10.0.0.1");
|
||||
request.setSshPort(2222);
|
||||
request.setSshUser("admin");
|
||||
request.setSslEnabled(true);
|
||||
request.setSslEmail("ssl@mysite.com");
|
||||
|
||||
mockMvc.perform(put("/api/sites/{id}/self-hosted", siteId)
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.domain").value("mysite.com"))
|
||||
.andExpect(jsonPath("$.publicKey").value("ssh-ed25519 BBB..."));
|
||||
|
||||
verify(selfHostedConfigRepository).save(any(SelfHostedConfig.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void putSaveConfigUsesDefaultPathsWhenNotProvided() throws Exception {
|
||||
givenSiteOwnedByUser();
|
||||
givenSaveReturnsInput();
|
||||
when(selfHostedConfigRepository.findBySiteId(siteId)).thenReturn(Optional.empty());
|
||||
when(keyManager.getPublicKey(siteId.toString())).thenReturn("key");
|
||||
|
||||
SelfHostedConfigRequest request = new SelfHostedConfigRequest();
|
||||
request.setDomain("site.com");
|
||||
request.setSshHost("host");
|
||||
request.setSshPort(22);
|
||||
request.setSshUser("user");
|
||||
|
||||
mockMvc.perform(put("/api/sites/{id}/self-hosted", siteId)
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(selfHostedConfigRepository).save(argThat(config ->
|
||||
"/var/www/{domain}/public".equals(config.getDeployPath()) &&
|
||||
"/etc/nginx/sites-available".equals(config.getNginxSitesPath())
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void putReturns400OnValidationError() throws Exception {
|
||||
SelfHostedConfigRequest request = new SelfHostedConfigRequest();
|
||||
|
||||
mockMvc.perform(put("/api/sites/{id}/self-hosted", siteId)
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteRemovesConfigAndCallsDeployer() throws Exception {
|
||||
givenSiteOwnedByUser();
|
||||
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.siteId(siteId)
|
||||
.domain("example.com")
|
||||
.sshHost("host")
|
||||
.sshPort(22)
|
||||
.sshUser("user")
|
||||
.build();
|
||||
when(selfHostedConfigRepository.findBySiteId(siteId)).thenReturn(Optional.of(config));
|
||||
|
||||
mockMvc.perform(delete("/api/sites/{id}/self-hosted", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.message").value("Self-hosted configuration removed"));
|
||||
|
||||
verify(deployer).remove(config);
|
||||
verify(selfHostedConfigRepository).delete(config);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteSwallowsDeployerException() throws Exception {
|
||||
givenSiteOwnedByUser();
|
||||
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.siteId(siteId)
|
||||
.domain("example.com")
|
||||
.sshHost("host")
|
||||
.sshPort(22)
|
||||
.sshUser("user")
|
||||
.build();
|
||||
when(selfHostedConfigRepository.findBySiteId(siteId)).thenReturn(Optional.of(config));
|
||||
doThrow(new RuntimeException("SSH error")).when(deployer).remove(config);
|
||||
|
||||
mockMvc.perform(delete("/api/sites/{id}/self-hosted", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(selfHostedConfigRepository).delete(config);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteHandlesNoConfig() throws Exception {
|
||||
givenSiteOwnedByUser();
|
||||
when(selfHostedConfigRepository.findBySiteId(siteId)).thenReturn(Optional.empty());
|
||||
|
||||
mockMvc.perform(delete("/api/sites/{id}/self-hosted", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(deployer, never()).remove(any());
|
||||
verify(selfHostedConfigRepository, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getConfigReturns400WhenSiteNotFound() throws Exception {
|
||||
when(siteRepository.findById(siteId)).thenReturn(Optional.empty());
|
||||
|
||||
mockMvc.perform(get("/api/sites/{id}/self-hosted", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getConfigReturns400ForWrongOwner() throws Exception {
|
||||
Site site = Site.builder()
|
||||
.id(siteId)
|
||||
.userId(UUID.randomUUID())
|
||||
.name("Test Site")
|
||||
.build();
|
||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
|
||||
|
||||
mockMvc.perform(get("/api/sites/{id}/self-hosted", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.krrishg.repository;
|
||||
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@DataJpaTest
|
||||
class SelfHostedConfigRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private SelfHostedConfigRepository repository;
|
||||
|
||||
@Test
|
||||
void saveAndFindBySiteId() {
|
||||
UUID siteId = UUID.randomUUID();
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.siteId(siteId)
|
||||
.domain("example.com")
|
||||
.sshHost("192.168.1.1")
|
||||
.sshPort(22)
|
||||
.sshUser("deploy")
|
||||
.build();
|
||||
repository.save(config);
|
||||
|
||||
Optional<SelfHostedConfig> found = repository.findBySiteId(siteId);
|
||||
|
||||
assertTrue(found.isPresent());
|
||||
assertEquals("example.com", found.get().getDomain());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findBySiteIdReturnsEmptyWhenNotExists() {
|
||||
Optional<SelfHostedConfig> found = repository.findBySiteId(UUID.randomUUID());
|
||||
assertTrue(found.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByDomainReturnsConfig() {
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.siteId(UUID.randomUUID())
|
||||
.domain("mysite.com")
|
||||
.sshHost("10.0.0.1")
|
||||
.sshPort(2222)
|
||||
.sshUser("admin")
|
||||
.build();
|
||||
repository.save(config);
|
||||
|
||||
Optional<SelfHostedConfig> found = repository.findByDomain("mysite.com");
|
||||
|
||||
assertTrue(found.isPresent());
|
||||
assertEquals("mysite.com", found.get().getDomain());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByDomainReturnsEmptyWhenNotExists() {
|
||||
Optional<SelfHostedConfig> found = repository.findByDomain("nonexistent.com");
|
||||
assertTrue(found.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsBySiteIdReturnsTrueWhenExists() {
|
||||
UUID siteId = UUID.randomUUID();
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.siteId(siteId)
|
||||
.domain("exists.com")
|
||||
.sshHost("host")
|
||||
.sshPort(22)
|
||||
.sshUser("user")
|
||||
.build();
|
||||
repository.save(config);
|
||||
|
||||
assertTrue(repository.existsBySiteId(siteId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsBySiteIdReturnsFalseWhenNotExists() {
|
||||
assertFalse(repository.existsBySiteId(UUID.randomUUID()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsByDomainReturnsTrueWhenExists() {
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.siteId(UUID.randomUUID())
|
||||
.domain("domain-check.com")
|
||||
.sshHost("host")
|
||||
.sshPort(22)
|
||||
.sshUser("user")
|
||||
.build();
|
||||
repository.save(config);
|
||||
|
||||
assertTrue(repository.existsByDomain("domain-check.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsByDomainReturnsFalseWhenNotExists() {
|
||||
assertFalse(repository.existsByDomain("no-domain.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void savedEntityHasIdAndTimestamps() {
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.siteId(UUID.randomUUID())
|
||||
.domain("timestamps.com")
|
||||
.sshHost("host")
|
||||
.sshPort(22)
|
||||
.sshUser("user")
|
||||
.build();
|
||||
SelfHostedConfig saved = repository.save(config);
|
||||
|
||||
assertNotNull(saved.getId());
|
||||
assertNotNull(saved.getCreatedAt());
|
||||
assertNotNull(saved.getUpdatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void siteIdIsUniqueByConstraint() {
|
||||
UUID siteId = UUID.randomUUID();
|
||||
|
||||
SelfHostedConfig config1 = SelfHostedConfig.builder()
|
||||
.siteId(siteId)
|
||||
.domain("first.com")
|
||||
.sshHost("host1")
|
||||
.sshPort(22)
|
||||
.sshUser("user1")
|
||||
.build();
|
||||
repository.saveAndFlush(config1);
|
||||
|
||||
SelfHostedConfig config2 = SelfHostedConfig.builder()
|
||||
.siteId(siteId)
|
||||
.domain("second.com")
|
||||
.sshHost("host2")
|
||||
.sshPort(22)
|
||||
.sshUser("user2")
|
||||
.build();
|
||||
|
||||
assertThrows(Exception.class, () -> repository.saveAndFlush(config2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.dto.SiteStructure;
|
||||
import com.krrishg.model.*;
|
||||
import com.krrishg.model.Site.DeploymentTarget;
|
||||
import com.krrishg.service.RenderService.SiteOutput;
|
||||
import com.krrishg.support.FakeGitRemoteRepository;
|
||||
import com.krrishg.support.FakeSelfHostedConfigRepository;
|
||||
import com.krrishg.support.FakeSiteRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DeploymentServiceTest {
|
||||
|
||||
private FakeSiteRepository siteRepository;
|
||||
private FakeGitRemoteRepository gitRemoteRepository;
|
||||
private FakeSelfHostedConfigRepository selfHostedConfigRepository;
|
||||
|
||||
@Mock
|
||||
private RenderService renderService;
|
||||
|
||||
@Mock
|
||||
private GitService gitService;
|
||||
|
||||
@Mock
|
||||
private LLMService llmService;
|
||||
|
||||
@Mock
|
||||
private Deployer deployer;
|
||||
|
||||
private DeploymentService deploymentService;
|
||||
|
||||
private UUID siteId;
|
||||
private UUID userId;
|
||||
private Site site;
|
||||
private SiteStructure structure;
|
||||
private SiteOutput siteOutput;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
siteRepository = new FakeSiteRepository();
|
||||
gitRemoteRepository = new FakeGitRemoteRepository();
|
||||
selfHostedConfigRepository = new FakeSelfHostedConfigRepository();
|
||||
|
||||
deploymentService = new DeploymentService(renderService, gitService,
|
||||
siteRepository, llmService, gitRemoteRepository,
|
||||
selfHostedConfigRepository, deployer);
|
||||
|
||||
siteId = UUID.randomUUID();
|
||||
userId = UUID.randomUUID();
|
||||
|
||||
site = Site.builder()
|
||||
.userId(userId)
|
||||
.name("Test Site")
|
||||
.build();
|
||||
site.setId(siteId);
|
||||
site.setCreatedAt(LocalDateTime.now());
|
||||
site.setUpdatedAt(LocalDateTime.now());
|
||||
|
||||
structure = SiteStructure.builder()
|
||||
.pages(List.of(
|
||||
SiteStructure.PageStructure.builder()
|
||||
.title("Home").slug("home").rawHtml("<h1>Hello</h1>")
|
||||
.build()
|
||||
))
|
||||
.build();
|
||||
|
||||
siteOutput = new SiteOutput("css", "js", new LinkedHashMap<>());
|
||||
}
|
||||
|
||||
private void givenVersionExists() {
|
||||
GenerationVersion version = GenerationVersion.builder()
|
||||
.id("v1").siteId(siteId).versionNumber(1).fullStructure("{}").build();
|
||||
when(llmService.getVersions(siteId)).thenReturn(List.of(version));
|
||||
when(llmService.restoreVersion("v1")).thenReturn(structure);
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishWithGitPagesTargetDelegatesToGitService() {
|
||||
site.setDeploymentTarget(DeploymentTarget.GIT_PAGES);
|
||||
siteRepository.save(site);
|
||||
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.siteId(siteId)
|
||||
.provider(GitRemote.GitProvider.GITHUB)
|
||||
.remoteUrl("https://github.com/user/repo.git")
|
||||
.encryptedToken("token")
|
||||
.build();
|
||||
gitRemoteRepository.save(remote);
|
||||
|
||||
givenVersionExists();
|
||||
when(renderService.renderSite(structure)).thenReturn(siteOutput);
|
||||
when(gitService.deployToPages(eq(siteId.toString()), any(GitRemote.class), eq(structure)))
|
||||
.thenReturn("https://user.github.io/repo/");
|
||||
|
||||
String url = deploymentService.publish(siteId);
|
||||
|
||||
assertEquals("https://user.github.io/repo/", url);
|
||||
verify(gitService).deployToPages(siteId.toString(), remote, structure);
|
||||
Site updated = siteRepository.findById(siteId).orElseThrow();
|
||||
assertEquals(SiteStatus.PUBLISHED, updated.getStatus());
|
||||
assertNotNull(updated.getPublishedAt());
|
||||
assertEquals("https://user.github.io/repo/", updated.getPublishedUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishWithSelfHostedTargetDelegatesToDeployer() throws Exception {
|
||||
site.setDeploymentTarget(DeploymentTarget.SELF_HOSTED);
|
||||
siteRepository.save(site);
|
||||
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.siteId(siteId)
|
||||
.domain("example.com")
|
||||
.sshHost("host")
|
||||
.sshPort(22)
|
||||
.sshUser("user")
|
||||
.build();
|
||||
selfHostedConfigRepository.save(config);
|
||||
|
||||
givenVersionExists();
|
||||
when(renderService.renderSite(structure)).thenReturn(siteOutput);
|
||||
when(deployer.deploy(any(SiteOutput.class), any(SelfHostedConfig.class)))
|
||||
.thenReturn("http://example.com/");
|
||||
when(deployer.buildUrl(any(SelfHostedConfig.class))).thenReturn("http://example.com/");
|
||||
|
||||
String url = deploymentService.publish(siteId);
|
||||
|
||||
assertEquals("http://example.com/", url);
|
||||
verify(deployer).deploy(siteOutput, config);
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishWithNoneTargetReturnsNullUrl() throws Exception {
|
||||
site.setDeploymentTarget(DeploymentTarget.NONE);
|
||||
siteRepository.save(site);
|
||||
|
||||
givenVersionExists();
|
||||
when(renderService.renderSite(structure)).thenReturn(siteOutput);
|
||||
|
||||
String url = deploymentService.publish(siteId);
|
||||
|
||||
assertNull(url);
|
||||
verify(gitService, never()).deployToPages(any(), any(), any());
|
||||
verify(deployer, never()).deploy(any(), any());
|
||||
Site updated = siteRepository.findById(siteId).orElseThrow();
|
||||
assertEquals(SiteStatus.PUBLISHED, updated.getStatus());
|
||||
assertNull(updated.getPublishedUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishThrowsWhenSiteNotFound() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> deploymentService.publish(UUID.randomUUID()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishThrowsWhenNoVersions() {
|
||||
siteRepository.save(site);
|
||||
when(llmService.getVersions(siteId)).thenReturn(List.of());
|
||||
|
||||
IllegalStateException ex = assertThrows(IllegalStateException.class,
|
||||
() -> deploymentService.publish(siteId));
|
||||
assertTrue(ex.getMessage().contains("No generation versions"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishWithGitPagesTargetThrowsWhenRemoteMissing() {
|
||||
site.setDeploymentTarget(DeploymentTarget.GIT_PAGES);
|
||||
siteRepository.save(site);
|
||||
|
||||
givenVersionExists();
|
||||
when(renderService.renderSite(structure)).thenReturn(siteOutput);
|
||||
|
||||
IllegalStateException ex = assertThrows(IllegalStateException.class,
|
||||
() -> deploymentService.publish(siteId));
|
||||
assertTrue(ex.getMessage().contains("no git remote is configured"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishWithSelfHostedTargetThrowsWhenConfigMissing() {
|
||||
site.setDeploymentTarget(DeploymentTarget.SELF_HOSTED);
|
||||
siteRepository.save(site);
|
||||
|
||||
givenVersionExists();
|
||||
when(renderService.renderSite(structure)).thenReturn(siteOutput);
|
||||
|
||||
IllegalStateException ex = assertThrows(IllegalStateException.class,
|
||||
() -> deploymentService.publish(siteId));
|
||||
assertTrue(ex.getMessage().contains("no self-hosted config exists"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unpublishResetsSiteToDraft() {
|
||||
site.setStatus(SiteStatus.PUBLISHED);
|
||||
site.setPublishedUrl("https://example.com");
|
||||
site.setPublishedAt(LocalDateTime.now());
|
||||
siteRepository.save(site);
|
||||
|
||||
deploymentService.unpublish(siteId);
|
||||
|
||||
Site updated = siteRepository.findById(siteId).orElseThrow();
|
||||
assertEquals(SiteStatus.DRAFT, updated.getStatus());
|
||||
assertNull(updated.getPublishedAt());
|
||||
assertNull(updated.getPublishedUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unpublishWithRemoteRemovesPages() {
|
||||
site.setDeploymentTarget(DeploymentTarget.GIT_PAGES);
|
||||
siteRepository.save(site);
|
||||
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.siteId(siteId)
|
||||
.provider(GitRemote.GitProvider.GITHUB)
|
||||
.remoteUrl("https://github.com/user/repo.git")
|
||||
.encryptedToken("token")
|
||||
.build();
|
||||
gitRemoteRepository.save(remote);
|
||||
|
||||
deploymentService.unpublish(siteId);
|
||||
|
||||
verify(gitService).removePages(siteId.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unpublishWithSelfHostedConfigRemovesDeploy() throws Exception {
|
||||
siteRepository.save(site);
|
||||
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.siteId(siteId)
|
||||
.domain("example.com")
|
||||
.sshHost("host")
|
||||
.sshPort(22)
|
||||
.sshUser("user")
|
||||
.build();
|
||||
selfHostedConfigRepository.save(config);
|
||||
|
||||
deploymentService.unpublish(siteId);
|
||||
|
||||
verify(deployer).remove(config);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unpublishThrowsWhenSiteNotFound() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> deploymentService.unpublish(UUID.randomUUID()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unpublishSwallowsDeployerException() throws Exception {
|
||||
siteRepository.save(site);
|
||||
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.siteId(siteId)
|
||||
.domain("example.com")
|
||||
.sshHost("host")
|
||||
.sshPort(22)
|
||||
.sshUser("user")
|
||||
.build();
|
||||
selfHostedConfigRepository.save(config);
|
||||
|
||||
doThrow(new RuntimeException("SSH failed")).when(deployer).remove(config);
|
||||
|
||||
deploymentService.unpublish(siteId);
|
||||
|
||||
Site updated = siteRepository.findById(siteId).orElseThrow();
|
||||
assertEquals(SiteStatus.DRAFT, updated.getStatus());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.model.GitRemote;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class GitHubPagesProviderTest {
|
||||
|
||||
private final GitHubPagesProvider provider = new GitHubPagesProvider();
|
||||
|
||||
@Test
|
||||
void branchNameIsGhPages() {
|
||||
assertEquals("gh-pages", provider.branchName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentRootIsTempDirItself(@TempDir Path tempDir) {
|
||||
assertEquals(tempDir, provider.contentRoot(tempDir));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeProviderFilesCreatesNoJekyll(@TempDir Path tempDir) throws Exception {
|
||||
provider.writeProviderFiles(tempDir);
|
||||
|
||||
Path nojekyll = tempDir.resolve(".nojekyll");
|
||||
assertTrue(Files.exists(nojekyll));
|
||||
assertEquals("", Files.readString(nojekyll));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildPagesUrlWithStandardRemote() {
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.remoteUrl("https://github.com/user/my-repo.git")
|
||||
.build();
|
||||
|
||||
String url = provider.buildPagesUrl(remote);
|
||||
|
||||
assertEquals("https://user.github.io/my-repo/", url);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildPagesUrlWithoutGitSuffix() {
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.remoteUrl("https://github.com/org/project")
|
||||
.build();
|
||||
|
||||
String url = provider.buildPagesUrl(remote);
|
||||
|
||||
assertEquals("https://org.github.io/project/", url);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildPagesUrlWithTrailingSlash() {
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.remoteUrl("https://github.com/team/app/")
|
||||
.build();
|
||||
|
||||
String url = provider.buildPagesUrl(remote);
|
||||
|
||||
assertEquals("https://team.github.io/app/", url);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildPagesUrlReturnsOriginalWhenNotGithub() {
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.remoteUrl("https://example.com/user/repo.git")
|
||||
.build();
|
||||
|
||||
String url = provider.buildPagesUrl(remote);
|
||||
|
||||
assertNotNull(url);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildPagesUrlHandlesSshUrl() {
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.remoteUrl("git@github.com:user/project.git")
|
||||
.build();
|
||||
|
||||
String url = provider.buildPagesUrl(remote);
|
||||
|
||||
assertEquals("https://user.github.io/project/", url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.model.GitRemote;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class GitLabPagesProviderTest {
|
||||
|
||||
private final GitLabPagesProvider provider = new GitLabPagesProvider();
|
||||
|
||||
@Test
|
||||
void branchNameIsGlPages() {
|
||||
assertEquals("gl-pages", provider.branchName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentRootIsPublicDirectory(@TempDir Path tempDir) {
|
||||
assertEquals(tempDir.resolve("public"), provider.contentRoot(tempDir));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeProviderFilesCreatesGitLabCiYml(@TempDir Path tempDir) throws Exception {
|
||||
provider.writeProviderFiles(tempDir);
|
||||
|
||||
Path ciFile = tempDir.resolve(".gitlab-ci.yml");
|
||||
assertTrue(Files.exists(ciFile));
|
||||
String content = Files.readString(ciFile);
|
||||
assertTrue(content.contains("pages:"));
|
||||
assertTrue(content.contains("gl-pages"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildPagesUrlWithStandardRemote() {
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.remoteUrl("https://gitlab.com/mygroup/myproject.git")
|
||||
.build();
|
||||
|
||||
String url = provider.buildPagesUrl(remote);
|
||||
|
||||
assertEquals("https://mygroup.gitlab.io/myproject/", url);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildPagesUrlWithoutGitSuffix() {
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.remoteUrl("https://gitlab.com/team/app")
|
||||
.build();
|
||||
|
||||
String url = provider.buildPagesUrl(remote);
|
||||
|
||||
assertEquals("https://team.gitlab.io/app/", url);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildPagesUrlWithTrailingSlash() {
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.remoteUrl("https://gitlab.com/org/repo/")
|
||||
.build();
|
||||
|
||||
String url = provider.buildPagesUrl(remote);
|
||||
|
||||
assertEquals("https://org.gitlab.io/repo/", url);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildPagesUrlReturnsOriginalUrlWhenNotGitlab() {
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.remoteUrl("https://example.com/user/repo.git")
|
||||
.build();
|
||||
|
||||
String url = provider.buildPagesUrl(remote);
|
||||
|
||||
assertNotNull(url);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildPagesUrlHandlesSshUrl() {
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.remoteUrl("git@gitlab.com:group/project.git")
|
||||
.build();
|
||||
|
||||
String url = provider.buildPagesUrl(remote);
|
||||
|
||||
assertEquals("https://group.gitlab.io/project/", url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.config.GitConfig;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class KeyManagerTest {
|
||||
|
||||
private Path tempDir;
|
||||
private KeyManager keyManager;
|
||||
private String siteId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp(@TempDir Path tempDir) {
|
||||
this.tempDir = tempDir;
|
||||
siteId = "test-site";
|
||||
|
||||
GitConfig gitConfig = new GitConfig() {
|
||||
@Override
|
||||
public Path getReposPath() {
|
||||
return KeyManagerTest.this.tempDir;
|
||||
}
|
||||
};
|
||||
gitConfig.setAuthorName("Test");
|
||||
gitConfig.setAuthorEmail("test@test.com");
|
||||
|
||||
keyManager = new KeyManager(gitConfig);
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasKeyPairReturnsFalseWhenKeysDoNotExist() {
|
||||
assertFalse(keyManager.hasKeyPair(siteId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPublicKeyGeneratesKeysWhenMissing() {
|
||||
String publicKey = keyManager.getPublicKey(siteId);
|
||||
|
||||
assertNotNull(publicKey);
|
||||
assertTrue(publicKey.startsWith("ssh-ed25519"));
|
||||
assertTrue(keyManager.hasKeyPair(siteId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPublicKeyReturnsExistingKey() {
|
||||
keyManager.generateKeyPair(siteId);
|
||||
String firstCall = keyManager.getPublicKey(siteId);
|
||||
String secondCall = keyManager.getPublicKey(siteId);
|
||||
|
||||
assertEquals(firstCall, secondCall);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateKeyPairCreatesBothFiles() {
|
||||
keyManager.generateKeyPair(siteId);
|
||||
|
||||
Path sshDir = tempDir.resolve(siteId).resolve(".ssh");
|
||||
assertTrue(Files.exists(sshDir.resolve("id_ed25519")));
|
||||
assertTrue(Files.exists(sshDir.resolve("id_ed25519.pub")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteKeysRemovesKeyFilesAndDirectory() {
|
||||
keyManager.generateKeyPair(siteId);
|
||||
assertTrue(keyManager.hasKeyPair(siteId));
|
||||
|
||||
keyManager.deleteKeys(siteId);
|
||||
|
||||
assertFalse(keyManager.hasKeyPair(siteId));
|
||||
assertFalse(Files.exists(tempDir.resolve(siteId).resolve(".ssh")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteKeysDoesNotThrowWhenKeysDoNotExist() {
|
||||
assertDoesNotThrow(() -> keyManager.deleteKeys(siteId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPrivateKeyPathGeneratesOnDemand() {
|
||||
Path keyPath = keyManager.getPrivateKeyPath(siteId);
|
||||
|
||||
assertTrue(Files.exists(keyPath));
|
||||
assertTrue(keyPath.endsWith("id_ed25519"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSshDirReturnsCorrectPath() {
|
||||
Path sshDir = keyManager.getSshDir(siteId);
|
||||
assertEquals(tempDir.resolve(siteId).resolve(".ssh"), sshDir);
|
||||
}
|
||||
}
|
||||
149
backend/src/test/java/com/krrishg/service/MediaServiceTest.java
Normal file
149
backend/src/test/java/com/krrishg/service/MediaServiceTest.java
Normal file
@@ -0,0 +1,149 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.service.MediaService.MediaUploadResult;
|
||||
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.web.multipart.MultipartFile;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class MediaServiceTest {
|
||||
|
||||
@Mock
|
||||
private S3Client s3Client;
|
||||
|
||||
private MediaService mediaService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
mediaService = new MediaService(s3Client);
|
||||
|
||||
var bucketField = MediaService.class.getDeclaredField("bucket");
|
||||
bucketField.setAccessible(true);
|
||||
bucketField.set(mediaService, "test-bucket");
|
||||
|
||||
var urlField = MediaService.class.getDeclaredField("publicUrlBase");
|
||||
urlField.setAccessible(true);
|
||||
urlField.set(mediaService, "https://cdn.example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadStoresFileAndReturnsResult() throws IOException {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn(1024L);
|
||||
when(file.getContentType()).thenReturn("image/jpeg");
|
||||
when(file.getOriginalFilename()).thenReturn("photo.jpg");
|
||||
when(file.getBytes()).thenReturn("image-data".getBytes());
|
||||
|
||||
UUID siteId = UUID.randomUUID();
|
||||
MediaUploadResult result = mediaService.upload(file, siteId);
|
||||
|
||||
assertNotNull(result.key());
|
||||
assertTrue(result.key().startsWith("media/" + siteId + "/"));
|
||||
assertTrue(result.key().endsWith(".jpg"));
|
||||
assertEquals("https://cdn.example.com/" + result.key(), result.url());
|
||||
assertEquals("photo.jpg", result.originalFilename());
|
||||
assertEquals("image/jpeg", result.contentType());
|
||||
assertEquals(1024L, result.size());
|
||||
|
||||
ArgumentCaptor<PutObjectRequest> requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class);
|
||||
verify(s3Client).putObject(requestCaptor.capture(), any(RequestBody.class));
|
||||
assertEquals("test-bucket", requestCaptor.getValue().bucket());
|
||||
assertEquals(result.key(), requestCaptor.getValue().key());
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadRejectsEmptyFile() {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(true);
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> mediaService.upload(file, UUID.randomUUID()));
|
||||
verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadRejectsOversizedFile() {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn(11 * 1024 * 1024L);
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> mediaService.upload(file, UUID.randomUUID()));
|
||||
verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadRejectsUnsupportedContentType() {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn(1024L);
|
||||
when(file.getContentType()).thenReturn("application/exe");
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> mediaService.upload(file, UUID.randomUUID()));
|
||||
verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadRejectsNullContentType() {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn(1024L);
|
||||
when(file.getContentType()).thenReturn(null);
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> mediaService.upload(file, UUID.randomUUID()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadHandlesFilenameWithoutExtension() throws IOException {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn(512L);
|
||||
when(file.getContentType()).thenReturn("image/png");
|
||||
when(file.getOriginalFilename()).thenReturn("logo");
|
||||
when(file.getBytes()).thenReturn("png-data".getBytes());
|
||||
|
||||
UUID siteId = UUID.randomUUID();
|
||||
MediaUploadResult result = mediaService.upload(file, siteId);
|
||||
|
||||
assertFalse(result.key().endsWith("."));
|
||||
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadThrowsOnIOException() throws IOException {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn(1024L);
|
||||
when(file.getContentType()).thenReturn("image/jpeg");
|
||||
when(file.getOriginalFilename()).thenReturn("img.jpg");
|
||||
when(file.getBytes()).thenThrow(new IOException("S3 unreachable"));
|
||||
|
||||
assertThrows(RuntimeException.class,
|
||||
() -> mediaService.upload(file, UUID.randomUUID()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPublicUrlAppendsKey() {
|
||||
String url = mediaService.getPublicUrl("media/site/file.jpg");
|
||||
assertEquals("https://cdn.example.com/media/site/file.jpg", url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SshRsyncDeployerTest {
|
||||
|
||||
@Mock
|
||||
private KeyManager keyManager;
|
||||
|
||||
private SshRsyncDeployer deployer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
deployer = new SshRsyncDeployer(keyManager);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildUrlReturnsHttpWhenSslDisabled() {
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.domain("example.com")
|
||||
.sslEnabled(false)
|
||||
.build();
|
||||
|
||||
String url = deployer.buildUrl(config);
|
||||
|
||||
assertEquals("http://example.com/", url);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildUrlReturnsHttpsWhenSslEnabled() {
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.domain("example.com")
|
||||
.sslEnabled(true)
|
||||
.build();
|
||||
|
||||
String url = deployer.buildUrl(config);
|
||||
|
||||
assertEquals("https://example.com/", url);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildNginxConfigWithSslIncludesRedirect() throws Exception {
|
||||
Method method = SshRsyncDeployer.class.getDeclaredMethod(
|
||||
"buildNginxConfig", String.class, String.class, boolean.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
String config = (String) method.invoke(deployer, "example.com", "/var/www/example/public", true);
|
||||
|
||||
assertTrue(config.contains("listen 443 ssl http2"));
|
||||
assertTrue(config.contains("ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem"));
|
||||
assertTrue(config.contains("return 301 https://$host$request_uri"));
|
||||
assertTrue(config.contains("server_name example.com"));
|
||||
assertTrue(config.contains("listen 80;"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildNginxConfigWithoutSslIsPlainHttp() throws Exception {
|
||||
Method method = SshRsyncDeployer.class.getDeclaredMethod(
|
||||
"buildNginxConfig", String.class, String.class, boolean.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
String config = (String) method.invoke(deployer, "mysite.org", "/var/www/mysite/public", false);
|
||||
|
||||
assertTrue(config.contains("listen 80;"));
|
||||
assertTrue(config.contains("root /var/www/mysite/public;"));
|
||||
assertFalse(config.contains("ssl_certificate"));
|
||||
assertFalse(config.contains("return 301"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.krrishg.service.llm;
|
||||
|
||||
import com.krrishg.dto.LLMOptions;
|
||||
import com.krrishg.dto.LLMResult;
|
||||
import com.krrishg.dto.Reference;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class GeminiProviderTest {
|
||||
|
||||
@Mock
|
||||
private RestTemplate restTemplate;
|
||||
|
||||
private GeminiProvider provider;
|
||||
|
||||
private LLMOptions defaultOptions;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
RestTemplateBuilder builder = new RestTemplateBuilder()
|
||||
.setConnectTimeout(Duration.ofSeconds(1));
|
||||
try {
|
||||
var field = RestTemplateBuilder.class.getDeclaredField("interceptors");
|
||||
field.setAccessible(true);
|
||||
} catch (Exception e) {
|
||||
}
|
||||
|
||||
provider = new GeminiProvider("test-key", "gemini-2.0-flash",
|
||||
"https://generativelanguage.googleapis.com/v1beta/models",
|
||||
builder);
|
||||
|
||||
try {
|
||||
var restTemplateField = GeminiProvider.class.getDeclaredField("restTemplate");
|
||||
restTemplateField.setAccessible(true);
|
||||
restTemplateField.set(provider, restTemplate);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
defaultOptions = LLMOptions.builder().build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateReturnsContentOnSuccess() {
|
||||
Map<String, Object> responseBody = Map.of(
|
||||
"candidates", List.of(
|
||||
Map.of("content", Map.of(
|
||||
"parts", List.of(Map.of("text", "Hello from Gemini"))
|
||||
))
|
||||
),
|
||||
"usageMetadata", Map.of(
|
||||
"promptTokenCount", 10,
|
||||
"candidatesTokenCount", 20
|
||||
)
|
||||
);
|
||||
when(restTemplate.exchange(
|
||||
any(String.class),
|
||||
any(),
|
||||
any(HttpEntity.class),
|
||||
eq(Map.class)
|
||||
)).thenReturn(ResponseEntity.ok(responseBody));
|
||||
|
||||
LLMResult result = provider.generate("system", "user prompt", null, defaultOptions);
|
||||
|
||||
assertEquals("Hello from Gemini", result.getContent());
|
||||
assertEquals(10, result.getPromptTokens());
|
||||
assertEquals(20, result.getCompletionTokens());
|
||||
assertEquals("gemini-2.0-flash", result.getModel());
|
||||
assertTrue(result.getLatencyMs() >= 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateThrowsOnEmptyCandidates() {
|
||||
Map<String, Object> responseBody = Map.of(
|
||||
"candidates", List.of()
|
||||
);
|
||||
when(restTemplate.exchange(
|
||||
any(String.class), any(), any(HttpEntity.class), eq(Map.class)
|
||||
)).thenReturn(ResponseEntity.ok(responseBody));
|
||||
|
||||
assertThrows(RuntimeException.class,
|
||||
() -> provider.generate("sys", "user", null, defaultOptions));
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateThrowsOnNullResponseBody() {
|
||||
when(restTemplate.exchange(
|
||||
any(String.class), any(), any(HttpEntity.class), eq(Map.class)
|
||||
)).thenReturn(ResponseEntity.ok(null));
|
||||
|
||||
assertThrows(RuntimeException.class,
|
||||
() -> provider.generate("sys", "user", null, defaultOptions));
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateThrowsOnMalformedResponse() {
|
||||
Map<String, Object> responseBody = Map.of(
|
||||
"candidates", List.of(Map.of("invalid", "data"))
|
||||
);
|
||||
when(restTemplate.exchange(
|
||||
any(String.class), any(), any(HttpEntity.class), eq(Map.class)
|
||||
)).thenReturn(ResponseEntity.ok(responseBody));
|
||||
|
||||
assertThrows(RuntimeException.class,
|
||||
() -> provider.generate("sys", "user", null, defaultOptions));
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateIncludesImageReferencesInParts() {
|
||||
Map<String, Object> responseBody = Map.of(
|
||||
"candidates", List.of(
|
||||
Map.of("content", Map.of(
|
||||
"parts", List.of(Map.of("text", "result"))
|
||||
))
|
||||
),
|
||||
"usageMetadata", Map.of(
|
||||
"promptTokenCount", 5,
|
||||
"candidatesTokenCount", 10
|
||||
)
|
||||
);
|
||||
when(restTemplate.exchange(
|
||||
any(String.class), any(), any(HttpEntity.class), eq(Map.class)
|
||||
)).thenReturn(ResponseEntity.ok(responseBody));
|
||||
|
||||
List<Reference> refs = List.of(
|
||||
Reference.builder()
|
||||
.type(Reference.ReferenceType.IMAGE)
|
||||
.url("data:image/png;base64,abc123")
|
||||
.altText("Screenshot")
|
||||
.build()
|
||||
);
|
||||
|
||||
LLMResult result = provider.generate("sys", "user", refs, defaultOptions);
|
||||
|
||||
assertEquals("result", result.getContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getNameReturnsGemini() {
|
||||
assertEquals("gemini", provider.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateReturnsZeroTokensWhenUsageMissing() {
|
||||
Map<String, Object> responseBody = Map.of(
|
||||
"candidates", List.of(
|
||||
Map.of("content", Map.of(
|
||||
"parts", List.of(Map.of("text", "no usage"))
|
||||
))
|
||||
)
|
||||
);
|
||||
when(restTemplate.exchange(
|
||||
any(String.class), any(), any(HttpEntity.class), eq(Map.class)
|
||||
)).thenReturn(ResponseEntity.ok(responseBody));
|
||||
|
||||
LLMResult result = provider.generate("sys", "prompt", null, defaultOptions);
|
||||
|
||||
assertEquals(0, result.getPromptTokens());
|
||||
assertEquals(0, result.getCompletionTokens());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.krrishg.service.llm;
|
||||
|
||||
import com.krrishg.dto.LLMOptions;
|
||||
import com.krrishg.dto.LLMResult;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class OpenAIProviderTest {
|
||||
|
||||
@Mock
|
||||
private RestTemplate restTemplate;
|
||||
|
||||
private OpenAIProvider provider;
|
||||
|
||||
private LLMOptions defaultOptions;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
RestTemplateBuilder builder = new RestTemplateBuilder()
|
||||
.setConnectTimeout(Duration.ofSeconds(1));
|
||||
|
||||
provider = new OpenAIProvider("sk-test", "gpt-4o-mini",
|
||||
"https://api.openai.com/v1/chat/completions", builder);
|
||||
|
||||
try {
|
||||
var field = OpenAIProvider.class.getDeclaredField("restTemplate");
|
||||
field.setAccessible(true);
|
||||
field.set(provider, restTemplate);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
defaultOptions = LLMOptions.builder().build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateReturnsContentOnSuccess() {
|
||||
Map<String, Object> responseBody = Map.of(
|
||||
"choices", List.of(
|
||||
Map.of("message", Map.of("content", "Hello from OpenAI"))
|
||||
),
|
||||
"usage", Map.of(
|
||||
"prompt_tokens", 15,
|
||||
"completion_tokens", 25
|
||||
)
|
||||
);
|
||||
when(restTemplate.exchange(
|
||||
any(String.class), any(), any(HttpEntity.class), eq(Map.class)
|
||||
)).thenReturn(ResponseEntity.ok(responseBody));
|
||||
|
||||
LLMResult result = provider.generate("system", "user prompt", null, defaultOptions);
|
||||
|
||||
assertEquals("Hello from OpenAI", result.getContent());
|
||||
assertEquals(15, result.getPromptTokens());
|
||||
assertEquals(25, result.getCompletionTokens());
|
||||
assertEquals("gpt-4o-mini", result.getModel());
|
||||
assertTrue(result.getLatencyMs() >= 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateThrowsOnEmptyChoices() {
|
||||
Map<String, Object> responseBody = Map.of(
|
||||
"choices", List.of()
|
||||
);
|
||||
when(restTemplate.exchange(
|
||||
any(String.class), any(), any(HttpEntity.class), eq(Map.class)
|
||||
)).thenReturn(ResponseEntity.ok(responseBody));
|
||||
|
||||
assertThrows(RuntimeException.class,
|
||||
() -> provider.generate("sys", "user", null, defaultOptions));
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateThrowsOnNullResponseBody() {
|
||||
when(restTemplate.exchange(
|
||||
any(String.class), any(), any(HttpEntity.class), eq(Map.class)
|
||||
)).thenReturn(ResponseEntity.ok(null));
|
||||
|
||||
assertThrows(RuntimeException.class,
|
||||
() -> provider.generate("sys", "user", null, defaultOptions));
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateThrowsOnMalformedResponse() {
|
||||
Map<String, Object> responseBody = Map.of(
|
||||
"choices", List.of(Map.of("invalid", "data"))
|
||||
);
|
||||
when(restTemplate.exchange(
|
||||
any(String.class), any(), any(HttpEntity.class), eq(Map.class)
|
||||
)).thenReturn(ResponseEntity.ok(responseBody));
|
||||
|
||||
assertThrows(RuntimeException.class,
|
||||
() -> provider.generate("sys", "user", null, defaultOptions));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getNameReturnsOpenai() {
|
||||
assertEquals("openai", provider.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateReturnsZeroTokensWhenUsageMissing() {
|
||||
Map<String, Object> responseBody = Map.of(
|
||||
"choices", List.of(
|
||||
Map.of("message", Map.of("content", "no usage"))
|
||||
)
|
||||
);
|
||||
when(restTemplate.exchange(
|
||||
any(String.class), any(), any(HttpEntity.class), eq(Map.class)
|
||||
)).thenReturn(ResponseEntity.ok(responseBody));
|
||||
|
||||
LLMResult result = provider.generate("sys", "prompt", null, defaultOptions);
|
||||
|
||||
assertEquals(0, result.getPromptTokens());
|
||||
assertEquals(0, result.getCompletionTokens());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.krrishg.support;
|
||||
|
||||
import com.krrishg.model.GitRemote;
|
||||
import com.krrishg.repository.GitRemoteRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public class FakeGitRemoteRepository
|
||||
extends InMemoryRepository<GitRemote, UUID>
|
||||
implements GitRemoteRepository {
|
||||
|
||||
@Override
|
||||
protected UUID getId(GitRemote entity) {
|
||||
return entity.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected GitRemote setId(GitRemote entity, UUID id) {
|
||||
entity.setId(id);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected UUID generateId() {
|
||||
return UUID.randomUUID();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<GitRemote> findBySiteId(UUID siteId) {
|
||||
return store.values().stream()
|
||||
.filter(r -> siteId.equals(r.getSiteId()))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsBySiteId(UUID siteId) {
|
||||
return store.values().stream()
|
||||
.anyMatch(r -> siteId.equals(r.getSiteId()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteBySiteId(UUID siteId) {
|
||||
store.values().removeIf(r -> siteId.equals(r.getSiteId()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user