package db import ( "errors" "path/filepath" "testing" ) func newTestStore(t *testing.T) *Store { t.Helper() path := filepath.Join(t.TempDir(), "test.db") s, err := Open(path) if err != nil { t.Fatalf("open: %v", err) } t.Cleanup(func() { _ = s.Close() }) if err := Migrate(s.DB()); err != nil { t.Fatalf("migrate: %v", err) } return s } // --------------------------------------------------------------------- projects func TestCreateProject_DefaultsDrawingName(t *testing.T) { s := newTestStore(t) p, err := s.CreateProject("LOFT", "", "") if err != nil { t.Fatalf("create: %v", err) } if p.Name != "LOFT" { t.Errorf("name = %q, want LOFT", p.Name) } if p.DrawingName != "LOFT.excalidraw" { t.Errorf("drawing_name = %q, want LOFT.excalidraw", p.DrawingName) } } func TestCreateProject_AcceptsExplicitDrawingName(t *testing.T) { s := newTestStore(t) p, err := s.CreateProject("OFFICE", "office-rack.excalidraw", "rack only") if err != nil { t.Fatalf("create: %v", err) } if p.DrawingName != "office-rack.excalidraw" { t.Errorf("drawing_name = %q, want office-rack.excalidraw", p.DrawingName) } if p.Description != "rack only" { t.Errorf("description = %q", p.Description) } } func TestCreateProject_EmptyNameRejected(t *testing.T) { s := newTestStore(t) if _, err := s.CreateProject(" ", "", ""); !errors.Is(err, ErrInvalidInput) { t.Fatalf("err = %v, want ErrInvalidInput", err) } } func TestCreateProject_DuplicateNameRejected(t *testing.T) { s := newTestStore(t) if _, err := s.CreateProject("LOFT", "", ""); err != nil { t.Fatalf("first create: %v", err) } if _, err := s.CreateProject("LOFT", "", ""); !errors.Is(err, ErrConflict) { t.Fatalf("second create err = %v, want ErrConflict", err) } } func TestListProjects_OrderedByName(t *testing.T) { s := newTestStore(t) for _, name := range []string{"OFFICE", "LOFT", "GARAGE"} { if _, err := s.CreateProject(name, "", ""); err != nil { t.Fatalf("create %s: %v", name, err) } } got, err := s.ListProjects() if err != nil { t.Fatalf("list: %v", err) } want := []string{"GARAGE", "LOFT", "OFFICE"} for i, p := range got { if p.Name != want[i] { t.Errorf("[%d] = %q, want %q", i, p.Name, want[i]) } } } func TestGetProject_NotFound(t *testing.T) { s := newTestStore(t) if _, err := s.GetProject(999); !errors.Is(err, ErrNotFound) { t.Fatalf("err = %v, want ErrNotFound", err) } } func TestUpdateProject_PartialFields(t *testing.T) { s := newTestStore(t) p, _ := s.CreateProject("LOFT", "", "") newName := "LOFT-2" updated, err := s.UpdateProject(p.ID, ProjectUpdate{Name: &newName}) if err != nil { t.Fatalf("update: %v", err) } if updated.Name != "LOFT-2" { t.Errorf("name = %q, want LOFT-2", updated.Name) } // drawing_name should not auto-change from a Name update — it's only // auto-defaulted when drawing_name is explicitly set to empty. if updated.DrawingName != "LOFT.excalidraw" { t.Errorf("drawing_name = %q, want LOFT.excalidraw (unchanged)", updated.DrawingName) } } func TestUpdateProject_BlankDrawingNameDefaults(t *testing.T) { s := newTestStore(t) p, _ := s.CreateProject("LOFT", "old.excalidraw", "") blank := " " updated, err := s.UpdateProject(p.ID, ProjectUpdate{DrawingName: &blank}) if err != nil { t.Fatalf("update: %v", err) } if updated.DrawingName != "LOFT.excalidraw" { t.Errorf("drawing_name = %q, want LOFT.excalidraw", updated.DrawingName) } } func TestDeleteProject_ConfirmGuardrail(t *testing.T) { s := newTestStore(t) p, _ := s.CreateProject("LOFT", "", "") // Wrong name → no delete. if err := s.DeleteProject(p.ID, "OFFICE"); !errors.Is(err, ErrConfirmName) { t.Fatalf("wrong-name err = %v, want ErrConfirmName", err) } if _, err := s.GetProject(p.ID); err != nil { t.Fatalf("project should still exist: %v", err) } // Empty confirm → no delete. if err := s.DeleteProject(p.ID, ""); !errors.Is(err, ErrConfirmName) { t.Fatalf("empty-confirm err = %v, want ErrConfirmName", err) } // Correct name → delete. if err := s.DeleteProject(p.ID, "LOFT"); err != nil { t.Fatalf("correct-name delete: %v", err) } if _, err := s.GetProject(p.ID); !errors.Is(err, ErrNotFound) { t.Fatalf("project should be gone: %v", err) } } func TestSnapshot_IncludesGlobalCableTypes(t *testing.T) { s := newTestStore(t) p, _ := s.CreateProject("LOFT", "", "") snap, err := s.Snapshot(p.ID) if err != nil { t.Fatalf("snapshot: %v", err) } if snap.Project.ID != p.ID { t.Errorf("project.id = %d, want %d", snap.Project.ID, p.ID) } if len(snap.CableTypes) != 5 { t.Errorf("cable_types len = %d, want 5 (the seeded defaults)", len(snap.CableTypes)) } if snap.Frames == nil || snap.Devices == nil || snap.Ports == nil || snap.Cables == nil || snap.IOMarkers == nil || snap.Bundles == nil { t.Errorf("snapshot collections must be non-nil arrays, not null, for slice-1 JSON output") } } // ------------------------------------------------------------------ cable_types func TestListCableTypes_SeededFive(t *testing.T) { s := newTestStore(t) ts, err := s.ListCableTypes() if err != nil { t.Fatalf("list: %v", err) } wantNames := []string{"Power", "USB", "HDMI", "DP", "RJ45"} if len(ts) != 5 { t.Fatalf("len = %d, want 5", len(ts)) } for i, want := range wantNames { if ts[i].Name != want { t.Errorf("[%d].Name = %q, want %q", i, ts[i].Name, want) } if ts[i].Color == "" { t.Errorf("[%d].Color empty", i) } } } func TestCreateCableType_GlobalUnique(t *testing.T) { s := newTestStore(t) if _, err := s.CreateCableType("Audio", "#ff0000"); err != nil { t.Fatalf("create: %v", err) } if _, err := s.CreateCableType("Audio", "#00ff00"); !errors.Is(err, ErrConflict) { t.Fatalf("dup err = %v, want ErrConflict", err) } } func TestUpdateCableType_RenameAndRecolour(t *testing.T) { s := newTestStore(t) ts, _ := s.ListCableTypes() hdmi := ts[2] // seed order: Power, USB, HDMI, DP, RJ45 newName := "HDMI-2.1" newColor := "#000000" updated, err := s.UpdateCableType(hdmi.ID, CableTypeUpdate{Name: &newName, Color: &newColor}) if err != nil { t.Fatalf("update: %v", err) } if updated.Name != "HDMI-2.1" || updated.Color != "#000000" { t.Errorf("got %+v", updated) } } func TestDeleteCableType_BlockedByCable(t *testing.T) { s := newTestStore(t) p, _ := s.CreateProject("LOFT", "", "") // Reach the seeded Power cable type. ts, _ := s.ListCableTypes() power := ts[0] // Wire up a minimal cable referencing the Power type via the raw DB // (the typed device/port API ships in slice 2+). The schema CHECK // requires exactly one endpoint each side — use device-level binding // against placeholder rows. d := s.DB() res, err := d.Exec(`INSERT INTO devices (project_id, name, x, y, width, height) VALUES (?, ?, 0, 0, 100, 30)`, p.ID, "PlaceholderA") if err != nil { t.Fatalf("insert device A: %v", err) } deviceA, _ := res.LastInsertId() res, err = d.Exec(`INSERT INTO devices (project_id, name, x, y, width, height) VALUES (?, ?, 0, 0, 100, 30)`, p.ID, "PlaceholderB") if err != nil { t.Fatalf("insert device B: %v", err) } deviceB, _ := res.LastInsertId() if _, err := d.Exec(`INSERT INTO cables (project_id, type_id, from_device_id, to_device_id) VALUES (?, ?, ?, ?)`, p.ID, power.ID, deviceA, deviceB); err != nil { t.Fatalf("insert cable: %v", err) } // Now delete → must be blocked. if err := s.DeleteCableType(power.ID); !errors.Is(err, ErrInUse) { t.Fatalf("delete err = %v, want ErrInUse", err) } n, err := s.CountCablesUsingType(power.ID) if err != nil { t.Fatalf("count: %v", err) } if n != 1 { t.Errorf("count = %d, want 1", n) } } func TestDeleteCableType_UnusedSucceeds(t *testing.T) { s := newTestStore(t) t2, _ := s.CreateCableType("Audio", "#000000") if err := s.DeleteCableType(t2.ID); err != nil { t.Fatalf("delete: %v", err) } } func TestDeleteProject_DoesNotTouchCableTypes(t *testing.T) { s := newTestStore(t) p, _ := s.CreateProject("LOFT", "", "") if err := s.DeleteProject(p.ID, "LOFT"); err != nil { t.Fatalf("delete: %v", err) } ts, _ := s.ListCableTypes() if len(ts) != 5 { t.Errorf("cable_types should survive project deletion; got %d, want 5", len(ts)) } }