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>
213 lines
5.3 KiB
Go
213 lines
5.3 KiB
Go
package domain
|
|
|
|
import (
|
|
"testing"
|
|
)
|
|
|
|
func TestGenerateSlug(t *testing.T) {
|
|
t.Run("generates correct length", func(t *testing.T) {
|
|
slug, err := GenerateSlug()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(slug) != SlugLength {
|
|
t.Errorf("slug length = %d, want %d", len(slug), SlugLength)
|
|
}
|
|
})
|
|
|
|
t.Run("contains only allowed characters", func(t *testing.T) {
|
|
for i := 0; i < 100; i++ {
|
|
slug, err := GenerateSlug()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if err := ValidateSlug(slug); err != nil {
|
|
t.Errorf("generated slug %q failed validation: %v", slug, err)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("generates unique slugs", func(t *testing.T) {
|
|
seen := make(map[string]bool)
|
|
for i := 0; i < 1000; i++ {
|
|
slug, err := GenerateSlug()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if seen[slug] {
|
|
t.Errorf("duplicate slug generated: %s", slug)
|
|
}
|
|
seen[slug] = true
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestValidateSlug(t *testing.T) {
|
|
tests := []struct {
|
|
slug string
|
|
wantErr bool
|
|
}{
|
|
{"k7m2x9p4", false},
|
|
{"abcdefgh", false},
|
|
{"23456789", false},
|
|
{"", true}, // empty
|
|
{"short", true}, // too short
|
|
{"toolongslug", true}, // too long
|
|
{"ABCDEFGH", true}, // uppercase
|
|
{"k7m2x9p0", true}, // contains 0
|
|
{"k7m2x9p1", true}, // contains 1
|
|
{"k7m2x9po", true}, // contains o
|
|
{"k7m2x9pl", true}, // contains l
|
|
{"k7m2x9pi", true}, // contains i
|
|
{"k7m2-9p4", true}, // contains hyphen
|
|
{"k7m2_9p4", true}, // contains underscore
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.slug, func(t *testing.T) {
|
|
err := ValidateSlug(tt.slug)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("ValidateSlug(%q) error = %v, wantErr = %v", tt.slug, err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDomainType(t *testing.T) {
|
|
t.Run("Valid", func(t *testing.T) {
|
|
tests := []struct {
|
|
dtype DomainType
|
|
want bool
|
|
}{
|
|
{DomainTypePrimaryAuto, true},
|
|
{DomainTypePrimaryCustom, true},
|
|
{DomainTypeAlias, true},
|
|
{DomainType("invalid"), false},
|
|
{DomainType(""), false},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := tt.dtype.Valid(); got != tt.want {
|
|
t.Errorf("%q.Valid() = %v, want %v", tt.dtype, got, tt.want)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("IsPrimary", func(t *testing.T) {
|
|
tests := []struct {
|
|
dtype DomainType
|
|
want bool
|
|
}{
|
|
{DomainTypePrimaryAuto, true},
|
|
{DomainTypePrimaryCustom, true},
|
|
{DomainTypeAlias, false},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := tt.dtype.IsPrimary(); got != tt.want {
|
|
t.Errorf("%q.IsPrimary() = %v, want %v", tt.dtype, got, tt.want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestValidateSubdomain(t *testing.T) {
|
|
tests := []struct {
|
|
subdomain string
|
|
wantErr bool
|
|
}{
|
|
{"my-app", false},
|
|
{"myapp123", false},
|
|
{"a", false},
|
|
{"landing-page", false},
|
|
{"", true}, // empty
|
|
{"-myapp", true}, // starts with hyphen
|
|
{"123app", true}, // starts with number
|
|
{"my_app", true}, // contains underscore
|
|
{"MyApp", true}, // contains uppercase
|
|
{"my.app", true}, // contains dot
|
|
{"www", true}, // reserved
|
|
{"api", true}, // reserved
|
|
{"git", true}, // reserved
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.subdomain, func(t *testing.T) {
|
|
err := ValidateSubdomain(tt.subdomain)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("ValidateSubdomain(%q) error = %v, wantErr = %v", tt.subdomain, err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateFQDN(t *testing.T) {
|
|
tests := []struct {
|
|
domain string
|
|
wantErr bool
|
|
}{
|
|
{"example.com", false},
|
|
{"www.example.com", false},
|
|
{"sub.domain.example.com", false},
|
|
{"my-app.threesix.ai", false},
|
|
{"a.b.c", false},
|
|
{"", true},
|
|
{"example", false}, // single label is valid
|
|
{"-example.com", true}, // starts with hyphen
|
|
{"example-.com", true}, // ends with hyphen
|
|
{"exam ple.com", true}, // contains space
|
|
{"example..com", true}, // double dot
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.domain, func(t *testing.T) {
|
|
err := ValidateFQDN(tt.domain)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("ValidateFQDN(%q) error = %v, wantErr = %v", tt.domain, err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsSubdomainOf(t *testing.T) {
|
|
tests := []struct {
|
|
domain string
|
|
baseDomain string
|
|
want bool
|
|
}{
|
|
{"my-app.threesix.ai", "threesix.ai", true},
|
|
{"deep.sub.threesix.ai", "threesix.ai", true},
|
|
{"example.com", "threesix.ai", false},
|
|
{"threesix.ai", "threesix.ai", false}, // same domain, not subdomain
|
|
{"MY-APP.THREESIX.AI", "threesix.ai", true}, // case insensitive
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.domain, func(t *testing.T) {
|
|
if got := IsSubdomainOf(tt.domain, tt.baseDomain); got != tt.want {
|
|
t.Errorf("IsSubdomainOf(%q, %q) = %v, want %v", tt.domain, tt.baseDomain, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractSubdomain(t *testing.T) {
|
|
tests := []struct {
|
|
domain string
|
|
baseDomain string
|
|
want string
|
|
}{
|
|
{"my-app.threesix.ai", "threesix.ai", "my-app"},
|
|
{"deep.sub.threesix.ai", "threesix.ai", "deep.sub"},
|
|
{"example.com", "threesix.ai", ""},
|
|
{"threesix.ai", "threesix.ai", ""},
|
|
{"MY-APP.THREESIX.AI", "threesix.ai", "my-app"}, // case normalized
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.domain, func(t *testing.T) {
|
|
if got := ExtractSubdomain(tt.domain, tt.baseDomain); got != tt.want {
|
|
t.Errorf("ExtractSubdomain(%q, %q) = %q, want %q", tt.domain, tt.baseDomain, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|