Files
av 5ae0c5ff81 reindex: пересборка витрины проигрыванием журнала
- `healthlog reindex` собирает витрину из журнала (тела архива + учёт
  доставок) в ОТДЕЛЬНЫЙ файл базы, строго по `(received_at, id)`; рабочую
  базу читает без наката миграций и не трогает вовсе. Подмену делает
  человек при остановленном сервисе: переименование поверх открытого
  дескриптора портит базу молча.
- Журналом считается архив, а не таблица доставок: тело без учётной записи
  заводится заново (метка из ULID, размер и хеш по распакованному телу),
  запись без тела переносится, но не сворачивается. Оракул сходимости
  встроен — два отпечатка и «объектов было/стало»; пустой журнал успехом не
  считается.
- Прогон живого архива переехал на новый пакет: второго проигрывателя
  журнала в проекте не осталось, а его утверждение о ключе сна перестало
  быть константой, протухающей с каждой доставкой.
2026-08-02 09:07:46 +03:00

168 lines
5.2 KiB
Go

package archive_test
import (
"io"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/archive"
)
func TestWriteAndOpenRoundTrip(t *testing.T) {
a := newArchive(t)
body := []byte(`{"data":{"metrics":[]}}`)
at := time.Date(2026, 7, 31, 14, 5, 0, 0, time.UTC)
rel, err := a.Write("01k1abc", at, body)
if err != nil {
t.Fatalf("Write: %v", err)
}
if want := filepath.Join("2026", "07", "31", "01k1abc.json.gz"); rel != want {
t.Errorf("путь = %q, ожидался %q", rel, want)
}
r, err := a.Open(rel)
if err != nil {
t.Fatalf("Open: %v", err)
}
defer func() { _ = r.Close() }()
got, err := io.ReadAll(r)
if err != nil {
t.Fatalf("чтение: %v", err)
}
if string(got) != string(body) {
t.Errorf("прочитано %q, записано %q", got, body)
}
}
// Раскладка по дате приёма — в UTC: иначе один и тот же момент попадал бы в
// разные каталоги в зависимости от зоны машины.
func TestWriteUsesUTCDate(t *testing.T) {
a := newArchive(t)
// 01:30 по московскому времени — это ещё предыдущие сутки по UTC.
msk := time.FixedZone("MSK", 3*60*60)
at := time.Date(2026, 8, 1, 1, 30, 0, 0, msk)
rel, err := a.Write("01k1def", at, []byte(`{"data":{}}`))
if err != nil {
t.Fatalf("Write: %v", err)
}
if want := filepath.Join("2026", "07", "31", "01k1def.json.gz"); rel != want {
t.Errorf("путь = %q, ожидался %q", rel, want)
}
}
// Временных файлов после успешной записи остаться не должно.
func TestWriteLeavesNoTempFiles(t *testing.T) {
root := t.TempDir()
a, err := archive.New(root)
if err != nil {
t.Fatalf("New: %v", err)
}
if _, err := a.Write("01k1ghi", time.Date(2026, 7, 31, 0, 0, 0, 0, time.UTC), []byte(`{"data":{}}`)); err != nil {
t.Fatalf("Write: %v", err)
}
err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if strings.HasSuffix(path, ".tmp") {
t.Errorf("остался временный файл: %s", path)
}
return nil
})
if err != nil {
t.Fatalf("обход архива: %v", err)
}
}
func newArchive(t *testing.T) *archive.Archive {
t.Helper()
a, err := archive.New(filepath.Join(t.TempDir(), "raw"))
if err != nil {
t.Fatalf("New: %v", err)
}
return a
}
// Перечисление тел — вход пересборки. Всё, что телом не является, обязано быть
// посчитано, а не выброшено молча: тело есть, а в отчёте его нет.
func TestListРазводитТелаИПрочиеФайлы(t *testing.T) {
t.Parallel()
root := t.TempDir()
a, err := archive.New(root)
if err != nil {
t.Fatalf("New: %v", err)
}
at := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
for _, id := range []string{"b", "a"} {
if _, err := a.Write(id, at, []byte(`{"data":{}}`)); err != nil {
t.Fatalf("Write: %v", err)
}
}
day := filepath.Join(root, "2026", "08", "01")
for _, name := range []string{"a.json.gz.tmp", "readme.txt"} {
if err := os.WriteFile(filepath.Join(day, name), []byte("x"), 0o600); err != nil {
t.Fatalf("подготовка файла: %v", err)
}
}
got, err := a.List()
if err != nil {
t.Fatalf("List: %v", err)
}
want := []string{
filepath.Join("2026", "08", "01", "a.json.gz"),
filepath.Join("2026", "08", "01", "b.json.gz"),
}
if !reflect.DeepEqual(got.Bodies, want) {
t.Errorf("тела %v, ожидались %v", got.Bodies, want)
}
if len(got.Skipped) != 2 {
t.Errorf("пропущено %v, ожидалось два файла", got.Skipped)
}
if id := archive.BodyID(got.Bodies[0]); id != "a" {
t.Errorf("BodyID = %q, ожидался %q", id, "a")
}
}
// Нечитаемый каталог означает «неизвестно, есть ли тела», а не «тел нет»:
// пустой журнал даёт пустую витрину, которая по отпечатку совпадает с любой
// другой пустой витриной и выглядит идеальной сходимостью.
func TestОтсутствующийКаталогНеЯвляетсяПустымАрхивом(t *testing.T) {
t.Parallel()
missing := filepath.Join(t.TempDir(), "нет-такого")
if _, err := archive.Existing(missing); err == nil {
t.Error("Existing создал или принял отсутствующий каталог")
}
if _, err := os.Stat(missing); err == nil {
t.Error("Existing создал каталог — пересборке этого делать нельзя")
}
// А приёму каталог создать надо: это и есть разница между конструкторами.
if _, err := archive.New(missing); err != nil {
t.Errorf("New: %v", err)
}
a, err := archive.Existing(missing)
if err != nil {
t.Fatalf("Existing после New: %v", err)
}
list, err := a.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(list.Bodies) != 0 {
t.Errorf("в пустом архиве нашлись тела: %v", list.Bodies)
}
}