- имя раздачи нормализуется на границе разбора: вырожденное `-` (metainfo.NoName) даёт пустое имя, пробельное схлопывается — сентинел больше не доходит ни до контекста распознавания, ни до source_ref, ни до подсказки вывода имени - контракт «на любом пути ошибки приёма результат нулевой» объявлен в ingest и удерживается структурно; три транспорта перестали обещать идентификатор, которого нет, и коррелируют отказ по request_id - scoped-логгер загрузки ставится до вызова внешнего сервиса в семи командах воркера — записи об отказе qBittorrent и метабаз получили download_id и infohash; граница разбора bencode записана в docs/research
138 lines
5.2 KiB
Go
138 lines
5.2 KiB
Go
package httpapi_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.vakhrushev.me/av/jellybit/internal/httpapi"
|
|
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
|
)
|
|
|
|
// Веб-форма с выбранным .torrent-файлом → приём по байтам (TorrentData).
|
|
func TestUIAddTorrentFile(t *testing.T) {
|
|
ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateCatched}}
|
|
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
|
|
|
|
var body bytes.Buffer
|
|
mw := multipart.NewWriter(&body)
|
|
_ = mw.WriteField("source", "") // источник пуст — используется файл
|
|
fw, _ := mw.CreateFormFile("torrent", "dune.torrent")
|
|
torrentBytes := []byte("d8:announce…bytes")
|
|
_, _ = fw.Write(torrentBytes)
|
|
_ = mw.Close()
|
|
|
|
resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads", mw.FormDataContentType(), &body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusSeeOther {
|
|
t.Fatalf("status = %d, want 303", resp.StatusCode)
|
|
}
|
|
if !bytes.Equal(ing.lastReq.TorrentData, torrentBytes) {
|
|
t.Errorf("TorrentData не проброшен в ingest: %q", ing.lastReq.TorrentData)
|
|
}
|
|
}
|
|
|
|
// Без файла — прежний текстовый путь (Source), TorrentData пуст.
|
|
func TestUIAddTextNoFile(t *testing.T) {
|
|
ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateCatched}}
|
|
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
|
|
|
|
var body bytes.Buffer
|
|
mw := multipart.NewWriter(&body)
|
|
_ = mw.WriteField("source", "magnet:?xt=urn:btih:abc")
|
|
_ = mw.Close()
|
|
|
|
resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads", mw.FormDataContentType(), &body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusSeeOther {
|
|
t.Fatalf("status = %d, want 303", resp.StatusCode)
|
|
}
|
|
if len(ing.lastReq.TorrentData) != 0 {
|
|
t.Errorf("TorrentData должен быть пуст без файла")
|
|
}
|
|
if !strings.HasPrefix(ing.lastReq.Source, "magnet:") {
|
|
t.Errorf("source не проброшен: %q", ing.lastReq.Source)
|
|
}
|
|
}
|
|
|
|
// Обычная (не-multipart) urlencoded-отправка тоже работает (ErrNotMultipart
|
|
// глотается, поля уже в PostForm) — устойчивость к curl/старой странице.
|
|
func TestUIAddUrlencoded(t *testing.T) {
|
|
ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateCatched}}
|
|
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
|
|
|
|
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads", url.Values{
|
|
"source": {"magnet:?xt=urn:btih:abc"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusSeeOther {
|
|
t.Fatalf("status = %d, want 303", resp.StatusCode)
|
|
}
|
|
if !strings.HasPrefix(ing.lastReq.Source, "magnet:") {
|
|
t.Errorf("urlencoded source не проброшен: %q", ing.lastReq.Source)
|
|
}
|
|
}
|
|
|
|
// Отказ приёма на HTTP-границе: идентификатора загрузки нет (контракт
|
|
// ingest.Ingest — нулевой Result на любом пути ошибки), поэтому корреляционным
|
|
// ключом остаётся request_id запроса. Проверяются оба HTTP-транспорта: REST
|
|
// отдаёт ключ полем тела, веб-форма — текстом флеш-сообщения в редиректе.
|
|
func TestIngestErrorCorrelatesByRequestID(t *testing.T) {
|
|
t.Run("REST", func(t *testing.T) {
|
|
ing := &fakeIngestor{err: fmt.Errorf("ingest: create download: %w", errors.New("boom"))}
|
|
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
|
|
|
|
resp, err := http.Post(srv.URL+"/api/downloads", "application/json",
|
|
strings.NewReader(`{"source":"magnet:?xt=urn:btih:abc"}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
var body map[string]any
|
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if s, _ := body["request_id"].(string); s == "" {
|
|
t.Errorf("в теле отказа нет request_id: %v", body)
|
|
}
|
|
if _, ok := body["download_id"]; ok {
|
|
t.Errorf("в теле отказа обещан download_id: %v", body)
|
|
}
|
|
})
|
|
|
|
t.Run("веб-форма", func(t *testing.T) {
|
|
ing := &fakeIngestor{err: fmt.Errorf("ingest: create download: %w", errors.New("boom"))}
|
|
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
|
|
|
|
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads",
|
|
url.Values{"source": {"magnet:?xt=urn:btih:abc"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
loc := resp.Header.Get("Location")
|
|
if !strings.Contains(loc, "request_id%3D") && !strings.Contains(loc, "request_id=") {
|
|
t.Errorf("в сообщении отказа нет request_id: %q", loc)
|
|
}
|
|
if strings.Contains(loc, "download_id") {
|
|
t.Errorf("в сообщении отказа обещан download_id: %q", loc)
|
|
}
|
|
})
|
|
}
|