| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316 |
- """A print still running when its user is deleted is billed to its cost centre.
- At completion neither the queue row's billing user nor the archive's creator is
- left, and the charge returned "has no creator ID" before it released the budget
- reservation: the cost centre's reservation stayed active for good and its ledger
- missed the print. It is now charged to its cost centre without a user (shown
- as "No user", as is a print that never had one with login off), also the
- user's own private one their personal prints go through. Only a print with no cost centre at all has nobody to charge. Deleting
- the user together with their items releases what their prints still held.
- """
- import pytest
- from sqlalchemy import select
- from backend.app.models.archive import PrintArchive
- from backend.app.models.finance import BudgetReservation, CostCenter, WalletTransaction
- from backend.app.models.print_queue import PrintQueueItem
- from backend.app.services.finance_billing import apply_print_charge_for_archive
- from backend.tests.unit.services.test_finance_service_billing import enable_billing
- pytestmark = pytest.mark.asyncio
- async def _orphaned_print(db_session, *, cost_center: bool):
- """A completed print whose user was deleted while it ran: no creator, no
- billing user, and an active reservation from its dispatch."""
- await enable_billing(db_session)
- center = None
- if cost_center:
- center = CostCenter(name="Lab", is_active=True, is_private=False)
- db_session.add(center)
- await db_session.flush()
- archive = PrintArchive(
- filename="part.3mf",
- file_path="archives/test/part.3mf",
- file_size=1,
- status="completed",
- cost=5.0,
- created_by_id=None,
- cost_center_id=center.id if center else None,
- )
- db_session.add(archive)
- await db_session.flush()
- item = PrintQueueItem(
- archive_id=archive.id,
- cost_center_id=center.id if center else None,
- estimated_cost=5.0,
- position=1,
- status="printing",
- created_by_id=None,
- billing_run_id="run-of-a-deleted-user",
- )
- db_session.add(item)
- await db_session.flush()
- reservation = None
- if center: # budgets, and so reservations, exist per cost centre only
- reservation = BudgetReservation(
- cost_center_id=center.id,
- amount=5.0,
- status="active",
- source_type="print_queue",
- source_id=item.id,
- print_archive_id=archive.id,
- )
- db_session.add(reservation)
- await db_session.commit()
- return archive, item, reservation, center
- async def _charge(db_session, archive, item, center):
- return await apply_print_charge_for_archive(
- db_session,
- archive.id,
- charged_user_id=None,
- cost_center_id=center.id if center else None,
- print_queue_id=item.id,
- print_run_id="run-of-a-deleted-user",
- )
- async def _charges(db_session) -> list[WalletTransaction]:
- return list((await db_session.scalars(select(WalletTransaction))).all())
- async def test_a_cost_centre_print_of_a_deleted_user_is_charged_to_the_cost_centre(db_session):
- archive, item, reservation, center = await _orphaned_print(db_session, cost_center=True)
- assert await _charge(db_session, archive, item, center) is True
- await db_session.commit()
- (charge,) = await _charges(db_session)
- assert charge.user_id is None
- assert charge.cost_center_id == center.id
- assert charge.amount == -5.0
- assert charge.balance_after == -5.0
- assert charge.print_archive_id == archive.id
- await db_session.refresh(reservation)
- assert reservation.status == "consumed"
- async def test_a_personal_print_of_a_deleted_user_charges_nobody(db_session):
- """No cost centre at all (started while billing was off): nobody is left to
- charge, and it holds no reservation."""
- archive, item, _reservation, center = await _orphaned_print(db_session, cost_center=False)
- assert await _charge(db_session, archive, item, center) is False
- await db_session.commit()
- assert await _charges(db_session) == []
- async def test_the_same_run_is_not_charged_twice(db_session):
- archive, item, _reservation, center = await _orphaned_print(db_session, cost_center=True)
- await _charge(db_session, archive, item, center)
- await db_session.commit()
- assert await _charge(db_session, archive, item, center) is False
- await db_session.commit()
- assert len(await _charges(db_session)) == 1
- async def test_a_private_cost_centre_of_a_deleted_user_is_charged(db_session):
- """With billing on a "personal" print goes through the user's own private
- cost centre, which stays after the user (its owner set to none)."""
- from backend.app.models.finance import CostCenter
- archive, item, reservation, center = await _orphaned_print(db_session, cost_center=True)
- private = await db_session.get(CostCenter, center.id)
- private.is_private = True
- private.owner_user_id = None
- await db_session.commit()
- assert await _charge(db_session, archive, item, center) is True
- await db_session.commit()
- (charge,) = await _charges(db_session)
- assert charge.user_id is None and charge.cost_center_id == center.id
- await db_session.refresh(reservation)
- assert reservation.status == "consumed"
- async def test_deleting_a_user_with_their_items_releases_their_reservations(async_client, db_session):
- """Their archives and queue items are deleted, so no completion can settle
- the reservations (it finds no archive): they held budget for good, and the
- cost centre could not be deleted."""
- from backend.app.models.user import User
- user = User(username="leaver", password_hash="x", role="user")
- db_session.add(user)
- await db_session.commit()
- archive, item, reservation, _center = await _orphaned_print(db_session, cost_center=True)
- archive.created_by_id = user.id
- item.created_by_id = user.id
- item.status = "pending" # not printing yet: it goes, and so does what it held
- user_id, reservation_id = user.id, reservation.id
- await db_session.commit()
- response = await async_client.delete(f"/api/v1/users/{user_id}", params={"delete_items": "true"})
- assert response.status_code in (200, 204), response.text
- db_session.expire_all()
- reservation = await db_session.get(BudgetReservation, reservation_id)
- assert reservation.status == "released"
- assert reservation.released_at is not None
- async def test_a_reprint_by_a_deleted_user_is_not_put_on_the_archives_owner(db_session):
- """A queue print's user is the one who queued it. A reprint reuses the
- source archive, whose creator is someone else: that owner did not print it."""
- from backend.app.models.user import User
- owner = User(username="archive_owner", password_hash="x", role="user")
- db_session.add(owner)
- await db_session.commit()
- archive, item, _reservation, center = await _orphaned_print(db_session, cost_center=True)
- archive.created_by_id = owner.id # the source archive; the queue row's user was deleted
- await db_session.commit()
- assert await _charge(db_session, archive, item, center) is True
- await db_session.commit()
- (charge,) = await _charges(db_session)
- assert charge.user_id is None
- async def test_deleting_a_user_with_their_items_keeps_a_print_still_running(async_client, db_session):
- """A running reprint of another user's archive: its queue row went, and the
- completion then charged the archive's owner, who never printed it. Running,
- it is detached (no user) instead, and billed to its cost centre as such."""
- from backend.app.models.user import User
- owner = User(username="archive_owner", password_hash="x", role="user")
- leaver = User(username="leaver", password_hash="x", role="user")
- db_session.add_all([owner, leaver])
- await db_session.commit()
- archive, item, reservation, center = await _orphaned_print(db_session, cost_center=True)
- archive.created_by_id = owner.id # the source archive of the reprint
- item.created_by_id = leaver.id # printing
- leaver_id, item_id, reservation_id = leaver.id, item.id, reservation.id
- archive_id, center_id = archive.id, center.id
- await db_session.commit()
- response = await async_client.delete(f"/api/v1/users/{leaver_id}", params={"delete_items": "true"})
- assert response.status_code in (200, 204), response.text
- db_session.expire_all()
- kept = await db_session.get(PrintQueueItem, item_id)
- assert kept is not None and kept.created_by_id is None
- assert (await db_session.get(BudgetReservation, reservation_id)).status == "active"
- archive = await db_session.get(PrintArchive, archive_id)
- center = await db_session.get(CostCenter, center_id)
- assert await _charge(db_session, archive, kept, center) is True
- await db_session.commit()
- (charge,) = await _charges(db_session)
- assert charge.user_id is None
- assert (await db_session.get(BudgetReservation, reservation_id)).status == "consumed"
- async def test_deleting_a_user_with_their_items_keeps_their_own_archive_still_printing(async_client, db_session):
- """Their own archive of a running print: deleted, the completion found no
- archive and the print went unbilled. Detached, it is billed as "No user"."""
- from backend.app.models.user import User
- leaver = User(username="leaver", password_hash="x", role="user")
- db_session.add(leaver)
- await db_session.commit()
- archive, item, _reservation, _center = await _orphaned_print(db_session, cost_center=True)
- archive.status = "printing"
- archive.created_by_id = leaver.id
- item.created_by_id = leaver.id
- leaver_id, archive_id = leaver.id, archive.id
- await db_session.commit()
- response = await async_client.delete(f"/api/v1/users/{leaver_id}", params={"delete_items": "true"})
- assert response.status_code in (200, 204), response.text
- db_session.expire_all()
- kept = await db_session.get(PrintArchive, archive_id)
- assert kept is not None and kept.created_by_id is None
- async def test_a_print_whose_cost_comes_out_as_zero_releases_its_reservation(db_session):
- """An older or imported archive with grams but no stored cost is reserved
- from the estimate; at completion its cost is none, and the charge returned
- before releasing the reservation, which then held budget for good."""
- archive, item, reservation, center = await _orphaned_print(db_session, cost_center=True)
- archive.cost = None
- reservation_id = reservation.id
- await db_session.commit()
- assert await _charge(db_session, archive, item, center) is False
- await db_session.commit()
- assert await _charges(db_session) == []
- db_session.expire_all()
- assert (await db_session.get(BudgetReservation, reservation_id)).status == "released"
- async def test_deleting_a_user_with_their_items_keeps_their_archive_another_user_is_printing(async_client, db_session):
- """Another user's reprint of their archive, dispatched (queue row printing)
- before the printer's start marked the archive printing: deleted, the archive
- took that user's queue row with it and their print was never billed."""
- from backend.app.models.user import User
- leaver = User(username="leaver", password_hash="x", role="user")
- printer_user = User(username="reprinter", password_hash="x", role="user")
- db_session.add_all([leaver, printer_user])
- await db_session.commit()
- archive, item, reservation, _center = await _orphaned_print(db_session, cost_center=True)
- archive.created_by_id = leaver.id # still "completed": the start has not come yet
- item.created_by_id = printer_user.id # their row, printing
- leaver_id, archive_id, item_id, reservation_id = leaver.id, archive.id, item.id, reservation.id
- printer_user_id = printer_user.id
- await db_session.commit()
- response = await async_client.delete(f"/api/v1/users/{leaver_id}", params={"delete_items": "true"})
- assert response.status_code in (200, 204), response.text
- db_session.expire_all()
- kept = await db_session.get(PrintArchive, archive_id)
- assert kept is not None and kept.created_by_id is None
- assert (await db_session.get(PrintQueueItem, item_id)).created_by_id == printer_user_id
- assert (await db_session.get(BudgetReservation, reservation_id)).status == "active"
- async def test_a_billing_user_deleted_just_before_the_charge_counts_as_none(db_session):
- """The completion read the queue row's user, then the user was deleted
- before the charge (seconds of usage and archive work in between): the id
- names nobody. PostgreSQL refused the wallet for it (FK) and the reservation
- stayed active; SQLite wrote a wallet and a charge for a missing user."""
- from backend.app.models.finance import UserWallet
- archive, item, reservation, center = await _orphaned_print(db_session, cost_center=True)
- reservation_id = reservation.id
- gone_user_id = 987654 # no such user any more
- assert (
- await apply_print_charge_for_archive(
- db_session,
- archive.id,
- charged_user_id=gone_user_id,
- cost_center_id=center.id,
- print_queue_id=item.id,
- print_run_id="run-of-a-deleted-user",
- )
- is True
- )
- await db_session.commit()
- (charge,) = await _charges(db_session)
- assert charge.user_id is None
- assert await db_session.scalar(select(UserWallet).where(UserWallet.user_id == gone_user_id)) is None
- db_session.expire_all()
- assert (await db_session.get(BudgetReservation, reservation_id)).status == "consumed"
|