diff --git a/backend/services/job_search_service.py b/backend/services/job_search_service.py index e9f34a9..b348c9a 100644 --- a/backend/services/job_search_service.py +++ b/backend/services/job_search_service.py @@ -103,6 +103,16 @@ def search_cached(self, query: JobSearchQuery) -> JobSearchResult: posted_within_days=query.posted_within_days, page_size=max(1, min(int(query.page_size or 20), 50)), offset=max(0, int(query.offset or 0)), + # Carry the dropdown / sort fields through. Earlier + # versions dropped them here, which silently disabled the + # Work-mode filter and the Sort dropdown — the chip / + # label would flip in the UI but the request never reached + # the RPC with the new value. Whitelisting + lowercasing + # still happens in CachedJobsStore.search() so the schema + # layer stays decoupled from RPC vocabulary. + work_modes=list(query.work_modes or []), + employment_types=list(query.employment_types or []), + sort_by=str(query.sort_by or "relevance").strip().lower() or "relevance", ) store = self._get_cache_store() @@ -123,6 +133,9 @@ def search_cached(self, query: JobSearchQuery) -> JobSearchResult: posted_within_days=normalized_query.posted_within_days, limit=normalized_query.page_size, offset=normalized_query.offset, + work_modes=list(normalized_query.work_modes) or None, + employment_types=list(normalized_query.employment_types) or None, + sort_by=normalized_query.sort_by, ) except Exception as exc: # noqa: BLE001 — cache outage shouldn't kill search # Fall through to the live path. The cache is a perf @@ -153,6 +166,15 @@ def search(self, query: JobSearchQuery) -> JobSearchResult: posted_within_days=query.posted_within_days, page_size=max(1, min(int(query.page_size or 20), 50)), offset=max(0, int(query.offset or 0)), + # Preserve dropdown / sort fields. The live path can't push + # work_modes / employment_types into the upstream boards + # (their APIs don't accept those filters), but we keep the + # values on the query so any downstream in-memory sort or + # filter pass sees them — and so the echoed JobSearchQuery + # in the response matches what the UI sent. + work_modes=list(query.work_modes or []), + employment_types=list(query.employment_types or []), + sort_by=str(query.sort_by or "relevance").strip().lower() or "relevance", ) requested_sources = { str(item).strip().lower() diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index d1a568d..70a4582 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -5464,18 +5464,33 @@ code { z-index: 1; } /* Hover (only on enabled steps) — pill bg + accent ring + tiny lift. - The ring is the strongest "I'm clickable" cue; the lift adds depth. */ -.b-rail-step:hover:not(:disabled) { + The ring is the strongest "I'm clickable" cue; the lift adds depth. + `[data-locked]` is excluded from the lift so the gated step doesn't + feel like a normal tab, but it still gets a faint hover tint via + the rule below so the user knows their click WILL do something + (route them to the missing prerequisite + show a notice). */ +.b-rail-step:hover:not(:disabled):not([data-locked="true"]) { color: var(--fg); background: rgba(255, 255, 255, 0.05); border-color: rgba(90, 134, 255, 0.30); transform: translateY(-1px); } -/* Gated — fade out the whole chip clearly so it doesn't look like an - un-clicked tab. Cursor + title attribute give the user a reason. */ -.b-rail-step:disabled { +/* Gated — fade the chip clearly so it doesn't look like an un-clicked + tab. Cursor: help signals "tap to learn what's missing" — earlier + the chip was HTML-disabled with cursor: not-allowed, which made the + click a silent no-op; the new behavior navigates the user to the + missing prereq + surfaces a notice. */ +.b-rail-step:disabled, +.b-rail-step[data-locked="true"] { opacity: 0.40; - cursor: not-allowed; + cursor: help; +} +.b-rail-step[data-locked="true"]:hover { + /* Faint hover tint so the user gets a click-affordance cue without + the full "this is the active tab" treatment. Confirms the chip is + interactive even though it's gated. */ + background: rgba(255, 255, 255, 0.03); + border-color: rgba(255, 255, 255, 0.10); } .b-rail-step[aria-selected="true"] { color: var(--fg); @@ -5907,6 +5922,24 @@ code { font-weight: 500; margin-left: 2px; } +/* "Run analysis to compute" / "Re-run analysis (inputs changed)" hint + shown under the Match-score tile before analysisState is fresh. + Small, dim, and italicized so it reads as a sublabel rather than + competing with the metric value. */ +.b-jd-metric-hint { + margin-top: 2px; + font-size: 11.5px; + font-style: italic; + color: var(--fg-3); + line-height: 1.35; +} +/* Muted variant for placeholder tiles whose value is "—" — fades the + big number so the user understands it's not a real measurement + yet, without removing the column slot (which would cause layout + shift the moment analysis completes). */ +.b-jd-metric[data-tone="muted"] .b-jd-metric-value { + color: var(--fg-3); +} /* ── Two-up sections (Skills + Experience, Hard + Soft skills) ── */ .b-resume-twoup { diff --git a/frontend/src/components/workspace/JDReview.tsx b/frontend/src/components/workspace/JDReview.tsx index 88b7a4f..05b00cb 100644 --- a/frontend/src/components/workspace/JDReview.tsx +++ b/frontend/src/components/workspace/JDReview.tsx @@ -181,7 +181,22 @@ export function JDReview({ // Hero metrics: prefer the parsed analysisState numbers when fresh, // fall back to the JobReview computed by `buildJobReview` from the // current manualJobText. - const metrics = (() => { + // + // The "Match score" tile is always rendered (even before analysis + // runs) with a `hint` placeholder. Earlier the score only appeared + // post-analysis, so the UI test reported "no match-score on Job + // Detail" — a user who pasted a JD but hadn't yet run Step 04 saw + // no fit signal at all and didn't know where it would surface. + // The placeholder gives a visible affordance + tells them what to + // do next without forcing a navigation away from this tab. + type HeroMetric = { + label: string; + value: string; + unit: string; + hint?: string; + tone?: "muted"; + }; + const metrics = ((): HeroMetric[] => { if (analysisState && !analysisIsStale) { const fit = analysisState.fit_analysis; return [ @@ -211,6 +226,19 @@ export function JDReview({ } if (review) { return [ + { + label: "Match score", + value: "—", + unit: "", + // Stale analysis lingering in state when the inputs have + // changed = a number that's no longer trustworthy. Tell the + // user the cause + remedy in the hint so we're not silently + // showing a wrong fit %. + hint: analysisState && analysisIsStale + ? "Re-run analysis (inputs changed)" + : "Run analysis to compute", + tone: "muted", + }, { label: "Hard skills", value: String(review.hardSkills.length), @@ -354,9 +382,16 @@ export function JDReview({ {metrics.length ? (
{metrics.map((metric) => ( -
+
{metric.label}
+ {metric.hint ? ( +
{metric.hint}
+ ) : null}
))}
diff --git a/frontend/src/components/workspace/WorkspaceShell.tsx b/frontend/src/components/workspace/WorkspaceShell.tsx index 47721f1..255723a 100644 --- a/frontend/src/components/workspace/WorkspaceShell.tsx +++ b/frontend/src/components/workspace/WorkspaceShell.tsx @@ -181,11 +181,48 @@ function formatUtcTimestamp(value: string) { function formatRemainingCalls(dailyQuota: DailyQuotaStatus | null) { if (!dailyQuota) return "Unavailable"; if (dailyQuota.remaining_calls === null || dailyQuota.max_calls === null) { + // Unlimited tiers are internal/admin (no daily cost cap). Append + // the tier so the dev account is obviously identifiable in the + // popover and a paying user with `null` caps (e.g. an explicit + // "Unlimited" addon down the road) sees the same shape. + const tier = (dailyQuota.plan_tier || "").trim().toLowerCase(); + if (tier === "internal" || tier === "admin") { + return `Unlimited (${formatTier(tier)})`; + } return "Unlimited"; } return `${dailyQuota.remaining_calls}/${dailyQuota.max_calls}`; } +/** + * Capitalize a raw plan_tier string ("internal", "business", "pro", + * "free", "admin") for display. Backend stores these lowercase as + * enum-ish strings; the UI should render them as proper nouns. + * Falls back to the raw value (with first letter uppercased) for + * any tier we haven't enumerated explicitly, so a future "enterprise" + * tier still renders sanely without a code change. + */ +function formatTier(tier: string | null | undefined): string { + const normalized = (tier || "").trim().toLowerCase(); + switch (normalized) { + case "free": + return "Free"; + case "pro": + case "paid": + case "plus": + return "Pro"; + case "business": + return "Business"; + case "internal": + return "Internal"; + case "admin": + return "Admin"; + default: + if (!normalized) return "Free"; + return normalized.charAt(0).toUpperCase() + normalized.slice(1); + } +} + function getInitialMainTab(): WorkspaceMainTab { if (typeof window === "undefined") return "resume"; @@ -2150,7 +2187,7 @@ export function WorkspaceShell() {
Plan
-
{authSession?.app_user.plan_tier || "free"}
+
{formatTier(authSession?.app_user.plan_tier)}
Runs left
@@ -2271,6 +2308,32 @@ export function WorkspaceShell() { jd: "", analysis: "Need a parsed resume + job description first.", }; + // When the user clicks a locked rail step we don't want a + // silent no-op — the UI was confusing as "is this broken?". + // Compute the first missing prerequisite for each gated step + // so the click handler can jump them there and surface a + // helpful notice. Today only Analysis is gated; keeping the + // shape per-step so future locks plug in the same way. + const lockedPrereqStep: Record = { + resume: null, + jobs: null, + jd: null, + analysis: !resumeText.trim() + ? "resume" + : !manualJobText.trim() + ? "jd" + : null, + }; + const lockedPrereqMessage: Record = { + resume: "", + jobs: "", + jd: "", + analysis: !resumeText.trim() + ? "Upload a résumé in Step 01 to unlock Analysis." + : !manualJobText.trim() + ? "Paste a job description in Step 03 to unlock Analysis." + : "Both inputs are loaded — Analysis is ready to run.", + }; return (
{ - if (ready) setMainTab(step); + if (ready) { + setMainTab(step); + return; + } + // Locked-step click handling: instead of a + // silent no-op (the old behavior with `disabled`), + // route the user to the missing prerequisite and + // surface a helpful inline notice. Falls back to + // a plain notice if no specific prereq is known. + const prereq = lockedPrereqStep[step]; + const message = lockedPrereqMessage[step] || lockReason[step]; + if (message) { + setWorkspaceNotice({ level: "warning", message }); + } + if (prereq) { + setMainTab(prereq); + } }} role="tab" title={tooltip} diff --git a/src/config.py b/src/config.py index 9934d9f..8f9d3ef 100644 --- a/src/config.py +++ b/src/config.py @@ -323,7 +323,13 @@ def get_daily_quota_for_plan(plan_tier: str): normalized_plan = (plan_tier or AUTH_DEFAULT_PLAN_TIER).strip().lower() if normalized_plan in {"admin", "internal"}: return {"max_calls": None, "max_total_tokens": None, "plan_tier": normalized_plan} - if normalized_plan in {"paid", "pro", "plus"}: + # Paid tiers share the same daily cost-cap regardless of monthly + # feature limits — the monthly TIER_CAPS in backend/tiers.py is + # where tier differentiation actually lives. The daily cap here + # is a runaway-cost safety net; Pro and Business get the same + # generous daily budget so a Business user isn't silently throttled + # mid-workflow just because they fell through to the FREE bucket. + if normalized_plan in {"paid", "pro", "plus", "business"}: return { "max_calls": PAID_TIER_MAX_CALLS_PER_DAY, "max_total_tokens": PAID_TIER_MAX_TOKENS_PER_DAY, diff --git a/src/services/jd_llm_parser_service.py b/src/services/jd_llm_parser_service.py index e77f2b9..557b2e7 100644 --- a/src/services/jd_llm_parser_service.py +++ b/src/services/jd_llm_parser_service.py @@ -14,17 +14,76 @@ from __future__ import annotations -import json +import re from typing import Any from src.openai_service import OpenAIService +# Section-header strings the LLM sometimes echoes as a list item when +# the JD's headings sit immediately above a bullet block (e.g. the +# n8n "AI Product Builder" listing ends Must-Haves with the literal +# label "REQUIREMENTS / MUST-HAVES"). Anything matching this pattern +# after normalizing whitespace / punctuation is dropped from +# must_haves / nice_to_haves. +_SECTION_LABEL_ARTIFACT = re.compile( + r"^(requirements?|must[\s\-_/]?haves?|nice[\s\-_/]?to[\s\-_/]?haves?|" + r"qualifications?|preferred(?:\s+qualifications?)?|good\s+signals?|" + r"good\s+to\s+have|bonus(?:\s+points?)?|" + # "What you'll do" / "What we're looking for" / "What we look for" / + # "What we want" — heading-style phrases the LLM sometimes echoes + # as a list item. Accepts straight + smart apostrophes, optional + # contraction ('re / 'll / are / will), an operative verb, and a + # trailing "for" / "in" preposition. + r"what\s+(?:we|you)(?:['’](?:re|ll|s)|\s+(?:are|will))?" + r"(?:\s+(?:looking|look|need|want|do))(?:\s+(?:for|in|at))?" + r")[\s:.\-]*$", + re.IGNORECASE, +) + +# Benefits / perks vocabulary the LLM sometimes swept into +# nice_to_haves when a "Benefits" block sits adjacent to the +# "Nice to have" block in the JD. These are compensation, not +# job-requirement signal — they don't belong in either list because +# matching against them produces nonsense ("candidate has 401k"). +_BENEFIT_KEYWORDS = ( + "vacation", " pto ", "(pto)", "paid time off", "parental leave", + "maternity leave", "paternity leave", "health insurance", + "medical insurance", "dental insurance", "vision insurance", + "medical, dental", "dental, vision", "health, dental", + "health coverage", "medical coverage", " hsa ", "(hsa)", + "health savings", "401(k)", "401k", " 401 k ", "retirement plan", + "stock options", "equity grant", "rsu", " esop ", + "wellness stipend", "wellness benefit", "gym membership", + "free lunch", "snacks", "remote stipend", "home office stipend", + "commuter benefit", "transit benefit", "life insurance", + "disability insurance", +) + + +def _is_section_label_artifact(text: str) -> bool: + return bool(_SECTION_LABEL_ARTIFACT.match(text.strip())) + + +def _looks_like_benefit(text: str) -> bool: + # Pad with spaces so the substring scan treats abbreviation tokens + # like ' pto ' / ' 401k ' as whole-word matches instead of matching + # inside e.g. 'computational tools'. + haystack = " " + text.strip().lower() + " " + return any(keyword in haystack for keyword in _BENEFIT_KEYWORDS) + + def _coerce_string(value: Any) -> str: return str(value or "").strip() -def _coerce_string_list(value: Any, *, limit: int = 24) -> list[str]: +def _coerce_string_list( + value: Any, + *, + limit: int = 24, + drop_section_labels: bool = False, + drop_benefits: bool = False, +) -> list[str]: if not isinstance(value, list): return [] cleaned: list[str] = [] @@ -34,6 +93,16 @@ def _coerce_string_list(value: Any, *, limit: int = 24) -> list[str]: normalized = text.lower() if not text or normalized in seen: continue + if drop_section_labels and _is_section_label_artifact(text): + # LLM echoed a section header (e.g. "REQUIREMENTS", + # "MUST-HAVES") as a list item — silently drop instead of + # surfacing as a requirement. + continue + if drop_benefits and _looks_like_benefit(text): + # Compensation / perks crept into a requirements list. + # Drop instead of matching against the candidate's + # qualifications, which would produce nonsense signal. + continue cleaned.append(text) seen.add(normalized) if len(cleaned) >= limit: @@ -61,8 +130,16 @@ def _build_jd_llm_parser_prompt(jd_text: str) -> dict[str, Any]: "(e.g. 'communication', 'leadership', 'collaboration')", "must_haves": "array of strings — required-experience phrases the JD marks as " "mandatory (e.g. '5+ years building production backend services', " - "'BSc in Computer Science'). Each entry should be a distinct line.", - "nice_to_haves": "array of strings — preferred / bonus / nice-to-have qualifications", + "'BSc in Computer Science'). Each entry should be a distinct line. " + "Do NOT echo section headers like 'REQUIREMENTS', 'MUST-HAVES', " + "'QUALIFICATIONS' as list items — those are headings, not requirements.", + "nice_to_haves": "array of strings — preferred / bonus / nice-to-have QUALIFICATIONS " + "(extra skills, prior experience, certifications). Do NOT include " + "benefits, perks, or compensation (vacation, PTO, parental leave, " + "health / medical / dental / vision insurance, HSA, 401(k), stock " + "options, RSU, wellness stipend, gym, remote stipend) — those are " + "what the company offers the candidate, not what the candidate " + "needs to bring.", } contract_lines = "\n".join( '- "{key}": {description}'.format(key=key, description=description) @@ -152,6 +229,20 @@ def parse( "experience_requirement": _coerce_string(payload.get("experience_requirement")), "hard_skills": _coerce_string_list(payload.get("hard_skills"), limit=40), "soft_skills": _coerce_string_list(payload.get("soft_skills"), limit=20), - "must_haves": _coerce_string_list(payload.get("must_haves"), limit=10), - "nice_to_haves": _coerce_string_list(payload.get("nice_to_haves"), limit=10), + # must_haves / nice_to_haves get the extra scrub passes: + # strip section-header artifacts the LLM occasionally echoes + # as list items, and drop benefit / perk vocabulary that + # shouldn't be matched against the candidate's skills. + "must_haves": _coerce_string_list( + payload.get("must_haves"), + limit=10, + drop_section_labels=True, + drop_benefits=True, + ), + "nice_to_haves": _coerce_string_list( + payload.get("nice_to_haves"), + limit=10, + drop_section_labels=True, + drop_benefits=True, + ), } diff --git a/tests/test_jd_llm_parser_service.py b/tests/test_jd_llm_parser_service.py index 695c123..6c265fd 100644 --- a/tests/test_jd_llm_parser_service.py +++ b/tests/test_jd_llm_parser_service.py @@ -1,4 +1,42 @@ -from src.services.jd_llm_parser_service import JobDescriptionLLMParserService +"""Tests for `src/services/jd_llm_parser_service.py`. + +Two layers of behavior are pinned here: + + * The LLM call's `run_json_prompt` kwargs — output budget + retry + safety net. A tight cap with retry disabled used to truncate + detailed JDs and silently degrade build_job_description_from_text + _auto into the deterministic fallback, which then cascaded + through fit analysis, tailoring, and the cover letter. + + * The deterministic scrub passes between the LLM payload and the + JobDescription handed back to the caller: + - `_is_section_label_artifact` drops "REQUIREMENTS" / + "MUST-HAVES" / "QUALIFICATIONS" / etc. that the model + occasionally echoes as list items because the JD's headings + were inlined adjacent to a bullet block (the n8n "AI Product + Builder" listing tripped this). + - `_looks_like_benefit` drops compensation / perks vocabulary + (vacation, PTO, parental leave, medical/dental/vision, HSA, + 401(k), stock options, wellness stipend) that some LLM passes + swept into `nice_to_haves` when a "Benefits" block sat right + above the "Nice to have" block. Benefits are what the company + offers, not what the candidate brings — matching against + them produces nonsense signal. + - `_coerce_string_list(drop_section_labels=, drop_benefits=)` — + the public knob the parser uses on must_haves / nice_to_haves. + +The full-fidelity LLM call itself isn't exercised here — that's +covered by `tests/quality/jd_parser_quality_runner.py` against +fixtures. +""" +from __future__ import annotations + +from src.services.jd_llm_parser_service import ( + JobDescriptionLLMParserService, + _coerce_string_list, + _is_section_label_artifact, + _looks_like_benefit, +) class _RecordingOpenAIService: @@ -42,3 +80,104 @@ def test_jd_parser_requests_generous_budget_and_enables_retry(): assert recorder.kwargs is not None assert recorder.kwargs["max_completion_tokens"] >= 4000 assert recorder.kwargs["allow_output_budget_retry"] is True + + +def test_section_label_artifact_matches_common_headers(): + samples = [ + "REQUIREMENTS", + "Must-Haves", + "MUST HAVES", + "must_haves", + "Nice to have", + "Nice-to-haves", + "Qualifications:", + "PREFERRED", + "Good signals", + "Good to have", + "Bonus", + "Bonus points", + "What you'll do", + "What we're looking for", + ] + for sample in samples: + assert _is_section_label_artifact(sample), sample + + +def test_section_label_artifact_leaves_real_requirements_alone(): + samples = [ + "5+ years building production backend services", + "BSc in Computer Science", + "Strong English communication", + "Experience with PostgreSQL at scale", + # 'requirement' (singular noun, not a heading) embedded in a + # sentence shouldn't false-positive. + "Experience meeting product requirement docs", + ] + for sample in samples: + assert not _is_section_label_artifact(sample), sample + + +def test_looks_like_benefit_matches_compensation_vocab(): + samples = [ + "Unlimited vacation", + "Generous PTO", + "Paid time off", + "Parental leave", + "Health, dental and vision insurance", + "Medical, dental, vision coverage", + "HSA contribution", + "401(k) match", + "401k retirement plan", + "Stock options and RSU grants", + "Monthly wellness stipend", + "Home office stipend", + "Commuter benefit", + ] + for sample in samples: + assert _looks_like_benefit(sample), sample + + +def test_looks_like_benefit_leaves_real_requirements_alone(): + samples = [ + "Experience designing scalable APIs", + "Comfort with Python and Django", + "Track record shipping production ML systems", + "Strong analytical and product-thinking skills", + # "stock" appears (not stock options) — shouldn't match. + "Familiarity with stock-management systems", + ] + for sample in samples: + assert not _looks_like_benefit(sample), sample + + +def test_coerce_string_list_drops_artifacts_and_benefits_when_enabled(): + raw = [ + "5+ years building production backend services", + "REQUIREMENTS", + "MUST-HAVES", + "BSc in Computer Science", + "Unlimited PTO", + "401(k) match", + "Strong English communication", + "", + "5+ years building production backend services", # duplicate + ] + cleaned = _coerce_string_list( + raw, limit=20, drop_section_labels=True, drop_benefits=True + ) + assert cleaned == [ + "5+ years building production backend services", + "BSc in Computer Science", + "Strong English communication", + ] + + +def test_coerce_string_list_default_behavior_unchanged(): + # Without the new flags the function should behave exactly as + # before: dedupe + strip + drop empties, but pass headers / + # benefits through. The hard_skills / soft_skills call sites rely + # on this — a tool name like "401k Plan SDK" (yes, hypothetical) + # shouldn't get killed by the benefits filter on the skills list. + raw = ["Python", "REQUIREMENTS", "Unlimited PTO", "", "python"] + cleaned = _coerce_string_list(raw) + assert cleaned == ["Python", "REQUIREMENTS", "Unlimited PTO"] diff --git a/tests/test_job_search_service.py b/tests/test_job_search_service.py index ffbbe9b..96a8e4d 100644 --- a/tests/test_job_search_service.py +++ b/tests/test_job_search_service.py @@ -285,6 +285,16 @@ def search( posted_within_days, limit, offset, + # JobSearchService now also threads work_modes / employment_ + # types / sort_by through to the store after the filter- + # passthrough fix. The fake doesn't filter on them — the offset + # pagination tests don't care about facet behavior — but the + # signature must accept them or the service call would error + # with "unexpected keyword argument" the moment the fix + # lands. + work_modes=None, + employment_types=None, + sort_by="relevance", ): self.calls.append({"limit": limit, "offset": offset}) return self._rows[offset : offset + limit] @@ -330,3 +340,49 @@ def test_cached_search_full_final_page_still_reports_has_more(): assert len(result.results) == 10 assert result.has_more is True + + +def test_search_cached_threads_work_modes_and_sort_by_to_store(): + """Regression: `JobSearchService.search_cached` used to rebuild + `normalized_query` without copying the new filter / sort fields, + so the UI's Work-mode dropdown and Sort selector silently no-op'd + against the cache. This test pins the contract that those values + reach `store.search()` exactly as supplied. + """ + + class _CapturingStore: + def __init__(self): + self.last_kwargs = None + + def is_configured(self): + return True + + def search(self, **kwargs): + self.last_kwargs = kwargs + return [] + + captured = _CapturingStore() + service = JobSearchService(sources=[], cache_store=captured) + query = JobSearchQuery( + query="data engineer", + location="London", + page_size=15, + work_modes=["remote", "hybrid"], + employment_types=["fulltime"], + sort_by="newest", + ) + + result = service.search_cached(query) + + assert result.source_status["cache"] == "ok" + assert captured.last_kwargs is not None + assert captured.last_kwargs["work_modes"] == ["remote", "hybrid"] + assert captured.last_kwargs["employment_types"] == ["fulltime"] + assert captured.last_kwargs["sort_by"] == "newest" + # Empty lists should collapse to None so the RPC's `IS NULL` + # short-circuit fires instead of passing an empty array filter. + empty_query = JobSearchQuery(query="data engineer", page_size=10) + service.search_cached(empty_query) + assert captured.last_kwargs["work_modes"] is None + assert captured.last_kwargs["employment_types"] is None + assert captured.last_kwargs["sort_by"] == "relevance" diff --git a/tests/test_quota_service.py b/tests/test_quota_service.py index d6bf535..6dd3d7f 100644 --- a/tests/test_quota_service.py +++ b/tests/test_quota_service.py @@ -1,4 +1,9 @@ from src.auth_service import AuthService +from src.config import ( + FREE_TIER_MAX_CALLS_PER_DAY, + PAID_TIER_MAX_CALLS_PER_DAY, + get_daily_quota_for_plan, +) from src.quota_service import QuotaService from src.schemas import DailyQuotaStatus @@ -76,4 +81,46 @@ def test_quota_service_marks_quota_exhausted_when_daily_limit_is_reached(): assert status.quota_exhausted is True assert status.remaining_calls == 0 - assert status.remaining_total_tokens == 0 \ No newline at end of file + assert status.remaining_total_tokens == 0 + + +def test_business_tier_gets_paid_daily_caps_not_free(): + """Regression: `get_daily_quota_for_plan` used to lack a "business" + branch — Business users fell through to the FREE caps (12 calls / + 60k tokens per day), silently throttling paying customers on the + daily cost-limiter even though the monthly TIER_CAPS table grants + them generous feature quotas. They should share the Pro daily cap. + """ + business = get_daily_quota_for_plan("business") + pro = get_daily_quota_for_plan("pro") + free = get_daily_quota_for_plan("free") + + assert business["max_calls"] == PAID_TIER_MAX_CALLS_PER_DAY + assert business["max_calls"] == pro["max_calls"] + assert business["max_calls"] != free["max_calls"] + assert business["max_total_tokens"] == pro["max_total_tokens"] + # plan_tier is echoed lowercase so the downstream label is honest + # about which tier the user is on. + assert business["plan_tier"] == "business" + + +def test_internal_tier_remains_unlimited(): + """Internal / admin emails get no daily cap (None == unlimited). + The plan_tier label flows through so the UI can render + "Unlimited (Internal)" when the dev account is signed in.""" + internal = get_daily_quota_for_plan("internal") + admin = get_daily_quota_for_plan("admin") + + assert internal["max_calls"] is None + assert internal["max_total_tokens"] is None + assert internal["plan_tier"] == "internal" + assert admin["max_calls"] is None + assert admin["plan_tier"] == "admin" + + +def test_unknown_tier_falls_back_to_free(): + """Defensive fallback: an unrecognised plan_tier (typo, future + tier we haven't shipped yet) should resolve to the FREE caps + rather than crashing or returning unlimited.""" + unknown = get_daily_quota_for_plan("enterprise_xl") + assert unknown["max_calls"] == FREE_TIER_MAX_CALLS_PER_DAY \ No newline at end of file