Explorar o código

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

maziggy hai 2 días
pai
achega
ac4806f029

+ 9 - 0
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.

+ 41 - 5
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()
 

+ 14 - 5
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:

+ 6 - 12
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)
 
 
 # ---------------------------------------------------------------------------

+ 4 - 0
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.

+ 5 - 0
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:

+ 30 - 1
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(

+ 34 - 8
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/<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
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()
 

+ 54 - 0
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):

+ 8 - 0
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")
 

+ 38 - 0
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)

+ 44 - 10
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

+ 86 - 0
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

+ 66 - 0
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)

+ 31 - 0
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

+ 44 - 0
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")