package store import ( "context" "errors" "slices" "strconv" "strings" "testing" ) // allStates — полный перечень состояний машины (для проверки хорошей // сформированности графа). Держим локально в тесте: если добавится новое // состояние, тест напомнит внести его сюда и в граф. var allStates = []State{ StateCatched, StateDownloading, StateCompleted, StateRecognizing, StateReview, StateLinking, StateDone, StateDeferred, StateStuck, StateFailed, StateCancelled, StateReverted, StateTargetMissing, StateOrphaned, StateDeleted, } // seedState заводит загрузку и приводит её к нужному состоянию кратчайшим // доверенным путём (в обход гейта графа — через прямой UPDATE), чтобы тест // проверял именно проверяемый переход, а не путь подготовки. func seedState(t *testing.T, st *Store, ih string, state State) string { t.Helper() ctx := context.Background() d := &Download{SourceType: SourceMagnet, SourceRef: "magnet:?xt=urn:btih:" + ih, State: StateCatched} if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}, nil); err != nil { t.Fatalf("seed create: %v", err) } if state != StateCatched { if _, err := st.DB.ExecContext(ctx, `UPDATE download SET state = ? WHERE id = ?`, string(state), d.ID); err != nil { t.Fatalf("seed set state %s: %v", state, err) } } return d.ID } func stateOf(t *testing.T, st *Store, id string) State { t.Helper() d, err := st.GetDownload(context.Background(), id) if err != nil { t.Fatalf("get: %v", err) } return d.State } // Граф хорошо сформирован: все состояния из ключей и значений — известные, и // ни один список не перечисляет сам ключ (петли не объявляются явно — // самопереход добавляет invertTransitions). func TestTransitionGraphWellFormed(t *testing.T) { known := func(s State) bool { return slices.Contains(allStates, s) } for from, tos := range allowedTransitions { if !known(from) { t.Errorf("неизвестное состояние-ключ: %q", from) } for _, to := range tos { if !known(to) { t.Errorf("%s → неизвестное состояние %q", from, to) } if to == from { t.Errorf("%s: петля перечислена явно (самопереход неявен)", from) } } } // Каждое состояние присутствует в графе как ключ — иначе setState fail-closed // отклонит переход в него. for _, s := range allStates { if _, ok := allowedTransitions[s]; !ok { t.Errorf("состояние %q отсутствует среди ключей графа", s) } } } // Инвариант Defer: deferred — легальная цель из КАЖДОГО не-терминального // состояния (кроме самого deferred — это самопереход). Ловит класс дыры «забыли // состояние» (напр. linking после краха процесса). func TestDeferReachableFromEveryNonTerminal(t *testing.T) { for _, s := range allStates { if s.IsTerminal() || s == StateDeferred { continue // deferred → deferred покрыт самопереходом } if !slices.Contains(transitionSources[StateDeferred], s) { t.Errorf("%s → deferred не легально (Defer допускает любое не-терминальное)", s) } } } // Инвариант универсального стоп-крана Dismiss: cancelled — легальная цель из // ЛЮБОГО состояния, кроме deleted (строго терминален) и самого cancelled // (самопереход). Шире инварианта Defer: покрывает и терминальные // done/failed/reverted/target_missing/orphaned. НЕ объединять с проверкой // deferred — у них разные множества источников. func TestCancelledReachableFromEveryStateButDeleted(t *testing.T) { for _, s := range allStates { if s == StateDeleted || s == StateCancelled { continue // deleted строго терминален; cancelled → cancelled — самопереход } if !slices.Contains(transitionSources[StateCancelled], s) { t.Errorf("%s → cancelled не легально (Dismiss/Cancel допускают любое состояние, кроме deleted)", s) } } } // Объявленные не-revive рёбра проходят через SetDownloadState. func TestSetStateAllowsDeclaredEdges(t *testing.T) { edges := []struct{ from, to State }{ {StateDownloading, StateCompleted}, {StateDownloading, StateStuck}, {StateCompleted, StateRecognizing}, {StateRecognizing, StateReview}, {StateRecognizing, StateLinking}, {StateReview, StateLinking}, {StateReview, StateRecognizing}, {StateLinking, StateDone}, {StateLinking, StateReview}, {StateDone, StateReverted}, {StateStuck, StateCancelled}, {StateReview, StateDeferred}, // Стоп-кран Dismiss: терминал → cancelled идёт обычным SetDownloadState // (цель cancelled терминальна → гард терминальности не мешает, revive не // нужен). {StateDone, StateCancelled}, {StateFailed, StateCancelled}, {StateReverted, StateCancelled}, {StateTargetMissing, StateCancelled}, {StateOrphaned, StateCancelled}, } for i, e := range edges { st := newTestStore(t) ih := infohashN(i) id := seedState(t, st, ih, e.from) if err := st.SetDownloadState(context.Background(), id, e.to, "", ""); err != nil { t.Errorf("легальное ребро %s → %s отклонено: %v", e.from, e.to, err) continue } if got := stateOf(t, st, id); got != e.to { t.Errorf("%s → %s: состояние стало %q", e.from, e.to, got) } } } // Preflight-рёбра reconcileToReality при пропавшем источнике: из ревью/ // терминальных состояний в orphaned/deleted проходят через SetDownloadState // (цель терминальна → гард терминальности не мешает). func TestPreflightDesyncEdges(t *testing.T) { edges := []struct{ from, to State }{ {StateReview, StateOrphaned}, {StateReview, StateDeleted}, {StateDeferred, StateOrphaned}, {StateDeferred, StateDeleted}, {StateReverted, StateOrphaned}, {StateReverted, StateDeleted}, {StateCancelled, StateOrphaned}, {StateCancelled, StateDeleted}, } for i, e := range edges { st := newTestStore(t) id := seedState(t, st, infohashN(i), e.from) if err := st.SetDownloadState(context.Background(), id, e.to, "", ""); err != nil { t.Errorf("preflight-ребро %s → %s отклонено: %v", e.from, e.to, err) continue } if got := stateOf(t, st, id); got != e.to { t.Errorf("%s → %s: состояние стало %q", e.from, e.to, got) } } } // Необъявленные рёбра отклоняются, состояние не меняется. func TestSetStateRejectsUndeclaredEdges(t *testing.T) { edges := []struct{ from, to State }{ {StateReview, StateDone}, {StateDownloading, StateDone}, {StateCompleted, StateLinking}, {StateDownloading, StateReview}, } for i, e := range edges { st := newTestStore(t) id := seedState(t, st, infohashN(i), e.from) err := st.SetDownloadState(context.Background(), id, e.to, "", "") if err == nil { t.Errorf("нелегальное ребро %s → %s прошло", e.from, e.to) continue } if !strings.Contains(err.Error(), "illegal transition") { t.Errorf("%s → %s: ожидалось 'illegal transition', got: %v", e.from, e.to, err) } if got := stateOf(t, st, id); got != e.from { t.Errorf("%s → %s отклонён, но состояние стало %q", e.from, e.to, got) } } } // Самопереход (идемпотентная переустановка того же состояния) разрешён. func TestSelfTransitionAllowed(t *testing.T) { st := newTestStore(t) id := seedState(t, st, infohashN(0), StateDeferred) if err := st.SetDownloadState(context.Background(), id, StateDeferred, "", ""); err != nil { t.Fatalf("самопереход deferred → deferred отклонён: %v", err) } if got := stateOf(t, st, id); got != StateDeferred { t.Errorf("состояние стало %q", got) } } // Ребро из терминального состояния в активное проходит только revive-путём // (ActivateIfNoOtherActive), но не обычным SetDownloadState. func TestTerminalReviveOnlyViaActivate(t *testing.T) { ctx := context.Background() // SetDownloadState (не-revive): failed → downloading отклоняется гардом // терминальности, ошибка указывает на revive. st := newTestStore(t) id := seedState(t, st, infohashN(0), StateFailed) err := st.SetDownloadState(ctx, id, StateDownloading, "", "") if err == nil { t.Fatal("failed → downloading через SetDownloadState прошло") } if !strings.Contains(err.Error(), "terminal revive") { t.Errorf("ожидалось 'terminal revive', got: %v", err) } if got := stateOf(t, st, id); got != StateFailed { t.Errorf("состояние изменилось на %q", got) } // ActivateIfNoOtherActive (revive): тот же переход при свободном infohash // проходит. st2 := newTestStore(t) id2 := seedState(t, st2, infohashN(1), StateFailed) if err := st2.ActivateIfNoOtherActive(ctx, id2, StateDownloading, "", ""); err != nil { t.Fatalf("revive failed → downloading отклонён: %v", err) } if got := stateOf(t, st2, id2); got != StateDownloading { t.Errorf("revive: состояние стало %q, want downloading", got) } } // Отсутствующая загрузка → ErrNotFound (гейт графа не маскирует «не найдено»). func TestSetStateNotFound(t *testing.T) { st := newTestStore(t) err := st.SetDownloadState(context.Background(), "01hnonexistentnonexistent", StateCompleted, "", "") if !errors.Is(err, ErrNotFound) { t.Errorf("ожидался ErrNotFound, got: %v", err) } } // infohashN — детерминированный 40-hex инфохэш по индексу (уникальность между // подтестами без общего состояния). Цифры и 'a'-паддинг — валидный hex. func infohashN(n int) string { s := strconv.Itoa(n) return strings.Repeat("a", 40-len(s)) + s }