package logs_test import ( "os" "path/filepath" "strings" "testing" "time" "vetting/internal/events" "vetting/internal/logs" ) // TestAppendFansOutToSSE verifies the two guarantees of the log hub: // (a) every line is persisted to the per-run file, and (b) every line // is published as an SSE event with name log-. The UI relies on // both — the file for reload replay, the event for live tail. func TestAppendFansOutToSSE(t *testing.T) { dir := t.TempDir() hub := events.NewHub() lh, err := logs.NewHub(dir, hub) if err != nil { t.Fatalf("NewHub: %v", err) } defer lh.Close() _, ch, cancel := hub.Subscribe() defer cancel() w, err := lh.WriterFor(77) if err != nil { t.Fatalf("WriterFor: %v", err) } w.Append(logs.Line{Level: "info", Text: "hello from agent"}) w.Append(logs.Line{Level: "error", Text: ""}) got := collect(ch, 3, 500*time.Millisecond) // Filter out heartbeats that may sneak in. var logEvents []events.Event for _, ev := range got { if strings.HasPrefix(ev.Name, "log-") { logEvents = append(logEvents, ev) } } if len(logEvents) < 2 { t.Fatalf("expected 2 log events, got %d (all=%+v)", len(logEvents), got) } for _, ev := range logEvents { if ev.Name != "log-77" { t.Fatalf("unexpected event name %q", ev.Name) } } // XSS protection: raw ") { t.Fatalf("disk log should keep raw text (unescaped): %q", text) } if !strings.Contains(text, "INFO") || !strings.Contains(text, "ERROR") { t.Fatalf("disk log missing level prefix: %q", text) } } // TestWriterForIsCached verifies a second call returns the same Writer // — otherwise parallel /log POSTs would race on file opens and possibly // stomp on in-flight writes. func TestWriterForIsCached(t *testing.T) { hub := events.NewHub() lh, err := logs.NewHub(t.TempDir(), hub) if err != nil { t.Fatalf("NewHub: %v", err) } defer lh.Close() w1, err := lh.WriterFor(1) if err != nil { t.Fatalf("WriterFor: %v", err) } w2, err := lh.WriterFor(1) if err != nil { t.Fatalf("WriterFor: %v", err) } if w1 != w2 { t.Fatalf("Writer not cached: %p vs %p", w1, w2) } } // collect drains up to max events or bails after deadline. func collect(ch <-chan events.Event, max int, deadline time.Duration) []events.Event { out := []events.Event{} timer := time.NewTimer(deadline) defer timer.Stop() for len(out) < max { select { case ev, ok := <-ch: if !ok { return out } out = append(out, ev) case <-timer.C: return out } } return out }