Files
CableGUI/internal/db/frames_devices_test.go
mAi cf1671e8c1 feat: db store — frames + devices CRUD, project-scoped
Snapshot now populates frames + devices from the DB (slice 1 left them as
empty arrays).

Frame store:
- CreateFrame requires positive width/height; rejects empty name; UNIQUE
  (project_id, name) collisions surface as ErrConflict via mapWriteErr.
- GetFrame is project-scoped — wrong-project read returns ErrNotFound.
- UpdateFrame applies a partial; project_id is not exposed (moving a
  frame across projects would orphan its devices).
- DeleteFrame relies on the schema's ON DELETE SET NULL to drop
  devices' frame_id refs cleanly; verified by test.

Device store:
- CreateDevice defaults color to #1e1e1e if blank; rejects empty name,
  non-positive size; validates frame_id is in the same project (returns
  ErrInvalidInput on cross-project ref).
- UpdateDevice uses a FrameRef tri-state for frame_id so callers can
  distinguish "leave alone" from "clear to NULL" from "move to frame X".
- Cross-project frame_id on PATCH is rejected with ErrInvalidInput.
- ListDevices supports an optional frame_id filter.

13 new table-driven tests, all green with -race.
2026-05-15 18:16:33 +02:00

236 lines
8.5 KiB
Go

package db
import (
"errors"
"testing"
)
// ----------------------------------------------------------------------- frames
func TestCreateFrame_Basics(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
f, err := s.CreateFrame(p.ID, FrameCreate{Name: "desk", X: 10, Y: 20, Width: 800, Height: 600})
if err != nil {
t.Fatalf("create: %v", err)
}
if f.ProjectID != p.ID || f.Name != "desk" || f.Width != 800 {
t.Errorf("unexpected frame: %+v", f)
}
}
func TestCreateFrame_RejectsZeroSize(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
if _, err := s.CreateFrame(p.ID, FrameCreate{Name: "x", Width: 0, Height: 50}); !errors.Is(err, ErrInvalidInput) {
t.Errorf("zero width should be ErrInvalidInput; got %v", err)
}
if _, err := s.CreateFrame(p.ID, FrameCreate{Name: "y", Width: 50, Height: 0}); !errors.Is(err, ErrInvalidInput) {
t.Errorf("zero height should be ErrInvalidInput; got %v", err)
}
}
func TestCreateFrame_DuplicateNameInSameProject(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
if _, err := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 100, Height: 50}); err != nil {
t.Fatalf("first: %v", err)
}
if _, err := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 200, Height: 70}); !errors.Is(err, ErrConflict) {
t.Errorf("duplicate frame name should ErrConflict; got %v", err)
}
}
func TestCreateFrame_SameNameAcrossProjectsOK(t *testing.T) {
s := newTestStore(t)
p1, _ := s.CreateProject("LOFT", "", "")
p2, _ := s.CreateProject("OFFICE", "", "")
if _, err := s.CreateFrame(p1.ID, FrameCreate{Name: "desk", Width: 100, Height: 50}); err != nil {
t.Fatalf("p1: %v", err)
}
if _, err := s.CreateFrame(p2.ID, FrameCreate{Name: "desk", Width: 100, Height: 50}); err != nil {
t.Fatalf("p2: %v", err)
}
}
func TestGetFrame_WrongProjectIsNotFound(t *testing.T) {
s := newTestStore(t)
p1, _ := s.CreateProject("LOFT", "", "")
p2, _ := s.CreateProject("OFFICE", "", "")
f, _ := s.CreateFrame(p1.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
if _, err := s.GetFrame(p2.ID, f.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("cross-project GetFrame should be ErrNotFound; got %v", err)
}
}
func TestListFrames_OrderedByCreation(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
for _, n := range []string{"rack", "desk", "media"} {
if _, err := s.CreateFrame(p.ID, FrameCreate{Name: n, Width: 100, Height: 50}); err != nil {
t.Fatalf("create %s: %v", n, err)
}
}
got, _ := s.ListFrames(p.ID)
if len(got) != 3 {
t.Fatalf("len = %d", len(got))
}
if got[0].Name != "rack" || got[2].Name != "media" {
t.Errorf("order = %v", []string{got[0].Name, got[1].Name, got[2].Name})
}
}
func TestUpdateFrame_PartialFields(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
f, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", X: 0, Y: 0, Width: 100, Height: 50})
nx := 42.0
updated, err := s.UpdateFrame(p.ID, f.ID, FrameUpdate{X: &nx})
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.X != 42 || updated.Name != "desk" || updated.Width != 100 {
t.Errorf("got %+v", updated)
}
}
func TestDeleteFrame_SetsDeviceFrameIDToNull(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
f, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 800, Height: 600})
d, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Mac", FrameID: &f.ID, X: 10, Y: 20, Width: 100, Height: 35})
if d.FrameID == nil || *d.FrameID != f.ID {
t.Fatalf("device frame_id pre-delete = %v, want %d", d.FrameID, f.ID)
}
if err := s.DeleteFrame(p.ID, f.ID); err != nil {
t.Fatalf("delete frame: %v", err)
}
d2, _ := s.GetDevice(p.ID, d.ID)
if d2.FrameID != nil {
t.Errorf("device frame_id post-delete = %v, want nil (SET NULL)", d2.FrameID)
}
}
// ---------------------------------------------------------------------- devices
func TestCreateDevice_DefaultsColor(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
d, err := s.CreateDevice(p.ID, DeviceCreate{Name: "Mac", X: 10, Y: 20, Width: 100, Height: 35})
if err != nil {
t.Fatalf("create: %v", err)
}
if d.Color != "#1e1e1e" {
t.Errorf("default color = %q, want #1e1e1e", d.Color)
}
if d.FrameID != nil {
t.Errorf("frame_id = %v, want nil for unframed device", d.FrameID)
}
}
func TestCreateDevice_DuplicateNameInProject(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
if _, err := s.CreateDevice(p.ID, DeviceCreate{Name: "Mac", X: 0, Y: 0, Width: 100, Height: 35}); err != nil {
t.Fatalf("first: %v", err)
}
if _, err := s.CreateDevice(p.ID, DeviceCreate{Name: "Mac", X: 10, Y: 10, Width: 100, Height: 35}); !errors.Is(err, ErrConflict) {
t.Errorf("dup device name should ErrConflict; got %v", err)
}
}
func TestCreateDevice_CrossProjectFrameRejected(t *testing.T) {
s := newTestStore(t)
p1, _ := s.CreateProject("LOFT", "", "")
p2, _ := s.CreateProject("OFFICE", "", "")
f2, _ := s.CreateFrame(p2.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
// Try to put a LOFT device into an OFFICE frame.
_, err := s.CreateDevice(p1.ID, DeviceCreate{Name: "Mac", FrameID: &f2.ID, X: 0, Y: 0, Width: 100, Height: 35})
if !errors.Is(err, ErrInvalidInput) {
t.Errorf("cross-project frame_id should ErrInvalidInput; got %v", err)
}
}
func TestUpdateDevice_FrameIDTriState(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
f1, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
f2, _ := s.CreateFrame(p.ID, FrameCreate{Name: "rack", Width: 100, Height: 50})
d, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Mac", FrameID: &f1.ID, X: 0, Y: 0, Width: 100, Height: 35})
// Leave alone (FrameID.Set=false) — even passing a different X.
nx := 99.0
u1, _ := s.UpdateDevice(p.ID, d.ID, DeviceUpdate{X: &nx})
if u1.FrameID == nil || *u1.FrameID != f1.ID {
t.Errorf("frame_id should be unchanged (f1); got %v", u1.FrameID)
}
// Move to f2.
u2, _ := s.UpdateDevice(p.ID, d.ID, DeviceUpdate{FrameID: FrameRef{Set: true, ID: &f2.ID}})
if u2.FrameID == nil || *u2.FrameID != f2.ID {
t.Errorf("frame_id should be f2; got %v", u2.FrameID)
}
// Clear (move outside any frame).
u3, _ := s.UpdateDevice(p.ID, d.ID, DeviceUpdate{FrameID: FrameRef{Set: true, ID: nil}})
if u3.FrameID != nil {
t.Errorf("frame_id should be nil after Set:true,ID:nil; got %v", *u3.FrameID)
}
}
func TestUpdateDevice_RejectsCrossProjectFrame(t *testing.T) {
s := newTestStore(t)
p1, _ := s.CreateProject("LOFT", "", "")
p2, _ := s.CreateProject("OFFICE", "", "")
d, _ := s.CreateDevice(p1.ID, DeviceCreate{Name: "Mac", X: 0, Y: 0, Width: 100, Height: 35})
f2, _ := s.CreateFrame(p2.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
_, err := s.UpdateDevice(p1.ID, d.ID, DeviceUpdate{FrameID: FrameRef{Set: true, ID: &f2.ID}})
if !errors.Is(err, ErrInvalidInput) {
t.Errorf("cross-project frame_id should ErrInvalidInput; got %v", err)
}
}
func TestListDevices_FilterByFrame(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
f1, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
f2, _ := s.CreateFrame(p.ID, FrameCreate{Name: "rack", Width: 100, Height: 50})
_, _ = s.CreateDevice(p.ID, DeviceCreate{Name: "A", FrameID: &f1.ID, Width: 100, Height: 35})
_, _ = s.CreateDevice(p.ID, DeviceCreate{Name: "B", FrameID: &f2.ID, Width: 100, Height: 35})
_, _ = s.CreateDevice(p.ID, DeviceCreate{Name: "C", Width: 100, Height: 35}) // outside
all, _ := s.ListDevices(p.ID, nil)
if len(all) != 3 {
t.Errorf("all len = %d, want 3", len(all))
}
inF1, _ := s.ListDevices(p.ID, &f1.ID)
if len(inF1) != 1 || inF1[0].Name != "A" {
t.Errorf("inF1 = %+v", inF1)
}
}
func TestSnapshot_PopulatesFramesAndDevices(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
f, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
_, _ = s.CreateDevice(p.ID, DeviceCreate{Name: "Mac", FrameID: &f.ID, Width: 100, Height: 35})
snap, err := s.Snapshot(p.ID)
if err != nil {
t.Fatalf("snapshot: %v", err)
}
if len(snap.Frames) != 1 || len(snap.Devices) != 1 {
t.Errorf("snapshot frames=%d devices=%d", len(snap.Frames), len(snap.Devices))
}
if len(snap.CableTypes) != 5 {
t.Errorf("cable_types = %d, want 5", len(snap.CableTypes))
}
}
func TestDeleteDevice_NotFoundIsNotFound(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
if err := s.DeleteDevice(p.ID, 999); !errors.Is(err, ErrNotFound) {
t.Errorf("got %v, want ErrNotFound", err)
}
}