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>
456 lines
12 KiB
Go
456 lines
12 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
)
|
|
|
|
// mockGitRepository implements port.GitRepository for testing.
|
|
type mockGitRepository struct {
|
|
repos map[string]*domain.Repo
|
|
err error
|
|
}
|
|
|
|
func newMockGitRepository() *mockGitRepository {
|
|
return &mockGitRepository{repos: make(map[string]*domain.Repo)}
|
|
}
|
|
|
|
func (m *mockGitRepository) CreateRepo(_ context.Context, name, description string, private bool) (*domain.Repo, error) {
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
repo := &domain.Repo{
|
|
ID: 1,
|
|
Owner: "threesix",
|
|
Name: name,
|
|
FullName: "threesix/" + name,
|
|
Description: description,
|
|
Private: private,
|
|
CloneSSH: fmt.Sprintf("git@git.threesix.ai:threesix/%s.git", name),
|
|
CloneHTTP: fmt.Sprintf("https://git.threesix.ai/threesix/%s.git", name),
|
|
HTMLURL: fmt.Sprintf("https://git.threesix.ai/threesix/%s", name),
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
m.repos[name] = repo
|
|
return repo, nil
|
|
}
|
|
|
|
func (m *mockGitRepository) DeleteRepo(_ context.Context, _, name string) error {
|
|
if m.err != nil {
|
|
return m.err
|
|
}
|
|
delete(m.repos, name)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockGitRepository) ListRepos(_ context.Context, _ string) ([]*domain.Repo, error) {
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
var repos []*domain.Repo
|
|
for _, r := range m.repos {
|
|
repos = append(repos, r)
|
|
}
|
|
return repos, nil
|
|
}
|
|
|
|
func (m *mockGitRepository) GetRepo(_ context.Context, _, name string) (*domain.Repo, error) {
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
r, ok := m.repos[name]
|
|
if !ok {
|
|
return nil, fmt.Errorf("repo not found: %s", name)
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
func (m *mockGitRepository) AddCollaborator(context.Context, string, string, string, string) error {
|
|
return m.err
|
|
}
|
|
func (m *mockGitRepository) RemoveCollaborator(context.Context, string, string, string) error {
|
|
return m.err
|
|
}
|
|
func (m *mockGitRepository) AddDeployKey(context.Context, string, string, string, string, bool) (*domain.DeployKey, error) {
|
|
return nil, m.err
|
|
}
|
|
func (m *mockGitRepository) DeleteDeployKey(context.Context, string, string, int64) error {
|
|
return m.err
|
|
}
|
|
func (m *mockGitRepository) CreateWebhook(context.Context, string, string, string, string, []string) (*domain.RepoWebhook, error) {
|
|
return nil, m.err
|
|
}
|
|
func (m *mockGitRepository) DeleteWebhook(context.Context, string, string, int64) error {
|
|
return m.err
|
|
}
|
|
|
|
// mockDNSProvider implements port.DNSProvider for testing.
|
|
type mockDNSProvider struct {
|
|
records map[string]*domain.DNSRecord
|
|
err error
|
|
}
|
|
|
|
func newMockDNSProvider() *mockDNSProvider {
|
|
return &mockDNSProvider{records: make(map[string]*domain.DNSRecord)}
|
|
}
|
|
|
|
func (m *mockDNSProvider) CreateRecord(_ context.Context, record domain.DNSRecord) (*domain.DNSRecord, error) {
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
record.ID = "rec-" + record.Name
|
|
m.records[record.Name] = &record
|
|
return &record, nil
|
|
}
|
|
|
|
func (m *mockDNSProvider) UpdateRecord(_ context.Context, recordID string, record domain.DNSRecord) (*domain.DNSRecord, error) {
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
record.ID = recordID
|
|
m.records[recordID] = &record
|
|
return &record, nil
|
|
}
|
|
|
|
func (m *mockDNSProvider) DeleteRecord(_ context.Context, recordID string) error {
|
|
if m.err != nil {
|
|
return m.err
|
|
}
|
|
delete(m.records, recordID)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockDNSProvider) DeleteRecordByName(_ context.Context, _, name string) error {
|
|
if m.err != nil {
|
|
return m.err
|
|
}
|
|
delete(m.records, name)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockDNSProvider) GetRecord(_ context.Context, recordID string) (*domain.DNSRecord, error) {
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
r, ok := m.records[recordID]
|
|
if !ok {
|
|
return nil, fmt.Errorf("record not found")
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
func (m *mockDNSProvider) ListRecords(_ context.Context, recordType string) ([]*domain.DNSRecord, error) {
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
var result []*domain.DNSRecord
|
|
for _, r := range m.records {
|
|
if recordType == "" || r.Type == recordType {
|
|
result = append(result, r)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (m *mockDNSProvider) FindRecord(_ context.Context, _, name string) (*domain.DNSRecord, error) {
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
r, ok := m.records[name]
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// mockDeployer implements port.Deployer for testing.
|
|
type mockDeployer struct {
|
|
deployments map[string]*domain.DeployStatus
|
|
logs string
|
|
err error
|
|
}
|
|
|
|
func newMockDeployer() *mockDeployer {
|
|
return &mockDeployer{deployments: make(map[string]*domain.DeployStatus)}
|
|
}
|
|
|
|
func (m *mockDeployer) Deploy(_ context.Context, spec domain.DeploySpec) error {
|
|
if m.err != nil {
|
|
return m.err
|
|
}
|
|
m.deployments[spec.ProjectName] = &domain.DeployStatus{
|
|
ProjectName: spec.ProjectName,
|
|
Image: spec.Image,
|
|
Replicas: spec.Replicas,
|
|
ReadyReplicas: 0,
|
|
URL: "https://" + spec.Domain,
|
|
Status: domain.DeploymentStatusPending,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockDeployer) Undeploy(_ context.Context, projectName string) error {
|
|
if m.err != nil {
|
|
return m.err
|
|
}
|
|
delete(m.deployments, projectName)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockDeployer) GetStatus(_ context.Context, projectName string) (*domain.DeployStatus, error) {
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
s, ok := m.deployments[projectName]
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (m *mockDeployer) Restart(_ context.Context, _ string) error {
|
|
return m.err
|
|
}
|
|
|
|
func (m *mockDeployer) Scale(_ context.Context, projectName string, replicas int) error {
|
|
if m.err != nil {
|
|
return m.err
|
|
}
|
|
if s, ok := m.deployments[projectName]; ok {
|
|
s.Replicas = replicas
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockDeployer) GetLogs(_ context.Context, _ string, _ int) (string, error) {
|
|
if m.err != nil {
|
|
return "", m.err
|
|
}
|
|
return m.logs, nil
|
|
}
|
|
|
|
func setupInfraHandler() (*InfrastructureHandler, *mockGitRepository, *mockDNSProvider, *mockDeployer, chi.Router) {
|
|
git := newMockGitRepository()
|
|
dns := newMockDNSProvider()
|
|
deployer := newMockDeployer()
|
|
h := NewInfrastructureHandler(git, dns, deployer, nil, nil, nil, InfrastructureConfig{
|
|
DefaultGitOwner: "threesix",
|
|
DefaultDomain: "threesix.ai",
|
|
ClusterIP: "208.122.204.172",
|
|
})
|
|
r := chi.NewRouter()
|
|
h.Mount(r)
|
|
return h, git, dns, deployer, r
|
|
}
|
|
|
|
func TestInfrastructureHandler_CreateRepo(t *testing.T) {
|
|
t.Run("success", func(t *testing.T) {
|
|
_, git, _, _, router := setupInfraHandler()
|
|
|
|
body, _ := json.Marshal(CreateRepoRequest{Description: "Test repo", Private: true})
|
|
req := httptest.NewRequest("POST", "/projects/myapp/repo", bytes.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusCreated {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusCreated)
|
|
}
|
|
if _, ok := git.repos["myapp"]; !ok {
|
|
t.Error("repo not created")
|
|
}
|
|
})
|
|
|
|
t.Run("invalid project id", func(t *testing.T) {
|
|
_, _, _, _, router := setupInfraHandler()
|
|
|
|
req := httptest.NewRequest("POST", "/projects/INVALID_NAME!/repo", bytes.NewReader([]byte("{}")))
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
}
|
|
})
|
|
|
|
t.Run("empty body allowed", func(t *testing.T) {
|
|
_, _, _, _, router := setupInfraHandler()
|
|
|
|
req := httptest.NewRequest("POST", "/projects/myapp/repo", bytes.NewReader([]byte("")))
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
// Should succeed with empty body (EOF is allowed)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusCreated)
|
|
}
|
|
})
|
|
|
|
t.Run("git not configured", func(t *testing.T) {
|
|
h := NewInfrastructureHandler(nil, nil, nil, nil, nil, nil, InfrastructureConfig{})
|
|
r := chi.NewRouter()
|
|
h.Mount(r)
|
|
|
|
req := httptest.NewRequest("POST", "/projects/myapp/repo", bytes.NewReader([]byte("{}")))
|
|
rec := httptest.NewRecorder()
|
|
r.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestInfrastructureHandler_GetRepo(t *testing.T) {
|
|
t.Run("found", func(t *testing.T) {
|
|
_, git, _, _, router := setupInfraHandler()
|
|
git.repos["myapp"] = &domain.Repo{
|
|
ID: 1, Owner: "threesix", Name: "myapp", FullName: "threesix/myapp",
|
|
CloneSSH: "git@git.threesix.ai:threesix/myapp.git",
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/projects/myapp/repo", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
})
|
|
|
|
t.Run("not found", func(t *testing.T) {
|
|
_, _, _, _, router := setupInfraHandler()
|
|
|
|
req := httptest.NewRequest("GET", "/projects/missing/repo", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusNotFound)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestInfrastructureHandler_DeleteRepo(t *testing.T) {
|
|
t.Run("success", func(t *testing.T) {
|
|
_, git, _, _, router := setupInfraHandler()
|
|
git.repos["myapp"] = &domain.Repo{ID: 1, Name: "myapp"}
|
|
|
|
req := httptest.NewRequest("DELETE", "/projects/myapp/repo", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestInfrastructureHandler_Deploy(t *testing.T) {
|
|
t.Run("success", func(t *testing.T) {
|
|
_, _, _, deployer, router := setupInfraHandler()
|
|
|
|
body, _ := json.Marshal(DeployRequest{
|
|
Image: "registry.threesix.ai/myapp:latest",
|
|
Port: 8080,
|
|
Replicas: 2,
|
|
})
|
|
req := httptest.NewRequest("POST", "/projects/myapp/deploy", bytes.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusCreated {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusCreated)
|
|
}
|
|
if _, ok := deployer.deployments["myapp"]; !ok {
|
|
t.Error("deployment not created")
|
|
}
|
|
})
|
|
|
|
t.Run("missing image", func(t *testing.T) {
|
|
_, _, _, _, router := setupInfraHandler()
|
|
|
|
body, _ := json.Marshal(DeployRequest{Port: 8080})
|
|
req := httptest.NewRequest("POST", "/projects/myapp/deploy", 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("deployer not configured", func(t *testing.T) {
|
|
h := NewInfrastructureHandler(nil, nil, nil, nil, nil, nil, InfrastructureConfig{})
|
|
r := chi.NewRouter()
|
|
h.Mount(r)
|
|
|
|
body, _ := json.Marshal(DeployRequest{Image: "myimage:latest"})
|
|
req := httptest.NewRequest("POST", "/projects/myapp/deploy", 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_GetDeployStatus(t *testing.T) {
|
|
t.Run("found", func(t *testing.T) {
|
|
_, _, _, deployer, router := setupInfraHandler()
|
|
deployer.deployments["myapp"] = &domain.DeployStatus{
|
|
ProjectName: "myapp",
|
|
Image: "myimage:latest",
|
|
Status: domain.DeploymentStatusRunning,
|
|
Replicas: 2,
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/projects/myapp/deploy/status", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
})
|
|
|
|
t.Run("not found", func(t *testing.T) {
|
|
_, _, _, _, router := setupInfraHandler()
|
|
|
|
req := httptest.NewRequest("GET", "/projects/missing/deploy/status", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusNotFound)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestInfrastructureHandler_Undeploy(t *testing.T) {
|
|
t.Run("success", func(t *testing.T) {
|
|
_, _, _, deployer, router := setupInfraHandler()
|
|
deployer.deployments["myapp"] = &domain.DeployStatus{ProjectName: "myapp"}
|
|
|
|
req := httptest.NewRequest("DELETE", "/projects/myapp/deploy", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
})
|
|
}
|