mirror of
https://github.com/prdlk/jobyssey.git
synced 2026-11-02 13:12:49 +00:00
test(tests): update suite for sync‑twenty migration and drop obsolete gmail/notion sync tests
This commit is contained in:
1 parent
7007c4aa9d
commit
b552b70783
8 files changed
+201
-312
No files matched your search
@@ -27,10 +27,9 @@ REPO = Path(__file__).resolve().parent.parent
|
|||||||
COMMANDS = REPO / ".claude" / "commands"
|
COMMANDS = REPO / ".claude" / "commands"
|
||||||
APPLY = COMMANDS / "apply.md"
|
APPLY = COMMANDS / "apply.md"
|
||||||
OUTCOME = COMMANDS / "outcome.md"
|
OUTCOME = COMMANDS / "outcome.md"
|
||||||
GMAIL_SYNC = COMMANDS / "gmail-sync.md"
|
|
||||||
HTML_REPORT = COMMANDS / "html-report.md"
|
HTML_REPORT = COMMANDS / "html-report.md"
|
||||||
INTERVIEW = COMMANDS / "interview.md"
|
INTERVIEW = COMMANDS / "interview.md"
|
||||||
NOTION_SYNC = COMMANDS / "notion-sync.md"
|
SYNC_TWENTY = COMMANDS / "sync-twenty.md"
|
||||||
SKILL = REPO / ".claude" / "skills" / "job-application-assistant" / "SKILL.md"
|
SKILL = REPO / ".claude" / "skills" / "job-application-assistant" / "SKILL.md"
|
||||||
SCRAPER = REPO / ".claude" / "skills" / "job-scraper" / "SKILL.md"
|
SCRAPER = REPO / ".claude" / "skills" / "job-scraper" / "SKILL.md"
|
||||||
DOCS_README = REPO / "documents" / "README.md"
|
DOCS_README = REPO / "documents" / "README.md"
|
||||||
@@ -190,10 +189,7 @@ class DraftedMeansDraftedToEveryReader(unittest.TestCase):
|
|||||||
"""`drafted` is non-final, so readers that mean *submitted* must say so.
|
"""`drafted` is non-final, so readers that mean *submitted* must say so.
|
||||||
|
|
||||||
Each of these defines its set by exclusion from the final statuses, so
|
Each of these defines its set by exclusion from the final statuses, so
|
||||||
a new non-final value joins them all silently. The one exception is
|
a new non-final value joins them all silently.
|
||||||
/gmail-sync, which must keep searching for drafted rows: the user
|
|
||||||
submitting by hand and not running /outcome is the failure #269 is
|
|
||||||
about, and an employer reply is how it gets caught.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
CASES = [
|
CASES = [
|
||||||
@@ -207,21 +203,6 @@ class DraftedMeansDraftedToEveryReader(unittest.TestCase):
|
|||||||
(OUTCOME, "## Step 4: Update the Tracker",
|
(OUTCOME, "## Step 4: Update the Tracker",
|
||||||
"overwrite its `date` column with the actual submission date",
|
"overwrite its `date` column with the actual submission date",
|
||||||
"the drafting date would be reported as the application date"),
|
"the drafting date would be reported as the application date"),
|
||||||
(GMAIL_SYNC, None, "`drafted` rows stay in this set",
|
|
||||||
"excluding them discards the row that identifies a submitted-but-"
|
|
||||||
"unrecorded application, which is the recovery #269 asks for"),
|
|
||||||
(GMAIL_SYNC, "## Step 5", "`drafted` -> `applied`, otherwise",
|
|
||||||
"the acknowledgement is the one email that proves a hand-submitted "
|
|
||||||
"application was sent; classified as noise, the recovery never fires"),
|
|
||||||
(GMAIL_SYNC, "### Step 7a", "also set `date` to the email's date",
|
|
||||||
"the row would keep the drafting date after being proved submitted"),
|
|
||||||
(GMAIL_SYNC, "## Step 9: Staleness Check", "Skip `drafted` rows here",
|
|
||||||
"an unsent draft reported as a forgotten application"),
|
|
||||||
(NOTION_SYNC, None, "omit when the status is `drafted`",
|
|
||||||
"an 'Applied on' date for a job never applied to"),
|
|
||||||
(NOTION_SYNC, None, "not yet submitted",
|
|
||||||
"page bodies are write-once, so calling drafts 'submitted documents' "
|
|
||||||
"is permanent even after /outcome records the real submission"),
|
|
||||||
(SCRAPER, None, "do not add a second row",
|
(SCRAPER, None, "do not add a second row",
|
||||||
"/scrape would duplicate the row Step 3b just wrote"),
|
"/scrape would duplicate the row Step 3b just wrote"),
|
||||||
(APPLY, "### Step 6b: Record the Application", "bare number, 0-100",
|
(APPLY, "### Step 6b: Record the Application", "bare number, 0-100",
|
||||||
@@ -315,8 +296,8 @@ class DeadlineSurvivesEveryWrite(unittest.TestCase):
|
|||||||
|
|
||||||
The header migration must be header-line-only (inserting it mid-column
|
The header migration must be header-line-only (inserting it mid-column
|
||||||
shifts every value of every existing row), and every path that rewrites
|
shifts every value of every existing row), and every path that rewrites
|
||||||
a tracker row (/outcome Step 4, /gmail-sync Step 7a) must preserve
|
a tracker row (/outcome Step 4) must preserve fields it does not parse -
|
||||||
fields it does not parse - the deadline is the first such field.
|
the deadline is the first such field.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
CASES = [
|
CASES = [
|
||||||
@@ -341,13 +322,9 @@ class DeadlineSurvivesEveryWrite(unittest.TestCase):
|
|||||||
"surfacing the deadline must not drag drafted rows into the follow-up offer"),
|
"surfacing the deadline must not drag drafted rows into the follow-up offer"),
|
||||||
(OUTCOME, "## Step 4: Update the Tracker", "preserve every other field of the row",
|
(OUTCOME, "## Step 4: Update the Tracker", "preserve every other field of the row",
|
||||||
"a status update that rewrites the row would blank the deadline column"),
|
"a status update that rewrites the row would blank the deadline column"),
|
||||||
(GMAIL_SYNC, "### Step 7a: Write Approved Updates", "preserve every other field",
|
(SYNC_TWENTY, None, "**Deadline precedence: the tracker wins too**",
|
||||||
"the sync path rewrites the row too - it must carry the same preservation rule"),
|
|
||||||
(NOTION_SYNC, None, "**Deadline precedence: the tracker wins too**",
|
|
||||||
"the tracker's deadline (written from the posting the application was actually "
|
"the tracker's deadline (written from the posting the application was actually "
|
||||||
"built on) must override the scraper's stored value"),
|
"built on) must override the scraper's stored value"),
|
||||||
(NOTION_SYNC, None, "tracker `deadline` column",
|
|
||||||
"the Deadine property must name the tracker column as its source"),
|
|
||||||
(SKILL, "### Step 3b: Record the Application", "`deadline` is the application deadline",
|
(SKILL, "### Step 3b: Record the Application", "`deadline` is the application deadline",
|
||||||
"the /scrape path reaches Step 3b without running /apply Step 0, so it must "
|
"the /scrape path reaches Step 3b without running /apply Step 0, so it must "
|
||||||
"still be told what the field is and where it comes from"),
|
"still be told what the field is and where it comes from"),
|
||||||
@@ -372,7 +349,7 @@ class DeadlineSurvivesEveryWrite(unittest.TestCase):
|
|||||||
"Step 4 forbids restructuring the CSV, so without this the header append reads "
|
"Step 4 forbids restructuring the CSV, so without this the header append reads "
|
||||||
"as a violation of the same command's own rule and an implementer has a "
|
"as a violation of the same command's own rule and an implementer has a "
|
||||||
"documented reason to skip the migration"),
|
"documented reason to skip the migration"),
|
||||||
(NOTION_SYNC, None, "never reconcile the two by picking the earlier or later date",
|
(SYNC_TWENTY, None, "never reconcile the two dates with `min()`/`max()`",
|
||||||
"the tracker-wins rule says which source to prefer but does not forbid the "
|
"the tracker-wins rule says which source to prefer but does not forbid the "
|
||||||
"plausible-looking min() of the two, which syncs a date the user never "
|
"plausible-looking min() of the two, which syncs a date the user never "
|
||||||
"applied against"),
|
"applied against"),
|
||||||
@@ -419,19 +396,12 @@ class ArchiveNameIsOnePathComponent(unittest.TestCase):
|
|||||||
"by the **Subfolder naming** rule in `documents/README.md`",
|
"by the **Subfolder naming** rule in `documents/README.md`",
|
||||||
"the /scrape path writes its documents before Step 3b consults /apply, "
|
"the /scrape path writes its documents before Step 3b consults /apply, "
|
||||||
"so /apply's filename rule cannot protect it"),
|
"so /apply's filename rule cannot protect it"),
|
||||||
(GMAIL_SYNC, "## Step 2: Load State",
|
|
||||||
"by the **Subfolder naming** rule in `documents/README.md`",
|
|
||||||
"gmail-sync both locates and creates archives; its old spaces-only "
|
|
||||||
"paraphrase would split state across two folders"),
|
|
||||||
(INTERVIEW, "## Step 1: Load the Application Context",
|
(INTERVIEW, "## Step 1: Load the Application Context",
|
||||||
"by the **Subfolder naming** rule in `documents/README.md`",
|
"by the **Subfolder naming** rule in `documents/README.md`",
|
||||||
"interview must read the same archive /apply and /outcome wrote"),
|
"interview must read the same archive /apply and /outcome wrote"),
|
||||||
(INTERVIEW, "### 6. Logistics",
|
(INTERVIEW, "### 6. Logistics",
|
||||||
"archive folder derived in Step 1",
|
"archive folder derived in Step 1",
|
||||||
"interview must reuse its canonical read path when writing the prep pack"),
|
"interview must reuse its canonical read path when writing the prep pack"),
|
||||||
(NOTION_SYNC, "## Step 5: Write the Detail Page",
|
|
||||||
"by the **Subfolder naming** rule in `documents/README.md`",
|
|
||||||
"notion-sync otherwise reports that the sanitized local archive is absent"),
|
|
||||||
(DOCS_README, "## applications/",
|
(DOCS_README, "## applications/",
|
||||||
"If the derived name is empty",
|
"If the derived name is empty",
|
||||||
"dropping untrusted punctuation can produce no component at all, which "
|
"dropping untrusted punctuation can produce no component at all, which "
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
"""Guards for /gmail-sync's Gmail query semantics.
|
|
||||||
|
|
||||||
The command's stated intent is "skip sent/drafts - status signals come
|
|
||||||
from what employers send you". `in:inbox` does not mean that: it matches
|
|
||||||
only messages currently IN the inbox, so it also excludes every archived
|
|
||||||
message - and, self-defeatingly, the mail matched by the very
|
|
||||||
job-search label Step 3.1 hunts for, because the standard filter that
|
|
||||||
applies such a label also archives ("skip the inbox"). The correct
|
|
||||||
operators for the stated intent are `-in:sent -in:drafts` (review
|
|
||||||
finding F18, 2026-08-19). The failure mode is silent under-detection: a
|
|
||||||
missed rejection or interview invite just looks like "no updates".
|
|
||||||
"""
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parent.parent
|
|
||||||
GMAIL_SYNC = REPO / ".claude" / "commands" / "gmail-sync.md"
|
|
||||||
|
|
||||||
|
|
||||||
class TestGmailQueryOperators(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.text = GMAIL_SYNC.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
def test_query_excludes_sent_and_drafts_explicitly(self):
|
|
||||||
self.assertIn(
|
|
||||||
"-in:sent -in:drafts",
|
|
||||||
self.text,
|
|
||||||
"the query must exclude sent/drafts with negative operators, "
|
|
||||||
"which keep archived and label-filtered mail in scope",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_query_never_restricts_to_the_inbox(self):
|
|
||||||
self.assertNotIn(
|
|
||||||
"in:inbox",
|
|
||||||
self.text.replace("-in:sent", "").replace("-in:drafts", ""),
|
|
||||||
"in:inbox silently drops archived mail and everything a "
|
|
||||||
"label-and-archive filter routed past the inbox - exactly the "
|
|
||||||
"mail the label search in Step 3.1 exists to find",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
"""Guards for the /notion-sync command spec.
|
|
||||||
|
|
||||||
The command is a markdown spec (the spec IS the implementation), so these
|
|
||||||
tests pin the invariants that would break silently: the header format that
|
|
||||||
lint_skills.py enforces, the gitignore entry that keeps the personal sync
|
|
||||||
state out of version control, and the privacy rule that forbids syncing
|
|
||||||
document content to Notion.
|
|
||||||
"""
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
try:
|
|
||||||
import yaml # noqa: F401 - only probing availability for the lint integration test
|
|
||||||
_HAVE_YAML = True
|
|
||||||
except ImportError:
|
|
||||||
_HAVE_YAML = False
|
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parent.parent
|
|
||||||
COMMAND = REPO / ".claude" / "commands" / "notion-sync.md"
|
|
||||||
GITIGNORE = REPO / ".gitignore"
|
|
||||||
|
|
||||||
|
|
||||||
class NotionSyncCommandSpec(unittest.TestCase):
|
|
||||||
def test_command_file_exists_with_lint_compliant_header(self):
|
|
||||||
self.assertTrue(COMMAND.is_file(), "command spec missing")
|
|
||||||
first_line = COMMAND.read_text(encoding="utf-8").splitlines()[0]
|
|
||||||
self.assertTrue(
|
|
||||||
first_line.startswith("# /notion-sync"),
|
|
||||||
f"header must start with '# /notion-sync' (lint_skills.py enforces it), got: {first_line!r}",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_command_file_is_substantive(self):
|
|
||||||
text = COMMAND.read_text(encoding="utf-8")
|
|
||||||
for section in ("## Step 0", "## Step 4", "## Important Rules"):
|
|
||||||
self.assertIn(section, text, f"spec lost its {section!r} section")
|
|
||||||
|
|
||||||
def test_personal_sync_state_is_gitignored(self):
|
|
||||||
self.assertIn(
|
|
||||||
"job_scraper/notion_sync.json",
|
|
||||||
GITIGNORE.read_text(encoding="utf-8"),
|
|
||||||
"notion_sync.json is personal state and must never be committable",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_privacy_rule_documents_never_sync(self):
|
|
||||||
text = COMMAND.read_text(encoding="utf-8")
|
|
||||||
self.assertIn(
|
|
||||||
"never upload, attach, or embed",
|
|
||||||
text,
|
|
||||||
"spec lost the rule that CV/cover-letter content never syncs to Notion",
|
|
||||||
)
|
|
||||||
|
|
||||||
@unittest.skipUnless(
|
|
||||||
_HAVE_YAML,
|
|
||||||
"PyYAML not installed (the CI Python-test job omits it; the lint job runs lint_skills.py directly)",
|
|
||||||
)
|
|
||||||
def test_lint_skills_passes(self):
|
|
||||||
result = subprocess.run(
|
|
||||||
[sys.executable, str(REPO / "tools" / "lint_skills.py")],
|
|
||||||
cwd=REPO,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
self.assertEqual(result.returncode, 0, f"lint_skills.py failed:\n{result.stdout}{result.stderr}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -64,14 +64,6 @@ class OutcomeFollowupBranchSpec(unittest.TestCase):
|
|||||||
"spec lost the cap that stops the follow-up branch from nagging indefinitely",
|
"spec lost the cap that stops the follow-up branch from nagging indefinitely",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_threshold_contrast_with_gmail_sync_documented(self):
|
|
||||||
text = COMMAND.read_text(encoding="utf-8")
|
|
||||||
self.assertIn(
|
|
||||||
"30-day staleness flag",
|
|
||||||
text,
|
|
||||||
"spec lost the rationale for the 10-day nudge vs /gmail-sync's 30-day alarm",
|
|
||||||
)
|
|
||||||
|
|
||||||
@unittest.skipUnless(
|
@unittest.skipUnless(
|
||||||
_HAVE_YAML,
|
_HAVE_YAML,
|
||||||
"PyYAML not installed (the CI Python-test job omits it; the lint job runs lint_skills.py directly)",
|
"PyYAML not installed (the CI Python-test job omits it; the lint job runs lint_skills.py directly)",
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
"""Guards for CI's placeholder-integrity sentinels.
|
|
||||||
|
|
||||||
The job exists to catch personal data committed to the upstream template.
|
|
||||||
That only works when each sentinel sits IN the data /setup replaces: the
|
|
||||||
CV's old sentinel was `[YOUR_NAME]`, whose only occurrences were a header
|
|
||||||
comment and the hyperref pdftitle - /setup's documented edit ("replace
|
|
||||||
placeholder personal data with their actual name, contact info") touches
|
|
||||||
neither, so a fully personalized CV with a real name, address, phone and
|
|
||||||
email passed the check (review finding F28, 2026-08-19; proven
|
|
||||||
empirically). Same weakness for 01-candidate-profile.md's `<!-- SETUP`
|
|
||||||
comment sentinel.
|
|
||||||
|
|
||||||
These tests pin (a) that ci.yml checks data-located sentinels, (b) that
|
|
||||||
the sentinels exist in the pristine files, and (c) that simulating the
|
|
||||||
/setup edit destroys at least one checked sentinel per file - i.e. the
|
|
||||||
guard actually fires on the failure it exists to catch.
|
|
||||||
"""
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parent.parent
|
|
||||||
CI = REPO / ".github" / "workflows" / "ci.yml"
|
|
||||||
EXAMPLE_CV = REPO / "cv" / "main_example.tex"
|
|
||||||
PROFILE = REPO / ".claude" / "skills" / "job-application-assistant" / "01-candidate-profile.md"
|
|
||||||
|
|
||||||
# The literal sentinel strings (unescaped) that ci.yml's grep patterns match.
|
|
||||||
CV_SENTINELS = ["\\name{[First]}{[Last]}", "\\email{[your.email@example.com]}"]
|
|
||||||
PROFILE_SENTINEL = "[YOUR_EMAIL]"
|
|
||||||
|
|
||||||
|
|
||||||
def personalize_cv(text: str) -> str:
|
|
||||||
"""Apply /setup Step 3.7's documented edit: replace placeholder personal
|
|
||||||
data with a real name and contact info. Header comments and hyperref
|
|
||||||
metadata are not personal data, so they are deliberately left alone -
|
|
||||||
that is exactly why a comment-located sentinel guards nothing."""
|
|
||||||
return (
|
|
||||||
text.replace("\\name{[First]}{[Last]}", "\\name{Jane}{Doe}")
|
|
||||||
.replace("[Your Address, City, Country]", "Some Street 1, Aarhus, Denmark")
|
|
||||||
.replace("[+XX XXXXXXXXXX]", "+45 12345678")
|
|
||||||
.replace("[your.email@example.com]", "jane.doe@example.org")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCvSentinelsAreDataLocated(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.ci = CI.read_text(encoding="utf-8")
|
|
||||||
self.cv = EXAMPLE_CV.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
def test_ci_checks_the_name_and_email_data_lines(self):
|
|
||||||
self.assertIn(
|
|
||||||
"check cv/main_example.tex '\\\\name{\\[First\\]}{\\[Last\\]}'",
|
|
||||||
self.ci,
|
|
||||||
"ci.yml must assert the sentinel inside the \\name{} data line",
|
|
||||||
)
|
|
||||||
self.assertIn(
|
|
||||||
"check cv/main_example.tex '\\\\email{\\[your\\.email@example\\.com\\]}'",
|
|
||||||
self.ci,
|
|
||||||
"ci.yml must assert the sentinel inside the \\email{} data line",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_pristine_cv_carries_both_sentinels(self):
|
|
||||||
for sentinel in CV_SENTINELS:
|
|
||||||
self.assertIn(sentinel, self.cv)
|
|
||||||
|
|
||||||
def test_setup_edit_destroys_the_sentinels(self):
|
|
||||||
personalized = personalize_cv(self.cv)
|
|
||||||
self.assertNotEqual(personalized, self.cv, "the simulated /setup edit must change the file")
|
|
||||||
surviving = [s for s in CV_SENTINELS if s in personalized]
|
|
||||||
self.assertEqual(
|
|
||||||
surviving,
|
|
||||||
[],
|
|
||||||
"a sentinel survived the documented /setup personalization - the "
|
|
||||||
f"guard would pass on committed personal data: {surviving}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestProfileSentinelIsDataLocated(unittest.TestCase):
|
|
||||||
def test_ci_checks_a_data_placeholder_not_the_header_comment(self):
|
|
||||||
ci = CI.read_text(encoding="utf-8")
|
|
||||||
self.assertIn(
|
|
||||||
"check .claude/skills/job-application-assistant/01-candidate-profile.md '\\[YOUR_EMAIL\\]'",
|
|
||||||
ci,
|
|
||||||
"01's sentinel must sit in the Identity data /setup fills, not in "
|
|
||||||
"a header comment the model may leave untouched",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_pristine_profile_carries_the_sentinel(self):
|
|
||||||
self.assertIn(PROFILE_SENTINEL, PROFILE.read_text(encoding="utf-8"))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -21,8 +21,8 @@ They also pin the review findings on the fix itself:
|
|||||||
4. The vocabulary block must not split Step 1's numbered list: section-scoped
|
4. The vocabulary block must not split Step 1's numbered list: section-scoped
|
||||||
reads of Step 1 must still see items 2-4.
|
reads of Step 1 must still see items 2-4.
|
||||||
5. /html-report's bucket map needs a catch-all so no tracker value drops out of
|
5. /html-report's bucket map needs a catch-all so no tracker value drops out of
|
||||||
the stats silently, and /notion-sync must normalise space forms before
|
the stats silently, and /sync-twenty must normalise space forms before
|
||||||
writing Status (Notion auto-creates a select option per unique string).
|
mapping to Twenty's fixed stage enum.
|
||||||
6. /apply and /interview make status decisions and must anchor them to the
|
6. /apply and /interview make status decisions and must anchor them to the
|
||||||
block, not restate an ad-hoc set.
|
block, not restate an ad-hoc set.
|
||||||
|
|
||||||
@@ -37,9 +37,8 @@ REPO = Path(__file__).resolve().parent.parent
|
|||||||
COMMANDS = REPO / ".claude" / "commands"
|
COMMANDS = REPO / ".claude" / "commands"
|
||||||
|
|
||||||
OUTCOME = COMMANDS / "outcome.md"
|
OUTCOME = COMMANDS / "outcome.md"
|
||||||
GMAIL_SYNC = COMMANDS / "gmail-sync.md"
|
|
||||||
HTML_REPORT = COMMANDS / "html-report.md"
|
HTML_REPORT = COMMANDS / "html-report.md"
|
||||||
NOTION_SYNC = COMMANDS / "notion-sync.md"
|
SYNC_TWENTY = COMMANDS / "sync-twenty.md"
|
||||||
APPLY = COMMANDS / "apply.md"
|
APPLY = COMMANDS / "apply.md"
|
||||||
INTERVIEW = COMMANDS / "interview.md"
|
INTERVIEW = COMMANDS / "interview.md"
|
||||||
|
|
||||||
@@ -221,64 +220,32 @@ class ReadersBucketMap(unittest.TestCase):
|
|||||||
"it is part of the archive `outcome.md` Status: enum, not a tracker CSV value",
|
"it is part of the archive `outcome.md` Status: enum, not a tracker CSV value",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_gmail_sync_references_vocabulary_block(self):
|
def test_sync_twenty_normalises_status_before_write(self):
|
||||||
"""gmail-sync must defer to /outcome's vocabulary block for the
|
"""The status → stage table must map legacy space spellings to the
|
||||||
open-application set, not hardcode the final-status set with
|
canonical enum before writing. Twenty's stage field is a fixed enum,
|
||||||
space spellings that diverge from the writer."""
|
so an unmapped space form either errors or silently drops the update."""
|
||||||
step2_text = section(GMAIL_SYNC, "## Step 2: Load State")
|
step6_text = section(SYNC_TWENTY, "## Step 6: Upsert Opportunity Records")
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"Tracker status vocabulary",
|
"normalise legacy space spellings",
|
||||||
step2_text,
|
step6_text,
|
||||||
"/gmail-sync Step 2 must reference the /outcome vocabulary block "
|
"/sync-twenty Step 6 must map space forms per the /outcome vocabulary block",
|
||||||
"instead of restating the final-status set with its own spellings",
|
|
||||||
)
|
|
||||||
self.assertNotIn(
|
|
||||||
"no response",
|
|
||||||
step2_text,
|
|
||||||
"/gmail-sync Step 2 must not restate the space spellings locally - "
|
|
||||||
"the vocabulary block is the single source for what counts as final, "
|
|
||||||
"and a second local list is what drifted in #298",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_notion_sync_normalises_status_before_write(self):
|
def test_sync_twenty_uses_underscore_status_spellings(self):
|
||||||
"""Step 4 must map legacy space spellings to canonical before setting
|
"""The stage mapping table must key on canonical tracker spellings
|
||||||
Status. Notion auto-creates a select option per unique string, so pushing
|
so the sync recognises exactly what /outcome writes."""
|
||||||
a space form would give an existing database two options for one status
|
step6_text = section(SYNC_TWENTY, "## Step 6: Upsert Opportunity Records")
|
||||||
and split closed applications across two filter buckets."""
|
|
||||||
step4_text = section(NOTION_SYNC, "## Step 4: Upsert Database Rows")
|
|
||||||
self.assertIn(
|
|
||||||
"never push a space form",
|
|
||||||
step4_text,
|
|
||||||
"/notion-sync Step 4 must never write a space-form status to Notion",
|
|
||||||
)
|
|
||||||
self.assertIn(
|
|
||||||
"Tracker status vocabulary",
|
|
||||||
step4_text,
|
|
||||||
"/notion-sync Step 4 must map space forms per the /outcome vocabulary block",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_notion_sync_uses_underscore_status_spellings(self):
|
|
||||||
"""Notion Status select options must match canonical tracker spellings
|
|
||||||
so that upserted values are consistent with what /outcome writes."""
|
|
||||||
step3_text = section(NOTION_SYNC, "## Step 3: Load Sync State and Locate the Database")
|
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"no_response",
|
"no_response",
|
||||||
step3_text,
|
step6_text,
|
||||||
"/notion-sync Step 3 must list `no_response` (underscore) as a Status "
|
"/sync-twenty Step 6 must map `no_response` (underscore) so it matches "
|
||||||
"option so it matches what /outcome writes to the tracker",
|
"what /outcome writes to the tracker",
|
||||||
)
|
)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"offer_declined",
|
"offer_declined",
|
||||||
step3_text,
|
step6_text,
|
||||||
"/notion-sync Step 3 must list `offer_declined` (underscore) as a Status "
|
"/sync-twenty Step 6 must map `offer_declined` (underscore) so it matches "
|
||||||
"option so it matches what /outcome writes to the tracker",
|
"what /outcome writes to the tracker",
|
||||||
)
|
|
||||||
self.assertNotIn(
|
|
||||||
"no response",
|
|
||||||
step3_text,
|
|
||||||
"/notion-sync Step 3 must not list 'no response' (space) as the primary "
|
|
||||||
"option — Notion creates a distinct select value for each unique string, "
|
|
||||||
"so mixing spellings creates duplicate options in the database",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -311,14 +278,6 @@ class ReaderCases(unittest.TestCase):
|
|||||||
"excluded from every statistic below",
|
"excluded from every statistic below",
|
||||||
"drafted rows must be excluded from every statistic, not counted as sent",
|
"drafted rows must be excluded from every statistic, not counted as sent",
|
||||||
),
|
),
|
||||||
# /gmail-sync staleness check skips drafted
|
|
||||||
(
|
|
||||||
GMAIL_SYNC,
|
|
||||||
"## Step 9: Staleness Check",
|
|
||||||
"Skip `drafted` rows here",
|
|
||||||
"the staleness check must skip drafted rows — nothing was sent, "
|
|
||||||
"so nobody is late replying",
|
|
||||||
),
|
|
||||||
# /apply's append-vs-update decision anchors to the vocabulary
|
# /apply's append-vs-update decision anchors to the vocabulary
|
||||||
(
|
(
|
||||||
APPLY,
|
APPLY,
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""Guards for the deterministic cores of the Twenty CRM tooling.
|
||||||
|
|
||||||
|
tools/twenty_enrich.py owns the enrichment plan (salary ladder, company field
|
||||||
|
fills, post-link name cleanup) and tools/gather_recent_context.py owns the
|
||||||
|
email-thread -> opportunity matching. Both are pure given their inputs, so
|
||||||
|
these tests pin the contracts the /sync-twenty and /gather-recent-context
|
||||||
|
commands rely on:
|
||||||
|
|
||||||
|
- source priority: a stated posting salary always beats the levels.fyi
|
||||||
|
estimate, and an already-filled amount is never touched;
|
||||||
|
- company fills only ever ADD missing fields and reject the jobs.db
|
||||||
|
stringified-NaN domain artifact;
|
||||||
|
- name cleanup fires only after the company relation is set;
|
||||||
|
- thread matching goes through companyId (names are role-only), respects the
|
||||||
|
active-stage set, and never guesses across companies.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools"))
|
||||||
|
|
||||||
|
from twenty_enrich import ( # noqa: E402
|
||||||
|
build_company_plan,
|
||||||
|
build_name_cleanup_plan,
|
||||||
|
build_salary_plan,
|
||||||
|
clean_domain,
|
||||||
|
norm_company,
|
||||||
|
)
|
||||||
|
from gather_recent_context import build_plan, match_thread # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def opp(id="o1", name="Backend Engineer", stage="RANKED", company_id="c1",
|
||||||
|
url="https://jobs.example/1", amount=None):
|
||||||
|
return {"id": id, "name": name, "stage": stage, "companyId": company_id,
|
||||||
|
"url": {"primaryLinkUrl": url}, "amount": amount}
|
||||||
|
|
||||||
|
|
||||||
|
class NormalizationTests(unittest.TestCase):
|
||||||
|
def test_suffixes_and_punctuation_collapse(self):
|
||||||
|
self.assertEqual(norm_company("DraftKings Inc."), norm_company("draftkings"))
|
||||||
|
self.assertEqual(norm_company("Chainlink Labs"), norm_company("Chainlink"))
|
||||||
|
|
||||||
|
def test_clean_domain_rejects_nan_artifact(self):
|
||||||
|
# jobs.db stores stringified float NaN; https://nan is a real failure
|
||||||
|
# we shipped once - Twenty rejects it and fails the whole batch.
|
||||||
|
self.assertIsNone(clean_domain("nan"))
|
||||||
|
self.assertIsNone(clean_domain(""))
|
||||||
|
self.assertIsNone(clean_domain(None))
|
||||||
|
|
||||||
|
def test_clean_domain_strips_scheme_and_www(self):
|
||||||
|
self.assertEqual(clean_domain("https://www.example.com/"), "example.com")
|
||||||
|
|
||||||
|
|
||||||
|
class SalaryPlanTests(unittest.TestCase):
|
||||||
|
def test_stated_salary_beats_levels_estimate(self):
|
||||||
|
ranked = [{"url": "https://jobs.example/1", "company": "Acme",
|
||||||
|
"title": "Backend Engineer", "salary_max_stated": 200000}]
|
||||||
|
updates, unresolved = build_salary_plan(
|
||||||
|
ranked, [opp()], postings_salary={}, levels={"acme": 150000})
|
||||||
|
self.assertEqual(len(updates), 1)
|
||||||
|
self.assertEqual(updates[0]["source"], "seen_jobs.salary_max_stated")
|
||||||
|
self.assertEqual(updates[0]["patch"]["amount"]["amountMicros"], 200000 * 1_000_000)
|
||||||
|
self.assertEqual(unresolved, [])
|
||||||
|
|
||||||
|
def test_levels_fallback_is_labeled_estimate(self):
|
||||||
|
ranked = [{"url": "https://jobs.example/1", "company": "Acme",
|
||||||
|
"title": "Backend Engineer"}]
|
||||||
|
updates, _ = build_salary_plan(ranked, [opp()], postings_salary={},
|
||||||
|
levels={norm_company("Acme"): 150000})
|
||||||
|
self.assertIn("estimate", updates[0]["source"])
|
||||||
|
|
||||||
|
def test_filled_amount_is_never_touched(self):
|
||||||
|
ranked = [{"url": "https://jobs.example/1", "company": "Acme",
|
||||||
|
"title": "Backend Engineer", "salary_max_stated": 999999}]
|
||||||
|
o = opp(amount={"amountMicros": 1, "currencyCode": "USD"})
|
||||||
|
updates, unresolved = build_salary_plan(ranked, [o], {}, {})
|
||||||
|
self.assertEqual(updates, [])
|
||||||
|
self.assertEqual(unresolved, [])
|
||||||
|
|
||||||
|
def test_no_source_lands_in_unresolved_not_in_updates(self):
|
||||||
|
ranked = [{"url": "https://jobs.example/1", "company": "Acme",
|
||||||
|
"title": "Backend Engineer"}]
|
||||||
|
updates, unresolved = build_salary_plan(ranked, [opp()], {}, {})
|
||||||
|
self.assertEqual(updates, [])
|
||||||
|
self.assertEqual([u["company"] for u in unresolved], ["Acme"])
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyPlanTests(unittest.TestCase):
|
||||||
|
DB = {norm_company("Acme"): {
|
||||||
|
"company_id": "db1", "linkedin_url": "http://www.linkedin.com/company/acme",
|
||||||
|
"country": "United States", "state": "NY", "apollo_city": "New York",
|
||||||
|
}}
|
||||||
|
|
||||||
|
def test_fills_only_missing_fields(self):
|
||||||
|
company = {"id": "c1", "name": "Acme",
|
||||||
|
"domainName": {"primaryLinkUrl": "https://acme.com"}, # present
|
||||||
|
"linkedinLink": {}, "address": {}}
|
||||||
|
updates, unresolved = build_company_plan([company], self.DB, {"db1": "acme.com"})
|
||||||
|
patch = updates[0]["patch"]
|
||||||
|
self.assertNotIn("domainName", patch) # never overwrite
|
||||||
|
self.assertEqual(patch["linkedinLink"]["primaryLinkUrl"],
|
||||||
|
"https://www.linkedin.com/company/acme")
|
||||||
|
self.assertEqual(patch["address"]["addressCity"], "New York")
|
||||||
|
self.assertEqual(unresolved, [])
|
||||||
|
|
||||||
|
def test_nan_domain_never_reaches_the_patch(self):
|
||||||
|
company = {"id": "c1", "name": "Acme", "domainName": {},
|
||||||
|
"linkedinLink": {"primaryLinkUrl": "https://x"}, "address": {"addressCity": "NY"}}
|
||||||
|
updates, unresolved = build_company_plan([company], self.DB, {"db1": "nan"})
|
||||||
|
self.assertEqual(updates, []) # nothing groundable -> no patch at all
|
||||||
|
self.assertEqual(unresolved[0]["missing"], ["domain"])
|
||||||
|
|
||||||
|
def test_unknown_company_is_fully_unresolved(self):
|
||||||
|
company = {"id": "c9", "name": "Ghost Startup",
|
||||||
|
"domainName": {}, "linkedinLink": {}, "address": {}}
|
||||||
|
updates, unresolved = build_company_plan([company], self.DB, {})
|
||||||
|
self.assertEqual(updates, [])
|
||||||
|
self.assertEqual(unresolved[0]["missing"], ["domain", "linkedin", "address"])
|
||||||
|
|
||||||
|
|
||||||
|
class NameCleanupTests(unittest.TestCase):
|
||||||
|
def test_strips_suffix_only_when_linked(self):
|
||||||
|
linked = opp(name="Backend Engineer — Acme")
|
||||||
|
unlinked = opp(id="o2", name="ML Engineer — Ghost", company_id=None)
|
||||||
|
clean = opp(id="o3", name="Platform Engineer")
|
||||||
|
updates = build_name_cleanup_plan([linked, unlinked, clean])
|
||||||
|
self.assertEqual(updates, [{"id": "o1", "patch": {"name": "Backend Engineer"}}])
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadMatchingTests(unittest.TestCase):
|
||||||
|
COMPANIES = {"c1": "Acme", "c2": "Globex"}
|
||||||
|
|
||||||
|
def test_matches_through_company_relation_not_name(self):
|
||||||
|
# Opportunity names are role-only after cleanup; a name-based matcher
|
||||||
|
# would find nothing here.
|
||||||
|
o = opp()
|
||||||
|
t = {"thread_id": "t1", "company": "Acme Inc.", "role": None}
|
||||||
|
self.assertIs(match_thread(t, [o], self.COMPANIES), o)
|
||||||
|
|
||||||
|
def test_final_stage_loops_never_match(self):
|
||||||
|
o = opp(stage="REJECTED")
|
||||||
|
t = {"thread_id": "t1", "company": "Acme", "role": None}
|
||||||
|
self.assertIsNone(match_thread(t, [o], self.COMPANIES))
|
||||||
|
|
||||||
|
def test_role_tokens_break_ties_within_a_company(self):
|
||||||
|
backend = opp(id="o1", name="Backend Engineer")
|
||||||
|
frontend = opp(id="o2", name="Frontend Engineer")
|
||||||
|
t = {"thread_id": "t1", "company": "Acme", "role": "Senior Frontend Engineer"}
|
||||||
|
self.assertEqual(match_thread(t, [backend, frontend], self.COMPANIES)["id"], "o2")
|
||||||
|
|
||||||
|
def test_plan_dedups_processed_threads_and_reports_unmatched(self):
|
||||||
|
o = opp()
|
||||||
|
companies = [{"id": "c1", "name": "Acme"}]
|
||||||
|
threads = [
|
||||||
|
{"thread_id": "old", "company": "Acme", "role": None,
|
||||||
|
"date": "2026-08-01", "subject": "s", "summary": "x", "signal": "other"},
|
||||||
|
{"thread_id": "new", "company": "Acme", "role": None,
|
||||||
|
"date": "2026-08-02", "subject": "s", "summary": "y", "signal": "interview"},
|
||||||
|
{"thread_id": "stray", "company": "Nowhere", "role": None,
|
||||||
|
"date": "2026-08-03", "subject": "s", "summary": "z", "signal": "other"},
|
||||||
|
]
|
||||||
|
plan = build_plan(threads, [o], companies,
|
||||||
|
state={"processed_thread_ids": ["old"]})
|
||||||
|
self.assertEqual(plan["skipped_already_processed"], 1)
|
||||||
|
self.assertEqual([n["thread_ids"] for n in plan["notes"]], [["new"]])
|
||||||
|
self.assertEqual([u["company"] for u in plan["unmatched_threads"]], ["Nowhere"])
|
||||||
|
self.assertTrue(plan["notes"][0]["title"].startswith("Email Context — Acme"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -70,9 +70,9 @@ class UpskillSkillSpec(unittest.TestCase):
|
|||||||
step3 = sections.get("Step 3: Pass 1 — Hard Skill Diff", "")
|
step3 = sections.get("Step 3: Pass 1 — Hard Skill Diff", "")
|
||||||
self.assertIn("case-insensitive company + role", step3, "Step 3 must specify the dedupe key")
|
self.assertIn("case-insensitive company + role", step3, "Step 3 must specify the dedupe key")
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"/notion-sync",
|
"/sync-twenty",
|
||||||
step3,
|
step3,
|
||||||
"Step 3 must cite the upstream precedent for the dedupe key, not a fork-only file",
|
"Step 3 must cite the shared precedent for the dedupe key, not restate it",
|
||||||
)
|
)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"Recorded gaps beat inferred skills",
|
"Recorded gaps beat inferred skills",
|
||||||
|
|||||||
Reference in new issue
Block a user