test: exhaustive unit tests for all provider-specific interfaces

Add 740 total tests (up from ~200) covering:
- Compile-time interface compliance for all providers
- GetCapabilities assertions for every provider
- Parse function edge cases: empty, malformed, single-entry, multi-entry
- apt: extract inline parse logic into testable functions (parsePolicyCandidate,
  parseUpgradeSimulation, parseHoldList, parseOwner, parseSourcesLine)
- dnf/rpm: edge cases for both dnf4 and dnf5 parsers, normalize/parseArch
- pacman/aur: parseUpgrades, parseGroupPkgSet, capabilities
- apk: parseUpgradeSimulation, parseListLine, SupportsDryRun
- flatpak/snap: semverCmp, stripNonNumeric edge cases
- pkg/ports: all parse functions with thorough edge cases

Every provider now has:
- Interface compliance checks (what it implements AND what it doesn't)
- Capabilities test via snack.GetCapabilities()
- Parse function unit tests with table-driven edge cases
This commit is contained in:
2026-03-05 23:56:00 +00:00
parent e38a787beb
commit c34b7a467c
17 changed files with 3737 additions and 296 deletions

View File

@@ -17,13 +17,23 @@ func TestSplitNameVersion(t *testing.T) {
{"libcurl-doc-8.5.0-r0", "libcurl-doc", "8.5.0-r0"}, {"libcurl-doc-8.5.0-r0", "libcurl-doc", "8.5.0-r0"},
{"go-1.21.5-r0", "go", "1.21.5-r0"}, {"go-1.21.5-r0", "go", "1.21.5-r0"},
{"noversion", "noversion", ""}, {"noversion", "noversion", ""},
// Edge cases
{"", "", ""},
{"a-1", "a", "1"},
{"my-pkg-name-0.1-r0", "my-pkg-name", "0.1-r0"},
{"a-b-c-3.0", "a-b-c", "3.0"},
{"single", "single", ""},
{"-1.0", "-1.0", ""}, // no digit follows last hyphen at position > 0
{"pkg-0", "pkg", "0"},
} }
for _, tt := range tests { for _, tt := range tests {
name, ver := splitNameVersion(tt.input) t.Run(tt.input, func(t *testing.T) {
if name != tt.name || ver != tt.version { name, ver := splitNameVersion(tt.input)
t.Errorf("splitNameVersion(%q) = (%q, %q), want (%q, %q)", if name != tt.name || ver != tt.version {
tt.input, name, ver, tt.name, tt.version) t.Errorf("splitNameVersion(%q) = (%q, %q), want (%q, %q)",
} tt.input, name, ver, tt.name, tt.version)
}
})
} }
} }
@@ -46,6 +56,111 @@ musl-1.2.4-r2 x86_64 {musl} (MIT) [installed]
} }
} }
func TestParseListInstalledEdgeCases(t *testing.T) {
t.Run("empty input", func(t *testing.T) {
pkgs := parseListInstalled("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages, got %d", len(pkgs))
}
})
t.Run("whitespace only", func(t *testing.T) {
pkgs := parseListInstalled(" \n \n ")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages, got %d", len(pkgs))
}
})
t.Run("single package", func(t *testing.T) {
pkgs := parseListInstalled("busybox-1.36.1-r5 x86_64 {busybox} (GPL-2.0-only) [installed]\n")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "busybox" || pkgs[0].Version != "1.36.1-r5" {
t.Errorf("unexpected package: %+v", pkgs[0])
}
})
t.Run("not installed", func(t *testing.T) {
pkgs := parseListInstalled("curl-8.5.0-r0 x86_64 {curl} (MIT)\n")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Installed {
t.Error("expected Installed=false")
}
})
}
func TestParseListLine(t *testing.T) {
tests := []struct {
name string
line string
wantName string
wantVer string
wantArch string
installed bool
}{
{
name: "full line",
line: "curl-8.5.0-r0 x86_64 {curl} (MIT) [installed]",
wantName: "curl",
wantVer: "8.5.0-r0",
wantArch: "x86_64",
installed: true,
},
{
name: "no installed marker",
line: "vim-9.0-r0 x86_64 {vim} (Vim)",
wantName: "vim",
wantVer: "9.0-r0",
wantArch: "x86_64",
installed: false,
},
{
name: "name only",
line: "curl-8.5.0-r0",
wantName: "curl",
wantVer: "8.5.0-r0",
wantArch: "",
installed: false,
},
{
name: "empty line",
line: "",
wantName: "",
wantVer: "",
wantArch: "",
installed: false,
},
{
name: "aarch64 arch",
line: "openssl-3.1.4-r0 aarch64 {openssl} (Apache-2.0) [installed]",
wantName: "openssl",
wantVer: "3.1.4-r0",
wantArch: "aarch64",
installed: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkg := parseListLine(tt.line)
if pkg.Name != tt.wantName {
t.Errorf("Name = %q, want %q", pkg.Name, tt.wantName)
}
if pkg.Version != tt.wantVer {
t.Errorf("Version = %q, want %q", pkg.Version, tt.wantVer)
}
if pkg.Arch != tt.wantArch {
t.Errorf("Arch = %q, want %q", pkg.Arch, tt.wantArch)
}
if pkg.Installed != tt.installed {
t.Errorf("Installed = %v, want %v", pkg.Installed, tt.installed)
}
})
}
}
func TestParseSearch(t *testing.T) { func TestParseSearch(t *testing.T) {
// verbose output // verbose output
output := `curl-8.5.0-r0 - URL retrieval utility and library output := `curl-8.5.0-r0 - URL retrieval utility and library
@@ -76,6 +191,51 @@ curl-doc-8.5.0-r0
} }
} }
func TestParseSearchEdgeCases(t *testing.T) {
t.Run("empty input", func(t *testing.T) {
pkgs := parseSearch("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages, got %d", len(pkgs))
}
})
t.Run("single result verbose", func(t *testing.T) {
pkgs := parseSearch("nginx-1.24.0-r0 - HTTP and reverse proxy server\n")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "nginx" {
t.Errorf("expected nginx, got %q", pkgs[0].Name)
}
if pkgs[0].Description != "HTTP and reverse proxy server" {
t.Errorf("unexpected description: %q", pkgs[0].Description)
}
})
t.Run("single result plain", func(t *testing.T) {
pkgs := parseSearch("nginx-1.24.0-r0\n")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "nginx" || pkgs[0].Version != "1.24.0-r0" {
t.Errorf("unexpected package: %+v", pkgs[0])
}
if pkgs[0].Description != "" {
t.Errorf("expected empty description, got %q", pkgs[0].Description)
}
})
t.Run("description with hyphens", func(t *testing.T) {
pkgs := parseSearch("git-2.43.0-r0 - Distributed version control system - fast\n")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Description != "Distributed version control system - fast" {
t.Errorf("unexpected description: %q", pkgs[0].Description)
}
})
}
func TestParseInfo(t *testing.T) { func TestParseInfo(t *testing.T) {
output := `curl-8.5.0-r0 installed size: output := `curl-8.5.0-r0 installed size:
description: URL retrieval utility and library description: URL retrieval utility and library
@@ -94,11 +254,72 @@ webpage: https://curl.se/
} }
} }
func TestParseInfoEdgeCases(t *testing.T) {
t.Run("empty input", func(t *testing.T) {
pkg := parseInfo("")
if pkg == nil {
t.Fatal("expected non-nil (parseInfo always returns a pkg)")
}
})
t.Run("no description", func(t *testing.T) {
pkg := parseInfo("arch: aarch64\n")
if pkg == nil {
t.Fatal("expected non-nil")
}
if pkg.Arch != "aarch64" {
t.Errorf("expected aarch64, got %q", pkg.Arch)
}
if pkg.Description != "" {
t.Errorf("expected empty description, got %q", pkg.Description)
}
})
t.Run("multiple colons in value", func(t *testing.T) {
pkg := parseInfo("description: A tool: does things: really well\n")
if pkg == nil {
t.Fatal("expected non-nil")
}
// Note: strings.Cut splits on first colon only
if pkg.Description != "A tool: does things: really well" {
t.Errorf("unexpected description: %q", pkg.Description)
}
})
}
func TestParseInfoNameVersion(t *testing.T) { func TestParseInfoNameVersion(t *testing.T) {
output := "curl-8.5.0-r0 description:\nsome stuff" tests := []struct {
name, ver := parseInfoNameVersion(output) name string
if name != "curl" || ver != "8.5.0-r0" { input string
t.Errorf("got (%q, %q), want (curl, 8.5.0-r0)", name, ver) wantN string
wantV string
}{
{
name: "standard",
input: "curl-8.5.0-r0 description:\nsome stuff",
wantN: "curl",
wantV: "8.5.0-r0",
},
{
name: "single line no version",
input: "noversion",
wantN: "noversion",
wantV: "",
},
{
name: "multi-hyphen name",
input: "lib-ssl-dev-3.0.0-r0 some text",
wantN: "lib-ssl-dev",
wantV: "3.0.0-r0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
name, ver := parseInfoNameVersion(tt.input)
if name != tt.wantN || ver != tt.wantV {
t.Errorf("got (%q, %q), want (%q, %q)", name, ver, tt.wantN, tt.wantV)
}
})
} }
} }
@@ -112,3 +333,148 @@ func TestName(t *testing.T) {
t.Errorf("expected apk, got %q", a.Name()) t.Errorf("expected apk, got %q", a.Name())
} }
} }
// Compile-time interface compliance checks
var (
_ snack.VersionQuerier = (*Apk)(nil)
_ snack.Cleaner = (*Apk)(nil)
_ snack.FileOwner = (*Apk)(nil)
_ snack.DryRunner = (*Apk)(nil)
_ snack.PackageUpgrader = (*Apk)(nil)
)
func TestInterfaceCompliance(t *testing.T) {
// Verify at test time as well
var m snack.Manager = New()
if _, ok := m.(snack.VersionQuerier); !ok {
t.Error("Apk should implement VersionQuerier")
}
if _, ok := m.(snack.Cleaner); !ok {
t.Error("Apk should implement Cleaner")
}
if _, ok := m.(snack.FileOwner); !ok {
t.Error("Apk should implement FileOwner")
}
if _, ok := m.(snack.DryRunner); !ok {
t.Error("Apk should implement DryRunner")
}
if _, ok := m.(snack.PackageUpgrader); !ok {
t.Error("Apk should implement PackageUpgrader")
}
}
func TestCapabilities(t *testing.T) {
caps := snack.GetCapabilities(New())
if !caps.VersionQuery {
t.Error("expected VersionQuery=true")
}
if !caps.Clean {
t.Error("expected Clean=true")
}
if !caps.FileOwnership {
t.Error("expected FileOwnership=true")
}
if !caps.DryRun {
t.Error("expected DryRun=true")
}
// Should be false
if caps.Hold {
t.Error("expected Hold=false")
}
if caps.RepoManagement {
t.Error("expected RepoManagement=false")
}
if caps.KeyManagement {
t.Error("expected KeyManagement=false")
}
if caps.Groups {
t.Error("expected Groups=false")
}
if caps.NameNormalize {
t.Error("expected NameNormalize=false")
}
}
func TestSupportsDryRun(t *testing.T) {
a := New()
if !a.SupportsDryRun() {
t.Error("SupportsDryRun() should return true")
}
}
func TestParseUpgradeSimulation(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
wantPkgs []snack.Package
}{
{
name: "empty",
input: "",
wantLen: 0,
},
{
name: "OK only",
input: "OK: 123 MiB in 45 packages\n",
wantLen: 0,
},
{
name: "single upgrade",
input: "(1/1) Upgrading curl (8.5.0-r0 -> 8.6.0-r0)\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "curl", Version: "8.6.0-r0", Installed: true},
},
},
{
name: "multiple upgrades",
input: `(1/3) Upgrading musl (1.2.4-r2 -> 1.2.5-r0)
(2/3) Upgrading openssl (3.1.4-r0 -> 3.2.0-r0)
(3/3) Upgrading curl (8.5.0-r0 -> 8.6.0-r0)
OK: 123 MiB in 45 packages
`,
wantLen: 3,
wantPkgs: []snack.Package{
{Name: "musl", Version: "1.2.5-r0", Installed: true},
{Name: "openssl", Version: "3.2.0-r0", Installed: true},
{Name: "curl", Version: "8.6.0-r0", Installed: true},
},
},
{
name: "non-upgrade lines only",
input: "Purging old package\nInstalling new-pkg\n",
wantLen: 0,
},
{
name: "upgrade without version parens",
input: "(1/1) Upgrading busybox\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "busybox", Version: "", Installed: true},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgs := parseUpgradeSimulation(tt.input)
if len(pkgs) != tt.wantLen {
t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs))
}
for i, want := range tt.wantPkgs {
if i >= len(pkgs) {
break
}
if pkgs[i].Name != want.Name {
t.Errorf("pkg[%d].Name = %q, want %q", i, pkgs[i].Name, want.Name)
}
if pkgs[i].Version != want.Version {
t.Errorf("pkg[%d].Version = %q, want %q", i, pkgs[i].Version, want.Version)
}
if pkgs[i].Installed != want.Installed {
t.Errorf("pkg[%d].Installed = %v, want %v", i, pkgs[i].Installed, want.Installed)
}
}
})
}
}

View File

@@ -67,5 +67,49 @@ func TestNew(t *testing.T) {
} }
} }
// Verify Apt implements snack.Manager at compile time. func TestSupportsDryRun(t *testing.T) {
var _ snack.Manager = (*Apt)(nil) a := New()
if !a.SupportsDryRun() {
t.Error("expected SupportsDryRun() = true")
}
}
func TestCapabilities(t *testing.T) {
caps := snack.GetCapabilities(New())
checks := []struct {
name string
got bool
want bool
}{
{"VersionQuery", caps.VersionQuery, true},
{"Hold", caps.Hold, true},
{"Clean", caps.Clean, true},
{"FileOwnership", caps.FileOwnership, true},
{"RepoManagement", caps.RepoManagement, true},
{"KeyManagement", caps.KeyManagement, true},
{"Groups", caps.Groups, false},
{"NameNormalize", caps.NameNormalize, true},
{"DryRun", caps.DryRun, true},
}
for _, c := range checks {
t.Run(c.name, func(t *testing.T) {
if c.got != c.want {
t.Errorf("Capabilities.%s = %v, want %v", c.name, c.got, c.want)
}
})
}
}
// Compile-time interface checks.
var (
_ snack.Manager = (*Apt)(nil)
_ snack.VersionQuerier = (*Apt)(nil)
_ snack.Holder = (*Apt)(nil)
_ snack.Cleaner = (*Apt)(nil)
_ snack.FileOwner = (*Apt)(nil)
_ snack.RepoManager = (*Apt)(nil)
_ snack.KeyManager = (*Apt)(nil)
_ snack.NameNormalizer = (*Apt)(nil)
_ snack.DryRunner = (*Apt)(nil)
_ snack.PackageUpgrader = (*Apt)(nil)
)

View File

@@ -23,17 +23,11 @@ func latestVersion(ctx context.Context, pkg string) (string, error) {
if err != nil { if err != nil {
return "", fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotFound) return "", fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotFound)
} }
for _, line := range strings.Split(string(out), "\n") { candidate := parsePolicyCandidate(string(out))
line = strings.TrimSpace(line) if candidate == "" {
if strings.HasPrefix(line, "Candidate:") { return "", fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotFound)
candidate := strings.TrimSpace(strings.TrimPrefix(line, "Candidate:"))
if candidate == "(none)" {
return "", fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotFound)
}
return candidate, nil
}
} }
return "", fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotFound) return candidate, nil
} }
func listUpgrades(ctx context.Context) ([]snack.Package, error) { func listUpgrades(ctx context.Context) ([]snack.Package, error) {
@@ -45,38 +39,7 @@ func listUpgrades(ctx context.Context) ([]snack.Package, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("apt-get --just-print upgrade: %w", err) return nil, fmt.Errorf("apt-get --just-print upgrade: %w", err)
} }
var pkgs []snack.Package return parseUpgradeSimulation(string(out)), nil
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
// Lines starting with "Inst " indicate upgradable packages.
// Format: "Inst pkg [old-ver] (new-ver repo [arch])"
if !strings.HasPrefix(line, "Inst ") {
continue
}
line = strings.TrimPrefix(line, "Inst ")
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
name := fields[0]
// Find the new version in parentheses
parenStart := strings.Index(line, "(")
parenEnd := strings.Index(line, ")")
if parenStart < 0 || parenEnd < 0 {
continue
}
verFields := strings.Fields(line[parenStart+1 : parenEnd])
if len(verFields) < 1 {
continue
}
p := snack.Package{
Name: name,
Version: verFields[0],
Installed: true,
}
pkgs = append(pkgs, p)
}
return pkgs, nil
} }
func upgradeAvailable(ctx context.Context, pkg string) (bool, error) { func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
@@ -85,19 +48,12 @@ func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
if err != nil { if err != nil {
return false, fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotFound) return false, fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotFound)
} }
var installed, candidate string installed := parsePolicyInstalled(string(out))
for _, line := range strings.Split(string(out), "\n") { candidate := parsePolicyCandidate(string(out))
line = strings.TrimSpace(line) if installed == "" {
if strings.HasPrefix(line, "Installed:") {
installed = strings.TrimSpace(strings.TrimPrefix(line, "Installed:"))
} else if strings.HasPrefix(line, "Candidate:") {
candidate = strings.TrimSpace(strings.TrimPrefix(line, "Candidate:"))
}
}
if installed == "(none)" || installed == "" {
return false, fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotInstalled) return false, fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotInstalled)
} }
if candidate == "(none)" || candidate == "" || candidate == installed { if candidate == "" || candidate == installed {
return false, nil return false, nil
} }
return true, nil return true, nil
@@ -148,15 +104,7 @@ func listHeld(ctx context.Context) ([]snack.Package, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("apt-mark showhold: %w", err) return nil, fmt.Errorf("apt-mark showhold: %w", err)
} }
var pkgs []snack.Package return parseHoldList(string(out)), nil
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
pkgs = append(pkgs, snack.Package{Name: line, Installed: true})
}
return pkgs, nil
} }
func isHeld(ctx context.Context, pkg string) (bool, error) { func isHeld(ctx context.Context, pkg string) (bool, error) {
@@ -198,14 +146,7 @@ func fileList(ctx context.Context, pkg string) ([]string, error) {
} }
return nil, fmt.Errorf("dpkg-query -L %s: %w: %s", pkg, err, errMsg) return nil, fmt.Errorf("dpkg-query -L %s: %w: %s", pkg, err, errMsg)
} }
var files []string return parseFileList(string(out)), nil
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
line = strings.TrimSpace(line)
if line != "" {
files = append(files, line)
}
}
return files, nil
} }
func owner(ctx context.Context, path string) (string, error) { func owner(ctx context.Context, path string) (string, error) {
@@ -216,18 +157,11 @@ func owner(ctx context.Context, path string) (string, error) {
if err != nil { if err != nil {
return "", fmt.Errorf("dpkg -S %s: %w", path, snack.ErrNotFound) return "", fmt.Errorf("dpkg -S %s: %w", path, snack.ErrNotFound)
} }
// Output format: "package: /path/to/file" or "package1, package2: /path" pkg := parseOwner(string(out))
line := strings.TrimSpace(strings.Split(string(out), "\n")[0]) if pkg == "" {
colonIdx := strings.Index(line, ":")
if colonIdx < 0 {
return "", fmt.Errorf("dpkg -S %s: unexpected output", path) return "", fmt.Errorf("dpkg -S %s: unexpected output", path)
} }
// Return first package if multiple return pkg, nil
pkgPart := line[:colonIdx]
if commaIdx := strings.Index(pkgPart, ","); commaIdx >= 0 {
pkgPart = strings.TrimSpace(pkgPart[:commaIdx])
}
return strings.TrimSpace(pkgPart), nil
} }
// --- RepoManager --- // --- RepoManager ---
@@ -249,51 +183,14 @@ func listRepos(_ context.Context) ([]snack.Repository, error) {
} }
scanner := bufio.NewScanner(bytes.NewReader(data)) scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() { for scanner.Scan() {
line := strings.TrimSpace(scanner.Text()) if r := parseSourcesLine(scanner.Text()); r != nil {
if line == "" || strings.HasPrefix(line, "#") { repos = append(repos, *r)
continue
}
enabled := true
// deb822 format (.sources files) not fully parsed; treat as single entry
if strings.HasPrefix(line, "deb ") || strings.HasPrefix(line, "deb-src ") {
repos = append(repos, snack.Repository{
ID: line,
URL: extractURL(line),
Enabled: enabled,
Type: strings.Fields(line)[0],
})
} }
} }
} }
return repos, nil return repos, nil
} }
// extractURL pulls the URL from a deb/deb-src line.
func extractURL(line string) string {
fields := strings.Fields(line)
inOptions := false
for i, f := range fields {
if i == 0 {
continue // skip deb/deb-src
}
if inOptions {
if strings.HasSuffix(f, "]") {
inOptions = false
}
continue
}
if strings.HasPrefix(f, "[") {
if strings.HasSuffix(f, "]") {
// Single-token options like [arch=amd64]
continue
}
inOptions = true
continue
}
return f
}
return ""
}
func addRepo(ctx context.Context, repo snack.Repository) error { func addRepo(ctx context.Context, repo snack.Repository) error {
repoLine := repo.URL repoLine := repo.URL

View File

@@ -150,50 +150,3 @@ func TestBuildArgs(t *testing.T) {
}) })
} }
} }
func TestExtractURL(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "basic_deb",
input: "deb http://archive.ubuntu.com/ubuntu/ jammy main",
want: "http://archive.ubuntu.com/ubuntu/",
},
{
name: "deb_src",
input: "deb-src http://archive.ubuntu.com/ubuntu/ jammy main",
want: "http://archive.ubuntu.com/ubuntu/",
},
{
name: "with_options",
input: "deb [arch=amd64] https://apt.example.com/repo stable main",
want: "https://apt.example.com/repo",
},
{
name: "with_signed_by",
input: "deb [arch=amd64 signed-by=/etc/apt/keyrings/key.gpg] https://repo.example.com/deb stable main",
want: "https://repo.example.com/deb",
},
{
name: "just_type",
input: "deb",
want: "",
},
{
name: "empty_options_bracket",
input: "deb [] http://example.com/repo stable",
want: "http://example.com/repo",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractURL(tt.input)
if got != tt.want {
t.Errorf("extractURL(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}

View File

@@ -51,6 +51,158 @@ func parseSearch(output string) []snack.Package {
return pkgs return pkgs
} }
// parsePolicyCandidate extracts the Candidate version from apt-cache policy output.
// Returns empty string if no candidate is found or candidate is "(none)".
func parsePolicyCandidate(output string) string {
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Candidate:") {
candidate := strings.TrimSpace(strings.TrimPrefix(line, "Candidate:"))
if candidate == "(none)" {
return ""
}
return candidate
}
}
return ""
}
// parsePolicyInstalled extracts the Installed version from apt-cache policy output.
// Returns empty string if not installed or "(none)".
func parsePolicyInstalled(output string) string {
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Installed:") {
installed := strings.TrimSpace(strings.TrimPrefix(line, "Installed:"))
if installed == "(none)" {
return ""
}
return installed
}
}
return ""
}
// parseUpgradeSimulation parses apt-get --just-print upgrade output.
// Lines starting with "Inst " indicate upgradable packages.
// Format: "Inst pkg [old-ver] (new-ver repo [arch])"
func parseUpgradeSimulation(output string) []snack.Package {
var pkgs []snack.Package
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "Inst ") {
continue
}
line = strings.TrimPrefix(line, "Inst ")
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
name := fields[0]
parenStart := strings.Index(line, "(")
parenEnd := strings.Index(line, ")")
if parenStart < 0 || parenEnd < 0 {
continue
}
verFields := strings.Fields(line[parenStart+1 : parenEnd])
if len(verFields) < 1 {
continue
}
pkgs = append(pkgs, snack.Package{
Name: name,
Version: verFields[0],
Installed: true,
})
}
return pkgs
}
// parseHoldList parses apt-mark showhold output (one package name per line).
func parseHoldList(output string) []snack.Package {
var pkgs []snack.Package
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
pkgs = append(pkgs, snack.Package{Name: line, Installed: true})
}
return pkgs
}
// parseFileList parses dpkg-query -L output (one file path per line).
func parseFileList(output string) []string {
var files []string
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
line = strings.TrimSpace(line)
if line != "" {
files = append(files, line)
}
}
return files
}
// parseOwner parses dpkg -S output to extract the owning package name.
// Output format: "package: /path/to/file" or "pkg1, pkg2: /path".
// Returns the first package name.
func parseOwner(output string) string {
line := strings.TrimSpace(strings.Split(output, "\n")[0])
colonIdx := strings.Index(line, ":")
if colonIdx < 0 {
return ""
}
pkgPart := line[:colonIdx]
if commaIdx := strings.Index(pkgPart, ","); commaIdx >= 0 {
pkgPart = strings.TrimSpace(pkgPart[:commaIdx])
}
return strings.TrimSpace(pkgPart)
}
// parseSourcesLine parses a single deb/deb-src line from sources.list.
// Returns a Repository if the line is valid, or nil if it's a comment/blank.
func parseSourcesLine(line string) *snack.Repository {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
return nil
}
if !strings.HasPrefix(line, "deb ") && !strings.HasPrefix(line, "deb-src ") {
return nil
}
return &snack.Repository{
ID: line,
URL: extractURL(line),
Enabled: true,
Type: strings.Fields(line)[0],
}
}
// extractURL pulls the URL from a deb/deb-src line.
func extractURL(line string) string {
fields := strings.Fields(line)
inOptions := false
for i, f := range fields {
if i == 0 {
continue // skip deb/deb-src
}
if inOptions {
if strings.HasSuffix(f, "]") {
inOptions = false
}
continue
}
if strings.HasPrefix(f, "[") {
if strings.HasSuffix(f, "]") {
// Single-token options like [arch=amd64]
continue
}
inOptions = true
continue
}
return f
}
return ""
}
// parseInfo parses apt-cache show output into a Package. // parseInfo parses apt-cache show output into a Package.
func parseInfo(output string) (*snack.Package, error) { func parseInfo(output string) (*snack.Package, error) {
p := &snack.Package{} p := &snack.Package{}

View File

@@ -194,3 +194,445 @@ func TestParseInfo_EdgeCases(t *testing.T) {
} }
}) })
} }
// --- New parse function tests ---
func TestParsePolicyCandidate(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "normal_policy_output",
input: `bash:
Installed: 5.2-1
Candidate: 5.2-2
Version table:
5.2-2 500
500 http://archive.ubuntu.com/ubuntu jammy/main amd64 Packages
*** 5.2-1 100
100 /var/lib/dpkg/status`,
want: "5.2-2",
},
{
name: "candidate_none",
input: `virtual-pkg:
Installed: (none)
Candidate: (none)
Version table:`,
want: "",
},
{
name: "empty_input",
input: "",
want: "",
},
{
name: "installed_equals_candidate",
input: `curl:
Installed: 7.88.1-10+deb12u4
Candidate: 7.88.1-10+deb12u4
Version table:
*** 7.88.1-10+deb12u4 500`,
want: "7.88.1-10+deb12u4",
},
{
name: "epoch_version",
input: `systemd:
Installed: 1:252-2
Candidate: 1:252-3
Version table:`,
want: "1:252-3",
},
{
name: "no_candidate_line",
input: "bash:\n Installed: 5.2-1\n",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parsePolicyCandidate(tt.input)
if got != tt.want {
t.Errorf("parsePolicyCandidate() = %q, want %q", got, tt.want)
}
})
}
}
func TestParsePolicyInstalled(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "normal",
input: `bash:
Installed: 5.2-1
Candidate: 5.2-2`,
want: "5.2-1",
},
{
name: "not_installed",
input: `foo:
Installed: (none)
Candidate: 1.0`,
want: "",
},
{
name: "empty",
input: "",
want: "",
},
{
name: "epoch_version",
input: `systemd:
Installed: 1:252-2
Candidate: 1:252-3`,
want: "1:252-2",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parsePolicyInstalled(tt.input)
if got != tt.want {
t.Errorf("parsePolicyInstalled() = %q, want %q", got, tt.want)
}
})
}
}
func TestParseUpgradeSimulation(t *testing.T) {
tests := []struct {
name string
input string
want []snack.Package
}{
{
name: "empty",
input: "",
want: nil,
},
{
name: "single_upgrade",
input: `Reading package lists...
Building dependency tree...
Reading state information...
The following packages will be upgraded:
bash
1 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.
Inst bash [5.2-1] (5.2-2 Ubuntu:22.04/jammy [amd64])
Conf bash (5.2-2 Ubuntu:22.04/jammy [amd64])`,
want: []snack.Package{
{Name: "bash", Version: "5.2-2", Installed: true},
},
},
{
name: "multiple_upgrades",
input: `Inst bash [5.2-1] (5.2-2 Ubuntu:22.04/jammy [amd64])
Inst curl [7.88.0] (7.88.1 Ubuntu:22.04/jammy [amd64])
Inst systemd [1:252-1] (1:252-2 Ubuntu:22.04/jammy [amd64])
Conf bash (5.2-2 Ubuntu:22.04/jammy [amd64])
Conf curl (7.88.1 Ubuntu:22.04/jammy [amd64])
Conf systemd (1:252-2 Ubuntu:22.04/jammy [amd64])`,
want: []snack.Package{
{Name: "bash", Version: "5.2-2", Installed: true},
{Name: "curl", Version: "7.88.1", Installed: true},
{Name: "systemd", Version: "1:252-2", Installed: true},
},
},
{
name: "no_inst_lines",
input: "Reading package lists...\nBuilding dependency tree...\n0 upgraded, 0 newly installed.\n",
want: nil,
},
{
name: "inst_without_parens",
input: "Inst bash no-parens-here\n",
want: nil,
},
{
name: "inst_with_empty_parens",
input: "Inst bash [5.2-1] ()\n",
want: nil,
},
{
name: "conf_lines_ignored",
input: "Conf bash (5.2-2 Ubuntu:22.04/jammy [amd64])\n",
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseUpgradeSimulation(tt.input)
if len(got) != len(tt.want) {
t.Fatalf("parseUpgradeSimulation() returned %d packages, want %d", len(got), len(tt.want))
}
for i, g := range got {
w := tt.want[i]
if g.Name != w.Name || g.Version != w.Version || g.Installed != w.Installed {
t.Errorf("package[%d] = %+v, want %+v", i, g, w)
}
}
})
}
}
func TestParseHoldList(t *testing.T) {
tests := []struct {
name string
input string
want []string
}{
{"empty", "", nil},
{"whitespace_only", " \n \n", nil},
{"single_package", "bash\n", []string{"bash"}},
{"multiple_packages", "bash\ncurl\nnginx\n", []string{"bash", "curl", "nginx"}},
{"blank_lines_mixed", "\nbash\n\ncurl\n\n", []string{"bash", "curl"}},
{"trailing_whitespace", " bash \n curl \n", []string{"bash", "curl"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgs := parseHoldList(tt.input)
var names []string
for _, p := range pkgs {
names = append(names, p.Name)
if !p.Installed {
t.Errorf("expected Installed=true for %q", p.Name)
}
}
if len(names) != len(tt.want) {
t.Fatalf("got %d packages, want %d", len(names), len(tt.want))
}
for i, n := range names {
if n != tt.want[i] {
t.Errorf("package[%d] = %q, want %q", i, n, tt.want[i])
}
}
})
}
}
func TestParseFileList(t *testing.T) {
tests := []struct {
name string
input string
want []string
}{
{"empty", "", nil},
{"whitespace_only", " \n\n ", nil},
{
name: "single_file",
input: "/usr/bin/bash\n",
want: []string{"/usr/bin/bash"},
},
{
name: "multiple_files",
input: "/.\n/usr\n/usr/bin\n/usr/bin/bash\n/usr/share/man/man1/bash.1.gz\n",
want: []string{"/.", "/usr", "/usr/bin", "/usr/bin/bash", "/usr/share/man/man1/bash.1.gz"},
},
{
name: "blank_lines_mixed",
input: "\n/usr/bin/curl\n\n/usr/share/doc/curl\n\n",
want: []string{"/usr/bin/curl", "/usr/share/doc/curl"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseFileList(tt.input)
if len(got) != len(tt.want) {
t.Fatalf("parseFileList() returned %d files, want %d", len(got), len(tt.want))
}
for i, f := range got {
if f != tt.want[i] {
t.Errorf("file[%d] = %q, want %q", i, f, tt.want[i])
}
}
})
}
}
func TestParseOwner(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "single_package",
input: "bash: /usr/bin/bash\n",
want: "bash",
},
{
name: "multiple_packages",
input: "bash, dash: /usr/bin/sh\n",
want: "bash",
},
{
name: "package_with_arch",
input: "libc6:amd64: /lib/x86_64-linux-gnu/libc.so.6\n",
want: "libc6", // parseOwner splits on first colon, arch suffix is stripped
},
{
name: "multiple_lines",
input: "coreutils: /usr/bin/ls\ncoreutils: /usr/bin/cat\n",
want: "coreutils",
},
{
name: "no_colon",
input: "unexpected output without colon",
want: "",
},
{
name: "empty",
input: "",
want: "",
},
{
name: "whitespace_around_package",
input: " nginx : /usr/sbin/nginx\n",
want: "nginx",
},
{
name: "three_packages_comma_separated",
input: "pkg1, pkg2, pkg3: /some/path\n",
want: "pkg1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseOwner(tt.input)
if got != tt.want {
t.Errorf("parseOwner() = %q, want %q", got, tt.want)
}
})
}
}
func TestParseSourcesLine(t *testing.T) {
tests := []struct {
name string
input string
wantNil bool
wantURL string
wantTyp string
}{
{
name: "empty",
input: "",
wantNil: true,
},
{
name: "comment",
input: "# This is a comment",
wantNil: true,
},
{
name: "non_deb_line",
input: "some random text",
wantNil: true,
},
{
name: "basic_deb",
input: "deb http://archive.ubuntu.com/ubuntu/ jammy main",
wantURL: "http://archive.ubuntu.com/ubuntu/",
wantTyp: "deb",
},
{
name: "deb_src",
input: "deb-src http://archive.ubuntu.com/ubuntu/ jammy main",
wantURL: "http://archive.ubuntu.com/ubuntu/",
wantTyp: "deb-src",
},
{
name: "with_options",
input: "deb [arch=amd64] https://repo.example.com/deb stable main",
wantURL: "https://repo.example.com/deb",
wantTyp: "deb",
},
{
name: "with_signed_by",
input: "deb [arch=amd64 signed-by=/etc/apt/keyrings/key.gpg] https://repo.example.com stable main",
wantURL: "https://repo.example.com",
wantTyp: "deb",
},
{
name: "leading_whitespace",
input: " deb http://example.com/repo stable main",
wantURL: "http://example.com/repo",
wantTyp: "deb",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := parseSourcesLine(tt.input)
if tt.wantNil {
if r != nil {
t.Errorf("parseSourcesLine() = %+v, want nil", r)
}
return
}
if r == nil {
t.Fatal("parseSourcesLine() = nil, want non-nil")
}
if r.URL != tt.wantURL {
t.Errorf("URL = %q, want %q", r.URL, tt.wantURL)
}
if r.Type != tt.wantTyp {
t.Errorf("Type = %q, want %q", r.Type, tt.wantTyp)
}
if !r.Enabled {
t.Error("expected Enabled=true")
}
})
}
}
func TestExtractURL(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "basic_deb",
input: "deb http://archive.ubuntu.com/ubuntu/ jammy main",
want: "http://archive.ubuntu.com/ubuntu/",
},
{
name: "deb_src",
input: "deb-src http://archive.ubuntu.com/ubuntu/ jammy main",
want: "http://archive.ubuntu.com/ubuntu/",
},
{
name: "with_options",
input: "deb [arch=amd64] https://apt.example.com/repo stable main",
want: "https://apt.example.com/repo",
},
{
name: "with_signed_by",
input: "deb [arch=amd64 signed-by=/etc/apt/keyrings/key.gpg] https://repo.example.com/deb stable main",
want: "https://repo.example.com/deb",
},
{
name: "just_type",
input: "deb",
want: "",
},
{
name: "empty_options_bracket",
input: "deb [] http://example.com/repo stable",
want: "http://example.com/repo",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractURL(tt.input)
if got != tt.want {
t.Errorf("extractURL(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}

View File

@@ -3,7 +3,9 @@ package aur
import ( import (
"testing" "testing"
"github.com/gogrlx/snack"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestParsePackageList(t *testing.T) { func TestParsePackageList(t *testing.T) {
@@ -63,3 +65,141 @@ func TestNewWithOptions(t *testing.T) {
assert.Equal(t, "/tmp/aur-builds", a.BuildDir) assert.Equal(t, "/tmp/aur-builds", a.BuildDir)
assert.Equal(t, []string{"--skippgpcheck", "--nocheck"}, a.MakepkgFlags) assert.Equal(t, []string{"--skippgpcheck", "--nocheck"}, a.MakepkgFlags)
} }
func TestInterfaceCompliance(t *testing.T) {
var _ snack.Manager = (*AUR)(nil)
var _ snack.VersionQuerier = (*AUR)(nil)
var _ snack.Cleaner = (*AUR)(nil)
var _ snack.PackageUpgrader = (*AUR)(nil)
}
func TestInterfaceNonCompliance(t *testing.T) {
a := New()
var m snack.Manager = a
if _, ok := m.(snack.FileOwner); ok {
t.Error("AUR should not implement FileOwner")
}
if _, ok := m.(snack.Holder); ok {
t.Error("AUR should not implement Holder")
}
if _, ok := m.(snack.RepoManager); ok {
t.Error("AUR should not implement RepoManager")
}
if _, ok := m.(snack.KeyManager); ok {
t.Error("AUR should not implement KeyManager")
}
if _, ok := m.(snack.Grouper); ok {
t.Error("AUR should not implement Grouper")
}
if _, ok := m.(snack.NameNormalizer); ok {
t.Error("AUR should not implement NameNormalizer")
}
if _, ok := m.(snack.DryRunner); ok {
t.Error("AUR should not implement DryRunner")
}
}
func TestCapabilities(t *testing.T) {
caps := snack.GetCapabilities(New())
tests := []struct {
name string
got bool
want bool
}{
{"VersionQuery", caps.VersionQuery, true},
{"Clean", caps.Clean, true},
{"FileOwnership", caps.FileOwnership, false},
{"Hold", caps.Hold, false},
{"RepoManagement", caps.RepoManagement, false},
{"KeyManagement", caps.KeyManagement, false},
{"Groups", caps.Groups, false},
{"NameNormalize", caps.NameNormalize, false},
{"DryRun", caps.DryRun, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.got != tt.want {
t.Errorf("%s = %v, want %v", tt.name, tt.got, tt.want)
}
})
}
}
func TestName(t *testing.T) {
a := New()
assert.Equal(t, "aur", a.Name())
}
func TestParsePackageList_EdgeCases(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
wantNames []string
wantVers []string
}{
{
name: "empty string",
input: "",
wantLen: 0,
},
{
name: "whitespace only",
input: " \n\t\n \n",
wantLen: 0,
},
{
name: "single package",
input: "yay 12.5.7-1\n",
wantLen: 1,
wantNames: []string{"yay"},
wantVers: []string{"12.5.7-1"},
},
{
name: "malformed single field",
input: "orphan\n",
wantLen: 0,
},
{
name: "malformed mixed with valid",
input: "orphan\nyay 12.5.7-1\nbadline\nparu 2.0-1\n",
wantLen: 2,
wantNames: []string{"yay", "paru"},
wantVers: []string{"12.5.7-1", "2.0-1"},
},
{
name: "extra fields ignored",
input: "yay 12.5.7-1 extra stuff\n",
wantLen: 1,
wantNames: []string{"yay"},
wantVers: []string{"12.5.7-1"},
},
{
name: "trailing and leading whitespace on lines",
input: " yay 12.5.7-1 \n paru 2.0.4-1\n\n",
wantLen: 2,
wantNames: []string{"yay", "paru"},
wantVers: []string{"12.5.7-1", "2.0.4-1"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgs := parsePackageList(tt.input)
require.Len(t, pkgs, tt.wantLen)
for i, p := range pkgs {
assert.Equal(t, "aur", p.Repository, "all packages should have Repository=aur")
assert.True(t, p.Installed, "all packages should have Installed=true")
if i < len(tt.wantNames) {
assert.Equal(t, tt.wantNames[i], p.Name)
}
if i < len(tt.wantVers) {
assert.Equal(t, tt.wantVers[i], p.Version)
}
}
})
}
}

86
dnf/dnf_test.go Normal file
View File

@@ -0,0 +1,86 @@
package dnf
import (
"testing"
"github.com/gogrlx/snack"
)
// Compile-time interface assertions — DNF implements all optional interfaces.
var (
_ snack.Manager = (*DNF)(nil)
_ snack.VersionQuerier = (*DNF)(nil)
_ snack.Holder = (*DNF)(nil)
_ snack.Cleaner = (*DNF)(nil)
_ snack.FileOwner = (*DNF)(nil)
_ snack.RepoManager = (*DNF)(nil)
_ snack.KeyManager = (*DNF)(nil)
_ snack.Grouper = (*DNF)(nil)
_ snack.NameNormalizer = (*DNF)(nil)
_ snack.DryRunner = (*DNF)(nil)
_ snack.PackageUpgrader = (*DNF)(nil)
)
func TestName(t *testing.T) {
d := New()
if got := d.Name(); got != "dnf" {
t.Errorf("Name() = %q, want %q", got, "dnf")
}
}
func TestSupportsDryRun(t *testing.T) {
d := New()
if !d.SupportsDryRun() {
t.Error("SupportsDryRun() = false, want true")
}
}
func TestGetCapabilities(t *testing.T) {
d := New()
caps := snack.GetCapabilities(d)
tests := []struct {
name string
got bool
}{
{"VersionQuery", caps.VersionQuery},
{"Hold", caps.Hold},
{"Clean", caps.Clean},
{"FileOwnership", caps.FileOwnership},
{"RepoManagement", caps.RepoManagement},
{"KeyManagement", caps.KeyManagement},
{"Groups", caps.Groups},
{"NameNormalize", caps.NameNormalize},
{"DryRun", caps.DryRun},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if !tt.got {
t.Errorf("Capabilities.%s = false, want true", tt.name)
}
})
}
}
func TestNormalizeNameMethod(t *testing.T) {
d := New()
tests := []struct {
input, want string
}{
{"nginx.x86_64", "nginx"},
{"curl", "curl"},
}
for _, tt := range tests {
if got := d.NormalizeName(tt.input); got != tt.want {
t.Errorf("NormalizeName(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestParseArchMethod(t *testing.T) {
d := New()
name, arch := d.ParseArch("nginx.x86_64")
if name != "nginx" || arch != "x86_64" {
t.Errorf("ParseArch(\"nginx.x86_64\") = (%q, %q), want (\"nginx\", \"x86_64\")", name, arch)
}
}

View File

@@ -3,8 +3,6 @@ package dnf
import ( import (
"strings" "strings"
"testing" "testing"
"github.com/gogrlx/snack"
) )
func TestParseList(t *testing.T) { func TestParseList(t *testing.T) {
@@ -458,15 +456,399 @@ updates-testing Fedora 43 - x86_64 - Test Updates
} }
} }
// Ensure interface checks from capabilities.go are satisfied. // --- Edge case tests ---
var (
_ snack.Manager = (*DNF)(nil) func TestParseListEmpty(t *testing.T) {
_ snack.VersionQuerier = (*DNF)(nil) pkgs := parseList("")
_ snack.Holder = (*DNF)(nil) if len(pkgs) != 0 {
_ snack.Cleaner = (*DNF)(nil) t.Errorf("expected 0 packages from empty input, got %d", len(pkgs))
_ snack.FileOwner = (*DNF)(nil) }
_ snack.RepoManager = (*DNF)(nil) }
_ snack.KeyManager = (*DNF)(nil)
_ snack.Grouper = (*DNF)(nil) func TestParseListSinglePackage(t *testing.T) {
_ snack.NameNormalizer = (*DNF)(nil) input := `Installed Packages
) curl.x86_64 7.76.1-23.el9 @baseos
`
pkgs := parseList(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "curl" {
t.Errorf("Name = %q, want curl", pkgs[0].Name)
}
}
func TestParseListMalformedLines(t *testing.T) {
input := `Installed Packages
curl.x86_64 7.76.1-23.el9 @baseos
thislinehasnospaces
only-one-field
bash.x86_64 5.1.8-6.el9 @anaconda
`
pkgs := parseList(input)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages (skip malformed), got %d", len(pkgs))
}
}
func TestParseListNoHeader(t *testing.T) {
// Lines that look like packages without the "Installed Packages" header
input := `curl.x86_64 7.76.1-23.el9 @baseos
bash.x86_64 5.1.8-6.el9 @anaconda
`
pkgs := parseList(input)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages, got %d", len(pkgs))
}
}
func TestParseListTwoColumns(t *testing.T) {
// Only name.arch and version, no repo column
input := `Installed Packages
curl.x86_64 7.76.1-23.el9
`
pkgs := parseList(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Repository != "" {
t.Errorf("Repository = %q, want empty", pkgs[0].Repository)
}
}
func TestParseSearchEmpty(t *testing.T) {
pkgs := parseSearch("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages from empty input, got %d", len(pkgs))
}
}
func TestParseSearchSingleResult(t *testing.T) {
input := `=== Name Exactly Matched: curl ===
curl.x86_64 : A utility for getting files from remote servers
`
pkgs := parseSearch(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "curl" {
t.Errorf("Name = %q, want curl", pkgs[0].Name)
}
}
func TestParseSearchMalformedLines(t *testing.T) {
input := `=== Name Matched ===
curl.x86_64 : A utility
no-separator-here
another.line.without : proper : colons
bash.noarch : Shell
`
pkgs := parseSearch(input)
// "curl.x86_64 : A utility" and "another.line.without : proper : colons" and "bash.noarch : Shell"
if len(pkgs) != 3 {
t.Fatalf("expected 3 packages, got %d", len(pkgs))
}
}
func TestParseInfoEmpty(t *testing.T) {
p := parseInfo("")
if p != nil {
t.Errorf("expected nil from empty input, got %+v", p)
}
}
func TestParseInfoNoName(t *testing.T) {
input := `Version : 1.0
Architecture : x86_64
`
p := parseInfo(input)
if p != nil {
t.Errorf("expected nil when no Name field, got %+v", p)
}
}
func TestParseInfoReleaseBeforeVersion(t *testing.T) {
// Release without prior Version should not panic
input := `Name : test
Release : 1.el9
Version : 2.0
`
p := parseInfo(input)
if p == nil {
t.Fatal("expected non-nil package")
}
// Release came before Version was set, so it won't append properly,
// but Version should at least be set
if p.Name != "test" {
t.Errorf("Name = %q, want test", p.Name)
}
}
func TestParseInfoFromRepo(t *testing.T) {
input := `Name : bash
Version : 5.1.8
Release : 6.el9
From repo : baseos
Summary : The GNU Bourne Again shell
`
p := parseInfo(input)
if p == nil {
t.Fatal("expected non-nil package")
}
if p.Repository != "baseos" {
t.Errorf("Repository = %q, want baseos", p.Repository)
}
}
func TestParseVersionLockEmpty(t *testing.T) {
pkgs := parseVersionLock("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages from empty input, got %d", len(pkgs))
}
}
func TestParseVersionLockSingleEntry(t *testing.T) {
input := `nginx-0:1.20.1-14.el9_2.1.*
`
pkgs := parseVersionLock(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "nginx" {
t.Errorf("Name = %q, want nginx", pkgs[0].Name)
}
}
func TestParseRepoListEmpty(t *testing.T) {
repos := parseRepoList("")
if len(repos) != 0 {
t.Errorf("expected 0 repos from empty input, got %d", len(repos))
}
}
func TestParseRepoListSingleRepo(t *testing.T) {
input := `repo id repo name status
baseos CentOS Stream 9 - BaseOS enabled
`
repos := parseRepoList(input)
if len(repos) != 1 {
t.Fatalf("expected 1 repo, got %d", len(repos))
}
if repos[0].ID != "baseos" || !repos[0].Enabled {
t.Errorf("unexpected repo: %+v", repos[0])
}
}
func TestParseGroupListEmpty(t *testing.T) {
groups := parseGroupList("")
if len(groups) != 0 {
t.Errorf("expected 0 groups from empty input, got %d", len(groups))
}
}
func TestParseGroupInfoEmpty(t *testing.T) {
pkgs := parseGroupInfo("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages from empty input, got %d", len(pkgs))
}
}
func TestParseGroupInfoWithMarks(t *testing.T) {
input := `Group: Web Server
Mandatory Packages:
= httpd
+ mod_ssl
- php
`
pkgs := parseGroupInfo(input)
if len(pkgs) != 3 {
t.Fatalf("expected 3 packages, got %d", len(pkgs))
}
names := map[string]bool{}
for _, p := range pkgs {
names[p.Name] = true
}
for _, want := range []string{"httpd", "mod_ssl", "php"} {
if !names[want] {
t.Errorf("missing package %q", want)
}
}
}
func TestParseGroupIsInstalledEmpty(t *testing.T) {
if parseGroupIsInstalled("", "anything") {
t.Error("expected false for empty input")
}
}
func TestNormalizeNameEdgeCases(t *testing.T) {
tests := []struct {
input, want string
}{
{"", ""},
{"pkg.unknown.ext", "pkg.unknown.ext"},
{"name.with.dots.x86_64", "name.with.dots"},
{"python3.11", "python3.11"},
{"glibc.s390x", "glibc"},
{"kernel.src", "kernel"},
{".x86_64", ""},
{"pkg.ppc64le", "pkg"},
{"pkg.armv7hl", "pkg"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := normalizeName(tt.input)
if got != tt.want {
t.Errorf("normalizeName(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestParseArchEdgeCases(t *testing.T) {
tests := []struct {
input, wantName, wantArch string
}{
{"", "", ""},
{"pkg.i386", "pkg", "i386"},
{"pkg.ppc64le", "pkg", "ppc64le"},
{"pkg.s390x", "pkg", "s390x"},
{"pkg.armv7hl", "pkg", "armv7hl"},
{"pkg.src", "pkg", "src"},
{"pkg.unknown", "pkg.unknown", ""},
{"name.with.many.dots.noarch", "name.with.many.dots", "noarch"},
{".noarch", "", "noarch"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
name, arch := parseArch(tt.input)
if name != tt.wantName || arch != tt.wantArch {
t.Errorf("parseArch(%q) = (%q, %q), want (%q, %q)",
tt.input, name, arch, tt.wantName, tt.wantArch)
}
})
}
}
// --- dnf5 edge case tests ---
func TestStripPreambleEmpty(t *testing.T) {
got := stripPreamble("")
if got != "" {
t.Errorf("expected empty, got %q", got)
}
}
func TestStripPreambleNoPreamble(t *testing.T) {
input := "Installed packages\nbash.x86_64 5.3.0-2.fc43 abc\n"
got := stripPreamble(input)
if got != input {
t.Errorf("expected unchanged output when no preamble present")
}
}
func TestParseListDNF5Empty(t *testing.T) {
pkgs := parseListDNF5("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages from empty input, got %d", len(pkgs))
}
}
func TestParseListDNF5SinglePackage(t *testing.T) {
input := `Installed packages
curl.aarch64 7.76.1-23.el9 abc123
`
pkgs := parseListDNF5(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "curl" || pkgs[0].Arch != "aarch64" {
t.Errorf("unexpected: %+v", pkgs[0])
}
}
func TestParseSearchDNF5Empty(t *testing.T) {
pkgs := parseSearchDNF5("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages from empty input, got %d", len(pkgs))
}
}
func TestParseInfoDNF5Empty(t *testing.T) {
p := parseInfoDNF5("")
if p != nil {
t.Errorf("expected nil from empty input, got %+v", p)
}
}
func TestParseInfoDNF5NoName(t *testing.T) {
input := `Version : 1.0
Architecture : x86_64
`
p := parseInfoDNF5(input)
if p != nil {
t.Errorf("expected nil when no Name field, got %+v", p)
}
}
func TestParseGroupListDNF5Empty(t *testing.T) {
groups := parseGroupListDNF5("")
if len(groups) != 0 {
t.Errorf("expected 0 groups from empty input, got %d", len(groups))
}
}
func TestParseGroupIsInstalledDNF5Empty(t *testing.T) {
if parseGroupIsInstalledDNF5("", "anything") {
t.Error("expected false for empty input")
}
}
func TestParseVersionLockDNF5Empty(t *testing.T) {
pkgs := parseVersionLockDNF5("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages from empty input, got %d", len(pkgs))
}
}
func TestParseVersionLockDNF5SingleEntry(t *testing.T) {
input := `# Added by 'versionlock add' command on 2026-02-26 03:14:29
Package name: nginx
evr = 1.20.1-14.el9
`
pkgs := parseVersionLockDNF5(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "nginx" {
t.Errorf("Name = %q, want nginx", pkgs[0].Name)
}
}
func TestParseRepoListDNF5Empty(t *testing.T) {
repos := parseRepoListDNF5("")
if len(repos) != 0 {
t.Errorf("expected 0 repos from empty input, got %d", len(repos))
}
}
func TestParseGroupInfoDNF5Empty(t *testing.T) {
pkgs := parseGroupInfoDNF5("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages from empty input, got %d", len(pkgs))
}
}
func TestParseGroupInfoDNF5SinglePackage(t *testing.T) {
input := `Id : test-group
Name : Test
Mandatory packages : single-pkg
`
pkgs := parseGroupInfoDNF5(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "single-pkg" {
t.Errorf("Name = %q, want single-pkg", pkgs[0].Name)
}
}

View File

@@ -37,6 +37,71 @@ func TestParseListEmpty(t *testing.T) {
} }
} }
func TestParseListEdgeCases(t *testing.T) {
t.Run("single entry", func(t *testing.T) {
pkgs := parseList("Firefox\torg.mozilla.Firefox\t131.0\tflathub\n")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "Firefox" {
t.Errorf("expected Firefox, got %q", pkgs[0].Name)
}
})
t.Run("two fields only", func(t *testing.T) {
pkgs := parseList("Firefox\torg.mozilla.Firefox\n")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "Firefox" {
t.Errorf("expected Firefox, got %q", pkgs[0].Name)
}
if pkgs[0].Version != "" {
t.Errorf("expected empty version, got %q", pkgs[0].Version)
}
if pkgs[0].Repository != "" {
t.Errorf("expected empty repository, got %q", pkgs[0].Repository)
}
})
t.Run("single field skipped", func(t *testing.T) {
pkgs := parseList("Firefox\n")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages (needs >=2 fields), got %d", len(pkgs))
}
})
t.Run("extra fields", func(t *testing.T) {
pkgs := parseList("Firefox\torg.mozilla.Firefox\t131.0\tflathub\textra\n")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Repository != "flathub" {
t.Errorf("expected flathub, got %q", pkgs[0].Repository)
}
})
t.Run("whitespace only lines", func(t *testing.T) {
pkgs := parseList(" \n\t\n \n")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages, got %d", len(pkgs))
}
})
t.Run("three fields no repo", func(t *testing.T) {
pkgs := parseList("GIMP\torg.gimp.GIMP\t2.10.38\n")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Version != "2.10.38" {
t.Errorf("expected version 2.10.38, got %q", pkgs[0].Version)
}
if pkgs[0].Repository != "" {
t.Errorf("expected empty repository, got %q", pkgs[0].Repository)
}
})
}
func TestParseSearch(t *testing.T) { func TestParseSearch(t *testing.T) {
input := "Firefox\torg.mozilla.Firefox\t131.0\tflathub\n" + input := "Firefox\torg.mozilla.Firefox\t131.0\tflathub\n" +
"Firefox ESR\torg.mozilla.FirefoxESR\t128.3\tflathub\n" "Firefox ESR\torg.mozilla.FirefoxESR\t128.3\tflathub\n"
@@ -60,6 +125,32 @@ func TestParseSearchNoMatches(t *testing.T) {
} }
} }
func TestParseSearchEdgeCases(t *testing.T) {
t.Run("empty", func(t *testing.T) {
pkgs := parseSearch("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages, got %d", len(pkgs))
}
})
t.Run("single field line skipped", func(t *testing.T) {
pkgs := parseSearch("JustAName\n")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages (needs >=2 fields), got %d", len(pkgs))
}
})
t.Run("not installed result", func(t *testing.T) {
pkgs := parseSearch("VLC\torg.videolan.VLC\t3.0.20\tflathub\n")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Installed {
t.Error("search results should not be marked installed")
}
})
}
func TestParseInfo(t *testing.T) { func TestParseInfo(t *testing.T) {
input := `Name: Firefox input := `Name: Firefox
Description: Fast, private web browser Description: Fast, private web browser
@@ -92,6 +183,46 @@ func TestParseInfoEmpty(t *testing.T) {
} }
} }
func TestParseInfoEdgeCases(t *testing.T) {
t.Run("name only", func(t *testing.T) {
pkg := parseInfo("Name: VLC\n")
if pkg == nil {
t.Fatal("expected non-nil")
}
if pkg.Name != "VLC" {
t.Errorf("expected VLC, got %q", pkg.Name)
}
})
t.Run("no name returns nil", func(t *testing.T) {
pkg := parseInfo("Version: 1.0\nArch: x86_64\n")
if pkg != nil {
t.Error("expected nil when no Name field")
}
})
t.Run("no colon lines ignored", func(t *testing.T) {
pkg := parseInfo("Name: Test\nsome random line without colon\nVersion: 2.0\n")
if pkg == nil {
t.Fatal("expected non-nil")
}
if pkg.Version != "2.0" {
t.Errorf("expected version 2.0, got %q", pkg.Version)
}
})
t.Run("value with colons", func(t *testing.T) {
pkg := parseInfo("Name: MyApp\nDescription: A tool: does things: well\n")
if pkg == nil {
t.Fatal("expected non-nil")
}
// parseInfo uses strings.Index for first colon
if pkg.Description != "A tool: does things: well" {
t.Errorf("unexpected description: %q", pkg.Description)
}
})
}
func TestParseRemotes(t *testing.T) { func TestParseRemotes(t *testing.T) {
input := "flathub\thttps://dl.flathub.org/repo/\t\n" + input := "flathub\thttps://dl.flathub.org/repo/\t\n" +
"gnome-nightly\thttps://nightly.gnome.org/repo/\tdisabled\n" "gnome-nightly\thttps://nightly.gnome.org/repo/\tdisabled\n"
@@ -113,10 +244,162 @@ func TestParseRemotes(t *testing.T) {
} }
} }
func TestParseRemotesEdgeCases(t *testing.T) {
t.Run("empty", func(t *testing.T) {
repos := parseRemotes("")
if len(repos) != 0 {
t.Errorf("expected 0 repos, got %d", len(repos))
}
})
t.Run("single enabled remote", func(t *testing.T) {
repos := parseRemotes("flathub\thttps://dl.flathub.org/repo/\t\n")
if len(repos) != 1 {
t.Fatalf("expected 1 repo, got %d", len(repos))
}
if !repos[0].Enabled {
t.Error("expected enabled")
}
if repos[0].Name != "flathub" {
t.Errorf("expected Name=flathub, got %q", repos[0].Name)
}
})
t.Run("single disabled remote", func(t *testing.T) {
repos := parseRemotes("test-remote\thttps://example.com/\tdisabled\n")
if len(repos) != 1 {
t.Fatalf("expected 1 repo, got %d", len(repos))
}
if repos[0].Enabled {
t.Error("expected disabled")
}
})
t.Run("no URL field", func(t *testing.T) {
repos := parseRemotes("myremote\n")
if len(repos) != 1 {
t.Fatalf("expected 1 repo, got %d", len(repos))
}
if repos[0].ID != "myremote" {
t.Errorf("expected myremote, got %q", repos[0].ID)
}
if repos[0].URL != "" {
t.Errorf("expected empty URL, got %q", repos[0].URL)
}
if !repos[0].Enabled {
t.Error("expected enabled by default")
}
})
t.Run("whitespace lines ignored", func(t *testing.T) {
repos := parseRemotes(" \n\n \n")
if len(repos) != 0 {
t.Errorf("expected 0 repos, got %d", len(repos))
}
})
}
func TestSemverCmp(t *testing.T) {
tests := []struct {
name string
a, b string
want int
}{
{"equal", "1.0.0", "1.0.0", 0},
{"less major", "1.0.0", "2.0.0", -1},
{"greater major", "2.0.0", "1.0.0", 1},
{"less minor", "1.2.3", "1.3.0", -1},
{"less patch", "1.2.3", "1.2.4", -1},
{"multi-digit", "1.10.0", "1.9.0", 1},
{"short vs long equal", "1.0", "1.0.0", 0},
{"short vs long less", "1.0", "1.0.1", -1},
{"short vs long greater", "1.1", "1.0.9", 1},
{"single component", "5", "3", 1},
{"single equal", "3", "3", 0},
{"empty vs empty", "", "", 0},
{"empty vs version", "", "1.0", -1},
{"version vs empty", "1.0", "", 1},
{"non-numeric suffix", "1.0.0-beta", "1.0.0-rc1", 0},
{"pre-release stripped", "1.0.0beta2", "1.0.0rc1", 0},
{"four components", "1.2.3.4", "1.2.3.5", -1},
{"different lengths", "1.0.0.0", "1.0.0", 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := semverCmp(tt.a, tt.b)
if got != tt.want {
t.Errorf("semverCmp(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
func TestStripNonNumeric(t *testing.T) {
tests := []struct {
input string
want string
}{
{"123", "123"},
{"123abc", "123"},
{"abc", ""},
{"0beta", "0"},
{"", ""},
{"42-rc1", "42"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := stripNonNumeric(tt.input)
if got != tt.want {
t.Errorf("stripNonNumeric(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestInterfaceCompliance(t *testing.T) { func TestInterfaceCompliance(t *testing.T) {
var _ snack.Manager = (*Flatpak)(nil) var _ snack.Manager = (*Flatpak)(nil)
var _ snack.Cleaner = (*Flatpak)(nil) var _ snack.Cleaner = (*Flatpak)(nil)
var _ snack.RepoManager = (*Flatpak)(nil) var _ snack.RepoManager = (*Flatpak)(nil)
var _ snack.VersionQuerier = (*Flatpak)(nil)
var _ snack.PackageUpgrader = (*Flatpak)(nil)
}
// Compile-time interface checks in test file
var (
_ snack.VersionQuerier = (*Flatpak)(nil)
_ snack.PackageUpgrader = (*Flatpak)(nil)
)
func TestCapabilities(t *testing.T) {
caps := snack.GetCapabilities(New())
if !caps.Clean {
t.Error("expected Clean=true")
}
if !caps.RepoManagement {
t.Error("expected RepoManagement=true")
}
if !caps.VersionQuery {
t.Error("expected VersionQuery=true")
}
// Should be false
if caps.FileOwnership {
t.Error("expected FileOwnership=false")
}
if caps.DryRun {
t.Error("expected DryRun=false")
}
if caps.Hold {
t.Error("expected Hold=false")
}
if caps.KeyManagement {
t.Error("expected KeyManagement=false")
}
if caps.Groups {
t.Error("expected Groups=false")
}
if caps.NameNormalize {
t.Error("expected NameNormalize=false")
}
} }
func TestName(t *testing.T) { func TestName(t *testing.T) {

View File

@@ -79,36 +79,88 @@ func TestBuildArgs_RootBeforeBaseArgs(t *testing.T) {
assert.Greater(t, sIdx, rIdx, "root flag should come before base args") assert.Greater(t, sIdx, rIdx, "root flag should come before base args")
} }
func TestParseUpgrades_Empty(t *testing.T) { func TestParseUpgrades(t *testing.T) {
assert.Empty(t, parseUpgrades("")) tests := []struct {
} name string
input string
wantLen int
wantNames []string
wantVers []string
}{
{
name: "empty",
input: "",
wantLen: 0,
},
{
name: "whitespace only",
input: " \n \n\n\t\n",
wantLen: 0,
},
{
name: "standard arrow format",
input: "linux 6.7.3.arch1-1 -> 6.7.4.arch1-1\nvim 9.0.2-1 -> 9.1.0-1\n",
wantLen: 2,
wantNames: []string{"linux", "vim"},
wantVers: []string{"6.7.4.arch1-1", "9.1.0-1"},
},
{
name: "single package arrow format",
input: "curl 8.6.0-1 -> 8.7.1-1\n",
wantLen: 1,
wantNames: []string{"curl"},
wantVers: []string{"8.7.1-1"},
},
{
name: "fallback two-field format",
input: "pkg 2.0\n",
wantLen: 1,
wantNames: []string{"pkg"},
wantVers: []string{"2.0"},
},
{
name: "mixed arrow and fallback",
input: "linux 6.7.3 -> 6.7.4\npkg 2.0\n",
wantLen: 2,
wantNames: []string{"linux", "pkg"},
wantVers: []string{"6.7.4", "2.0"},
},
{
name: "whitespace around entries",
input: "\n \nlinux 6.7.3 -> 6.7.4\n\n",
wantLen: 1,
wantNames: []string{"linux"},
wantVers: []string{"6.7.4"},
},
{
name: "single field line skipped",
input: "orphan\nvalid 1.0 -> 2.0\n",
wantLen: 1,
},
{
name: "epoch in version",
input: "java-runtime 1:21.0.2-1 -> 1:21.0.3-1\n",
wantLen: 1,
wantNames: []string{"java-runtime"},
wantVers: []string{"1:21.0.3-1"},
},
}
func TestParseUpgrades_Standard(t *testing.T) { for _, tt := range tests {
input := `linux 6.7.3.arch1-1 -> 6.7.4.arch1-1 t.Run(tt.name, func(t *testing.T) {
vim 9.0.2-1 -> 9.1.0-1 pkgs := parseUpgrades(tt.input)
` require.Len(t, pkgs, tt.wantLen)
pkgs := parseUpgrades(input) for i, p := range pkgs {
require.Len(t, pkgs, 2) assert.True(t, p.Installed, "all upgrade entries should have Installed=true")
assert.Equal(t, "linux", pkgs[0].Name) if i < len(tt.wantNames) {
assert.Equal(t, "6.7.4.arch1-1", pkgs[0].Version) assert.Equal(t, tt.wantNames[i], p.Name)
assert.True(t, pkgs[0].Installed) }
assert.Equal(t, "vim", pkgs[1].Name) if i < len(tt.wantVers) {
assert.Equal(t, "9.1.0-1", pkgs[1].Version) assert.Equal(t, tt.wantVers[i], p.Version)
} }
}
func TestParseUpgrades_FallbackFormat(t *testing.T) { })
// Some versions of pacman might output "pkg newver" without the arrow }
input := "pkg 2.0\n"
pkgs := parseUpgrades(input)
require.Len(t, pkgs, 1)
assert.Equal(t, "pkg", pkgs[0].Name)
assert.Equal(t, "2.0", pkgs[0].Version)
}
func TestParseUpgrades_WhitespaceLines(t *testing.T) {
input := "\n \nlinux 6.7.3 -> 6.7.4\n\n"
pkgs := parseUpgrades(input)
require.Len(t, pkgs, 1)
} }
func TestParseGroupPkgSet_Empty(t *testing.T) { func TestParseGroupPkgSet_Empty(t *testing.T) {
@@ -149,6 +201,31 @@ group pkg2
assert.Len(t, set, 2) assert.Len(t, set, 2)
} }
func TestParseGroupPkgSet_WhitespaceOnly(t *testing.T) {
set := parseGroupPkgSet(" \n \n\t\n")
assert.Empty(t, set)
}
func TestParseGroupPkgSet_MultipleGroups(t *testing.T) {
// Different group names, same package names — set uses pkg name (second field)
input := `base-devel gcc
xorg xorg-server
base-devel gcc
`
set := parseGroupPkgSet(input)
assert.Len(t, set, 2)
assert.Contains(t, set, "gcc")
assert.Contains(t, set, "xorg-server")
}
func TestParseGroupPkgSet_ExtraFields(t *testing.T) {
// Lines with more than 2 fields — should still use second field
input := "group pkg extra stuff\n"
set := parseGroupPkgSet(input)
assert.Len(t, set, 1)
assert.Contains(t, set, "pkg")
}
func TestNew(t *testing.T) { func TestNew(t *testing.T) {
p := New() p := New()
assert.NotNil(t, p) assert.NotNil(t, p)

View File

@@ -130,6 +130,58 @@ func TestBuildArgs(t *testing.T) {
func TestInterfaceCompliance(t *testing.T) { func TestInterfaceCompliance(t *testing.T) {
var _ snack.Manager = (*Pacman)(nil) var _ snack.Manager = (*Pacman)(nil)
var _ snack.VersionQuerier = (*Pacman)(nil)
var _ snack.Cleaner = (*Pacman)(nil)
var _ snack.FileOwner = (*Pacman)(nil)
var _ snack.Grouper = (*Pacman)(nil)
var _ snack.DryRunner = (*Pacman)(nil)
var _ snack.PackageUpgrader = (*Pacman)(nil)
}
func TestInterfaceNonCompliance(t *testing.T) {
p := New()
var m snack.Manager = p
if _, ok := m.(snack.Holder); ok {
t.Error("Pacman should not implement Holder")
}
if _, ok := m.(snack.RepoManager); ok {
t.Error("Pacman should not implement RepoManager")
}
if _, ok := m.(snack.KeyManager); ok {
t.Error("Pacman should not implement KeyManager")
}
if _, ok := m.(snack.NameNormalizer); ok {
t.Error("Pacman should not implement NameNormalizer")
}
}
func TestCapabilities(t *testing.T) {
caps := snack.GetCapabilities(New())
tests := []struct {
name string
got bool
want bool
}{
{"VersionQuery", caps.VersionQuery, true},
{"Clean", caps.Clean, true},
{"FileOwnership", caps.FileOwnership, true},
{"Groups", caps.Groups, true},
{"DryRun", caps.DryRun, true},
{"Hold", caps.Hold, false},
{"RepoManagement", caps.RepoManagement, false},
{"KeyManagement", caps.KeyManagement, false},
{"NameNormalize", caps.NameNormalize, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.got != tt.want {
t.Errorf("%s = %v, want %v", tt.name, tt.got, tt.want)
}
})
}
} }
func TestName(t *testing.T) { func TestName(t *testing.T) {

View File

@@ -23,6 +23,94 @@ func TestParseQuery(t *testing.T) {
} }
} }
func TestParseQueryEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
wantPkgs []snack.Package
}{
{
name: "empty input",
input: "",
wantLen: 0,
},
{
name: "whitespace only",
input: " \n \n\n",
wantLen: 0,
},
{
name: "single entry",
input: "vim\t9.0\tVi IMproved\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "vim", Version: "9.0", Description: "Vi IMproved", Installed: true},
},
},
{
name: "missing description (two fields only)",
input: "bash\t5.2\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "bash", Version: "5.2", Description: "", Installed: true},
},
},
{
name: "single field only (no tabs, skipped)",
input: "justname\n",
wantLen: 0,
},
{
name: "description with tabs",
input: "pkg\t1.0\tA\ttabbed\tdescription\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "pkg", Version: "1.0", Description: "A\ttabbed\tdescription", Installed: true},
},
},
{
name: "trailing and leading whitespace on lines",
input: " nginx\t1.24.0\tWeb server \n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "nginx", Version: "1.24.0", Description: "Web server", Installed: true},
},
},
{
name: "multiple entries with blank lines between",
input: "a\t1.0\tAlpha\n\nb\t2.0\tBeta\n",
wantLen: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgs := parseQuery(tt.input)
if len(pkgs) != tt.wantLen {
t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs))
}
for i, want := range tt.wantPkgs {
if i >= len(pkgs) {
break
}
got := pkgs[i]
if got.Name != want.Name {
t.Errorf("[%d] Name = %q, want %q", i, got.Name, want.Name)
}
if got.Version != want.Version {
t.Errorf("[%d] Version = %q, want %q", i, got.Version, want.Version)
}
if got.Description != want.Description {
t.Errorf("[%d] Description = %q, want %q", i, got.Description, want.Description)
}
if got.Installed != want.Installed {
t.Errorf("[%d] Installed = %v, want %v", i, got.Installed, want.Installed)
}
}
})
}
}
func TestParseSearch(t *testing.T) { func TestParseSearch(t *testing.T) {
input := `nginx-1.24.0 Robust and small WWW server input := `nginx-1.24.0 Robust and small WWW server
curl-8.5.0 Command line tool for transferring data curl-8.5.0 Command line tool for transferring data
@@ -39,6 +127,81 @@ curl-8.5.0 Command line tool for transferring data
} }
} }
func TestParseSearchEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
wantPkgs []snack.Package
}{
{
name: "empty input",
input: "",
wantLen: 0,
},
{
name: "name with many hyphens",
input: "py39-django-rest-framework-3.14.0 RESTful Web APIs for Django\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "py39-django-rest-framework", Version: "3.14.0", Description: "RESTful Web APIs for Django"},
},
},
{
name: "no comment (name-version only)",
input: "zsh-5.9\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "zsh", Version: "5.9", Description: ""},
},
},
{
name: "very long output many packages",
input: "a-1.0 desc1\nb-2.0 desc2\nc-3.0 desc3\nd-4.0 desc4\ne-5.0 desc5\n",
wantLen: 5,
},
{
name: "single character name",
input: "R-4.3.2 Statistical Computing\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "R", Version: "4.3.2", Description: "Statistical Computing"},
},
},
{
name: "version with complex suffix",
input: "libressl-3.8.2_1 TLS library\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "libressl", Version: "3.8.2_1", Description: "TLS library"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgs := parseSearch(tt.input)
if len(pkgs) != tt.wantLen {
t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs))
}
for i, want := range tt.wantPkgs {
if i >= len(pkgs) {
break
}
got := pkgs[i]
if got.Name != want.Name {
t.Errorf("[%d] Name = %q, want %q", i, got.Name, want.Name)
}
if got.Version != want.Version {
t.Errorf("[%d] Version = %q, want %q", i, got.Version, want.Version)
}
if got.Description != want.Description {
t.Errorf("[%d] Description = %q, want %q", i, got.Description, want.Description)
}
}
})
}
}
func TestParseInfo(t *testing.T) { func TestParseInfo(t *testing.T) {
input := `Name : nginx input := `Name : nginx
Version : 1.24.0 Version : 1.24.0
@@ -63,6 +226,77 @@ Arch : FreeBSD:14:amd64
} }
} }
func TestParseInfoEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
wantNil bool
want *snack.Package
}{
{
name: "empty input",
input: "",
wantNil: true,
},
{
name: "no name field returns nil",
input: "Version : 1.0\nComment : test\n",
wantNil: true,
},
{
name: "name only (missing other fields)",
input: "Name : bash\n",
want: &snack.Package{Name: "bash", Installed: true},
},
{
name: "extra unknown fields are ignored",
input: "Name : vim\nVersion : 9.0\nMaintainer : someone@example.com\nWWW : https://vim.org\nComment : Vi IMproved\n",
want: &snack.Package{Name: "vim", Version: "9.0", Description: "Vi IMproved", Installed: true},
},
{
name: "colon in value",
input: "Name : nginx\nComment : HTTP server: fast and reliable\n",
want: &snack.Package{Name: "nginx", Description: "HTTP server: fast and reliable", Installed: true},
},
{
name: "lines without colons are skipped",
input: "This is random text\nNo colons here\n",
wantNil: true,
},
{
name: "whitespace around values",
input: "Name : curl \nVersion : 8.5.0 \n",
want: &snack.Package{Name: "curl", Version: "8.5.0", Installed: true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseInfo(tt.input)
if tt.wantNil {
if got != nil {
t.Errorf("expected nil, got %+v", got)
}
return
}
if got == nil {
t.Fatal("expected non-nil package")
}
if got.Name != tt.want.Name {
t.Errorf("Name = %q, want %q", got.Name, tt.want.Name)
}
if got.Version != tt.want.Version {
t.Errorf("Version = %q, want %q", got.Version, tt.want.Version)
}
if got.Description != tt.want.Description {
t.Errorf("Description = %q, want %q", got.Description, tt.want.Description)
}
if got.Installed != tt.want.Installed {
t.Errorf("Installed = %v, want %v", got.Installed, tt.want.Installed)
}
})
}
}
func TestParseUpgrades(t *testing.T) { func TestParseUpgrades(t *testing.T) {
input := `Updating FreeBSD repository catalogue... input := `Updating FreeBSD repository catalogue...
The following 2 package(s) will be affected: The following 2 package(s) will be affected:
@@ -84,6 +318,90 @@ Number of packages to be upgraded: 2
} }
} }
func TestParseUpgradesEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
wantPkgs []snack.Package
}{
{
name: "empty input",
input: "",
wantLen: 0,
},
{
name: "no upgrade lines",
input: "Updating FreeBSD repository catalogue...\nAll packages are up to date.\n",
wantLen: 0,
},
{
name: "mix of Upgrading Installing Reinstalling",
input: `Upgrading nginx: 1.24.0 -> 1.26.0
Installing newpkg: 0 -> 1.0.0
Reinstalling bash: 5.2 -> 5.2
`,
wantLen: 3,
wantPkgs: []snack.Package{
{Name: "nginx", Version: "1.26.0", Installed: true},
{Name: "newpkg", Version: "1.0.0", Installed: true},
{Name: "bash", Version: "5.2", Installed: true},
},
},
{
name: "line with -> but no recognized prefix is skipped",
input: "Something: 1.0 -> 2.0\n",
wantLen: 0,
},
{
name: "upgrading line without colon is skipped",
input: "Upgrading nginx 1.24.0 -> 1.26.0\n",
wantLen: 0,
},
{
name: "upgrading line with -> but not enough parts after colon",
input: "Upgrading nginx: -> \n",
wantLen: 0,
},
{
name: "upgrading line with wrong arrow",
input: "Upgrading nginx: 1.24.0 => 1.26.0\n",
wantLen: 0,
},
{
name: "single upgrade line",
input: "Upgrading zsh: 5.8 -> 5.9\n",
wantPkgs: []snack.Package{
{Name: "zsh", Version: "5.9", Installed: true},
},
wantLen: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgs := parseUpgrades(tt.input)
if len(pkgs) != tt.wantLen {
t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs))
}
for i, want := range tt.wantPkgs {
if i >= len(pkgs) {
break
}
got := pkgs[i]
if got.Name != want.Name {
t.Errorf("[%d] Name = %q, want %q", i, got.Name, want.Name)
}
if got.Version != want.Version {
t.Errorf("[%d] Version = %q, want %q", i, got.Version, want.Version)
}
if got.Installed != want.Installed {
t.Errorf("[%d] Installed = %v, want %v", i, got.Installed, want.Installed)
}
}
})
}
}
func TestParseFileList(t *testing.T) { func TestParseFileList(t *testing.T) {
input := `nginx-1.24.0: input := `nginx-1.24.0:
/usr/local/sbin/nginx /usr/local/sbin/nginx
@@ -99,6 +417,67 @@ func TestParseFileList(t *testing.T) {
} }
} }
func TestParseFileListEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
want []string
}{
{
name: "empty input",
input: "",
wantLen: 0,
},
{
name: "header only no files",
input: "nginx-1.24.0:\n",
wantLen: 0,
},
{
name: "paths with spaces",
input: "pkg-1.0:\n\t/usr/local/share/my package/file name.txt\n\t/usr/local/share/another dir/test\n",
wantLen: 2,
want: []string{
"/usr/local/share/my package/file name.txt",
"/usr/local/share/another dir/test",
},
},
{
name: "single file",
input: "bash-5.2:\n\t/usr/local/bin/bash\n",
wantLen: 1,
want: []string{"/usr/local/bin/bash"},
},
{
name: "no header just file paths",
input: "/usr/local/bin/curl\n/usr/local/lib/libcurl.so\n",
wantLen: 2,
},
{
name: "blank lines between files",
input: "pkg-1.0:\n\t/usr/local/bin/a\n\n\t/usr/local/bin/b\n",
wantLen: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
files := parseFileList(tt.input)
if len(files) != tt.wantLen {
t.Fatalf("expected %d files, got %d", tt.wantLen, len(files))
}
for i, w := range tt.want {
if i >= len(files) {
break
}
if files[i] != w {
t.Errorf("[%d] got %q, want %q", i, files[i], w)
}
}
})
}
}
func TestParseOwner(t *testing.T) { func TestParseOwner(t *testing.T) {
input := "/usr/local/sbin/nginx was installed by package nginx-1.24.0\n" input := "/usr/local/sbin/nginx was installed by package nginx-1.24.0\n"
name := parseOwner(input) name := parseOwner(input)
@@ -107,6 +486,48 @@ func TestParseOwner(t *testing.T) {
} }
} }
func TestParseOwnerEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "empty input",
input: "",
want: "",
},
{
name: "standard format",
input: "/usr/local/bin/curl was installed by package curl-8.5.0\n",
want: "curl",
},
{
name: "package name with hyphens",
input: "/usr/local/lib/libpython3.so was installed by package py39-python-3.9.18\n",
want: "py39-python",
},
{
name: "no match returns trimmed input",
input: "some random output\n",
want: "some random output",
},
{
name: "whitespace around",
input: " /usr/local/bin/bash was installed by package bash-5.2.21 \n",
want: "bash",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseOwner(tt.input)
if got != tt.want {
t.Errorf("parseOwner(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestSplitNameVersion(t *testing.T) { func TestSplitNameVersion(t *testing.T) {
tests := []struct { tests := []struct {
input string input string
@@ -126,6 +547,73 @@ func TestSplitNameVersion(t *testing.T) {
} }
} }
func TestSplitNameVersionEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
wantName string
wantVersion string
}{
{
name: "empty string",
input: "",
wantName: "",
wantVersion: "",
},
{
name: "no hyphen",
input: "singleword",
wantName: "singleword",
wantVersion: "",
},
{
name: "multiple hyphens",
input: "py39-django-rest-3.14.0",
wantName: "py39-django-rest",
wantVersion: "3.14.0",
},
{
name: "leading hyphen",
input: "-1.0",
wantName: "-1.0",
wantVersion: "",
},
{
name: "trailing hyphen",
input: "nginx-",
wantName: "nginx",
wantVersion: "",
},
{
name: "only hyphen",
input: "-",
wantName: "-",
wantVersion: "",
},
{
name: "hyphen at index 1",
input: "a-1.0",
wantName: "a",
wantVersion: "1.0",
},
{
name: "version with underscore suffix",
input: "libressl-3.8.2_1",
wantName: "libressl",
wantVersion: "3.8.2_1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
name, ver := splitNameVersion(tt.input)
if name != tt.wantName || ver != tt.wantVersion {
t.Errorf("splitNameVersion(%q) = (%q, %q), want (%q, %q)",
tt.input, name, ver, tt.wantName, tt.wantVersion)
}
})
}
}
func TestInterfaceCompliance(t *testing.T) { func TestInterfaceCompliance(t *testing.T) {
var _ snack.Manager = (*Pkg)(nil) var _ snack.Manager = (*Pkg)(nil)
var _ snack.VersionQuerier = (*Pkg)(nil) var _ snack.VersionQuerier = (*Pkg)(nil)
@@ -133,9 +621,48 @@ func TestInterfaceCompliance(t *testing.T) {
var _ snack.FileOwner = (*Pkg)(nil) var _ snack.FileOwner = (*Pkg)(nil)
} }
func TestPackageUpgraderInterface(t *testing.T) {
var _ snack.PackageUpgrader = (*Pkg)(nil)
}
func TestName(t *testing.T) { func TestName(t *testing.T) {
p := New() p := New()
if p.Name() != "pkg" { if p.Name() != "pkg" {
t.Errorf("Name() = %q, want %q", p.Name(), "pkg") t.Errorf("Name() = %q, want %q", p.Name(), "pkg")
} }
} }
func TestCapabilities(t *testing.T) {
caps := snack.GetCapabilities(New())
// Should be true
if !caps.VersionQuery {
t.Error("expected VersionQuery=true")
}
if !caps.Clean {
t.Error("expected Clean=true")
}
if !caps.FileOwnership {
t.Error("expected FileOwnership=true")
}
// Should be false
if caps.Hold {
t.Error("expected Hold=false")
}
if caps.RepoManagement {
t.Error("expected RepoManagement=false")
}
if caps.KeyManagement {
t.Error("expected KeyManagement=false")
}
if caps.Groups {
t.Error("expected Groups=false")
}
if caps.NameNormalize {
t.Error("expected NameNormalize=false")
}
if caps.DryRun {
t.Error("expected DryRun=false")
}
}

View File

@@ -29,6 +29,93 @@ python-3.11.7p0 interpreted object-oriented programming language
} }
} }
func TestParseListEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
wantPkgs []snack.Package
}{
{
name: "empty input",
input: "",
wantLen: 0,
},
{
name: "whitespace only",
input: " \n \n\n",
wantLen: 0,
},
{
name: "single entry",
input: "vim-9.0.2100 Vi IMproved\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "vim", Version: "9.0.2100", Description: "Vi IMproved", Installed: true},
},
},
{
name: "no description",
input: "vim-9.0.2100\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "vim", Version: "9.0.2100", Description: "", Installed: true},
},
},
{
name: "empty lines between entries",
input: "bash-5.2 Shell\n\n\ncurl-8.5.0 Transfer tool\n",
wantLen: 2,
wantPkgs: []snack.Package{
{Name: "bash", Version: "5.2", Description: "Shell", Installed: true},
{Name: "curl", Version: "8.5.0", Description: "Transfer tool", Installed: true},
},
},
{
name: "package with p-suffix version (OpenBSD style)",
input: "python-3.11.7p0 interpreted language\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "python", Version: "3.11.7p0", Description: "interpreted language", Installed: true},
},
},
{
name: "package name with multiple hyphens",
input: "py3-django-rest-3.14.0 REST framework\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "py3-django-rest", Version: "3.14.0", Description: "REST framework", Installed: true},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgs := parseList(tt.input)
if len(pkgs) != tt.wantLen {
t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs))
}
for i, want := range tt.wantPkgs {
if i >= len(pkgs) {
break
}
got := pkgs[i]
if got.Name != want.Name {
t.Errorf("[%d] Name = %q, want %q", i, got.Name, want.Name)
}
if got.Version != want.Version {
t.Errorf("[%d] Version = %q, want %q", i, got.Version, want.Version)
}
if got.Description != want.Description {
t.Errorf("[%d] Description = %q, want %q", i, got.Description, want.Description)
}
if got.Installed != want.Installed {
t.Errorf("[%d] Installed = %v, want %v", i, got.Installed, want.Installed)
}
}
})
}
}
func TestParseSearchResults(t *testing.T) { func TestParseSearchResults(t *testing.T) {
input := `nginx-1.24.0 input := `nginx-1.24.0
nginx-1.25.3 nginx-1.25.3
@@ -42,6 +129,83 @@ nginx-1.25.3
} }
} }
func TestParseSearchResultsEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
wantPkgs []snack.Package
}{
{
name: "empty input",
input: "",
wantLen: 0,
},
{
name: "whitespace only",
input: " \n\n \n",
wantLen: 0,
},
{
name: "single result",
input: "curl-8.5.0\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "curl", Version: "8.5.0"},
},
},
{
name: "name with many hyphens",
input: "py3-django-rest-framework-3.14.0\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "py3-django-rest-framework", Version: "3.14.0"},
},
},
{
name: "no version (no hyphen)",
input: "quirks\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "quirks", Version: ""},
},
},
{
name: "p-suffix version",
input: "python-3.11.7p0\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "python", Version: "3.11.7p0"},
},
},
{
name: "multiple results with blank lines",
input: "a-1.0\n\nb-2.0\n\nc-3.0\n",
wantLen: 3,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgs := parseSearchResults(tt.input)
if len(pkgs) != tt.wantLen {
t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs))
}
for i, want := range tt.wantPkgs {
if i >= len(pkgs) {
break
}
got := pkgs[i]
if got.Name != want.Name {
t.Errorf("[%d] Name = %q, want %q", i, got.Name, want.Name)
}
if got.Version != want.Version {
t.Errorf("[%d] Version = %q, want %q", i, got.Version, want.Version)
}
}
})
}
}
func TestParseInfoOutput(t *testing.T) { func TestParseInfoOutput(t *testing.T) {
input := `Information for nginx-1.24.0: input := `Information for nginx-1.24.0:
@@ -82,6 +246,131 @@ curl is a tool to transfer data from or to a server.
} }
} }
func TestParseInfoOutputEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
pkgArg string
wantNil bool
want *snack.Package
}{
{
name: "empty input and empty pkg arg",
input: "",
pkgArg: "",
wantNil: true,
},
{
name: "empty input with pkg arg fallback",
input: "",
pkgArg: "curl-8.5.0",
want: &snack.Package{Name: "curl", Version: "8.5.0", Installed: true},
},
{
name: "no header, falls back to pkg arg",
input: "Some random output\nwithout the expected header",
pkgArg: "vim-9.0",
want: &snack.Package{Name: "vim", Version: "9.0", Installed: true},
},
{
name: "comment on same line as Comment: label",
input: `Information for zsh-5.9:
Comment: Zsh shell
`,
pkgArg: "zsh-5.9",
want: &snack.Package{Name: "zsh", Version: "5.9", Description: "Zsh shell", Installed: true},
},
{
name: "comment on next line after Comment: label (not captured as description)",
input: `Information for bash-5.2:
Comment:
GNU Bourne Again Shell
`,
pkgArg: "bash-5.2",
// Comment: with nothing after the colon sets Description="",
// and the next line isn't in a Description: block so it's ignored.
want: &snack.Package{Name: "bash", Version: "5.2", Description: "", Installed: true},
},
{
name: "description spans multiple lines",
input: `Information for git-2.43.0:
Comment: distributed version control system
Description:
Git is a fast, scalable, distributed revision control system
with an unusually rich command set.
`,
pkgArg: "git-2.43.0",
want: &snack.Package{Name: "git", Version: "2.43.0", Description: "distributed version control system", Installed: true},
},
{
name: "extra fields are ignored",
input: `Information for tmux-3.3a:
Comment: terminal multiplexer
Maintainer: someone@openbsd.org
WWW: https://tmux.github.io
Description:
tmux is a terminal multiplexer.
`,
pkgArg: "tmux-3.3a",
want: &snack.Package{Name: "tmux", Version: "3.3a", Description: "terminal multiplexer", Installed: true},
},
{
name: "pkg arg with no version (no hyphen)",
input: "",
pkgArg: "quirks",
want: &snack.Package{Name: "quirks", Version: "", Installed: true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseInfoOutput(tt.input, tt.pkgArg)
if tt.wantNil {
if got != nil {
t.Errorf("expected nil, got %+v", got)
}
return
}
if got == nil {
t.Fatal("expected non-nil package")
}
if got.Name != tt.want.Name {
t.Errorf("Name = %q, want %q", got.Name, tt.want.Name)
}
if got.Version != tt.want.Version {
t.Errorf("Version = %q, want %q", got.Version, tt.want.Version)
}
if tt.want.Description != "" && got.Description != tt.want.Description {
t.Errorf("Description = %q, want %q", got.Description, tt.want.Description)
}
if got.Installed != tt.want.Installed {
t.Errorf("Installed = %v, want %v", got.Installed, tt.want.Installed)
}
})
}
}
func TestParseInfoOutputEmpty(t *testing.T) {
pkg := parseInfoOutput("", "")
if pkg != nil {
t.Error("expected nil for empty input and empty pkg name")
}
}
func TestParseInfoOutputFallbackToPkgArg(t *testing.T) {
input := "Some random output\nwithout the expected header"
pkg := parseInfoOutput(input, "curl-8.5.0")
if pkg == nil {
t.Fatal("expected non-nil package from fallback")
}
if pkg.Name != "curl" || pkg.Version != "8.5.0" {
t.Errorf("unexpected fallback parse: %+v", pkg)
}
}
func TestSplitNameVersion(t *testing.T) { func TestSplitNameVersion(t *testing.T) {
tests := []struct { tests := []struct {
input string input string
@@ -101,57 +390,76 @@ func TestSplitNameVersion(t *testing.T) {
} }
} }
func TestParseListEmpty(t *testing.T) { func TestSplitNameVersionEdgeCases(t *testing.T) {
pkgs := parseList("") tests := []struct {
if len(pkgs) != 0 { name string
t.Fatalf("expected 0 packages, got %d", len(pkgs)) input string
wantName string
wantVersion string
}{
{
name: "empty string",
input: "",
wantName: "",
wantVersion: "",
},
{
name: "no hyphen",
input: "singleword",
wantName: "singleword",
wantVersion: "",
},
{
name: "multiple hyphens",
input: "py3-django-rest-3.14.0",
wantName: "py3-django-rest",
wantVersion: "3.14.0",
},
{
name: "leading hyphen (idx=0, returns whole string)",
input: "-1.0",
wantName: "-1.0",
wantVersion: "",
},
{
name: "trailing hyphen",
input: "nginx-",
wantName: "nginx",
wantVersion: "",
},
{
name: "only hyphen",
input: "-",
wantName: "-",
wantVersion: "",
},
{
name: "hyphen at index 1",
input: "a-1.0",
wantName: "a",
wantVersion: "1.0",
},
{
name: "p-suffix version",
input: "python-3.11.7p0",
wantName: "python",
wantVersion: "3.11.7p0",
},
{
name: "version with v prefix",
input: "go-v1.21.5",
wantName: "go",
wantVersion: "v1.21.5",
},
} }
} for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
func TestParseListWhitespaceOnly(t *testing.T) { name, ver := splitNameVersion(tt.input)
pkgs := parseList(" \n \n\n") if name != tt.wantName || ver != tt.wantVersion {
if len(pkgs) != 0 { t.Errorf("splitNameVersion(%q) = (%q, %q), want (%q, %q)",
t.Fatalf("expected 0 packages, got %d", len(pkgs)) tt.input, name, ver, tt.wantName, tt.wantVersion)
} }
} })
func TestParseListNoDescription(t *testing.T) {
input := "vim-9.0.2100\n"
pkgs := parseList(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "vim" || pkgs[0].Version != "9.0.2100" {
t.Errorf("unexpected package: %+v", pkgs[0])
}
if pkgs[0].Description != "" {
t.Errorf("expected empty description, got %q", pkgs[0].Description)
}
}
func TestParseSearchResultsEmpty(t *testing.T) {
pkgs := parseSearchResults("")
if len(pkgs) != 0 {
t.Fatalf("expected 0 packages, got %d", len(pkgs))
}
}
func TestParseInfoOutputEmpty(t *testing.T) {
pkg := parseInfoOutput("", "")
if pkg != nil {
t.Error("expected nil for empty input and empty pkg name")
}
}
func TestParseInfoOutputFallbackToPkgArg(t *testing.T) {
// No "Information for" header — should fall back to parsing the pkg argument.
input := "Some random output\nwithout the expected header"
pkg := parseInfoOutput(input, "curl-8.5.0")
if pkg == nil {
t.Fatal("expected non-nil package from fallback")
}
if pkg.Name != "curl" || pkg.Version != "8.5.0" {
t.Errorf("unexpected fallback parse: %+v", pkg)
} }
} }
@@ -163,14 +471,7 @@ func TestSplitNameVersionNoHyphen(t *testing.T) {
} }
func TestSplitNameVersionLeadingHyphen(t *testing.T) { func TestSplitNameVersionLeadingHyphen(t *testing.T) {
// A hyphen at position 0 should return the whole string as name.
name, ver := splitNameVersion("-1.0") name, ver := splitNameVersion("-1.0")
if name != "" || ver != "1.0" {
// LastIndex("-1.0", "-") is 0, and idx <= 0 returns (s, "")
// Actually idx=0 means the condition idx <= 0 is true
}
// Re-check: idx=0, condition is idx <= 0, so returns (s, "")
name, ver = splitNameVersion("-1.0")
if name != "-1.0" || ver != "" { if name != "-1.0" || ver != "" {
t.Errorf("splitNameVersion(\"-1.0\") = (%q, %q), want (\"-1.0\", \"\")", name, ver) t.Errorf("splitNameVersion(\"-1.0\") = (%q, %q), want (\"-1.0\", \"\")", name, ver)
} }
@@ -196,6 +497,91 @@ python-3.11.7p0 -> python-3.11.8p0
} }
} }
func TestParseUpgradeOutputEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
wantPkgs []snack.Package
}{
{
name: "empty input",
input: "",
wantLen: 0,
},
{
name: "whitespace only",
input: " \n\n \n",
wantLen: 0,
},
{
name: "single upgrade",
input: "bash-5.2 -> bash-5.3\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "bash", Version: "5.3", Installed: true},
},
},
{
name: "line without -> is skipped",
input: "Some info line\nbash-5.2 -> bash-5.3\nAnother line\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "bash", Version: "5.3", Installed: true},
},
},
{
name: "malformed line (-> but not enough fields)",
input: "-> bash-5.3\n",
wantLen: 0,
},
{
name: "wrong arrow (=> instead of ->)",
input: "bash-5.2 => bash-5.3\n",
wantLen: 0,
},
{
name: "package name with multiple hyphens",
input: "py3-django-rest-3.14.0 -> py3-django-rest-3.15.0\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "py3-django-rest", Version: "3.15.0", Installed: true},
},
},
{
name: "p-suffix versions",
input: "python-3.11.7p0 -> python-3.11.8p1\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "python", Version: "3.11.8p1", Installed: true},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgs := parseUpgradeOutput(tt.input)
if len(pkgs) != tt.wantLen {
t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs))
}
for i, want := range tt.wantPkgs {
if i >= len(pkgs) {
break
}
got := pkgs[i]
if got.Name != want.Name {
t.Errorf("[%d] Name = %q, want %q", i, got.Name, want.Name)
}
if got.Version != want.Version {
t.Errorf("[%d] Version = %q, want %q", i, got.Version, want.Version)
}
if got.Installed != want.Installed {
t.Errorf("[%d] Installed = %v, want %v", i, got.Installed, want.Installed)
}
}
})
}
}
func TestParseUpgradeOutputEmpty(t *testing.T) { func TestParseUpgradeOutputEmpty(t *testing.T) {
pkgs := parseUpgradeOutput("") pkgs := parseUpgradeOutput("")
if len(pkgs) != 0 { if len(pkgs) != 0 {
@@ -221,6 +607,73 @@ Files:
} }
} }
func TestParseFileListOutputEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
want []string
}{
{
name: "empty input",
input: "",
wantLen: 0,
},
{
name: "header only no files",
input: "Information for pkg-1.0:\n\nFiles:\n",
wantLen: 0,
},
{
name: "paths with spaces",
input: "Information for pkg-1.0:\n\nFiles:\n/usr/local/share/my dir/file name.txt\n/usr/local/share/another path/test\n",
wantLen: 2,
want: []string{
"/usr/local/share/my dir/file name.txt",
"/usr/local/share/another path/test",
},
},
{
name: "single file",
input: "Files:\n/usr/local/bin/bash\n",
wantLen: 1,
want: []string{"/usr/local/bin/bash"},
},
{
name: "no header just paths",
input: "/usr/local/bin/a\n/usr/local/bin/b\n",
wantLen: 2,
},
{
name: "blank lines between files",
input: "Files:\n/usr/local/bin/a\n\n/usr/local/bin/b\n",
wantLen: 2,
},
{
name: "non-path lines are skipped",
input: "Information for pkg-1.0:\n\nFiles:\nNot a path\n/usr/local/bin/real\n",
wantLen: 1,
want: []string{"/usr/local/bin/real"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
files := parseFileListOutput(tt.input)
if len(files) != tt.wantLen {
t.Fatalf("expected %d files, got %d", tt.wantLen, len(files))
}
for i, w := range tt.want {
if i >= len(files) {
break
}
if files[i] != w {
t.Errorf("[%d] got %q, want %q", i, files[i], w)
}
}
})
}
}
func TestParseFileListOutputEmpty(t *testing.T) { func TestParseFileListOutputEmpty(t *testing.T) {
files := parseFileListOutput("") files := parseFileListOutput("")
if len(files) != 0 { if len(files) != 0 {
@@ -245,6 +698,58 @@ func TestParseOwnerOutput(t *testing.T) {
} }
} }
func TestParseOwnerOutputEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "empty input",
input: "",
want: "",
},
{
name: "whitespace only",
input: " \n ",
want: "",
},
{
name: "name with many hyphens",
input: "py3-django-rest-framework-3.14.0",
want: "py3-django-rest-framework",
},
{
name: "no version (no hyphen)",
input: "quirks",
want: "quirks",
},
{
name: "leading/trailing whitespace",
input: " curl-8.5.0 ",
want: "curl",
},
{
name: "p-suffix version",
input: "python-3.11.7p0",
want: "python",
},
{
name: "trailing newline",
input: "bash-5.2\n",
want: "bash",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseOwnerOutput(tt.input)
if got != tt.want {
t.Errorf("parseOwnerOutput(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestInterfaceCompliance(t *testing.T) { func TestInterfaceCompliance(t *testing.T) {
var _ snack.Manager = (*Ports)(nil) var _ snack.Manager = (*Ports)(nil)
var _ snack.VersionQuerier = (*Ports)(nil) var _ snack.VersionQuerier = (*Ports)(nil)
@@ -253,9 +758,48 @@ func TestInterfaceCompliance(t *testing.T) {
var _ snack.PackageUpgrader = (*Ports)(nil) var _ snack.PackageUpgrader = (*Ports)(nil)
} }
func TestPackageUpgraderInterface(t *testing.T) {
var _ snack.PackageUpgrader = (*Ports)(nil)
}
func TestName(t *testing.T) { func TestName(t *testing.T) {
p := New() p := New()
if p.Name() != "ports" { if p.Name() != "ports" {
t.Errorf("Name() = %q, want %q", p.Name(), "ports") t.Errorf("Name() = %q, want %q", p.Name(), "ports")
} }
} }
func TestCapabilities(t *testing.T) {
caps := snack.GetCapabilities(New())
// Should be true
if !caps.VersionQuery {
t.Error("expected VersionQuery=true")
}
if !caps.Clean {
t.Error("expected Clean=true")
}
if !caps.FileOwnership {
t.Error("expected FileOwnership=true")
}
// Should be false
if caps.Hold {
t.Error("expected Hold=false")
}
if caps.RepoManagement {
t.Error("expected RepoManagement=false")
}
if caps.KeyManagement {
t.Error("expected KeyManagement=false")
}
if caps.Groups {
t.Error("expected Groups=false")
}
if caps.NameNormalize {
t.Error("expected NameNormalize=false")
}
if caps.DryRun {
t.Error("expected DryRun=false")
}
}

View File

@@ -2,8 +2,6 @@ package rpm
import ( import (
"testing" "testing"
"github.com/gogrlx/snack"
) )
func TestParseList(t *testing.T) { func TestParseList(t *testing.T) {
@@ -95,9 +93,128 @@ func TestParseArchSuffix(t *testing.T) {
} }
} }
// Compile-time interface checks. // --- Edge case tests ---
var (
_ snack.Manager = (*RPM)(nil) func TestParseListEmpty(t *testing.T) {
_ snack.FileOwner = (*RPM)(nil) pkgs := parseList("")
_ snack.NameNormalizer = (*RPM)(nil) if len(pkgs) != 0 {
) t.Errorf("expected 0 packages from empty input, got %d", len(pkgs))
}
}
func TestParseListSinglePackage(t *testing.T) {
input := "curl\t7.76.1-23.el9\tA utility\n"
pkgs := parseList(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "curl" {
t.Errorf("Name = %q, want curl", pkgs[0].Name)
}
}
func TestParseListNoDescription(t *testing.T) {
input := "bash\t5.1.8-6.el9\n"
pkgs := parseList(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Description != "" {
t.Errorf("Description = %q, want empty", pkgs[0].Description)
}
}
func TestParseListMalformedLines(t *testing.T) {
input := "bash\t5.1.8-6.el9\tShell\nno-tab-here\ncurl\t7.76.1\tHTTP tool\n"
pkgs := parseList(input)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages (skip malformed), got %d", len(pkgs))
}
}
func TestParseInfoEmpty(t *testing.T) {
p := parseInfo("")
if p != nil {
t.Errorf("expected nil from empty input, got %+v", p)
}
}
func TestParseInfoNoName(t *testing.T) {
input := `Version : 1.0
Architecture: x86_64
`
p := parseInfo(input)
if p != nil {
t.Errorf("expected nil when no Name field, got %+v", p)
}
}
func TestParseInfoArchField(t *testing.T) {
// Test both "Architecture" and "Arch" key forms
input := `Name : test
Version : 1.0
Release : 1.el9
Arch : aarch64
Summary : Test package
`
p := parseInfo(input)
if p == nil {
t.Fatal("expected non-nil package")
}
if p.Arch != "aarch64" {
t.Errorf("Arch = %q, want aarch64", p.Arch)
}
}
func TestNormalizeNameEdgeCases(t *testing.T) {
tests := []struct {
input, want string
}{
{"", ""},
{"pkg.unknown.ext", "pkg.unknown.ext"},
{"name.with.dots.x86_64", "name.with.dots"},
{"python3.11", "python3.11"},
{"glibc.s390x", "glibc"},
{"kernel.src", "kernel"},
{".x86_64", ""},
{"pkg.ppc64le", "pkg"},
{"pkg.armv7hl", "pkg"},
{"pkg.i386", "pkg"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := normalizeName(tt.input)
if got != tt.want {
t.Errorf("normalizeName(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestParseArchSuffixEdgeCases(t *testing.T) {
tests := []struct {
input, wantName, wantArch string
}{
{"", "", ""},
{"pkg.i386", "pkg", "i386"},
{"pkg.ppc64le", "pkg", "ppc64le"},
{"pkg.s390x", "pkg", "s390x"},
{"pkg.armv7hl", "pkg", "armv7hl"},
{"pkg.src", "pkg", "src"},
{"pkg.aarch64", "pkg", "aarch64"},
{"pkg.noarch", "pkg", "noarch"},
{"pkg.unknown", "pkg.unknown", ""},
{"name.with.many.dots.noarch", "name.with.many.dots", "noarch"},
{".noarch", "", "noarch"},
{"pkg.x86_64.extra", "pkg.x86_64.extra", ""},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
name, arch := parseArchSuffix(tt.input)
if name != tt.wantName || arch != tt.wantArch {
t.Errorf("parseArchSuffix(%q) = (%q, %q), want (%q, %q)",
tt.input, name, arch, tt.wantName, tt.wantArch)
}
})
}
}

87
rpm/rpm_test.go Normal file
View File

@@ -0,0 +1,87 @@
package rpm
import (
"testing"
"github.com/gogrlx/snack"
)
// Compile-time interface assertions.
var (
_ snack.Manager = (*RPM)(nil)
_ snack.FileOwner = (*RPM)(nil)
_ snack.NameNormalizer = (*RPM)(nil)
)
func TestName(t *testing.T) {
r := New()
if got := r.Name(); got != "rpm" {
t.Errorf("Name() = %q, want %q", got, "rpm")
}
}
func TestGetCapabilities(t *testing.T) {
r := New()
caps := snack.GetCapabilities(r)
wantTrue := map[string]bool{
"FileOwnership": caps.FileOwnership,
"NameNormalize": caps.NameNormalize,
}
for name, got := range wantTrue {
t.Run(name+"_true", func(t *testing.T) {
if !got {
t.Errorf("Capabilities.%s = false, want true", name)
}
})
}
wantFalse := map[string]bool{
"VersionQuery": caps.VersionQuery,
"Hold": caps.Hold,
"Clean": caps.Clean,
"RepoManagement": caps.RepoManagement,
"KeyManagement": caps.KeyManagement,
"Groups": caps.Groups,
"DryRun": caps.DryRun,
}
for name, got := range wantFalse {
t.Run(name+"_false", func(t *testing.T) {
if got {
t.Errorf("Capabilities.%s = true, want false", name)
}
})
}
}
func TestNormalizeNameMethod(t *testing.T) {
r := New()
tests := []struct {
input, want string
}{
{"nginx.x86_64", "nginx"},
{"curl", "curl"},
{"bash.noarch", "bash"},
}
for _, tt := range tests {
if got := r.NormalizeName(tt.input); got != tt.want {
t.Errorf("NormalizeName(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestParseArchMethod(t *testing.T) {
r := New()
name, arch := r.ParseArch("nginx.x86_64")
if name != "nginx" || arch != "x86_64" {
t.Errorf("ParseArch(\"nginx.x86_64\") = (%q, %q), want (\"nginx\", \"x86_64\")", name, arch)
}
}
// RPM should NOT implement DryRunner.
func TestNotDryRunner(t *testing.T) {
r := New()
if _, ok := interface{}(r).(snack.DryRunner); ok {
t.Error("RPM should not implement DryRunner")
}
}

View File

@@ -35,6 +35,56 @@ func TestParseSnapListEmpty(t *testing.T) {
} }
} }
func TestParseSnapListEdgeCases(t *testing.T) {
t.Run("single entry", func(t *testing.T) {
input := `Name Version Rev Tracking Publisher Notes
core22 20240111 1122 latest/stable canonical✓ base
`
pkgs := parseSnapList(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "core22" {
t.Errorf("expected core22, got %q", pkgs[0].Name)
}
})
t.Run("header only no trailing newline", func(t *testing.T) {
input := "Name Version Rev Tracking Publisher Notes"
pkgs := parseSnapList(input)
if len(pkgs) != 0 {
t.Errorf("expected 0 packages, got %d", len(pkgs))
}
})
t.Run("single field line skipped", func(t *testing.T) {
input := "Name Version Rev Tracking Publisher Notes\nsinglefield\n"
pkgs := parseSnapList(input)
if len(pkgs) != 0 {
t.Errorf("expected 0 packages (need >=2 fields), got %d", len(pkgs))
}
})
t.Run("extra whitespace lines", func(t *testing.T) {
input := "Name Version Rev Tracking Publisher Notes\n \n\n"
pkgs := parseSnapList(input)
if len(pkgs) != 0 {
t.Errorf("expected 0 packages, got %d", len(pkgs))
}
})
t.Run("many columns", func(t *testing.T) {
input := "Name Version Rev Tracking Publisher Notes\nsnap1 2.0 100 latest/stable pub note extra more\n"
pkgs := parseSnapList(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "snap1" || pkgs[0].Version != "2.0" {
t.Errorf("unexpected package: %+v", pkgs[0])
}
})
}
func TestParseSnapFind(t *testing.T) { func TestParseSnapFind(t *testing.T) {
input := `Name Version Publisher Notes Summary input := `Name Version Publisher Notes Summary
firefox 131.0 mozilla✓ - Mozilla Firefox web browser firefox 131.0 mozilla✓ - Mozilla Firefox web browser
@@ -52,6 +102,67 @@ chromium 129.0 nickvdp - Chromium web browser
} }
} }
func TestParseSnapFindEdgeCases(t *testing.T) {
t.Run("single result", func(t *testing.T) {
input := "Name Version Publisher Notes Summary\nfirefox 131.0 mozilla - Web browser\n"
pkgs := parseSnapFind(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "firefox" {
t.Errorf("expected firefox, got %q", pkgs[0].Name)
}
if pkgs[0].Description != "Web browser" {
t.Errorf("expected 'Web browser', got %q", pkgs[0].Description)
}
})
t.Run("header only", func(t *testing.T) {
input := "Name Version Publisher Notes Summary\n"
pkgs := parseSnapFind(input)
if len(pkgs) != 0 {
t.Errorf("expected 0 packages, got %d", len(pkgs))
}
})
t.Run("too few fields skipped", func(t *testing.T) {
input := "Name Version Publisher Notes Summary\nfoo 1.0 pub\n"
pkgs := parseSnapFind(input)
if len(pkgs) != 0 {
t.Errorf("expected 0 packages (need >=4 fields), got %d", len(pkgs))
}
})
t.Run("exactly four fields no summary", func(t *testing.T) {
input := "Name Version Publisher Notes Summary\nfoo 1.0 pub note\n"
pkgs := parseSnapFind(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Description != "" {
t.Errorf("expected empty description, got %q", pkgs[0].Description)
}
})
t.Run("multi-word summary", func(t *testing.T) {
input := "Name Version Publisher Notes Summary\nmysnap 2.0 me - A very long description with many words\n"
pkgs := parseSnapFind(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Description != "A very long description with many words" {
t.Errorf("unexpected description: %q", pkgs[0].Description)
}
})
t.Run("empty input", func(t *testing.T) {
pkgs := parseSnapFind("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages, got %d", len(pkgs))
}
})
}
func TestParseSnapInfo(t *testing.T) { func TestParseSnapInfo(t *testing.T) {
input := `name: firefox input := `name: firefox
summary: Mozilla Firefox web browser summary: Mozilla Firefox web browser
@@ -84,6 +195,57 @@ func TestParseSnapInfoEmpty(t *testing.T) {
} }
} }
func TestParseSnapInfoEdgeCases(t *testing.T) {
t.Run("not installed snap", func(t *testing.T) {
input := `name: hello-world
summary: A simple hello world snap
publisher: Canonical✓ (canonical✓)
snap-id: buPKUD3TKqCOgLEjjHx5kSiCpIs5cMuQ
`
pkg := parseSnapInfo(input)
if pkg == nil {
t.Fatal("expected non-nil")
}
if pkg.Name != "hello-world" {
t.Errorf("expected hello-world, got %q", pkg.Name)
}
if pkg.Installed {
t.Error("expected Installed=false for snap without installed field")
}
if pkg.Version != "" {
t.Errorf("expected empty version, got %q", pkg.Version)
}
})
t.Run("name only", func(t *testing.T) {
pkg := parseSnapInfo("name: test\n")
if pkg == nil {
t.Fatal("expected non-nil")
}
if pkg.Name != "test" {
t.Errorf("expected test, got %q", pkg.Name)
}
})
t.Run("no name returns nil", func(t *testing.T) {
pkg := parseSnapInfo("summary: something\ninstalled: 1.0 (1) 10MB\n")
if pkg != nil {
t.Error("expected nil when no name field")
}
})
t.Run("no colon lines ignored", func(t *testing.T) {
input := "name: mysnap\nrandom text without colon\nsummary: A snap\n"
pkg := parseSnapInfo(input)
if pkg == nil {
t.Fatal("expected non-nil")
}
if pkg.Description != "A snap" {
t.Errorf("unexpected description: %q", pkg.Description)
}
})
}
func TestParseSnapInfoVersion(t *testing.T) { func TestParseSnapInfoVersion(t *testing.T) {
input := `name: firefox input := `name: firefox
channels: channels:
@@ -105,6 +267,39 @@ func TestParseSnapInfoVersionMissing(t *testing.T) {
} }
} }
func TestParseSnapInfoVersionEdgeCases(t *testing.T) {
t.Run("empty input", func(t *testing.T) {
ver := parseSnapInfoVersion("")
if ver != "" {
t.Errorf("expected empty, got %q", ver)
}
})
t.Run("stable channel with dashes", func(t *testing.T) {
input := " latest/stable: 2024.01.15 2024-01-15 (100) 50MB -\n"
ver := parseSnapInfoVersion(input)
if ver != "2024.01.15" {
t.Errorf("expected 2024.01.15, got %q", ver)
}
})
t.Run("closed channel marked --", func(t *testing.T) {
input := " latest/stable: --\n"
ver := parseSnapInfoVersion(input)
if ver != "" {
t.Errorf("expected empty for closed channel, got %q", ver)
}
})
t.Run("closed channel marked ^", func(t *testing.T) {
input := " latest/stable: ^ \n"
ver := parseSnapInfoVersion(input)
if ver != "" {
t.Errorf("expected empty for ^ channel, got %q", ver)
}
})
}
func TestParseSnapRefreshList(t *testing.T) { func TestParseSnapRefreshList(t *testing.T) {
input := `Name Version Rev Publisher Notes input := `Name Version Rev Publisher Notes
firefox 132.0 4650 mozilla✓ - firefox 132.0 4650 mozilla✓ -
@@ -128,30 +323,127 @@ All snaps up to date.
} }
} }
func TestParseSnapRefreshListEdgeCases(t *testing.T) {
t.Run("multiple upgrades", func(t *testing.T) {
input := "Name Version Rev Publisher Notes\nfoo 2.0 10 pub -\nbar 3.0 20 pub -\n"
pkgs := parseSnapRefreshList(input)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages, got %d", len(pkgs))
}
if pkgs[0].Name != "foo" || pkgs[1].Name != "bar" {
t.Errorf("unexpected packages: %+v, %+v", pkgs[0], pkgs[1])
}
})
t.Run("header only", func(t *testing.T) {
input := "Name Version Rev Publisher Notes\n"
pkgs := parseSnapRefreshList(input)
if len(pkgs) != 0 {
t.Errorf("expected 0 packages, got %d", len(pkgs))
}
})
}
func TestSemverCmp(t *testing.T) { func TestSemverCmp(t *testing.T) {
tests := []struct { tests := []struct {
name string
a, b string a, b string
want int want int
}{ }{
{"1.0.0", "1.0.0", 0}, {"equal", "1.0.0", "1.0.0", 0},
{"1.0.0", "2.0.0", -1}, {"less major", "1.0.0", "2.0.0", -1},
{"2.0.0", "1.0.0", 1}, {"greater major", "2.0.0", "1.0.0", 1},
{"1.2.3", "1.2.4", -1}, {"less patch", "1.2.3", "1.2.4", -1},
{"1.10.0", "1.9.0", 1}, {"multi-digit minor", "1.10.0", "1.9.0", 1},
{"1.0", "1.0.0", 0}, {"short vs long equal", "1.0", "1.0.0", 0},
{"131.0", "132.0", -1}, {"real versions", "131.0", "132.0", -1},
// Edge cases
{"single component", "5", "3", 1},
{"single equal", "3", "3", 0},
{"empty vs empty", "", "", 0},
{"empty vs version", "", "1.0", -1},
{"version vs empty", "1.0", "", 1},
{"non-numeric suffix", "1.0.0-beta", "1.0.0-rc1", 0},
{"four components", "1.2.3.4", "1.2.3.5", -1},
{"different lengths padded", "1.0.0.0", "1.0.0", 0},
{"short less", "1.0", "1.0.1", -1},
{"short greater", "1.1", "1.0.9", 1},
} }
for _, tt := range tests { for _, tt := range tests {
got := semverCmp(tt.a, tt.b) t.Run(tt.name, func(t *testing.T) {
if got != tt.want { got := semverCmp(tt.a, tt.b)
t.Errorf("semverCmp(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want) if got != tt.want {
} t.Errorf("semverCmp(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
func TestStripNonNumeric(t *testing.T) {
tests := []struct {
input string
want string
}{
{"123", "123"},
{"123abc", "123"},
{"abc", ""},
{"0beta", "0"},
{"", ""},
{"42-rc1", "42"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := stripNonNumeric(tt.input)
if got != tt.want {
t.Errorf("stripNonNumeric(%q) = %q, want %q", tt.input, got, tt.want)
}
})
} }
} }
func TestInterfaceCompliance(t *testing.T) { func TestInterfaceCompliance(t *testing.T) {
var _ snack.Manager = (*Snap)(nil) var _ snack.Manager = (*Snap)(nil)
var _ snack.VersionQuerier = (*Snap)(nil) var _ snack.VersionQuerier = (*Snap)(nil)
var _ snack.Cleaner = (*Snap)(nil)
var _ snack.PackageUpgrader = (*Snap)(nil)
}
// Compile-time interface checks
var (
_ snack.Cleaner = (*Snap)(nil)
_ snack.PackageUpgrader = (*Snap)(nil)
)
func TestCapabilities(t *testing.T) {
caps := snack.GetCapabilities(New())
if !caps.VersionQuery {
t.Error("expected VersionQuery=true")
}
if !caps.Clean {
t.Error("expected Clean=true")
}
// Should be false
if caps.FileOwnership {
t.Error("expected FileOwnership=false")
}
if caps.DryRun {
t.Error("expected DryRun=false")
}
if caps.Hold {
t.Error("expected Hold=false")
}
if caps.RepoManagement {
t.Error("expected RepoManagement=false")
}
if caps.KeyManagement {
t.Error("expected KeyManagement=false")
}
if caps.Groups {
t.Error("expected Groups=false")
}
if caps.NameNormalize {
t.Error("expected NameNormalize=false")
}
} }
func TestName(t *testing.T) { func TestName(t *testing.T) {