package models_test import ( "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/datatypes" "rocketgit.ru/rsmon/worker/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