fix(salary): validate category shape and add --validate preflight (#156)

validate_data() accepted category values that are not {count?, index?}
objects. They slipped through to format_entry(), which then raised
AttributeError on a normal table lookup (or serialized a malformed shape
under --json). It also accepted duplicate company names silently.

- collect_validation_issues() now also flags a non-object category value
  (and non-numeric count / non number-or-string index) as a hard error,
  and duplicate company names as a warning.
- validate_data() keeps its eager-fail behavior (same messages), so
  existing tests and load_data() are unchanged.
- --validate runs the checks standalone and prints an actionable report
  (exit 1 on errors, 0 on warnings-only/clean), letting users pre-flight
  their BYO salary_data.json.

Reproduced on master: validate_data({'companies':[{'company':'Acme',
'categories':{'eng':'not_a_dict'}}]}) returns without error, but
format_entry then raises AttributeError.

Co-authored-by: Tunic Assistant <assistant@tunic.local>
This commit is contained in:
Alaa-Taieb
2026-07-15 07:53:01 +02:00
committed by GitHub
co-authored by Tunic Assistant
parent 47118dcbf2
commit 55ba1c1652
3 changed files with 189 additions and 12 deletions
+85 -1
View File
@@ -3,8 +3,9 @@
import io
import tempfile
import unittest
from contextlib import redirect_stderr
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
from unittest import mock
import salary_lookup
from salary_lookup import (
@@ -15,6 +16,7 @@ from salary_lookup import (
match_score,
search_company,
validate_data,
collect_validation_issues,
)
@@ -241,6 +243,88 @@ class ValidateDataTests(unittest.TestCase):
)
class ValidateDataShapeTests(ValidateDataTests):
"""Category-shape and duplicate-name checks (reuses assert_invalid_data)."""
def test_malformed_category_value_rejected(self):
data = {"companies": [{"company": "Acme", "categories": {"eng": "not_a_dict"}}]}
self.assert_invalid_data(data, "must be an object with 'count' and/or 'index'")
def test_non_numeric_count_rejected(self):
data = {
"companies": [
{"company": "Acme", "categories": {"eng": {"count": "many"}}}
]
}
self.assert_invalid_data(data, "count must be a number")
def test_duplicate_company_name_is_warning(self):
data = {
"companies": [
{"company": "Acme"},
{"company": "Other Corp"},
{"company": "Acme"},
]
}
errors, warnings = collect_validation_issues(data)
self.assertEqual(errors, [])
self.assertEqual(len(warnings), 1)
self.assertIn("Duplicate company name 'Acme'", warnings[0])
def test_valid_categories_have_no_issues(self):
data = {
"companies": [
{"company": "Acme", "categories": {"eng": {"count": 5, "index": 108.5}}}
]
}
errors, warnings = collect_validation_issues(data)
self.assertEqual(errors, [])
self.assertEqual(warnings, [])
class ValidateFlagTests(unittest.TestCase):
"""End-to-end checks for the --validate pre-flight flow."""
def _run_validate(self, payload):
with tempfile.TemporaryDirectory() as tmpdir:
data_file = Path(tmpdir) / "salary_data.json"
data_file.write_text(payload, encoding="utf-8")
original_data_file = salary_lookup.DATA_FILE
salary_lookup.DATA_FILE = data_file
argv_patch = mock.patch("sys.argv", ["salary_lookup.py", "--validate"])
argv_patch.start()
try:
stdout = io.StringIO()
with self.assertRaises(SystemExit) as raised:
with redirect_stdout(stdout):
salary_lookup.main()
return raised.exception.code, stdout.getvalue()
finally:
argv_patch.stop()
salary_lookup.DATA_FILE = original_data_file
def test_validate_flag_exits_1_on_errors(self):
code, out = self._run_validate(
'{"companies": [{"company": "Acme", "categories": {"eng": "not_a_dict"}}]}'
)
self.assertEqual(code, 1)
self.assertIn("must be an object with 'count' and/or 'index'", out)
def test_validate_flag_exits_0_on_clean(self):
code, out = self._run_validate(
'{"companies": [{"company": "Acme", "categories": {"eng": {"count": 5}}}]}'
)
self.assertEqual(code, 0)
self.assertIn("OK", out)
def test_validate_flag_exits_0_on_duplicates_only(self):
code, out = self._run_validate(
'{"companies": [{"company": "Acme"}, {"company": "Acme"}]}'
)
self.assertEqual(code, 0)
self.assertIn("Duplicate company name", out)
class UtilityTests(unittest.TestCase):
def test_normalize_strips_suffix_and_noise(self):
self.assertEqual(normalize("Novo Nordisk A/S"), "novonordisk")