package assets_test import ( "bytes" "context" "database/sql" "path/filepath" "strings" "testing" "vibetree/internal/assets" "vibetree/internal/database" "vibetree/internal/folders" "vibetree/internal/models" "vibetree/internal/settings" ) // Seed data from migrations/0002_seed.sql gives us stable IDs to rely on: // item types 1=Laptop 2=Desktop 3=Server 4=Switch ... statuses 1=In Use 2=Spare ... const ( itemTypeLaptop = 1 itemTypeSwitch = 4 statusInUse = 1 statusSpare = 2 ) func testStores(t *testing.T) (*assets.Store, *folders.Store, *sql.DB) { t.Helper() dir := t.TempDir() db, err := database.Open(filepath.Join(dir, "test.db")) if err != nil { t.Fatalf("open db: %v", err) } t.Cleanup(func() { db.Close() }) fs := folders.NewStore(db) st := settings.NewStore(db) return assets.NewStore(db, st, fs), fs, db } func TestAssetCreateAutoGeneratesID(t *testing.T) { ctx := context.Background() as, _, _ := testStores(t) a1 := &models.Asset{Name: "Core Switch", ItemTypeID: itemTypeSwitch, StatusID: statusInUse} if err := as.Create(ctx, a1, nil); err != nil { t.Fatalf("create: %v", err) } if a1.AssetTag != "IT-000001" { t.Fatalf("expected IT-000001, got %s", a1.AssetTag) } a2 := &models.Asset{Name: "Firewall", ItemTypeID: itemTypeSwitch, StatusID: statusInUse} if err := as.Create(ctx, a2, nil); err != nil { t.Fatalf("create: %v", err) } if a2.AssetTag != "IT-000002" { t.Fatalf("expected IT-000002, got %s", a2.AssetTag) } } func TestAssetCreateWithManualIDAndCollision(t *testing.T) { ctx := context.Background() as, _, _ := testStores(t) a1 := &models.Asset{AssetTag: "CUSTOM-1", Name: "Core Switch", ItemTypeID: itemTypeSwitch, StatusID: statusInUse} if err := as.Create(ctx, a1, nil); err != nil { t.Fatalf("create: %v", err) } dup := &models.Asset{AssetTag: "CUSTOM-1", Name: "Other", ItemTypeID: itemTypeSwitch, StatusID: statusInUse} if err := as.Create(ctx, dup, nil); err != assets.ErrTagTaken { t.Fatalf("expected ErrTagTaken, got %v", err) } } func TestAssetDuplicateGeneratesNewID(t *testing.T) { ctx := context.Background() as, _, _ := testStores(t) orig := &models.Asset{Name: "Laptop 1", ItemTypeID: itemTypeLaptop, StatusID: statusInUse, Notes: "hello"} if err := as.Create(ctx, orig, nil); err != nil { t.Fatalf("create: %v", err) } dup, err := as.Duplicate(ctx, orig.ID) if err != nil { t.Fatalf("duplicate: %v", err) } if dup.AssetTag == orig.AssetTag { t.Fatalf("expected different asset tag, got same: %s", dup.AssetTag) } if dup.Notes != "hello" { t.Fatalf("expected notes copied, got %q", dup.Notes) } } func TestAssetListFilterByFolder(t *testing.T) { ctx := context.Background() as, fs, _ := testStores(t) f1 := &models.Folder{Name: "Office"} must(t, fs.Create(ctx, f1)) f2 := &models.Folder{Name: "Warehouse"} must(t, fs.Create(ctx, f2)) a1 := &models.Asset{Name: "A1", ItemTypeID: itemTypeLaptop, StatusID: statusInUse, FolderID: &f1.ID} must(t, as.Create(ctx, a1, nil)) a2 := &models.Asset{Name: "A2", ItemTypeID: itemTypeLaptop, StatusID: statusInUse, FolderID: &f2.ID} must(t, as.Create(ctx, a2, nil)) list, total, err := as.List(ctx, assets.ListFilter{FolderID: &f1.ID, FolderSet: true, Page: 1, PageSize: 10}) if err != nil { t.Fatalf("list: %v", err) } if total != 1 || len(list) != 1 || list[0].Name != "A1" { t.Fatalf("unexpected list result: total=%d list=%+v", total, list) } } func TestAssetCSVExportImportRoundTrip(t *testing.T) { ctx := context.Background() as, fs, db := testStores(t) office := &models.Folder{Name: "Main Office"} must(t, fs.Create(ctx, office)) orig := &models.Asset{Name: "Core Switch", ItemTypeID: itemTypeSwitch, StatusID: statusInUse, FolderID: &office.ID} must(t, as.Create(ctx, orig, nil)) var buf bytes.Buffer if err := as.Export(ctx, &buf, assets.ListFilter{}); err != nil { t.Fatalf("export: %v", err) } if !strings.Contains(buf.String(), "Core Switch") { t.Fatalf("expected exported CSV to contain asset name, got: %s", buf.String()) } // Import a new asset referencing a not-yet-existing nested folder path, // with CreateMissingFolders enabled. csvData := "Name,Item Type,Folder,Status\nBackup Router,Switch,Main Office/Network,In Use\n" itemTypes := seedItemTypes(t, db) statuses := seedStatuses(t, db) result, err := as.Import(ctx, strings.NewReader(csvData), assets.ImportOptions{CreateMissingFolders: true}, itemTypes, statuses, nil) if err != nil { t.Fatalf("import: %v", err) } if result.Created != 1 { t.Fatalf("expected 1 created, got %+v", result) } all, total, err := as.List(ctx, assets.ListFilter{Page: 1, PageSize: 10}) if err != nil { t.Fatalf("list: %v", err) } if total != 2 { t.Fatalf("expected 2 assets total after import, got %d (%+v)", total, all) } } func seedItemTypes(t *testing.T, db *sql.DB) []models.ItemType { t.Helper() rows, err := db.Query(`SELECT id, name, icon, enabled, sort_order FROM item_types`) if err != nil { t.Fatalf("query item types: %v", err) } defer rows.Close() var out []models.ItemType for rows.Next() { var it models.ItemType if err := rows.Scan(&it.ID, &it.Name, &it.Icon, &it.Enabled, &it.SortOrder); err != nil { t.Fatalf("scan: %v", err) } out = append(out, it) } return out } func seedStatuses(t *testing.T, db *sql.DB) []models.Status { t.Helper() rows, err := db.Query(`SELECT id, name, color, sort_order FROM statuses`) if err != nil { t.Fatalf("query statuses: %v", err) } defer rows.Close() var out []models.Status for rows.Next() { var st models.Status if err := rows.Scan(&st.ID, &st.Name, &st.Color, &st.SortOrder); err != nil { t.Fatalf("scan: %v", err) } out = append(out, st) } return out } func must(t *testing.T, err error) { t.Helper() if err != nil { t.Fatalf("unexpected error: %v", err) } }