detached 1357af0

test(phases): add comprehensive unit tests for preflight and bootstrap phases

commit
1357af0f47574dd81843134c14d6148f7a967a4c
author
bnema · · 2025-10-27 17:10 UTC
committer
bnema · · 2025-10-27 18:00 UTC
parent
33586b1
signature
unsigned
2 files · +830 -0
A internal/phases/bootstrap_test.go +357 -0
@@ -0,0 +1,357 @@
1+package phases
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "io"
7+ "net/http"
8+ "path/filepath"
9+ "testing"
10+
11+ "github.com/bnema/archup/internal/config"
12+ "github.com/bnema/archup/internal/interfaces/mocks"
13+ "github.com/bnema/archup/internal/logger"
14+ "go.uber.org/mock/gomock"
15+)
16+
17+// TestBootstrapPhasePreCheck tests internet connectivity validation
18+func TestBootstrapPhasePreCheck(t *testing.T) {
19+ tests := []struct {
20+ name string
21+ setupMocks func(*mocks.MockHTTPClient)
22+ wantErr bool
23+ errContains string
24+ }{
25+ {
26+ name: "Internet connectivity - success",
27+ setupMocks: func(mockHTTP *mocks.MockHTTPClient) {
28+ resp := &http.Response{
29+ StatusCode: http.StatusOK,
30+ Body: io.NopCloser(bytes.NewBufferString("")),
31+ }
32+ mockHTTP.EXPECT().Get("https://raw.githubusercontent.com").Return(resp, nil).Times(1)
33+ },
34+ wantErr: false,
35+ },
36+ {
37+ name: "Internet connectivity - connection failed",
38+ setupMocks: func(mockHTTP *mocks.MockHTTPClient) {
39+ mockHTTP.EXPECT().Get("https://raw.githubusercontent.com").Return(nil, fmt.Errorf("connection refused")).Times(1)
40+ },
41+ wantErr: true,
42+ errContains: "no internet connectivity",
43+ },
44+ {
45+ name: "Internet connectivity - timeout",
46+ setupMocks: func(mockHTTP *mocks.MockHTTPClient) {
47+ mockHTTP.EXPECT().Get("https://raw.githubusercontent.com").Return(nil, fmt.Errorf("context deadline exceeded")).Times(1)
48+ },
49+ wantErr: true,
50+ errContains: "no internet connectivity",
51+ },
52+ }
53+
54+ for _, tt := range tests {
55+ t.Run(tt.name, func(t *testing.T) {
56+ ctrl := gomock.NewController(t)
57+ defer ctrl.Finish()
58+
59+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
60+ mockFS := mocks.NewMockFileSystem(ctrl)
61+
62+ tt.setupMocks(mockHTTP)
63+
64+ tmpDir := t.TempDir()
65+ logPath := filepath.Join(tmpDir, "test.log")
66+ log, err := logger.New(logPath, false)
67+ if err != nil {
68+ t.Fatalf("Failed to create logger: %v", err)
69+ }
70+ defer log.Close()
71+
72+ cfg := config.NewConfig("test")
73+ phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS)
74+
75+ err = phase.PreCheck()
76+ if (err != nil) != tt.wantErr {
77+ t.Errorf("PreCheck() error = %v, wantErr %v", err, tt.wantErr)
78+ return
79+ }
80+ if tt.wantErr && err != nil {
81+ if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
82+ t.Errorf("PreCheck() error = %q, should contain %q", errMsg, tt.errContains)
83+ }
84+ }
85+ })
86+ }
87+}
88+
89+// TestBootstrapPhaseDownloadFile tests file download functionality
90+func TestBootstrapPhaseDownloadFile(t *testing.T) {
91+ tests := []struct {
92+ name string
93+ url string
94+ setupMocks func(*mocks.MockHTTPClient, *mocks.MockFileSystem)
95+ wantErr bool
96+ errContains string
97+ }{
98+ {
99+ name: "Download file - success",
100+ url: "https://example.com/file.txt",
101+ setupMocks: func(mockHTTP *mocks.MockHTTPClient, mockFS *mocks.MockFileSystem) {
102+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).Times(1)
103+
104+ resp := &http.Response{
105+ StatusCode: http.StatusOK,
106+ Status: "200 OK",
107+ Body: io.NopCloser(bytes.NewBufferString("file content")),
108+ }
109+ mockHTTP.EXPECT().Get("https://example.com/file.txt").Return(resp, nil).Times(1)
110+
111+ mockFile := NewMockWriteCloser()
112+ mockFS.EXPECT().Create(gomock.Any()).Return(mockFile, nil).Times(1)
113+ },
114+ wantErr: false,
115+ },
116+ {
117+ name: "Download file - HTTP 404 not found",
118+ url: "https://example.com/missing.txt",
119+ setupMocks: func(mockHTTP *mocks.MockHTTPClient, mockFS *mocks.MockFileSystem) {
120+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).Times(1)
121+
122+ resp := &http.Response{
123+ StatusCode: http.StatusNotFound,
124+ Status: "404 Not Found",
125+ Body: io.NopCloser(bytes.NewBufferString("")),
126+ }
127+ mockHTTP.EXPECT().Get("https://example.com/missing.txt").Return(resp, nil).Times(1)
128+ },
129+ wantErr: true,
130+ errContains: "HTTP 404",
131+ },
132+ {
133+ name: "Download file - HTTP 500 server error",
134+ url: "https://example.com/error.txt",
135+ setupMocks: func(mockHTTP *mocks.MockHTTPClient, mockFS *mocks.MockFileSystem) {
136+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).Times(1)
137+
138+ resp := &http.Response{
139+ StatusCode: http.StatusInternalServerError,
140+ Status: "500 Internal Server Error",
141+ Body: io.NopCloser(bytes.NewBufferString("")),
142+ }
143+ mockHTTP.EXPECT().Get("https://example.com/error.txt").Return(resp, nil).Times(1)
144+ },
145+ wantErr: true,
146+ errContains: "HTTP 500",
147+ },
148+ {
149+ name: "Download file - HTTP request failed",
150+ url: "https://example.com/file.txt",
151+ setupMocks: func(mockHTTP *mocks.MockHTTPClient, mockFS *mocks.MockFileSystem) {
152+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).Times(1)
153+
154+ mockHTTP.EXPECT().Get("https://example.com/file.txt").Return(nil, fmt.Errorf("connection timeout")).Times(1)
155+ },
156+ wantErr: true,
157+ errContains: "HTTP request failed",
158+ },
159+ {
160+ name: "Download file - directory creation failed",
161+ url: "https://example.com/file.txt",
162+ setupMocks: func(mockHTTP *mocks.MockHTTPClient, mockFS *mocks.MockFileSystem) {
163+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(fmt.Errorf("permission denied")).Times(1)
164+ },
165+ wantErr: true,
166+ errContains: "failed to create directory",
167+ },
168+ {
169+ name: "Download file - file creation failed",
170+ url: "https://example.com/file.txt",
171+ setupMocks: func(mockHTTP *mocks.MockHTTPClient, mockFS *mocks.MockFileSystem) {
172+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).Times(1)
173+
174+ resp := &http.Response{
175+ StatusCode: http.StatusOK,
176+ Status: "200 OK",
177+ Body: io.NopCloser(bytes.NewBufferString("content")),
178+ }
179+ mockHTTP.EXPECT().Get("https://example.com/file.txt").Return(resp, nil).Times(1)
180+
181+ mockFS.EXPECT().Create(gomock.Any()).Return(nil, fmt.Errorf("no space left")).Times(1)
182+ },
183+ wantErr: true,
184+ errContains: "failed to create file",
185+ },
186+ }
187+
188+ for _, tt := range tests {
189+ t.Run(tt.name, func(t *testing.T) {
190+ ctrl := gomock.NewController(t)
191+ defer ctrl.Finish()
192+
193+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
194+ mockFS := mocks.NewMockFileSystem(ctrl)
195+
196+ tt.setupMocks(mockHTTP, mockFS)
197+
198+ tmpDir := t.TempDir()
199+ logPath := filepath.Join(tmpDir, "test.log")
200+ log, err := logger.New(logPath, false)
201+ if err != nil {
202+ t.Fatalf("Failed to create logger: %v", err)
203+ }
204+ defer log.Close()
205+
206+ cfg := config.NewConfig("test")
207+ phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS)
208+
209+ err = phase.downloadFile(tt.url, filepath.Join(tmpDir, "dest.txt"))
210+ if (err != nil) != tt.wantErr {
211+ t.Errorf("downloadFile() error = %v, wantErr %v", err, tt.wantErr)
212+ return
213+ }
214+ if tt.wantErr && err != nil {
215+ if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
216+ t.Errorf("downloadFile() error = %q, should contain %q", errMsg, tt.errContains)
217+ }
218+ }
219+ })
220+ }
221+}
222+
223+// TestBootstrapPhaseExecute tests the Execute method
224+func TestBootstrapPhaseExecute(t *testing.T) {
225+ ctrl := gomock.NewController(t)
226+ defer ctrl.Finish()
227+
228+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
229+ mockFS := mocks.NewMockFileSystem(ctrl)
230+
231+ // Setup successful downloads
232+ mockFS.EXPECT().MkdirAll(config.DefaultInstallDir, gomock.Any()).Return(nil).Times(1)
233+
234+ // Expect MkdirAll calls for each file's directory
235+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
236+
237+ // Expect Create calls for each file
238+ mockFile := NewMockWriteCloser()
239+ mockFS.EXPECT().Create(gomock.Any()).Return(mockFile, nil).AnyTimes()
240+
241+ // Expect HTTP Get calls for each file
242+ resp := &http.Response{
243+ StatusCode: http.StatusOK,
244+ Status: "200 OK",
245+ Body: io.NopCloser(bytes.NewBufferString("file content")),
246+ }
247+ mockHTTP.EXPECT().Get(gomock.Any()).Return(resp, nil).AnyTimes()
248+
249+ tmpDir := t.TempDir()
250+ logPath := filepath.Join(tmpDir, "test.log")
251+ log, err := logger.New(logPath, false)
252+ if err != nil {
253+ t.Fatalf("Failed to create logger: %v", err)
254+ }
255+ defer log.Close()
256+
257+ cfg := config.NewConfig("test")
258+ cfg.RawURL = "https://example.com"
259+ phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS)
260+
261+ progressChan := make(chan ProgressUpdate, 100)
262+ result := phase.Execute(progressChan)
263+
264+ if !result.Success {
265+ t.Errorf("Execute() Success = %v, want true", result.Success)
266+ }
267+ if result.Message != "All configuration files downloaded" {
268+ t.Errorf("Execute() Message = %q, want 'All configuration files downloaded'", result.Message)
269+ }
270+}
271+
272+// TestBootstrapPhaseRollback tests rollback cleanup
273+func TestBootstrapPhaseRollback(t *testing.T) {
274+ tests := []struct {
275+ name string
276+ setupMocks func(*mocks.MockFileSystem)
277+ wantErr bool
278+ errContains string
279+ }{
280+ {
281+ name: "Rollback - success",
282+ setupMocks: func(mockFS *mocks.MockFileSystem) {
283+ mockFS.EXPECT().RemoveAll(config.DefaultInstallDir).Return(nil).Times(1)
284+ },
285+ wantErr: false,
286+ },
287+ {
288+ name: "Rollback - directory removal failed",
289+ setupMocks: func(mockFS *mocks.MockFileSystem) {
290+ mockFS.EXPECT().RemoveAll(config.DefaultInstallDir).Return(fmt.Errorf("permission denied")).Times(1)
291+ },
292+ wantErr: true,
293+ errContains: "failed to remove install directory",
294+ },
295+ }
296+
297+ for _, tt := range tests {
298+ t.Run(tt.name, func(t *testing.T) {
299+ ctrl := gomock.NewController(t)
300+ defer ctrl.Finish()
301+
302+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
303+ mockFS := mocks.NewMockFileSystem(ctrl)
304+
305+ tt.setupMocks(mockFS)
306+
307+ tmpDir := t.TempDir()
308+ logPath := filepath.Join(tmpDir, "test.log")
309+ log, err := logger.New(logPath, false)
310+ if err != nil {
311+ t.Fatalf("Failed to create logger: %v", err)
312+ }
313+ defer log.Close()
314+
315+ cfg := config.NewConfig("test")
316+ phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS)
317+
318+ err = phase.Rollback()
319+ if (err != nil) != tt.wantErr {
320+ t.Errorf("Rollback() error = %v, wantErr %v", err, tt.wantErr)
321+ return
322+ }
323+ if tt.wantErr && err != nil {
324+ if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
325+ t.Errorf("Rollback() error = %q, should contain %q", errMsg, tt.errContains)
326+ }
327+ }
328+ })
329+ }
330+}
331+
332+// MockWriteCloser is a mock implementation of io.WriteCloser
333+type MockWriteCloser struct {
334+ buf bytes.Buffer
335+}
336+
337+func NewMockWriteCloser() *MockWriteCloser {
338+ return &MockWriteCloser{}
339+}
340+
341+func (m *MockWriteCloser) Write(p []byte) (n int, err error) {
342+ return m.buf.Write(p)
343+}
344+
345+func (m *MockWriteCloser) Close() error {
346+ return nil
347+}
348+
349+// containsSubstring helper function (also defined in preflight_test.go)
350+func containsSubstring(s, substr string) bool {
351+ for i := 0; i < len(s)-len(substr)+1; i++ {
352+ if s[i:i+len(substr)] == substr {
353+ return true
354+ }
355+ }
356+ return false
357+}
@@ -0,0 +1,357 @@
1+package phases
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "io"
7+ "net/http"
8+ "path/filepath"
9+ "testing"
10+
11+ "github.com/bnema/archup/internal/config"
12+ "github.com/bnema/archup/internal/interfaces/mocks"
13+ "github.com/bnema/archup/internal/logger"
14+ "go.uber.org/mock/gomock"
15+)
16+
17+// TestBootstrapPhasePreCheck tests internet connectivity validation
18+func TestBootstrapPhasePreCheck(t *testing.T) {
19+ tests := []struct {
20+ name string
21+ setupMocks func(*mocks.MockHTTPClient)
22+ wantErr bool
23+ errContains string
24+ }{
25+ {
26+ name: "Internet connectivity - success",
27+ setupMocks: func(mockHTTP *mocks.MockHTTPClient) {
28+ resp := &http.Response{
29+ StatusCode: http.StatusOK,
30+ Body: io.NopCloser(bytes.NewBufferString("")),
31+ }
32+ mockHTTP.EXPECT().Get("https://raw.githubusercontent.com").Return(resp, nil).Times(1)
33+ },
34+ wantErr: false,
35+ },
36+ {
37+ name: "Internet connectivity - connection failed",
38+ setupMocks: func(mockHTTP *mocks.MockHTTPClient) {
39+ mockHTTP.EXPECT().Get("https://raw.githubusercontent.com").Return(nil, fmt.Errorf("connection refused")).Times(1)
40+ },
41+ wantErr: true,
42+ errContains: "no internet connectivity",
43+ },
44+ {
45+ name: "Internet connectivity - timeout",
46+ setupMocks: func(mockHTTP *mocks.MockHTTPClient) {
47+ mockHTTP.EXPECT().Get("https://raw.githubusercontent.com").Return(nil, fmt.Errorf("context deadline exceeded")).Times(1)
48+ },
49+ wantErr: true,
50+ errContains: "no internet connectivity",
51+ },
52+ }
53+
54+ for _, tt := range tests {
55+ t.Run(tt.name, func(t *testing.T) {
56+ ctrl := gomock.NewController(t)
57+ defer ctrl.Finish()
58+
59+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
60+ mockFS := mocks.NewMockFileSystem(ctrl)
61+
62+ tt.setupMocks(mockHTTP)
63+
64+ tmpDir := t.TempDir()
65+ logPath := filepath.Join(tmpDir, "test.log")
66+ log, err := logger.New(logPath, false)
67+ if err != nil {
68+ t.Fatalf("Failed to create logger: %v", err)
69+ }
70+ defer log.Close()
71+
72+ cfg := config.NewConfig("test")
73+ phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS)
74+
75+ err = phase.PreCheck()
76+ if (err != nil) != tt.wantErr {
77+ t.Errorf("PreCheck() error = %v, wantErr %v", err, tt.wantErr)
78+ return
79+ }
80+ if tt.wantErr && err != nil {
81+ if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
82+ t.Errorf("PreCheck() error = %q, should contain %q", errMsg, tt.errContains)
83+ }
84+ }
85+ })
86+ }
87+}
88+
89+// TestBootstrapPhaseDownloadFile tests file download functionality
90+func TestBootstrapPhaseDownloadFile(t *testing.T) {
91+ tests := []struct {
92+ name string
93+ url string
94+ setupMocks func(*mocks.MockHTTPClient, *mocks.MockFileSystem)
95+ wantErr bool
96+ errContains string
97+ }{
98+ {
99+ name: "Download file - success",
100+ url: "https://example.com/file.txt",
101+ setupMocks: func(mockHTTP *mocks.MockHTTPClient, mockFS *mocks.MockFileSystem) {
102+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).Times(1)
103+
104+ resp := &http.Response{
105+ StatusCode: http.StatusOK,
106+ Status: "200 OK",
107+ Body: io.NopCloser(bytes.NewBufferString("file content")),
108+ }
109+ mockHTTP.EXPECT().Get("https://example.com/file.txt").Return(resp, nil).Times(1)
110+
111+ mockFile := NewMockWriteCloser()
112+ mockFS.EXPECT().Create(gomock.Any()).Return(mockFile, nil).Times(1)
113+ },
114+ wantErr: false,
115+ },
116+ {
117+ name: "Download file - HTTP 404 not found",
118+ url: "https://example.com/missing.txt",
119+ setupMocks: func(mockHTTP *mocks.MockHTTPClient, mockFS *mocks.MockFileSystem) {
120+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).Times(1)
121+
122+ resp := &http.Response{
123+ StatusCode: http.StatusNotFound,
124+ Status: "404 Not Found",
125+ Body: io.NopCloser(bytes.NewBufferString("")),
126+ }
127+ mockHTTP.EXPECT().Get("https://example.com/missing.txt").Return(resp, nil).Times(1)
128+ },
129+ wantErr: true,
130+ errContains: "HTTP 404",
131+ },
132+ {
133+ name: "Download file - HTTP 500 server error",
134+ url: "https://example.com/error.txt",
135+ setupMocks: func(mockHTTP *mocks.MockHTTPClient, mockFS *mocks.MockFileSystem) {
136+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).Times(1)
137+
138+ resp := &http.Response{
139+ StatusCode: http.StatusInternalServerError,
140+ Status: "500 Internal Server Error",
141+ Body: io.NopCloser(bytes.NewBufferString("")),
142+ }
143+ mockHTTP.EXPECT().Get("https://example.com/error.txt").Return(resp, nil).Times(1)
144+ },
145+ wantErr: true,
146+ errContains: "HTTP 500",
147+ },
148+ {
149+ name: "Download file - HTTP request failed",
150+ url: "https://example.com/file.txt",
151+ setupMocks: func(mockHTTP *mocks.MockHTTPClient, mockFS *mocks.MockFileSystem) {
152+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).Times(1)
153+
154+ mockHTTP.EXPECT().Get("https://example.com/file.txt").Return(nil, fmt.Errorf("connection timeout")).Times(1)
155+ },
156+ wantErr: true,
157+ errContains: "HTTP request failed",
158+ },
159+ {
160+ name: "Download file - directory creation failed",
161+ url: "https://example.com/file.txt",
162+ setupMocks: func(mockHTTP *mocks.MockHTTPClient, mockFS *mocks.MockFileSystem) {
163+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(fmt.Errorf("permission denied")).Times(1)
164+ },
165+ wantErr: true,
166+ errContains: "failed to create directory",
167+ },
168+ {
169+ name: "Download file - file creation failed",
170+ url: "https://example.com/file.txt",
171+ setupMocks: func(mockHTTP *mocks.MockHTTPClient, mockFS *mocks.MockFileSystem) {
172+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).Times(1)
173+
174+ resp := &http.Response{
175+ StatusCode: http.StatusOK,
176+ Status: "200 OK",
177+ Body: io.NopCloser(bytes.NewBufferString("content")),
178+ }
179+ mockHTTP.EXPECT().Get("https://example.com/file.txt").Return(resp, nil).Times(1)
180+
181+ mockFS.EXPECT().Create(gomock.Any()).Return(nil, fmt.Errorf("no space left")).Times(1)
182+ },
183+ wantErr: true,
184+ errContains: "failed to create file",
185+ },
186+ }
187+
188+ for _, tt := range tests {
189+ t.Run(tt.name, func(t *testing.T) {
190+ ctrl := gomock.NewController(t)
191+ defer ctrl.Finish()
192+
193+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
194+ mockFS := mocks.NewMockFileSystem(ctrl)
195+
196+ tt.setupMocks(mockHTTP, mockFS)
197+
198+ tmpDir := t.TempDir()
199+ logPath := filepath.Join(tmpDir, "test.log")
200+ log, err := logger.New(logPath, false)
201+ if err != nil {
202+ t.Fatalf("Failed to create logger: %v", err)
203+ }
204+ defer log.Close()
205+
206+ cfg := config.NewConfig("test")
207+ phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS)
208+
209+ err = phase.downloadFile(tt.url, filepath.Join(tmpDir, "dest.txt"))
210+ if (err != nil) != tt.wantErr {
211+ t.Errorf("downloadFile() error = %v, wantErr %v", err, tt.wantErr)
212+ return
213+ }
214+ if tt.wantErr && err != nil {
215+ if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
216+ t.Errorf("downloadFile() error = %q, should contain %q", errMsg, tt.errContains)
217+ }
218+ }
219+ })
220+ }
221+}
222+
223+// TestBootstrapPhaseExecute tests the Execute method
224+func TestBootstrapPhaseExecute(t *testing.T) {
225+ ctrl := gomock.NewController(t)
226+ defer ctrl.Finish()
227+
228+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
229+ mockFS := mocks.NewMockFileSystem(ctrl)
230+
231+ // Setup successful downloads
232+ mockFS.EXPECT().MkdirAll(config.DefaultInstallDir, gomock.Any()).Return(nil).Times(1)
233+
234+ // Expect MkdirAll calls for each file's directory
235+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
236+
237+ // Expect Create calls for each file
238+ mockFile := NewMockWriteCloser()
239+ mockFS.EXPECT().Create(gomock.Any()).Return(mockFile, nil).AnyTimes()
240+
241+ // Expect HTTP Get calls for each file
242+ resp := &http.Response{
243+ StatusCode: http.StatusOK,
244+ Status: "200 OK",
245+ Body: io.NopCloser(bytes.NewBufferString("file content")),
246+ }
247+ mockHTTP.EXPECT().Get(gomock.Any()).Return(resp, nil).AnyTimes()
248+
249+ tmpDir := t.TempDir()
250+ logPath := filepath.Join(tmpDir, "test.log")
251+ log, err := logger.New(logPath, false)
252+ if err != nil {
253+ t.Fatalf("Failed to create logger: %v", err)
254+ }
255+ defer log.Close()
256+
257+ cfg := config.NewConfig("test")
258+ cfg.RawURL = "https://example.com"
259+ phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS)
260+
261+ progressChan := make(chan ProgressUpdate, 100)
262+ result := phase.Execute(progressChan)
263+
264+ if !result.Success {
265+ t.Errorf("Execute() Success = %v, want true", result.Success)
266+ }
267+ if result.Message != "All configuration files downloaded" {
268+ t.Errorf("Execute() Message = %q, want 'All configuration files downloaded'", result.Message)
269+ }
270+}
271+
272+// TestBootstrapPhaseRollback tests rollback cleanup
273+func TestBootstrapPhaseRollback(t *testing.T) {
274+ tests := []struct {
275+ name string
276+ setupMocks func(*mocks.MockFileSystem)
277+ wantErr bool
278+ errContains string
279+ }{
280+ {
281+ name: "Rollback - success",
282+ setupMocks: func(mockFS *mocks.MockFileSystem) {
283+ mockFS.EXPECT().RemoveAll(config.DefaultInstallDir).Return(nil).Times(1)
284+ },
285+ wantErr: false,
286+ },
287+ {
288+ name: "Rollback - directory removal failed",
289+ setupMocks: func(mockFS *mocks.MockFileSystem) {
290+ mockFS.EXPECT().RemoveAll(config.DefaultInstallDir).Return(fmt.Errorf("permission denied")).Times(1)
291+ },
292+ wantErr: true,
293+ errContains: "failed to remove install directory",
294+ },
295+ }
296+
297+ for _, tt := range tests {
298+ t.Run(tt.name, func(t *testing.T) {
299+ ctrl := gomock.NewController(t)
300+ defer ctrl.Finish()
301+
302+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
303+ mockFS := mocks.NewMockFileSystem(ctrl)
304+
305+ tt.setupMocks(mockFS)
306+
307+ tmpDir := t.TempDir()
308+ logPath := filepath.Join(tmpDir, "test.log")
309+ log, err := logger.New(logPath, false)
310+ if err != nil {
311+ t.Fatalf("Failed to create logger: %v", err)
312+ }
313+ defer log.Close()
314+
315+ cfg := config.NewConfig("test")
316+ phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS)
317+
318+ err = phase.Rollback()
319+ if (err != nil) != tt.wantErr {
320+ t.Errorf("Rollback() error = %v, wantErr %v", err, tt.wantErr)
321+ return
322+ }
323+ if tt.wantErr && err != nil {
324+ if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
325+ t.Errorf("Rollback() error = %q, should contain %q", errMsg, tt.errContains)
326+ }
327+ }
328+ })
329+ }
330+}
331+
332+// MockWriteCloser is a mock implementation of io.WriteCloser
333+type MockWriteCloser struct {
334+ buf bytes.Buffer
335+}
336+
337+func NewMockWriteCloser() *MockWriteCloser {
338+ return &MockWriteCloser{}
339+}
340+
341+func (m *MockWriteCloser) Write(p []byte) (n int, err error) {
342+ return m.buf.Write(p)
343+}
344+
345+func (m *MockWriteCloser) Close() error {
346+ return nil
347+}
348+
349+// containsSubstring helper function (also defined in preflight_test.go)
350+func containsSubstring(s, substr string) bool {
351+ for i := 0; i < len(s)-len(substr)+1; i++ {
352+ if s[i:i+len(substr)] == substr {
353+ return true
354+ }
355+ }
356+ return false
357+}
A internal/phases/preflight_test.go +473 -0
@@ -0,0 +1,473 @@
1+package phases
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/archup/internal/config"
11+ "github.com/bnema/archup/internal/interfaces/mocks"
12+ "github.com/bnema/archup/internal/logger"
13+ "go.uber.org/mock/gomock"
14+)
15+
16+// TestPreflightPhasePreCheck tests the PreCheck validation
17+func TestPreflightPhasePreCheck(t *testing.T) {
18+ tests := []struct {
19+ name string
20+ setupMocks func(*mocks.MockFileSystem, *mocks.MockCommandExecutor)
21+ wantErr bool
22+ errContains string
23+ }{
24+ {
25+ name: "Arch Linux detection - file not found",
26+ setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) {
27+ mockFS.EXPECT().Stat("/etc/arch-release").Return(nil, fmt.Errorf("not found")).Times(1)
28+ mockFS.EXPECT().IsNotExist(gomock.Not(nil)).Return(true).Times(1)
29+ },
30+ wantErr: true,
31+ errContains: "must be running on Arch Linux",
32+ },
33+ {
34+ name: "Arch Linux detected - CachyOS derivative detected",
35+ setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) {
36+ mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1)
37+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
38+ // For derivatives, check all 4 since map iteration order isn't guaranteed
39+ mockFS.EXPECT().Stat("/etc/cachyos-release").Return(mockFileInfo{}, nil).Times(1)
40+ mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).AnyTimes()
41+ mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).AnyTimes()
42+ mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).AnyTimes()
43+ },
44+ wantErr: true,
45+ errContains: "CachyOS",
46+ },
47+ {
48+ name: "Arch Linux detected - vanilla Arch no derivatives",
49+ setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) {
50+ mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1)
51+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(2)
52+ // All derivatives should return not found
53+ mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1)
54+ mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1)
55+ mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1)
56+ mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1)
57+ mockCmd.EXPECT().Execute("uname", "-m").Return([]byte("x86_64\n"), nil).Times(1)
58+ mockFS.EXPECT().Stat("/sys/firmware/efi/efivars").Return(mockFileInfo{}, nil).Times(1)
59+ mockCmd.EXPECT().Execute("bootctl", "status").Return([]byte("Secure Boot: disabled\n"), nil).Times(1)
60+ },
61+ wantErr: false,
62+ },
63+ {
64+ name: "Vanilla Arch - CPU detection fails",
65+ setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) {
66+ mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1)
67+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
68+ // All derivatives should return not found
69+ mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1)
70+ mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1)
71+ mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1)
72+ mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1)
73+ mockCmd.EXPECT().Execute("uname", "-m").Return(nil, fmt.Errorf("uname failed")).Times(1)
74+ },
75+ wantErr: true,
76+ errContains: "failed to detect architecture",
77+ },
78+ {
79+ name: "Vanilla Arch - CPU not x86_64",
80+ setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) {
81+ mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1)
82+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
83+ // All derivatives should return not found
84+ mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1)
85+ mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1)
86+ mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1)
87+ mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1)
88+ mockCmd.EXPECT().Execute("uname", "-m").Return([]byte("armv7l\n"), nil).Times(1)
89+ },
90+ wantErr: true,
91+ errContains: "must be x86_64",
92+ },
93+ {
94+ name: "x86_64 detected - UEFI not found",
95+ setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) {
96+ mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1)
97+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
98+ // All derivatives should return not found
99+ mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1)
100+ mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1)
101+ mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1)
102+ mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1)
103+ mockCmd.EXPECT().Execute("uname", "-m").Return([]byte("x86_64\n"), nil).Times(1)
104+ mockFS.EXPECT().Stat("/sys/firmware/efi/efivars").Return(nil, fmt.Errorf("not found")).Times(1)
105+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(true).Times(1)
106+ },
107+ wantErr: true,
108+ errContains: "must be UEFI boot mode",
109+ },
110+ {
111+ name: "UEFI detected - Secure Boot enabled",
112+ setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) {
113+ mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1)
114+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(2)
115+ // All derivatives should return not found
116+ mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1)
117+ mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1)
118+ mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1)
119+ mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1)
120+ mockCmd.EXPECT().Execute("uname", "-m").Return([]byte("x86_64\n"), nil).Times(1)
121+ mockFS.EXPECT().Stat("/sys/firmware/efi/efivars").Return(mockFileInfo{}, nil).Times(1)
122+ mockCmd.EXPECT().Execute("bootctl", "status").Return([]byte("Secure Boot: enabled\n"), nil).Times(1)
123+ },
124+ wantErr: true,
125+ errContains: "Secure Boot must be disabled",
126+ },
127+ {
128+ name: "Secure Boot disabled - all checks pass",
129+ setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) {
130+ mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1)
131+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(2)
132+ // All derivatives should return not found
133+ mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1)
134+ mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1)
135+ mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1)
136+ mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1)
137+ mockCmd.EXPECT().Execute("uname", "-m").Return([]byte("x86_64\n"), nil).Times(1)
138+ mockFS.EXPECT().Stat("/sys/firmware/efi/efivars").Return(mockFileInfo{}, nil).Times(1)
139+ mockCmd.EXPECT().Execute("bootctl", "status").Return([]byte("Secure Boot: disabled\n"), nil).Times(1)
140+ },
141+ wantErr: false,
142+ },
143+ {
144+ name: "bootctl command fails - precheck should still pass",
145+ setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) {
146+ mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1)
147+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(2)
148+ // All derivatives should return not found
149+ mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1)
150+ mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1)
151+ mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1)
152+ mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1)
153+ mockCmd.EXPECT().Execute("uname", "-m").Return([]byte("x86_64\n"), nil).Times(1)
154+ mockFS.EXPECT().Stat("/sys/firmware/efi/efivars").Return(mockFileInfo{}, nil).Times(1)
155+ mockCmd.EXPECT().Execute("bootctl", "status").Return(nil, fmt.Errorf("bootctl not available")).Times(1)
156+ },
157+ wantErr: false,
158+ },
159+ }
160+
161+ for _, tt := range tests {
162+ t.Run(tt.name, func(t *testing.T) {
163+ ctrl := gomock.NewController(t)
164+ defer ctrl.Finish()
165+
166+ mockFS := mocks.NewMockFileSystem(ctrl)
167+ mockCmd := mocks.NewMockCommandExecutor(ctrl)
168+
169+ tt.setupMocks(mockFS, mockCmd)
170+
171+ tmpDir := t.TempDir()
172+ logPath := filepath.Join(tmpDir, "test.log")
173+ log, err := logger.New(logPath, false)
174+ if err != nil {
175+ t.Fatalf("Failed to create logger: %v", err)
176+ }
177+ defer log.Close()
178+
179+ cfg := config.NewConfig("test")
180+ phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)
181+
182+ err = phase.PreCheck()
183+ if (err != nil) != tt.wantErr {
184+ t.Errorf("PreCheck() error = %v, wantErr %v", err, tt.wantErr)
185+ return
186+ }
187+ if tt.wantErr && err != nil {
188+ if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
189+ t.Errorf("PreCheck() error = %q, should contain %q", errMsg, tt.errContains)
190+ }
191+ }
192+ })
193+ }
194+}
195+
196+// TestPreflightPhaseSetDefaults tests setting default configuration values
197+func TestPreflightPhaseSetDefaults(t *testing.T) {
198+ tests := []struct {
199+ name string
200+ initialHostname string
201+ initialTimezone string
202+ expectedHostname string
203+ expectedTimezone string
204+ expectedBootloader string
205+ }{
206+ {
207+ name: "Empty hostname and timezone - use defaults",
208+ initialHostname: "",
209+ initialTimezone: "",
210+ expectedHostname: "archup",
211+ expectedTimezone: "UTC",
212+ expectedBootloader: config.BootloaderLimine,
213+ },
214+ {
215+ name: "Custom hostname provided - keep custom",
216+ initialHostname: "my-arch",
217+ initialTimezone: "",
218+ expectedHostname: "my-arch",
219+ expectedTimezone: "UTC",
220+ expectedBootloader: config.BootloaderLimine,
221+ },
222+ {
223+ name: "Custom timezone provided - keep custom",
224+ initialHostname: "",
225+ initialTimezone: "Europe/London",
226+ expectedHostname: "archup",
227+ expectedTimezone: "Europe/London",
228+ expectedBootloader: config.BootloaderLimine,
229+ },
230+ {
231+ name: "Both custom - keep both",
232+ initialHostname: "arch-desktop",
233+ initialTimezone: "America/New_York",
234+ expectedHostname: "arch-desktop",
235+ expectedTimezone: "America/New_York",
236+ expectedBootloader: config.BootloaderLimine,
237+ },
238+ }
239+
240+ for _, tt := range tests {
241+ t.Run(tt.name, func(t *testing.T) {
242+ ctrl := gomock.NewController(t)
243+ defer ctrl.Finish()
244+
245+ mockFS := mocks.NewMockFileSystem(ctrl)
246+ mockCmd := mocks.NewMockCommandExecutor(ctrl)
247+
248+ tmpDir := t.TempDir()
249+ logPath := filepath.Join(tmpDir, "test.log")
250+ log, err := logger.New(logPath, false)
251+ if err != nil {
252+ t.Fatalf("Failed to create logger: %v", err)
253+ }
254+ defer log.Close()
255+
256+ cfg := config.NewConfig("test")
257+ cfg.Hostname = tt.initialHostname
258+ cfg.Timezone = tt.initialTimezone
259+
260+ phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)
261+ phase.SetDefaults()
262+
263+ if cfg.Hostname != tt.expectedHostname {
264+ t.Errorf("Hostname = %q, want %q", cfg.Hostname, tt.expectedHostname)
265+ }
266+ if cfg.Timezone != tt.expectedTimezone {
267+ t.Errorf("Timezone = %q, want %q", cfg.Timezone, tt.expectedTimezone)
268+ }
269+ if cfg.Bootloader != tt.expectedBootloader {
270+ t.Errorf("Bootloader = %q, want %q", cfg.Bootloader, tt.expectedBootloader)
271+ }
272+ })
273+ }
274+}
275+
276+// TestPreflightPhasePostCheck tests configuration validation after form submission
277+func TestPreflightPhasePostCheck(t *testing.T) {
278+ tests := []struct {
279+ name string
280+ username string
281+ hostname string
282+ timezone string
283+ wantErr bool
284+ errContains string
285+ }{
286+ {
287+ name: "All fields present - validation passes",
288+ username: "archuser",
289+ hostname: "my-arch",
290+ timezone: "UTC",
291+ wantErr: false,
292+ },
293+ {
294+ name: "Missing username - validation fails",
295+ username: "",
296+ hostname: "my-arch",
297+ timezone: "UTC",
298+ wantErr: true,
299+ errContains: "username is required",
300+ },
301+ {
302+ name: "Missing hostname - validation fails",
303+ username: "archuser",
304+ hostname: "",
305+ timezone: "UTC",
306+ wantErr: true,
307+ errContains: "hostname is required",
308+ },
309+ {
310+ name: "Missing timezone - validation fails",
311+ username: "archuser",
312+ hostname: "my-arch",
313+ timezone: "",
314+ wantErr: true,
315+ errContains: "timezone is required",
316+ },
317+ {
318+ name: "All fields empty - validation fails on username",
319+ username: "",
320+ hostname: "",
321+ timezone: "",
322+ wantErr: true,
323+ errContains: "username is required",
324+ },
325+ }
326+
327+ for _, tt := range tests {
328+ t.Run(tt.name, func(t *testing.T) {
329+ ctrl := gomock.NewController(t)
330+ defer ctrl.Finish()
331+
332+ mockFS := mocks.NewMockFileSystem(ctrl)
333+ mockCmd := mocks.NewMockCommandExecutor(ctrl)
334+
335+ tmpDir := t.TempDir()
336+ logPath := filepath.Join(tmpDir, "test.log")
337+ log, err := logger.New(logPath, false)
338+ if err != nil {
339+ t.Fatalf("Failed to create logger: %v", err)
340+ }
341+ defer log.Close()
342+
343+ cfg := config.NewConfig("test")
344+ cfg.Username = tt.username
345+ cfg.Hostname = tt.hostname
346+ cfg.Timezone = tt.timezone
347+ cfg.ConfigPath = filepath.Join(tmpDir, "config") // Set config path to temp dir so Save() works
348+
349+ phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)
350+
351+ err = phase.PostCheck()
352+ if (err != nil) != tt.wantErr {
353+ t.Errorf("PostCheck() error = %v, wantErr %v", err, tt.wantErr)
354+ return
355+ }
356+ if tt.wantErr && err != nil {
357+ if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
358+ t.Errorf("PostCheck() error = %q, should contain %q", errMsg, tt.errContains)
359+ }
360+ }
361+ })
362+ }
363+}
364+
365+// TestPreflightPhaseExecute tests the Execute method
366+func TestPreflightPhaseExecute(t *testing.T) {
367+ ctrl := gomock.NewController(t)
368+ defer ctrl.Finish()
369+
370+ mockFS := mocks.NewMockFileSystem(ctrl)
371+ mockCmd := mocks.NewMockCommandExecutor(ctrl)
372+
373+ tmpDir := t.TempDir()
374+ logPath := filepath.Join(tmpDir, "test.log")
375+ log, err := logger.New(logPath, false)
376+ if err != nil {
377+ t.Fatalf("Failed to create logger: %v", err)
378+ }
379+ defer log.Close()
380+
381+ cfg := config.NewConfig("test")
382+ phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)
383+
384+ progressChan := make(chan ProgressUpdate, 10)
385+ result := phase.Execute(progressChan)
386+
387+ if !result.Success {
388+ t.Errorf("Execute() Success = %v, want true", result.Success)
389+ }
390+
391+ if result.Message != "Preflight checks passed" {
392+ t.Errorf("Execute() Message = %q, want 'Preflight checks passed'", result.Message)
393+ }
394+
395+ // Verify progress updates were sent
396+ updates := []ProgressUpdate{}
397+ for len(progressChan) > 0 {
398+ updates = append(updates, <-progressChan)
399+ }
400+
401+ if len(updates) < 1 {
402+ t.Error("Execute() should send progress updates")
403+ }
404+}
405+
406+// TestPreflightPhaseRollback tests the Rollback method
407+func TestPreflightPhaseRollback(t *testing.T) {
408+ ctrl := gomock.NewController(t)
409+ defer ctrl.Finish()
410+
411+ mockFS := mocks.NewMockFileSystem(ctrl)
412+ mockCmd := mocks.NewMockCommandExecutor(ctrl)
413+
414+ tmpDir := t.TempDir()
415+ logPath := filepath.Join(tmpDir, "test.log")
416+ log, err := logger.New(logPath, false)
417+ if err != nil {
418+ t.Fatalf("Failed to create logger: %v", err)
419+ }
420+ defer log.Close()
421+
422+ cfg := config.NewConfig("test")
423+ phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)
424+
425+ err = phase.Rollback()
426+ if err != nil {
427+ t.Errorf("Rollback() error = %v, want nil", err)
428+ }
429+}
430+
431+// TestPreflightPhaseCanSkip tests the CanSkip method
432+func TestPreflightPhaseCanSkip(t *testing.T) {
433+ ctrl := gomock.NewController(t)
434+ defer ctrl.Finish()
435+
436+ mockFS := mocks.NewMockFileSystem(ctrl)
437+ mockCmd := mocks.NewMockCommandExecutor(ctrl)
438+
439+ tmpDir := t.TempDir()
440+ logPath := filepath.Join(tmpDir, "test.log")
441+ log, err := logger.New(logPath, false)
442+ if err != nil {
443+ t.Fatalf("Failed to create logger: %v", err)
444+ }
445+ defer log.Close()
446+
447+ cfg := config.NewConfig("test")
448+ phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)
449+
450+ if phase.CanSkip() {
451+ t.Error("CanSkip() = true, want false")
452+ }
453+}
454+
455+// Helper function to check if a string contains a substring
456+func containsSubstring(s, substr string) bool {
457+ for i := 0; i < len(s)-len(substr)+1; i++ {
458+ if s[i:i+len(substr)] == substr {
459+ return true
460+ }
461+ }
462+ return false
463+}
464+
465+// mockFileInfo implements os.FileInfo for testing
466+type mockFileInfo struct{}
467+
468+func (m mockFileInfo) Name() string { return "mock" }
469+func (m mockFileInfo) Size() int64 { return 0 }
470+func (m mockFileInfo) Mode() os.FileMode { return 0 }
471+func (m mockFileInfo) ModTime() time.Time { return time.Now() }
472+func (m mockFileInfo) IsDir() bool { return false }
473+func (m mockFileInfo) Sys() interface{} { return nil }
@@ -0,0 +1,473 @@
1+package phases
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/archup/internal/config"
11+ "github.com/bnema/archup/internal/interfaces/mocks"
12+ "github.com/bnema/archup/internal/logger"
13+ "go.uber.org/mock/gomock"
14+)
15+
16+// TestPreflightPhasePreCheck tests the PreCheck validation
17+func TestPreflightPhasePreCheck(t *testing.T) {
18+ tests := []struct {
19+ name string
20+ setupMocks func(*mocks.MockFileSystem, *mocks.MockCommandExecutor)
21+ wantErr bool
22+ errContains string
23+ }{
24+ {
25+ name: "Arch Linux detection - file not found",
26+ setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) {
27+ mockFS.EXPECT().Stat("/etc/arch-release").Return(nil, fmt.Errorf("not found")).Times(1)
28+ mockFS.EXPECT().IsNotExist(gomock.Not(nil)).Return(true).Times(1)
29+ },
30+ wantErr: true,
31+ errContains: "must be running on Arch Linux",
32+ },
33+ {
34+ name: "Arch Linux detected - CachyOS derivative detected",
35+ setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) {
36+ mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1)
37+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
38+ // For derivatives, check all 4 since map iteration order isn't guaranteed
39+ mockFS.EXPECT().Stat("/etc/cachyos-release").Return(mockFileInfo{}, nil).Times(1)
40+ mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).AnyTimes()
41+ mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).AnyTimes()
42+ mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).AnyTimes()
43+ },
44+ wantErr: true,
45+ errContains: "CachyOS",
46+ },
47+ {
48+ name: "Arch Linux detected - vanilla Arch no derivatives",
49+ setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) {
50+ mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1)
51+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(2)
52+ // All derivatives should return not found
53+ mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1)
54+ mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1)
55+ mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1)
56+ mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1)
57+ mockCmd.EXPECT().Execute("uname", "-m").Return([]byte("x86_64\n"), nil).Times(1)
58+ mockFS.EXPECT().Stat("/sys/firmware/efi/efivars").Return(mockFileInfo{}, nil).Times(1)
59+ mockCmd.EXPECT().Execute("bootctl", "status").Return([]byte("Secure Boot: disabled\n"), nil).Times(1)
60+ },
61+ wantErr: false,
62+ },
63+ {
64+ name: "Vanilla Arch - CPU detection fails",
65+ setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) {
66+ mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1)
67+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
68+ // All derivatives should return not found
69+ mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1)
70+ mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1)
71+ mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1)
72+ mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1)
73+ mockCmd.EXPECT().Execute("uname", "-m").Return(nil, fmt.Errorf("uname failed")).Times(1)
74+ },
75+ wantErr: true,
76+ errContains: "failed to detect architecture",
77+ },
78+ {
79+ name: "Vanilla Arch - CPU not x86_64",
80+ setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) {
81+ mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1)
82+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
83+ // All derivatives should return not found
84+ mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1)
85+ mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1)
86+ mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1)
87+ mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1)
88+ mockCmd.EXPECT().Execute("uname", "-m").Return([]byte("armv7l\n"), nil).Times(1)
89+ },
90+ wantErr: true,
91+ errContains: "must be x86_64",
92+ },
93+ {
94+ name: "x86_64 detected - UEFI not found",
95+ setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) {
96+ mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1)
97+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
98+ // All derivatives should return not found
99+ mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1)
100+ mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1)
101+ mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1)
102+ mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1)
103+ mockCmd.EXPECT().Execute("uname", "-m").Return([]byte("x86_64\n"), nil).Times(1)
104+ mockFS.EXPECT().Stat("/sys/firmware/efi/efivars").Return(nil, fmt.Errorf("not found")).Times(1)
105+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(true).Times(1)
106+ },
107+ wantErr: true,
108+ errContains: "must be UEFI boot mode",
109+ },
110+ {
111+ name: "UEFI detected - Secure Boot enabled",
112+ setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) {
113+ mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1)
114+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(2)
115+ // All derivatives should return not found
116+ mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1)
117+ mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1)
118+ mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1)
119+ mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1)
120+ mockCmd.EXPECT().Execute("uname", "-m").Return([]byte("x86_64\n"), nil).Times(1)
121+ mockFS.EXPECT().Stat("/sys/firmware/efi/efivars").Return(mockFileInfo{}, nil).Times(1)
122+ mockCmd.EXPECT().Execute("bootctl", "status").Return([]byte("Secure Boot: enabled\n"), nil).Times(1)
123+ },
124+ wantErr: true,
125+ errContains: "Secure Boot must be disabled",
126+ },
127+ {
128+ name: "Secure Boot disabled - all checks pass",
129+ setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) {
130+ mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1)
131+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(2)
132+ // All derivatives should return not found
133+ mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1)
134+ mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1)
135+ mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1)
136+ mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1)
137+ mockCmd.EXPECT().Execute("uname", "-m").Return([]byte("x86_64\n"), nil).Times(1)
138+ mockFS.EXPECT().Stat("/sys/firmware/efi/efivars").Return(mockFileInfo{}, nil).Times(1)
139+ mockCmd.EXPECT().Execute("bootctl", "status").Return([]byte("Secure Boot: disabled\n"), nil).Times(1)
140+ },
141+ wantErr: false,
142+ },
143+ {
144+ name: "bootctl command fails - precheck should still pass",
145+ setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) {
146+ mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1)
147+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(2)
148+ // All derivatives should return not found
149+ mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1)
150+ mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1)
151+ mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1)
152+ mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1)
153+ mockCmd.EXPECT().Execute("uname", "-m").Return([]byte("x86_64\n"), nil).Times(1)
154+ mockFS.EXPECT().Stat("/sys/firmware/efi/efivars").Return(mockFileInfo{}, nil).Times(1)
155+ mockCmd.EXPECT().Execute("bootctl", "status").Return(nil, fmt.Errorf("bootctl not available")).Times(1)
156+ },
157+ wantErr: false,
158+ },
159+ }
160+
161+ for _, tt := range tests {
162+ t.Run(tt.name, func(t *testing.T) {
163+ ctrl := gomock.NewController(t)
164+ defer ctrl.Finish()
165+
166+ mockFS := mocks.NewMockFileSystem(ctrl)
167+ mockCmd := mocks.NewMockCommandExecutor(ctrl)
168+
169+ tt.setupMocks(mockFS, mockCmd)
170+
171+ tmpDir := t.TempDir()
172+ logPath := filepath.Join(tmpDir, "test.log")
173+ log, err := logger.New(logPath, false)
174+ if err != nil {
175+ t.Fatalf("Failed to create logger: %v", err)
176+ }
177+ defer log.Close()
178+
179+ cfg := config.NewConfig("test")
180+ phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)
181+
182+ err = phase.PreCheck()
183+ if (err != nil) != tt.wantErr {
184+ t.Errorf("PreCheck() error = %v, wantErr %v", err, tt.wantErr)
185+ return
186+ }
187+ if tt.wantErr && err != nil {
188+ if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
189+ t.Errorf("PreCheck() error = %q, should contain %q", errMsg, tt.errContains)
190+ }
191+ }
192+ })
193+ }
194+}
195+
196+// TestPreflightPhaseSetDefaults tests setting default configuration values
197+func TestPreflightPhaseSetDefaults(t *testing.T) {
198+ tests := []struct {
199+ name string
200+ initialHostname string
201+ initialTimezone string
202+ expectedHostname string
203+ expectedTimezone string
204+ expectedBootloader string
205+ }{
206+ {
207+ name: "Empty hostname and timezone - use defaults",
208+ initialHostname: "",
209+ initialTimezone: "",
210+ expectedHostname: "archup",
211+ expectedTimezone: "UTC",
212+ expectedBootloader: config.BootloaderLimine,
213+ },
214+ {
215+ name: "Custom hostname provided - keep custom",
216+ initialHostname: "my-arch",
217+ initialTimezone: "",
218+ expectedHostname: "my-arch",
219+ expectedTimezone: "UTC",
220+ expectedBootloader: config.BootloaderLimine,
221+ },
222+ {
223+ name: "Custom timezone provided - keep custom",
224+ initialHostname: "",
225+ initialTimezone: "Europe/London",
226+ expectedHostname: "archup",
227+ expectedTimezone: "Europe/London",
228+ expectedBootloader: config.BootloaderLimine,
229+ },
230+ {
231+ name: "Both custom - keep both",
232+ initialHostname: "arch-desktop",
233+ initialTimezone: "America/New_York",
234+ expectedHostname: "arch-desktop",
235+ expectedTimezone: "America/New_York",
236+ expectedBootloader: config.BootloaderLimine,
237+ },
238+ }
239+
240+ for _, tt := range tests {
241+ t.Run(tt.name, func(t *testing.T) {
242+ ctrl := gomock.NewController(t)
243+ defer ctrl.Finish()
244+
245+ mockFS := mocks.NewMockFileSystem(ctrl)
246+ mockCmd := mocks.NewMockCommandExecutor(ctrl)
247+
248+ tmpDir := t.TempDir()
249+ logPath := filepath.Join(tmpDir, "test.log")
250+ log, err := logger.New(logPath, false)
251+ if err != nil {
252+ t.Fatalf("Failed to create logger: %v", err)
253+ }
254+ defer log.Close()
255+
256+ cfg := config.NewConfig("test")
257+ cfg.Hostname = tt.initialHostname
258+ cfg.Timezone = tt.initialTimezone
259+
260+ phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)
261+ phase.SetDefaults()
262+
263+ if cfg.Hostname != tt.expectedHostname {
264+ t.Errorf("Hostname = %q, want %q", cfg.Hostname, tt.expectedHostname)
265+ }
266+ if cfg.Timezone != tt.expectedTimezone {
267+ t.Errorf("Timezone = %q, want %q", cfg.Timezone, tt.expectedTimezone)
268+ }
269+ if cfg.Bootloader != tt.expectedBootloader {
270+ t.Errorf("Bootloader = %q, want %q", cfg.Bootloader, tt.expectedBootloader)
271+ }
272+ })
273+ }
274+}
275+
276+// TestPreflightPhasePostCheck tests configuration validation after form submission
277+func TestPreflightPhasePostCheck(t *testing.T) {
278+ tests := []struct {
279+ name string
280+ username string
281+ hostname string
282+ timezone string
283+ wantErr bool
284+ errContains string
285+ }{
286+ {
287+ name: "All fields present - validation passes",
288+ username: "archuser",
289+ hostname: "my-arch",
290+ timezone: "UTC",
291+ wantErr: false,
292+ },
293+ {
294+ name: "Missing username - validation fails",
295+ username: "",
296+ hostname: "my-arch",
297+ timezone: "UTC",
298+ wantErr: true,
299+ errContains: "username is required",
300+ },
301+ {
302+ name: "Missing hostname - validation fails",
303+ username: "archuser",
304+ hostname: "",
305+ timezone: "UTC",
306+ wantErr: true,
307+ errContains: "hostname is required",
308+ },
309+ {
310+ name: "Missing timezone - validation fails",
311+ username: "archuser",
312+ hostname: "my-arch",
313+ timezone: "",
314+ wantErr: true,
315+ errContains: "timezone is required",
316+ },
317+ {
318+ name: "All fields empty - validation fails on username",
319+ username: "",
320+ hostname: "",
321+ timezone: "",
322+ wantErr: true,
323+ errContains: "username is required",
324+ },
325+ }
326+
327+ for _, tt := range tests {
328+ t.Run(tt.name, func(t *testing.T) {
329+ ctrl := gomock.NewController(t)
330+ defer ctrl.Finish()
331+
332+ mockFS := mocks.NewMockFileSystem(ctrl)
333+ mockCmd := mocks.NewMockCommandExecutor(ctrl)
334+
335+ tmpDir := t.TempDir()
336+ logPath := filepath.Join(tmpDir, "test.log")
337+ log, err := logger.New(logPath, false)
338+ if err != nil {
339+ t.Fatalf("Failed to create logger: %v", err)
340+ }
341+ defer log.Close()
342+
343+ cfg := config.NewConfig("test")
344+ cfg.Username = tt.username
345+ cfg.Hostname = tt.hostname
346+ cfg.Timezone = tt.timezone
347+ cfg.ConfigPath = filepath.Join(tmpDir, "config") // Set config path to temp dir so Save() works
348+
349+ phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)
350+
351+ err = phase.PostCheck()
352+ if (err != nil) != tt.wantErr {
353+ t.Errorf("PostCheck() error = %v, wantErr %v", err, tt.wantErr)
354+ return
355+ }
356+ if tt.wantErr && err != nil {
357+ if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
358+ t.Errorf("PostCheck() error = %q, should contain %q", errMsg, tt.errContains)
359+ }
360+ }
361+ })
362+ }
363+}
364+
365+// TestPreflightPhaseExecute tests the Execute method
366+func TestPreflightPhaseExecute(t *testing.T) {
367+ ctrl := gomock.NewController(t)
368+ defer ctrl.Finish()
369+
370+ mockFS := mocks.NewMockFileSystem(ctrl)
371+ mockCmd := mocks.NewMockCommandExecutor(ctrl)
372+
373+ tmpDir := t.TempDir()
374+ logPath := filepath.Join(tmpDir, "test.log")
375+ log, err := logger.New(logPath, false)
376+ if err != nil {
377+ t.Fatalf("Failed to create logger: %v", err)
378+ }
379+ defer log.Close()
380+
381+ cfg := config.NewConfig("test")
382+ phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)
383+
384+ progressChan := make(chan ProgressUpdate, 10)
385+ result := phase.Execute(progressChan)
386+
387+ if !result.Success {
388+ t.Errorf("Execute() Success = %v, want true", result.Success)
389+ }
390+
391+ if result.Message != "Preflight checks passed" {
392+ t.Errorf("Execute() Message = %q, want 'Preflight checks passed'", result.Message)
393+ }
394+
395+ // Verify progress updates were sent
396+ updates := []ProgressUpdate{}
397+ for len(progressChan) > 0 {
398+ updates = append(updates, <-progressChan)
399+ }
400+
401+ if len(updates) < 1 {
402+ t.Error("Execute() should send progress updates")
403+ }
404+}
405+
406+// TestPreflightPhaseRollback tests the Rollback method
407+func TestPreflightPhaseRollback(t *testing.T) {
408+ ctrl := gomock.NewController(t)
409+ defer ctrl.Finish()
410+
411+ mockFS := mocks.NewMockFileSystem(ctrl)
412+ mockCmd := mocks.NewMockCommandExecutor(ctrl)
413+
414+ tmpDir := t.TempDir()
415+ logPath := filepath.Join(tmpDir, "test.log")
416+ log, err := logger.New(logPath, false)
417+ if err != nil {
418+ t.Fatalf("Failed to create logger: %v", err)
419+ }
420+ defer log.Close()
421+
422+ cfg := config.NewConfig("test")
423+ phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)
424+
425+ err = phase.Rollback()
426+ if err != nil {
427+ t.Errorf("Rollback() error = %v, want nil", err)
428+ }
429+}
430+
431+// TestPreflightPhaseCanSkip tests the CanSkip method
432+func TestPreflightPhaseCanSkip(t *testing.T) {
433+ ctrl := gomock.NewController(t)
434+ defer ctrl.Finish()
435+
436+ mockFS := mocks.NewMockFileSystem(ctrl)
437+ mockCmd := mocks.NewMockCommandExecutor(ctrl)
438+
439+ tmpDir := t.TempDir()
440+ logPath := filepath.Join(tmpDir, "test.log")
441+ log, err := logger.New(logPath, false)
442+ if err != nil {
443+ t.Fatalf("Failed to create logger: %v", err)
444+ }
445+ defer log.Close()
446+
447+ cfg := config.NewConfig("test")
448+ phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)
449+
450+ if phase.CanSkip() {
451+ t.Error("CanSkip() = true, want false")
452+ }
453+}
454+
455+// Helper function to check if a string contains a substring
456+func containsSubstring(s, substr string) bool {
457+ for i := 0; i < len(s)-len(substr)+1; i++ {
458+ if s[i:i+len(substr)] == substr {
459+ return true
460+ }
461+ }
462+ return false
463+}
464+
465+// mockFileInfo implements os.FileInfo for testing
466+type mockFileInfo struct{}
467+
468+func (m mockFileInfo) Name() string { return "mock" }
469+func (m mockFileInfo) Size() int64 { return 0 }
470+func (m mockFileInfo) Mode() os.FileMode { return 0 }
471+func (m mockFileInfo) ModTime() time.Time { return time.Now() }
472+func (m mockFileInfo) IsDir() bool { return false }
473+func (m mockFileInfo) Sys() interface{} { return nil }

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 1

Tags 0

    ↑↓ moveenter openesc close