add tests
This commit is contained in:
425
PLAN-selfhosted.md
Normal file
425
PLAN-selfhosted.md
Normal file
@@ -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<SelfHostedConfig, UUID> {
|
||||||
|
Optional<SelfHostedConfig> findBySiteId(UUID siteId);
|
||||||
|
Optional<SelfHostedConfig> 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 <user>@<host> -p <port> -i <keyfile> "mkdir -p <deployPath>"`
|
||||||
|
5. `rsync -avz --delete -e "ssh -p <port> -i <keyfile> -o StrictHostKeyChecking=accept-new" <tempDir>/ <user>@<host>:<deployPath>/`
|
||||||
|
6. Generate nginx config string for the domain
|
||||||
|
7. Write nginx config to temp file, rsync it to `<nginxSitesPath>/<domain>` on server
|
||||||
|
8. `ssh <user>@<host> -i <keyfile> "sudo ln -sf <nginxSitesPath>/<domain> /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 <domain>;
|
||||||
|
root <deployPath>;
|
||||||
|
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 <deployPath>`
|
||||||
|
2. SSH `sudo rm <nginxSitesPath>/<domain> /etc/nginx/sites-enabled/<domain>`
|
||||||
|
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<SelfHostedConfigResponse> {
|
||||||
|
return this.http.get<SelfHostedConfigResponse>(`/api/sites/${siteId}/self-hosted`);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveSelfHostedConfig(siteId: string, config: SelfHostedConfigRequest): Observable<SelfHostedConfigResponse> {
|
||||||
|
return this.http.put<SelfHostedConfigResponse>(`/api/sites/${siteId}/self-hosted`, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSelfHostedConfig(siteId: string): Observable<void> {
|
||||||
|
return this.http.delete<void>(`/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)
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String> 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<String, String> 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<String, String> 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<String, String> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
48
backend/src/test/java/com/krrishg/engine/JsBundleTest.java
Normal file
48
backend/src/test/java/com/krrishg/engine/JsBundleTest.java
Normal file
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }"));
|
||||||
|
}
|
||||||
|
}
|
||||||
70
backend/src/test/java/com/krrishg/model/GitRemoteTest.java
Normal file
70
backend/src/test/java/com/krrishg/model/GitRemoteTest.java
Normal file
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GitRemote> found = repository.findBySiteId(siteId);
|
||||||
|
|
||||||
|
assertTrue(found.isPresent());
|
||||||
|
assertEquals(siteId, found.get().getSiteId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findBySiteIdReturnsEmptyWhenNotExists() {
|
||||||
|
Optional<GitRemote> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Object> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
131
backend/src/test/java/com/krrishg/service/DomainServiceTest.java
Normal file
131
backend/src/test/java/com/krrishg/service/DomainServiceTest.java
Normal file
@@ -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<Site> result = domainService.resolveSite("mysite.indie.example.com");
|
||||||
|
|
||||||
|
assertTrue(result.isPresent());
|
||||||
|
assertEquals("mysite", result.get().getSubdomain());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveSiteReturnsEmptyForUnknownSubdomain() {
|
||||||
|
Optional<Site> result = domainService.resolveSite("unknown.indie.example.com");
|
||||||
|
assertTrue(result.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveSiteReturnsEmptyForNonMatchingHostname() {
|
||||||
|
Optional<Site> result = domainService.resolveSite("example.com");
|
||||||
|
assertTrue(result.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveSiteReturnsEmptyWhenHostnameIsNull() {
|
||||||
|
Optional<Site> 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<Site> 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<Site> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("<h1>Hello</h1>")
|
||||||
|
.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("<h1>Hello</h1>")
|
||||||
|
.build(),
|
||||||
|
SiteStructure.PageStructure.builder()
|
||||||
|
.id("p2")
|
||||||
|
.title("About")
|
||||||
|
.slug("about")
|
||||||
|
.rawHtml("<p>About</p>")
|
||||||
|
.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<String> getZipEntries(byte[] data) throws Exception {
|
||||||
|
java.util.Set<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
124
backend/src/test/java/com/krrishg/service/PagesProviderTest.java
Normal file
124
backend/src/test/java/com/krrishg/service/PagesProviderTest.java
Normal file
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
203
backend/src/test/java/com/krrishg/service/RenderServiceTest.java
Normal file
203
backend/src/test/java/com/krrishg/service/RenderServiceTest.java
Normal file
@@ -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("<h1>Hello</h1>")
|
||||||
|
.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("<h1>Hello</h1>")
|
||||||
|
.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("<p>Content</p>")
|
||||||
|
.build()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
RenderService.SiteOutput output = renderService.renderSite(structure);
|
||||||
|
|
||||||
|
String html = output.pages().get("home");
|
||||||
|
assertTrue(html.contains("<title>Custom SEO Title</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("<p>Content</p>")
|
||||||
|
.build()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
RenderService.SiteOutput output = renderService.renderSite(structure);
|
||||||
|
|
||||||
|
String html = output.pages().get("home");
|
||||||
|
assertTrue(html.contains("<title>Fallback Title</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("<p>Content</p>")
|
||||||
|
.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("<p>Content</p>")
|
||||||
|
.build()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
RenderService.SiteOutput output = renderService.renderSite(structure);
|
||||||
|
Map<String, byte[]> 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("<p>Content</p>")
|
||||||
|
.build()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
RenderService.SiteOutput output = renderService.renderSite(structure);
|
||||||
|
Map<String, byte[]> 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("<p>About us</p>")
|
||||||
|
.build()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
RenderService.SiteOutput output = renderService.renderSite(structure);
|
||||||
|
Map<String, byte[]> 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<String, byte[]> 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user