Files
ratatoskr-go/internal/update/update_test.go
ki.sagidullin cd0619926e
Some checks failed
CI / test (push) Failing after 1m15s
CI / build-and-package (amd64, linux) (push) Failing after 58s
CI / build-and-package (amd64, windows) (push) Successful in 30s
perf(chat,update): пул воркеров per-user вместо сериальной очереди + HEAD-проба обновлений
- chat.Router: ограниченный пул chatWorkers=4 воркеров + FIFO-очереди
  per-user (userState/workerLoop/runUser). Порядок сообщений одного UserID
  сохраняется; разные пользователи обрабатываются параллельно (до 4
  одновременных LLM-вызовов), long-poll Telegram не блокируется чужим
  аналитиком. Backpressure по jobs — только на перегруженного пользователя.
- app.FreeChat: sessions под sync.Mutex (защита от data race при параллельных
  воркерах роутера).
- update: ResolveLatest проверяет наличие бинаря HEAD-пробой без скачивания
  тела (fallback GET Range 0-0 при 405/501), сортировка версий по id убыв.;
  один общий http.Client (keep-alive) вместо нового на каждый запрос.
- тесты: порядок/параллелизм per-user в router, HEAD-без-тела и фоллбэк на
  версию без бинаря в update.
- память Serena: инварианты Router/update, примечания по форматированию на Windows.
2026-08-22 11:44:34 +05:00

355 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package update
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
)
// mockGitea поднимает httptest-сервер: листинг версий `/api/v1/packages/...`
// и файлы конкретных версий `/api/packages/.../{version}/{filename}`.
// version — имя новейшей версии (id бóльший), У которой есть бинарь+метаданные;
// старые версии бинаря не содержат и отсеются ResolveLatest.
func mockGitea(t *testing.T, bin []byte, version, checksum string) *httptest.Server {
t.Helper()
name := PlatformFilename()
// старый commit, чтобы проверить, что Resolve берёт именно новейший.
old := "commit-old" + version[len("commit-"):]
fileSets := map[string]map[string][]byte{
version: {
name: bin,
name + ".version": []byte(version),
name + ".sha256": []byte(checksum),
},
}
versions := []map[string]any{
{"id": 1, "version": old},
{"id": 2, "version": version},
}
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/packages/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/generic/ratatoskr") {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(versions)
})
mux.HandleFunc("/api/packages/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
// путь: /api/packages/{owner}/generic/{pkg}/{version}/{filename}
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) < 6 {
http.NotFound(w, r)
return
}
ver := parts[len(parts)-2]
fileName := parts[len(parts)-1]
body, ok := fileSets[ver][fileName]
if !ok {
http.NotFound(w, r)
return
}
if r.Method == http.MethodGet {
_, _ = w.Write(body)
}
// HEAD — просто 200, тело не пишем
})
return httptest.NewServer(mux)
}
// testUpdater строит Updater с базой на мок-сервере и каталогом tmp.
func testUpdater(t *testing.T, bin []byte, version, cur string) (*Updater, string) {
t.Helper()
sum := sha256hex(bin)
srv := mockGitea(t, bin, version, sum)
t.Cleanup(srv.Close)
dir := t.TempDir()
u := &Updater{
BaseURL: srv.URL,
Owner: "kamelion",
Package: "ratatoskr",
CurrentVersion: cur,
Dir: dir,
}
return u, dir
}
func TestFilename(t *testing.T) {
cases := []struct{ goos, goarch, want string }{
{"linux", "amd64", "ratatoskr-linux-amd64"},
{"linux", "arm64", "ratatoskr-linux-arm64"},
{"darwin", "arm64", "ratatoskr-darwin-arm64"},
{"windows", "amd64", "ratatoskr-windows-amd64.exe"},
}
for _, c := range cases {
if got := Filename(c.goos, c.goarch); got != c.want {
t.Errorf("Filename(%s,%s)=%q want %q", c.goos, c.goarch, got, c.want)
}
}
}
func TestCheck_UpdateAvailable(t *testing.T) {
u, _ := testUpdater(t, []byte("bin"), "commit-abc1234", "commit-old0000")
res := u.Check(context.Background())
if res.Err != nil {
t.Fatalf("Check err = %v", res.Err)
}
if !res.UpdateAvailable {
t.Fatal("expected update available")
}
if res.Version != "commit-abc1234" {
t.Errorf("Version = %q want commit-abc1234", res.Version)
}
}
func TestCheck_AlreadyCurrent(t *testing.T) {
u, _ := testUpdater(t, []byte("bin"), "commit-abc1234", "commit-abc1234")
res := u.Check(context.Background())
if res.Err != nil {
t.Fatalf("Check err = %v", res.Err)
}
if res.UpdateAvailable {
t.Fatal("not expected update (same version)")
}
}
func TestCheck_ServerDown(t *testing.T) {
dir := t.TempDir()
u := &Updater{BaseURL: "http://127.0.0.1:1", Owner: "k", Package: "p", CurrentVersion: "v1", Dir: dir}
res := u.Check(context.Background())
if res.Err == nil {
t.Fatal("expected error for unreachable server")
}
// ошибка должна классифицироваться как U1 ErrCheckFailed
var uerr *Error
if !errorsAs(res.Err, &uerr) || uerr.Code != U1 {
t.Errorf("expected U1 error, got %v", res.Err)
}
}
// TestResolveLatest_HeadNotFullDownload проверяет, что ResolveLatest проверяет
// наличие бинаря HEAD'ом и НЕ скачивает полное тело бинаря (прошлая версия
// читала каждый файл целиком — O(N)×размер бинаря).
func TestResolveLatest_HeadNotFullDownload(t *testing.T) {
name := PlatformFilename()
_ = name
var headReqs, bodyReqs int64
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/packages/", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode([]map[string]any{
{"id": 1, "version": "commit-aaa1111"},
{"id": 2, "version": "commit-abc1234"},
})
})
mux.HandleFunc("/api/packages/", func(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) < 6 {
http.NotFound(w, r)
return
}
ver := parts[len(parts)-2]
fileName := parts[len(parts)-1]
if ver != "commit-abc1234" || fileName != name {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodHead:
atomic.AddInt64(&headReqs, 1)
case http.MethodGet:
atomic.AddInt64(&bodyReqs, 1)
default:
http.Error(w, "method", http.StatusMethodNotAllowed)
}
})
srv := httptest.NewServer(mux)
defer srv.Close()
u := &Updater{BaseURL: srv.URL, Owner: "k", Package: "p", CurrentVersion: "v1", Dir: t.TempDir()}
ver, err := u.ResolveLatest(context.Background())
if err != nil {
t.Fatalf("ResolveLatest err = %v", err)
}
if ver != "commit-abc1234" {
t.Errorf("ResolveLatest = %q, want commit-abc1234", ver)
}
if atomic.LoadInt64(&headReqs) == 0 {
t.Error("ResolveLatest не делал HEAD-проб на файлы")
}
if atomic.LoadInt64(&bodyReqs) != 0 {
t.Errorf("ResolveLatest скачал тело бинарника: %d полных GET", atomic.LoadInt64(&bodyReqs))
}
}
// TestResolveLatest_SkipsBinarylessVersion проверяет фоллбэк: новейшая версия
// без бинаря (разные матрицы платформ публикуются не все сразу) пропускается,
// берётся следующая, где файл есть.
func TestResolveLatest_SkipsBinarylessVersion(t *testing.T) {
name := PlatformFilename()
bin := []byte("binary")
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/packages/", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode([]map[string]any{
{"id": 1, "version": "commit-aaa1111"},
{"id": 2, "version": "commit-mid2222"},
{"id": 3, "version": "commit-new3333"},
})
})
mux.HandleFunc("/api/packages/", func(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) < 6 {
http.NotFound(w, r)
return
}
ver := parts[len(parts)-2]
fileName := parts[len(parts)-1]
// бинарь есть только у commit-mid2222 — новейшие 1 и 3 пропускаются
if ver != "commit-mid2222" || fileName != name {
http.NotFound(w, r)
return
}
if r.Method != http.MethodHead {
_, _ = w.Write(bin)
}
})
srv := httptest.NewServer(mux)
defer srv.Close()
u := &Updater{BaseURL: srv.URL, Owner: "k", Package: "p", CurrentVersion: "v0", Dir: t.TempDir()}
ver, err := u.ResolveLatest(context.Background())
if err != nil {
t.Fatalf("ResolveLatest err = %v", err)
}
if ver != "commit-mid2222" {
t.Errorf("ResolveLatest = %q, want commit-mid2222 (фоллбэк от версии без бинаря)", ver)
}
}
func TestDownload_And_Verify_Good(t *testing.T) {
bin := []byte("ratatoskr-binary-content-v2")
u, dir := testUpdater(t, bin, "commit-new12345", "")
file, err := u.Download(context.Background(), "commit-new12345")
if err != nil {
t.Fatalf("Download err = %v", err)
}
if !strings.Contains(file, ".new") {
t.Errorf("temp file %q should end with .new", filepath.Base(file))
}
if err := u.Verify(context.Background(), "commit-new12345", file); err != nil {
t.Fatalf("Verify err = %v", err)
}
// .new лежит в каталоге
entries, _ := os.ReadDir(dir)
if len(entries) != 1 || !strings.HasSuffix(entries[0].Name(), ".new") {
t.Errorf("expected 1 .new file in dir, got %v", entries)
}
}
func TestVerify_ChecksumMismatch(t *testing.T) {
bin := []byte("A")
srv := mockGitea(t, bin, "commit-x", sha256hex([]byte("differente-content")))
t.Cleanup(srv.Close)
u := &Updater{BaseURL: srv.URL, Owner: "k", Package: "p", Dir: t.TempDir()}
file := filepath.Join(u.Dir, ".ratatoskr.commit-x.new")
if err := os.WriteFile(file, []byte("A"), 0o755); err != nil {
t.Fatal(err)
}
err := u.Verify(context.Background(), "commit-x", file)
if err == nil {
t.Fatal("expected checksum mismatch error")
}
var uerr *Error
if !errorsAs(err, &uerr) || uerr.Code != U4 {
t.Errorf("expected U4 error, got %v", err)
}
}
func TestSanitizeVer(t *testing.T) {
cases := map[string]string{
"commit-abc1234": "commit-abc1234",
"v1.2.3": "v1.2.3",
"a/b c": "a_b_c",
"": "unknown",
}
for in, want := range cases {
if got := sanitizeVer(in); got != want {
t.Errorf("sanitizeVer(%q)=%q want %q", in, got, want)
}
}
}
func TestApplySwap_NoPending(t *testing.T) {
dir := t.TempDir()
u := &Updater{Dir: dir}
if err := u.applySwap(dir, filepath.Join(dir, "ratatoskr")); err != nil {
t.Fatalf("applySwap with no .new should return nil, got %v", err)
}
}
func TestApplySwap_Applies(t *testing.T) {
dir := t.TempDir()
exe := filepath.Join(dir, "ratatoskr")
if err := os.WriteFile(exe, []byte("old"), 0o755); err != nil {
t.Fatal(err)
}
newFile := filepath.Join(dir, ".ratatoskr.commit-n.new")
if err := os.WriteFile(newFile, []byte("new-content"), 0o755); err != nil {
t.Fatal(err)
}
u := &Updater{Dir: dir}
if err := u.applySwap(dir, exe); err != nil {
t.Fatalf("applySwap err = %v", err)
}
got, _ := os.ReadFile(exe)
if string(got) != "new-content" {
t.Errorf("exe content = %q want new-content", got)
}
// старый бэкап в .old
old, _ := os.ReadFile(filepath.Join(dir, CrashSafeTag))
if string(old) != "old" {
t.Errorf(".old content = %q want old", old)
}
}
func TestErrorsAs(t *testing.T) {
// проверка классификатора через errorsAs на Error(U4)
err := ue(U4, "verify", os.ErrPermission)
var uerr *Error
if !errorsAs(err, &uerr) {
t.Fatal("errorsAs failed to unwrap *Error")
}
if uerr.Code != U4 {
t.Errorf("code = %v want U4", uerr.Code)
}
if !errors.Is(err, os.ErrPermission) {
t.Errorf("errors.Is(err, ErrPermission) = false, want true")
}
}
// errorsAs — тонкая обёртка errors.As для классификатора update.Error.
func errorsAs(err error, target **Error) bool {
type causer interface{ Unwrap() error }
for err != nil {
if e, ok := err.(*Error); ok {
*target = e
return true
}
c, ok := err.(causer)
if !ok {
return false
}
err = c.Unwrap()
}
return false
}