package models_test import ( "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "rocketgit.ru/rsmon/worker/app/models" "rocketgit.ru/rsmon/worker/spec/factories" ) // TestNotificationGetContactsIncludesSystemContact exercises the regression // reported when the production dump was restored into dev: the // contacts.is_system column was missing and the GORM preload panicked on // GetContacts. The fix has two layers: // // 1. AutoMigrate must add is_system (and deletion_requested_at) before the // notifier scheduler starts running. // 2. GetContacts itself must not panic on a query error so a single bad row // cannot tear down the scheduler goroutine. // // This test verifies both layers by: // - asserting that the schema post-Migrate includes is_system, so the // production-like scenario no longer panics; and // - building a notification that contains a contact flagged is_system=true // and checking GetContacts returns it. func TestNotificationGetContactsIncludesSystemContact(t *testing.T) { models.Drop() models.Migrate() // Column must exist after Migrate; otherwise GetContacts would fail // with the same panic we saw in production. assertColumnExists(t, "contacts", "is_system") account := &models.Account{Name: "acct-get-contacts"} 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) notification := factories.PersistedNotification( account, []int64{contact.ID}, nil, 300, false, ) // Reload so the model has its persisted ID; the factory's PersistRelations // may have left ContactIDs empty on the returned value depending on the // GORM version, so fetch fresh. require.NoError(t, models.DB(). Preload("Contacts"). First(¬ification, notification.ID).Error) got := notification.GetContacts() ids := make([]int64, 0, len(got)) for _, c := range got { ids = append(ids, c.ID) } assert.Contains(t, ids, contact.ID, "GetContacts must include the is_system contact") } // TestUserDeletionRequestedAtColumnAndRoundTrip verifies the second missing // column reported by the panic: users.deletion_requested_at. It asserts that // AutoMigrate creates the column and that the field round-trips through the // DB correctly. ProcessPendingDeletions (the consumer of this column) relies // on it being present and queryable. func TestUserDeletionRequestedAtColumnAndRoundTrip(t *testing.T) { models.Drop() models.Migrate() assertColumnExists(t, "users", "deletion_requested_at") user := factories.PersistedUser("deletion-roundtrip@test.ru", "secret") now := time.Now().UTC().Truncate(time.Microsecond) user.DeletionRequestedAt = &now require.NoError(t, models.DB().Save(&user).Error) reloaded := models.User{} require.NoError(t, models.DB().First(&reloaded, user.ID).Error) require.NotNil(t, reloaded.DeletionRequestedAt, "deletion_requested_at must round-trip via Save/First") assert.True(t, reloaded.DeletionRequestedAt.Equal(now), "deletion_requested_at must preserve the timestamp value (got %v, want %v)", reloaded.DeletionRequestedAt, now) // ProcessPendingDeletions should not panic on the populated schema and // must respect the cutoff: a recently-set deletion_requested_at is // still inside the 7-day grace period, so no hard-delete must occur. deleted, err := models.ProcessPendingDeletions() require.NoError(t, err) assert.Equal(t, 0, deleted, "users within the 7-day grace period must not be hard-deleted") } // TestProcessPendingDeletionsQueriesMissingColumnGracefully asserts that even // if the deletion_requested_at column were missing, ProcessPendingDeletions // would not panic (the panic-on-error pattern was historically present in // other notifier helpers). We force the failure by renaming the column back, // calling ProcessPendingDeletions, then restoring the column. func TestProcessPendingDeletionsQueriesMissingColumnGracefully(t *testing.T) { models.Drop() models.Migrate() assertColumnExists(t, "users", "deletion_requested_at") // Simulate the production-missing-column scenario in a contained way: // rename the column so the SELECT against deletion_requested_at fails. require.NoError(t, models.DB(). Exec("ALTER TABLE users RENAME COLUMN deletion_requested_at TO deletion_requested_at_hidden").Error) t.Cleanup(func() { // Restore so subsequent tests in this package keep working. _ = models.DB(). Exec("ALTER TABLE users RENAME COLUMN deletion_requested_at_hidden TO deletion_requested_at").Error }) // Must not panic; must return an error. assert.NotPanics(t, func() { _, err := models.ProcessPendingDeletions() assert.Error(t, err, "missing column must surface as an error, not a panic") }) } // assertColumnExists checks that the given table has the given column by // querying information_schema. It is the canary for the AutoMigrate step // ordering bug: if the column is missing, every test that touches it will // panic with SQLSTATE 42703. func assertColumnExists(t *testing.T, table, column string) { t.Helper() var n int err := models.DB().Raw( `SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?`, table, column, ).Scan(&n).Error require.NoError(t, err, "information_schema query must succeed") assert.Equal(t, 1, n, "table %q must have column %q after Migrate()", table, column) }