From 60b68060e793fdef4f187fa8d91b43f7cb92fda0 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Fri, 6 Mar 2026 00:01:43 +0000 Subject: [PATCH] test(dpkg,apk): exhaustive tests for dpkg extras, fix apk parseInfoNameVersion panic - dpkg: add normalize_test.go with NormalizeName/ParseArch table tests - dpkg: add capabilities, DryRunner, interface compliance, parse edge cases - apk: fix parseInfoNameVersion panic on empty input - apk: add empty/whitespace test cases for parseInfoNameVersion 807 tests passing, 0 failures. --- apk/apk_test.go | 12 ++++ apk/parse.go | 10 +-- dpkg/dpkg_test.go | 143 ++++++++++++++++++++++++++++++++++++++++- dpkg/normalize_test.go | 74 +++++++++++++++++++++ 4 files changed, 233 insertions(+), 6 deletions(-) create mode 100644 dpkg/normalize_test.go diff --git a/apk/apk_test.go b/apk/apk_test.go index 384fbf8..baf8c4b 100644 --- a/apk/apk_test.go +++ b/apk/apk_test.go @@ -312,6 +312,18 @@ func TestParseInfoNameVersion(t *testing.T) { wantN: "lib-ssl-dev", wantV: "3.0.0-r0", }, + { + name: "empty", + input: "", + wantN: "", + wantV: "", + }, + { + name: "whitespace_only", + input: " ", + wantN: "", + wantV: "", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/apk/parse.go b/apk/parse.go index 2a7c7d4..af6756a 100644 --- a/apk/parse.go +++ b/apk/parse.go @@ -127,10 +127,12 @@ func parseInfo(output string) *snack.Package { // The first line is typically "pkgname-version description". func parseInfoNameVersion(output string) (string, string) { lines := strings.Split(strings.TrimSpace(output), "\n") - if len(lines) == 0 { + if len(lines) == 0 || lines[0] == "" { return "", "" } - // first line: name-version - first := strings.Fields(lines[0])[0] - return splitNameVersion(first) + fields := strings.Fields(lines[0]) + if len(fields) == 0 { + return "", "" + } + return splitNameVersion(fields[0]) } diff --git a/dpkg/dpkg_test.go b/dpkg/dpkg_test.go index f0b4ace..882b39a 100644 --- a/dpkg/dpkg_test.go +++ b/dpkg/dpkg_test.go @@ -86,5 +86,144 @@ func TestUpdateUnsupported(t *testing.T) { } } -// Verify Dpkg implements snack.Manager at compile time. -var _ snack.Manager = (*Dpkg)(nil) +// Compile-time interface assertions. +var ( + _ snack.Manager = (*Dpkg)(nil) + _ snack.FileOwner = (*Dpkg)(nil) + _ snack.NameNormalizer = (*Dpkg)(nil) + _ snack.DryRunner = (*Dpkg)(nil) +) + +func TestSupportsDryRun(t *testing.T) { + d := New() + if !d.SupportsDryRun() { + t.Error("expected SupportsDryRun() = true") + } +} + +func TestCapabilities(t *testing.T) { + caps := snack.GetCapabilities(New()) + checks := []struct { + name string + got bool + want bool + }{ + {"FileOwnership", caps.FileOwnership, true}, + {"NameNormalize", caps.NameNormalize, true}, + {"DryRun", caps.DryRun, true}, + {"VersionQuery", caps.VersionQuery, false}, + {"Hold", caps.Hold, false}, + {"Clean", caps.Clean, false}, + {"RepoManagement", caps.RepoManagement, false}, + {"KeyManagement", caps.KeyManagement, false}, + {"Groups", caps.Groups, false}, + } + for _, c := range checks { + t.Run(c.name, func(t *testing.T) { + if c.got != c.want { + t.Errorf("%s = %v, want %v", c.name, c.got, c.want) + } + }) + } +} + +func TestNormalizeNameMethod(t *testing.T) { + d := New() + if got := d.NormalizeName("curl:amd64"); got != "curl" { + t.Errorf("NormalizeName(curl:amd64) = %q, want %q", got, "curl") + } +} + +func TestParseArchMethod(t *testing.T) { + d := New() + name, arch := d.ParseArch("bash:arm64") + if name != "bash" || arch != "arm64" { + t.Errorf("ParseArch(bash:arm64) = (%q, %q), want (bash, arm64)", name, arch) + } +} + +func TestParseListEdgeCases(t *testing.T) { + tests := []struct { + name string + input string + want int + }{ + {"empty", "", 0}, + {"whitespace_only", " \n \n ", 0}, + {"single_installed", "bash\t5.2-1\tinstall ok installed", 1}, + {"no_status_field", "bash\t5.2-1", 1}, + {"blank_lines_mixed", "\nbash\t5.2-1\tinstall ok installed\n\ncurl\t7.88\tinstall ok installed\n", 2}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkgs := parseList(tt.input) + if len(pkgs) != tt.want { + t.Errorf("parseList() returned %d packages, want %d", len(pkgs), tt.want) + } + }) + } +} + +func TestParseDpkgListEdgeCases(t *testing.T) { + tests := []struct { + name string + input string + want int + }{ + {"empty", "", 0}, + {"header_only", "Desired=Unknown/Install/Remove/Purge/Hold\n||/ Name Version Architecture Description\n+++-====-====-====-====", 0}, + {"single_package", "ii bash 5.2-1 amd64 GNU Bourne Again SHell", 1}, + {"held_package", "hi nginx 1.24 amd64 web server", 1}, + {"purge_pending", "pn oldpkg 1.0 amd64 old package", 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkgs := parseDpkgList(tt.input) + if len(pkgs) != tt.want { + t.Errorf("parseDpkgList() returned %d packages, want %d", len(pkgs), tt.want) + } + }) + } +} + +func TestParseInfoEdgeCases(t *testing.T) { + t.Run("no_package_field", func(t *testing.T) { + _, err := parseInfo("Version: 1.0\nArchitecture: amd64\n") + if err != snack.ErrNotFound { + t.Errorf("expected ErrNotFound, got %v", err) + } + }) + + t.Run("all_fields", func(t *testing.T) { + input := "Package: vim\nStatus: install ok installed\nVersion: 9.0\nArchitecture: arm64\nDescription: Vi IMproved\n" + p, err := parseInfo(input) + if err != nil { + t.Fatal(err) + } + if p.Name != "vim" || p.Version != "9.0" || p.Arch != "arm64" || !p.Installed { + t.Errorf("unexpected: %+v", p) + } + }) + + t.Run("version_with_epoch", func(t *testing.T) { + input := "Package: systemd\nVersion: 1:252-2\n" + p, err := parseInfo(input) + if err != nil { + t.Fatal(err) + } + if p.Version != "1:252-2" { + t.Errorf("Version = %q, want %q", p.Version, "1:252-2") + } + }) + + t.Run("not_installed_status", func(t *testing.T) { + input := "Package: curl\nStatus: deinstall ok config-files\nVersion: 7.88\n" + p, err := parseInfo(input) + if err != nil { + t.Fatal(err) + } + if p.Installed { + t.Error("expected Installed=false for deinstall status") + } + }) +} diff --git a/dpkg/normalize_test.go b/dpkg/normalize_test.go new file mode 100644 index 0000000..4b3f713 --- /dev/null +++ b/dpkg/normalize_test.go @@ -0,0 +1,74 @@ +package dpkg + +import "testing" + +func TestNormalizeName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"curl", "curl"}, + {"curl:amd64", "curl"}, + {"bash:arm64", "bash"}, + {"python3:i386", "python3"}, + {"libc6:armhf", "libc6"}, + {"pkg:armel", "pkg"}, + {"pkg:mips", "pkg"}, + {"pkg:mipsel", "pkg"}, + {"pkg:mips64el", "pkg"}, + {"pkg:ppc64el", "pkg"}, + {"pkg:s390x", "pkg"}, + {"pkg:all", "pkg"}, + {"pkg:any", "pkg"}, + // Unknown arch suffix should be kept + {"pkg:unknown", "pkg:unknown"}, + {"libstdc++6:amd64", "libstdc++6"}, + {"", ""}, + // Multiple colons — only last one checked + {"a:b:amd64", "a:b"}, + } + 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 TestParseArch(t *testing.T) { + tests := []struct { + input string + wantName string + wantArch string + }{ + {"curl:amd64", "curl", "amd64"}, + {"bash:arm64", "bash", "arm64"}, + {"python3", "python3", ""}, + {"curl:i386", "curl", "i386"}, + {"pkg:armhf", "pkg", "armhf"}, + {"pkg:armel", "pkg", "armel"}, + {"pkg:mips", "pkg", "mips"}, + {"pkg:mipsel", "pkg", "mipsel"}, + {"pkg:mips64el", "pkg", "mips64el"}, + {"pkg:ppc64el", "pkg", "ppc64el"}, + {"pkg:s390x", "pkg", "s390x"}, + {"pkg:all", "pkg", "all"}, + {"pkg:any", "pkg", "any"}, + // Unknown arch — not split + {"pkg:foobar", "pkg:foobar", ""}, + {"", "", ""}, + // Multiple colons + {"a:b:arm64", "a:b", "arm64"}, + } + 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) + } + }) + } +}