mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
implemented pr feedback #2
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user