agent-watcher/internal/inbox/inbox_test.go
bob-boat 50e8ece83d Collector milestone 1: inbox writer + tests
Layer 1 keystone. The internal/inbox package writes ping-shaped JSONL
events to recipient inbox files in a format bit-identical to the
agent-ping CLI's output, so the existing UserPromptSubmit hook and the
future MCP Watcher cannot tell whether a line came from `ping` or the
Collector.

- O_APPEND opens for atomic line writes (POSIX guarantees writes <=
  PIPE_BUF, our lines are well under).
- Per-recipient sync.Mutex bounds contention; multiple goroutines
  writing to one inbox stay correctly serialized.
- 7 tests passing: shape, ID/TS preservation, omitempty for optional
  fields, key-set + compactness match against ping CLI's separators=
  (",",":") output, 100-goroutine concurrent-write torn-line check,
  bad-input rejection, empty-dir rejection.

go.mod at git.botbought.ai/foreman/agent-watcher; module name matches
the public Forgejo path so eventual consumers can `go get` it.

Next milestones:
- Source plugin interface
- Drop folder source (inotify, via fsnotify)
- HTTP webhook source
- Config loader (YAML)
- main.go wiring
- systemd unit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:09:56 -04:00

182 lines
4.4 KiB
Go

package inbox
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
func TestWriter_Write_BasicShape(t *testing.T) {
dir := t.TempDir()
w, err := NewWriter(dir)
if err != nil {
t.Fatal(err)
}
if err := w.Write("bob", &Event{
From: "foreman",
Type: "INFO",
Priority: "normal",
Payload: "hi",
}); err != nil {
t.Fatal(err)
}
got := readLines(t, filepath.Join(dir, "bob.inbox"))
if len(got) != 1 {
t.Fatalf("want 1 line, got %d", len(got))
}
var ev Event
if err := json.Unmarshal([]byte(got[0]), &ev); err != nil {
t.Fatalf("not JSON: %v", err)
}
if ev.To != "bob" {
t.Errorf("To = %q, want bob", ev.To)
}
if ev.From != "foreman" {
t.Errorf("From = %q, want foreman", ev.From)
}
if ev.Payload != "hi" {
t.Errorf("Payload = %q", ev.Payload)
}
if !strings.HasPrefix(ev.ID, "ping-") {
t.Errorf("ID %q missing ping- prefix", ev.ID)
}
if len(ev.ID) != len("ping-")+8 {
t.Errorf("ID %q wrong length", ev.ID)
}
if ev.TS == "" {
t.Errorf("TS unset")
}
}
func TestWriter_Write_PreservesProvidedIDAndTS(t *testing.T) {
dir := t.TempDir()
w, err := NewWriter(dir)
if err != nil {
t.Fatal(err)
}
want := &Event{
TS: "2026-05-06T12:00:00Z",
ID: "ping-deadbeef",
From: "x",
Type: "INFO",
Priority: "normal",
Payload: "p",
}
if err := w.Write("r", want); err != nil {
t.Fatal(err)
}
got := readLines(t, filepath.Join(dir, "r.inbox"))
var ev Event
json.Unmarshal([]byte(got[0]), &ev)
if ev.ID != want.ID {
t.Errorf("ID overwritten: got %q want %q", ev.ID, want.ID)
}
if ev.TS != want.TS {
t.Errorf("TS overwritten: got %q want %q", ev.TS, want.TS)
}
}
func TestWriter_Write_OmitsEmptyOptionalFields(t *testing.T) {
dir := t.TempDir()
w, _ := NewWriter(dir)
w.Write("r", &Event{From: "f", Type: "INFO", Priority: "normal", Payload: "p"})
line := readLines(t, filepath.Join(dir, "r.inbox"))[0]
if strings.Contains(line, "sentinel") {
t.Errorf("empty sentinel leaked into JSON: %s", line)
}
if strings.Contains(line, "source") {
t.Errorf("empty source leaked into JSON: %s", line)
}
}
func TestWriter_Write_PingShapeMatchesCLI(t *testing.T) {
// The ping CLI emits JSON with separators=(",", ":") (compact) and these
// keys in this order: ts, id, from, to, type, priority, payload [, sentinel].
// Verify our marshaled output preserves the key set and compactness.
dir := t.TempDir()
w, _ := NewWriter(dir)
w.Write("r", &Event{
From: "f", Type: "INFO", Priority: "normal", Payload: "p", Sentinel: "/x",
})
line := strings.TrimSpace(readLines(t, filepath.Join(dir, "r.inbox"))[0])
wantKeys := []string{`"ts":`, `"id":`, `"from":`, `"to":`, `"type":`, `"priority":`, `"payload":`, `"sentinel":`}
for _, k := range wantKeys {
if !strings.Contains(line, k) {
t.Errorf("missing key %s in: %s", k, line)
}
}
// compact (no spaces around colons or commas)
if strings.Contains(line, ": ") || strings.Contains(line, ", ") {
t.Errorf("non-compact JSON: %s", line)
}
}
func TestWriter_Write_ConcurrentSameRecipient(t *testing.T) {
// 100 goroutines writing to the same inbox should produce 100 valid JSON
// lines, none torn or interleaved.
dir := t.TempDir()
w, _ := NewWriter(dir)
const n = 100
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
w.Write("r", &Event{From: "f", Type: "INFO", Priority: "normal", Payload: "x"})
}(i)
}
wg.Wait()
lines := readLines(t, filepath.Join(dir, "r.inbox"))
if len(lines) != n {
t.Fatalf("got %d lines, want %d (torn writes?)", len(lines), n)
}
for i, l := range lines {
var ev Event
if err := json.Unmarshal([]byte(l), &ev); err != nil {
t.Errorf("line %d not JSON: %v: %s", i, err, l)
}
}
}
func TestWriter_Write_RejectsBadInputs(t *testing.T) {
w, _ := NewWriter(t.TempDir())
if err := w.Write("", &Event{}); err == nil {
t.Error("empty recipient: expected error")
}
if err := w.Write("r", nil); err == nil {
t.Error("nil event: expected error")
}
}
func TestNewWriter_RejectsEmptyDir(t *testing.T) {
if _, err := NewWriter(""); err == nil {
t.Error("expected error for empty dir")
}
}
func readLines(t *testing.T, path string) []string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
out := []string{}
for _, l := range strings.Split(strings.TrimRight(string(b), "\n"), "\n") {
if l != "" {
out = append(out, l)
}
}
return out
}