package api_test import ( "bytes" "database/sql" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/syonad/clonepack/config" "github.com/syonad/clonepack/internal/api" "github.com/syonad/clonepack/internal/core" "github.com/syonad/clonepack/internal/store" ) // testServer wires up a full in-memory stack and returns an httptest.Server. func newTestServer(t *testing.T) (*httptest.Server, *sql.DB) { t.Helper() db, err := store.Open(config.DBConfig{Path: ":memory:"}) if err != nil { t.Fatalf("open db: %v", err) } t.Cleanup(func() { db.Close() }) dataDir := t.TempDir() repoStore := store.NewRepoStore(db) jobStore := store.NewCloneJobStore(db) pendingStore := store.NewPendingPackageStore(db) blockedStore := store.NewBlockedPackageStore(db) snapshotStore := store.NewSnapshotStore(db) repoSvc := core.NewRepoService(repoStore, dataDir) cloneSvc := core.NewCloneService(repoStore, jobStore, dataDir) snapshotSvc := core.NewSnapshotService(snapshotStore, repoStore, dataDir) syncSvc := core.NewSyncService(repoStore, pendingStore, blockedStore, cloneSvc, snapshotSvc, dataDir) router := api.NewRouter( api.NewRepoHandler(repoSvc), api.NewCloneHandler(cloneSvc), api.NewSyncHandler(syncSvc), api.NewSnapshotHandler(snapshotSvc), api.NewProxyHandler(repoSvc, dataDir), ) srv := httptest.NewServer(router) t.Cleanup(srv.Close) return srv, db } func do(t *testing.T, method, url string, body any) *http.Response { t.Helper() var buf bytes.Buffer if body != nil { json.NewEncoder(&buf).Encode(body) } req, err := http.NewRequest(method, url, &buf) if err != nil { t.Fatalf("new request: %v", err) } if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("%s %s: %v", method, url, err) } return resp } func decodeJSON(t *testing.T, resp *http.Response, v any) { t.Helper() defer resp.Body.Close() if err := json.NewDecoder(resp.Body).Decode(v); err != nil { t.Fatalf("decode response: %v", err) } } // --- Health --- func TestHealth(t *testing.T) { srv, _ := newTestServer(t) resp := do(t, http.MethodGet, srv.URL+"/health", nil) if resp.StatusCode != http.StatusOK { t.Errorf("health: got %d, want 200", resp.StatusCode) } } // --- Create repo --- func TestCreateRepo_rpm(t *testing.T) { srv, _ := newTestServer(t) payload := map[string]any{ "name": "rocky9", "type": "rpm", "source_url": "https://download.rockylinux.org/pub/rocky/9/BaseOS/x86_64/os", } resp := do(t, http.MethodPost, srv.URL+"/api/v1/repos", payload) if resp.StatusCode != http.StatusCreated { t.Fatalf("create rpm: got %d, want 201", resp.StatusCode) } var repo api.RepoResponse decodeJSON(t, resp, &repo) if repo.ID <= 0 { t.Errorf("expected positive ID, got %d", repo.ID) } if repo.Name != "rocky9" { t.Errorf("Name: got %q, want rocky9", repo.Name) } if repo.SyncMode != "auto" { t.Errorf("SyncMode default: got %q, want auto", repo.SyncMode) } } func TestCreateRepo_apt(t *testing.T) { srv, _ := newTestServer(t) payload := map[string]any{ "name": "debian-bookworm", "type": "apt", "source_url": "https://deb.debian.org/debian", "apt_suite": "bookworm", "apt_components": []string{"main", "contrib"}, "apt_architectures": []string{"amd64"}, } resp := do(t, http.MethodPost, srv.URL+"/api/v1/repos", payload) if resp.StatusCode != http.StatusCreated { t.Fatalf("create apt: got %d, want 201", resp.StatusCode) } var repo api.RepoResponse decodeJSON(t, resp, &repo) if repo.AptSuite != "bookworm" { t.Errorf("AptSuite: got %q, want bookworm", repo.AptSuite) } if len(repo.AptComponents) != 2 { t.Errorf("AptComponents: got %v", repo.AptComponents) } } func TestCreateRepo_invalidType(t *testing.T) { srv, _ := newTestServer(t) resp := do(t, http.MethodPost, srv.URL+"/api/v1/repos", map[string]any{ "name": "bad", "type": "maven", "source_url": "https://x.com", }) if resp.StatusCode != http.StatusUnprocessableEntity { t.Errorf("invalid type: got %d, want 422", resp.StatusCode) } resp.Body.Close() } func TestCreateRepo_aptMissingSuite(t *testing.T) { srv, _ := newTestServer(t) resp := do(t, http.MethodPost, srv.URL+"/api/v1/repos", map[string]any{ "name": "apt-no-suite", "type": "apt", "source_url": "https://deb.debian.org/debian", }) if resp.StatusCode != http.StatusUnprocessableEntity { t.Errorf("missing suite: got %d, want 422", resp.StatusCode) } resp.Body.Close() } func TestCreateRepo_invalidJSON(t *testing.T) { srv, _ := newTestServer(t) req, _ := http.NewRequest(http.MethodPost, srv.URL+"/api/v1/repos", bytes.NewBufferString("not-json")) req.Header.Set("Content-Type", "application/json") resp, _ := http.DefaultClient.Do(req) if resp.StatusCode != http.StatusBadRequest { t.Errorf("bad json: got %d, want 400", resp.StatusCode) } resp.Body.Close() } // --- List repos --- func TestListRepos_empty(t *testing.T) { srv, _ := newTestServer(t) resp := do(t, http.MethodGet, srv.URL+"/api/v1/repos", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("list: got %d, want 200", resp.StatusCode) } var body api.ListReposResponse decodeJSON(t, resp, &body) if body.Total != 0 { t.Errorf("expected 0 repos, got %d", body.Total) } } func TestListRepos_afterCreate(t *testing.T) { srv, _ := newTestServer(t) do(t, http.MethodPost, srv.URL+"/api/v1/repos", map[string]any{ "name": "repo-a", "type": "rpm", "source_url": "https://a.com", }).Body.Close() do(t, http.MethodPost, srv.URL+"/api/v1/repos", map[string]any{ "name": "repo-b", "type": "rpm", "source_url": "https://b.com", }).Body.Close() resp := do(t, http.MethodGet, srv.URL+"/api/v1/repos", nil) var body api.ListReposResponse decodeJSON(t, resp, &body) if body.Total != 2 { t.Errorf("expected 2 repos, got %d", body.Total) } } // --- Get repo --- func TestGetRepo(t *testing.T) { srv, _ := newTestServer(t) createResp := do(t, http.MethodPost, srv.URL+"/api/v1/repos", map[string]any{ "name": "rocky9", "type": "rpm", "source_url": "https://example.com", }) var created api.RepoResponse decodeJSON(t, createResp, &created) resp := do(t, http.MethodGet, srv.URL+"/api/v1/repos/1", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("get: got %d, want 200", resp.StatusCode) } var repo api.RepoResponse decodeJSON(t, resp, &repo) if repo.ID != created.ID { t.Errorf("ID: got %d, want %d", repo.ID, created.ID) } } func TestGetRepo_notFound(t *testing.T) { srv, _ := newTestServer(t) resp := do(t, http.MethodGet, srv.URL+"/api/v1/repos/999", nil) if resp.StatusCode != http.StatusNotFound { t.Errorf("not found: got %d, want 404", resp.StatusCode) } resp.Body.Close() } // --- Update repo --- func TestUpdateRepo_sourceURL(t *testing.T) { srv, _ := newTestServer(t) do(t, http.MethodPost, srv.URL+"/api/v1/repos", map[string]any{ "name": "rocky9", "type": "rpm", "source_url": "https://old.example.com", }).Body.Close() resp := do(t, http.MethodPatch, srv.URL+"/api/v1/repos/1", map[string]any{ "source_url": "https://new.example.com", }) if resp.StatusCode != http.StatusOK { t.Fatalf("update: got %d, want 200", resp.StatusCode) } var repo api.RepoResponse decodeJSON(t, resp, &repo) if repo.SourceURL != "https://new.example.com" { t.Errorf("SourceURL: got %q", repo.SourceURL) } } func TestUpdateRepo_aptConfig(t *testing.T) { srv, _ := newTestServer(t) do(t, http.MethodPost, srv.URL+"/api/v1/repos", map[string]any{ "name": "debian", "type": "apt", "source_url": "https://deb.debian.org/debian", "apt_suite": "bookworm", }).Body.Close() resp := do(t, http.MethodPatch, srv.URL+"/api/v1/repos/1", map[string]any{ "apt_components": []string{"main", "contrib", "non-free"}, }) if resp.StatusCode != http.StatusOK { t.Fatalf("update apt: got %d, want 200", resp.StatusCode) } var repo api.RepoResponse decodeJSON(t, resp, &repo) if len(repo.AptComponents) != 3 { t.Errorf("AptComponents: got %v", repo.AptComponents) } } func TestUpdateRepo_notFound(t *testing.T) { srv, _ := newTestServer(t) resp := do(t, http.MethodPatch, srv.URL+"/api/v1/repos/999", map[string]any{ "source_url": "https://x.com", }) if resp.StatusCode != http.StatusNotFound { t.Errorf("not found: got %d, want 404", resp.StatusCode) } resp.Body.Close() } // --- Delete repo --- func TestDeleteRepo(t *testing.T) { srv, _ := newTestServer(t) do(t, http.MethodPost, srv.URL+"/api/v1/repos", map[string]any{ "name": "to-delete", "type": "rpm", "source_url": "https://example.com", }).Body.Close() resp := do(t, http.MethodDelete, srv.URL+"/api/v1/repos/1", nil) if resp.StatusCode != http.StatusNoContent { t.Fatalf("delete: got %d, want 204", resp.StatusCode) } resp.Body.Close() resp = do(t, http.MethodGet, srv.URL+"/api/v1/repos/1", nil) if resp.StatusCode != http.StatusNotFound { t.Errorf("after delete: got %d, want 404", resp.StatusCode) } resp.Body.Close() } func TestDeleteRepo_notFound(t *testing.T) { srv, _ := newTestServer(t) resp := do(t, http.MethodDelete, srv.URL+"/api/v1/repos/999", nil) if resp.StatusCode != http.StatusNotFound { t.Errorf("not found: got %d, want 404", resp.StatusCode) } resp.Body.Close() } // --- Sync --- func TestSyncTrigger_notFound(t *testing.T) { srv, _ := newTestServer(t) resp := do(t, http.MethodPost, srv.URL+"/api/v1/repos/999/sync/trigger", nil) if resp.StatusCode != http.StatusNotFound { t.Errorf("sync trigger not found: got %d, want 404", resp.StatusCode) } resp.Body.Close() } func TestSyncListPending_empty(t *testing.T) { srv, _ := newTestServer(t) do(t, http.MethodPost, srv.URL+"/api/v1/repos", map[string]any{ "name": "rpm-repo", "type": "rpm", "source_url": "https://example.com", }).Body.Close() resp := do(t, http.MethodGet, srv.URL+"/api/v1/repos/1/sync/pending", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("sync pending: got %d, want 200", resp.StatusCode) } var body struct { Total int `json:"total"` } decodeJSON(t, resp, &body) if body.Total != 0 { t.Errorf("expected empty pending list, got total=%d", body.Total) } }