package api_test import ( "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "testing" "github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/api" "github.com/uopi/uopi/internal/audit" "github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/confmgr" "github.com/uopi/uopi/internal/controllogic" "github.com/uopi/uopi/internal/datasource/stub" "github.com/uopi/uopi/internal/datasource/synthetic" "github.com/uopi/uopi/internal/panelacl" "github.com/uopi/uopi/internal/storage" ) // setupSynthetic builds an API server whose synthetic data source is enabled, so // the synthetic CRUD/versioning/trace handlers run their real bodies. func setupSynthetic(t *testing.T) (*httptest.Server, func()) { t.Helper() ctx, cancel := context.WithCancel(context.Background()) log := slog.New(slog.NewTextHandler(io.Discard, nil)) brk := broker.New(ctx, log) ds := stub.New() if err := ds.Connect(ctx); err != nil { t.Fatal("stub connect:", err) } brk.Register(ds) dir := t.TempDir() store, _ := storage.New(dir) acl, _ := panelacl.New(dir) clStore, _ := controllogic.NewStore(dir) cfgStore, _ := confmgr.New(dir) clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log) synthDS := synthetic.New(dir, brk, log) if err := synthDS.Connect(ctx); err != nil { t.Fatal("synthetic connect:", err) } brk.Register(synthDS) mux := http.NewServeMux() api.New(brk, synthDS, store, cfgStore, access.New("", nil), acl, clStore, clEngine, audit.Nop(), "", "", 0, log).Register(mux, "/api/v1") srv := httptest.NewServer(mux) return srv, func() { srv.Close(); cancel() } } // putJSON marshals body and issues a PUT, mirroring postJSON. func putJSON(t *testing.T, srv *httptest.Server, path string, body any) *http.Response { t.Helper() b, err := json.Marshal(body) if err != nil { t.Fatal("json.Marshal:", err) } return putRaw(t, srv, path, "application/json", b) } // syntheticBody returns a minimal valid graph signal sourced from the stub's // "sine" signal through a gain op. func syntheticBody(name string) map[string]any { return map[string]any{ "name": name, "visibility": "global", "graph": map[string]any{ "output": "out", "nodes": []map[string]any{ {"id": "a", "kind": "source", "ds": "stub", "signal": "sine"}, {"id": "g", "kind": "op", "op": "gain", "inputs": []string{"a"}, "params": map[string]any{"k": 2.0}}, {"id": "out", "kind": "output", "inputs": []string{"g"}}, }, }, } } func TestSyntheticCRUDEnabled(t *testing.T) { srv, teardown := setupSynthetic(t) defer teardown() // List — initially empty. resp := get(t, srv, "/api/v1/synthetic") assertStatus(t, resp, http.StatusOK) var list []any readJSON(t, resp, &list) if len(list) != 0 { t.Fatalf("expected no synthetic signals, got %d", len(list)) } // Create. resp = postJSON(t, srv, "/api/v1/synthetic", syntheticBody("doubled")) assertStatus(t, resp, http.StatusCreated) resp.Body.Close() // Duplicate create → 400 (AddSignal rejects an existing name). resp = postJSON(t, srv, "/api/v1/synthetic", syntheticBody("doubled")) assertStatus(t, resp, http.StatusBadRequest) // Invalid JSON → 400. resp = postRaw(t, srv, "/api/v1/synthetic", "application/json", []byte(`{bad`)) assertStatus(t, resp, http.StatusBadRequest) // Get. resp = get(t, srv, "/api/v1/synthetic/doubled") assertStatus(t, resp, http.StatusOK) resp.Body.Close() // Get missing → 404. resp = get(t, srv, "/api/v1/synthetic/nope") assertStatus(t, resp, http.StatusNotFound) // Update (changes gain factor). upd := syntheticBody("doubled") upd["graph"].(map[string]any)["nodes"].([]map[string]any)[1]["params"] = map[string]any{"k": 3.0} resp = putJSON(t, srv, "/api/v1/synthetic/doubled", upd) assertStatus(t, resp, http.StatusOK) resp.Body.Close() // Update missing → 404. resp = putJSON(t, srv, "/api/v1/synthetic/nope", syntheticBody("nope")) assertStatus(t, resp, http.StatusNotFound) // Versions list (the update created a backup). resp = get(t, srv, "/api/v1/synthetic/doubled/versions") assertStatus(t, resp, http.StatusOK) var versions []map[string]any readJSON(t, resp, &versions) if len(versions) < 2 { t.Fatalf("expected >=2 synthetic versions, got %d", len(versions)) } // Get v1. resp = get(t, srv, "/api/v1/synthetic/doubled/versions/1") assertStatus(t, resp, http.StatusOK) resp.Body.Close() // Bad version → 400. resp = get(t, srv, "/api/v1/synthetic/doubled/versions/xyz") assertStatus(t, resp, http.StatusBadRequest) // Promote v1. resp = postRaw(t, srv, "/api/v1/synthetic/doubled/versions/1/promote", "application/json", nil) assertStatus(t, resp, http.StatusOK) resp.Body.Close() // Fork v1 → new signal. resp = postRaw(t, srv, "/api/v1/synthetic/doubled/versions/1/fork", "application/json", nil) assertStatus(t, resp, http.StatusCreated) resp.Body.Close() // Trace an unsaved graph. resp = postJSON(t, srv, "/api/v1/synthetic/trace", syntheticBody("scratch")) assertStatus(t, resp, http.StatusOK) resp.Body.Close() // Trace with invalid JSON → 400. resp = postRaw(t, srv, "/api/v1/synthetic/trace", "application/json", []byte(`{bad`)) assertStatus(t, resp, http.StatusBadRequest) // Delete. resp = deleteReq(t, srv, "/api/v1/synthetic/doubled") assertStatus(t, resp, http.StatusNoContent) // Delete missing → 404. resp = deleteReq(t, srv, "/api/v1/synthetic/doubled") assertStatus(t, resp, http.StatusNotFound) }