- список клиентов: поиск по домену и названию, фильтр по статусу (чипы со счётчиками), сортировка по любой колонке, пагинация 10/25/50/100; состояние в URL — отфильтрованный вид можно сохранить ссылкой - размеры последних бэкапов одним запросом вместо запроса на клиента - шапка выровнена по колонке контента; настройки на всю ширину: ntfy и Telegram двумя карточками, на узком экране — друг под другом Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
88 lines
3.1 KiB
Go
88 lines
3.1 KiB
Go
package server
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func testClients() ([]*Client, map[int64]int64) {
|
|
now := time.Now().Unix()
|
|
var cs []*Client
|
|
sizes := map[int64]int64{}
|
|
for i := 1; i <= 30; i++ {
|
|
c := &Client{ID: int64(i), Domain: fmt.Sprintf("srv%02d.example.com", i), Name: fmt.Sprintf("srv%02d", i),
|
|
StaleHours: 26, LastBackupAt: now - int64(i)*60, CreatedAt: now}
|
|
sizes[c.ID] = int64(i) * 1000
|
|
cs = append(cs, c)
|
|
}
|
|
cs[0].Name = "Нидерланды" // srv01
|
|
cs[1].LastBackupAt = 0 // srv02 — never
|
|
cs[2].LastErrorAt = now // srv03 — fail
|
|
cs[3].Disabled = true // srv04 — off
|
|
cs[4].LastBackupAt = now - 100*3600 // srv05 — stale
|
|
return cs, sizes
|
|
}
|
|
|
|
func view(t *testing.T, rawQuery string) *clientsView {
|
|
t.Helper()
|
|
cs, sizes := testClients()
|
|
r := httptest.NewRequest("GET", "/?"+rawQuery, nil)
|
|
return buildClientsView(parseClientsQuery(r), cs, sizes)
|
|
}
|
|
|
|
func TestClientsSearchFilter(t *testing.T) {
|
|
v := view(t, "q=нидер")
|
|
if v.Found != 1 || v.Rows[0].Domain != "srv01.example.com" {
|
|
t.Fatalf("поиск по названию: %d", v.Found)
|
|
}
|
|
if v := view(t, "q=SRV1"); v.Found != 10 { // srv10..srv19
|
|
t.Fatalf("поиск без учёта регистра: %d", v.Found)
|
|
}
|
|
v = view(t, "status=fail")
|
|
if v.Found != 1 || v.Rows[0].Domain != "srv03.example.com" {
|
|
t.Fatalf("фильтр fail: %+v", v.Found)
|
|
}
|
|
chips := map[string]int{}
|
|
for _, c := range v.Chips {
|
|
chips[c.Key] = c.Count
|
|
}
|
|
if chips[""] != 30 || chips["never"] != 1 || chips["off"] != 1 || chips["stale"] != 1 || chips["ok"] != 26 {
|
|
t.Fatalf("счётчики: %v", chips)
|
|
}
|
|
}
|
|
|
|
func TestClientsSortAndPaging(t *testing.T) {
|
|
v := view(t, "")
|
|
if v.Found != 30 || len(v.Rows) != 25 || v.Rows[0].Domain != "srv01.example.com" || v.Next == "" {
|
|
t.Fatalf("первая страница: found=%d rows=%d", v.Found, len(v.Rows))
|
|
}
|
|
v = view(t, "page=2")
|
|
if len(v.Rows) != 5 || v.From != 26 || v.To != 30 || v.Prev == "" || v.Next != "" {
|
|
t.Fatalf("вторая страница: %+v", v)
|
|
}
|
|
if v := view(t, "page=99"); v.Query.Page != 2 {
|
|
t.Fatalf("страница за пределами: %d", v.Query.Page)
|
|
}
|
|
v = view(t, "sort=size&per=10")
|
|
if v.Rows[0].Domain != "srv30.example.com" || len(v.Rows) != 10 {
|
|
t.Fatalf("размер по убыванию по умолчанию: %s", v.Rows[0].Domain)
|
|
}
|
|
if v := view(t, "sort=size&dir=asc"); v.Rows[0].Domain != "srv01.example.com" {
|
|
t.Fatalf("размер по возрастанию: %s", v.Rows[0].Domain)
|
|
}
|
|
if v := view(t, "sort=status"); v.Rows[0].StatusClass != "fail" {
|
|
t.Fatalf("сортировка по статусу: %s", v.Rows[0].StatusClass)
|
|
}
|
|
// повторный клик по текущей колонке меняет направление
|
|
if u := view(t, "sort=size").Query.SortURL("size"); !strings.Contains(u, "dir=asc") {
|
|
t.Fatalf("SortURL: %s", u)
|
|
}
|
|
// фильтр сохраняется в ссылках пагинации
|
|
if u := view(t, "q=srv&status=ok").Query.PageURL(2); !strings.Contains(u, "q=srv") || !strings.Contains(u, "status=ok") {
|
|
t.Fatalf("PageURL: %s", u)
|
|
}
|
|
}
|