mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
510 lines
20 KiB
Python
510 lines
20 KiB
Python
"""Cloud-profile collection for Git backup (#2717).
|
|
|
|
The collector used to read a ``setting`` key the Bambu Cloud API never returns,
|
|
so ``cloud_profiles/*`` was never written while ``backup_metadata.json`` claimed
|
|
it was. It also asked for the auth-disabled credential store unconditionally,
|
|
which meant it saw no accounts at all once auth was on. These tests pin the
|
|
response shape it actually has to parse, the account enumeration, and the
|
|
metadata now telling the truth.
|
|
"""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from backend.app.models.settings import Settings
|
|
from backend.app.models.user import User
|
|
from backend.app.services.github_backup import GitHubBackupService
|
|
|
|
# The real listing body: keyed by preset type, each holding private/public
|
|
# lists. There is no top-level "setting" array, and the entries carry no "type"
|
|
# of their own — the type is the outer key, and Bambu calls process "print".
|
|
BAMBU_LISTING = {
|
|
"filament": {
|
|
"private": [
|
|
{"setting_id": "PFUS1", "name": "My PLA", "version": "1.0", "user_id": "u-123"},
|
|
],
|
|
"public": [
|
|
{"setting_id": "GFSA00", "name": "Bambu PLA Basic", "version": "1.0"},
|
|
],
|
|
},
|
|
"printer": {
|
|
"private": [{"setting_id": "PMUS1", "name": "My X1C", "version": "1.0"}],
|
|
"public": [],
|
|
},
|
|
"print": {
|
|
"private": [{"setting_id": "PSUS1", "name": "My 0.2mm", "version": "1.0"}],
|
|
"public": [],
|
|
},
|
|
}
|
|
|
|
|
|
def _detail(setting_id: str, name: str, base: str) -> dict:
|
|
return {
|
|
"setting_id": setting_id,
|
|
"name": name,
|
|
"type": "filament",
|
|
"version": "1.0",
|
|
"base_id": base,
|
|
"filament_id": "P1234",
|
|
"setting": {"filament_flow_ratio": ["0.98"]},
|
|
}
|
|
|
|
|
|
def _bambu_cloud(listing=None, detail_side_effect=None):
|
|
cloud = MagicMock()
|
|
cloud.is_authenticated = True
|
|
cloud.get_slicer_settings = AsyncMock(return_value=listing if listing is not None else BAMBU_LISTING)
|
|
cloud.get_setting_detail = AsyncMock(
|
|
side_effect=detail_side_effect or (lambda sid: _detail(sid, f"detail-{sid}", "GFSA00")),
|
|
)
|
|
cloud.close = AsyncMock()
|
|
return cloud
|
|
|
|
|
|
def _orca_service(profiles):
|
|
svc = MagicMock()
|
|
svc.list_profiles = AsyncMock(return_value=profiles)
|
|
svc.close = AsyncMock()
|
|
return svc
|
|
|
|
|
|
@pytest.fixture
|
|
def service():
|
|
return GitHubBackupService()
|
|
|
|
|
|
class TestCloudAccountEnumeration:
|
|
"""Which accounts a backup collects from."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auth_disabled_uses_the_global_store(self, service, db_session):
|
|
"""With auth off there is no User row at all — credentials live in the
|
|
Settings table and the account is keyed ``global``."""
|
|
with (
|
|
patch(
|
|
"backend.app.services.bambu_cloud_credentials.get_stored_token",
|
|
new_callable=AsyncMock,
|
|
return_value=("bambu-token", "a@b.c", "global"),
|
|
),
|
|
patch(
|
|
"backend.app.api.routes.orca_cloud._load_credentials",
|
|
new_callable=AsyncMock,
|
|
return_value=MagicMock(token=None),
|
|
),
|
|
):
|
|
bambu, orca = await service.cloud_accounts(db_session)
|
|
|
|
assert bambu == [("global", None)]
|
|
assert orca == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auth_enabled_finds_every_user_holding_a_token(self, service, db_session):
|
|
"""The bug that made this invisible: with auth on, tokens live on User
|
|
rows, and the collector only ever looked at the global store. Each cloud
|
|
is enumerated separately so a user connected to one shows up only there.
|
|
"""
|
|
both = User(username="both", cloud_token="t1", orca_cloud_token="o1")
|
|
bambu_only = User(username="bambu-only", cloud_token="t2")
|
|
orca_only = User(username="orca-only", orca_cloud_token="o2")
|
|
neither = User(username="neither")
|
|
db_session.add_all([both, bambu_only, orca_only, neither])
|
|
await db_session.commit()
|
|
|
|
with (
|
|
patch(
|
|
"backend.app.services.bambu_cloud_credentials.get_stored_token",
|
|
new_callable=AsyncMock,
|
|
return_value=(None, None, "global"),
|
|
),
|
|
patch(
|
|
"backend.app.api.routes.orca_cloud._load_credentials",
|
|
new_callable=AsyncMock,
|
|
return_value=MagicMock(token=None),
|
|
),
|
|
):
|
|
bambu, orca = await service.cloud_accounts(db_session)
|
|
|
|
assert sorted(key for key, _ in bambu) == [f"user-{both.id}", f"user-{bambu_only.id}"]
|
|
assert sorted(key for key, _ in orca) == [f"user-{both.id}", f"user-{orca_only.id}"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_global_and_per_user_accounts_coexist(self, service, db_session):
|
|
"""A Settings row survives someone enabling auth later. Dropping it
|
|
would silently stop backing up that account's presets."""
|
|
db_session.add(User(username="u", cloud_token="t1"))
|
|
await db_session.commit()
|
|
|
|
with (
|
|
patch(
|
|
"backend.app.services.bambu_cloud_credentials.get_stored_token",
|
|
new_callable=AsyncMock,
|
|
return_value=("legacy-global", None, "global"),
|
|
),
|
|
patch(
|
|
"backend.app.api.routes.orca_cloud._load_credentials",
|
|
new_callable=AsyncMock,
|
|
return_value=MagicMock(token=None),
|
|
),
|
|
):
|
|
bambu, _orca = await service.cloud_accounts(db_session)
|
|
|
|
assert "global" in [key for key, _ in bambu]
|
|
assert len(bambu) == 2
|
|
|
|
|
|
class TestBambuCollection:
|
|
@pytest.mark.asyncio
|
|
async def test_reads_the_shape_the_api_actually_returns(self, service, db_session):
|
|
"""The whole bug in one assertion: presets come out of
|
|
``data[type]["private"]``, not a flat ``setting`` list, and ``print``
|
|
maps to ``process``."""
|
|
files: dict = {}
|
|
with patch(
|
|
"backend.app.api.routes.cloud.build_authenticated_cloud",
|
|
new_callable=AsyncMock,
|
|
return_value=_bambu_cloud(),
|
|
):
|
|
counts = await service._collect_bambu_profiles(db_session, files, "global", None)
|
|
|
|
assert counts == {"filament": 1, "printer": 1, "process": 1}
|
|
assert set(files) == {
|
|
"cloud_profiles/bambu/global/filament.json",
|
|
"cloud_profiles/bambu/global/printer.json",
|
|
"cloud_profiles/bambu/global/process.json",
|
|
}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_public_presets_are_not_backed_up(self, service, db_session):
|
|
"""Bambu's bundled catalogue is identical for everyone, re-downloadable,
|
|
and not recreatable under your account — backing it up would churn the
|
|
repository on every run for no recovery value."""
|
|
files: dict = {}
|
|
with patch(
|
|
"backend.app.api.routes.cloud.build_authenticated_cloud",
|
|
new_callable=AsyncMock,
|
|
return_value=_bambu_cloud(),
|
|
):
|
|
await service._collect_bambu_profiles(db_session, files, "global", None)
|
|
|
|
filament = files["cloud_profiles/bambu/global/filament.json"]["profiles"]
|
|
assert [p["setting_id"] for p in filament] == ["PFUS1"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stores_the_payload_a_restore_needs(self, service, db_session):
|
|
"""The listing is metadata only. Without ``base_id`` and ``setting``
|
|
the backup is a list of names — ``create_setting`` cannot rebuild from
|
|
it."""
|
|
files: dict = {}
|
|
with patch(
|
|
"backend.app.api.routes.cloud.build_authenticated_cloud",
|
|
new_callable=AsyncMock,
|
|
return_value=_bambu_cloud(),
|
|
):
|
|
await service._collect_bambu_profiles(db_session, files, "global", None)
|
|
|
|
preset = files["cloud_profiles/bambu/global/filament.json"]["profiles"][0]
|
|
assert preset["base_id"] == "GFSA00"
|
|
assert preset["setting"] == {"filament_flow_ratio": ["0.98"]}
|
|
assert preset["type"] == "filament"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_account_identity_is_not_written_to_the_repo(self, service, db_session):
|
|
"""Backup repositories can be public, and ``user_id`` adds nothing to a
|
|
rebuild."""
|
|
files: dict = {}
|
|
with patch(
|
|
"backend.app.api.routes.cloud.build_authenticated_cloud",
|
|
new_callable=AsyncMock,
|
|
return_value=_bambu_cloud(),
|
|
):
|
|
await service._collect_bambu_profiles(db_session, files, "global", None)
|
|
|
|
for payload in files.values():
|
|
for preset in payload["profiles"]:
|
|
assert "user_id" not in preset
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_one_unreadable_preset_does_not_lose_the_others(self, service, db_session):
|
|
"""And it is counted, not swallowed — a partial backup that looks
|
|
complete is how #2717 stayed invisible."""
|
|
|
|
def detail(setting_id):
|
|
if setting_id == "PFUS1":
|
|
raise RuntimeError("boom")
|
|
return _detail(setting_id, "ok", "GFSA00")
|
|
|
|
files: dict = {}
|
|
with patch(
|
|
"backend.app.api.routes.cloud.build_authenticated_cloud",
|
|
new_callable=AsyncMock,
|
|
return_value=_bambu_cloud(detail_side_effect=detail),
|
|
):
|
|
counts = await service._collect_bambu_profiles(db_session, files, "global", None)
|
|
|
|
assert "cloud_profiles/bambu/global/filament.json" not in files
|
|
assert counts["printer"] == 1
|
|
assert counts["process"] == 1
|
|
assert counts["failed"] == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unauthenticated_account_writes_nothing(self, service, db_session):
|
|
files: dict = {}
|
|
with patch(
|
|
"backend.app.api.routes.cloud.build_authenticated_cloud",
|
|
new_callable=AsyncMock,
|
|
return_value=None,
|
|
):
|
|
counts = await service._collect_bambu_profiles(db_session, files, "user-1", MagicMock())
|
|
|
|
assert counts == {}
|
|
assert files == {}
|
|
|
|
|
|
class TestOrcaCollection:
|
|
@pytest.mark.asyncio
|
|
async def test_groups_by_content_type_including_aliases(self, service, db_session):
|
|
"""Orca carries the type at ``content.type`` and uses BambuStudio-style
|
|
aliases — ``machine`` is a printer, ``process`` and ``print`` are both
|
|
process. Same map the Orca tab groups by."""
|
|
profiles = [
|
|
{"id": 1, "name": "f", "content": {"type": "filament"}},
|
|
{"id": 2, "name": "m", "content": {"type": "machine"}},
|
|
{"id": 3, "name": "p", "content": {"type": "print"}},
|
|
{"id": 4, "name": "p2", "content": {"type": "process"}},
|
|
]
|
|
files: dict = {}
|
|
with patch(
|
|
"backend.app.api.routes.orca_cloud._build_authenticated_service",
|
|
new_callable=AsyncMock,
|
|
return_value=_orca_service(profiles),
|
|
):
|
|
counts = await service._collect_orca_profiles(db_session, files, "user-3", MagicMock())
|
|
|
|
assert counts == {"filament": 1, "printer": 1, "process": 2}
|
|
assert "cloud_profiles/orca/user-3/printer.json" in files
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_content_is_stored_inline_without_a_second_fetch(self, service, db_session):
|
|
"""The sync-pull listing already carries each profile's content, so
|
|
unlike Bambu there is no per-profile round trip."""
|
|
svc = _orca_service([{"id": 7, "name": "f", "content": {"type": "filament", "flow": 0.98}}])
|
|
files: dict = {}
|
|
with patch(
|
|
"backend.app.api.routes.orca_cloud._build_authenticated_service",
|
|
new_callable=AsyncMock,
|
|
return_value=svc,
|
|
):
|
|
await service._collect_orca_profiles(db_session, files, "global", None)
|
|
|
|
stored = files["cloud_profiles/orca/global/filament.json"]["profiles"][0]
|
|
assert stored["content"] == {"type": "filament", "flow": 0.98}
|
|
assert svc.list_profiles.await_count == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unmapped_types_are_kept_not_dropped(self, service, db_session):
|
|
"""The Orca *route* drops profiles whose type it can't render, which is
|
|
right for a list and wrong for a backup: silently omitting a profile
|
|
because Orca added a type is the same class of bug as #2717."""
|
|
profiles = [
|
|
{"id": 1, "name": "f", "content": {"type": "filament"}},
|
|
{"id": 2, "name": "x", "content": {"type": "something_new"}},
|
|
{"id": 3, "name": "y", "content": {}},
|
|
]
|
|
files: dict = {}
|
|
with patch(
|
|
"backend.app.api.routes.orca_cloud._build_authenticated_service",
|
|
new_callable=AsyncMock,
|
|
return_value=_orca_service(profiles),
|
|
):
|
|
counts = await service._collect_orca_profiles(db_session, files, "global", None)
|
|
|
|
assert counts["other"] == 2
|
|
assert len(files["cloud_profiles/orca/global/other.json"]["profiles"]) == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dead_pairing_writes_nothing_and_does_not_raise(self, service, db_session):
|
|
"""An unexpected failure building the Orca client must not abort the
|
|
rest of the backup — the other accounts and the other cloud still have
|
|
profiles worth collecting."""
|
|
files: dict = {}
|
|
with patch(
|
|
"backend.app.api.routes.orca_cloud._build_authenticated_service",
|
|
new_callable=AsyncMock,
|
|
side_effect=RuntimeError("session expired"),
|
|
):
|
|
counts = await service._collect_orca_profiles(db_session, files, "user-2", MagicMock())
|
|
|
|
assert counts == {}
|
|
assert files == {}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_the_backup_never_disconnects_an_account(self, service, db_session):
|
|
"""A backup is an observer. It must not change anyone's sign-in state
|
|
on a schedule — least of all on Orca's composite rejection reason,
|
|
which cannot tell a real revocation from a lost refresh-rotation race.
|
|
The Profiles route clears the dead pairing instead, with the user
|
|
present to act on it.
|
|
"""
|
|
from fastapi import HTTPException
|
|
|
|
build = AsyncMock(side_effect=HTTPException(status_code=401, detail="grant already used"))
|
|
files: dict = {}
|
|
with patch("backend.app.api.routes.orca_cloud._build_authenticated_service", build):
|
|
counts = await service._collect_orca_profiles(db_session, files, "global", None)
|
|
|
|
assert counts == {}
|
|
assert build.await_args.kwargs["clear_on_auth_failure"] is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_rejected_session_says_it_will_keep_being_skipped(self, service, db_session, caplog):
|
|
"""Not clearing means the warning recurs every run, so the one line the
|
|
operator sees has to say how to stop it."""
|
|
from fastapi import HTTPException
|
|
|
|
files: dict = {}
|
|
with (
|
|
caplog.at_level("WARNING"),
|
|
patch(
|
|
"backend.app.api.routes.orca_cloud._build_authenticated_service",
|
|
new_callable=AsyncMock,
|
|
side_effect=HTTPException(status_code=401, detail="refresh rejected: grant already used"),
|
|
),
|
|
):
|
|
counts = await service._collect_orca_profiles(db_session, files, "global", None)
|
|
|
|
assert counts == {}
|
|
assert "paired again" in caplog.text
|
|
assert "Later runs will skip it too" in caplog.text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_unreachable_orca_is_a_transient_skip(self, service, db_session, caplog):
|
|
"""502 is very likely gone by the next run, so it must not carry the
|
|
"go and re-pair" advice a rejected session does."""
|
|
from fastapi import HTTPException
|
|
|
|
files: dict = {}
|
|
with (
|
|
caplog.at_level("WARNING"),
|
|
patch(
|
|
"backend.app.api.routes.orca_cloud._build_authenticated_service",
|
|
new_callable=AsyncMock,
|
|
side_effect=HTTPException(status_code=502, detail="Orca Cloud unreachable: timeout"),
|
|
),
|
|
):
|
|
counts = await service._collect_orca_profiles(db_session, files, "user-9", None)
|
|
|
|
assert counts == {}
|
|
assert "unreachable" in caplog.text
|
|
assert "paired again" not in caplog.text
|
|
|
|
|
|
class TestCollectorAndMetadata:
|
|
@pytest.mark.asyncio
|
|
async def test_no_connected_account_collects_nothing(self, service, db_session):
|
|
files: dict = {}
|
|
with (
|
|
patch(
|
|
"backend.app.services.bambu_cloud_credentials.get_stored_token",
|
|
new_callable=AsyncMock,
|
|
return_value=(None, None, "global"),
|
|
),
|
|
patch(
|
|
"backend.app.api.routes.orca_cloud._load_credentials",
|
|
new_callable=AsyncMock,
|
|
return_value=MagicMock(token=None),
|
|
),
|
|
):
|
|
summary = await service._collect_cloud_profiles(db_session, files)
|
|
|
|
assert summary == {"bambu": {}, "orca": {}}
|
|
assert files == {}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_one_failing_account_does_not_stop_the_others(self, service, db_session):
|
|
a = User(username="a", cloud_token="t1")
|
|
b = User(username="b", cloud_token="t2")
|
|
db_session.add_all([a, b])
|
|
await db_session.commit()
|
|
|
|
def build(db, user=None):
|
|
if user is not None and user.username == "a":
|
|
raise RuntimeError("cloud down for this account")
|
|
return _bambu_cloud()
|
|
|
|
files: dict = {}
|
|
with (
|
|
patch(
|
|
"backend.app.services.bambu_cloud_credentials.get_stored_token",
|
|
new_callable=AsyncMock,
|
|
return_value=(None, None, "global"),
|
|
),
|
|
patch(
|
|
"backend.app.api.routes.orca_cloud._load_credentials",
|
|
new_callable=AsyncMock,
|
|
return_value=MagicMock(token=None),
|
|
),
|
|
patch(
|
|
"backend.app.api.routes.cloud.build_authenticated_cloud",
|
|
new_callable=AsyncMock,
|
|
side_effect=build,
|
|
),
|
|
):
|
|
summary = await service._collect_cloud_profiles(db_session, files)
|
|
|
|
assert f"user-{a.id}" not in summary["bambu"]
|
|
assert summary["bambu"][f"user-{b.id}"] == {"filament": 1, "printer": 1, "process": 1}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_metadata_reports_collection_not_configuration(self, service, db_session):
|
|
"""``contents.cloud_profiles`` said ``true`` on every backup, including
|
|
the ones that wrote nothing. A restore has to be able to trust it."""
|
|
config = MagicMock(
|
|
backup_kprofiles=False,
|
|
backup_cloud_profiles=True,
|
|
backup_settings=False,
|
|
backup_spools=False,
|
|
backup_archives=False,
|
|
)
|
|
with patch.object(
|
|
service, "_collect_cloud_profiles", new_callable=AsyncMock, return_value={"bambu": {}, "orca": {}}
|
|
):
|
|
files = await service._collect_backup_data(db_session, config)
|
|
|
|
assert files["backup_metadata.json"]["contents"]["cloud_profiles"] is False
|
|
assert "cloud_profiles" not in files["backup_metadata.json"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_metadata_records_per_account_counts_when_collected(self, service, db_session):
|
|
config = MagicMock(
|
|
backup_kprofiles=False,
|
|
backup_cloud_profiles=True,
|
|
backup_settings=False,
|
|
backup_spools=False,
|
|
backup_archives=False,
|
|
)
|
|
summary = {"bambu": {"user-3": {"filament": 2}}, "orca": {}}
|
|
with patch.object(service, "_collect_cloud_profiles", new_callable=AsyncMock, return_value=summary):
|
|
files = await service._collect_backup_data(db_session, config)
|
|
|
|
assert files["backup_metadata.json"]["contents"]["cloud_profiles"] is True
|
|
assert files["backup_metadata.json"]["cloud_profiles"] == summary
|
|
|
|
|
|
class TestSettingsFallbackIsStillHonoured:
|
|
@pytest.mark.asyncio
|
|
async def test_global_orca_row_is_discovered(self, service, db_session):
|
|
"""Orca's auth-disabled fallback lives in the same Settings table as
|
|
Bambu's; both stores are read on every run."""
|
|
db_session.add(Settings(key="orca_cloud_token", value="oc_ext_x"))
|
|
await db_session.commit()
|
|
|
|
with patch(
|
|
"backend.app.services.bambu_cloud_credentials.get_stored_token",
|
|
new_callable=AsyncMock,
|
|
return_value=(None, None, "global"),
|
|
):
|
|
_bambu, orca = await service.cloud_accounts(db_session)
|
|
|
|
assert orca == [("global", None)]
|