diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 3324b84..bfb67de 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -7,13 +7,16 @@ import ( "fmt" "net/http" "net/http/httptest" + "path/filepath" "strings" "sync" "testing" + "time" "github.com/UnityInFlow/releasewave/internal/config" "github.com/UnityInFlow/releasewave/internal/model" "github.com/UnityInFlow/releasewave/internal/provider" + "github.com/UnityInFlow/releasewave/internal/store" ) // mockProvider implements provider.Provider for testing. @@ -599,3 +602,690 @@ func TestUnknownRoute(t *testing.T) { t.Fatalf("expected 404 for unknown route, got %d", rec.Code) } } + +// newTestStore creates a temporary SQLite store for testing. +// The caller should defer cleanup by calling os.RemoveAll on the returned dir. +func newTestStore(t *testing.T) (*store.Store, string) { + t.Helper() + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + st, err := store.New(dbPath) + if err != nil { + t.Fatalf("failed to create test store: %v", err) + } + return st, dir +} + +// newTestHandlerWithStore creates an apiHandler wired up with a real store. +func newTestHandlerWithStore(cfg *config.Config, providers map[string]provider.Provider, st *store.Store) http.Handler { + return Handler(cfg, providers, st) +} + +func TestGetServiceReleases_WithStore_Success(t *testing.T) { + st, _ := newTestStore(t) + defer st.Close() + + // Record some releases for the service. + now := time.Now().UTC().Truncate(time.Second) + for i, tag := range []string{"v1.0.0", "v1.1.0", "v1.2.0"} { + err := st.RecordRelease(store.Release{ + Service: "my-svc", + Tag: tag, + Platform: "github", + URL: fmt.Sprintf("https://github.com/org/repo/releases/tag/%s", tag), + PublishedAt: now.Add(time.Duration(i) * time.Hour), + DiscoveredAt: now.Add(time.Duration(i) * time.Hour), + }) + if err != nil { + t.Fatalf("failed to record release %s: %v", tag, err) + } + } + + cfg := &config.Config{} + h := newTestHandlerWithStore(cfg, nil, st) + + req := httptest.NewRequest(http.MethodGet, "/v1/services/my-svc/releases", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d; body: %s", rec.Code, rec.Body.String()) + } + + var body struct { + Service string `json:"service"` + Total int `json:"total"` + Releases []struct { + Service string `json:"service"` + Tag string `json:"tag"` + Platform string `json:"platform"` + URL string `json:"url"` + } `json:"releases"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if body.Service != "my-svc" { + t.Fatalf("expected service 'my-svc', got %q", body.Service) + } + if body.Total != 3 { + t.Fatalf("expected total 3, got %d", body.Total) + } + if len(body.Releases) != 3 { + t.Fatalf("expected 3 releases, got %d", len(body.Releases)) + } +} + +func TestGetServiceReleases_WithStore_Empty(t *testing.T) { + st, _ := newTestStore(t) + defer st.Close() + + cfg := &config.Config{} + h := newTestHandlerWithStore(cfg, nil, st) + + req := httptest.NewRequest(http.MethodGet, "/v1/services/nonexistent/releases", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d; body: %s", rec.Code, rec.Body.String()) + } + + var body struct { + Service string `json:"service"` + Total int `json:"total"` + Releases []interface{} `json:"releases"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if body.Service != "nonexistent" { + t.Fatalf("expected service 'nonexistent', got %q", body.Service) + } + if body.Total != 0 { + t.Fatalf("expected total 0, got %d", body.Total) + } +} + +func TestGetServiceReleases_WithStore_ClosedDB(t *testing.T) { + st, _ := newTestStore(t) + // Close the store to force an error on GetHistory. + st.Close() + + cfg := &config.Config{} + h := newTestHandlerWithStore(cfg, nil, st) + + req := httptest.NewRequest(http.MethodGet, "/v1/services/my-svc/releases", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected 500 for closed DB, got %d; body: %s", rec.Code, rec.Body.String()) + } + + var body map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if !strings.Contains(body["error"], "failed to query history") { + t.Fatalf("expected 'failed to query history' error, got %q", body["error"]) + } +} + +func TestGetTimeline_WithStore_EmptyServices(t *testing.T) { + st, _ := newTestStore(t) + defer st.Close() + + cfg := &config.Config{ + Services: []config.ServiceConfig{}, + } + h := newTestHandlerWithStore(cfg, nil, st) + + req := httptest.NewRequest(http.MethodGet, "/v1/timeline", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d; body: %s", rec.Code, rec.Body.String()) + } + + var body struct { + Total int `json:"total"` + Timeline []interface{} `json:"timeline"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if body.Total != 0 { + t.Fatalf("expected total 0, got %d", body.Total) + } +} + +func TestGetTimeline_WithStore_SingleService(t *testing.T) { + st, _ := newTestStore(t) + defer st.Close() + + now := time.Now().UTC().Truncate(time.Second) + for i, tag := range []string{"v1.0.0", "v2.0.0"} { + err := st.RecordRelease(store.Release{ + Service: "api-svc", + Tag: tag, + Platform: "github", + URL: fmt.Sprintf("https://github.com/org/api-svc/releases/tag/%s", tag), + PublishedAt: now.Add(time.Duration(i) * time.Hour), + DiscoveredAt: now.Add(time.Duration(i) * time.Hour), + }) + if err != nil { + t.Fatalf("failed to record release: %v", err) + } + } + + cfg := &config.Config{ + Services: []config.ServiceConfig{ + {Name: "api-svc", Repo: "github.com/org/api-svc"}, + }, + } + h := newTestHandlerWithStore(cfg, nil, st) + + req := httptest.NewRequest(http.MethodGet, "/v1/timeline", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d; body: %s", rec.Code, rec.Body.String()) + } + + var body struct { + Total int `json:"total"` + Timeline []struct { + Service string `json:"service"` + Tag string `json:"tag"` + Platform string `json:"platform"` + URL string `json:"url"` + } `json:"timeline"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if body.Total != 2 { + t.Fatalf("expected total 2, got %d", body.Total) + } + if len(body.Timeline) != 2 { + t.Fatalf("expected 2 timeline entries, got %d", len(body.Timeline)) + } + + // Entries should be for the correct service. + for _, entry := range body.Timeline { + if entry.Service != "api-svc" { + t.Fatalf("expected service 'api-svc', got %q", entry.Service) + } + if entry.Platform != "github" { + t.Fatalf("expected platform 'github', got %q", entry.Platform) + } + } +} + +func TestGetTimeline_WithStore_MultipleServices(t *testing.T) { + st, _ := newTestStore(t) + defer st.Close() + + now := time.Now().UTC().Truncate(time.Second) + + // Record releases for two different services. + services := []struct { + name string + tags []string + }{ + {"frontend", []string{"v1.0.0", "v1.1.0"}}, + {"backend", []string{"v2.0.0", "v2.1.0", "v2.2.0"}}, + } + + for _, svc := range services { + for i, tag := range svc.tags { + err := st.RecordRelease(store.Release{ + Service: svc.name, + Tag: tag, + Platform: "github", + URL: fmt.Sprintf("https://github.com/org/%s/releases/tag/%s", svc.name, tag), + PublishedAt: now.Add(time.Duration(i) * time.Hour), + DiscoveredAt: now.Add(time.Duration(i) * time.Hour), + }) + if err != nil { + t.Fatalf("failed to record release %s/%s: %v", svc.name, tag, err) + } + } + } + + cfg := &config.Config{ + Services: []config.ServiceConfig{ + {Name: "frontend", Repo: "github.com/org/frontend"}, + {Name: "backend", Repo: "github.com/org/backend"}, + }, + } + h := newTestHandlerWithStore(cfg, nil, st) + + req := httptest.NewRequest(http.MethodGet, "/v1/timeline", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d; body: %s", rec.Code, rec.Body.String()) + } + + var body struct { + Total int `json:"total"` + Timeline []struct { + Service string `json:"service"` + Tag string `json:"tag"` + } `json:"timeline"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + // Should have 5 total entries (2 from frontend + 3 from backend). + if body.Total != 5 { + t.Fatalf("expected total 5, got %d", body.Total) + } + if len(body.Timeline) != 5 { + t.Fatalf("expected 5 timeline entries, got %d", len(body.Timeline)) + } + + // Count entries per service. + counts := make(map[string]int) + for _, entry := range body.Timeline { + counts[entry.Service]++ + } + if counts["frontend"] != 2 { + t.Fatalf("expected 2 frontend entries, got %d", counts["frontend"]) + } + if counts["backend"] != 3 { + t.Fatalf("expected 3 backend entries, got %d", counts["backend"]) + } +} + +func TestGetTimeline_WithStore_ClosedDB(t *testing.T) { + st, _ := newTestStore(t) + // Close the store to force an error path (the continue branch). + st.Close() + + cfg := &config.Config{ + Services: []config.ServiceConfig{ + {Name: "svc-a", Repo: "github.com/org/svc-a"}, + }, + } + h := newTestHandlerWithStore(cfg, nil, st) + + req := httptest.NewRequest(http.MethodGet, "/v1/timeline", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + // The timeline handler continues past errors, so it should return 200 + // with an empty timeline. + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 even with store error, got %d; body: %s", rec.Code, rec.Body.String()) + } + + var body struct { + Total int `json:"total"` + Timeline []interface{} `json:"timeline"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if body.Total != 0 { + t.Fatalf("expected total 0 when store errors, got %d", body.Total) + } +} + +func TestListServices_InvalidRepoFormat(t *testing.T) { + // Service with a repo that has fewer than 3 parts, which triggers + // the ParseRepo error path in listServices. + cfg := &config.Config{ + Services: []config.ServiceConfig{ + {Name: "bad-repo", Repo: "invalid"}, + }, + } + providers := map[string]provider.Provider{} + + h := newTestHandler(cfg, providers) + req := httptest.NewRequest(http.MethodGet, "/v1/services", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + + var body struct { + Services []struct { + Name string `json:"name"` + Error string `json:"error"` + } `json:"services"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if len(body.Services) != 1 { + t.Fatalf("expected 1 service, got %d", len(body.Services)) + } + if body.Services[0].Error == "" { + t.Fatal("expected error for invalid repo format") + } + if !strings.Contains(body.Services[0].Error, "invalid repo format") { + t.Fatalf("expected 'invalid repo format' error, got %q", body.Services[0].Error) + } +} + +func TestListServices_MultipleServicesWithMixedResults(t *testing.T) { + mock := &mockProvider{ + name: "github", + release: &model.Release{Tag: "v3.0.0"}, + } + + cfg := &config.Config{ + Services: []config.ServiceConfig{ + {Name: "good-svc", Repo: "github.com/org/good"}, + {Name: "bad-repo", Repo: "invalid"}, + {Name: "no-provider", Repo: "bitbucket.org/org/repo"}, + }, + } + providers := map[string]provider.Provider{"github": mock} + + h := newTestHandler(cfg, providers) + req := httptest.NewRequest(http.MethodGet, "/v1/services", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + + var body struct { + Total int `json:"total"` + Services []struct { + Name string `json:"name"` + Latest string `json:"latest_release"` + Error string `json:"error"` + } `json:"services"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if body.Total != 3 { + t.Fatalf("expected total 3, got %d", body.Total) + } + + // Find each service by name. + svcMap := make(map[string]struct { + Latest string + Error string + }) + for _, s := range body.Services { + svcMap[s.Name] = struct { + Latest string + Error string + }{s.Latest, s.Error} + } + + // good-svc should have a latest release and no error. + if svcMap["good-svc"].Latest != "v3.0.0" { + t.Fatalf("expected good-svc latest v3.0.0, got %q", svcMap["good-svc"].Latest) + } + if svcMap["good-svc"].Error != "" { + t.Fatalf("expected no error for good-svc, got %q", svcMap["good-svc"].Error) + } + + // bad-repo should have a ParseRepo error. + if svcMap["bad-repo"].Error == "" { + t.Fatal("expected error for bad-repo") + } + + // no-provider should have unsupported platform error. + if svcMap["no-provider"].Error != "unsupported platform" { + t.Fatalf("expected 'unsupported platform' for no-provider, got %q", svcMap["no-provider"].Error) + } +} + +func TestListServices_WithRegistry(t *testing.T) { + mock := &mockProvider{ + name: "github", + release: &model.Release{Tag: "v1.0.0"}, + } + + cfg := &config.Config{ + Services: []config.ServiceConfig{ + {Name: "with-reg", Repo: "github.com/org/repo", Registry: "ghcr.io/org/repo"}, + }, + } + providers := map[string]provider.Provider{"github": mock} + + h := newTestHandler(cfg, providers) + req := httptest.NewRequest(http.MethodGet, "/v1/services", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + + var body struct { + Services []struct { + Name string `json:"name"` + Registry string `json:"registry"` + } `json:"services"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if body.Services[0].Registry != "ghcr.io/org/repo" { + t.Fatalf("expected registry 'ghcr.io/org/repo', got %q", body.Services[0].Registry) + } +} + +func TestAddService_EmptyBody(t *testing.T) { + cfg := &config.Config{} + h := newTestHandler(cfg, nil) + + req := httptest.NewRequest(http.MethodPost, "/v1/services", strings.NewReader("")) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for empty body, got %d", rec.Code) + } + + var body map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if !strings.Contains(body["error"], "invalid JSON") { + t.Fatalf("expected 'invalid JSON' error for empty body, got %q", body["error"]) + } +} + +func TestAddService_BothFieldsEmpty(t *testing.T) { + cfg := &config.Config{} + h := newTestHandler(cfg, nil) + + payload := `{"name":"","repo":""}` + req := httptest.NewRequest(http.MethodPost, "/v1/services", strings.NewReader(payload)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } + + var body map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if !strings.Contains(body["error"], "name and repo are required") { + t.Fatalf("expected 'name and repo are required' error, got %q", body["error"]) + } +} + +func TestAddService_WithoutRegistry(t *testing.T) { + cfg := &config.Config{} + h := newTestHandler(cfg, nil) + + payload := `{"name":"no-reg","repo":"github.com/org/repo"}` + req := httptest.NewRequest(http.MethodPost, "/v1/services", strings.NewReader(payload)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d; body: %s", rec.Code, rec.Body.String()) + } + + if len(cfg.Services) != 1 { + t.Fatalf("expected 1 service, got %d", len(cfg.Services)) + } + if cfg.Services[0].Registry != "" { + t.Fatalf("expected empty registry, got %q", cfg.Services[0].Registry) + } +} + +func TestDeleteService_FromMultiple(t *testing.T) { + cfg := &config.Config{ + Services: []config.ServiceConfig{ + {Name: "svc-a", Repo: "github.com/org/a"}, + {Name: "svc-b", Repo: "github.com/org/b"}, + {Name: "svc-c", Repo: "github.com/org/c"}, + }, + } + h := newTestHandler(cfg, nil) + + // Delete the middle service. + req := httptest.NewRequest(http.MethodDelete, "/v1/services/svc-b", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + + if len(cfg.Services) != 2 { + t.Fatalf("expected 2 services remaining, got %d", len(cfg.Services)) + } + + // Verify the correct services remain. + names := make(map[string]bool) + for _, svc := range cfg.Services { + names[svc.Name] = true + } + if !names["svc-a"] || !names["svc-c"] { + t.Fatalf("expected svc-a and svc-c to remain, got %v", names) + } + if names["svc-b"] { + t.Fatal("svc-b should have been deleted") + } +} + +func TestDeleteService_VerifyResponseBody(t *testing.T) { + cfg := &config.Config{ + Services: []config.ServiceConfig{ + {Name: "my-service", Repo: "github.com/org/repo"}, + }, + } + h := newTestHandler(cfg, nil) + + req := httptest.NewRequest(http.MethodDelete, "/v1/services/my-service", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + + var body map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if body["status"] != "deleted" { + t.Fatalf("expected status 'deleted', got %q", body["status"]) + } + if body["name"] != "my-service" { + t.Fatalf("expected name 'my-service', got %q", body["name"]) + } +} + +func TestGetTimeline_WithStore_NoReleasesForService(t *testing.T) { + st, _ := newTestStore(t) + defer st.Close() + + // Config has a service, but we record no releases for it. + cfg := &config.Config{ + Services: []config.ServiceConfig{ + {Name: "empty-svc", Repo: "github.com/org/empty"}, + }, + } + h := newTestHandlerWithStore(cfg, nil, st) + + req := httptest.NewRequest(http.MethodGet, "/v1/timeline", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + + var body struct { + Total int `json:"total"` + Timeline []interface{} `json:"timeline"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if body.Total != 0 { + t.Fatalf("expected total 0, got %d", body.Total) + } +} + +func TestGetTimeline_ResponseContentType(t *testing.T) { + st, _ := newTestStore(t) + defer st.Close() + + cfg := &config.Config{ + Services: []config.ServiceConfig{ + {Name: "svc", Repo: "github.com/org/svc"}, + }, + } + h := newTestHandlerWithStore(cfg, nil, st) + + req := httptest.NewRequest(http.MethodGet, "/v1/timeline", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + ct := rec.Header().Get("Content-Type") + if ct != "application/json" { + t.Fatalf("expected Content-Type application/json, got %q", ct) + } +} + +func TestGetServiceReleases_ResponseContentType(t *testing.T) { + st, _ := newTestStore(t) + defer st.Close() + + cfg := &config.Config{} + h := newTestHandlerWithStore(cfg, nil, st) + + req := httptest.NewRequest(http.MethodGet, "/v1/services/svc/releases", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + ct := rec.Header().Get("Content-Type") + if ct != "application/json" { + t.Fatalf("expected Content-Type application/json, got %q", ct) + } +} + diff --git a/internal/discovery/discovery_test.go b/internal/discovery/discovery_test.go index 67c2d5f..9832271 100644 --- a/internal/discovery/discovery_test.go +++ b/internal/discovery/discovery_test.go @@ -154,3 +154,247 @@ func TestDiscoverFromWorkload_EmptyAnnotationRepo(t *testing.T) { t.Errorf("expected inferred repo, got %q", svcs[0].Repo) } } + +func TestNewK8sDiscoverer(t *testing.T) { + d := NewK8sDiscoverer("/path/to/kubeconfig", "my-context", "my-namespace") + if d.kubeconfig != "/path/to/kubeconfig" { + t.Errorf("expected kubeconfig '/path/to/kubeconfig', got %q", d.kubeconfig) + } + if d.context != "my-context" { + t.Errorf("expected context 'my-context', got %q", d.context) + } + if d.namespace != "my-namespace" { + t.Errorf("expected namespace 'my-namespace', got %q", d.namespace) + } +} + +func TestNewK8sDiscoverer_EmptyParams(t *testing.T) { + d := NewK8sDiscoverer("", "", "") + if d.kubeconfig != "" { + t.Errorf("expected empty kubeconfig, got %q", d.kubeconfig) + } + if d.context != "" { + t.Errorf("expected empty context, got %q", d.context) + } + if d.namespace != "" { + t.Errorf("expected empty namespace, got %q", d.namespace) + } +} + +func TestDiscoverFromWorkload_MultipleContainersAllInferable(t *testing.T) { + annotations := map[string]string{} + containers := []corev1.Container{ + {Image: "ghcr.io/org/frontend:v1.0"}, + {Image: "ghcr.io/org/backend:v2.0"}, + } + + svcs := discoverFromWorkload("my-app", annotations, containers) + + if len(svcs) != 2 { + t.Fatalf("expected 2 services, got %d", len(svcs)) + } + if svcs[0].Repo != "github.com/org/frontend" { + t.Errorf("expected first repo 'github.com/org/frontend', got %q", svcs[0].Repo) + } + if svcs[0].Name != "my-app" { + t.Errorf("expected first name 'my-app', got %q", svcs[0].Name) + } + if svcs[1].Repo != "github.com/org/backend" { + t.Errorf("expected second repo 'github.com/org/backend', got %q", svcs[1].Repo) + } +} + +func TestDiscoverFromWorkload_MixedContainersSomeInferable(t *testing.T) { + // One container is inferable (ghcr.io), the other is not (nginx). + // Exercises the `continue` branch when inferRepoFromImage returns "". + annotations := map[string]string{} + containers := []corev1.Container{ + {Image: "nginx:latest"}, + {Image: "ghcr.io/org/api:v1.0"}, + {Image: "busybox:1.36"}, + } + + svcs := discoverFromWorkload("mixed-deploy", annotations, containers) + + if len(svcs) != 1 { + t.Fatalf("expected 1 service (only ghcr.io inferable), got %d", len(svcs)) + } + if svcs[0].Repo != "github.com/org/api" { + t.Errorf("expected repo 'github.com/org/api', got %q", svcs[0].Repo) + } + if svcs[0].Registry != "ghcr.io/org/api" { + t.Errorf("expected registry 'ghcr.io/org/api', got %q", svcs[0].Registry) + } +} + +func TestDiscoverFromWorkload_InferredWithAnnotationName(t *testing.T) { + // When AnnotationRepo is absent but AnnotationName is set, + // the inferred path should use AnnotationName as the service name. + annotations := map[string]string{ + AnnotationName: "custom-service-name", + } + containers := []corev1.Container{ + {Image: "ghcr.io/org/myapp:v3.0"}, + } + + svcs := discoverFromWorkload("deploy-name", annotations, containers) + + if len(svcs) != 1 { + t.Fatalf("expected 1 service, got %d", len(svcs)) + } + if svcs[0].Name != "custom-service-name" { + t.Errorf("expected name 'custom-service-name', got %q", svcs[0].Name) + } + if svcs[0].Repo != "github.com/org/myapp" { + t.Errorf("expected inferred repo, got %q", svcs[0].Repo) + } +} + +func TestDiscoverFromWorkload_NilAnnotations(t *testing.T) { + // nil annotations map should not panic; Go map lookups on nil return zero value. + containers := []corev1.Container{ + {Image: "ghcr.io/org/service:v1"}, + } + + svcs := discoverFromWorkload("nil-ann", nil, containers) + + if len(svcs) != 1 { + t.Fatalf("expected 1 service, got %d", len(svcs)) + } + if svcs[0].Name != "nil-ann" { + t.Errorf("expected name 'nil-ann', got %q", svcs[0].Name) + } +} + +func TestDiscoverFromWorkload_NoContainersNoAnnotation(t *testing.T) { + svcs := discoverFromWorkload("empty", map[string]string{}, nil) + + if len(svcs) != 0 { + t.Errorf("expected 0 services, got %d", len(svcs)) + } +} + +func TestDiscoverFromWorkload_NilAnnotationsNilContainers(t *testing.T) { + svcs := discoverFromWorkload("empty", nil, nil) + + if len(svcs) != 0 { + t.Errorf("expected 0 services, got %d", len(svcs)) + } +} + +func TestDiscoverFromWorkload_AnnotationRepoIgnoresContainers(t *testing.T) { + // When AnnotationRepo is set, containers should be ignored entirely. + annotations := map[string]string{ + AnnotationRepo: "github.com/org/explicit", + } + containers := []corev1.Container{ + {Image: "ghcr.io/org/frontend:v1"}, + {Image: "ghcr.io/org/backend:v2"}, + } + + svcs := discoverFromWorkload("workload", annotations, containers) + + if len(svcs) != 1 { + t.Fatalf("expected 1 service from annotation (not containers), got %d", len(svcs)) + } + if svcs[0].Repo != "github.com/org/explicit" { + t.Errorf("expected annotated repo, got %q", svcs[0].Repo) + } + // Registry should not be set when using annotation path + if svcs[0].Registry != "" { + t.Errorf("expected empty registry for annotation path, got %q", svcs[0].Registry) + } +} + +func TestImageWithoutTag_AdditionalCases(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"empty string", "", ""}, + {"digest with tag", "ghcr.io/org/app:v1@sha256:abc", "ghcr.io/org/app"}, + {"port only no tag", "localhost:5000/app", "localhost:5000/app"}, + {"port with nested path", "registry.example.com:5000/org/app:v1", "registry.example.com:5000/org/app"}, + {"just registry and image", "ghcr.io/app:latest", "ghcr.io/app"}, + {"digest only no tag", "ghcr.io/org/app@sha256:deadbeef", "ghcr.io/org/app"}, + {"multiple colons with port and tag", "myhost:5000/org/img:v2.1", "myhost:5000/org/img"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := imageWithoutTag(tt.input) + if got != tt.want { + t.Errorf("imageWithoutTag(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestInferRepoFromImage_AdditionalCases(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"empty string", "", ""}, + {"single part", "nginx", ""}, + {"two parts no tag", "org/app", ""}, + {"ghcr no tag", "ghcr.io/org/app", "github.com/org/app"}, + {"docker.io library image", "docker.io/library/nginx:latest", "github.com/library/nginx"}, + {"gitlab with nested path", "registry.gitlab.com/group/project:v1", "gitlab.com/group/project"}, + {"ghcr with extra path segments", "ghcr.io/org/app/extra:v1", "github.com/org/app"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := inferRepoFromImage(tt.input) + if got != tt.want { + t.Errorf("inferRepoFromImage(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestDiscoverFromWorkload_InferredWithEmptyAnnotationName(t *testing.T) { + // AnnotationName present but empty string should fall back to workload name. + annotations := map[string]string{ + AnnotationName: "", + } + containers := []corev1.Container{ + {Image: "ghcr.io/org/svc:v1"}, + } + + svcs := discoverFromWorkload("workload-name", annotations, containers) + + if len(svcs) != 1 { + t.Fatalf("expected 1 service, got %d", len(svcs)) + } + if svcs[0].Name != "workload-name" { + t.Errorf("expected name 'workload-name' (empty annotation should not override), got %q", svcs[0].Name) + } +} + +func TestDiscoverFromWorkload_MultipleContainersWithGitLab(t *testing.T) { + annotations := map[string]string{} + containers := []corev1.Container{ + {Image: "registry.gitlab.com/team/api:v1.0"}, + {Image: "docker.io/org/worker:v2.0"}, + } + + svcs := discoverFromWorkload("multi-registry", annotations, containers) + + if len(svcs) != 2 { + t.Fatalf("expected 2 services, got %d", len(svcs)) + } + if svcs[0].Repo != "gitlab.com/team/api" { + t.Errorf("expected gitlab repo, got %q", svcs[0].Repo) + } + if svcs[0].Registry != "registry.gitlab.com/team/api" { + t.Errorf("expected gitlab registry, got %q", svcs[0].Registry) + } + if svcs[1].Repo != "github.com/org/worker" { + t.Errorf("expected docker.io -> github.com repo, got %q", svcs[1].Repo) + } + if svcs[1].Registry != "docker.io/org/worker" { + t.Errorf("expected docker.io registry, got %q", svcs[1].Registry) + } +} diff --git a/internal/provider/github/github_test.go b/internal/provider/github/github_test.go index 87c664e..252b09c 100644 --- a/internal/provider/github/github_test.go +++ b/internal/provider/github/github_test.go @@ -2,9 +2,15 @@ package github import ( "context" + "encoding/base64" + "errors" "net/http" "net/http/httptest" + "strings" "testing" + + rwerrors "github.com/UnityInFlow/releasewave/internal/errors" + "github.com/UnityInFlow/releasewave/internal/ratelimit" ) func fakeGitHubServer(t *testing.T) *httptest.Server { @@ -58,9 +64,104 @@ func fakeGitHubServer(t *testing.T) *httptest.Server { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`[]`)) + case "/repos/testorg/empty/tags": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + case "/repos/testorg/broken/releases": w.WriteHeader(http.StatusInternalServerError) + case "/repos/testorg/broken/releases/latest": + w.WriteHeader(http.StatusInternalServerError) + + case "/repos/testorg/broken/tags": + w.WriteHeader(http.StatusInternalServerError) + + // Invalid JSON responses for decode error paths + case "/repos/testorg/badjson/releases": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`not valid json`)) + + case "/repos/testorg/badjson/releases/latest": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`not valid json`)) + + case "/repos/testorg/badjson/tags": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`not valid json`)) + + // Rate limit response + case "/repos/testorg/ratelimited/releases": + w.WriteHeader(http.StatusTooManyRequests) + + // Auth error response + case "/repos/testorg/autherror/releases": + w.WriteHeader(http.StatusForbidden) + + // Invalid published_at time + case "/repos/testorg/badtime/releases": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{ + "tag_name": "v1.0.0", + "name": "Bad Time", + "body": "release with bad time", + "draft": false, + "prerelease": true, + "published_at": "not-a-date", + "html_url": "https://github.com/testorg/badtime/releases/tag/v1.0.0" + }]`)) + + case "/repos/testorg/badtime/releases/latest": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "tag_name": "v1.0.0", + "name": "Bad Time", + "body": "release with bad time", + "draft": false, + "prerelease": true, + "published_at": "not-a-date", + "html_url": "https://github.com/testorg/badtime/releases/tag/v1.0.0" + }`)) + + // Empty published_at (should not log warning) + case "/repos/testorg/emptytime/releases": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{ + "tag_name": "v0.1.0", + "name": "Draft", + "body": "", + "draft": true, + "prerelease": false, + "published_at": "", + "html_url": "" + }]`)) + + // File content endpoints + case "/repos/testorg/testrepo/contents/README.md": + w.Header().Set("Content-Type", "application/json") + encoded := base64.StdEncoding.EncodeToString([]byte("# Hello World")) + _, _ = w.Write([]byte(`{"content":"` + encoded + `","encoding":"base64"}`)) + + case "/repos/testorg/testrepo/contents/plain.txt": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"content":"plain text content","encoding":"none"}`)) + + case "/repos/testorg/testrepo/contents/bad-base64.txt": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"content":"!!!not-valid-base64!!!","encoding":"base64"}`)) + + case "/repos/testorg/testrepo/contents/bad-json.txt": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`not valid json`)) + + // Base64 with embedded newlines (GitHub does this) + case "/repos/testorg/testrepo/contents/multiline.txt": + w.Header().Set("Content-Type", "application/json") + raw := base64.StdEncoding.EncodeToString([]byte("line1\nline2\nline3")) + // Inject newlines to simulate GitHub's chunked base64 (escaped in JSON) + chunked := raw[:4] + `\n` + raw[4:] + _, _ = w.Write([]byte(`{"content":"` + chunked + `","encoding":"base64"}`)) + default: w.WriteHeader(http.StatusNotFound) } @@ -189,3 +290,340 @@ func TestListTags(t *testing.T) { t.Errorf("commit = %q, want %q", tags[0].Commit, "abc123def456") } } + +func TestName(t *testing.T) { + client := New("token") + if got := client.Name(); got != "github" { + t.Errorf("Name() = %q, want %q", got, "github") + } +} + +func TestWithRateLimiter(t *testing.T) { + limiter := ratelimit.New(10, 10) + client := New("token", WithRateLimiter(limiter)) + if client.limiter != limiter { + t.Error("WithRateLimiter did not set limiter") + } +} + +func TestListTags_HTTPError(t *testing.T) { + server := fakeGitHubServer(t) + defer server.Close() + client := newTestClient(server.URL) + + _, err := client.ListTags(context.Background(), "testorg", "broken") + if err == nil { + t.Fatal("expected error for server error, got nil") + } + if !strings.Contains(err.Error(), "list tags") { + t.Errorf("error = %q, want it to contain 'list tags'", err.Error()) + } +} + +func TestListTags_EmptyResponse(t *testing.T) { + server := fakeGitHubServer(t) + defer server.Close() + client := newTestClient(server.URL) + + tags, err := client.ListTags(context.Background(), "testorg", "empty") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(tags) != 0 { + t.Errorf("got %d tags, want 0", len(tags)) + } +} + +func TestListTags_InvalidJSON(t *testing.T) { + server := fakeGitHubServer(t) + defer server.Close() + client := newTestClient(server.URL) + + _, err := client.ListTags(context.Background(), "testorg", "badjson") + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } + if !strings.Contains(err.Error(), "parse tags JSON") { + t.Errorf("error = %q, want it to contain 'parse tags JSON'", err.Error()) + } +} + +func TestListTags_NotFound(t *testing.T) { + server := fakeGitHubServer(t) + defer server.Close() + client := newTestClient(server.URL) + + _, err := client.ListTags(context.Background(), "testorg", "doesnotexist") + if err == nil { + t.Fatal("expected error for 404, got nil") + } + var provErr *rwerrors.ProviderError + if !errors.As(err, &provErr) { + t.Fatalf("expected ProviderError, got %T: %v", err, err) + } + if provErr.Status != http.StatusNotFound { + t.Errorf("status = %d, want %d", provErr.Status, http.StatusNotFound) + } +} + +func TestListReleases_InvalidJSON(t *testing.T) { + server := fakeGitHubServer(t) + defer server.Close() + client := newTestClient(server.URL) + + _, err := client.ListReleases(context.Background(), "testorg", "badjson") + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } + if !strings.Contains(err.Error(), "parse releases JSON") { + t.Errorf("error = %q, want it to contain 'parse releases JSON'", err.Error()) + } +} + +func TestListReleases_InvalidTime(t *testing.T) { + server := fakeGitHubServer(t) + defer server.Close() + client := newTestClient(server.URL) + + releases, err := client.ListReleases(context.Background(), "testorg", "badtime") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(releases) != 1 { + t.Fatalf("got %d releases, want 1", len(releases)) + } + if releases[0].Tag != "v1.0.0" { + t.Errorf("tag = %q, want %q", releases[0].Tag, "v1.0.0") + } + if !releases[0].Prerelease { + t.Error("expected prerelease=true") + } + // PublishedAt should be zero value since the time is invalid + if !releases[0].PublishedAt.IsZero() { + t.Errorf("expected zero PublishedAt for invalid time, got %v", releases[0].PublishedAt) + } +} + +func TestListReleases_EmptyPublishedAt(t *testing.T) { + server := fakeGitHubServer(t) + defer server.Close() + client := newTestClient(server.URL) + + releases, err := client.ListReleases(context.Background(), "testorg", "emptytime") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(releases) != 1 { + t.Fatalf("got %d releases, want 1", len(releases)) + } + if !releases[0].Draft { + t.Error("expected draft=true") + } + if !releases[0].PublishedAt.IsZero() { + t.Errorf("expected zero PublishedAt for empty string, got %v", releases[0].PublishedAt) + } +} + +func TestListReleases_RateLimitError(t *testing.T) { + server := fakeGitHubServer(t) + defer server.Close() + client := newTestClient(server.URL) + + _, err := client.ListReleases(context.Background(), "testorg", "ratelimited") + if err == nil { + t.Fatal("expected error for rate limit, got nil") + } + var provErr *rwerrors.ProviderError + if !errors.As(err, &provErr) { + t.Fatalf("expected ProviderError, got %T: %v", err, err) + } + if provErr.Status != http.StatusTooManyRequests { + t.Errorf("status = %d, want %d", provErr.Status, http.StatusTooManyRequests) + } + if !rwerrors.IsRateLimit(err) { + t.Error("expected IsRateLimit to return true") + } +} + +func TestListReleases_AuthError(t *testing.T) { + server := fakeGitHubServer(t) + defer server.Close() + client := newTestClient(server.URL) + + _, err := client.ListReleases(context.Background(), "testorg", "autherror") + if err == nil { + t.Fatal("expected error for auth failure, got nil") + } + var provErr *rwerrors.ProviderError + if !errors.As(err, &provErr) { + t.Fatalf("expected ProviderError, got %T: %v", err, err) + } + if provErr.Status != http.StatusForbidden { + t.Errorf("status = %d, want %d", provErr.Status, http.StatusForbidden) + } + if !rwerrors.IsAuth(err) { + t.Error("expected IsAuth to return true") + } +} + +func TestGetLatestRelease_InvalidJSON(t *testing.T) { + server := fakeGitHubServer(t) + defer server.Close() + client := newTestClient(server.URL) + + _, err := client.GetLatestRelease(context.Background(), "testorg", "badjson") + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } + if !strings.Contains(err.Error(), "parse release JSON") { + t.Errorf("error = %q, want it to contain 'parse release JSON'", err.Error()) + } +} + +func TestGetLatestRelease_ServerError(t *testing.T) { + server := fakeGitHubServer(t) + defer server.Close() + client := newTestClient(server.URL) + + _, err := client.GetLatestRelease(context.Background(), "testorg", "broken") + if err == nil { + t.Fatal("expected error for server error, got nil") + } + if !strings.Contains(err.Error(), "get latest release") { + t.Errorf("error = %q, want it to contain 'get latest release'", err.Error()) + } +} + +func TestGetLatestRelease_InvalidTime(t *testing.T) { + server := fakeGitHubServer(t) + defer server.Close() + client := newTestClient(server.URL) + + release, err := client.GetLatestRelease(context.Background(), "testorg", "badtime") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if release.Tag != "v1.0.0" { + t.Errorf("tag = %q, want %q", release.Tag, "v1.0.0") + } + if !release.PublishedAt.IsZero() { + t.Errorf("expected zero PublishedAt for invalid time, got %v", release.PublishedAt) + } +} + +func TestGetFileContent_Base64(t *testing.T) { + server := fakeGitHubServer(t) + defer server.Close() + client := newTestClient(server.URL) + + content, err := client.GetFileContent(context.Background(), "testorg", "testrepo", "README.md") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(content) != "# Hello World" { + t.Errorf("content = %q, want %q", string(content), "# Hello World") + } +} + +func TestGetFileContent_NonBase64Encoding(t *testing.T) { + server := fakeGitHubServer(t) + defer server.Close() + client := newTestClient(server.URL) + + content, err := client.GetFileContent(context.Background(), "testorg", "testrepo", "plain.txt") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(content) != "plain text content" { + t.Errorf("content = %q, want %q", string(content), "plain text content") + } +} + +func TestGetFileContent_InvalidBase64(t *testing.T) { + server := fakeGitHubServer(t) + defer server.Close() + client := newTestClient(server.URL) + + _, err := client.GetFileContent(context.Background(), "testorg", "testrepo", "bad-base64.txt") + if err == nil { + t.Fatal("expected error for invalid base64, got nil") + } + if !strings.Contains(err.Error(), "decode base64 content") { + t.Errorf("error = %q, want it to contain 'decode base64 content'", err.Error()) + } +} + +func TestGetFileContent_InvalidJSON(t *testing.T) { + server := fakeGitHubServer(t) + defer server.Close() + client := newTestClient(server.URL) + + _, err := client.GetFileContent(context.Background(), "testorg", "testrepo", "bad-json.txt") + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } + if !strings.Contains(err.Error(), "parse file response") { + t.Errorf("error = %q, want it to contain 'parse file response'", err.Error()) + } +} + +func TestGetFileContent_NotFound(t *testing.T) { + server := fakeGitHubServer(t) + defer server.Close() + client := newTestClient(server.URL) + + _, err := client.GetFileContent(context.Background(), "testorg", "testrepo", "nonexistent.txt") + if err == nil { + t.Fatal("expected error for missing file, got nil") + } + if !strings.Contains(err.Error(), "get file content") { + t.Errorf("error = %q, want it to contain 'get file content'", err.Error()) + } + if !rwerrors.IsNotFound(err) { + t.Error("expected IsNotFound to return true") + } +} + +func TestGetFileContent_Base64WithNewlines(t *testing.T) { + server := fakeGitHubServer(t) + defer server.Close() + client := newTestClient(server.URL) + + content, err := client.GetFileContent(context.Background(), "testorg", "testrepo", "multiline.txt") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(content) != "line1\nline2\nline3" { + t.Errorf("content = %q, want %q", string(content), "line1\nline2\nline3") + } +} + +func TestDoRequest_CancelledContext(t *testing.T) { + server := fakeGitHubServer(t) + defer server.Close() + client := newTestClient(server.URL) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + _, err := client.ListReleases(ctx, "testorg", "testrepo") + if err == nil { + t.Fatal("expected error for cancelled context, got nil") + } +} + +func TestDoRequest_NoToken(t *testing.T) { + server := fakeGitHubServer(t) + defer server.Close() + // Create client with empty token + client := New("", WithBaseURL(server.URL)) + + // Should still work - just no auth header + releases, err := client.ListReleases(context.Background(), "testorg", "testrepo") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(releases) != 2 { + t.Errorf("got %d releases, want 2", len(releases)) + } +} diff --git a/internal/provider/gitlab/gitlab_test.go b/internal/provider/gitlab/gitlab_test.go index 4490ce5..66e9724 100644 --- a/internal/provider/gitlab/gitlab_test.go +++ b/internal/provider/gitlab/gitlab_test.go @@ -2,9 +2,15 @@ package gitlab import ( "context" + "encoding/base64" + "errors" "net/http" "net/http/httptest" + "strings" "testing" + + rwerrors "github.com/UnityInFlow/releasewave/internal/errors" + "github.com/UnityInFlow/releasewave/internal/ratelimit" ) func fakeGitLabServer(t *testing.T) *httptest.Server { @@ -52,10 +58,98 @@ func fakeGitLabServer(t *testing.T) *httptest.Server { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`[]`)) + case "/projects/testorg%2Fempty/repository/tags", + "/projects/testorg/empty/repository/tags": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + case "/projects/testorg%2Fbroken/releases", "/projects/testorg/broken/releases": w.WriteHeader(http.StatusInternalServerError) + case "/projects/testorg%2Fbroken/repository/tags", + "/projects/testorg/broken/repository/tags": + w.WriteHeader(http.StatusInternalServerError) + + // Invalid JSON responses + case "/projects/testorg%2Fbadjson/releases", + "/projects/testorg/badjson/releases": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`not valid json`)) + + case "/projects/testorg%2Fbadjson/repository/tags", + "/projects/testorg/badjson/repository/tags": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`not valid json`)) + + // Rate limit response + case "/projects/testorg%2Fratelimited/releases", + "/projects/testorg/ratelimited/releases": + w.WriteHeader(http.StatusTooManyRequests) + + // Auth error response + case "/projects/testorg%2Fautherror/releases", + "/projects/testorg/autherror/releases": + w.WriteHeader(http.StatusForbidden) + + // Invalid released_at time + case "/projects/testorg%2Fbadtime/releases", + "/projects/testorg/badtime/releases": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{ + "tag_name": "v1.0.0", + "name": "Bad Time", + "description": "release with bad time", + "released_at": "not-a-date", + "upcoming_release": true, + "_links": {"self": "https://gitlab.com/api/v4/projects/1/releases/v1.0.0"} + }]`)) + + // Empty released_at + case "/projects/testorg%2Femptytime/releases", + "/projects/testorg/emptytime/releases": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{ + "tag_name": "v0.1.0", + "name": "Draft", + "description": "", + "released_at": "", + "upcoming_release": false, + "_links": {"self": ""} + }]`)) + + // File content endpoints - base64 encoded + case "/projects/testorg%2Ftestrepo/repository/files/README.md", + "/projects/testorg/testrepo/repository/files/README.md": + w.Header().Set("Content-Type", "application/json") + encoded := base64.StdEncoding.EncodeToString([]byte("# Hello GitLab")) + _, _ = w.Write([]byte(`{"content":"` + encoded + `","encoding":"base64"}`)) + + // File content - plain encoding + case "/projects/testorg%2Ftestrepo/repository/files/plain.txt", + "/projects/testorg/testrepo/repository/files/plain.txt": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"content":"plain text content","encoding":"none"}`)) + + // File content - invalid base64 + case "/projects/testorg%2Ftestrepo/repository/files/bad-base64.txt", + "/projects/testorg/testrepo/repository/files/bad-base64.txt": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"content":"!!!not-valid-base64!!!","encoding":"base64"}`)) + + // File content - invalid JSON + case "/projects/testorg%2Ftestrepo/repository/files/bad-json.txt", + "/projects/testorg/testrepo/repository/files/bad-json.txt": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`not valid json`)) + + // File with path encoding (nested path) + case "/projects/testorg%2Ftestrepo/repository/files/src%2Fmain.go", + "/projects/testorg/testrepo/repository/files/src%2Fmain.go": + w.Header().Set("Content-Type", "application/json") + encoded := base64.StdEncoding.EncodeToString([]byte("package main")) + _, _ = w.Write([]byte(`{"content":"` + encoded + `","encoding":"base64"}`)) + default: w.WriteHeader(http.StatusNotFound) } @@ -177,3 +271,328 @@ func TestProjectPath(t *testing.T) { t.Errorf("projectPath = %q, want %q", result, expected) } } + +func TestName(t *testing.T) { + client := New("token") + if got := client.Name(); got != "gitlab" { + t.Errorf("Name() = %q, want %q", got, "gitlab") + } +} + +func TestWithRateLimiter(t *testing.T) { + limiter := ratelimit.New(10, 10) + client := New("token", WithRateLimiter(limiter)) + if client.limiter != limiter { + t.Error("WithRateLimiter did not set limiter") + } +} + +func TestListTags_HTTPError(t *testing.T) { + server := fakeGitLabServer(t) + defer server.Close() + client := newTestClient(server.URL) + + _, err := client.ListTags(context.Background(), "testorg", "broken") + if err == nil { + t.Fatal("expected error for server error, got nil") + } + if !strings.Contains(err.Error(), "list tags") { + t.Errorf("error = %q, want it to contain 'list tags'", err.Error()) + } +} + +func TestListTags_EmptyResponse(t *testing.T) { + server := fakeGitLabServer(t) + defer server.Close() + client := newTestClient(server.URL) + + tags, err := client.ListTags(context.Background(), "testorg", "empty") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(tags) != 0 { + t.Errorf("got %d tags, want 0", len(tags)) + } +} + +func TestListTags_InvalidJSON(t *testing.T) { + server := fakeGitLabServer(t) + defer server.Close() + client := newTestClient(server.URL) + + _, err := client.ListTags(context.Background(), "testorg", "badjson") + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } + if !strings.Contains(err.Error(), "parse tags JSON") { + t.Errorf("error = %q, want it to contain 'parse tags JSON'", err.Error()) + } +} + +func TestListTags_NotFound(t *testing.T) { + server := fakeGitLabServer(t) + defer server.Close() + client := newTestClient(server.URL) + + _, err := client.ListTags(context.Background(), "testorg", "doesnotexist") + if err == nil { + t.Fatal("expected error for 404, got nil") + } + var provErr *rwerrors.ProviderError + if !errors.As(err, &provErr) { + t.Fatalf("expected ProviderError, got %T: %v", err, err) + } + if provErr.Status != http.StatusNotFound { + t.Errorf("status = %d, want %d", provErr.Status, http.StatusNotFound) + } +} + +func TestListReleases_InvalidJSON(t *testing.T) { + server := fakeGitLabServer(t) + defer server.Close() + client := newTestClient(server.URL) + + _, err := client.ListReleases(context.Background(), "testorg", "badjson") + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } + if !strings.Contains(err.Error(), "parse releases JSON") { + t.Errorf("error = %q, want it to contain 'parse releases JSON'", err.Error()) + } +} + +func TestListReleases_InvalidTime(t *testing.T) { + server := fakeGitLabServer(t) + defer server.Close() + client := newTestClient(server.URL) + + releases, err := client.ListReleases(context.Background(), "testorg", "badtime") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(releases) != 1 { + t.Fatalf("got %d releases, want 1", len(releases)) + } + if releases[0].Tag != "v1.0.0" { + t.Errorf("tag = %q, want %q", releases[0].Tag, "v1.0.0") + } + if !releases[0].Prerelease { + t.Error("expected prerelease=true for upcoming_release") + } + if !releases[0].PublishedAt.IsZero() { + t.Errorf("expected zero PublishedAt for invalid time, got %v", releases[0].PublishedAt) + } +} + +func TestListReleases_EmptyReleasedAt(t *testing.T) { + server := fakeGitLabServer(t) + defer server.Close() + client := newTestClient(server.URL) + + releases, err := client.ListReleases(context.Background(), "testorg", "emptytime") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(releases) != 1 { + t.Fatalf("got %d releases, want 1", len(releases)) + } + if !releases[0].PublishedAt.IsZero() { + t.Errorf("expected zero PublishedAt for empty string, got %v", releases[0].PublishedAt) + } +} + +func TestListReleases_RateLimitError(t *testing.T) { + server := fakeGitLabServer(t) + defer server.Close() + client := newTestClient(server.URL) + + _, err := client.ListReleases(context.Background(), "testorg", "ratelimited") + if err == nil { + t.Fatal("expected error for rate limit, got nil") + } + var provErr *rwerrors.ProviderError + if !errors.As(err, &provErr) { + t.Fatalf("expected ProviderError, got %T: %v", err, err) + } + if provErr.Status != http.StatusTooManyRequests { + t.Errorf("status = %d, want %d", provErr.Status, http.StatusTooManyRequests) + } + if !rwerrors.IsRateLimit(err) { + t.Error("expected IsRateLimit to return true") + } +} + +func TestListReleases_AuthError(t *testing.T) { + server := fakeGitLabServer(t) + defer server.Close() + client := newTestClient(server.URL) + + _, err := client.ListReleases(context.Background(), "testorg", "autherror") + if err == nil { + t.Fatal("expected error for auth failure, got nil") + } + var provErr *rwerrors.ProviderError + if !errors.As(err, &provErr) { + t.Fatalf("expected ProviderError, got %T: %v", err, err) + } + if provErr.Status != http.StatusForbidden { + t.Errorf("status = %d, want %d", provErr.Status, http.StatusForbidden) + } + if !rwerrors.IsAuth(err) { + t.Error("expected IsAuth to return true") + } +} + +func TestGetLatestRelease_ServerError(t *testing.T) { + server := fakeGitLabServer(t) + defer server.Close() + client := newTestClient(server.URL) + + _, err := client.GetLatestRelease(context.Background(), "testorg", "broken") + if err == nil { + t.Fatal("expected error for server error, got nil") + } +} + +func TestGetLatestRelease_NotFound(t *testing.T) { + server := fakeGitLabServer(t) + defer server.Close() + client := newTestClient(server.URL) + + _, err := client.GetLatestRelease(context.Background(), "testorg", "doesnotexist") + if err == nil { + t.Fatal("expected error for 404, got nil") + } +} + +func TestGetFileContent_Base64(t *testing.T) { + server := fakeGitLabServer(t) + defer server.Close() + client := newTestClient(server.URL) + + content, err := client.GetFileContent(context.Background(), "testorg", "testrepo", "README.md") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(content) != "# Hello GitLab" { + t.Errorf("content = %q, want %q", string(content), "# Hello GitLab") + } +} + +func TestGetFileContent_NonBase64Encoding(t *testing.T) { + server := fakeGitLabServer(t) + defer server.Close() + client := newTestClient(server.URL) + + content, err := client.GetFileContent(context.Background(), "testorg", "testrepo", "plain.txt") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(content) != "plain text content" { + t.Errorf("content = %q, want %q", string(content), "plain text content") + } +} + +func TestGetFileContent_InvalidBase64(t *testing.T) { + server := fakeGitLabServer(t) + defer server.Close() + client := newTestClient(server.URL) + + _, err := client.GetFileContent(context.Background(), "testorg", "testrepo", "bad-base64.txt") + if err == nil { + t.Fatal("expected error for invalid base64, got nil") + } + if !strings.Contains(err.Error(), "decode base64 content") { + t.Errorf("error = %q, want it to contain 'decode base64 content'", err.Error()) + } +} + +func TestGetFileContent_InvalidJSON(t *testing.T) { + server := fakeGitLabServer(t) + defer server.Close() + client := newTestClient(server.URL) + + _, err := client.GetFileContent(context.Background(), "testorg", "testrepo", "bad-json.txt") + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } + if !strings.Contains(err.Error(), "parse file response") { + t.Errorf("error = %q, want it to contain 'parse file response'", err.Error()) + } +} + +func TestGetFileContent_NotFound(t *testing.T) { + server := fakeGitLabServer(t) + defer server.Close() + client := newTestClient(server.URL) + + _, err := client.GetFileContent(context.Background(), "testorg", "testrepo", "nonexistent.txt") + if err == nil { + t.Fatal("expected error for missing file, got nil") + } + if !strings.Contains(err.Error(), "get file content") { + t.Errorf("error = %q, want it to contain 'get file content'", err.Error()) + } + if !rwerrors.IsNotFound(err) { + t.Error("expected IsNotFound to return true") + } +} + +func TestGetFileContent_NestedPath(t *testing.T) { + server := fakeGitLabServer(t) + defer server.Close() + client := newTestClient(server.URL) + + content, err := client.GetFileContent(context.Background(), "testorg", "testrepo", "src/main.go") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(content) != "package main" { + t.Errorf("content = %q, want %q", string(content), "package main") + } +} + +func TestDoRequest_CancelledContext(t *testing.T) { + server := fakeGitLabServer(t) + defer server.Close() + client := newTestClient(server.URL) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + _, err := client.ListReleases(ctx, "testorg", "testrepo") + if err == nil { + t.Fatal("expected error for cancelled context, got nil") + } +} + +func TestDoRequest_NoToken(t *testing.T) { + server := fakeGitLabServer(t) + defer server.Close() + // Create client with empty token + client := New("", WithBaseURL(server.URL)) + + // Should still work - just no auth header + releases, err := client.ListReleases(context.Background(), "testorg", "testrepo") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(releases) != 2 { + t.Errorf("got %d releases, want 2", len(releases)) + } +} + +func TestListReleases_HTMLURLFormat(t *testing.T) { + server := fakeGitLabServer(t) + defer server.Close() + client := newTestClient(server.URL) + + releases, err := client.ListReleases(context.Background(), "testorg", "testrepo") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := "https://gitlab.com/testorg/testrepo/-/releases/v3.0.0" + if releases[0].HTMLURL != expected { + t.Errorf("HTMLURL = %q, want %q", releases[0].HTMLURL, expected) + } +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go index ea761ec..ec30276 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -1,6 +1,7 @@ package store import ( + "fmt" "os" "path/filepath" "testing" @@ -137,3 +138,503 @@ func TestKVStore(t *testing.T) { t.Errorf("value = %q, want %q", val, "v2.0.0") } } + +// --------------------------------------------------------------------------- +// Additional tests to improve coverage +// --------------------------------------------------------------------------- + +func TestNew_InvalidPath(t *testing.T) { + // Attempt to create a database at a path that cannot exist. + _, err := New("/nonexistent_dir_xyz/sub/test.db") + if err == nil { + t.Fatal("expected error for invalid path, got nil") + } +} + +func TestClose(t *testing.T) { + dir := t.TempDir() + s, err := New(filepath.Join(dir, "close_test.db")) + if err != nil { + t.Fatalf("New: %v", err) + } + + if err := s.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + // Operations after close should return an error. + if err := s.SetKV("k", "v"); err == nil { + t.Error("expected error on SetKV after Close") + } +} + +func TestGetKV_EmptyValue(t *testing.T) { + s := testStore(t) + + // Store an empty string value. + if err := s.SetKV("empty_key", ""); err != nil { + t.Fatalf("SetKV empty: %v", err) + } + + val, found, err := s.GetKV("empty_key") + if err != nil { + t.Fatalf("GetKV: %v", err) + } + if !found { + t.Error("expected key to be found") + } + if val != "" { + t.Errorf("value = %q, want empty string", val) + } +} + +func TestGetKV_AfterClose(t *testing.T) { + dir := t.TempDir() + s, err := New(filepath.Join(dir, "kv_close.db")) + if err != nil { + t.Fatalf("New: %v", err) + } + s.Close() + + _, _, err = s.GetKV("anything") + if err == nil { + t.Error("expected error on GetKV after Close") + } +} + +func TestSetKV_MultipleKeys(t *testing.T) { + s := testStore(t) + + keys := map[string]string{ + "a": "1", + "b": "2", + "c": "3", + } + for k, v := range keys { + if err := s.SetKV(k, v); err != nil { + t.Fatalf("SetKV(%q, %q): %v", k, v, err) + } + } + + for k, want := range keys { + got, found, err := s.GetKV(k) + if err != nil { + t.Fatalf("GetKV(%q): %v", k, err) + } + if !found { + t.Errorf("key %q not found", k) + } + if got != want { + t.Errorf("GetKV(%q) = %q, want %q", k, got, want) + } + } +} + +func TestSetKV_UpdatePreservesOtherKeys(t *testing.T) { + s := testStore(t) + + if err := s.SetKV("k1", "original"); err != nil { + t.Fatalf("SetKV: %v", err) + } + if err := s.SetKV("k2", "untouched"); err != nil { + t.Fatalf("SetKV: %v", err) + } + + // Update k1, k2 should remain. + if err := s.SetKV("k1", "updated"); err != nil { + t.Fatalf("SetKV update: %v", err) + } + + val, found, err := s.GetKV("k2") + if err != nil { + t.Fatalf("GetKV: %v", err) + } + if !found || val != "untouched" { + t.Errorf("k2 = %q (found=%v), want %q", val, found, "untouched") + } +} + +func TestRecordRelease_DuplicateIgnored(t *testing.T) { + s := testStore(t) + + now := time.Now().Truncate(time.Second) + r := Release{ + Service: "svc", + Tag: "v1.0.0", + Platform: "github", + URL: "https://example.com/v1", + PublishedAt: now, + DiscoveredAt: now, + } + + if err := s.RecordRelease(r); err != nil { + t.Fatalf("RecordRelease: %v", err) + } + + // Insert duplicate with different platform/URL -- should be ignored due to UNIQUE(service, tag). + r2 := r + r2.Platform = "gitlab" + r2.URL = "https://gitlab.com/v1" + if err := s.RecordRelease(r2); err != nil { + t.Fatalf("RecordRelease duplicate: %v", err) + } + + history, err := s.GetHistory("svc", 10) + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 1 { + t.Fatalf("expected 1 release after duplicate, got %d", len(history)) + } + // Original record should be preserved (INSERT OR IGNORE keeps the first). + if history[0].Platform != "github" { + t.Errorf("platform = %q, want %q", history[0].Platform, "github") + } +} + +func TestRecordRelease_MultipleServices(t *testing.T) { + s := testStore(t) + + now := time.Now().Truncate(time.Second) + services := []string{"api", "web", "worker"} + for _, svc := range services { + r := Release{ + Service: svc, + Tag: "v1.0.0", + Platform: "github", + URL: "https://example.com/" + svc, + PublishedAt: now, + DiscoveredAt: now, + } + if err := s.RecordRelease(r); err != nil { + t.Fatalf("RecordRelease(%s): %v", svc, err) + } + } + + // Each service should have exactly 1 release. + for _, svc := range services { + h, err := s.GetHistory(svc, 10) + if err != nil { + t.Fatalf("GetHistory(%s): %v", svc, err) + } + if len(h) != 1 { + t.Errorf("GetHistory(%s): got %d releases, want 1", svc, len(h)) + } + } +} + +func TestRecordRelease_AllFieldsPersisted(t *testing.T) { + s := testStore(t) + + now := time.Now().Truncate(time.Second) + r := Release{ + Service: "myservice", + Tag: "v2.3.4", + Platform: "npm", + URL: "https://npmjs.com/package/myservice/v/2.3.4", + PublishedAt: now.Add(-1 * time.Hour), + DiscoveredAt: now, + } + + if err := s.RecordRelease(r); err != nil { + t.Fatalf("RecordRelease: %v", err) + } + + history, err := s.GetHistory("myservice", 1) + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 1 { + t.Fatalf("expected 1 release, got %d", len(history)) + } + + got := history[0] + if got.Service != r.Service { + t.Errorf("Service = %q, want %q", got.Service, r.Service) + } + if got.Tag != r.Tag { + t.Errorf("Tag = %q, want %q", got.Tag, r.Tag) + } + if got.Platform != r.Platform { + t.Errorf("Platform = %q, want %q", got.Platform, r.Platform) + } + if got.URL != r.URL { + t.Errorf("URL = %q, want %q", got.URL, r.URL) + } +} + +func TestRecordRelease_AfterClose(t *testing.T) { + dir := t.TempDir() + s, err := New(filepath.Join(dir, "release_close.db")) + if err != nil { + t.Fatalf("New: %v", err) + } + s.Close() + + err = s.RecordRelease(Release{Service: "x", Tag: "v1"}) + if err == nil { + t.Error("expected error on RecordRelease after Close") + } +} + +func TestGetHistory_NegativeLimit(t *testing.T) { + s := testStore(t) + + now := time.Now().Truncate(time.Second) + if err := s.RecordRelease(Release{ + Service: "svc", Tag: "v1.0.0", DiscoveredAt: now, + }); err != nil { + t.Fatalf("RecordRelease: %v", err) + } + + // Negative limit should default to 50, and still return the 1 record. + history, err := s.GetHistory("svc", -1) + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 1 { + t.Errorf("expected 1 release, got %d", len(history)) + } +} + +func TestGetHistory_Ordering(t *testing.T) { + s := testStore(t) + + base := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + tags := []string{"v1.0.0", "v2.0.0", "v3.0.0"} + for i, tag := range tags { + r := Release{ + Service: "api", + Tag: tag, + DiscoveredAt: base.Add(time.Duration(i) * time.Hour), + } + if err := s.RecordRelease(r); err != nil { + t.Fatalf("RecordRelease(%s): %v", tag, err) + } + } + + history, err := s.GetHistory("api", 10) + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 3 { + t.Fatalf("expected 3 releases, got %d", len(history)) + } + + // Newest first (discovered_at DESC). + if history[0].Tag != "v3.0.0" { + t.Errorf("first = %q, want v3.0.0", history[0].Tag) + } + if history[1].Tag != "v2.0.0" { + t.Errorf("second = %q, want v2.0.0", history[1].Tag) + } + if history[2].Tag != "v1.0.0" { + t.Errorf("third = %q, want v1.0.0", history[2].Tag) + } +} + +func TestGetHistory_LimitEnforced(t *testing.T) { + s := testStore(t) + + base := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + for i := 0; i < 5; i++ { + r := Release{ + Service: "api", + Tag: fmt.Sprintf("v1.0.%d", i), + DiscoveredAt: base.Add(time.Duration(i) * time.Hour), + } + if err := s.RecordRelease(r); err != nil { + t.Fatalf("RecordRelease: %v", err) + } + } + + history, err := s.GetHistory("api", 2) + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 2 { + t.Errorf("expected 2 releases with limit=2, got %d", len(history)) + } +} + +func TestGetHistory_NoResultsForUnknownService(t *testing.T) { + s := testStore(t) + + history, err := s.GetHistory("unknown_service", 10) + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 0 { + t.Errorf("expected 0 releases for unknown service, got %d", len(history)) + } +} + +func TestGetHistory_AfterClose(t *testing.T) { + dir := t.TempDir() + s, err := New(filepath.Join(dir, "hist_close.db")) + if err != nil { + t.Fatalf("New: %v", err) + } + s.Close() + + _, err = s.GetHistory("api", 10) + if err == nil { + t.Error("expected error on GetHistory after Close") + } +} + +func TestLogToolCall_ErrorStatus(t *testing.T) { + s := testStore(t) + + tc := ToolCall{ + Tool: "fetch_releases", + Args: `{"owner":"org","repo":"broken"}`, + Status: "error", + DurationMs: 5000, + CalledAt: time.Now(), + } + + if err := s.LogToolCall(tc); err != nil { + t.Fatalf("LogToolCall: %v", err) + } +} + +func TestLogToolCall_EmptyArgs(t *testing.T) { + s := testStore(t) + + tc := ToolCall{ + Tool: "ping", + Args: "", + Status: "ok", + DurationMs: 0, + CalledAt: time.Now(), + } + + if err := s.LogToolCall(tc); err != nil { + t.Fatalf("LogToolCall: %v", err) + } +} + +func TestLogToolCall_ZeroDuration(t *testing.T) { + s := testStore(t) + + tc := ToolCall{ + Tool: "noop", + Args: "{}", + Status: "ok", + DurationMs: 0, + CalledAt: time.Now(), + } + + if err := s.LogToolCall(tc); err != nil { + t.Fatalf("LogToolCall: %v", err) + } +} + +func TestLogToolCall_MultipleCalls(t *testing.T) { + s := testStore(t) + + statuses := []string{"ok", "error", "timeout", "ok"} + for i, status := range statuses { + tc := ToolCall{ + Tool: "tool_" + status, + Args: "{}", + Status: status, + DurationMs: int64(i * 100), + CalledAt: time.Now(), + } + if err := s.LogToolCall(tc); err != nil { + t.Fatalf("LogToolCall(%d): %v", i, err) + } + } +} + +func TestLogToolCall_AfterClose(t *testing.T) { + dir := t.TempDir() + s, err := New(filepath.Join(dir, "tc_close.db")) + if err != nil { + t.Fatalf("New: %v", err) + } + s.Close() + + err = s.LogToolCall(ToolCall{Tool: "x", CalledAt: time.Now()}) + if err == nil { + t.Error("expected error on LogToolCall after Close") + } +} + +func TestNew_ReopenExistingDatabase(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "reopen.db") + + // Create and populate. + s1, err := New(path) + if err != nil { + t.Fatalf("New (first): %v", err) + } + if err := s1.SetKV("persist", "yes"); err != nil { + t.Fatalf("SetKV: %v", err) + } + s1.Close() + + // Reopen and verify data persists. + s2, err := New(path) + if err != nil { + t.Fatalf("New (reopen): %v", err) + } + defer s2.Close() + + val, found, err := s2.GetKV("persist") + if err != nil { + t.Fatalf("GetKV: %v", err) + } + if !found { + t.Error("expected key to persist across reopen") + } + if val != "yes" { + t.Errorf("value = %q, want %q", val, "yes") + } +} + +func TestRecordRelease_MinimalFields(t *testing.T) { + s := testStore(t) + + // Only required fields (service + tag); platform, url are empty defaults. + r := Release{ + Service: "minimal", + Tag: "v0.0.1", + DiscoveredAt: time.Now().Truncate(time.Second), + } + + if err := s.RecordRelease(r); err != nil { + t.Fatalf("RecordRelease: %v", err) + } + + history, err := s.GetHistory("minimal", 10) + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 1 { + t.Fatalf("expected 1 release, got %d", len(history)) + } + if history[0].Platform != "" { + t.Errorf("platform = %q, want empty", history[0].Platform) + } + if history[0].URL != "" { + t.Errorf("url = %q, want empty", history[0].URL) + } +} + +func TestSetKV_AfterClose(t *testing.T) { + dir := t.TempDir() + s, err := New(filepath.Join(dir, "setkv_close.db")) + if err != nil { + t.Fatalf("New: %v", err) + } + s.Close() + + err = s.SetKV("key", "value") + if err == nil { + t.Error("expected error on SetKV after Close") + } +}