Files
ai-job-search/tests/test_tracker_notes_csv_safe.py
T
Jakob Stender Guldberg c93609cd22 fix(gmail-sync,outcome): keep free-form tracker notes free of CSV-breaking characters (#454) (#455)
* fix(gmail-sync): strip CSV-breaking characters from the email subject

Step 7a interpolated the raw subject line of a received email into the
`notes` column of job_search_tracker.csv. No writer here emits a quoted
tracker field, so an unescaped comma splits the row - for csv.DictReader
just as much as for a naive split, which matters because
tools/rank_state.py is the repo's only machine reader and uses exactly
that. `notes` is column 10 of 14, so a subject as ordinary as
"Re: Your application, Data Scientist" shifted cv_file,
cover_letter_file and source a column left. A line break is worse: it
ends the row and starts a second one.

The rule now sits on the append instruction itself rather than in a
general note a writer can miss. The subject survives verbatim in the
archive's outcome.md, which is Markdown and carries no such constraint.

/outcome Step 4 (outcome.md:195) is also free-form and has the same
exposure, but its text is model-authored in a turn the user is watching
rather than copied from third-party mail unattended. Left out
deliberately, to be filed separately.

* fix(outcome): keep the Step 4 tracker note free of CSV-breaking characters

/outcome Step 4 appended "a short dated note" to `notes` with no
constraint on its content, the same exposure /gmail-sync Step 7a had:
nothing quotes a tracker field, so `rejected, no feedback given` shifts
cv_file, cover_letter_file and source a column left under
csv.DictReader, and a line break ends the row. The append instruction
now requires a note with no commas, double quotes or line breaks.

Folded in at the maintainer's request on #455 so one entry and one rule
cover both free-form writers. The CSV-safety tests move out of
test_gmail_sync_command.py into test_tracker_notes_csv_safe.py, where a
CASES table pins the rule on each writer's append line.
2026-09-16 06:45:55 +02:00

136 lines
5.2 KiB
Python

"""Guards for the rule that keeps free-form `notes` from breaking a tracker row.
No writer in this framework emits a quoted tracker field, so a comma inside
`notes` splits the row - for the `csv.DictReader` in `tools/rank_state.py` as
much as for a naive split - and shifts `cv_file`, `cover_letter_file` and
`source` a column left. A line break is worse: it ends the row. Two writers put
free-form text into `notes`: `/gmail-sync` Step 7a copies an email subject, and
`/outcome` Step 4 writes a short note of its own. The fixed-format writers
(`followed up YYYY-MM-DD`, `stale resolved no_response (YYYY-MM-DD)`,
`redrafted`) cannot contain the characters and are not listed.
The spec IS the implementation, so the guard is `CASES`: each rule must sit on
the line that instructs the append, not merely somewhere in the section. The
shape tests below it parse with `csv.DictReader` and document why the rule
exists; they pass on master too.
"""
import csv
import io
import re
import unittest
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
COMMANDS = REPO / ".claude" / "commands"
GMAIL_SYNC = COMMANDS / "gmail-sync.md"
OUTCOME = COMMANDS / "outcome.md"
TRACKER_HEADER = (
"date,company,sector,role,role_type,channel,status,contact_person,"
"fit_rating,notes,cv_file,cover_letter_file,source,deadline"
)
def section(path, heading):
"""The body of one markdown section, up to the next heading of any depth."""
text = path.read_text(encoding="utf-8")
start = text.index(heading) + len(heading)
rest = text[start:]
end = re.search(r"^#{1,4} ", rest, re.MULTILINE)
return rest[: end.start()] if end else rest
class FreeFormNotesWritersStateTheRule(unittest.TestCase):
"""Format: (path, heading, line_anchor, rule, why)"""
CASES = [
(
GMAIL_SYNC,
"### Step 7a: Write Approved Updates",
"append to `notes`",
"with every comma, double quote and line break deleted from the subject first",
"Step 7a item 2 deliberately keeps the subject verbatim in `outcome.md`, "
"so the rule must sit on the tracker append, not anywhere in the step",
),
(
OUTCOME,
"## Step 4: Update the Tracker",
"append a short dated note",
"containing no commas, double quotes or line breaks",
"Step 4 is the primary status-update path and its note is written "
"free-form - `rejected, no feedback given` is the natural sentence",
),
]
def test_rule_is_stated_where_the_append_happens(self):
for path, heading, anchor, rule, why in self.CASES:
with self.subTest(path=path.name, heading=heading):
lines = [l for l in section(path, heading).splitlines() if anchor in l]
self.assertEqual(len(lines), 1, f"expected one append instruction: {why}")
self.assertIn(rule, lines[0], why)
class NotesShapeUnderTheShippedReader(unittest.TestCase):
"""Why the rule exists, demonstrated with the reader the repo ships."""
SUBJECT = 'Re: Your application, Data Scientist - "next steps"'
def test_sanitised_note_keeps_the_row_parseable(self):
safe = self.SUBJECT.replace(",", "").replace('"', "")
rows = self._parse(f'2026-09-12 gmail-sync: acknowledged ("{safe}")')
self.assertEqual(len(rows), 1, "the note must not end the row early")
row = rows[0]
self.assertIsNone(row.get(None), "the row must be no wider than the header")
self.assertEqual(row["cv_file"], "cv/main_acme_data_scientist.tex")
self.assertEqual(
row["cover_letter_file"], "cover_letters/cover_acme_data_scientist.tex"
)
self.assertEqual(row["source"], "linkedin")
def test_a_comma_in_the_note_shifts_the_columns(self):
for note in (
f'2026-09-12 gmail-sync: acknowledged ("{self.SUBJECT}")',
"2026-09-12 rejected, no feedback given",
):
with self.subTest(note=note):
row = self._parse(note)[0]
self.assertIsNotNone(row.get(None), "the comma must widen the row")
self.assertNotEqual(row["cv_file"], "cv/main_acme_data_scientist.tex")
self.assertNotEqual(row["source"], "linkedin")
def test_a_line_break_in_the_note_splits_the_row_in_two(self):
rows = self._parse('2026-09-12 gmail-sync: acknowledged ("Re: update\nlater")')
self.assertEqual(len(rows), 2)
self.assertIsNone(rows[0]["cv_file"], "the first row ends mid-note")
@classmethod
def _parse(cls, notes):
stream = io.StringIO(TRACKER_HEADER + "\n" + cls._row(notes) + "\n")
return list(csv.DictReader(stream))
@staticmethod
def _row(notes):
return ",".join(
[
"2026-09-01",
"Acme",
"tech",
"Data Scientist",
"full_time",
"portal",
"applied",
"",
"8",
notes,
"cv/main_acme_data_scientist.tex",
"cover_letters/cover_acme_data_scientist.tex",
"linkedin",
"2026-09-30",
]
)
if __name__ == "__main__":
unittest.main()