diff --git a/CHANGELOG.md b/CHANGELOG.md index 522cefb70..f545a2c5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/backend/app/api/routes/cloud.py b/backend/app/api/routes/cloud.py index 5110c1885..638daa30e 100644 --- a/backend/app/api/routes/cloud.py +++ b/backend/app/api/routes/cloud.py @@ -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() diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index 1c937851b..a0ac11790 100644 --- a/backend/app/api/routes/library.py +++ b/backend/app/api/routes/library.py @@ -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: diff --git a/backend/app/api/routes/mfa.py b/backend/app/api/routes/mfa.py index b87874fea..55d6176c0 100644 --- a/backend/app/api/routes/mfa.py +++ b/backend/app/api/routes/mfa.py @@ -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) # --------------------------------------------------------------------------- diff --git a/backend/app/api/routes/slicer_presets.py b/backend/app/api/routes/slicer_presets.py index 9fb7dc01d..349d69716 100644 --- a/backend/app/api/routes/slicer_presets.py +++ b/backend/app/api/routes/slicer_presets.py @@ -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. diff --git a/backend/app/main.py b/backend/app/main.py index c8d82ec36..5a551712d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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: diff --git a/backend/app/services/archive_cost_estimate.py b/backend/app/services/archive_cost_estimate.py index cafa193d8..a62d5e99b 100644 --- a/backend/app/services/archive_cost_estimate.py +++ b/backend/app/services/archive_cost_estimate.py @@ -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( diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index ece42ade8..e12e0328f 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -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/" – 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 diff --git a/backend/app/services/usage_tracker.py b/backend/app/services/usage_tracker.py index f9bb696d7..b6ef7536a 100644 --- a/backend/app/services/usage_tracker.py +++ b/backend/app/services/usage_tracker.py @@ -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() diff --git a/backend/tests/integration/test_library_api.py b/backend/tests/integration/test_library_api.py index ebdcb5cb4..c5f4f5ec7 100644 --- a/backend/tests/integration/test_library_api.py +++ b/backend/tests/integration/test_library_api.py @@ -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): diff --git a/backend/tests/integration/test_print_complete_end_to_end.py b/backend/tests/integration/test_print_complete_end_to_end.py index 1e2251748..92c2b0238 100644 --- a/backend/tests/integration/test_print_complete_end_to_end.py +++ b/backend/tests/integration/test_print_complete_end_to_end.py @@ -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") diff --git a/backend/tests/unit/services/test_archive_cost_estimate_3261.py b/backend/tests/unit/services/test_archive_cost_estimate_3261.py index 16afee70c..f437dfba3 100644 --- a/backend/tests/unit/services/test_archive_cost_estimate_3261.py +++ b/backend/tests/unit/services/test_archive_cost_estimate_3261.py @@ -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) diff --git a/backend/tests/unit/services/test_bambu_mqtt.py b/backend/tests/unit/services/test_bambu_mqtt.py index c45e06af7..9cb9622f4 100644 --- a/backend/tests/unit/services/test_bambu_mqtt.py +++ b/backend/tests/unit/services/test_bambu_mqtt.py @@ -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 diff --git a/backend/tests/unit/test_cloud_filament_id_map_single_flight.py b/backend/tests/unit/test_cloud_filament_id_map_single_flight.py new file mode 100644 index 000000000..70b7d2dd2 --- /dev/null +++ b/backend/tests/unit/test_cloud_filament_id_map_single_flight.py @@ -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 diff --git a/backend/tests/unit/test_cost_tracking.py b/backend/tests/unit/test_cost_tracking.py index 50ca44ccf..46add143c 100644 --- a/backend/tests/unit/test_cost_tracking.py +++ b/backend/tests/unit/test_cost_tracking.py @@ -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) diff --git a/backend/tests/unit/test_mfa_login_response_auth_source.py b/backend/tests/unit/test_mfa_login_response_auth_source.py new file mode 100644 index 000000000..fab6fd507 --- /dev/null +++ b/backend/tests/unit/test_mfa_login_response_auth_source.py @@ -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 diff --git a/backend/tests/unit/test_slice_progress_timeout_detail.py b/backend/tests/unit/test_slice_progress_timeout_detail.py new file mode 100644 index 000000000..29a03139e --- /dev/null +++ b/backend/tests/unit/test_slice_progress_timeout_detail.py @@ -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")