- 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.
355 lines
11 KiB
Go
355 lines
11 KiB
Go
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
|
||
}
|