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