package repository import ( "errors" "testing" "time" "building-mgmt/internal/models" ) // --- чистые функции (база не нужна) --- func TestPageNormalize(t *testing.T) { cases := []struct { name string in Page want Page }{ {"дефолт", Page{}, Page{Limit: DefaultLimit}}, {"нормальный лимит", Page{Limit: 10}, Page{Limit: 10}}, {"лимит за потолком", Page{Limit: 500}, Page{Limit: DefaultLimit}}, {"нулевой лимит", Page{Limit: 0}, Page{Limit: DefaultLimit}}, {"отрицательный сдвиг", Page{Limit: 5, Offset: -3}, Page{Limit: 5, Offset: 0}}, {"порядок сохраняется", Page{Limit: 5, Order: "asc"}, Page{Limit: 5, Order: "asc"}}, {"мусорный порядок сбрасывается", Page{Limit: 5, Order: "up"}, Page{Limit: 5}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := tc.in.normalize() if got != tc.want { t.Errorf("normalize(%+v) = %+v, want %+v", tc.in, got, tc.want) } }) } } func TestOrderBy(t *testing.T) { // Словарь с ключами по умолчанию: created_at — по убыванию, name — по возрастанию. sorts := map[string]SortSpec{ "created_at": {Col: "t.created_at", Desc: true}, "name": {Col: "t.name", Desc: false}, } t.Run("дефолт списка", func(t *testing.T) { got, err := orderBy(Page{}, sorts, "created_at", "t.id DESC") if err != nil { t.Fatalf("err = %v, want nil", err) } if want := " ORDER BY t.created_at DESC, t.id DESC"; got != want { t.Errorf("got %q, want %q", got, want) } }) t.Run("направление из запроса", func(t *testing.T) { got, err := orderBy(Page{Sort: "name", Order: "asc"}, sorts, "created_at", "t.id DESC") if err != nil { t.Fatalf("err = %v, want nil", err) } if want := " ORDER BY t.name ASC, t.id DESC"; got != want { t.Errorf("got %q, want %q", got, want) } }) t.Run("desc переопределяет дефолт", func(t *testing.T) { got, err := orderBy(Page{Sort: "name", Order: "desc"}, sorts, "created_at", "t.id DESC") if err != nil { t.Fatalf("err = %v, want nil", err) } if want := " ORDER BY t.name DESC, t.id DESC"; got != want { t.Errorf("got %q, want %q", got, want) } }) t.Run("неизвестный ключ — ошибка, а не дефолт", func(t *testing.T) { if _, err := orderBy(Page{Sort: "password_hash"}, sorts, "created_at", "t.id"); !errors.Is(err, ErrInvalid) { t.Errorf("err = %v, want ErrInvalid", err) } }) t.Run("мусорное направление — ошибка", func(t *testing.T) { if _, err := orderBy(Page{Sort: "name", Order: "sideways"}, sorts, "created_at", "t.id"); !errors.Is(err, ErrInvalid) { t.Errorf("err = %v, want ErrInvalid", err) } }) } // --- интеграционные (нужна живая БД, без DATABASE_URL скипаются) --- // TestTicketListPagination — страницы, total и фильтры по полям заявки. // Ключевое: total считается до обрезки, а offset за концом не ошибка, // а пустая страница с прежним total. func TestTicketListPagination(t *testing.T) { q, ctx := newTestQuerier(t) tickets := NewTicketRepo(q) client := createProfile(t, ctx, q, "client") projectID, objectID, address := createProjectObject(t, ctx, q, client.ID) // Пять заявок: категории и приоритеты чередуются, чтобы проверить // сортировку и фильтры независимо от порядка вставки. specs := []struct{ category, priority string }{ {"plumbing", "high"}, {"electric", "normal"}, {"plumbing", "low"}, {"climate", "high"}, {"plumbing", "normal"}, } var created int for _, s := range specs { tk := &models.Ticket{ ClientID: client.ID, ProjectID: projectID, ObjectID: objectID, Address: address, Category: s.category, Description: "описание " + s.category, Priority: s.priority, } if err := tickets.Create(ctx, tk); err != nil { t.Fatalf("create ticket: %v", err) } created++ } if created != len(specs) { t.Fatalf("создано %d заявок, want %d", created, len(specs)) } t.Run("total считается до обрезки", func(t *testing.T) { items, total, err := tickets.List(ctx, ListFilter{ClientID: client.ID, Page: Page{Limit: 2}}) if err != nil { t.Fatalf("list: %v", err) } if len(items) != 2 || total != 5 { t.Errorf("len=%d total=%d, want 2 и 5", len(items), total) } }) t.Run("страницы идут подряд без пересечений", func(t *testing.T) { seen := map[int64]bool{} offset := 0 for page := 1; page <= 3; page++ { items, total, err := tickets.List(ctx, ListFilter{ ClientID: client.ID, Page: Page{Limit: 2, Offset: offset}, }) if err != nil { t.Fatalf("page %d: %v", page, err) } if total != 5 { t.Errorf("page %d total = %d, want 5", page, total) } if page < 3 && len(items) != 2 { t.Errorf("page %d len = %d, want 2", page, len(items)) } if page == 3 && len(items) != 1 { t.Errorf("page 3 len = %d, want 1", len(items)) } for _, tk := range items { if seen[tk.ID] { t.Errorf("ticket %d выдан дважды", tk.ID) } seen[tk.ID] = true } offset += 2 } if len(seen) != 5 { t.Errorf("уникальных заявок за 3 страницы = %d, want 5", len(seen)) } }) t.Run("offset за концом — пустая страница, total цел", func(t *testing.T) { items, total, err := tickets.List(ctx, ListFilter{ClientID: client.ID, Page: Page{Limit: 2, Offset: 100}}) if err != nil { t.Fatalf("list: %v", err) } if len(items) != 0 || total != 5 { t.Errorf("len=%d total=%d, want 0 и 5", len(items), total) } }) t.Run("лимит вне границ подрезается, а не падает", func(t *testing.T) { items, _, err := tickets.List(ctx, ListFilter{ClientID: client.ID, Page: Page{Limit: 500}}) if err != nil { t.Fatalf("list: %v", err) } if len(items) != 5 { t.Errorf("len = %d, want 5 (лимит 500 → 100 → все)", len(items)) } }) t.Run("сортировка по приоритету: сначала важные", func(t *testing.T) { items, _, err := tickets.List(ctx, ListFilter{ ClientID: client.ID, Page: Page{Limit: MaxLimit, Sort: "priority", Order: "asc"}, }) if err != nil { t.Fatalf("list: %v", err) } if len(items) != 5 { t.Fatalf("len = %d, want 5", len(items)) } if items[0].Priority != "high" || items[len(items)-1].Priority != "low" { t.Errorf("порядок приоритетов: %v", priorities(items)) } }) t.Run("неизвестное поле сортировки — ErrInvalid", func(t *testing.T) { if _, _, err := tickets.List(ctx, ListFilter{Page: Page{Sort: "description"}}); !errors.Is(err, ErrInvalid) { t.Errorf("err = %v, want ErrInvalid", err) } }) t.Run("фильтр по категории", func(t *testing.T) { items, total, err := tickets.List(ctx, ListFilter{ ClientID: client.ID, Category: "plumbing", Page: Page{Limit: MaxLimit}, }) if err != nil { t.Fatalf("list: %v", err) } if total != 3 || len(items) != 3 { t.Errorf("total=%d len=%d, want 3 и 3", total, len(items)) } for _, tk := range items { if tk.Category != "plumbing" { t.Errorf("фильтр category пропустил %q", tk.Category) } } }) t.Run("фильтр по приоритету", func(t *testing.T) { _, total, err := tickets.List(ctx, ListFilter{ ClientID: client.ID, Priority: "high", Page: Page{Limit: MaxLimit}, }) if err != nil { t.Fatalf("list: %v", err) } if total != 2 { t.Errorf("total = %d, want 2", total) } }) t.Run("фильтры комбинируются", func(t *testing.T) { _, total, err := tickets.List(ctx, ListFilter{ ClientID: client.ID, Category: "plumbing", Priority: "high", Page: Page{Limit: MaxLimit}, }) if err != nil { t.Fatalf("list: %v", err) } if total != 1 { t.Errorf("total = %d, want 1 (только plumbing+high)", total) } }) t.Run("диапазон дат", func(t *testing.T) { today := time.Now().AddDate(0, 0, -1) tomorrow := today.AddDate(0, 0, 2) _, total, err := tickets.List(ctx, ListFilter{ ClientID: client.ID, DateFrom: &today, DateTo: &tomorrow, Page: Page{Limit: MaxLimit}, }) if err != nil { t.Fatalf("list: %v", err) } if total != 5 { t.Errorf("диапазон, покрывающий сегодняшний день: total = %d, want 5", total) } onlyFuture := tomorrow.AddDate(0, 0, 10) items, total, err := tickets.List(ctx, ListFilter{ ClientID: client.ID, DateFrom: &onlyFuture, Page: Page{Limit: MaxLimit}, }) if err != nil { t.Fatalf("list: %v", err) } if total != 0 || len(items) != 0 { t.Errorf("будущий диапазон: total=%d len=%d, want 0 и 0", total, len(items)) } }) t.Run("пустая выборка возвращает total=0", func(t *testing.T) { items, total, err := tickets.List(ctx, ListFilter{ClientID: "00000000-0000-0000-0000-000000000000"}) if err != nil { t.Fatalf("list: %v", err) } if len(items) != 0 || total != 0 { t.Errorf("len=%d total=%d, want 0 и 0", len(items), total) } }) } // priorities — приоритеты страницы по порядку, для сообщений об ошибках. func priorities(items []models.Ticket) []string { out := make([]string, 0, len(items)) for _, tk := range items { out = append(out, tk.Priority) } return out }