| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799 |
- """Integration tests for the finance/billing API."""
- import pytest
- from httpx import AsyncClient
- 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.settings import Settings
- from backend.app.models.user import User
- class TestFinanceAPI:
- @pytest.fixture
- async def admin_user(self, db_session):
- user = User(
- username="finance-admin",
- email="finance-admin@example.com",
- password_hash=get_password_hash("AdminPass1!"),
- role="admin",
- is_active=True,
- )
- db_session.add(user)
- await db_session.commit()
- await db_session.refresh(user)
- return user
- @pytest.fixture
- async def auth_headers(self, async_client: AsyncClient, db_session, admin_user):
- db_session.add(Settings(key="auth_enabled", value="true"))
- db_session.add(Settings(key="advanced_auth_enabled", value="false"))
- # Ensure billing is enabled for finance integration tests
- existing = await db_session.scalar(select(Settings).where(Settings.key == "billing_enabled"))
- if existing is None:
- db_session.add(Settings(key="billing_enabled", value="true"))
- else:
- existing.value = "true"
- await db_session.commit()
- response = await async_client.post(
- "/api/v1/auth/login",
- json={"username": admin_user.username, "password": "AdminPass1!"},
- )
- assert response.status_code == 200
- return {"Authorization": f"Bearer {response.json()['access_token']}"}
- async def _enable_basic_user_creation(self, db_session):
- return None
- async def _create_user_via_api(self, async_client: AsyncClient, auth_headers: dict[str, str], username: str):
- response = await async_client.post(
- "/api/v1/users",
- json={
- "username": username,
- "password": "Regularpass1!",
- "email": f"{username}@example.com",
- "role": "user",
- },
- headers=auth_headers,
- )
- assert response.status_code == 201
- return response.json()
- async def _login_user(self, async_client: AsyncClient, username: str) -> dict[str, str]:
- response = await async_client.post(
- "/api/v1/auth/login",
- json={"username": username, "password": "Regularpass1!"},
- )
- assert response.status_code == 200
- return {"Authorization": f"Bearer {response.json()['access_token']}"}
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_create_cost_center_assign_member_and_list_mine(
- self,
- async_client: AsyncClient,
- auth_headers: dict[str, str],
- db_session,
- ):
- await self._enable_basic_user_creation(db_session)
- created_user = await self._create_user_via_api(async_client, auth_headers, "carol")
- user_headers = await self._login_user(async_client, "carol")
- create_response = await async_client.post(
- "/api/v1/finance/cost-centers",
- json={
- "name": "Shared Lab",
- "monthly_budget": 120.0,
- "total_budget": 999.0,
- "is_active": True,
- },
- headers=auth_headers,
- )
- assert create_response.status_code == 200
- shared_center = create_response.json()
- assert shared_center["name"] == "Shared Lab"
- assert shared_center["monthly_budget"] == 120.0
- assert shared_center["total_budget"] is None
- assert shared_center["budget_mode"] == "monthly"
- member_response = await async_client.post(
- f"/api/v1/finance/cost-centers/{shared_center['id']}/members",
- json={"user_id": created_user["id"], "can_print": False},
- headers=auth_headers,
- )
- assert member_response.status_code == 200
- assert member_response.json()["user_id"] == created_user["id"]
- assert member_response.json()["can_print"] is False
- mine_response = await async_client.get("/api/v1/finance/cost-centers/mine", headers=user_headers)
- assert mine_response.status_code == 200
- mine_names = {center["name"] for center in mine_response.json()}
- assert "carol" in mine_names
- assert "Shared Lab" in mine_names
- detail_response = await async_client.get(
- f"/api/v1/finance/cost-centers/{shared_center['id']}", headers=auth_headers
- )
- assert detail_response.status_code == 200
- detail = detail_response.json()
- assert len(detail["members"]) == 1
- assert detail["members"][0]["user_id"] == created_user["id"]
- remove_response = await async_client.delete(
- f"/api/v1/finance/cost-centers/{shared_center['id']}/members/{created_user['id']}",
- headers=auth_headers,
- )
- assert remove_response.status_code == 200
- mine_after_remove = await async_client.get("/api/v1/finance/cost-centers/mine", headers=user_headers)
- assert mine_after_remove.status_code == 200
- assert {center["name"] for center in mine_after_remove.json()} == {"carol"}
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_wallet_adjustments_and_transaction_ledger_rebuild(
- self,
- async_client: AsyncClient,
- auth_headers: dict[str, str],
- 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.
- """
- 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(
- select(CostCenter).where(CostCenter.owner_user_id == created_user["id"], CostCenter.is_private.is_(True))
- )
- assert shared_center is not None
- # Deposit to cost center: affects cost-center balance, not user 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},
- headers=auth_headers,
- )
- assert deposit.status_code == 200
- assert deposit.json()["transaction"]["cost_center_id"] == shared_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
- 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},
- headers=auth_headers,
- )
- assert withdraw.status_code == 200
- assert withdraw.json()["transaction"]["amount"] == -5.0
- assert withdraw.json()["transaction"]["balance_after"] == 20.0 # CC balance after withdraw
- assert withdraw.json()["balance"]["balance"] == 20.0 # Response shows CC balance
- # Personal deposit: affects user wallet
- personal_deposit = await async_client.post(
- f"/api/v1/finance/users/{created_user['id']}/deposit",
- json={"amount": 30.0, "description": "Personal top-up", "cost_center_id": None},
- headers=auth_headers,
- )
- 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
- transactions_response = await async_client.get(
- f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
- )
- 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]
- personal_txs = [tx for tx in transactions if tx["cost_center_id"] is None]
- assert len(cc_txs) == 2
- assert len(personal_txs) == 1
- balance_response = await async_client.get(
- 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
- # Delete personal transaction, user wallet should decrease
- personal_tx = next(tx for tx in transactions if tx["cost_center_id"] is None)
- delete_response = await async_client.delete(
- f"/api/v1/finance/transactions/{personal_tx['id']}", headers=auth_headers
- )
- assert delete_response.status_code == 200
- balance_after_delete = await async_client.get(
- 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
- remaining = await async_client.get(
- f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
- )
- assert remaining.status_code == 200
- assert len(remaining.json()) == 2 # 2 CC transactions remain
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_delete_cost_center_transaction_rebuilds_remaining_ledger(
- self,
- async_client: AsyncClient,
- auth_headers: dict[str, str],
- db_session,
- ):
- await self._enable_basic_user_creation(db_session)
- created_user = await self._create_user_via_api(async_client, auth_headers, "erin")
- shared_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
- first_deposit = await async_client.post(
- f"/api/v1/finance/users/{created_user['id']}/deposit",
- json={"amount": 25.0, "description": "CC top-up", "cost_center_id": shared_center.id},
- headers=auth_headers,
- )
- assert first_deposit.status_code == 200
- cc_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},
- headers=auth_headers,
- )
- assert cc_withdraw.status_code == 200
- personal_deposit = await async_client.post(
- f"/api/v1/finance/users/{created_user['id']}/deposit",
- json={"amount": 12.0, "description": "Personal top-up", "cost_center_id": None},
- headers=auth_headers,
- )
- assert personal_deposit.status_code == 200
- delete_response = await async_client.delete(
- f"/api/v1/finance/transactions/{first_deposit.json()['transaction']['id']}",
- headers=auth_headers,
- )
- assert delete_response.status_code == 200
- transactions_response = await async_client.get(
- f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
- )
- assert transactions_response.status_code == 200
- transactions = transactions_response.json()
- assert len(transactions) == 2
- cc_transaction = next(tx for tx in transactions if tx["cost_center_id"] == shared_center.id)
- assert cc_transaction["amount"] == -5.0
- assert cc_transaction["balance_after"] == -5.0
- balance_response = await async_client.get(
- 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
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_delete_print_charge_stays_deleted_after_recalculate(
- self,
- async_client: AsyncClient,
- auth_headers: dict[str, str],
- db_session,
- ):
- await self._enable_basic_user_creation(db_session)
- created_user = await self._create_user_via_api(async_client, auth_headers, "frank")
- user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
- assert user is not None
- archive = PrintArchive(
- printer_id=None,
- filename="print.gcode",
- file_path="archives/test/print.gcode",
- file_size=10,
- content_hash="hash-print",
- status="completed",
- cost=4.0,
- created_by_id=user.id,
- )
- db_session.add(archive)
- await db_session.flush()
- tx = WalletTransaction(
- user_id=user.id,
- transaction_type="print_charge",
- amount=-4.0,
- balance_after=-4.0,
- description="Print charge: print.gcode",
- created_by_user_id=None,
- print_archive_id=archive.id,
- )
- db_session.add(tx)
- await db_session.commit()
- tx_rows_before = (
- (await db_session.execute(select(WalletTransaction).where(WalletTransaction.user_id == user.id)))
- .scalars()
- .all()
- )
- assert len(tx_rows_before) == 1
- delete_response = await async_client.delete(
- f"/api/v1/finance/transactions/{tx_rows_before[0].id}", headers=auth_headers
- )
- assert delete_response.status_code == 200
- tx_rows_after = (
- (await db_session.execute(select(WalletTransaction).where(WalletTransaction.user_id == user.id)))
- .scalars()
- .all()
- )
- assert tx_rows_after == []
- async def test_edit_transaction_updates_ledger(
- self,
- async_client: AsyncClient,
- auth_headers: dict[str, str],
- db_session,
- ):
- """Test that editing a transaction (user, cost_center, amount, description) rebuilds ledger."""
- await self._enable_basic_user_creation(db_session)
- user1 = await self._create_user_via_api(async_client, auth_headers, "user1")
- user2 = await self._create_user_via_api(async_client, auth_headers, "user2")
- # Create a cost center
- cc_response = await async_client.post(
- "/api/v1/finance/cost-centers",
- json={"name": "Test Center", "is_active": True},
- headers=auth_headers,
- )
- assert cc_response.status_code == 200
- cost_center = cc_response.json()
- # Get user records from DB
- user1_db = await db_session.scalar(select(User).where(User.id == user1["id"]))
- user2_db = await db_session.scalar(select(User).where(User.id == user2["id"]))
- # Create a personal transaction for user1
- tx_response = await async_client.post(
- f"/api/v1/finance/users/{user1_db.id}/deposit",
- json={"amount": 50.0, "description": "Initial deposit"},
- headers=auth_headers,
- )
- assert tx_response.status_code == 200
- tx_data = tx_response.json()
- tx_id = tx_data["transaction"]["id"]
- # Get the original transaction
- original_tx = await db_session.scalar(select(WalletTransaction).where(WalletTransaction.id == tx_id))
- assert original_tx.user_id == user1_db.id
- assert original_tx.cost_center_id is None
- assert original_tx.amount == 50.0
- assert original_tx.balance_after == 50.0
- # Edit the transaction: change user, add cost center, change amount
- edit_response = await async_client.patch(
- f"/api/v1/finance/transactions/{tx_id}",
- json={
- "user_id": user2_db.id,
- "cost_center_id": cost_center["id"],
- "amount": 75.0,
- "description": "Updated deposit (Admin edit)",
- },
- headers=auth_headers,
- )
- assert edit_response.status_code == 200
- edited_tx_data = edit_response.json()
- # Verify transaction was updated
- assert edited_tx_data["user_id"] == user2_db.id
- assert edited_tx_data["cost_center_id"] == cost_center["id"]
- assert edited_tx_data["amount"] == 75.0
- # Description should have "(Admin edit)" appended
- assert "(Admin edit)" in edited_tx_data["description"]
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_create_manual_print_and_recalculates_ledger(
- self,
- async_client: AsyncClient,
- auth_headers: dict[str, str],
- db_session,
- ):
- """Posting a manual print (manual_adjustment) creates a transaction and rebuilds ledger."""
- await self._enable_basic_user_creation(db_session)
- created_user = await self._create_user_via_api(async_client, auth_headers, "gina")
- # Get user DB record
- user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
- assert user is not None
- # Private cost center for user
- private_cc = await db_session.scalar(
- select(CostCenter).where(CostCenter.owner_user_id == created_user["id"], CostCenter.is_private.is_(True))
- )
- assert private_cc is not None
- # Post manual print affecting the cost center
- payload = {
- "user_id": user.id,
- "cost_center_id": private_cc.id,
- "amount": -4.0,
- "description": "Manual adjustment for a print",
- "created_at": "2026-05-12T12:00:00Z",
- }
- response = await async_client.post("/api/v1/finance/transactions/manual", json=payload, headers=auth_headers)
- assert response.status_code == 200
- resp_json = response.json()
- assert "transaction" in resp_json or "id" in resp_json
- # Response contains the created transaction details
- assert resp_json["transaction_type"] == "manual_adjustment"
- assert resp_json["amount"] == -4.0
- assert resp_json["cost_center_id"] == private_cc.id
- # The response includes the computed running balance for the transaction
- assert resp_json.get("balance_after") == -4.0
- class TestPartialPrintChargesIntegration:
- """Integration tests for partial print charge calculation."""
- @pytest.fixture
- async def admin_user(self, db_session):
- user = User(
- username="partial-admin",
- email="partial-admin@example.com",
- password_hash=get_password_hash("AdminPass1!"),
- role="admin",
- is_active=True,
- )
- db_session.add(user)
- await db_session.commit()
- await db_session.refresh(user)
- return user
- @pytest.fixture
- async def auth_headers(self, async_client: AsyncClient, db_session, admin_user):
- db_session.add(Settings(key="auth_enabled", value="true"))
- db_session.add(Settings(key="advanced_auth_enabled", value="false"))
- # Ensure billing is enabled for these partial-charge integration tests
- existing = await db_session.scalar(select(Settings).where(Settings.key == "billing_enabled"))
- if existing is None:
- db_session.add(Settings(key="billing_enabled", value="true"))
- else:
- existing.value = "true"
- await db_session.commit()
- response = await async_client.post(
- "/api/v1/auth/login",
- json={"username": admin_user.username, "password": "AdminPass1!"},
- )
- assert response.status_code == 200
- return {"Authorization": f"Bearer {response.json()['access_token']}"}
- async def _create_user_via_api(self, async_client: AsyncClient, auth_headers: dict[str, str], username: str):
- response = await async_client.post(
- "/api/v1/users",
- json={
- "username": username,
- "password": "Regularpass1!",
- "email": f"{username}@example.com",
- "role": "user",
- },
- headers=auth_headers,
- )
- assert response.status_code == 201
- return response.json()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_aborted_print_charges_proportionally_via_recalculate_endpoint(
- self,
- async_client: AsyncClient,
- auth_headers: dict[str, str],
- db_session,
- ):
- """Verify aborted prints are included in recalculate and charged proportionally."""
- created_user = await self._create_user_via_api(async_client, auth_headers, "frank")
- user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
- assert user is not None
- # Wallet is already created by ensure_user_finance_defaults during user creation
- # Archive: completed print (100% charge)
- completed = PrintArchive(
- printer_id=None,
- filename="completed.3mf",
- file_path="archives/test/completed.3mf",
- file_size=100,
- content_hash="partial-complete",
- status="completed",
- cost=10.0,
- created_by_id=user.id,
- )
- # Archive: aborted print (50% filament used = 50% charge)
- aborted = PrintArchive(
- printer_id=None,
- filename="aborted.3mf",
- file_path="archives/test/aborted.3mf",
- file_size=100,
- content_hash="partial-aborted",
- status="aborted",
- cost=8.0,
- filament_used_grams=50.0,
- extra_data={"filament_grams_total": 100.0},
- created_by_id=user.id,
- )
- # Archive: failed print (0% filament used = no charge)
- failed = PrintArchive(
- printer_id=None,
- filename="failed.3mf",
- file_path="archives/test/failed.3mf",
- file_size=100,
- content_hash="partial-failed",
- status="failed",
- cost=5.0,
- filament_used_grams=0.0,
- created_by_id=user.id,
- )
- db_session.add_all([completed, aborted, failed])
- await db_session.commit()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_partial_charges_appear_in_transaction_ledger(
- self,
- async_client: AsyncClient,
- auth_headers: dict[str, str],
- db_session,
- ):
- """Verify transaction descriptions indicate partial charges."""
- created_user = await self._create_user_via_api(async_client, auth_headers, "grace")
- user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
- assert user is not None
- # Wallet is already created by ensure_user_finance_defaults during user creation
- cancelled = PrintArchive(
- printer_id=None,
- filename="cancelled.3mf",
- file_path="archives/test/cancelled.3mf",
- file_size=100,
- content_hash="partial-cancel",
- status="cancelled",
- cost=12.0,
- filament_used_grams=25.0,
- extra_data={"filament_grams_total": 100.0},
- print_name="Partially Cancelled Print",
- created_by_id=user.id,
- )
- db_session.add(cancelled)
- await db_session.commit()
- from backend.app.services.finance_billing import apply_print_charge_for_archive
- changed = await apply_print_charge_for_archive(db_session, cancelled.id)
- assert changed is True
- await db_session.commit()
- tx_response = await async_client.get(
- f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
- )
- assert tx_response.status_code == 200
- transactions = tx_response.json()
- assert len(transactions) == 1
- tx = transactions[0]
- assert tx["transaction_type"] == "print_charge"
- assert tx["amount"] == -3.0 # 25% of 12.0
- assert "cancelled" in tx["description"].lower()
- assert "25.0g/100.0" in tx["description"] # filament amounts in description
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_partial_charges_with_cost_center_override(
- self,
- async_client: AsyncClient,
- auth_headers: dict[str, str],
- db_session,
- ):
- """Verify partial charges respect cost_center_id when present."""
- created_user = await self._create_user_via_api(async_client, auth_headers, "henry")
- user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
- assert user is not None
- # Create cost centers
- default_cc = CostCenter(name="Default CC", owner_user_id=user.id, is_active=True, is_private=False)
- lab_cc = CostCenter(name="Lab CC", owner_user_id=user.id, is_active=True, is_private=False)
- db_session.add_all([default_cc, lab_cc])
- await db_session.flush()
- # Wallet is already created by ensure_user_finance_defaults during user creation
- # Archive assigned to default_cc
- aborted = PrintArchive(
- printer_id=None,
- filename="aborted_cc.3mf",
- file_path="archives/test/aborted_cc.3mf",
- file_size=100,
- content_hash="partial-cc",
- status="aborted",
- cost=6.0,
- filament_used_grams=30.0,
- extra_data={"filament_grams_total": 100.0},
- cost_center_id=default_cc.id,
- created_by_id=user.id,
- )
- db_session.add(aborted)
- await db_session.commit()
- # Manually apply charge with override
- from backend.app.services.finance_billing import apply_print_charge_for_archive
- changed = await apply_print_charge_for_archive(
- db_session,
- aborted.id,
- cost_center_id=lab_cc.id,
- )
- await db_session.commit()
- assert changed is True
- tx_response = await async_client.get(
- f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
- )
- assert tx_response.status_code == 200
- transactions = tx_response.json()
- assert len(transactions) == 1
- tx = transactions[0]
- assert tx["cost_center_id"] == lab_cc.id # Overridden to lab_cc
- assert tx["amount"] == pytest.approx(-1.8, abs=0.01) # 30% of 6.0
- class TestFinanceUserDefaults:
- """Tests for user creation and finance defaults initialization."""
- @pytest.fixture
- async def admin_user(self, db_session):
- user = User(
- username="billing-admin",
- email="billing-admin@example.com",
- password_hash=get_password_hash("AdminPass1!"),
- role="admin",
- is_active=True,
- )
- db_session.add(user)
- await db_session.commit()
- await db_session.refresh(user)
- return user
- @pytest.fixture
- async def auth_headers(self, async_client: AsyncClient, db_session, admin_user):
- db_session.add(Settings(key="auth_enabled", value="true"))
- db_session.add(Settings(key="advanced_auth_enabled", value="false"))
- await db_session.commit()
- response = await async_client.post(
- "/api/v1/auth/login",
- json={"username": admin_user.username, "password": "AdminPass1!"},
- )
- assert response.status_code == 200
- return {"Authorization": f"Bearer {response.json()['access_token']}"}
- async def _create_user_via_api(self, async_client: AsyncClient, auth_headers: dict[str, str], username: str):
- response = await async_client.post(
- "/api/v1/users",
- json={
- "username": username,
- "password": "Regularpass1!",
- "email": f"{username}@example.com",
- "role": "user",
- },
- headers=auth_headers,
- )
- assert response.status_code == 201
- return response.json()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_create_user_initializes_wallet_and_private_cost_center(
- self,
- async_client: AsyncClient,
- auth_headers: dict[str, str],
- db_session,
- ):
- """Verify user creation initializes wallet, private cost center, and membership."""
- result = await async_client.post(
- "/api/v1/users",
- json={
- "username": "alice",
- "password": "Regularpass1!",
- "email": "alice@example.com",
- "role": "user",
- },
- headers=auth_headers,
- )
- assert result.status_code == 201
- created = result.json()
- assert created["username"] == "alice"
- user = await db_session.scalar(select(User).where(User.username == "alice"))
- assert user is not None
- from backend.app.models.finance import CostCenterMember
- wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
- assert wallet is not None
- assert wallet.balance == 0.0
- 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
- assert private_center.name == "alice"
- membership = await db_session.scalar(
- select(CostCenterMember).where(
- CostCenterMember.cost_center_id == private_center.id,
- CostCenterMember.user_id == user.id,
- )
- )
- assert membership is not None
- assert membership.can_print is True
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_user_keeps_private_cost_center_in_sync(
- self,
- async_client: AsyncClient,
- auth_headers: dict[str, str],
- db_session,
- ):
- """Verify user updates keep private cost center name in sync."""
- created = await self._create_user_via_api(async_client, auth_headers, "bob")
- response = await async_client.patch(
- f"/api/v1/users/{created['id']}",
- json={"username": "bobby"},
- headers=auth_headers,
- )
- assert response.status_code == 200
- assert response.json()["username"] == "bobby"
- user = await db_session.scalar(select(User).where(User.id == created["id"]))
- assert user is not None
- private_centers = (
- (
- await db_session.execute(
- select(CostCenter).where(CostCenter.owner_user_id == user.id, CostCenter.is_private.is_(True))
- )
- )
- .scalars()
- .all()
- )
- assert len(private_centers) == 1
- assert private_centers[0].name == "bobby"
- wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
- assert wallet is not None
|