- свёртка спрашивает журнал, встречалось ли имя строго раньше по паре (received_at, id), и пишет WARN с атрибутом uncovered_new; повторные молчат. Признак выводится, а не хранится — реестр был бы второй копией факта - добавлена подкоманда `healthlog uncovered`: перечень накопленного, чтение только на чтение, экранированные имена и названные границы носителя - синк документации: ADR о выводе новизны из журнала, две записи в журнал дефектов, два правила промоутом в конвенции, терминал оператора назван адресатом недоверенного входа
269 lines
11 KiB
Go
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("уцелевшая строка перестала участвовать в сверке")
|
|
}
|
|
}
|