package db import ( "errors" "testing" ) func setupTwoDevices(t *testing.T, s *Store) (int64, int64, int64) { t.Helper() p, _ := s.CreateProject("LOFT", "", "") a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "NAS", X: 0, Y: 0, Width: 100, Height: 35}) b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Switch", X: 200, Y: 0, Width: 100, Height: 35}) return p.ID, a.ID, b.ID } func TestCreateConnReq_Basic(t *testing.T) { s := newTestStore(t) pid, a, b := setupTwoDevices(t, s) rj45 := int64(5) r, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{ FromDeviceID: a, ToDeviceID: b, PreferredCableTypeID: &rj45, }) if err != nil { t.Fatalf("create: %v", err) } if !r.MustConnect { t.Errorf("must_connect default should be true") } if r.PreferredCableTypeID == nil || *r.PreferredCableTypeID != rj45 { t.Errorf("preferred_cable_type_id wrong: %+v", r.PreferredCableTypeID) } } func TestCreateConnReq_PairNormalisationRejectsReverse(t *testing.T) { s := newTestStore(t) pid, a, b := setupTwoDevices(t, s) rj45 := int64(5) if _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{ FromDeviceID: a, ToDeviceID: b, PreferredCableTypeID: &rj45, }); err != nil { t.Fatalf("first: %v", err) } // (B, A, RJ45) should collide on UNIQUE (pair_lo, pair_hi, type). _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{ FromDeviceID: b, ToDeviceID: a, PreferredCableTypeID: &rj45, }) if !errors.Is(err, ErrConflict) { t.Errorf("reverse pair err = %v, want ErrConflict", err) } } func TestCreateConnReq_DifferentCableTypesCoexist(t *testing.T) { s := newTestStore(t) pid, a, b := setupTwoDevices(t, s) rj45, power := int64(5), int64(1) if _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{ FromDeviceID: a, ToDeviceID: b, PreferredCableTypeID: &rj45, }); err != nil { t.Fatalf("rj45: %v", err) } if _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{ FromDeviceID: a, ToDeviceID: b, PreferredCableTypeID: &power, }); err != nil { t.Errorf("power on same pair should be allowed: %v", err) } } func TestCreateConnReq_SelfLoopRejected(t *testing.T) { s := newTestStore(t) pid, a, _ := setupTwoDevices(t, s) _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{ FromDeviceID: a, ToDeviceID: a, }) if !errors.Is(err, ErrInvalidInput) { t.Errorf("self-loop err = %v, want ErrInvalidInput", err) } } func TestCreateConnReq_CrossProjectDeviceRejected(t *testing.T) { s := newTestStore(t) pid, a, _ := setupTwoDevices(t, s) p2, _ := s.CreateProject("OFFICE", "", "") b2, _ := s.CreateDevice(p2.ID, DeviceCreate{Name: "X", X: 0, Y: 0, Width: 100, Height: 35}) _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{ FromDeviceID: a, ToDeviceID: b2.ID, }) if !errors.Is(err, ErrInvalidInput) { t.Errorf("cross-project to-device err = %v, want ErrInvalidInput", err) } } func TestCreateConnReq_NullCableTypeUniqueByPair(t *testing.T) { // Two NULL-cable-type reqs on the same pair are NOT a conflict in // SQLite (NULL != NULL in UNIQUE comparisons). This is fine — they // represent "solver picks" both times; the second wins when solving. s := newTestStore(t) pid, a, b := setupTwoDevices(t, s) if _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{ FromDeviceID: a, ToDeviceID: b, }); err != nil { t.Fatalf("first: %v", err) } if _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{ FromDeviceID: a, ToDeviceID: b, }); err != nil { t.Errorf("second NULL-type req should be allowed (SQLite NULL != NULL): %v", err) } } func TestUpdateConnReq_PartialFields(t *testing.T) { s := newTestStore(t) pid, a, b := setupTwoDevices(t, s) rj45, power := int64(5), int64(1) r, _ := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{ FromDeviceID: a, ToDeviceID: b, PreferredCableTypeID: &rj45, }) notes := "important" must := false updated, err := s.UpdateConnectionRequirement(pid, r.ID, ConnectionRequirementUpdate{ PreferredCableTypeID: FrameRef{Set: true, ID: &power}, MustConnect: &must, Notes: ¬es, }) if err != nil { t.Fatalf("update: %v", err) } if updated.PreferredCableTypeID == nil || *updated.PreferredCableTypeID != power { t.Errorf("cable type not switched: %+v", updated.PreferredCableTypeID) } if updated.MustConnect { t.Errorf("must_connect should be false") } if updated.Notes != "important" { t.Errorf("notes = %q", updated.Notes) } // Clear the cable type. cleared, _ := s.UpdateConnectionRequirement(pid, r.ID, ConnectionRequirementUpdate{ PreferredCableTypeID: FrameRef{Set: true, ID: nil}, }) if cleared.PreferredCableTypeID != nil { t.Errorf("preferred_cable_type_id should be nil after clear; got %v", *cleared.PreferredCableTypeID) } } func TestDeleteConnReq_CascadesOnDeviceDelete(t *testing.T) { s := newTestStore(t) pid, a, b := setupTwoDevices(t, s) r, _ := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{ FromDeviceID: a, ToDeviceID: b, }) if err := s.DeleteDevice(pid, a); err != nil { t.Fatalf("delete device a: %v", err) } if _, err := s.GetConnectionRequirement(pid, r.ID); !errors.Is(err, ErrNotFound) { t.Errorf("requirement should be gone after device delete; got %v", err) } } func TestSnapshot_IncludesConnectionRequirements(t *testing.T) { s := newTestStore(t) pid, a, b := setupTwoDevices(t, s) _, _ = s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{ FromDeviceID: a, ToDeviceID: b, }) snap, err := s.Snapshot(pid) if err != nil { t.Fatalf("snapshot: %v", err) } if len(snap.ConnectionRequirements) != 1 { t.Errorf("snapshot.connection_requirements = %d, want 1", len(snap.ConnectionRequirements)) } } func TestDeleteConnReq_NotFound(t *testing.T) { s := newTestStore(t) p, _ := s.CreateProject("LOFT", "", "") if err := s.DeleteConnectionRequirement(p.ID, 999); !errors.Is(err, ErrNotFound) { t.Errorf("got %v, want ErrNotFound", err) } }