Files
silo/internal/db/items_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

273 lines
7.3 KiB
Go

package db
import (
"context"
"fmt"
"testing"
)
func TestItemCreate(t *testing.T) {
database := mustConnectTestDB(t)
repo := NewItemRepository(database)
ctx := context.Background()
item := &Item{
PartNumber: "TEST-0001",
ItemType: "part",
Description: "Test item",
}
err := repo.Create(ctx, item, map[string]any{"color": "red"})
if err != nil {
t.Fatalf("Create: %v", err)
}
if item.ID == "" {
t.Error("expected item ID to be set")
}
if item.CurrentRevision != 1 {
t.Errorf("current revision: got %d, want 1", item.CurrentRevision)
}
}
func TestItemGetByPartNumber(t *testing.T) {
database := mustConnectTestDB(t)
repo := NewItemRepository(database)
ctx := context.Background()
item := &Item{PartNumber: "GET-PN-001", ItemType: "part", Description: "get by pn test"}
if err := repo.Create(ctx, item, nil); err != nil {
t.Fatalf("Create: %v", err)
}
got, err := repo.GetByPartNumber(ctx, "GET-PN-001")
if err != nil {
t.Fatalf("GetByPartNumber: %v", err)
}
if got == nil {
t.Fatal("expected item, got nil")
}
if got.Description != "get by pn test" {
t.Errorf("description: got %q, want %q", got.Description, "get by pn test")
}
// Non-existent should return nil, not error
missing, err := repo.GetByPartNumber(ctx, "DOES-NOT-EXIST")
if err != nil {
t.Fatalf("GetByPartNumber (missing): %v", err)
}
if missing != nil {
t.Error("expected nil for missing item")
}
}
func TestItemGetByID(t *testing.T) {
database := mustConnectTestDB(t)
repo := NewItemRepository(database)
ctx := context.Background()
item := &Item{PartNumber: "GET-ID-001", ItemType: "assembly", Description: "get by id"}
if err := repo.Create(ctx, item, nil); err != nil {
t.Fatalf("Create: %v", err)
}
got, err := repo.GetByID(ctx, item.ID)
if err != nil {
t.Fatalf("GetByID: %v", err)
}
if got == nil {
t.Fatal("expected item, got nil")
}
if got.PartNumber != "GET-ID-001" {
t.Errorf("part_number: got %q, want %q", got.PartNumber, "GET-ID-001")
}
}
func TestItemList(t *testing.T) {
database := mustConnectTestDB(t)
repo := NewItemRepository(database)
ctx := context.Background()
for i := 0; i < 3; i++ {
item := &Item{
PartNumber: fmt.Sprintf("LIST-%04d", i),
ItemType: "part",
Description: fmt.Sprintf("list item %d", i),
}
if err := repo.Create(ctx, item, nil); err != nil {
t.Fatalf("Create #%d: %v", i, err)
}
}
items, err := repo.List(ctx, ListOptions{})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(items) != 3 {
t.Errorf("expected 3 items, got %d", len(items))
}
}
func TestItemListByType(t *testing.T) {
database := mustConnectTestDB(t)
repo := NewItemRepository(database)
ctx := context.Background()
repo.Create(ctx, &Item{PartNumber: "TYPE-P-001", ItemType: "part", Description: "a part"}, nil)
repo.Create(ctx, &Item{PartNumber: "TYPE-A-001", ItemType: "assembly", Description: "an assembly"}, nil)
repo.Create(ctx, &Item{PartNumber: "TYPE-P-002", ItemType: "part", Description: "another part"}, nil)
items, err := repo.List(ctx, ListOptions{ItemType: "part"})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(items) != 2 {
t.Errorf("expected 2 parts, got %d", len(items))
}
}
func TestItemUpdate(t *testing.T) {
database := mustConnectTestDB(t)
repo := NewItemRepository(database)
ctx := context.Background()
item := &Item{PartNumber: "UPD-001", ItemType: "part", Description: "original"}
if err := repo.Create(ctx, item, nil); err != nil {
t.Fatalf("Create: %v", err)
}
cost := 42.50
err := repo.Update(ctx, item.ID, UpdateItemFields{
PartNumber: "UPD-001",
ItemType: "part",
Description: "updated",
StandardCost: &cost,
})
if err != nil {
t.Fatalf("Update: %v", err)
}
got, _ := repo.GetByID(ctx, item.ID)
if got.Description != "updated" {
t.Errorf("description: got %q, want %q", got.Description, "updated")
}
if got.StandardCost == nil || *got.StandardCost != 42.50 {
t.Errorf("standard_cost: got %v, want 42.50", got.StandardCost)
}
}
func TestItemArchiveUnarchive(t *testing.T) {
database := mustConnectTestDB(t)
repo := NewItemRepository(database)
ctx := context.Background()
item := &Item{PartNumber: "ARC-001", ItemType: "part", Description: "archivable"}
if err := repo.Create(ctx, item, nil); err != nil {
t.Fatalf("Create: %v", err)
}
// Archive
if err := repo.Archive(ctx, item.ID); err != nil {
t.Fatalf("Archive: %v", err)
}
// Should not appear in GetByPartNumber (excludes archived)
got, _ := repo.GetByPartNumber(ctx, "ARC-001")
if got != nil {
t.Error("archived item should not be returned by GetByPartNumber")
}
// But should still be accessible by ID
gotByID, _ := repo.GetByID(ctx, item.ID)
if gotByID == nil {
t.Fatal("archived item should still be accessible by GetByID")
}
if gotByID.ArchivedAt == nil {
t.Error("archived_at should be set")
}
// Unarchive
if err := repo.Unarchive(ctx, item.ID); err != nil {
t.Fatalf("Unarchive: %v", err)
}
got, _ = repo.GetByPartNumber(ctx, "ARC-001")
if got == nil {
t.Error("unarchived item should be returned by GetByPartNumber")
}
}
func TestItemCreateRevision(t *testing.T) {
database := mustConnectTestDB(t)
repo := NewItemRepository(database)
ctx := context.Background()
item := &Item{PartNumber: "REV-001", ItemType: "part", Description: "revisable"}
if err := repo.Create(ctx, item, map[string]any{"v": 1}); err != nil {
t.Fatalf("Create: %v", err)
}
// Create second revision
rev := &Revision{
ItemID: item.ID,
Properties: map[string]any{"v": 2},
}
if err := repo.CreateRevision(ctx, rev); err != nil {
t.Fatalf("CreateRevision: %v", err)
}
if rev.RevisionNumber != 2 {
t.Errorf("revision number: got %d, want 2", rev.RevisionNumber)
}
// Item's current_revision should be updated by trigger
got, _ := repo.GetByPartNumber(ctx, "REV-001")
if got.CurrentRevision != 2 {
t.Errorf("current_revision: got %d, want 2", got.CurrentRevision)
}
}
func TestItemGetRevisions(t *testing.T) {
database := mustConnectTestDB(t)
repo := NewItemRepository(database)
ctx := context.Background()
item := &Item{PartNumber: "REVS-001", ItemType: "part", Description: "multi rev"}
if err := repo.Create(ctx, item, map[string]any{"step": "initial"}); err != nil {
t.Fatalf("Create: %v", err)
}
comment := "second revision"
repo.CreateRevision(ctx, &Revision{
ItemID: item.ID, Properties: map[string]any{"step": "updated"}, Comment: &comment,
})
revisions, err := repo.GetRevisions(ctx, item.ID)
if err != nil {
t.Fatalf("GetRevisions: %v", err)
}
if len(revisions) != 2 {
t.Errorf("expected 2 revisions, got %d", len(revisions))
}
// Revisions are returned newest first
if revisions[0].RevisionNumber != 2 {
t.Errorf("first revision should be #2 (newest), got #%d", revisions[0].RevisionNumber)
}
}
func TestItemSetThumbnailKey(t *testing.T) {
database := mustConnectTestDB(t)
repo := NewItemRepository(database)
ctx := context.Background()
item := &Item{PartNumber: "THUMB-001", ItemType: "part", Description: "thumbnail test"}
if err := repo.Create(ctx, item, nil); err != nil {
t.Fatalf("Create: %v", err)
}
if err := repo.SetThumbnailKey(ctx, item.ID, "items/thumb.png"); err != nil {
t.Fatalf("SetThumbnailKey: %v", err)
}
got, _ := repo.GetByID(ctx, item.ID)
if got.ThumbnailKey == nil || *got.ThumbnailKey != "items/thumb.png" {
t.Errorf("thumbnail_key: got %v, want %q", got.ThumbnailKey, "items/thumb.png")
}
}