Files
CableGUI/internal/db/store_test.go
mAi 905c75c6db test+docs: store coverage + README for slice 1
Adds table-driven store tests:
- projects: drawing_name auto-default, explicit-name accept, empty-name
  reject, duplicate-name conflict, ordered list, GetProject not-found,
  partial PATCH semantics, blank-drawing-name re-default on PATCH,
  ?confirm=<name> guardrail (wrong / empty / correct), snapshot returns
  the 5 globally-seeded cable_types
- cable_types: 5 seeded with the legend colours, global UNIQUE(name),
  rename + recolour, RESTRICT-blocked delete when a cable references the
  type (with count surfaced via CountCablesUsingType), unused delete
  succeeds, project deletion does NOT cascade into cable_types

go test -race ./... passes. Updates README.md with run instructions,
env vars, the slice-1 API surface, and the slice roadmap.
2026-05-15 16:48:29 +02:00

282 lines
8.1 KiB
Go

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