Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm install
- run: npm ci
working-directory: frontend
- run: npm test
working-directory: frontend
- run: npm run build
working-directory: frontend
5 changes: 5 additions & 0 deletions backend/pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[pytest]
asyncio_mode = auto
filterwarnings =
ignore::FutureWarning
ignore::DeprecationWarning
52 changes: 52 additions & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""
Shared fixtures.

Every test here is hermetic: Gemini and Mongo are always mocked, so the suite
never spends API quota, never needs credentials, and passes in CI where no .env
exists. If a test in this package makes a real network call, that is a bug in the
test.
"""
import pytest

QUESTIONS = [
{"id": "q1", "question": "Why a Session?", "file_reference": "sessions.py", "category": "problem"},
{"id": "q2", "question": "How does preparation work?", "file_reference": "models.py", "category": "logic"},
{"id": "q3", "question": "Why urllib3?", "file_reference": "adapters.py", "category": "stack"},
]

COMPLEXITY = {"tier": "complex", "reasoning": "many interacting parts"}


@pytest.fixture
def questions():
return [dict(q) for q in QUESTIONS]


@pytest.fixture
def complexity():
return dict(COMPLEXITY)


@pytest.fixture
def attempt(questions, complexity):
"""A stored quiz_attempts document mid-flow, awaiting its follow-up."""
return {
"_id": "quiz-1",
"repo_url": "https://github.com/psf/requests",
"user_id": "u1",
"questions": questions,
"complexity": complexity,
"answers": [
{"question_id": "q1", "answer": "typed reply", "seconds_left": 4.0,
"flagged_paste": False, "paste_delta": 0},
{"question_id": "q2", "answer": "x" * 300, "seconds_left": 68.0,
"flagged_paste": True, "paste_delta": 300},
],
"followup": {
"id": "f1",
"question": "You said X — what happens if Y throws?",
"targets_question_id": "q2",
"answer": None,
},
"status": "awaiting_followup",
}
80 changes: 80 additions & 0 deletions backend/tests/test_gemini_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""
Parsing of the model's response.

These cover the degradation paths rather than the prompt text. `complexity` is a
required field on QuizGenerateResponse, so a single malformed reply that reached
the schema unparsed would be a ValidationError and a 500 for the candidate.
"""
import json

import pytest

from app.integrations.gemini_client import (
UNKNOWN_COMPLEXITY,
_parse_quiz_payload,
_strip_code_fence,
)


def test_well_formed_object():
questions, complexity = _parse_quiz_payload(json.dumps({
"questions": [{"question": "q", "category": "logic"}],
"complexity": {"tier": "complex", "reasoning": "many parts"},
}))
assert len(questions) == 1
assert complexity == {"tier": "complex", "reasoning": "many parts"}


def test_bare_array_still_yields_questions():
"""The model ignoring the object wrapper must not lose the quiz."""
questions, complexity = _parse_quiz_payload(json.dumps([{"question": "q"}]))
assert len(questions) == 1
assert complexity["tier"] == "unknown"


@pytest.mark.parametrize("payload", [
{"questions": [{"question": "q"}]}, # complexity absent
{"questions": [{"question": "q"}], "complexity": "hard"}, # not a dict
{"questions": [{"question": "q"}], "complexity": None},
])
def test_unusable_complexity_degrades_but_keeps_questions(payload):
questions, complexity = _parse_quiz_payload(json.dumps(payload))
assert len(questions) == 1
assert complexity == UNKNOWN_COMPLEXITY


@pytest.mark.parametrize("given,expected", [
("complex", "complex"),
("Moderate", "moderate"), # case is normalised
(" TRIVIAL ", "trivial"), # whitespace is stripped
("extreme", "unknown"), # invented tiers are not passed through
("", "unknown"),
])
def test_tier_normalisation(given, expected):
_, complexity = _parse_quiz_payload(json.dumps({
"questions": [], "complexity": {"tier": given, "reasoning": "r"},
}))
assert complexity["tier"] == expected


def test_missing_reasoning_gets_a_default():
_, complexity = _parse_quiz_payload(json.dumps({
"questions": [], "complexity": {"tier": "trivial"},
}))
assert complexity["tier"] == "trivial"
assert complexity["reasoning"]


def test_missing_questions_key_yields_empty_list():
questions, _ = _parse_quiz_payload(json.dumps({"complexity": {"tier": "trivial", "reasoning": "r"}}))
assert questions == []


@pytest.mark.parametrize("raw,expected", [
('{"a": 1}', '{"a": 1}'),
('```json\n{"a": 1}\n```', '{"a": 1}'),
('```\n{"a": 1}\n```', '{"a": 1}'),
(' {"a": 1} ', '{"a": 1}'),
])
def test_strip_code_fence(raw, expected):
assert _strip_code_fence(raw) == expected
143 changes: 143 additions & 0 deletions backend/tests/test_github_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""
Repo file selection.

Both cases here are regressions. The original code sorted ascending and took the
smallest files, which selected empty __init__.py files and produced questions
asking why blank files were blank — while still returning HTTP 200. The SKIP_DIRS
check used a path prefix, so nested vendored directories were never excluded;
harmless while the smallest files were preferred, load-bearing once the largest are.
"""
import base64

import pytest

from app.integrations import github_client


class FakeResponse:
def __init__(self, payload, status_code=200):
self._payload = payload
self.status_code = status_code

def json(self):
return self._payload

def raise_for_status(self):
if self.status_code != 200:
raise AssertionError(f"unexpected status {self.status_code}")


class FakeClient:
"""Stands in for httpx.AsyncClient: serves one tree and file contents."""

def __init__(self, tree, missing_branches=(), **kwargs):
self.tree = tree
self.missing_branches = missing_branches
self.requested = []

async def __aenter__(self):
return self

async def __aexit__(self, *exc):
return False

async def get(self, url):
self.requested.append(url)
if "/git/trees/" in url:
branch = url.split("/git/trees/")[1].split("?")[0]
if branch in self.missing_branches:
return FakeResponse({}, status_code=404)
return FakeResponse({"tree": self.tree})
path = url.split("/contents/")[1]
return FakeResponse({
"encoding": "base64",
"content": base64.b64encode(f"source of {path}".encode()).decode(),
})


def blob(path, size):
return {"type": "blob", "path": path, "size": size}


@pytest.fixture
def patch_client(monkeypatch):
def apply(tree, missing_branches=()):
holder = {}

def factory(**kwargs):
holder["client"] = FakeClient(tree, missing_branches, **kwargs)
return holder["client"]

monkeypatch.setattr(github_client.httpx, "AsyncClient", factory)
return holder

return apply


async def test_prefers_largest_files_and_drops_stubs(patch_client):
patch_client([
blob("app/__init__.py", 0),
blob("app/schemas/__init__.py", 0),
blob("py.typed", 12),
blob("app/service.py", 4000),
blob("app/client.py", 2000),
blob("app/tiny.py", 150), # under the 200-byte floor
])
files = await github_client.fetch_repo_files("https://github.com/o/r")

paths = [f["path"] for f in files]
assert paths == ["app/service.py", "app/client.py"]
assert not any("__init__" in p for p in paths), "empty stubs must never be selected"


async def test_excludes_nested_vendored_directories(patch_client):
patch_client([
blob("frontend/node_modules/react/index.js", 9000),
blob("backend/dist/bundle.js", 8000),
blob("src/build/out.js", 7000),
blob("backend/__pycache__/x.pyc", 6000),
blob("app/main.py", 1000),
])
files = await github_client.fetch_repo_files("https://github.com/o/r")
assert [f["path"] for f in files] == ["app/main.py"]


async def test_does_not_exclude_lookalike_paths(patch_client):
"""`dist/` must not match `mydist/`, and a file named build.py is fine."""
patch_client([
blob("mydist/helper.py", 3000),
blob("rebuild/tool.py", 2500),
blob("app/build.py", 2000),
])
files = await github_client.fetch_repo_files("https://github.com/o/r")
assert sorted(f["path"] for f in files) == ["app/build.py", "mydist/helper.py", "rebuild/tool.py"]


async def test_skips_binaries_and_docs(patch_client):
patch_client([
blob("README.md", 5000),
blob("logo.png", 4000),
blob("uv.lock", 9000),
blob("app/main.py", 1000),
])
files = await github_client.fetch_repo_files("https://github.com/o/r")
assert [f["path"] for f in files] == ["app/main.py"]


async def test_falls_back_to_master_branch(patch_client):
holder = patch_client([blob("app/main.py", 1000)], missing_branches=("main",))
files = await github_client.fetch_repo_files("https://github.com/o/r")
assert [f["path"] for f in files] == ["app/main.py"]
assert any("trees/master" in u for u in holder["client"].requested)


async def test_returns_empty_when_nothing_qualifies(patch_client):
"""Drives the 400 the endpoint returns for a repo with no usable source."""
patch_client([blob("README.md", 5000), blob("app/__init__.py", 0)])
assert await github_client.fetch_repo_files("https://github.com/o/r") == []


async def test_respects_max_files(patch_client):
patch_client([blob(f"app/mod{i}.py", 1000 + i) for i in range(30)])
files = await github_client.fetch_repo_files("https://github.com/o/r", max_files=4)
assert len(files) == 4
16 changes: 0 additions & 16 deletions backend/tests/test_quiz.py

This file was deleted.

Loading
Loading