rdev/internal/adapter/cached/project_repository_test.go
jordan 72d16929ca feat: Implement hexagonal architecture with services, webhooks, queue, and telemetry
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>
2026-01-25 19:57:46 -07:00

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)
}
}