layout: непомещающееся целевое имя уводит задачу в review вместо failed
- предел длины компонента (255 байт) проверяется в BuildLinks до первой операции с ФС: ни каталога, ни ссылки при отказе не создаётся - причина пустого предпросмотра считается на показе (ReviewData.PreviewError) и печатается в панели действий и в карточке Telegram: у задачи без записанной причины взять её больше неоткуда
This commit is contained in:
@@ -352,3 +352,58 @@ func TestRetryListShowsProgress(t *testing.T) {
|
||||
t.Errorf("карточка downloading без прогресс-поллера: %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestActionBarNamesReasonWithoutPreview: при пустом предпросмотре панель
|
||||
// действий печатает записанную причину, а не общее «Подтверди источник» —
|
||||
// иначе экран советует подтвердить уже подтверждённое и молчит о настоящей
|
||||
// причине (см. spec review, «Панель действий при пустом предпросмотре»).
|
||||
func TestActionBarNamesReasonWithoutPreview(t *testing.T) {
|
||||
t.Run("причина показа старше записанной", func(t *testing.T) {
|
||||
rd := reviewDataWithSource(false)
|
||||
rd.PreviewError = `layout: имя не помещается: "Очень длинное" — 400 байт при пределе 255`
|
||||
rd.Download.ErrorMsg = store.NullString("устаревшая причина прошлого перехода")
|
||||
rv := actionReviewer{stubReviewer: stubReviewer{data: rd}}
|
||||
h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{})
|
||||
|
||||
rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("nobase (htmx) = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, "не помещается") {
|
||||
t.Errorf("панель не назвала причину: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "Подтверди источник") {
|
||||
t.Errorf("панель печатает общий текст вместо причины: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "устаревшая") {
|
||||
t.Errorf("записанная причина перебила посчитанную на показе: %s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("записанная причина, когда посчитанной нет", func(t *testing.T) {
|
||||
rd := reviewDataWithSource(false)
|
||||
rd.Download.ErrorMsg = store.NullString("целевой файл уже существует")
|
||||
rv := actionReviewer{stubReviewer: stubReviewer{data: rd}}
|
||||
h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{})
|
||||
|
||||
rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true)
|
||||
if !strings.Contains(rr.Body.String(), "уже существует") {
|
||||
t.Errorf("панель не назвала записанную причину: %s", rr.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("причины нет → прежний общий текст", func(t *testing.T) {
|
||||
rd := reviewDataWithSource(false)
|
||||
rv := actionReviewer{stubReviewer: stubReviewer{data: rd}}
|
||||
h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{})
|
||||
|
||||
rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("nobase (htmx) = %d, want 200", rr.Code)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "Подтверди источник") {
|
||||
t.Errorf("без причины ожидался общий текст: %s", rr.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -770,7 +770,8 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
// (magnet.ErrNotMagnet), oversized `.torrent` (ingest.ErrTorrentTooLarge) и
|
||||
// некорректный ввод команды (worker.ErrInvalidInput) →
|
||||
// 400; недокачанный источник (worker.ErrNotReady), коллизия цели
|
||||
// (layout.ErrCollision) и конфликт состояния (worker.ErrConflict) → 409; прочее
|
||||
// (layout.ErrCollision), непомещающееся целевое имя (layout.ErrNameTooLong) и
|
||||
// конфликт состояния (worker.ErrConflict) → 409; прочее
|
||||
// → 500. Полная ошибка уже в логах на доменной границе — наружу отдаём только
|
||||
// сообщение + корреляционный ключ.
|
||||
func classifyErr(err error) (int, string) {
|
||||
@@ -793,6 +794,10 @@ func classifyErr(err error) (int, string) {
|
||||
// Целевой путь уже занят: задача штатно ушла в review с причиной —
|
||||
// это не сбой, а требующий разбора конфликт.
|
||||
return http.StatusConflict, "целевой файл уже существует, задача отправлена в ревью"
|
||||
case errors.Is(err, layout.ErrNameTooLong):
|
||||
// Целевое имя не помещается в файловую систему: задача штатно ушла в
|
||||
// review, где название правится подсказкой. Не сбой сервера.
|
||||
return http.StatusConflict, "целевое имя слишком длинное, задача отправлена в ревью"
|
||||
case errors.Is(err, worker.ErrConflict):
|
||||
// Нормальный конфликт состояния (операция недопустима сейчас), не сбой.
|
||||
return http.StatusConflict, "действие недоступно в текущем состоянии"
|
||||
|
||||
@@ -1098,3 +1098,25 @@ func TestRerecognize(t *testing.T) {
|
||||
t.Errorf("rerecognized = %v, want [%s]", rv.rerecognized, tid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPICommandNameTooLong(t *testing.T) {
|
||||
// Непомещающееся целевое имя (layout.ErrNameTooLong) → 409 (штатно ушло в
|
||||
// review), не 500: это конфликт, требующий разбора, а не сбой сервера.
|
||||
// Наружу — нейтральное сообщение, сырой текст ошибки остаётся в логах.
|
||||
cmd := &fakeCommander{err: fmt.Errorf("apply: %w", layout.ErrNameTooLong)}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
|
||||
|
||||
resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409", resp.StatusCode)
|
||||
}
|
||||
var got map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||
if msg, _ := got["error"].(string); !strings.Contains(msg, "слишком длинное") {
|
||||
t.Errorf("error = %q, want содержащее «слишком длинное»", msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ type reviewView struct {
|
||||
State string
|
||||
Error string // из ?err=
|
||||
StateError string // error_msg загрузки (напр. причина коллизии)
|
||||
PreviewError string // почему предпросмотр не построился, посчитано на показе
|
||||
MediaType string
|
||||
IsSeries bool
|
||||
Title string
|
||||
@@ -113,7 +114,11 @@ func buildReviewView(id string, rd *worker.ReviewData, errMsg string) reviewView
|
||||
State: string(rd.Download.State),
|
||||
Error: errMsg,
|
||||
StateError: rd.Download.ErrorMsg.String,
|
||||
Hints: rd.Hints,
|
||||
// Причина пустого предпросмотра считается на показе и потому всегда про
|
||||
// текущий план; error_msg остался от последнего перехода и после смены
|
||||
// источника уже не про него.
|
||||
PreviewError: rd.PreviewError,
|
||||
Hints: rd.Hints,
|
||||
}
|
||||
if rec := rd.Recognition; rec != nil {
|
||||
view.MediaType = string(rd.Plan.Type)
|
||||
|
||||
Reference in New Issue
Block a user