diff --git a/README.md b/README.md index a3f9530..133cde9 100644 --- a/README.md +++ b/README.md @@ -104,14 +104,70 @@ many candidates before it can be calibrated, which does not exist yet. The complexity tier is a deliberate stopgap — a second, independent signal — not a substitute for it. +## The other side: the posting quiz + +A resume is a claim about a person; a job posting is a claim about a role. Both are +cheap to generate and both are usually written by someone other than the person who +will live with them. So the company side runs the same interrogation in reverse. + +1. An employer writes the posting — company, role, stack, what the job actually is. +2. The same engine generates questions grounded in that posting, in four categories: + - **role** — what this person does day to day, and what "doing well" looks like at 90 days + - **stack** — which of the listed technologies the hire actually touches, and why each is there + - **team** — who they work with, who decides what gets built + - **reality** — the constraints, the legacy, what makes the role genuinely hard +3. Same 75-second clock, same silent paste recording, same single adaptive follow-up + before anything is graded. +4. A posting is published **only** if the answers clear 70/100. Nothing else in the + codebase creates a job: `job_service.post_job()` has exactly one caller, and it is + the grading step. + +The posting that goes live is the draft the questions were generated from, held +server-side for the whole round — so a company cannot answer honestly about the real +role and then publish a rosier version of it. + +What this catches is a posting nobody behind it can account for. Asked about a +posting listing Kafka and Kubernetes next to a description that only mentions +FastAPI and Mongo, the first question generated back was where Kafka actually sits +in that flow — the kind of question a template cannot survive and the engineer who +owns the pipeline answers without thinking. A round answered with specifics and a +defended follow-up scored 98/100 and published; a round whose answers repeated +themselves and whose follow-up went undefended scored 52 and published nothing. + +**Same seam as the candidate side.** The clock and the paste detector are +client-side, and the pass mark is a single model judgement. What holds is the +follow-up: it is generated from the employer's own wording at response time, so it +cannot be prepared in advance. Grading also cannot be replayed — an attempt is +graded once, keeps the job id it produced, and a retried call returns that instead of +publishing again. + ## Status -**Built:** repo quiz end to end (generate → answer → grade), minimal job -posting/application CRUD. +**Built:** repo quiz end to end (generate → answer → grade), the company-side +quiz gating job postings, anonymous-first candidate profiles, the reputation +score (quiz depth + round history, shown as a breakdown), connections and a +text-only community feed, job listing/application CRUD. **Designed, not yet built** (see `docs/ARCHITECTURE.md` for where these -slot in): company-side quiz gating job postings, anonymous-first candidate -profiles, unified reputation score, bug-hunt mode, community threads. +slot in): reputation feeding the reveal threshold, difficulty-calibrated +scoring, bug-hunt mode. + +### Community: what is deliberately absent + +Connections are instant and mutual — one document per pair, no request, no +approval, no pending state — and a post is text that gets created and listed. +Left out on purpose, each one a schema change rather than a flag so the feed +cannot drift into a social network by default: + +- direct messages +- threaded replies and comments +- likes, reactions, any engagement counter +- approval-required connections (requests, accept/decline, blocking) +- media in posts + +An unrevealed candidate is a pseudonym in the feed and in a connections list for +exactly as long as they are one on their profile: names are resolved at read time +from `users`, and an unrevealed one is never in the payload at all. ## Running locally diff --git a/backend/app/api/v1/endpoints/connections.py b/backend/app/api/v1/endpoints/connections.py new file mode 100644 index 0000000..c0420a6 --- /dev/null +++ b/backend/app/api/v1/endpoints/connections.py @@ -0,0 +1,37 @@ +""" +Thin HTTP layer for connections. No business logic here — only request/response +translation and HTTP error mapping. Logic lives in +app/services/community_service.py. + +Instant and mutual: there is no request to send, accept, or decline, so there is +no pending state and no endpoint to advance one. +""" +from fastapi import APIRouter, Depends, HTTPException + +from app.core.dependencies import get_current_user +from app.schemas.community import ConnectionsResponse, ConnectResponse +from app.services import community_service + +router = APIRouter() + + +@router.post("/{user_id}/connect", response_model=ConnectResponse) +async def connect(user_id: str, user: dict = Depends(get_current_user)): + """ + Connect the caller to `user_id`. Idempotent — a repeat returns the existing + connection with `created: false` rather than failing. + """ + try: + return await community_service.connect(user["user_id"], user_id) + except LookupError: + raise HTTPException(404, "Profile not found") + except ValueError: + raise HTTPException(400, "You cannot connect to yourself.") + + +@router.get("/{user_id}/connections", response_model=ConnectionsResponse) +async def list_connections(user_id: str): + try: + return await community_service.list_connections(user_id) + except LookupError: + raise HTTPException(404, "Profile not found") diff --git a/backend/app/api/v1/endpoints/jobs.py b/backend/app/api/v1/endpoints/jobs.py index bb4f585..d99b0c3 100644 --- a/backend/app/api/v1/endpoints/jobs.py +++ b/backend/app/api/v1/endpoints/jobs.py @@ -1,16 +1,31 @@ -"""Thin HTTP layer for job postings and applications.""" -from fastapi import APIRouter +""" +Thin HTTP layer for job postings, applications, and the company-side quiz. +No business logic here — logic lives in app/services/. -from app.schemas.job import ApplicationRequest, JobCreateRequest -from app.services import job_service +There is deliberately no plain "create a job" route. A posting exists only as the +output of a defended company quiz (generate -> submit -> followup), which is what +"the quiz gates job posting creation" has to mean to be worth anything: an ungated +create endpoint alongside it would make the gate decorative. Job creation itself +still lives in job_service.post_job(); company_quiz_service is its only caller. -router = APIRouter() +The quiz routes are employer-only and each attempt belongs to the account that +generated it. +""" +from fastapi import APIRouter, Depends, HTTPException +from app.core.dependencies import get_current_employer +from app.schemas.job import ( + ApplicationRequest, + CompanyQuizFollowUpRequest, + CompanyQuizGenerateRequest, + CompanyQuizGenerateResponse, + CompanyQuizResultResponse, + CompanyQuizSubmitRequest, + CompanyQuizSubmitResponse, +) +from app.services import company_quiz_service, job_service -@router.post("/") -async def create_job(job: JobCreateRequest): - job_id = await job_service.post_job(job.dict()) - return {"id": job_id} +router = APIRouter() @router.get("/") @@ -22,3 +37,51 @@ async def list_jobs(): async def apply(application: ApplicationRequest): app_id = await job_service.apply_to_job(application.dict()) return {"id": app_id} + + +# --- company quiz ---------------------------------------------------------- + + +@router.post("/company-quiz/generate", response_model=CompanyQuizGenerateResponse) +async def generate_company_quiz( + draft: CompanyQuizGenerateRequest, user: dict = Depends(get_current_employer) +): + """Interrogates the draft posting. The draft is held server-side until grading.""" + try: + return await company_quiz_service.create_quiz(draft.model_dump(), user["user_id"]) + except ValueError: + raise HTTPException(400, "Couldn't build questions from that posting — add more detail about the role.") + + +@router.post("/company-quiz/submit", response_model=CompanyQuizSubmitResponse) +async def submit_company_quiz( + req: CompanyQuizSubmitRequest, user: dict = Depends(get_current_employer) +): + """Records answers and returns the adaptive follow-up. Does not grade or post.""" + try: + return await company_quiz_service.start_followup( + req.quiz_id, [a.model_dump() for a in req.answers], user["user_id"] + ) + except LookupError: + # Also the answer when the quiz exists but belongs to somebody else, so this + # cannot be used to discover which quiz ids are real. + raise HTTPException(404, "Quiz not found") + except company_quiz_service.QuizClosed: + raise HTTPException(409, "This quiz has already been graded.") + except ValueError: + raise HTTPException(400, "No answers submitted") + + +@router.post("/company-quiz/followup", response_model=CompanyQuizResultResponse) +async def company_quiz_followup( + req: CompanyQuizFollowUpRequest, user: dict = Depends(get_current_employer) +): + """Grades the round and, on a pass, publishes the posting that was defended.""" + try: + return await company_quiz_service.grade_and_post( + req.quiz_id, req.answer, user["user_id"], req.seconds_left + ) + except LookupError: + raise HTTPException(404, "Quiz not found") + except company_quiz_service.QuizClosed: + raise HTTPException(409, "Answer the follow-up round before grading.") diff --git a/backend/app/api/v1/endpoints/posts.py b/backend/app/api/v1/endpoints/posts.py new file mode 100644 index 0000000..b7c97f6 --- /dev/null +++ b/backend/app/api/v1/endpoints/posts.py @@ -0,0 +1,36 @@ +""" +Thin HTTP layer for the community feed. No business logic here — only +request/response translation and HTTP error mapping. Logic lives in +app/services/community_service.py. + +Create and list, text only. No comments, likes, reactions, or media: the feed is +a place to say something, not a social network. +""" +from fastapi import APIRouter, Depends, HTTPException, Query + +from app.core.dependencies import get_current_user +from app.schemas.community import PostCreateRequest, PostListResponse, PostResponse +from app.services import community_service + +router = APIRouter() + + +@router.post("/", response_model=PostResponse, status_code=201) +async def create_post(req: PostCreateRequest, user: dict = Depends(get_current_user)): + try: + return await community_service.create_post( + user["user_id"], req.text, req.job_id, req.company_name + ) + except LookupError: + raise HTTPException(404, "That job posting does not exist.") + except ValueError: + raise HTTPException(400, "A post needs text.") + + +@router.get("/", response_model=PostListResponse) +async def list_posts( + limit: int = Query(community_service.DEFAULT_PAGE, ge=1, le=community_service.MAX_PAGE), + skip: int = Query(0, ge=0), +): + """Most recent first. Open to read, like the profile and reputation views.""" + return await community_service.list_posts(limit=limit, skip=skip) diff --git a/backend/app/api/v1/endpoints/reputation.py b/backend/app/api/v1/endpoints/reputation.py new file mode 100644 index 0000000..216074b --- /dev/null +++ b/backend/app/api/v1/endpoints/reputation.py @@ -0,0 +1,24 @@ +""" +Thin HTTP layer for the reputation score. No business logic here — only +request/response translation and HTTP error mapping. Logic lives in +app/services/reputation_service.py. + +Open for the same reason profiles are: the funnel exists so an employer can weigh +a candidate before either side has committed to anything, and the payload carries +no identity — only what the candidate has demonstrated. +""" +from fastapi import APIRouter, HTTPException + +from app.schemas.reputation import ReputationResponse +from app.services import reputation_service + +router = APIRouter() + + +@router.get("/{user_id}/reputation", response_model=ReputationResponse) +async def get_reputation(user_id: str): + try: + breakdown = await reputation_service.compute_reputation(user_id) + except LookupError: + raise HTTPException(404, "Profile not found") + return {"user_id": user_id, **breakdown} diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 16b9ecf..5695223 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,10 +1,22 @@ """Aggregates every v1 route. main.py only ever imports this one router.""" from fastapi import APIRouter -from app.api.v1.endpoints import auth, jobs, profile, quiz +from app.api.v1.endpoints import ( + auth, + connections, + jobs, + posts, + profile, + quiz, + reputation, +) api_router = APIRouter() api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(quiz.router, prefix="/quiz", tags=["quiz"]) api_router.include_router(jobs.router, prefix="/jobs", tags=["jobs"]) api_router.include_router(profile.router, prefix="/profile", tags=["profile"]) +api_router.include_router(reputation.router, prefix="/users", tags=["reputation"]) +# Shares the /users prefix with reputation: both hang off a person. +api_router.include_router(connections.router, prefix="/users", tags=["community"]) +api_router.include_router(posts.router, prefix="/posts", tags=["community"]) diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py index 7c41e3e..39791b2 100644 --- a/backend/app/core/dependencies.py +++ b/backend/app/core/dependencies.py @@ -29,3 +29,17 @@ async def get_current_user( if not user_id: raise HTTPException(401, "Invalid or expired token") return {"user_id": user_id, "role": claims.get("role")} + + +async def get_current_employer(user: dict = Depends(get_current_user)) -> dict: + """ + Same as get_current_user, but only for employer accounts. + + The role is read off the signed token rather than a request body, so a + candidate account cannot post jobs by claiming to be a company. 403 rather + than 404: which routes exist is not a secret, and a candidate who wound up + here should be told why the door is shut. + """ + if user.get("role") != "employer": + raise HTTPException(403, "Only employer accounts can post jobs.") + return user diff --git a/backend/app/integrations/gemini_client.py b/backend/app/integrations/gemini_client.py index 838077c..2bbb89b 100644 --- a/backend/app/integrations/gemini_client.py +++ b/backend/app/integrations/gemini_client.py @@ -31,6 +31,13 @@ def _strip_code_fence(text: str) -> str: } +def _parse_questions(data) -> list[dict]: + """Questions out of either shape the model returns: a bare array or {"questions": [...]}.""" + if isinstance(data, list): + return data + return data.get("questions") or [] + + def _parse_quiz_payload(text: str) -> tuple[list[dict], dict]: """ Split the model's response into (questions, complexity). @@ -42,9 +49,9 @@ def _parse_quiz_payload(text: str) -> tuple[list[dict], dict]: data = json.loads(text) if isinstance(data, list): # model ignored the object wrapper - return data, dict(UNKNOWN_COMPLEXITY) + return _parse_questions(data), dict(UNKNOWN_COMPLEXITY) - questions = data.get("questions") or [] + questions = _parse_questions(data) raw = data.get("complexity") if not isinstance(raw, dict): return questions, dict(UNKNOWN_COMPLEXITY) @@ -98,15 +105,30 @@ async def generate_quiz_questions(files: list[dict], n_questions: int = 5) -> tu return _parse_quiz_payload(_strip_code_fence(response.text)) -async def generate_followup_question(question: dict, answer: str) -> str: +# How the follow-up addresses whoever is answering. The generator itself is identical +# for both sides of the market - only the framing changes, so a company defending its +# own posting is pushed on exactly as hard as a candidate defending their code. +CANDIDATE_FRAMING = { + "opening": "A developer was asked this about a project they claim to have built", + "topic": "the project", +} +COMPANY_FRAMING = { + "opening": "The person who wrote a job posting was asked this about the role they are hiring for", + "topic": "the role", +} + + +async def generate_followup_question( + question: dict, answer: str, framing: dict = CANDIDATE_FRAMING +) -> str: """ - One sharp follow-up that pushes on the candidate's own wording. + One sharp follow-up that pushes on the answerer's own wording. The point is not to ask something harder - it is to ask something that is only answerable by whoever actually meant what they wrote. A pasted answer has no author behind it to defend it. """ - prompt = f"""A developer was asked this about a project they claim to have built: + prompt = f"""{framing["opening"]}: QUESTION: {question.get("question", "")} @@ -120,7 +142,7 @@ async def generate_followup_question(question: dict, answer: str) -> str: throws?" It must be unanswerable by someone who did not mean what they wrote. Do not ask a -general question about the project. Do not ask them to quote code. +general question about {framing["topic"]}. Do not ask them to quote code. Return ONLY the question text. No prose, no JSON, no quotes around it. """ @@ -183,3 +205,116 @@ async def grade_answers( """ response = await _model().generate_content_async(prompt) return json.loads(_strip_code_fence(response.text)) + + +def _posting_block(draft: dict) -> str: + stack = ", ".join(draft.get("tech_stack") or []) or "(none listed)" + return f"""COMPANY: {draft.get("company_name", "")} +ROLE: {draft.get("role_title", "")} +TECH STACK LISTED: {stack} +DESCRIPTION: +{draft.get("description", "")}""" + + +async def generate_company_quiz_questions(draft: dict, n_questions: int = 5) -> list[dict]: + """ + Interrogate a job posting the way the repo quiz interrogates a repo. + + Same engine, mirrored subject: instead of asking a developer whether they + understand the code they submitted, this asks the poster whether they know the + role they are advertising. A posting assembled from a template is exactly as + cheap as a resume assembled from one, and fails here for the same reason - there + is no lived detail behind it to produce under a clock. + """ + prompt = f"""You are an experienced engineer who just read this job posting and is +deciding whether to apply. You get to ask the hiring manager {n_questions} questions +first. + +Ask what someone who actually owns this role could answer instantly and someone who +pasted together a template could not. Push on vague recruiter language rather than +accepting it - if the posting says "fast-paced" or "rockstar" or lists ten +technologies, make them say what that concretely means here. + +Cover these four categories, at least one question each: + role - what this person actually does day to day, and what "doing well" looks like in the first 90 days + stack - which of the listed technologies the hire actually touches, and why each one is in the stack + team - who they work with, who decides what they build, how big the team is + reality - the unglamorous part: the constraints, the legacy, what makes this role genuinely hard, why the seat is open + +Ground each question in something specific from the posting - a phrase they used, a +technology they listed, a claim they made. Do NOT ask about salary, benefits, or +interview logistics. Do NOT ask questions answerable by re-reading the posting aloud. + +Return ONLY a JSON object, no prose: +{{"questions": [{{"question": "...", "category": "role" | "stack" | "team" | "reality"}}]}} + +POSTING: +{_posting_block(draft)} +""" + response = await _model().generate_content_async(prompt) + return _parse_questions(json.loads(_strip_code_fence(response.text))) + + +async def grade_company_answers( + draft: dict, questions: list[dict], answers: list[dict], followup: dict | None = None +) -> dict: + """ + Score whether the posting reflects a role this person actually knows. + + The mirror of grade_answers: there, a candidate's claim is their code; here it is + their posting. The failure being caught is the same one - a confident description + of something the author has no real contact with. + """ + qa_pairs = "\n\n".join( + "Q: {q}\nA: {a}".format( + q=q["question"], + a=next((a["answer"] for a in answers if a["question_id"] == q["id"]), "(no answer)"), + ) + for q in questions + ) + + followup_block = "" + if followup and followup.get("question"): + followup_block = f""" + +FOLLOW-UP ROUND - this is the strongest signal you have. +After answering, they were pushed on their own wording and replied under time pressure +with no chance to prepare: + + Follow-up asked : {followup.get("question")} + They replied : {followup.get("answer") or "(no answer)"} + +Weigh this heavily. If they cannot defend or even engage with wording they themselves +used, treat the original answer it came from as very likely not describing a role they +know, and score that answer down hard regardless of how polished it looked. +""" + + prompt = f"""You are scoring a hiring manager on whether the job posting below +reflects a role they actually know, rather than a template they assembled. + +Score WELL: concrete, specific answers - named systems, real trade-offs, honest +constraints, an admission that part of the job is tedious or unresolved. Someone +describing a real seat on a real team says things that could not be said about any +other job. + +Score POORLY: answers that would fit any company hiring any engineer, buzzwords with +nothing under them, restating the posting back, or dodging the question. Also score +down when an answer contradicts the posting - a listed technology nobody touches, or +a scope that turns out to be far smaller or larger than advertised - because the +posting failing to match the role is exactly the thing being tested. + +Do NOT reward polish, length, or enthusiasm. Do NOT penalise blunt or unflattering +honesty about the role; that is evidence they know it. + +Return ONLY JSON, no prose: +{{"overall_score": 0-100, "breakdown": [{{"question": "...", "score": 0-10, "note": "..."}}]}} + +THE POSTING THEY WROTE: +{_posting_block(draft)} + +THEIR ANSWERS: +{qa_pairs} +{followup_block} +""" + response = await _model().generate_content_async(prompt) + return json.loads(_strip_code_fence(response.text)) diff --git a/backend/app/models/community.py b/backend/app/models/community.py new file mode 100644 index 0000000..fae9464 --- /dev/null +++ b/backend/app/models/community.py @@ -0,0 +1,32 @@ +""" +connections collection: +{ + "_id": str (uuid4), + "users": [str, str], # the pair, sorted — see connection_repository.pair() + "created_at": datetime (UTC), +} + +One document per connection, not two. Connections are instant and mutual, so +there is no direction to record and no status field: a document existing IS the +connection. There is deliberately no `pending`, `requested_by`, or `accepted_at` +— an approval flow is roadmap, not scope. + +posts collection: +{ + "_id": str (uuid4), + "author_id": str, # from the access token, never from a request body + "text": str, # trimmed, 1..MAX_POST_LENGTH + "job_id": str | None, # optional reference to a jobs document + "company_name": str | None, + "created_at": datetime (UTC), +} + +Append-only and text-only. No comment, like, reaction, or attachment storage +exists here, and adding any of them is a schema change rather than a flag — +which is the point: the feed cannot quietly grow into a social network. + +Neither collection stores a display name. Names live in `users` and are resolved +at read time through community_service._display, so an unrevealed candidate is a +pseudonym in a feed and a connections list for exactly as long as they are one on +their profile. +""" diff --git a/backend/app/models/job.py b/backend/app/models/job.py index edc5bae..45493cf 100644 --- a/backend/app/models/job.py +++ b/backend/app/models/job.py @@ -6,9 +6,17 @@ "role_title": str, "description": str, "tech_stack": list[str], + "posted_by": str, # user_id of the employer who defended the quiz + "company_quiz_id": str, # the attempt that unlocked this posting "posted_at": datetime, } +Every document here is written by services/company_quiz_service.py on a passing +grade, from the draft that was stored when the questions were generated. There is +no route that inserts a job any other way, so a posting in this collection has by +construction been defended by the account named in `posted_by`. Documents created +before the gate existed have neither key. + applications collection: { "_id": str (uuid4), @@ -17,4 +25,24 @@ "quiz_score_id": str | None, "status": "applied" | "reviewed" | "rejected" | "accepted", } + +company_quiz_attempts collection (see repositories/company_quiz_repository.py for +why this is not merged into quiz_attempts): +{ + "_id": str (uuid4), + "user_id": str, # the employer; ownership is checked on every step + "draft": {company_name, role_title, description, tech_stack}, + "questions": [{"id": str, "question": str, "category": str}], + "answers": [{"question_id": str, "answer": str, "seconds_left": float | None, + "flagged_paste": bool, "paste_delta": int}], + "followup": {"id": str, "question": str, "targets_question_id": str, + "answer": str | None, "seconds_left": float | None}, + "result": {"overall_score": float, "breakdown": [...]}, + "passed": bool, + "job_id": str | None, # the posting this attempt produced, None if it failed + "status": "generated" | "awaiting_followup" | "graded", +} + +`status: "graded"` is terminal: the attempt keeps whatever job_id it produced and +is never re-graded, so it cannot be replayed into a second posting. """ diff --git a/backend/app/repositories/company_quiz_repository.py b/backend/app/repositories/company_quiz_repository.py new file mode 100644 index 0000000..e20251c --- /dev/null +++ b/backend/app/repositories/company_quiz_repository.py @@ -0,0 +1,52 @@ +""" +Only file allowed to query the company_quiz_attempts collection directly. + +Deliberately a separate collection from quiz_attempts rather than a `kind` field +on the same one. quiz_repository.has_graded_attempt_scoring_at_least() matches any +graded attempt owned by a user, and it backs the candidate reveal threshold — a +company quiz sharing that collection would silently count as comprehension a +candidate never demonstrated. Separate collections make that impossible rather +than merely unlikely. +""" +from typing import Optional + +from app.db.mongodb import get_collection + +collection = get_collection("company_quiz_attempts") + + +async def save_attempt(doc: dict) -> None: + await collection.insert_one(doc) + + +async def get_attempt(quiz_id: str) -> Optional[dict]: + return await collection.find_one({"_id": quiz_id}) + + +async def update_followup(quiz_id: str, answers: list[dict], followup: dict) -> None: + await collection.update_one( + {"_id": quiz_id}, + {"$set": {"status": "awaiting_followup", "answers": answers, "followup": followup}}, + ) + + +async def update_result( + quiz_id: str, result: dict, followup: dict, passed: bool, job_id: Optional[str] +) -> None: + """ + Close the attempt out. `job_id` is the posting this quiz unlocked, or None when + it did not clear the bar — recorded either way so a graded attempt can never be + replayed to mint a second posting. + """ + await collection.update_one( + {"_id": quiz_id}, + { + "$set": { + "status": "graded", + "result": result, + "followup": followup, + "passed": passed, + "job_id": job_id, + } + }, + ) diff --git a/backend/app/repositories/connection_repository.py b/backend/app/repositories/connection_repository.py new file mode 100644 index 0000000..42082a5 --- /dev/null +++ b/backend/app/repositories/connection_repository.py @@ -0,0 +1,38 @@ +""" +Only file allowed to query the connections collection directly. + +A connection is stored once, not twice. The pair is held in a sorted two-element +array, so `{"users": someone}` finds it from either side and the same two people +can only ever produce one document. Two directed rows would mean every read had +to union two queries and every write had to keep them in step. +""" +from datetime import datetime, timezone +from typing import List, Optional + +from app.db.mongodb import get_collection + +collection = get_collection("connections") + + +def pair(a: str, b: str) -> List[str]: + """The canonical form of a connection between two people: sorted, so it is one value.""" + return sorted([a, b]) + + +async def get_connection(a: str, b: str) -> Optional[dict]: + return await collection.find_one({"users": pair(a, b)}) + + +async def create_connection(connection_id: str, a: str, b: str) -> dict: + doc = { + "_id": connection_id, + "users": pair(a, b), + "created_at": datetime.now(timezone.utc), + } + await collection.insert_one(doc) + return doc + + +async def list_for_user(user_id: str, limit: int = 200) -> List[dict]: + """Every connection this user is part of, most recent first.""" + return await collection.find({"users": user_id}).sort("created_at", -1).to_list(limit) diff --git a/backend/app/repositories/job_repository.py b/backend/app/repositories/job_repository.py index 8b92c9a..69b5fef 100644 --- a/backend/app/repositories/job_repository.py +++ b/backend/app/repositories/job_repository.py @@ -21,3 +21,16 @@ async def get_job(job_id: str) -> Optional[dict]: async def create_application(doc: dict) -> None: await applications_collection.insert_one(doc) + + +async def count_applications_with_status(user_id: str, statuses: tuple[str, ...]) -> int: + """ + How many of this user's applications sit in one of `statuses`. + + Which statuses mean anything is the service's call, not this file's — this + only counts. Counted server-side rather than by pulling the documents: the + caller wants the number, not the applications. + """ + return await applications_collection.count_documents( + {"user_id": user_id, "status": {"$in": list(statuses)}} + ) diff --git a/backend/app/repositories/post_repository.py b/backend/app/repositories/post_repository.py new file mode 100644 index 0000000..afcb9b5 --- /dev/null +++ b/backend/app/repositories/post_repository.py @@ -0,0 +1,31 @@ +""" +Only file allowed to query the posts collection directly. + +Append-only: posts are created and listed, never edited or deleted. There is no +comment, like, or reaction storage here on purpose — see models/community.py. +""" +from typing import List + +from app.db.mongodb import get_collection + +collection = get_collection("posts") + + +async def create_post(doc: dict) -> None: + await collection.insert_one(doc) + + +async def list_posts(limit: int = 20, skip: int = 0) -> List[dict]: + """ + Most recent first. + + Offset paging rather than a cursor. Posts are append-only, so the only + anomaly is a post arriving mid-scroll and shifting a page boundary by one — + cheap to live with at this size, and a `created_at` cursor is the fix when it + stops being. + """ + return await collection.find().sort("created_at", -1).skip(skip).to_list(limit) + + +async def count_posts() -> int: + return await collection.count_documents({}) diff --git a/backend/app/repositories/quiz_repository.py b/backend/app/repositories/quiz_repository.py index 608b3e9..20e6384 100644 --- a/backend/app/repositories/quiz_repository.py +++ b/backend/app/repositories/quiz_repository.py @@ -48,3 +48,28 @@ async def has_graded_attempt_scoring_at_least(user_id: str, minimum: float) -> b {"_id": 1}, ) return match is not None + + +async def graded_scores_for_user(user_id: str, limit: int = 500) -> list[float]: + """ + Every defended score this user holds, for the reputation average. + + Only `status: "graded"` counts, for the same reason the reveal threshold only + counts graded attempts: an attempt still awaiting its follow-up has no score + the candidate has had to stand behind. Attempts whose stored result is missing + or non-numeric are skipped rather than coerced, so a malformed grade lowers the + quiz count instead of dragging the average toward zero. + + Projected down to the one field the caller averages. + """ + docs = await collection.find( + {"user_id": user_id, "status": "graded"}, + {"result.overall_score": 1}, + ).to_list(limit) + + scores = [] + for doc in docs: + raw = (doc.get("result") or {}).get("overall_score") + if isinstance(raw, (int, float)) and not isinstance(raw, bool): + scores.append(float(raw)) + return scores diff --git a/backend/app/repositories/user_repository.py b/backend/app/repositories/user_repository.py index 4c90221..ecab847 100644 --- a/backend/app/repositories/user_repository.py +++ b/backend/app/repositories/user_repository.py @@ -49,3 +49,21 @@ async def mark_revealed(user_id: str) -> None: pseudonym, so there is no un-reveal counterpart to this. """ await collection.update_one({"_id": user_id}, {"$set": {"revealed": True}}) + + +async def get_users_by_ids(user_ids: list[str]) -> dict[str, dict]: + """ + Look up several users at once, keyed by id. + + One query instead of one per row: the feed and the connections list both need + to resolve a page of authors, and doing that in a loop is how a list view + starts costing twenty round trips. Projected down to the display fields — + nothing here should be pulling password material into a list view. + """ + ids = list(user_ids) + if not ids: + return {} + docs = await collection.find( + {"_id": {"$in": ids}}, {"name": 1, "revealed": 1, "role": 1} + ).to_list(len(ids)) + return {doc["_id"]: _with_defaults(doc) for doc in docs} diff --git a/backend/app/schemas/community.py b/backend/app/schemas/community.py new file mode 100644 index 0000000..c7cacd6 --- /dev/null +++ b/backend/app/schemas/community.py @@ -0,0 +1,64 @@ +"""Request/response DTOs for the community endpoints — what crosses the wire.""" +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel, Field + +from app.services.community_service import MAX_POST_LENGTH + + +class PersonSummary(BaseModel): + """ + A person as a list view may show them. + + `name` carries the pseudonym while `revealed` is False — the same rule the + profile response follows, so a feed or a connections list never becomes the + hole the anonymous funnel is plugged everywhere else. + """ + + user_id: str + name: str + revealed: bool + + +class ConnectResponse(BaseModel): + connection_id: str + user_id: str + connected_to: str + # False when the connection already existed. Connecting twice is a no-op + # rather than an error, so this is how a caller tells the two apart. + created: bool + + +class ConnectionSummary(PersonSummary): + connected_at: Optional[datetime] = None + + +class ConnectionsResponse(BaseModel): + user_id: str + count: int + connections: List[ConnectionSummary] + + +class PostCreateRequest(BaseModel): + # author is deliberately absent: it comes from the access token, so a post + # cannot be attributed to somebody else by editing the body. + text: str = Field(min_length=1, max_length=MAX_POST_LENGTH) + job_id: Optional[str] = None + company_name: Optional[str] = None + + +class PostResponse(BaseModel): + post_id: str + author: PersonSummary + text: str + job_id: Optional[str] = None + company_name: Optional[str] = None + created_at: Optional[datetime] = None + + +class PostListResponse(BaseModel): + total: int + limit: int + skip: int + posts: List[PostResponse] diff --git a/backend/app/schemas/job.py b/backend/app/schemas/job.py index 94fd1e1..78953ea 100644 --- a/backend/app/schemas/job.py +++ b/backend/app/schemas/job.py @@ -4,6 +4,13 @@ from pydantic import BaseModel +from app.schemas.quiz import ( + TIME_LIMIT_SECONDS, + FollowUpQuestion, + QuizAnswer, + QuizQuestion, +) + class JobCreateRequest(BaseModel): company_name: str @@ -25,3 +32,57 @@ class ApplicationRequest(BaseModel): job_id: str user_id: str quiz_score_id: Optional[str] = None + + +# --- company quiz ---------------------------------------------------------- +# +# The DTOs below deliberately reuse QuizAnswer, QuizQuestion and FollowUpQuestion +# from schemas/quiz.py rather than redeclaring them. The two sides of the market +# run the same engine, so the anti-gaming fields (seconds_left, flagged_paste, +# paste_delta) must stay one definition — a company-side copy would drift. + + +class CompanyQuizGenerateRequest(JobCreateRequest): + """ + The draft posting itself. Questions are generated from it, and it is stored on + the attempt — the job is later created from that stored copy, never from a + second body sent at the end, so a passing quiz cannot be redeemed against a + different posting than the one that was defended. + """ + + +class CompanyQuizGenerateResponse(BaseModel): + quiz_id: str + role_title: str + questions: List[QuizQuestion] + time_limit_seconds: int = TIME_LIMIT_SECONDS + + +class CompanyQuizSubmitRequest(BaseModel): + quiz_id: str + answers: List[QuizAnswer] + + +class CompanyQuizSubmitResponse(BaseModel): + """Like the candidate flow, submitting opens the follow-up rather than grading.""" + + quiz_id: str + followup: FollowUpQuestion + time_limit_seconds: int = TIME_LIMIT_SECONDS + + +class CompanyQuizFollowUpRequest(BaseModel): + quiz_id: str + answer: str + seconds_left: Optional[float] = None + + +class CompanyQuizResultResponse(BaseModel): + quiz_id: str + score: float + pass_score: float + passed: bool + # The posting that was created, or None when the quiz did not clear the bar. + # This is the whole gate: no other route creates a job. + job_id: Optional[str] = None + feedback: List[dict] diff --git a/backend/app/schemas/reputation.py b/backend/app/schemas/reputation.py new file mode 100644 index 0000000..0fb0d4a --- /dev/null +++ b/backend/app/schemas/reputation.py @@ -0,0 +1,18 @@ +"""Request/response DTOs for the reputation endpoint — what crosses the wire.""" +from pydantic import BaseModel + + +class ReputationResponse(BaseModel): + """ + The unified score and the components it came from. + + The components are part of the response, not an optional expansion of it: a + single number here would be read as a measure of engineering ability, and it + is not one. See services/reputation_service.compute_reputation. + """ + + user_id: str + overall: int + comprehension: int # mean of every defended quiz score, 0 with no quizzes + quiz_count: int # how many defended scores that mean is over + rounds_reached: int # applications that moved past the pile diff --git a/backend/app/services/community_service.py b/backend/app/services/community_service.py new file mode 100644 index 0000000..5a623cd --- /dev/null +++ b/backend/app/services/community_service.py @@ -0,0 +1,160 @@ +""" +Business logic for the community surface: connections and the post feed. + +Deliberately small. A connection is instant and mutual — there is no request, no +approval, no pending state — and a post is text that gets created and listed. +Comments, likes, reactions, threaded replies, DMs, notifications and media are +all out of scope; see models/community.py and the README roadmap. + +The one thing that is not lightweight here is identity. Both surfaces list other +people, and an unrevealed candidate is a pseudonym everywhere or the funnel means +nothing — so every name that leaves this module goes through `_display`, which +reads the latched `revealed` flag and drops the name if it is False. This module +never evaluates the reveal threshold itself: that belongs to the profile read, +where flipping the latch is the documented behaviour. A candidate who has just +earned their reveal therefore appears here as themselves only after their profile +has been read, which is the safe direction to be wrong in. +""" +import uuid +from datetime import datetime, timezone + +from app.repositories import ( + connection_repository, + job_repository, + post_repository, + user_repository, +) +from app.services.reputation_service import ANONYMOUS_NAME + +MAX_POST_LENGTH = 2000 +DEFAULT_PAGE = 20 +MAX_PAGE = 50 + + +def _display(user_id: str, users: dict) -> dict: + """One person as a list view may show them.""" + user = users.get(user_id) or {} + revealed = bool(user.get("revealed")) + return { + "user_id": user_id, + # Absent for an unrevealed account rather than blanked by the client, so + # the name is not in the payload at all. + "name": (user.get("name") or ANONYMOUS_NAME) if revealed else ANONYMOUS_NAME, + "revealed": revealed, + } + + +# --- connections ----------------------------------------------------------- + + +async def connect(user_id: str, target_id: str) -> dict: + """ + Connect two people, instantly and mutually. + + Idempotent: connecting to someone you are already connected to returns the + existing connection rather than failing or creating a second one. With no + approval flow there is no state for a repeat to advance, so an error would + only punish a double-click. + + Raises ValueError for a self-connection and LookupError for an unknown target. + """ + if user_id == target_id: + raise ValueError("cannot_connect_to_self") + if not await user_repository.get_user_by_id(target_id): + raise LookupError("user_not_found") + + existing = await connection_repository.get_connection(user_id, target_id) + if existing: + return {"connection_id": existing["_id"], "user_id": user_id, + "connected_to": target_id, "created": False} + + # A concurrent duplicate is possible between the check above and this write. + # A unique index on `users` is the real fix; the consequence today is a second + # document that reads identically, not a wrong answer. + doc = await connection_repository.create_connection( + str(uuid.uuid4()), user_id, target_id + ) + return {"connection_id": doc["_id"], "user_id": user_id, + "connected_to": target_id, "created": True} + + +async def list_connections(user_id: str) -> dict: + """Who this user is connected to. Raises LookupError for an unknown user.""" + if not await user_repository.get_user_by_id(user_id): + raise LookupError("user_not_found") + + rows = await connection_repository.list_for_user(user_id) + # The stored pair holds both sides; the caller wants the other one. + others = [next(u for u in row["users"] if u != user_id) for row in rows] + users = await user_repository.get_users_by_ids(others) + + return { + "user_id": user_id, + "count": len(others), + "connections": [ + {**_display(other, users), "connected_at": row.get("created_at")} + for other, row in zip(others, rows) + ], + } + + +# --- posts ----------------------------------------------------------------- + + +async def create_post(author_id: str, text: str, job_id=None, company_name=None) -> dict: + """ + Add a post to the feed. + + The author comes from the caller's token, never from the body — the same rule + the quiz uses, so a post cannot be attributed to somebody else. Raises + ValueError for empty or over-long text, LookupError for a job reference that + does not exist. + """ + body = (text or "").strip() + if not body or len(body) > MAX_POST_LENGTH: + raise ValueError("invalid_text") + + if job_id and not await job_repository.get_job(job_id): + raise LookupError("job_not_found") + + doc = { + "_id": str(uuid.uuid4()), + "author_id": author_id, + "text": body, + "job_id": job_id, + "company_name": (company_name or "").strip() or None, + "created_at": datetime.now(timezone.utc), + } + await post_repository.create_post(doc) + + # Rendered from the document that was stored, so the post the author sees is + # the post the feed will show them a moment later. + users = await user_repository.get_users_by_ids([author_id]) + return _post_view(doc, users) + + +def _post_view(row: dict, users: dict) -> dict: + return { + "post_id": row["_id"], + "author": _display(row.get("author_id"), users), + "text": row.get("text", ""), + "job_id": row.get("job_id"), + "company_name": row.get("company_name"), + "created_at": row.get("created_at"), + } + + +async def list_posts(limit: int = DEFAULT_PAGE, skip: int = 0) -> dict: + """Most recent first, with authors resolved in one query rather than per row.""" + limit = max(1, min(limit, MAX_PAGE)) + skip = max(0, skip) + + rows = await post_repository.list_posts(limit=limit, skip=skip) + users = await user_repository.get_users_by_ids([r.get("author_id") for r in rows]) + + return { + "total": await post_repository.count_posts(), + "limit": limit, + "skip": skip, + "posts": [_post_view(row, users) for row in rows], + } diff --git a/backend/app/services/company_quiz_service.py b/backend/app/services/company_quiz_service.py new file mode 100644 index 0000000..3eccf3e --- /dev/null +++ b/backend/app/services/company_quiz_service.py @@ -0,0 +1,193 @@ +""" +Business logic for the company-side quiz — the employer mirror of quiz_service. + +The thesis cuts both ways. A candidate's claim is their repo, and the repo quiz +tests whether they understand it. A company's claim is its posting, and this tests +whether the posting describes a role the poster actually knows. Both are cheap to +generate and expensive to defend, which is the whole point. + +It is the same engine: the same clock, the same paste ranking +(quiz_service.pick_suspect_answer), the same one adaptive follow-up before anything +is graded. Only the prompts differ — see integrations/gemini_client.py. + +The gate: a job posting is created here, on a passing grade, from the draft stored +at generation time. No other code path creates a job. Two consequences worth being +explicit about: + + * The posting that gets created is the one the questions were generated from, so + a company cannot defend an honest draft and redeem the pass against a different, + rosier one. + * A graded attempt is closed forever. It carries the job_id it produced (or None), + so replaying the final call returns what already happened instead of minting a + second posting. +""" +import uuid + +from app.integrations import gemini_client +from app.repositories import company_quiz_repository +from app.schemas.quiz import TIME_LIMIT_SECONDS +from app.services import job_service +from app.services.quiz_service import pick_suspect_answer + + +class QuizClosed(Exception): + """The attempt is not in a state where the requested step is meaningful.""" + + +# The bar a posting must clear to go live. A module constant for the same reason as +# reputation_service.REVEAL_MIN_SCORE: the tests assert against this rather than a +# literal, so moving the bar cannot quietly leave the suite asserting the old one. +PASS_SCORE = 70.0 + +# Fields of the stored draft that describe the posting. Anything else on the attempt +# (ownership, answers, grading) is bookkeeping and must not reach the jobs collection. +DRAFT_FIELDS = ("company_name", "role_title", "description", "tech_stack") + + +async def create_quiz(draft: dict, user_id: str) -> dict: + """Generate the interrogation for a draft posting and hold the draft for later.""" + raw_questions = await gemini_client.generate_company_quiz_questions(draft) + if not raw_questions: + raise ValueError("no_questions") + + questions = [{"id": str(uuid.uuid4()), **q} for q in raw_questions] + stored_draft = {k: draft.get(k) for k in DRAFT_FIELDS} + + quiz_id = str(uuid.uuid4()) + await company_quiz_repository.save_attempt( + { + "_id": quiz_id, + "draft": stored_draft, + "user_id": user_id, + "questions": questions, + "status": "generated", + } + ) + return { + "quiz_id": quiz_id, + "role_title": stored_draft.get("role_title") or "", + "questions": questions, + "time_limit_seconds": TIME_LIMIT_SECONDS, + } + + +async def _load_owned_attempt(quiz_id: str, user_id: str) -> dict: + """ + Fetch an attempt, but only for the account it belongs to. + + Mirrors quiz_service._load_owned_attempt: a quiz that exists but belongs to + someone else raises the same LookupError as one that does not exist, so the + endpoint answers 404 either way and cannot be used to discover which quiz ids + are real. Kept here rather than shared because the two read different + collections, and the ownership rule is short enough that a wrong-collection + abstraction would cost more than the duplication. + """ + attempt = await company_quiz_repository.get_attempt(quiz_id) + if not attempt or attempt.get("user_id") != user_id: + raise LookupError("quiz_not_found") + return attempt + + +async def start_followup(quiz_id: str, answers: list[dict], user_id: str) -> dict: + """Record the answers and open the follow-up round. Does not grade, does not post.""" + attempt = await _load_owned_attempt(quiz_id, user_id) + if attempt.get("status") == "graded": + # Re-opening a closed attempt would let a company answer again after seeing + # how it scored. + raise QuizClosed("already_graded") + + suspect = pick_suspect_answer(answers) + if suspect is None: + raise ValueError("no_answers") + + target = next( + (q for q in attempt["questions"] if q["id"] == suspect.get("question_id")), + attempt["questions"][0], + ) + question_text = await gemini_client.generate_followup_question( + target, suspect.get("answer") or "", framing=gemini_client.COMPANY_FRAMING + ) + + followup = { + "id": str(uuid.uuid4()), + "question": question_text, + "targets_question_id": target["id"], + "answer": None, + } + await company_quiz_repository.update_followup(quiz_id, answers, followup) + + return { + "quiz_id": quiz_id, + "followup": followup, + "time_limit_seconds": TIME_LIMIT_SECONDS, + } + + +def _result_payload(quiz_id: str, score: float, passed: bool, job_id, breakdown) -> dict: + return { + "quiz_id": quiz_id, + "score": score, + "pass_score": PASS_SCORE, + "passed": passed, + "job_id": job_id, + "feedback": breakdown or [], + } + + +async def grade_and_post( + quiz_id: str, followup_answer: str, user_id: str, seconds_left: float | None = None +) -> dict: + """ + Grade the whole round and, only on a pass, create the posting. + + Replaying this against an already-graded attempt returns the stored outcome + rather than grading again, so a retried request cannot produce a second posting. + """ + attempt = await _load_owned_attempt(quiz_id, user_id) + + if attempt.get("status") == "graded": + stored = attempt.get("result") or {} + return _result_payload( + quiz_id, + float(stored.get("overall_score") or 0), + bool(attempt.get("passed")), + attempt.get("job_id"), + stored.get("breakdown"), + ) + + followup = dict(attempt.get("followup") or {}) + if not followup.get("question"): + # The follow-up is the round that actually holds; grading without it would + # let a company post by calling this endpoint directly and skipping the + # interrogation entirely. + raise QuizClosed("followup_not_started") + + followup["answer"] = followup_answer + followup["seconds_left"] = seconds_left + + result = await gemini_client.grade_company_answers( + attempt.get("draft") or {}, + attempt["questions"], + attempt.get("answers") or [], + followup=followup, + ) + score = float(result.get("overall_score") or 0) + passed = score >= PASS_SCORE + + # Posted before the attempt is closed out, so a failure here leaves the attempt + # re-gradable rather than burning a quiz that never produced a posting. The cost + # is a narrow window: if the write below fails after this insert, a retry can + # post twice. Recording the outcome first would trade that for the opposite + # failure, and losing an honest company's defended quiz is the worse one. + job_id = None + if passed: + job_id = await job_service.post_job( + { + **(attempt.get("draft") or {}), + "posted_by": user_id, + "company_quiz_id": quiz_id, + } + ) + + await company_quiz_repository.update_result(quiz_id, result, followup, passed, job_id) + return _result_payload(quiz_id, score, passed, job_id, result.get("breakdown")) diff --git a/backend/app/services/reputation_service.py b/backend/app/services/reputation_service.py index ee089fa..369c75e 100644 --- a/backend/app/services/reputation_service.py +++ b/backend/app/services/reputation_service.py @@ -18,7 +18,7 @@ unrevealed profile has no name and no email anywhere in the response, so there is nothing for a curious viewer to read out of the network tab. """ -from app.repositories import quiz_repository, user_repository +from app.repositories import job_repository, quiz_repository, user_repository ANONYMOUS_NAME = "Anonymous Candidate" @@ -78,3 +78,60 @@ async def get_public_profile(user_id: str) -> dict: "role": user.get("role", "candidate"), "revealed": True, } + + +# --- the reputation score -------------------------------------------------- +# +# Two components, deliberately kept visible next to each other rather than +# blended away. Comprehension is what the platform actually verifies, so it +# carries most of the weight; rounds reached is corroboration from outside this +# system, which is worth something precisely because we did not generate it. +COMPREHENSION_WEIGHT = 0.75 +ROUNDS_WEIGHT = 0.25 + +# Rounds saturate: getting through four of them says a great deal more than +# getting through zero, and the fortieth says almost nothing the fourth did not. +# Without a ceiling the score would reward volume of applications. +ROUNDS_FOR_FULL_CREDIT = 4 + +# An application that moved past the pile. `applied` is the only status that means +# nothing happened; the rest all imply a human looked. `rejected` is counted on +# purpose and is the arguable one - status is a single field that moves, so a +# rejection after three rounds and a rejection at first screening are stored +# identically, and refusing to count it would erase the former to avoid crediting +# the latter. Separate per-round tracking is what actually fixes this. +ROUND_STATUSES = ("reviewed", "rejected", "accepted") + + +async def compute_reputation(user_id: str) -> dict: + """ + The unified score, as a breakdown rather than a number. + + Every caller gets the components alongside the total for the same reason + ScoreResult never renders a bare quiz score: one blended figure invites being + read as a measure of engineering ability, which it is not. A candidate with a + 92 average across one quiz and one with 92 across six are not the same + candidate, and the payload has to make that visible. + + A user with no history is not an error - it is a new account, and it scores + zero across the board. Raises LookupError for an unknown user; the endpoint + turns that into a 404. + """ + if not await user_repository.get_user_by_id(user_id): + raise LookupError("user_not_found") + + scores = await quiz_repository.graded_scores_for_user(user_id) + comprehension = round(sum(scores) / len(scores)) if scores else 0 + + rounds_reached = await job_repository.count_applications_with_status( + user_id, ROUND_STATUSES + ) + rounds_component = min(rounds_reached / ROUNDS_FOR_FULL_CREDIT, 1.0) * 100 + + return { + "overall": round(comprehension * COMPREHENSION_WEIGHT + + rounds_component * ROUNDS_WEIGHT), + "comprehension": comprehension, + "rounds_reached": rounds_reached, + "quiz_count": len(scores), + } diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 877c35d..3bf678a 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -50,3 +50,51 @@ def attempt(questions, complexity): }, "status": "awaiting_followup", } + + +COMPANY_QUESTIONS = [ + {"id": "cq1", "question": "What does this hire do in their first week?", "category": "role"}, + {"id": "cq2", "question": "Which of the six listed technologies do they touch daily?", "category": "stack"}, + {"id": "cq3", "question": "Who decides what they build?", "category": "team"}, +] + +DRAFT = { + "company_name": "Acme", + "role_title": "Backend Engineer", + "description": "Own the ingest pipeline end to end.", + "tech_stack": ["Python", "MongoDB"], +} + + +@pytest.fixture +def draft(): + return dict(DRAFT) + + +@pytest.fixture +def company_questions(): + return [dict(q) for q in COMPANY_QUESTIONS] + + +@pytest.fixture +def company_attempt(company_questions, draft): + """A stored company_quiz_attempts document mid-flow, awaiting its follow-up.""" + return { + "_id": "cquiz-1", + "user_id": "e1", + "draft": draft, + "questions": company_questions, + "answers": [ + {"question_id": "cq1", "answer": "typed reply", "seconds_left": 6.0, + "flagged_paste": False, "paste_delta": 0}, + {"question_id": "cq2", "answer": "x" * 400, "seconds_left": 70.0, + "flagged_paste": True, "paste_delta": 400}, + ], + "followup": { + "id": "cf1", + "question": "You said they own ingest — who is on call for it today?", + "targets_question_id": "cq2", + "answer": None, + }, + "status": "awaiting_followup", + } diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py index b349e24..35f94d9 100644 --- a/backend/tests/test_auth_api.py +++ b/backend/tests/test_auth_api.py @@ -9,7 +9,7 @@ import pytest from fastapi.testclient import TestClient -from app.core.security import create_access_token, hash_password +from app.core.security import create_access_token, decode_access_token, hash_password from app.main import app from app.services import auth_service, quiz_service @@ -68,6 +68,21 @@ def test_register_starts_the_account_anonymous(no_such_user): assert no_such_user.call_args.args[0]["revealed"] is False +def test_the_issued_token_carries_the_role_that_was_chosen(no_such_user): + """ + The registration form is where an employer account comes from, and the + employer-only routes read the role off the token rather than the body. If the + role were dropped between the two, signing up as an employer would produce an + account that cannot reach the company quiz. + """ + body = client.post("/api/v1/auth/register", + json={"name": "Ada", "email": "e@f.com", + "password": "hunter2hunter2", "role": "employer"}).json() + + assert decode_access_token(body["access_token"])["role"] == "employer" + assert no_such_user.call_args.args[0]["role"] == "employer" + + def test_register_defaults_to_candidate(no_such_user): client.post("/api/v1/auth/register", json={"name": "Ada", "email": "c@d.com", "password": "hunter2hunter2"}) diff --git a/backend/tests/test_community_api.py b/backend/tests/test_community_api.py new file mode 100644 index 0000000..0dc08d9 --- /dev/null +++ b/backend/tests/test_community_api.py @@ -0,0 +1,169 @@ +""" +HTTP contract for connections and the post feed. + +Writing needs a token and the author comes off it; reading is open, like profiles +and reputation. The response models are pinned because `response_model` strips +undeclared fields — an author summary that loses `revealed` would render a name +the funnel says nobody may see. +""" +from datetime import datetime, timezone +from unittest.mock import AsyncMock + +import pytest +from fastapi.testclient import TestClient + +from app.core.security import create_access_token +from app.main import app +from app.services import community_service + +NOW = datetime(2026, 8, 24, tzinfo=timezone.utc) +AUTH = {"Authorization": f"Bearer {create_access_token('u1', 'candidate')}"} + +client = TestClient(app, headers=AUTH) +anon = TestClient(app) + +HIDDEN_AUTHOR = {"user_id": "u3", "name": "Anonymous Candidate", "revealed": False} + + +# --- connections ------------------------------------------------------------ + +def test_connect_uses_the_caller_from_the_token(monkeypatch): + spy = AsyncMock(return_value={"connection_id": "c1", "user_id": "u1", + "connected_to": "u2", "created": True}) + monkeypatch.setattr(community_service, "connect", spy) + + resp = client.post("/api/v1/users/u2/connect") + + assert resp.status_code == 200 + assert resp.json()["created"] is True + assert spy.call_args.args == ("u1", "u2"), "caller from the token, target from the path" + + +def test_connecting_twice_reports_created_false(monkeypatch): + monkeypatch.setattr(community_service, "connect", AsyncMock(return_value={ + "connection_id": "c1", "user_id": "u1", "connected_to": "u2", "created": False, + })) + assert client.post("/api/v1/users/u2/connect").json()["created"] is False + + +def test_connecting_needs_a_token(): + assert anon.post("/api/v1/users/u2/connect").status_code == 401 + + +def test_connecting_to_yourself_is_400(monkeypatch): + monkeypatch.setattr(community_service, "connect", AsyncMock(side_effect=ValueError())) + assert client.post("/api/v1/users/u1/connect").status_code == 400 + + +def test_connecting_to_an_unknown_user_is_404(monkeypatch): + monkeypatch.setattr(community_service, "connect", AsyncMock(side_effect=LookupError())) + assert client.post("/api/v1/users/ghost/connect").status_code == 404 + + +def test_the_connections_list_is_open_and_keeps_the_reveal_flag(monkeypatch): + monkeypatch.setattr(community_service, "list_connections", AsyncMock(return_value={ + "user_id": "u1", "count": 1, + "connections": [{**HIDDEN_AUTHOR, "connected_at": NOW}], + })) + + body = anon.get("/api/v1/users/u1/connections").json() + + assert body["count"] == 1 + assert body["connections"][0]["revealed"] is False + assert body["connections"][0]["name"] == "Anonymous Candidate" + + +def test_listing_connections_for_an_unknown_user_is_404(monkeypatch): + monkeypatch.setattr(community_service, "list_connections", + AsyncMock(side_effect=LookupError())) + assert anon.get("/api/v1/users/ghost/connections").status_code == 404 + + +# --- posts ------------------------------------------------------------------ + +def post_view(**over): + return {"post_id": "p1", "author": dict(HIDDEN_AUTHOR), "text": "hello", + "job_id": None, "company_name": None, "created_at": NOW, **over} + + +def test_creating_a_post_attributes_it_to_the_token(monkeypatch): + spy = AsyncMock(return_value=post_view()) + monkeypatch.setattr(community_service, "create_post", spy) + + resp = client.post("/api/v1/posts/", json={"text": "hello"}) + + assert resp.status_code == 201 + assert spy.call_args.args[0] == "u1", "author is never read from the body" + + +def test_an_author_field_in_the_body_is_ignored(monkeypatch): + """Otherwise anyone could post as anyone.""" + spy = AsyncMock(return_value=post_view()) + monkeypatch.setattr(community_service, "create_post", spy) + + client.post("/api/v1/posts/", json={"text": "hello", "author_id": "somebody-else"}) + + assert spy.call_args.args[0] == "u1" + + +def test_a_post_may_carry_a_job_reference(monkeypatch): + spy = AsyncMock(return_value=post_view(job_id="job-1", company_name="Acme")) + monkeypatch.setattr(community_service, "create_post", spy) + + body = client.post("/api/v1/posts/", json={ + "text": "we are hiring", "job_id": "job-1", "company_name": "Acme", + }).json() + + assert spy.call_args.args[1:] == ("we are hiring", "job-1", "Acme") + assert body["job_id"] == "job-1" + + +def test_posting_needs_a_token(): + assert anon.post("/api/v1/posts/", json={"text": "hello"}).status_code == 401 + + +@pytest.mark.parametrize("payload", [ + {}, + {"text": ""}, + {"text": "x" * (community_service.MAX_POST_LENGTH + 1)}, +]) +def test_post_validation(payload): + assert client.post("/api/v1/posts/", json=payload).status_code == 422 + + +def test_a_post_referencing_a_missing_job_is_404(monkeypatch): + monkeypatch.setattr(community_service, "create_post", AsyncMock(side_effect=LookupError())) + resp = client.post("/api/v1/posts/", json={"text": "see this", "job_id": "nope"}) + assert resp.status_code == 404 + + +def test_the_feed_is_open_to_read(monkeypatch): + monkeypatch.setattr(community_service, "list_posts", AsyncMock(return_value={ + "total": 1, "limit": 20, "skip": 0, "posts": [post_view()], + })) + + body = anon.get("/api/v1/posts/").json() + + assert body["total"] == 1 + assert body["posts"][0]["author"]["revealed"] is False + + +def test_the_feed_forwards_paging(monkeypatch): + spy = AsyncMock(return_value={"total": 0, "limit": 5, "skip": 10, "posts": []}) + monkeypatch.setattr(community_service, "list_posts", spy) + + anon.get("/api/v1/posts/?limit=5&skip=10") + + assert spy.call_args.kwargs == {"limit": 5, "skip": 10} + + +@pytest.mark.parametrize("query", ["limit=0", "limit=999", "skip=-1"]) +def test_the_feed_rejects_nonsense_paging(query): + assert anon.get(f"/api/v1/posts/?{query}").status_code == 422 + + +def test_there_are_no_comment_like_or_reaction_routes(): + """Scope guard: the feed creates and lists, and that is all it does.""" + paths = app.openapi()["paths"] + for word in ("comment", "like", "reaction", "reply", "message"): + assert not [p for p in paths if word in p.lower()], f"unexpected {word} route" diff --git a/backend/tests/test_community_service.py b/backend/tests/test_community_service.py new file mode 100644 index 0000000..dd7fea2 --- /dev/null +++ b/backend/tests/test_community_service.py @@ -0,0 +1,231 @@ +""" +Connections and the post feed. + +Two things carry weight here. Connections are instant and mutual, so the stored +shape has to be one document that reads the same from either side. And both +surfaces list other people, which makes them the two places the anonymous funnel +could quietly spring a leak. +""" +from datetime import datetime, timezone +from unittest.mock import AsyncMock + +import pytest + +from app.repositories.connection_repository import pair +from app.services import community_service +from app.services.reputation_service import ANONYMOUS_NAME + +NOW = datetime(2026, 8, 24, tzinfo=timezone.utc) + +REVEALED = {"_id": "u2", "name": "Ada Lovelace", "revealed": True} +HIDDEN = {"_id": "u3", "name": "Grace Hopper", "revealed": False} + + +def patch_users(monkeypatch, by_id, exists=True): + monkeypatch.setattr(community_service.user_repository, "get_users_by_ids", + AsyncMock(return_value=by_id)) + monkeypatch.setattr(community_service.user_repository, "get_user_by_id", + AsyncMock(return_value={"_id": "u2"} if exists else None)) + + +# --- connections ------------------------------------------------------------ + +async def test_connecting_is_instant_and_has_no_pending_state(monkeypatch): + patch_users(monkeypatch, {}) + monkeypatch.setattr(community_service.connection_repository, "get_connection", + AsyncMock(return_value=None)) + created = AsyncMock(return_value={"_id": "c1", "users": pair("u1", "u2")}) + monkeypatch.setattr(community_service.connection_repository, "create_connection", created) + + out = await community_service.connect("u1", "u2") + + assert out["created"] is True + assert out["connected_to"] == "u2" + doc_fields = created.call_args.args + assert doc_fields[1:] == ("u1", "u2") + + +async def test_the_pair_is_stored_once_in_canonical_order(): + """One document, findable from either side — not two directed rows.""" + assert pair("u2", "u1") == pair("u1", "u2") == ["u1", "u2"] + + +async def test_connecting_twice_is_a_no_op(monkeypatch): + """No approval flow means a repeat has no state to advance; erroring would + only punish a double-click.""" + patch_users(monkeypatch, {}) + monkeypatch.setattr(community_service.connection_repository, "get_connection", + AsyncMock(return_value={"_id": "c1", "users": pair("u1", "u2")})) + created = AsyncMock() + monkeypatch.setattr(community_service.connection_repository, "create_connection", created) + + out = await community_service.connect("u1", "u2") + + assert out["created"] is False + assert out["connection_id"] == "c1" + created.assert_not_called() + + +async def test_connecting_to_yourself_is_refused(monkeypatch): + patch_users(monkeypatch, {}) + with pytest.raises(ValueError): + await community_service.connect("u1", "u1") + + +async def test_connecting_to_an_unknown_user_is_refused(monkeypatch): + patch_users(monkeypatch, {}, exists=False) + created = AsyncMock() + monkeypatch.setattr(community_service.connection_repository, "create_connection", created) + + with pytest.raises(LookupError): + await community_service.connect("u1", "ghost") + created.assert_not_called() + + +async def test_the_connections_list_returns_the_other_side(monkeypatch): + patch_users(monkeypatch, {"u2": REVEALED, "u3": HIDDEN}) + monkeypatch.setattr(community_service.connection_repository, "list_for_user", + AsyncMock(return_value=[ + {"_id": "c1", "users": pair("u1", "u2"), "created_at": NOW}, + {"_id": "c2", "users": pair("u1", "u3"), "created_at": NOW}, + ])) + + out = await community_service.list_connections("u1") + + assert out["count"] == 2 + assert [c["user_id"] for c in out["connections"]] == ["u2", "u3"] + assert "u1" not in [c["user_id"] for c in out["connections"]] + + +async def test_listing_connections_for_an_unknown_user_raises(monkeypatch): + patch_users(monkeypatch, {}, exists=False) + with pytest.raises(LookupError): + await community_service.list_connections("ghost") + + +# --- the funnel must not leak ---------------------------------------------- + +async def test_an_unrevealed_connection_is_a_pseudonym(monkeypatch): + """The connections list must not be the place a hidden name escapes.""" + patch_users(monkeypatch, {"u3": HIDDEN}) + monkeypatch.setattr(community_service.connection_repository, "list_for_user", + AsyncMock(return_value=[ + {"_id": "c2", "users": pair("u1", "u3"), "created_at": NOW}, + ])) + + shown = (await community_service.list_connections("u1"))["connections"][0] + + assert shown["name"] == ANONYMOUS_NAME + assert shown["revealed"] is False + assert "Grace" not in str(shown), "the real name must not be in the payload at all" + + +async def test_a_revealed_connection_shows_their_name(monkeypatch): + patch_users(monkeypatch, {"u2": REVEALED}) + monkeypatch.setattr(community_service.connection_repository, "list_for_user", + AsyncMock(return_value=[ + {"_id": "c1", "users": pair("u1", "u2"), "created_at": NOW}, + ])) + + shown = (await community_service.list_connections("u1"))["connections"][0] + + assert shown["name"] == "Ada Lovelace" and shown["revealed"] is True + + +async def test_an_unrevealed_author_posts_under_the_pseudonym(monkeypatch): + patch_users(monkeypatch, {"u3": HIDDEN}) + monkeypatch.setattr(community_service.post_repository, "list_posts", + AsyncMock(return_value=[ + {"_id": "p1", "author_id": "u3", "text": "hello", "created_at": NOW}, + ])) + monkeypatch.setattr(community_service.post_repository, "count_posts", AsyncMock(return_value=1)) + + post = (await community_service.list_posts())["posts"][0] + + assert post["author"]["name"] == ANONYMOUS_NAME + assert "Grace" not in str(post) + + +# --- posts ------------------------------------------------------------------ + +def patch_post_create(monkeypatch, job=None, author=HIDDEN): + saved = AsyncMock() + monkeypatch.setattr(community_service.post_repository, "create_post", saved) + monkeypatch.setattr(community_service.job_repository, "get_job", AsyncMock(return_value=job)) + monkeypatch.setattr(community_service.user_repository, "get_users_by_ids", + AsyncMock(return_value={author["_id"]: author})) + return saved + + +async def test_a_post_is_attributed_to_the_caller(monkeypatch): + saved = patch_post_create(monkeypatch) + + out = await community_service.create_post("u3", " shipped the ingest rewrite ") + + doc = saved.call_args.args[0] + assert doc["author_id"] == "u3" + assert doc["text"] == "shipped the ingest rewrite", "text is trimmed" + assert out["text"] == doc["text"] + assert out["post_id"] == doc["_id"], "the view is rendered from the stored document" + + +async def test_a_post_may_reference_a_job(monkeypatch): + saved = patch_post_create(monkeypatch, job={"_id": "job-1"}) + + await community_service.create_post("u3", "we are hiring", job_id="job-1", + company_name="Acme") + + doc = saved.call_args.args[0] + assert doc["job_id"] == "job-1" + assert doc["company_name"] == "Acme" + + +async def test_a_post_referencing_a_missing_job_is_refused(monkeypatch): + saved = patch_post_create(monkeypatch, job=None) + + with pytest.raises(LookupError): + await community_service.create_post("u3", "see this role", job_id="nope") + saved.assert_not_called() + + +@pytest.mark.parametrize("text", ["", " ", "\n\t "]) +async def test_an_empty_post_is_refused(monkeypatch, text): + saved = patch_post_create(monkeypatch) + with pytest.raises(ValueError): + await community_service.create_post("u3", text) + saved.assert_not_called() + + +async def test_an_over_long_post_is_refused(monkeypatch): + saved = patch_post_create(monkeypatch) + with pytest.raises(ValueError): + await community_service.create_post("u3", "x" * (community_service.MAX_POST_LENGTH + 1)) + saved.assert_not_called() + + +async def test_the_feed_is_paginated_and_capped(monkeypatch): + listed = AsyncMock(return_value=[]) + monkeypatch.setattr(community_service.post_repository, "list_posts", listed) + monkeypatch.setattr(community_service.post_repository, "count_posts", AsyncMock(return_value=0)) + monkeypatch.setattr(community_service.user_repository, "get_users_by_ids", + AsyncMock(return_value={})) + + out = await community_service.list_posts(limit=9999, skip=-5) + + assert listed.call_args.kwargs == {"limit": community_service.MAX_PAGE, "skip": 0} + assert out["limit"] == community_service.MAX_PAGE and out["skip"] == 0 + + +async def test_the_feed_resolves_authors_in_one_query(monkeypatch): + """A page of posts must not cost one lookup per row.""" + rows = [{"_id": f"p{i}", "author_id": "u3", "text": "x", "created_at": NOW} for i in range(10)] + monkeypatch.setattr(community_service.post_repository, "list_posts", + AsyncMock(return_value=rows)) + monkeypatch.setattr(community_service.post_repository, "count_posts", + AsyncMock(return_value=10)) + lookup = AsyncMock(return_value={"u3": HIDDEN}) + monkeypatch.setattr(community_service.user_repository, "get_users_by_ids", lookup) + + await community_service.list_posts() + + lookup.assert_called_once() diff --git a/backend/tests/test_company_quiz_api.py b/backend/tests/test_company_quiz_api.py new file mode 100644 index 0000000..6437980 --- /dev/null +++ b/backend/tests/test_company_quiz_api.py @@ -0,0 +1,257 @@ +""" +HTTP contract for the company-side quiz. + +Two things are being pinned here. First the flow, which mirrors the candidate +quiz: generate -> submit -> followup, with /submit returning a follow-up rather +than a score. Second the gate itself: these routes are employer-only, and there +is no other way to create a job posting. +""" +from unittest.mock import AsyncMock + +import pytest +from fastapi.testclient import TestClient + +from app.core.security import create_access_token +from app.main import app +from app.services import company_quiz_service + +EMPLOYER = {"Authorization": f"Bearer {create_access_token('test-employer', 'employer')}"} +CANDIDATE = {"Authorization": f"Bearer {create_access_token('test-user', 'candidate')}"} + +client = TestClient(app, headers=EMPLOYER) +anon = TestClient(app) + +DRAFT_BODY = { + "company_name": "Acme", + "role_title": "Backend Engineer", + "description": "Own the ingest pipeline end to end.", + "tech_stack": ["Python", "MongoDB"], +} + + +def generated(questions): + return {"quiz_id": "cquiz-1", "role_title": "Backend Engineer", + "questions": questions, "time_limit_seconds": 75} + + +def followup_payload(targets="cq2"): + return {"quiz_id": "cquiz-1", + "followup": {"id": "cf1", "question": "Who is on call?", "targets_question_id": targets}, + "time_limit_seconds": 75} + + +# --- the gate -------------------------------------------------------------- + +def test_there_is_no_ungated_way_to_create_a_job(): + """ + The whole feature is worthless if a plain POST /jobs/ still inserts a posting. + This asserts the route is gone, not merely unused by the frontend. + """ + resp = client.post("/api/v1/jobs/", json=DRAFT_BODY) + assert resp.status_code in (404, 405) + + +def test_listing_jobs_still_works(): + """Reading postings is public; only creating one is gated.""" + with pytest.MonkeyPatch.context() as mp: + from app.services import job_service + mp.setattr(job_service, "get_jobs", AsyncMock(return_value=[])) + assert anon.get("/api/v1/jobs/").status_code == 200 + + +# --- auth ------------------------------------------------------------------ + +@pytest.mark.parametrize("path, body", [ + ("generate", DRAFT_BODY), + ("submit", {"quiz_id": "cquiz-1", "answers": [{"question_id": "cq1", "answer": "a"}]}), + ("followup", {"quiz_id": "cquiz-1", "answer": "a"}), +]) +def test_every_company_quiz_route_rejects_an_anonymous_caller(path, body): + assert anon.post(f"/api/v1/jobs/company-quiz/{path}", json=body).status_code == 401 + + +@pytest.mark.parametrize("path, body", [ + ("generate", DRAFT_BODY), + ("submit", {"quiz_id": "cquiz-1", "answers": [{"question_id": "cq1", "answer": "a"}]}), + ("followup", {"quiz_id": "cquiz-1", "answer": "a"}), +]) +def test_candidate_accounts_cannot_post_jobs(path, body): + """The role is read off the signed token, so this cannot be forged in a body.""" + resp = TestClient(app, headers=CANDIDATE).post(f"/api/v1/jobs/company-quiz/{path}", json=body) + assert resp.status_code == 403 + + +def test_generate_is_refused_before_any_api_call_is_spent(monkeypatch): + spy = AsyncMock() + monkeypatch.setattr(company_quiz_service, "create_quiz", spy) + TestClient(app, headers=CANDIDATE).post("/api/v1/jobs/company-quiz/generate", json=DRAFT_BODY) + spy.assert_not_called() + + +# --- generate -------------------------------------------------------------- + +def test_generate_returns_questions_and_the_time_limit(monkeypatch, company_questions): + monkeypatch.setattr(company_quiz_service, "create_quiz", + AsyncMock(return_value=generated(company_questions))) + + resp = client.post("/api/v1/jobs/company-quiz/generate", json=DRAFT_BODY) + + assert resp.status_code == 200 + body = resp.json() + assert body["quiz_id"] == "cquiz-1" + assert body["time_limit_seconds"] == 75 + assert [q["category"] for q in body["questions"]] == ["role", "stack", "team"] + + +def test_generate_forwards_the_draft_and_the_token_identity(monkeypatch, company_questions): + spy = AsyncMock(return_value=generated(company_questions)) + monkeypatch.setattr(company_quiz_service, "create_quiz", spy) + + client.post("/api/v1/jobs/company-quiz/generate", json=DRAFT_BODY) + + draft, user_id = spy.call_args.args + assert draft["role_title"] == "Backend Engineer" + assert draft["tech_stack"] == ["Python", "MongoDB"] + assert user_id == "test-employer" + + +def test_generate_requires_the_posting_fields(): + resp = client.post("/api/v1/jobs/company-quiz/generate", json={"company_name": "Acme"}) + assert resp.status_code == 422 + + +def test_generate_maps_an_unusable_posting_to_400(monkeypatch): + monkeypatch.setattr(company_quiz_service, "create_quiz", + AsyncMock(side_effect=ValueError("no_questions"))) + resp = client.post("/api/v1/jobs/company-quiz/generate", json=DRAFT_BODY) + assert resp.status_code == 400 + + +# --- submit ---------------------------------------------------------------- + +def test_submit_returns_a_followup_and_no_score(monkeypatch): + monkeypatch.setattr(company_quiz_service, "start_followup", + AsyncMock(return_value=followup_payload())) + + resp = client.post("/api/v1/jobs/company-quiz/submit", json={ + "quiz_id": "cquiz-1", "answers": [{"question_id": "cq1", "answer": "a"}], + }) + + assert resp.status_code == 200 + body = resp.json() + assert body["followup"]["targets_question_id"] == "cq2" + assert "score" not in body + assert "job_id" not in body + + +def test_submit_forwards_paste_flags_and_timing(monkeypatch): + """The anti-gaming signals are useless if they do not survive the wire.""" + spy = AsyncMock(return_value=followup_payload("cq1")) + monkeypatch.setattr(company_quiz_service, "start_followup", spy) + + client.post("/api/v1/jobs/company-quiz/submit", json={"quiz_id": "cquiz-1", "answers": [ + {"question_id": "cq1", "answer": "a", "seconds_left": 61.5, + "flagged_paste": True, "paste_delta": 220}, + ]}) + + sent = spy.call_args.args[1][0] + assert sent["flagged_paste"] is True + assert sent["paste_delta"] == 220 + assert sent["seconds_left"] == 61.5 + + +def test_submit_defaults_paste_flags_when_the_client_omits_them(monkeypatch): + spy = AsyncMock(return_value=followup_payload("cq1")) + monkeypatch.setattr(company_quiz_service, "start_followup", spy) + + resp = client.post("/api/v1/jobs/company-quiz/submit", json={ + "quiz_id": "cquiz-1", "answers": [{"question_id": "cq1", "answer": "a"}], + }) + + assert resp.status_code == 200 + sent = spy.call_args.args[1][0] + assert sent["flagged_paste"] is False + assert sent["paste_delta"] == 0 + assert sent["seconds_left"] is None + + +def test_submit_unknown_quiz_is_404(monkeypatch): + monkeypatch.setattr(company_quiz_service, "start_followup", AsyncMock(side_effect=LookupError())) + resp = client.post("/api/v1/jobs/company-quiz/submit", json={ + "quiz_id": "nope", "answers": [{"question_id": "cq1", "answer": "a"}], + }) + assert resp.status_code == 404 + + +def test_submit_on_a_graded_quiz_is_409(monkeypatch): + monkeypatch.setattr(company_quiz_service, "start_followup", + AsyncMock(side_effect=company_quiz_service.QuizClosed())) + resp = client.post("/api/v1/jobs/company-quiz/submit", json={ + "quiz_id": "cquiz-1", "answers": [{"question_id": "cq1", "answer": "a"}], + }) + assert resp.status_code == 409 + + +def test_submit_with_no_answers_is_400(monkeypatch): + monkeypatch.setattr(company_quiz_service, "start_followup", AsyncMock(side_effect=ValueError())) + resp = client.post("/api/v1/jobs/company-quiz/submit", json={"quiz_id": "cquiz-1", "answers": []}) + assert resp.status_code == 400 + + +# --- followup / grading ---------------------------------------------------- + +def test_a_pass_returns_the_posting_it_created(monkeypatch): + monkeypatch.setattr(company_quiz_service, "grade_and_post", AsyncMock(return_value={ + "quiz_id": "cquiz-1", "score": 84.0, "pass_score": 70.0, "passed": True, + "job_id": "job-1", "feedback": [{"question": "q", "score": 9, "note": "specific"}], + })) + + resp = client.post("/api/v1/jobs/company-quiz/followup", json={ + "quiz_id": "cquiz-1", "answer": "our defence", "seconds_left": 12.0, + }) + + assert resp.status_code == 200 + body = resp.json() + assert body["passed"] is True + assert body["job_id"] == "job-1" + assert body["score"] == 84.0 + assert body["feedback"][0]["note"] == "specific" + + +def test_a_fail_returns_no_posting(monkeypatch): + monkeypatch.setattr(company_quiz_service, "grade_and_post", AsyncMock(return_value={ + "quiz_id": "cquiz-1", "score": 31.0, "pass_score": 70.0, "passed": False, + "job_id": None, "feedback": [], + })) + + body = client.post("/api/v1/jobs/company-quiz/followup", + json={"quiz_id": "cquiz-1", "answer": "vague"}).json() + + assert body["passed"] is False + assert body["job_id"] is None + + +def test_followup_accepts_a_blank_answer(monkeypatch): + """A timed-out follow-up submits blank - that is a result, not an error.""" + monkeypatch.setattr(company_quiz_service, "grade_and_post", AsyncMock(return_value={ + "quiz_id": "cquiz-1", "score": 0.0, "pass_score": 70.0, "passed": False, + "job_id": None, "feedback": [], + })) + resp = client.post("/api/v1/jobs/company-quiz/followup", + json={"quiz_id": "cquiz-1", "answer": ""}) + assert resp.status_code == 200 + assert resp.json()["score"] == 0.0 + + +def test_followup_unknown_quiz_is_404(monkeypatch): + monkeypatch.setattr(company_quiz_service, "grade_and_post", AsyncMock(side_effect=LookupError())) + resp = client.post("/api/v1/jobs/company-quiz/followup", json={"quiz_id": "nope", "answer": "a"}) + assert resp.status_code == 404 + + +def test_grading_without_the_followup_round_is_409(monkeypatch): + monkeypatch.setattr(company_quiz_service, "grade_and_post", + AsyncMock(side_effect=company_quiz_service.QuizClosed())) + resp = client.post("/api/v1/jobs/company-quiz/followup", + json={"quiz_id": "cquiz-1", "answer": "a"}) + assert resp.status_code == 409 diff --git a/backend/tests/test_company_quiz_service.py b/backend/tests/test_company_quiz_service.py new file mode 100644 index 0000000..b2572c1 --- /dev/null +++ b/backend/tests/test_company_quiz_service.py @@ -0,0 +1,246 @@ +""" +The company-side gate. + +Everything here is about one property: a job posting exists only as the output of +a quiz its owner defended, and it is the posting that was defended. The scoring +itself is the model's business; what these tests hold down is that no other path +reaches the jobs collection. +""" +from unittest.mock import AsyncMock + +import pytest + +from app.services import company_quiz_service +from app.services.company_quiz_service import PASS_SCORE, QuizClosed + + +def patch_generation(monkeypatch, questions=None): + gen = AsyncMock(return_value=questions if questions is not None else [ + {"question": "What does this hire do in week one?", "category": "role"}, + ]) + monkeypatch.setattr(company_quiz_service.gemini_client, "generate_company_quiz_questions", gen) + saved = AsyncMock() + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "save_attempt", saved) + return gen, saved + + +def patch_grading(monkeypatch, attempt, score, breakdown=None): + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "get_attempt", + AsyncMock(return_value=attempt)) + monkeypatch.setattr(company_quiz_service.gemini_client, "grade_company_answers", + AsyncMock(return_value={"overall_score": score, + "breakdown": breakdown or []})) + recorded = AsyncMock() + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "update_result", recorded) + posted = AsyncMock(return_value="job-1") + monkeypatch.setattr(company_quiz_service.job_service, "post_job", posted) + return posted, recorded + + +# --- generate -------------------------------------------------------------- + +async def test_create_quiz_assigns_ids_and_stores_the_draft(monkeypatch, draft): + _, saved = patch_generation(monkeypatch) + + out = await company_quiz_service.create_quiz(draft, "e1") + + assert out["questions"][0]["id"], "questions must be given ids" + assert out["time_limit_seconds"] == 75 + doc = saved.call_args.args[0] + assert doc["draft"] == draft + assert doc["user_id"] == "e1" + assert doc["status"] == "generated" + + +async def test_create_quiz_stores_only_posting_fields(monkeypatch, draft): + """A caller cannot smuggle extra keys into the jobs document via the draft.""" + _, saved = patch_generation(monkeypatch) + + await company_quiz_service.create_quiz( + {**draft, "posted_by": "someone-else", "verified": True}, "e1" + ) + + assert set(saved.call_args.args[0]["draft"]) == set(company_quiz_service.DRAFT_FIELDS) + + +async def test_create_quiz_rejects_a_posting_that_yields_no_questions(monkeypatch, draft): + patch_generation(monkeypatch, questions=[]) + with pytest.raises(ValueError): + await company_quiz_service.create_quiz(draft, "e1") + + +# --- follow-up round ------------------------------------------------------- + +async def test_start_followup_targets_the_flagged_answer(monkeypatch, company_attempt): + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "get_attempt", + AsyncMock(return_value=company_attempt)) + gen = AsyncMock(return_value="Who is on call for ingest today?") + monkeypatch.setattr(company_quiz_service.gemini_client, "generate_followup_question", gen) + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "update_followup", AsyncMock()) + + out = await company_quiz_service.start_followup("cquiz-1", company_attempt["answers"], "e1") + + assert out["followup"]["targets_question_id"] == "cq2" + assert gen.call_args.args[1] == company_attempt["answers"][1]["answer"] + + +async def test_followup_is_asked_in_the_company_framing(monkeypatch, company_attempt): + """Same generator as the candidate side - it must not address them as a developer.""" + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "get_attempt", + AsyncMock(return_value=company_attempt)) + gen = AsyncMock(return_value="q?") + monkeypatch.setattr(company_quiz_service.gemini_client, "generate_followup_question", gen) + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "update_followup", AsyncMock()) + + await company_quiz_service.start_followup("cquiz-1", company_attempt["answers"], "e1") + + assert gen.call_args.kwargs["framing"] is company_quiz_service.gemini_client.COMPANY_FRAMING + + +async def test_start_followup_does_not_grade_or_post(monkeypatch, company_attempt): + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "get_attempt", + AsyncMock(return_value=company_attempt)) + monkeypatch.setattr(company_quiz_service.gemini_client, "generate_followup_question", + AsyncMock(return_value="q?")) + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "update_followup", AsyncMock()) + posted = AsyncMock() + monkeypatch.setattr(company_quiz_service.job_service, "post_job", posted) + graded = AsyncMock() + monkeypatch.setattr(company_quiz_service.gemini_client, "grade_company_answers", graded) + + out = await company_quiz_service.start_followup("cquiz-1", company_attempt["answers"], "e1") + + posted.assert_not_called() + graded.assert_not_called() + assert "score" not in out + + +async def test_start_followup_refuses_a_graded_attempt(monkeypatch, company_attempt): + """Re-opening a closed attempt would mean answering again after seeing the score.""" + company_attempt["status"] = "graded" + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "get_attempt", + AsyncMock(return_value=company_attempt)) + with pytest.raises(QuizClosed): + await company_quiz_service.start_followup("cquiz-1", company_attempt["answers"], "e1") + + +# --- the gate -------------------------------------------------------------- + +async def test_passing_publishes_the_posting_that_was_defended(monkeypatch, company_attempt, draft): + posted, recorded = patch_grading(monkeypatch, company_attempt, PASS_SCORE + 12) + + out = await company_quiz_service.grade_and_post("cquiz-1", "our defence", "e1", 8.0) + + assert out["passed"] is True + assert out["job_id"] == "job-1" + assert out["pass_score"] == PASS_SCORE + sent = posted.call_args.args[0] + for field in draft: + assert sent[field] == draft[field], "the posting must be the stored draft" + assert sent["posted_by"] == "e1" + assert sent["company_quiz_id"] == "cquiz-1" + assert recorded.call_args.args[4] == "job-1" + + +async def test_failing_publishes_nothing(monkeypatch, company_attempt): + posted, recorded = patch_grading(monkeypatch, company_attempt, PASS_SCORE - 1) + + out = await company_quiz_service.grade_and_post("cquiz-1", "vague defence", "e1") + + assert out["passed"] is False + assert out["job_id"] is None + posted.assert_not_called() + assert recorded.call_args.args[3] is False + + +async def test_exactly_the_pass_score_passes(monkeypatch, company_attempt): + """The bar is inclusive - asserted against the constant, not a literal.""" + posted, _ = patch_grading(monkeypatch, company_attempt, PASS_SCORE) + + out = await company_quiz_service.grade_and_post("cquiz-1", "defence", "e1") + + assert out["passed"] is True + posted.assert_called_once() + + +async def test_the_defence_and_the_posting_both_reach_the_grader(monkeypatch, company_attempt): + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "get_attempt", + AsyncMock(return_value=company_attempt)) + grade = AsyncMock(return_value={"overall_score": 81, "breakdown": []}) + monkeypatch.setattr(company_quiz_service.gemini_client, "grade_company_answers", grade) + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "update_result", AsyncMock()) + monkeypatch.setattr(company_quiz_service.job_service, "post_job", AsyncMock(return_value="job-1")) + + await company_quiz_service.grade_and_post("cquiz-1", "on-call is me", "e1", 9.0) + + sent = grade.call_args.kwargs["followup"] + assert sent["answer"] == "on-call is me" + assert sent["targets_question_id"] == "cq2" + # The posting is graded alongside the answers, so a contradiction between the two + # is visible to the grader rather than invisible. + assert grade.call_args.args[0] == company_attempt["draft"] + + +async def test_grading_requires_the_followup_round(monkeypatch, company_attempt): + """Calling the API directly must not let a company skip the interrogation.""" + company_attempt["status"] = "generated" + company_attempt["followup"] = None + posted, _ = patch_grading(monkeypatch, company_attempt, 95) + + with pytest.raises(QuizClosed): + await company_quiz_service.grade_and_post("cquiz-1", "", "e1") + posted.assert_not_called() + + +async def test_a_graded_attempt_cannot_mint_a_second_posting(monkeypatch, company_attempt): + """A retried request returns what already happened rather than posting again.""" + company_attempt.update({ + "status": "graded", + "passed": True, + "job_id": "job-1", + "result": {"overall_score": 88.0, "breakdown": [{"question": "q", "score": 9, "note": "n"}]}, + }) + posted, recorded = patch_grading(monkeypatch, company_attempt, 100) + + out = await company_quiz_service.grade_and_post("cquiz-1", "again", "e1") + + assert out == {"quiz_id": "cquiz-1", "score": 88.0, "pass_score": PASS_SCORE, + "passed": True, "job_id": "job-1", + "feedback": [{"question": "q", "score": 9, "note": "n"}]} + posted.assert_not_called() + recorded.assert_not_called() + + +async def test_a_failed_attempt_cannot_be_regraded_into_a_pass(monkeypatch, company_attempt): + company_attempt.update({"status": "graded", "passed": False, "job_id": None, + "result": {"overall_score": 31.0, "breakdown": []}}) + posted, _ = patch_grading(monkeypatch, company_attempt, 99) + + out = await company_quiz_service.grade_and_post("cquiz-1", "better defence", "e1") + + assert out["passed"] is False and out["job_id"] is None + posted.assert_not_called() + + +# --- ownership ------------------------------------------------------------- + +async def test_another_account_cannot_grade_or_post(monkeypatch, company_attempt): + posted, _ = patch_grading(monkeypatch, company_attempt, 95) + + with pytest.raises(LookupError): + await company_quiz_service.grade_and_post("cquiz-1", "defence", "another-employer") + posted.assert_not_called() + + +async def test_a_missing_quiz_and_someone_elses_are_indistinguishable(monkeypatch, company_attempt): + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "get_attempt", + AsyncMock(return_value=None)) + with pytest.raises(LookupError) as missing: + await company_quiz_service.grade_and_post("nope", "d", "e1") + + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "get_attempt", + AsyncMock(return_value=company_attempt)) + with pytest.raises(LookupError) as foreign: + await company_quiz_service.grade_and_post("cquiz-1", "d", "another-employer") + + assert str(missing.value) == str(foreign.value) diff --git a/backend/tests/test_reputation_api.py b/backend/tests/test_reputation_api.py new file mode 100644 index 0000000..979e82d --- /dev/null +++ b/backend/tests/test_reputation_api.py @@ -0,0 +1,52 @@ +""" +HTTP contract for GET /users/{user_id}/reputation. + +The response model is the thing being pinned: response_model strips undeclared +fields, so a component that is not declared silently stops crossing the wire and +the page renders a bare number instead of a breakdown. +""" +from unittest.mock import AsyncMock + +from fastapi.testclient import TestClient + +from app.main import app +from app.services import reputation_service + +client = TestClient(app) + +BREAKDOWN = {"overall": 66, "comprehension": 80, "quiz_count": 2, "rounds_reached": 1} + + +def test_the_breakdown_survives_the_response_model(monkeypatch): + monkeypatch.setattr(reputation_service, "compute_reputation", + AsyncMock(return_value=dict(BREAKDOWN))) + + resp = client.get("/api/v1/users/u1/reputation") + + assert resp.status_code == 200 + assert resp.json() == {"user_id": "u1", **BREAKDOWN} + + +def test_a_new_account_is_zeros_rather_than_a_404(monkeypatch): + monkeypatch.setattr(reputation_service, "compute_reputation", AsyncMock(return_value={ + "overall": 0, "comprehension": 0, "quiz_count": 0, "rounds_reached": 0, + })) + + resp = client.get("/api/v1/users/fresh/reputation") + + assert resp.status_code == 200 + assert resp.json()["overall"] == 0 + assert resp.json()["quiz_count"] == 0 + + +def test_an_unknown_user_is_404(monkeypatch): + monkeypatch.setattr(reputation_service, "compute_reputation", + AsyncMock(side_effect=LookupError())) + assert client.get("/api/v1/users/nobody/reputation").status_code == 404 + + +def test_reading_a_reputation_needs_no_token(monkeypatch): + """Open like profiles: the payload carries no identity, only what was earned.""" + monkeypatch.setattr(reputation_service, "compute_reputation", + AsyncMock(return_value=dict(BREAKDOWN))) + assert TestClient(app).get("/api/v1/users/u1/reputation").status_code == 200 diff --git a/backend/tests/test_reputation_service.py b/backend/tests/test_reputation_service.py new file mode 100644 index 0000000..298b65e --- /dev/null +++ b/backend/tests/test_reputation_service.py @@ -0,0 +1,141 @@ +""" +The unified reputation score. + +The property under test is not the arithmetic - it is that the components stay +visible and that an account with no history is an ordinary answer rather than an +error. A single blended number would be read as a measure of engineering ability, +which it is not. +""" +from unittest.mock import AsyncMock + +import pytest + +from app.services import reputation_service +from app.services.reputation_service import ( + COMPREHENSION_WEIGHT, + ROUNDS_FOR_FULL_CREDIT, + ROUNDS_WEIGHT, + ROUND_STATUSES, +) + + +def patch_history(monkeypatch, scores, rounds=0, user={"_id": "u1"}): + monkeypatch.setattr(reputation_service.user_repository, "get_user_by_id", + AsyncMock(return_value=user)) + monkeypatch.setattr(reputation_service.quiz_repository, "graded_scores_for_user", + AsyncMock(return_value=scores)) + counted = AsyncMock(return_value=rounds) + monkeypatch.setattr(reputation_service.job_repository, + "count_applications_with_status", counted) + return counted + + +# --- the two scenarios that have to hold ------------------------------------ + +async def test_two_graded_quizzes_and_an_accepted_application(monkeypatch): + """The worked example: the breakdown has to explain the total.""" + patch_history(monkeypatch, scores=[88.0, 72.0], rounds=1) + + out = await reputation_service.compute_reputation("u1") + + assert out["comprehension"] == 80, "mean of 88 and 72" + assert out["quiz_count"] == 2 + assert out["rounds_reached"] == 1 + # 80 * 0.75 + (1/4 * 100) * 0.25 = 60 + 6.25 + assert out["overall"] == 66 + assert set(out) == {"overall", "comprehension", "quiz_count", "rounds_reached"} + + +async def test_a_brand_new_user_scores_zero_without_erroring(monkeypatch): + patch_history(monkeypatch, scores=[], rounds=0) + + out = await reputation_service.compute_reputation("u1") + + assert out == {"overall": 0, "comprehension": 0, "quiz_count": 0, "rounds_reached": 0} + + +# --- components ------------------------------------------------------------- + +async def test_the_average_is_over_defended_scores_only(monkeypatch): + """One 100 does not make a reputation; the count is what shows that.""" + patch_history(monkeypatch, scores=[100.0], rounds=0) + one = await reputation_service.compute_reputation("u1") + + patch_history(monkeypatch, scores=[100.0] * 6, rounds=0) + six = await reputation_service.compute_reputation("u1") + + assert one["comprehension"] == six["comprehension"] == 100 + assert one["overall"] == six["overall"], "the average alone cannot separate them" + assert (one["quiz_count"], six["quiz_count"]) == (1, 6), "the count must" + + +async def test_rounds_alone_produce_a_score(monkeypatch): + """Outside corroboration counts even with no quiz taken yet.""" + patch_history(monkeypatch, scores=[], rounds=ROUNDS_FOR_FULL_CREDIT) + + out = await reputation_service.compute_reputation("u1") + + assert out["comprehension"] == 0 + assert out["overall"] == round(100 * ROUNDS_WEIGHT) + + +async def test_rounds_saturate_so_volume_is_not_rewarded(monkeypatch): + patch_history(monkeypatch, scores=[80.0], rounds=ROUNDS_FOR_FULL_CREDIT) + at_ceiling = await reputation_service.compute_reputation("u1") + + patch_history(monkeypatch, scores=[80.0], rounds=ROUNDS_FOR_FULL_CREDIT * 10) + far_past = await reputation_service.compute_reputation("u1") + + assert at_ceiling["overall"] == far_past["overall"] + assert far_past["rounds_reached"] == ROUNDS_FOR_FULL_CREDIT * 10, "still reported" + + +async def test_a_perfect_history_is_a_hundred(monkeypatch): + patch_history(monkeypatch, scores=[100.0], rounds=ROUNDS_FOR_FULL_CREDIT) + assert (await reputation_service.compute_reputation("u1"))["overall"] == 100 + + +async def test_comprehension_carries_most_of_the_weight(monkeypatch): + """The platform verifies comprehension; rounds are corroboration, not the point.""" + assert COMPREHENSION_WEIGHT > ROUNDS_WEIGHT + assert COMPREHENSION_WEIGHT + ROUNDS_WEIGHT == 1.0 + + patch_history(monkeypatch, scores=[100.0], rounds=0) + quizzes_only = await reputation_service.compute_reputation("u1") + patch_history(monkeypatch, scores=[], rounds=99) + rounds_only = await reputation_service.compute_reputation("u1") + + assert quizzes_only["overall"] > rounds_only["overall"] + + +# --- which applications count ----------------------------------------------- + +async def test_applied_is_the_only_status_that_does_not_count(monkeypatch): + """Submitting an application is not an achievement; being looked at is.""" + counted = patch_history(monkeypatch, scores=[], rounds=0) + await reputation_service.compute_reputation("u1") + + user_id, statuses = counted.call_args.args + assert user_id == "u1" + assert "applied" not in statuses + assert set(statuses) == {"reviewed", "rejected", "accepted"} == set(ROUND_STATUSES) + + +# --- unknown user ----------------------------------------------------------- + +async def test_an_unknown_user_raises_rather_than_scoring_zero(monkeypatch): + """Zeros mean a new account. A missing account has to be distinguishable.""" + patch_history(monkeypatch, scores=[], rounds=0, user=None) + with pytest.raises(LookupError): + await reputation_service.compute_reputation("nobody") + + +async def test_an_unknown_user_costs_no_further_queries(monkeypatch): + monkeypatch.setattr(reputation_service.user_repository, "get_user_by_id", + AsyncMock(return_value=None)) + scores = AsyncMock() + monkeypatch.setattr(reputation_service.quiz_repository, "graded_scores_for_user", scores) + + with pytest.raises(LookupError): + await reputation_service.compute_reputation("nobody") + scores.assert_not_called() diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1cbc5e9..128bda2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -69,6 +69,67 @@ response carries `"Anonymous Candidate"` and a null email, so an unrevealed name never reaches the browser at all. `features/profile/display.js` re-checks the flag as a second lock. +## Request flow example - posting a job + +A posting is the output of a defended quiz, not an input to one. There is no +ungated create route: `job_service.post_job()` has exactly one caller. + +``` +POST /api/v1/jobs/company-quiz/generate (employer token required) + -> api/v1/endpoints/jobs.py::generate_company_quiz() + -> services/company_quiz_service.py::create_quiz() + -> integrations/gemini_client.py::generate_company_quiz_questions() (Gemini) + -> repositories/company_quiz_repository.py::save_attempt() (Mongo, holds the draft) + +POST /api/v1/jobs/company-quiz/submit + -> services/company_quiz_service.py::start_followup() + -> services/quiz_service.py::pick_suspect_answer() (same ranking as the repo quiz) + -> integrations/gemini_client.py::generate_followup_question(framing=COMPANY_FRAMING) + +POST /api/v1/jobs/company-quiz/followup + -> services/company_quiz_service.py::grade_and_post() + -> integrations/gemini_client.py::grade_company_answers() (Gemini) + -> services/job_service.py::post_job() (Mongo, ONLY on a pass, + from the stored draft) + -> repositories/company_quiz_repository.py::update_result() + <- CompanyQuizResultResponse {score, passed, job_id} +``` + +Two properties the layering exists to protect: + +* The posting that goes live is the draft the questions were generated from, so a + company cannot defend an honest draft and publish a different one. +* `status: "graded"` is terminal and carries the job_id it produced, so a replayed + final call returns the stored outcome instead of minting a second posting. + +Company attempts live in their own collection rather than in `quiz_attempts` with a +discriminator: `quiz_repository.has_graded_attempt_scoring_at_least()` backs the +candidate reveal threshold and matches any graded attempt by user, so a shared +collection would let an employer quiz count as candidate comprehension. + +## Request flow example - the reputation score + +``` +GET /api/v1/users/{user_id}/reputation + -> api/v1/endpoints/reputation.py::get_reputation() + -> services/reputation_service.py::compute_reputation() + -> repositories/user_repository.py::get_user_by_id() (Mongo, 404s if absent) + -> repositories/quiz_repository.py::graded_scores_for_user() (Mongo) + -> repositories/job_repository.py (Mongo) + ::count_applications_with_status() + <- ReputationResponse {overall, comprehension, quiz_count, rounds_reached} + <- 200 JSON +``` + +The components are part of the response rather than an expansion of it. One +blended figure gets read as a measure of engineering ability, and it is not one: +a 92 average across one quiz and a 92 across six are different claims, and only +`quiz_count` says so. `features/reputation/ReputationPage.jsx` renders them in a +single return for the same reason `ScoreResult` does. + +Which statuses count as a round reached is the service's decision, not the +repository's — `count_applications_with_status()` only counts what it is handed. + ## Frontend structure ``` @@ -87,11 +148,16 @@ should ever import from a `features/` folder — dependencies point inward. ## What's next architecturally (not yet built) -- The real reputation score. `services/reputation_service.py` exists and owns the - anonymous-first funnel, but `meets_reveal_threshold()` is a placeholder: one - graded quiz at `REVEAL_MIN_SCORE` or better. The unified score (quiz depth + - interview rounds elsewhere) replaces the body of that one function — it reads - from quiz + application outcomes and writes to a `scores` collection via a new - `score_repository.py`. Nothing else in the funnel changes when it lands. -- `services/company_quiz_service.py` — same quiz engine, different prompt, - gates job posting creation. +- Wiring the reputation score into the reveal. `compute_reputation()` now exists + and combines quiz depth with round history, but `meets_reveal_threshold()` is + still its own placeholder: one graded quiz at `REVEAL_MIN_SCORE` or better. The + two are deliberately not connected yet — the reveal is a one-way latch on a + candidate's identity, and moving it onto a score whose weights are still + guesses would latch accounts open on a formula nobody has calibrated. When it + lands it replaces the body of that one function; nothing else in the funnel + changes. +- Difficulty-calibrated scoring, per the README. `comprehension` is a flat mean + of defended scores, so six trivial repos average the same as six hard ones. + That needs answer data across many candidates before it can be calibrated. +- Company-side bug-hunt and community threads, per the README status list. + Nothing about them is designed yet. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index df66544..f8ada56 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,16 +1,22 @@ import { useState } from "react"; import QuizPage from "./features/quiz/QuizPage"; import ProfilePage from "./features/profile/ProfilePage"; +import ReputationPage from "./features/reputation/ReputationPage"; +import CommunityPage from "./features/community/CommunityPage"; +import PostJobPage from "./features/jobs/PostJobPage"; import LoginPage from "./features/auth/LoginPage"; import RegisterPage from "./features/auth/RegisterPage"; import { isLoggedIn, logout } from "./features/auth/api"; +import { getRole } from "./shared/api/token"; -// Swap for a router (react-router) once the jobs/community pages exist. +// Swap for a router (react-router) once the community pages exist. export default function App() { // Seeded from storage so a reload does not log you out. const [authed, setAuthed] = useState(isLoggedIn); const [showRegister, setShowRegister] = useState(false); - const [tab, setTab] = useState("quiz"); + // Employers land on the posting flow; candidates on the repo quiz. Both are the + // thing that account type came here to do. + const [tab, setTab] = useState(() => (getRole() === "employer" ? "post" : "quiz")); if (!authed) { const Page = showRegister ? RegisterPage : LoginPage; @@ -18,28 +24,48 @@ export default function App() {

OneStop

Log in to take a repo quiz.

- setAuthed(true)} onSwitch={() => setShowRegister((v) => !v)} /> + { + setTab(getRole() === "employer" ? "post" : "quiz"); + setAuthed(true); + }} + onSwitch={() => setShowRegister((v) => !v)} + />
); } const onUnauthorized = () => setAuthed(false); + // One flow per account type: an employer answers for postings, a candidate + // answers for repos. Neither is offered the other's, because neither number + // means anything on the wrong side of the market. + // + // Hiding a tab is a convenience, not the gate: every company-quiz route is + // employer-only on the backend, checked against the signed token. + const employer = getRole() === "employer"; + const tabs = [ + employer ? ["post", "Post a Job"] : ["quiz", "Quiz"], + ["profile", "Profile"], + // Reputation is quiz depth plus what happened when you applied, so it is a + // candidate instrument; an employer looking at their own would read zeros. + ...(employer ? [] : [["reputation", "Reputation"]]), + // Shared ground: the feed is the one surface both sides of the market read. + ["community", "Community"], + ]; + return ( <>
- - + {tabs.map(([id, label]) => ( + + ))}
- {tab === "quiz" ? ( - - ) : ( - // Re-fetched on every visit, so a reveal earned in the quiz tab shows up - // as soon as the candidate looks. - + + {tab === "post" && } + {tab === "quiz" && } + {/* Re-fetched on every visit, so a reveal earned in the quiz tab shows up as + soon as the candidate looks. */} + {tab === "profile" && ( + setTab("reputation")} + /> )} + {tab === "reputation" && } + {tab === "community" && } ); } diff --git a/frontend/src/features/auth/AuthForm.jsx b/frontend/src/features/auth/AuthForm.jsx index 573010f..ae00d02 100644 --- a/frontend/src/features/auth/AuthForm.jsx +++ b/frontend/src/features/auth/AuthForm.jsx @@ -9,6 +9,13 @@ import { useState } from "react"; * name field arrived and made it three things at once; naming it for the mode * rather than for one of the fields keeps the next addition from repeating that. */ +// [value sent as `role`, label, what picking it means]. The values must match +// schemas/auth.py's Role literal — anything else is a 422. +const ROLES = [ + ["candidate", "Candidate", "Take repo quizzes and build a profile."], + ["employer", "Employer", "Post roles — after answering for them."], +]; + export default function AuthForm({ title, submitLabel, onSubmit, signup, footer }) { const [name, setName] = useState(""); const [email, setEmail] = useState(""); @@ -74,14 +81,29 @@ export default function AuthForm({ title, submitLabel, onSubmit, signup, footer /> + {/* Radios rather than a dropdown: this choice decides which half of the + product you land in, so both options should be visible without opening + anything. The value is still only a hint — every employer-only route + re-checks the role against the signed token. */} {signup && ( - +
+ I am a + {ROLES.map(([value, label, hint]) => ( + + ))} +
)} {error &&

{error}

} diff --git a/frontend/src/features/community/CommunityPage.jsx b/frontend/src/features/community/CommunityPage.jsx new file mode 100644 index 0000000..e533542 --- /dev/null +++ b/frontend/src/features/community/CommunityPage.jsx @@ -0,0 +1,131 @@ +import { useEffect, useState } from "react"; +import { createPost, fetchPosts } from "./api"; + +/** + * The community feed: text posts, newest first, with a box to add one. + * + * Deliberately the whole feature. No comments, likes, reactions, threaded + * replies or media — those are roadmap, and each of them is a schema change + * rather than a flag, which is what keeps this from drifting into a social + * network by accident. + * + * Authors render as whatever the backend says: an unrevealed candidate is + * "Anonymous Candidate" here for exactly as long as they are one on their + * profile, and the real name is never in the payload to begin with. + */ +const PAGE = 20; + +export default function CommunityPage({ onUnauthorized }) { + const [feed, setFeed] = useState(null); + const [text, setText] = useState(""); + const [jobId, setJobId] = useState(""); + const [companyName, setCompanyName] = useState(""); + const [posting, setPosting] = useState(false); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(true); + + async function load(skip = 0) { + setLoading(true); + try { + setFeed(await fetchPosts(PAGE, skip)); + setError(""); + } catch (e) { + if (e.status === 401) return onUnauthorized?.(); + setError(e.message); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(0); + }, []); + + async function handlePost() { + if (!text.trim() || posting) return; + setPosting(true); + setError(""); + try { + await createPost({ text: text.trim(), jobId, companyName }); + setText(""); + setJobId(""); + setCompanyName(""); + // Re-read rather than splicing the new post in: the feed is the source of + // truth for ordering, and it is one cheap call. + await load(0); + } catch (e) { + if (e.status === 401) return onUnauthorized?.(); + setError(e.message); + } finally { + setPosting(false); + } + } + + const posts = feed?.posts ?? []; + const skip = feed?.skip ?? 0; + const total = feed?.total ?? 0; + + return ( +
+

Community

+

What people are working on, hiring for, and running into.

+ +
+