implemented pr feedback #2

This commit is contained in:
behrinml
2026-08-05 21:25:46 +02:00
parent 43bf854bd5
commit 9a397f46e9
30 changed files with 1035 additions and 259 deletions
+84 -18
View File
@@ -7,8 +7,10 @@ from sqlalchemy import select
from backend.app.core.auth import get_password_hash
from backend.app.models.archive import PrintArchive
from backend.app.models.finance import CostCenter, UserWallet, WalletTransaction
from backend.app.models.group import Group
from backend.app.models.settings import Settings
from backend.app.models.user import User
from backend.app.services.finance_billing import apply_print_charge_for_archive
class TestFinanceAPI:
@@ -143,34 +145,30 @@ class TestFinanceAPI:
admin_user,
db_session,
):
"""Test cost-center and personal transaction handling.
Cost-center transactions affect cost-center balance only.
Personal transactions (no cost_center_id) affect user wallet.
"""
"""A user's private cost center and unassigned entries share one wallet."""
await self._enable_basic_user_creation(db_session)
created_user = await self._create_user_via_api(async_client, auth_headers, "dave")
shared_center = await db_session.scalar(
private_center = await db_session.scalar(
select(CostCenter).where(CostCenter.owner_user_id == created_user["id"], CostCenter.is_private.is_(True))
)
assert shared_center is not None
assert private_center is not None
# Deposit to cost center: affects cost-center balance, not user wallet
# The owner's private cost center affects both its own ledger and the wallet.
deposit = await async_client.post(
f"/api/v1/finance/users/{created_user['id']}/deposit",
json={"amount": 25.0, "description": "Initial CC top-up", "cost_center_id": shared_center.id},
json={"amount": 25.0, "description": "Initial CC top-up", "cost_center_id": private_center.id},
headers=auth_headers,
)
assert deposit.status_code == 200
assert deposit.json()["transaction"]["cost_center_id"] == shared_center.id
assert deposit.json()["transaction"]["cost_center_id"] == private_center.id
assert deposit.json()["transaction"]["balance_after"] == 25.0 # CC balance
assert deposit.json()["balance"]["balance"] == 25.0 # Response shows CC balance
# Withdraw from cost center: affects cost-center balance only
# A withdrawal updates both views by the same amount.
withdraw = await async_client.post(
f"/api/v1/finance/users/{created_user['id']}/withdraw",
json={"amount": 5.0, "description": "CC Usage", "cost_center_id": shared_center.id},
json={"amount": 5.0, "description": "CC Usage", "cost_center_id": private_center.id},
headers=auth_headers,
)
assert withdraw.status_code == 200
@@ -186,8 +184,8 @@ class TestFinanceAPI:
)
assert personal_deposit.status_code == 200
assert personal_deposit.json()["transaction"]["cost_center_id"] is None
assert personal_deposit.json()["transaction"]["balance_after"] == 30.0 # Personal balance
assert personal_deposit.json()["balance"]["balance"] == 30.0 # User wallet updated
assert personal_deposit.json()["transaction"]["balance_after"] == 50.0
assert personal_deposit.json()["balance"]["balance"] == 50.0
transactions_response = await async_client.get(
f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
@@ -195,7 +193,7 @@ class TestFinanceAPI:
assert transactions_response.status_code == 200
transactions = transactions_response.json()
assert len(transactions) == 3
cc_txs = [tx for tx in transactions if tx["cost_center_id"] == shared_center.id]
cc_txs = [tx for tx in transactions if tx["cost_center_id"] == private_center.id]
personal_txs = [tx for tx in transactions if tx["cost_center_id"] is None]
assert len(cc_txs) == 2
assert len(personal_txs) == 1
@@ -204,7 +202,7 @@ class TestFinanceAPI:
f"/api/v1/finance/users/{created_user['id']}/balance", headers=auth_headers
)
assert balance_response.status_code == 200
assert balance_response.json()["balance"] == 30.0 # Only personal balance
assert balance_response.json()["balance"] == 50.0
# Delete personal transaction, user wallet should decrease
personal_tx = next(tx for tx in transactions if tx["cost_center_id"] is None)
@@ -217,7 +215,7 @@ class TestFinanceAPI:
f"/api/v1/finance/users/{created_user['id']}/balance", headers=auth_headers
)
assert balance_after_delete.status_code == 200
assert balance_after_delete.json()["balance"] == 0.0 # Personal balance back to 0
assert balance_after_delete.json()["balance"] == 20.0
remaining = await async_client.get(
f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
@@ -225,6 +223,74 @@ class TestFinanceAPI:
assert remaining.status_code == 200
assert len(remaining.json()) == 2 # 2 CC transactions remain
@pytest.mark.asyncio
@pytest.mark.integration
async def test_admin_and_user_balances_agree_after_charge_adjustment_and_delete(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
created_user = await self._create_user_via_api(async_client, auth_headers, "balance-lifecycle")
user = await db_session.get(User, created_user["id"])
operators = await db_session.scalar(select(Group).where(Group.name == "Operators"))
assert operators is not None
user.groups.append(operators)
await db_session.commit()
user_headers = await self._login_user(async_client, "balance-lifecycle")
private_center = await db_session.scalar(
select(CostCenter).where(CostCenter.owner_user_id == user.id, CostCenter.is_private.is_(True))
)
assert private_center is not None
deposit = await async_client.post(
f"/api/v1/finance/users/{user.id}/deposit",
json={"amount": 20.0, "cost_center_id": private_center.id},
headers=auth_headers,
)
assert deposit.status_code == 200
archive = PrintArchive(
filename="charged.gcode.3mf",
file_path="archives/test/charged.gcode.3mf",
file_size=10,
status="completed",
cost=4.0,
created_by_id=user.id,
cost_center_id=private_center.id,
)
db_session.add(archive)
await db_session.commit()
assert await apply_print_charge_for_archive(db_session, archive.id) is True
await db_session.commit()
adjustment = await async_client.post(
f"/api/v1/finance/users/{user.id}/deposit",
json={"amount": 5.0, "description": "temporary adjustment"},
headers=auth_headers,
)
assert adjustment.status_code == 200
async def assert_views_agree(expected: float):
admin_view = await async_client.get(
f"/api/v1/finance/users/{user.id}/balance",
headers=auth_headers,
)
user_view = await async_client.get("/api/v1/finance/me/balance", headers=user_headers)
assert admin_view.status_code == 200
assert user_view.status_code == 200
assert admin_view.json()["balance"] == expected
assert user_view.json()["balance"] == expected
await assert_views_agree(21.0)
delete_response = await async_client.delete(
f"/api/v1/finance/transactions/{adjustment.json()['transaction']['id']}",
headers=auth_headers,
)
assert delete_response.status_code == 200
await assert_views_agree(16.0)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_cost_center_transaction_rebuilds_remaining_ledger(
@@ -283,7 +349,7 @@ class TestFinanceAPI:
f"/api/v1/finance/users/{created_user['id']}/balance", headers=auth_headers
)
assert balance_response.status_code == 200
assert balance_response.json()["balance"] == 12.0
assert balance_response.json()["balance"] == 7.0
@pytest.mark.asyncio
@pytest.mark.integration
@@ -146,7 +146,7 @@ class TestPrintQueueAPI:
"""Verify item can be added to queue with cost_center_id."""
await enable_billing(db_session)
printer = await printer_factory()
archive = await archive_factory()
archive = await archive_factory(cost=1.25, filament_used_grams=50.0)
cost_center = CostCenter(name="Queue CC", is_active=True, is_private=False)
db_session.add(cost_center)
await db_session.commit()
@@ -158,7 +158,7 @@ class TestPrintQueueAPI:
"printer_id": printer.id,
"archive_id": archive.id,
"cost_center_id": cost_center.id,
"estimated_cost": 1.25,
"estimated_cost": 0.01,
},
)
assert response.status_code == 200
@@ -175,13 +175,13 @@ class TestPrintQueueAPI:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_to_queue_with_cost_center_requires_estimated_cost(
async def test_add_to_queue_derives_cost_without_client_estimate(
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
):
"""Cost-center queue items require an estimated cost for budget checks."""
"""The persisted budget estimate comes from the archive, not the request."""
await enable_billing(db_session)
printer = await printer_factory()
archive = await archive_factory()
archive = await archive_factory(cost=1.25, filament_used_grams=50.0)
cost_center = CostCenter(name="Budget CC", is_active=True, is_private=False, monthly_budget=10.0)
db_session.add(cost_center)
await db_session.commit()
@@ -196,18 +196,18 @@ class TestPrintQueueAPI:
},
)
assert response.status_code == 400
assert "Estimated cost is required" in response.json()["detail"]
assert response.status_code == 200
assert response.json()["estimated_cost"] == 1.25
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_to_queue_rejects_when_estimated_cost_exceeds_budget(
async def test_add_to_queue_rejects_tampered_client_cost_when_server_cost_exceeds_budget(
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
):
"""Queue creation is rejected if the estimated cost exceeds remaining budget."""
"""A forged low client hint cannot bypass the server-derived budget check."""
await enable_billing(db_session)
printer = await printer_factory()
archive = await archive_factory()
archive = await archive_factory(cost=2.0, filament_used_grams=50.0)
cost_center = CostCenter(name="Tiny Budget CC", is_active=True, is_private=False, monthly_budget=1.0)
db_session.add(cost_center)
await db_session.commit()
@@ -219,7 +219,7 @@ class TestPrintQueueAPI:
"printer_id": printer.id,
"archive_id": archive.id,
"cost_center_id": cost_center.id,
"estimated_cost": 2.0,
"estimated_cost": 0.01,
},
)
@@ -234,7 +234,7 @@ class TestPrintQueueAPI:
"""Billing enforcement rejects queue jobs that omit cost center."""
await enable_billing(db_session)
printer = await printer_factory()
archive = await archive_factory()
archive = await archive_factory(cost=1.0, filament_used_grams=50.0)
response = await async_client.post(
"/api/v1/queue/",
@@ -255,7 +255,7 @@ class TestPrintQueueAPI:
"""Open queue items reserve budget until they leave pending/printing states."""
await enable_billing(db_session)
printer = await printer_factory()
archive = await archive_factory()
archive = await archive_factory(cost=3.0, filament_used_grams=50.0)
cost_center = CostCenter(name="Reserved Budget CC", is_active=True, is_private=False, monthly_budget=10.0)
db_session.add(cost_center)
await db_session.commit()
@@ -274,7 +274,7 @@ class TestPrintQueueAPI:
"printer_id": printer.id,
"archive_id": archive.id,
"cost_center_id": cost_center.id,
"estimated_cost": 3.0,
"estimated_cost": 0.01,
},
)
@@ -345,7 +345,7 @@ class TestPrintQueueAPI:
"""Verify a pending queue item can be updated with cost_center_id."""
await enable_billing(db_session)
printer = await printer_factory()
archive = await archive_factory()
archive = await archive_factory(cost=1.25, filament_used_grams=50.0)
item = await queue_item_factory(printer_id=printer.id, archive_id=archive.id)
cost_center = CostCenter(name="Update CC", is_active=True, is_private=False)
db_session.add(cost_center)
@@ -354,7 +354,7 @@ class TestPrintQueueAPI:
response = await async_client.patch(
f"/api/v1/queue/{item.id}",
json={"cost_center_id": cost_center.id, "estimated_cost": 1.25},
json={"cost_center_id": cost_center.id, "estimated_cost": 0.01},
)
assert response.status_code == 200
@@ -822,7 +822,7 @@ class TestPrintQueueAPI:
"""Deleting a pending cost-center queue item releases its reserved budget."""
await enable_billing(db_session)
printer = await printer_factory()
archive = await archive_factory()
archive = await archive_factory(cost=3.0, filament_used_grams=50.0)
cost_center = CostCenter(
name="Delete Releases Budget CC", is_active=True, is_private=False, monthly_budget=10.0
)
@@ -843,7 +843,7 @@ class TestPrintQueueAPI:
"printer_id": printer.id,
"archive_id": archive.id,
"cost_center_id": cost_center.id,
"estimated_cost": 3.0,
"estimated_cost": 0.01,
},
)
assert blocked.status_code == 400
@@ -857,7 +857,7 @@ class TestPrintQueueAPI:
"printer_id": printer.id,
"archive_id": archive.id,
"cost_center_id": cost_center.id,
"estimated_cost": 3.0,
"estimated_cost": 0.01,
},
)
assert allowed.status_code == 200
@@ -1730,11 +1730,12 @@ class TestBulkUpdateEndpoint:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bulk_update_cost_center_counts_pending_reservations(
self, async_client: AsyncClient, queue_item_factory, db_session
self, async_client: AsyncClient, queue_item_factory, archive_factory, db_session
):
"""Bulk updates cannot reserve more than the available cost-center budget."""
"""A forged bulk-update hint cannot weaken the server-derived reservation."""
await enable_billing(db_session)
item = await queue_item_factory()
archive = await archive_factory(cost=3.0, filament_used_grams=50.0)
item = await queue_item_factory(archive_id=archive.id)
existing = await queue_item_factory()
cost_center = CostCenter(name="Bulk Reserved CC", is_active=True, is_private=False, monthly_budget=10.0)
db_session.add(cost_center)
@@ -1747,7 +1748,7 @@ class TestBulkUpdateEndpoint:
response = await async_client.patch(
"/api/v1/queue/bulk",
json={"item_ids": [item.id], "cost_center_id": cost_center.id, "estimated_cost": 3.0},
json={"item_ids": [item.id], "cost_center_id": cost_center.id, "estimated_cost": 0.01},
)
assert response.status_code == 400