Skip to content
Open
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
20 changes: 10 additions & 10 deletions backend/app/background/taskiq/taskiq.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@

from app.config import settings

if settings.REDIS_URL.startswith("rediss://"):
_ssl_ctx: ssl.SSLContext | None = ssl.create_default_context()
assert _ssl_ctx is not None
_ssl_ctx.check_hostname = False
_ssl_ctx.verify_mode = ssl.CERT_NONE
# Determine broker type
if getattr(settings, "BROKER_TYPE", "redis") == "rabbitmq":
from taskiq_aio_pika import AioPikaBroker, AioPikaResultBackend
broker = AioPikaBroker(url=settings.BROKER_URL).with_result_backend(
AioPikaResultBackend(url=settings.BROKER_URL)
)
else:
_ssl_ctx = None

broker = ListQueueBroker(url=settings.REDIS_URL).with_result_backend(
RedisAsyncResultBackend(redis_url=settings.REDIS_URL)
)
from taskiq_redis import ListQueueBroker, RedisAsyncResultBackend
broker = ListQueueBroker(url=settings.REDIS_URL).with_result_backend(
RedisAsyncResultBackend(redis_url=settings.REDIS_URL)
)
5 changes: 5 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ class Settings(BaseSettings):
APP_NAME: str = "InterXAI"
DEBUG: bool = False
API_V1_PREFIX: str = "/api/v1"
MAX_UPLOAD_SIZE: int = 5 * 1024 * 1024

# Database
DATABASE_URL: str = "sqlite+aiosqlite:///./dev.db"

Expand All @@ -16,6 +18,9 @@ class Settings(BaseSettings):

# Redis/Celery
REDIS_URL: str = "redis://localhost:6379/0"
# RabbitMQ support
BROKER_TYPE: str = "redis" # options: redis, rabbitmq
BROKER_URL: str = "redis://localhost:6379/0"

# LLM
LLM_MODEL_NAME: str = "groq/openai/gpt-oss-120b"
Expand Down
10 changes: 7 additions & 3 deletions backend/app/models/dsa_question.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
from typing import Any

from sqlalchemy import Integer, String, Text
from sqlalchemy import Integer, String, Text, JSON
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column

from app.models.base import BaseTable

# Use JSONB on PostgreSQL, and standard JSON on SQLite/other dialects
JSONB_TYPE = JSON().with_variant(JSONB, "postgresql")


class DsaQuestion(BaseTable):
__tablename__ = "dsa_questions"
Expand All @@ -16,8 +19,9 @@ class DsaQuestion(BaseTable):
description: Mapped[str] = mapped_column(Text, nullable=False)

# Each entry: {"stdin": str, "expected_stdout": str}. Multi-line values use "\n".
test_cases: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False)
sample_test_cases: Mapped[list[dict[str, Any]] | None] = mapped_column(JSONB, nullable=True)
test_cases: Mapped[list[dict[str, Any]]] = mapped_column(JSONB_TYPE, nullable=False)
sample_test_cases: Mapped[list[dict[str, Any]] | None] = mapped_column(JSONB_TYPE, nullable=True)


sample_solution: Mapped[str | None] = mapped_column(Text, nullable=True)
time_limit_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=5000)
Expand Down
14 changes: 14 additions & 0 deletions backend/app/routers/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.config import settings
from app.database import get_db
from app.exceptions.common import BadRequestError, ForbiddenError, NotFoundError

from app.logger import get_logger
from app.models.application import Application
from app.models.interview import CustomInterview
Expand Down Expand Up @@ -91,6 +93,18 @@ async def apply_for_interview(
if existing_app_result.scalar_one_or_none():
raise BadRequestError("You have already applied for this interview")

# Validate file size before reading into memory
file_size = resume.size
if file_size is None:
await resume.seek(0, 2)
file_size = await resume.tell()
await resume.seek(0)

if file_size > settings.MAX_UPLOAD_SIZE:
raise BadRequestError(
f"File size exceeds the maximum allowed limit of {settings.MAX_UPLOAD_SIZE // (1024 * 1024)} MB"
)

file_bytes = await resume.read()

time_str = int(datetime.utcnow().timestamp())
Expand Down
2 changes: 2 additions & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ dependencies = [
"pypdf2>=3.0.1",
"taskiq>=0.11.0",
"taskiq-redis>=0.5.0",
"taskiq-aio-pika>=0.4",
"aio-pika>=9.2",
"python-multipart>=0.0.27",
"supabase>=2.30.0",
"authlib>=1.7.2",
Expand Down
9 changes: 9 additions & 0 deletions backend/setup.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[options]
packages = find:
include_package_data = true

[options.packages.find]
where = .
exclude =
tests*
docs*
4 changes: 4 additions & 0 deletions backend/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from setuptools import setup

if __name__ == "__main__":
setup()
1 change: 1 addition & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ async def create_tables() -> AsyncGenerator[None, None]:
"""Create all ORM tables once per test session, drop them afterwards."""
# Import all models so Base.metadata knows about them.
import app.models.application # noqa: F401
import app.models.dsa_question # noqa: F401
import app.models.interaction # noqa: F401
import app.models.interview # noqa: F401
import app.models.organization # noqa: F401
Expand Down
183 changes: 183 additions & 0 deletions backend/tests/test_routers/test_application.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import pytest
import uuid
from datetime import datetime, timedelta
from typing import Any
from unittest.mock import AsyncMock, patch
from httpx import AsyncClient
from app.config import settings

def _unique_name(prefix: str) -> str:
uid = uuid.uuid4().hex[:8]
return f"{prefix}_{uid}"

@pytest.mark.asyncio
class TestApplicationSubmit:
"""Integration tests for POST /applications/{interview_id} resume upload."""

async def _setup_users_and_interview(self, client: AsyncClient) -> tuple[dict[str, Any], dict[str, Any], int]:
"""Helper to create an organization, a candidate, and a valid interview."""
org_username = _unique_name("org")
org_email = f"{org_username}@example.com"

# 1. Sign up organization
org_res = await client.post(
"/organizations/signup",
json={
"username": org_username,
"password": "SecurePass123!",
"email": org_email
}
)
assert org_res.status_code == 201
org_token = org_res.json()["access_token"]

# 2. Create an interview
now = datetime.utcnow()
interview_res = await client.post(
"/interviews/",
headers={"Authorization": f"Bearer {org_token}"},
json={
"description": "Software Engineering role",
"position": "Backend Developer",
"experience": "Entry",
"submission_deadline": (now + timedelta(days=2)).isoformat() + "Z",
"start_time": (now + timedelta(days=3)).isoformat() + "Z",
"end_time": (now + timedelta(days=5)).isoformat() + "Z",
"duration": 45,
"dsa_score": 40,
"dev_score": 60,
"resume_shortlist_score": 0.0,
"ask_questions_on_resume": False,
"questions": [
{"question": "What is FastAPI?", "expected_answer": "A modern web framework."}
],
"dsa_topics": [
{"topic": "Arrays", "difficulty": "easy"}
]
}
)
assert interview_res.status_code == 201
interview_id = interview_res.json()["id"]

# 3. Sign up candidate
candidate_username = _unique_name("candidate")
candidate_email = f"{candidate_username}@example.com"
cand_res = await client.post(
"/users/signup",
json={
"username": candidate_username,
"password": "SecurePass123!",
"email": candidate_email
}
)
assert cand_res.status_code == 201
candidate_data = cand_res.json()

return org_res.json(), candidate_data, interview_id

async def test_apply_success(self, client: AsyncClient) -> None:
"""A normal user should be able to upload a valid resume and apply successfully."""
org_data, candidate_data, interview_id = await self._setup_users_and_interview(client)
token = candidate_data["token"]

# 100 bytes resume content
resume_content = b"a" * 100

# Mock process_resume_task in the background to avoid external tasks execution
with patch("app.routers.application.default_worker_provider") as mock_worker_prov:
mock_worker = mock_worker_prov.return_value
mock_worker.process_resume_task = AsyncMock()
response = await client.post(
f"/applications/{interview_id}",
headers={"Authorization": f"Bearer {token}"},
files={"resume": ("resume.pdf", resume_content, "application/pdf")}
)
assert response.status_code == 201
assert response.json()["status"] == "applied"
mock_worker.process_resume_task.assert_called_once()

async def test_apply_exceeds_size_limit(self, client: AsyncClient) -> None:
"""Uploading a file exceeding the configured MAX_UPLOAD_SIZE limit must return 400 Bad Request."""
org_data, candidate_data, interview_id = await self._setup_users_and_interview(client)
token = candidate_data["token"]

# Temporarily mock Settings.MAX_UPLOAD_SIZE to a very small size (10 bytes) for testing
with patch.object(settings, "MAX_UPLOAD_SIZE", 10):
# 50 bytes exceeds the 10 bytes limit
resume_content = b"a" * 50
response = await client.post(
f"/applications/{interview_id}",
headers={"Authorization": f"Bearer {token}"},
files={"resume": ("large_resume.pdf", resume_content, "application/pdf")}
)
assert response.status_code == 400
assert "File size exceeds the maximum allowed limit" in response.json()["detail"]

async def test_apply_with_exact_limit(self, client: AsyncClient) -> None:
"""Uploading a file with exact size as the configured MAX_UPLOAD_SIZE limit must succeed."""
org_data, candidate_data, interview_id = await self._setup_users_and_interview(client)
token = candidate_data["token"]

with patch.object(settings, "MAX_UPLOAD_SIZE", 20):
resume_content = b"a" * 20
with patch("app.routers.application.default_worker_provider") as mock_worker_prov:
mock_worker = mock_worker_prov.return_value
mock_worker.process_resume_task = AsyncMock()
response = await client.post(
f"/applications/{interview_id}",
headers={"Authorization": f"Bearer {token}"},
files={"resume": ("limit_resume.pdf", resume_content, "application/pdf")}
)
assert response.status_code == 201

async def test_apply_org_fails(self, client: AsyncClient) -> None:
"""Organizations must be rejected when trying to apply for interviews (403 Forbidden)."""
org_data, candidate_data, interview_id = await self._setup_users_and_interview(client)
org_token = org_data["access_token"]

resume_content = b"a" * 100
response = await client.post(
f"/applications/{interview_id}",
headers={"Authorization": f"Bearer {org_token}"},
files={"resume": ("resume.pdf", resume_content, "application/pdf")}
)
assert response.status_code == 403
assert "Organizations cannot apply for interviews" in response.json()["detail"]

async def test_apply_not_found(self, client: AsyncClient) -> None:
"""Applying for a non-existent interview must return 404 Not Found."""
org_data, candidate_data, _ = await self._setup_users_and_interview(client)
token = candidate_data["token"]

resume_content = b"a" * 100
response = await client.post(
"/applications/999999",
headers={"Authorization": f"Bearer {token}"},
files={"resume": ("resume.pdf", resume_content, "application/pdf")}
)
assert response.status_code == 404
assert "Interview not found" in response.json()["detail"]

async def test_apply_duplicate_fails(self, client: AsyncClient) -> None:
"""Applying for the same interview twice must be rejected with 400 Bad Request."""
org_data, candidate_data, interview_id = await self._setup_users_and_interview(client)
token = candidate_data["token"]

resume_content = b"a" * 100
with patch("app.routers.application.default_worker_provider") as mock_worker_prov:
mock_worker = mock_worker_prov.return_value
mock_worker.process_resume_task = AsyncMock()
response1 = await client.post(
f"/applications/{interview_id}",
headers={"Authorization": f"Bearer {token}"},
files={"resume": ("resume.pdf", resume_content, "application/pdf")}
)
assert response1.status_code == 201

response2 = await client.post(
f"/applications/{interview_id}",
headers={"Authorization": f"Bearer {token}"},
files={"resume": ("resume.pdf", resume_content, "application/pdf")}
)
assert response2.status_code == 400
assert "You have already applied for this interview" in response2.json()["detail"]
Loading