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