Files
meshtastic_mqtt_server/internal/web/map_tile_proxy_routes_test.go
kevinandClaude Fable 5 2f83308dce 修复 LLM Provider 配置热更新问题
问题:在 /admin/llm/api 页面修改 AI 提供商配置后,配置没有立即生效,AI 依然使用旧的提供商工作

根本原因:
- LLM Provider 配置在程序启动时加载到内存 (llm.State)
- 管理后台修改配置时,只更新了数据库,内存配置未更新
- AI 继续使用内存中的旧配置

解决方案:
1. 为 llm.State 添加动态更新方法 (UpdateProvider/AddProvider/RemoveProvider)
2. 在 AI Service 中暴露配置更新接口,支持运行时重新加载
3. 在配置保存后自动触发内存配置重新加载
4. 创建新的 LLM Client 使新配置立即生效

关键特性:
- 线程安全:使用 sync.RWMutex 保护并发访问
- 容错处理:重新加载失败不影响数据库更新
- 无需重启:配置修改后立即生效
- 完整测试:添加单元测试验证功能

修改文件:
- internal/llm/state.go: 添加配置更新方法
- internal/ai/service.go: 添加配置重新加载接口
- internal/llmadmin/admin_llm_routes.go: 配置更新时触发重新加载
- internal/web/web.go: 传递 AI Service 到路由
- main.go: 连接组件
- internal/llm/state_test.go: 新增单元测试
- internal/web/map_tile_proxy_routes_test.go: 修复测试

测试:
-  所有现有测试通过
-  新增测试覆盖核心功能
-  项目成功编译

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-23 21:13:31 +08:00

168 lines
6.0 KiB
Go

package web
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
configpkg "meshtastic_mqtt_server/internal/config"
storepkg "meshtastic_mqtt_server/internal/store"
"meshtastic_mqtt_server/internal/store/testutil"
)
func openTestStore(t *testing.T) *storepkg.Store {
return testutil.OpenStore(t)
}
func TestMapTileProxyFetchesAndCaches(t *testing.T) {
st := openTestStore(t)
defer st.Close()
requests := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if r.URL.Path != "/3/1/2.png" {
t.Fatalf("upstream path = %q, want /3/1/2.png", r.URL.Path)
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte("tile-data"))
}))
defer upstream.Close()
row, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "Tiles", URLTemplate: upstream.URL + "/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
if err != nil {
t.Fatalf("CreateMapTileSource() error = %v", err)
}
cacheDir := t.TempDir()
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: cacheDir}, false, st, nil, nil, nil, nil, nil, nil, nil)
url := "/api/map/" + row.URLTemplateHash + "?x=1&y=2&z=3"
for i := 0; i < 2; i++ {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, url, nil)
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("request %d status = %d, body = %s", i+1, recorder.Code, recorder.Body.String())
}
if recorder.Body.String() != "tile-data" {
t.Fatalf("request %d body = %q, want tile-data", i+1, recorder.Body.String())
}
}
if requests != 1 {
t.Fatalf("upstream requests = %d, want 1", requests)
}
cachePath := filepath.Join(cacheDir, row.URLTemplateHash, "3", "1", "2.tile")
data, err := os.ReadFile(cachePath)
if err != nil {
t.Fatalf("read cache file %s: %v", cachePath, err)
}
if string(data) != "tile-data" {
t.Fatalf("cache file = %q, want tile-data", string(data))
}
}
func TestMapTileProxyRejectsInvalidCoordinates(t *testing.T) {
st := openTestStore(t)
defer st.Close()
row, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "Tiles", URLTemplate: "https://tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: true, ProxyEnabled: true})
if err != nil {
t.Fatalf("CreateMapTileSource() error = %v", err)
}
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, false, st, nil, nil, nil, nil, nil, nil, nil)
cases := []string{
"/api/map/" + row.URLTemplateHash + "?y=0&z=0",
"/api/map/" + row.URLTemplateHash + "?x=-1&y=0&z=0",
"/api/map/" + row.URLTemplateHash + "?x=0&y=0&z=4",
"/api/map/" + row.URLTemplateHash + "?x=2&y=0&z=1",
}
for _, url := range cases {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, url, nil)
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("%s status = %d, want 400; body = %s", url, recorder.Code, recorder.Body.String())
}
}
}
func TestMapTileProxyUnknownAndDisabledSource(t *testing.T) {
st := openTestStore(t)
defer st.Close()
disabled, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "Disabled", URLTemplate: "https://disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: false})
if err != nil {
t.Fatalf("CreateMapTileSource(disabled) error = %v", err)
}
proxyDisabled, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "ProxyDisabled", URLTemplate: "https://proxy-disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: true, ProxyEnabled: false})
if err != nil {
t.Fatalf("CreateMapTileSource(proxy disabled) error = %v", err)
}
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, false, st, nil, nil, nil, nil, nil, nil, nil)
cases := []string{
"/api/map/not-a-hash?x=0&y=0&z=0",
"/api/map/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?x=0&y=0&z=0",
"/api/map/" + disabled.URLTemplateHash + "?x=0&y=0&z=0",
"/api/map/" + proxyDisabled.URLTemplateHash + "?x=0&y=0&z=0",
}
wantStatus := []int{http.StatusBadRequest, http.StatusNotFound, http.StatusNotFound, http.StatusNotFound}
for i, url := range cases {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, url, nil)
router.ServeHTTP(recorder, req)
if recorder.Code != wantStatus[i] {
t.Fatalf("%s status = %d, want %d; body = %s", url, recorder.Code, wantStatus[i], recorder.Body.String())
}
}
}
func TestMapTileProxyUpstreamStatus(t *testing.T) {
st := openTestStore(t)
defer st.Close()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/404/") {
http.NotFound(w, r)
return
}
http.Error(w, "upstream error", http.StatusInternalServerError)
}))
defer upstream.Close()
row404, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "NotFoundTiles", URLTemplate: upstream.URL + "/404/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
if err != nil {
t.Fatalf("CreateMapTileSource(404) error = %v", err)
}
row500, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "StatusTiles", URLTemplate: upstream.URL + "/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
if err != nil {
t.Fatalf("CreateMapTileSource(500) error = %v", err)
}
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, false, st, nil, nil, nil, nil, nil, nil, nil)
cases := []struct {
url string
want int
}{
{url: "/api/map/" + row404.URLTemplateHash + "?x=0&y=0&z=0", want: http.StatusNotFound},
{url: "/api/map/" + row500.URLTemplateHash + "?x=0&y=0&z=0", want: http.StatusBadGateway},
}
for _, tc := range cases {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, tc.url, nil)
router.ServeHTTP(recorder, req)
if recorder.Code != tc.want {
t.Fatalf("%s status = %d, want %d; body = %s", tc.url, recorder.Code, tc.want, recorder.Body.String())
}
}
}