827 lines
31 KiB
Go
827 lines
31 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"safesight-control/internal/config"
|
|
"safesight-control/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(
|
|
0, "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", "alarm_service", `{"name":"token_main","type":"alarm_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 != "safesightd" {
|
|
t.Fatalf("expected safesightd 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":"alarm_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(
|
|
0, "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(
|
|
0, "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(
|
|
0, "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(
|
|
0, "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)
|
|
}
|
|
}
|
|
|
|
// ── P1 #6: injectIntegrationsIntoConfig ──
|
|
|
|
func TestInjectIntegrationsIntoConfig_InjectsAlarmAndStorage(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())
|
|
svc := NewConfigPreviewService(&config.Config{}, repo)
|
|
|
|
// Seed integration services
|
|
saveIntegrationServiceForPreviewTest(t, repo, "alarm", "alarm_service",
|
|
`{"get_token_url":"http://10.0.0.1/api/token","put_message_url":"http://10.0.0.1/api/msg","tenant_code":"01"}`)
|
|
saveIntegrationServiceForPreviewTest(t, repo, "storage", "object_storage",
|
|
`{"endpoint":"http://10.0.0.1:9000","bucket":"alarms","access_key":"ak","secret_key":"sk"}`)
|
|
|
|
cfg := map[string]any{
|
|
"templates": map[string]any{
|
|
"tpl__inst": map[string]any{
|
|
"nodes": []any{
|
|
map[string]any{"id": "alarm_violation", "type": "alarm", "actions": map[string]any{}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
injectIntegrationsIntoConfig(cfg, svc)
|
|
|
|
tpl := cfg["templates"].(map[string]any)["tpl__inst"].(map[string]any)
|
|
nodes := tpl["nodes"].([]any)
|
|
node := nodes[0].(map[string]any)
|
|
actions := node["actions"].(map[string]any)
|
|
|
|
// Verify HTTP action was injected
|
|
if http, ok := actions["http"].(map[string]any); !ok {
|
|
t.Error("expected http action to be injected")
|
|
} else {
|
|
if http["url"] != "http://127.0.0.1:9100/v1/alarms/report" {
|
|
t.Errorf("http url = %v", http["url"])
|
|
}
|
|
if http["enable"] != true {
|
|
t.Error("http action should be enabled")
|
|
}
|
|
}
|
|
|
|
// Verify snapshot upload was injected
|
|
if snap, ok := actions["snapshot"].(map[string]any); !ok {
|
|
t.Error("expected snapshot action")
|
|
} else {
|
|
if upload, ok := snap["upload"].(map[string]any); !ok {
|
|
t.Error("expected snapshot.upload")
|
|
} else {
|
|
if upload["bucket"] != "alarms" {
|
|
t.Errorf("snapshot bucket = %v", upload["bucket"])
|
|
}
|
|
if upload["access_key"] != "ak" {
|
|
t.Errorf("snapshot access_key = %v", upload["access_key"])
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify clip upload was injected
|
|
if clip, ok := actions["clip"].(map[string]any); !ok {
|
|
t.Error("expected clip action")
|
|
} else {
|
|
if upload, ok := clip["upload"].(map[string]any); !ok {
|
|
t.Error("expected clip.upload")
|
|
} else {
|
|
if upload["endpoint"] != "http://10.0.0.1:9000" {
|
|
t.Errorf("clip endpoint = %v", upload["endpoint"])
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify external_api was injected
|
|
if ext, ok := actions["external_api"].(map[string]any); !ok {
|
|
t.Error("expected external_api action")
|
|
} else {
|
|
if ext["getTokenUrl"] != "http://10.0.0.1/api/token" {
|
|
t.Errorf("external_api getTokenUrl = %v", ext["getTokenUrl"])
|
|
}
|
|
if ext["putMessageUrl"] != "http://10.0.0.1/api/msg" {
|
|
t.Errorf("external_api putMessageUrl = %v", ext["putMessageUrl"])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInjectIntegrationsIntoConfig_SkipsDisabledServices(t *testing.T) {
|
|
store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
|
defer store.Close()
|
|
repo := storage.NewAssetsRepo(store.DB())
|
|
svc := NewConfigPreviewService(&config.Config{}, repo)
|
|
|
|
// Only alarm service, no storage
|
|
saveIntegrationServiceForPreviewTest(t, repo, "alarm", "alarm_service",
|
|
`{"get_token_url":"http://10.0.0.1/api/token","put_message_url":"http://10.0.0.1/api/msg","tenant_code":"01"}`)
|
|
|
|
cfg := map[string]any{
|
|
"templates": map[string]any{
|
|
"tpl__inst": map[string]any{
|
|
"nodes": []any{
|
|
map[string]any{"id": "alarm_violation", "type": "alarm", "actions": map[string]any{}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
injectIntegrationsIntoConfig(cfg, svc)
|
|
|
|
tpl := cfg["templates"].(map[string]any)["tpl__inst"].(map[string]any)
|
|
node := tpl["nodes"].([]any)[0].(map[string]any)
|
|
actions := node["actions"].(map[string]any)
|
|
|
|
// HTTP and external_api should still be injected
|
|
if _, ok := actions["http"]; !ok {
|
|
t.Error("expected http action")
|
|
}
|
|
if _, ok := actions["external_api"]; !ok {
|
|
t.Error("expected external_api action")
|
|
}
|
|
// But snapshot/clip should NOT have upload since no storage service
|
|
if snap, ok := actions["snapshot"].(map[string]any); ok {
|
|
if _, ok := snap["upload"]; ok {
|
|
t.Log("snapshot.upload may exist with defaults (ok)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── P2 #7: ResolveChannelToSource ──
|
|
|
|
func TestResolveChannelToSource_UnknownReturnsInput(t *testing.T) {
|
|
store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
|
defer store.Close()
|
|
svc := NewConfigPreviewService(&config.Config{}, storage.NewAssetsRepo(store.DB()))
|
|
|
|
// Unknown channel returns the channel name itself (fallback)
|
|
if got := svc.ResolveChannelToSource("nonexistent"); got != "nonexistent" {
|
|
t.Errorf("expected 'nonexistent', got %q", got)
|
|
}
|
|
}
|
|
|
|
// ── P2 #8: SaveVideoSourceAsset ──
|
|
|
|
func TestSaveVideoSourceAsset_Create(t *testing.T) {
|
|
store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
|
defer store.Close()
|
|
repo := storage.NewAssetsRepo(store.DB())
|
|
svc := NewConfigPreviewService(&config.Config{}, repo)
|
|
|
|
asset := ConfigVideoSourceAsset{
|
|
Name: "cam1", SourceType: "rtsp",
|
|
Config: VideoSourceConfig{URL: "rtsp://10.0.0.1/live", Resolution: "1920x1080", FPS: "30"},
|
|
}
|
|
if err := svc.SaveVideoSourceAsset(asset, ""); err != nil {
|
|
t.Fatalf("SaveVideoSourceAsset: %v", err)
|
|
}
|
|
|
|
sources, err := svc.ListVideoSources()
|
|
if err != nil {
|
|
t.Fatalf("ListVideoSources: %v", err)
|
|
}
|
|
if len(sources) != 1 {
|
|
t.Errorf("expected 1 source, got %d", len(sources))
|
|
} else if sources[0].Name != "cam1" {
|
|
t.Errorf("expected cam1, got %s", sources[0].Name)
|
|
}
|
|
}
|
|
|
|
func TestSaveVideoSourceAsset_EmptyName(t *testing.T) {
|
|
store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
|
defer store.Close()
|
|
svc := NewConfigPreviewService(&config.Config{}, storage.NewAssetsRepo(store.DB()))
|
|
|
|
err := svc.SaveVideoSourceAsset(ConfigVideoSourceAsset{Name: "", SourceType: "rtsp"}, "")
|
|
if err == nil {
|
|
t.Error("expected error for empty name")
|
|
}
|
|
}
|
|
|
|
// ── New: mergeRuntimeInstancePatch wraps nodes under override ──
|
|
|
|
func TestMergeRuntimeInstancePatch_WrapsNodesUnderOverride(t *testing.T) {
|
|
overlay := map[string]any{
|
|
"nodes": map[string]any{
|
|
"alarm_violation": map[string]any{
|
|
"rules": []any{
|
|
map[string]any{"name": "test_rule", "cooldown_ms": 30000},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
instance := map[string]any{
|
|
"name": "inst1",
|
|
"template": "tpl__inst1",
|
|
}
|
|
|
|
result := mergeRuntimeInstancePatch(instance, overlay)
|
|
|
|
// Edge expects instance.override.nodes, NOT instance.nodes
|
|
if _, ok := result["nodes"]; ok {
|
|
t.Error("nodes should NOT be at instance root level")
|
|
}
|
|
ov, ok := result["override"].(map[string]any)
|
|
if !ok {
|
|
t.Fatal("expected instance.override to be present")
|
|
}
|
|
nodes, ok := ov["nodes"].(map[string]any)
|
|
if !ok {
|
|
t.Fatal("expected instance.override.nodes to be present")
|
|
}
|
|
if node, ok := nodes["alarm_violation"].(map[string]any); !ok {
|
|
t.Error("expected alarm_violation node override")
|
|
} else {
|
|
rules := node["rules"].([]any)
|
|
if len(rules) != 1 {
|
|
t.Errorf("expected 1 rule, got %d", len(rules))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMergeRuntimeInstancePatch_PreservesParams(t *testing.T) {
|
|
patch := map[string]any{
|
|
"params": map[string]any{"key": "value"},
|
|
"nodes": map[string]any{"detect_face": map[string]any{"conf_thresh": 0.8}},
|
|
}
|
|
|
|
instance := map[string]any{
|
|
"name": "inst1",
|
|
"template": "tpl__inst1",
|
|
}
|
|
|
|
result := mergeRuntimeInstancePatch(instance, patch)
|
|
|
|
// params at root level
|
|
if params, ok := result["params"].(map[string]any); !ok {
|
|
t.Error("expected params at instance root")
|
|
} else {
|
|
if params["key"] != "value" {
|
|
t.Errorf("params key = %v", params["key"])
|
|
}
|
|
}
|
|
|
|
// nodes under override
|
|
ov, ok := result["override"].(map[string]any)
|
|
if !ok {
|
|
t.Fatal("expected instance.override")
|
|
}
|
|
if _, ok := ov["nodes"]; !ok {
|
|
t.Error("expected instance.override.nodes")
|
|
}
|
|
}
|