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