2026-07-17 21:26:02 +02:00
|
|
|
"""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",
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-17 19:01:34 +01:00
|
|
|
def test_step2_join_names_the_field_ranked_entries_actually_carry(self):
|
|
|
|
|
# seen_jobs.json entries carry `title` (job-scraper SKILL.md schema), the
|
|
|
|
|
# tracker carries `role`. "matched ... on company + role" read as a
|
|
|
|
|
# lookup of `role` on the entry, which never matches, so one job became
|
|
|
|
|
# two Notion rows with different Keys. The join is role-vs-title.
|
|
|
|
|
text = COMMAND.read_text(encoding="utf-8")
|
2026-10-18 03:09:09 -03:00
|
|
|
line = next(l for l in text.splitlines() if "matched to `job_scraper/seen_jobs.json` entries" in l)
|
2026-10-17 19:01:34 +01:00
|
|
|
self.assertIn("company + title", line)
|
|
|
|
|
self.assertIn("`title`", line)
|
|
|
|
|
self.assertIn("`role`", line)
|
|
|
|
|
self.assertNotIn("company + role", line)
|
|
|
|
|
|
2026-07-17 21:26:02 +02:00
|
|
|
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",
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-19 14:09:23 -04:00
|
|
|
def test_expired_jobs_are_update_only_and_never_create_or_rebuild_pages(self):
|
|
|
|
|
text = COMMAND.read_text(encoding="utf-8")
|
|
|
|
|
step2 = text.split("## Step 2:", 1)[1].split("## Step 3:", 1)[0]
|
|
|
|
|
step3 = text.split("## Step 3:", 1)[1].split("## Step 4:", 1)[0]
|
|
|
|
|
step4 = text.split("## Step 4:", 1)[1].split("## Step 5:", 1)[0]
|
|
|
|
|
step5 = text.split("## Step 5:", 1)[1].split("## Step 6:", 1)[0]
|
|
|
|
|
|
|
|
|
|
self.assertIn("entries with status `expired` join the sync set regardless of score as update-only candidates", step2)
|
|
|
|
|
self.assertIn("An entry selected only because it is `expired` and has no matching tracker row", step2)
|
|
|
|
|
self.assertIn("If a matching tracker row exists, it remains an ordinary sync item and tracker status precedence applies.", step2)
|
|
|
|
|
self.assertIn("sync set contains only expired-only entries", step3)
|
|
|
|
|
self.assertIn("stop without asking where to create a database", step3)
|
|
|
|
|
self.assertIn("without creating the database or writing `job_scraper/notion_sync.json`", step3)
|
|
|
|
|
self.assertIn("if the job is an expired-only entry, skip it - do not create a page or write a body", step4)
|
|
|
|
|
self.assertIn("Match, expired-only entry** → update **Status only** to `expired`", step4)
|
|
|
|
|
self.assertIn("Never write or rebuild a body for an expired-only entry, including when `--rebuild` is set.", step5)
|
|
|
|
|
|
2026-07-17 21:26:02 +02:00
|
|
|
@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()
|