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

139 lines
5.5 KiB
Go

package fold
import (
"bytes"
"context"
"encoding/json"
"errors"
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// Внутренний тест, а не через `Fold`, и это осознанно. Сломать сверку снаружи
// больше нечем: испорченную учётную запись запрос теперь пропускает, а
// единственная настоящая причина отказа — отмена контекста и занятость базы —
// в полном проходе свёртки утащила бы за собой и слияние, то есть проверялась
// бы уже другая ветвь. Заводить интерфейс хранилища ради мока дороже: он
// пережил бы тест и остался бы в коде навсегда.
func TestNoveltyОтменённыйКонтекстНеМолчит(t *testing.T) {
t.Parallel()
dir := t.TempDir()
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
if err != nil {
t.Fatalf("база: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
s := &Service{store: st, log: slog.New(slog.DiscardHandler)}
ctx, cancel := context.WithCancel(context.Background())
cancel()
got := s.novelty(ctx, []string{"ecg", "symptoms"}, store.DeliveryRef{
ID: "d1",
ReceivedAt: time.Now().UTC(),
})
if !got.unknown {
t.Fatal("сверка на отменённом контексте объявила себя состоявшейся")
}
if len(got.fresh) != 2 {
t.Errorf("новыми объявлены %v, ожидались все имена доставки", got.fresh)
}
if got.cause == nil {
t.Error("причина отказа потеряна: занятость базы и вечную порчу колонки по признаку не различить")
}
}
// Пустой список сверку не берёт вовсе: на живом потоке все три приезжающие
// секции покрыты, и запрос по журналу в штатном режиме не стоит ничего.
func TestNoveltyПустойСписокЗапросаНеБерёт(t *testing.T) {
t.Parallel()
s := &Service{store: nil, log: slog.New(slog.DiscardHandler)}
// Хранилище nil: возьмись запрос — тест упал бы паникой.
got := s.novelty(context.Background(), nil, store.DeliveryRef{ID: "d1"})
if got.unknown || len(got.fresh) != 0 {
t.Errorf("на пустом списке сверка что-то решила: %+v", got)
}
}
// Признак несостоявшейся сверки без причины неразбираем: занятость базы пройдёт
// сама, испорченная колонка не пройдёт никогда и будет поднимать признак на
// каждой доставке.
func TestLogResultПечатаетПричинуНесостоявшейсяСверки(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
s := &Service{log: slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))}
s.logResult(context.Background(), "d1", Stats{
Uncovered: []string{"ecg"},
UncoveredNew: []string{"ecg"},
UncoveredSeenUnknown: true,
UncoveredSeenError: errors.New("журнал недоступен"),
})
var rec map[string]any
line := strings.TrimSpace(buf.String())
if err := json.Unmarshal([]byte(line), &rec); err != nil {
t.Fatalf("запись не разбирается: %v", err)
}
if rec["uncovered_seen_unknown"] != true {
t.Error("признак несостоявшейся сверки не выставлен")
}
if rec["uncovered_seen_error"] != "журнал недоступен" {
t.Errorf("причина в записи: %v", rec["uncovered_seen_error"])
}
if rec["level"] != "WARN" {
t.Errorf("уровень %v, ожидался WARN: имена объявлены новыми", rec["level"])
}
}
// Запись об отказе несёт и признак несостоявшейся сверки, и причину: у отказа
// разбора та же цена молчания, что у успешного пути — имя уже в учёте.
func TestFailПечатаетНесостоявшуюсяСверку(t *testing.T) {
t.Parallel()
dir := t.TempDir()
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
if err != nil {
t.Fatalf("база: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
ctx := context.Background()
err = st.CreateDelivery(ctx, store.Delivery{
ID: "d1", ReceivedAt: time.Now().UTC(), RawPath: "d1.json.gz",
SHA256: "-", ParseStatus: store.ParsePending,
})
if err != nil {
t.Fatalf("учёт доставки: %v", err)
}
var buf bytes.Buffer
s := &Service{
store: st,
log: slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})),
}
s.fail(ctx, "d1", errors.New("непонятое содержимое"), parseResidue{
uncovered: []string{"ecg"},
novelty: sectionNovelty{fresh: []string{"ecg"}, unknown: true, cause: errors.New("журнал недоступен")},
})
out := buf.String()
for _, want := range []string{"uncovered_new", "ecg", "uncovered_seen_unknown", "журнал недоступен"} {
if !strings.Contains(out, want) {
t.Errorf("в записи об отказе нет %q:\n%s", want, out)
}
}
}