Files
jellybit/internal/worker/reconcile_test.go
T
avandClaude Opus 4.8 cc7e51b3a4 Обработка рассинхрона состояния с реальностью (state-reconciliation)
Распознаём ручное удаление источника (раздача в qBittorrent) и/или цели
(разложенные хардлинки) и отражаем его в состоянии задачи, без автодействий.

- Новая capability state-reconciliation (OpenSpec): фоновая сверка по матрице
  «источник × цель» → состояния target_missing/orphaned/deleted, переходы и
  самовосстановление (healing).
- worker: reconcileDesync в Poll (только разложенные/desync-задачи), дебаунс
  пропажи источника (порог [worker].source_missing_threshold) и синхронный
  preflight перед действиями (relink/recognize/apply/undo) — не доверяем
  state в БД.
- layout.Undo: отказ снять последнюю копию (nlink<=1 или нет источника),
  отказ всего батча без частичного отката (ErrLastCopy).
- store: единый список terminalStates для IsTerminal и FindActiveByInfohash
  (иначе семантика «активности» разъезжается), столбец source_miss_count,
  миграция 0003.
- httpapi/web и Telegram: показ новых состояний и уведомления о рассинхроне.
- Доки: workflow.md, jellyfin-layout.md, database.md (+0003), config.

Change заархивирован в openspec/changes/archive, дельта влита в openspec/specs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 11:07:09 +03:00

187 lines
6.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package worker
import (
"context"
"os"
"path/filepath"
"testing"
"git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/store"
)
// reconcileFixture готовит done-задачу с одной разложенной ссылкой: цель —
// файл в temp-каталоге (создаётся при makeTarget), источник — наличие
// раздачи в fakeQbt (sourcePresent).
type reconcileFixture struct {
w *Worker
st *memStore
dst string
}
func newReconcileFixture(t *testing.T, state store.State, sourcePresent, makeTarget bool) reconcileFixture {
t.Helper()
dir := t.TempDir()
dst := filepath.Join(dir, "Movie (2024).mkv")
src := filepath.Join(dir, "src.mkv")
if makeTarget {
if err := os.WriteFile(dst, []byte("video"), 0o644); err != nil {
t.Fatal(err)
}
}
st := newMemStore()
d := completedDownload(1)
d.State = state
st.put(d)
st.links = append(st.links, store.FileLink{
DownloadID: 1, ApplyBatchID: "b1", SrcPath: src, DstPath: dst,
Kind: "video", Status: "linked",
})
var torrents []qbt.Torrent
if sourcePresent {
torrents = []qbt.Torrent{{Hash: ihTest}}
}
w := testWorkerWith(st, &fakeQbt{torrents: torrents}, nil, nil)
w.cfg.SourceMissingThreshold = 1 // помечаем при первой же пропаже (без задержки)
return reconcileFixture{w: w, st: st, dst: dst}
}
func TestReconcileMatrix(t *testing.T) {
cases := []struct {
name string
source, target bool
want store.State
}{
{"источник+цель → done", true, true, store.StateDone},
{"цель удалена → target_missing", true, false, store.StateTargetMissing},
{"источник удалён → orphaned", false, true, store.StateOrphaned},
{"оба удалены → deleted", false, false, store.StateDeleted},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newReconcileFixture(t, store.StateDone, tc.source, tc.target)
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := f.st.downloads[1].State; got != tc.want {
t.Errorf("state = %q, want %q", got, tc.want)
}
})
}
}
func TestReconcileHealing(t *testing.T) {
// Источник вернулся, цель на месте → orphaned лечится обратно в done.
f := newReconcileFixture(t, store.StateOrphaned, true, true)
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := f.st.downloads[1].State; got != store.StateDone {
t.Errorf("state = %q, want done (healing)", got)
}
}
func TestReconcilePartialTargetLoss(t *testing.T) {
// Две ссылки, одна цель удалена → цель считается отсутствующей.
f := newReconcileFixture(t, store.StateDone, true, true)
missing := filepath.Join(filepath.Dir(f.dst), "Movie (2024).en.srt")
f.st.links = append(f.st.links, store.FileLink{
DownloadID: 1, ApplyBatchID: "b1", SrcPath: "/x.srt", DstPath: missing,
Kind: "subtitle", Status: "linked",
})
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := f.st.downloads[1].State; got != store.StateTargetMissing {
t.Errorf("state = %q, want target_missing (частичная пропажа)", got)
}
}
func TestReconcileDebounce(t *testing.T) {
// Порог 3: первые два промаха не помечают, третий — помечает orphaned;
// возврат источника лечит обратно и сбрасывает счётчик.
f := newReconcileFixture(t, store.StateDone, false, true)
f.w.cfg.SourceMissingThreshold = 3
for i := 1; i <= 2; i++ {
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll %d: %v", i, err)
}
if got := f.st.downloads[1].State; got != store.StateDone {
t.Fatalf("tick %d: state = %q, want done (до порога)", i, got)
}
if got := f.st.downloads[1].SourceMissCount; got != i {
t.Errorf("tick %d: miss = %d, want %d", i, got, i)
}
}
if err := f.w.Poll(context.Background()); err != nil { // третий промах
t.Fatalf("Poll 3: %v", err)
}
if got := f.st.downloads[1].State; got != store.StateOrphaned {
t.Fatalf("tick 3: state = %q, want orphaned (порог достигнут)", got)
}
// Источник вернулся.
f.w.qbt.(*fakeQbt).torrents = []qbt.Torrent{{Hash: ihTest}}
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll heal: %v", err)
}
if got := f.st.downloads[1].State; got != store.StateDone {
t.Errorf("state = %q, want done (источник вернулся)", got)
}
if got := f.st.downloads[1].SourceMissCount; got != 0 {
t.Errorf("miss = %d, want 0 (сброс)", got)
}
}
func TestReconcileSkipsActiveStates(t *testing.T) {
// downloading сверкой не трогаем, даже если раздачи нет в qBittorrent.
f := newReconcileFixture(t, store.StateDownloading, false, true)
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := f.st.downloads[1].State; got != store.StateDownloading {
t.Errorf("state = %q, want downloading (сверка не трогает активные)", got)
}
}
func TestUndoRejectedForOrphaned(t *testing.T) {
f := newReconcileFixture(t, store.StateOrphaned, false, true)
err := f.w.Undo(context.Background(), 1)
if err == nil {
t.Fatal("ожидали отказ Undo для orphaned")
}
if got := f.st.downloads[1].State; got != store.StateOrphaned {
t.Errorf("state = %q, want orphaned (без изменений)", got)
}
}
func TestRelinkFromTargetMissing(t *testing.T) {
// target_missing + источник на месте → relink ведёт в recognizing.
f := newReconcileFixture(t, store.StateTargetMissing, true, false)
if err := f.w.Relink(context.Background(), 1); err != nil {
t.Fatalf("Relink: %v", err)
}
if got := f.st.downloads[1].State; got != store.StateRecognizing {
t.Errorf("state = %q, want recognizing", got)
}
if f.st.overrides[1][ovrForceReview] != "1" {
t.Errorf("force_review = %q, want 1", f.st.overrides[1][ovrForceReview])
}
}
func TestPreflightFixesStaleState(t *testing.T) {
// В БД target_missing (источник якобы есть), но фактически источник пропал,
// а цель на месте: relink немедленно приводит состояние к orphaned, не
// дожидаясь фоновой сверки.
f := newReconcileFixture(t, store.StateTargetMissing, false, true)
if err := f.w.Relink(context.Background(), 1); err == nil {
t.Fatal("ожидали отказ relink при пропавшем источнике")
}
if got := f.st.downloads[1].State; got != store.StateOrphaned {
t.Errorf("state = %q, want orphaned (preflight привёл к реальности)", got)
}
}