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("уцелевшая строка перестала участвовать в сверке") } }