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.
236 lines
8.5 KiB
Go
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)
|
|
}
|
|
}
|