package store import ( "context" "errors" "path/filepath" "strings" "testing" ) // Смерть щупа — не гипотеза: `sql.Conn`, закрытый кем угодно, отвечает // `ErrConnDone` навсегда. Без пересоздания читающий контур остался бы без // условного запроса до конца жизни процесса; с пересозданием обязано смениться // и поколение — счётчик у нового соединения начинается заново, и старая метка // совпала бы с новой на другом состоянии витрины. func TestЩупПересоздаётсяСНовымПоколением(t *testing.T) { t.Parallel() st, err := Open(filepath.Join(t.TempDir(), "healthlog.db")) if err != nil { t.Fatalf("открытие базы: %v", err) } t.Cleanup(func() { _ = st.Close() }) before, err := st.StateVersion(t.Context()) if err != nil { t.Fatalf("версия витрины: %v", err) } // Ровно то, что делает непригодным настоящее соединение: явное закрытие. st.probe.mu.Lock() _ = st.probe.conn.Close() st.probe.mu.Unlock() after, err := st.StateVersion(t.Context()) if err != nil { t.Fatalf("версия после смерти щупа: %v", err) } if generationOf(before) == generationOf(after) { t.Errorf("поколение переиспользовано: %q → %q", before, after) } } func generationOf(version string) string { return strings.SplitN(version, "-", 2)[0] } // Отказ пробы — не отказ чтения: маршрут обязан деградировать до полного // ответа, а не до `500`. Ветка исполняется только когда проба не удалась, а // чтение прошло, и другого способа туда попасть нет. func TestVersionedReadПриОтказеПробыОтдаётЧтениеБезВерсии(t *testing.T) { t.Parallel() st, err := Open(filepath.Join(t.TempDir(), "healthlog.db")) if err != nil { t.Fatalf("открытие базы: %v", err) } t.Cleanup(func() { _ = st.Close() }) // Щуп закрыт, а база — нет: проба отказывает, чтение проходит. st.closeProbe() read := false version, err := st.VersionedRead(t.Context(), func(context.Context) error { read = true return nil }) if err != nil { t.Fatalf("чтение подменено отказом пробы: %v", err) } if !read { t.Error("чтение не выполнено") } if version != "" { t.Errorf("ответ подписан версией %q, хотя проба отказала", version) } } // Пул закрыт, а флаг «закрыто» не взведён: щупа нет и завести его нечем. // Отдельная ветка от ErrClosed, и она обязана быть отказом, а не пустой // версией — иначе отказ базы выглядел бы как «версии нет». func TestВерсияОтказываетКогдаПулЗакрыт(t *testing.T) { t.Parallel() st, err := Open(filepath.Join(t.TempDir(), "healthlog.db")) if err != nil { t.Fatalf("открытие базы: %v", err) } if err := st.db.Close(); err != nil { t.Fatalf("закрытие пула: %v", err) } if _, err := st.StateVersion(t.Context()); err == nil { t.Error("версия снята с закрытого пула") } } // Запрос версии против закрытия хранилища: ровно та гонка, ради которой заведён // флаг «закрыто». Проверяется под `-race`; исход законен любой, кроме // воскресшего соединения — его ловит требование «после Close версия отказывает». func TestВерсияПротивЗакрытия(t *testing.T) { t.Parallel() st, err := Open(filepath.Join(t.TempDir(), "healthlog.db")) if err != nil { t.Fatalf("открытие базы: %v", err) } const n = 8 done := make(chan struct{}, n) for range n { go func() { _, _ = st.StateVersion(context.Background()) done <- struct{}{} }() } _ = st.Close() for range n { <-done } if _, err := st.StateVersion(context.Background()); !errors.Is(err, ErrClosed) { t.Errorf("после закрытия версия отвечает %v, ждали %v", err, ErrClosed) } } // Оборванный клиентом запрос — обычное событие, и смертью щупа он быть не // имеет права: иначе каждый такой обрыв менял бы поколение и обнулял метки всех // потребителей, то есть механизм схлопывался бы под нагрузкой. func TestОборванныйЗапросНеМеняетПоколение(t *testing.T) { t.Parallel() st, err := Open(filepath.Join(t.TempDir(), "healthlog.db")) if err != nil { t.Fatalf("открытие базы: %v", err) } t.Cleanup(func() { _ = st.Close() }) before, err := st.StateVersion(t.Context()) if err != nil { t.Fatalf("версия витрины: %v", err) } dead, cancel := context.WithCancel(context.Background()) cancel() if _, err := st.StateVersion(dead); err == nil { t.Fatal("запрос на отменённом контексте прошёл — тест проверяет не то") } after, err := st.StateVersion(t.Context()) if err != nil { t.Fatalf("версия после обрыва: %v", err) } if generationOf(before) != generationOf(after) { t.Errorf("обрыв запроса сменил поколение: %q → %q", before, after) } } // Предел файла журнала — строка в DSN, и её пропажу не заметит ни один тест // поведения: журнал просто останется на пике навсегда. func TestПределЖурналаЗадан(t *testing.T) { t.Parallel() st, err := Open(filepath.Join(t.TempDir(), "healthlog.db")) if err != nil { t.Fatalf("открытие базы: %v", err) } t.Cleanup(func() { _ = st.Close() }) var limit int64 if err := st.db.QueryRowContext(t.Context(), "PRAGMA journal_size_limit").Scan(&limit); err != nil { t.Fatalf("предел журнала: %v", err) } if limit != journalSizeLimit { t.Errorf("предел журнала %d, ждали %d", limit, journalSizeLimit) } }