| 1 | package applog |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | "time" |
| 8 | ) |
| 9 | |
| 10 | // TestRecordWireShapeIsAdditive pins the wire contract: every field but ts |
| 11 | // and msg is omitempty, and a record written by a future revision with keys |
| 12 | // this one has never heard of still parses. Live chunks exist in production |
| 13 | // buckets from the moment this ships — anything written before a change |
| 14 | // must still parse (AGENTS.md wire-shape rule). |
| 15 | func TestRecordWireShapeIsAdditive(t *testing.T) { |
| 16 | r := Record{ |
| 17 | TS: time.Date(2026, 8, 10, 14, 3, 11, 482913000, time.UTC), |
| 18 | Msg: "hello", |
| 19 | } |
| 20 | b, err := json.Marshal(r) |
| 21 | if err != nil { |
| 22 | t.Fatal(err) |
| 23 | } |
| 24 | for _, absent := range []string{"box", "host", "stream", "level", "fields", "seq"} { |
| 25 | if strings.Contains(string(b), `"`+absent+`"`) { |
| 26 | t.Errorf("zero-valued %q should be omitted from the wire: %s", absent, b) |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | // A future record with unknown keys parses; known keys land. |
| 31 | future := `{"ts":"2026-08-10T14:03:11.482913Z","msg":"m","stream":"app",` + |
| 32 | `"seq":41822,"box":"b1","host":"h","level":"error",` + |
| 33 | `"fields":{"k":"v"},"shiny_new_thing":{"deep":true}}` |
| 34 | var got Record |
| 35 | if err := json.Unmarshal([]byte(future), &got); err != nil { |
| 36 | t.Fatalf("future record must parse: %v", err) |
| 37 | } |
| 38 | if got.Seq != 41822 || got.Stream != StreamApp || got.Fields["k"] != "v" { |
| 39 | t.Fatalf("known fields lost in additive parse: %+v", got) |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | // TestParseAppLine covers the JSON-line contract: a top-level JSON object |
| 44 | // yields fields from its scalar values, msg from msg/message, level only |
| 45 | // from a recognized value — and anything else is opaque text, untouched. |
| 46 | func TestParseAppLine(t *testing.T) { |
| 47 | cases := []struct { |
| 48 | name, line string |
| 49 | wantMsg, wantLevel string |
| 50 | wantFields map[string]string |
| 51 | }{ |
| 52 | {"plain text", "listening on :8080", "listening on :8080", "", nil}, |
| 53 | {"non-object json", `[1,2,3]`, `[1,2,3]`, "", nil}, |
| 54 | { |
| 55 | "structured", |
| 56 | `{"level":"ERROR","msg":"boom","request_id":"a1b2","n":7,"ok":true}`, |
| 57 | "boom", "error", |
| 58 | map[string]string{"request_id": "a1b2", "n": "7", "ok": "true"}, |
| 59 | }, |
| 60 | { |
| 61 | "message key and unknown level", |
| 62 | `{"level":"shouting","message":"hi"}`, |
| 63 | "hi", "", |
| 64 | // An unrecognized level is DATA, not a level — it must survive |
| 65 | // as a field rather than be guessed at or dropped. |
| 66 | map[string]string{"level": "shouting"}, |
| 67 | }, |
| 68 | { |
| 69 | "nested values are not flattened", |
| 70 | `{"msg":"m","deep":{"a":1}}`, |
| 71 | "m", "", nil, |
| 72 | }, |
| 73 | {"empty line", "", "", "", nil}, |
| 74 | } |
| 75 | for _, tc := range cases { |
| 76 | t.Run(tc.name, func(t *testing.T) { |
| 77 | msg, level, fields := ParseAppLine(tc.line) |
| 78 | if msg != tc.wantMsg || level != tc.wantLevel { |
| 79 | t.Fatalf("msg=%q level=%q, want %q/%q", msg, level, tc.wantMsg, tc.wantLevel) |
| 80 | } |
| 81 | if len(fields) != len(tc.wantFields) { |
| 82 | t.Fatalf("fields=%v, want %v", fields, tc.wantFields) |
| 83 | } |
| 84 | for k, v := range tc.wantFields { |
| 85 | if fields[k] != v { |
| 86 | t.Fatalf("fields[%q]=%q, want %q", k, fields[k], v) |
| 87 | } |
| 88 | } |
| 89 | }) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | // TestLineWriter: the tee's writer half splits on \n, caps line length with |
| 94 | // an explicit truncation marker, and flushes a trailing partial on Close — |
| 95 | // output bytes are never reordered or dropped silently. |
| 96 | func TestLineWriter(t *testing.T) { |
| 97 | var got []string |
| 98 | w := NewLineWriter(16, func(line string) { got = append(got, line) }) |
| 99 | |
| 100 | // Split across writes; only complete lines emit. |
| 101 | w.Write([]byte("hel")) |
| 102 | w.Write([]byte("lo\nwor")) |
| 103 | if len(got) != 1 || got[0] != "hello" { |
| 104 | t.Fatalf("after partial writes got %q", got) |
| 105 | } |
| 106 | // An over-long line is truncated once, marked, and the tail discarded — |
| 107 | // not emitted as a phantom second line. |
| 108 | w.Write([]byte("ldxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-tail\nnext\n")) |
| 109 | if len(got) != 3 { |
| 110 | t.Fatalf("got %q", got) |
| 111 | } |
| 112 | if !strings.HasSuffix(got[1], "…(truncated)") || len(got[1]) > 16+len("…(truncated)") { |
| 113 | t.Fatalf("long line not truncated with marker: %q", got[1]) |
| 114 | } |
| 115 | if got[2] != "next" { |
| 116 | t.Fatalf("line after a truncated one corrupted: %q", got[2]) |
| 117 | } |
| 118 | // Close flushes the partial. |
| 119 | w.Write([]byte("no newline")) |
| 120 | w.Close() |
| 121 | if got[len(got)-1] != "no newline" { |
| 122 | t.Fatalf("Close did not flush the partial: %q", got) |
| 123 | } |
| 124 | // Empty lines are dropped (a trailing \n on every Printf would |
| 125 | // otherwise ship a blank record each time). |
| 126 | got = nil |
| 127 | w2 := NewLineWriter(16, func(line string) { got = append(got, line) }) |
| 128 | w2.Write([]byte("\n\n")) |
| 129 | w2.Close() |
| 130 | if len(got) != 0 { |
| 131 | t.Fatalf("blank lines shipped: %q", got) |
| 132 | } |
| 133 | } |
| 134 | |