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