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}