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
252 lines
5.8 KiB
Go
252 lines
5.8 KiB
Go
package schema
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// findSchemasDir walks upward to find the project root and returns
|
|
// the path to the schemas/ directory.
|
|
func findSchemasDir(t *testing.T) string {
|
|
t.Helper()
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("getting working directory: %v", err)
|
|
}
|
|
for {
|
|
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
|
return filepath.Join(dir, "schemas")
|
|
}
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
t.Fatal("could not find project root")
|
|
}
|
|
dir = parent
|
|
}
|
|
}
|
|
|
|
func TestLoadSchema(t *testing.T) {
|
|
schemasDir := findSchemasDir(t)
|
|
path := filepath.Join(schemasDir, "kindred-rd.yaml")
|
|
|
|
s, err := Load(path)
|
|
if err != nil {
|
|
t.Fatalf("Load returned error: %v", err)
|
|
}
|
|
|
|
if s.Name != "kindred-rd" {
|
|
t.Errorf("schema name: got %q, want %q", s.Name, "kindred-rd")
|
|
}
|
|
if s.Separator != "-" {
|
|
t.Errorf("separator: got %q, want %q", s.Separator, "-")
|
|
}
|
|
if len(s.Segments) == 0 {
|
|
t.Fatal("expected at least one segment")
|
|
}
|
|
|
|
// First segment should be the category enum
|
|
cat := s.Segments[0]
|
|
if cat.Name != "category" {
|
|
t.Errorf("first segment name: got %q, want %q", cat.Name, "category")
|
|
}
|
|
if cat.Type != "enum" {
|
|
t.Errorf("first segment type: got %q, want %q", cat.Type, "enum")
|
|
}
|
|
if len(cat.Values) == 0 {
|
|
t.Error("category segment has no values")
|
|
}
|
|
}
|
|
|
|
func TestLoadSchemaDir(t *testing.T) {
|
|
schemasDir := findSchemasDir(t)
|
|
|
|
schemas, err := LoadAll(schemasDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadAll returned error: %v", err)
|
|
}
|
|
|
|
if len(schemas) == 0 {
|
|
t.Fatal("expected at least one schema")
|
|
}
|
|
|
|
if _, ok := schemas["kindred-rd"]; !ok {
|
|
t.Error("kindred-rd schema not found in loaded schemas")
|
|
}
|
|
}
|
|
|
|
func TestLoadSchemaValidation(t *testing.T) {
|
|
schemasDir := findSchemasDir(t)
|
|
|
|
schemas, err := LoadAll(schemasDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadAll returned error: %v", err)
|
|
}
|
|
|
|
for name, s := range schemas {
|
|
if s.Name == "" {
|
|
continue // non-part-numbering schemas (e.g., location_schema)
|
|
}
|
|
if err := s.Validate(); err != nil {
|
|
t.Errorf("schema %q failed validation: %v", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGetPropertiesForCategory(t *testing.T) {
|
|
ps := &PropertySchemas{
|
|
Version: 1,
|
|
Defaults: map[string]PropertyDefinition{
|
|
"weight": {Type: "number", Unit: "kg"},
|
|
"color": {Type: "string"},
|
|
},
|
|
Categories: map[string]map[string]PropertyDefinition{
|
|
"F": {
|
|
"thread_size": {Type: "string", Required: true},
|
|
"weight": {Type: "number", Unit: "g"}, // override default
|
|
},
|
|
},
|
|
}
|
|
|
|
props := ps.GetPropertiesForCategory("F01")
|
|
|
|
// Should have all three: weight (overridden), color (default), thread_size (category)
|
|
if len(props) != 3 {
|
|
t.Errorf("expected 3 properties, got %d", len(props))
|
|
}
|
|
if props["weight"].Unit != "g" {
|
|
t.Errorf("weight unit: got %q, want %q (should be overridden by category)", props["weight"].Unit, "g")
|
|
}
|
|
if props["color"].Type != "string" {
|
|
t.Errorf("color type: got %q, want %q", props["color"].Type, "string")
|
|
}
|
|
if !props["thread_size"].Required {
|
|
t.Error("thread_size should be required")
|
|
}
|
|
}
|
|
|
|
func TestGetPropertiesForUnknownCategory(t *testing.T) {
|
|
ps := &PropertySchemas{
|
|
Version: 1,
|
|
Defaults: map[string]PropertyDefinition{
|
|
"weight": {Type: "number"},
|
|
},
|
|
Categories: map[string]map[string]PropertyDefinition{
|
|
"F": {"thread_size": {Type: "string"}},
|
|
},
|
|
}
|
|
|
|
props := ps.GetPropertiesForCategory("Z99")
|
|
|
|
// Only defaults, no category-specific properties
|
|
if len(props) != 1 {
|
|
t.Errorf("expected 1 property (defaults only), got %d", len(props))
|
|
}
|
|
if _, ok := props["weight"]; !ok {
|
|
t.Error("default property 'weight' should be present")
|
|
}
|
|
}
|
|
|
|
func TestApplyDefaults(t *testing.T) {
|
|
ps := &PropertySchemas{
|
|
Version: 1,
|
|
Defaults: map[string]PropertyDefinition{
|
|
"status": {Type: "string", Default: "draft"},
|
|
"weight": {Type: "number"},
|
|
},
|
|
}
|
|
|
|
props := map[string]any{"custom": "value"}
|
|
result := ps.ApplyDefaults(props, "X")
|
|
|
|
if result["status"] != "draft" {
|
|
t.Errorf("status: got %v, want %q", result["status"], "draft")
|
|
}
|
|
if result["custom"] != "value" {
|
|
t.Errorf("custom: got %v, want %q", result["custom"], "value")
|
|
}
|
|
// weight has no default, should not be added
|
|
if _, ok := result["weight"]; ok {
|
|
t.Error("weight should not be added (no default value)")
|
|
}
|
|
}
|
|
|
|
func TestSchemaValidate(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
schema Schema
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "empty name",
|
|
schema: Schema{Name: "", Segments: []Segment{{Name: "s", Type: "constant", Value: "X"}}},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "no segments",
|
|
schema: Schema{Name: "test", Segments: nil},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "valid minimal",
|
|
schema: Schema{
|
|
Name: "test",
|
|
Segments: []Segment{
|
|
{Name: "prefix", Type: "constant", Value: "X"},
|
|
},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "enum without values",
|
|
schema: Schema{
|
|
Name: "test",
|
|
Segments: []Segment{
|
|
{Name: "cat", Type: "enum"},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "serial without length",
|
|
schema: Schema{
|
|
Name: "test",
|
|
Segments: []Segment{
|
|
{Name: "seq", Type: "serial", Length: 0},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := tt.schema.Validate()
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetSegment(t *testing.T) {
|
|
s := &Schema{
|
|
Segments: []Segment{
|
|
{Name: "prefix", Type: "constant", Value: "KS"},
|
|
{Name: "serial", Type: "serial", Length: 4},
|
|
},
|
|
}
|
|
|
|
seg := s.GetSegment("serial")
|
|
if seg == nil {
|
|
t.Fatal("GetSegment returned nil for existing segment")
|
|
}
|
|
if seg.Type != "serial" {
|
|
t.Errorf("segment type: got %q, want %q", seg.Type, "serial")
|
|
}
|
|
|
|
if s.GetSegment("nonexistent") != nil {
|
|
t.Error("GetSegment should return nil for nonexistent segment")
|
|
}
|
|
}
|