package dao import ( "errors" "path/filepath" "strings" "testing" "hy2xs-admin/model/constant" ) func newConfigDB(t *testing.T) { t.Helper() dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db") if err := InitSqliteDBAt(dbPath); err != nil { t.Fatalf("не удалось открыть тестовую базу: %v", err) } if err := RunMigrations(); err != nil { t.Fatalf("не удалось применить миграции: %v", err) } t.Cleanup(func() { _ = CloseSqliteDB() }) } // Запись в отсутствующую строку — отказ, а не тихий успех. // // UPDATE без совпавших строк не является ошибкой SQL: gorm возвращает nil и // RowsAffected = 0. Пока результат определялся только по tx.Error, оператор // получал подтверждение изменения, которого не произошло, а // applyRuntimeConfigUpdates следом применял новое значение к планировщику — // то есть рантайм и база расходились до ближайшего перезапуска сервиса. func TestUpdateConfigFailsWhenRowIsMissing(t *testing.T) { newConfigDB(t) if tx := sqliteDB.Exec("DELETE FROM config WHERE key = ?", constant.ResetTrafficCron); tx.Error != nil { t.Fatalf("подготовка повреждённой базы: %v", tx.Error) } err := UpdateConfig([]string{constant.ResetTrafficCron}, map[string]interface{}{"value": "@weekly"}) if err == nil { t.Fatal("запись в отсутствующую строку выдана за успех") } if !strings.Contains(err.Error(), constant.ResetTrafficCron) { t.Errorf("отказ не называет ключ: %v", err) } // UpdateConfig обновляет существующее и не имеет права заводить ключи: // иначе он молча создавал бы строки, которых нет в модели продукта. if _, err := GetConfig("key = ?", constant.ResetTrafficCron); !errors.Is(err, ErrConfigNotFound) { t.Fatalf("строка появилась после отказа: %v", err) } } // Обычная запись в существующую строку продолжает работать. func TestUpdateConfigWritesExistingRow(t *testing.T) { newConfigDB(t) if err := UpdateConfig([]string{constant.ResetTrafficCron}, map[string]interface{}{"value": "@weekly"}); err != nil { t.Fatalf("запись существующего ключа отклонена: %v", err) } cfg, err := GetConfig("key = ?", constant.ResetTrafficCron) if err != nil { t.Fatalf("чтение: %v", err) } if *cfg.Value != "@weekly" { t.Fatalf("значение не сохранено: %q", *cfg.Value) } } // Транзакционная запись обязана вести себя так же: отказ на отсутствующей // строке откатывает всю партию, а не оставляет её наполовину применённой. func TestConfigTxRollsBackWhenRowIsMissing(t *testing.T) { newConfigDB(t) if tx := sqliteDB.Exec("DELETE FROM config WHERE key = ?", constant.JwtSecret); tx.Error != nil { t.Fatalf("подготовка: %v", tx.Error) } err := WithConfigTx(func(tx ConfigTx) error { if err := tx.UpdateConfig([]string{constant.ResetTrafficCron}, map[string]interface{}{"value": "@weekly"}); err != nil { return err } return tx.UpdateConfig([]string{constant.JwtSecret}, map[string]interface{}{"value": "x"}) }) if err == nil { t.Fatal("партия с отсутствующей строкой не отклонена") } cfg, getErr := GetConfig("key = ?", constant.ResetTrafficCron) if getErr != nil { t.Fatalf("чтение: %v", getErr) } if *cfg.Value == "@weekly" { t.Fatal("первый ключ партии остался применённым после отказа на втором") } } // Отсутствие записи и отказ хранилища — разные ответы. func TestGetConfigReportsNotFoundDistinctly(t *testing.T) { newConfigDB(t) _, err := GetConfig("key = ?", "NO_SUCH_KEY") if !errors.Is(err, ErrConfigNotFound) { t.Fatalf("отсутствие ключа не распознано как ErrConfigNotFound: %v", err) } if errors.Is(err, ErrStorage) { t.Fatal("отсутствие ключа выдано за отказ хранилища") } } func TestGetPeerReportsNotFoundDistinctly(t *testing.T) { newConfigDB(t) _, err := GetPeer("name = ?", "no-such-peer") if !errors.Is(err, ErrPeerNotFound) { t.Fatalf("отсутствие пира не распознано как ErrPeerNotFound: %v", err) } if !IsNotFound(err) { t.Fatal("IsNotFound не распознаёт ErrPeerNotFound") } if IsNotFound(ErrStorage) { t.Fatal("IsNotFound принимает отказ хранилища за отсутствие записи") } } // UpsertConfigValue, в отличие от UpdateConfig, строку заводит: это её работа. func TestUpsertConfigValueCreatesMissingRow(t *testing.T) { newConfigDB(t) if tx := sqliteDB.Exec("DELETE FROM config WHERE key = ?", constant.ResetTrafficCron); tx.Error != nil { t.Fatalf("подготовка: %v", tx.Error) } if err := UpsertConfigValue(constant.ResetTrafficCron, "@daily"); err != nil { t.Fatalf("upsert отклонён: %v", err) } cfg, err := GetConfig("key = ?", constant.ResetTrafficCron) if err != nil { t.Fatalf("строка не создана: %v", err) } if *cfg.Value != "@daily" { t.Fatalf("значение не сохранено: %q", *cfg.Value) } }