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:
parent
10c0316603
commit
6ca0ae88dd
4 changed files with 98 additions and 2 deletions
39
backend/jobs.py
Normal file
39
backend/jobs.py
Normal 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()
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue