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>
227 lines
7.3 KiB
Go
227 lines
7.3 KiB
Go
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())
|
|
}
|
|
}
|