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"
|
||||
APPLY = COMMANDS / "apply.md"
|
||||
OUTCOME = COMMANDS / "outcome.md"
|
||||
GMAIL_SYNC = COMMANDS / "gmail-sync.md"
|
||||
HTML_REPORT = COMMANDS / "html-report.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"
|
||||
SCRAPER = REPO / ".claude" / "skills" / "job-scraper" / "SKILL.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.
|
||||
|
||||
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
|
||||
/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.
|
||||
a new non-final value joins them all silently.
|
||||
"""
|
||||
|
||||
CASES = [
|
||||
@@ -207,21 +203,6 @@ class DraftedMeansDraftedToEveryReader(unittest.TestCase):
|
||||
(OUTCOME, "## Step 4: Update the Tracker",
|
||||
"overwrite its `date` column with the actual submission 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",
|
||||
"/scrape would duplicate the row Step 3b just wrote"),
|
||||
(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
|
||||
shifts every value of every existing row), and every path that rewrites
|
||||
a tracker row (/outcome Step 4, /gmail-sync Step 7a) must preserve
|
||||
fields it does not parse - the deadline is the first such field.
|
||||
a tracker row (/outcome Step 4) must preserve fields it does not parse -
|
||||
the deadline is the first such field.
|
||||
"""
|
||||
|
||||
CASES = [
|
||||
@@ -341,13 +322,9 @@ class DeadlineSurvivesEveryWrite(unittest.TestCase):
|
||||
"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",
|
||||
"a status update that rewrites the row would blank the deadline column"),
|
||||
(GMAIL_SYNC, "### Step 7a: Write Approved Updates", "preserve every other field",
|
||||
"the sync path rewrites the row too - it must carry the same preservation rule"),
|
||||
(NOTION_SYNC, None, "**Deadline precedence: the tracker wins too**",
|
||||
(SYNC_TWENTY, None, "**Deadline precedence: the tracker wins too**",
|
||||
"the tracker's deadline (written from the posting the application was actually "
|
||||
"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",
|
||||
"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"),
|
||||
@@ -372,7 +349,7 @@ class DeadlineSurvivesEveryWrite(unittest.TestCase):
|
||||
"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 "
|
||||
"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 "
|
||||
"plausible-looking min() of the two, which syncs a date the user never "
|
||||
"applied against"),
|
||||
@@ -419,19 +396,12 @@ class ArchiveNameIsOnePathComponent(unittest.TestCase):
|
||||
"by the **Subfolder naming** rule in `documents/README.md`",
|
||||
"the /scrape path writes its documents before Step 3b consults /apply, "
|
||||
"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",
|
||||
"by the **Subfolder naming** rule in `documents/README.md`",
|
||||
"interview must read the same archive /apply and /outcome wrote"),
|
||||
(INTERVIEW, "### 6. Logistics",
|
||||
"archive folder derived in Step 1",
|
||||
"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/",
|
||||
"If the derived name is empty",
|
||||
"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",
|
||||
)
|
||||
|
||||
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(
|
||||
_HAVE_YAML,
|
||||
"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
|
||||
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
|
||||
the stats silently, and /notion-sync must normalise space forms before
|
||||
writing Status (Notion auto-creates a select option per unique string).
|
||||
the stats silently, and /sync-twenty must normalise space forms before
|
||||
mapping to Twenty's fixed stage enum.
|
||||
6. /apply and /interview make status decisions and must anchor them to the
|
||||
block, not restate an ad-hoc set.
|
||||
|
||||
@@ -37,9 +37,8 @@ REPO = Path(__file__).resolve().parent.parent
|
||||
COMMANDS = REPO / ".claude" / "commands"
|
||||
|
||||
OUTCOME = COMMANDS / "outcome.md"
|
||||
GMAIL_SYNC = COMMANDS / "gmail-sync.md"
|
||||
HTML_REPORT = COMMANDS / "html-report.md"
|
||||
NOTION_SYNC = COMMANDS / "notion-sync.md"
|
||||
SYNC_TWENTY = COMMANDS / "sync-twenty.md"
|
||||
APPLY = COMMANDS / "apply.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",
|
||||
)
|
||||
|
||||
def test_gmail_sync_references_vocabulary_block(self):
|
||||
"""gmail-sync must defer to /outcome's vocabulary block for the
|
||||
open-application set, not hardcode the final-status set with
|
||||
space spellings that diverge from the writer."""
|
||||
step2_text = section(GMAIL_SYNC, "## Step 2: Load State")
|
||||
def test_sync_twenty_normalises_status_before_write(self):
|
||||
"""The status → stage table must map legacy space spellings to the
|
||||
canonical enum before writing. Twenty's stage field is a fixed enum,
|
||||
so an unmapped space form either errors or silently drops the update."""
|
||||
step6_text = section(SYNC_TWENTY, "## Step 6: Upsert Opportunity Records")
|
||||
self.assertIn(
|
||||
"Tracker status vocabulary",
|
||||
step2_text,
|
||||
"/gmail-sync Step 2 must reference 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",
|
||||
"normalise legacy space spellings",
|
||||
step6_text,
|
||||
"/sync-twenty Step 6 must map space forms per the /outcome vocabulary block",
|
||||
)
|
||||
|
||||
def test_notion_sync_normalises_status_before_write(self):
|
||||
"""Step 4 must map legacy space spellings to canonical before setting
|
||||
Status. Notion auto-creates a select option per unique string, so pushing
|
||||
a space form would give an existing database two options for one status
|
||||
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")
|
||||
def test_sync_twenty_uses_underscore_status_spellings(self):
|
||||
"""The stage mapping table must key on canonical tracker spellings
|
||||
so the sync recognises exactly what /outcome writes."""
|
||||
step6_text = section(SYNC_TWENTY, "## Step 6: Upsert Opportunity Records")
|
||||
self.assertIn(
|
||||
"no_response",
|
||||
step3_text,
|
||||
"/notion-sync Step 3 must list `no_response` (underscore) as a Status "
|
||||
"option so it matches what /outcome writes to the tracker",
|
||||
step6_text,
|
||||
"/sync-twenty Step 6 must map `no_response` (underscore) so it matches "
|
||||
"what /outcome writes to the tracker",
|
||||
)
|
||||
self.assertIn(
|
||||
"offer_declined",
|
||||
step3_text,
|
||||
"/notion-sync Step 3 must list `offer_declined` (underscore) as a Status "
|
||||
"option so it matches 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",
|
||||
step6_text,
|
||||
"/sync-twenty Step 6 must map `offer_declined` (underscore) so it matches "
|
||||
"what /outcome writes to the tracker",
|
||||
)
|
||||
|
||||
|
||||
@@ -311,14 +278,6 @@ class ReaderCases(unittest.TestCase):
|
||||
"excluded from every statistic below",
|
||||
"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,
|
||||
|
||||
@@ -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", "")
|
||||
self.assertIn("case-insensitive company + role", step3, "Step 3 must specify the dedupe key")
|
||||
self.assertIn(
|
||||
"/notion-sync",
|
||||
"/sync-twenty",
|
||||
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(
|
||||
"Recorded gaps beat inferred skills",
|
||||
|
||||
Reference in new issue
Block a user