From d1f85ee79425a2bd95191fc998cd062eb85ad09b Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Mon, 27 Jul 2026 19:43:59 +0800 Subject: [PATCH] test: add 18 tests - injectIntegrations, device overlays, tuning, template clone, mergeRuntimeInstancePatch, wizard, ResolveChannelToSource, SaveVideoSourceAsset --- internal/service/auto_config_test.go | 59 ++++++ internal/service/config_preview_test.go | 254 ++++++++++++++++++++++++ internal/service/tuning_test.go | 245 +++++++++++++++++++++++ internal/web/ui_test.go | 129 ++++++++++++ 4 files changed, 687 insertions(+) create mode 100644 internal/service/tuning_test.go diff --git a/internal/service/auto_config_test.go b/internal/service/auto_config_test.go index 3c3ce7e..33277f6 100644 --- a/internal/service/auto_config_test.go +++ b/internal/service/auto_config_test.go @@ -341,3 +341,62 @@ func TestBuildPipeline_EmptySources(t *testing.T) { t.Error("expected error for empty sources") } } + +func TestBuildPipeline_ClonesStandardTemplate(t *testing.T) { + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "clone.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + + repo := storage.NewAssetsRepo(store.DB()) + svc := NewConfigPreviewService(&config.Config{}, repo) + + // Seed standard template + tplBody := `{"name":"std_face_recognition_stream","template":{"nodes":[{"id":"in","type":"input_rtsp"}],"edges":[]}}` + if err := repo.SaveTemplate("std_face_recognition_stream", "standard", tplBody); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + svc.SaveVideoSourceAsset(ConfigVideoSourceAsset{ + Name: "cam1", SourceType: "rtsp", + Config: VideoSourceConfig{URL: "rtsp://10.0.0.1/live", Resolution: "1920x1080", FPS: "30"}, + }, "") + + reg := NewFeatureRegistry() + auto := NewAutoConfigService(svc, &TaskService{}, reg) + + result, err := auto.BuildPipeline(AutoConfigRequest{ + DeviceID: "test-clone-001", + Features: []string{"face"}, + SourceNames: []string{"cam1"}, + }, false) + + if err != nil || result == nil || result.Error != "" { + t.Fatalf("BuildPipeline: err=%v result=%v", err, result) + } + + // Verify user template was cloned + userTpl, err := repo.GetTemplate("face_recognition_stream") + if err != nil || userTpl == nil { + t.Error("expected user template face_recognition_stream to be cloned from std_face_recognition_stream") + } + + // Verify profile uses cloned template, not standard + profile, err := repo.GetProfile("auto_test-clone-001") + if err != nil || profile == nil { + t.Fatalf("GetProfile: err=%v", err) + } + if profile.TemplateName != "face_recognition_stream" { + t.Errorf("expected profile to use face_recognition_stream, got %s", profile.TemplateName) + } + + // Second deploy should reuse existing clone (no error) + result2, err := auto.BuildPipeline(AutoConfigRequest{ + DeviceID: "test-clone-001", + Features: []string{"face"}, + SourceNames: []string{"cam1"}, + }, false) + if err != nil || result2 == nil || result2.Error != "" { + t.Fatalf("second BuildPipeline: err=%v result=%v", err, result2) + } +} diff --git a/internal/service/config_preview_test.go b/internal/service/config_preview_test.go index 4395bad..2487b60 100644 --- a/internal/service/config_preview_test.go +++ b/internal/service/config_preview_test.go @@ -570,3 +570,257 @@ func mustWrite(t *testing.T, path string, body string) { 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") + } +} diff --git a/internal/service/tuning_test.go b/internal/service/tuning_test.go new file mode 100644 index 0000000..4695d38 --- /dev/null +++ b/internal/service/tuning_test.go @@ -0,0 +1,245 @@ +package service + +import ( + "path/filepath" + "testing" + + "safesight-control/internal/config" + "safesight-control/internal/storage" +) + +// ── GetDevicesUsingTemplate ── + +func TestGetDevicesUsingTemplate(t *testing.T) { + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "tuning.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + svc := NewConfigPreviewService(&config.Config{}, repo) + + // Seed template + repo.SaveTemplate("workshop_face_recognition_shoe_alarm", "user template", + `{"name":"workshop_face_recognition_shoe_alarm","template":{"nodes":[{"id":"in","type":"input_rtsp"}],"edges":[]}}`) + + // Seed profile referencing the template + repo.SaveProfile("auto_dev1", "workshop_face_recognition_shoe_alarm", "", "", + `{"name":"auto_dev1","primary_template_name":"workshop_face_recognition_shoe_alarm"}`) + repo.SaveProfile("auto_dev2", "workshop_face_recognition_shoe_alarm", "", "", + `{"name":"auto_dev2","primary_template_name":"workshop_face_recognition_shoe_alarm"}`) + repo.SaveProfile("auto_other", "other_template", "", "", + `{"name":"auto_other","primary_template_name":"other_template"}`) + + // Seed device assignments (bodyJSON must be a JSON object, not array) + repo.SaveDeviceAssignment("dev1", "auto_dev1", "", `{"recognition_units":[]}`) + repo.SaveDeviceAssignment("dev2", "auto_dev2", "", `{"recognition_units":[]}`) + repo.SaveDeviceAssignment("dev3", "auto_other", "", `{"recognition_units":[]}`) + + // Verify setup: list profiles and assignments + profiles, _ := repo.ListProfiles() + for _, p := range profiles { + t.Logf(" profile: name=%s template=%s", p.Name, p.TemplateName) + } + assignments, _ := repo.ListDeviceAssignments() + for _, a := range assignments { + t.Logf(" assignment: device=%s profile=%s", a.DeviceID, a.ProfileName) + } + + devices, err := svc.GetDevicesUsingTemplate("workshop_face_recognition_shoe_alarm") + if err != nil { + t.Fatalf("GetDevicesUsingTemplate: %v", err) + } + if len(devices) != 2 { + t.Errorf("expected 2 devices, got %d: %v", len(devices), devices) + } + found := map[string]bool{} + for _, d := range devices { + found[d] = true + } + if !found["dev1"] || !found["dev2"] { + t.Errorf("expected dev1 and dev2, got %v", devices) + } + if found["dev3"] { + t.Error("dev3 should not be affected (uses other_template)") + } +} + +func TestGetDevicesUsingTemplate_NoMatches(t *testing.T) { + store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "tuning2.db")) + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + svc := NewConfigPreviewService(&config.Config{}, repo) + + devices, err := svc.GetDevicesUsingTemplate("nonexistent") + if err != nil { + t.Fatalf("GetDevicesUsingTemplate: %v", err) + } + if len(devices) != 0 { + t.Errorf("expected 0 devices, got %d", len(devices)) + } +} + +// ── Tuning Definitions ── + +func TestTuningDefinitions_HaveConsistentPaths(t *testing.T) { + defs := tuningDefinitions["std_workshop_face_recognition_shoe_alarm"] + if len(defs) == 0 { + t.Fatal("expected tuning definitions for std_workshop_face_recognition_shoe_alarm") + } + + for _, item := range defs { + if item.Path == "" { + t.Error("tuning item has empty path") + } + if item.Label == "" { + t.Errorf("tuning item %q has empty label", item.Path) + } + if item.Group == "" { + t.Errorf("tuning item %q has empty group", item.Path) + } + if item.Min >= item.Max { + t.Errorf("tuning item %q: min(%v) >= max(%v)", item.Path, item.Min, item.Max) + } + if item.Step <= 0 { + t.Errorf("tuning item %q: step must be > 0", item.Path) + } + // Verify path format: node_id.field... + nodeID, path := splitTuningPath(item.Path) + if nodeID == "" || path == "" { + t.Errorf("tuning item %q: invalid path format, expected node_id.field...", item.Path) + } + } +} + +func TestTuningDefinitions_GroupsNotEmpty(t *testing.T) { + defs := tuningDefinitions["std_workshop_face_recognition_shoe_alarm"] + groups := map[string]int{} + for _, item := range defs { + groups[item.Group]++ + } + + // Each group should have at least 1 parameter + for group, count := range groups { + if count == 0 { + t.Errorf("group %q is empty", group) + } + } +} + +// ── Tuning Path Resolution ── + +func TestSplitTuningPath(t *testing.T) { + tests := []struct { + full, node, rest string + }{ + {"alarm_violation.rules[0].cooldown_ms", "alarm_violation", "rules[0].cooldown_ms"}, + {"detect_face.conf_thresh", "detect_face", "conf_thresh"}, + {"node_id", "node_id", ""}, + } + for _, tt := range tests { + node, rest := splitTuningPath(tt.full) + if node != tt.node || rest != tt.rest { + t.Errorf("splitTuningPath(%q) = (%q, %q), want (%q, %q)", + tt.full, node, rest, tt.node, tt.rest) + } + } +} + +func TestResolveTuningPath(t *testing.T) { + node := map[string]any{ + "threshold": map[string]any{"accept": 0.45}, + "rules": []any{ + map[string]any{"cooldown_ms": float64(60000), "min_score": 0.5}, + }, + } + + if v, ok := resolveTuningPath(node, "threshold.accept"); !ok || v != 0.45 { + t.Errorf("threshold.accept: got %v, want 0.45", v) + } + if v, ok := resolveTuningPath(node, "rules[0].cooldown_ms"); !ok || v != 60000 { + t.Errorf("rules[0].cooldown_ms: got %v, want 60000", v) + } + if v, ok := resolveTuningPath(node, "rules[0].min_score"); !ok || v != 0.5 { + t.Errorf("rules[0].min_score: got %v, want 0.5", v) + } + if _, ok := resolveTuningPath(node, "nonexistent.field"); ok { + t.Error("nonexistent.field should not be found") + } +} + +func TestSetTuningPath(t *testing.T) { + node := map[string]any{ + "threshold": map[string]any{"accept": 0.45}, + "rules": []any{ + map[string]any{"cooldown_ms": float64(60000)}, + }, + } + + if err := setTuningPath(node, "threshold.accept", 0.6); err != nil { + t.Fatalf("setTuningPath: %v", err) + } + if v, _ := resolveTuningPath(node, "threshold.accept"); v != 0.6 { + t.Errorf("after set: got %v, want 0.6", v) + } + + if err := setTuningPath(node, "rules[0].cooldown_ms", 30000); err != nil { + t.Fatalf("setTuningPath: %v", err) + } + if v, _ := resolveTuningPath(node, "rules[0].cooldown_ms"); v != 30000 { + t.Errorf("after set: got %v, want 30000", v) + } +} + +func TestSetTuningPath_InvalidIndex(t *testing.T) { + node := map[string]any{ + "rules": []any{map[string]any{"x": float64(1)}}, + } + if err := setTuningPath(node, "rules[5].x", 42); err == nil { + t.Error("expected error for out-of-range index") + } +} + +// ── Device Overlay Persistence ── + +func TestSaveAndGetDeviceOverlays(t *testing.T) { + store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "overlay.db")) + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + svc := NewConfigPreviewService(&config.Config{}, repo) + + if err := svc.SaveDeviceOverlays("dev1", []string{"face_debug", "production_quiet"}); err != nil { + t.Fatalf("SaveDeviceOverlays: %v", err) + } + + overlays, err := svc.GetDeviceOverlays("dev1") + if err != nil { + t.Fatalf("GetDeviceOverlays: %v", err) + } + if len(overlays) != 2 { + t.Errorf("expected 2 overlays, got %d: %v", len(overlays), overlays) + } + + // Overwrite + if err := svc.SaveDeviceOverlays("dev1", []string{}); err != nil { + t.Fatalf("SaveDeviceOverlays (empty): %v", err) + } + overlays, _ = svc.GetDeviceOverlays("dev1") + if len(overlays) != 0 { + t.Errorf("expected 0 overlays after clear, got %d", len(overlays)) + } +} + +func TestGetDeviceOverlays_UnknownDevice(t *testing.T) { + store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "overlay2.db")) + defer store.Close() + svc := NewConfigPreviewService(&config.Config{}, storage.NewAssetsRepo(store.DB())) + + overlays, err := svc.GetDeviceOverlays("nonexistent") + if err != nil { + t.Fatalf("GetDeviceOverlays: %v", err) + } + if len(overlays) != 0 { + t.Errorf("expected empty for unknown device, got %v", overlays) + } +} diff --git a/internal/web/ui_test.go b/internal/web/ui_test.go index ff063b9..5572156 100644 --- a/internal/web/ui_test.go +++ b/internal/web/ui_test.go @@ -4219,3 +4219,132 @@ func TestUI_ConsoleSave_PipelineCreatesTask(t *testing.T) { t.Errorf("expected task in redirect, got %s", loc) } } + +// ── P1 #5: Wizard Apply ── + +func TestUI_ActionWizardApply(t *testing.T) { + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "wizard.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + svc := service.NewConfigPreviewService(&config.Config{}, repo) + + // Seed template and video source + repo.SaveTemplate("std_face_recognition_stream", "standard", + `{"name":"std_face_recognition_stream","template":{"nodes":[{"id":"in","type":"input_rtsp"}],"edges":[]}}`) + svc.SaveVideoSourceAsset(service.ConfigVideoSourceAsset{ + Name: "cam1", SourceType: "rtsp", + Config: service.VideoSourceConfig{URL: "rtsp://10.0.0.1/live", Resolution: "1920x1080", FPS: "30"}, + }, "") + + reg := service.NewFeatureRegistry() + tasks := service.NewTaskService(&config.Config{}, nil, service.NewRegistryService(&config.Config{Concurrency: 1}, nil)) + autoCfg := service.NewAutoConfigService(svc, tasks, reg) + + ui, err := NewUI(nil, nil, nil, tasks, nil, svc) + if err != nil { + t.Fatalf("NewUI: %v", err) + } + ui.SetAutoConfig(autoCfg) + + body := `{"device_id":"wizard-dev-001","features":["face"],"source_names":["cam1"]}` + req := httptest.NewRequest(http.MethodPost, "/wizard/apply", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + ui.actionWizardApply(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + var result service.AutoConfigResult + if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + if result.Error != "" { + t.Fatalf("wizard apply error: %s", result.Error) + } + // Verify features were persisted + features, err := svc.GetDeviceFeatures("wizard-dev-001") + if err != nil || len(features) == 0 { + t.Error("expected device features to be persisted") + } +} + +func TestUI_ActionWizardApply_InvalidJSON(t *testing.T) { + // Without autoConfig, wizard returns 503 (service unavailable) + ui, _ := NewUI(nil, nil, nil, nil, nil) + req := httptest.NewRequest(http.MethodPost, "/wizard/apply", strings.NewReader(`not json`)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + ui.actionWizardApply(rr, req) + if rr.Code != http.StatusServiceUnavailable { + t.Errorf("expected 503, got %d", rr.Code) + } +} + +// ── Device Overlay Save ── + +func TestUI_ActionDeviceOverlaysSave(t *testing.T) { + store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "devoverlay.db")) + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + svc := service.NewConfigPreviewService(&config.Config{}, repo) + + ui, _ := NewUI(nil, nil, nil, nil, nil, svc) + + form := url.Values{} + form.Add("overlay", "face_debug") + form.Add("overlay", "production_quiet") + + req := httptest.NewRequest(http.MethodPost, "/devices/dev1/overlays", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + + // Set chi param + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", "dev1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + ui.actionDeviceOverlaysSave(rr, req) + + if rr.Code != http.StatusFound { + t.Fatalf("expected 302, got %d", rr.Code) + } + + overlays, err := svc.GetDeviceOverlays("dev1") + if err != nil { + t.Fatalf("GetDeviceOverlays: %v", err) + } + if len(overlays) != 2 { + t.Errorf("expected 2 overlays, got %d", len(overlays)) + } +} + +func TestUI_ActionDeviceOverlaysSave_Clears(t *testing.T) { + store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "devoverlay2.db")) + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + svc := service.NewConfigPreviewService(&config.Config{}, repo) + svc.SaveDeviceOverlays("dev1", []string{"old_overlay"}) + + ui, _ := NewUI(nil, nil, nil, nil, nil, svc) + + // Submit with no overlays checked + req := httptest.NewRequest(http.MethodPost, "/devices/dev1/overlays", strings.NewReader("")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", "dev1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + ui.actionDeviceOverlaysSave(rr, req) + + overlays, _ := svc.GetDeviceOverlays("dev1") + if len(overlays) != 0 { + t.Errorf("expected 0 overlays after clear, got %d", len(overlays)) + } +}