Метки времени в SQLite переведены с формата datetime('now')
(«2006-01-02 15:04:05») на RFC 3339 всегда-UTC («2006-01-02T15:04:05Z»):
самоописываемое хранилище (зона в значении), валидный ISO 8601, единый
формат с логами. Фиксированная ширина сохраняет лексикографическую
сортировку TEXT = хронологию (COALESCE(source_added_at, created_at)).
- Единая точка генерации времени в Go: store.Now()/FormatTime; DEFAULT
(datetime('now')) снят со всех колонок — время всегда пишет приложение
(зеркально ident.NewID для id), fail-loud при забытой вставке (NOT NULL).
Все INSERT-сайты в store передают created_at/updated_at явно.
- Миграция 0008 (rebuild 7 таблиц без DEFAULT + backfill strftime, FK/PK/
индексы сохранены байт-в-байт по образцу 0006); симметричная down.
- Новая секция конфига [general] с полем timezone (дефолт UTC) — зона
ОТОБРАЖЕНИЯ в веб-UI; хранение остаётся UTC. Жёсткая валидация зоны на
старте; zoneinfo встроен (time/tzdata), заменён зашитый Europe/Moscow.
- Тесты: round-trip миграции (up/down, NULL source_added_at), валидация
зоны, сдвиг даты по зоне; обновлены фикстуры и TestUlidMigration.
- Docs: конвенции database/config, ER-схема; спека web-ui (таймзона).
OpenSpec change time-storage-rfc3339 (заархивирован).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
457 lines
16 KiB
Go
457 lines
16 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
func newTestStore(t *testing.T) *Store {
|
|
t.Helper()
|
|
st, err := Open(t.TempDir() + "/test.db")
|
|
if err != nil {
|
|
t.Fatalf("open store: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = st.Close() })
|
|
return st
|
|
}
|
|
|
|
func newDownloading() *Download {
|
|
return &Download{
|
|
SourceType: SourceMagnet,
|
|
SourceRef: "magnet:?xt=urn:btih:test",
|
|
Context: "ctx",
|
|
State: StateDownloading,
|
|
}
|
|
}
|
|
|
|
// mustCreate заводит загрузку с хешем и возвращает её id; дедуп на
|
|
// существующую активную — ошибка теста.
|
|
func mustCreate(t *testing.T, st *Store, infohash string) string {
|
|
t.Helper()
|
|
d := newDownloading()
|
|
existing, err := st.CreateDownloadIfNoActive(context.Background(), d, []string{infohash})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if existing != nil {
|
|
t.Fatalf("unexpected dedup to %s", existing.ID)
|
|
}
|
|
return d.ID
|
|
}
|
|
|
|
func TestCreateAndGetDownload(t *testing.T) {
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
|
|
const ih = "AABBCCDDEEFF00112233445566778899aabbccdd" // смешанный регистр — нормализуется
|
|
id := mustCreate(t, st, ih)
|
|
|
|
if len(id) != 26 || id != strings.ToLower(id) {
|
|
t.Errorf("id = %q, want lowercase ULID (26 chars)", id)
|
|
}
|
|
|
|
got, err := st.GetDownload(ctx, id)
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
if got.State != StateDownloading {
|
|
t.Errorf("state = %q, want downloading", got.State)
|
|
}
|
|
if got.Context != "ctx" {
|
|
t.Errorf("context = %q", got.Context)
|
|
}
|
|
if got.CreatedAt == "" {
|
|
t.Error("created_at пуст")
|
|
}
|
|
if len(got.Infohashes) != 1 {
|
|
t.Fatalf("infohashes = %v, want 1", got.Infohashes)
|
|
}
|
|
if h := got.Infohashes[0]; h.Infohash != strings.ToLower(ih) || h.Kind != HashV1 {
|
|
t.Errorf("infohash = %+v, want lowercase v1", h)
|
|
}
|
|
}
|
|
|
|
func TestFindActiveByInfohash(t *testing.T) {
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
const ih = "1111111111111111111111111111111111111111"
|
|
|
|
if d, err := st.FindActiveByInfohash(ctx, ih); err != nil || d != nil {
|
|
t.Fatalf("ожидался (nil,nil), получили (%v,%v)", d, err)
|
|
}
|
|
|
|
id := mustCreate(t, st, ih)
|
|
d, err := st.FindActiveByInfohash(ctx, ih)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if d == nil || d.ID != id {
|
|
t.Fatalf("активная задача не найдена: %v", d)
|
|
}
|
|
if len(d.Infohashes) != 1 {
|
|
t.Fatalf("хеши не подгружены: %v", d.Infohashes)
|
|
}
|
|
}
|
|
|
|
// Состояния рассинхрона (target_missing/orphaned/deleted) терминальны: задача
|
|
// в них не должна считаться «активной» (иначе relink/ingest-дедуп решат, что
|
|
// для infohash уже есть активная задача). Регрессия: FindActiveByInfohash и
|
|
// IsTerminal обязаны опираться на один список терминальных состояний.
|
|
func TestFindActiveByInfohash_DesyncStatesNotActive(t *testing.T) {
|
|
for _, st := range []State{StateTargetMissing, StateOrphaned, StateDeleted} {
|
|
t.Run(string(st), func(t *testing.T) {
|
|
store := newTestStore(t)
|
|
ctx := context.Background()
|
|
const ih = "3333333333333333333333333333333333333333"
|
|
|
|
id := mustCreate(t, store, ih)
|
|
if err := store.SetDownloadState(ctx, id, st, "", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if d, err := store.FindActiveByInfohash(ctx, ih); err != nil || d != nil {
|
|
t.Fatalf("%s: активной задачи быть не должно, получили (%v,%v)", st, d, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Терминальное состояние освобождает infohash: тот же хеш заводится заново
|
|
// новой задачей (повторная закачка спустя время) — активность выводится
|
|
// только из state.
|
|
func TestTerminalReleasesInfohash(t *testing.T) {
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
const ih = "2222222222222222222222222222222222222222"
|
|
|
|
id := mustCreate(t, st, ih)
|
|
if err := st.SetDownloadState(ctx, id, StateFailed, "qbit_add", "boom"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// После терминального состояния активной задачи нет.
|
|
if d, err := st.FindActiveByInfohash(ctx, ih); err != nil || d != nil {
|
|
t.Fatalf("после failed активная задача не должна находиться: (%v,%v)", d, err)
|
|
}
|
|
got, err := st.GetDownload(ctx, id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.ErrorCode.String != "qbit_add" {
|
|
t.Errorf("error_code = %q", got.ErrorCode.String)
|
|
}
|
|
|
|
// Тот же infohash заводится заново новой задачей.
|
|
id2 := mustCreate(t, st, ih)
|
|
if id2 == id {
|
|
t.Error("ожидалась новая задача")
|
|
}
|
|
}
|
|
|
|
// Повторный приём при активной задаче дедуплицируется: вторая вставка не
|
|
// создаёт строку, а возвращает существующую активную задачу.
|
|
func TestActiveDuplicateDeduplicated(t *testing.T) {
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
const ih = "3333333333333333333333333333333333333333"
|
|
|
|
id := mustCreate(t, st, ih)
|
|
|
|
d := newDownloading()
|
|
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if existing == nil || existing.ID != id {
|
|
t.Fatalf("ожидался дедуп на %s, получили %v", id, existing)
|
|
}
|
|
if len(existing.Infohashes) != 1 {
|
|
t.Fatalf("у существующей задачи не подгружены хеши: %v", existing.Infohashes)
|
|
}
|
|
}
|
|
|
|
// Дедуп ловит совпадение по ЛЮБОМУ из хешей: активная задача знает v1+v2,
|
|
// новый приём приходит только с v2.
|
|
func TestDedupByAnyHash(t *testing.T) {
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
const v1 = "4444444444444444444444444444444444444444"
|
|
const v2 = "6666666666666666666666666666666666666666666666666666666666666666"
|
|
|
|
id := mustCreate(t, st, v1)
|
|
if err := st.AddInfohashes(ctx, id, []string{v2}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if existing == nil || existing.ID != id {
|
|
t.Fatalf("ожидался дедуп по v2-хешу на %s, получили %v", id, existing)
|
|
}
|
|
|
|
// Хеши задачи: v1 раньше v2 (стабильный порядок), повторное добавление
|
|
// идемпотентно.
|
|
if err := st.AddInfohashes(ctx, id, []string{v2, v1}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := st.GetDownload(ctx, id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got.Infohashes) != 2 || got.Infohashes[0].Kind != HashV1 || got.Infohashes[1].Kind != HashV2 {
|
|
t.Fatalf("infohashes = %+v, want [v1, v2]", got.Infohashes)
|
|
}
|
|
if got.PrimaryInfohash() != v1 {
|
|
t.Errorf("primary = %q, want v1", got.PrimaryInfohash())
|
|
}
|
|
}
|
|
|
|
// ActivateIfNoOtherActive отказывает, когда хешем владеет другая активная
|
|
// задача, и пропускает, когда владелец ушёл в терминал.
|
|
func TestActivateIfNoOtherActive(t *testing.T) {
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
const ih = "5555555555555555555555555555555555555555"
|
|
|
|
id1 := mustCreate(t, st, ih)
|
|
if err := st.SetDownloadState(ctx, id1, StateFailed, "magnet_timeout", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Хеш перехватила новая активная задача.
|
|
id2 := mustCreate(t, st, ih)
|
|
|
|
err := st.ActivateIfNoOtherActive(ctx, id1, StateDownloading, "", "")
|
|
if !errors.Is(err, ErrInfohashTaken) {
|
|
t.Fatalf("ожидался ErrInfohashTaken, получили %v", err)
|
|
}
|
|
if d, _ := st.GetDownload(ctx, id1); d.State != StateFailed {
|
|
t.Fatalf("задача не должна была активироваться: %s", d.State)
|
|
}
|
|
|
|
// Владелец завершился → активация проходит.
|
|
if err := st.SetDownloadState(ctx, id2, StateDone, "", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.ActivateIfNoOtherActive(ctx, id1, StateDownloading, "", ""); err != nil {
|
|
t.Fatalf("активация после ухода владельца: %v", err)
|
|
}
|
|
if d, _ := st.GetDownload(ctx, id1); d.State != StateDownloading {
|
|
t.Fatalf("state = %s, want downloading", d.State)
|
|
}
|
|
}
|
|
|
|
// Дедуп-ветка дописывает существующей задаче недостающие хеши гибридного
|
|
// вызова — второй хеш не теряется, и последующий приём по нему дедупится.
|
|
func TestCreateDedupTopsUpHashes(t *testing.T) {
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
const v1 = "aaaa111111111111111111111111111111111111"
|
|
const v2 = "bbbb222222222222222222222222222222222222222222222222222222222222"
|
|
|
|
id := mustCreate(t, st, v1)
|
|
|
|
// Гибридный вызов с {v1, v2} дедупится на задачу и доносит ей v2.
|
|
existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v1, v2})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if existing == nil || existing.ID != id {
|
|
t.Fatalf("ожидался дедуп на %s, получили %v", id, existing)
|
|
}
|
|
if len(existing.Infohashes) != 2 {
|
|
t.Fatalf("хеши existing = %+v, want v1+v2 (top-up)", existing.Infohashes)
|
|
}
|
|
// Теперь приём только по v2 тоже дедупится, а не создаёт вторую задачу.
|
|
byV2, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if byV2 == nil || byV2.ID != id {
|
|
t.Fatalf("дедуп по донесённому v2 не сработал: %v", byV2)
|
|
}
|
|
}
|
|
|
|
// AddInfohashes под гардом: хеш, которым владеет другая активная задача,
|
|
// не дописывается — возвращается ErrInfohashTaken.
|
|
func TestAddInfohashesGuard(t *testing.T) {
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
const h1 = "cccc111111111111111111111111111111111111"
|
|
const h2 = "dddd222222222222222222222222222222222222"
|
|
|
|
a := mustCreate(t, st, h1)
|
|
_ = mustCreate(t, st, h2) // активный владелец h2
|
|
|
|
err := st.AddInfohashes(ctx, a, []string{h2})
|
|
if !errors.Is(err, ErrInfohashTaken) {
|
|
t.Fatalf("ожидался ErrInfohashTaken, получили %v", err)
|
|
}
|
|
got, _ := st.GetDownload(ctx, a)
|
|
if len(got.Infohashes) != 1 || got.Infohashes[0].Infohash != h1 {
|
|
t.Fatalf("чужой хеш не должен был дописаться: %+v", got.Infohashes)
|
|
}
|
|
|
|
// Хеш терминального владельца дописывается свободно.
|
|
if err := st.SetDownloadState(ctx, a, StateDone, "", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b := mustCreate(t, st, "eeee333333333333333333333333333333333333")
|
|
if err := st.AddInfohashes(ctx, b, []string{h1}); err != nil {
|
|
t.Fatalf("хеш терминальной задачи должен дописываться: %v", err)
|
|
}
|
|
}
|
|
|
|
// Гард активации не маскирует конфликт самой активируемой задачей: stuck
|
|
// (нетерминальна) с более новым id не должна перекрыть старшего владельца.
|
|
func TestActivateExcludesSelf(t *testing.T) {
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
const h = "ffff111111111111111111111111111111111111"
|
|
|
|
older := mustCreate(t, st, h) // активный владелец, id старше
|
|
newer := mustCreate(t, st, "0000222222222222222222222222222222222222")
|
|
if err := st.SetDownloadState(ctx, newer, StateStuck, "stalled", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Легаси/аварийное состояние: у newer тот же хеш h (мимо API — гард
|
|
// такого не создаст, но обязан не маскировать).
|
|
if _, err := st.DB.ExecContext(ctx,
|
|
`INSERT INTO download_infohash (download_id, infohash, kind, created_at) VALUES (?, ?, 'v1', ?)`,
|
|
newer, h, FormatTime(Now())); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err := st.ActivateIfNoOtherActive(ctx, newer, StateDownloading, "", "")
|
|
if !errors.Is(err, ErrInfohashTaken) {
|
|
t.Fatalf("гард замаскирован self-строкой: ожидался ErrInfohashTaken, получили %v", err)
|
|
}
|
|
_ = older
|
|
}
|
|
|
|
// Механический бэкстоп: публичный SetDownloadState не оживляет терминальную
|
|
// задачу — возврат в активное только через ActivateIfNoOtherActive.
|
|
func TestSetDownloadStateRejectsRevive(t *testing.T) {
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
|
|
id := mustCreate(t, st, "1234511111111111111111111111111111111111")
|
|
if err := st.SetDownloadState(ctx, id, StateFailed, "x", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.SetDownloadState(ctx, id, StateDownloading, "", ""); err == nil {
|
|
t.Fatal("терминал→активное мимо гарда должно отклоняться")
|
|
}
|
|
if d, _ := st.GetDownload(ctx, id); d.State != StateFailed {
|
|
t.Fatalf("state = %s, want failed (без изменений)", d.State)
|
|
}
|
|
// Терминал→терминал разрешён (например, сверка double-terminal переходов).
|
|
if err := st.SetDownloadState(ctx, id, StateDeleted, "", ""); err != nil {
|
|
t.Fatalf("терминал→терминал должен проходить: %v", err)
|
|
}
|
|
// Штатный путь оживления работает.
|
|
if err := st.ActivateIfNoOtherActive(ctx, id, StateDownloading, "", ""); err != nil {
|
|
t.Fatalf("оживление через гард: %v", err)
|
|
}
|
|
}
|
|
|
|
// Конкурентные создания одного infohash сериализуются write-транзакциями
|
|
// (_txlock=immediate): ровно одна задача создаётся, остальные дедупятся.
|
|
func TestConcurrentCreateDedup(t *testing.T) {
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
const ih = "abcde11111111111111111111111111111111111"
|
|
const n = 8
|
|
|
|
ids := make(chan string, n)
|
|
errs := make(chan error, n)
|
|
var wg sync.WaitGroup
|
|
for range n {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
d := newDownloading()
|
|
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih})
|
|
if err != nil {
|
|
errs <- err
|
|
return
|
|
}
|
|
if existing != nil {
|
|
ids <- existing.ID
|
|
} else {
|
|
ids <- d.ID
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(ids)
|
|
close(errs)
|
|
for err := range errs {
|
|
t.Fatalf("конкурентное создание упало: %v", err)
|
|
}
|
|
uniq := map[string]bool{}
|
|
for id := range ids {
|
|
uniq[id] = true
|
|
}
|
|
if len(uniq) != 1 {
|
|
t.Fatalf("создано %d разных задач на один infohash, want 1: %v", len(uniq), uniq)
|
|
}
|
|
all, _ := st.ListDownloads(ctx)
|
|
if len(all) != 1 {
|
|
t.Fatalf("в БД %d строк, want 1", len(all))
|
|
}
|
|
}
|
|
|
|
func TestExistsByInfohash(t *testing.T) {
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
const ih = "7777777777777777777777777777777777777777"
|
|
|
|
if ok, err := st.ExistsByInfohash(ctx, ih); err != nil || ok {
|
|
t.Fatalf("ожидался (false,nil), получили (%v,%v)", ok, err)
|
|
}
|
|
id := mustCreate(t, st, ih)
|
|
if err := st.SetDownloadState(ctx, id, StateDone, "", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Exists видит и терминальные (в отличие от FindActive).
|
|
if ok, err := st.ExistsByInfohash(ctx, ih); err != nil || !ok {
|
|
t.Fatalf("ожидался (true,nil), получили (%v,%v)", ok, err)
|
|
}
|
|
}
|
|
|
|
func TestListAndByState(t *testing.T) {
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
|
|
id1 := mustCreate(t, st, "4444444444444444444444444444444444444444")
|
|
id2 := mustCreate(t, st, "5555555555555555555555555555555555555555")
|
|
if err := st.SetDownloadState(ctx, id2, StateCompleted, "", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
all, err := st.ListDownloads(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(all) != 2 {
|
|
t.Fatalf("ListDownloads = %d, want 2", len(all))
|
|
}
|
|
for _, d := range all {
|
|
if len(d.Infohashes) != 1 {
|
|
t.Fatalf("у %s не подгружены хеши", d.ID)
|
|
}
|
|
}
|
|
|
|
dl, err := st.ListDownloadsByState(ctx, StateDownloading)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(dl) != 1 || dl[0].ID != id1 {
|
|
t.Fatalf("ListDownloadsByState(downloading) = %v", dl)
|
|
}
|
|
}
|