carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package applog
2
3import (
4 "bytes"
5 "compress/gzip"
6 "io"
7 "testing"
8 "time"
9)
10
11func mustGunzip(t *testing.T, b []byte) []byte {
12 t.Helper()
13 zr, err := gzip.NewReader(bytes.NewReader(b))
14 if err != nil {
15 t.Fatal(err)
16 }
17 out, err := io.ReadAll(zr)
18 if err != nil {
19 t.Fatal(err)
20 }
21 return out
22}
23
24func mustGzip(t *testing.T, b []byte) []byte {
25 t.Helper()
26 var buf bytes.Buffer
27 zw := gzip.NewWriter(&buf)
28 if _, err := zw.Write(b); err != nil {
29 t.Fatal(err)
30 }
31 if err := zw.Close(); err != nil {
32 t.Fatal(err)
33 }
34 return buf.Bytes()
35}
36
37func TestKeysAndHourFormat(t *testing.T) {
38 at := time.Date(2026, 8, 10, 21, 59, 3, 0, time.UTC)
39 if got := HourOf(at); got != "2026-08-10-21" {
40 t.Fatalf("HourOf = %q", got) // same vocabulary as metrics' hourFormat
41 }
42 if got := must(t)(Prefix("bde", "ledgerpay")); got != "apps/bde/ledgerpay/logs/" {
43 t.Fatalf("Prefix = %q", got)
44 }
45 if got := must(t)(BoxPrefix("bde", "ledgerpay", "box1")); got != "apps/bde/ledgerpay/logs/box1/" {
46 t.Fatalf("BoxPrefix = %q", got)
47 }
48 if got := must(t)(HourPrefix("bde", "ledgerpay", "box1", "2026-08-10-21")); got != "apps/bde/ledgerpay/logs/box1/2026-08-10-21/" {
49 t.Fatalf("HourPrefix = %q", got)
50 }
51 // Zero-padded so lexical order is numeric order; the byte size rides in
52 // the key so retention can count without a Store interface change.
53 key := must(t)(ChunkKey("bde", "ledgerpay", "box1", "2026-08-10-21", 41822, 8123))
54 if key != "apps/bde/ledgerpay/logs/box1/2026-08-10-21/0000000000041822-8123.jsonl.gz" {
55 t.Fatalf("ChunkKey = %q", key)
56 }
57 if n, ok := ChunkKeySize(key); !ok || n != 8123 {
58 t.Fatalf("ChunkKeySize = %d, %v", n, ok)
59 }
60 for _, bad := range []string{"x/no-size.jsonl.gz", "x/whatever.json", "0000000000041822-8123.jsonl.gz.bak"} {
61 if _, ok := ChunkKeySize(bad); ok {
62 t.Errorf("ChunkKeySize(%q) parsed", bad)
63 }
64 }
65}
66
67func TestChunkRoundTrip(t *testing.T) {
68 in := []Record{
69 {TS: time.Date(2026, 8, 10, 21, 0, 1, 0, time.UTC), Box: "b1", Host: "h", Stream: StreamApp, Msg: "one", Seq: 1},
70 {TS: time.Date(2026, 8, 10, 21, 0, 2, 0, time.UTC), Box: "b1", Stream: StreamPlatform, Level: LevelError, Msg: "two", Seq: 2, Fields: map[string]string{"k": "v"}},
71 }
72 b, err := EncodeChunk(in)
73 if err != nil {
74 t.Fatal(err)
75 }
76 out, err := DecodeChunk(b)
77 if err != nil {
78 t.Fatal(err)
79 }
80 if len(out) != 2 || !out[0].TS.Equal(in[0].TS) || out[1].Fields["k"] != "v" || out[1].Seq != 2 {
81 t.Fatalf("round trip lost data: %+v", out)
82 }
83}
84
85// TestDecodeChunkSkipsMalformedLines: one corrupt line must not cost the
86// member the rest of the hour — decode keeps what parses.
87func TestDecodeChunkSkipsMalformedLines(t *testing.T) {
88 good := []Record{{TS: time.Date(2026, 8, 10, 21, 0, 1, 0, time.UTC), Msg: "ok", Seq: 9}}
89 b, err := EncodeChunk(good)
90 if err != nil {
91 t.Fatal(err)
92 }
93 // Splice garbage into the gzip payload by re-encoding by hand.
94 raw := append([]byte("this is not json\n"), mustGunzip(t, b)...)
95 out, err := DecodeChunk(mustGzip(t, raw))
96 if err != nil {
97 t.Fatalf("decode failed outright on one bad line: %v", err)
98 }
99 if len(out) != 1 || out[0].Msg != "ok" {
100 t.Fatalf("good line lost: %+v", out)
101 }
102 // Truly corrupt gzip IS an error — that's a broken object, not a line.
103 if _, err := DecodeChunk([]byte("not gzip at all")); err == nil {
104 t.Fatal("corrupt chunk decoded to no error")
105 }
106}
107
108func TestHourNameOfDir(t *testing.T) {
109 for dir, want := range map[string]string{
110 "apps/x/y/logs/box/2026-09-07-10/": "2026-09-07-10",
111 "apps/x/y/logs/box/2026-09-07-10": "2026-09-07-10",
112 "plain": "plain",
113 "": "",
114 } {
115 if got := HourNameOfDir(dir); got != want {
116 t.Errorf("HourNameOfDir(%q) = %q, want %q", dir, got, want)
117 }
118 }
119}
120
121func TestHourBefore(t *testing.T) {
122 cutoff := time.Date(2026, 8, 10, 21, 30, 0, 0, time.UTC)
123 for hour, want := range map[string]bool{
124 "2026-08-10-20": true, // strictly before the cutoff's hour
125 "2026-08-10-21": false, // the cutoff's own hour is live
126 "2026-08-10-22": false,
127 "garbage": false, // unparsable never matches — never delete on a guess
128 } {
129 if got := HourBefore(hour, cutoff); got != want {
130 t.Errorf("HourBefore(%q) = %v, want %v", hour, got, want)
131 }
132 }
133}
134
135func TestFamilyKeysMirrorLogs(t *testing.T) {
136 if got, want := must(t)(FamilyPrefix(FamilyLogs, "bab", "web")), must(t)(Prefix("bab", "web")); got != want {
137 t.Fatalf("FamilyPrefix(logs) = %q, Prefix = %q", got, want)
138 }
139 if got, want := must(t)(FamilyChunkKey(FamilyLogs, "bab", "web", "box1", "2026-09-07-10", 42, 7)), must(t)(ChunkKey("bab", "web", "box1", "2026-09-07-10", 42, 7)); got != want {
140 t.Fatalf("FamilyChunkKey(logs) = %q, ChunkKey = %q", got, want)
141 }
142 got := must(t)(FamilyChunkKey(FamilyErrors, "bab", "web", "box1", "2026-09-07-10", 42, 7))
143 want := "apps/bab/web/errors/box1/2026-09-07-10/0000000000000042-7.jsonl.gz"
144 if got != want {
145 t.Fatalf("errors key = %q, want %q", got, want)
146 }
147 if n, ok := ChunkKeySize(got); !ok || n != 7 {
148 t.Fatalf("ChunkKeySize on an errors key = %d,%v", n, ok)
149 }
150}
151
152func TestEncodeJSONLRoundTripsAnyShape(t *testing.T) {
153 type rec struct {
154 A string `json:"a"`
155 N int `json:"n,omitempty"`
156 }
157 b, err := EncodeJSONL([]rec{{A: "x", N: 1}, {A: "y"}})
158 if err != nil {
159 t.Fatal(err)
160 }
161 out, err := DecodeJSONL[rec](b)
162 if err != nil {
163 t.Fatal(err)
164 }
165 if len(out) != 2 || out[0].A != "x" || out[0].N != 1 || out[1].A != "y" || out[1].N != 0 {
166 t.Fatalf("round trip = %+v", out)
167 }
168 // EncodeChunk must be byte-identical to EncodeJSONL over the same
169 // records: the logs family's wire shape does not move.
170 recs := []Record{{TS: time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC), Msg: "hi", Seq: 1}}
171 a, _ := EncodeChunk(recs)
172 c, _ := EncodeJSONL(recs)
173 da, _ := DecodeChunk(a)
174 dc, _ := DecodeJSONL[Record](c)
175 if len(da) != 1 || len(dc) != 1 || da[0].Msg != dc[0].Msg || !da[0].TS.Equal(dc[0].TS) {
176 t.Fatalf("EncodeChunk and EncodeJSONL disagree: %+v vs %+v", da, dc)
177 }
178}
179
180// must unwraps a key builder's (key, error) for fixtures whose names are
181// known good: must(t)(ChunkKey(...)).
182func must(t testing.TB) func(string, error) string {
183 return func(k string, err error) string {
184 t.Helper()
185 if err != nil {
186 t.Fatal(err)
187 }
188 return k
189 }
190}
191