fix: a failed first run no longer leaves the archive at its partial cost (built-in inventory); completion stops the start cost estimate before writing the real cost; 2FA/SSO logins report LDAP/OIDC users' auth source; a fault in both print_error and HMS is listed once; a refused H2D load's tray target expires; refused K-profile re-applies are logged; the cloud filament-name map is fetched once per account without holding a DB connection; the library edit route refuses folder changes across the external boundary; a slow slice-progress answer is not reported as an unreachable sidecar

This commit is contained in:
maziggy
2026-10-20 14:29:08 +02:00
parent 808e461c52
commit ac4806f029
17 changed files with 520 additions and 42 deletions
+9
View File
@@ -164,6 +164,15 @@ All notable changes to Bambuddy will be documented in this file.
- **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
### Fixed
- **A print that failed on its first run left the archive costing only the part it printed** — With the built-in inventory, the archive took the failed run's partial cost, so a reprint was estimated, reserved and billed from that fraction. The archive now keeps the cost of one full print (as in Spoolman mode); the failed run's own cost is still recorded in the Print Log.
- **Spoolman: a very short print could end with its estimated cost instead of the real one** — The cost estimate made at print start could still be waiting on Spoolman when the print ended, and then wrote over the real cost. Completion now stops it first.
- **After a 2FA or SSO login, LDAP and OIDC users were offered "Change Password"** — The login answered as if they were local users, until the page was reloaded.
- **The same printer fault could be listed twice** — A fault the printer reported both as a print error and in its HMS list was not recognised as the same one.
- **H2D: a refused manual filament load could show the wrong active tray later** — Its target slot was remembered until something cleared it, so a print loading the same slot number on another AMS much later was shown as that slot. It is now forgotten after two minutes.
- **K-profiles: a printer refusing the re-applied profile after an RFID read was not in the logs** — Only the "applied" line was, which made wrong pressure-advance prints hard to diagnose.
- **K-profiles: opening several pages at once could fetch the cloud filament names many times** — Each one fetched every custom preset from Bambu Cloud while holding a database connection; they now share one fetch.
- **Library API: changing a file's folder could move it into or out of an external folder without moving the file** — The file then showed in a folder it was not in. That change is now refused with a pointer to the move endpoint, which moves the file itself.
- **Slicing: a slow progress answer was reported as "Slicer sidecar unreachable"** — It now says the sidecar did not answer in time.
- **Printer card: the cooling/heating mode switched on the printer showed the old mode** — The live updates did not carry it, so the card caught up only with its next refresh.
- **Printer card: an H2C rack slot's material label disappeared** — The live updates replaced the rack without each slot's filament type, so the label fell back to the filament ID or went blank until the next refresh.
- **Git backup: a print running when the backup was made came back as a failed print** — Restored as still printing, the archive was closed on the next printer reconnect as if the print had just failed, with a failure notification and a Print Log entry. It is now restored as cancelled ("No status update received"), the way Bambuddy closes a print it lost track of; an archive this instance still has keeps its own status on overwrite.
+41 -5
View File
@@ -15,6 +15,7 @@ from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core import database
from backend.app.core.auth import (
RequirePermissionIfAuthEnabled,
_user_from_api_key,
@@ -24,6 +25,7 @@ from backend.app.core.auth import (
)
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.core.tasks import spawn_background_task
from backend.app.models.api_key import APIKey
from backend.app.models.settings import Settings
from backend.app.models.user import User
@@ -184,6 +186,8 @@ router = APIRouter(prefix="/cloud", tags=["cloud"], dependencies=[Depends(_cloud
def _forget_account_presets(account: int | None) -> None:
"""Drop what was read with an account's cloud token, once that token changes."""
_filament_id_name_cache.pop(account, None)
# A build still running reads with the old token; it must not be joined.
_filament_id_map_inflight.pop(account, None)
for key in [key for key in _filament_cache if key[0] == account]:
del _filament_cache[key]
@@ -191,6 +195,7 @@ def _forget_account_presets(account: int | None) -> None:
def forget_all_cloud_presets() -> None:
"""Drop every account's cached presets (the user table was replaced)."""
_filament_id_name_cache.clear()
_filament_id_map_inflight.clear()
_filament_cache.clear()
@@ -1303,11 +1308,12 @@ async def get_builtin_filaments(
# cloud account: the user whose token read it, or None for the global token
# used when auth is off. Value: (time read, mapping).
_filament_id_name_cache: dict[int | None, tuple[float, dict[str, str]]] = {}
# Builds still running, by account (RC-C11).
_filament_id_map_inflight: dict[int | None, asyncio.Future] = {}
@router.get("/filament-id-map")
async def get_filament_id_map(
db: AsyncSession = Depends(get_db),
current_user: User | None = cloud_caller(Permission.FILAMENTS_READ),
):
"""
@@ -1326,7 +1332,36 @@ async def get_filament_id_map(
if cached and time.time() - cached[0] < FILAMENT_CACHE_TTL:
return cached[1]
cloud = await build_authenticated_cloud(db, current_user)
# One build per account at a time: when the cache expired, every K-profile
# view asked at once and each ran the full 1 + N fetch (RC-C11). Registered
# before anything is awaited, so callers arriving together all join it.
running = _filament_id_map_inflight.get(account)
if running is None or running.done():
running = spawn_background_task(_build_filament_id_map(current_user, account), name="cloud-filament-id-map")
_filament_id_map_inflight[account] = running
def _forget(done: asyncio.Future, account: int | None = account) -> None:
if _filament_id_map_inflight.get(account) is done:
del _filament_id_map_inflight[account]
running.add_done_callback(_forget)
try:
# shield: a caller that goes away must not cancel the build others joined.
return dict(await asyncio.shield(running))
except Exception:
return cached[1] if cached else {}
async def _build_filament_id_map(user: User | None, account: int | None) -> dict[str, str]:
"""Fetch an account's custom presets and map their filament ids to names.
Reads the stored token in a session of its own, closed before the Bambu
Cloud round-trips: no request holds a database connection across them.
"""
import time
async with database.async_session() as db:
cloud = await build_authenticated_cloud(db, user)
if cloud is None or not cloud.is_authenticated:
if cloud is not None:
await cloud.close()
@@ -1355,10 +1390,11 @@ async def get_filament_id_map(
except Exception:
pass
_filament_id_name_cache[account] = (time.time(), result)
# Not when the account signed out meanwhile: its build was dropped,
# and this map was read with the token that is gone.
if _filament_id_map_inflight.get(account) is asyncio.current_task():
_filament_id_name_cache[account] = (time.time(), result)
return result
except Exception:
return cached[1] if cached else {}
finally:
await cloud.close()
+14 -5
View File
@@ -5483,12 +5483,21 @@ async def update_file(
# and _without_print_name strips the embedded 3MF Title on import (#1489).
if data.folder_id is not None:
if data.folder_id == 0:
file.folder_id = None
else:
target_folder = None
if data.folder_id != 0:
# library:update_all moves into any folder, as before (#3201).
await get_writable_folder(db, data.folder_id, None if can_modify_all else user)
file.folder_id = data.folder_id
target_folder = await get_writable_folder(db, data.folder_id, None if can_modify_all else user)
# Into or out of an external folder the bytes have to move too, and a
# read-only one refuses them: that is the move route's work. Here only
# folder_id changed, so the file showed in a folder its bytes were not
# in (F8).
unchanged = (target_folder.id if target_folder is not None else None) == file.folder_id
if not unchanged and (file.is_external or (target_folder is not None and target_folder.is_external)):
raise HTTPException(
status_code=400,
detail="Move a file into or out of an external folder with POST /api/v1/library/files/move",
)
file.folder_id = target_folder.id if target_folder is not None else None
if data.project_id is not None:
if data.project_id == 0:
+6 -12
View File
@@ -68,7 +68,6 @@ from backend.app.schemas.auth import (
EmailOTPDisableRequest,
EmailOTPEnableConfirmRequest,
EmailOTPSendRequest,
GroupBrief,
LoginResponse,
OIDCAuthorizeResponse,
OIDCExchangeRequest,
@@ -197,17 +196,12 @@ router = APIRouter(prefix="/auth", tags=["2fa", "oidc"])
# Helper: user response
# ---------------------------------------------------------------------------
def _user_to_response(user: User) -> UserResponse:
return UserResponse(
id=user.id,
username=user.username,
email=user.email,
role=user.role,
is_active=user.is_active,
is_admin=user.is_admin,
groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],
permissions=sorted(user.get_permissions()),
created_at=user.created_at.isoformat(),
)
# The auth routes' builder, not a copy: the copy here left out
# ``auth_source``, so a 2FA or OIDC login reported LDAP and OIDC users as
# local and the page offered them "Change Password" (PC-C9).
from backend.app.api.routes.auth import _user_to_response as build
return build(user)
# ---------------------------------------------------------------------------
+4
View File
@@ -848,6 +848,10 @@ async def get_preview_slice_progress(
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(url)
except httpx.TimeoutException:
# Busy, not gone: a heavy slice can hold the progress answer past 5 s
# (TO-C14). Still 503, so the poller keeps trying.
raise HTTPException(status_code=503, detail="Slicer sidecar did not answer in time") from None
except httpx.RequestError:
# Sidecar unreachable: surface as 503 instead of 500 so the
# frontend's poller can keep trying without flagging a hard error.
+5
View File
@@ -107,6 +107,7 @@ from backend.app.services.ams_assignment_lock import get_ams_assignment_lock
from backend.app.services.archive import ArchiveService, peek_plate_index_in_3mf, swap_plate_suffix
from backend.app.services.archive_cost_estimate import (
schedule_archive_cost_estimate as _schedule_archive_cost_estimate,
settle_archive_cost_estimate as _settle_archive_cost_estimate,
)
from backend.app.services.archive_purge import archive_purge_service
from backend.app.services.bambu_ftp import (
@@ -8435,6 +8436,10 @@ async def _close_out_print(printer_id: int, data: dict):
if archive_id:
notify_plate_id = _print_plate_ids.pop(archive_id, None)
# The start estimate must not outlive the real cost written below (COST-4).
if archive_id:
await _settle_archive_cost_estimate(archive_id)
# Internal inventory: track AMS remain% deltas (skip if Spoolman handles usage)
try:
async with async_session() as db:
+30 -1
View File
@@ -26,6 +26,7 @@ Strictly best-effort. Nothing here may fail a print start.
"""
import asyncio
import contextlib
import json
import logging
@@ -183,10 +184,38 @@ def schedule_archive_cost_estimate(
await estimate_archive_cost_at_start(db, printer_id, archive_id, printer_manager, mapping, plate_id)
try:
return spawn_background_task(_run(), name=f"archive-cost-estimate-{archive_id}")
task = spawn_background_task(_run(), name=f"archive-cost-estimate-{archive_id}")
except Exception: # noqa: BLE001 -- an estimate must never fail a print start
logger.warning("[COST] Archive %s: could not schedule the cost estimate", archive_id, exc_info=True)
return None
_running_estimates[archive_id] = task
def _forget(done: asyncio.Task, archive_id: int = archive_id) -> None:
if _running_estimates.get(archive_id) is done:
del _running_estimates[archive_id]
task.add_done_callback(_forget)
return task
# Estimates still running, by archive: completion stops its archive's before
# writing the real cost (COST-4).
_running_estimates: dict[int, asyncio.Task] = {}
async def settle_archive_cost_estimate(archive_id: int) -> None:
"""Stop the archive's start estimate, if still running, before the real cost.
Completion commits the measured cost before it marks the archive ended; an
estimate still waiting on Spoolman then saw a printing archive with no runs
logged, and wrote its figure over the real one on a very short print.
"""
task = _running_estimates.pop(archive_id, None)
if task is None or task.done():
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await task
async def estimate_archive_cost_at_start(
+34 -8
View File
@@ -29,6 +29,11 @@ from backend.app.utils.paho_teardown import retire_paho_client
logger = logging.getLogger(__name__)
# How long a manual load's tray target stays a hint for an H2D's tray_now: long
# enough for a real load to report, short enough that a refused one cannot
# relabel a later print's load of the same slot number (FW-C7).
PENDING_TRAY_TARGET_TTL = 120.0
# AMS module name prefixes used in get_version responses.
# The numeric suffix after '/' is the AMS unit ID as reported in push_status.
# "ams/<id>" – original AMS (X1C, X1E, P1S, …)
@@ -1043,6 +1048,8 @@ class PrinterState:
last_loaded_tray: int = -1
# Pending load target - used to track what tray we're loading for H2D disambiguation
pending_tray_target: int | None = None
# When it was set (time.monotonic): see PENDING_TRAY_TARGET_TTL.
pending_tray_target_at: float | None = None
# AMS status for filament change tracking (from print.ams.ams_status field)
# ams_status is a combined value: lower 8 bits = sub status, bits 8-15 = main status
# Main status: 0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration, etc.
@@ -2586,15 +2593,16 @@ class BambuMQTTClient:
self._dev_mode_probe_seq is not None
and print_data.get("sequence_id") == self._dev_mode_probe_seq
)
if (
cmd == "ams_filament_setting"
and not is_dev_mode_probe
and isinstance(result, str)
and result.lower() != "success"
):
# extrusion_cali_sel too (FW-C3): every RFID re-read
# re-applies the spool's K-profile and logs "Applied PA
# profile", so its refusal must reach the bundle beside it.
# The printer's word, not a verdict: the extrusion_cali_*
# acks are known to say "fail" for writes that applied.
if not is_dev_mode_probe and isinstance(result, str) and result.lower() != "success":
logger.info(
"[%s] ams_filament_setting refused: result=%s reason=%s ams_id=%s tray_id=%s",
"[%s] %s refused: result=%s reason=%s ams_id=%s tray_id=%s",
self.serial_number,
cmd,
result,
print_data.get("reason", ""),
print_data.get("ams_id"),
@@ -3377,6 +3385,19 @@ class BambuMQTTClient:
if self._is_dual_nozzle and 0 <= parsed_tray_now <= 3:
# First, check if we have a pending target that matches this slot
pending_target = self.state.pending_tray_target
pending_at = self.state.pending_tray_target_at
if (
pending_target is not None
and pending_at is not None
and time.monotonic() - pending_at > PENDING_TRAY_TARGET_TTL
):
# A load the printer refused or dropped: nothing clears
# it (tray_now stays 255), and a print loading the same
# slot number on another AMS long after was shown as
# this target (FW-C7). Fall through to the snow field.
self.state.pending_tray_target = None
self.state.pending_tray_target_at = None
pending_target = None
if pending_target is not None:
# Same slot rule as the snow branch below: an AMS-HT
# (128-135) has its one tray in slot 0.
@@ -5247,7 +5268,10 @@ class BambuMQTTClient:
for e in self.state.hms_errors:
# Extract short code from existing errors
e_module = (e.attr >> 16) & 0xFFFF
e_error = int(e.code.replace("0x", ""), 16) if e.code else 0
# The low half only: an hms[] code carries its alert
# level above it (0x18061 for 0500_8061), and read
# whole it never matched, listing the fault twice (M-U9).
e_error = (int(e.code.replace("0x", ""), 16) & 0xFFFF) if e.code else 0
existing_short_codes.add(f"{e_module:04X}_{e_error:04X}")
if short_code not in existing_short_codes:
@@ -7859,6 +7883,7 @@ class BambuMQTTClient:
# Track this load request for H2D dual-nozzle disambiguation
# H2D reports only slot number (0-3) in tray_now, so we use our tracked value
self.state.pending_tray_target = tray_id
self.state.pending_tray_target_at = time.monotonic()
logger.info("[%s] Set pending_tray_target=%s for H2D disambiguation", self.serial_number, tray_id)
return True
@@ -7965,6 +7990,7 @@ class BambuMQTTClient:
# Clear tracked load request since we're unloading
self.state.pending_tray_target = None
self.state.pending_tray_target_at = None
logger.info("[%s] Cleared pending_tray_target (unload)", self.serial_number)
return True
+6 -1
View File
@@ -1070,7 +1070,12 @@ async def on_print_complete(
# 100 g/$X print with a 10 g/$X/10 partial (#1378).
from backend.app.services.print_log import other_runs_of
if not await other_runs_of(db, archive):
# Nor from a run that did not finish: the archive's cost is one
# full print of the file -- the queue estimate, plate scaling and
# billing read it so -- and a failed run's partial cost already
# lands on its own Print Log entry. Spoolman mode never wrote it
# either (COST-3).
if status == "completed" and not await other_runs_of(db, archive):
archive.cost = round(total_cost, 2)
await db.commit()
@@ -308,6 +308,60 @@ class TestLibraryFilesAPI:
assert len(result) == 1
assert result[0]["id"] == other_file.id
@pytest.mark.asyncio
@pytest.mark.integration
@pytest.mark.parametrize("direction", ["into", "out of"])
async def test_an_edit_does_not_move_a_file_across_the_external_boundary(
self, async_client: AsyncClient, folder_factory, file_factory, db_session, direction
):
"""F8: the edit route only changed folder_id -- no byte move, no
read-only check -- so the file showed in a folder its bytes are not
in. Such a move goes through POST /library/files/move."""
internal = await folder_factory(name="Mine")
nas = await folder_factory(name="NAS", is_external=True, external_path="/mnt/nas")
if direction == "into":
file = await file_factory(folder_id=internal.id, filename="a.3mf", is_external=False)
target = nas.id
else:
file = await file_factory(folder_id=nas.id, filename="a.3mf", is_external=True)
target = internal.id
response = await async_client.put(f"/api/v1/library/files/{file.id}", json={"folder_id": target})
assert response.status_code == 400
assert "/library/files/move" in response.json()["detail"]
await db_session.refresh(file)
assert file.folder_id != target
@pytest.mark.asyncio
@pytest.mark.integration
async def test_an_external_file_sent_with_its_own_folder_is_no_move(
self, async_client: AsyncClient, folder_factory, file_factory
):
nas = await folder_factory(name="NAS", is_external=True, external_path="/mnt/nas")
file = await file_factory(folder_id=nas.id, filename="a.3mf", is_external=True)
response = await async_client.put(
f"/api/v1/library/files/{file.id}", json={"folder_id": nas.id, "notes": "kept"}
)
assert response.status_code == 200
@pytest.mark.asyncio
@pytest.mark.integration
async def test_an_edit_still_moves_a_file_between_its_own_folders(
self, async_client: AsyncClient, folder_factory, file_factory, db_session
):
first = await folder_factory(name="One")
second = await folder_factory(name="Two")
file = await file_factory(folder_id=first.id, filename="a.3mf", is_external=False)
response = await async_client.put(f"/api/v1/library/files/{file.id}", json={"folder_id": second.id})
assert response.status_code == 200
await db_session.refresh(file)
assert file.folder_id == second.id
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_files_internal_only(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
@@ -164,6 +164,14 @@ async def test_a_completion_reconciled_at_reconnect_has_no_actual_duration(test_
assert archive_data["actual_time_seconds"] is None
async def test_completion_stops_the_start_estimate_before_writing_the_cost(test_engine, db_session, queued_print):
"""COST-4: the background estimate must not outlive the real cost."""
with patch("backend.app.main._settle_archive_cost_estimate", new_callable=AsyncMock) as settle:
await _complete(test_engine, queued_print["printer"], "completed")
settle.assert_awaited_once_with(queued_print["archive"])
async def test_a_failed_print(test_engine, db_session, queued_print):
await _complete(test_engine, queued_print["printer"], "failed")
@@ -377,3 +377,41 @@ class TestRaceWithCompletion:
await db_session.refresh(archive)
assert archive.cost == 1.23
ws.assert_not_awaited()
class TestCompletionStopsTheEstimate:
@pytest.mark.asyncio
async def test_settling_cancels_a_running_estimate_before_it_writes(self):
"""COST-4: completion commits the real cost before it marks the archive
ended, so an estimate still waiting on Spoolman saw "printing" and wrote
over it. Completion now stops the estimate first."""
import asyncio
from contextlib import asynccontextmanager
wrote: list[int] = []
blocked = asyncio.Event()
async def slow_estimate(*_args):
await blocked.wait()
wrote.append(1)
@asynccontextmanager
async def fake_session():
yield object()
with (
patch("backend.app.core.database.async_session", new=fake_session),
patch.object(archive_cost_estimate, "estimate_archive_cost_at_start", new=slow_estimate),
):
task = archive_cost_estimate.schedule_archive_cost_estimate(3, 42, "pm")
await asyncio.sleep(0)
await archive_cost_estimate.settle_archive_cost_estimate(42)
blocked.set()
await asyncio.sleep(0)
assert task.cancelled()
assert wrote == []
@pytest.mark.asyncio
async def test_settling_with_nothing_running_is_harmless(self):
await archive_cost_estimate.settle_archive_cost_estimate(9999)
+44 -10
View File
@@ -3041,6 +3041,19 @@ class TestTrayNowDualNozzleH2DPendingTarget(_H2DFixtureMixin):
assert h2d_client.state.tray_now == 5
assert h2d_client.state.pending_tray_target is None # cleared
def test_a_pending_target_from_a_load_long_ago_is_not_used(self, h2d_client):
"""FW-C7: a manual load the printer refused leaves its target pending
(tray_now stays 255, which keeps it). A print loading the same slot
number on another AMS much later was shown as that old target."""
import time
h2d_client.state.pending_tray_target = 5
h2d_client.state.pending_tray_target_at = time.monotonic() - 600
h2d_client._process_message(_ams_payload(1))
assert h2d_client.state.tray_now != 5
assert h2d_client.state.pending_tray_target is None
def test_pending_target_slot_mismatch(self, h2d_client):
"""pending=5, tray_now='2' → uses raw slot, clears pending."""
h2d_client.state.pending_tray_target = 5
@@ -5545,6 +5558,14 @@ class TestHMSUserActionFiltering:
assert len(mqtt_client.state.hms_errors) == 1
assert mqtt_client.state.hms_errors[0].code == "0x8061"
def test_a_fault_in_both_hms_and_print_error_is_listed_once(self, mqtt_client):
"""M-U9: an hms[] code carries its alert level in the high half
(0x00018061); the dedupe read it unmasked, so it never matched
print_error's 0500_8061 and the card listed the fault twice."""
mqtt_client._update_state({"hms": [{"attr": 0x05000100, "code": 0x00018061}], "print_error": 0x0500_8061})
assert len(mqtt_client.state.hms_errors) == 1
class TestHMSFullCode:
"""full_code is the firmware-matching key for HMS-related commands.
@@ -8474,6 +8495,29 @@ class TestAmsFilamentSettingRefusalLogging:
assert "ams_id=0" in refusals[0]
assert "tray_id=1" in refusals[0]
def test_a_refused_k_profile_selection_is_logged_too(self, mqtt_client, caplog):
"""FW-C3: an RFID re-read re-applies the spool's K-profile and logs
"Applied PA profile"; the printer's refusal of it stayed at DEBUG, so
a support bundle showed only the success."""
caplog.set_level(logging.INFO, logger="backend.app.services.bambu_mqtt")
mqtt_client._process_message(
{
"print": {
"command": "extrusion_cali_sel",
"result": "fail",
"reason": "cali_idx not found",
"ams_id": 0,
"tray_id": 2,
"sequence_id": "0",
}
}
)
lines = [line for line in caplog.text.splitlines() if "extrusion_cali_sel refused" in line]
assert len(lines) == 1
assert "cali_idx not found" in lines[0]
def test_success_stays_quiet(self, mqtt_client, caplog):
caplog.set_level(logging.INFO, logger="backend.app.services.bambu_mqtt")
@@ -8530,16 +8574,6 @@ class TestAmsFilamentSettingRefusalLogging:
assert len(self._refusals(caplog)) == 1
def test_extrusion_cali_sel_is_untouched(self, mqtt_client, caplog):
"""The sibling in the same branch keeps its DEBUG-only handling; this
change is scoped to the write #2756 is about."""
caplog.set_level(logging.INFO, logger="backend.app.services.bambu_mqtt")
mqtt_client._process_message({"print": {"command": "extrusion_cali_sel", "result": "fail", "sequence_id": "0"}})
assert self._refusals(caplog) == []
assert "extrusion_cali_sel" not in caplog.text
class TestDryingCycleEndReport:
"""#2863 — the cycle-end report carries what was seen of the cycle, so the
@@ -0,0 +1,86 @@
"""The cloud filament-ID map is built once per account at a time, without a
request holding a database connection (RC-C11).
When the 5-minute cache expired, every K-profile view asked at once and each
ran the full 1 + N Bambu Cloud fetch while its request held a database
connection idle in transaction.
"""
import asyncio
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from backend.app.api.routes import cloud
pytestmark = pytest.mark.asyncio
@pytest.fixture(autouse=True)
def _empty():
cloud.forget_all_cloud_presets()
yield
cloud.forget_all_cloud_presets()
def _slow_cloud(release: asyncio.Event):
async def settings():
await release.wait()
return {"filament": {"private": [{"setting_id": "S1"}]}}
return SimpleNamespace(
is_authenticated=True,
get_slicer_settings=AsyncMock(side_effect=settings),
get_setting_detail=AsyncMock(return_value={"filament_id": "P1", "name": "Mine @Bambu Lab X1C"}),
close=AsyncMock(),
)
@asynccontextmanager
async def _own_session():
yield "own session"
async def test_views_arriving_together_share_one_build():
release = asyncio.Event()
built: list = []
async def build(db, user):
# A real token read awaits the database.
await asyncio.sleep(0.001)
built.append(db)
return _slow_cloud(release)
user = SimpleNamespace(id=7)
with (
patch.object(cloud, "build_authenticated_cloud", new=build),
patch("backend.app.core.database.async_session", new=_own_session),
):
calls = [asyncio.create_task(cloud.get_filament_id_map(current_user=user)) for _ in range(3)]
await asyncio.sleep(0.05)
release.set()
results = await asyncio.gather(*calls)
assert results == [{"P1": "Mine"}] * 3
assert built == ["own session"], "every view built the map itself, or in its request's session"
async def test_a_build_running_when_the_account_signs_out_is_not_cached():
release = asyncio.Event()
async def build(db, user):
return _slow_cloud(release)
with (
patch.object(cloud, "build_authenticated_cloud", new=build),
patch("backend.app.core.database.async_session", new=_own_session),
):
call = asyncio.create_task(cloud.get_filament_id_map(current_user=SimpleNamespace(id=7)))
await asyncio.sleep(0.05)
cloud._forget_account_presets(7)
release.set()
await call
assert 7 not in cloud._filament_id_name_cache
+66
View File
@@ -443,6 +443,72 @@ class TestCostCalculation:
# Cost = 20g / 1000 * 20.0 = 0.40
assert results[0]["cost"] == 0.40
async def test_a_failed_first_run_leaves_the_full_print_cost_on_the_archive(self):
"""COST-3: an archive's cost is one full print of the file -- what the
queue estimate, plate scaling and billing read it as. A run that failed
half way charged its partial cost to its Print Log entry, and also
wrote it onto the archive, so a reprint was estimated and billed from
half a print. Spoolman mode never did."""
spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=20.0)
assignment = _make_assignment(spool_id=1)
archive = _make_archive(archive_id=10)
archive.cost = 5.0 # the full-print estimate from print start
_active_sessions[1] = PrintSession(
printer_id=1,
print_name="Test",
started_at=datetime.now(timezone.utc),
tray_remain_start={(0, 0): 80},
tray_now_at_start=0,
)
# Failed at 50% progress
printer_manager = MagicMock()
printer_manager.get_status.return_value = SimpleNamespace(
raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
progress=50,
layer_num=25,
tray_now=0,
)
# db returns: archive, queue_item(None), assignment, spool
# ... and the archive again for the cost it carries
db = _mock_db_sequential([archive, None, assignment, spool, archive])
# 40g total, but only 50% used
filament_usage = [{"slot_id": 1, "used_g": 40.0, "type": "PLA", "color": "#FF0000"}]
with (
# Its first run: the archive's cost is the one it would take.
patch("backend.app.services.print_log.other_runs_of", AsyncMock(return_value=[])),
patch("backend.app.core.config.settings") as mock_settings,
patch("backend.app.api.routes.settings.get_setting", return_value="15.0"),
patch(
"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
return_value=filament_usage,
),
patch(
"backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
return_value=None, # No layer data, use linear scaling
),
):
mock_settings.base_dir = MagicMock()
mock_path = MagicMock()
mock_path.exists.return_value = True
mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
results = await on_print_complete(
printer_id=1,
data={"status": "failed", "last_progress": 50.0},
printer_manager=printer_manager,
db=db,
archive_id=10,
)
# The run's own cost is still measured (it goes to its Print Log entry).
assert results[0]["cost"] == 0.40
assert archive.cost == 5.0
async def _failed_after_firmware_reset(self, *, last_progress=0.0, last_layer_num=0, layer_usage=None):
"""A failed print whose printer already reset progress and layer to 0."""
spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=20.0)
@@ -0,0 +1,31 @@
"""A 2FA or SSO login reports the user's real auth source (PC-C9).
mfa.py kept its own copy of the user-response builder, and the copy left out
``auth_source``: after a 2FA or OIDC login the schema default "local" reached
the page, which then offered an LDAP or OIDC user "Change Password".
"""
from datetime import datetime
from types import SimpleNamespace
import pytest
from backend.app.api.routes import mfa
@pytest.mark.parametrize("source", ["ldap", "oidc", "local"])
def test_the_login_response_carries_the_auth_source(source):
user = SimpleNamespace(
id=3,
username="ana",
email=None,
role="user",
is_active=True,
is_admin=False,
auth_source=source,
groups=[],
get_permissions=lambda: set(),
created_at=datetime(2026, 10, 1),
)
assert mfa._user_to_response(user).auth_source == source
@@ -0,0 +1,44 @@
"""A slow slice-progress answer is reported as slow, not as an unreachable sidecar (TO-C14).
A heavy slice can keep the sidecar from answering its progress poll within
5 s; httpx's ReadTimeout is a RequestError, so the proxy said "Slicer sidecar
unreachable". The poller retries on 503 either way; the text was wrong.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from fastapi import HTTPException
from backend.app.api.routes import slicer_presets
pytestmark = pytest.mark.asyncio
async def _poll(error: Exception) -> HTTPException:
client = MagicMock()
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=False)
client.get = AsyncMock(side_effect=error)
with (
patch.object(slicer_presets, "_resolve_slicer_api_url", AsyncMock(return_value="http://sidecar:3000")),
patch("httpx.AsyncClient", return_value=client),
pytest.raises(HTTPException) as raised,
):
await slicer_presets.get_preview_slice_progress(request_id="abc", db=MagicMock(), _=(None, True))
return raised.value
async def test_a_timeout_says_the_sidecar_is_busy():
refused = await _poll(httpx.ReadTimeout("read timed out"))
assert refused.status_code == 503
assert "unreachable" not in refused.detail
assert "did not answer in time" in refused.detail
async def test_a_refused_connection_is_still_unreachable():
refused = await _poll(httpx.ConnectError("connection refused"))
assert (refused.status_code, refused.detail) == (503, "Slicer sidecar unreachable")