package service import ( "encoding/json" "os" "path/filepath" "strings" "testing" "3588AdminBackend/internal/config" "3588AdminBackend/internal/storage" ) func mustImportAssetsFromMediaRepo(t *testing.T, svc *ConfigPreviewService) { t.Helper() if _, err := svc.ImportAssetsFromMediaRepo(); err != nil { t.Fatalf("ImportAssetsFromMediaRepo: %v", err) } } func TestConfigPreviewServiceListsSources(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{}`) mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{}`) mustWrite(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) mustImportAssetsFromMediaRepo(t, svc) sources, err := svc.ListSources() if err != nil { t.Fatalf("ListSources: %v", err) } if sources.Root != "SQLite" { t.Fatalf("expected root %q, got %q", "SQLite", sources.Root) } if got := sourceNames(sources.Templates); strings.Join(got, ",") != "std_workshop_face_recognition_shoe_alarm" { t.Fatalf("unexpected templates: %v", got) } if got := sourceNames(sources.Profiles); strings.Join(got, ",") != "local_3588_test" { t.Fatalf("unexpected profiles: %v", got) } if got := sourceNames(sources.Overlays); strings.Join(got, ",") != "face_debug" { t.Fatalf("unexpected overlays: %v", got) } } func TestConfigPreviewServiceListSourcesAllowsEmptyConfigsDir(t *testing.T) { root := t.TempDir() store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil { t.Fatalf("SaveTemplate: %v", err) } if err := repo.SaveProfile("line_a", "helmet", "Line A", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"helmet","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil { t.Fatalf("SaveProfile: %v", err) } svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) sources, err := svc.ListSources() if err != nil { t.Fatalf("ListSources: %v", err) } if got := sourceNames(sources.Templates); len(got) != 1 || got[0] != "helmet" { t.Fatalf("unexpected templates: %#v", got) } if got := sourceNames(sources.Profiles); len(got) != 1 || got[0] != "line_a" { t.Fatalf("unexpected profiles: %#v", got) } } func TestConfigPreviewServiceRejectsUnsafeNames(t *testing.T) { root := t.TempDir() svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}) _, err := svc.Render(ConfigPreviewRequest{ Template: "../secret", Profile: "local_3588_test", ConfigID: "preview", ConfigVersion: "v1", }) if err == nil { t.Fatal("expected unsafe template name to be rejected") } } func TestConfigPreviewServiceImportsAssetsIntoSQLite(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "configs", "templates", "std_face_recognition_stream.json"), `{"name":"std_face_recognition_stream","description":"helmet template","template":{"nodes":[],"edges":[]}}`) mustWrite(t, filepath.Join(root, "configs", "profiles", "gate_a.json"), `{"name":"gate_a","business_name":"Gate A","description":"gate profile","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`) mustWrite(t, filepath.Join(root, "configs", "overlays", "night_relaxed.json"), `{"name":"night_relaxed","description":"overlay","instance_overrides":{"cam1":{"override":{}}}}`) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) result, err := svc.ImportAssetsFromMediaRepo() if err != nil { t.Fatalf("ImportAssetsFromMediaRepo: %v", err) } if result.Templates != 1 || result.Profiles != 1 || result.Overlays != 1 { t.Fatalf("unexpected import result: %#v", result) } sources, err := svc.ListSources() if err != nil { t.Fatalf("ListSources: %v", err) } if got := sourceNames(sources.Templates); len(got) != 1 || got[0] != "std_face_recognition_stream" { t.Fatalf("unexpected templates after import: %#v", got) } if record, err := repo.GetTemplate("std_face_recognition_stream"); err != nil { t.Fatalf("GetTemplate: %v", err) } else if record == nil { t.Fatal("expected imported standard template") } } func TestConfigPreviewServiceExportsAssetJSONFromSQLite(t *testing.T) { store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) const raw = "{\n \"name\": \"helmet\",\n \"template\": {\n \"nodes\": [],\n \"edges\": []\n }\n}\n" if err := repo.SaveTemplate("helmet", "helmet template", raw); err != nil { t.Fatalf("SaveTemplate: %v", err) } svc := NewConfigPreviewService(&config.Config{}, repo) body, filename, err := svc.ExportAssetJSON("templates", "helmet") if err != nil { t.Fatalf("ExportAssetJSON: %v", err) } if filename != "helmet.json" { t.Fatalf("unexpected export filename: %q", filename) } if string(body) != raw { t.Fatalf("unexpected export body: %s", string(body)) } } func TestConfigPreviewServiceRenderProfileEditorWritesResolvedBindings(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{ "name":"std_workshop_face_recognition_shoe_alarm", "template":{ "nodes":[ {"id":"input_rtsp_main","type":"input_rtsp","url":"${slot:video_input_main.url}"}, {"id":"publish_stream","type":"publish","outputs":[{"proto":"hls","path":"${slot:stream_output_main.publish_hls_path}"}]} ], "edges":[] } }`) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) if err := repo.SaveVideoSource( "gate_cam_01", "rtsp", "东门入口", "东门主入口摄像头", `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`, ); err != nil { t.Fatalf("SaveVideoSource: %v", err) } saveIntegrationServiceForPreviewTest(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) saveIntegrationServiceForPreviewTest(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`) saveIntegrationServiceForPreviewTest(t, repo, "alarm_main", "alarm_service", `{"name":"alarm_main","type":"alarm_service","config":{"put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}}`) svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) mustImportAssetsFromMediaRepo(t, svc) editor := ConfigProfileEditor{ Name: "line_a", Instances: []ConfigProfileInstanceEditor{{ Name: "cam1", Template: "std_workshop_face_recognition_shoe_alarm", VideoSourceRef: "gate_cam_01", PublishHLSPath: "./web/hls/cam1/index.m3u8", PublishRTSPPort: "8555", PublishRTSPPath: "/live/cam1", ChannelNo: "cam1", ServiceBindings: map[string]ServiceBindingEditor{ "object_storage_main": {ServiceRef: "minio_main"}, "token_service_main": {ServiceRef: "token_main"}, "alarm_service_main": {ServiceRef: "alarm_main"}, }, }}, } result, err := svc.RenderProfileEditor(editor, ConfigPreviewRequest{ Template: "std_workshop_face_recognition_shoe_alarm", ConfigID: "preview", ConfigVersion: "v1", }) if err != nil { t.Fatalf("RenderProfileEditor: %v", err) } var doc map[string]any if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil { t.Fatalf("unmarshal render result: %v", err) } templates, _ := doc["templates"].(map[string]any) renderedTemplate, _ := templates["std_workshop_face_recognition_shoe_alarm__cam1"].(map[string]any) nodes, _ := renderedTemplate["nodes"].([]any) inputNode, _ := nodes[0].(map[string]any) if got := stringValue(inputNode["url"]); got != "rtsp://10.0.0.1/live" { t.Fatalf("expected expanded input url, got %#v", inputNode) } publishNode, _ := nodes[1].(map[string]any) outputs, _ := publishNode["outputs"].([]any) output, _ := outputs[0].(map[string]any) if got := stringValue(output["path"]); got != "./web/hls/cam1/index.m3u8" { t.Fatalf("expected expanded output path, got %#v", output) } metadata, _ := doc["metadata"].(map[string]any) if got := stringValue(metadata["rendered_by"]); got != "managerd" { t.Fatalf("expected managerd renderer metadata, got %#v", metadata) } } func TestConfigPreviewServiceRenderProfileEditorAllowsUnboundOptionalServiceSlot(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{ "name":"std_workshop_face_recognition_shoe_alarm", "source":"standard", "slots":{ "inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}], "services":[{"name":"token_service_main","type":"token_service","required":false,"description":"认证服务"}], "outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}] }, "template":{ "nodes":[ {"id":"input_rtsp_main","type":"input_rtsp","url":"${slot:video_input_main.url}"}, {"id":"alarm_violation","type":"alarm","outputs":[{"external_api":{"getTokenUrl":"${slot:token_service_main.get_token_url}"}}]} ], "edges":[] } }`) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) if err := repo.SaveVideoSource( "gate_cam_01", "rtsp", "东门入口", "东门主入口摄像头", `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`, ); err != nil { t.Fatalf("SaveVideoSource: %v", err) } svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) mustImportAssetsFromMediaRepo(t, svc) editor := ConfigProfileEditor{ Name: "line_a", Instances: []ConfigProfileInstanceEditor{{ Name: "cam1", Template: "std_workshop_face_recognition_shoe_alarm", VideoSourceRef: "gate_cam_01", PublishRTSPPort: "8555", ChannelNo: "cam1", }}, } result, err := svc.RenderProfileEditor(editor, ConfigPreviewRequest{ Template: "std_workshop_face_recognition_shoe_alarm", ConfigID: "preview", ConfigVersion: "v1", }) if err != nil { t.Fatalf("RenderProfileEditor: %v", err) } var doc map[string]any if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil { t.Fatalf("unmarshal render result: %v", err) } templates, _ := doc["templates"].(map[string]any) renderedTemplate, _ := templates["std_workshop_face_recognition_shoe_alarm__cam1"].(map[string]any) nodes, _ := renderedTemplate["nodes"].([]any) alarmNode, _ := nodes[1].(map[string]any) outputs, _ := alarmNode["outputs"].([]any) output, _ := outputs[0].(map[string]any) externalAPI, _ := output["external_api"].(map[string]any) if got := stringValue(externalAPI["getTokenUrl"]); got != "" { t.Fatalf("expected empty getTokenUrl for unbound optional slot, got %#v", externalAPI) } } func TestConfigPreviewServiceRenderUsesSQLiteProfileAndOverlay(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "configs", "templates", "std_service_test_stream.json"), `{ "name":"std_service_test_stream", "template":{ "nodes":[ {"id":"input_rtsp_main","type":"input_rtsp","url":"${slot:video_input_main.url}"}, {"id":"publish_stream","type":"publish","outputs":[{"proto":"hls","path":"${slot:stream_output_main.publish_hls_path}"}]} ], "edges":[] } }`) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) if err := repo.SaveProfile("line_a", "std_service_test_stream", "Line A", "scene profile", `{ "name":"line_a", "business_name":"Line A", "instances":[ { "name":"cam1", "template":"std_service_test_stream", "input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}, "output_bindings":{"stream_output_main":{"publish_hls_path":"./web/hls/cam1/index.m3u8","publish_rtsp_port":8555,"publish_rtsp_path":"/live/cam1","channel_no":"cam1"}} } ] }`); err != nil { t.Fatalf("SaveProfile: %v", err) } if err := repo.SaveOverlay("night_relaxed", "night overlay", `{"name":"night_relaxed","instance_overrides":{"cam1":{"params":{"debug":true}}}}`); err != nil { t.Fatalf("SaveOverlay: %v", err) } if err := repo.SaveVideoSource( "gate_cam_01", "rtsp", "东门入口", "东门主入口摄像头", `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`, ); err != nil { t.Fatalf("SaveVideoSource: %v", err) } svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) mustImportAssetsFromMediaRepo(t, svc) result, err := svc.Render(ConfigPreviewRequest{ Template: "std_service_test_stream", Profile: "line_a", Overlays: []string{"night_relaxed"}, ConfigID: "preview", ConfigVersion: "v1", }) if err != nil { t.Fatalf("Render: %v", err) } var doc map[string]any if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil { t.Fatalf("unmarshal render result: %v", err) } metadata, _ := doc["metadata"].(map[string]any) if got := stringValue(metadata["profile"]); got != "line_a" { t.Fatalf("expected sqlite profile metadata, got %#v", metadata) } names, _ := metadata["overlays"].([]any) if len(names) != 1 || stringValue(names[0]) != "night_relaxed" { t.Fatalf("expected sqlite overlay metadata, got %#v", metadata["overlays"]) } templates, _ := doc["templates"].(map[string]any) renderedTemplate, _ := templates["std_service_test_stream__cam1"].(map[string]any) nodes, _ := renderedTemplate["nodes"].([]any) inputNode, _ := nodes[0].(map[string]any) if got := stringValue(inputNode["url"]); got != "rtsp://10.0.0.1/live" { t.Fatalf("expected expanded input url, got %#v", inputNode) } instances, _ := doc["instances"].([]any) instance, _ := instances[0].(map[string]any) params, _ := instance["params"].(map[string]any) if got := boolValue(params["debug"], false); !got { t.Fatalf("expected overlay params to merge into runtime instance, got %#v", instance) } } func TestConfigPreviewServiceRenderUsesProfileOverlayWhenRequestOmitsIt(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "configs", "templates", "std_service_test_stream.json"), `{ "name":"std_service_test_stream", "template":{ "nodes":[ {"id":"input_rtsp_main","type":"input_rtsp","url":"${slot:video_input_main.url}"} ], "edges":[] } }`) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) if err := repo.SaveProfile("line_a", "std_service_test_stream", "Line A", "scene profile", `{ "name":"line_a", "business_name":"Line A", "overlays":["night_relaxed"], "instances":[ { "name":"cam1", "template":"std_service_test_stream", "input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}} } ] }`); err != nil { t.Fatalf("SaveProfile: %v", err) } if err := repo.SaveOverlay("night_relaxed", "night overlay", `{"name":"night_relaxed","instance_overrides":{"cam1":{"params":{"debug":true}}}}`); err != nil { t.Fatalf("SaveOverlay: %v", err) } if err := repo.SaveVideoSource( "gate_cam_01", "rtsp", "东门入口", "东门主入口摄像头", `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`, ); err != nil { t.Fatalf("SaveVideoSource: %v", err) } svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) mustImportAssetsFromMediaRepo(t, svc) result, err := svc.Render(ConfigPreviewRequest{ Template: "std_service_test_stream", Profile: "line_a", ConfigID: "preview", ConfigVersion: "v1", }) if err != nil { t.Fatalf("Render: %v", err) } var doc map[string]any if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil { t.Fatalf("unmarshal render result: %v", err) } metadata, _ := doc["metadata"].(map[string]any) names, _ := metadata["overlays"].([]any) if len(names) != 1 || stringValue(names[0]) != "night_relaxed" { t.Fatalf("expected profile overlay metadata, got %#v", metadata["overlays"]) } instances, _ := doc["instances"].([]any) instance, _ := instances[0].(map[string]any) params, _ := instance["params"].(map[string]any) if got := boolValue(params["debug"], false); !got { t.Fatalf("expected profile overlay params to merge into runtime instance, got %#v", instance) } } func TestSaveProfileEditorRequiresAssetRepository(t *testing.T) { svc := NewConfigPreviewService(&config.Config{}) err := svc.SaveProfileEditor(ConfigProfileEditor{ Name: "line_a", Instances: []ConfigProfileInstanceEditor{{ Name: "cam1", Template: "helmet", VideoSourceRef: "gate_cam_01", }}, }) if err == nil || !strings.Contains(err.Error(), "asset repository is not configured") { t.Fatalf("expected asset repository error, got %v", err) } } func TestConfigPreviewServiceResolveSceneBindings(t *testing.T) { store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } defer store.Close() repo := storage.NewAssetsRepo(store.DB()) if err := repo.SaveVideoSource( "gate_cam_01", "rtsp", "东门入口", "东门主入口摄像头", `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`, ); err != nil { t.Fatalf("SaveVideoSource: %v", err) } saveIntegrationServiceForPreviewTest(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) svc := NewConfigPreviewService(&config.Config{}, repo) resolved, err := svc.resolveSceneBindings(map[string]any{ "name": "line_a", "instances": []any{ map[string]any{ "name": "cam1", "template": "std_workshop_face_recognition_shoe_alarm", "input_bindings": map[string]any{ "video_input_main": map[string]any{ "video_source_ref": "gate_cam_01", }, }, "service_bindings": map[string]any{ "object_storage_main": map[string]any{ "service_ref": "minio_main", }, }, }, }, }) if err != nil { t.Fatalf("resolveSceneBindings: %v", err) } instances, _ := resolved["instances"].([]any) instanceMap, _ := instances[0].(map[string]any) inputBindings, _ := instanceMap["input_bindings"].(map[string]any) videoInput, _ := inputBindings["video_input_main"].(map[string]any) resolvedInput, _ := videoInput["resolved"].(map[string]any) if got := stringValue(resolvedInput["url"]); got != "rtsp://10.0.0.1/live" { t.Fatalf("expected resolved input url, got %#v", resolvedInput) } serviceBindings, _ := instanceMap["service_bindings"].(map[string]any) objectStorage, _ := serviceBindings["object_storage_main"].(map[string]any) resolvedService, _ := objectStorage["resolved"].(map[string]any) if got := stringValue(resolvedService["bucket"]); got != "myminio" { t.Fatalf("expected resolved service binding, got %#v", resolvedService) } } func saveIntegrationServiceForPreviewTest(t *testing.T, repo *storage.AssetsRepo, name string, serviceType string, body string) { t.Helper() if err := repo.SaveIntegrationService(name, serviceType, name, true, body); err != nil { t.Fatalf("SaveIntegrationService(%s): %v", name, err) } } func sourceNames(items []ConfigSource) []string { out := make([]string, 0, len(items)) for _, item := range items { out = append(out, item.Name) } return out } func mustWrite(t *testing.T, path string, body string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) } if err := os.WriteFile(path, []byte(body), 0644); err != nil { t.Fatalf("write %s: %v", path, err) } }