package store import ( "context" "errors" "slices" "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, } } func newCatched() *Download { return &Download{ SourceType: SourceMagnet, SourceRef: "magnet:?xt=urn:btih:test", Context: "ctx", State: StateCatched, } } func TestSetDisplayNamePostHoc(t *testing.T) { st := newTestStore(t) ctx := context.Background() d := newDownloading() d.DisplayName = "Unknown" if existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(1)}, nil); err != nil || existing != nil { t.Fatalf("create: existing=%v err=%v", existing, err) } // Терминальное состояние — SetDisplayName не завязан на state (в отличие от // PromoteCatched) и должен обновлять имя и на done. forceState(t, st, d.ID, StateDone) if err := st.SetDisplayName(ctx, d.ID, "Harold and the Purple Crayon (2024)"); err != nil { t.Fatalf("SetDisplayName: %v", err) } got, err := st.GetDownload(ctx, d.ID) if err != nil { t.Fatalf("GetDownload: %v", err) } if got.DisplayName != "Harold and the Purple Crayon (2024)" { t.Errorf("display_name = %q", got.DisplayName) } if got.State != StateDone { t.Errorf("state = %q, SetDisplayName не должен двигать FSM", got.State) } } // catched — нетерминальное активное состояние: его наличие блокирует повторный // приём того же infohash (инвариант «≤1 активная на infohash»). func TestCatchedIsActiveForDedup(t *testing.T) { st := newTestStore(t) ctx := context.Background() const ih = "aabbccddeeff00112233445566778899aabbccdd" d1 := newCatched() if existing, err := st.CreateDownloadIfNoActive(ctx, d1, []string{ih}, nil); err != nil || existing != nil { t.Fatalf("первый catched: existing=%v err=%v", existing, err) } // Повторный приём того же хеша → дедуп на активную catched-задачу. existing, err := st.CreateDownloadIfNoActive(ctx, newCatched(), []string{ih}, nil) if err != nil { t.Fatalf("повторный приём: %v", err) } if existing == nil || existing.ID != d1.ID { t.Errorf("ожидался дедуп на catched %s, got %v", d1.ID, existing) } } func TestPromoteCatched(t *testing.T) { st := newTestStore(t) ctx := context.Background() const ih = "aabbccddeeff00112233445566778899aabbccdd" d := newCatched() if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}, nil); err != nil { t.Fatalf("create: %v", err) } if err := st.PromoteCatched(ctx, d.ID, "Дюна (2024)"); err != nil { t.Fatalf("promote: %v", err) } got, err := st.GetDownload(ctx, d.ID) if err != nil { t.Fatalf("get: %v", err) } if got.State != StateDownloading { t.Errorf("state = %q, want downloading", got.State) } if got.DisplayName != "Дюна (2024)" { t.Errorf("display_name = %q", got.DisplayName) } // Повторный promote (уже не catched) — отклоняется гардом state='catched'. if err := st.PromoteCatched(ctx, d.ID, "X"); err == nil { t.Error("ожидалась ошибка promote для не-catched задачи") } } // 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}, nil) if err != nil { t.Fatalf("create: %v", err) } if existing != nil { t.Fatalf("unexpected dedup to %s", existing.ID) } return d.ID } // forceState принудительно проставляет состояние прямым UPDATE в обход гейта // графа переходов — для подготовки фикстур, где проверяется поведение в заданном // состоянии, а не путь его достижения. Реальные переходы идут через // SetDownloadState/ActivateIfNoOtherActive (их легальность проверяет // transition_test.go). func forceState(t *testing.T, st *Store, id string, state State) { t.Helper() if _, err := st.DB.ExecContext(context.Background(), `UPDATE download SET state = ? WHERE id = ?`, string(state), id); err != nil { t.Fatalf("force state %s: %v", state, err) } } 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) } } // F1: дедуп-ветка CreateDownloadIfNoActive доносит недостающие хеши под пер-хеш // гардом владения — хеш, которым владеет ДРУГАЯ активная задача (split-identity // гибрида), красть не должна, иначе две активные владели бы одним инфохэшем. func TestDedupTopUpDoesNotStealForeignHash(t *testing.T) { st := newTestStore(t) ctx := context.Background() v1 := hashN(1) v2 := hashN(2) // A владеет v1, B владеет v2 — две активные задачи одного гибрида (split). idA := mustCreate(t, st, v1) idB := mustCreate(t, st, v2) // Приём гибрида {v1, v2} → дедуп на одну из активных; чужой хеш не крадём. existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v1, v2}, nil) if err != nil { t.Fatalf("create: %v", err) } if existing == nil { t.Fatal("ожидался дедуп на активную задачу") } assertHashes(t, st, idA, v1) // A по-прежнему владеет только v1 assertHashes(t, st, idB, v2) // B по-прежнему владеет только v2 } // assertHashes проверяет точный набор инфохэшей загрузки. func assertHashes(t *testing.T, st *Store, id string, want ...string) { t.Helper() d, err := st.GetDownload(context.Background(), id) if err != nil { t.Fatalf("get %s: %v", id, err) } got := d.HashList() if len(got) != len(want) { t.Fatalf("%s hashes = %v, want %v", id, got, want) } for _, w := range want { if !slices.Contains(got, w) { t.Fatalf("%s hashes = %v, want содержит %s", id, got, w) } } } // Состояния рассинхрона (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) forceState(t, store, id, st) 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}, nil) 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}, nil) 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) } // Владелец завершился → активация проходит. forceState(t, st, id2, StateDone) 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}, nil) 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}, nil) 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) } // Хеш терминального владельца дописывается свободно. forceState(t, st, a, StateDone) 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) } // Терминал→активное обычным SetDownloadState отклоняется (revive-гард): ребро // failed → downloading в графе есть, но проходит только через гард владения. 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) } // Штатный путь оживления работает. 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}, nil) 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) forceState(t, st, id, StateDone) // 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) } }