Initial implementation of inventree
A small IT asset inventory system built around a nested folder tree, matching the project brief: Go + html/template + Bootstrap 5 + vanilla JS, SQLite storage (WAL, FKs, migrations), embedded templates/static assets, single compiled binary. - AJAX-driven inventory tree: lazy-loaded folder expansion with per-user persisted expand state, drag-and-drop move for folders/assets, safe delete-with-relocate-or-cascade confirmation, search with breadcrumbs. - Assets: CRUD, duplicate, move, auto-generated IT-000001-style IDs, CSV import (with optional folder auto-create) and export. - Admin-defined item types with custom fields (text/long_text/number/date/ boolean/dropdown/url/ip_address). - Configurable statuses, session-based auth (bcrypt, secure cookies), admin/user roles, user management. - CLI: serve, version, database migrate, database backup; /healthz; graceful shutdown; Dockerfile/docker-compose/systemd unit; Go test suite.
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
package assets_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"inventree/internal/assets"
|
||||
"inventree/internal/database"
|
||||
"inventree/internal/folders"
|
||||
"inventree/internal/models"
|
||||
"inventree/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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user