Merge pull request #34 from gogrlx/cd/apt-test-coverage

fix(apt): fix extractURL multi-token options parsing and add unit tests
This commit is contained in:
2026-03-05 12:26:45 -05:00
committed by GitHub
4 changed files with 482 additions and 6 deletions

View File

@@ -271,19 +271,25 @@ func listRepos(_ context.Context) ([]snack.Repository, error) {
// 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 strings.HasPrefix(f, "[") {
// skip options block
for ; i < len(fields); i++ {
if strings.HasSuffix(fields[i], "]") {
break
}
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 ""

199
apt/helpers_linux_test.go Normal file
View File

@@ -0,0 +1,199 @@
//go:build linux
package apt
import (
"testing"
"github.com/gogrlx/snack"
)
func TestFormatTargets(t *testing.T) {
tests := []struct {
name string
targets []snack.Target
want []string
}{
{
name: "empty",
targets: nil,
want: []string{},
},
{
name: "name_only",
targets: []snack.Target{{Name: "curl"}},
want: []string{"curl"},
},
{
name: "with_version",
targets: []snack.Target{{Name: "curl", Version: "7.88"}},
want: []string{"curl=7.88"},
},
{
name: "mixed",
targets: []snack.Target{
{Name: "curl"},
{Name: "bash", Version: "5.2-1"},
{Name: "vim"},
},
want: []string{"curl", "bash=5.2-1", "vim"},
},
{
name: "version_with_epoch",
targets: []snack.Target{{Name: "systemd", Version: "1:252-2"}},
want: []string{"systemd=1:252-2"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := formatTargets(tt.targets)
if len(got) != len(tt.want) {
t.Fatalf("formatTargets() returned %d args, want %d", len(got), len(tt.want))
}
for i, g := range got {
if g != tt.want[i] {
t.Errorf("formatTargets()[%d] = %q, want %q", i, g, tt.want[i])
}
}
})
}
}
func TestBuildArgs(t *testing.T) {
tests := []struct {
name string
cmd string
pkgs []snack.Target
opts []snack.Option
want []string
}{
{
name: "basic_install",
cmd: "install",
pkgs: []snack.Target{{Name: "curl"}},
want: []string{"apt-get", "install", "curl"},
},
{
name: "install_with_yes",
cmd: "install",
pkgs: []snack.Target{{Name: "curl"}},
opts: []snack.Option{snack.WithAssumeYes()},
want: []string{"apt-get", "install", "-y", "curl"},
},
{
name: "install_with_dry_run",
cmd: "install",
pkgs: []snack.Target{{Name: "curl"}},
opts: []snack.Option{snack.WithDryRun()},
want: []string{"apt-get", "install", "--dry-run", "curl"},
},
{
name: "install_with_sudo",
cmd: "install",
pkgs: []snack.Target{{Name: "curl"}},
opts: []snack.Option{snack.WithSudo()},
want: []string{"sudo", "apt-get", "install", "curl"},
},
{
name: "install_with_reinstall",
cmd: "install",
pkgs: []snack.Target{{Name: "curl"}},
opts: []snack.Option{snack.WithReinstall()},
want: []string{"apt-get", "install", "--reinstall", "curl"},
},
{
name: "remove_no_reinstall_flag",
cmd: "remove",
pkgs: []snack.Target{{Name: "curl"}},
opts: []snack.Option{snack.WithReinstall()},
want: []string{"apt-get", "remove", "curl"}, // --reinstall only on install
},
{
name: "install_from_repo",
cmd: "install",
pkgs: []snack.Target{{Name: "curl"}},
opts: []snack.Option{snack.WithFromRepo("stable")},
want: []string{"apt-get", "install", "-t", "stable", "curl"},
},
{
name: "all_options",
cmd: "install",
pkgs: []snack.Target{{Name: "curl", Version: "7.88"}},
opts: []snack.Option{snack.WithSudo(), snack.WithAssumeYes(), snack.WithDryRun(), snack.WithFromRepo("sid"), snack.WithReinstall()},
want: []string{"sudo", "apt-get", "install", "-y", "--dry-run", "-t", "sid", "--reinstall", "curl=7.88"},
},
{
name: "multiple_packages",
cmd: "install",
pkgs: []snack.Target{{Name: "curl"}, {Name: "wget"}, {Name: "bash", Version: "5.2"}},
want: []string{"apt-get", "install", "curl", "wget", "bash=5.2"},
},
{
name: "upgrade_no_packages",
cmd: "upgrade",
pkgs: nil,
opts: []snack.Option{snack.WithAssumeYes()},
want: []string{"apt-get", "upgrade", "-y"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := buildArgs(tt.cmd, tt.pkgs, tt.opts...)
if len(got) != len(tt.want) {
t.Fatalf("buildArgs() = %v (len %d), want %v (len %d)", got, len(got), tt.want, len(tt.want))
}
for i, g := range got {
if g != tt.want[i] {
t.Errorf("buildArgs()[%d] = %q, want %q", i, g, tt.want[i])
}
}
})
}
}
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)
}
})
}
}

75
apt/normalize_test.go Normal file
View File

@@ -0,0 +1,75 @@
package apt
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"},
// Colon that isn't a known arch should be kept
{"pkg:unknown", "pkg:unknown"},
{"libstdc++6:amd64", "libstdc++6"},
// No colon
{"", ""},
// 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)
}
})
}
}

196
apt/parse_test.go Normal file
View File

@@ -0,0 +1,196 @@
package apt
import (
"testing"
"github.com/gogrlx/snack"
)
func TestParseList_EdgeCases(t *testing.T) {
tests := []struct {
name string
input string
want int
}{
{"empty", "", 0},
{"whitespace_only", " \n \n ", 0},
{"single_tab_field", "bash", 0}, // needs at least 2 tab-separated fields
{"no_description", "bash\t5.2-1", 1}, // 2 fields is OK
{"with_description", "bash\t5.2-1\tGNU Bourne Again SHell", 1},
{"blank_lines_mixed", "\nbash\t5.2-1\n\ncurl\t7.88\n\n", 2},
{"trailing_newline", "bash\t5.2-1\n", 1},
}
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)
}
})
}
// Verify fields are populated correctly
t.Run("field_values", func(t *testing.T) {
input := "bash\t5.2-1\tGNU Bourne Again SHell"
pkgs := parseList(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
p := pkgs[0]
if p.Name != "bash" {
t.Errorf("Name = %q, want %q", p.Name, "bash")
}
if p.Version != "5.2-1" {
t.Errorf("Version = %q, want %q", p.Version, "5.2-1")
}
if p.Description != "GNU Bourne Again SHell" {
t.Errorf("Description = %q, want %q", p.Description, "GNU Bourne Again SHell")
}
if !p.Installed {
t.Error("expected Installed=true")
}
})
t.Run("no_description_fields", func(t *testing.T) {
pkgs := parseList("vim\t9.0")
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)
}
})
t.Run("description_with_tabs", func(t *testing.T) {
// Third field captures everything after the second tab
pkgs := parseList("pkg\t1.0\tdesc\twith\ttabs")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Description != "desc\twith\ttabs" {
t.Errorf("Description = %q, want %q", pkgs[0].Description, "desc\twith\ttabs")
}
})
}
func TestParseSearch_EdgeCases(t *testing.T) {
tests := []struct {
name string
input string
want int
}{
{"empty", "", 0},
{"whitespace_only", " \n ", 0},
{"no_dash_separator", "vim", 1}, // still parses, just no description
{"normal", "vim - Vi IMproved", 1},
{"blank_lines", "\nvim - Vi IMproved\n\nnano - small editor\n", 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgs := parseSearch(tt.input)
if len(pkgs) != tt.want {
t.Errorf("parseSearch() returned %d packages, want %d", len(pkgs), tt.want)
}
})
}
t.Run("no_description", func(t *testing.T) {
pkgs := parseSearch("vim")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "vim" {
t.Errorf("Name = %q, want %q", pkgs[0].Name, "vim")
}
if pkgs[0].Description != "" {
t.Errorf("Description = %q, want empty", pkgs[0].Description)
}
})
t.Run("description_with_dashes", func(t *testing.T) {
// Only splits on first " - "
pkgs := parseSearch("gcc - GNU C Compiler - version 12")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "gcc" {
t.Errorf("Name = %q, want %q", pkgs[0].Name, "gcc")
}
if pkgs[0].Description != "GNU C Compiler - version 12" {
t.Errorf("Description = %q, want %q", pkgs[0].Description, "GNU C Compiler - version 12")
}
})
t.Run("whitespace_trimming", func(t *testing.T) {
pkgs := parseSearch(" vim - Vi IMproved ")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "vim" {
t.Errorf("Name = %q, want %q", pkgs[0].Name, "vim")
}
if pkgs[0].Description != "Vi IMproved" {
t.Errorf("Description = %q, want %q", pkgs[0].Description, "Vi IMproved")
}
})
}
func TestParseInfo_EdgeCases(t *testing.T) {
t.Run("all_fields", func(t *testing.T) {
input := "Package: bash\nVersion: 5.2-1\nArchitecture: amd64\nDescription: GNU Bourne Again SHell\n"
p, err := parseInfo(input)
if err != nil {
t.Fatal(err)
}
if p.Name != "bash" || p.Version != "5.2-1" || p.Arch != "amd64" || p.Description != "GNU Bourne Again SHell" {
t.Errorf("unexpected package: %+v", p)
}
})
t.Run("empty_returns_not_found", func(t *testing.T) {
_, err := parseInfo("")
if err != snack.ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", err)
}
})
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("extra_fields_ignored", func(t *testing.T) {
input := "Package: curl\nMaintainer: someone\nVersion: 7.88\nPriority: optional\n"
p, err := parseInfo(input)
if err != nil {
t.Fatal(err)
}
if p.Name != "curl" || p.Version != "7.88" {
t.Errorf("unexpected: %+v", p)
}
})
t.Run("lines_without_colon", func(t *testing.T) {
input := "Package: vim\nsome continuation line\nVersion: 9.0\n"
p, err := parseInfo(input)
if err != nil {
t.Fatal(err)
}
if p.Name != "vim" || p.Version != "9.0" {
t.Errorf("unexpected: %+v", p)
}
})
t.Run("version_with_epoch", func(t *testing.T) {
input := "Package: systemd\nVersion: 1:252-2\nArchitecture: amd64\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")
}
})
}