Files
MyGoNavi/internal/jvm/http_provider_test.go
Syngnat 9d08b185d0 feat(jvm): 新增持续监控与采样链路
- 后端新增监控会话管理,支持启动、停止和历史查询

- JMX、Endpoint、Agent Provider 补齐监控快照采集能力

- JMX helper 增加内存、GC、线程、类加载采样并更新内嵌运行时

- 生成 Wails 监控接口绑定并补充后端回归测试
2026-04-26 14:33:41 +08:00

587 lines
18 KiB
Go

package jvm
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"GoNavi-Wails/internal/connection"
)
func TestHTTPProviderListResourcesBuildsRequestAndDecodesResponse(t *testing.T) {
provider := NewHTTPProvider()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Fatalf("expected GET request, got %s", r.Method)
}
if r.URL.Path != "/manage/jvm/resources" {
t.Fatalf("expected path /manage/jvm/resources, got %s", r.URL.Path)
}
if got := r.URL.Query().Get("parentPath"); got != "/cache/orders" {
t.Fatalf("expected parentPath /cache/orders, got %q", got)
}
if got := r.Header.Get("X-API-Key"); got != "secret-token" {
t.Fatalf("expected X-API-Key header to pass through, got %q", got)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode([]ResourceSummary{{
ID: "cache.orders",
Kind: "folder",
Name: "Orders",
Path: "/cache/orders",
ProviderMode: ModeEndpoint,
CanRead: true,
CanWrite: true,
HasChildren: true,
}})
}))
defer server.Close()
items, err := provider.ListResources(context.Background(), newHTTPProviderTestConfig(server.URL+"/manage/jvm/", 3), "/cache/orders")
if err != nil {
t.Fatalf("ListResources returned error: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 resource, got %#v", items)
}
if items[0].ProviderMode != ModeEndpoint || items[0].Path != "/cache/orders" {
t.Fatalf("unexpected resource payload: %#v", items[0])
}
}
func TestHTTPProviderGetValueDecodesResponse(t *testing.T) {
provider := NewHTTPProvider()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Fatalf("expected GET request, got %s", r.Method)
}
if r.URL.Path != "/runtime/value" {
t.Fatalf("expected path /runtime/value, got %s", r.URL.Path)
}
if got := r.URL.Query().Get("resourcePath"); got != "/cache/orders" {
t.Fatalf("expected resourcePath /cache/orders, got %q", got)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(ValueSnapshot{
ResourceID: "/cache/orders",
Kind: "entry",
Format: "json",
Version: "v1",
Value: map[string]any{
"status": "ready",
},
})
}))
defer server.Close()
value, err := provider.GetValue(context.Background(), newHTTPProviderTestConfig(server.URL+"/runtime", 3), "/cache/orders")
if err != nil {
t.Fatalf("GetValue returned error: %v", err)
}
if value.ResourceID != "/cache/orders" || value.Version != "v1" {
t.Fatalf("unexpected value payload: %#v", value)
}
}
func TestHTTPProviderGetMonitoringSnapshotDecodesResponse(t *testing.T) {
provider := &HTTPProvider{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Fatalf("expected GET request, got %s", r.Method)
}
if r.URL.Path != "/manage/jvm/metrics" {
t.Fatalf("expected path /manage/jvm/metrics, got %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(JVMMonitoringSnapshot{
Point: JVMMonitoringPoint{
Timestamp: 1713945600000,
ThreadCount: 18,
HeapUsedBytes: 805306368,
ProcessCpuLoad: 0.48,
ProcessRssBytes: 1879048192,
LoadedClassCount: 4096,
},
RecentGCEvents: []RecentGCEvent{{
Timestamp: 1713945600000,
Name: "G1 Old Generation",
DurationMs: 41,
}},
AvailableMetrics: []string{"thread.count", "heap.used", "cpu.process", "memory.rss", "class.loading"},
MissingMetrics: []string{"cpu.system"},
ProviderWarnings: []string{"endpoint cpu metric unavailable"},
})
}))
defer server.Close()
snapshot, err := provider.GetMonitoringSnapshot(context.Background(), newHTTPProviderTestConfig(server.URL+"/manage/jvm", 3), nil)
if err != nil {
t.Fatalf("GetMonitoringSnapshot returned error: %v", err)
}
if snapshot.Point.ThreadCount != 18 || snapshot.Point.HeapUsedBytes != 805306368 || snapshot.Point.ProcessRssBytes != 1879048192 {
t.Fatalf("unexpected monitoring snapshot: %#v", snapshot)
}
if len(snapshot.RecentGCEvents) != 1 || snapshot.RecentGCEvents[0].Name != "G1 Old Generation" {
t.Fatalf("unexpected recent gc events: %#v", snapshot.RecentGCEvents)
}
if len(snapshot.MissingMetrics) != 1 || snapshot.MissingMetrics[0] != "cpu.system" {
t.Fatalf("unexpected missing metrics: %#v", snapshot)
}
}
func TestHTTPProviderPreviewChangeAndApplySendJSONBody(t *testing.T) {
provider := NewHTTPProvider()
request := ChangeRequest{
ProviderMode: ModeEndpoint,
ResourceID: "/cache/orders",
Action: "put",
Reason: "refresh cache",
ExpectedVersion: "v1",
Payload: map[string]any{
"status": "warm",
},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("failed to read request body: %v", err)
}
defer r.Body.Close()
if contentType := r.Header.Get("Content-Type"); !strings.Contains(contentType, "application/json") {
t.Fatalf("expected JSON content type, got %q", contentType)
}
var got ChangeRequest
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
if got.ResourceID != request.ResourceID || got.Action != request.Action || got.ExpectedVersion != request.ExpectedVersion {
t.Fatalf("unexpected request body: %#v", got)
}
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/manage/jvm/preview":
if r.Method != http.MethodPost {
t.Fatalf("expected POST /preview, got %s", r.Method)
}
_ = json.NewEncoder(w).Encode(ChangePreview{
Allowed: true,
Summary: "preview ready",
RiskLevel: "low",
Before: ValueSnapshot{
ResourceID: request.ResourceID,
Kind: "entry",
Format: "json",
},
After: ValueSnapshot{
ResourceID: request.ResourceID,
Kind: "entry",
Format: "json",
Value: map[string]any{
"status": "warm",
},
},
})
case "/manage/jvm/apply":
if r.Method != http.MethodPost {
t.Fatalf("expected POST /apply, got %s", r.Method)
}
_ = json.NewEncoder(w).Encode(ApplyResult{
Status: "applied",
Message: "updated",
UpdatedValue: ValueSnapshot{
ResourceID: request.ResourceID,
Kind: "entry",
Format: "json",
Value: map[string]any{
"status": "warm",
},
},
})
default:
t.Fatalf("unexpected request path: %s", r.URL.Path)
}
}))
defer server.Close()
preview, err := provider.PreviewChange(context.Background(), newHTTPProviderTestConfig(server.URL+"/manage/jvm", 3), request)
if err != nil {
t.Fatalf("PreviewChange returned error: %v", err)
}
if !preview.Allowed || preview.Summary != "preview ready" {
t.Fatalf("unexpected preview payload: %#v", preview)
}
result, err := provider.ApplyChange(context.Background(), newHTTPProviderTestConfig(server.URL+"/manage/jvm", 3), request)
if err != nil {
t.Fatalf("ApplyChange returned error: %v", err)
}
if result.Status != "applied" || result.UpdatedValue.ResourceID != request.ResourceID {
t.Fatalf("unexpected apply payload: %#v", result)
}
}
func TestHTTPProviderProbeCapabilitiesReflectsReadOnlyConnection(t *testing.T) {
provider := NewHTTPProvider()
cfg := newHTTPProviderTestConfig("https://orders.internal/manage/jvm", 3)
readOnly := true
cfg.JVM.ReadOnly = &readOnly
caps, err := provider.ProbeCapabilities(context.Background(), cfg)
if err != nil {
t.Fatalf("ProbeCapabilities returned error: %v", err)
}
if len(caps) != 1 {
t.Fatalf("expected one capability, got %#v", caps)
}
if caps[0].CanWrite {
t.Fatalf("expected endpoint capability to be readonly, got %#v", caps[0])
}
if caps[0].Reason != "当前连接只读" {
t.Fatalf("expected readonly reason, got %#v", caps[0])
}
}
func TestHTTPProviderProbeCapabilitiesReturnsConfigValidationError(t *testing.T) {
provider := NewHTTPProvider()
_, err := provider.ProbeCapabilities(context.Background(), connection.ConnectionConfig{
Type: "jvm",
JVM: connection.JVMConfig{
Endpoint: connection.JVMEndpointConfig{
BaseURL: "",
},
},
})
if err == nil {
t.Fatal("expected endpoint config validation error")
}
if !strings.Contains(err.Error(), "endpoint baseURL is required") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestHTTPProviderReturnsReadableStatusErrors(t *testing.T) {
provider := NewHTTPProvider()
tests := []struct {
name string
path string
code int
body string
call func(context.Context, Provider, connection.ConnectionConfig) error
want []string
}{
{
name: "list resources unauthorized",
path: "/resources",
code: http.StatusUnauthorized,
body: "missing api key",
call: func(ctx context.Context, provider Provider, cfg connection.ConnectionConfig) error {
_, err := provider.ListResources(ctx, cfg, "/cache/orders")
return err
},
want: []string{"list resources", "401 Unauthorized", "missing api key"},
},
{
name: "get value forbidden",
path: "/value",
code: http.StatusForbidden,
body: "access denied",
call: func(ctx context.Context, provider Provider, cfg connection.ConnectionConfig) error {
_, err := provider.GetValue(ctx, cfg, "/cache/orders")
return err
},
want: []string{"get value", "403 Forbidden", "access denied"},
},
{
name: "preview change server error",
path: "/preview",
code: http.StatusInternalServerError,
body: "preview backend exploded",
call: func(ctx context.Context, provider Provider, cfg connection.ConnectionConfig) error {
_, err := provider.PreviewChange(ctx, cfg, ChangeRequest{
ProviderMode: ModeEndpoint,
ResourceID: "/cache/orders",
Action: "put",
Reason: "refresh cache",
})
return err
},
want: []string{"preview change", "500 Internal Server Error", "preview backend exploded"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/manage/jvm"+tc.path {
t.Fatalf("expected path %s, got %s", "/manage/jvm"+tc.path, r.URL.Path)
}
http.Error(w, tc.body, tc.code)
}))
defer server.Close()
err := tc.call(context.Background(), provider, newHTTPProviderTestConfig(server.URL+"/manage/jvm", 3))
if err == nil {
t.Fatal("expected request error")
}
for _, fragment := range tc.want {
if !strings.Contains(err.Error(), fragment) {
t.Fatalf("expected error %q to contain %q", err.Error(), fragment)
}
}
})
}
}
func TestHTTPProviderReturnsInvalidJSONError(t *testing.T) {
provider := NewHTTPProvider()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"resourceId":`))
}))
defer server.Close()
_, err := provider.GetValue(context.Background(), newHTTPProviderTestConfig(server.URL, 3), "/cache/orders")
if err == nil {
t.Fatal("expected invalid JSON error")
}
if !strings.Contains(strings.ToLower(err.Error()), "invalid json") {
t.Fatalf("expected invalid JSON error, got %v", err)
}
}
func TestHTTPProviderReturnsTimeoutError(t *testing.T) {
provider := NewHTTPProvider()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(1200 * time.Millisecond)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode([]ResourceSummary{})
}))
defer server.Close()
_, err := provider.ListResources(context.Background(), newHTTPProviderTestConfig(server.URL, 1), "/cache/orders")
if err == nil {
t.Fatal("expected timeout error")
}
if !strings.Contains(err.Error(), "timed out after 1s") {
t.Fatalf("expected timeout error, got %v", err)
}
}
func TestHTTPProviderRealEndpointRoundTrip(t *testing.T) {
if _, err := exec.LookPath("java"); err != nil {
t.Skipf("java 不可用,跳过真实 Endpoint 集成测试: %v", err)
}
if _, err := exec.LookPath("javac"); err != nil {
t.Skipf("javac 不可用,跳过真实 Endpoint 集成测试: %v", err)
}
provider := NewHTTPProvider()
fixture := startEndpointFixture(t)
cfg := newHTTPProviderTestConfig(fixture.baseURL+"/manage/jvm", 5)
waitForTest(t, 10*time.Second, func() error {
return provider.TestConnection(context.Background(), cfg)
})
caps, err := provider.ProbeCapabilities(context.Background(), cfg)
if err != nil {
t.Fatalf("ProbeCapabilities returned error: %v", err)
}
if len(caps) != 1 || !caps[0].CanBrowse || !caps[0].CanWrite || !caps[0].CanPreview {
t.Fatalf("unexpected capabilities: %#v", caps)
}
rootItems, err := provider.ListResources(context.Background(), cfg, "")
if err != nil {
t.Fatalf("ListResources(root) returned error: %v", err)
}
if len(rootItems) != 1 || rootItems[0].Name != "Orders" || !rootItems[0].HasChildren {
t.Fatalf("unexpected root resources: %#v", rootItems)
}
children, err := provider.ListResources(context.Background(), cfg, rootItems[0].Path)
if err != nil {
t.Fatalf("ListResources(child) returned error: %v", err)
}
stateResource := findResourceByName(t, children, "State")
before, err := provider.GetValue(context.Background(), cfg, stateResource.Path)
if err != nil {
t.Fatalf("GetValue(before) returned error: %v", err)
}
beforeMap, ok := before.Value.(map[string]any)
if !ok || beforeMap["status"] != "warm" || strings.TrimSpace(before.Version) == "" {
t.Fatalf("unexpected initial value snapshot: %#v", before)
}
preview, err := provider.PreviewChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeEndpoint,
ResourceID: stateResource.Path,
Action: "put",
Reason: "更新订单缓存状态",
ExpectedVersion: before.Version,
Payload: map[string]any{
"status": "hot",
"lastUpdated": "preview-check",
},
})
if err != nil {
t.Fatalf("PreviewChange returned error: %v", err)
}
previewAfter, ok := preview.After.Value.(map[string]any)
if !preview.Allowed || !ok || previewAfter["status"] != "hot" {
t.Fatalf("unexpected preview payload: %#v", preview)
}
result, err := provider.ApplyChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeEndpoint,
ResourceID: stateResource.Path,
Action: "put",
Reason: "更新订单缓存状态",
ExpectedVersion: before.Version,
Payload: map[string]any{
"status": "hot",
"lastUpdated": "manual-check",
},
})
if err != nil {
t.Fatalf("ApplyChange returned error: %v", err)
}
updatedMap, ok := result.UpdatedValue.Value.(map[string]any)
if result.Status != "applied" || !ok || updatedMap["status"] != "hot" || updatedMap["lastUpdated"] != "manual-check" {
t.Fatalf("unexpected apply result: %#v", result)
}
after, err := provider.GetValue(context.Background(), cfg, stateResource.Path)
if err != nil {
t.Fatalf("GetValue(after) returned error: %v", err)
}
afterMap, ok := after.Value.(map[string]any)
if !ok || afterMap["status"] != "hot" || after.Version == before.Version {
t.Fatalf("unexpected updated value snapshot: %#v", after)
}
}
type endpointFixtureProcess struct {
port int
baseURL string
cmd *exec.Cmd
}
func startEndpointFixture(t *testing.T) endpointFixtureProcess {
t.Helper()
javaBin, err := exec.LookPath("java")
if err != nil {
t.Fatalf("look up java failed: %v", err)
}
javacBin, err := exec.LookPath("javac")
if err != nil {
t.Fatalf("look up javac failed: %v", err)
}
classesDir := filepath.Join(t.TempDir(), "endpoint-fixture-classes")
sourceRoot := filepath.Join(testRepoRoot(t), "internal", "jvm", "testdata", "endpointfixture", "src")
javaFiles, err := filepath.Glob(filepath.Join(sourceRoot, "com", "gonavi", "fixture", "*.java"))
if err != nil {
t.Fatalf("glob endpoint fixture sources failed: %v", err)
}
if len(javaFiles) == 0 {
t.Fatalf("expected endpoint fixture java files under %s", sourceRoot)
}
compileCmd := exec.Command(javacBin, append([]string{"-d", classesDir}, javaFiles...)...)
output, err := compileCmd.CombinedOutput()
if err != nil {
t.Fatalf("compile endpoint fixture failed: %v\n%s", err, strings.TrimSpace(string(output)))
}
port := reserveTCPPort(t)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
cmd := exec.CommandContext(ctx, javaBin, "-cp", classesDir, "com.gonavi.fixture.EndpointTestServer", fmt.Sprintf("%d", port))
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatalf("endpoint fixture stdout pipe failed: %v", err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("start endpoint fixture failed: %v", err)
}
t.Cleanup(func() {
cancel()
_ = cmd.Wait()
})
ready := make(chan error, 1)
go func() {
line, readErr := bufio.NewReader(stdout).ReadString('\n')
if readErr != nil {
ready <- fmt.Errorf("endpoint fixture readiness read failed: %w", readErr)
return
}
if strings.TrimSpace(line) != "READY" {
ready <- fmt.Errorf("unexpected endpoint fixture readiness line: %q", strings.TrimSpace(line))
return
}
ready <- nil
}()
select {
case err := <-ready:
if err != nil {
t.Fatalf("wait endpoint fixture ready failed: %v", err)
}
case <-time.After(20 * time.Second):
t.Fatal("endpoint fixture did not become ready within 20s")
}
waitForTest(t, 10*time.Second, func() error {
conn, dialErr := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 500*time.Millisecond)
if dialErr != nil {
return dialErr
}
_ = conn.Close()
return nil
})
return endpointFixtureProcess{
port: port,
baseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
cmd: cmd,
}
}
func newHTTPProviderTestConfig(baseURL string, timeoutSeconds int) connection.ConnectionConfig {
readOnly := false
return connection.ConnectionConfig{
Type: "jvm",
Timeout: timeoutSeconds,
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
AllowedModes: []string{ModeEndpoint},
PreferredMode: ModeEndpoint,
Endpoint: connection.JVMEndpointConfig{
BaseURL: baseURL,
APIKey: "secret-token",
TimeoutSeconds: timeoutSeconds,
},
},
}
}