validator: port FillValidator + EmailValidator from Rust validator crate
Per architecture_comparison.md universal-win for Go side: ports the Rust crates/validator/src/staffing/ to internal/validator/. Production safety net Go was missing — FillValidator catches phantom worker IDs + status/blacklist/geo/role mismatches; EmailValidator catches SSN-shape PII + salary disclosure + wrong-target name in email/SMS drafts. Files: - types.go: Artifact (FillProposal | EmailDraft), Validator interface, WorkerLookup interface, ValidationError + Finding + Severity - lookup.go: InMemoryWorkerLookup with case-insensitive ID lookup - fill.go: FillValidator — schema → completeness → cross-roster (phantom ID / status / blacklist / geo / role) - email.go: EmailValidator — schema → length → PII (SSN + salary) → worker-name consistency - fill_test.go + email_test.go: 24 tests covering happy path + every error variant + the load-bearing edge cases (phone-pattern not flagged as SSN, flanking-digit guard rejects extended numeric runs) Validator names match Rust (staffing.fill / staffing.email) so cross-runtime audit logs share the same identifier. PII scanners (containsSSNPattern, containsSalaryDisclosure) ported byte-for-byte so a draft flagged by one runtime is flagged by the other. Caveat: the Rust validator crate also has parquet_lookup.rs (loads workers_500k.parquet at startup) and playbook.rs (additional checks). Those weren't ported in this wave — only the two load-bearing validators that were named in the comparison doc. Closes one of the two universal-win items for Go side. The other (materializer port) remains deferred — it's a bigger surface change and depends on transforms.ts source-class adapters. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Helpers — mirror the Rust test helpers.
|
||||
|
||||
func mkLookup(records ...WorkerRecord) WorkerLookup {
|
||||
return NewInMemoryWorkerLookup(records)
|
||||
}
|
||||
|
||||
func mkWorker(id, name, status, city, state, role string) WorkerRecord {
|
||||
return WorkerRecord{
|
||||
CandidateID: id,
|
||||
Name: name,
|
||||
Status: status,
|
||||
City: strPtr(city),
|
||||
State: strPtr(state),
|
||||
Role: strPtr(role),
|
||||
}
|
||||
}
|
||||
|
||||
func asValidationError(err error) (*ValidationError, bool) {
|
||||
var ve *ValidationError
|
||||
if errors.As(err, &ve) {
|
||||
return ve, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ── Schema-level errors ──
|
||||
|
||||
func TestFill_WrongArtifactType_FailsSchema(t *testing.T) {
|
||||
v := NewFillValidator(mkLookup())
|
||||
_, err := v.Validate(Artifact{EmailDraft: map[string]any{}})
|
||||
ve, ok := asValidationError(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected ValidationError, got %v", err)
|
||||
}
|
||||
if ve.Kind != ErrSchema || ve.Field != "artifact" {
|
||||
t.Errorf("expected schema/artifact error, got %+v", ve)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFill_MissingFillsArray_FailsSchema(t *testing.T) {
|
||||
v := NewFillValidator(mkLookup())
|
||||
_, err := v.Validate(Artifact{FillProposal: map[string]any{}})
|
||||
ve, _ := asValidationError(err)
|
||||
if ve == nil || ve.Kind != ErrSchema || ve.Field != "fills" {
|
||||
t.Errorf("expected schema/fills error, got %+v", ve)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFill_MissingCandidateID_FailsSchema(t *testing.T) {
|
||||
v := NewFillValidator(mkLookup())
|
||||
_, err := v.Validate(Artifact{FillProposal: map[string]any{
|
||||
"fills": []any{
|
||||
map[string]any{"name": "Alice"},
|
||||
},
|
||||
}})
|
||||
ve, _ := asValidationError(err)
|
||||
if ve == nil || ve.Kind != ErrSchema || ve.Field != "fills[0].candidate_id" {
|
||||
t.Errorf("expected schema/fills[0].candidate_id error, got %+v", ve)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Completeness ──
|
||||
|
||||
func TestFill_TargetCountMismatch_FailsCompleteness(t *testing.T) {
|
||||
v := NewFillValidator(mkLookup(mkWorker("w1", "Alice", "active", "Toledo", "OH", "Welder")))
|
||||
_, err := v.Validate(Artifact{FillProposal: map[string]any{
|
||||
"_context": map[string]any{"target_count": float64(2)},
|
||||
"fills": []any{map[string]any{"candidate_id": "w1", "name": "Alice"}},
|
||||
}})
|
||||
ve, _ := asValidationError(err)
|
||||
if ve == nil || ve.Kind != ErrCompleteness {
|
||||
t.Errorf("expected completeness error, got %+v", ve)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cross-roster checks ──
|
||||
|
||||
func TestFill_PhantomID_FailsConsistency(t *testing.T) {
|
||||
// Lookup is empty → any candidate_id is "phantom" — the
|
||||
// load-bearing check for the 0→85% pattern.
|
||||
v := NewFillValidator(mkLookup())
|
||||
_, err := v.Validate(Artifact{FillProposal: map[string]any{
|
||||
"fills": []any{map[string]any{"candidate_id": "phantom-id", "name": "Alice"}},
|
||||
}})
|
||||
ve, _ := asValidationError(err)
|
||||
if ve == nil || ve.Kind != ErrConsistency {
|
||||
t.Errorf("expected consistency error on phantom ID, got %+v", ve)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFill_DuplicateID_FailsConsistency(t *testing.T) {
|
||||
v := NewFillValidator(mkLookup(mkWorker("w1", "Alice", "active", "Toledo", "OH", "Welder")))
|
||||
_, err := v.Validate(Artifact{FillProposal: map[string]any{
|
||||
"fills": []any{
|
||||
map[string]any{"candidate_id": "w1", "name": "Alice"},
|
||||
map[string]any{"candidate_id": "w1", "name": "Alice"},
|
||||
},
|
||||
}})
|
||||
ve, _ := asValidationError(err)
|
||||
if ve == nil || ve.Kind != ErrConsistency {
|
||||
t.Errorf("expected consistency error on duplicate ID, got %+v", ve)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFill_InactiveStatus_FailsConsistency(t *testing.T) {
|
||||
v := NewFillValidator(mkLookup(mkWorker("w1", "Alice", "inactive", "Toledo", "OH", "Welder")))
|
||||
_, err := v.Validate(Artifact{FillProposal: map[string]any{
|
||||
"fills": []any{map[string]any{"candidate_id": "w1", "name": "Alice"}},
|
||||
}})
|
||||
ve, _ := asValidationError(err)
|
||||
if ve == nil || ve.Kind != ErrConsistency {
|
||||
t.Errorf("expected consistency error on inactive status, got %+v", ve)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFill_Blacklist_FailsPolicy(t *testing.T) {
|
||||
w := mkWorker("w1", "Alice", "active", "Toledo", "OH", "Welder")
|
||||
w.BlacklistedClients = []string{"CLI-99"}
|
||||
v := NewFillValidator(mkLookup(w))
|
||||
_, err := v.Validate(Artifact{FillProposal: map[string]any{
|
||||
"_context": map[string]any{"client_id": "cli-99"}, // case-insensitive
|
||||
"fills": []any{map[string]any{"candidate_id": "w1", "name": "Alice"}},
|
||||
}})
|
||||
ve, _ := asValidationError(err)
|
||||
if ve == nil || ve.Kind != ErrPolicy {
|
||||
t.Errorf("expected policy error on blacklist, got %+v", ve)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFill_GeoMismatch_FailsConsistency(t *testing.T) {
|
||||
// Worker in Detroit, contract says Toledo.
|
||||
v := NewFillValidator(mkLookup(mkWorker("w1", "Alice", "active", "Detroit", "MI", "Welder")))
|
||||
_, err := v.Validate(Artifact{FillProposal: map[string]any{
|
||||
"_context": map[string]any{"city": "Toledo", "state": "OH"},
|
||||
"fills": []any{map[string]any{"candidate_id": "w1", "name": "Alice"}},
|
||||
}})
|
||||
ve, _ := asValidationError(err)
|
||||
if ve == nil || ve.Kind != ErrConsistency {
|
||||
t.Errorf("expected consistency error on geo mismatch, got %+v", ve)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFill_RoleMismatch_FailsConsistency(t *testing.T) {
|
||||
v := NewFillValidator(mkLookup(mkWorker("w1", "Alice", "active", "Toledo", "OH", "Forklift Operator")))
|
||||
_, err := v.Validate(Artifact{FillProposal: map[string]any{
|
||||
"_context": map[string]any{"role": "Welder"},
|
||||
"fills": []any{map[string]any{"candidate_id": "w1", "name": "Alice"}},
|
||||
}})
|
||||
ve, _ := asValidationError(err)
|
||||
if ve == nil || ve.Kind != ErrConsistency {
|
||||
t.Errorf("expected consistency error on role mismatch, got %+v", ve)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Happy path ──
|
||||
|
||||
func TestFill_WellFormed_Passes(t *testing.T) {
|
||||
v := NewFillValidator(mkLookup(
|
||||
mkWorker("w1", "Alice", "active", "Toledo", "OH", "Welder"),
|
||||
mkWorker("w2", "Bob", "active", "Toledo", "OH", "Welder"),
|
||||
))
|
||||
report, err := v.Validate(Artifact{FillProposal: map[string]any{
|
||||
"_context": map[string]any{
|
||||
"target_count": float64(2),
|
||||
"city": "Toledo",
|
||||
"state": "OH",
|
||||
"role": "Welder",
|
||||
},
|
||||
"fills": []any{
|
||||
map[string]any{"candidate_id": "w1", "name": "Alice"},
|
||||
map[string]any{"candidate_id": "w2", "name": "Bob"},
|
||||
},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("expected pass, got %v", err)
|
||||
}
|
||||
if len(report.Findings) != 0 {
|
||||
t.Errorf("expected zero findings, got %v", report.Findings)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Name mismatch is a Finding (warning), not an error ──
|
||||
|
||||
func TestFill_NameMismatch_EmitsWarning(t *testing.T) {
|
||||
v := NewFillValidator(mkLookup(mkWorker("w1", "Alice Smith", "active", "Toledo", "OH", "Welder")))
|
||||
report, err := v.Validate(Artifact{FillProposal: map[string]any{
|
||||
"fills": []any{
|
||||
map[string]any{"candidate_id": "w1", "name": "Alyssa Smith"}, // typo / outdated
|
||||
},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("name mismatch should NOT error, got %v", err)
|
||||
}
|
||||
if len(report.Findings) != 1 || report.Findings[0].Severity != SeverityWarning {
|
||||
t.Errorf("expected 1 warning finding, got %v", report.Findings)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Case-insensitive matches ──
|
||||
|
||||
func TestFill_CaseInsensitiveMatch_Passes(t *testing.T) {
|
||||
v := NewFillValidator(mkLookup(mkWorker("w1", "Alice", "ACTIVE", "TOLEDO", "oh", "Welder")))
|
||||
_, err := v.Validate(Artifact{FillProposal: map[string]any{
|
||||
"_context": map[string]any{"city": "Toledo", "state": "OH"},
|
||||
"fills": []any{map[string]any{"candidate_id": "w1", "name": "Alice"}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Errorf("case-insensitive comparisons should pass, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validator name is stable ──
|
||||
|
||||
func TestFill_NameMatchesRust(t *testing.T) {
|
||||
v := NewFillValidator(mkLookup())
|
||||
if v.Name() != "staffing.fill" {
|
||||
t.Errorf("name should match Rust 'staffing.fill', got %q", v.Name())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user