Files
silo/internal/db/relationships_test.go
Forbes d08b178466 test: add comprehensive test suite for backend
Add 56 tests covering the core backend packages:

Unit tests (no database required):
- internal/partnum: 7 tests for part number generation logic
  (sequence, format templates, enum validation, constants)
- internal/schema: 8 tests for YAML schema loading, property
  merging, validation, and default application

Integration tests (require TEST_DATABASE_URL):
- internal/db/items: 10 tests for item CRUD, archive/unarchive,
  revisions, and thumbnail operations
- internal/db/relationships: 10 tests for BOM CRUD, cycle detection,
  self-reference blocking, where-used, expanded/flat BOM
- internal/db/projects: 5 tests for project CRUD and item association
- internal/api/bom_handlers: 6 HTTP handler tests for BOM endpoints
  including flat BOM, cost calculation, add/delete entries
- internal/api/items: 5 HTTP handler tests for item CRUD endpoints

Infrastructure:
- internal/testutil: shared helpers for test DB pool setup,
  migration runner, and table truncation
- internal/db/helpers_test.go: DB wrapper for integration tests
- internal/db/db.go: add NewFromPool constructor
- Makefile: add test-integration target with default DSN

Integration tests skip gracefully when TEST_DATABASE_URL is unset.
Dev-mode auth (nil authConfig) used for API handler tests.

Fixes: fmt.Errorf Go vet warning in partnum/generator.go

Closes #2
2026-02-07 01:57:10 -06:00

279 lines
8.3 KiB
Go

package db
import (
"context"
"strings"
"testing"
)
// createTestItem is a helper that creates a minimal item for BOM tests.
func createTestItem(t *testing.T, repo *ItemRepository, pn, desc string) *Item {
t.Helper()
item := &Item{PartNumber: pn, ItemType: "part", Description: desc}
if err := repo.Create(context.Background(), item, nil); err != nil {
t.Fatalf("creating test item %s: %v", pn, err)
}
return item
}
func TestBOMCreate(t *testing.T) {
database := mustConnectTestDB(t)
items := NewItemRepository(database)
rels := NewRelationshipRepository(database)
ctx := context.Background()
parent := createTestItem(t, items, "BOM-P-001", "parent")
child := createTestItem(t, items, "BOM-C-001", "child")
qty := 3.0
rel := &Relationship{
ParentItemID: parent.ID,
ChildItemID: child.ID,
RelType: "component",
Quantity: &qty,
}
if err := rels.Create(ctx, rel); err != nil {
t.Fatalf("Create: %v", err)
}
if rel.ID == "" {
t.Error("expected relationship ID to be set")
}
}
func TestBOMGetBOM(t *testing.T) {
database := mustConnectTestDB(t)
items := NewItemRepository(database)
rels := NewRelationshipRepository(database)
ctx := context.Background()
parent := createTestItem(t, items, "BOM-G-001", "parent")
child1 := createTestItem(t, items, "BOM-G-002", "child1")
child2 := createTestItem(t, items, "BOM-G-003", "child2")
qty1, qty2 := 2.0, 5.0
rels.Create(ctx, &Relationship{ParentItemID: parent.ID, ChildItemID: child1.ID, RelType: "component", Quantity: &qty1})
rels.Create(ctx, &Relationship{ParentItemID: parent.ID, ChildItemID: child2.ID, RelType: "component", Quantity: &qty2})
bom, err := rels.GetBOM(ctx, parent.ID)
if err != nil {
t.Fatalf("GetBOM: %v", err)
}
if len(bom) != 2 {
t.Errorf("expected 2 BOM entries, got %d", len(bom))
}
}
func TestBOMSelfReference(t *testing.T) {
database := mustConnectTestDB(t)
items := NewItemRepository(database)
rels := NewRelationshipRepository(database)
ctx := context.Background()
item := createTestItem(t, items, "BOM-SELF-001", "self-referencing")
err := rels.Create(ctx, &Relationship{
ParentItemID: item.ID,
ChildItemID: item.ID,
RelType: "component",
})
if err == nil {
t.Fatal("expected error for self-reference, got nil")
}
}
func TestBOMCycleDetection(t *testing.T) {
database := mustConnectTestDB(t)
items := NewItemRepository(database)
rels := NewRelationshipRepository(database)
ctx := context.Background()
a := createTestItem(t, items, "CYC-A", "A")
b := createTestItem(t, items, "CYC-B", "B")
c := createTestItem(t, items, "CYC-C", "C")
// A → B → C
rels.Create(ctx, &Relationship{ParentItemID: a.ID, ChildItemID: b.ID, RelType: "component"})
rels.Create(ctx, &Relationship{ParentItemID: b.ID, ChildItemID: c.ID, RelType: "component"})
// C → A should be detected as a cycle
err := rels.Create(ctx, &Relationship{
ParentItemID: c.ID,
ChildItemID: a.ID,
RelType: "component",
})
if err == nil {
t.Fatal("expected cycle error, got nil")
}
if !strings.Contains(err.Error(), "cycle") {
t.Errorf("expected cycle error, got: %v", err)
}
}
func TestBOMDelete(t *testing.T) {
database := mustConnectTestDB(t)
items := NewItemRepository(database)
rels := NewRelationshipRepository(database)
ctx := context.Background()
parent := createTestItem(t, items, "BOM-D-001", "parent")
child := createTestItem(t, items, "BOM-D-002", "child")
rel := &Relationship{ParentItemID: parent.ID, ChildItemID: child.ID, RelType: "component"}
rels.Create(ctx, rel)
if err := rels.Delete(ctx, rel.ID); err != nil {
t.Fatalf("Delete: %v", err)
}
bom, _ := rels.GetBOM(ctx, parent.ID)
if len(bom) != 0 {
t.Errorf("expected 0 BOM entries after delete, got %d", len(bom))
}
}
func TestBOMUpdate(t *testing.T) {
database := mustConnectTestDB(t)
items := NewItemRepository(database)
rels := NewRelationshipRepository(database)
ctx := context.Background()
parent := createTestItem(t, items, "BOM-U-001", "parent")
child := createTestItem(t, items, "BOM-U-002", "child")
qty := 1.0
rel := &Relationship{ParentItemID: parent.ID, ChildItemID: child.ID, RelType: "component", Quantity: &qty}
rels.Create(ctx, rel)
newQty := 10.0
if err := rels.Update(ctx, rel.ID, nil, &newQty, nil, nil, nil, nil, nil); err != nil {
t.Fatalf("Update: %v", err)
}
bom, _ := rels.GetBOM(ctx, parent.ID)
if len(bom) != 1 {
t.Fatalf("expected 1 BOM entry, got %d", len(bom))
}
if bom[0].Quantity == nil || *bom[0].Quantity != 10.0 {
t.Errorf("quantity: got %v, want 10.0", bom[0].Quantity)
}
}
func TestBOMWhereUsed(t *testing.T) {
database := mustConnectTestDB(t)
items := NewItemRepository(database)
rels := NewRelationshipRepository(database)
ctx := context.Background()
parent1 := createTestItem(t, items, "WU-P1", "parent1")
parent2 := createTestItem(t, items, "WU-P2", "parent2")
child := createTestItem(t, items, "WU-C1", "shared child")
rels.Create(ctx, &Relationship{ParentItemID: parent1.ID, ChildItemID: child.ID, RelType: "component"})
rels.Create(ctx, &Relationship{ParentItemID: parent2.ID, ChildItemID: child.ID, RelType: "component"})
wu, err := rels.GetWhereUsed(ctx, child.ID)
if err != nil {
t.Fatalf("GetWhereUsed: %v", err)
}
if len(wu) != 2 {
t.Errorf("expected 2 where-used entries, got %d", len(wu))
}
}
func TestBOMExpandedBOM(t *testing.T) {
database := mustConnectTestDB(t)
items := NewItemRepository(database)
rels := NewRelationshipRepository(database)
ctx := context.Background()
// A → B → C (3 levels)
a := createTestItem(t, items, "EXP-A", "top assembly")
b := createTestItem(t, items, "EXP-B", "sub assembly")
c := createTestItem(t, items, "EXP-C", "leaf part")
qty2, qty3 := 2.0, 3.0
rels.Create(ctx, &Relationship{ParentItemID: a.ID, ChildItemID: b.ID, RelType: "component", Quantity: &qty2})
rels.Create(ctx, &Relationship{ParentItemID: b.ID, ChildItemID: c.ID, RelType: "component", Quantity: &qty3})
expanded, err := rels.GetExpandedBOM(ctx, a.ID, 10)
if err != nil {
t.Fatalf("GetExpandedBOM: %v", err)
}
if len(expanded) != 2 {
t.Errorf("expected 2 expanded entries (B and C), got %d", len(expanded))
}
// Verify depths
for _, e := range expanded {
if e.ChildPartNumber == "EXP-B" && e.Depth != 1 {
t.Errorf("EXP-B depth: got %d, want 1", e.Depth)
}
if e.ChildPartNumber == "EXP-C" && e.Depth != 2 {
t.Errorf("EXP-C depth: got %d, want 2", e.Depth)
}
}
}
func TestBOMFlatBOM(t *testing.T) {
database := mustConnectTestDB(t)
items := NewItemRepository(database)
rels := NewRelationshipRepository(database)
ctx := context.Background()
// Assembly A (qty 1)
// ├── Sub-assembly B (qty 2)
// │ ├── Part X (qty 3) → total 6
// │ └── Part Y (qty 1) → total 2
// └── Part X (qty 4) → total 4 (+ 6 = 10 total for X)
a := createTestItem(t, items, "FLAT-A", "top")
b := createTestItem(t, items, "FLAT-B", "sub")
x := createTestItem(t, items, "FLAT-X", "leaf X")
y := createTestItem(t, items, "FLAT-Y", "leaf Y")
q2, q3, q1, q4 := 2.0, 3.0, 1.0, 4.0
rels.Create(ctx, &Relationship{ParentItemID: a.ID, ChildItemID: b.ID, RelType: "component", Quantity: &q2})
rels.Create(ctx, &Relationship{ParentItemID: a.ID, ChildItemID: x.ID, RelType: "component", Quantity: &q4})
rels.Create(ctx, &Relationship{ParentItemID: b.ID, ChildItemID: x.ID, RelType: "component", Quantity: &q3})
rels.Create(ctx, &Relationship{ParentItemID: b.ID, ChildItemID: y.ID, RelType: "component", Quantity: &q1})
flat, err := rels.GetFlatBOM(ctx, a.ID)
if err != nil {
t.Fatalf("GetFlatBOM: %v", err)
}
if len(flat) != 2 {
t.Errorf("expected 2 leaf parts, got %d", len(flat))
}
for _, e := range flat {
switch e.PartNumber {
case "FLAT-X":
if e.TotalQuantity != 10.0 {
t.Errorf("FLAT-X total qty: got %.1f, want 10.0", e.TotalQuantity)
}
case "FLAT-Y":
if e.TotalQuantity != 2.0 {
t.Errorf("FLAT-Y total qty: got %.1f, want 2.0", e.TotalQuantity)
}
default:
t.Errorf("unexpected part in flat BOM: %s", e.PartNumber)
}
}
}
func TestBOMFlatBOMEmpty(t *testing.T) {
database := mustConnectTestDB(t)
items := NewItemRepository(database)
rels := NewRelationshipRepository(database)
ctx := context.Background()
item := createTestItem(t, items, "FLAT-EMPTY", "no children")
flat, err := rels.GetFlatBOM(ctx, item.ID)
if err != nil {
t.Fatalf("GetFlatBOM: %v", err)
}
if len(flat) != 0 {
t.Errorf("expected 0 leaf parts for item with no BOM, got %d", len(flat))
}
}