Phase 2: Job enqueueing from counter

- Add backend/jobs.py with enqueue_random_number_job function
- Modify counter increment endpoint to enqueue job after incrementing
- Add mock_enqueue_job fixture to conftest.py for all tests
- Add test_increment_enqueues_job_with_user_id to verify correct user_id
- Job is enqueued synchronously; failure causes request to fail
This commit is contained in:
counterweight 2025-12-21 22:44:31 +01:00
parent 10c0316603
commit 6ca0ae88dd
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
4 changed files with 98 additions and 2 deletions

39
backend/jobs.py Normal file
View file

@ -0,0 +1,39 @@
"""Job definitions and enqueueing utilities using pgqueuer."""
import json
import os
import asyncpg
from pgqueuer.queries import Queries
# Job type constants
JOB_RANDOM_NUMBER = "random_number"
# SQLAlchemy uses postgresql+asyncpg://, but asyncpg needs postgresql://
_raw_url = os.getenv(
"DATABASE_URL", "postgresql+asyncpg://postgres:postgres@localhost:5432/arbret"
)
DATABASE_URL = _raw_url.replace("postgresql+asyncpg://", "postgresql://")
async def enqueue_random_number_job(user_id: int) -> int:
"""
Enqueue a random number job for the given user.
Args:
user_id: The ID of the user who triggered the job.
Returns:
The job ID.
Raises:
Exception: If enqueueing fails.
"""
conn = await asyncpg.connect(DATABASE_URL)
try:
queries = Queries.from_asyncpg_connection(conn)
payload = json.dumps({"user_id": user_id}).encode()
job_ids = await queries.enqueue(JOB_RANDOM_NUMBER, payload)
return job_ids[0]
finally:
await conn.close()

View file

@ -1,11 +1,12 @@
"""Counter routes.""" """Counter routes."""
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from auth import require_permission from auth import require_permission
from database import get_db from database import get_db
from jobs import enqueue_random_number_job
from models import Counter, CounterRecord, Permission, User from models import Counter, CounterRecord, Permission, User
router = APIRouter(prefix="/api/counter", tags=["counter"]) router = APIRouter(prefix="/api/counter", tags=["counter"])
@ -38,7 +39,7 @@ async def increment_counter(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_permission(Permission.INCREMENT_COUNTER)), current_user: User = Depends(require_permission(Permission.INCREMENT_COUNTER)),
) -> dict[str, int]: ) -> dict[str, int]:
"""Increment the counter and record the action.""" """Increment the counter, record the action, and enqueue a random number job."""
counter = await get_or_create_counter(db) counter = await get_or_create_counter(db)
value_before = counter.value value_before = counter.value
counter.value += 1 counter.value += 1
@ -49,5 +50,15 @@ async def increment_counter(
value_after=counter.value, value_after=counter.value,
) )
db.add(record) db.add(record)
# Enqueue random number job - if this fails, the request fails
try:
await enqueue_random_number_job(current_user.id)
except Exception as e:
await db.rollback()
raise HTTPException(
status_code=500, detail=f"Failed to enqueue job: {e}"
) from e
await db.commit() await db.commit()
return {"value": counter.value} return {"value": counter.value}

View file

@ -1,5 +1,6 @@
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, patch
# Set required env vars before importing app # Set required env vars before importing app
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing-only") os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing-only")
@ -238,3 +239,16 @@ async def user_no_roles(client_factory):
"cookies": dict(response.cookies), "cookies": dict(response.cookies),
"response": response, "response": response,
} }
@pytest.fixture(autouse=True)
def mock_enqueue_job():
"""Mock job enqueueing for all tests.
pgqueuer requires PostgreSQL-specific features that aren't available
in the test database setup. We mock the enqueue function to avoid
connection issues while still testing the counter logic.
"""
mock = AsyncMock(return_value=1) # Return a fake job ID
with patch("routes.counter.enqueue_random_number_job", mock):
yield mock

View file

@ -11,6 +11,38 @@ from tests.conftest import create_user_with_roles
from tests.helpers import create_invite_for_godfather, unique_email from tests.helpers import create_invite_for_godfather, unique_email
@pytest.mark.asyncio
async def test_increment_enqueues_job_with_user_id(client_factory, mock_enqueue_job):
"""Verify that incrementing the counter enqueues a job with the user's ID."""
async with client_factory.get_db_session() as db:
godfather = await create_user_with_roles(
db, unique_email("gf"), "pass123", [ROLE_REGULAR]
)
invite_code = await create_invite_for_godfather(db, godfather.id)
reg = await client_factory.post(
"/api/auth/register",
json={
"email": unique_email(),
"password": "testpass123",
"invite_identifier": invite_code,
},
)
cookies = dict(reg.cookies)
# Get user ID from the me endpoint
async with client_factory.create(cookies=cookies) as authed:
me_response = await authed.get("/api/auth/me")
user_id = me_response.json()["id"]
# Increment counter
response = await authed.post("/api/counter/increment")
assert response.status_code == 200
# Verify enqueue was called with the correct user_id
mock_enqueue_job.assert_called_once_with(user_id)
# Protected endpoint tests - without auth # Protected endpoint tests - without auth
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_counter_requires_auth(client): async def test_get_counter_requires_auth(client):