diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 605cb89..0f2ac90 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -621,6 +621,12 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) { return } + if err := s.partgen.Validate(partNumber, schemaName); err != nil { + s.logger.Error().Err(err).Str("part_number", partNumber).Msg("generated part number failed validation") + writeError(w, http.StatusInternalServerError, "validation_failed", err.Error()) + return + } + item = &db.Item{ PartNumber: partNumber, ItemType: itemType, diff --git a/internal/partnum/generator.go b/internal/partnum/generator.go index 31b3776..6325c3f 100644 --- a/internal/partnum/generator.go +++ b/internal/partnum/generator.go @@ -178,7 +178,84 @@ func (g *Generator) Validate(partNumber string, schemaName string) error { return fmt.Errorf("unknown schema: %s", schemaName) } - // TODO: parse part number and validate each segment - _ = s + parts := strings.Split(partNumber, s.Separator) + if len(parts) != len(s.Segments) { + return fmt.Errorf("expected %d segments, got %d", len(s.Segments), len(parts)) + } + + for i, seg := range s.Segments { + val := parts[i] + if err := g.validateSegment(&seg, val); err != nil { + return fmt.Errorf("segment %s: %w", seg.Name, err) + } + } + + return nil +} + +// validateSegment checks that a single segment value is valid. +func (g *Generator) validateSegment(seg *schema.Segment, val string) error { + switch seg.Type { + case "constant": + if val != seg.Value { + return fmt.Errorf("expected %q, got %q", seg.Value, val) + } + + case "enum": + if _, ok := seg.Values[val]; !ok { + return fmt.Errorf("invalid enum value: %s", val) + } + + case "string": + if seg.Length > 0 && len(val) != seg.Length { + return fmt.Errorf("value must be exactly %d characters", seg.Length) + } + if seg.MinLength > 0 && len(val) < seg.MinLength { + return fmt.Errorf("value must be at least %d characters", seg.MinLength) + } + if seg.MaxLength > 0 && len(val) > seg.MaxLength { + return fmt.Errorf("value must be at most %d characters", seg.MaxLength) + } + if seg.Case == "upper" && val != strings.ToUpper(val) { + return fmt.Errorf("value must be uppercase") + } + if seg.Case == "lower" && val != strings.ToLower(val) { + return fmt.Errorf("value must be lowercase") + } + if seg.Validation.Pattern != "" { + re := regexp.MustCompile(seg.Validation.Pattern) + if !re.MatchString(val) { + msg := seg.Validation.Message + if msg == "" { + msg = fmt.Sprintf("value does not match pattern %s", seg.Validation.Pattern) + } + return fmt.Errorf("%s", msg) + } + } + + case "serial": + if seg.Length > 0 && len(val) != seg.Length { + return fmt.Errorf("value must be exactly %d characters", seg.Length) + } + for _, ch := range val { + if ch < '0' || ch > '9' { + return fmt.Errorf("serial must be numeric") + } + } + + case "date": + layout := seg.Value + if layout == "" { + layout = "20060102" + } + expected := time.Now().UTC().Format(layout) + if len(val) != len(expected) { + return fmt.Errorf("date segment length mismatch: expected %d, got %d", len(expected), len(val)) + } + + default: + return fmt.Errorf("unknown segment type: %s", seg.Type) + } + return nil } diff --git a/internal/partnum/generator_test.go b/internal/partnum/generator_test.go index 0e63ccb..ec420b8 100644 --- a/internal/partnum/generator_test.go +++ b/internal/partnum/generator_test.go @@ -226,6 +226,118 @@ func TestGenerateDateSegmentCustomFormat(t *testing.T) { } } +// --- Validation tests --- + +func TestValidateBasic(t *testing.T) { + s := testSchema() + gen := NewGenerator(map[string]*schema.Schema{"test": s}, &mockSeqStore{}) + + if err := gen.Validate("F01-0001", "test"); err != nil { + t.Fatalf("expected valid, got error: %v", err) + } +} + +func TestValidateWrongSegmentCount(t *testing.T) { + s := testSchema() + gen := NewGenerator(map[string]*schema.Schema{"test": s}, &mockSeqStore{}) + + if err := gen.Validate("F01-0001-EXTRA", "test"); err == nil { + t.Fatal("expected error for wrong segment count") + } +} + +func TestValidateInvalidEnum(t *testing.T) { + s := testSchema() + gen := NewGenerator(map[string]*schema.Schema{"test": s}, &mockSeqStore{}) + + if err := gen.Validate("ZZZ-0001", "test"); err == nil { + t.Fatal("expected error for invalid enum value") + } +} + +func TestValidateNonNumericSerial(t *testing.T) { + s := testSchema() + gen := NewGenerator(map[string]*schema.Schema{"test": s}, &mockSeqStore{}) + + if err := gen.Validate("F01-ABCD", "test"); err == nil { + t.Fatal("expected error for non-numeric serial") + } +} + +func TestValidateSerialWrongLength(t *testing.T) { + s := testSchema() + gen := NewGenerator(map[string]*schema.Schema{"test": s}, &mockSeqStore{}) + + if err := gen.Validate("F01-01", "test"); err == nil { + t.Fatal("expected error for wrong serial length") + } +} + +func TestValidateConstantSegment(t *testing.T) { + s := &schema.Schema{ + Name: "const-val", + Version: 1, + Separator: "-", + Segments: []schema.Segment{ + {Name: "prefix", Type: "constant", Value: "KS"}, + {Name: "serial", Type: "serial", Length: 4}, + }, + } + gen := NewGenerator(map[string]*schema.Schema{"const-val": s}, &mockSeqStore{}) + + if err := gen.Validate("KS-0001", "const-val"); err != nil { + t.Fatalf("expected valid, got error: %v", err) + } + if err := gen.Validate("XX-0001", "const-val"); err == nil { + t.Fatal("expected error for wrong constant value") + } +} + +func TestValidateUnknownSchema(t *testing.T) { + gen := NewGenerator(map[string]*schema.Schema{}, &mockSeqStore{}) + + if err := gen.Validate("F01-0001", "nonexistent"); err == nil { + t.Fatal("expected error for unknown schema") + } +} + +func TestValidateDateSegment(t *testing.T) { + s := &schema.Schema{ + Name: "date-val", + Version: 1, + Separator: "-", + Segments: []schema.Segment{ + {Name: "date", Type: "date"}, + {Name: "serial", Type: "serial", Length: 3}, + }, + } + gen := NewGenerator(map[string]*schema.Schema{"date-val": s}, &mockSeqStore{}) + + today := time.Now().UTC().Format("20060102") + if err := gen.Validate(today+"-001", "date-val"); err != nil { + t.Fatalf("expected valid, got error: %v", err) + } + if err := gen.Validate("20-001", "date-val"); err == nil { + t.Fatal("expected error for wrong date length") + } +} + +func TestValidateGeneratedOutput(t *testing.T) { + s := testSchema() + gen := NewGenerator(map[string]*schema.Schema{"test": s}, &mockSeqStore{}) + + pn, err := gen.Generate(context.Background(), Input{ + SchemaName: "test", + Values: map[string]string{"category": "F01"}, + }) + if err != nil { + t.Fatalf("Generate error: %v", err) + } + if err := gen.Validate(pn, "test"); err != nil { + t.Fatalf("generated part number %q failed validation: %v", pn, err) + } +} + func TestGenerateDateSegmentYearOnly(t *testing.T) { s := &schema.Schema{ Name: "date-year",