package applog import ( "bytes" "compress/gzip" "io" "testing" "time" ) func mustGunzip(t *testing.T, b []byte) []byte { t.Helper() zr, err := gzip.NewReader(bytes.NewReader(b)) if err != nil { t.Fatal(err) } out, err := io.ReadAll(zr) if err != nil { t.Fatal(err) } return out } func mustGzip(t *testing.T, b []byte) []byte { t.Helper() var buf bytes.Buffer zw := gzip.NewWriter(&buf) if _, err := zw.Write(b); err != nil { t.Fatal(err) } if err := zw.Close(); err != nil { t.Fatal(err) } return buf.Bytes() } func TestKeysAndHourFormat(t *testing.T) { at := time.Date(2026, 8, 10, 21, 59, 3, 0, time.UTC) if got := HourOf(at); got != "2026-08-10-21" { t.Fatalf("HourOf = %q", got) // same vocabulary as metrics' hourFormat } if got := must(t)(Prefix("bde", "ledgerpay")); got != "apps/bde/ledgerpay/logs/" { t.Fatalf("Prefix = %q", got) } if got := must(t)(BoxPrefix("bde", "ledgerpay", "box1")); got != "apps/bde/ledgerpay/logs/box1/" { t.Fatalf("BoxPrefix = %q", got) } if got := must(t)(HourPrefix("bde", "ledgerpay", "box1", "2026-08-10-21")); got != "apps/bde/ledgerpay/logs/box1/2026-08-10-21/" { t.Fatalf("HourPrefix = %q", got) } // Zero-padded so lexical order is numeric order; the byte size rides in // the key so retention can count without a Store interface change. key := must(t)(ChunkKey("bde", "ledgerpay", "box1", "2026-08-10-21", 41822, 8123)) if key != "apps/bde/ledgerpay/logs/box1/2026-08-10-21/0000000000041822-8123.jsonl.gz" { t.Fatalf("ChunkKey = %q", key) } if n, ok := ChunkKeySize(key); !ok || n != 8123 { t.Fatalf("ChunkKeySize = %d, %v", n, ok) } for _, bad := range []string{"x/no-size.jsonl.gz", "x/whatever.json", "0000000000041822-8123.jsonl.gz.bak"} { if _, ok := ChunkKeySize(bad); ok { t.Errorf("ChunkKeySize(%q) parsed", bad) } } } func TestChunkRoundTrip(t *testing.T) { in := []Record{ {TS: time.Date(2026, 8, 10, 21, 0, 1, 0, time.UTC), Box: "b1", Host: "h", Stream: StreamApp, Msg: "one", Seq: 1}, {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"}}, } b, err := EncodeChunk(in) if err != nil { t.Fatal(err) } out, err := DecodeChunk(b) if err != nil { t.Fatal(err) } if len(out) != 2 || !out[0].TS.Equal(in[0].TS) || out[1].Fields["k"] != "v" || out[1].Seq != 2 { t.Fatalf("round trip lost data: %+v", out) } } // TestDecodeChunkSkipsMalformedLines: one corrupt line must not cost the // member the rest of the hour — decode keeps what parses. func TestDecodeChunkSkipsMalformedLines(t *testing.T) { good := []Record{{TS: time.Date(2026, 8, 10, 21, 0, 1, 0, time.UTC), Msg: "ok", Seq: 9}} b, err := EncodeChunk(good) if err != nil { t.Fatal(err) } // Splice garbage into the gzip payload by re-encoding by hand. raw := append([]byte("this is not json\n"), mustGunzip(t, b)...) out, err := DecodeChunk(mustGzip(t, raw)) if err != nil { t.Fatalf("decode failed outright on one bad line: %v", err) } if len(out) != 1 || out[0].Msg != "ok" { t.Fatalf("good line lost: %+v", out) } // Truly corrupt gzip IS an error — that's a broken object, not a line. if _, err := DecodeChunk([]byte("not gzip at all")); err == nil { t.Fatal("corrupt chunk decoded to no error") } } func TestHourNameOfDir(t *testing.T) { for dir, want := range map[string]string{ "apps/x/y/logs/box/2026-09-07-10/": "2026-09-07-10", "apps/x/y/logs/box/2026-09-07-10": "2026-09-07-10", "plain": "plain", "": "", } { if got := HourNameOfDir(dir); got != want { t.Errorf("HourNameOfDir(%q) = %q, want %q", dir, got, want) } } } func TestHourBefore(t *testing.T) { cutoff := time.Date(2026, 8, 10, 21, 30, 0, 0, time.UTC) for hour, want := range map[string]bool{ "2026-08-10-20": true, // strictly before the cutoff's hour "2026-08-10-21": false, // the cutoff's own hour is live "2026-08-10-22": false, "garbage": false, // unparsable never matches — never delete on a guess } { if got := HourBefore(hour, cutoff); got != want { t.Errorf("HourBefore(%q) = %v, want %v", hour, got, want) } } } func TestFamilyKeysMirrorLogs(t *testing.T) { if got, want := must(t)(FamilyPrefix(FamilyLogs, "bab", "web")), must(t)(Prefix("bab", "web")); got != want { t.Fatalf("FamilyPrefix(logs) = %q, Prefix = %q", got, want) } 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 { t.Fatalf("FamilyChunkKey(logs) = %q, ChunkKey = %q", got, want) } got := must(t)(FamilyChunkKey(FamilyErrors, "bab", "web", "box1", "2026-09-07-10", 42, 7)) want := "apps/bab/web/errors/box1/2026-09-07-10/0000000000000042-7.jsonl.gz" if got != want { t.Fatalf("errors key = %q, want %q", got, want) } if n, ok := ChunkKeySize(got); !ok || n != 7 { t.Fatalf("ChunkKeySize on an errors key = %d,%v", n, ok) } } func TestEncodeJSONLRoundTripsAnyShape(t *testing.T) { type rec struct { A string `json:"a"` N int `json:"n,omitempty"` } b, err := EncodeJSONL([]rec{{A: "x", N: 1}, {A: "y"}}) if err != nil { t.Fatal(err) } out, err := DecodeJSONL[rec](b) if err != nil { t.Fatal(err) } if len(out) != 2 || out[0].A != "x" || out[0].N != 1 || out[1].A != "y" || out[1].N != 0 { t.Fatalf("round trip = %+v", out) } // EncodeChunk must be byte-identical to EncodeJSONL over the same // records: the logs family's wire shape does not move. recs := []Record{{TS: time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC), Msg: "hi", Seq: 1}} a, _ := EncodeChunk(recs) c, _ := EncodeJSONL(recs) da, _ := DecodeChunk(a) dc, _ := DecodeJSONL[Record](c) if len(da) != 1 || len(dc) != 1 || da[0].Msg != dc[0].Msg || !da[0].TS.Equal(dc[0].TS) { t.Fatalf("EncodeChunk and EncodeJSONL disagree: %+v vs %+v", da, dc) } } // must unwraps a key builder's (key, error) for fixtures whose names are // known good: must(t)(ChunkKey(...)). func must(t testing.TB) func(string, error) string { return func(k string, err error) string { t.Helper() if err != nil { t.Fatal(err) } return k } }