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)) } }