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>
244 lines
7.6 KiB
Go
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())
|
|
}
|
|
})
|
|
}
|