- `healthlog reindex` собирает витрину из журнала (тела архива + учёт доставок) в ОТДЕЛЬНЫЙ файл базы, строго по `(received_at, id)`; рабочую базу читает без наката миграций и не трогает вовсе. Подмену делает человек при остановленном сервисе: переименование поверх открытого дескриптора портит базу молча. - Журналом считается архив, а не таблица доставок: тело без учётной записи заводится заново (метка из ULID, размер и хеш по распакованному телу), запись без тела переносится, но не сворачивается. Оракул сходимости встроен — два отпечатка и «объектов было/стало»; пустой журнал успехом не считается. - Прогон живого архива переехал на новый пакет: второго проигрывателя журнала в проекте не осталось, а его утверждение о ключе сна перестало быть константой, протухающей с каждой доставкой.
168 lines
5.2 KiB
Go
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)
|
|
}
|
|
}
|