Files
jellybit/internal/store/download_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

181 lines
5.6 KiB
Go

package store
import (
"context"
"testing"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
st, err := Open(t.TempDir() + "/test.db")
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
return st
}
func newDownloading(infohash string) *Download {
return &Download{
SourceType: SourceMagnet,
SourceRef: "magnet:?xt=urn:btih:" + infohash,
Context: "ctx",
Infohash: NullString(infohash),
IdempotencyKey: NullString(infohash),
State: StateDownloading,
}
}
func TestCreateAndGetDownload(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
id, err := st.CreateDownload(ctx, newDownloading("aabbccddeeff00112233445566778899aabbccdd"))
if err != nil {
t.Fatalf("create: %v", err)
}
got, err := st.GetDownload(ctx, id)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.State != StateDownloading {
t.Errorf("state = %q, want downloading", got.State)
}
if got.Context != "ctx" {
t.Errorf("context = %q", got.Context)
}
if got.CreatedAt == "" {
t.Error("created_at пуст")
}
}
func TestFindActiveByInfohash(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const ih = "1111111111111111111111111111111111111111"
if d, err := st.FindActiveByInfohash(ctx, ih); err != nil || d != nil {
t.Fatalf("ожидался (nil,nil), получили (%v,%v)", d, err)
}
id, err := st.CreateDownload(ctx, newDownloading(ih))
if err != nil {
t.Fatal(err)
}
d, err := st.FindActiveByInfohash(ctx, ih)
if err != nil {
t.Fatal(err)
}
if d == nil || d.ID != id {
t.Fatalf("активная задача не найдена: %v", d)
}
}
// Состояния рассинхрона (target_missing/orphaned/deleted) терминальны: задача
// в них не должна считаться «активной» (иначе relink/ingest-дедуп решат, что
// для infohash уже есть активная задача). Регрессия: FindActiveByInfohash и
// IsTerminal обязаны опираться на один список терминальных состояний.
func TestFindActiveByInfohash_DesyncStatesNotActive(t *testing.T) {
for _, st := range []State{StateTargetMissing, StateOrphaned, StateDeleted} {
t.Run(string(st), func(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
ih := "33333333333333333333333333333333333333" + string(st[0:2])
id, err := store.CreateDownload(ctx, newDownloading(ih))
if err != nil {
t.Fatal(err)
}
if err := store.SetDownloadState(ctx, id, st, "", ""); err != nil {
t.Fatal(err)
}
if d, err := store.FindActiveByInfohash(ctx, ih); err != nil || d != nil {
t.Fatalf("%s: активной задачи быть не должно, получили (%v,%v)", st, d, err)
}
})
}
}
// Терминальное состояние снимает ключ идемпотентности и позволяет завести
// тот же infohash заново (повторная закачка спустя время).
func TestTerminalReleasesInfohash(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const ih = "2222222222222222222222222222222222222222"
id, err := st.CreateDownload(ctx, newDownloading(ih))
if err != nil {
t.Fatal(err)
}
if err := st.SetDownloadState(ctx, id, StateFailed, "qbit_add", "boom"); err != nil {
t.Fatal(err)
}
// После терминального состояния активной задачи нет.
if d, err := st.FindActiveByInfohash(ctx, ih); err != nil || d != nil {
t.Fatalf("после failed активная задача не должна находиться: (%v,%v)", d, err)
}
// Ключ идемпотентности снят.
got, err := st.GetDownload(ctx, id)
if err != nil {
t.Fatal(err)
}
if got.IdempotencyKey.Valid {
t.Errorf("idempotency_key должен быть NULL, получили %q", got.IdempotencyKey.String)
}
if got.ErrorCode.String != "qbit_add" {
t.Errorf("error_code = %q", got.ErrorCode.String)
}
// Тот же infohash заводится заново — unique index не мешает.
id2, err := st.CreateDownload(ctx, newDownloading(ih))
if err != nil {
t.Fatalf("повторное добавление после терминального должно проходить: %v", err)
}
if id2 == id {
t.Error("ожидалась новая задача")
}
}
// Две активные задачи с одним ключом идемпотентности недопустимы.
func TestActiveDuplicateRejected(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const ih = "3333333333333333333333333333333333333333"
if _, err := st.CreateDownload(ctx, newDownloading(ih)); err != nil {
t.Fatal(err)
}
if _, err := st.CreateDownload(ctx, newDownloading(ih)); err == nil {
t.Error("ожидалось нарушение уникальности idempotency_key")
}
}
func TestListAndByState(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
id1, _ := st.CreateDownload(ctx, newDownloading("4444444444444444444444444444444444444444"))
id2, _ := st.CreateDownload(ctx, newDownloading("5555555555555555555555555555555555555555"))
if err := st.SetDownloadState(ctx, id2, StateCompleted, "", ""); err != nil {
t.Fatal(err)
}
all, err := st.ListDownloads(ctx)
if err != nil {
t.Fatal(err)
}
if len(all) != 2 {
t.Fatalf("ListDownloads = %d, want 2", len(all))
}
dl, err := st.ListDownloadsByState(ctx, StateDownloading)
if err != nil {
t.Fatal(err)
}
if len(dl) != 1 || dl[0].ID != id1 {
t.Fatalf("ListDownloadsByState(downloading) = %v", dl)
}
}