rdev/internal/handlers/infrastructure_domains_test.go
jordan c86516c53a feat: Add multi-domain support with auto-generated slugs for landing page cookbook
Landing page cookbook implementation (Weeks 1-4):

Domain Infrastructure:
- Add project_domains table with migration (013_project_domains.sql)
- Add ProjectDomain model with domain types (primary_auto, primary_custom, alias)
- Add SlugGenerator and ProjectDomainRepository interfaces
- Implement postgres adapters for domain and slug management

Service Layer:
- Add domain CRUD methods to ProjectInfraService
- Generate 8-char random slugs for auto-domains
- Support custom subdomains during project creation
- Add site_live health check to project status
- Trigger CI build after template seeding

Handler Updates:
- Add DomainService interface and adapter pattern
- Rewrite domain handlers to use database-backed service
- Add proper error handling for duplicate/missing domains

CI Integration:
- Add TriggerBuild to CIProvider interface
- Implement TriggerBuild in Woodpecker adapter
- Manually trigger initial build after template seed

Cookbook & Scripts:
- Add landing-test.sh script for E2E testing
- Add release.sh for version releases
- Add logs.sh for quick log access

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 12:55:59 -07:00

244 lines
7.6 KiB
Go

package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/domain"
)
// mockDomainService implements DomainService for testing.
type mockDomainService struct {
domains map[string][]*domain.ProjectDomain // projectID -> domains
err error
}
func newMockDomainService() *mockDomainService {
return &mockDomainService{
domains: make(map[string][]*domain.ProjectDomain),
}
}
func (m *mockDomainService) ListDomains(ctx context.Context, projectID string) ([]*domain.ProjectDomain, error) {
if m.err != nil {
return nil, m.err
}
return m.domains[projectID], nil
}
func (m *mockDomainService) AddDomain(ctx context.Context, req DomainAddRequest) (*domain.ProjectDomain, error) {
if m.err != nil {
return nil, m.err
}
pd := &domain.ProjectDomain{
ID: int64(len(m.domains[req.ProjectID]) + 1),
ProjectID: req.ProjectID,
Domain: req.Domain,
Type: req.Type,
DNSRecordType: req.RecordType,
Verified: true,
}
m.domains[req.ProjectID] = append(m.domains[req.ProjectID], pd)
return pd, nil
}
func (m *mockDomainService) RemoveDomain(ctx context.Context, projectID, fqdn string) error {
if m.err != nil {
return m.err
}
domains := m.domains[projectID]
for i, d := range domains {
if d.Domain == fqdn {
// Check if it's the primary auto domain
if d.Type == domain.DomainTypePrimaryAuto {
return domain.ErrDomainNotFound // Mimic primary domain protection
}
m.domains[projectID] = append(domains[:i], domains[i+1:]...)
return nil
}
}
return domain.ErrDomainNotFound
}
func setupInfraDomainHandler() (*InfrastructureHandler, *mockDomainService, chi.Router) {
domainSvc := newMockDomainService()
h := NewInfrastructureHandler(nil, nil, nil, nil, nil, domainSvc, InfrastructureConfig{
DefaultGitOwner: "threesix",
DefaultDomain: "threesix.ai",
ClusterIP: "208.122.204.172",
})
r := chi.NewRouter()
h.Mount(r)
return h, domainSvc, r
}
func TestInfrastructureHandler_ListDomains(t *testing.T) {
t.Run("returns domains from database", func(t *testing.T) {
_, domainSvc, router := setupInfraDomainHandler()
// Add domains to mock service
domainSvc.domains["landing"] = []*domain.ProjectDomain{
{ID: 1, ProjectID: "landing", Domain: "abc12345.threesix.ai", Type: domain.DomainTypePrimaryAuto, DNSRecordType: "A", Verified: true},
{ID: 2, ProjectID: "landing", Domain: "landing.threesix.ai", Type: domain.DomainTypePrimaryCustom, DNSRecordType: "A", Verified: true},
}
req := httptest.NewRequest("GET", "/projects/landing/domains", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
data := resp["data"].(map[string]any)
total := int(data["total"].(float64))
if total != 2 {
t.Errorf("total = %d, want 2", total)
}
})
t.Run("domain service not configured", func(t *testing.T) {
h := NewInfrastructureHandler(nil, nil, nil, nil, nil, nil, InfrastructureConfig{
DefaultGitOwner: "threesix",
DefaultDomain: "threesix.ai",
ClusterIP: "208.122.204.172",
})
r := chi.NewRouter()
h.Mount(r)
req := httptest.NewRequest("GET", "/projects/myapp/domains", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
}
})
}
func TestInfrastructureHandler_AddDomainAlias(t *testing.T) {
t.Run("add domain alias", func(t *testing.T) {
_, domainSvc, router := setupInfraDomainHandler()
body, _ := json.Marshal(DomainAliasRequest{Domain: "www.threesix.ai"})
req := httptest.NewRequest("POST", "/projects/landing/domains", bytes.NewReader(body))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String())
}
if len(domainSvc.domains["landing"]) != 1 {
t.Errorf("domains = %d, want 1", len(domainSvc.domains["landing"]))
}
})
t.Run("add primary custom domain", func(t *testing.T) {
_, domainSvc, router := setupInfraDomainHandler()
body, _ := json.Marshal(DomainAliasRequest{
Domain: "mysite.threesix.ai",
DomainType: "primary_custom",
})
req := httptest.NewRequest("POST", "/projects/landing/domains", bytes.NewReader(body))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String())
}
if len(domainSvc.domains["landing"]) != 1 {
t.Errorf("domains = %d, want 1", len(domainSvc.domains["landing"]))
}
if domainSvc.domains["landing"][0].Type != domain.DomainTypePrimaryCustom {
t.Errorf("type = %s, want primary_custom", domainSvc.domains["landing"][0].Type)
}
})
t.Run("invalid type", func(t *testing.T) {
_, _, router := setupInfraDomainHandler()
body, _ := json.Marshal(DomainAliasRequest{Domain: "www.threesix.ai", Type: "MX"})
req := httptest.NewRequest("POST", "/projects/landing/domains", bytes.NewReader(body))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
})
t.Run("missing domain", func(t *testing.T) {
_, _, router := setupInfraDomainHandler()
body, _ := json.Marshal(DomainAliasRequest{})
req := httptest.NewRequest("POST", "/projects/landing/domains", bytes.NewReader(body))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
})
t.Run("domain service not configured", func(t *testing.T) {
h := NewInfrastructureHandler(nil, nil, nil, nil, nil, nil, InfrastructureConfig{
DefaultGitOwner: "threesix",
DefaultDomain: "threesix.ai",
ClusterIP: "208.122.204.172",
})
r := chi.NewRouter()
h.Mount(r)
body, _ := json.Marshal(DomainAliasRequest{Domain: "www.threesix.ai"})
req := httptest.NewRequest("POST", "/projects/landing/domains", bytes.NewReader(body))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
}
})
}
func TestInfrastructureHandler_RemoveDomainAlias(t *testing.T) {
t.Run("removes alias", func(t *testing.T) {
_, domainSvc, router := setupInfraDomainHandler()
domainSvc.domains["landing"] = []*domain.ProjectDomain{
{ID: 1, ProjectID: "landing", Domain: "www.threesix.ai", Type: domain.DomainTypeAlias, DNSRecordType: "A"},
}
req := httptest.NewRequest("DELETE", "/projects/landing/domains/www.threesix.ai", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
}
if len(domainSvc.domains["landing"]) != 0 {
t.Errorf("domains = %d, want 0", len(domainSvc.domains["landing"]))
}
})
t.Run("not found", func(t *testing.T) {
_, _, router := setupInfraDomainHandler()
req := httptest.NewRequest("DELETE", "/projects/landing/domains/nonexistent.threesix.ai", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String())
}
})
}