Files
healthlog/internal/store/uncovered_test.go
T
av bd5d17b079 первая встреча непокрытой секции стала наблюдаемым событием
- свёртка спрашивает журнал, встречалось ли имя строго раньше по паре
  (received_at, id), и пишет WARN с атрибутом uncovered_new; повторные молчат.
  Признак выводится, а не хранится — реестр был бы второй копией факта
- добавлена подкоманда `healthlog uncovered`: перечень накопленного, чтение
  только на чтение, экранированные имена и названные границы носителя
- синк документации: ADR о выводе новизны из журнала, две записи в журнал
  дефектов, два правила промоутом в конвенции, терминал оператора назван
  адресатом недоверенного входа
2026-08-04 13:39:48 +03:00

269 lines
11 KiB
Go

package store_test
import (
"context"
"database/sql"
"path/filepath"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/store"
_ "modernc.org/sqlite" // прямая запись в файл базы — ради теста на битую колонку
)
// seedUncovered заводит доставку с уже записанным исходом разбора: список
// непокрытых секций живёт в учётной записи, и заполняет его свёртка.
func seedUncovered(t *testing.T, st *store.Store, id string, at time.Time, status string, sections ...string) {
t.Helper()
seedPending(t, st, id, at)
err := st.FinishParse(context.Background(), id, store.ParseOutcome{
Status: status,
Layer: "hour",
Uncovered: sections,
})
if err != nil {
t.Fatalf("исход разбора %q: %v", id, err)
}
}
// Признак новизны обязан быть функцией ЖУРНАЛА, а не порядка свёртки: имя
// считается виденным, только если встречалось строго раньше по паре
// `(received_at, id)`.
func TestSectionsSeenBeforeСудитПоЖурналу(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
at := ts(t, "2026-08-01T12:00:00Z")
early := at
late := at.Add(time.Second)
seedUncovered(t, st, "d-early", early, store.ParsePartial, "ecg")
seedUncovered(t, st, "d-late", late, store.ParsePartial, "ecg")
seen, err := st.SectionsSeenBefore(ctx, []string{"ecg"}, store.DeliveryRef{ID: "d-late", ReceivedAt: late})
if err != nil {
t.Fatalf("SectionsSeenBefore: %v", err)
}
if _, ok := seen["ecg"]; !ok {
t.Error("для поздней доставки имя обязано быть виденным: раньше неё в журнале оно есть")
}
// Ранняя доставка судится тем же запросом и обязана признать имя новым —
// собственная строка в счёт не идёт, а более поздняя лежит после неё.
seen, err = st.SectionsSeenBefore(ctx, []string{"ecg"}, store.DeliveryRef{ID: "d-early", ReceivedAt: early})
if err != nil {
t.Fatalf("SectionsSeenBefore: %v", err)
}
if _, ok := seen["ecg"]; ok {
t.Error("для ранней доставки имя обязано быть новым: раньше неё его в журнале нет")
}
}
// Метка приёма хранится с секундной точностью, поэтому доставки одной секунды
// разводятся идентификатором — иначе исход зависел бы от того, какая из них
// свернулась первой.
func TestSectionsSeenBeforeРазводитОднуСекундуИдентификатором(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
at := ts(t, "2026-08-01T12:00:00Z")
seedUncovered(t, st, "d-a", at, store.ParsePartial, "symptoms")
seedUncovered(t, st, "d-b", at, store.ParsePartial, "symptoms")
seen, err := st.SectionsSeenBefore(ctx, []string{"symptoms"}, store.DeliveryRef{ID: "d-b", ReceivedAt: at})
if err != nil {
t.Fatalf("SectionsSeenBefore: %v", err)
}
if _, ok := seen["symptoms"]; !ok {
t.Error("для второй доставки той же секунды имя обязано быть виденным")
}
seen, err = st.SectionsSeenBefore(ctx, []string{"symptoms"}, store.DeliveryRef{ID: "d-a", ReceivedAt: at})
if err != nil {
t.Fatalf("SectionsSeenBefore: %v", err)
}
if len(seen) != 0 {
t.Errorf("для первой доставки той же секунды виденных имён быть не может, получено %v", seen)
}
}
// Повторный вопрос обязан давать тот же ответ: на этом стоит воспроизводимость
// событий при пересборке журнала.
func TestSectionsSeenBeforeИдемпотентен(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
at := ts(t, "2026-08-01T12:00:00Z")
seedUncovered(t, st, "d1", at, store.ParsePartial, "ecg", "symptoms")
seedUncovered(t, st, "d2", at.Add(time.Minute), store.ParsePartial, "ecg")
ref := store.DeliveryRef{ID: "d2", ReceivedAt: at.Add(time.Minute)}
first, err := st.SectionsSeenBefore(ctx, []string{"ecg", "medications"}, ref)
if err != nil {
t.Fatalf("SectionsSeenBefore: %v", err)
}
second, err := st.SectionsSeenBefore(ctx, []string{"ecg", "medications"}, ref)
if err != nil {
t.Fatalf("SectionsSeenBefore: %v", err)
}
if len(first) != 1 || len(second) != 1 {
t.Fatalf("виденных имён %d и %d, ожидалось по одному", len(first), len(second))
}
if _, ok := first["ecg"]; !ok {
t.Error("`ecg` обязан быть виденным")
}
if _, ok := second["medications"]; ok {
t.Error("`medications` журнал не приносил и виденным быть не может")
}
}
// Перечень отвечает по всем статусам разбора: список непокрытых секций
// переживает отказ свёртки, и молчать о таком имени значило бы терять как раз
// подозрительное.
func TestUncoveredSectionsСчитаетВстречиИГраницы(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
at := ts(t, "2026-08-01T12:00:00Z")
seedUncovered(t, st, "d1", at, store.ParsePartial, "symptoms")
seedUncovered(t, st, "d2", at.Add(time.Minute), store.ParsePartial, "symptoms", "ecg")
// Отказавшая доставка: имя записано, статус — `failed`.
seedUncovered(t, st, "d3", at.Add(2*time.Minute), store.ParseFailed, "ecg")
got, total, err := st.UncoveredSections(ctx, 10)
if err != nil {
t.Fatalf("UncoveredSections: %v", err)
}
if total != 2 {
t.Errorf("различных имён %d, ожидалось 2", total)
}
if len(got) != 2 {
t.Fatalf("строк перечня %d, ожидалось 2", len(got))
}
// Порядок детерминирован — по имени.
if got[0].Name != "ecg" || got[1].Name != "symptoms" {
t.Fatalf("порядок строк %q, %q", got[0].Name, got[1].Name)
}
ecg := got[0]
if ecg.Deliveries != 2 {
t.Errorf("`ecg` принесли %d доставок, ожидалось 2", ecg.Deliveries)
}
if ecg.FirstDeliveryID != "d2" || ecg.LastDeliveryID != "d3" {
t.Errorf("границы `ecg`: %q…%q, ожидалось d2…d3", ecg.FirstDeliveryID, ecg.LastDeliveryID)
}
if !ecg.FirstSeen.Equal(at.Add(time.Minute)) || !ecg.LastSeen.Equal(at.Add(2*time.Minute)) {
t.Errorf("метки `ecg`: %s…%s", ecg.FirstSeen, ecg.LastSeen)
}
sym := got[1]
if sym.Deliveries != 2 || sym.FirstDeliveryID != "d1" || sym.LastDeliveryID != "d2" {
t.Errorf("`symptoms`: %d доставок, %q…%q", sym.Deliveries, sym.FirstDeliveryID, sym.LastDeliveryID)
}
}
// Предел объявлен, а остаток обязан быть назван числом: «здесь всё» и «здесь
// часть» — разные ответы, и молчаливый обрыв делает их неотличимыми.
func TestUncoveredSectionsОстатокНазванЧислом(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
at := ts(t, "2026-08-01T12:00:00Z")
seedUncovered(t, st, "d1", at, store.ParsePartial, "aaa", "bbb", "ccc")
got, total, err := st.UncoveredSections(ctx, 2)
if err != nil {
t.Fatalf("UncoveredSections: %v", err)
}
if len(got) != 2 {
t.Fatalf("строк перечня %d, ожидалось 2", len(got))
}
if total != 3 {
t.Errorf("всего имён %d, ожидалось 3", total)
}
}
// Пустой журнал и журнал без непокрытых секций отвечают пустым перечнем, а не
// отказом: колонка по умолчанию держит `[]`, и до json_each такие строки не
// доходят.
func TestUncoveredSectionsНаЧистомЖурнале(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
at := ts(t, "2026-08-01T12:00:00Z")
seedUncovered(t, st, "d1", at, store.ParseDone)
got, total, err := st.UncoveredSections(ctx, 10)
if err != nil {
t.Fatalf("UncoveredSections: %v", err)
}
if len(got) != 0 || total != 0 {
t.Errorf("перечень %v, всего %d — ожидалась пустота", got, total)
}
}
// Одна учётная запись с неразбираемым содержимым колонки не имеет права
// обесценить и сверку, и перечень: `json_each` над таким значением отвечает
// ошибкой и валит ВЕСЬ запрос, а не пропускает строку.
//
// Штатный путь записи такого значения не производит — тем хуже был бы отказ:
// он необъясним, а последствие вечно (сверка на каждой доставке говорила бы
// «не состоялась», команда отказывала бы целиком).
func TestUncoveredSectionsПереживаетБитуюСтроку(t *testing.T) {
t.Parallel()
dir := t.TempDir()
dbPath := filepath.Join(dir, "healthlog.db")
st, err := store.Open(dbPath)
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
ctx := context.Background()
at := ts(t, "2026-08-01T12:00:00Z")
seedUncovered(t, st, "d1", at, store.ParsePartial, "ecg")
seedUncovered(t, st, "d2", at.Add(time.Minute), store.ParsePartial, "symptoms")
db, err := sql.Open("sqlite", "file:"+dbPath+"?_pragma=busy_timeout(5000)")
if err != nil {
t.Fatalf("прямое подключение: %v", err)
}
defer func() { _ = db.Close() }()
if _, err := db.ExecContext(ctx,
`UPDATE delivery SET uncovered_sections = '{не JSON' WHERE id = ?`, "d1"); err != nil {
t.Fatalf("порча колонки: %v", err)
}
got, total, err := st.UncoveredSections(ctx, 10)
if err != nil {
t.Fatalf("перечень отказал целиком из-за одной строки: %v", err)
}
if total != 1 || len(got) != 1 || got[0].Name != "symptoms" {
t.Errorf("перечень %v (всего %d), ожидалась одна уцелевшая секция", got, total)
}
seen, err := st.SectionsSeenBefore(ctx, []string{"symptoms"},
store.DeliveryRef{ID: "d3", ReceivedAt: at.Add(2 * time.Minute)})
if err != nil {
t.Fatalf("сверка отказала из-за одной строки: %v", err)
}
if _, ok := seen["symptoms"]; !ok {
t.Error("уцелевшая строка перестала участвовать в сверке")
}
}