axe/internal/repository/page_test.go

306 lines
9.7 KiB
Go
Raw Normal View History

2026-10-18 19:07:25 +00:00
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
}