Два дубля-близнеца на один инфохэш рождались, когда повторный приём
попадал на запись в target_missing: дедуп искал только активную задачу,
а target_missing терминален → заводилась новая загрузка, воркер усыновлял
уже присутствующий торрент и раскладывал его.
- Приём: критерий дедупа расширен до «блокирующей повторный приём» =
активные ∪ {target_missing, orphaned}. Повторный приём такого инфохэша
привязывается к существующей записи (спящей, без обращения к qBittorrent),
а не плодит близнеца. Прочие терминальные (done/cancelled/failed/reverted/
deleted) повторный приём не блокируют — осознанная свежая попытка. Новый
read-метод FindReingestBlockingByInfohash (приоритет активной над desync);
общий active-гард не тронут.
- Команда «Закрыть» (Dismiss) — универсальный стоп-кран из любого состояния,
кроме deleted → cancelled (error_code=user_dismiss). Только меняет статус:
файлы (в т.ч. хардлинки done/orphaned) и раздачу qBittorrent не трогает,
в отличие от «Удалить». Веб — danger-зона внизу страницы; Telegram —
кнопка с подтверждением; из cancelled — идемпотентный no-op.
- Транспорты при дедупе на desync-запись сообщают адресно (target_missing —
привязать заново/закрыть; orphaned — закрыть и добавить заново); веб при
дедупе ведёт на страницу существующей записи.
Спеки: ingest (дедуп), state-reconciliation (стоп-кран); граф переходов
допополнен рёбрами <терминал>→cancelled. OpenSpec change
dedup-target-missing-and-dismiss заархивирован.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
256 lines
11 KiB
Go
256 lines
11 KiB
Go
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
|
|
}
|