feat(partnum): implement part number validation (#80)
Implement Generator.Validate() to check part numbers against schemas: - Split by separator, verify segment count - Constant: must equal expected value - Enum: must be in allowed values map - String: length, case, pattern constraints - Serial: length + numeric-only check - Date: length matches expected format output Add belt-and-suspenders call in HandleCreateItem after Generate(). Add 9 validation tests (all pass alongside 10 existing tests). Closes #80
This commit is contained in:
@@ -621,6 +621,12 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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{
|
item = &db.Item{
|
||||||
PartNumber: partNumber,
|
PartNumber: partNumber,
|
||||||
ItemType: itemType,
|
ItemType: itemType,
|
||||||
|
|||||||
@@ -178,7 +178,84 @@ func (g *Generator) Validate(partNumber string, schemaName string) error {
|
|||||||
return fmt.Errorf("unknown schema: %s", schemaName)
|
return fmt.Errorf("unknown schema: %s", schemaName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: parse part number and validate each segment
|
parts := strings.Split(partNumber, s.Separator)
|
||||||
_ = s
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestGenerateDateSegmentYearOnly(t *testing.T) {
|
||||||
s := &schema.Schema{
|
s := &schema.Schema{
|
||||||
Name: "date-year",
|
Name: "date-year",
|
||||||
|
|||||||
Reference in New Issue
Block a user