Phase 0.1: Remove backend deprecated code

- Delete routes: counter.py, sum.py
- Delete jobs.py and worker.py
- Delete tests: test_counter.py, test_jobs.py
- Update audit.py: keep only price-history endpoints
- Update models.py: remove VIEW_COUNTER, INCREMENT_COUNTER, USE_SUM permissions
- Update models.py: remove Counter, SumRecord, CounterRecord, RandomNumberOutcome models
- Update schemas.py: remove sum/counter related schemas
- Update main.py: remove deleted router imports
- Update test_permissions.py: remove tests for deprecated features
- Update test_price_history.py: remove worker-related tests
- Update conftest.py: remove mock_enqueue_job fixture
- Update auth.py: fix example in docstring
This commit is contained in:
counterweight 2025-12-22 18:07:14 +01:00
parent ea85198171
commit 5bad1e7e17
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
14 changed files with 35 additions and 1393 deletions

View file

@ -1,6 +1,5 @@
import os
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, patch
# Set required env vars before importing app
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing-only")
@ -239,18 +238,3 @@ async def user_no_roles(client_factory):
"cookies": dict(response.cookies),
"response": response,
}
@pytest.fixture
def mock_enqueue_job():
"""Mock job enqueueing for tests that hit the counter increment endpoint.
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.
Tests that call POST /api/counter/increment must use this fixture.
"""
mock = AsyncMock(return_value=1) # Return a fake job ID
with patch("routes.counter.enqueue_random_number_job", mock):
yield mock

View file

@ -1,239 +0,0 @@
"""Tests for counter endpoints.
Note: Registration now requires an invite code.
"""
import pytest
from auth import COOKIE_NAME
from models import ROLE_REGULAR
from tests.conftest import create_user_with_roles
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
@pytest.mark.asyncio
async def test_get_counter_requires_auth(client):
response = await client.get("/api/counter")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_increment_counter_requires_auth(client):
response = await client.post("/api/counter/increment")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_get_counter_invalid_cookie(client_factory):
async with client_factory.create(cookies={COOKIE_NAME: "invalidtoken"}) as authed:
response = await authed.get("/api/counter")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_increment_counter_invalid_cookie(client_factory):
async with client_factory.create(cookies={COOKIE_NAME: "invalidtoken"}) as authed:
response = await authed.post("/api/counter/increment")
assert response.status_code == 401
# Authenticated counter tests
@pytest.mark.asyncio
async def test_get_counter_authenticated(client_factory):
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)
async with client_factory.create(cookies=cookies) as authed:
response = await authed.get("/api/counter")
assert response.status_code == 200
assert "value" in response.json()
@pytest.mark.asyncio
async def test_increment_counter(client_factory, mock_enqueue_job):
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)
async with client_factory.create(cookies=cookies) as authed:
# Get current value
before = await authed.get("/api/counter")
before_value = before.json()["value"]
# Increment
response = await authed.post("/api/counter/increment")
assert response.status_code == 200
assert response.json()["value"] == before_value + 1
@pytest.mark.asyncio
async def test_increment_counter_multiple(client_factory, mock_enqueue_job):
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)
async with client_factory.create(cookies=cookies) as authed:
# Get starting value
before = await authed.get("/api/counter")
start = before.json()["value"]
# Increment 3 times
await authed.post("/api/counter/increment")
await authed.post("/api/counter/increment")
response = await authed.post("/api/counter/increment")
assert response.json()["value"] == start + 3
@pytest.mark.asyncio
async def test_get_counter_after_increment(client_factory, mock_enqueue_job):
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)
async with client_factory.create(cookies=cookies) as authed:
before = await authed.get("/api/counter")
start = before.json()["value"]
await authed.post("/api/counter/increment")
await authed.post("/api/counter/increment")
response = await authed.get("/api/counter")
assert response.json()["value"] == start + 2
# Counter is shared between users
@pytest.mark.asyncio
async def test_counter_shared_between_users(client_factory, mock_enqueue_job):
# Create godfather and invites for two users
async with client_factory.get_db_session() as db:
godfather = await create_user_with_roles(
db, unique_email("gf"), "pass123", [ROLE_REGULAR]
)
invite1 = await create_invite_for_godfather(db, godfather.id)
invite2 = await create_invite_for_godfather(db, godfather.id)
# Create first user
reg1 = await client_factory.post(
"/api/auth/register",
json={
"email": unique_email("share1"),
"password": "testpass123",
"invite_identifier": invite1,
},
)
cookies1 = dict(reg1.cookies)
async with client_factory.create(cookies=cookies1) as user1:
# Get starting value
before = await user1.get("/api/counter")
start = before.json()["value"]
await user1.post("/api/counter/increment")
await user1.post("/api/counter/increment")
# Create second user - should see the increments
reg2 = await client_factory.post(
"/api/auth/register",
json={
"email": unique_email("share2"),
"password": "testpass123",
"invite_identifier": invite2,
},
)
cookies2 = dict(reg2.cookies)
async with client_factory.create(cookies=cookies2) as user2:
response = await user2.get("/api/counter")
assert response.json()["value"] == start + 2
# Second user increments
await user2.post("/api/counter/increment")
# First user sees the increment
async with client_factory.create(cookies=cookies1) as user1:
response = await user1.get("/api/counter")
assert response.json()["value"] == start + 3

View file

@ -1,176 +0,0 @@
"""Tests for job handler logic."""
import json
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock
import pytest
from worker import process_random_number_job
def create_mock_pool(mock_conn: AsyncMock) -> MagicMock:
"""Create a mock asyncpg pool with proper async context manager behavior."""
mock_pool = MagicMock()
@asynccontextmanager
async def mock_acquire():
yield mock_conn
mock_pool.acquire = mock_acquire
return mock_pool
class TestRandomNumberJobHandler:
"""Tests for the random number job handler logic."""
@pytest.mark.asyncio
async def test_generates_random_number_in_range(self):
"""Verify random number is in range [0, 100]."""
# Create mock job
job = MagicMock()
job.id = 123
job.payload = json.dumps({"user_id": 1}).encode()
# Create mock db pool
mock_conn = AsyncMock()
mock_pool = create_mock_pool(mock_conn)
# Run the job handler
await process_random_number_job(job, mock_pool)
# Verify execute was called
mock_conn.execute.assert_called_once()
call_args = mock_conn.execute.call_args
# Extract the value argument (position 3 in the args)
# Args: (query, job_id, user_id, value, duration_ms, status)
value = call_args[0][3]
assert 0 <= value <= 100, f"Value {value} is not in range [0, 100]"
@pytest.mark.asyncio
async def test_stores_correct_user_id(self):
"""Verify the correct user_id is stored in the outcome."""
user_id = 42
job = MagicMock()
job.id = 123
job.payload = json.dumps({"user_id": user_id}).encode()
mock_conn = AsyncMock()
mock_pool = create_mock_pool(mock_conn)
await process_random_number_job(job, mock_pool)
mock_conn.execute.assert_called_once()
call_args = mock_conn.execute.call_args
# Args: (query, job_id, user_id, value, duration_ms, status)
stored_user_id = call_args[0][2]
assert stored_user_id == user_id
@pytest.mark.asyncio
async def test_stores_job_id(self):
"""Verify the job_id is stored in the outcome."""
job_id = 456
job = MagicMock()
job.id = job_id
job.payload = json.dumps({"user_id": 1}).encode()
mock_conn = AsyncMock()
mock_pool = create_mock_pool(mock_conn)
await process_random_number_job(job, mock_pool)
mock_conn.execute.assert_called_once()
call_args = mock_conn.execute.call_args
# Args: (query, job_id, user_id, value, duration_ms, status)
stored_job_id = call_args[0][1]
assert stored_job_id == job_id
@pytest.mark.asyncio
async def test_stores_status_completed(self):
"""Verify the status is set to 'completed'."""
job = MagicMock()
job.id = 123
job.payload = json.dumps({"user_id": 1}).encode()
mock_conn = AsyncMock()
mock_pool = create_mock_pool(mock_conn)
await process_random_number_job(job, mock_pool)
mock_conn.execute.assert_called_once()
call_args = mock_conn.execute.call_args
# Args: (query, job_id, user_id, value, duration_ms, status)
status = call_args[0][5]
assert status == "completed"
@pytest.mark.asyncio
async def test_records_duration_ms(self):
"""Verify duration_ms is recorded (should be >= 0)."""
job = MagicMock()
job.id = 123
job.payload = json.dumps({"user_id": 1}).encode()
mock_conn = AsyncMock()
mock_pool = create_mock_pool(mock_conn)
await process_random_number_job(job, mock_pool)
mock_conn.execute.assert_called_once()
call_args = mock_conn.execute.call_args
# Args: (query, job_id, user_id, value, duration_ms, status)
duration_ms = call_args[0][4]
assert isinstance(duration_ms, int)
assert duration_ms >= 0
@pytest.mark.asyncio
async def test_missing_user_id_does_not_insert(self):
"""Verify no insert happens if user_id is missing from payload."""
job = MagicMock()
job.id = 123
job.payload = json.dumps({}).encode() # Missing user_id
mock_conn = AsyncMock()
mock_pool = create_mock_pool(mock_conn)
await process_random_number_job(job, mock_pool)
# Should not have called execute
mock_conn.execute.assert_not_called()
@pytest.mark.asyncio
async def test_empty_payload_does_not_insert(self):
"""Verify no insert happens with empty payload."""
job = MagicMock()
job.id = 123
job.payload = None
mock_conn = AsyncMock()
mock_pool = create_mock_pool(mock_conn)
await process_random_number_job(job, mock_pool)
# Should not have called execute
mock_conn.execute.assert_not_called()
@pytest.mark.asyncio
async def test_malformed_json_payload_does_not_insert(self):
"""Verify no insert happens with malformed JSON payload."""
job = MagicMock()
job.id = 123
job.payload = b"not valid json {"
mock_conn = AsyncMock()
mock_pool = create_mock_pool(mock_conn)
await process_random_number_job(job, mock_pool)
# Should not have called execute
mock_conn.execute.assert_not_called()

View file

@ -50,10 +50,10 @@ class TestRoleAssignment:
data = response.json()
permissions = data["permissions"]
# Should have counter and sum permissions
assert Permission.VIEW_COUNTER.value in permissions
assert Permission.INCREMENT_COUNTER.value in permissions
assert Permission.USE_SUM.value in permissions
# Should have profile and booking permissions
assert Permission.MANAGE_OWN_PROFILE.value in permissions
assert Permission.BOOK_APPOINTMENT.value in permissions
assert Permission.VIEW_OWN_APPOINTMENTS.value in permissions
# Should NOT have audit permission
assert Permission.VIEW_AUDIT.value not in permissions
@ -69,10 +69,8 @@ class TestRoleAssignment:
# Should have audit permission
assert Permission.VIEW_AUDIT.value in permissions
# Should NOT have counter/sum permissions
assert Permission.VIEW_COUNTER.value not in permissions
assert Permission.INCREMENT_COUNTER.value not in permissions
assert Permission.USE_SUM.value not in permissions
# Should NOT have booking permissions (those are for regular users)
assert Permission.BOOK_APPOINTMENT.value not in permissions
@pytest.mark.asyncio
async def test_user_with_no_roles_has_no_permissions(
@ -86,124 +84,6 @@ class TestRoleAssignment:
assert data["permissions"] == []
# =============================================================================
# Counter Endpoint Access Tests
# =============================================================================
class TestCounterAccess:
"""Test access control for counter endpoints."""
@pytest.mark.asyncio
async def test_regular_user_can_view_counter(self, client_factory, regular_user):
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.get("/api/counter")
assert response.status_code == 200
assert "value" in response.json()
@pytest.mark.asyncio
async def test_regular_user_can_increment_counter(
self, client_factory, regular_user, mock_enqueue_job
):
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post("/api/counter/increment")
assert response.status_code == 200
assert "value" in response.json()
@pytest.mark.asyncio
async def test_admin_cannot_view_counter(self, client_factory, admin_user):
"""Admin users should be forbidden from counter endpoints."""
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.get("/api/counter")
assert response.status_code == 403
assert "permission" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_admin_cannot_increment_counter(self, client_factory, admin_user):
"""Admin users should be forbidden from incrementing counter."""
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.post("/api/counter/increment")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_user_without_roles_cannot_view_counter(
self, client_factory, user_no_roles
):
"""Users with no roles should be forbidden."""
async with client_factory.create(cookies=user_no_roles["cookies"]) as client:
response = await client.get("/api/counter")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_unauthenticated_cannot_view_counter(self, client):
"""Unauthenticated requests should get 401."""
response = await client.get("/api/counter")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_unauthenticated_cannot_increment_counter(self, client):
"""Unauthenticated requests should get 401."""
response = await client.post("/api/counter/increment")
assert response.status_code == 401
# =============================================================================
# Sum Endpoint Access Tests
# =============================================================================
class TestSumAccess:
"""Test access control for sum endpoint."""
@pytest.mark.asyncio
async def test_regular_user_can_use_sum(self, client_factory, regular_user):
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post(
"/api/sum",
json={"a": 5, "b": 3},
)
assert response.status_code == 200
data = response.json()
assert data["result"] == 8
@pytest.mark.asyncio
async def test_admin_cannot_use_sum(self, client_factory, admin_user):
"""Admin users should be forbidden from sum endpoint."""
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.post(
"/api/sum",
json={"a": 5, "b": 3},
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_user_without_roles_cannot_use_sum(
self, client_factory, user_no_roles
):
async with client_factory.create(cookies=user_no_roles["cookies"]) as client:
response = await client.post(
"/api/sum",
json={"a": 5, "b": 3},
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_unauthenticated_cannot_use_sum(self, client):
response = await client.post(
"/api/sum",
json={"a": 5, "b": 3},
)
assert response.status_code == 401
# =============================================================================
# Audit Endpoint Access Tests
# =============================================================================
@ -213,89 +93,37 @@ class TestAuditAccess:
"""Test access control for audit endpoints."""
@pytest.mark.asyncio
async def test_admin_can_view_counter_audit(self, client_factory, admin_user):
async def test_admin_can_view_price_history(self, client_factory, admin_user):
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.get("/api/audit/counter")
response = await client.get("/api/audit/price-history")
assert response.status_code == 200
data = response.json()
assert "records" in data
assert "total" in data
# Returns a list
assert isinstance(response.json(), list)
@pytest.mark.asyncio
async def test_admin_can_view_sum_audit(self, client_factory, admin_user):
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.get("/api/audit/sum")
assert response.status_code == 200
data = response.json()
assert "records" in data
assert "total" in data
@pytest.mark.asyncio
async def test_regular_user_cannot_view_counter_audit(
async def test_regular_user_cannot_view_price_history(
self, client_factory, regular_user
):
"""Regular users should be forbidden from audit endpoints."""
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.get("/api/audit/counter")
response = await client.get("/api/audit/price-history")
assert response.status_code == 403
assert "permission" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_regular_user_cannot_view_sum_audit(
self, client_factory, regular_user
):
"""Regular users should be forbidden from audit endpoints."""
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.get("/api/audit/sum")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_user_without_roles_cannot_view_audit(
self, client_factory, user_no_roles
):
async with client_factory.create(cookies=user_no_roles["cookies"]) as client:
response = await client.get("/api/audit/counter")
response = await client.get("/api/audit/price-history")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_unauthenticated_cannot_view_counter_audit(self, client):
response = await client.get("/api/audit/counter")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_unauthenticated_cannot_view_sum_audit(self, client):
response = await client.get("/api/audit/sum")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_admin_can_view_random_jobs(self, client_factory, admin_user):
"""Admin should be able to view random job outcomes."""
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.get("/api/audit/random-jobs")
assert response.status_code == 200
# Returns a list (no pagination)
assert isinstance(response.json(), list)
@pytest.mark.asyncio
async def test_regular_user_cannot_view_random_jobs(
self, client_factory, regular_user
):
"""Regular users should be forbidden from random-jobs endpoint."""
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.get("/api/audit/random-jobs")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_unauthenticated_cannot_view_random_jobs(self, client):
"""Unauthenticated users should get 401."""
response = await client.get("/api/audit/random-jobs")
async def test_unauthenticated_cannot_view_price_history(self, client):
response = await client.get("/api/audit/price-history")
assert response.status_code == 401
@ -320,18 +148,18 @@ class TestSecurityBypassAttempts:
"""
# Regular user tries to access audit endpoint
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.get("/api/audit/counter")
response = await client.get("/api/audit/price-history")
# Should be denied regardless of any manipulation attempts
assert response.status_code == 403
@pytest.mark.asyncio
async def test_cannot_access_counter_with_expired_session(self, client_factory):
async def test_cannot_access_with_expired_session(self, client_factory):
"""Test that invalid/expired tokens are rejected."""
fake_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5OTk5IiwiZXhwIjoxfQ.invalid"
async with client_factory.create(cookies={"auth_token": fake_token}) as client:
response = await client.get("/api/counter")
response = await client.get("/api/profile")
assert response.status_code == 401
@ -348,7 +176,7 @@ class TestSecurityBypassAttempts:
async with client_factory.create(
cookies={"auth_token": tampered_token}
) as client:
response = await client.get("/api/counter")
response = await client.get("/api/profile")
assert response.status_code == 401
@ -386,7 +214,7 @@ class TestSecurityBypassAttempts:
# Try to access audit with this new user
async with client_factory.create(cookies=dict(response.cookies)) as client:
audit_response = await client.get("/api/audit/counter")
audit_response = await client.get("/api/audit/price-history")
assert audit_response.status_code == 403
@ -452,10 +280,10 @@ class TestSecurityBypassAttempts:
)
cookies = dict(login_response.cookies)
# Verify can access counter but not audit
# Verify can access profile but not audit
async with client_factory.create(cookies=cookies) as client:
assert (await client.get("/api/counter")).status_code == 200
assert (await client.get("/api/audit/counter")).status_code == 403
assert (await client.get("/api/profile")).status_code == 200
assert (await client.get("/api/audit/price-history")).status_code == 403
# Change user's role from regular to admin
async with client_factory.get_db_session() as db:
@ -468,62 +296,7 @@ class TestSecurityBypassAttempts:
user.roles = [admin_role] # Replace roles with admin only
await db.commit()
# Now should have audit access but not counter access
# Now should have audit access but not profile access (admin doesn't have MANAGE_OWN_PROFILE)
async with client_factory.create(cookies=cookies) as client:
assert (await client.get("/api/audit/counter")).status_code == 200
assert (await client.get("/api/counter")).status_code == 403
# =============================================================================
# Audit Record Tests
# =============================================================================
class TestAuditRecords:
"""Test that actions are properly recorded in audit logs."""
@pytest.mark.asyncio
async def test_counter_increment_creates_audit_record(
self, client_factory, regular_user, admin_user, mock_enqueue_job
):
"""Verify that counter increments are recorded and visible in audit."""
# Regular user increments counter
async with client_factory.create(cookies=regular_user["cookies"]) as client:
await client.post("/api/counter/increment")
# Admin checks audit
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.get("/api/audit/counter")
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
# Find record for our user
records = data["records"]
user_records = [r for r in records if r["user_email"] == regular_user["email"]]
assert len(user_records) >= 1
@pytest.mark.asyncio
async def test_sum_operation_creates_audit_record(
self, client_factory, regular_user, admin_user
):
"""Verify that sum operations are recorded and visible in audit."""
# Regular user uses sum
async with client_factory.create(cookies=regular_user["cookies"]) as client:
await client.post("/api/sum", json={"a": 10, "b": 20})
# Admin checks audit
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.get("/api/audit/sum")
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
# Find record with our values
records = data["records"]
matching = [
r for r in records if r["a"] == 10 and r["b"] == 20 and r["result"] == 30
]
assert len(matching) >= 1
assert (await client.get("/api/audit/price-history")).status_code == 200
assert (await client.get("/api/profile")).status_code == 403

View file

@ -1,6 +1,5 @@
"""Tests for price history feature."""
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
@ -9,7 +8,6 @@ import pytest
from models import PriceHistory
from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price
from worker import process_bitcoin_price_job
def create_mock_httpx_client(
@ -293,76 +291,3 @@ class TestManualFetch:
data = response.json()
assert data["id"] == existing_id
assert data["price"] == 90000.0 # Original price, not the new one
def create_mock_pool(mock_conn: AsyncMock) -> MagicMock:
"""Create a mock asyncpg pool with proper async context manager behavior."""
mock_pool = MagicMock()
@asynccontextmanager
async def mock_acquire():
yield mock_conn
mock_pool.acquire = mock_acquire
return mock_pool
class TestProcessBitcoinPriceJob:
"""Tests for the scheduled Bitcoin price job handler."""
@pytest.mark.asyncio
async def test_stores_price_on_success(self):
"""Verify price is stored in database on successful fetch."""
mock_http_client = create_mock_httpx_client(
json_response=create_bitfinex_ticker_response(95000.0)
)
mock_conn = AsyncMock()
mock_pool = create_mock_pool(mock_conn)
with patch("price_fetcher.httpx.AsyncClient", return_value=mock_http_client):
await process_bitcoin_price_job(mock_pool)
# Verify execute was called with correct values
mock_conn.execute.assert_called_once()
call_args = mock_conn.execute.call_args
# Check the SQL parameters
assert call_args[0][1] == SOURCE_BITFINEX # source
assert call_args[0][2] == PAIR_BTC_EUR # pair
assert call_args[0][3] == 95000.0 # price
@pytest.mark.asyncio
async def test_fails_silently_on_api_error(self):
"""Verify no exception is raised and no DB insert on API error."""
import httpx
error = httpx.HTTPStatusError(
"Server Error", request=MagicMock(), response=MagicMock()
)
mock_http_client = create_mock_httpx_client(raise_for_status_error=error)
mock_conn = AsyncMock()
mock_pool = create_mock_pool(mock_conn)
with patch("price_fetcher.httpx.AsyncClient", return_value=mock_http_client):
# Should not raise an exception
await process_bitcoin_price_job(mock_pool)
# Should not have called execute
mock_conn.execute.assert_not_called()
@pytest.mark.asyncio
async def test_fails_silently_on_db_error(self):
"""Verify no exception is raised on database error."""
mock_http_client = create_mock_httpx_client(
json_response=create_bitfinex_ticker_response(95000.0)
)
mock_conn = AsyncMock()
mock_conn.execute.side_effect = Exception("Database connection error")
mock_pool = create_mock_pool(mock_conn)
with patch("price_fetcher.httpx.AsyncClient", return_value=mock_http_client):
# Should not raise an exception
await process_bitcoin_price_job(mock_pool)