package ingest import ( "context" "errors" "io" "log/slog" "strings" "testing" "time" "git.vakhrushev.me/av/jellybit/internal/ident" "git.vakhrushev.me/av/jellybit/internal/qbt" "git.vakhrushev.me/av/jellybit/internal/store" ) const sampleMagnet = "magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6&dn=Dune" const sampleInfohash = "541adcff3b6dd5dba7088ea83317d9d6fac331d6" type fakeStore struct { active *store.Download created []store.Download hashes [][]string toppedUp []string stateCalls []stateCall } type stateCall struct { id string state store.State code string msg string } func (f *fakeStore) FindActiveByInfohash(_ context.Context, _ ...string) (*store.Download, error) { return f.active, nil } func (f *fakeStore) CreateDownloadIfNoActive(_ context.Context, d *store.Download, hashes []string) (*store.Download, error) { if f.active != nil { return f.active, nil } d.ID = ident.NewID() f.created = append(f.created, *d) f.hashes = append(f.hashes, hashes) return nil, nil } func (f *fakeStore) AddInfohashes(_ context.Context, id string, hashes []string) error { f.toppedUp = append(f.toppedUp, hashes...) _ = id return nil } func (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error { f.stateCalls = append(f.stateCalls, stateCall{id, st, code, msg}) return nil } type fakeQbt struct { added []qbt.AddRequest err error } func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error { if f.err != nil { return f.err } f.added = append(f.added, ar) return nil } // fakeNamer возвращает заранее заданное имя; фиксирует переданные аргументы. type fakeNamer struct { name string gotContext string gotHint string called bool } func (f *fakeNamer) DeriveName(_ context.Context, contextText, hint string) string { f.called = true f.gotContext = contextText f.gotHint = hint return f.name } func newService(st Store, qb QBittorrent) *Service { return newServiceWithNamer(st, qb, nil) } func newServiceWithNamer(st Store, qb QBittorrent, nm Namer) *Service { return New(st, qb, nm, Config{Category: "jellybit", SavePath: "/srv/media/downloads"}, slog.New(slog.NewTextHandler(io.Discard, nil))) } func TestIngestHappyPath(t *testing.T) { fs := &fakeStore{} fq := &fakeQbt{} res, err := newService(fs, fq).Ingest(context.Background(), Request{Source: sampleMagnet, Context: "Дюна 2"}) if err != nil { t.Fatalf("Ingest: %v", err) } if len(res.Infohashes) != 1 || res.Infohashes[0] != sampleInfohash { t.Errorf("infohashes = %v", res.Infohashes) } if res.State != store.StateDownloading || res.Deduplicated { t.Errorf("res = %+v", res) } if len(fs.created) != 1 { t.Fatalf("создано задач: %d, want 1", len(fs.created)) } // download.Context = пользовательский текст + синтез из полей magnet // (dn=Dune). Текст пользователя идёт первым. if got := fs.created[0].Context; !strings.HasPrefix(got, "Дюна 2") || !strings.Contains(got, "Dune") { t.Errorf("сохранённый контекст = %q", got) } if len(fs.hashes) != 1 || len(fs.hashes[0]) != 1 || fs.hashes[0][0] != sampleInfohash { t.Errorf("хеши задачи: %v", fs.hashes) } if len(fq.added) != 1 { t.Fatalf("вызовов qbt.Add: %d, want 1", len(fq.added)) } add := fq.added[0] if len(add.URLs) != 1 || add.URLs[0] != sampleMagnet { t.Errorf("URLs = %v", add.URLs) } if add.Category != "jellybit" || add.SavePath != "/srv/media/downloads" { t.Errorf("category/savepath = %q/%q", add.Category, add.SavePath) } } func TestIngestSetsDisplayName(t *testing.T) { fs := &fakeStore{} fq := &fakeQbt{} nm := &fakeNamer{name: "Дюна: Часть вторая (2024)"} _, err := newServiceWithNamer(fs, fq, nm).Ingest(context.Background(), Request{Source: sampleMagnet, Context: "Дюна 2"}) if err != nil { t.Fatalf("Ingest: %v", err) } if !nm.called || nm.gotContext != "Дюна 2" || nm.gotHint != "Dune" { t.Errorf("namer получил context=%q hint=%q (called=%v)", nm.gotContext, nm.gotHint, nm.called) } if len(fq.added) != 1 || fq.added[0].Rename != "Дюна: Часть вторая (2024)" { t.Errorf("rename = %q, want %q", fq.added[0].Rename, "Дюна: Часть вторая (2024)") } // То же имя сохраняется у загрузки — заголовок в веб-UI. if len(fs.created) != 1 || fs.created[0].DisplayName != "Дюна: Часть вторая (2024)" { t.Errorf("display_name = %q, want %q", fs.created[0].DisplayName, "Дюна: Часть вторая (2024)") } } func TestIngestEmptyNameOmitsRename(t *testing.T) { fs := &fakeStore{} fq := &fakeQbt{} nm := &fakeNamer{name: ""} // имя не получено if _, err := newServiceWithNamer(fs, fq, nm).Ingest(context.Background(), Request{Source: sampleMagnet}); err != nil { t.Fatalf("Ingest: %v", err) } if len(fq.added) != 1 || fq.added[0].Rename != "" { t.Errorf("rename = %q, want пусто", fq.added[0].Rename) } } func TestIngestIdempotent(t *testing.T) { existing := &store.Download{ID: "01hzzzexisting000000000000", State: store.StateDownloading} fs := &fakeStore{active: existing} fq := &fakeQbt{} res, err := newService(fs, fq).Ingest(context.Background(), Request{Source: sampleMagnet}) if err != nil { t.Fatalf("Ingest: %v", err) } if !res.Deduplicated || res.DownloadID != existing.ID { t.Errorf("ожидалось присоединение к существующей задаче: %+v", res) } if len(fs.created) != 0 { t.Error("не должно создаваться новой задачи") } if len(fq.added) != 0 { t.Error("не должно быть повторного добавления в qBittorrent") } } // Быстрый дедуп-путь доносит существующей задаче недостающие хеши // гибридного magnet (иначе последующий приём по второму хешу создал бы // вторую активную задачу). func TestIngestDedupTopsUpHashes(t *testing.T) { const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" existing := &store.Download{ ID: "01hzzzexisting000000000000", State: store.StateDownloading, Infohashes: []store.Infohash{{DownloadID: "01hzzzexisting000000000000", Infohash: sampleInfohash, Kind: store.HashV1}}, } fs := &fakeStore{active: existing} res, err := newService(fs, &fakeQbt{}).Ingest(context.Background(), Request{Source: sampleMagnet + "&xt=urn:btmh:1220" + v2}) if err != nil { t.Fatalf("Ingest: %v", err) } if !res.Deduplicated { t.Fatalf("ожидался дедуп: %+v", res) } if len(fs.toppedUp) != 2 { t.Errorf("хеши не донесены существующей задаче: %v", fs.toppedUp) } } // Голый magnet без текста: download.Context синтезируется из полей ссылки // (dn-имя + размер), приём проходит штатно. func TestIngestMagnetOnlySynthesizesContext(t *testing.T) { const raw = "magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6" + "&dn=Dune.Part.Two.2024.2160p&xl=2200000000" fs := &fakeStore{} if _, err := newService(fs, &fakeQbt{}).Ingest(context.Background(), Request{Source: raw}); err != nil { t.Fatalf("Ingest: %v", err) } if len(fs.created) != 1 { t.Fatalf("создано задач: %d, want 1", len(fs.created)) } ctx := fs.created[0].Context if !strings.Contains(ctx, "Dune.Part.Two.2024.2160p") || !strings.Contains(ctx, "Размер:") { t.Errorf("контекст не синтезирован из magnet: %q", ctx) } } // Регресс B1: строки-факты синтеза (Трекер:/Размер:) не должны становиться // отображаемым именем. Namer получает СЫРОЙ контекст (+ dn-hint), а domain // уходит только в download.Context для recognition. Заглушка-dn как строка- // название в контекст не попадает. func TestIngestSynthFactsNeverBecomeName(t *testing.T) { const raw = "magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6" + "&dn=rutracker-topic-6514485&tr=http%3A%2F%2Fbt.t-ru.org%2Fann%3Fmagnet" fs := &fakeStore{} fq := &fakeQbt{} nm := &fakeNamer{name: "rutracker-topic-6514485"} // как вывел бы фолбек из dn-hint if _, err := newServiceWithNamer(fs, fq, nm).Ingest(context.Background(), Request{Source: raw}); err != nil { t.Fatalf("Ingest: %v", err) } if nm.gotContext != "" { t.Errorf("namer получил не сырой контекст: %q", nm.gotContext) } if strings.Contains(fq.added[0].Rename, "Трекер") { t.Errorf("строка-факт просочилась в rename: %q", fq.added[0].Rename) } got := fs.created[0].Context if !strings.Contains(got, "t-ru.org") { t.Errorf("download.Context не обогащён доменом трекера: %q", got) } if strings.Contains(got, "rutracker-topic") { t.Errorf("заглушка-dn просочилась в контекст как имя: %q", got) } } // Реальная рутрекер-ссылка без текста: download.Context = релиз-заголовок из // dn (раскодирован) + домен трекера; namer получает пустой контекст и dn-hint. func TestIngestRealRutrackerMagnetOnly(t *testing.T) { const raw = "magnet:?xt=urn:btih:BACA24E18C7382A9E9A44132C8D7DB86C4D319C2" + "&tr=http%3A%2F%2Fbt4.t-ru.org%2Fann%3Fmagnet" + "&dn=%D0%91%D1%83%D1%85%D1%82%D0%B0%20%D0%B2%D0%B4%D0%BE%D0%B2%20%2F%20Widow's%20Bay%20%2F%20%D0%A1%D0%B5%D0%B7%D0%BE%D0%BD%3A%201%20%5B2026%2C%20%D0%A1%D0%A8%D0%90%2C%20WEB-DL%201080p%5D" fs := &fakeStore{} nm := &fakeNamer{name: "Бухта вдов (2026)"} if _, err := newServiceWithNamer(fs, &fakeQbt{}, nm).Ingest(context.Background(), Request{Source: raw}); err != nil { t.Fatalf("Ingest: %v", err) } if nm.gotContext != "" { t.Errorf("namer получил не сырой контекст: %q", nm.gotContext) } ctx := fs.created[0].Context if !strings.Contains(ctx, "Widow's Bay") || !strings.Contains(ctx, "Трекер: t-ru.org") { t.Errorf("download.Context не обогащён: %q", ctx) } } func TestIngestQbitErrorMarksFailed(t *testing.T) { fs := &fakeStore{} fq := &fakeQbt{err: errors.New("connection refused")} res, err := newService(fs, fq).Ingest(context.Background(), Request{Source: sampleMagnet}) if err == nil { t.Fatal("ожидалась ошибка") } if res.State != store.StateFailed { t.Errorf("state = %q, want failed", res.State) } if len(fs.stateCalls) != 1 || fs.stateCalls[0].state != store.StateFailed { t.Errorf("ожидался перевод в failed: %+v", fs.stateCalls) } } func TestIngestQbitErrorNotifies(t *testing.T) { fs := &fakeStore{} fq := &fakeQbt{err: errors.New("connection refused")} svc := newService(fs, fq) got := make(chan string, 1) svc.SetFailureNotifier(func(id string) { got <- id }) if _, err := svc.Ingest(context.Background(), Request{Source: sampleMagnet}); err == nil { t.Fatal("ожидалась ошибка") } select { case id := <-got: if id == "" { t.Errorf("уведомление с пустым id") } case <-time.After(2 * time.Second): t.Fatal("уведомление о падении приёма не пришло") } } func TestIngestRejectsNonMagnet(t *testing.T) { fs := &fakeStore{} fq := &fakeQbt{} if _, err := newService(fs, fq).Ingest(context.Background(), Request{Source: "https://example.com/x.torrent"}); err == nil { t.Fatal("ожидалась ошибка для не-magnet источника") } if len(fs.created) != 0 || len(fq.added) != 0 { t.Error("не должно быть ни записи, ни добавления") } }