1package accesslog
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "sync"
11 "testing"
12 "time"
13
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"
16
17 out "github.com/bnema/gordon/internal/boundaries/out"
18)
19
20var sampleEntry = out.AccessLogEntry{
21 Time: time.Date(2026, 4, 3, 14, 20, 54, 167_000_000, time.UTC),
22 ClientIP: "10.0.0.1",
23 Method: "POST",
24 Host: "api.example.com",
25 Path: "/api/v1/data",
26 Query: "limit=10",
27 Status: 200,
28 BytesSent: 512,
29 DurationMS: 3.5,
30 UserAgent: "curl/7.88",
31 Referer: "https://example.com",
32 RequestID: "abc123",
33 Proto: "HTTP/2.0",
34}
35
36// --- New() validation ---
37
38func TestNew_InvalidFormat(t *testing.T) {
39 _, err := New(Config{Format: "xml", Output: "stdout"})
40 require.Error(t, err)
41 assert.Contains(t, err.Error(), "invalid logging.access_log.format")
42}
43
44func TestNew_InvalidOutput(t *testing.T) {
45 _, err := New(Config{Format: "json", Output: "kafka"})
46 require.Error(t, err)
47 assert.Contains(t, err.Error(), "invalid logging.access_log.output")
48}
49
50func TestNew_FileOutput_EmptyPath(t *testing.T) {
51 _, err := New(Config{Format: "json", Output: "file", FilePath: ""})
52 require.Error(t, err)
53}
54
55func TestNew_FileOutput_CreatesDirectory(t *testing.T) {
56 dir := t.TempDir()
57 path := filepath.Join(dir, "sub", "access.log")
58 w, err := New(Config{
59 Format: "json",
60 Output: "file",
61 FilePath: path,
62 MaxSize: 10,
63 })
64 require.NoError(t, err)
65 defer w.Close()
66
67 assert.DirExists(t, filepath.Join(dir, "sub"))
68}
69
70// --- stdout sink ---
71
72func TestWriter_Stdout_JSON(t *testing.T) {
73 // Replace stdout to capture output.
74 orig := os.Stdout
75 r, w, _ := os.Pipe()
76 os.Stdout = w
77
78 writer, err := New(Config{Format: "json", Output: "stdout"})
79 require.NoError(t, err)
80
81 require.NoError(t, writer.Write(sampleEntry))
82 require.NoError(t, writer.Close())
83
84 w.Close()
85 os.Stdout = orig
86
87 var buf bytes.Buffer
88 _, _ = buf.ReadFrom(r)
89 line := strings.TrimSpace(buf.String())
90
91 var got map[string]any
92 require.NoError(t, json.Unmarshal([]byte(line), &got), "output must be valid JSON: %s", line)
93 assert.Equal(t, "10.0.0.1", got["client_ip"])
94 assert.Equal(t, "POST", got["method"])
95 assert.InDelta(t, 200, got["status"], 0)
96}
97
98func TestWriter_Stdout_CLF(t *testing.T) {
99 orig := os.Stdout
100 r, w, _ := os.Pipe()
101 os.Stdout = w
102
103 writer, err := New(Config{Format: "clf", Output: "stdout"})
104 require.NoError(t, err)
105 require.NoError(t, writer.Write(sampleEntry))
106 require.NoError(t, writer.Close())
107
108 w.Close()
109 os.Stdout = orig
110
111 var buf bytes.Buffer
112 _, _ = buf.ReadFrom(r)
113 line := strings.TrimSpace(buf.String())
114
115 assert.True(t, strings.HasPrefix(line, "10.0.0.1 - - ["))
116 assert.Contains(t, line, `"POST /api/v1/data?limit=10 HTTP/2.0"`)
117 assert.Contains(t, line, " 200 512")
118}
119
120func TestWriter_Stdout_Combined(t *testing.T) {
121 orig := os.Stdout
122 r, w, _ := os.Pipe()
123 os.Stdout = w
124
125 writer, err := New(Config{Format: "combined", Output: "stdout"})
126 require.NoError(t, err)
127 require.NoError(t, writer.Write(sampleEntry))
128 require.NoError(t, writer.Close())
129
130 w.Close()
131 os.Stdout = orig
132
133 var buf bytes.Buffer
134 _, _ = buf.ReadFrom(r)
135 line := strings.TrimSpace(buf.String())
136
137 assert.Contains(t, line, `"https://example.com"`)
138 assert.Contains(t, line, `"curl/7.88"`)
139}
140
141// --- file sink ---
142
143func TestWriter_File_WritesLines(t *testing.T) {
144 dir := t.TempDir()
145 path := filepath.Join(dir, "access.log")
146
147 writer, err := New(Config{
148 Format: "json",
149 Output: "file",
150 FilePath: path,
151 MaxSize: 10,
152 MaxBackups: 1,
153 })
154 require.NoError(t, err)
155
156 require.NoError(t, writer.Write(sampleEntry))
157 require.NoError(t, writer.Write(sampleEntry))
158 require.NoError(t, writer.Close())
159
160 data, err := os.ReadFile(path)
161 require.NoError(t, err)
162
163 lines := strings.Split(strings.TrimSpace(string(data)), "\n")
164 assert.Len(t, lines, 2)
165 for _, l := range lines {
166 var obj map[string]any
167 assert.NoError(t, json.Unmarshal([]byte(l), &obj), "each line must be valid JSON")
168 }
169}
170
171// --- concurrency ---
172
173func TestWriter_ConcurrentWrites(t *testing.T) {
174 dir := t.TempDir()
175 path := filepath.Join(dir, "concurrent.log")
176
177 writer, err := New(Config{
178 Format: "json",
179 Output: "file",
180 FilePath: path,
181 MaxSize: 100,
182 })
183 require.NoError(t, err)
184 defer writer.Close()
185
186 const goroutines = 50
187 var wg sync.WaitGroup
188 wg.Add(goroutines)
189 for i := range goroutines {
190 go func(n int) {
191 defer wg.Done()
192 e := sampleEntry
193 e.RequestID = fmt.Sprintf("req-%d", n)
194 assert.NoError(t, writer.Write(e))
195 }(i)
196 }
197 wg.Wait()
198
199 data, err := os.ReadFile(path)
200 require.NoError(t, err)
201
202 lines := strings.Split(strings.TrimSpace(string(data)), "\n")
203 assert.Len(t, lines, goroutines, "each goroutine should produce exactly one line")
204 for _, l := range lines {
205 var obj map[string]any
206 assert.NoError(t, json.Unmarshal([]byte(l), &obj), "each line must be valid JSON")
207 }
208}