Files
nmon/internal/importcsv/importcsv_test.go

185 lines
5.6 KiB
Go

package importcsv_test
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/v10networks/nmon/internal/clock"
"github.com/v10networks/nmon/internal/deps"
"github.com/v10networks/nmon/internal/importcsv"
"github.com/v10networks/nmon/internal/store"
"github.com/v10networks/nmon/internal/testutil"
"github.com/v10networks/nmon/internal/ulid"
)
func newStore(t *testing.T) store.Store {
t.Helper()
return testutil.OpenStore(t, clock.NewFake(time.Date(2026, 10, 22, 0, 0, 0, 0, time.UTC)))
}
func build(t *testing.T, st store.Store, devs, ports string) (*importcsv.Plan, error) {
t.Helper()
in := importcsv.Inputs{}
if devs != "" {
in.Devices = strings.NewReader(devs)
}
if ports != "" {
in.Ports = strings.NewReader(ports)
}
return importcsv.Build(context.Background(), st, in)
}
func deviceCount(t *testing.T, st store.Store) int64 {
t.Helper()
_, n, err := st.ListDevices(context.Background(), store.DeviceFilter{IncludeRetired: true}, store.Page{})
if err != nil {
t.Fatal(err)
}
return n
}
func TestImportHappyPathCreatesDevicesParentsCredentialsAndAliases(t *testing.T) {
ctx := context.Background()
st := newStore(t)
existing := &store.Device{Name: "core", Kind: store.KindNetwork, Enabled: true}
if err := st.CreateDevice(ctx, existing); err != nil {
t.Fatal(err)
}
if err := st.CreateCredential(ctx, &store.SNMPCredential{Name: "lab-v2", Version: "v2c", Community: "x"}); err != nil {
t.Fatal(err)
}
ifID := ulid.New(time.Unix(1700000000, 0))
if err := st.UpsertInterfaces(ctx, existing.ID, []store.Interface{{ID: ifID, IfName: "ge-0/0/1", IfIndex: 1}}); err != nil {
t.Fatal(err)
}
devs := "Name,Addresses,Site,Role,Kind,SNMP_Version,Community,Credential,Port,Parents\n" +
"sw1,10.0.0.2;2001:db8::2,pop1,access,,,,lab-v2,1161,core\n" +
"ap1,10.0.0.3,pop1,ap,,v1,public,,,sw1;core\n" +
"\n"
ports := "device,ifname,alias\n" + existing.Slug + ",GE-0/0/1,Customer ACME\n"
plan, err := build(t, st, devs, ports)
if err != nil {
t.Fatal(err)
}
res, err := importcsv.Apply(ctx, st, plan)
if err != nil {
t.Fatal(err)
}
if res.DevicesCreated != 2 || res.CredentialsCreated != 1 || res.PortsUpdated != 1 {
t.Fatalf("result %+v", res)
}
sw, _, err := st.ListDevices(ctx, store.DeviceFilter{Q: "sw1"}, store.Page{})
if err != nil || len(sw) == 0 {
t.Fatalf("sw1 missing: %v", err)
}
d := sw[0]
if d.SNMPPort != 1161 || d.SNMPCredentialID == nil || d.Site != "pop1" {
t.Fatalf("sw1 %+v", d)
}
addrs, _ := st.ListAddresses(ctx, d.ID)
if len(addrs) != 2 {
t.Fatalf("addresses %v", addrs)
}
ps, _ := st.ListParents(ctx, d.ID, store.RelNetwork)
if len(ps) != 1 || ps[0].ParentID != existing.ID {
t.Fatalf("sw1 parents %+v", ps)
}
ap, _, _ := st.ListDevices(ctx, store.DeviceFilter{Q: "ap1"}, store.Page{})
aps, _ := st.ListParents(ctx, ap[0].ID, store.RelNetwork)
if len(aps) != 2 {
t.Fatalf("ap1 parents %+v", aps)
}
ifs, _ := st.ListInterfaces(ctx, existing.ID, false)
if ifs[0].Description != "Customer ACME" {
t.Fatalf("description %q", ifs[0].Description)
}
}
func TestRowErrorsCarryRowNumbersAndNothingIsWritten(t *testing.T) {
st := newStore(t)
devs := "name,addresses,kind,port,parents,credential\n" +
"ok1,10.0.0.1,,,,\n" +
"bad,not-an-ip,router,70000,ghost,nocred\n" +
"ok1,10.0.0.9,,,,\n"
_, err := build(t, st, devs, "")
var pe *importcsv.PlanError
if !errors.As(err, &pe) {
t.Fatalf("want PlanError, got %v", err)
}
rows := map[int]int{}
for _, e := range pe.Errors {
rows[e.Row]++
}
if rows[3] != 5 || rows[4] != 1 || rows[2] != 0 {
t.Fatalf("errors by row %v: %v", rows, err)
}
if !strings.Contains(err.Error(), "devices csv row 3") {
t.Fatal(err)
}
if n := deviceCount(t, st); n != 0 {
t.Fatalf("%d devices written", n)
}
}
func TestPortErrors(t *testing.T) {
st := newStore(t)
d := &store.Device{Name: "core", Kind: store.KindNetwork, Enabled: true}
if err := st.CreateDevice(context.Background(), d); err != nil {
t.Fatal(err)
}
_, err := build(t, st, "", "device,ifname,alias\ncore,nope,x\nghost,eth0,y\n")
var pe *importcsv.PlanError
if !errors.As(err, &pe) || len(pe.Errors) != 2 || pe.Errors[0].File != importcsv.FilePorts || pe.Errors[1].Row != 3 {
t.Fatalf("got %v", err)
}
}
func TestMissingRequiredColumn(t *testing.T) {
if _, err := build(t, newStore(t), "addresses\n10.0.0.1\n", ""); err == nil || !strings.Contains(err.Error(), `missing column "name"`) {
t.Fatalf("got %v", err)
}
}
func TestParentCycleRejectedWithPathAndNothingWritten(t *testing.T) {
st := newStore(t)
devs := "name,parents\na,c\nb,a\nc,b\n"
_, err := build(t, st, devs, "")
var ce *deps.CycleError
if !errors.As(err, &ce) {
t.Fatalf("want cycle, got %v", err)
}
if len(ce.Path) != 4 || ce.Path[0] != ce.Path[3] || !strings.Contains(err.Error(), " -> ") {
t.Fatalf("path %v", ce.Path)
}
if n := deviceCount(t, st); n != 0 {
t.Fatalf("%d devices written", n)
}
}
func TestSelfParentIsRejected(t *testing.T) {
st := newStore(t)
if _, err := build(t, st, "name,parents\nloop,loop\n", ""); err == nil {
t.Fatal("self parent accepted")
}
}
func TestApplyFailureRollsEverythingBack(t *testing.T) {
ctx := context.Background()
st := newStore(t)
plan, err := build(t, st, "name,addresses\nr1,10.0.0.1\nr2,10.0.0.2\n", "")
if err != nil {
t.Fatal(err)
}
// Corrupt the second row after planning so Apply fails after the first device was inserted.
plan.Devices[1].Kind = "bogus"
if _, err := importcsv.Apply(ctx, st, plan); err == nil || !strings.Contains(err.Error(), "row 3") {
t.Fatalf("got %v", err)
}
if n := deviceCount(t, st); n != 0 {
t.Fatalf("%d devices survived a failed import", n)
}
}