From b12f956e45c7be51e53f05e0396d8ec5c2aceef5 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Thu, 26 Feb 2026 02:50:48 +0000 Subject: [PATCH] test: exhaustive integration tests with codecov - Root package unit tests: Targets, TargetNames, ApplyOptions, error sentinels - Every provider integration test now covers: - All Manager interface methods (positive + negative cases) - GetCapabilities verification (assert expected interfaces) - VersionQuerier: LatestVersion, ListUpgrades, UpgradeAvailable, VersionCmp - Holder: Hold, ListHeld, Unhold (apt, dnf) - Cleaner: Autoremove, Clean - FileOwner: FileList, Owner (+ not-found cases) - RepoManager: ListRepos (apt, dnf, flatpak) - KeyManager: ListKeys (apt, dnf) - Grouper: GroupList, GroupInfo (pacman, dnf) - NameNormalizer: NormalizeName, ParseArch table tests (apt, dpkg, dnf, rpm) - Containertest matrix: 5 distros (debian, alpine, arch, fedora39, fedora-latest) - CI: coverage profiles uploaded per-job, merged in codecov job - Added .gitignore for coverage files --- .github/workflows/integration.yml | 77 ++++++- .gitignore | 2 + apk/apk_integration_test.go | 214 ++++++++++++++--- apt/apt_integration_test.go | 323 ++++++++++++++++++++++++-- detect/detect_integration_test.go | 59 ++++- dnf/dnf_integration_test.go | 346 +++++++++++++++++++++++++--- dpkg/dpkg_integration_test.go | 125 +++++++++- flatpak/flatpak_integration_test.go | 102 ++++++-- integration_test.go | 103 ++++++--- pacman/pacman_integration_test.go | 246 +++++++++++++++++--- rpm/rpm_integration_test.go | 125 +++++++++- snack_test.go | 117 ++++++++++ snap/snap_integration_test.go | 115 ++++++++- 13 files changed, 1739 insertions(+), 215 deletions(-) create mode 100644 .gitignore create mode 100644 snack_test.go diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 59849fe..fbbb3ca 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -16,7 +16,11 @@ jobs: go-version-file: go.mod - run: go build ./... - run: go vet ./... - - run: go test -race ./... + - run: go test -race -coverprofile=coverage-unit.out -coverpkg=./... ./... + - uses: actions/upload-artifact@v4 + with: + name: coverage-unit + path: coverage-unit.out debian: name: Debian (apt) @@ -32,7 +36,12 @@ jobs: apt-get update apt-get install -y sudo tree curl - name: Integration tests - run: go test -v -tags integration -count=1 ./apt/ ./dpkg/ ./detect/ + run: go test -v -tags integration -count=1 -coverprofile=coverage-debian.out -coverpkg=./... ./apt/ ./dpkg/ ./detect/ + - uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-debian + path: coverage-debian.out ubuntu: name: Ubuntu (apt + snap) @@ -48,9 +57,14 @@ jobs: sudo apt-get install -y tree sudo snap install hello-world 2>/dev/null; sudo snap remove hello-world 2>/dev/null - name: Integration tests (apt) - run: sudo -E go test -v -tags integration -count=1 ./apt/ ./dpkg/ ./detect/ + run: sudo -E go test -v -tags integration -count=1 -coverprofile=coverage-ubuntu-apt.out -coverpkg=./... ./apt/ ./dpkg/ ./detect/ - name: Integration tests (snap) - run: sudo -E go test -v -tags integration -count=1 ./snap/ + run: sudo -E go test -v -tags integration -count=1 -coverprofile=coverage-ubuntu-snap.out -coverpkg=./... ./snap/ + - uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-ubuntu + path: coverage-ubuntu-*.out fedora-dnf4: name: Fedora 39 (dnf4) @@ -65,7 +79,12 @@ jobs: run: | dnf install -y tree sudo - name: Integration tests - run: go test -v -tags integration -count=1 ./dnf/ ./rpm/ ./detect/ + run: go test -v -tags integration -count=1 -coverprofile=coverage-fedora39.out -coverpkg=./... ./dnf/ ./rpm/ ./detect/ + - uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-fedora39 + path: coverage-fedora39.out fedora-dnf5: name: Fedora latest (dnf5) @@ -80,7 +99,12 @@ jobs: run: | dnf install -y tree sudo - name: Integration tests - run: go test -v -tags integration -count=1 ./dnf/ ./rpm/ ./detect/ + run: go test -v -tags integration -count=1 -coverprofile=coverage-fedora-latest.out -coverpkg=./... ./dnf/ ./rpm/ ./detect/ + - uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-fedora-latest + path: coverage-fedora-latest.out alpine: name: Alpine (apk) @@ -95,7 +119,12 @@ jobs: run: | apk add --no-cache sudo tree bash - name: Integration tests - run: go test -v -tags integration -count=1 ./apk/ ./detect/ + run: go test -v -tags integration -count=1 -coverprofile=coverage-alpine.out -coverpkg=./... ./apk/ ./detect/ + - uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-alpine + path: coverage-alpine.out arch: name: Arch Linux (pacman) @@ -111,7 +140,12 @@ jobs: pacman -Syu --noconfirm pacman -S --noconfirm sudo tree - name: Integration tests - run: go test -v -tags integration -count=1 ./pacman/ ./detect/ + run: go test -v -tags integration -count=1 -coverprofile=coverage-arch.out -coverpkg=./... ./pacman/ ./detect/ + - uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-arch + path: coverage-arch.out flatpak: name: Ubuntu + Flatpak @@ -130,4 +164,29 @@ jobs: sudo apt-get install -y flatpak sudo flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo - name: Integration tests - run: sudo -E go test -v -tags integration -count=1 ./flatpak/ + run: sudo -E go test -v -tags integration -count=1 -coverprofile=coverage-flatpak.out -coverpkg=./... ./flatpak/ + - uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-flatpak + path: coverage-flatpak.out + + codecov: + name: Upload Coverage + runs-on: ubuntu-latest + needs: [unit-tests, debian, ubuntu, fedora-dnf4, fedora-dnf5, alpine, arch, flatpak] + if: always() + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + pattern: coverage-* + merge-multiple: true + - name: List coverage files + run: ls -la coverage-*.out 2>/dev/null || echo "No coverage files found" + - uses: codecov/codecov-action@v5 + with: + files: coverage-unit.out,coverage-debian.out,coverage-ubuntu-apt.out,coverage-ubuntu-snap.out,coverage-fedora39.out,coverage-fedora-latest.out,coverage-alpine.out,coverage-arch.out,coverage-flatpak.out + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1745da5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +coverage.out +coverage.html diff --git a/apk/apk_integration_test.go b/apk/apk_integration_test.go index 048d0d5..6e08fb9 100644 --- a/apk/apk_integration_test.go +++ b/apk/apk_integration_test.go @@ -13,95 +13,249 @@ import ( ) func TestIntegration_Apk(t *testing.T) { - var mgr snack.Manager = apk.New() + mgr := apk.New() if !mgr.Available() { t.Skip("apk not available") } ctx := context.Background() + assert.Equal(t, "apk", mgr.Name()) + + caps := snack.GetCapabilities(mgr) + assert.True(t, caps.VersionQuery, "apk should support VersionQuery") + assert.True(t, caps.Clean, "apk should support Clean") + assert.True(t, caps.FileOwnership, "apk should support FileOwnership") + assert.False(t, caps.Hold, "apk should not support Hold") + assert.False(t, caps.RepoManagement, "apk should not support RepoManagement") + assert.False(t, caps.KeyManagement, "apk should not support KeyManagement") + assert.False(t, caps.Groups, "apk should not support Groups") + assert.False(t, caps.NameNormalize, "apk should not support NameNormalize") + t.Run("Update", func(t *testing.T) { - err := mgr.Update(ctx) - require.NoError(t, err) + require.NoError(t, mgr.Update(ctx)) }) t.Run("Search", func(t *testing.T) { pkgs, err := mgr.Search(ctx, "curl") require.NoError(t, err) - require.NotEmpty(t, pkgs) + require.NotEmpty(t, pkgs, "search for curl should return results") + + found := false + for _, p := range pkgs { + if p.Name == "curl" { + found = true + break + } + } + assert.True(t, found, "curl should appear in search results") }) - t.Run("Info", func(t *testing.T) { - // apk info only works on installed packages, use "tree" after install - // or test with a pre-installed package like "busybox" + t.Run("Search_NoResults", func(t *testing.T) { + pkgs, err := mgr.Search(ctx, "xyznonexistentpackage999") + require.NoError(t, err) + assert.Empty(t, pkgs) + }) + + t.Run("Info_PreInstalled", func(t *testing.T) { pkg, err := mgr.Info(ctx, "busybox") if err != nil { - t.Skip("busybox not installed, skipping Info test") + t.Skip("busybox not installed") } require.NotNil(t, pkg) + assert.Equal(t, "busybox", pkg.Name) + assert.NotEmpty(t, pkg.Version) }) - t.Run("Install", func(t *testing.T) { - err := mgr.Install(ctx, snack.Targets("tree"), snack.WithSudo(), snack.WithAssumeYes()) + t.Run("Info_NotFound", func(t *testing.T) { + _, err := mgr.Info(ctx, "xyznonexistentpackage999") + assert.Error(t, err) + }) + + t.Run("Install_Single", func(t *testing.T) { + _ = mgr.Remove(ctx, snack.Targets("tree")) + err := mgr.Install(ctx, snack.Targets("tree")) require.NoError(t, err) }) - t.Run("IsInstalled", func(t *testing.T) { + t.Run("IsInstalled_True", func(t *testing.T) { installed, err := mgr.IsInstalled(ctx, "tree") require.NoError(t, err) assert.True(t, installed) }) - t.Run("Version", func(t *testing.T) { + t.Run("IsInstalled_False", func(t *testing.T) { + installed, err := mgr.IsInstalled(ctx, "xyznonexistentpackage999") + require.NoError(t, err) + assert.False(t, installed) + }) + + t.Run("Version_Installed", func(t *testing.T) { ver, err := mgr.Version(ctx, "tree") require.NoError(t, err) assert.NotEmpty(t, ver) + t.Logf("tree version: %s", ver) }) - t.Run("List", func(t *testing.T) { + t.Run("Version_NotInstalled", func(t *testing.T) { + _, err := mgr.Version(ctx, "xyznonexistentpackage999") + assert.Error(t, err) + }) + + t.Run("List_ContainsInstalled", func(t *testing.T) { pkgs, err := mgr.List(ctx) require.NoError(t, err) + require.NotEmpty(t, pkgs) + found := false for _, p := range pkgs { if p.Name == "tree" { found = true + assert.NotEmpty(t, p.Version) break } } assert.True(t, found, "tree should be in installed list") }) - t.Run("Remove", func(t *testing.T) { - err := mgr.Remove(ctx, snack.Targets("tree"), snack.WithSudo(), snack.WithAssumeYes()) + t.Run("Info_AfterInstall", func(t *testing.T) { + pkg, err := mgr.Info(ctx, "tree") require.NoError(t, err) + require.NotNil(t, pkg) + assert.Equal(t, "tree", pkg.Name) + assert.NotEmpty(t, pkg.Version) }) - t.Run("IsInstalled_After_Remove", func(t *testing.T) { + t.Run("Install_Multiple", func(t *testing.T) { + err := mgr.Install(ctx, snack.Targets("tree", "less")) + require.NoError(t, err) + + for _, pkg := range []string{"tree", "less"} { + installed, err := mgr.IsInstalled(ctx, pkg) + require.NoError(t, err) + assert.True(t, installed, "%s should be installed", pkg) + } + }) + + t.Run("Purge", func(t *testing.T) { + // apk purge is same as remove + err := mgr.Purge(ctx, snack.Targets("less")) + require.NoError(t, err) + + installed, err := mgr.IsInstalled(ctx, "less") + require.NoError(t, err) + assert.False(t, installed) + }) + + t.Run("Remove", func(t *testing.T) { + err := mgr.Remove(ctx, snack.Targets("tree")) + require.NoError(t, err) + installed, err := mgr.IsInstalled(ctx, "tree") require.NoError(t, err) assert.False(t, installed) }) - t.Run("Capabilities", func(t *testing.T) { - if vq, ok := mgr.(snack.VersionQuerier); ok { - ver, err := vq.LatestVersion(ctx, "busybox") + // --- VersionQuerier --- + t.Run("VersionQuerier", func(t *testing.T) { + vq, ok := mgr.(snack.VersionQuerier) + require.True(t, ok) + + t.Run("LatestVersion", func(t *testing.T) { + ver, err := vq.LatestVersion(ctx, "curl") require.NoError(t, err) assert.NotEmpty(t, ver) + t.Logf("curl latest: %s", ver) + }) - upgrades, err := vq.ListUpgrades(ctx) + t.Run("LatestVersion_NotFound", func(t *testing.T) { + _, err := vq.LatestVersion(ctx, "xyznonexistentpackage999") + assert.Error(t, err) + }) + + t.Run("ListUpgrades", func(t *testing.T) { + pkgs, err := vq.ListUpgrades(ctx) require.NoError(t, err) - _ = upgrades - } + t.Logf("upgradable packages: %d", len(pkgs)) + }) - if cl, ok := mgr.(snack.Cleaner); ok { + t.Run("UpgradeAvailable", func(t *testing.T) { + avail, err := vq.UpgradeAvailable(ctx, "busybox") + require.NoError(t, err) + _ = avail + }) + + t.Run("VersionCmp", func(t *testing.T) { + tests := []struct { + v1, v2 string + want int + }{ + {"1.0.0-r0", "1.0.0-r1", -1}, + {"2.0.0-r0", "1.0.0-r0", 1}, + {"1.0.0-r0", "1.0.0-r0", 0}, + } + for _, tt := range tests { + cmp, err := vq.VersionCmp(ctx, tt.v1, tt.v2) + require.NoError(t, err, "VersionCmp(%s, %s)", tt.v1, tt.v2) + assert.Equal(t, tt.want, cmp, "VersionCmp(%s, %s)", tt.v1, tt.v2) + } + }) + }) + + // --- Cleaner --- + t.Run("Cleaner", func(t *testing.T) { + cl, ok := mgr.(snack.Cleaner) + require.True(t, ok) + + t.Run("Autoremove", func(t *testing.T) { + // apk doesn't have autoremove but we exercise the path + err := cl.Autoremove(ctx) + // May or may not be supported + _ = err + }) + + t.Run("Clean", func(t *testing.T) { err := cl.Clean(ctx) require.NoError(t, err) - } + }) + }) - if fo, ok := mgr.(snack.FileOwner); ok { - owner, err := fo.Owner(ctx, "/usr/bin/apk") - if err == nil { - assert.NotEmpty(t, owner) - } - } + // --- FileOwner --- + t.Run("FileOwner", func(t *testing.T) { + fo, ok := mgr.(snack.FileOwner) + require.True(t, ok) + + // Install tree for file tests + _ = mgr.Install(ctx, snack.Targets("tree")) + + t.Run("FileList", func(t *testing.T) { + files, err := fo.FileList(ctx, "tree") + require.NoError(t, err) + require.NotEmpty(t, files) + t.Logf("tree files: %d", len(files)) + }) + + t.Run("FileList_NotInstalled", func(t *testing.T) { + _, err := fo.FileList(ctx, "xyznonexistentpackage999") + assert.Error(t, err) + }) + + t.Run("Owner", func(t *testing.T) { + owner, err := fo.Owner(ctx, "/usr/bin/tree") + require.NoError(t, err) + assert.Contains(t, owner, "tree") + }) + + t.Run("Owner_NotFound", func(t *testing.T) { + _, err := fo.Owner(ctx, "/nonexistent/path/xyz") + assert.Error(t, err) + }) + + _ = mgr.Remove(ctx, snack.Targets("tree")) + }) + + // --- Upgrade --- + t.Run("Upgrade", func(t *testing.T) { + err := mgr.Upgrade(ctx) + require.NoError(t, err) }) } diff --git a/apt/apt_integration_test.go b/apt/apt_integration_test.go index 2379235..ca38161 100644 --- a/apt/apt_integration_test.go +++ b/apt/apt_integration_test.go @@ -13,92 +13,365 @@ import ( ) func TestIntegration_Apt(t *testing.T) { - var mgr snack.Manager = apt.New() + mgr := apt.New() if !mgr.Available() { t.Skip("apt not available") } ctx := context.Background() + // Verify Name + assert.Equal(t, "apt", mgr.Name()) + + // Verify capabilities reported correctly + caps := snack.GetCapabilities(mgr) + assert.True(t, caps.VersionQuery, "apt should support VersionQuery") + assert.True(t, caps.Hold, "apt should support Hold") + assert.True(t, caps.Clean, "apt should support Clean") + assert.True(t, caps.FileOwnership, "apt should support FileOwnership") + assert.True(t, caps.RepoManagement, "apt should support RepoManagement") + assert.True(t, caps.KeyManagement, "apt should support KeyManagement") + assert.True(t, caps.NameNormalize, "apt should support NameNormalize") + assert.False(t, caps.Groups, "apt should not support Groups") + t.Run("Update", func(t *testing.T) { - err := mgr.Update(ctx) - require.NoError(t, err) + require.NoError(t, mgr.Update(ctx)) }) t.Run("Search", func(t *testing.T) { pkgs, err := mgr.Search(ctx, "curl") require.NoError(t, err) - require.NotEmpty(t, pkgs) + require.NotEmpty(t, pkgs, "search for curl should return results") + + // Verify Package struct fields are populated + found := false + for _, p := range pkgs { + if p.Name == "curl" { + found = true + assert.NotEmpty(t, p.Description, "curl should have a description") + break + } + } + assert.True(t, found, "curl should appear in search results") + }) + + t.Run("Search_NoResults", func(t *testing.T) { + pkgs, err := mgr.Search(ctx, "xyznonexistentpackage999") + require.NoError(t, err) + assert.Empty(t, pkgs) }) t.Run("Info", func(t *testing.T) { - pkg, err := mgr.Info(ctx, "curl") + pkg, err := mgr.Info(ctx, "bash") require.NoError(t, err) require.NotNil(t, pkg) - assert.Equal(t, "curl", pkg.Name) + assert.Equal(t, "bash", pkg.Name) + assert.NotEmpty(t, pkg.Version) + assert.NotEmpty(t, pkg.Description) }) - t.Run("Install", func(t *testing.T) { - err := mgr.Install(ctx, snack.Targets("tree"), snack.WithSudo(), snack.WithAssumeYes()) + t.Run("Info_NotFound", func(t *testing.T) { + _, err := mgr.Info(ctx, "xyznonexistentpackage999") + assert.Error(t, err) + }) + + t.Run("Install_Single", func(t *testing.T) { + // Remove first to ensure clean state + _ = mgr.Remove(ctx, snack.Targets("tree"), snack.WithAssumeYes()) + + err := mgr.Install(ctx, snack.Targets("tree"), snack.WithAssumeYes()) require.NoError(t, err) }) - t.Run("IsInstalled", func(t *testing.T) { + t.Run("IsInstalled_True", func(t *testing.T) { installed, err := mgr.IsInstalled(ctx, "tree") require.NoError(t, err) assert.True(t, installed) }) - t.Run("Version", func(t *testing.T) { + t.Run("IsInstalled_False", func(t *testing.T) { + installed, err := mgr.IsInstalled(ctx, "xyznonexistentpackage999") + require.NoError(t, err) + assert.False(t, installed) + }) + + t.Run("Version_Installed", func(t *testing.T) { ver, err := mgr.Version(ctx, "tree") require.NoError(t, err) assert.NotEmpty(t, ver) + t.Logf("tree version: %s", ver) }) - t.Run("List", func(t *testing.T) { + t.Run("Version_NotInstalled", func(t *testing.T) { + _, err := mgr.Version(ctx, "xyznonexistentpackage999") + assert.Error(t, err) + }) + + t.Run("List_ContainsInstalled", func(t *testing.T) { pkgs, err := mgr.List(ctx) require.NoError(t, err) + require.NotEmpty(t, pkgs) + found := false for _, p := range pkgs { if p.Name == "tree" { found = true + assert.NotEmpty(t, p.Version) + assert.True(t, p.Installed) break } } assert.True(t, found, "tree should be in installed list") }) - t.Run("Remove", func(t *testing.T) { - err := mgr.Remove(ctx, snack.Targets("tree"), snack.WithSudo(), snack.WithAssumeYes()) + t.Run("Install_WithVersion", func(t *testing.T) { + // Get current version and reinstall with explicit version + ver, err := mgr.Version(ctx, "tree") require.NoError(t, err) + + err = mgr.Install(ctx, []snack.Target{{Name: "tree", Version: ver}}, snack.WithAssumeYes()) + // Should succeed (already installed at that version) or be a no-op + // Some apt versions may return success, others may note it's already installed + _ = err }) - t.Run("IsInstalled_After_Remove", func(t *testing.T) { + t.Run("Purge", func(t *testing.T) { + // Install then purge (removes config files too) + _ = mgr.Install(ctx, snack.Targets("tree"), snack.WithAssumeYes()) + err := mgr.Purge(ctx, snack.Targets("tree"), snack.WithAssumeYes()) + require.NoError(t, err) + installed, err := mgr.IsInstalled(ctx, "tree") require.NoError(t, err) assert.False(t, installed) }) - t.Run("Capabilities", func(t *testing.T) { - if vq, ok := mgr.(snack.VersionQuerier); ok { + t.Run("Install_Multiple", func(t *testing.T) { + err := mgr.Install(ctx, snack.Targets("tree", "less"), snack.WithAssumeYes()) + require.NoError(t, err) + + for _, pkg := range []string{"tree", "less"} { + installed, err := mgr.IsInstalled(ctx, pkg) + require.NoError(t, err) + assert.True(t, installed, "%s should be installed", pkg) + } + }) + + t.Run("Remove", func(t *testing.T) { + err := mgr.Remove(ctx, snack.Targets("tree"), snack.WithAssumeYes()) + require.NoError(t, err) + + installed, err := mgr.IsInstalled(ctx, "tree") + require.NoError(t, err) + assert.False(t, installed) + }) + + // --- VersionQuerier --- + t.Run("VersionQuerier", func(t *testing.T) { + vq, ok := mgr.(snack.VersionQuerier) + require.True(t, ok, "apt must implement VersionQuerier") + + t.Run("LatestVersion", func(t *testing.T) { ver, err := vq.LatestVersion(ctx, "curl") require.NoError(t, err) assert.NotEmpty(t, ver) + t.Logf("curl latest: %s", ver) + }) - upgrades, err := vq.ListUpgrades(ctx) + t.Run("LatestVersion_NotFound", func(t *testing.T) { + _, err := vq.LatestVersion(ctx, "xyznonexistentpackage999") + assert.Error(t, err) + }) + + t.Run("ListUpgrades", func(t *testing.T) { + pkgs, err := vq.ListUpgrades(ctx) require.NoError(t, err) - _ = upgrades - } + // May be empty in a freshly updated container, that's fine + t.Logf("upgradable packages: %d", len(pkgs)) + for _, p := range pkgs { + assert.NotEmpty(t, p.Name) + assert.NotEmpty(t, p.Version) + } + }) - if cl, ok := mgr.(snack.Cleaner); ok { + t.Run("UpgradeAvailable", func(t *testing.T) { + // bash is typically at latest in a fresh container + avail, err := vq.UpgradeAvailable(ctx, "bash") + require.NoError(t, err) + _ = avail // just exercising the code path + }) + + t.Run("VersionCmp", func(t *testing.T) { + tests := []struct { + v1, v2 string + want int + }{ + {"1.0-1", "1.0-2", -1}, + {"2.0-1", "1.0-1", 1}, + {"1.0-1", "1.0-1", 0}, + {"1:1.0-1", "1.0-1", 1}, // epoch + } + for _, tt := range tests { + cmp, err := vq.VersionCmp(ctx, tt.v1, tt.v2) + require.NoError(t, err, "VersionCmp(%s, %s)", tt.v1, tt.v2) + assert.Equal(t, tt.want, cmp, "VersionCmp(%s, %s)", tt.v1, tt.v2) + } + }) + }) + + // --- Holder --- + t.Run("Holder", func(t *testing.T) { + h, ok := mgr.(snack.Holder) + require.True(t, ok, "apt must implement Holder") + + // Ensure tree is installed for hold tests + _ = mgr.Install(ctx, snack.Targets("tree"), snack.WithAssumeYes()) + + t.Run("Hold", func(t *testing.T) { + err := h.Hold(ctx, []string{"tree"}) + require.NoError(t, err) + }) + + t.Run("ListHeld", func(t *testing.T) { + held, err := h.ListHeld(ctx) + require.NoError(t, err) + found := false + for _, p := range held { + if p.Name == "tree" { + found = true + break + } + } + assert.True(t, found, "tree should be in held list") + }) + + t.Run("Unhold", func(t *testing.T) { + err := h.Unhold(ctx, []string{"tree"}) + require.NoError(t, err) + + held, err := h.ListHeld(ctx) + require.NoError(t, err) + for _, p := range held { + assert.NotEqual(t, "tree", p.Name, "tree should not be held after unhold") + } + }) + }) + + // --- Cleaner --- + t.Run("Cleaner", func(t *testing.T) { + cl, ok := mgr.(snack.Cleaner) + require.True(t, ok, "apt must implement Cleaner") + + t.Run("Autoremove", func(t *testing.T) { + err := cl.Autoremove(ctx) + require.NoError(t, err) + }) + + t.Run("Clean", func(t *testing.T) { err := cl.Clean(ctx) require.NoError(t, err) - } + }) + }) - if fo, ok := mgr.(snack.FileOwner); ok { - owner, err := fo.Owner(ctx, "/usr/bin/apt") - if err == nil { - assert.NotEmpty(t, owner) + // --- FileOwner --- + t.Run("FileOwner", func(t *testing.T) { + fo, ok := mgr.(snack.FileOwner) + require.True(t, ok, "apt must implement FileOwner") + + // Ensure tree is installed + _ = mgr.Install(ctx, snack.Targets("tree"), snack.WithAssumeYes()) + + t.Run("FileList", func(t *testing.T) { + files, err := fo.FileList(ctx, "tree") + require.NoError(t, err) + require.NotEmpty(t, files) + + found := false + for _, f := range files { + if f == "/usr/bin/tree" { + found = true + break + } } + assert.True(t, found, "/usr/bin/tree should be in file list") + }) + + t.Run("FileList_NotInstalled", func(t *testing.T) { + _, err := fo.FileList(ctx, "xyznonexistentpackage999") + assert.Error(t, err) + }) + + t.Run("Owner", func(t *testing.T) { + owner, err := fo.Owner(ctx, "/usr/bin/tree") + require.NoError(t, err) + assert.Contains(t, owner, "tree") + }) + + t.Run("Owner_NotFound", func(t *testing.T) { + _, err := fo.Owner(ctx, "/nonexistent/path/xyz") + assert.Error(t, err) + }) + }) + + // --- RepoManager --- + t.Run("RepoManager", func(t *testing.T) { + rm, ok := mgr.(snack.RepoManager) + require.True(t, ok, "apt must implement RepoManager") + + t.Run("ListRepos", func(t *testing.T) { + repos, err := rm.ListRepos(ctx) + require.NoError(t, err) + require.NotEmpty(t, repos, "should have at least one repo") + for _, r := range repos { + assert.NotEmpty(t, r.URL, "repo should have a URL") + } + t.Logf("repos: %d", len(repos)) + }) + }) + + // --- KeyManager --- + t.Run("KeyManager", func(t *testing.T) { + km, ok := mgr.(snack.KeyManager) + require.True(t, ok, "apt must implement KeyManager") + + t.Run("ListKeys", func(t *testing.T) { + keys, err := km.ListKeys(ctx) + require.NoError(t, err) + // May have keys or not, just exercising the path + t.Logf("keys: %d", len(keys)) + }) + }) + + // --- NameNormalizer --- + t.Run("NameNormalizer", func(t *testing.T) { + nn, ok := mgr.(snack.NameNormalizer) + require.True(t, ok, "apt must implement NameNormalizer") + + tests := []struct { + input, wantName, wantArch string + }{ + {"curl:amd64", "curl", "amd64"}, + {"bash:arm64", "bash", "arm64"}, + {"python3", "python3", ""}, + } + for _, tt := range tests { + t.Run("NormalizeName_"+tt.input, func(t *testing.T) { + got := nn.NormalizeName(tt.input) + assert.Equal(t, tt.wantName, got) + }) + t.Run("ParseArch_"+tt.input, func(t *testing.T) { + name, arch := nn.ParseArch(tt.input) + assert.Equal(t, tt.wantName, name) + assert.Equal(t, tt.wantArch, arch) + }) } }) + + // --- Upgrade (run last as it may change state) --- + t.Run("Upgrade", func(t *testing.T) { + err := mgr.Upgrade(ctx, snack.WithAssumeYes()) + require.NoError(t, err) + }) + + // Cleanup + _ = mgr.Remove(ctx, snack.Targets("tree", "less"), snack.WithAssumeYes()) } diff --git a/detect/detect_integration_test.go b/detect/detect_integration_test.go index 6bc9e55..4fec692 100644 --- a/detect/detect_integration_test.go +++ b/detect/detect_integration_test.go @@ -12,18 +12,53 @@ import ( ) func TestIntegration_Detect(t *testing.T) { - mgr, err := detect.Default() - require.NoError(t, err) - require.NotNil(t, mgr) - t.Logf("Detected: %s", mgr.Name()) + t.Run("Default", func(t *testing.T) { + mgr, err := detect.Default() + require.NoError(t, err) + require.NotNil(t, mgr) + assert.NotEmpty(t, mgr.Name()) + assert.True(t, mgr.Available()) + t.Logf("Detected default: %s", mgr.Name()) + }) - all := detect.All() - require.NotEmpty(t, all) - for _, m := range all { - t.Logf("Available: %s", m.Name()) - } + t.Run("All", func(t *testing.T) { + all := detect.All() + require.NotEmpty(t, all) + for _, m := range all { + assert.NotEmpty(t, m.Name()) + assert.True(t, m.Available()) + t.Logf("Available: %s", m.Name()) + } + }) - caps := snack.GetCapabilities(mgr) - t.Logf("Capabilities: %+v", caps) - assert.NotEmpty(t, mgr.Name()) + t.Run("ByName_Valid", func(t *testing.T) { + all := detect.All() + require.NotEmpty(t, all) + + // Should be able to find each detected manager by name + for _, m := range all { + found, err := detect.ByName(m.Name()) + require.NoError(t, err, "ByName(%s)", m.Name()) + require.NotNil(t, found) + assert.Equal(t, m.Name(), found.Name()) + } + }) + + t.Run("ByName_Invalid", func(t *testing.T) { + _, err := detect.ByName("xyznonexistentmanager999") + assert.Error(t, err) + assert.ErrorIs(t, err, snack.ErrManagerNotFound) + }) + + t.Run("Capabilities", func(t *testing.T) { + mgr, err := detect.Default() + require.NoError(t, err) + + caps := snack.GetCapabilities(mgr) + t.Logf("Default manager %s capabilities: %+v", mgr.Name(), caps) + + // Every manager should have at least the base Manager interface + // (which isn't in Capabilities, but let's verify some basics) + assert.NotEmpty(t, mgr.Name()) + }) } diff --git a/dnf/dnf_integration_test.go b/dnf/dnf_integration_test.go index 2850407..2f0f276 100644 --- a/dnf/dnf_integration_test.go +++ b/dnf/dnf_integration_test.go @@ -21,110 +21,384 @@ func TestIntegration_DNF_Detection(t *testing.T) { } func TestIntegration_DNF(t *testing.T) { - var mgr snack.Manager = dnf.New() + mgr := dnf.New() if !mgr.Available() { t.Skip("dnf not available") } ctx := context.Background() + assert.Equal(t, "dnf", mgr.Name()) + + caps := snack.GetCapabilities(mgr) + assert.True(t, caps.VersionQuery, "dnf should support VersionQuery") + assert.True(t, caps.Hold, "dnf should support Hold") + assert.True(t, caps.Clean, "dnf should support Clean") + assert.True(t, caps.FileOwnership, "dnf should support FileOwnership") + assert.True(t, caps.RepoManagement, "dnf should support RepoManagement") + assert.True(t, caps.KeyManagement, "dnf should support KeyManagement") + assert.True(t, caps.Groups, "dnf should support Groups") + assert.True(t, caps.NameNormalize, "dnf should support NameNormalize") + t.Run("Update", func(t *testing.T) { - err := mgr.Update(ctx) - require.NoError(t, err) + require.NoError(t, mgr.Update(ctx)) }) t.Run("Search", func(t *testing.T) { pkgs, err := mgr.Search(ctx, "curl") require.NoError(t, err) require.NotEmpty(t, pkgs) + + found := false + for _, p := range pkgs { + if p.Name == "curl" { + found = true + assert.NotEmpty(t, p.Description) + break + } + } + assert.True(t, found, "curl should appear in search results") + }) + + t.Run("Search_NoResults", func(t *testing.T) { + pkgs, err := mgr.Search(ctx, "xyznonexistentpackage999") + require.NoError(t, err) + assert.Empty(t, pkgs) }) t.Run("Info", func(t *testing.T) { - pkg, err := mgr.Info(ctx, "curl") + pkg, err := mgr.Info(ctx, "bash") require.NoError(t, err) require.NotNil(t, pkg) - assert.Equal(t, "curl", pkg.Name) + assert.Equal(t, "bash", pkg.Name) + assert.NotEmpty(t, pkg.Version) }) - t.Run("Install", func(t *testing.T) { - err := mgr.Install(ctx, snack.Targets("tree"), snack.WithSudo(), snack.WithAssumeYes()) + t.Run("Info_NotFound", func(t *testing.T) { + _, err := mgr.Info(ctx, "xyznonexistentpackage999") + assert.Error(t, err) + assert.ErrorIs(t, err, snack.ErrNotFound) + }) + + t.Run("Install_Single", func(t *testing.T) { + _ = mgr.Remove(ctx, snack.Targets("tree"), snack.WithAssumeYes()) + err := mgr.Install(ctx, snack.Targets("tree"), snack.WithAssumeYes()) require.NoError(t, err) }) - t.Run("IsInstalled", func(t *testing.T) { + t.Run("IsInstalled_True", func(t *testing.T) { installed, err := mgr.IsInstalled(ctx, "tree") require.NoError(t, err) assert.True(t, installed) }) - t.Run("Version", func(t *testing.T) { + t.Run("IsInstalled_False", func(t *testing.T) { + installed, err := mgr.IsInstalled(ctx, "xyznonexistentpackage999") + require.NoError(t, err) + assert.False(t, installed) + }) + + t.Run("Version_Installed", func(t *testing.T) { ver, err := mgr.Version(ctx, "tree") require.NoError(t, err) assert.NotEmpty(t, ver) + t.Logf("tree version: %s", ver) }) - t.Run("List", func(t *testing.T) { + t.Run("Version_NotInstalled", func(t *testing.T) { + _, err := mgr.Version(ctx, "xyznonexistentpackage999") + assert.Error(t, err) + assert.ErrorIs(t, err, snack.ErrNotInstalled) + }) + + t.Run("List_ContainsInstalled", func(t *testing.T) { pkgs, err := mgr.List(ctx) require.NoError(t, err) + require.NotEmpty(t, pkgs) + found := false for _, p := range pkgs { if p.Name == "tree" { found = true + assert.NotEmpty(t, p.Version) break } } assert.True(t, found, "tree should be in installed list") }) - t.Run("Remove", func(t *testing.T) { - err := mgr.Remove(ctx, snack.Targets("tree"), snack.WithSudo(), snack.WithAssumeYes()) + t.Run("Install_Multiple", func(t *testing.T) { + err := mgr.Install(ctx, snack.Targets("tree", "less"), snack.WithAssumeYes()) + require.NoError(t, err) + + for _, pkg := range []string{"tree", "less"} { + installed, err := mgr.IsInstalled(ctx, pkg) + require.NoError(t, err) + assert.True(t, installed, "%s should be installed", pkg) + } + }) + + t.Run("Install_WithRefresh", func(t *testing.T) { + err := mgr.Install(ctx, snack.Targets("tree"), snack.WithAssumeYes(), snack.WithRefresh()) require.NoError(t, err) }) - t.Run("IsInstalled_After_Remove", func(t *testing.T) { + t.Run("Purge", func(t *testing.T) { + err := mgr.Purge(ctx, snack.Targets("less"), snack.WithAssumeYes()) + require.NoError(t, err) + + installed, err := mgr.IsInstalled(ctx, "less") + require.NoError(t, err) + assert.False(t, installed) + }) + + t.Run("Remove", func(t *testing.T) { + err := mgr.Remove(ctx, snack.Targets("tree"), snack.WithAssumeYes()) + require.NoError(t, err) + installed, err := mgr.IsInstalled(ctx, "tree") require.NoError(t, err) assert.False(t, installed) }) - t.Run("Capabilities", func(t *testing.T) { - if vq, ok := mgr.(snack.VersionQuerier); ok { + // --- VersionQuerier --- + t.Run("VersionQuerier", func(t *testing.T) { + vq, ok := mgr.(snack.VersionQuerier) + require.True(t, ok) + + t.Run("LatestVersion", func(t *testing.T) { ver, err := vq.LatestVersion(ctx, "curl") require.NoError(t, err) assert.NotEmpty(t, ver) + t.Logf("curl latest: %s", ver) + }) - upgrades, err := vq.ListUpgrades(ctx) + t.Run("LatestVersion_NotFound", func(t *testing.T) { + _, err := vq.LatestVersion(ctx, "xyznonexistentpackage999") + assert.Error(t, err) + }) + + t.Run("ListUpgrades", func(t *testing.T) { + pkgs, err := vq.ListUpgrades(ctx) require.NoError(t, err) - _ = upgrades - } + t.Logf("upgradable packages: %d", len(pkgs)) + for _, p := range pkgs { + assert.NotEmpty(t, p.Name) + assert.NotEmpty(t, p.Version) + } + }) - if cl, ok := mgr.(snack.Cleaner); ok { + t.Run("UpgradeAvailable", func(t *testing.T) { + avail, err := vq.UpgradeAvailable(ctx, "bash") + require.NoError(t, err) + _ = avail + }) + + t.Run("VersionCmp", func(t *testing.T) { + // rpmdev-vercmp may not be installed; skip if not available + cmp, err := vq.VersionCmp(ctx, "1.0-1", "1.0-2") + if err != nil { + t.Skip("rpmdev-vercmp not available") + } + assert.Equal(t, -1, cmp) + + cmp, err = vq.VersionCmp(ctx, "2.0-1", "1.0-1") + require.NoError(t, err) + assert.Equal(t, 1, cmp) + + cmp, err = vq.VersionCmp(ctx, "1.0-1", "1.0-1") + require.NoError(t, err) + assert.Equal(t, 0, cmp) + }) + }) + + // --- Holder --- + t.Run("Holder", func(t *testing.T) { + h, ok := mgr.(snack.Holder) + require.True(t, ok) + + // Install tree for hold tests; also ensure versionlock plugin + _ = mgr.Install(ctx, snack.Targets("tree"), snack.WithAssumeYes()) + + t.Run("Hold", func(t *testing.T) { + err := h.Hold(ctx, []string{"tree"}) + if err != nil { + t.Skip("versionlock plugin not available:", err) + } + }) + + t.Run("ListHeld", func(t *testing.T) { + held, err := h.ListHeld(ctx) + if err != nil { + t.Skip("versionlock plugin not available:", err) + } + found := false + for _, p := range held { + if p.Name == "tree" { + found = true + break + } + } + assert.True(t, found, "tree should be in held list") + }) + + t.Run("Unhold", func(t *testing.T) { + err := h.Unhold(ctx, []string{"tree"}) + if err != nil { + t.Skip("versionlock plugin not available:", err) + } + }) + + _ = mgr.Remove(ctx, snack.Targets("tree"), snack.WithAssumeYes()) + }) + + // --- Cleaner --- + t.Run("Cleaner", func(t *testing.T) { + cl, ok := mgr.(snack.Cleaner) + require.True(t, ok) + + t.Run("Autoremove", func(t *testing.T) { + err := cl.Autoremove(ctx) + require.NoError(t, err) + }) + + t.Run("Clean", func(t *testing.T) { err := cl.Clean(ctx) require.NoError(t, err) - } + }) + }) - if fo, ok := mgr.(snack.FileOwner); ok { - owner, err := fo.Owner(ctx, "/usr/bin/dnf") - if err == nil { - assert.NotEmpty(t, owner) - } - } + // --- FileOwner --- + t.Run("FileOwner", func(t *testing.T) { + fo, ok := mgr.(snack.FileOwner) + require.True(t, ok) - if g, ok := mgr.(snack.Grouper); ok { - groups, err := g.GroupList(ctx) + _ = mgr.Install(ctx, snack.Targets("tree"), snack.WithAssumeYes()) + + t.Run("FileList", func(t *testing.T) { + files, err := fo.FileList(ctx, "tree") require.NoError(t, err) - _ = groups - } + require.NotEmpty(t, files) - if rm, ok := mgr.(snack.RepoManager); ok { + found := false + for _, f := range files { + if f == "/usr/bin/tree" { + found = true + break + } + } + assert.True(t, found, "/usr/bin/tree should be in file list") + }) + + t.Run("FileList_NotInstalled", func(t *testing.T) { + _, err := fo.FileList(ctx, "xyznonexistentpackage999") + assert.Error(t, err) + assert.ErrorIs(t, err, snack.ErrNotInstalled) + }) + + t.Run("Owner", func(t *testing.T) { + owner, err := fo.Owner(ctx, "/usr/bin/tree") + require.NoError(t, err) + assert.Contains(t, owner, "tree") + }) + + t.Run("Owner_NotFound", func(t *testing.T) { + _, err := fo.Owner(ctx, "/nonexistent/path/xyz") + assert.Error(t, err) + }) + + _ = mgr.Remove(ctx, snack.Targets("tree"), snack.WithAssumeYes()) + }) + + // --- RepoManager --- + t.Run("RepoManager", func(t *testing.T) { + rm, ok := mgr.(snack.RepoManager) + require.True(t, ok) + + t.Run("ListRepos", func(t *testing.T) { repos, err := rm.ListRepos(ctx) require.NoError(t, err) - require.NotEmpty(t, repos, "should have at least one repo") + require.NotEmpty(t, repos) + for _, r := range repos { + assert.NotEmpty(t, r.ID) + } t.Logf("repos: %d", len(repos)) - } + }) + }) - if nn, ok := mgr.(snack.NameNormalizer); ok { - got := nn.NormalizeName("curl.x86_64") - assert.Equal(t, "curl", got) + // --- KeyManager --- + t.Run("KeyManager", func(t *testing.T) { + km, ok := mgr.(snack.KeyManager) + require.True(t, ok) + + t.Run("ListKeys", func(t *testing.T) { + keys, err := km.ListKeys(ctx) + require.NoError(t, err) + require.NotEmpty(t, keys, "Fedora should have GPG keys") + t.Logf("keys: %d", len(keys)) + }) + }) + + // --- Grouper --- + t.Run("Grouper", func(t *testing.T) { + g, ok := mgr.(snack.Grouper) + require.True(t, ok) + + t.Run("GroupList", func(t *testing.T) { + groups, err := g.GroupList(ctx) + require.NoError(t, err) + require.NotEmpty(t, groups) + t.Logf("groups: %d", len(groups)) + }) + + t.Run("GroupInfo", func(t *testing.T) { + // Use a group that should exist + groups, err := g.GroupList(ctx) + require.NoError(t, err) + require.NotEmpty(t, groups) + + pkgs, err := g.GroupInfo(ctx, groups[0]) + // Some groups may not have packages queryable by name + if err == nil { + t.Logf("group %q packages: %d", groups[0], len(pkgs)) + } + }) + + t.Run("GroupInfo_NotFound", func(t *testing.T) { + _, err := g.GroupInfo(ctx, "xyznonexistentgroup999") + assert.Error(t, err) + }) + }) + + // --- NameNormalizer --- + t.Run("NameNormalizer", func(t *testing.T) { + nn, ok := mgr.(snack.NameNormalizer) + require.True(t, ok) + + tests := []struct { + input, wantName, wantArch string + }{ + {"curl.x86_64", "curl", "x86_64"}, + {"bash.aarch64", "bash", "aarch64"}, + {"python3.noarch", "python3", "noarch"}, + {"python3.11.x86_64", "python3.11", "x86_64"}, + {"glibc", "glibc", ""}, + } + for _, tt := range tests { + t.Run("NormalizeName_"+tt.input, func(t *testing.T) { + got := nn.NormalizeName(tt.input) + assert.Equal(t, tt.wantName, got) + }) + t.Run("ParseArch_"+tt.input, func(t *testing.T) { + name, arch := nn.ParseArch(tt.input) + assert.Equal(t, tt.wantName, name) + assert.Equal(t, tt.wantArch, arch) + }) } }) + + // --- Upgrade --- + t.Run("Upgrade", func(t *testing.T) { + err := mgr.Upgrade(ctx, snack.WithAssumeYes()) + require.NoError(t, err) + }) } diff --git a/dpkg/dpkg_integration_test.go b/dpkg/dpkg_integration_test.go index 3ac0414..4388021 100644 --- a/dpkg/dpkg_integration_test.go +++ b/dpkg/dpkg_integration_test.go @@ -13,29 +13,63 @@ import ( ) func TestIntegration_Dpkg(t *testing.T) { - var mgr snack.Manager = dpkg.New() + mgr := dpkg.New() if !mgr.Available() { t.Skip("dpkg not available") } ctx := context.Background() + assert.Equal(t, "dpkg", mgr.Name()) + + caps := snack.GetCapabilities(mgr) + assert.True(t, caps.FileOwnership, "dpkg should support FileOwnership") + assert.True(t, caps.NameNormalize, "dpkg should support NameNormalize") + assert.False(t, caps.VersionQuery) + assert.False(t, caps.Hold) + assert.False(t, caps.Clean) + assert.False(t, caps.RepoManagement) + assert.False(t, caps.KeyManagement) + assert.False(t, caps.Groups) + t.Run("List", func(t *testing.T) { pkgs, err := mgr.List(ctx) require.NoError(t, err) require.NotEmpty(t, pkgs) + + found := false + for _, p := range pkgs { + if p.Name == "bash" { + found = true + assert.NotEmpty(t, p.Version) + assert.True(t, p.Installed) + break + } + } + assert.True(t, found, "bash should be in list") }) - t.Run("IsInstalled", func(t *testing.T) { - // bash should always be installed + t.Run("IsInstalled_True", func(t *testing.T) { installed, err := mgr.IsInstalled(ctx, "bash") require.NoError(t, err) assert.True(t, installed) }) + t.Run("IsInstalled_False", func(t *testing.T) { + installed, err := mgr.IsInstalled(ctx, "xyznonexistentpackage999") + require.NoError(t, err) + assert.False(t, installed) + }) + t.Run("Version", func(t *testing.T) { ver, err := mgr.Version(ctx, "bash") require.NoError(t, err) assert.NotEmpty(t, ver) + t.Logf("bash version: %s", ver) + }) + + t.Run("Version_NotInstalled", func(t *testing.T) { + _, err := mgr.Version(ctx, "xyznonexistentpackage999") + assert.Error(t, err) }) t.Run("Info", func(t *testing.T) { @@ -43,6 +77,12 @@ func TestIntegration_Dpkg(t *testing.T) { require.NoError(t, err) require.NotNil(t, pkg) assert.Equal(t, "bash", pkg.Name) + assert.NotEmpty(t, pkg.Version) + }) + + t.Run("Info_NotFound", func(t *testing.T) { + _, err := mgr.Info(ctx, "xyznonexistentpackage999") + assert.Error(t, err) }) t.Run("Search", func(t *testing.T) { @@ -51,15 +91,86 @@ func TestIntegration_Dpkg(t *testing.T) { require.NotEmpty(t, pkgs) }) + t.Run("Search_NoResults", func(t *testing.T) { + pkgs, err := mgr.Search(ctx, "xyznonexistentpackage999") + require.NoError(t, err) + assert.Empty(t, pkgs) + }) + + // --- FileOwner --- t.Run("FileOwner", func(t *testing.T) { - if fo, ok := mgr.(snack.FileOwner); ok { + fo, ok := mgr.(snack.FileOwner) + require.True(t, ok) + + t.Run("FileList", func(t *testing.T) { + files, err := fo.FileList(ctx, "bash") + require.NoError(t, err) + require.NotEmpty(t, files) + + found := false + for _, f := range files { + if f == "/usr/bin/bash" || f == "/bin/bash" { + found = true + break + } + } + assert.True(t, found, "bash binary should be in file list") + }) + + t.Run("FileList_NotInstalled", func(t *testing.T) { + _, err := fo.FileList(ctx, "xyznonexistentpackage999") + assert.Error(t, err) + }) + + t.Run("Owner", func(t *testing.T) { owner, err := fo.Owner(ctx, "/usr/bin/dpkg") require.NoError(t, err) assert.NotEmpty(t, owner) + assert.Contains(t, owner, "dpkg") + }) - files, err := fo.FileList(ctx, "bash") - require.NoError(t, err) - assert.NotEmpty(t, files) + t.Run("Owner_NotFound", func(t *testing.T) { + _, err := fo.Owner(ctx, "/nonexistent/path/xyz") + assert.Error(t, err) + }) + }) + + // --- NameNormalizer --- + t.Run("NameNormalizer", func(t *testing.T) { + nn, ok := mgr.(snack.NameNormalizer) + require.True(t, ok) + + tests := []struct { + input, wantName, wantArch string + }{ + {"curl:amd64", "curl", "amd64"}, + {"bash:arm64", "bash", "arm64"}, + {"python3", "python3", ""}, + } + for _, tt := range tests { + t.Run("NormalizeName_"+tt.input, func(t *testing.T) { + got := nn.NormalizeName(tt.input) + assert.Equal(t, tt.wantName, got) + }) + t.Run("ParseArch_"+tt.input, func(t *testing.T) { + name, arch := nn.ParseArch(tt.input) + assert.Equal(t, tt.wantName, name) + assert.Equal(t, tt.wantArch, arch) + }) } }) + + // --- Operations that dpkg doesn't really support (exercise error paths) --- + t.Run("Install_Unsupported", func(t *testing.T) { + // dpkg install requires a .deb file path, not a package name + // This should fail gracefully + err := mgr.Install(ctx, snack.Targets("tree")) + assert.Error(t, err) + }) + + t.Run("Update", func(t *testing.T) { + // dpkg doesn't have update, should be a no-op or error + err := mgr.Update(ctx) + _ = err // exercise path + }) } diff --git a/flatpak/flatpak_integration_test.go b/flatpak/flatpak_integration_test.go index 70004ea..5d71463 100644 --- a/flatpak/flatpak_integration_test.go +++ b/flatpak/flatpak_integration_test.go @@ -13,32 +13,45 @@ import ( ) func TestIntegration_Flatpak(t *testing.T) { - var mgr snack.Manager = flatpak.New() + mgr := flatpak.New() if !mgr.Available() { - t.Skip("flatpak not available — install it first") + t.Skip("flatpak not available") } ctx := context.Background() + assert.Equal(t, "flatpak", mgr.Name()) + + caps := snack.GetCapabilities(mgr) + assert.True(t, caps.Clean, "flatpak should support Clean") + assert.True(t, caps.RepoManagement, "flatpak should support RepoManagement") + assert.False(t, caps.VersionQuery) + assert.False(t, caps.Hold) + assert.False(t, caps.FileOwnership) + assert.False(t, caps.KeyManagement) + assert.False(t, caps.Groups) + assert.False(t, caps.NameNormalize) + t.Run("Update", func(t *testing.T) { - err := mgr.Update(ctx) - require.NoError(t, err) + require.NoError(t, mgr.Update(ctx)) }) + // --- RepoManager --- t.Run("RepoManager", func(t *testing.T) { rm, ok := mgr.(snack.RepoManager) - if !ok { - t.Skip("RepoManager not implemented") - } - repos, err := rm.ListRepos(ctx) - require.NoError(t, err) - found := false - for _, r := range repos { - if r.Name == "flathub" || r.ID == "flathub" { - found = true - break + require.True(t, ok) + + t.Run("ListRepos", func(t *testing.T) { + repos, err := rm.ListRepos(ctx) + require.NoError(t, err) + found := false + for _, r := range repos { + if r.Name == "flathub" || r.ID == "flathub" { + found = true + break + } } - } - assert.True(t, found, "flathub repo should be configured") + assert.True(t, found, "flathub repo should be configured") + }) }) t.Run("Search", func(t *testing.T) { @@ -47,18 +60,48 @@ func TestIntegration_Flatpak(t *testing.T) { require.NotEmpty(t, pkgs) }) + t.Run("Search_NoResults", func(t *testing.T) { + pkgs, err := mgr.Search(ctx, "xyznonexistentpackage999") + require.NoError(t, err) + assert.Empty(t, pkgs) + }) + t.Run("Install", func(t *testing.T) { err := mgr.Install(ctx, snack.Targets("com.github.tchx84.Flatseal"), snack.WithSudo(), snack.WithAssumeYes()) require.NoError(t, err) }) - t.Run("IsInstalled", func(t *testing.T) { + t.Run("IsInstalled_True", func(t *testing.T) { installed, err := mgr.IsInstalled(ctx, "com.github.tchx84.Flatseal") require.NoError(t, err) assert.True(t, installed) }) - t.Run("List", func(t *testing.T) { + t.Run("IsInstalled_False", func(t *testing.T) { + installed, err := mgr.IsInstalled(ctx, "xyznonexistentpackage999") + require.NoError(t, err) + assert.False(t, installed) + }) + + t.Run("Info", func(t *testing.T) { + pkg, err := mgr.Info(ctx, "com.github.tchx84.Flatseal") + require.NoError(t, err) + require.NotNil(t, pkg) + }) + + t.Run("Info_NotFound", func(t *testing.T) { + _, err := mgr.Info(ctx, "xyznonexistentpackage999") + assert.Error(t, err) + }) + + t.Run("Version", func(t *testing.T) { + ver, err := mgr.Version(ctx, "com.github.tchx84.Flatseal") + require.NoError(t, err) + assert.NotEmpty(t, ver) + t.Logf("Flatseal version: %s", ver) + }) + + t.Run("List_ContainsInstalled", func(t *testing.T) { pkgs, err := mgr.List(ctx) require.NoError(t, err) found := false @@ -68,7 +111,7 @@ func TestIntegration_Flatpak(t *testing.T) { break } } - assert.True(t, found, "Flatseal should be in installed list (by Name or Application ID)") + assert.True(t, found, "Flatseal should be in installed list") }) t.Run("Remove", func(t *testing.T) { @@ -81,4 +124,25 @@ func TestIntegration_Flatpak(t *testing.T) { require.NoError(t, err) assert.False(t, installed) }) + + // --- Cleaner --- + t.Run("Cleaner", func(t *testing.T) { + cl, ok := mgr.(snack.Cleaner) + require.True(t, ok) + + t.Run("Autoremove", func(t *testing.T) { + err := cl.Autoremove(ctx, snack.WithAssumeYes()) + require.NoError(t, err) + }) + + t.Run("Clean", func(t *testing.T) { + err := cl.Clean(ctx) + require.NoError(t, err) + }) + }) + + t.Run("Upgrade", func(t *testing.T) { + err := mgr.Upgrade(ctx, snack.WithAssumeYes()) + require.NoError(t, err) + }) } diff --git a/integration_test.go b/integration_test.go index 21bd88f..8300aa6 100644 --- a/integration_test.go +++ b/integration_test.go @@ -1,9 +1,9 @@ //go:build containertest // Package snack_test provides testcontainers-based integration tests that -// run each package manager in its native container. Use: +// exercise every Manager method and capability interface across distros. // -// go test -tags containertest -v -count=1 -timeout 10m . +// go test -tags containertest -v -count=1 -timeout 15m . // // Requires Docker. package snack_test @@ -22,11 +22,20 @@ import ( const goVersion = "1.26.0" +// distroTest describes a container environment and the test packages available in it. type distroTest struct { name string image string - setup string // shell commands to install deps (Go installed separately) - packages string // space-separated test directories + setup string // shell commands to install deps + packages string // space-separated Go test directories + + // Test fixture data — packages known to exist in this distro's repos. + testPkg string // a small package to install/remove (e.g. "tree") + searchQuery string // a query that returns results (e.g. "curl") + infoPkg string // a package to get info on (always available) + knownFile string // a file path owned by a known package + knownFileOwner string // the package that owns knownFile (may include version) + groupName string // a package group name (empty if no Grouper) } // installGo returns a shell snippet that installs Go from the official tarball. @@ -39,34 +48,62 @@ func installGo(prereqs string) string { var distros = []distroTest{ { - name: "debian-apt", - image: "debian:bookworm", - setup: installGo("apt-get update && apt-get install -y sudo tree curl wget"), - packages: "./apt/ ./dpkg/ ./detect/", + name: "debian-apt", + image: "debian:bookworm", + setup: installGo("apt-get update && apt-get install -y sudo tree curl wget dpkg"), + packages: "./apt/ ./dpkg/ ./detect/", + testPkg: "tree", + searchQuery: "curl", + infoPkg: "bash", + knownFile: "/usr/bin/tree", + knownFileOwner: "tree", }, { - name: "alpine-apk", - image: "alpine:latest", - setup: installGo("apk add --no-cache sudo tree bash wget libc6-compat"), - packages: "./apk/ ./detect/", + name: "alpine-apk", + image: "alpine:latest", + setup: installGo("apk add --no-cache sudo tree bash wget libc6-compat curl"), + packages: "./apk/ ./detect/", + testPkg: "tree", + searchQuery: "curl", + infoPkg: "bash", + knownFile: "/usr/bin/tree", + knownFileOwner: "tree", }, { - name: "archlinux-pacman", - image: "archlinux:latest", - setup: installGo("pacman -Syu --noconfirm && pacman -S --noconfirm sudo tree wget"), - packages: "./pacman/ ./detect/", + name: "archlinux-pacman", + image: "archlinux:latest", + setup: installGo("pacman -Syu --noconfirm && pacman -S --noconfirm sudo tree wget"), + packages: "./pacman/ ./detect/", + testPkg: "tree", + searchQuery: "curl", + infoPkg: "bash", + knownFile: "/usr/bin/tree", + knownFileOwner: "tree", + groupName: "base-devel", }, { - name: "fedora-dnf4", - image: "fedora:39", - setup: installGo("dnf install -y tree sudo wget"), - packages: "./dnf/ ./rpm/ ./detect/", + name: "fedora-dnf4", + image: "fedora:39", + setup: installGo("dnf install -y tree sudo wget"), + packages: "./dnf/ ./rpm/ ./detect/", + testPkg: "tree", + searchQuery: "curl", + infoPkg: "bash", + knownFile: "/usr/bin/tree", + knownFileOwner: "tree", + groupName: "Development Tools", }, { - name: "fedora-dnf5", - image: "fedora:latest", - setup: installGo("dnf install -y tree sudo wget"), - packages: "./dnf/ ./rpm/ ./detect/", + name: "fedora-dnf5", + image: "fedora:latest", + setup: installGo("dnf install -y tree sudo wget"), + packages: "./dnf/ ./rpm/ ./detect/", + testPkg: "tree", + searchQuery: "curl", + infoPkg: "bash", + knownFile: "/usr/bin/tree", + knownFileOwner: "tree", + groupName: "Development Tools", }, } @@ -79,7 +116,7 @@ func TestContainers(t *testing.T) { d := d t.Run(d.name, func(t *testing.T) { t.Parallel() - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute) defer cancel() req := testcontainers.ContainerRequest{ @@ -100,7 +137,6 @@ func TestContainers(t *testing.T) { _ = container.Terminate(ctx) }() - // Copy source into container containerID := container.GetContainerID() // Execute setup @@ -116,9 +152,10 @@ func TestContainers(t *testing.T) { t.Fatalf("docker cp failed: %v\n%s", err, out) } - // Run integration tests + // Run integration tests with coverage testCmd := fmt.Sprintf( - "cd /src && export PATH=/usr/local/go/bin:$PATH && export GOPATH=/tmp/go && go test -v -tags integration -count=1 %s", + "cd /src && export PATH=/usr/local/go/bin:$PATH && export GOPATH=/tmp/go && "+ + "go test -v -tags integration -count=1 -coverprofile=/tmp/coverage.out -coverpkg=./... %s 2>&1", d.packages, ) t.Logf("Running tests in %s: %s", d.name, testCmd) @@ -128,8 +165,7 @@ func TestContainers(t *testing.T) { t.Fatalf("test exec failed: %v", err) } - // Read output - buf := make([]byte, 64*1024) + buf := make([]byte, 128*1024) var output2 strings.Builder for { n, readErr := reader.Read(buf) @@ -145,6 +181,13 @@ func TestContainers(t *testing.T) { if exitCode != 0 { t.Fatalf("%s integration tests failed (exit %d)", d.name, exitCode) } + + // Extract coverage file + cpOut := exec.CommandContext(ctx, "docker", "cp", + containerID+":/tmp/coverage.out", fmt.Sprintf("coverage-%s.out", d.name)) + if out, err := cpOut.CombinedOutput(); err != nil { + t.Logf("coverage extraction failed (non-fatal): %v\n%s", err, out) + } }) } } diff --git a/pacman/pacman_integration_test.go b/pacman/pacman_integration_test.go index 0a8ab9f..efaa2c0 100644 --- a/pacman/pacman_integration_test.go +++ b/pacman/pacman_integration_test.go @@ -13,98 +13,288 @@ import ( ) func TestIntegration_Pacman(t *testing.T) { - var mgr snack.Manager = pacman.New() + mgr := pacman.New() if !mgr.Available() { t.Skip("pacman not available") } ctx := context.Background() + assert.Equal(t, "pacman", mgr.Name()) + + caps := snack.GetCapabilities(mgr) + assert.True(t, caps.VersionQuery, "pacman should support VersionQuery") + assert.True(t, caps.Clean, "pacman should support Clean") + assert.True(t, caps.FileOwnership, "pacman should support FileOwnership") + assert.True(t, caps.Groups, "pacman should support Groups") + assert.False(t, caps.Hold, "pacman should not support Hold") + assert.False(t, caps.RepoManagement, "pacman should not support RepoManagement") + assert.False(t, caps.KeyManagement, "pacman should not support KeyManagement") + assert.False(t, caps.NameNormalize, "pacman should not support NameNormalize") + t.Run("Update", func(t *testing.T) { - err := mgr.Update(ctx) - require.NoError(t, err) + require.NoError(t, mgr.Update(ctx)) }) t.Run("Search", func(t *testing.T) { pkgs, err := mgr.Search(ctx, "curl") require.NoError(t, err) require.NotEmpty(t, pkgs) + + found := false + for _, p := range pkgs { + if p.Name == "curl" { + found = true + assert.NotEmpty(t, p.Description) + break + } + } + assert.True(t, found, "curl should appear in search results") + }) + + t.Run("Search_NoResults", func(t *testing.T) { + pkgs, err := mgr.Search(ctx, "xyznonexistentpackage999") + require.NoError(t, err) + assert.Empty(t, pkgs) }) t.Run("Info", func(t *testing.T) { - pkg, err := mgr.Info(ctx, "curl") + pkg, err := mgr.Info(ctx, "bash") require.NoError(t, err) require.NotNil(t, pkg) - assert.Equal(t, "curl", pkg.Name) + assert.Equal(t, "bash", pkg.Name) + assert.NotEmpty(t, pkg.Version) }) - t.Run("Install", func(t *testing.T) { - err := mgr.Install(ctx, snack.Targets("tree"), snack.WithSudo(), snack.WithAssumeYes()) + t.Run("Info_NotFound", func(t *testing.T) { + _, err := mgr.Info(ctx, "xyznonexistentpackage999") + assert.Error(t, err) + }) + + t.Run("Install_Single", func(t *testing.T) { + _ = mgr.Remove(ctx, snack.Targets("tree")) + err := mgr.Install(ctx, snack.Targets("tree"), snack.WithAssumeYes()) require.NoError(t, err) }) - t.Run("IsInstalled", func(t *testing.T) { + t.Run("IsInstalled_True", func(t *testing.T) { installed, err := mgr.IsInstalled(ctx, "tree") require.NoError(t, err) assert.True(t, installed) }) - t.Run("Version", func(t *testing.T) { + t.Run("IsInstalled_False", func(t *testing.T) { + installed, err := mgr.IsInstalled(ctx, "xyznonexistentpackage999") + require.NoError(t, err) + assert.False(t, installed) + }) + + t.Run("Version_Installed", func(t *testing.T) { ver, err := mgr.Version(ctx, "tree") require.NoError(t, err) assert.NotEmpty(t, ver) + t.Logf("tree version: %s", ver) }) - t.Run("List", func(t *testing.T) { + t.Run("Version_NotInstalled", func(t *testing.T) { + _, err := mgr.Version(ctx, "xyznonexistentpackage999") + assert.Error(t, err) + }) + + t.Run("List_ContainsInstalled", func(t *testing.T) { pkgs, err := mgr.List(ctx) require.NoError(t, err) + require.NotEmpty(t, pkgs) + found := false for _, p := range pkgs { if p.Name == "tree" { found = true + assert.NotEmpty(t, p.Version) break } } assert.True(t, found, "tree should be in installed list") }) - t.Run("Remove", func(t *testing.T) { - err := mgr.Remove(ctx, snack.Targets("tree"), snack.WithSudo(), snack.WithAssumeYes()) + t.Run("Install_Multiple", func(t *testing.T) { + err := mgr.Install(ctx, snack.Targets("tree", "less"), snack.WithAssumeYes()) require.NoError(t, err) + + for _, pkg := range []string{"tree", "less"} { + installed, err := mgr.IsInstalled(ctx, pkg) + require.NoError(t, err) + assert.True(t, installed, "%s should be installed", pkg) + } }) - t.Run("IsInstalled_After_Remove", func(t *testing.T) { + t.Run("Purge", func(t *testing.T) { + err := mgr.Purge(ctx, snack.Targets("less"), snack.WithAssumeYes()) + require.NoError(t, err) + + installed, err := mgr.IsInstalled(ctx, "less") + require.NoError(t, err) + assert.False(t, installed) + }) + + t.Run("Remove", func(t *testing.T) { + err := mgr.Remove(ctx, snack.Targets("tree"), snack.WithAssumeYes()) + require.NoError(t, err) + installed, err := mgr.IsInstalled(ctx, "tree") require.NoError(t, err) assert.False(t, installed) }) - t.Run("Capabilities", func(t *testing.T) { - if vq, ok := mgr.(snack.VersionQuerier); ok { + // --- VersionQuerier --- + t.Run("VersionQuerier", func(t *testing.T) { + vq, ok := mgr.(snack.VersionQuerier) + require.True(t, ok) + + t.Run("LatestVersion", func(t *testing.T) { ver, err := vq.LatestVersion(ctx, "curl") require.NoError(t, err) assert.NotEmpty(t, ver) + t.Logf("curl latest: %s", ver) + }) - upgrades, err := vq.ListUpgrades(ctx) + t.Run("LatestVersion_NotFound", func(t *testing.T) { + _, err := vq.LatestVersion(ctx, "xyznonexistentpackage999") + assert.Error(t, err) + }) + + t.Run("ListUpgrades", func(t *testing.T) { + pkgs, err := vq.ListUpgrades(ctx) require.NoError(t, err) - _ = upgrades - } + t.Logf("upgradable packages: %d", len(pkgs)) + }) - if cl, ok := mgr.(snack.Cleaner); ok { + t.Run("UpgradeAvailable", func(t *testing.T) { + avail, err := vq.UpgradeAvailable(ctx, "bash") + require.NoError(t, err) + _ = avail + }) + + t.Run("VersionCmp", func(t *testing.T) { + tests := []struct { + v1, v2 string + want int + }{ + {"1.0.0-1", "1.0.0-2", -1}, + {"2.0.0-1", "1.0.0-1", 1}, + {"1.0.0-1", "1.0.0-1", 0}, + } + for _, tt := range tests { + cmp, err := vq.VersionCmp(ctx, tt.v1, tt.v2) + require.NoError(t, err, "VersionCmp(%s, %s)", tt.v1, tt.v2) + assert.Equal(t, tt.want, cmp, "VersionCmp(%s, %s)", tt.v1, tt.v2) + } + }) + }) + + // --- Cleaner --- + t.Run("Cleaner", func(t *testing.T) { + cl, ok := mgr.(snack.Cleaner) + require.True(t, ok) + + t.Run("Autoremove", func(t *testing.T) { + err := cl.Autoremove(ctx) + require.NoError(t, err) + }) + + t.Run("Clean", func(t *testing.T) { err := cl.Clean(ctx) require.NoError(t, err) - } + }) + }) - if fo, ok := mgr.(snack.FileOwner); ok { - owner, err := fo.Owner(ctx, "/usr/bin/pacman") - if err == nil { - assert.NotEmpty(t, owner) + // --- FileOwner --- + t.Run("FileOwner", func(t *testing.T) { + fo, ok := mgr.(snack.FileOwner) + require.True(t, ok) + + _ = mgr.Install(ctx, snack.Targets("tree"), snack.WithAssumeYes()) + + t.Run("FileList", func(t *testing.T) { + files, err := fo.FileList(ctx, "tree") + require.NoError(t, err) + require.NotEmpty(t, files) + + found := false + for _, f := range files { + if f == "/usr/bin/tree" { + found = true + break + } } - } + assert.True(t, found, "/usr/bin/tree should be in file list") + }) - if g, ok := mgr.(snack.Grouper); ok { + t.Run("FileList_NotInstalled", func(t *testing.T) { + _, err := fo.FileList(ctx, "xyznonexistentpackage999") + assert.Error(t, err) + }) + + t.Run("Owner", func(t *testing.T) { + owner, err := fo.Owner(ctx, "/usr/bin/tree") + require.NoError(t, err) + assert.Contains(t, owner, "tree") + }) + + t.Run("Owner_NotFound", func(t *testing.T) { + _, err := fo.Owner(ctx, "/nonexistent/path/xyz") + assert.Error(t, err) + }) + + _ = mgr.Remove(ctx, snack.Targets("tree"), snack.WithAssumeYes()) + }) + + // --- Grouper --- + t.Run("Grouper", func(t *testing.T) { + g, ok := mgr.(snack.Grouper) + require.True(t, ok) + + t.Run("GroupList", func(t *testing.T) { groups, err := g.GroupList(ctx) require.NoError(t, err) - assert.NotEmpty(t, groups) - } + require.NotEmpty(t, groups) + t.Logf("groups: %d", len(groups)) + + found := false + for _, grp := range groups { + if grp == "base-devel" { + found = true + break + } + } + assert.True(t, found, "base-devel group should exist") + }) + + t.Run("GroupInfo", func(t *testing.T) { + pkgs, err := g.GroupInfo(ctx, "base-devel") + require.NoError(t, err) + require.NotEmpty(t, pkgs) + t.Logf("base-devel packages: %d", len(pkgs)) + + // Should contain gcc or make + found := false + for _, p := range pkgs { + if p.Name == "gcc" || p.Name == "make" { + found = true + break + } + } + assert.True(t, found, "base-devel should contain gcc or make") + }) + + t.Run("GroupInfo_NotFound", func(t *testing.T) { + _, err := g.GroupInfo(ctx, "xyznonexistentgroup999") + assert.Error(t, err) + }) + }) + + // --- Upgrade --- + t.Run("Upgrade", func(t *testing.T) { + err := mgr.Upgrade(ctx, snack.WithAssumeYes()) + require.NoError(t, err) }) } diff --git a/rpm/rpm_integration_test.go b/rpm/rpm_integration_test.go index 27e3c3e..3157f3a 100644 --- a/rpm/rpm_integration_test.go +++ b/rpm/rpm_integration_test.go @@ -13,28 +13,62 @@ import ( ) func TestIntegration_RPM(t *testing.T) { - var mgr snack.Manager = rpm.New() + mgr := rpm.New() if !mgr.Available() { t.Skip("rpm not available") } ctx := context.Background() + assert.Equal(t, "rpm", mgr.Name()) + + caps := snack.GetCapabilities(mgr) + assert.True(t, caps.FileOwnership, "rpm should support FileOwnership") + assert.True(t, caps.NameNormalize, "rpm should support NameNormalize") + assert.False(t, caps.VersionQuery) + assert.False(t, caps.Hold) + assert.False(t, caps.Clean) + assert.False(t, caps.RepoManagement) + assert.False(t, caps.KeyManagement) + assert.False(t, caps.Groups) + t.Run("List", func(t *testing.T) { pkgs, err := mgr.List(ctx) require.NoError(t, err) require.NotEmpty(t, pkgs) + + found := false + for _, p := range pkgs { + if p.Name == "bash" { + found = true + assert.NotEmpty(t, p.Version) + break + } + } + assert.True(t, found, "bash should be in list") }) - t.Run("IsInstalled", func(t *testing.T) { + t.Run("IsInstalled_True", func(t *testing.T) { installed, err := mgr.IsInstalled(ctx, "bash") require.NoError(t, err) assert.True(t, installed) }) + t.Run("IsInstalled_False", func(t *testing.T) { + installed, err := mgr.IsInstalled(ctx, "xyznonexistentpackage999") + require.NoError(t, err) + assert.False(t, installed) + }) + t.Run("Version", func(t *testing.T) { ver, err := mgr.Version(ctx, "bash") require.NoError(t, err) assert.NotEmpty(t, ver) + t.Logf("bash version: %s", ver) + }) + + t.Run("Version_NotInstalled", func(t *testing.T) { + _, err := mgr.Version(ctx, "xyznonexistentpackage999") + assert.Error(t, err) }) t.Run("Info", func(t *testing.T) { @@ -42,17 +76,92 @@ func TestIntegration_RPM(t *testing.T) { require.NoError(t, err) require.NotNil(t, pkg) assert.Equal(t, "bash", pkg.Name) + assert.NotEmpty(t, pkg.Version) + assert.NotEmpty(t, pkg.Description) }) - t.Run("FileOwner", func(t *testing.T) { - if fo, ok := mgr.(snack.FileOwner); ok { - owner, err := fo.Owner(ctx, "/bin/bash") - require.NoError(t, err) - assert.NotEmpty(t, owner) + t.Run("Info_NotFound", func(t *testing.T) { + _, err := mgr.Info(ctx, "xyznonexistentpackage999") + assert.Error(t, err) + }) + t.Run("Search", func(t *testing.T) { + pkgs, err := mgr.Search(ctx, "bash") + require.NoError(t, err) + require.NotEmpty(t, pkgs) + }) + + t.Run("Search_NoResults", func(t *testing.T) { + pkgs, err := mgr.Search(ctx, "xyznonexistentpackage999") + require.NoError(t, err) + assert.Empty(t, pkgs) + }) + + // --- FileOwner --- + t.Run("FileOwner", func(t *testing.T) { + fo, ok := mgr.(snack.FileOwner) + require.True(t, ok) + + t.Run("FileList", func(t *testing.T) { files, err := fo.FileList(ctx, "bash") require.NoError(t, err) - assert.NotEmpty(t, files) + require.NotEmpty(t, files) + }) + + t.Run("FileList_NotInstalled", func(t *testing.T) { + _, err := fo.FileList(ctx, "xyznonexistentpackage999") + assert.Error(t, err) + }) + + t.Run("Owner", func(t *testing.T) { + owner, err := fo.Owner(ctx, "/usr/bin/bash") + if err != nil { + // Try /bin/bash for older distros + owner, err = fo.Owner(ctx, "/bin/bash") + } + require.NoError(t, err) + assert.Contains(t, owner, "bash") + }) + + t.Run("Owner_NotFound", func(t *testing.T) { + _, err := fo.Owner(ctx, "/nonexistent/path/xyz") + assert.Error(t, err) + }) + }) + + // --- NameNormalizer --- + t.Run("NameNormalizer", func(t *testing.T) { + nn, ok := mgr.(snack.NameNormalizer) + require.True(t, ok) + + tests := []struct { + input, wantName, wantArch string + }{ + {"curl.x86_64", "curl", "x86_64"}, + {"bash.aarch64", "bash", "aarch64"}, + {"python3", "python3", ""}, + } + for _, tt := range tests { + t.Run("NormalizeName_"+tt.input, func(t *testing.T) { + got := nn.NormalizeName(tt.input) + assert.Equal(t, tt.wantName, got) + }) + t.Run("ParseArch_"+tt.input, func(t *testing.T) { + name, arch := nn.ParseArch(tt.input) + assert.Equal(t, tt.wantName, name) + assert.Equal(t, tt.wantArch, arch) + }) } }) + + // --- Operations rpm doesn't support --- + t.Run("Install_Unsupported", func(t *testing.T) { + err := mgr.Install(ctx, snack.Targets("tree")) + assert.Error(t, err) + }) + + t.Run("Update", func(t *testing.T) { + err := mgr.Update(ctx) + _ = err + }) } diff --git a/snack_test.go b/snack_test.go new file mode 100644 index 0000000..f5e9702 --- /dev/null +++ b/snack_test.go @@ -0,0 +1,117 @@ +package snack_test + +import ( + "testing" + + "github.com/gogrlx/snack" + "github.com/stretchr/testify/assert" +) + +func TestTargets(t *testing.T) { + tests := []struct { + names []string + want int + }{ + {nil, 0}, + {[]string{}, 0}, + {[]string{"curl"}, 1}, + {[]string{"curl", "wget", "tree"}, 3}, + } + for _, tt := range tests { + targets := snack.Targets(tt.names...) + assert.Len(t, targets, tt.want) + for i, tgt := range targets { + assert.Equal(t, tgt.Name, tt.names[i]) + assert.Empty(t, tgt.Version) + assert.Empty(t, tgt.FromRepo) + assert.Empty(t, tgt.Source) + } + } +} + +func TestTargetNames(t *testing.T) { + targets := []snack.Target{ + {Name: "curl", Version: "8.0"}, + {Name: "wget"}, + {Name: "tree", FromRepo: "main"}, + } + names := snack.TargetNames(targets) + assert.Equal(t, []string{"curl", "wget", "tree"}, names) +} + +func TestTargetNames_Empty(t *testing.T) { + names := snack.TargetNames(nil) + assert.Empty(t, names) +} + +func TestApplyOptions(t *testing.T) { + t.Run("defaults", func(t *testing.T) { + o := snack.ApplyOptions() + assert.False(t, o.Sudo) + assert.False(t, o.AssumeYes) + assert.False(t, o.DryRun) + assert.False(t, o.Verbose) + assert.False(t, o.Refresh) + assert.False(t, o.Reinstall) + assert.Empty(t, o.Root) + assert.Empty(t, o.FromRepo) + }) + + t.Run("all options", func(t *testing.T) { + o := snack.ApplyOptions( + snack.WithSudo(), + snack.WithAssumeYes(), + snack.WithDryRun(), + snack.WithVerbose(), + snack.WithRefresh(), + snack.WithReinstall(), + snack.WithRoot("/mnt"), + snack.WithFromRepo("testing"), + ) + assert.True(t, o.Sudo) + assert.True(t, o.AssumeYes) + assert.True(t, o.DryRun) + assert.True(t, o.Verbose) + assert.True(t, o.Refresh) + assert.True(t, o.Reinstall) + assert.Equal(t, "/mnt", o.Root) + assert.Equal(t, "testing", o.FromRepo) + }) +} + +func TestGetCapabilities_NilSafe(t *testing.T) { + // GetCapabilities should work on any Manager implementation + // We can't easily test with nil, but we can verify the struct fields + caps := snack.Capabilities{} + assert.False(t, caps.VersionQuery) + assert.False(t, caps.Hold) + assert.False(t, caps.Clean) + assert.False(t, caps.FileOwnership) + assert.False(t, caps.RepoManagement) + assert.False(t, caps.KeyManagement) + assert.False(t, caps.Groups) + assert.False(t, caps.NameNormalize) +} + +func TestErrors(t *testing.T) { + // Verify error sentinels are distinct + errs := []error{ + snack.ErrNotInstalled, + snack.ErrNotFound, + snack.ErrUnsupportedPlatform, + snack.ErrPermissionDenied, + snack.ErrAlreadyInstalled, + snack.ErrDependencyConflict, + snack.ErrManagerNotFound, + snack.ErrDaemonNotRunning, + } + for i, e1 := range errs { + assert.NotNil(t, e1) + assert.NotEmpty(t, e1.Error()) + for j, e2 := range errs { + if i != j { + assert.NotEqual(t, e1, e2, "%v should not equal %v", e1, e2) + } + } + } +} diff --git a/snap/snap_integration_test.go b/snap/snap_integration_test.go index 6d555f1..9ff537e 100644 --- a/snap/snap_integration_test.go +++ b/snap/snap_integration_test.go @@ -13,15 +13,26 @@ import ( ) func TestIntegration_Snap(t *testing.T) { - var mgr snack.Manager = snap.New() + mgr := snap.New() if !mgr.Available() { t.Skip("snap not available") } ctx := context.Background() + assert.Equal(t, "snap", mgr.Name()) + + caps := snack.GetCapabilities(mgr) + assert.True(t, caps.VersionQuery, "snap should support VersionQuery") + assert.False(t, caps.Hold) + assert.False(t, caps.Clean) + assert.False(t, caps.FileOwnership) + assert.False(t, caps.RepoManagement) + assert.False(t, caps.KeyManagement) + assert.False(t, caps.Groups) + assert.False(t, caps.NameNormalize) + t.Run("Update", func(t *testing.T) { - err := mgr.Update(ctx) - require.NoError(t, err) + require.NoError(t, mgr.Update(ctx)) }) t.Run("Search", func(t *testing.T) { @@ -30,51 +41,133 @@ func TestIntegration_Snap(t *testing.T) { require.NotEmpty(t, pkgs) }) + t.Run("Search_NoResults", func(t *testing.T) { + pkgs, err := mgr.Search(ctx, "xyznonexistentpackage999zzz") + require.NoError(t, err) + assert.Empty(t, pkgs) + }) + t.Run("Info", func(t *testing.T) { pkg, err := mgr.Info(ctx, "hello-world") require.NoError(t, err) require.NotNil(t, pkg) assert.Equal(t, "hello-world", pkg.Name) + assert.NotEmpty(t, pkg.Version) + }) + + t.Run("Info_NotFound", func(t *testing.T) { + _, err := mgr.Info(ctx, "xyznonexistentpackage999") + assert.Error(t, err) }) t.Run("Install", func(t *testing.T) { - err := mgr.Install(ctx, snack.Targets("hello-world"), snack.WithSudo(), snack.WithAssumeYes()) + _ = mgr.Remove(ctx, snack.Targets("hello-world"), snack.WithSudo()) + err := mgr.Install(ctx, snack.Targets("hello-world"), snack.WithSudo()) require.NoError(t, err) }) - t.Run("IsInstalled", func(t *testing.T) { + t.Run("IsInstalled_True", func(t *testing.T) { installed, err := mgr.IsInstalled(ctx, "hello-world") require.NoError(t, err) assert.True(t, installed) }) - t.Run("Version", func(t *testing.T) { + t.Run("IsInstalled_False", func(t *testing.T) { + installed, err := mgr.IsInstalled(ctx, "xyznonexistentpackage999") + require.NoError(t, err) + assert.False(t, installed) + }) + + t.Run("Version_Installed", func(t *testing.T) { ver, err := mgr.Version(ctx, "hello-world") require.NoError(t, err) assert.NotEmpty(t, ver) + t.Logf("hello-world version: %s", ver) }) - t.Run("List", func(t *testing.T) { + t.Run("Version_NotInstalled", func(t *testing.T) { + _, err := mgr.Version(ctx, "xyznonexistentpackage999") + assert.Error(t, err) + }) + + t.Run("List_ContainsInstalled", func(t *testing.T) { pkgs, err := mgr.List(ctx) require.NoError(t, err) found := false for _, p := range pkgs { if p.Name == "hello-world" { found = true + assert.NotEmpty(t, p.Version) break } } assert.True(t, found, "hello-world should be in installed list") }) - t.Run("Remove", func(t *testing.T) { - err := mgr.Remove(ctx, snack.Targets("hello-world"), snack.WithSudo(), snack.WithAssumeYes()) - require.NoError(t, err) + // --- VersionQuerier --- + t.Run("VersionQuerier", func(t *testing.T) { + vq, ok := mgr.(snack.VersionQuerier) + require.True(t, ok) + + t.Run("LatestVersion", func(t *testing.T) { + ver, err := vq.LatestVersion(ctx, "hello-world") + require.NoError(t, err) + assert.NotEmpty(t, ver) + t.Logf("hello-world latest: %s", ver) + }) + + t.Run("LatestVersion_NotFound", func(t *testing.T) { + _, err := vq.LatestVersion(ctx, "xyznonexistentpackage999") + assert.Error(t, err) + }) + + t.Run("ListUpgrades", func(t *testing.T) { + pkgs, err := vq.ListUpgrades(ctx) + require.NoError(t, err) + t.Logf("upgradable snaps: %d", len(pkgs)) + }) + + t.Run("UpgradeAvailable", func(t *testing.T) { + avail, err := vq.UpgradeAvailable(ctx, "hello-world") + require.NoError(t, err) + _ = avail + }) + + t.Run("VersionCmp", func(t *testing.T) { + tests := []struct { + v1, v2 string + want int + }{ + {"1.0", "2.0", -1}, + {"2.0", "1.0", 1}, + {"1.0", "1.0", 0}, + {"1.0.1", "1.0.0", 1}, + } + for _, tt := range tests { + cmp, err := vq.VersionCmp(ctx, tt.v1, tt.v2) + require.NoError(t, err, "VersionCmp(%s, %s)", tt.v1, tt.v2) + assert.Equal(t, tt.want, cmp, "VersionCmp(%s, %s)", tt.v1, tt.v2) + } + }) }) - t.Run("IsInstalled_After_Remove", func(t *testing.T) { + t.Run("Remove", func(t *testing.T) { + err := mgr.Remove(ctx, snack.Targets("hello-world"), snack.WithSudo()) + require.NoError(t, err) + installed, err := mgr.IsInstalled(ctx, "hello-world") require.NoError(t, err) assert.False(t, installed) }) + + t.Run("Purge", func(t *testing.T) { + _ = mgr.Install(ctx, snack.Targets("hello-world"), snack.WithSudo()) + err := mgr.Purge(ctx, snack.Targets("hello-world"), snack.WithSudo()) + require.NoError(t, err) + }) + + t.Run("Upgrade", func(t *testing.T) { + err := mgr.Upgrade(ctx, snack.WithSudo()) + require.NoError(t, err) + }) }