|
@@ -0,0 +1,316 @@
|
|
|
|
|
+"""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"
|