Files
bambuddy/backend/tests/unit/test_vp_mode_rename_migration.py
maziggy 5d6d928b3f fix(virtual-printer): #1429 net.info[*].ip cache leak + mode wire-value rename
#1429 (reported by @TrickShotMLG02, confirmed by @Mape6 on a flat single-LAN
  that rules out subnet / mDNS-reflector theories): with the physical printer
  off the slicer's "Send" landed in Bambuddy's archive; once the printer
  powered on every subsequent "Send" went straight to the printer's SD card
  and bypassed Bambuddy. Bundle analysis: mape6-before showed clean FTP
  receive + archive lines, mape6-after had zero FTP attempts to Bambuddy
  once the printer was online.

  Cause: mqtt_bridge.py::_resolve_client encoded _target_ip_uint32_le /
  _vp_ip_uint32_le ONLY on client-identity change and early-returned on
  every refresh tick when the same client object was still bound. If
  target_client.ip_address was empty at first bind (DB row stale, or client
  constructed before SSDP refresh filled it in), the encoding stayed None,
  the net.info[*].ip rewrite block was skipped, the cache filled with the
  real printer IP, sticky-key preservation kept the poisoned net value
  alive across every subsequent incremental push, and the slicer followed
  the leaked IP. Only Bambuddy-restart-with-printer-off cleared it — the
  workaround both reporters independently arrived at. Same shape on
  multi-NIC printers (X1C, H2D Pro): the rewrite only matched entries
  whose ip equalled _target_ip_uint32_le, so a secondary interface IP
  Bambuddy never saw would leak through unchanged.

  Bridge fix:
  - _resolve_client calls a new _refresh_ip_encoding() on every refresh
    tick, even when client identity is unchanged; self-heals once
    ip_address becomes valid.
  - _refresh_ip_encoding() sweeps the existing _latest_print_state when
    encoding becomes valid for the first time. Without the sweep,
    sticky-key preservation keeps the pre-arm poisoned cache alive
    forever — incremental pushes that don't include net carry the bad
    value forward.
  - _rewrite_net_info_ips() rewrites EVERY non-zero net.info[].ip entry
    that doesn't already equal the VP IP, not just entries matching
    _target_ip_uint32_le. Multi-NIC printers stop leaking secondary
    interfaces. Zero-IP placeholders are left alone so "active interface"
    detection still works.
  - INFO logging on encoding arm/update and on cache sweep so future
    bundles directly answer "did the rewrite fire?".

  Mode wire-value rename (#1429 follow-up, separate confusion source):
  - Both reporters' support bundles showed mode: immediate while the UI
    said "Archive"; @TrickShotMLG02 quoted: "I have no idea why it says
    immediate in the support-info.json file. In the webui the printer is
    set to archive". UI button "Archive" had always saved immediate, and
    "Queue" had always saved print_queue. Canonical wire values are now
    archive / review / queue / proxy, matching the button labels 1:1.
  - New normalize_vp_mode() + VP_MODE_* constants in
    models/virtual_printer.py; manager.py normalises on construction so
    a legacy row read pre-migration still dispatches correctly.
  - core/database.py::run_migrations rewrites existing virtual_printers
    and settings rows; idempotent (re-runs are no-ops); identical SQL
    under SQLite and Postgres.
  - API routes accept both legacy and canonical on input, normalise
    before storage. GET /settings/virtual-printer normalises on read so
    the frontend's mode-button highlight works for stale legacy values.
  - Three frontend VP components (VirtualPrinterSettings,
    VirtualPrinterCard, VirtualPrinterAddDialog) switched click handlers
    and type aliases to canonical; each got its own normalizeMode()
    helper so a stale-cached settings payload still highlights the right
    button. Two pre-existing `printer.mode === 'queue' ? 'review'`
    legacy mappings in VirtualPrinterCard were the source of a test
    failure caught mid-implementation where the new canonical 'queue'
    was being mis-aliased back to 'review' and hiding the auto-dispatch
    + force-color-match toggles.

  mode handler is NOT the dispatch bug: manager.py::_archive_file (the
  handler for archive mode) doesn't dispatch to the physical printer.
  The "files end up on the printer's SD card" symptom was the IP-leak
  from the bridge cache. The mode rename is purely clarity / support-
  bundle accuracy.
2026-06-02 14:33:20 +02:00

152 lines
5.0 KiB
Python

"""Regression test for the VP mode wire-value rename migration (#1429 follow-up).
The UI buttons "Archive" and "Queue" had always saved the wire values
`immediate` and `print_queue` — confusing in every support bundle. The
rename migration in ``run_migrations`` rewrites existing rows to the
canonical names. This test verifies it on both fresh and legacy schemas
and confirms it's idempotent so reruns are safe (boot-on-boot).
"""
from __future__ import annotations
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from backend.app.core.database import run_migrations
@pytest.fixture(autouse=True)
def force_sqlite_dialect(monkeypatch):
"""Force the SQLite branch regardless of test env settings."""
from backend.app.core import db_dialect
monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
from backend.app.core import database as database_module
monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
def _register_all_models():
"""run_migrations touches multiple tables; the full schema must exist."""
from backend.app.models import ( # noqa: F401
ams_history,
ams_label,
api_key,
archive,
color_catalog,
external_link,
filament,
group,
kprofile_note,
maintenance,
notification,
notification_template,
print_log,
print_queue,
printer,
project,
project_bom,
settings,
slot_preset,
smart_plug,
smart_plug_energy_snapshot,
spool,
spool_assignment,
spool_catalog,
spool_k_profile,
spool_usage_history,
spoolbuddy_device,
user,
user_email_pref,
virtual_printer,
)
@pytest.fixture
async def engine():
from backend.app.core.database import Base
_register_all_models()
eng = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield eng
await eng.dispose()
@pytest.mark.asyncio
async def test_legacy_mode_rows_get_canonical_names(engine):
"""Existing rows with `immediate` / `print_queue` get rewritten to
`archive` / `queue` while canonical values and unrelated modes pass
through untouched."""
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO virtual_printers (id, name, enabled, mode, serial_suffix, position) VALUES "
"(1, 'A', 0, 'immediate', '391800001', 1),"
"(2, 'B', 0, 'print_queue', '391800002', 2),"
"(3, 'C', 0, 'review', '391800003', 3),"
"(4, 'D', 0, 'proxy', '391800004', 4),"
"(5, 'E', 0, 'archive', '391800005', 5),"
"(6, 'F', 0, 'queue', '391800006', 6)"
)
)
async with engine.begin() as conn:
await run_migrations(conn)
async with engine.connect() as conn:
result = await conn.execute(text("SELECT id, mode FROM virtual_printers ORDER BY id"))
rows = dict(result.fetchall())
assert rows[1] == "archive" # immediate → archive
assert rows[2] == "queue" # print_queue → queue
assert rows[3] == "review" # untouched
assert rows[4] == "proxy" # untouched
assert rows[5] == "archive" # already canonical
assert rows[6] == "queue" # already canonical
@pytest.mark.asyncio
async def test_legacy_settings_row_gets_canonical_name(engine):
"""The legacy single-VP `virtual_printer_mode` setting also gets renamed
so the GET response (which feeds the support bundle and the settings
page) reads the canonical name."""
async with engine.begin() as conn:
await conn.execute(text("INSERT INTO settings (key, value) VALUES ('virtual_printer_mode', 'immediate')"))
async with engine.begin() as conn:
await run_migrations(conn)
async with engine.connect() as conn:
result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_mode'"))
value = result.scalar()
assert value == "archive"
@pytest.mark.asyncio
async def test_migration_is_idempotent(engine):
"""Running the migration twice must be a no-op on canonical values —
every boot re-runs the migration set."""
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO virtual_printers (id, name, enabled, mode, serial_suffix, position) "
"VALUES (1, 'A', 0, 'immediate', '391800001', 1)"
)
)
async with engine.begin() as conn:
await run_migrations(conn)
# Second run on already-canonical values.
async with engine.begin() as conn:
await run_migrations(conn)
async with engine.connect() as conn:
result = await conn.execute(text("SELECT mode FROM virtual_printers WHERE id = 1"))
assert result.scalar() == "archive"