package managed import ( "bytes" "context" "encoding/json" "io" "net/http" "strings" "testing" ) type fakeDockerRuntime struct { statusByContainer map[string]string restarted []string } func (f *fakeDockerRuntime) GetContainerStatus(containerName string) (string, error) { if status, ok := f.statusByContainer[containerName]; ok { return status, nil } return "unknown", nil } func (f *fakeDockerRuntime) RestartContainer(containerName string) error { f.restarted = append(f.restarted, containerName) return nil } func TestManagedDockerAndRemoteAPI(t *testing.T) { t.Parallel() storeConfig := map[string]any{ "config_path": "/srv/store/config/local.yaml", "stream": map[string]any{ "rtsp_url": "rtsp://store-old/stream", }, } client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { response := func(status int, body any) (*http.Response, error) { data, err := json.Marshal(body) if err != nil { return nil, err } return &http.Response{ StatusCode: status, Header: make(http.Header), Body: io.NopCloser(bytes.NewReader(data)), }, nil } switch { case r.Method == http.MethodGet && r.URL.Path == "/store/api/manage/config": return response(http.StatusOK, storeConfig) case r.Method == http.MethodPut && r.URL.Path == "/store/api/manage/config": var payload map[string]string if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Fatalf("decode update payload: %v", err) } storeConfig["stream"].(map[string]any)["rtsp_url"] = payload["rtsp_url"] return response(http.StatusOK, storeConfig) case r.Method == http.MethodGet && r.URL.Path == "/store/api/manage/summary": return response(http.StatusOK, ResultSummary{ ResultType: "store_dwell_alert", Headline: "Latest report shows 1 active customers, longest dwell 900s", LastResultTime: "2026-04-16T10:00:00+08:00", Metrics: map[string]any{ "active_customer_count": 1, "longest_dwell_seconds": 900, }, }) case r.Method == http.MethodGet && r.URL.Path == "/store/api/manage/files": return response(http.StatusOK, map[string]any{ "files": []ResultFile{{ Path: "logs/events.jsonl", Name: "events.jsonl", }}, }) case r.Method == http.MethodGet && r.URL.Path == "/store/api/manage/files/preview": return response(http.StatusOK, FilePreview{ Path: "logs/events.jsonl", Lines: []string{"line1", "line2"}, Count: 2, }) case r.Method == http.MethodGet && r.URL.Path == "/store/api/manage/files/download": return &http.Response{ StatusCode: http.StatusOK, Header: http.Header{ "Content-Disposition": []string{`attachment; filename="events.jsonl"`}, }, Body: io.NopCloser(strings.NewReader("downloaded")), }, nil default: t.Fatalf("unexpected child request: %s %s", r.Method, r.URL.String()) return nil, nil } })} registry := &Registry{ Services: []Service{{ ID: "store_dwell_alert", DisplayName: "Store Dwell Alert", ProjectType: "store_dwell_alert", ProjectRoot: "/srv/store", ContainerName: "store-dwell-alert", ServiceName: "store-dwell-alert", APIBaseURL: "http://managed.invalid/store", ResultType: "store_dwell_alert", }}, } docker := &fakeDockerRuntime{ statusByContainer: map[string]string{ "store-dwell-alert": "running", }, } manager := NewManager(registry, docker, NewRemoteClient(client)) states := manager.List() if len(states) != 1 { t.Fatalf("len(List()) = %d, want 1", len(states)) } if states[0].Status != "running" { t.Fatalf("List()[0].Status = %q", states[0].Status) } state, err := manager.Detail("store_dwell_alert") if err != nil { t.Fatalf("Detail() error = %v", err) } if state.Status != "running" { t.Fatalf("state.Status = %q", state.Status) } if state.RTSP != "rtsp://store-old/stream" { t.Fatalf("state.RTSP = %q", state.RTSP) } if state.ConfigPath != "/srv/store/config/local.yaml" { t.Fatalf("state.ConfigPath = %q", state.ConfigPath) } if state.Summary == nil || state.Summary.Metrics["longest_dwell_seconds"] != float64(900) { t.Fatalf("unexpected summary: %#v", state.Summary) } if len(state.ResultFiles) != 1 { t.Fatalf("len(ResultFiles) = %d", len(state.ResultFiles)) } if _, err := manager.UpdateRTSP("store_dwell_alert", "rtsp://store-new/stream"); err != nil { t.Fatalf("UpdateRTSP() error = %v", err) } if got := storeConfig["stream"].(map[string]any)["rtsp_url"]; got != "rtsp://store-new/stream" { t.Fatalf("updated rtsp = %#v", got) } summary, err := manager.Summary("store_dwell_alert") if err != nil { t.Fatalf("Summary() error = %v", err) } if summary.Headline == "" { t.Fatalf("Summary().Headline is empty") } files, err := manager.Files("store_dwell_alert") if err != nil { t.Fatalf("Files() error = %v", err) } if len(files) != 1 { t.Fatalf("len(Files()) = %d", len(files)) } preview, err := manager.PreviewFile("store_dwell_alert", "logs/events.jsonl", 2) if err != nil { t.Fatalf("PreviewFile() error = %v", err) } if preview.Count != 2 { t.Fatalf("preview.Count = %d", preview.Count) } resp, err := manager.Download(context.Background(), "store_dwell_alert", "logs/events.jsonl") if err != nil { t.Fatalf("Download() error = %v", err) } defer resp.Body.Close() if !strings.Contains(resp.Header.Get("Content-Disposition"), "events.jsonl") { t.Fatalf("Content-Disposition = %q", resp.Header.Get("Content-Disposition")) } if _, err := manager.Restart("store_dwell_alert"); err != nil { t.Fatalf("Restart() error = %v", err) } if len(docker.restarted) != 1 || docker.restarted[0] != "store-dwell-alert" { t.Fatalf("restarted = %#v", docker.restarted) } }