package plugin import ( "encoding/json" "strings" "testing" "unicode/utf8" "github.com/alexei/tinyforge/internal/events" "github.com/alexei/tinyforge/internal/store" ) // capturePublisher records every event published on it so a test can // assert on the bus payload. Satisfies plugin.EventPublisher. type capturePublisher struct { events []events.Event } func (c *capturePublisher) Publish(evt events.Event) { c.events = append(c.events, evt) } // newEmitDeps builds a plugin.Deps backed by an in-memory store and a // capturing publisher. Mirrors the in-memory store pattern used by the // store + source-plugin tests. func newEmitDeps(t *testing.T) (Deps, *capturePublisher) { t.Helper() st, err := store.New(":memory:") if err != nil { t.Fatalf("open store: %v", err) } t.Cleanup(func() { _ = st.Close() }) pub := &capturePublisher{} return Deps{Store: st, Events: pub}, pub } func TestEmitDeployEvent(t *testing.T) { tests := []struct { name string status string wantSeverity string }{ {name: "deployed is info", status: "deployed", wantSeverity: "info"}, {name: "deploying is info", status: "deploying", wantSeverity: "info"}, {name: "failed is error", status: "failed: pull foo failed", wantSeverity: "error"}, {name: "failed bare is error", status: "failed", wantSeverity: "error"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { deps, pub := newEmitDeps(t) w := Workload{ID: "wl-123", Name: "my-app"} EmitDeployEvent(deps, w, "image", tt.status) // Persisted row carries the workload scope + derived severity. rows, err := deps.Store.ListEvents(store.EventLogFilter{WorkloadID: w.ID}) if err != nil { t.Fatalf("ListEvents: %v", err) } if len(rows) != 1 { t.Fatalf("got %d persisted events, want 1", len(rows)) } got := rows[0] if got.Severity != tt.wantSeverity { t.Errorf("severity = %q, want %q", got.Severity, tt.wantSeverity) } if got.Source != "image" { t.Errorf("source = %q, want %q", got.Source, "image") } if got.WorkloadID != w.ID { t.Errorf("workload_id = %q, want %q", got.WorkloadID, w.ID) } wantMsg := w.Name + ": " + tt.status if got.Message != wantMsg { t.Errorf("message = %q, want %q", got.Message, wantMsg) } // Metadata JSON carries workload_id / workload_name / status. var meta map[string]string if err := json.Unmarshal([]byte(got.Metadata), &meta); err != nil { t.Fatalf("unmarshal metadata %q: %v", got.Metadata, err) } if meta["workload_id"] != w.ID { t.Errorf("metadata workload_id = %q, want %q", meta["workload_id"], w.ID) } if meta["workload_name"] != w.Name { t.Errorf("metadata workload_name = %q, want %q", meta["workload_name"], w.Name) } if meta["status"] != tt.status { t.Errorf("metadata status = %q, want %q", meta["status"], tt.status) } // The persisted row is also re-published on the bus as an // EventLog so SSE clients see it live. if len(pub.events) != 1 { t.Fatalf("got %d published events, want 1", len(pub.events)) } ev := pub.events[0] if ev.Type != events.EventLog { t.Errorf("event type = %q, want %q", ev.Type, events.EventLog) } payload, ok := ev.Payload.(events.EventLogPayload) if !ok { t.Fatalf("payload type = %T, want events.EventLogPayload", ev.Payload) } if payload.WorkloadID != w.ID { t.Errorf("payload workload_id = %q, want %q", payload.WorkloadID, w.ID) } if payload.Severity != tt.wantSeverity { t.Errorf("payload severity = %q, want %q", payload.Severity, tt.wantSeverity) } if payload.ID != got.ID { t.Errorf("payload id = %d, want %d", payload.ID, got.ID) } }) } } // TestEmitDeployEvent_CapsLongStatus verifies a long failure status (e.g. one // embedding raw subprocess output) is bounded to maxDeployStatusRunes runes in // both the persisted message and metadata, cut on a UTF-8 boundary, while // severity is still derived from the original "failed" prefix. func TestEmitDeployEvent_CapsLongStatus(t *testing.T) { deps, pub := newEmitDeps(t) w := Workload{ID: "wl-cap", Name: "app"} // Multibyte body so a naive byte-slice would corrupt a rune; prefix with // "failed: " so the severity check exercises the pre-cap derivation. longStatus := "failed: " + strings.Repeat("é", 400) EmitDeployEvent(deps, w, "compose", longStatus) rows, err := deps.Store.ListEvents(store.EventLogFilter{WorkloadID: w.ID}) if err != nil { t.Fatalf("ListEvents: %v", err) } if len(rows) != 1 { t.Fatalf("got %d events, want 1", len(rows)) } got := rows[0] if got.Severity != "error" { t.Errorf("severity = %q, want error (derived from pre-cap prefix)", got.Severity) } var meta map[string]string if err := json.Unmarshal([]byte(got.Metadata), &meta); err != nil { t.Fatalf("unmarshal metadata: %v", err) } capped := meta["status"] if rc := len([]rune(capped)); rc != maxDeployStatusRunes+1 { // +1 for the ellipsis rune t.Errorf("capped status = %d runes, want %d", rc, maxDeployStatusRunes+1) } if !utf8.ValidString(capped) { t.Errorf("capped status is not valid UTF-8: %q", capped) } if !strings.HasSuffix(capped, "…") { t.Errorf("capped status missing ellipsis suffix: %q", capped) } wantMsg := w.Name + ": " + capped if got.Message != wantMsg { t.Errorf("message = %q, want %q", got.Message, wantMsg) } if len(pub.events) != 1 { t.Fatalf("got %d published events, want 1", len(pub.events)) } }