Единое окно: полное пользовательское удаление загрузки (delete)
Вторая половина «единого окна»: команда «Удалить» снимает наши библиотечные хардлинки (гард последней копии осознанно выключен, в отличие от Undo) и сносит раздачу с файлами из qBittorrent (deleteFiles=true) → терминальный deleted. Доступна из done/orphaned/target_missing, идемпотентна к отсутствующей стороне; инициатор различается через error_code=user_delete. Подтверждение обязательно: веб — danger-секция внизу страницы (hx-confirm + details), Telegram — двухшаговый inline-confirm. qbt.Delete + layout.Remove (unlink без ErrLastCopy, только свои ссылки под movies/series). Граф переходов не менялся — рёбра уже были. OpenSpec: state-reconciliation +1 требование; синк workflow.md; беклог закрыт. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -872,6 +872,7 @@ func TestDefer(t *testing.T) {
|
||||
type applyFixture struct {
|
||||
w *Worker
|
||||
st *memStore
|
||||
qb *fakeQbt
|
||||
downloads string
|
||||
movies string
|
||||
series string
|
||||
@@ -911,7 +912,7 @@ func newApplyFixture(t *testing.T, plan recognize.Plan) applyFixture {
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, SavePath: downloads, Category: "jellybit", State: "uploading"}}}
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{}, lay)
|
||||
|
||||
return applyFixture{w: w, st: st, downloads: downloads, movies: movies, series: series}
|
||||
return applyFixture{w: w, st: st, qb: qb, downloads: downloads, movies: movies, series: series}
|
||||
}
|
||||
|
||||
func TestApply_LinksAndDone(t *testing.T) {
|
||||
@@ -1068,6 +1069,125 @@ func TestUndo_RevertsLinks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete_FromDone_RemovesBothSides(t *testing.T) {
|
||||
f := newApplyFixture(t, seriesResult().Plan)
|
||||
if err := f.w.Apply(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
|
||||
|
||||
if err := f.w.Delete(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
d := f.st.downloads["1"]
|
||||
if d.State != store.StateDeleted {
|
||||
t.Errorf("state = %q, want deleted", d.State)
|
||||
}
|
||||
if d.ErrorCode.String != "user_delete" {
|
||||
t.Errorf("error_code = %q, want user_delete", d.ErrorCode.String)
|
||||
}
|
||||
if _, err := os.Stat(dst); !os.IsNotExist(err) {
|
||||
t.Errorf("library link must be removed: %v", err)
|
||||
}
|
||||
if len(f.st.links) != 0 {
|
||||
t.Errorf("file_links must be deleted, got %d", len(f.st.links))
|
||||
}
|
||||
if len(f.qb.deleted) != 1 || len(f.qb.deleted[0]) == 0 || f.qb.deleted[0][0] != ihTest {
|
||||
t.Errorf("qbt.Delete calls = %v, want [[%s]]", f.qb.deleted, ihTest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete_FromOrphaned_RemovesLastCopy(t *testing.T) {
|
||||
f := newApplyFixture(t, seriesResult().Plan)
|
||||
if err := f.w.Apply(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
|
||||
// Источник пропал → библиотечная ссылка стала последней копией (nlink==1):
|
||||
// Undo бы отказал, Delete снимает осознанно.
|
||||
for _, src := range []string{"Show/e1.mkv", "Show/e2.mkv"} {
|
||||
_ = os.Remove(filepath.Join(f.downloads, src))
|
||||
}
|
||||
f.qb.torrents = nil // раздачи в qBittorrent больше нет
|
||||
f.st.downloads["1"].State = store.StateOrphaned
|
||||
|
||||
if err := f.w.Delete(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if f.st.downloads["1"].State != store.StateDeleted {
|
||||
t.Errorf("state = %q, want deleted", f.st.downloads["1"].State)
|
||||
}
|
||||
if _, err := os.Stat(dst); !os.IsNotExist(err) {
|
||||
t.Errorf("last-copy link must be removed despite guard: %v", err)
|
||||
}
|
||||
// Отсутствие раздачи не ошибка — вызов всё равно состоялся идемпотентно.
|
||||
if len(f.qb.deleted) != 1 {
|
||||
t.Errorf("qbt.Delete calls = %d, want 1 (idempotent)", len(f.qb.deleted))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete_FromTargetMissing_Idempotent(t *testing.T) {
|
||||
f := newApplyFixture(t, seriesResult().Plan)
|
||||
if err := f.w.Apply(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
// Цель удалена вручную, источник на месте.
|
||||
dst1 := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
|
||||
dst2 := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E02.mkv")
|
||||
_ = os.Remove(dst1)
|
||||
_ = os.Remove(dst2)
|
||||
f.st.downloads["1"].State = store.StateTargetMissing
|
||||
|
||||
if err := f.w.Delete(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if f.st.downloads["1"].State != store.StateDeleted {
|
||||
t.Errorf("state = %q, want deleted", f.st.downloads["1"].State)
|
||||
}
|
||||
if len(f.qb.deleted) != 1 { // остаточную раздачу снесли
|
||||
t.Errorf("qbt.Delete calls = %d, want 1", len(f.qb.deleted))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete_WrongStateRejected(t *testing.T) {
|
||||
f := newApplyFixture(t, seriesResult().Plan) // состояние review
|
||||
err := f.w.Delete(context.Background(), "1")
|
||||
if !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("Delete from review = %v, want ErrConflict", err)
|
||||
}
|
||||
if f.st.downloads["1"].State != store.StateReview {
|
||||
t.Errorf("state = %q, want review (unchanged)", f.st.downloads["1"].State)
|
||||
}
|
||||
if len(f.qb.deleted) != 0 {
|
||||
t.Errorf("qbt.Delete must not be called, got %v", f.qb.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete_QbtErrorKeepsState(t *testing.T) {
|
||||
f := newApplyFixture(t, seriesResult().Plan)
|
||||
if err := f.w.Apply(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
f.qb.deleteErr = errors.New("qbittorrent boom")
|
||||
|
||||
if err := f.w.Delete(context.Background(), "1"); err == nil {
|
||||
t.Fatal("Delete must return qbittorrent error")
|
||||
}
|
||||
// Ложно в deleted не уходим (место не освобождено).
|
||||
if f.st.downloads["1"].State == store.StateDeleted {
|
||||
t.Errorf("state = deleted despite qbt error; want unchanged (done)")
|
||||
}
|
||||
// Повторный delete после устранения сбоя идемпотентно дожимает удаление:
|
||||
// цель уже снята (file_links удалены), источник сносится, задача → deleted.
|
||||
f.qb.deleteErr = nil
|
||||
if err := f.w.Delete(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("repeat Delete: %v", err)
|
||||
}
|
||||
if f.st.downloads["1"].State != store.StateDeleted {
|
||||
t.Errorf("state = %q after repeat, want deleted", f.st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewData(t *testing.T) {
|
||||
plan := seriesResult().Plan
|
||||
f := newApplyFixture(t, plan)
|
||||
|
||||
Reference in New Issue
Block a user