detached f12840f

test(updater): add unit tests for IsPacman and SelfUpdateBlockedReason

commit
f12840fe45a39c8107414ddb3a877cff541bda43
author
bnema · · 2025-12-30 21:32 UTC
parent
57043db
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +237 -0
M internal/infrastructure/env/manager_test.go +62 -0
@@ -3,6 +3,7 @@ package env_test
33 import (
44 "context"
55 "os"
6+ "os/exec"
67 "testing"
78
89 "github.com/bnema/dumber/internal/application/port"
@@ -170,3 +171,64 @@ func restoreEnv(key, value string) {
170171 os.Setenv(key, value)
171172 }
172173 }
174+
175+func TestIsFlatpak(t *testing.T) {
176+ // IsFlatpak checks for /.flatpak-info file existence
177+ // In normal test environment, this should return false
178+ result := env.IsFlatpak()
179+
180+ // We can't easily mock filesystem, so just verify it returns a boolean
181+ // and doesn't panic. In CI/dev environment it should be false.
182+ assert.False(t, result, "should not detect Flatpak in test environment")
183+}
184+
185+func TestIsPacman(t *testing.T) {
186+ tests := []struct {
187+ name string
188+ binary string
189+ expected bool
190+ }{
191+ {
192+ name: "system binary owned by pacman",
193+ binary: "/usr/bin/bash",
194+ expected: true, // bash is always installed via pacman on Arch
195+ },
196+ {
197+ name: "non-existent binary",
198+ binary: "/nonexistent/path/to/binary",
199+ expected: false,
200+ },
201+ }
202+
203+ // Only run these tests on Arch-based systems with pacman
204+ if _, err := os.Stat("/usr/bin/pacman"); os.IsNotExist(err) {
205+ t.Skip("pacman not found, skipping Arch-specific tests")
206+ }
207+
208+ for _, tt := range tests {
209+ t.Run(tt.name, func(t *testing.T) {
210+ // We can't easily test IsPacman directly since it uses os.Executable()
211+ // but we can test the underlying logic with exec.Command
212+ if tt.binary == "/usr/bin/bash" {
213+ // Verify pacman -Qo works for a known system binary
214+ cmd := exec.Command("pacman", "-Qo", tt.binary)
215+ err := cmd.Run()
216+ if tt.expected {
217+ assert.NoError(t, err, "pacman should own %s", tt.binary)
218+ } else {
219+ assert.Error(t, err, "pacman should not own %s", tt.binary)
220+ }
221+ }
222+ })
223+ }
224+}
225+
226+func TestIsPacman_NonArchSystem(t *testing.T) {
227+ // On non-Arch systems, IsPacman should return false (pacman not found)
228+ if _, err := os.Stat("/usr/bin/pacman"); err == nil {
229+ t.Skip("pacman found, skipping non-Arch test")
230+ }
231+
232+ result := env.IsPacman()
233+ assert.False(t, result, "should return false when pacman is not installed")
234+}
@@ -3,6 +3,7 @@ package env_test
3 import (3 import (
4 "context"4 "context"
5 "os"5 "os"
6+ "os/exec"
6 "testing"7 "testing"
7 8
8 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
@@ -170,3 +171,64 @@ func restoreEnv(key, value string) {
170 os.Setenv(key, value)171 os.Setenv(key, value)
171 }172 }
172 }173 }
174+
175+func TestIsFlatpak(t *testing.T) {
176+ // IsFlatpak checks for /.flatpak-info file existence
177+ // In normal test environment, this should return false
178+ result := env.IsFlatpak()
179+
180+ // We can't easily mock filesystem, so just verify it returns a boolean
181+ // and doesn't panic. In CI/dev environment it should be false.
182+ assert.False(t, result, "should not detect Flatpak in test environment")
183+}
184+
185+func TestIsPacman(t *testing.T) {
186+ tests := []struct {
187+ name string
188+ binary string
189+ expected bool
190+ }{
191+ {
192+ name: "system binary owned by pacman",
193+ binary: "/usr/bin/bash",
194+ expected: true, // bash is always installed via pacman on Arch
195+ },
196+ {
197+ name: "non-existent binary",
198+ binary: "/nonexistent/path/to/binary",
199+ expected: false,
200+ },
201+ }
202+
203+ // Only run these tests on Arch-based systems with pacman
204+ if _, err := os.Stat("/usr/bin/pacman"); os.IsNotExist(err) {
205+ t.Skip("pacman not found, skipping Arch-specific tests")
206+ }
207+
208+ for _, tt := range tests {
209+ t.Run(tt.name, func(t *testing.T) {
210+ // We can't easily test IsPacman directly since it uses os.Executable()
211+ // but we can test the underlying logic with exec.Command
212+ if tt.binary == "/usr/bin/bash" {
213+ // Verify pacman -Qo works for a known system binary
214+ cmd := exec.Command("pacman", "-Qo", tt.binary)
215+ err := cmd.Run()
216+ if tt.expected {
217+ assert.NoError(t, err, "pacman should own %s", tt.binary)
218+ } else {
219+ assert.Error(t, err, "pacman should not own %s", tt.binary)
220+ }
221+ }
222+ })
223+ }
224+}
225+
226+func TestIsPacman_NonArchSystem(t *testing.T) {
227+ // On non-Arch systems, IsPacman should return false (pacman not found)
228+ if _, err := os.Stat("/usr/bin/pacman"); err == nil {
229+ t.Skip("pacman found, skipping non-Arch test")
230+ }
231+
232+ result := env.IsPacman()
233+ assert.False(t, result, "should return false when pacman is not installed")
234+}
A internal/infrastructure/updater/applier_test.go +175 -0
@@ -0,0 +1,175 @@
1+package updater
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestApplier_SelfUpdateBlockedReason(t *testing.T) {
15+ // Create a temporary directory for test state
16+ tmpDir := t.TempDir()
17+ applier := NewApplier(tmpDir)
18+
19+ ctx := context.Background()
20+
21+ // Get the reason - the actual result depends on the environment
22+ reason := applier.SelfUpdateBlockedReason(ctx)
23+
24+ // Verify it returns a valid reason type
25+ validReasons := []port.SelfUpdateBlockedReason{
26+ port.SelfUpdateAllowed,
27+ port.SelfUpdateBlockedFlatpak,
28+ port.SelfUpdateBlockedPacman,
29+ port.SelfUpdateBlockedNotWritable,
30+ }
31+
32+ found := false
33+ for _, valid := range validReasons {
34+ if reason == valid {
35+ found = true
36+ break
37+ }
38+ }
39+ assert.True(t, found, "SelfUpdateBlockedReason returned invalid reason: %q", reason)
40+}
41+
42+func TestApplier_CanSelfUpdate_ConsistentWithReason(t *testing.T) {
43+ tmpDir := t.TempDir()
44+ applier := NewApplier(tmpDir)
45+ ctx := context.Background()
46+
47+ canUpdate := applier.CanSelfUpdate(ctx)
48+ reason := applier.SelfUpdateBlockedReason(ctx)
49+
50+ // If CanSelfUpdate returns true, reason should be SelfUpdateAllowed
51+ if canUpdate {
52+ assert.Equal(t, port.SelfUpdateAllowed, reason,
53+ "CanSelfUpdate=true but SelfUpdateBlockedReason=%q", reason)
54+ } else {
55+ // If CanSelfUpdate returns false, reason should NOT be SelfUpdateAllowed
56+ assert.NotEqual(t, port.SelfUpdateAllowed, reason,
57+ "CanSelfUpdate=false but SelfUpdateBlockedReason is empty")
58+ }
59+}
60+
61+func TestApplier_StagingPaths(t *testing.T) {
62+ tmpDir := t.TempDir()
63+ applier := NewApplier(tmpDir)
64+
65+ // Test staging directory path
66+ stagingDir := applier.stagingDir()
67+ assert.Equal(t, filepath.Join(tmpDir, stagingDirName), stagingDir)
68+
69+ // Test staged binary path
70+ stagedPath := applier.stagedBinaryPath()
71+ assert.Equal(t, filepath.Join(tmpDir, stagingDirName, stagedBinaryName), stagedPath)
72+}
73+
74+func TestApplier_HasStagedUpdate(t *testing.T) {
75+ tmpDir := t.TempDir()
76+ applier := NewApplier(tmpDir)
77+ ctx := context.Background()
78+
79+ // Initially no staged update
80+ assert.False(t, applier.HasStagedUpdate(ctx), "should have no staged update initially")
81+
82+ // Create staging directory and file
83+ stagingDir := applier.stagingDir()
84+ require.NoError(t, os.MkdirAll(stagingDir, 0o755))
85+
86+ stagedPath := applier.stagedBinaryPath()
87+
88+ // Create a non-executable file (should not count as staged)
89+ require.NoError(t, os.WriteFile(stagedPath, []byte("test"), 0o644))
90+ assert.False(t, applier.HasStagedUpdate(ctx), "non-executable file should not count as staged")
91+
92+ // Make it executable
93+ require.NoError(t, os.Chmod(stagedPath, 0o755))
94+ assert.True(t, applier.HasStagedUpdate(ctx), "executable file should count as staged")
95+}
96+
97+func TestApplier_ClearStagedUpdate(t *testing.T) {
98+ tmpDir := t.TempDir()
99+ applier := NewApplier(tmpDir)
100+ ctx := context.Background()
101+
102+ // Create staged update
103+ stagingDir := applier.stagingDir()
104+ require.NoError(t, os.MkdirAll(stagingDir, 0o755))
105+ stagedPath := applier.stagedBinaryPath()
106+ require.NoError(t, os.WriteFile(stagedPath, []byte("test"), 0o755))
107+
108+ assert.True(t, applier.HasStagedUpdate(ctx), "should have staged update")
109+
110+ // Clear it
111+ require.NoError(t, applier.ClearStagedUpdate(ctx))
112+
113+ assert.False(t, applier.HasStagedUpdate(ctx), "should have no staged update after clear")
114+
115+ // Verify directory was removed
116+ _, err := os.Stat(stagingDir)
117+ assert.True(t, os.IsNotExist(err), "staging directory should be removed")
118+}
119+
120+func TestApplier_StageUpdate(t *testing.T) {
121+ tmpDir := t.TempDir()
122+ applier := NewApplier(tmpDir)
123+ ctx := context.Background()
124+
125+ // Create a fake binary to stage
126+ srcBinary := filepath.Join(tmpDir, "new-binary")
127+ binaryContent := []byte("#!/bin/sh\necho 'new version'\n")
128+ require.NoError(t, os.WriteFile(srcBinary, binaryContent, 0o755))
129+
130+ // Stage the update
131+ require.NoError(t, applier.StageUpdate(ctx, srcBinary))
132+
133+ // Verify it was staged
134+ assert.True(t, applier.HasStagedUpdate(ctx), "should have staged update")
135+
136+ // Verify content was copied
137+ stagedContent, err := os.ReadFile(applier.stagedBinaryPath())
138+ require.NoError(t, err)
139+ assert.Equal(t, binaryContent, stagedContent, "staged content should match source")
140+
141+ // Verify permissions
142+ info, err := os.Stat(applier.stagedBinaryPath())
143+ require.NoError(t, err)
144+ assert.Equal(t, os.FileMode(0o755), info.Mode().Perm(), "staged binary should be executable")
145+}
146+
147+func TestNewApplierFromXDG(t *testing.T) {
148+ // This test verifies NewApplierFromXDG doesn't panic and returns valid applier
149+ applier, err := NewApplierFromXDG()
150+ require.NoError(t, err)
151+ require.NotNil(t, applier)
152+
153+ // Verify it has a valid state directory
154+ assert.NotEmpty(t, applier.stateDir)
155+}
156+
157+func TestSelfUpdateBlockedReason_Constants(t *testing.T) {
158+ // Verify the constants are distinct
159+ reasons := []port.SelfUpdateBlockedReason{
160+ port.SelfUpdateAllowed,
161+ port.SelfUpdateBlockedFlatpak,
162+ port.SelfUpdateBlockedPacman,
163+ port.SelfUpdateBlockedNotWritable,
164+ }
165+
166+ seen := make(map[port.SelfUpdateBlockedReason]bool)
167+ for _, r := range reasons {
168+ assert.False(t, seen[r], "duplicate reason: %q", r)
169+ seen[r] = true
170+ }
171+
172+ // Verify SelfUpdateAllowed is empty string
173+ assert.Equal(t, port.SelfUpdateBlockedReason(""), port.SelfUpdateAllowed,
174+ "SelfUpdateAllowed should be empty string")
175+}
@@ -0,0 +1,175 @@
1+package updater
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestApplier_SelfUpdateBlockedReason(t *testing.T) {
15+ // Create a temporary directory for test state
16+ tmpDir := t.TempDir()
17+ applier := NewApplier(tmpDir)
18+
19+ ctx := context.Background()
20+
21+ // Get the reason - the actual result depends on the environment
22+ reason := applier.SelfUpdateBlockedReason(ctx)
23+
24+ // Verify it returns a valid reason type
25+ validReasons := []port.SelfUpdateBlockedReason{
26+ port.SelfUpdateAllowed,
27+ port.SelfUpdateBlockedFlatpak,
28+ port.SelfUpdateBlockedPacman,
29+ port.SelfUpdateBlockedNotWritable,
30+ }
31+
32+ found := false
33+ for _, valid := range validReasons {
34+ if reason == valid {
35+ found = true
36+ break
37+ }
38+ }
39+ assert.True(t, found, "SelfUpdateBlockedReason returned invalid reason: %q", reason)
40+}
41+
42+func TestApplier_CanSelfUpdate_ConsistentWithReason(t *testing.T) {
43+ tmpDir := t.TempDir()
44+ applier := NewApplier(tmpDir)
45+ ctx := context.Background()
46+
47+ canUpdate := applier.CanSelfUpdate(ctx)
48+ reason := applier.SelfUpdateBlockedReason(ctx)
49+
50+ // If CanSelfUpdate returns true, reason should be SelfUpdateAllowed
51+ if canUpdate {
52+ assert.Equal(t, port.SelfUpdateAllowed, reason,
53+ "CanSelfUpdate=true but SelfUpdateBlockedReason=%q", reason)
54+ } else {
55+ // If CanSelfUpdate returns false, reason should NOT be SelfUpdateAllowed
56+ assert.NotEqual(t, port.SelfUpdateAllowed, reason,
57+ "CanSelfUpdate=false but SelfUpdateBlockedReason is empty")
58+ }
59+}
60+
61+func TestApplier_StagingPaths(t *testing.T) {
62+ tmpDir := t.TempDir()
63+ applier := NewApplier(tmpDir)
64+
65+ // Test staging directory path
66+ stagingDir := applier.stagingDir()
67+ assert.Equal(t, filepath.Join(tmpDir, stagingDirName), stagingDir)
68+
69+ // Test staged binary path
70+ stagedPath := applier.stagedBinaryPath()
71+ assert.Equal(t, filepath.Join(tmpDir, stagingDirName, stagedBinaryName), stagedPath)
72+}
73+
74+func TestApplier_HasStagedUpdate(t *testing.T) {
75+ tmpDir := t.TempDir()
76+ applier := NewApplier(tmpDir)
77+ ctx := context.Background()
78+
79+ // Initially no staged update
80+ assert.False(t, applier.HasStagedUpdate(ctx), "should have no staged update initially")
81+
82+ // Create staging directory and file
83+ stagingDir := applier.stagingDir()
84+ require.NoError(t, os.MkdirAll(stagingDir, 0o755))
85+
86+ stagedPath := applier.stagedBinaryPath()
87+
88+ // Create a non-executable file (should not count as staged)
89+ require.NoError(t, os.WriteFile(stagedPath, []byte("test"), 0o644))
90+ assert.False(t, applier.HasStagedUpdate(ctx), "non-executable file should not count as staged")
91+
92+ // Make it executable
93+ require.NoError(t, os.Chmod(stagedPath, 0o755))
94+ assert.True(t, applier.HasStagedUpdate(ctx), "executable file should count as staged")
95+}
96+
97+func TestApplier_ClearStagedUpdate(t *testing.T) {
98+ tmpDir := t.TempDir()
99+ applier := NewApplier(tmpDir)
100+ ctx := context.Background()
101+
102+ // Create staged update
103+ stagingDir := applier.stagingDir()
104+ require.NoError(t, os.MkdirAll(stagingDir, 0o755))
105+ stagedPath := applier.stagedBinaryPath()
106+ require.NoError(t, os.WriteFile(stagedPath, []byte("test"), 0o755))
107+
108+ assert.True(t, applier.HasStagedUpdate(ctx), "should have staged update")
109+
110+ // Clear it
111+ require.NoError(t, applier.ClearStagedUpdate(ctx))
112+
113+ assert.False(t, applier.HasStagedUpdate(ctx), "should have no staged update after clear")
114+
115+ // Verify directory was removed
116+ _, err := os.Stat(stagingDir)
117+ assert.True(t, os.IsNotExist(err), "staging directory should be removed")
118+}
119+
120+func TestApplier_StageUpdate(t *testing.T) {
121+ tmpDir := t.TempDir()
122+ applier := NewApplier(tmpDir)
123+ ctx := context.Background()
124+
125+ // Create a fake binary to stage
126+ srcBinary := filepath.Join(tmpDir, "new-binary")
127+ binaryContent := []byte("#!/bin/sh\necho 'new version'\n")
128+ require.NoError(t, os.WriteFile(srcBinary, binaryContent, 0o755))
129+
130+ // Stage the update
131+ require.NoError(t, applier.StageUpdate(ctx, srcBinary))
132+
133+ // Verify it was staged
134+ assert.True(t, applier.HasStagedUpdate(ctx), "should have staged update")
135+
136+ // Verify content was copied
137+ stagedContent, err := os.ReadFile(applier.stagedBinaryPath())
138+ require.NoError(t, err)
139+ assert.Equal(t, binaryContent, stagedContent, "staged content should match source")
140+
141+ // Verify permissions
142+ info, err := os.Stat(applier.stagedBinaryPath())
143+ require.NoError(t, err)
144+ assert.Equal(t, os.FileMode(0o755), info.Mode().Perm(), "staged binary should be executable")
145+}
146+
147+func TestNewApplierFromXDG(t *testing.T) {
148+ // This test verifies NewApplierFromXDG doesn't panic and returns valid applier
149+ applier, err := NewApplierFromXDG()
150+ require.NoError(t, err)
151+ require.NotNil(t, applier)
152+
153+ // Verify it has a valid state directory
154+ assert.NotEmpty(t, applier.stateDir)
155+}
156+
157+func TestSelfUpdateBlockedReason_Constants(t *testing.T) {
158+ // Verify the constants are distinct
159+ reasons := []port.SelfUpdateBlockedReason{
160+ port.SelfUpdateAllowed,
161+ port.SelfUpdateBlockedFlatpak,
162+ port.SelfUpdateBlockedPacman,
163+ port.SelfUpdateBlockedNotWritable,
164+ }
165+
166+ seen := make(map[port.SelfUpdateBlockedReason]bool)
167+ for _, r := range reasons {
168+ assert.False(t, seen[r], "duplicate reason: %q", r)
169+ seen[r] = true
170+ }
171+
172+ // Verify SelfUpdateAllowed is empty string
173+ assert.Equal(t, port.SelfUpdateBlockedReason(""), port.SelfUpdateAllowed,
174+ "SelfUpdateAllowed should be empty string")
175+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close