test(tests): update suite for sync‑twenty migration and drop obsolete gmail/notion sync tests

This commit is contained in:
Prad Nukala committed 2026-08-26 11:54:35 -04:00
1 parent 7007c4aa9d
commit b552b70783
8 files changed
+201 -312

No files matched your search

+6 -36
View File
@@ -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 "
-43
View File
@@ -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()
-69
View File
@@ -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()
-8
View File
@@ -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)",
-92
View File
@@ -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 -62
View File
@@ -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,
+172
View File
@@ -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()
+2 -2
View File
@@ -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",