package store import ( "context" "fmt" "testing" "time" ) // hashN — детерминированный валидный 40-hex infohash по номеру. func hashN(n int) string { return fmt.Sprintf("%040x", n) } // mkDownload заводит загрузку в заданном состоянии с display_name. func mkDownload(t *testing.T, st *Store, n int, state State, display string) int64 { t.Helper() ctx := context.Background() d := newDownloading(hashN(n)) d.DisplayName = display id, err := st.CreateDownload(ctx, d) if err != nil { t.Fatalf("create #%d: %v", n, err) } if state != StateDownloading { if err := st.SetDownloadState(ctx, id, state, "", ""); err != nil { t.Fatalf("set state #%d: %v", n, err) } } return id } func ids(ds []Download) []int64 { out := make([]int64, len(ds)) for i, d := range ds { out[i] = d.ID } return out } func TestListDownloadsPageFilterAndDeleted(t *testing.T) { st := newTestStore(t) ctx := context.Background() review := mkDownload(t, st, 1, StateReview, "Дюна") active := mkDownload(t, st, 2, StateDownloading, "Fargo") done := mkDownload(t, st, 3, StateDone, "Оно") deleted := mkDownload(t, st, 4, StateDeleted, "Старьё") // Группа review — только review/deferred. page, total, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupReview, Limit: 50}) if err != nil { t.Fatal(err) } if total != 1 || len(page) != 1 || page[0].ID != review { t.Fatalf("review group = %v (total %d), want [%d]", ids(page), total, review) } // all: deleted скрыт по умолчанию. page, total, err = st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50}) if err != nil { t.Fatal(err) } if total != 3 { t.Fatalf("all (без deleted) total = %d, want 3; ids=%v", total, ids(page)) } for _, d := range page { if d.ID == deleted { t.Fatalf("deleted не должен быть виден по умолчанию") } } _ = active _ = done // all + IncludeDeleted: показываем всё. _, total, err = st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, IncludeDeleted: true, Limit: 50}) if err != nil { t.Fatal(err) } if total != 4 { t.Fatalf("all+deleted total = %d, want 4", total) } } func TestListDownloadsPageSearch(t *testing.T) { st := newTestStore(t) ctx := context.Background() mkDownload(t, st, 1, StateReview, "Дюна: Часть вторая") mkDownload(t, st, 2, StateReview, "Fargo") // Поиск по display_name (кириллица — как введено, с учётом регистра). page, total, err := st.ListDownloadsPage(ctx, ListFilter{Query: "Дюна", Limit: 50}) if err != nil { t.Fatal(err) } if total != 1 || len(page) != 1 || page[0].DisplayName != "Дюна: Часть вторая" { t.Fatalf("search 'Дюна' = %v (total %d)", ids(page), total) } // Латиница ищется регистронезависимо (ASCII case-folding LIKE). page, _, err = st.ListDownloadsPage(ctx, ListFilter{Query: "fargo", Limit: 50}) if err != nil { t.Fatal(err) } if len(page) != 1 || page[0].DisplayName != "Fargo" { t.Fatalf("search 'fargo' = %v", ids(page)) } // Поиск по infohash. page, _, err = st.ListDownloadsPage(ctx, ListFilter{Query: hashN(2), Limit: 50}) if err != nil { t.Fatal(err) } if len(page) != 1 || page[0].Infohash.String != hashN(2) { t.Fatalf("search by infohash = %v", ids(page)) } } func TestListDownloadsPageOrderAndPagination(t *testing.T) { st := newTestStore(t) ctx := context.Background() // Три задачи; у #1 и #2 задано source_added_at (разное), у #3 — нет (фолбек // на created_at). Проверяем порядок DESC по времени добавления и tie-break. id1 := mkDownload(t, st, 1, StateDownloading, "one") id2 := mkDownload(t, st, 2, StateDownloading, "two") id3 := mkDownload(t, st, 3, StateDownloading, "three") base := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) // id1 добавлен раньше, id2 позже → id2 должен идти выше id1. if err := st.SetSourceAddedAt(ctx, id1, base); err != nil { t.Fatal(err) } if err := st.SetSourceAddedAt(ctx, id2, base.Add(time.Hour)); err != nil { t.Fatal(err) } page, total, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50}) if err != nil { t.Fatal(err) } if total != 3 { t.Fatalf("total = %d, want 3", total) } // id3 фолбечит на created_at (~сейчас, 2026-07-01) — свежее, чем добавления // id1/id2 в июне → id3 первым; затем id2 (позже добавлен), затем id1. got := ids(page) want := []int64{id3, id2, id1} for i := range want { if got[i] != want[i] { t.Fatalf("порядок = %v, want %v", got, want) } } // Пагинация: по 2 на страницу. p1, total, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 2, Offset: 0}) if err != nil { t.Fatal(err) } if total != 3 || len(p1) != 2 { t.Fatalf("page1 len=%d total=%d, want 2/3", len(p1), total) } p2, _, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 2, Offset: 2}) if err != nil { t.Fatal(err) } if len(p2) != 1 { t.Fatalf("page2 len=%d, want 1", len(p2)) } // Страницы не пересекаются и покрывают весь список. if p1[0].ID != id3 || p1[1].ID != id2 || p2[0].ID != id1 { t.Fatalf("страницы разъехались: p1=%v p2=%v", ids(p1), ids(p2)) } } func TestListDownloadsPageTieBreakByID(t *testing.T) { st := newTestStore(t) ctx := context.Background() // Одинаковое время добавления у всех → устойчивый порядок по id DESC. same := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) var idList []int64 for i := 1; i <= 3; i++ { id := mkDownload(t, st, i, StateDownloading, fmt.Sprintf("d%d", i)) if err := st.SetSourceAddedAt(ctx, id, same); err != nil { t.Fatal(err) } idList = append(idList, id) } page, _, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50}) if err != nil { t.Fatal(err) } got := ids(page) want := []int64{idList[2], idList[1], idList[0]} // id DESC for i := range want { if got[i] != want[i] { t.Fatalf("tie-break порядок = %v, want %v", got, want) } } } func TestSetSourceAddedAtOnce(t *testing.T) { st := newTestStore(t) ctx := context.Background() id := mkDownload(t, st, 1, StateDownloading, "x") first := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC) second := time.Date(2026, 6, 2, 10, 0, 0, 0, time.UTC) if err := st.SetSourceAddedAt(ctx, id, first); err != nil { t.Fatal(err) } // Повторная запись не должна перезаписать (гард IS NULL). if err := st.SetSourceAddedAt(ctx, id, second); err != nil { t.Fatal(err) } d, err := st.GetDownload(ctx, id) if err != nil { t.Fatal(err) } if !d.SourceAddedAt.Valid || d.SourceAddedAt.String != FormatTime(first) { t.Fatalf("source_added_at = %q, want %q (первое значение неизменно)", d.SourceAddedAt.String, FormatTime(first)) } } func TestListDownloadsPageRecTitleFallback(t *testing.T) { st := newTestStore(t) ctx := context.Background() // Загрузка без display_name, но с распознанным названием — RecTitle из join. id := mkDownload(t, st, 1, StateReview, "") if _, err := st.CreateRecognition(ctx, &Recognition{ DownloadID: id, Title: NullString("Настоящее название"), }, nil); err != nil { t.Fatal(err) } page, _, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50}) if err != nil { t.Fatal(err) } if len(page) != 1 { t.Fatalf("len = %d", len(page)) } if !page[0].RecTitle.Valid || page[0].RecTitle.String != "Настоящее название" { t.Fatalf("RecTitle = %q, want распознанное название", page[0].RecTitle.String) } } func TestCreateDownloadPersistsDisplayName(t *testing.T) { st := newTestStore(t) ctx := context.Background() id := mkDownload(t, st, 1, StateDownloading, "Дюна: Часть вторая (2024)") d, err := st.GetDownload(ctx, id) if err != nil { t.Fatal(err) } if d.DisplayName != "Дюна: Часть вторая (2024)" { t.Fatalf("display_name = %q", d.DisplayName) } }