Files
bambuddy/backend/tests/unit/test_github_backup_cloud_profiles.py
T
2026-09-20 12:12:56 +02:00

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)]