v3.4.1 de8f68e

service_test.go RawHistoryFolder

1package logs
2
3import (
4	"context"
5	"io"
6	"os"
7	"os/exec"
8	"path/filepath"
9	"testing"
10	"time"
11
12	"github.com/bnema/zerowrap"
13	"github.com/stretchr/testify/assert"
14	"github.com/stretchr/testify/mock"
15	"github.com/stretchr/testify/require"
16
17	"github.com/bnema/gordon/internal/boundaries/out/mocks"
18	"github.com/bnema/gordon/internal/domain"
19)
20
21func TestService_GetProcessLogs(t *testing.T) {
22	log := zerowrap.New(zerowrap.Config{Level: "warn"})
23
24	t.Run("returns lines from log file", func(t *testing.T) {
25		// Create temp log file with content
26		tmpDir := t.TempDir()
27		logPath := filepath.Join(tmpDir, "test.log")
28		content := "line1\nline2\nline3\nline4\nline5\n"
29		err := os.WriteFile(logPath, []byte(content), 0644)
30		require.NoError(t, err)
31
32		runtime := mocks.NewMockContainerRuntime(t)
33
34		svc := NewService(logPath, true, runtime, log)
35
36		lines, err := svc.GetProcessLogs(context.Background(), 3)
37		require.NoError(t, err)
38		assert.Len(t, lines, 3)
39		assert.Equal(t, []string{"line3", "line4", "line5"}, lines)
40	})
41
42	t.Run("returns empty slice for non-existent file", func(t *testing.T) {
43		// The journalctl fallback must not depend on the host: CI containers have none.
44		origExec := execCommandContext
45		execCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd {
46			return exec.CommandContext(ctx, "true") // #nosec G204
47		}
48		defer func() {
49			execCommandContext = origExec
50		}()
51
52		runtime := mocks.NewMockContainerRuntime(t)
53
54		svc := NewService("/nonexistent/file.log", true, runtime, log)
55
56		lines, err := svc.GetProcessLogs(context.Background(), 10)
57		require.NoError(t, err)
58		assert.Empty(t, lines)
59	})
60
61	t.Run("returns error when log file path not configured", func(t *testing.T) {
62		runtime := mocks.NewMockContainerRuntime(t)
63
64		svc := NewService("", true, runtime, log)
65
66		_, err := svc.GetProcessLogs(context.Background(), 10)
67		assert.Error(t, err)
68		assert.Contains(t, err.Error(), "log file path not configured")
69	})
70
71	t.Run("returns all lines when fewer than requested", func(t *testing.T) {
72		tmpDir := t.TempDir()
73		logPath := filepath.Join(tmpDir, "test.log")
74		content := "line1\nline2\n"
75		err := os.WriteFile(logPath, []byte(content), 0644)
76		require.NoError(t, err)
77
78		runtime := mocks.NewMockContainerRuntime(t)
79
80		svc := NewService(logPath, true, runtime, log)
81
82		lines, err := svc.GetProcessLogs(context.Background(), 10)
83		require.NoError(t, err)
84		assert.Equal(t, []string{"line1", "line2"}, lines)
85	})
86
87	t.Run("falls back to journalctl when file logging disabled", func(t *testing.T) {
88		origExec := execCommandContext
89		execCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd {
90			return exec.CommandContext(ctx, "bash", "-lc", "printf 'journal-line-1\\njournal-line-2\\n'") // #nosec G204
91		}
92		defer func() {
93			execCommandContext = origExec
94		}()
95
96		runtime := mocks.NewMockContainerRuntime(t)
97
98		svc := NewService("/tmp/unused.log", false, runtime, log)
99
100		lines, err := svc.GetProcessLogs(context.Background(), 10)
101		require.NoError(t, err)
102		assert.Equal(t, []string{"journal-line-1", "journal-line-2"}, lines)
103	})
104}
105
106func TestService_FollowProcessLogs(t *testing.T) {
107	log := zerowrap.New(zerowrap.Config{Level: "warn"})
108
109	t.Run("streams lines and respects context cancellation", func(t *testing.T) {
110		tmpDir := t.TempDir()
111		logPath := filepath.Join(tmpDir, "test.log")
112		content := "initial1\ninitial2\n"
113		err := os.WriteFile(logPath, []byte(content), 0644)
114		require.NoError(t, err)
115
116		runtime := mocks.NewMockContainerRuntime(t)
117
118		svc := NewService(logPath, true, runtime, log)
119
120		ctx, cancel := context.WithCancel(context.Background())
121		defer cancel()
122
123		ch, err := svc.FollowProcessLogs(ctx, 2)
124		require.NoError(t, err)
125
126		// Read initial lines
127		var received []string
128		for i := 0; i < 2; i++ {
129			select {
130			case line := <-ch:
131				received = append(received, line)
132			case <-time.After(time.Second):
133				t.Fatal("timeout waiting for initial lines")
134			}
135		}
136		assert.Equal(t, []string{"initial1", "initial2"}, received)
137
138		// Cancel context to stop following
139		cancel()
140
141		// Channel should be closed
142		select {
143		case <-ch:
144			// May receive one more line before closure, that's OK
145		case <-time.After(500 * time.Millisecond):
146			// OK, channel may already be closed
147		}
148	})
149}
150
151func TestService_GetContainerLogs(t *testing.T) {
152	log := zerowrap.New(zerowrap.Config{Level: "warn"})
153
154	t.Run("returns error when container not found", func(t *testing.T) {
155		runtime := mocks.NewMockContainerRuntime(t)
156
157		svc := NewService("/tmp/test.log", true, runtime, log)
158		svc.WithAppState(stubActiveApps{})
159
160		_, err := svc.GetContainerLogs(context.Background(), "unknown/web", 10)
161		assert.Error(t, err)
162		assert.Contains(t, err.Error(), "active app/service not found")
163	})
164
165	for _, tc := range []struct {
166		name string
167		ref  string
168	}{
169		{name: "raw Docker ID", ref: "abc123def456"},
170		{name: "foreign container ID", ref: "fedcba654321"},
171		{name: "stale container ID", ref: "012345abcdef"},
172	} {
173		t.Run("rejects "+tc.name+" without calling the runtime", func(t *testing.T) {
174			runtime := mocks.NewMockContainerRuntime(t)
175
176			svc := NewService("/tmp/test.log", true, runtime, log)
177			_, err := svc.GetContainerLogs(context.Background(), tc.ref, 10)
178			require.Error(t, err)
179			assert.Contains(t, err.Error(), "app/service")
180		})
181	}
182
183	t.Run("calls runtime with correct container ID", func(t *testing.T) {
184		runtime := mocks.NewMockContainerRuntime(t)
185
186		// Create a mock reader that returns empty content
187		runtime.EXPECT().GetContainerLogs(mock.Anything, "abc123", false).Return(&mockReader{}, nil)
188
189		svc := NewService("/tmp/test.log", true, runtime, log)
190		svc.WithAppState(stubActiveApps{"app": {App: "app", Services: map[string]domain.AppEffectiveService{
191			"web": {Container: "abc123"},
192		}}})
193
194		lines, err := svc.GetContainerLogs(context.Background(), "app/web", 10)
195		require.NoError(t, err)
196		assert.Empty(t, lines)
197	})
198}
199
200func TestService_FollowContainerLogs(t *testing.T) {
201	log := zerowrap.New(zerowrap.Config{Level: "warn"})
202
203	t.Run("returns error when container not found", func(t *testing.T) {
204		runtime := mocks.NewMockContainerRuntime(t)
205
206		svc := NewService("/tmp/test.log", true, runtime, log)
207		svc.WithAppState(stubActiveApps{})
208
209		_, err := svc.FollowContainerLogs(context.Background(), "unknown/web", 10)
210		assert.Error(t, err)
211		assert.Contains(t, err.Error(), "active app/service not found")
212	})
213
214	t.Run("rejects raw container ID without calling the runtime", func(t *testing.T) {
215		runtime := mocks.NewMockContainerRuntime(t)
216
217		svc := NewService("/tmp/test.log", true, runtime, log)
218		_, err := svc.FollowContainerLogs(context.Background(), "abc123def456", 10)
219		require.Error(t, err)
220		assert.Contains(t, err.Error(), "app/service")
221	})
222}
223
224type stubActiveApps map[string]domain.AppActive
225
226func (s stubActiveApps) LoadActive(_ context.Context, app string) (domain.AppActive, bool, error) {
227	active, ok := s[app]
228	return active, ok, nil
229}
230
231func TestTailLines(t *testing.T) {
232	t.Run("returns correct last N lines", func(t *testing.T) {
233		tmpDir := t.TempDir()
234		path := filepath.Join(tmpDir, "test.log")
235		content := "a\nb\nc\nd\ne\n"
236		err := os.WriteFile(path, []byte(content), 0644)
237		require.NoError(t, err)
238
239		file, err := os.Open(path)
240		require.NoError(t, err)
241		defer file.Close()
242
243		lines, err := tailLines(file, 3)
244		require.NoError(t, err)
245		assert.Equal(t, []string{"c", "d", "e"}, lines)
246	})
247
248	t.Run("returns all lines when n exceeds line count", func(t *testing.T) {
249		tmpDir := t.TempDir()
250		path := filepath.Join(tmpDir, "test.log")
251		content := "x\ny\n"
252		err := os.WriteFile(path, []byte(content), 0644)
253		require.NoError(t, err)
254
255		file, err := os.Open(path)
256		require.NoError(t, err)
257		defer file.Close()
258
259		lines, err := tailLines(file, 10)
260		require.NoError(t, err)
261		assert.Equal(t, []string{"x", "y"}, lines)
262	})
263
264	t.Run("returns empty slice for empty file", func(t *testing.T) {
265		tmpDir := t.TempDir()
266		path := filepath.Join(tmpDir, "test.log")
267		err := os.WriteFile(path, []byte(""), 0644)
268		require.NoError(t, err)
269
270		file, err := os.Open(path)
271		require.NoError(t, err)
272		defer file.Close()
273
274		lines, err := tailLines(file, 10)
275		require.NoError(t, err)
276		assert.Empty(t, lines)
277	})
278
279	t.Run("returns empty slice when n is zero", func(t *testing.T) {
280		tmpDir := t.TempDir()
281		path := filepath.Join(tmpDir, "test.log")
282		content := "a\nb\n"
283		err := os.WriteFile(path, []byte(content), 0644)
284		require.NoError(t, err)
285
286		file, err := os.Open(path)
287		require.NoError(t, err)
288		defer file.Close()
289
290		lines, err := tailLines(file, 0)
291		require.NoError(t, err)
292		assert.Empty(t, lines)
293	})
294}
295
296// mockReader implements io.ReadCloser for testing.
297type mockReader struct{}
298
299func (m *mockReader) Read(p []byte) (n int, err error) {
300	return 0, io.EOF
301}
302
303func (m *mockReader) Close() error {
304	return nil
305}

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 7

Tags 106

↑↓ moveenter openesc close