Files
worker/app/models/monitor_state_test.go
Gleb Tv 2c7a0236da feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
2026-07-13 17:55:14 +03:00

216 строки
7.3 KiB
Go

package models_test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/datatypes"
"rsgit.ru/rsmon/rsmon/app/models"
)
// monitorStateTestWorld bundles the account/group/monitor fixture the
// monitor-state tests need. It deliberately re-creates the row in each
// test rather than sharing, because Monitor.UpdateStatusFromChecks
// mutates the row in place and the per-test assertions need a clean
// baseline.
type monitorStateTestWorld struct {
plan models.Plan
account models.Account
group models.Group
monitor models.Monitor
}
// seedMonitorStateWorld provisions one plan/account/group/monitor with
// the requested initial state. The monitor is enabled so
// UpdateStatusFromChecks treats its checks as live.
func seedMonitorStateWorld(t *testing.T, initialState string) monitorStateTestWorld {
t.Helper()
models.Drop()
models.Migrate()
plan := models.Plan{Name: "ms-plan", Default: true}
require.NoError(t, models.DB().Create(&plan).Error)
account := models.Account{Name: "ms-acc", PlanID: &plan.ID}
require.NoError(t, models.DB().Create(&account).Error)
group := models.Group{AccountID: account.ID, Name: "ms"}
require.NoError(t, models.DB().Create(&group).Error)
mon := models.Monitor{
Name: stringPtrAgg("mon.test"),
Host: "mon.test",
GroupID: group.ID,
Enabled: true,
State: initialState,
}
require.NoError(t, models.DB().Create(&mon).Error)
return monitorStateTestWorld{
plan: plan,
account: account,
group: group,
monitor: mon,
}
}
// attachCheck creates an enabled check on the monitor with the given
// state. Returns the persisted check so the test can re-load it.
func attachCheck(t *testing.T, monitorID int64, kind string, state string) models.Check {
t.Helper()
enTrue := true
c := models.Check{
MonitorID: monitorID,
Kind: kind,
Interval: 60,
Enabled: &enTrue,
State: state,
Settings: datatypes.JSON([]byte(`{}`)),
}
require.NoError(t, models.DB().Create(&c).Error)
return c
}
// reloadMonitor pulls the latest monitor state from the DB so the test
// can compare against the post-UpdateStatusFromChecks row.
func reloadMonitor(t *testing.T, id int64) models.Monitor {
t.Helper()
var m models.Monitor
require.NoError(t, models.DB().First(&m, id).Error)
return m
}
// ---------------------------------------------------------------------------
// Phase 3 severity rules: FAIL > ERR > DEGRADED > WARN > OK.
// ---------------------------------------------------------------------------
// TestMonitorStateFromChecks_Degraded: monitor with 1 OK + 1 DEGRADED
// child → monitor.State = DEGRADED. The new severity rank must
// correctly promote DEGRADED above OK.
func TestMonitorStateFromChecks_Degraded(t *testing.T) {
world := seedMonitorStateWorld(t, "UNK")
attachCheck(t, world.monitor.ID, "http", "OK")
attachCheck(t, world.monitor.ID, "http", "DEGRADED")
world.monitor.UpdateStatusFromChecks()
got := reloadMonitor(t, world.monitor.ID)
assert.Equal(t, "DEGRADED", got.State,
"DEGRADED child must promote monitor above OK")
}
// TestMonitorStateFromChecks_DegradedWithError: monitor with 1 ERR +
// 1 DEGRADED → monitor.State = ERR. ERR beats DEGRADED in the
// severity order.
func TestMonitorStateFromChecks_DegradedWithError(t *testing.T) {
world := seedMonitorStateWorld(t, "UNK")
attachCheck(t, world.monitor.ID, "http", "ERR")
attachCheck(t, world.monitor.ID, "http", "DEGRADED")
world.monitor.UpdateStatusFromChecks()
got := reloadMonitor(t, world.monitor.ID)
assert.Equal(t, "ERR", got.State,
"ERR must beat DEGRADED — the order is FAIL > ERR > DEGRADED > WARN > OK")
}
// TestMonitorStateFromChecks_DegradedOnlyOK covers the single-DEGRADED
// case explicitly so a regression that treats DEGRADED as "WARN-ish"
// would flip this assertion.
func TestMonitorStateFromChecks_DegradedOnlyOK(t *testing.T) {
world := seedMonitorStateWorld(t, "UNK")
attachCheck(t, world.monitor.ID, "http", "OK")
attachCheck(t, world.monitor.ID, "http", "DEGRADED")
world.monitor.UpdateStatusFromChecks()
got := reloadMonitor(t, world.monitor.ID)
assert.Equal(t, "DEGRADED", got.State,
"mixed OK+DEGRADED monitor must be DEGRADED, not OK")
}
// TestMonitorStateFromChecks_SeverityOrderingTable is a table-driven
// sweep of the FAIL > ERR > DEGRADED > WARN > OK ladder. The case set
// is intentionally small — every pair that could reveal a wrong
// winner under the new severity rank. Keeping it table-driven makes
// it trivial to add more cases if a future state is introduced.
func TestMonitorStateFromChecks_SeverityOrderingTable(t *testing.T) {
cases := []struct {
name string
checks []string
wantMon string
}{
{"all_ok", []string{"OK", "OK"}, "OK"},
{"all_warn", []string{"WARN", "WARN"}, "WARN"},
{"ok_with_warn", []string{"OK", "WARN"}, "WARN"},
{"warn_with_ok", []string{"WARN", "OK"}, "WARN"}, // order independence
{"ok_with_degraded", []string{"OK", "DEGRADED"}, "DEGRADED"},
{"warn_with_degraded", []string{"WARN", "DEGRADED"}, "DEGRADED"},
{"degraded_with_warn", []string{"DEGRADED", "WARN"}, "DEGRADED"},
{"err_with_degraded", []string{"ERR", "DEGRADED"}, "ERR"},
{"degraded_with_err", []string{"DEGRADED", "ERR"}, "ERR"},
{"fail_with_err", []string{"FAIL", "ERR"}, "FAIL"},
{"err_with_fail", []string{"ERR", "FAIL"}, "FAIL"}, // order independence
{"fail_alone", []string{"FAIL"}, "FAIL"},
{"all_degraded", []string{"DEGRADED", "DEGRADED"}, "DEGRADED"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
world := seedMonitorStateWorld(t, "UNK")
for i, st := range c.checks {
attachCheck(t, world.monitor.ID,
"http-"+string(rune('a'+i)), st)
}
world.monitor.UpdateStatusFromChecks()
got := reloadMonitor(t, world.monitor.ID)
assert.Equal(t, c.wantMon, got.State,
"severity winner for child states %v must be %s", c.checks, c.wantMon)
})
}
}
// TestMonitorStateFromChecks_NoChecksIsWarn pins the existing "no
// enabled checks → WARN" behavior — the new severity rank must not
// accidentally produce OK for an empty monitor.
func TestMonitorStateFromChecks_NoChecksIsWarn(t *testing.T) {
world := seedMonitorStateWorld(t, "UNK")
world.monitor.UpdateStatusFromChecks()
got := reloadMonitor(t, world.monitor.ID)
assert.Equal(t, "WARN", got.State,
"an enabled monitor with zero checks must remain WARN")
}
// TestMonitorStateFromChecks_DisabledCheckIgnored verifies that a
// disabled check is not folded into the severity decision. Otherwise
// a stuck-in-ERR check that has been disabled would keep tripping the
// monitor forever.
func TestMonitorStateFromChecks_DisabledCheckIgnored(t *testing.T) {
world := seedMonitorStateWorld(t, "UNK")
attachCheck(t, world.monitor.ID, "http", "OK")
enFalse := false
disabled := models.Check{
MonitorID: world.monitor.ID,
Kind: "http-disabled",
Interval: 60,
Enabled: &enFalse,
State: "ERR",
Settings: datatypes.JSON([]byte(`{}`)),
}
require.NoError(t, models.DB().Create(&disabled).Error)
world.monitor.UpdateStatusFromChecks()
got := reloadMonitor(t, world.monitor.ID)
assert.Equal(t, "OK", got.State,
"disabled check must be ignored — only the enabled OK check counts")
}
// guard against time import being pruned by an editor when individual
// test bodies stop referencing it directly.
var _ = time.Second