Major refactoring to hexagonal (ports & adapters) architecture: - Add service layer (apikey_service, project_service) for business logic - Add webhook system with dispatcher and delivery tracking - Add command queue with priority-based processing - Add rate limiting with sliding window algorithm - Add audit logging for command execution - Add OpenTelemetry integration (traces, metrics, spans) - Add circuit breaker for fault tolerance - Add cached repository wrapper for performance - Add comprehensive validation package - Add Kubernetes client integration for pod management - Add database migrations (allowed_ips, audit_log, rate_limiting, queue, webhooks) - Add network policy and PodDisruptionBudget for k8s - Remove legacy executor and projects/registry packages - Untrack secrets.yaml (now managed via envault) - Add coverage.out to .gitignore - Add e2e test infrastructure with docker-compose - Add comprehensive documentation (API, architecture, operations, plans) - Add golangci-lint config and pre-commit hook Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
365 lines
8.1 KiB
Go
365 lines
8.1 KiB
Go
package cached
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
)
|
|
|
|
// mockProjectRepository is a test double for port.ProjectRepository
|
|
type mockProjectRepository struct {
|
|
projects []domain.Project
|
|
listCalls int
|
|
refreshCalls int
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func (m *mockProjectRepository) List(ctx context.Context) ([]domain.Project, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.listCalls++
|
|
return m.projects, nil
|
|
}
|
|
|
|
func (m *mockProjectRepository) Get(ctx context.Context, id domain.ProjectID) (*domain.Project, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for i := range m.projects {
|
|
if m.projects[i].ID == id {
|
|
return &m.projects[i], nil
|
|
}
|
|
}
|
|
return nil, domain.ErrProjectNotFound
|
|
}
|
|
|
|
func (m *mockProjectRepository) Exists(ctx context.Context, id domain.ProjectID) (bool, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for _, p := range m.projects {
|
|
if p.ID == id {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func (m *mockProjectRepository) RefreshStatus(ctx context.Context) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.refreshCalls++
|
|
return nil
|
|
}
|
|
|
|
func (m *mockProjectRepository) Register(ctx context.Context, p *domain.Project) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.projects = append(m.projects, *p)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockProjectRepository) Unregister(ctx context.Context, id domain.ProjectID) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for i, p := range m.projects {
|
|
if p.ID == id {
|
|
m.projects = append(m.projects[:i], m.projects[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func TestCachedProjectRepository_List_Caches(t *testing.T) {
|
|
mock := &mockProjectRepository{
|
|
projects: []domain.Project{
|
|
{ID: "proj-1", Name: "Project 1"},
|
|
{ID: "proj-2", Name: "Project 2"},
|
|
},
|
|
}
|
|
|
|
repo := NewProjectRepository(mock, 1*time.Minute)
|
|
ctx := context.Background()
|
|
|
|
// First call should hit inner repository
|
|
projects1, err := repo.List(ctx)
|
|
if err != nil {
|
|
t.Fatalf("List() error = %v", err)
|
|
}
|
|
if len(projects1) != 2 {
|
|
t.Errorf("List() returned %d projects, want 2", len(projects1))
|
|
}
|
|
if mock.listCalls != 1 {
|
|
t.Errorf("Inner List called %d times, want 1", mock.listCalls)
|
|
}
|
|
|
|
// Second call should use cache
|
|
projects2, err := repo.List(ctx)
|
|
if err != nil {
|
|
t.Fatalf("List() error = %v", err)
|
|
}
|
|
if len(projects2) != 2 {
|
|
t.Errorf("List() returned %d projects, want 2", len(projects2))
|
|
}
|
|
if mock.listCalls != 1 {
|
|
t.Errorf("Inner List should not be called again, was called %d times", mock.listCalls)
|
|
}
|
|
}
|
|
|
|
func TestCachedProjectRepository_List_Expires(t *testing.T) {
|
|
mock := &mockProjectRepository{
|
|
projects: []domain.Project{
|
|
{ID: "proj-1", Name: "Project 1"},
|
|
},
|
|
}
|
|
|
|
// Very short TTL for testing
|
|
repo := NewProjectRepository(mock, 50*time.Millisecond)
|
|
ctx := context.Background()
|
|
|
|
// First call
|
|
_, _ = repo.List(ctx)
|
|
if mock.listCalls != 1 {
|
|
t.Errorf("Expected 1 call, got %d", mock.listCalls)
|
|
}
|
|
|
|
// Wait for cache to expire
|
|
time.Sleep(60 * time.Millisecond)
|
|
|
|
// Should hit inner repository again
|
|
_, _ = repo.List(ctx)
|
|
if mock.listCalls != 2 {
|
|
t.Errorf("Expected 2 calls after expiry, got %d", mock.listCalls)
|
|
}
|
|
}
|
|
|
|
func TestCachedProjectRepository_Get_UsesCache(t *testing.T) {
|
|
mock := &mockProjectRepository{
|
|
projects: []domain.Project{
|
|
{ID: "proj-1", Name: "Project 1"},
|
|
},
|
|
}
|
|
|
|
repo := NewProjectRepository(mock, 1*time.Minute)
|
|
ctx := context.Background()
|
|
|
|
// Warm the cache
|
|
_, _ = repo.List(ctx)
|
|
|
|
// Get should use cached data
|
|
project, err := repo.Get(ctx, "proj-1")
|
|
if err != nil {
|
|
t.Fatalf("Get() error = %v", err)
|
|
}
|
|
if project.Name != "Project 1" {
|
|
t.Errorf("Name = %q, want %q", project.Name, "Project 1")
|
|
}
|
|
|
|
// Should not have called List again
|
|
if mock.listCalls != 1 {
|
|
t.Errorf("Inner List called %d times, want 1", mock.listCalls)
|
|
}
|
|
}
|
|
|
|
func TestCachedProjectRepository_Get_NotFound(t *testing.T) {
|
|
mock := &mockProjectRepository{
|
|
projects: []domain.Project{
|
|
{ID: "proj-1", Name: "Project 1"},
|
|
},
|
|
}
|
|
|
|
repo := NewProjectRepository(mock, 1*time.Minute)
|
|
ctx := context.Background()
|
|
|
|
_, err := repo.Get(ctx, "nonexistent")
|
|
if err != domain.ErrProjectNotFound {
|
|
t.Errorf("Get(nonexistent) error = %v, want ErrProjectNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestCachedProjectRepository_Exists(t *testing.T) {
|
|
mock := &mockProjectRepository{
|
|
projects: []domain.Project{
|
|
{ID: "proj-1", Name: "Project 1"},
|
|
},
|
|
}
|
|
|
|
repo := NewProjectRepository(mock, 1*time.Minute)
|
|
ctx := context.Background()
|
|
|
|
exists, err := repo.Exists(ctx, "proj-1")
|
|
if err != nil {
|
|
t.Fatalf("Exists() error = %v", err)
|
|
}
|
|
if !exists {
|
|
t.Error("Exists(proj-1) = false, want true")
|
|
}
|
|
|
|
exists, err = repo.Exists(ctx, "nonexistent")
|
|
if err != nil {
|
|
t.Fatalf("Exists() error = %v", err)
|
|
}
|
|
if exists {
|
|
t.Error("Exists(nonexistent) = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestCachedProjectRepository_RefreshStatus_InvalidatesCache(t *testing.T) {
|
|
mock := &mockProjectRepository{
|
|
projects: []domain.Project{
|
|
{ID: "proj-1", Name: "Project 1"},
|
|
},
|
|
}
|
|
|
|
repo := NewProjectRepository(mock, 1*time.Minute)
|
|
ctx := context.Background()
|
|
|
|
// Warm cache
|
|
_, _ = repo.List(ctx)
|
|
if mock.listCalls != 1 {
|
|
t.Errorf("Expected 1 call, got %d", mock.listCalls)
|
|
}
|
|
|
|
// Refresh status should invalidate cache
|
|
_ = repo.RefreshStatus(ctx)
|
|
|
|
// Next List should hit inner repository
|
|
_, _ = repo.List(ctx)
|
|
if mock.listCalls != 2 {
|
|
t.Errorf("Expected 2 calls after RefreshStatus, got %d", mock.listCalls)
|
|
}
|
|
}
|
|
|
|
func TestCachedProjectRepository_Register_InvalidatesCache(t *testing.T) {
|
|
mock := &mockProjectRepository{
|
|
projects: []domain.Project{},
|
|
}
|
|
|
|
repo := NewProjectRepository(mock, 1*time.Minute)
|
|
ctx := context.Background()
|
|
|
|
// Warm cache
|
|
_, _ = repo.List(ctx)
|
|
|
|
// Register should invalidate cache
|
|
_ = repo.Register(ctx, &domain.Project{ID: "new-proj", Name: "New"})
|
|
|
|
// Next List should hit inner repository
|
|
_, _ = repo.List(ctx)
|
|
if mock.listCalls != 2 {
|
|
t.Errorf("Expected 2 calls after Register, got %d", mock.listCalls)
|
|
}
|
|
}
|
|
|
|
func TestCachedProjectRepository_Invalidate(t *testing.T) {
|
|
mock := &mockProjectRepository{
|
|
projects: []domain.Project{
|
|
{ID: "proj-1", Name: "Project 1"},
|
|
},
|
|
}
|
|
|
|
repo := NewProjectRepository(mock, 1*time.Minute)
|
|
ctx := context.Background()
|
|
|
|
// Warm cache
|
|
_, _ = repo.List(ctx)
|
|
|
|
// Manually invalidate
|
|
repo.Invalidate()
|
|
|
|
// Next List should hit inner repository
|
|
_, _ = repo.List(ctx)
|
|
if mock.listCalls != 2 {
|
|
t.Errorf("Expected 2 calls after Invalidate, got %d", mock.listCalls)
|
|
}
|
|
}
|
|
|
|
func TestCachedProjectRepository_CacheStats(t *testing.T) {
|
|
mock := &mockProjectRepository{
|
|
projects: []domain.Project{
|
|
{ID: "proj-1", Name: "Project 1"},
|
|
{ID: "proj-2", Name: "Project 2"},
|
|
},
|
|
}
|
|
|
|
repo := NewProjectRepository(mock, 1*time.Minute)
|
|
ctx := context.Background()
|
|
|
|
// Before warming
|
|
stats := repo.CacheStats()
|
|
if stats.IsFresh {
|
|
t.Error("Cache should not be fresh before List")
|
|
}
|
|
if stats.Size != 0 {
|
|
t.Errorf("Size = %d, want 0", stats.Size)
|
|
}
|
|
|
|
// After warming
|
|
_, _ = repo.List(ctx)
|
|
stats = repo.CacheStats()
|
|
if !stats.IsFresh {
|
|
t.Error("Cache should be fresh after List")
|
|
}
|
|
if stats.Size != 2 {
|
|
t.Errorf("Size = %d, want 2", stats.Size)
|
|
}
|
|
if stats.TTL != 1*time.Minute {
|
|
t.Errorf("TTL = %v, want 1m", stats.TTL)
|
|
}
|
|
}
|
|
|
|
func TestCachedProjectRepository_Concurrent(t *testing.T) {
|
|
mock := &mockProjectRepository{
|
|
projects: []domain.Project{
|
|
{ID: "proj-1", Name: "Project 1"},
|
|
},
|
|
}
|
|
|
|
repo := NewProjectRepository(mock, 50*time.Millisecond)
|
|
ctx := context.Background()
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
// Concurrent List calls
|
|
for i := 0; i < 50; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
repo.List(ctx)
|
|
}()
|
|
}
|
|
|
|
// Concurrent Get calls
|
|
for i := 0; i < 50; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
_, _ = repo.Get(ctx, "proj-1")
|
|
}()
|
|
}
|
|
|
|
// Concurrent Exists calls
|
|
for i := 0; i < 50; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
_, _ = repo.Exists(ctx, "proj-1")
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
// Test passes if no race/deadlock
|
|
}
|
|
|
|
func TestNewProjectRepository_DefaultTTL(t *testing.T) {
|
|
mock := &mockProjectRepository{}
|
|
repo := NewProjectRepository(mock, 0) // Zero TTL should use default
|
|
|
|
stats := repo.CacheStats()
|
|
if stats.TTL != 30*time.Second {
|
|
t.Errorf("TTL = %v, want 30s (default)", stats.TTL)
|
|
}
|
|
}
|