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
279 lines
8.3 KiB
Go
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))
|
|
}
|
|
}
|