package main import ( "context" "encoding/json" "os" "path/filepath" "strings" "testing" "time" "git.novox.be/novox/mesh-sdk/go/health" ) // 1519/AC1: the manifest declares runtime-health {kind: tool, tool: mail_reader_health}. func TestTheManifestDeclaresRuntimeHealth(t *testing.T) { raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) if err != nil { t.Fatal(err) } var m struct { RuntimeHealth struct { Kind string `json:"kind"` Tool string `json:"tool"` } `json:"runtime-health"` } if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } if m.RuntimeHealth.Kind != "tool" || m.RuntimeHealth.Tool != HealthTool { t.Fatalf("runtime-health = %+v, want kind tool, tool %s", m.RuntimeHealth, HealthTool) } } // 1519/AC2: after a judgement refused, timed out or answered out of shape, mail_reader_health answers // unhealthy with why naming which of the three, the judgement's own words, and since when; later failures // keep the first since. func TestRecordJudgementUnhealthyKeepsTheFirstSince(t *testing.T) { first := time.Date(2026, 10, 20, 9, 30, 0, 0, time.UTC) after := RecordJudgement(ReaderHealth{Healthy: true}, OutcomeRefused, "holds no licence", first) if after.Healthy || after.Cause != OutcomeRefused || after.Why != "holds no licence" || !after.Since.Equal(first) { t.Fatalf("after one refusal: %+v", after) } second := first.Add(5 * time.Minute) again := RecordJudgement(after, OutcomeTimedOut, "no answer in 40s", second) if again.Healthy || again.Cause != OutcomeTimedOut || again.Why != "no answer in 40s" { t.Fatalf("after a second, different failure: %+v", again) } if !again.Since.Equal(first) { t.Fatalf("since = %v, want the FIRST failure's own time %v, not the second's %v", again.Since, first, second) } a := again.Answer(time.UTC) if a.Healthy || !strings.Contains(a.Why, "timed out") || !strings.Contains(a.Why, "no answer in 40s") || !strings.Contains(a.Why, "09:30") { t.Fatalf("Answer().Why = %q, want it to name the cause, the judgement's own words, and since 09:30", a.Why) } } // 1519/AC3: after a judgement that succeeds, it answers healthy. func TestRecordJudgementSuccessIsHealthyAgain(t *testing.T) { unhealthy := RecordJudgement(ReaderHealth{Healthy: true}, OutcomeOutOfShape, "not an object", time.Now()) healthy := RecordJudgement(unhealthy, OutcomeOK, "", time.Now()) if !healthy.Healthy || healthy.Cause != "" || !healthy.Since.IsZero() { t.Fatalf("after a success: %+v", healthy) } a := healthy.Answer(time.UTC) if a.State != health.StateHealthy { t.Fatalf("Answer() = %+v, want healthy", a) } } // 1519/AC4: the state is kept in the module's database, so a restart answers the same — proven here with the // store's in-memory double (kit_test.go); the SQL itself (reader_health) is pgstore_test.go's own concern. func TestHealthToolReadsTheKeptState(t *testing.T) { store := newMemStore() unhealthy := RecordJudgement(ReaderHealth{Healthy: true}, OutcomeRefused, "holds no licence", time.Now()) if err := store.SetReaderHealth(context.Background(), unhealthy); err != nil { t.Fatal(err) } // A fresh Bundle, as a restarted process would build one, reading the same store. b := &Bundle{ Caller: func(map[string]any) string { return "mesh-mail-web" }, callersOverride: func() (Callers, error) { return Callers{"mesh-mail-web"}, nil }, Store: func(context.Context) (Store, error) { return store, nil }, } got, err := b.healthTool(map[string]any{}) if err != nil { t.Fatal(err) } a, ok := got.(health.Answer) if !ok || a.Healthy { t.Fatalf("mail_reader_health after a restart = %+v, want the kept unhealthy state", got) } } // 1519: reader_health's own table exists in the schema, with a column for each of the kept state's parts. func TestTheSchemaHasAReaderHealthTable(t *testing.T) { if !strings.Contains(consolidated, "create table reader_health") { t.Fatal("schema.sql declares no reader_health table") } for _, column := range []string{"healthy", "cause", "why", "since"} { if !strings.Contains(consolidated, column) { t.Fatalf("schema.sql's reader_health has no column %q", column) } } }