Files
uopi/internal/api/synthetic_test.go
T
Martino Ferrari c0f7e662be Testing
2026-06-24 01:39:15 +02:00

176 lines
5.4 KiB
Go

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)
}