From 9e287e4e6aed335ec55f5d779c1a5f1920c1d55a Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sat, 25 Apr 2026 19:37:17 +0200 Subject: [PATCH] add test for the wall app Signed-off-by: GnomeZworc --- internal/api/handler_test.go | 354 ++++++++++++++++++++ internal/clone/apt/metadata_gen_test.go | 278 ++++++++++++++++ internal/clone/apt/packages_test.go | 138 ++++++++ internal/clone/apt/release_test.go | 126 +++++++ internal/clone/rpm/metadata_gen_test.go | 284 ++++++++++++++++ internal/core/repo_test.go | 355 ++++++++++++++++++++ internal/core/snapshot_test.go | 417 ++++++++++++++++++++++++ internal/core/sync_test.go | 268 +++++++++++++++ internal/store/blocked_test.go | 131 ++++++++ internal/store/clone_job.go | 2 +- internal/store/clone_job_test.go | 165 ++++++++++ internal/store/pending_test.go | 139 ++++++++ internal/store/repo_test.go | 188 +++++++++++ internal/store/snapshot_test.go | 142 ++++++++ internal/store/testhelper_test.go | 36 ++ 15 files changed, 3022 insertions(+), 1 deletion(-) create mode 100644 internal/api/handler_test.go create mode 100644 internal/clone/apt/metadata_gen_test.go create mode 100644 internal/clone/apt/packages_test.go create mode 100644 internal/clone/apt/release_test.go create mode 100644 internal/clone/rpm/metadata_gen_test.go create mode 100644 internal/core/repo_test.go create mode 100644 internal/core/snapshot_test.go create mode 100644 internal/core/sync_test.go create mode 100644 internal/store/blocked_test.go create mode 100644 internal/store/clone_job_test.go create mode 100644 internal/store/pending_test.go create mode 100644 internal/store/repo_test.go create mode 100644 internal/store/snapshot_test.go create mode 100644 internal/store/testhelper_test.go diff --git a/internal/api/handler_test.go b/internal/api/handler_test.go new file mode 100644 index 0000000..0cfe671 --- /dev/null +++ b/internal/api/handler_test.go @@ -0,0 +1,354 @@ +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) + } +} diff --git a/internal/clone/apt/metadata_gen_test.go b/internal/clone/apt/metadata_gen_test.go new file mode 100644 index 0000000..aebf138 --- /dev/null +++ b/internal/clone/apt/metadata_gen_test.go @@ -0,0 +1,278 @@ +package apt + +import ( + "bytes" + "compress/gzip" + "os" + "path/filepath" + "strings" + "testing" +) + +var testCfg = Config{ + Suite: "bookworm", + Components: []string{"main"}, + Architectures: []string{"amd64"}, +} + +// buildPackagesGz creates a gzipped Packages file from a list of stanzas. +func buildPackagesGz(t *testing.T, stanzas []string) []byte { + t.Helper() + plain := []byte(strings.Join(stanzas, "\n\n") + "\n") + + var buf bytes.Buffer + gzw := gzip.NewWriter(&buf) + gzw.Write(plain) + gzw.Close() + return buf.Bytes() +} + +func makeStanza(pkg, version, filename, sha256 string, size int) string { + return strings.Join([]string{ + "Package: " + pkg, + "Version: " + version, + "Architecture: amd64", + "Filename: " + filename, + "SHA256: " + sha256, + "Size: " + itoa(size), + }, "\n") +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + buf := make([]byte, 0, 10) + for n > 0 { + buf = append([]byte{byte('0' + n%10)}, buf...) + n /= 10 + } + return string(buf) +} + +// setupAptRepo creates the directory structure and writes the upstream +// Packages.gz for component/arch. It creates the given deb files on disk. +func setupAptRepo(t *testing.T, stanzas []string, presentOnDisk []string) string { + t.Helper() + dir := t.TempDir() + + binDir := filepath.Join(dir, "dists", testCfg.Suite, "main", "binary-amd64") + os.MkdirAll(binDir, 0o755) + os.MkdirAll(filepath.Join(dir, "pool"), 0o755) + + // Write upstream Packages.gz. + gzData := buildPackagesGz(t, stanzas) + if err := os.WriteFile(filepath.Join(binDir, upstreamPackagesGz), gzData, 0o644); err != nil { + t.Fatalf("write upstream Packages.gz: %v", err) + } + + // Create selected deb files on disk. + for _, filename := range presentOnDisk { + dest := filepath.Join(dir, filepath.FromSlash(filename)) + os.MkdirAll(filepath.Dir(dest), 0o755) + if err := os.WriteFile(dest, []byte("fake-deb"), 0o644); err != nil { + t.Fatalf("write deb %s: %v", filename, err) + } + } + + return dir +} + +// readLocalPackages reads and parses the local Packages file (plain text). +func readLocalPackages(t *testing.T, dir string) []DebPackage { + t.Helper() + path := filepath.Join(dir, "dists", testCfg.Suite, "main", "binary-amd64", "Packages") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read Packages: %v", err) + } + pkgs, err := ParsePackages(data) + if err != nil { + t.Fatalf("parse Packages: %v", err) + } + return pkgs +} + +// --- InitEmptyRepo --- + +func TestInitEmptyRepo_createsStructure(t *testing.T) { + dir := t.TempDir() + if err := InitEmptyRepo(dir, testCfg); err != nil { + t.Fatalf("InitEmptyRepo: %v", err) + } + + for _, path := range []string{ + filepath.Join(dir, "pool"), + filepath.Join(dir, "dists", testCfg.Suite), + filepath.Join(dir, "dists", testCfg.Suite, "main", "binary-amd64"), + filepath.Join(dir, "dists", testCfg.Suite, "Release"), + } { + if _, err := os.Stat(path); err != nil { + t.Errorf("expected %s to exist: %v", path, err) + } + } +} + +func TestInitEmptyRepo_releaseIsParseable(t *testing.T) { + dir := t.TempDir() + InitEmptyRepo(dir, testCfg) + + data, _ := os.ReadFile(filepath.Join(dir, "dists", testCfg.Suite, "Release")) + rf, err := ParseRelease(data) + if err != nil { + t.Fatalf("ParseRelease on generated file: %v", err) + } + if rf.Suite != testCfg.Suite { + t.Errorf("Suite: got %q, want %q", rf.Suite, testCfg.Suite) + } +} + +func TestInitEmptyRepo_idempotent(t *testing.T) { + dir := t.TempDir() + InitEmptyRepo(dir, testCfg) + + releaseBefore, _ := os.ReadFile(filepath.Join(dir, "dists", testCfg.Suite, "Release")) + InitEmptyRepo(dir, testCfg) // second call + releaseAfter, _ := os.ReadFile(filepath.Join(dir, "dists", testCfg.Suite, "Release")) + + if !bytes.Equal(releaseBefore, releaseAfter) { + t.Error("second InitEmptyRepo modified Release file") + } +} + +// --- RegenerateMetadata --- + +func TestRegenerateMetadata_keepsOnlyPresentFiles(t *testing.T) { + stanzas := []string{ + makeStanza("curl", "7.88", "pool/main/c/curl/curl_7.88_amd64.deb", "aaa", 100), + makeStanza("wget", "1.21", "pool/main/w/wget/wget_1.21_amd64.deb", "bbb", 200), + makeStanza("vim", "9.0", "pool/main/v/vim/vim_9.0_amd64.deb", "ccc", 300), + } + // Only curl and vim are present. + present := []string{ + "pool/main/c/curl/curl_7.88_amd64.deb", + "pool/main/v/vim/vim_9.0_amd64.deb", + } + dir := setupAptRepo(t, stanzas, present) + + if err := RegenerateMetadata(dir, testCfg); err != nil { + t.Fatalf("RegenerateMetadata: %v", err) + } + + pkgs := readLocalPackages(t, dir) + if len(pkgs) != 2 { + t.Fatalf("expected 2 packages, got %d", len(pkgs)) + } + names := map[string]bool{pkgs[0].Package: true, pkgs[1].Package: true} + if !names["curl"] { + t.Error("expected curl in output") + } + if !names["vim"] { + t.Error("expected vim in output") + } + if names["wget"] { + t.Error("wget should be filtered out (not on disk)") + } +} + +func TestRegenerateMetadata_allAbsent(t *testing.T) { + stanzas := []string{ + makeStanza("curl", "7.88", "pool/main/c/curl/curl_7.88_amd64.deb", "aaa", 100), + } + dir := setupAptRepo(t, stanzas, nil) // nothing on disk + + if err := RegenerateMetadata(dir, testCfg); err != nil { + t.Fatalf("RegenerateMetadata: %v", err) + } + + pkgs := readLocalPackages(t, dir) + if len(pkgs) != 0 { + t.Errorf("expected 0 packages, got %d", len(pkgs)) + } +} + +func TestRegenerateMetadata_writesRelease(t *testing.T) { + stanzas := []string{ + makeStanza("curl", "7.88", "pool/main/c/curl/curl_7.88_amd64.deb", "aaa", 100), + } + dir := setupAptRepo(t, stanzas, []string{"pool/main/c/curl/curl_7.88_amd64.deb"}) + + if err := RegenerateMetadata(dir, testCfg); err != nil { + t.Fatalf("RegenerateMetadata: %v", err) + } + + releasePath := filepath.Join(dir, "dists", testCfg.Suite, "Release") + data, err := os.ReadFile(releasePath) + if err != nil { + t.Fatalf("Release not written: %v", err) + } + + rf, err := ParseRelease(data) + if err != nil { + t.Fatalf("ParseRelease: %v", err) + } + if rf.Suite != testCfg.Suite { + t.Errorf("Suite: got %q, want %q", rf.Suite, testCfg.Suite) + } + // Release must reference both Packages and Packages.gz. + paths := map[string]bool{} + for _, e := range rf.SHA256 { + paths[e.Path] = true + } + if !paths["main/binary-amd64/Packages"] { + t.Error("Release missing main/binary-amd64/Packages entry") + } + if !paths["main/binary-amd64/Packages.gz"] { + t.Error("Release missing main/binary-amd64/Packages.gz entry") + } +} + +func TestRegenerateMetadata_writesPackagesGz(t *testing.T) { + stanzas := []string{ + makeStanza("curl", "7.88", "pool/main/c/curl/curl_7.88_amd64.deb", "aaa", 100), + } + dir := setupAptRepo(t, stanzas, []string{"pool/main/c/curl/curl_7.88_amd64.deb"}) + + RegenerateMetadata(dir, testCfg) + + gzPath := filepath.Join(dir, "dists", testCfg.Suite, "main", "binary-amd64", "Packages.gz") + if _, err := os.Stat(gzPath); err != nil { + t.Errorf("Packages.gz not written: %v", err) + } +} + +func TestRegenerateMetadata_noUpstream_skips(t *testing.T) { + // No .upstream-Packages.gz — should return nil (skip silently). + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, "dists", testCfg.Suite, "main", "binary-amd64"), 0o755) + + if err := RegenerateMetadata(dir, testCfg); err != nil { + t.Errorf("expected no error when upstream missing, got: %v", err) + } +} + +func TestRegenerateMetadata_alwaysFiltersFromUpstream(t *testing.T) { + // Two successive regens must always start from the upstream list. + stanzas := []string{ + makeStanza("curl", "7.88", "pool/main/c/curl/curl_7.88_amd64.deb", "aaa", 100), + makeStanza("wget", "1.21", "pool/main/w/wget/wget_1.21_amd64.deb", "bbb", 200), + } + dir := setupAptRepo(t, stanzas, []string{"pool/main/c/curl/curl_7.88_amd64.deb"}) + + // First regen: only curl. + RegenerateMetadata(dir, testCfg) + if len(readLocalPackages(t, dir)) != 1 { + t.Fatal("first regen: expected 1 package") + } + + // Add wget, regen again. + dest := filepath.Join(dir, "pool/main/w/wget/wget_1.21_amd64.deb") + os.MkdirAll(filepath.Dir(dest), 0o755) + os.WriteFile(dest, []byte("fake"), 0o644) + + RegenerateMetadata(dir, testCfg) + pkgs := readLocalPackages(t, dir) + if len(pkgs) != 2 { + t.Fatalf("second regen: expected 2 packages, got %d", len(pkgs)) + } +} diff --git a/internal/clone/apt/packages_test.go b/internal/clone/apt/packages_test.go new file mode 100644 index 0000000..714a1ad --- /dev/null +++ b/internal/clone/apt/packages_test.go @@ -0,0 +1,138 @@ +package apt + +import ( + "testing" +) + +const sampleStanza = `Package: curl +Version: 7.88.1-10+deb12u5 +Architecture: amd64 +Filename: pool/main/c/curl/curl_7.88.1-10+deb12u5_amd64.deb +SHA256: abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 +Size: 314456` + +const sampleStanza2 = `Package: wget +Version: 1.21.3-1+b1 +Architecture: amd64 +Filename: pool/main/w/wget/wget_1.21.3-1+b1_amd64.deb +SHA256: 0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff +Size: 512000` + +func TestParsePackages_single(t *testing.T) { + pkgs, err := ParsePackages([]byte(sampleStanza)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + p := pkgs[0] + if p.Package != "curl" { + t.Errorf("Package: got %q, want %q", p.Package, "curl") + } + if p.Version != "7.88.1-10+deb12u5" { + t.Errorf("Version: got %q, want %q", p.Version, "7.88.1-10+deb12u5") + } + if p.Architecture != "amd64" { + t.Errorf("Architecture: got %q, want %q", p.Architecture, "amd64") + } + if p.Filename != "pool/main/c/curl/curl_7.88.1-10+deb12u5_amd64.deb" { + t.Errorf("Filename: got %q", p.Filename) + } + if p.SHA256 != "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" { + t.Errorf("SHA256: got %q", p.SHA256) + } + if p.Size != 314456 { + t.Errorf("Size: got %d, want %d", p.Size, 314456) + } +} + +func TestParsePackages_multiple(t *testing.T) { + input := sampleStanza + "\n\n" + sampleStanza2 + pkgs, err := ParsePackages([]byte(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(pkgs) != 2 { + t.Fatalf("expected 2 packages, got %d", len(pkgs)) + } + if pkgs[0].Package != "curl" { + t.Errorf("first package: got %q, want curl", pkgs[0].Package) + } + if pkgs[1].Package != "wget" { + t.Errorf("second package: got %q, want wget", pkgs[1].Package) + } +} + +func TestParsePackages_empty(t *testing.T) { + pkgs, err := ParsePackages([]byte("")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(pkgs) != 0 { + t.Errorf("expected 0 packages, got %d", len(pkgs)) + } +} + +func TestParsePackages_missingPackageField(t *testing.T) { + bad := `Version: 1.0 +Architecture: amd64 +Filename: pool/main/x/x/x_1.0_amd64.deb +SHA256: 0000000000000000000000000000000000000000000000000000000000000000 +Size: 100` + _, err := ParsePackages([]byte(bad)) + if err == nil { + t.Error("expected error for missing Package field, got nil") + } +} + +func TestFilterPackages(t *testing.T) { + pkgs, _ := ParsePackages([]byte(sampleStanza + "\n\n" + sampleStanza2)) + + // Only curl is "on disk". + onDisk := map[string]bool{ + "pool/main/c/curl/curl_7.88.1-10+deb12u5_amd64.deb": true, + } + filtered := FilterPackages(pkgs, func(filename string) bool { + return onDisk[filename] + }) + + if len(filtered) != 1 { + t.Fatalf("expected 1 filtered package, got %d", len(filtered)) + } + if filtered[0].Package != "curl" { + t.Errorf("expected curl, got %q", filtered[0].Package) + } +} + +func TestParseEmitRoundTrip(t *testing.T) { + input := []byte(sampleStanza + "\n\n" + sampleStanza2 + "\n") + pkgs, err := ParsePackages(input) + if err != nil { + t.Fatalf("parse: %v", err) + } + + emitted := emitPackages(pkgs) + + reparsed, err := ParsePackages(emitted) + if err != nil { + t.Fatalf("reparse: %v", err) + } + if len(reparsed) != len(pkgs) { + t.Fatalf("round-trip length: got %d, want %d", len(reparsed), len(pkgs)) + } + for i := range pkgs { + if reparsed[i].Package != pkgs[i].Package { + t.Errorf("[%d] Package: got %q, want %q", i, reparsed[i].Package, pkgs[i].Package) + } + if reparsed[i].Filename != pkgs[i].Filename { + t.Errorf("[%d] Filename: got %q, want %q", i, reparsed[i].Filename, pkgs[i].Filename) + } + if reparsed[i].SHA256 != pkgs[i].SHA256 { + t.Errorf("[%d] SHA256: got %q, want %q", i, reparsed[i].SHA256, pkgs[i].SHA256) + } + if reparsed[i].Size != pkgs[i].Size { + t.Errorf("[%d] Size: got %d, want %d", i, reparsed[i].Size, pkgs[i].Size) + } + } +} diff --git a/internal/clone/apt/release_test.go b/internal/clone/apt/release_test.go new file mode 100644 index 0000000..8ea9276 --- /dev/null +++ b/internal/clone/apt/release_test.go @@ -0,0 +1,126 @@ +package apt + +import ( + "strings" + "testing" +) + +const plainRelease = `Origin: Debian +Suite: bookworm +Codename: bookworm +Components: main contrib +Architectures: amd64 arm64 +SHA256: + abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 12345 main/binary-amd64/Packages + 0000111122223333000011112222333300001111222233330000111122223333 67890 main/binary-amd64/Packages.gz +` + +const pgpArmoredRelease = `-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA512 + +Origin: Debian +Suite: bookworm +Codename: bookworm +Components: main contrib +Architectures: amd64 +SHA256: + abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 12345 main/binary-amd64/Packages +-----BEGIN PGP SIGNATURE----- + +iQEzBAABCgAdFiEE2F...fake-signature... +-----END PGP SIGNATURE----- +` + +func TestParseRelease_plain(t *testing.T) { + rf, err := ParseRelease([]byte(plainRelease)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rf.Suite != "bookworm" { + t.Errorf("Suite: got %q, want bookworm", rf.Suite) + } + if rf.Codename != "bookworm" { + t.Errorf("Codename: got %q, want bookworm", rf.Codename) + } + if rf.Components != "main contrib" { + t.Errorf("Components: got %q, want %q", rf.Components, "main contrib") + } + if rf.Architectures != "amd64 arm64" { + t.Errorf("Architectures: got %q, want %q", rf.Architectures, "amd64 arm64") + } +} + +func TestParseRelease_sha256entries(t *testing.T) { + rf, err := ParseRelease([]byte(plainRelease)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(rf.SHA256) != 2 { + t.Fatalf("SHA256 entries: got %d, want 2", len(rf.SHA256)) + } + e := rf.SHA256[0] + if e.Hash != "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" { + t.Errorf("entry[0] Hash: got %q", e.Hash) + } + if e.Size != 12345 { + t.Errorf("entry[0] Size: got %d, want 12345", e.Size) + } + if e.Path != "main/binary-amd64/Packages" { + t.Errorf("entry[0] Path: got %q", e.Path) + } +} + +func TestParseRelease_pgpArmored(t *testing.T) { + rf, err := ParseRelease([]byte(pgpArmoredRelease)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rf.Suite != "bookworm" { + t.Errorf("Suite: got %q, want bookworm", rf.Suite) + } + if len(rf.SHA256) != 1 { + t.Errorf("SHA256 entries: got %d, want 1", len(rf.SHA256)) + } + // PGP signature must not bleed into entries. + for _, e := range rf.SHA256 { + if strings.Contains(e.Path, "PGP") || strings.Contains(e.Hash, "PGP") { + t.Errorf("PGP artifact in entry: %+v", e) + } + } +} + +func TestGenerateRelease_parseable(t *testing.T) { + entries := []ReleaseEntry{ + {Hash: "aabbccdd", Size: 100, Path: "main/binary-amd64/Packages"}, + {Hash: "eeff0011", Size: 200, Path: "main/binary-amd64/Packages.gz"}, + } + data := GenerateRelease("bookworm", "bookworm", []string{"main", "contrib"}, []string{"amd64"}, entries) + + rf, err := ParseRelease(data) + if err != nil { + t.Fatalf("ParseRelease on generated output: %v", err) + } + if rf.Suite != "bookworm" { + t.Errorf("Suite: got %q, want bookworm", rf.Suite) + } + if len(rf.SHA256) != 2 { + t.Fatalf("SHA256 entries: got %d, want 2", len(rf.SHA256)) + } + if rf.SHA256[0].Size != 100 { + t.Errorf("entry[0] Size: got %d, want 100", rf.SHA256[0].Size) + } + if rf.SHA256[1].Path != "main/binary-amd64/Packages.gz" { + t.Errorf("entry[1] Path: got %q", rf.SHA256[1].Path) + } +} + +func TestGenerateRelease_emptyEntries(t *testing.T) { + data := GenerateRelease("bookworm", "bookworm", []string{"main"}, []string{"amd64"}, nil) + rf, err := ParseRelease(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(rf.SHA256) != 0 { + t.Errorf("expected 0 SHA256 entries, got %d", len(rf.SHA256)) + } +} diff --git a/internal/clone/rpm/metadata_gen_test.go b/internal/clone/rpm/metadata_gen_test.go new file mode 100644 index 0000000..22113de --- /dev/null +++ b/internal/clone/rpm/metadata_gen_test.go @@ -0,0 +1,284 @@ +package rpm + +import ( + "bytes" + "compress/gzip" + "encoding/xml" + "fmt" + "os" + "path/filepath" + "testing" +) + +// buildPrimaryGz creates a gzipped primary.xml with the given packages. +// Each entry is {name, locationHref}. +func buildPrimaryGz(t *testing.T, pkgs []struct{ name, href string }) []byte { + t.Helper() + var buf bytes.Buffer + buf.WriteString(``) + fmt.Fprintf(&buf, ``, len(pkgs)) + for _, p := range pkgs { + fmt.Fprintf(&buf, + `%s`, + p.name, p.href) + } + buf.WriteString(``) + + var gz bytes.Buffer + gzw := gzip.NewWriter(&gz) + gzw.Write(buf.Bytes()) + gzw.Close() + return gz.Bytes() +} + +// buildRepoMD creates a minimal repomd.xml pointing to the given primary href. +func buildRepoMD(t *testing.T, primaryHref string) []byte { + t.Helper() + repomd := RepoMD{ + Data: []RepoMDEntry{{ + Type: "primary", + Location: RepoMDLocation{Href: primaryHref}, + Checksum: RepoMDChecksum{Type: "sha256", Value: "placeholder"}, + OpenChecksum: RepoMDChecksum{Type: "sha256", Value: "placeholder"}, + }}, + } + out, err := xml.MarshalIndent(repomd, "", " ") + if err != nil { + t.Fatalf("marshal repomd: %v", err) + } + return append([]byte(xml.Header), out...) +} + +// setupRepo creates a local repo dir with a primary.xml.gz, a .primary-source +// marker and a repomd.xml. It creates the given rpm files on disk. +func setupRepo(t *testing.T, pkgs []struct{ name, href string }, presentOnDisk []string) string { + t.Helper() + dir := t.TempDir() + repodata := filepath.Join(dir, "repodata") + os.MkdirAll(repodata, 0o755) + + // Upstream primary at a hashed path (mimics real layout). + upstreamPrimary := "repodata/upstream-primary.xml.gz" + if err := os.WriteFile(filepath.Join(dir, upstreamPrimary), buildPrimaryGz(t, pkgs), 0o644); err != nil { + t.Fatalf("write upstream primary: %v", err) + } + + // .primary-source marker. + if err := os.WriteFile(filepath.Join(repodata, PrimarySourceFile), []byte(upstreamPrimary), 0o644); err != nil { + t.Fatalf("write .primary-source: %v", err) + } + + // repomd.xml pointing at local primary (which doesn't exist yet — regen will create it). + if err := os.WriteFile(filepath.Join(repodata, "repomd.xml"), buildRepoMD(t, localPrimaryHref), 0o644); err != nil { + t.Fatalf("write repomd.xml: %v", err) + } + + // Create selected rpm files on disk. + for _, href := range presentOnDisk { + dest := filepath.Join(dir, filepath.FromSlash(href)) + os.MkdirAll(filepath.Dir(dest), 0o755) + if err := os.WriteFile(dest, []byte("fake-rpm"), 0o644); err != nil { + t.Fatalf("write rpm %s: %v", href, err) + } + } + + return dir +} + +// parsePrimaryGz decompresses and parses a primary.xml.gz, returning package hrefs. +func parsePrimaryGz(t *testing.T, path string) []string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read primary.xml.gz: %v", err) + } + gz, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + t.Fatalf("open gzip: %v", err) + } + defer gz.Close() + var meta filterableMetadata + if err := xml.NewDecoder(gz).Decode(&meta); err != nil { + t.Fatalf("parse primary.xml: %v", err) + } + hrefs := make([]string, len(meta.Packages)) + for i, p := range meta.Packages { + hrefs[i] = p.Location.Href + } + return hrefs +} + +// --- InitEmptyRepo --- + +func TestInitEmptyRepo_createsStructure(t *testing.T) { + dir := t.TempDir() + if err := InitEmptyRepo(dir); err != nil { + t.Fatalf("InitEmptyRepo: %v", err) + } + + for _, path := range []string{ + filepath.Join(dir, "repodata"), + filepath.Join(dir, "Packages"), + filepath.Join(dir, "repodata", "repomd.xml"), + filepath.Join(dir, localPrimaryHref), + } { + if _, err := os.Stat(path); err != nil { + t.Errorf("expected %s to exist: %v", path, err) + } + } +} + +func TestInitEmptyRepo_repomdIsValid(t *testing.T) { + dir := t.TempDir() + InitEmptyRepo(dir) + + data, _ := os.ReadFile(filepath.Join(dir, "repodata", "repomd.xml")) + var repomd RepoMD + if err := xml.Unmarshal(data, &repomd); err != nil { + t.Fatalf("repomd.xml is not valid XML: %v", err) + } + if len(repomd.Data) == 0 { + t.Error("expected at least one entry in repomd.xml") + } + if repomd.Data[0].Type != "primary" { + t.Errorf("first entry type: got %q, want primary", repomd.Data[0].Type) + } +} + +func TestInitEmptyRepo_primaryIsEmpty(t *testing.T) { + dir := t.TempDir() + InitEmptyRepo(dir) + + hrefs := parsePrimaryGz(t, filepath.Join(dir, localPrimaryHref)) + if len(hrefs) != 0 { + t.Errorf("expected 0 packages in empty repo, got %d", len(hrefs)) + } +} + +func TestInitEmptyRepo_idempotent(t *testing.T) { + dir := t.TempDir() + InitEmptyRepo(dir) + + repomdBefore, _ := os.ReadFile(filepath.Join(dir, "repodata", "repomd.xml")) + InitEmptyRepo(dir) // second call + repomdAfter, _ := os.ReadFile(filepath.Join(dir, "repodata", "repomd.xml")) + + if !bytes.Equal(repomdBefore, repomdAfter) { + t.Error("second InitEmptyRepo modified repomd.xml") + } +} + +// --- RegenerateMetadata --- + +func TestRegenerateMetadata_keepsOnlyPresentFiles(t *testing.T) { + allPkgs := []struct{ name, href string }{ + {"curl", "Packages/curl-7.88-1.x86_64.rpm"}, + {"wget", "Packages/wget-1.21-1.x86_64.rpm"}, + {"vim", "Packages/vim-9.0-1.x86_64.rpm"}, + } + // Only curl and vim are on disk. + presentOnDisk := []string{"Packages/curl-7.88-1.x86_64.rpm", "Packages/vim-9.0-1.x86_64.rpm"} + + dir := setupRepo(t, allPkgs, presentOnDisk) + + if err := RegenerateMetadata(dir); err != nil { + t.Fatalf("RegenerateMetadata: %v", err) + } + + hrefs := parsePrimaryGz(t, filepath.Join(dir, localPrimaryHref)) + if len(hrefs) != 2 { + t.Fatalf("expected 2 packages, got %d: %v", len(hrefs), hrefs) + } + hrefSet := map[string]bool{hrefs[0]: true, hrefs[1]: true} + if !hrefSet["Packages/curl-7.88-1.x86_64.rpm"] { + t.Error("expected curl in output") + } + if !hrefSet["Packages/vim-9.0-1.x86_64.rpm"] { + t.Error("expected vim in output") + } + if hrefSet["Packages/wget-1.21-1.x86_64.rpm"] { + t.Error("wget should have been filtered out (not on disk)") + } +} + +func TestRegenerateMetadata_allAbsent(t *testing.T) { + allPkgs := []struct{ name, href string }{ + {"curl", "Packages/curl-7.88-1.x86_64.rpm"}, + {"wget", "Packages/wget-1.21-1.x86_64.rpm"}, + } + dir := setupRepo(t, allPkgs, nil) // nothing on disk + + if err := RegenerateMetadata(dir); err != nil { + t.Fatalf("RegenerateMetadata: %v", err) + } + + hrefs := parsePrimaryGz(t, filepath.Join(dir, localPrimaryHref)) + if len(hrefs) != 0 { + t.Errorf("expected 0 packages, got %d", len(hrefs)) + } +} + +func TestRegenerateMetadata_updatesRepoMD(t *testing.T) { + allPkgs := []struct{ name, href string }{ + {"curl", "Packages/curl-7.88-1.x86_64.rpm"}, + } + dir := setupRepo(t, allPkgs, []string{"Packages/curl-7.88-1.x86_64.rpm"}) + + if err := RegenerateMetadata(dir); err != nil { + t.Fatalf("RegenerateMetadata: %v", err) + } + + data, _ := os.ReadFile(filepath.Join(dir, "repodata", "repomd.xml")) + var repomd RepoMD + xml.Unmarshal(data, &repomd) + + var primary *RepoMDEntry + for i := range repomd.Data { + if repomd.Data[i].Type == "primary" { + primary = &repomd.Data[i] + } + } + if primary == nil { + t.Fatal("no primary entry in repomd.xml after regen") + } + if primary.Location.Href != localPrimaryHref { + t.Errorf("Location.Href: got %q, want %q", primary.Location.Href, localPrimaryHref) + } + if primary.Checksum.Type != "sha256" { + t.Errorf("Checksum.Type: got %q, want sha256", primary.Checksum.Type) + } + if primary.Checksum.Value == "placeholder" { + t.Error("checksum was not updated from placeholder") + } + if primary.Size == 0 { + t.Error("Size should be non-zero after regen") + } +} + +func TestRegenerateMetadata_alwaysFiltersFromUpstream(t *testing.T) { + // Simulates two successive approvals — regen must always read upstream, not the + // previously-filtered local primary. + allPkgs := []struct{ name, href string }{ + {"curl", "Packages/curl-7.88-1.x86_64.rpm"}, + {"wget", "Packages/wget-1.21-1.x86_64.rpm"}, + {"vim", "Packages/vim-9.0-1.x86_64.rpm"}, + } + dir := setupRepo(t, allPkgs, []string{"Packages/curl-7.88-1.x86_64.rpm"}) + + // First regen: only curl on disk. + RegenerateMetadata(dir) + hrefs := parsePrimaryGz(t, filepath.Join(dir, localPrimaryHref)) + if len(hrefs) != 1 { + t.Fatalf("after first regen: expected 1, got %d", len(hrefs)) + } + + // Add wget to disk, regen again — vim still absent. + dest := filepath.Join(dir, "Packages", "wget-1.21-1.x86_64.rpm") + os.WriteFile(dest, []byte("fake"), 0o644) + + RegenerateMetadata(dir) + hrefs = parsePrimaryGz(t, filepath.Join(dir, localPrimaryHref)) + if len(hrefs) != 2 { + t.Fatalf("after second regen: expected 2, got %d: %v", len(hrefs), hrefs) + } +} diff --git a/internal/core/repo_test.go b/internal/core/repo_test.go new file mode 100644 index 0000000..fc416b3 --- /dev/null +++ b/internal/core/repo_test.go @@ -0,0 +1,355 @@ +package core_test + +import ( + "context" + "encoding/json" + "errors" + "testing" + + aptclone "github.com/syonad/clonepack/internal/clone/apt" + "github.com/syonad/clonepack/internal/core" + "github.com/syonad/clonepack/internal/store" +) + +// mockRepoStore is an in-memory implementation of store.RepoStore for tests. +type mockRepoStore struct { + repos map[int64]*store.Repo + nextID int64 +} + +func newMockRepoStore() *mockRepoStore { + return &mockRepoStore{repos: make(map[int64]*store.Repo), nextID: 1} +} + +func (m *mockRepoStore) CreateRepo(_ context.Context, r *store.Repo) (int64, error) { + for _, existing := range m.repos { + if existing.Name == r.Name { + return 0, errors.New("UNIQUE constraint failed: repos.name") + } + } + id := m.nextID + m.nextID++ + clone := *r + clone.ID = id + m.repos[id] = &clone + return id, nil +} + +func (m *mockRepoStore) GetRepo(_ context.Context, id int64) (*store.Repo, error) { + r, ok := m.repos[id] + if !ok { + return nil, store.ErrNotFound + } + clone := *r + return &clone, nil +} + +func (m *mockRepoStore) GetRepoByName(_ context.Context, name string) (*store.Repo, error) { + for _, r := range m.repos { + if r.Name == name { + clone := *r + return &clone, nil + } + } + return nil, store.ErrNotFound +} + +func (m *mockRepoStore) ListRepos(_ context.Context) ([]store.Repo, error) { + out := make([]store.Repo, 0, len(m.repos)) + for _, r := range m.repos { + out = append(out, *r) + } + return out, nil +} + +func (m *mockRepoStore) DeleteRepo(_ context.Context, id int64) error { + if _, ok := m.repos[id]; !ok { + return store.ErrNotFound + } + delete(m.repos, id) + return nil +} + +func (m *mockRepoStore) UpdateRepoSyncMode(_ context.Context, id int64, mode string) error { + r, ok := m.repos[id] + if !ok { + return store.ErrNotFound + } + r.SyncMode = mode + return nil +} + +func (m *mockRepoStore) UpdateRepo(_ context.Context, id int64, sourceURL, syncMode, config string) error { + r, ok := m.repos[id] + if !ok { + return store.ErrNotFound + } + r.SourceURL = sourceURL + r.SyncMode = syncMode + r.Config = config + return nil +} + +func newTestRepoService(t *testing.T) *core.RepoService { + t.Helper() + return core.NewRepoService(newMockRepoStore(), t.TempDir()) +} + +// --- Create tests --- + +func TestCreate_validRPM(t *testing.T) { + svc := newTestRepoService(t) + repo, err := svc.Create(context.Background(), core.CreateRepoInput{ + Name: "rocky9", + Type: "rpm", + SourceURL: "https://download.rockylinux.org/pub/rocky/9/BaseOS/x86_64/os", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if repo.ID <= 0 { + t.Errorf("expected positive ID, got %d", repo.ID) + } + if repo.SyncMode != "auto" { + t.Errorf("default SyncMode: got %q, want auto", repo.SyncMode) + } +} + +func TestCreate_validAPT(t *testing.T) { + svc := newTestRepoService(t) + repo, err := svc.Create(context.Background(), core.CreateRepoInput{ + Name: "debian-bookworm", + Type: "apt", + SourceURL: "https://deb.debian.org/debian", + AptSuite: "bookworm", + AptComponents: []string{"main", "contrib"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Config should be valid JSON with suite set. + var cfg aptclone.Config + if err := json.Unmarshal([]byte(repo.Config), &cfg); err != nil { + t.Fatalf("Config is not valid JSON: %v", err) + } + if cfg.Suite != "bookworm" { + t.Errorf("cfg.Suite: got %q, want bookworm", cfg.Suite) + } + if len(cfg.Components) != 2 { + t.Errorf("cfg.Components: got %v, want [main contrib]", cfg.Components) + } +} + +func TestCreate_aptDefaultsComponents(t *testing.T) { + svc := newTestRepoService(t) + repo, err := svc.Create(context.Background(), core.CreateRepoInput{ + Name: "debian-minimal", + Type: "apt", + SourceURL: "https://deb.debian.org/debian", + AptSuite: "bookworm", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var cfg aptclone.Config + json.Unmarshal([]byte(repo.Config), &cfg) + if len(cfg.Components) == 0 { + t.Error("expected default components, got empty") + } + if len(cfg.Architectures) == 0 { + t.Error("expected default architectures, got empty") + } +} + +func TestCreate_missingName(t *testing.T) { + svc := newTestRepoService(t) + _, err := svc.Create(context.Background(), core.CreateRepoInput{ + Type: "rpm", + SourceURL: "https://example.com", + }) + if err == nil { + t.Error("expected error for missing name, got nil") + } +} + +func TestCreate_invalidType(t *testing.T) { + svc := newTestRepoService(t) + _, err := svc.Create(context.Background(), core.CreateRepoInput{ + Name: "bad-type", + Type: "maven", + SourceURL: "https://example.com", + }) + if err == nil { + t.Error("expected error for invalid type, got nil") + } +} + +func TestCreate_missingSourceURL(t *testing.T) { + svc := newTestRepoService(t) + _, err := svc.Create(context.Background(), core.CreateRepoInput{ + Name: "no-url", + Type: "rpm", + }) + if err == nil { + t.Error("expected error for missing source_url, got nil") + } +} + +func TestCreate_aptMissingSuite(t *testing.T) { + svc := newTestRepoService(t) + _, err := svc.Create(context.Background(), core.CreateRepoInput{ + Name: "apt-no-suite", + Type: "apt", + SourceURL: "https://deb.debian.org/debian", + }) + if err == nil { + t.Error("expected error for missing apt_suite, got nil") + } +} + +func TestCreate_invalidSyncMode(t *testing.T) { + svc := newTestRepoService(t) + _, err := svc.Create(context.Background(), core.CreateRepoInput{ + Name: "bad-mode", + Type: "rpm", + SourceURL: "https://example.com", + SyncMode: "cron", + }) + if err == nil { + t.Error("expected error for invalid sync_mode, got nil") + } +} + +// --- Update tests --- + +func TestUpdate_changeSourceURL(t *testing.T) { + svc := newTestRepoService(t) + ctx := context.Background() + + repo, _ := svc.Create(ctx, core.CreateRepoInput{ + Name: "rpm-repo", + Type: "rpm", + SourceURL: "https://old.example.com", + }) + + newURL := "https://new.example.com" + updated, err := svc.Update(ctx, repo.ID, core.UpdateRepoInput{SourceURL: &newURL}) + if err != nil { + t.Fatalf("Update: %v", err) + } + if updated.SourceURL != newURL { + t.Errorf("SourceURL: got %q, want %q", updated.SourceURL, newURL) + } +} + +func TestUpdate_changeSyncMode(t *testing.T) { + svc := newTestRepoService(t) + ctx := context.Background() + + repo, _ := svc.Create(ctx, core.CreateRepoInput{ + Name: "rpm-repo-2", + Type: "rpm", + SourceURL: "https://example.com", + }) + + mode := "manual" + updated, err := svc.Update(ctx, repo.ID, core.UpdateRepoInput{SyncMode: &mode}) + if err != nil { + t.Fatalf("Update: %v", err) + } + if updated.SyncMode != "manual" { + t.Errorf("SyncMode: got %q, want manual", updated.SyncMode) + } +} + +func TestUpdate_aptComponents(t *testing.T) { + svc := newTestRepoService(t) + ctx := context.Background() + + repo, _ := svc.Create(ctx, core.CreateRepoInput{ + Name: "apt-repo", + Type: "apt", + SourceURL: "https://deb.debian.org/debian", + AptSuite: "bookworm", + }) + + suite := "bookworm-backports" + updated, err := svc.Update(ctx, repo.ID, core.UpdateRepoInput{ + AptSuite: &suite, + AptComponents: []string{"main", "contrib", "non-free"}, + }) + if err != nil { + t.Fatalf("Update: %v", err) + } + + var cfg aptclone.Config + if err := json.Unmarshal([]byte(updated.Config), &cfg); err != nil { + t.Fatalf("Config JSON: %v", err) + } + if cfg.Suite != "bookworm-backports" { + t.Errorf("Suite: got %q, want bookworm-backports", cfg.Suite) + } + if len(cfg.Components) != 3 { + t.Errorf("Components: got %v", cfg.Components) + } +} + +func TestUpdate_invalidSyncMode(t *testing.T) { + svc := newTestRepoService(t) + ctx := context.Background() + + repo, _ := svc.Create(ctx, core.CreateRepoInput{ + Name: "rpm-repo-3", + Type: "rpm", + SourceURL: "https://example.com", + }) + + bad := "weekly" + _, err := svc.Update(ctx, repo.ID, core.UpdateRepoInput{SyncMode: &bad}) + if err == nil { + t.Error("expected error for invalid sync_mode, got nil") + } +} + +func TestUpdate_aptFieldsOnRPMRepo(t *testing.T) { + svc := newTestRepoService(t) + ctx := context.Background() + + repo, _ := svc.Create(ctx, core.CreateRepoInput{ + Name: "rpm-repo-4", + Type: "rpm", + SourceURL: "https://example.com", + }) + + suite := "bookworm" + _, err := svc.Update(ctx, repo.ID, core.UpdateRepoInput{AptSuite: &suite}) + if err == nil { + t.Error("expected error applying apt fields to rpm repo, got nil") + } +} + +func TestUpdate_emptySourceURL(t *testing.T) { + svc := newTestRepoService(t) + ctx := context.Background() + + repo, _ := svc.Create(ctx, core.CreateRepoInput{ + Name: "rpm-repo-5", + Type: "rpm", + SourceURL: "https://example.com", + }) + + empty := "" + _, err := svc.Update(ctx, repo.ID, core.UpdateRepoInput{SourceURL: &empty}) + if err == nil { + t.Error("expected error for empty source_url, got nil") + } +} + +func TestUpdate_notFound(t *testing.T) { + svc := newTestRepoService(t) + url := "https://example.com" + _, err := svc.Update(context.Background(), 9999, core.UpdateRepoInput{SourceURL: &url}) + if !errors.Is(err, store.ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } +} diff --git a/internal/core/snapshot_test.go b/internal/core/snapshot_test.go new file mode 100644 index 0000000..7284872 --- /dev/null +++ b/internal/core/snapshot_test.go @@ -0,0 +1,417 @@ +package core_test + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/json" + "encoding/xml" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + aptclone "github.com/syonad/clonepack/internal/clone/apt" + rpmclone "github.com/syonad/clonepack/internal/clone/rpm" + "github.com/syonad/clonepack/internal/store" +) + +// --- helpers --- + +// buildRPMRepo creates an RPM repo (repomd.xml + upstream primary.xml.gz + +// .primary-source marker) in localDir and writes fake package files on disk. +func buildRPMRepo(t *testing.T, localDir string, pkgs []struct{ name, version, href string }) { + t.Helper() + if err := rpmclone.InitEmptyRepo(localDir); err != nil { + t.Fatalf("InitEmptyRepo: %v", err) + } + + // Build upstream primary.xml.gz. + var xmlBuf bytes.Buffer + xmlBuf.WriteString(``) + fmt.Fprintf(&xmlBuf, ``, len(pkgs)) + for _, p := range pkgs { + fmt.Fprintf(&xmlBuf, + `%s`+ + ``+ + `x86_64`+ + `abc%s`+ + ``+ + ``+ + ``, + p.name, p.version, p.name, p.href) + } + xmlBuf.WriteString(``) + + var gz bytes.Buffer + gzw := gzip.NewWriter(&gz) + gzw.Write(xmlBuf.Bytes()) + gzw.Close() + + upstreamPath := filepath.Join(localDir, "repodata", "upstream-primary.xml.gz") + if err := os.WriteFile(upstreamPath, gz.Bytes(), 0o644); err != nil { + t.Fatalf("write upstream primary: %v", err) + } + + // .primary-source marker so RegenerateMetadata always filters from upstream. + marker := filepath.Join(localDir, "repodata", rpmclone.PrimarySourceFile) + if err := os.WriteFile(marker, []byte("repodata/upstream-primary.xml.gz"), 0o644); err != nil { + t.Fatalf("write .primary-source: %v", err) + } + + // Minimal repomd.xml pointing at the upstream (regen will redirect to local). + type loc struct { + Href string `xml:"href,attr"` + } + type chk struct { + Type string `xml:"type,attr"` + Value string `xml:",chardata"` + } + type entry struct { + Type string `xml:"type,attr"` + Location loc `xml:"location"` + Checksum chk `xml:"checksum"` + } + type repomdDoc struct { + XMLName xml.Name `xml:"repomd"` + Data []entry `xml:"data"` + } + rm := repomdDoc{Data: []entry{{ + Type: "primary", + Location: loc{Href: "repodata/upstream-primary.xml.gz"}, + Checksum: chk{Type: "sha256", Value: "placeholder"}, + }}} + repomdXML, _ := xml.MarshalIndent(rm, "", " ") + if err := os.WriteFile( + filepath.Join(localDir, "repodata", "repomd.xml"), + append([]byte(xml.Header), repomdXML...), + 0o644, + ); err != nil { + t.Fatalf("write repomd.xml: %v", err) + } + + // Create fake package files. + for _, p := range pkgs { + dest := filepath.Join(localDir, filepath.FromSlash(p.href)) + os.MkdirAll(filepath.Dir(dest), 0o755) + os.WriteFile(dest, []byte("fake-rpm"), 0o644) + } + + // Regenerate so repomd.xml reflects actual checksums. + if err := rpmclone.RegenerateMetadata(localDir); err != nil { + t.Fatalf("RegenerateMetadata: %v", err) + } +} + +// buildAPTRepo creates an APT repo with Packages files and fake deb files on disk. +func buildAPTRepo(t *testing.T, localDir string, cfg aptclone.Config, debs []struct{ pkg, version, filename string }) { + t.Helper() + if err := aptclone.InitEmptyRepo(localDir, cfg); err != nil { + t.Fatalf("InitEmptyRepo apt: %v", err) + } + + var stanzas []string + for _, d := range debs { + stanzas = append(stanzas, strings.Join([]string{ + "Package: " + d.pkg, + "Version: " + d.version, + "Architecture: amd64", + "Filename: " + d.filename, + "SHA256: abc" + d.pkg, + "Size: 1024", + }, "\n")) + } + + plain := []byte(strings.Join(stanzas, "\n\n") + "\n") + var gz bytes.Buffer + gzw := gzip.NewWriter(&gz) + gzw.Write(plain) + gzw.Close() + gzData := gz.Bytes() + + for _, component := range cfg.Components { + for _, arch := range cfg.Architectures { + binDir := filepath.Join(localDir, "dists", cfg.Suite, component, "binary-"+arch) + os.MkdirAll(binDir, 0o755) + os.WriteFile(filepath.Join(binDir, ".upstream-Packages.gz"), gzData, 0o644) + } + } + + for _, d := range debs { + dest := filepath.Join(localDir, filepath.FromSlash(d.filename)) + os.MkdirAll(filepath.Dir(dest), 0o755) + os.WriteFile(dest, []byte("fake-deb"), 0o644) + } + + if err := aptclone.RegenerateMetadata(localDir, cfg); err != nil { + t.Fatalf("RegenerateMetadata apt: %v", err) + } +} + +// --- TakeSnapshot RPM --- + +func TestTakeSnapshot_RPM_capturesPackages(t *testing.T) { + env := newTestEnv(t) + ctx := context.Background() + + repoID, _ := env.repoStore.CreateRepo(ctx, &store.Repo{ + Name: "rpm-repo", Type: "rpm", SourceURL: "https://example.com", SyncMode: "manual", + }) + localDir := filepath.Join(env.dataDir, "repos", "1", "rpm") + buildRPMRepo(t, localDir, []struct{ name, version, href string }{ + {"curl", "7.88", "Packages/curl-7.88-1.x86_64.rpm"}, + {"wget", "1.21", "Packages/wget-1.21-1.x86_64.rpm"}, + }) + + snapID, err := env.snapshotSvc.TakeSnapshot(ctx, repoID, "v1") + if err != nil { + t.Fatalf("TakeSnapshot: %v", err) + } + + pkgs, _ := env.snapshotStore.GetSnapshotPackages(ctx, snapID) + if len(pkgs) != 2 { + t.Fatalf("expected 2 packages, got %d", len(pkgs)) + } + names := map[string]bool{} + for _, p := range pkgs { + names[p.Name] = true + } + if !names["curl"] || !names["wget"] { + t.Errorf("unexpected package names: %v", names) + } +} + +func TestTakeSnapshot_RPM_emptyRepo(t *testing.T) { + env := newTestEnv(t) + ctx := context.Background() + + repoID, _ := env.repoStore.CreateRepo(ctx, &store.Repo{ + Name: "rpm-empty", Type: "rpm", SourceURL: "https://example.com", SyncMode: "manual", + }) + localDir := filepath.Join(env.dataDir, "repos", "1", "rpm") + buildRPMRepo(t, localDir, nil) + + snapID, err := env.snapshotSvc.TakeSnapshot(ctx, repoID, "empty") + if err != nil { + t.Fatalf("TakeSnapshot: %v", err) + } + pkgs, _ := env.snapshotStore.GetSnapshotPackages(ctx, snapID) + if len(pkgs) != 0 { + t.Errorf("expected 0 packages, got %d", len(pkgs)) + } +} + +func TestTakeSnapshot_repoNotFound(t *testing.T) { + env := newTestEnv(t) + _, err := env.snapshotSvc.TakeSnapshot(context.Background(), 9999, "v1") + if err == nil { + t.Error("expected error for unknown repo") + } +} + +// --- TakeSnapshot APT --- + +func TestTakeSnapshot_APT_capturesPackages(t *testing.T) { + env := newTestEnv(t) + ctx := context.Background() + + cfg := aptclone.Config{Suite: "bookworm", Components: []string{"main"}, Architectures: []string{"amd64"}} + cfgJSON, _ := json.Marshal(cfg) + repoID, _ := env.repoStore.CreateRepo(ctx, &store.Repo{ + Name: "apt-repo", Type: "apt", SourceURL: "https://deb.debian.org/debian", + SyncMode: "manual", Config: string(cfgJSON), + }) + localDir := filepath.Join(env.dataDir, "repos", "1", "apt") + buildAPTRepo(t, localDir, cfg, []struct{ pkg, version, filename string }{ + {"curl", "7.88.1-10", "pool/main/c/curl/curl_7.88.1-10_amd64.deb"}, + {"vim", "2:9.0.1378-2", "pool/main/v/vim/vim_9.0.1378-2_amd64.deb"}, + }) + + snapID, err := env.snapshotSvc.TakeSnapshot(ctx, repoID, "apt-v1") + if err != nil { + t.Fatalf("TakeSnapshot APT: %v", err) + } + pkgs, _ := env.snapshotStore.GetSnapshotPackages(ctx, snapID) + if len(pkgs) != 2 { + t.Fatalf("expected 2 packages, got %d", len(pkgs)) + } +} + +func TestTakeSnapshot_APT_deduplicatesAcrossComponents(t *testing.T) { + env := newTestEnv(t) + ctx := context.Background() + + cfg := aptclone.Config{ + Suite: "bookworm", Components: []string{"main", "contrib"}, Architectures: []string{"amd64"}, + } + cfgJSON, _ := json.Marshal(cfg) + repoID, _ := env.repoStore.CreateRepo(ctx, &store.Repo{ + Name: "apt-multi", Type: "apt", SourceURL: "https://deb.debian.org/debian", + SyncMode: "manual", Config: string(cfgJSON), + }) + localDir := filepath.Join(env.dataDir, "repos", "1", "apt") + aptclone.InitEmptyRepo(localDir, cfg) + + // Same .deb appears in both components. + stanza := strings.Join([]string{ + "Package: shared-pkg", + "Version: 1.0", + "Architecture: amd64", + "Filename: pool/main/s/shared-pkg/shared-pkg_1.0_amd64.deb", + "SHA256: abcdef", + "Size: 1024", + }, "\n") + "\n" + + var gz bytes.Buffer + gzw := gzip.NewWriter(&gz) + gzw.Write([]byte(stanza)) + gzw.Close() + gzData := gz.Bytes() + + for _, component := range cfg.Components { + binDir := filepath.Join(localDir, "dists", cfg.Suite, component, "binary-amd64") + os.MkdirAll(binDir, 0o755) + os.WriteFile(filepath.Join(binDir, ".upstream-Packages.gz"), gzData, 0o644) + } + dest := filepath.Join(localDir, "pool/main/s/shared-pkg/shared-pkg_1.0_amd64.deb") + os.MkdirAll(filepath.Dir(dest), 0o755) + os.WriteFile(dest, []byte("fake-deb"), 0o644) + aptclone.RegenerateMetadata(localDir, cfg) + + snapID, err := env.snapshotSvc.TakeSnapshot(ctx, repoID, "dedup-test") + if err != nil { + t.Fatalf("TakeSnapshot: %v", err) + } + pkgs, _ := env.snapshotStore.GetSnapshotPackages(ctx, snapID) + if len(pkgs) != 1 { + t.Errorf("expected 1 deduplicated package, got %d", len(pkgs)) + } +} + +// --- Diff --- + +func TestDiff_addsAndRemoves(t *testing.T) { + env := newTestEnv(t) + ctx := context.Background() + + repoID, _ := env.repoStore.CreateRepo(ctx, &store.Repo{ + Name: "rpm-repo", Type: "rpm", SourceURL: "https://example.com", SyncMode: "manual", + }) + + snap1ID, _ := env.snapshotStore.CreateSnapshot(ctx, repoID, "v1") + env.snapshotStore.AddSnapshotPackages(ctx, snap1ID, []store.SnapshotPackage{ + {Name: "curl", Version: "7.88-1", Arch: "x86_64", Location: "Packages/curl.rpm", ChecksumType: "sha256"}, + {Name: "wget", Version: "1.21-1", Arch: "x86_64", Location: "Packages/wget.rpm", ChecksumType: "sha256"}, + }) + + snap2ID, _ := env.snapshotStore.CreateSnapshot(ctx, repoID, "v2") + env.snapshotStore.AddSnapshotPackages(ctx, snap2ID, []store.SnapshotPackage{ + {Name: "curl", Version: "7.88-1", Arch: "x86_64", Location: "Packages/curl.rpm", ChecksumType: "sha256"}, + {Name: "vim", Version: "9.0-1", Arch: "x86_64", Location: "Packages/vim.rpm", ChecksumType: "sha256"}, + }) + + diff, err := env.snapshotSvc.Diff(ctx, repoID, snap1ID, snap2ID) + if err != nil { + t.Fatalf("Diff: %v", err) + } + if diff.Unchanged != 1 { + t.Errorf("Unchanged: got %d, want 1", diff.Unchanged) + } + if len(diff.Added) != 1 || diff.Added[0].Name != "vim" { + t.Errorf("Added: got %v, want [vim]", diff.Added) + } + if len(diff.Removed) != 1 || diff.Removed[0].Name != "wget" { + t.Errorf("Removed: got %v, want [wget]", diff.Removed) + } +} + +func TestDiff_wrongRepo(t *testing.T) { + env := newTestEnv(t) + ctx := context.Background() + + id1, _ := env.repoStore.CreateRepo(ctx, &store.Repo{Name: "r1", Type: "rpm", SourceURL: "https://a.com", SyncMode: "manual"}) + id2, _ := env.repoStore.CreateRepo(ctx, &store.Repo{Name: "r2", Type: "rpm", SourceURL: "https://b.com", SyncMode: "manual"}) + + snap1ID, _ := env.snapshotStore.CreateSnapshot(ctx, id1, "s1") + snap2ID, _ := env.snapshotStore.CreateSnapshot(ctx, id2, "s2") + + _, err := env.snapshotSvc.Diff(ctx, id1, snap1ID, snap2ID) + if err == nil { + t.Error("expected error when snapshots belong to different repos") + } +} + +// --- Rollback --- + +func TestRollback_removesExtraFiles(t *testing.T) { + env := newTestEnv(t) + ctx := context.Background() + + repoID, _ := env.repoStore.CreateRepo(ctx, &store.Repo{ + Name: "rpm-repo", Type: "rpm", SourceURL: "https://example.com", SyncMode: "manual", + }) + localDir := filepath.Join(env.dataDir, "repos", "1", "rpm") + buildRPMRepo(t, localDir, []struct{ name, version, href string }{ + {"curl", "7.88", "Packages/curl-7.88-1.x86_64.rpm"}, + {"wget", "1.21", "Packages/wget-1.21-1.x86_64.rpm"}, + }) + + // Snapshot records only curl. + snapID, _ := env.snapshotStore.CreateSnapshot(ctx, repoID, "curl-only") + env.snapshotStore.AddSnapshotPackages(ctx, snapID, []store.SnapshotPackage{ + {Name: "curl", Version: "7.88-1", Arch: "x86_64", + Location: "Packages/curl-7.88-1.x86_64.rpm", ChecksumType: "sha256"}, + }) + + curlPath := filepath.Join(localDir, "Packages", "curl-7.88-1.x86_64.rpm") + wgetPath := filepath.Join(localDir, "Packages", "wget-1.21-1.x86_64.rpm") + + if err := env.snapshotSvc.Rollback(ctx, repoID, snapID); err != nil { + t.Fatalf("Rollback: %v", err) + } + + if _, err := os.Stat(wgetPath); err == nil { + t.Error("wget should have been removed by rollback") + } + if _, err := os.Stat(curlPath); err != nil { + t.Errorf("curl should still be present: %v", err) + } +} + +func TestRollback_redownloadsMissingFiles(t *testing.T) { + const fileContent = "fake-rpm-bytes" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(fileContent)) + })) + defer srv.Close() + + env := newTestEnv(t) + ctx := context.Background() + + repoID, _ := env.repoStore.CreateRepo(ctx, &store.Repo{ + Name: "rpm-repo", Type: "rpm", SourceURL: srv.URL, SyncMode: "manual", + }) + localDir := filepath.Join(env.dataDir, "repos", "1", "rpm") + buildRPMRepo(t, localDir, []struct{ name, version, href string }{ + {"curl", "7.88", "Packages/curl-7.88-1.x86_64.rpm"}, + }) + + snapID, _ := env.snapshotStore.CreateSnapshot(ctx, repoID, "v1") + env.snapshotStore.AddSnapshotPackages(ctx, snapID, []store.SnapshotPackage{ + {Name: "curl", Version: "7.88-1", Arch: "x86_64", + Location: "Packages/curl-7.88-1.x86_64.rpm", ChecksumType: "sha256"}, + }) + + curlPath := filepath.Join(localDir, "Packages", "curl-7.88-1.x86_64.rpm") + os.Remove(curlPath) + + if err := env.snapshotSvc.Rollback(ctx, repoID, snapID); err != nil { + t.Fatalf("Rollback: %v", err) + } + if _, err := os.Stat(curlPath); err != nil { + t.Errorf("curl should have been re-downloaded: %v", err) + } +} diff --git a/internal/core/sync_test.go b/internal/core/sync_test.go new file mode 100644 index 0000000..7994e22 --- /dev/null +++ b/internal/core/sync_test.go @@ -0,0 +1,268 @@ +package core_test + +import ( + "context" + "database/sql" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/syonad/clonepack/config" + "github.com/syonad/clonepack/internal/core" + "github.com/syonad/clonepack/internal/store" +) + +// testEnv regroupe tous les stores + le SyncService câblé sur SQLite in-memory. +type testEnv struct { + db *sql.DB + repoStore *store.SQLiteRepoStore + pendingStore *store.SQLitePendingPackageStore + blockedStore *store.SQLiteBlockedPackageStore + snapshotStore *store.SQLiteSnapshotStore + cloneJobStore *store.SQLiteCloneJobStore + syncSvc *core.SyncService + snapshotSvc *core.SnapshotService + dataDir string +} + +func newTestEnv(t *testing.T) *testEnv { + 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) + pendingStore := store.NewPendingPackageStore(db) + blockedStore := store.NewBlockedPackageStore(db) + snapshotStore := store.NewSnapshotStore(db) + cloneJobStore := store.NewCloneJobStore(db) + + cloneSvc := core.NewCloneService(repoStore, cloneJobStore, dataDir) + snapshotSvc := core.NewSnapshotService(snapshotStore, repoStore, dataDir) + syncSvc := core.NewSyncService(repoStore, pendingStore, blockedStore, cloneSvc, snapshotSvc, dataDir) + + return &testEnv{ + db: db, + repoStore: repoStore, + pendingStore: pendingStore, + blockedStore: blockedStore, + snapshotStore: snapshotStore, + cloneJobStore: cloneJobStore, + syncSvc: syncSvc, + snapshotSvc: snapshotSvc, + dataDir: dataDir, + } +} + +func (e *testEnv) createRepo(t *testing.T, name, repoType string) int64 { + t.Helper() + id, err := e.repoStore.CreateRepo(context.Background(), &store.Repo{ + Name: name, Type: repoType, SourceURL: "https://example.com", SyncMode: "manual", + }) + if err != nil { + t.Fatalf("createRepo %q: %v", name, err) + } + return id +} + +func (e *testEnv) seedPending(t *testing.T, repoID int64, names ...string) []store.PendingPackage { + t.Helper() + pkgs := make([]store.PendingPackage, len(names)) + for i, name := range names { + pkgs[i] = store.PendingPackage{ + RepoID: repoID, Name: name, Version: "1.0", Arch: "amd64", + Location: "Packages/" + name + ".rpm", Checksum: "abc", ChecksumType: "sha256", Size: 100, + } + } + if err := e.pendingStore.UpsertPending(context.Background(), pkgs); err != nil { + t.Fatalf("seedPending: %v", err) + } + listed, _ := e.pendingStore.ListPending(context.Background(), repoID) + return listed +} + +// --- ListPending --- + +func TestSyncListPending(t *testing.T) { + env := newTestEnv(t) + repoID := env.createRepo(t, "rpm-repo", "rpm") + env.seedPending(t, repoID, "curl", "wget") + + pkgs, err := env.syncSvc.ListPending(context.Background(), repoID) + if err != nil { + t.Fatalf("ListPending: %v", err) + } + if len(pkgs) != 2 { + t.Errorf("expected 2 pending, got %d", len(pkgs)) + } +} + +func TestSyncListPending_repoNotFound(t *testing.T) { + env := newTestEnv(t) + _, err := env.syncSvc.ListPending(context.Background(), 9999) + if err == nil { + t.Error("expected error for unknown repo") + } +} + +// --- RejectPending --- + +func TestRejectPending(t *testing.T) { + env := newTestEnv(t) + repoID := env.createRepo(t, "rpm-repo", "rpm") + listed := env.seedPending(t, repoID, "curl", "wget", "vim") + + ids := []int64{listed[0].ID, listed[1].ID} + if err := env.syncSvc.RejectPending(context.Background(), repoID, ids); err != nil { + t.Fatalf("RejectPending: %v", err) + } + + remaining, _ := env.pendingStore.ListPending(context.Background(), repoID) + if len(remaining) != 1 { + t.Fatalf("expected 1 remaining, got %d", len(remaining)) + } + if remaining[0].Name != "vim" { + t.Errorf("expected vim remaining, got %q", remaining[0].Name) + } +} + +// --- BlockPackages --- + +func TestBlockPackages_removesFromPendingAndAddsToBlocked(t *testing.T) { + env := newTestEnv(t) + repoID := env.createRepo(t, "rpm-repo", "rpm") + listed := env.seedPending(t, repoID, "curl", "wget", "vim") + + // Block curl and wget (by pending ID). + ids := []int64{listed[0].ID, listed[1].ID} + if err := env.syncSvc.BlockPackages(context.Background(), repoID, ids); err != nil { + t.Fatalf("BlockPackages: %v", err) + } + + // Pending should only have vim left. + pending, _ := env.pendingStore.ListPending(context.Background(), repoID) + if len(pending) != 1 || pending[0].Name != "vim" { + t.Errorf("pending after block: expected [vim], got %v", pending) + } + + // Blocked should have curl and wget. + blocked, _ := env.blockedStore.ListBlocked(context.Background(), repoID) + if len(blocked) != 2 { + t.Fatalf("blocked: expected 2, got %d", len(blocked)) + } + names := map[string]bool{blocked[0].Name: true, blocked[1].Name: true} + if !names["curl"] || !names["wget"] { + t.Errorf("blocked names: got %v, want curl and wget", names) + } +} + +// --- UnblockPackages --- + +func TestUnblockPackages(t *testing.T) { + env := newTestEnv(t) + repoID := env.createRepo(t, "rpm-repo", "rpm") + listed := env.seedPending(t, repoID, "curl", "wget") + + env.syncSvc.BlockPackages(context.Background(), repoID, []int64{listed[0].ID, listed[1].ID}) + + blocked, _ := env.blockedStore.ListBlocked(context.Background(), repoID) + if len(blocked) != 2 { + t.Fatalf("setup: expected 2 blocked, got %d", len(blocked)) + } + + if err := env.syncSvc.UnblockPackages(context.Background(), repoID, []int64{blocked[0].ID}); err != nil { + t.Fatalf("UnblockPackages: %v", err) + } + + remaining, _ := env.blockedStore.ListBlocked(context.Background(), repoID) + if len(remaining) != 1 { + t.Fatalf("expected 1 blocked remaining, got %d", len(remaining)) + } +} + +// --- ApprovePending --- + +func TestApprovePending_downloadsAndRemovesFromPending(t *testing.T) { + // Serve a fake package file. + const fileContent = "fake-rpm-content" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(fileContent)) + })) + defer srv.Close() + + env := newTestEnv(t) + ctx := context.Background() + + // Repo with source URL pointing to our test server. + repoID, _ := env.repoStore.CreateRepo(ctx, &store.Repo{ + Name: "rpm-repo", Type: "rpm", SourceURL: srv.URL, SyncMode: "manual", + }) + + // Create the local repo dir so DownloadAndVerify can write. + localDir := filepath.Join(env.dataDir, "repos", "1", "rpm") + os.MkdirAll(localDir, 0o755) + + // Seed one pending package. Checksum left empty so verify is skipped. + pkg := store.PendingPackage{ + RepoID: repoID, Name: "curl", Version: "7.88", Arch: "amd64", + Location: "Packages/curl.rpm", Checksum: "", ChecksumType: "sha256", Size: int64(len(fileContent)), + } + env.pendingStore.UpsertPending(ctx, []store.PendingPackage{pkg}) + listed, _ := env.pendingStore.ListPending(ctx, repoID) + + if err := env.syncSvc.ApprovePending(ctx, repoID, []int64{listed[0].ID}); err != nil { + t.Fatalf("ApprovePending: %v", err) + } + + // Package must be removed from pending. + remaining, _ := env.pendingStore.ListPending(ctx, repoID) + if len(remaining) != 0 { + t.Errorf("expected empty pending after approve, got %d", len(remaining)) + } + + // File must exist on disk. + dest := filepath.Join(localDir, "Packages", "curl.rpm") + if _, err := os.Stat(dest); err != nil { + t.Errorf("expected file on disk at %s: %v", dest, err) + } +} + +func TestApprovePending_continuesOnDownloadError(t *testing.T) { + // Server returns 404 for everything. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer srv.Close() + + env := newTestEnv(t) + ctx := context.Background() + + repoID, _ := env.repoStore.CreateRepo(ctx, &store.Repo{ + Name: "rpm-repo", Type: "rpm", SourceURL: srv.URL, SyncMode: "manual", + }) + + env.pendingStore.UpsertPending(ctx, []store.PendingPackage{ + {RepoID: repoID, Name: "curl", Version: "1.0", Arch: "amd64", Location: "pkg/curl.rpm", ChecksumType: "sha256"}, + {RepoID: repoID, Name: "wget", Version: "1.0", Arch: "amd64", Location: "pkg/wget.rpm", ChecksumType: "sha256"}, + }) + listed, _ := env.pendingStore.ListPending(ctx, repoID) + ids := []int64{listed[0].ID, listed[1].ID} + + // Should return errors but not panic. + err := env.syncSvc.ApprovePending(ctx, repoID, ids) + if err == nil { + t.Error("expected errors for 404 downloads, got nil") + } + + // Nothing approved — pending should remain. + remaining, _ := env.pendingStore.ListPending(ctx, repoID) + if len(remaining) != 2 { + t.Errorf("expected 2 still pending, got %d", len(remaining)) + } +} diff --git a/internal/store/blocked_test.go b/internal/store/blocked_test.go new file mode 100644 index 0000000..31b1bca --- /dev/null +++ b/internal/store/blocked_test.go @@ -0,0 +1,131 @@ +package store_test + +import ( + "context" + "testing" + + "github.com/syonad/clonepack/internal/store" +) + +func newBlockedStore(t *testing.T) (*store.SQLiteBlockedPackageStore, int64) { + t.Helper() + db := newTestDB(t) + repoID := insertTestRepo(t, db) + return store.NewBlockedPackageStore(db), repoID +} + +func TestBlockAndListPackages(t *testing.T) { + s, repoID := newBlockedStore(t) + ctx := context.Background() + + pkgs := []store.BlockedPackage{ + {Name: "curl", Location: "Packages/curl-7.88.rpm"}, + {Name: "wget", Location: "Packages/wget-1.21.rpm"}, + } + if err := s.BlockPackages(ctx, repoID, pkgs); err != nil { + t.Fatalf("BlockPackages: %v", err) + } + + listed, err := s.ListBlocked(ctx, repoID) + if err != nil { + t.Fatalf("ListBlocked: %v", err) + } + if len(listed) != 2 { + t.Fatalf("expected 2 blocked, got %d", len(listed)) + } + if listed[0].Name != "curl" { + t.Errorf("expected curl first, got %q", listed[0].Name) + } + if listed[0].RepoID != repoID { + t.Errorf("RepoID: got %d, want %d", listed[0].RepoID, repoID) + } +} + +func TestBlockPackages_idempotent(t *testing.T) { + s, repoID := newBlockedStore(t) + ctx := context.Background() + + pkg := []store.BlockedPackage{{Name: "curl", Location: "Packages/curl.rpm"}} + s.BlockPackages(ctx, repoID, pkg) + // INSERT OR IGNORE — should not duplicate. + s.BlockPackages(ctx, repoID, pkg) + + listed, _ := s.ListBlocked(ctx, repoID) + if len(listed) != 1 { + t.Errorf("expected 1 after double block, got %d", len(listed)) + } +} + +func TestUnblockPackages(t *testing.T) { + s, repoID := newBlockedStore(t) + ctx := context.Background() + + s.BlockPackages(ctx, repoID, []store.BlockedPackage{ + {Name: "curl", Location: "Packages/curl.rpm"}, + {Name: "wget", Location: "Packages/wget.rpm"}, + }) + + listed, _ := s.ListBlocked(ctx, repoID) + if len(listed) != 2 { + t.Fatalf("setup: expected 2, got %d", len(listed)) + } + + // Unblock only the first. + if err := s.UnblockPackages(ctx, repoID, []int64{listed[0].ID}); err != nil { + t.Fatalf("UnblockPackages: %v", err) + } + + remaining, _ := s.ListBlocked(ctx, repoID) + if len(remaining) != 1 { + t.Fatalf("expected 1 remaining, got %d", len(remaining)) + } + if remaining[0].Name != "wget" { + t.Errorf("expected wget remaining, got %q", remaining[0].Name) + } +} + +func TestUnblockPackages_empty(t *testing.T) { + s, repoID := newBlockedStore(t) + ctx := context.Background() + + if err := s.UnblockPackages(ctx, repoID, nil); err != nil { + t.Errorf("UnblockPackages(nil): %v", err) + } + if err := s.UnblockPackages(ctx, repoID, []int64{}); err != nil { + t.Errorf("UnblockPackages([]): %v", err) + } +} + +func TestListBlocked_empty(t *testing.T) { + s, repoID := newBlockedStore(t) + listed, err := s.ListBlocked(context.Background(), repoID) + if err != nil { + t.Fatalf("ListBlocked: %v", err) + } + if len(listed) != 0 { + t.Errorf("expected 0, got %d", len(listed)) + } +} + +func TestBlockPackages_isolation(t *testing.T) { + db := newTestDB(t) + repoStore := store.NewRepoStore(db) + ctx := context.Background() + + id1, _ := repoStore.CreateRepo(ctx, &store.Repo{Name: "repo-1", Type: "rpm", SourceURL: "https://a.com", SyncMode: "auto"}) + id2, _ := repoStore.CreateRepo(ctx, &store.Repo{Name: "repo-2", Type: "rpm", SourceURL: "https://b.com", SyncMode: "auto"}) + + bs := store.NewBlockedPackageStore(db) + bs.BlockPackages(ctx, id1, []store.BlockedPackage{{Name: "curl", Location: "pkg/curl.rpm"}}) + bs.BlockPackages(ctx, id2, []store.BlockedPackage{{Name: "vim", Location: "pkg/vim.rpm"}}) + + list1, _ := bs.ListBlocked(ctx, id1) + list2, _ := bs.ListBlocked(ctx, id2) + + if len(list1) != 1 || list1[0].Name != "curl" { + t.Errorf("repo-1: expected [curl], got %v", list1) + } + if len(list2) != 1 || list2[0].Name != "vim" { + t.Errorf("repo-2: expected [vim], got %v", list2) + } +} diff --git a/internal/store/clone_job.go b/internal/store/clone_job.go index 60f755e..629961b 100644 --- a/internal/store/clone_job.go +++ b/internal/store/clone_job.go @@ -59,7 +59,7 @@ func (s *SQLiteCloneJobStore) GetCloneJob(ctx context.Context, id int64) (*Clone func (s *SQLiteCloneJobStore) GetLatestCloneJob(ctx context.Context, repoID int64) (*CloneJob, error) { row := s.db.QueryRowContext(ctx, - `SELECT id, repo_id, status, started_at, finished_at, error, created_at FROM clone_jobs WHERE repo_id = ? ORDER BY created_at DESC LIMIT 1`, repoID) + `SELECT id, repo_id, status, started_at, finished_at, error, created_at FROM clone_jobs WHERE repo_id = ? ORDER BY created_at DESC, id DESC LIMIT 1`, repoID) return scanCloneJob(row) } diff --git a/internal/store/clone_job_test.go b/internal/store/clone_job_test.go new file mode 100644 index 0000000..2c406e0 --- /dev/null +++ b/internal/store/clone_job_test.go @@ -0,0 +1,165 @@ +package store_test + +import ( + "context" + "testing" + + "github.com/syonad/clonepack/internal/store" +) + +func newCloneJobStore(t *testing.T) (*store.SQLiteCloneJobStore, int64) { + t.Helper() + db := newTestDB(t) + repoID := insertTestRepo(t, db) + return store.NewCloneJobStore(db), repoID +} + +func TestCreateAndGetCloneJob(t *testing.T) { + s, repoID := newCloneJobStore(t) + ctx := context.Background() + + jobID, err := s.CreateCloneJob(ctx, repoID) + if err != nil { + t.Fatalf("CreateCloneJob: %v", err) + } + + job, err := s.GetCloneJob(ctx, jobID) + if err != nil { + t.Fatalf("GetCloneJob: %v", err) + } + if job.RepoID != repoID { + t.Errorf("RepoID: got %d, want %d", job.RepoID, repoID) + } + if job.Status != store.CloneJobPending { + t.Errorf("Status: got %q, want pending", job.Status) + } + if job.StartedAt != nil { + t.Errorf("StartedAt should be nil initially") + } + if job.FinishedAt != nil { + t.Errorf("FinishedAt should be nil initially") + } +} + +func TestGetCloneJob_notFound(t *testing.T) { + s, _ := newCloneJobStore(t) + _, err := s.GetCloneJob(context.Background(), 9999) + if err != store.ErrNotFound { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestGetLatestCloneJob(t *testing.T) { + s, repoID := newCloneJobStore(t) + ctx := context.Background() + + s.CreateCloneJob(ctx, repoID) + id2, _ := s.CreateCloneJob(ctx, repoID) + + job, err := s.GetLatestCloneJob(ctx, repoID) + if err != nil { + t.Fatalf("GetLatestCloneJob: %v", err) + } + if job.ID != id2 { + t.Errorf("expected latest job ID %d, got %d", id2, job.ID) + } +} + +func TestGetLatestCloneJob_notFound(t *testing.T) { + s, repoID := newCloneJobStore(t) + _, err := s.GetLatestCloneJob(context.Background(), repoID) + if err != store.ErrNotFound { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestMarkCloneJobStarted(t *testing.T) { + s, repoID := newCloneJobStore(t) + ctx := context.Background() + + jobID, _ := s.CreateCloneJob(ctx, repoID) + if err := s.MarkCloneJobStarted(ctx, jobID); err != nil { + t.Fatalf("MarkCloneJobStarted: %v", err) + } + + job, _ := s.GetCloneJob(ctx, jobID) + if job.Status != store.CloneJobRunning { + t.Errorf("Status: got %q, want running", job.Status) + } + if job.StartedAt == nil { + t.Error("StartedAt should be set") + } +} + +func TestMarkCloneJobFinished_completed(t *testing.T) { + s, repoID := newCloneJobStore(t) + ctx := context.Background() + + jobID, _ := s.CreateCloneJob(ctx, repoID) + s.MarkCloneJobStarted(ctx, jobID) + if err := s.MarkCloneJobFinished(ctx, jobID, store.CloneJobCompleted, nil); err != nil { + t.Fatalf("MarkCloneJobFinished: %v", err) + } + + job, _ := s.GetCloneJob(ctx, jobID) + if job.Status != store.CloneJobCompleted { + t.Errorf("Status: got %q, want completed", job.Status) + } + if job.FinishedAt == nil { + t.Error("FinishedAt should be set") + } + if job.Error != nil { + t.Errorf("Error should be nil, got %q", *job.Error) + } +} + +func TestMarkCloneJobFinished_failed(t *testing.T) { + s, repoID := newCloneJobStore(t) + ctx := context.Background() + + jobID, _ := s.CreateCloneJob(ctx, repoID) + s.MarkCloneJobStarted(ctx, jobID) + errMsg := "connection refused" + if err := s.MarkCloneJobFinished(ctx, jobID, store.CloneJobFailed, &errMsg); err != nil { + t.Fatalf("MarkCloneJobFinished: %v", err) + } + + job, _ := s.GetCloneJob(ctx, jobID) + if job.Status != store.CloneJobFailed { + t.Errorf("Status: got %q, want failed", job.Status) + } + if job.Error == nil || *job.Error != errMsg { + t.Errorf("Error: got %v, want %q", job.Error, errMsg) + } +} + +func TestHasRunningCloneJob(t *testing.T) { + s, repoID := newCloneJobStore(t) + ctx := context.Background() + + running, err := s.HasRunningCloneJob(ctx, repoID) + if err != nil { + t.Fatalf("HasRunningCloneJob: %v", err) + } + if running { + t.Error("expected no running job initially") + } + + jobID, _ := s.CreateCloneJob(ctx, repoID) + running, _ = s.HasRunningCloneJob(ctx, repoID) + if !running { + t.Error("expected running job after create (status=pending)") + } + + s.MarkCloneJobStarted(ctx, jobID) + running, _ = s.HasRunningCloneJob(ctx, repoID) + if !running { + t.Error("expected running job after start") + } + + s.MarkCloneJobFinished(ctx, jobID, store.CloneJobCompleted, nil) + running, _ = s.HasRunningCloneJob(ctx, repoID) + if running { + t.Error("expected no running job after completion") + } +} diff --git a/internal/store/pending_test.go b/internal/store/pending_test.go new file mode 100644 index 0000000..0acde25 --- /dev/null +++ b/internal/store/pending_test.go @@ -0,0 +1,139 @@ +package store_test + +import ( + "context" + "testing" + + "github.com/syonad/clonepack/internal/store" +) + +func newPendingStore(t *testing.T) (*store.SQLitePendingPackageStore, int64) { + t.Helper() + db := newTestDB(t) + repoID := insertTestRepo(t, db) + return store.NewPendingPackageStore(db), repoID +} + +func makePending(repoID int64, name, version, location string) store.PendingPackage { + return store.PendingPackage{ + RepoID: repoID, + Name: name, + Version: version, + Arch: "amd64", + Location: location, + Checksum: "abc123", + ChecksumType: "sha256", + Size: 1024, + } +} + +func TestUpsertAndListPending(t *testing.T) { + s, repoID := newPendingStore(t) + ctx := context.Background() + + pkgs := []store.PendingPackage{ + makePending(repoID, "curl", "7.88", "Packages/curl.rpm"), + makePending(repoID, "wget", "1.21", "Packages/wget.rpm"), + } + if err := s.UpsertPending(ctx, pkgs); err != nil { + t.Fatalf("UpsertPending: %v", err) + } + + listed, err := s.ListPending(ctx, repoID) + if err != nil { + t.Fatalf("ListPending: %v", err) + } + if len(listed) != 2 { + t.Fatalf("expected 2 pending, got %d", len(listed)) + } + if listed[0].Name != "curl" { + t.Errorf("expected curl first, got %q", listed[0].Name) + } +} + +func TestUpsertPending_idempotent(t *testing.T) { + s, repoID := newPendingStore(t) + ctx := context.Background() + + pkg := makePending(repoID, "curl", "7.88", "Packages/curl.rpm") + s.UpsertPending(ctx, []store.PendingPackage{pkg}) + // Insert same package again — INSERT OR IGNORE, should not duplicate. + s.UpsertPending(ctx, []store.PendingPackage{pkg}) + + listed, _ := s.ListPending(ctx, repoID) + if len(listed) != 1 { + t.Errorf("expected 1 after double upsert, got %d", len(listed)) + } +} + +func TestDeletePending_byIDs(t *testing.T) { + s, repoID := newPendingStore(t) + ctx := context.Background() + + pkgs := []store.PendingPackage{ + makePending(repoID, "curl", "7.88", "Packages/curl.rpm"), + makePending(repoID, "wget", "1.21", "Packages/wget.rpm"), + makePending(repoID, "vim", "9.0", "Packages/vim.rpm"), + } + s.UpsertPending(ctx, pkgs) + + listed, _ := s.ListPending(ctx, repoID) + if len(listed) != 3 { + t.Fatalf("setup: expected 3, got %d", len(listed)) + } + + // Delete first two. + ids := []int64{listed[0].ID, listed[1].ID} + if err := s.DeletePending(ctx, ids); err != nil { + t.Fatalf("DeletePending: %v", err) + } + + remaining, _ := s.ListPending(ctx, repoID) + if len(remaining) != 1 { + t.Fatalf("expected 1 remaining, got %d", len(remaining)) + } + if remaining[0].Name != "vim" { + t.Errorf("expected vim remaining, got %q", remaining[0].Name) + } +} + +func TestDeletePending_empty(t *testing.T) { + s, _ := newPendingStore(t) + // Should not error on empty slice. + if err := s.DeletePending(context.Background(), nil); err != nil { + t.Errorf("DeletePending(nil): %v", err) + } + if err := s.DeletePending(context.Background(), []int64{}); err != nil { + t.Errorf("DeletePending([]): %v", err) + } +} + +func TestDeleteAllPending(t *testing.T) { + s, repoID := newPendingStore(t) + ctx := context.Background() + + s.UpsertPending(ctx, []store.PendingPackage{ + makePending(repoID, "curl", "7.88", "Packages/curl.rpm"), + makePending(repoID, "wget", "1.21", "Packages/wget.rpm"), + }) + + if err := s.DeleteAllPending(ctx, repoID); err != nil { + t.Fatalf("DeleteAllPending: %v", err) + } + + remaining, _ := s.ListPending(ctx, repoID) + if len(remaining) != 0 { + t.Errorf("expected 0 after DeleteAll, got %d", len(remaining)) + } +} + +func TestListPending_empty(t *testing.T) { + s, repoID := newPendingStore(t) + listed, err := s.ListPending(context.Background(), repoID) + if err != nil { + t.Fatalf("ListPending: %v", err) + } + if len(listed) != 0 { + t.Errorf("expected 0, got %d", len(listed)) + } +} diff --git a/internal/store/repo_test.go b/internal/store/repo_test.go new file mode 100644 index 0000000..9415ba7 --- /dev/null +++ b/internal/store/repo_test.go @@ -0,0 +1,188 @@ +package store_test + +import ( + "context" + "testing" + + "github.com/syonad/clonepack/internal/store" +) + +func newTestStore(t *testing.T) *store.SQLiteRepoStore { + t.Helper() + return store.NewRepoStore(newTestDB(t)) +} + +func TestCreateAndGetRepo(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + r := &store.Repo{Name: "test-repo", Type: "rpm", SourceURL: "https://example.com", SyncMode: "auto"} + id, err := s.CreateRepo(ctx, r) + if err != nil { + t.Fatalf("CreateRepo: %v", err) + } + if id <= 0 { + t.Errorf("expected positive id, got %d", id) + } + + got, err := s.GetRepo(ctx, id) + if err != nil { + t.Fatalf("GetRepo: %v", err) + } + if got.Name != "test-repo" { + t.Errorf("Name: got %q, want %q", got.Name, "test-repo") + } + if got.Type != "rpm" { + t.Errorf("Type: got %q, want %q", got.Type, "rpm") + } + if got.SourceURL != "https://example.com" { + t.Errorf("SourceURL: got %q", got.SourceURL) + } + if got.SyncMode != "auto" { + t.Errorf("SyncMode: got %q, want auto", got.SyncMode) + } +} + +func TestGetRepo_notFound(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + _, err := s.GetRepo(ctx, 9999) + if err != store.ErrNotFound { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestGetRepoByName(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + r := &store.Repo{Name: "debian-bookworm", Type: "apt", SourceURL: "https://deb.debian.org/debian", SyncMode: "manual", Config: `{"suite":"bookworm"}`} + id, err := s.CreateRepo(ctx, r) + if err != nil { + t.Fatalf("CreateRepo: %v", err) + } + + got, err := s.GetRepoByName(ctx, "debian-bookworm") + if err != nil { + t.Fatalf("GetRepoByName: %v", err) + } + if got.ID != id { + t.Errorf("ID: got %d, want %d", got.ID, id) + } + if got.Config != `{"suite":"bookworm"}` { + t.Errorf("Config: got %q", got.Config) + } +} + +func TestGetRepoByName_notFound(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + _, err := s.GetRepoByName(ctx, "does-not-exist") + if err != store.ErrNotFound { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestListRepos(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + repos, err := s.ListRepos(ctx) + if err != nil { + t.Fatalf("ListRepos (empty): %v", err) + } + if len(repos) != 0 { + t.Errorf("expected empty list, got %d repos", len(repos)) + } + + for _, name := range []string{"repo-a", "repo-b", "repo-c"} { + _, err := s.CreateRepo(ctx, &store.Repo{Name: name, Type: "rpm", SourceURL: "https://x.com", SyncMode: "auto"}) + if err != nil { + t.Fatalf("CreateRepo %q: %v", name, err) + } + } + + repos, err = s.ListRepos(ctx) + if err != nil { + t.Fatalf("ListRepos: %v", err) + } + if len(repos) != 3 { + t.Errorf("expected 3 repos, got %d", len(repos)) + } +} + +func TestDeleteRepo(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + id, _ := s.CreateRepo(ctx, &store.Repo{Name: "to-delete", Type: "rpm", SourceURL: "https://x.com", SyncMode: "auto"}) + + if err := s.DeleteRepo(ctx, id); err != nil { + t.Fatalf("DeleteRepo: %v", err) + } + _, err := s.GetRepo(ctx, id) + if err != store.ErrNotFound { + t.Errorf("expected ErrNotFound after delete, got %v", err) + } +} + +func TestDeleteRepo_notFound(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + err := s.DeleteRepo(ctx, 9999) + if err != store.ErrNotFound { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestUpdateRepo(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + id, _ := s.CreateRepo(ctx, &store.Repo{Name: "updatable", Type: "apt", SourceURL: "https://old.example.com", SyncMode: "auto", Config: ""}) + + newConfig := `{"suite":"bookworm","components":["main"],"architectures":["amd64"]}` + if err := s.UpdateRepo(ctx, id, "https://new.example.com", "manual", newConfig); err != nil { + t.Fatalf("UpdateRepo: %v", err) + } + + got, err := s.GetRepo(ctx, id) + if err != nil { + t.Fatalf("GetRepo after update: %v", err) + } + if got.SourceURL != "https://new.example.com" { + t.Errorf("SourceURL: got %q, want https://new.example.com", got.SourceURL) + } + if got.SyncMode != "manual" { + t.Errorf("SyncMode: got %q, want manual", got.SyncMode) + } + if got.Config != newConfig { + t.Errorf("Config: got %q", got.Config) + } +} + +func TestUpdateRepo_notFound(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + err := s.UpdateRepo(ctx, 9999, "https://x.com", "auto", "") + if err != store.ErrNotFound { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestCreateRepo_uniqueName(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + r := &store.Repo{Name: "unique", Type: "rpm", SourceURL: "https://x.com", SyncMode: "auto"} + if _, err := s.CreateRepo(ctx, r); err != nil { + t.Fatalf("first create: %v", err) + } + if _, err := s.CreateRepo(ctx, r); err == nil { + t.Error("expected error for duplicate name, got nil") + } +} diff --git a/internal/store/snapshot_test.go b/internal/store/snapshot_test.go new file mode 100644 index 0000000..c5d77f0 --- /dev/null +++ b/internal/store/snapshot_test.go @@ -0,0 +1,142 @@ +package store_test + +import ( + "context" + "testing" + + "github.com/syonad/clonepack/internal/store" +) + +func newSnapshotStore(t *testing.T) (*store.SQLiteSnapshotStore, int64) { + t.Helper() + db := newTestDB(t) + repoID := insertTestRepo(t, db) + return store.NewSnapshotStore(db), repoID +} + +func TestCreateAndGetSnapshot(t *testing.T) { + s, repoID := newSnapshotStore(t) + ctx := context.Background() + + id, err := s.CreateSnapshot(ctx, repoID, "v1.0") + if err != nil { + t.Fatalf("CreateSnapshot: %v", err) + } + + snap, err := s.GetSnapshot(ctx, id) + if err != nil { + t.Fatalf("GetSnapshot: %v", err) + } + if snap.RepoID != repoID { + t.Errorf("RepoID: got %d, want %d", snap.RepoID, repoID) + } + if snap.Label != "v1.0" { + t.Errorf("Label: got %q, want v1.0", snap.Label) + } +} + +func TestGetSnapshot_notFound(t *testing.T) { + s, _ := newSnapshotStore(t) + _, err := s.GetSnapshot(context.Background(), 9999) + if err != store.ErrNotFound { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestListSnapshots(t *testing.T) { + s, repoID := newSnapshotStore(t) + ctx := context.Background() + + snaps, err := s.ListSnapshots(ctx, repoID) + if err != nil { + t.Fatalf("ListSnapshots (empty): %v", err) + } + if len(snaps) != 0 { + t.Errorf("expected 0 snapshots, got %d", len(snaps)) + } + + s.CreateSnapshot(ctx, repoID, "snap-1") + s.CreateSnapshot(ctx, repoID, "snap-2") + + snaps, err = s.ListSnapshots(ctx, repoID) + if err != nil { + t.Fatalf("ListSnapshots: %v", err) + } + if len(snaps) != 2 { + t.Fatalf("expected 2 snapshots, got %d", len(snaps)) + } +} + +func TestAddAndGetSnapshotPackages(t *testing.T) { + s, repoID := newSnapshotStore(t) + ctx := context.Background() + + snapID, _ := s.CreateSnapshot(ctx, repoID, "with-pkgs") + + pkgs := []store.SnapshotPackage{ + {Name: "curl", Version: "7.88", Arch: "amd64", Location: "Packages/curl.rpm", Checksum: "abc", ChecksumType: "sha256", Size: 1000}, + {Name: "wget", Version: "1.21", Arch: "amd64", Location: "Packages/wget.rpm", Checksum: "def", ChecksumType: "sha256", Size: 2000}, + } + if err := s.AddSnapshotPackages(ctx, snapID, pkgs); err != nil { + t.Fatalf("AddSnapshotPackages: %v", err) + } + + got, err := s.GetSnapshotPackages(ctx, snapID) + if err != nil { + t.Fatalf("GetSnapshotPackages: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 packages, got %d", len(got)) + } + if got[0].Name != "curl" { + t.Errorf("pkg[0].Name: got %q, want curl", got[0].Name) + } + if got[1].Size != 2000 { + t.Errorf("pkg[1].Size: got %d, want 2000", got[1].Size) + } +} + +func TestAddSnapshotPackages_empty(t *testing.T) { + s, repoID := newSnapshotStore(t) + ctx := context.Background() + + snapID, _ := s.CreateSnapshot(ctx, repoID, "empty") + if err := s.AddSnapshotPackages(ctx, snapID, nil); err != nil { + t.Fatalf("AddSnapshotPackages with nil: %v", err) + } + pkgs, _ := s.GetSnapshotPackages(ctx, snapID) + if len(pkgs) != 0 { + t.Errorf("expected 0 packages, got %d", len(pkgs)) + } +} + +func TestDeleteSnapshot(t *testing.T) { + s, repoID := newSnapshotStore(t) + ctx := context.Background() + + snapID, _ := s.CreateSnapshot(ctx, repoID, "to-delete") + pkgs := []store.SnapshotPackage{ + {Name: "curl", Version: "7.88", Arch: "amd64", Location: "loc", Checksum: "abc", ChecksumType: "sha256", Size: 100}, + } + s.AddSnapshotPackages(ctx, snapID, pkgs) + + if err := s.DeleteSnapshot(ctx, snapID); err != nil { + t.Fatalf("DeleteSnapshot: %v", err) + } + if _, err := s.GetSnapshot(ctx, snapID); err != store.ErrNotFound { + t.Errorf("expected ErrNotFound after delete, got %v", err) + } + // Cascade: packages should also be gone. + remaining, _ := s.GetSnapshotPackages(ctx, snapID) + if len(remaining) != 0 { + t.Errorf("expected cascade delete of packages, got %d", len(remaining)) + } +} + +func TestDeleteSnapshot_notFound(t *testing.T) { + s, _ := newSnapshotStore(t) + err := s.DeleteSnapshot(context.Background(), 9999) + if err != store.ErrNotFound { + t.Errorf("expected ErrNotFound, got %v", err) + } +} diff --git a/internal/store/testhelper_test.go b/internal/store/testhelper_test.go new file mode 100644 index 0000000..f1b783c --- /dev/null +++ b/internal/store/testhelper_test.go @@ -0,0 +1,36 @@ +package store_test + +import ( + "context" + "database/sql" + "testing" + + "github.com/syonad/clonepack/config" + "github.com/syonad/clonepack/internal/store" +) + +func newTestDB(t *testing.T) *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() }) + return db +} + +// insertTestRepo crée un repo minimal et retourne son ID. +func insertTestRepo(t *testing.T, db *sql.DB) int64 { + t.Helper() + s := store.NewRepoStore(db) + id, err := s.CreateRepo(context.Background(), &store.Repo{ + Name: "test-repo", + Type: "rpm", + SourceURL: "https://example.com", + SyncMode: "auto", + }) + if err != nil { + t.Fatalf("insertTestRepo: %v", err) + } + return id +}