package httpapi import ( "errors" "net/http" "strings" "testing" "git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/worker" ) // TestFragProgressDownloading: маршрут прогресса остался гасителем старых // вкладок — отдаёт цифры снимка и НЕ несёт собственного опроса. func TestFragProgressDownloading(t *testing.T) { dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih5", Kind: store.HashV1}}, State: store.StateDownloading} lv := stubLive{m: map[string]worker.Live{"ih5": {Progress: 0.42, DlSpeed: 6400000, ETA: 720}}} h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, lv) rr := get(t, h, "/fragments/downloads/"+testULID+"/progress") if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } body := rr.Body.String() for _, want := range []string{"width:42%", "42%"} { if !strings.Contains(body, want) { t.Errorf("фрагмент прогресса не содержит %q\n%s", want, body) } } if strings.Contains(body, "hx-trigger") { t.Errorf("партиал прогресса всё ещё опрашивает сервер сам:\n%s", body) } } // TestProgressBlockHiddenOutsideDownloading: вне downloading блок живых цифр не // рисуется вовсе — скорость и ETA там смысла не имеют. Проверка на отсутствие // hx-trigger сюда не годится: партиал не несёт его ни при каком входе, и такой // тест был бы зелёным независимо от логики. func TestProgressBlockHiddenOutsideDownloading(t *testing.T) { dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih5", Kind: store.HashV1}}, State: store.StateDone} lv := stubLive{m: map[string]worker.Live{"ih5": {Progress: 0.9, DlSpeed: 6400000, ETA: 720}}} h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, lv) rr := get(t, h, "/fragments/downloads/"+testULID+"/progress") if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } body := rr.Body.String() for _, unwanted := range []string{`class="progress"`, "dl-stats", "90%"} { if strings.Contains(body, unwanted) { t.Errorf("вне downloading блок цифр не должен рисоваться, есть %q:\n%s", unwanted, body) } } } // TestFragErrKeepsSwapRoot: фрагмент отказа несёт корневой id того узла, который // он собой заменяет. Иначе своп уносит якорь поверхности: экран ревью или // страница загрузки теряют цель для всех своих действий и мертвы до перезагрузки // (docs/conventions/web-ui.md, «Единый источник разметки»). func TestFragErrKeepsSwapRoot(t *testing.T) { cases := []struct{ path, root string }{ {"/fragments/downloads/" + testULID + "/card", `id="card-` + testULID + `"`}, {"/fragments/downloads/" + testULID + "/progress", `id="dl-live-` + testULID + `"`}, {"/fragments/downloads/" + testULID + "/seeding", `id="seeding-` + testULID + `"`}, {"/fragments/downloads/" + testULID + "/review", `id="review-main"`}, } h := testRouterLive(t, stubReader{getErr: errors.New("db is gone")}, stubReviewer{}, stubLive{}) for _, c := range cases { rr := get(t, h, c.path) if rr.Code != http.StatusOK { t.Errorf("%s: status = %d, want 200", c.path, rr.Code) continue } if body := rr.Body.String(); !strings.Contains(body, c.root) { t.Errorf("%s: фрагмент отказа без корня %s:\n%s", c.path, c.root, body) } } } // TestPageTickFailureSelfTerminates: тик страницы идёт тем же маршрутом, что и // навигация, поэтому отказ на htmx-пути обязан отвечать 200 и фрагментом с // корнем #download-main без hx-*; навигационный GET по-прежнему получает статус. func TestPageTickFailureSelfTerminates(t *testing.T) { h := testRouterLive(t, stubReader{}, stubReviewer{}, stubLive{}) rr := getHTMX(t, h, "/download/"+testULID) if rr.Code != http.StatusOK { t.Fatalf("тик страницы: status = %d, want 200", rr.Code) } body := rr.Body.String() if !strings.Contains(body, `id="download-main"`) { t.Errorf("фрагмент отказа страницы без корня #download-main:\n%s", body) } if strings.Contains(body, "hx-trigger") { t.Errorf("фрагмент отказа страницы не самозавершается:\n%s", body) } if rr := get(t, h, "/download/"+testULID); rr.Code != http.StatusNotFound { t.Errorf("навигационный GET: status = %d, want 404", rr.Code) } } // TestFragSeeding: сидирующая задача → секция «Раздача» со статистикой и // поллингом. func TestFragSeeding(t *testing.T) { dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih9", Kind: store.HashV1}}, State: store.StateDone} lv := stubLive{m: map[string]worker.Live{"ih9": { Seeding: true, Progress: 1, Ratio: 2.41, Seeds: 38, Peers: 14, Uploaded: 1 << 30, UpSpeed: 1153433, }}} h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, lv) rr := get(t, h, "/fragments/downloads/"+testULID+"/seeding") if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } body := rr.Body.String() for _, want := range []string{"Раздача", "2.41", "38 / 14"} { if !strings.Contains(body, want) { t.Errorf("фрагмент раздачи не содержит %q\n%s", want, body) } } // Секция лежит внутри свопаемой области страницы — своего опроса не ведёт. if strings.Contains(body, "hx-trigger") { t.Errorf("секция раздачи всё ещё опрашивает сервер сама:\n%s", body) } } // TestFragSeedingDegrades: нет живых данных → секция отсутствует, поллинга нет. func TestFragSeedingDegrades(t *testing.T) { dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih9", Kind: store.HashV1}}, State: store.StateDone} h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{}) rr := get(t, h, "/fragments/downloads/"+testULID+"/seeding") if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } body := rr.Body.String() if strings.Contains(body, "Раздача") || strings.Contains(body, "hx-trigger") { t.Errorf("секция раздачи не деградировала:\n%s", body) } } // TestIndexCardShowsLiveProgress: активная карточка в списке несёт прогресс уже // в первом кадре (значения снимка), а опрашивает себя сама карточка — один // поллер на поверхность, во вложенном блоке прогресса его нет. func TestIndexCardShowsLiveProgress(t *testing.T) { dl := store.Download{ID: testULID, SourceRef: "The.Bear.S03", Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih3", Kind: store.HashV1}}, State: store.StateDownloading} lv := stubLive{m: map[string]worker.Live{"ih3": {Progress: 0.46, DlSpeed: 6400000, ETA: 720}}} h := testRouterLive(t, stubReader{list: []store.Download{dl}}, stubReviewer{}, lv) rr := get(t, h, "/") if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } body := rr.Body.String() for _, want := range []string{`class="progress"`, "width:46%", "/fragments/downloads/" + testULID + "/card"} { if !strings.Contains(body, want) { t.Errorf("карточка без живого прогресса: нет %q", want) } } if strings.Contains(body, "/fragments/downloads/"+testULID+"/progress") { t.Errorf("вложенный блок прогресса опрашивает себя сам:\n%s", body) } if n := strings.Count(body, `hx-trigger="every`); n != 1 { t.Errorf("объявлений самообновления на карточке = %d, want 1\n%s", n, body) } } // TestFragTickOnMissingDownload: тик по исчезнувшей задаче отвечает 200 и // фрагментом без hx-* — htmx не свопит 4xx/5xx, поэтому отказ статусом оставил // бы карточку прежней навсегда, а опрос — бесконечным. func TestFragTickOnMissingDownload(t *testing.T) { h := testRouterLive(t, stubReader{}, stubReviewer{}, stubLive{}) rr := get(t, h, "/fragments/downloads/01arz3ndektsv4rrffq69g5fff/card") if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } body := rr.Body.String() if !strings.Contains(body, "не найдена") { t.Errorf("фрагмент не объясняет отказ тика:\n%s", body) } if strings.Contains(body, "hx-trigger") || strings.Contains(body, "hx-get") { t.Errorf("фрагмент отказа не самозавершается:\n%s", body) } } // TestFragInvalidID: невалидный id → 404 без похода в БД (это не тик живой // поверхности, а запрос по несуществующему адресу). func TestFragInvalidID(t *testing.T) { h := testRouterLive(t, stubReader{}, stubReviewer{}, stubLive{}) if rr := get(t, h, "/fragments/downloads/404/progress"); rr.Code != http.StatusNotFound { t.Fatalf("status(invalid id) = %d, want 404", rr.Code) } } // TestFragTickOnStoreFailure: отказ хранилища на тике — тоже 200 и // самозавершающийся фрагмент, но с другим текстом: «не найдена» здесь соврало бы. func TestFragTickOnStoreFailure(t *testing.T) { h := testRouterLive(t, stubReader{getErr: errors.New("db is gone")}, stubReviewer{}, stubLive{}) rr := get(t, h, "/fragments/downloads/"+testULID+"/card") if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } body := rr.Body.String() if !strings.Contains(body, "не удалось обновить") { t.Errorf("отказ хранилища выдан за пропажу задачи:\n%s", body) } if strings.Contains(body, "hx-trigger") { t.Errorf("фрагмент отказа не самозавершается:\n%s", body) } } // TestFragCardSurvivesSizeFailure: отказ чтения размеров не роняет тик — // карточка деградирует на прочерк, а не на пустой ответ. func TestFragCardSurvivesSizeFailure(t *testing.T) { dl := store.Download{ID: testULID, SourceRef: "Rel", State: store.StateReview} rd := stubReader{one: &dl, sizesErr: errors.New("db is busy")} h := testRouterLive(t, rd, stubReviewer{}, stubLive{}) rr := get(t, h, "/fragments/downloads/"+testULID+"/card") if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } if body := rr.Body.String(); !strings.Contains(body, "Ревью →") { t.Errorf("тик не пережил отказ чтения размеров:\n%s", body) } } // TestCardSelfPollFollowsObservability: карточка опрашивает себя, пока задача // наблюдаема, и замолкает, когда двигать её может только человек. failed, // target_missing и orphaned наблюдаются: их возвращает в поток фоновая сверка. func TestCardSelfPollFollowsObservability(t *testing.T) { polling := []store.State{ store.StateCatched, store.StateDownloading, store.StateCompleted, store.StateRecognizing, store.StateReview, store.StateLinking, store.StateDeferred, store.StateStuck, store.StateFailed, store.StateTargetMissing, store.StateOrphaned, } silent := []store.State{ store.StateDone, store.StateCancelled, store.StateReverted, store.StateDeleted, } for _, st := range polling { dl := store.Download{ID: testULID, SourceRef: "Rel", State: st} h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{}) body := get(t, h, "/fragments/downloads/"+testULID+"/card").Body.String() if !strings.Contains(body, "/fragments/downloads/"+testULID+"/card") { t.Errorf("%s: наблюдаемая карточка не опрашивает себя:\n%s", st, body) } } for _, st := range silent { dl := store.Download{ID: testULID, SourceRef: "Rel", State: st} h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{}) body := get(t, h, "/fragments/downloads/"+testULID+"/card").Body.String() if strings.Contains(body, "hx-trigger") { t.Errorf("%s: ненаблюдаемая карточка продолжает опрос:\n%s", st, body) } } } // TestCardPollInterval: быстрый интервал — только там, где бегут цифры качания. func TestCardPollInterval(t *testing.T) { cases := []struct { state store.State want string }{ {store.StateDownloading, `hx-trigger="every ` + pollFast + `"`}, {store.StateReview, `hx-trigger="every ` + pollSlow + `"`}, {store.StateCatched, `hx-trigger="every ` + pollSlow + `"`}, } for _, c := range cases { dl := store.Download{ID: testULID, SourceRef: "Rel", State: c.state} h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{}) body := get(t, h, "/fragments/downloads/"+testULID+"/card").Body.String() if !strings.Contains(body, c.want) { t.Errorf("%s: нет %q\n%s", c.state, c.want, body) } } } // TestFragCardBringsNewStateAndActions: первый ответ фрагмента после смены // состояния приносит новый бейдж и новый набор действий — ради этого change и // затевался. func TestFragCardBringsNewStateAndActions(t *testing.T) { cases := []struct { state store.State want string }{ {store.StateReview, "Ревью →"}, {store.StateDone, "Откатить"}, } for _, c := range cases { dl := store.Download{ID: testULID, SourceRef: "Rel", State: c.state} h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{}) body := get(t, h, "/fragments/downloads/"+testULID+"/card").Body.String() if !strings.Contains(body, c.want) { t.Errorf("%s: фрагмент не принёс действие %q\n%s", c.state, c.want, body) } } } // TestDownloadPageSelfPoll: страница живёт по тому же правилу наблюдаемости, // интервал у неё всегда медленный (блока живых цифр качания на ней нет), а // секция «Раздача» своего опроса не ведёт — один поллер на поверхность. func TestDownloadPageSelfPoll(t *testing.T) { seedLive := stubLive{m: map[string]worker.Live{"ihp": {Seeding: true, Progress: 1, Ratio: 2.4, Seeds: 3, Peers: 1}}} hashes := []store.Infohash{{DownloadID: testULID, Infohash: "ihp", Kind: store.HashV1}} // Наблюдаемая задача с сидирующей раздачей: ровно одно объявление опроса. dl := store.Download{ID: testULID, SourceRef: "Rel", State: store.StateReview, Infohashes: hashes} h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{data: &worker.ReviewData{Download: dl}}, seedLive) body := get(t, h, "/download/"+testULID).Body.String() if !strings.Contains(body, `hx-trigger="every `+pollSlow+`"`) { t.Errorf("страница наблюдаемой задачи без медленного самообновления:\n%s", body) } if n := strings.Count(body, `hx-trigger="every`); n != 1 { t.Errorf("объявлений самообновления на странице = %d, want 1", n) } // Ненаблюдаемая задача: страница замолкает. done := store.Download{ID: testULID, SourceRef: "Rel", State: store.StateDone, Infohashes: hashes} h = testRouterLive(t, stubReader{one: &done}, stubReviewer{data: &worker.ReviewData{Download: done}}, seedLive) if body := get(t, h, "/download/"+testULID).Body.String(); strings.Contains(body, `hx-trigger="every`) { t.Errorf("страница ненаблюдаемой задачи продолжает опрос:\n%s", body) } } // TestFragCardKeepsLayoutSize: самообновление не теряет полей полного рендера — // размер разложенных файлов при отсутствии раздачи в снимке. func TestFragCardKeepsLayoutSize(t *testing.T) { dl := store.Download{ID: testULID, SourceRef: "Rel", State: store.StateOrphaned} rd := stubReader{one: &dl, sizes: map[string]int64{testULID: 3 << 30}} h := testRouterLive(t, rd, stubReviewer{}, stubLive{}) body := get(t, h, "/fragments/downloads/"+testULID+"/card").Body.String() if !strings.Contains(body, "3.0 ГиБ") { t.Errorf("фрагмент карточки потерял размер раскладки:\n%s", body) } }