Принимаем .torrent как загруженные байты — через файл-пикер в веб-форме и Telegram-документ, наряду с magnet. Файл несёт полные метаданные: работает там, где magnet не резолвится (закрытые трекеры, без DHT), и даёт максимум контекста для распознавания без сети. - internal/torrent: парсер поверх anacrolix/torrent/metainfo — инфохэш(и) (v1 SHA1 исходных байтов info; v2 BEP52 при наличии) + Context() из имени, дерева файлов, размера, трекеров. Извлечение файлов панико-безопасно (недоверенный вход). - Персистентность байтов: таблица-спутник download_torrent (миграция 0009); пишется в транзакции создания загрузки, только на ветке создания (не при дедупе). Байты живут весь срок строки — нужны для повторного добавления при retry. - ingest: Request.TorrentData/TorrentName, диспетч парсера; source_ref — человекочитаемый референс (имя раздачи/файла), не адрес добавления. - worker: общий sourceAddParts ветвит по source_type в ОБОИХ add-путях — processCatched и Retry (torrent добавляется файлом, не magnet-хешем). - Транспорты: multipart-форма с файл-пикером (деградация без JS) и приём Telegram-документа (скачивание с редактированием токена из ошибок — секрет не в логи; обработка до ветки pending/текста). Разработка по OpenSpec (SDD): change torrent-file-ingest, два чекпоинта ревью (дизайн до кода, код до архива) сабагентами; дельты влиты в спеки, change архивирован. Ручная проверка на живом qBittorrent (7.3) — за деплоем. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
518 lines
19 KiB
Go
518 lines
19 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,
|
|
}
|
|
}
|
|
|
|
func newCatched() *Download {
|
|
return &Download{
|
|
SourceType: SourceMagnet,
|
|
SourceRef: "magnet:?xt=urn:btih:test",
|
|
Context: "ctx",
|
|
State: StateCatched,
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// Состояния рассинхрона (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)
|
|
}
|
|
}
|