package notifier import ( "log" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "rsgit.ru/rsmon/rsmon/app/models" "rsgit.ru/rsmon/rsmon/config/database" "rsgit.ru/rsmon/rsmon/spec/factories" ) func init() { database.Init() } func TestRunExp(t *testing.T) { log.Println("TestRunExp") models.Drop() models.Migrate() var err error contact, notification, monitor := factories.MonitorWithNotification() check := factories.PersistedCheck(&monitor, "ssl") assert.Equal(t, "ssl", check.Kind, "check kind should be ssl") assert.Equal(t, monitor.ID, check.MonitorID, "check should have correct monitor id") exp := time.Now().Add(14 * 24 * time.Hour) check.Expires = &exp err = models.DB().Save(&check).Error if err != nil { t.Fatal(err) } RunExp() shoudHaveMessages("exp1 - contact should receive no messages", t, "exp", notification.ID, contact.ID, []int64{}) exp = time.Now().Add(1 * time.Hour) check.Expires = &exp err = models.DB().Save(&check).Error if err != nil { t.Fatal(err) } RunExp() shoudHaveMessages("exp2 - contact should receive messages", t, "exp", notification.ID, contact.ID, []int64{check.ID}) RunExp() shoudHaveMessages("exp3 - contact should not receive duplicate messages", t, "exp", notification.ID, contact.ID, []int64{check.ID}) } // TestRunExpExpiresSystemContact exercises the regression scenario that the // dev DB hit after being restored from the production dump: the // contacts.is_system column was missing from the production schema and the // notifier's RunExp panic'd on the GORM preload. // // The fix has three layers: AutoMigrate adds the column, GetContacts logs // instead of panicking, and RunExp/RunExpCheck recover from panics. This // test verifies all three by setting a contact's is_system=true, queueing an // expiring SSL check, and confirming RunExp: // - does not panic; // - writes a queued exp message for the is_system contact (i.e. the schema // has the column and the field round-trips); and // - leaves RunExp returnable to its caller. func TestRunExpExpiresSystemContact(t *testing.T) { log.Println("TestRunExpExpiresSystemContact") models.Drop() models.Migrate() account := &models.Account{Name: "acct-system-contact"} require.NoError(t, models.DB().Create(account).Error) accountID := account.ID trueVal := true contact := &models.Contact{ AccountID: &accountID, Name: "system-admin", Kind: "email", Value: "ops@example.com", IsSystem: &trueVal, } require.NoError(t, models.DB().Create(contact).Error) group := factories.PersistedGroup(account) notification := factories.PersistedNotification( account, []int64{contact.ID}, []int64{group.ID}, 300, false, ) monitor := factories.PersistedMonitor(&group) check := factories.PersistedCheck(&monitor, "ssl") exp := time.Now().Add(1 * time.Hour) check.Expires = &exp require.NoError(t, models.DB().Save(&check).Error) assert.NotPanics(t, func() { RunExp() }, "RunExp must not panic when processing an is_system contact") shoudHaveMessages( "is_system contact must receive the exp message", t, "exp", notification.ID, contact.ID, []int64{check.ID}, ) } // TestRunExpDoesNotPanicOnBrokenAssociation replays the original prod-dump // panic in a contained way: the notification_contacts join row references a // non-existent contact id, which forces GORM's preload of Contacts to fail. // The fix's defensive recover() must keep RunExpCheck returning cleanly so // the scheduler loop survives a single bad row. // // We deliberately bypass the contacts.is_system column-drop path because // Postgres caches prepared statements per session; mutating the contacts // schema mid-test triggers SQLSTATE 0A000 (cached plan must not change // result type) on the pool's other connections and masks the panic we want // to verify. func TestRunExpDoesNotPanicOnBrokenAssociation(t *testing.T) { log.Println("TestRunExpDoesNotPanicOnBrokenAssociation") models.Drop() models.Migrate() _, notification, monitor := factories.MonitorWithNotification() // Force a broken association by deleting the contact that the // notification points to. GORM's preload of Contacts will then have no // rows for that notification, exercising the empty-contacts path // without involving DDL or FK violations. require.NoError(t, models.DB(). Exec("DELETE FROM notification_contacts WHERE notification_id = ?", notification.ID).Error) // Re-add a join row pointing to a contact id that has been deleted // from contacts. We disable the FK temporarily so the join row sticks. require.NoError(t, models.DB(). Exec("SET session_replication_role = 'replica'").Error) t.Cleanup(func() { _ = models.DB(). Exec("SET session_replication_role = 'origin'").Error }) bogusContactID := int64(9999999) require.NoError(t, models.DB(). Exec( "INSERT INTO notification_contacts (notification_id, contact_id) VALUES (?, ?)", notification.ID, bogusContactID, ).Error) check := factories.PersistedCheck(&monitor, "ssl") exp := time.Now().Add(1 * time.Hour) check.Expires = &exp require.NoError(t, models.DB().Save(&check).Error) require.NoError(t, models.DB(). Preload("Monitor"). Preload("Monitor.Group"). Preload("Monitor.Group.Notifications"). Preload("Monitor.Group.Notifications.Contacts"). First(&check, check.ID).Error) assert.NotPanics(t, func() { RunExpCheck(&check) }, "RunExpCheck must not panic when Contact preload encounters a broken association") }