From 51d887a0059df36ddb1338029143d78253159be8 Mon Sep 17 00:00:00 2001 From: Krrish Ghimire Date: Mon, 6 Jul 2026 01:39:18 +0545 Subject: [PATCH] add tests --- PLAN-selfhosted.md | 425 ++++++++++++++++++ .../config/JwtAuthenticationTokenTest.java | 49 ++ .../controller/DeploymentControllerTest.java | 91 ++++ .../controller/ExportControllerTest.java | 156 +++++++ .../krrishg/controller/GitControllerTest.java | 182 ++++++++ .../controller/MediaControllerTest.java | 106 +++++ .../java/com/krrishg/engine/JsBundleTest.java | 48 ++ .../com/krrishg/engine/StyleCompilerTest.java | 44 ++ .../java/com/krrishg/model/GitRemoteTest.java | 70 +++ .../repository/GitRemoteRepositoryTest.java | 91 ++++ .../krrishg/service/CustomOAuth2UserTest.java | 47 ++ .../krrishg/service/DomainServiceTest.java | 131 ++++++ .../krrishg/service/ExportServiceTest.java | 83 ++++ .../krrishg/service/PagesProviderTest.java | 124 +++++ .../krrishg/service/RenderServiceTest.java | 203 +++++++++ 15 files changed, 1850 insertions(+) create mode 100644 PLAN-selfhosted.md create mode 100644 backend/src/test/java/com/krrishg/config/JwtAuthenticationTokenTest.java create mode 100644 backend/src/test/java/com/krrishg/controller/DeploymentControllerTest.java create mode 100644 backend/src/test/java/com/krrishg/controller/ExportControllerTest.java create mode 100644 backend/src/test/java/com/krrishg/controller/GitControllerTest.java create mode 100644 backend/src/test/java/com/krrishg/controller/MediaControllerTest.java create mode 100644 backend/src/test/java/com/krrishg/engine/JsBundleTest.java create mode 100644 backend/src/test/java/com/krrishg/engine/StyleCompilerTest.java create mode 100644 backend/src/test/java/com/krrishg/model/GitRemoteTest.java create mode 100644 backend/src/test/java/com/krrishg/repository/GitRemoteRepositoryTest.java create mode 100644 backend/src/test/java/com/krrishg/service/CustomOAuth2UserTest.java create mode 100644 backend/src/test/java/com/krrishg/service/DomainServiceTest.java create mode 100644 backend/src/test/java/com/krrishg/service/ExportServiceTest.java create mode 100644 backend/src/test/java/com/krrishg/service/PagesProviderTest.java create mode 100644 backend/src/test/java/com/krrishg/service/RenderServiceTest.java diff --git a/PLAN-selfhosted.md b/PLAN-selfhosted.md new file mode 100644 index 0000000..1cec3c4 --- /dev/null +++ b/PLAN-selfhosted.md @@ -0,0 +1,425 @@ +# Self-Hosted Deployment Plan + +Deploy sites to a custom server via SSH + rsync + nginx, alongside the existing git-based (GitHub/GitLab Pages) publishing. + +--- + +## Design Decisions + +1. **Git push always happens** when a `GitRemote` is configured — regardless of deployment target. +2. **Self-hosted deploy is additive** — rsyncs to the server on top of the git push. +3. **User selects `deploymentTarget`** on the Site (`GIT_PAGES`, `SELF_HOSTED`, or `NONE`) — controls which URL is the primary published URL and which prereqs are validated on publish. +4. **Custom domain lives in `SelfHostedConfig`**, not on `Site`. Custom domains for git pages are handled outside Indie. + +--- + +## Implementation Steps + +### Step 1 — Site Model: Add DeploymentTarget + +**File: `model/Site.java`** — add enum + field: + +```java +public enum DeploymentTarget { + GIT_PAGES, SELF_HOSTED, NONE +} + +@Enumerated(EnumType.STRING) +@Column(nullable = false) +@Builder.Default +private DeploymentTarget deploymentTarget = DeploymentTarget.NONE; +``` + +**File: `model/SiteStatus.java`** — no change needed. + +### Step 2 — DTOs: Update SiteRequest/SiteResponse + +**File: `dto/SiteRequest.java`** — add: +```java +private String deploymentTarget; // "GIT_PAGES", "SELF_HOSTED", "NONE" +``` + +**File: `dto/SiteResponse.java`** — add: +```java +private String deploymentTarget; +``` +Update `from(Site site)` to map the field. + +### Step 3 — SelfHostedConfig Entity (New) + +**File: `model/SelfHostedConfig.java`:** + +```java +package com.krrishg.model; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "self_hosted_configs") +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class SelfHostedConfig { + @Id + private UUID id; + + @Column(nullable = false, unique = true) + private UUID siteId; + + @Column(nullable = false, unique = true) + private String domain; + + @Column(nullable = false) + private String sshHost; + + @Column(nullable = false) + @Builder.Default + private int sshPort = 22; + + @Column(nullable = false) + private String sshUser; + + @Column(nullable = false, length = 8192) + private String encryptedSshKey; + + @Column(nullable = false) + @Builder.Default + private String deployPath = "/var/www/{domain}/public"; + + @Column(nullable = false) + @Builder.Default + private String nginxSitesPath = "/etc/nginx/sites-available"; + + @Column(nullable = false) + @Builder.Default + private Boolean nginxSslEnabled = false; + + private LocalDateTime deployedAt; + + @PrePersist + void onCreate() { + if (id == null) id = UUID.randomUUID(); + } +} +``` + +### Step 4 — Repository (New) + +**File: `repository/SelfHostedConfigRepository.java`:** + +```java +package com.krrishg.repository; + +import com.krrishg.model.SelfHostedConfig; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; +import java.util.UUID; + +public interface SelfHostedConfigRepository extends JpaRepository { + Optional findBySiteId(UUID siteId); + Optional findByDomain(String domain); + boolean existsBySiteId(UUID siteId); + boolean existsByDomain(String domain); +} +``` + +### Step 5 — KeyManager: SSH Key Encryption + +**File: `service/KeyManager.java`** — add methods using the same AES-256-GCM pattern as git token encryption: + +```java +public String encryptSshKey(String rawKey); +public String decryptSshKey(String encryptedKey); +``` + +### Step 6 — Deployer Interface (New) + +**File: `service/Deployer.java`:** + +```java +package com.krrishg.service; + +import com.krrishg.model.SelfHostedConfig; +import com.krrishg.service.RenderService.SiteOutput; + +public interface Deployer { + String deploy(SiteOutput output, SelfHostedConfig config) throws Exception; + void remove(SelfHostedConfig config) throws Exception; + String buildUrl(SelfHostedConfig config); +} +``` + +### Step 7 — SshRsyncDeployer Implementation (New) + +**File: `service/SshRsyncDeployer.java`:** + +Key operations: + +**`deploy()`:** +1. Render site files to temp directory +2. Expand `{domain}` placeholder in `deployPath` to `config.getDomain()` +3. Decrypt SSH key to temp file (chmod 0600, delete after) +4. `ssh @ -p -i "mkdir -p "` +5. `rsync -avz --delete -e "ssh -p -i -o StrictHostKeyChecking=accept-new" / @:/` +6. Generate nginx config string for the domain +7. Write nginx config to temp file, rsync it to `/` on server +8. `ssh @ -i "sudo ln -sf / /etc/nginx/sites-enabled/ && sudo nginx -s reload"` +9. Clean up temp files +10. Return `"https://" + domain + "/"` + +**Nginx config template:** +``` +server { + listen 80; + server_name ; + root ; + index index.html; + location / { + try_files $uri $uri/ /index.html; + } +} +``` + +Optionally support SSL: if `nginxSslEnabled`, also generate a `listen 443 ssl;` block with placeholder cert paths, or skip SSL (user configures separately). + +**`remove()`:** +1. SSH `rm -rf ` +2. SSH `sudo rm / /etc/nginx/sites-enabled/` +3. SSH `sudo nginx -s reload` + +### Step 8 — DeploymentService: Dispatch Logic + +**File: `service/DeploymentService.java`** — inject `SelfHostedConfigRepository`, `Deployer`, `SshRsyncDeployer`. + +Update `publish()`: + +```java +public String publish(UUID siteId) { + Site site = siteRepository.findById(siteId) + .orElseThrow(() -> new IllegalArgumentException("Site not found")); + + SiteStructure structure = getLatestVersion(siteId); + if (structure == null) { + throw new IllegalStateException("No generation versions found"); + } + + SiteOutput output = renderService.renderSite(structure); + + // Step 1: Always push to git if remote is configured + GitRemote remote = gitRemoteRepository.findBySiteId(siteId).orElse(null); + if (remote != null) { + gitService.deployToPages(siteId.toString(), remote, structure); + } + + // Step 2: Additionally deploy to custom server if configured + SelfHostedConfig selfHostedConfig = selfHostedConfigRepository.findBySiteId(siteId).orElse(null); + if (selfHostedConfig != null) { + deployer.deploy(output, selfHostedConfig); + } + + // Step 3: Validate and set URL based on deployment target + String url = null; + switch (site.getDeploymentTarget()) { + case GIT_PAGES: + if (remote == null) { + throw new IllegalStateException("Deployment target is GIT_PAGES but no git remote is configured"); + } + PagesProvider provider = PagesProvider.forRemote(remote); + url = provider.buildPagesUrl(remote); + break; + case SELF_HOSTED: + if (selfHostedConfig == null) { + throw new IllegalStateException("Deployment target is SELF_HOSTED but no self-hosted config exists"); + } + url = deployer.buildUrl(selfHostedConfig); + break; + case NONE: + // Still publish but no primary URL — user might want git push without pages URL + break; + } + + site.setStatus(SiteStatus.PUBLISHED); + site.setPublishedAt(LocalDateTime.now()); + site.setPublishedUrl(url); + siteRepository.save(site); + + log.info("Published site {}: target={}, url={}", siteId, site.getDeploymentTarget(), url); + return url; +} +``` + +Update `unpublish()` similarly — still removes git pages branch, and also removes from self-hosted server if configured. + +### Step 9 — SelfHostedController (New) + +**File: `controller/SelfHostedController.java`:** + +`@RestController @RequestMapping("/api/sites/{id}/self-hosted")` + +| Method | Path | Purpose | +|--------|------|---------| +| `GET` | `/api/sites/{id}/self-hosted` | Get config (sshKey omitted from response) | +| `PUT` | `/api/sites/{id}/self-hosted` | Create/update config. Encrypts sshKey via KeyManager. Optionally sets `site.deploymentTarget = SELF_HOSTED` | +| `DELETE` | `/api/sites/{id}/self-hosted` | Remove config. Removes files from server via `deployer.remove()`. Resets `site.deploymentTarget` if it was `SELF_HOSTED` | + +All endpoints require ownership check (same pattern as `SiteController.findSiteForUser`). + +### Step 10 — SiteController: Accept deploymentTarget + +**File: `controller/SiteController.java`** — in `createSite()` and `updateSite()`, set `site.setDeploymentTarget(...)` from the request. Validate that if target is `GIT_PAGES`, a GitRemote exists (or at least warn). If `SELF_HOSTED`, a `SelfHostedConfig` should exist. + +### Step 11 — DTOs for Self-Hosted (New) + +**File: `dto/SelfHostedConfigRequest.java`:** +```java +@Data +public class SelfHostedConfigRequest { + @NotBlank private String domain; + @NotBlank private String sshHost; + private int sshPort = 22; + @NotBlank private String sshUser; + @NotBlank private String sshKey; + private String deployPath; + private String nginxSitesPath; + private Boolean nginxSslEnabled; +} +``` + +**File: `dto/SelfHostedConfigResponse.java`:** +```java +@Data @Builder +public class SelfHostedConfigResponse { + private UUID id; + private UUID siteId; + private String domain; + private String sshHost; + private int sshPort; + private String sshUser; + private String deployPath; + private String nginxSitesPath; + private Boolean nginxSslEnabled; + private LocalDateTime deployedAt; +} +``` + +### Step 12 — Frontend: Models + +**File: `frontend/src/app/core/models/site.ts`** — add to `SiteResponse`: +```typescript +deploymentTarget: 'GIT_PAGES' | 'SELF_HOSTED' | 'NONE'; +``` + +**File: `frontend/src/app/core/models/deployment.ts`** — add: +```typescript +export interface SelfHostedConfigRequest { + domain: string; + sshHost: string; + sshPort: number; + sshUser: string; + sshKey: string; + deployPath: string; + nginxSitesPath: string; + nginxSslEnabled: boolean; +} + +export interface SelfHostedConfigResponse { + id: string; + siteId: string; + domain: string; + sshHost: string; + sshPort: number; + sshUser: string; + deployPath: string; + nginxSitesPath: string; + nginxSslEnabled: boolean; + deployedAt: string | null; +} +``` + +### Step 13 — Frontend: Site Service + +**File: `frontend/src/app/core/services/site.service.ts`** — add: +```typescript +getSelfHostedConfig(siteId: string): Observable { + return this.http.get(`/api/sites/${siteId}/self-hosted`); +} + +saveSelfHostedConfig(siteId: string, config: SelfHostedConfigRequest): Observable { + return this.http.put(`/api/sites/${siteId}/self-hosted`, config); +} + +deleteSelfHostedConfig(siteId: string): Observable { + return this.http.delete(`/api/sites/${siteId}/self-hosted`); +} +``` + +### Step 14 — Frontend: Settings Component + +**File: `frontend/src/app/llm-workspace/settings/settings.component.ts`** — add: + +1. **Deployment Target selector** (dropdown) — between "Git Pages", "Self-Hosted", "None". Shows status indicators for which targets are configured. + +2. **Self-Hosted Config card** — expandable section with form fields for domain, SSH host, SSH port, SSH user, SSH key (textarea, masked), deploy path, nginx sites path. Save and Remove buttons. + +3. **Publishing card update** — show which target will be used and the expected URL. Keep the publish/unpublish buttons. + +--- + +## Server-Side Prerequisites + +The SSH user on the target server needs: + +1. **Write access** to the `deployPath` directory +2. **sudo NOPASSWD** for: + ```bash + deploy-user ALL=(ALL) NOPASSWD: /usr/sbin/nginx -s reload, \ + /bin/ln -sf /etc/nginx/sites-available/* /etc/nginx/sites-enabled/, \ + /bin/rm /etc/nginx/sites-enabled/*, \ + /bin/rm /etc/nginx/sites-available/* + ``` + +--- + +## Files Summary + +### Backend (existing, modified) +| File | Change | +|------|--------| +| `model/Site.java` | Add `DeploymentTarget` enum + field | +| `model/SiteStatus.java` | No change | +| `dto/SiteRequest.java` | Add `deploymentTarget` | +| `dto/SiteResponse.java` | Add `deploymentTarget` | +| `service/KeyManager.java` | Add SSH key encrypt/decrypt methods | +| `service/DeploymentService.java` | Dispatch logic for both targets | +| `controller/SiteController.java` | Accept `deploymentTarget` in create/update | +| `repository/SiteRepository.java` | No change (optional: add findByDeploymentTarget) | + +### Backend (new files) +| File | Description | +|------|-------------| +| `model/SelfHostedConfig.java` | Entity | +| `repository/SelfHostedConfigRepository.java` | JPA repository | +| `dto/SelfHostedConfigRequest.java` | Request DTO | +| `dto/SelfHostedConfigResponse.java` | Response DTO | +| `service/Deployer.java` | Deployer interface | +| `service/SshRsyncDeployer.java` | SSH + rsync + nginx implementation | +| `controller/SelfHostedController.java` | REST endpoints | + +### Frontend (modified) +| File | Change | +|------|--------| +| `core/models/site.ts` | Add `deploymentTarget` | +| `core/models/deployment.ts` | Add self-hosted config interfaces | +| `core/services/site.service.ts` | Add self-hosted API methods | +| `llm-workspace/settings/settings.component.ts` | Add target selector + self-hosted config card | + +--- + +## Future Enhancements (Out of Scope) + +- Automatic TLS via Let's Encrypt / Certbot integration +- Multiple server targets per site +- Health checks / monitoring +- Rollback on server (keep previous deploy versions) +- Staged deployments (staging → production) diff --git a/backend/src/test/java/com/krrishg/config/JwtAuthenticationTokenTest.java b/backend/src/test/java/com/krrishg/config/JwtAuthenticationTokenTest.java new file mode 100644 index 0000000..9bc60c4 --- /dev/null +++ b/backend/src/test/java/com/krrishg/config/JwtAuthenticationTokenTest.java @@ -0,0 +1,49 @@ +package com.krrishg.config; + +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class JwtAuthenticationTokenTest { + + private final UserPrincipal principal = new UserPrincipal( + UUID.randomUUID(), "test@example.com", "Test User"); + private final String token = "test-jwt-token"; + private final JwtAuthenticationToken auth = new JwtAuthenticationToken(principal, token); + + @Test + void constructorSetsAuthenticated() { + assertTrue(auth.isAuthenticated()); + } + + @Test + void getCredentialsReturnsToken() { + assertEquals(token, auth.getCredentials()); + } + + @Test + void getPrincipalReturnsUserPrincipal() { + assertSame(principal, auth.getPrincipal()); + } + + @Test + void getAuthoritiesContainsRoleUser() { + var authorities = auth.getAuthorities(); + assertEquals(1, authorities.size()); + assertEquals("ROLE_USER", authorities.iterator().next().getAuthority()); + } + + @Test + void principalFieldsAreAccessible() { + assertEquals("test@example.com", principal.email()); + assertEquals("Test User", principal.name()); + assertNotNull(principal.id()); + } + + @Test + void principalIsInstanceOfUserPrincipal() { + assertInstanceOf(UserPrincipal.class, auth.getPrincipal()); + } +} diff --git a/backend/src/test/java/com/krrishg/controller/DeploymentControllerTest.java b/backend/src/test/java/com/krrishg/controller/DeploymentControllerTest.java new file mode 100644 index 0000000..56f5aad --- /dev/null +++ b/backend/src/test/java/com/krrishg/controller/DeploymentControllerTest.java @@ -0,0 +1,91 @@ +package com.krrishg.controller; + +import com.krrishg.config.GlobalExceptionHandler; +import com.krrishg.config.JwtAuthenticationToken; +import com.krrishg.config.UserPrincipal; +import com.krrishg.service.DeploymentService; +import com.krrishg.support.TestSecurityConfig; +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.test.web.servlet.MockMvc; + +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(DeploymentController.class) +@Import({GlobalExceptionHandler.class, TestSecurityConfig.class}) +class DeploymentControllerTest { + + private final MockMvc mockMvc; + + @MockBean + private DeploymentService deploymentService; + + private UUID userId; + private UUID siteId; + private UserPrincipal principal; + private JwtAuthenticationToken auth; + + DeploymentControllerTest(@Autowired MockMvc mockMvc) { + this.mockMvc = mockMvc; + } + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + siteId = UUID.randomUUID(); + principal = new UserPrincipal(userId, "user@test.com", "Test User"); + auth = new JwtAuthenticationToken(principal, "test-token"); + } + + @Test + void publishReturns200() throws Exception { + when(deploymentService.publish(siteId)).thenReturn("https://mysite.example.com"); + + mockMvc.perform(post("/api/sites/{id}/publish", siteId) + .with(authentication(auth))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Site published successfully")) + .andExpect(jsonPath("$.url").value("https://mysite.example.com")); + } + + @Test + void publishReturns400WhenNoVersion() throws Exception { + when(deploymentService.publish(siteId)) + .thenThrow(new IllegalStateException("No generation versions found for site " + siteId)); + + mockMvc.perform(post("/api/sites/{id}/publish", siteId) + .with(authentication(auth))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("No generation versions found for site " + siteId)); + } + + @Test + void publishReturns400WhenNoRemote() throws Exception { + when(deploymentService.publish(siteId)) + .thenThrow(new IllegalStateException("No git remote configured")); + + mockMvc.perform(post("/api/sites/{id}/publish", siteId) + .with(authentication(auth))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("No git remote configured")); + } + + @Test + void unpublishReturns200() throws Exception { + mockMvc.perform(post("/api/sites/{id}/unpublish", siteId) + .with(authentication(auth))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Site unpublished successfully")); + } +} diff --git a/backend/src/test/java/com/krrishg/controller/ExportControllerTest.java b/backend/src/test/java/com/krrishg/controller/ExportControllerTest.java new file mode 100644 index 0000000..6f780e6 --- /dev/null +++ b/backend/src/test/java/com/krrishg/controller/ExportControllerTest.java @@ -0,0 +1,156 @@ +package com.krrishg.controller; + +import com.krrishg.config.GlobalExceptionHandler; +import com.krrishg.config.JwtAuthenticationToken; +import com.krrishg.config.UserPrincipal; +import com.krrishg.dto.SiteStructure; +import com.krrishg.model.GenerationVersion; +import com.krrishg.model.Site; +import com.krrishg.model.SiteStatus; +import com.krrishg.repository.SiteRepository; +import com.krrishg.service.ExportService; +import com.krrishg.service.LLMService; +import com.krrishg.support.TestSecurityConfig; +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.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(ExportController.class) +@Import({GlobalExceptionHandler.class, TestSecurityConfig.class}) +class ExportControllerTest { + + private final MockMvc mockMvc; + + @MockBean + private ExportService exportService; + + @MockBean + private LLMService llmService; + + @MockBean + private SiteRepository siteRepository; + + private UUID userId; + private UUID siteId; + private UserPrincipal principal; + private JwtAuthenticationToken auth; + private Site site; + + ExportControllerTest(@Autowired MockMvc mockMvc) { + this.mockMvc = mockMvc; + } + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + siteId = UUID.randomUUID(); + principal = new UserPrincipal(userId, "user@test.com", "Test User"); + auth = new JwtAuthenticationToken(principal, "test-token"); + + site = Site.builder() + .id(siteId) + .userId(userId) + .name("My Site") + .subdomain("my-site") + .status(SiteStatus.DRAFT) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + } + + @Test + void exportReturnsZipFile() throws Exception { + when(siteRepository.findById(siteId)).thenReturn(Optional.of(site)); + + GenerationVersion version = GenerationVersion.builder() + .id("v1") + .siteId(siteId) + .versionNumber(1) + .createdAt(LocalDateTime.now()) + .build(); + when(llmService.getVersions(siteId)).thenReturn(List.of(version)); + when(llmService.restoreVersion("v1")).thenReturn(SiteStructure.builder().build()); + when(exportService.exportSite(any(SiteStructure.class))).thenReturn("fake-zip-content".getBytes()); + + mockMvc.perform(get("/api/sites/{id}/export", siteId) + .with(authentication(auth))) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", "attachment; filename=\"my-site-export.zip\"")) + .andExpect(header().string("Content-Type", "application/octet-stream")); + } + + @Test + void exportReturns400WhenNoVersions() throws Exception { + when(siteRepository.findById(siteId)).thenReturn(Optional.of(site)); + when(llmService.getVersions(siteId)).thenReturn(List.of()); + + mockMvc.perform(get("/api/sites/{id}/export", siteId) + .with(authentication(auth))) + .andExpect(status().isBadRequest()); + } + + @Test + void exportReturns500OnExportError() throws Exception { + when(siteRepository.findById(siteId)).thenReturn(Optional.of(site)); + + GenerationVersion version = GenerationVersion.builder() + .id("v1") + .siteId(siteId) + .versionNumber(1) + .createdAt(LocalDateTime.now()) + .build(); + when(llmService.getVersions(siteId)).thenReturn(List.of(version)); + when(llmService.restoreVersion("v1")).thenReturn(SiteStructure.builder().build()); + when(exportService.exportSite(any(SiteStructure.class))) + .thenThrow(new RuntimeException("Export failed")); + + mockMvc.perform(get("/api/sites/{id}/export", siteId) + .with(authentication(auth))) + .andExpect(status().isInternalServerError()); + } + + @Test + void exportSanitizesFilenameForSiteWithoutSubdomain() throws Exception { + Site siteNoSubdomain = Site.builder() + .id(siteId) + .userId(userId) + .name("My Cool Site!") + .subdomain(null) + .status(SiteStatus.DRAFT) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + when(siteRepository.findById(siteId)).thenReturn(Optional.of(siteNoSubdomain)); + + GenerationVersion version = GenerationVersion.builder() + .id("v1") + .siteId(siteId) + .versionNumber(1) + .createdAt(LocalDateTime.now()) + .build(); + when(llmService.getVersions(siteId)).thenReturn(List.of(version)); + when(llmService.restoreVersion("v1")).thenReturn(SiteStructure.builder().build()); + when(exportService.exportSite(any(SiteStructure.class))).thenReturn("zip".getBytes()); + + mockMvc.perform(get("/api/sites/{id}/export", siteId) + .with(authentication(auth))) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", + "attachment; filename=\"my-cool-site-export.zip\"")); + } +} diff --git a/backend/src/test/java/com/krrishg/controller/GitControllerTest.java b/backend/src/test/java/com/krrishg/controller/GitControllerTest.java new file mode 100644 index 0000000..16560df --- /dev/null +++ b/backend/src/test/java/com/krrishg/controller/GitControllerTest.java @@ -0,0 +1,182 @@ +package com.krrishg.controller; + +import com.krrishg.config.GlobalExceptionHandler; +import com.krrishg.config.JwtAuthenticationToken; +import com.krrishg.config.UserPrincipal; +import com.krrishg.model.GitRemote; +import com.krrishg.repository.GitRemoteRepository; +import com.krrishg.service.GitService; +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.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +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(GitController.class) +@Import({GlobalExceptionHandler.class, TestSecurityConfig.class}) +class GitControllerTest { + + private final MockMvc mockMvc; + private final ObjectMapper objectMapper; + + @MockBean + private GitService gitService; + + @MockBean + private GitRemoteRepository gitRemoteRepository; + + @MockBean + private KeyManager keyManager; + + private UUID userId; + private UUID siteId; + private UserPrincipal principal; + private JwtAuthenticationToken auth; + + GitControllerTest(@Autowired MockMvc mockMvc, @Autowired ObjectMapper objectMapper) { + this.mockMvc = mockMvc; + this.objectMapper = objectMapper; + } + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + siteId = UUID.randomUUID(); + principal = new UserPrincipal(userId, "user@test.com", "Test User"); + auth = new JwtAuthenticationToken(principal, "test-token"); + } + + @Test + void connectRemoteReturns200() throws Exception { + when(gitRemoteRepository.findBySiteId(siteId)).thenReturn(Optional.empty()); + when(keyManager.getPublicKey(siteId.toString())).thenReturn("ssh-ed25519 AAA..."); + + Map request = Map.of( + "provider", "GITHUB", + "remoteUrl", "https://github.com/user/repo.git", + "token", "ghp_token" + ); + + mockMvc.perform(post("/api/sites/{id}/git/connect", siteId) + .with(authentication(auth)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Remote connected successfully")) + .andExpect(jsonPath("$.provider").value("GITHUB")); + } + + @Test + void connectRemoteReturns400WhenFieldsMissing() throws Exception { + Map request = Map.of("provider", "GITHUB"); + + mockMvc.perform(post("/api/sites/{id}/git/connect", siteId) + .with(authentication(auth)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + void connectRemoteReturns400WhenInvalidProvider() throws Exception { + Map request = Map.of( + "provider", "INVALID", + "remoteUrl", "https://github.com/user/repo.git", + "token", "token" + ); + + mockMvc.perform(post("/api/sites/{id}/git/connect", siteId) + .with(authentication(auth)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Invalid provider. Must be GITHUB or GITLAB")); + } + + @Test + void connectRemoteReplacesExistingRemote() throws Exception { + GitRemote existing = GitRemote.builder() + .siteId(siteId) + .provider(GitRemote.GitProvider.GITLAB) + .remoteUrl("https://gitlab.com/old/repo.git") + .encryptedToken("old-token") + .build(); + when(gitRemoteRepository.findBySiteId(siteId)).thenReturn(Optional.of(existing)); + when(keyManager.getPublicKey(siteId.toString())).thenReturn("ssh-ed25519 AAA..."); + + Map request = Map.of( + "provider", "GITHUB", + "remoteUrl", "https://github.com/user/repo.git", + "token", "ghp_token" + ); + + mockMvc.perform(post("/api/sites/{id}/git/connect", siteId) + .with(authentication(auth)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + verify(gitRemoteRepository).delete(existing); + } + + @Test + void disconnectRemoteReturns200() throws Exception { + mockMvc.perform(post("/api/sites/{id}/git/disconnect", siteId) + .with(authentication(auth))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Remote disconnected")); + } + + @Test + void getStatusReturnsConnectedInfo() throws Exception { + GitRemote remote = GitRemote.builder() + .siteId(siteId) + .provider(GitRemote.GitProvider.GITHUB) + .remoteUrl("https://github.com/user/repo.git") + .encryptedToken("token") + .build(); + when(gitRemoteRepository.findBySiteId(siteId)).thenReturn(Optional.of(remote)); + when(keyManager.hasKeyPair(siteId.toString())).thenReturn(true); + when(gitService.getLog(eq(siteId.toString()), eq(10))).thenReturn(List.of()); + + mockMvc.perform(get("/api/sites/{id}/git/status", siteId) + .with(authentication(auth))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.connected").value(true)) + .andExpect(jsonPath("$.provider").value("GITHUB")) + .andExpect(jsonPath("$.remoteUrl").value("https://github.com/user/repo.git")) + .andExpect(jsonPath("$.hasKeys").value(true)); + } + + @Test + void getStatusReturnsDisconnectedState() throws Exception { + when(gitRemoteRepository.findBySiteId(siteId)).thenReturn(Optional.empty()); + when(keyManager.hasKeyPair(siteId.toString())).thenReturn(false); + when(gitService.getLog(eq(siteId.toString()), eq(10))).thenReturn(List.of()); + + mockMvc.perform(get("/api/sites/{id}/git/status", siteId) + .with(authentication(auth))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.connected").value(false)) + .andExpect(jsonPath("$.hasKeys").value(false)); + } +} diff --git a/backend/src/test/java/com/krrishg/controller/MediaControllerTest.java b/backend/src/test/java/com/krrishg/controller/MediaControllerTest.java new file mode 100644 index 0000000..8adbbd6 --- /dev/null +++ b/backend/src/test/java/com/krrishg/controller/MediaControllerTest.java @@ -0,0 +1,106 @@ +package com.krrishg.controller; + +import com.krrishg.config.GlobalExceptionHandler; +import com.krrishg.config.JwtAuthenticationToken; +import com.krrishg.config.UserPrincipal; +import com.krrishg.service.MediaService; +import com.krrishg.support.TestSecurityConfig; +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.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(MediaController.class) +@Import({GlobalExceptionHandler.class, TestSecurityConfig.class}) +class MediaControllerTest { + + private final MockMvc mockMvc; + + @MockBean + private MediaService mediaService; + + private UUID userId; + private UUID siteId; + private UserPrincipal principal; + private JwtAuthenticationToken auth; + + MediaControllerTest(@Autowired MockMvc mockMvc) { + this.mockMvc = mockMvc; + } + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + siteId = UUID.randomUUID(); + principal = new UserPrincipal(userId, "user@test.com", "Test User"); + auth = new JwtAuthenticationToken(principal, "test-token"); + } + + @Test + void uploadReturns200() throws Exception { + MediaService.MediaUploadResult result = new MediaService.MediaUploadResult( + "media/" + siteId + "/abc123.jpg", + "https://cdn.example.com/media/" + siteId + "/abc123.jpg", + "photo.jpg", + "image/jpeg", + 1024L + ); + when(mediaService.upload(any(), any())).thenReturn(result); + + MockMultipartFile file = new MockMultipartFile( + "file", "photo.jpg", "image/jpeg", "test-image-content".getBytes()); + + mockMvc.perform(multipart("/api/media/upload") + .file(file) + .param("siteId", siteId.toString()) + .with(authentication(auth))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.key").value("media/" + siteId + "/abc123.jpg")) + .andExpect(jsonPath("$.url").value("https://cdn.example.com/media/" + siteId + "/abc123.jpg")) + .andExpect(jsonPath("$.originalFilename").value("photo.jpg")) + .andExpect(jsonPath("$.contentType").value("image/jpeg")) + .andExpect(jsonPath("$.size").value(1024)); + } + + @Test + void uploadReturns400WhenInvalidFile() throws Exception { + when(mediaService.upload(any(), any())) + .thenThrow(new IllegalArgumentException("File type not allowed")); + + MockMultipartFile file = new MockMultipartFile( + "file", "bad.exe", "application/x-msdownload", "bad".getBytes()); + + mockMvc.perform(multipart("/api/media/upload") + .file(file) + .param("siteId", siteId.toString()) + .with(authentication(auth))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("File type not allowed")); + } + + @Test + void getMediaUrlReturns200() throws Exception { + String key = "abc.jpg"; + when(mediaService.getPublicUrl(key)).thenReturn("https://cdn.example.com/media/" + key); + + mockMvc.perform(get("/api/media/{key}", key) + .with(authentication(auth))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.key").value(key)) + .andExpect(jsonPath("$.url").value("https://cdn.example.com/media/abc.jpg")); + } +} diff --git a/backend/src/test/java/com/krrishg/engine/JsBundleTest.java b/backend/src/test/java/com/krrishg/engine/JsBundleTest.java new file mode 100644 index 0000000..d90f382 --- /dev/null +++ b/backend/src/test/java/com/krrishg/engine/JsBundleTest.java @@ -0,0 +1,48 @@ +package com.krrishg.engine; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class JsBundleTest { + + private final JsBundle jsBundle = new JsBundle(); + + @Test + void compileReturnsNonEmptyJs() { + String js = jsBundle.compile(); + assertNotNull(js); + assertFalse(js.isBlank()); + } + + @Test + void compileContainsDomContentLoaded() { + String js = jsBundle.compile(); + assertTrue(js.contains("DOMContentLoaded")); + } + + @Test + void compileContainsNavToggle() { + String js = jsBundle.compile(); + assertTrue(js.contains("nav-toggle")); + } + + @Test + void compileContainsSmoothScroll() { + String js = jsBundle.compile(); + assertTrue(js.contains("scrollIntoView")); + } + + @Test + void compileContainsLightboxHandler() { + String js = jsBundle.compile(); + assertTrue(js.contains("lightbox")); + } + + @Test + void compileContainsContactFormHandler() { + String js = jsBundle.compile(); + assertTrue(js.contains("data-static")); + assertTrue(js.contains("Message sent")); + } +} diff --git a/backend/src/test/java/com/krrishg/engine/StyleCompilerTest.java b/backend/src/test/java/com/krrishg/engine/StyleCompilerTest.java new file mode 100644 index 0000000..1ab4c5b --- /dev/null +++ b/backend/src/test/java/com/krrishg/engine/StyleCompilerTest.java @@ -0,0 +1,44 @@ +package com.krrishg.engine; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class StyleCompilerTest { + + private final StyleCompiler compiler = new StyleCompiler(); + + @Test + void compileReturnsNonEmptyCss() { + String css = compiler.compile(); + assertNotNull(css); + assertFalse(css.isBlank()); + } + + @Test + void compileContainsCssVariables() { + String css = compiler.compile(); + assertTrue(css.contains("--color-text")); + assertTrue(css.contains("--color-background")); + assertTrue(css.contains("--font-body")); + } + + @Test + void compileContainsContainerMaxWidth() { + String css = compiler.compile(); + assertTrue(css.contains("--spacing-containerWidth")); + } + + @Test + void compileContainsResponsiveQueries() { + String css = compiler.compile(); + assertTrue(css.contains("@media (max-width: 768px)")); + assertTrue(css.contains("@media (max-width: 480px)")); + } + + @Test + void compileStartsWithUniversalReset() { + String css = compiler.compile(); + assertTrue(css.startsWith("* { margin: 0; padding: 0; box-sizing: border-box; }")); + } +} diff --git a/backend/src/test/java/com/krrishg/model/GitRemoteTest.java b/backend/src/test/java/com/krrishg/model/GitRemoteTest.java new file mode 100644 index 0000000..15a993c --- /dev/null +++ b/backend/src/test/java/com/krrishg/model/GitRemoteTest.java @@ -0,0 +1,70 @@ +package com.krrishg.model; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class GitRemoteTest { + + @Test + void onCreateSetsIdAndTimestamps() { + GitRemote remote = new GitRemote(); + remote.onCreate(); + + assertNotNull(remote.getId()); + assertNotNull(remote.getCreatedAt()); + assertNotNull(remote.getUpdatedAt()); + } + + @Test + void onCreateDoesNotOverrideExistingId() { + java.util.UUID existingId = java.util.UUID.randomUUID(); + GitRemote remote = GitRemote.builder() + .id(existingId) + .siteId(java.util.UUID.randomUUID()) + .provider(GitRemote.GitProvider.GITHUB) + .remoteUrl("https://github.com/user/repo.git") + .encryptedToken("token") + .build(); + + remote.onCreate(); + + assertEquals(existingId, remote.getId()); + } + + @Test + void onUpdateSetsUpdatedAt() { + GitRemote remote = GitRemote.builder() + .siteId(java.util.UUID.randomUUID()) + .provider(GitRemote.GitProvider.GITLAB) + .remoteUrl("https://gitlab.com/user/repo.git") + .encryptedToken("token") + .build(); + remote.onCreate(); + + java.time.LocalDateTime before = remote.getUpdatedAt(); + remote.onUpdate(); + + assertTrue(remote.getUpdatedAt().isAfter(before) || remote.getUpdatedAt().equals(before)); + } + + @Test + void builderSetsDefaults() { + GitRemote remote = GitRemote.builder() + .siteId(java.util.UUID.randomUUID()) + .provider(GitRemote.GitProvider.GITHUB) + .remoteUrl("https://github.com/user/repo.git") + .encryptedToken("token") + .build(); + + assertEquals("main", remote.getDefaultBranch()); + } + + @Test + void gitProviderEnumValues() { + assertArrayEquals( + new GitRemote.GitProvider[]{GitRemote.GitProvider.GITHUB, GitRemote.GitProvider.GITLAB}, + GitRemote.GitProvider.values() + ); + } +} diff --git a/backend/src/test/java/com/krrishg/repository/GitRemoteRepositoryTest.java b/backend/src/test/java/com/krrishg/repository/GitRemoteRepositoryTest.java new file mode 100644 index 0000000..61a9637 --- /dev/null +++ b/backend/src/test/java/com/krrishg/repository/GitRemoteRepositoryTest.java @@ -0,0 +1,91 @@ +package com.krrishg.repository; + +import com.krrishg.model.GitRemote; +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 GitRemoteRepositoryTest { + + @Autowired + private GitRemoteRepository repository; + + @Test + void findBySiteIdReturnsRemoteWhenExists() { + UUID siteId = UUID.randomUUID(); + GitRemote remote = GitRemote.builder() + .siteId(siteId) + .provider(GitRemote.GitProvider.GITHUB) + .remoteUrl("https://github.com/user/repo.git") + .encryptedToken("encrypted-token") + .build(); + repository.save(remote); + + Optional found = repository.findBySiteId(siteId); + + assertTrue(found.isPresent()); + assertEquals(siteId, found.get().getSiteId()); + } + + @Test + void findBySiteIdReturnsEmptyWhenNotExists() { + Optional found = repository.findBySiteId(UUID.randomUUID()); + assertTrue(found.isEmpty()); + } + + @Test + void existsBySiteIdReturnsTrueWhenExists() { + UUID siteId = UUID.randomUUID(); + GitRemote remote = GitRemote.builder() + .siteId(siteId) + .provider(GitRemote.GitProvider.GITLAB) + .remoteUrl("https://gitlab.com/user/repo.git") + .encryptedToken("encrypted-token") + .build(); + repository.save(remote); + + assertTrue(repository.existsBySiteId(siteId)); + } + + @Test + void existsBySiteIdReturnsFalseWhenNotExists() { + assertFalse(repository.existsBySiteId(UUID.randomUUID())); + } + + @Test + void deleteBySiteIdRemovesRemote() { + UUID siteId = UUID.randomUUID(); + GitRemote remote = GitRemote.builder() + .siteId(siteId) + .provider(GitRemote.GitProvider.GITHUB) + .remoteUrl("https://github.com/user/repo.git") + .encryptedToken("encrypted-token") + .build(); + repository.save(remote); + + repository.deleteBySiteId(siteId); + + assertFalse(repository.existsBySiteId(siteId)); + } + + @Test + void savedRemoteHasIdAndTimestamps() { + GitRemote remote = GitRemote.builder() + .siteId(UUID.randomUUID()) + .provider(GitRemote.GitProvider.GITHUB) + .remoteUrl("https://github.com/user/repo.git") + .encryptedToken("encrypted-token") + .build(); + GitRemote saved = repository.save(remote); + + assertNotNull(saved.getId()); + assertNotNull(saved.getCreatedAt()); + assertNotNull(saved.getUpdatedAt()); + } +} diff --git a/backend/src/test/java/com/krrishg/service/CustomOAuth2UserTest.java b/backend/src/test/java/com/krrishg/service/CustomOAuth2UserTest.java new file mode 100644 index 0000000..71adb72 --- /dev/null +++ b/backend/src/test/java/com/krrishg/service/CustomOAuth2UserTest.java @@ -0,0 +1,47 @@ +package com.krrishg.service; + +import com.krrishg.model.AuthProvider; +import com.krrishg.model.User; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class CustomOAuth2UserTest { + + private final User user = User.builder() + .id(UUID.randomUUID()) + .email("user@example.com") + .name("Test User") + .provider(AuthProvider.GOOGLE) + .providerId("google-123") + .build(); + + private final Map attributes = Map.of("sub", "google-123", "email", "user@example.com"); + + private final CustomOAuth2User oAuth2User = new CustomOAuth2User(user, attributes); + + @Test + void getNameReturnsUserId() { + assertEquals(user.getId().toString(), oAuth2User.getName()); + } + + @Test + void getAttributesReturnsProvidedMap() { + assertEquals(attributes, oAuth2User.getAttributes()); + } + + @Test + void getAuthoritiesContainsRoleUser() { + var authorities = oAuth2User.getAuthorities(); + assertEquals(1, authorities.size()); + assertEquals("ROLE_USER", authorities.iterator().next().getAuthority()); + } + + @Test + void getUserReturnsTheUser() { + assertSame(user, oAuth2User.getUser()); + } +} diff --git a/backend/src/test/java/com/krrishg/service/DomainServiceTest.java b/backend/src/test/java/com/krrishg/service/DomainServiceTest.java new file mode 100644 index 0000000..92f8796 --- /dev/null +++ b/backend/src/test/java/com/krrishg/service/DomainServiceTest.java @@ -0,0 +1,131 @@ +package com.krrishg.service; + +import com.krrishg.model.Site; +import com.krrishg.model.SiteStatus; +import com.krrishg.support.FakeSiteRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class DomainServiceTest { + + private static final String SITE_DOMAIN = "indie.example.com"; + + private FakeSiteRepository siteRepository; + private DomainService domainService; + + @BeforeEach + void setUp() { + siteRepository = new FakeSiteRepository(); + domainService = new DomainService(siteRepository, SITE_DOMAIN); + } + + @Test + void resolveSiteReturnsSiteWhenSubdomainMatches() { + Site site = Site.builder() + .userId(UUID.randomUUID()) + .name("My Site") + .subdomain("mysite") + .status(SiteStatus.DRAFT) + .build(); + siteRepository.save(site); + + Optional result = domainService.resolveSite("mysite.indie.example.com"); + + assertTrue(result.isPresent()); + assertEquals("mysite", result.get().getSubdomain()); + } + + @Test + void resolveSiteReturnsEmptyForUnknownSubdomain() { + Optional result = domainService.resolveSite("unknown.indie.example.com"); + assertTrue(result.isEmpty()); + } + + @Test + void resolveSiteReturnsEmptyForNonMatchingHostname() { + Optional result = domainService.resolveSite("example.com"); + assertTrue(result.isEmpty()); + } + + @Test + void resolveSiteReturnsEmptyWhenHostnameIsNull() { + Optional result = domainService.resolveSite(null); + assertTrue(result.isEmpty()); + } + + @Test + void resolveSiteReturnsEmptyWhenSubdomainContainsDots() { + Site site = Site.builder() + .userId(UUID.randomUUID()) + .name("My Site") + .subdomain("mysite") + .status(SiteStatus.DRAFT) + .build(); + siteRepository.save(site); + + Optional result = domainService.resolveSite("mysite.staging.indie.example.com"); + assertTrue(result.isEmpty()); + } + + @Test + void resolveSiteIsCaseInsensitiveOnHostname() { + Site site = Site.builder() + .userId(UUID.randomUUID()) + .name("My Site") + .subdomain("mysite") + .status(SiteStatus.DRAFT) + .build(); + siteRepository.save(site); + + Optional result = domainService.resolveSite("MySite.indie.example.com"); + assertTrue(result.isPresent()); + assertEquals("mysite", result.get().getSubdomain()); + } + + @Test + void isSubdomainAvailableReturnsTrueWhenNotTaken() { + assertTrue(domainService.isSubdomainAvailable("available")); + } + + @Test + void isSubdomainAvailableReturnsFalseWhenTaken() { + Site site = Site.builder() + .userId(UUID.randomUUID()) + .name("My Site") + .subdomain("taken") + .status(SiteStatus.DRAFT) + .build(); + siteRepository.save(site); + + assertFalse(domainService.isSubdomainAvailable("taken")); + } + + @Test + void getSiteUrlReturnsHttpsUrlWithSubdomain() { + Site site = Site.builder() + .userId(UUID.randomUUID()) + .name("My Site") + .subdomain("mysite") + .status(SiteStatus.DRAFT) + .build(); + + String url = domainService.getSiteUrl(site); + assertEquals("https://mysite.indie.example.com", url); + } + + @Test + void getSiteUrlReturnsNullWhenNoSubdomain() { + Site site = Site.builder() + .userId(UUID.randomUUID()) + .name("My Site") + .status(SiteStatus.DRAFT) + .build(); + + assertNull(domainService.getSiteUrl(site)); + } +} diff --git a/backend/src/test/java/com/krrishg/service/ExportServiceTest.java b/backend/src/test/java/com/krrishg/service/ExportServiceTest.java new file mode 100644 index 0000000..a4e40a7 --- /dev/null +++ b/backend/src/test/java/com/krrishg/service/ExportServiceTest.java @@ -0,0 +1,83 @@ +package com.krrishg.service; + +import com.krrishg.dto.SiteStructure; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class ExportServiceTest { + + private final RenderService renderService = new RenderService( + new com.krrishg.engine.StyleCompiler(), + new com.krrishg.engine.JsBundle()); + private final ExportService exportService = new ExportService(renderService); + + @Test + void exportSiteReturnsNonEmptyZip() throws Exception { + SiteStructure structure = SiteStructure.builder() + .theme(SiteStructure.ThemeConfig.builder() + .colors(Map.of("primary", "#4f46e5")) + .build()) + .pages(List.of(SiteStructure.PageStructure.builder() + .id("p1") + .title("Home") + .slug("home") + .rawHtml("

Hello

") + .build())) + .build(); + + byte[] result = exportService.exportSite(structure); + + assertNotNull(result); + assertTrue(result.length > 0); + assertTrue(isValidZip(result)); + } + + @Test + void exportSiteZipContainsExpectedFiles() throws Exception { + SiteStructure structure = SiteStructure.builder() + .theme(SiteStructure.ThemeConfig.builder().build()) + .pages(List.of(SiteStructure.PageStructure.builder() + .id("p1") + .title("Home") + .slug("home") + .rawHtml("

Hello

") + .build(), + SiteStructure.PageStructure.builder() + .id("p2") + .title("About") + .slug("about") + .rawHtml("

About

") + .build())) + .build(); + + byte[] result = exportService.exportSite(structure); + + var entries = getZipEntries(result); + assertTrue(entries.contains("style.css")); + assertTrue(entries.contains("script.js")); + assertTrue(entries.contains("index.html")); + assertTrue(entries.contains("about/index.html")); + assertTrue(entries.contains("404.html")); + } + + private boolean isValidZip(byte[] data) { + return data.length > 22 + && data[0] == 'P' && data[1] == 'K'; + } + + private java.util.Set getZipEntries(byte[] data) throws Exception { + java.util.Set names = new java.util.LinkedHashSet<>(); + try (java.util.zip.ZipInputStream zis = + new java.util.zip.ZipInputStream(new java.io.ByteArrayInputStream(data))) { + java.util.zip.ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + names.add(entry.getName()); + } + } + return names; + } +} diff --git a/backend/src/test/java/com/krrishg/service/PagesProviderTest.java b/backend/src/test/java/com/krrishg/service/PagesProviderTest.java new file mode 100644 index 0000000..60f5678 --- /dev/null +++ b/backend/src/test/java/com/krrishg/service/PagesProviderTest.java @@ -0,0 +1,124 @@ +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 PagesProviderTest { + + private final GitRemote gitHubRemote = GitRemote.builder() + .siteId(java.util.UUID.randomUUID()) + .provider(GitRemote.GitProvider.GITHUB) + .remoteUrl("https://github.com/username/my-site.git") + .encryptedToken("token") + .build(); + + private final GitRemote gitLabRemote = GitRemote.builder() + .siteId(java.util.UUID.randomUUID()) + .provider(GitRemote.GitProvider.GITLAB) + .remoteUrl("https://gitlab.com/username/my-site.git") + .encryptedToken("token") + .build(); + + @Test + void forRemoteReturnsGitHubPagesForGitHubUrl() { + PagesProvider provider = PagesProvider.forRemote(gitHubRemote); + assertInstanceOf(GitHubPagesProvider.class, provider); + } + + @Test + void forRemoteReturnsGitLabPagesForGitLabUrl() { + PagesProvider provider = PagesProvider.forRemote(gitLabRemote); + assertInstanceOf(GitLabPagesProvider.class, provider); + } + + @Test + void gitHubBranchNameIsGhPages() { + PagesProvider provider = PagesProvider.forRemote(gitHubRemote); + assertEquals("gh-pages", provider.branchName()); + } + + @Test + void gitLabBranchNameIsGlPages() { + PagesProvider provider = PagesProvider.forRemote(gitLabRemote); + assertEquals("gl-pages", provider.branchName()); + } + + @Test + void gitHubContentRootIsTempDir() { + PagesProvider provider = PagesProvider.forRemote(gitHubRemote); + Path tempDir = Path.of("/tmp/test"); + assertEquals(tempDir, provider.contentRoot(tempDir)); + } + + @Test + void gitLabContentRootIsPublicSubdir() { + PagesProvider provider = PagesProvider.forRemote(gitLabRemote); + Path tempDir = Path.of("/tmp/test"); + assertEquals(tempDir.resolve("public"), provider.contentRoot(tempDir)); + } + + @Test + void gitHubWritesNoJekyllFile(@TempDir Path tempDir) throws Exception { + PagesProvider provider = PagesProvider.forRemote(gitHubRemote); + provider.writeProviderFiles(tempDir); + + assertTrue(Files.exists(tempDir.resolve(".nojekyll"))); + assertTrue(Files.readString(tempDir.resolve(".nojekyll")).isEmpty()); + } + + @Test + void gitLabWritesCiYmlFile(@TempDir Path tempDir) throws Exception { + PagesProvider provider = PagesProvider.forRemote(gitLabRemote); + provider.writeProviderFiles(tempDir); + + Path ciYml = tempDir.resolve(".gitlab-ci.yml"); + assertTrue(Files.exists(ciYml)); + String content = Files.readString(ciYml); + assertTrue(content.contains("gl-pages")); + assertTrue(content.contains("public")); + } + + @Test + void gitHubBuildPagesUrl() { + PagesProvider provider = PagesProvider.forRemote(gitHubRemote); + String url = provider.buildPagesUrl(gitHubRemote); + assertEquals("https://username.github.io/my-site/", url); + } + + @Test + void gitLabBuildPagesUrl() { + PagesProvider provider = PagesProvider.forRemote(gitLabRemote); + String url = provider.buildPagesUrl(gitLabRemote); + assertEquals("https://username.gitlab.io/my-site/", url); + } + + @Test + void gitHubBuildPagesUrlHandlesTrailingSlash() { + GitRemote remote = GitRemote.builder() + .siteId(java.util.UUID.randomUUID()) + .provider(GitRemote.GitProvider.GITHUB) + .remoteUrl("https://github.com/username/my-site/") + .encryptedToken("token") + .build(); + PagesProvider provider = PagesProvider.forRemote(remote); + assertEquals("https://username.github.io/my-site/", provider.buildPagesUrl(remote)); + } + + @Test + void gitLabBuildPagesUrlHandlesSshUrl() { + GitRemote remote = GitRemote.builder() + .siteId(java.util.UUID.randomUUID()) + .provider(GitRemote.GitProvider.GITLAB) + .remoteUrl("git@gitlab.com:username/my-site.git") + .encryptedToken("token") + .build(); + PagesProvider provider = PagesProvider.forRemote(remote); + assertEquals("https://username.gitlab.io/my-site/", provider.buildPagesUrl(remote)); + } +} diff --git a/backend/src/test/java/com/krrishg/service/RenderServiceTest.java b/backend/src/test/java/com/krrishg/service/RenderServiceTest.java new file mode 100644 index 0000000..2fbab53 --- /dev/null +++ b/backend/src/test/java/com/krrishg/service/RenderServiceTest.java @@ -0,0 +1,203 @@ +package com.krrishg.service; + +import com.krrishg.dto.SiteStructure; +import com.krrishg.engine.JsBundle; +import com.krrishg.engine.StyleCompiler; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class RenderServiceTest { + + private final StyleCompiler styleCompiler = new StyleCompiler(); + private final JsBundle jsBundle = new JsBundle(); + private final RenderService renderService = new RenderService(styleCompiler, jsBundle); + + @Test + void renderSiteReturnsOutputWithCssAndJs() { + SiteStructure structure = SiteStructure.builder() + .theme(SiteStructure.ThemeConfig.builder() + .colors(Map.of("primary", "#4f46e5", "background", "#ffffff")) + .fonts(Map.of("body", "Inter")) + .spacing(Map.of("containerWidth", "1100px")) + .build()) + .pages(List.of(SiteStructure.PageStructure.builder() + .id("p1") + .title("Home") + .slug("home") + .rawHtml("

Hello

") + .build())) + .build(); + + RenderService.SiteOutput output = renderService.renderSite(structure); + + assertNotNull(output.css()); + assertNotNull(output.js()); + assertEquals(1, output.pages().size()); + } + + @Test + void renderSiteHandlesNullPages() { + SiteStructure structure = SiteStructure.builder() + .theme(SiteStructure.ThemeConfig.builder().build()) + .build(); + + RenderService.SiteOutput output = renderService.renderSite(structure); + + assertNotNull(output.pages()); + assertTrue(output.pages().isEmpty()); + } + + @Test + void renderSiteHandlesNullTheme() { + SiteStructure structure = SiteStructure.builder() + .pages(List.of(SiteStructure.PageStructure.builder() + .id("p1") + .title("Home") + .slug("home") + .rawHtml("

Hello

") + .build())) + .build(); + + RenderService.SiteOutput output = renderService.renderSite(structure); + + assertNotNull(output.css()); + assertEquals(1, output.pages().size()); + } + + @Test + void renderPageUsesSeoTitleWhenPresent() { + SiteStructure structure = SiteStructure.builder() + .theme(SiteStructure.ThemeConfig.builder().build()) + .pages(List.of(SiteStructure.PageStructure.builder() + .id("p1") + .title("Home") + .slug("home") + .seoTitle("Custom SEO Title") + .rawHtml("

Content

") + .build())) + .build(); + + RenderService.SiteOutput output = renderService.renderSite(structure); + + String html = output.pages().get("home"); + assertTrue(html.contains("Custom SEO Title")); + } + + @Test + void renderPageFallsBackToTitleWhenNoSeoTitle() { + SiteStructure structure = SiteStructure.builder() + .theme(SiteStructure.ThemeConfig.builder().build()) + .pages(List.of(SiteStructure.PageStructure.builder() + .id("p1") + .title("Fallback Title") + .slug("home") + .rawHtml("

Content

") + .build())) + .build(); + + RenderService.SiteOutput output = renderService.renderSite(structure); + + String html = output.pages().get("home"); + assertTrue(html.contains("Fallback Title")); + } + + @Test + void renderPageIncludesCssVars() { + SiteStructure structure = SiteStructure.builder() + .theme(SiteStructure.ThemeConfig.builder() + .colors(Map.of("primary", "#4f46e5")) + .fonts(Map.of("body", "Inter")) + .spacing(Map.of("containerWidth", "1100px")) + .build()) + .pages(List.of(SiteStructure.PageStructure.builder() + .id("p1") + .title("Home") + .slug("home") + .rawHtml("

Content

") + .build())) + .build(); + + RenderService.SiteOutput output = renderService.renderSite(structure); + + String html = output.pages().get("home"); + assertTrue(html.contains("--color-primary: #4f46e5")); + assertTrue(html.contains("--font-body: Inter")); + assertTrue(html.contains("--spacing-containerWidth: 1100px")); + } + + @Test + void siteOutputAsFileMapIncludesStyleAndScript() { + SiteStructure structure = SiteStructure.builder() + .theme(SiteStructure.ThemeConfig.builder().build()) + .pages(List.of(SiteStructure.PageStructure.builder() + .id("p1") + .title("Home") + .slug("home") + .rawHtml("

Content

") + .build())) + .build(); + + RenderService.SiteOutput output = renderService.renderSite(structure); + Map files = output.asFileMap(); + + assertTrue(files.containsKey("style.css")); + assertTrue(files.containsKey("script.js")); + assertTrue(files.containsKey("404.html")); + } + + @Test + void siteOutputAsFileMapPutsHomePageAsIndexHtml() { + SiteStructure structure = SiteStructure.builder() + .theme(SiteStructure.ThemeConfig.builder().build()) + .pages(List.of(SiteStructure.PageStructure.builder() + .id("p1") + .title("Home") + .slug("home") + .rawHtml("

Content

") + .build())) + .build(); + + RenderService.SiteOutput output = renderService.renderSite(structure); + Map files = output.asFileMap(); + + assertTrue(files.containsKey("index.html")); + } + + @Test + void siteOutputAsFileMapPutsNonHomePageInSubdir() { + SiteStructure structure = SiteStructure.builder() + .theme(SiteStructure.ThemeConfig.builder().build()) + .pages(List.of(SiteStructure.PageStructure.builder() + .id("p1") + .title("About") + .slug("about") + .rawHtml("

About us

") + .build())) + .build(); + + RenderService.SiteOutput output = renderService.renderSite(structure); + Map files = output.asFileMap(); + + assertTrue(files.containsKey("about/index.html")); + } + + @Test + void siteOutputAsFileMapContains404Html() { + SiteStructure structure = SiteStructure.builder() + .theme(SiteStructure.ThemeConfig.builder().build()) + .pages(List.of()) + .build(); + + RenderService.SiteOutput output = renderService.renderSite(structure); + Map files = output.asFileMap(); + + assertTrue(files.containsKey("404.html")); + String content = new String(files.get("404.html")); + assertTrue(content.contains("404")); + assertTrue(content.contains("Go Home")); + } +}