fix(backup): keep both restore tallies equal to the number the preview showed (#2656)

Two ways the K-profile and spool categories broke the
    restored + skipped + failed == item_count invariant the settings count
    holds:

    * The spools preview counted only the spools and put the usage records
      in the detail, but _restore_spool_usage increments the same tally, so
      any backup with usage history reported a total larger than the number
      the user was shown. The preview now counts both and the detail breaks
      the total down instead of adding to it.
    * A K-profile entry that is not a dict was dropped silently on the
      connected path. _kprofile_profile_count includes it, so the offline,
      printer-missing and step-failed paths all account for it; only the one
      path that talks to a printer let it leave the tally. It now counts
      failed.
This commit is contained in:
maziggy
2026-08-15 14:26:14 +02:00
13 changed files with 98 additions and 12 deletions
+14 -1
View File
@@ -657,10 +657,16 @@ class GitHubRestoreService:
spools = payload.get("spools") if isinstance(payload, dict) else None
usage_payload = parsed.get(SPOOL_USAGE_PATH)
usage = usage_payload.get("usage_history") if isinstance(usage_payload, dict) else None
# Usage records are counted here, not just described in the detail:
# _restore_spool_usage increments this category's tally, so counting
# only the spools broke restored + skipped + failed == item_count —
# the invariant the settings count is careful to hold. The detail
# breaks the total down rather than adding to it.
count = len(spools) if isinstance(spools, list) else 0
detail = None
if isinstance(usage, list) and usage:
detail = _Detail("spoolsUsageCount", f"plus {len(usage)} usage records", {"count": len(usage)})
count += len(usage)
detail = _Detail("spoolsUsageCount", f"including {len(usage)} usage records", {"count": len(usage)})
return count, detail
if category == RestoreCategory.ARCHIVES:
@@ -1686,6 +1692,13 @@ class GitHubRestoreService:
claimed: set[int] = set()
for p in profiles:
if not isinstance(p, dict):
# Counted, not dropped. _kprofile_profile_count includes
# it, so the offline and printer-missing paths already
# count the same entry skipped and the failure path
# counts it outstanding — leaving the tally here was the
# one place a profile could vanish from
# restored + skipped + failed entirely.
tally.failed += 1
continue
match = self._match_kprofile(p, current, claimed)
if match is None:
+73
View File
@@ -411,6 +411,39 @@ class TestCompanionCredentials:
assert tally.restored + tally.skipped + tally.failed == item_count
assert (tally.restored, tally.skipped, tally.failed) == (1, 2, 1)
@pytest.mark.asyncio
async def test_the_spools_tally_holds_the_same_invariant(self, db_session):
"""Spools broke it the other way: the tally counted more than the preview.
``_restore_spool_usage`` increments this category's tally, but the
preview counted only the spools and mentioned the usage records in the
detail — so a backup with any usage history reported a total larger than
the number the user was shown.
"""
spools = {
"spools": [
{"id": 1, "material": "PLA", "brand": "Bambu Lab", "created_at": "2026-01-05 12:00:00"},
{"id": 2, "material": "PETG", "brand": "Bambu Lab", "created_at": "2026-01-05 12:00:00"},
]
}
usage = {
"usage_history": [
{"id": 9, "spool_id": 1, "grams_used": 12.5, "created_at": "2026-01-06 09:00:00"},
{"id": 10, "spool_id": 2, "grams_used": 4.0, "created_at": "2026-01-06 10:00:00"},
{"id": 11, "spool_id": 404, "grams_used": 1.0, "created_at": "2026-01-06 11:00:00"},
]
}
item_count, _ = await _service()._count_items(
db_session, RestoreCategory.SPOOLS, {SPOOLS_PATH: spools, SPOOL_USAGE_PATH: usage}
)
tally = _CategoryTally()
await _service()._restore_spools(db_session, spools, usage, False, tally, {})
await db_session.commit()
assert item_count == 5, "two spools plus three usage records, all of which the tally counts"
assert tally.restored + tally.skipped + tally.failed == item_count
@pytest.mark.asyncio
async def test_preview_count_drops_by_one_when_the_local_credential_is_missing(self, db_session):
parsed = {
@@ -1878,6 +1911,46 @@ class TestRestoreKprofiles:
assert tally.failed == 0
assert any("not connected" in note for note in _messages(tally))
@pytest.mark.asyncio
async def test_a_non_dict_profile_is_counted_failed_not_dropped(self, db_session, printer_factory):
"""The online path was the one place an entry left the tally entirely.
``_kprofile_profile_count`` counts it, so the offline and
printer-missing paths already count the same entry skipped and the
failure path counts it outstanding — only the connected path skipped it
silently, so restored + skipped + failed came up short of the number the
preview showed.
"""
await printer_factory(serial_number="00M09A123456789")
payload = self._payload()
path = next(iter(payload))
payload[path]["profiles"] = [payload[path]["profiles"][0], "nonsense"]
tally = _CategoryTally()
with patch("backend.app.services.github_restore.printer_manager") as manager:
manager.get_client = MagicMock(return_value=self._client())
await _service()._restore_kprofiles(db_session, payload, tally)
assert tally.failed == 1
assert tally.restored + tally.skipped + tally.failed == 2
@pytest.mark.asyncio
async def test_the_offline_path_counts_the_same_entry(self, db_session, printer_factory):
"""Control for the above: the two paths have to agree on the total."""
await printer_factory(serial_number="00M09A123456789")
payload = self._payload()
path = next(iter(payload))
payload[path]["profiles"] = [payload[path]["profiles"][0], "nonsense"]
client = MagicMock()
client.state.connected = False
tally = _CategoryTally()
with patch("backend.app.services.github_restore.printer_manager") as manager:
manager.get_client = MagicMock(return_value=client)
await _service()._restore_kprofiles(db_session, payload, tally)
assert tally.restored + tally.skipped + tally.failed == 2
@pytest.mark.asyncio
async def test_no_client_at_all_is_skipped(self, db_session, printer_factory):
await printer_factory(serial_number="00M09A123456789")
+1 -1
View File
@@ -5012,7 +5012,7 @@ export default {
settingsCredentialsWillSkip: '{{count}} zugangsdatenähnliche Schlüssel werden übersprungen',
settingsCompanionWillSkip: '{{count}} zugangsdatenähnliche Schlüssel werden übersprungen und {{companion}} davon abhängige Schalter bleiben aus',
settingsCompanionOnlyWillSkip: '{{companion}} Schalter bleiben aus - die dafür nötigen Zugangsdaten können nicht aus einem Backup wiederhergestellt werden',
spoolsUsageCount: 'zzgl. {{count}} Verbrauchseinträge',
spoolsUsageCount: 'davon {{count}} Verbrauchseinträge',
archivesMetadataOnly: 'Nur Metadaten - 3MF-Dateien und Vorschaubilder sind nicht im Git-Backup enthalten',
kprofilesPrinterCount: 'über {{count}} Drucker',
},
+1 -1
View File
@@ -5057,7 +5057,7 @@ export default {
settingsCredentialsWillSkip: '{{count}} credential-like key(s) will be skipped',
settingsCompanionWillSkip: '{{count}} credential-like key(s) will be skipped, and {{companion}} switch(es) that depend on them will be left off',
settingsCompanionOnlyWillSkip: '{{companion}} switch(es) will be left off - the credential each one needs cannot be restored from a backup',
spoolsUsageCount: 'plus {{count}} usage record(s)',
spoolsUsageCount: 'including {{count}} usage record(s)',
archivesMetadataOnly: 'Metadata only - 3MF files and thumbnails are not in a Git backup',
kprofilesPrinterCount: 'across {{count}} printer(s)',
},
+1 -1
View File
@@ -5019,7 +5019,7 @@ export default {
settingsCredentialsWillSkip: 'Se omitirán {{count}} claves con aspecto de credencial',
settingsCompanionWillSkip: 'Se omitirán {{count}} claves con aspecto de credencial y {{companion}} interruptores que dependen de ellas quedarán desactivados',
settingsCompanionOnlyWillSkip: '{{companion}} interruptores quedarán desactivados - la credencial que necesita cada uno no se puede restaurar desde una copia de seguridad',
spoolsUsageCount: 'más {{count}} registros de consumo',
spoolsUsageCount: 'incluidos {{count}} registros de consumo',
archivesMetadataOnly: 'Solo metadatos - los archivos 3MF y las miniaturas no están en una copia de Git',
kprofilesPrinterCount: 'en {{count}} impresoras',
},
+1 -1
View File
@@ -5001,7 +5001,7 @@ export default {
settingsCredentialsWillSkip: '{{count}} clés ressemblant à des identifiants seront ignorées',
settingsCompanionWillSkip: '{{count}} clés ressemblant à des identifiants seront ignorées, et {{companion}} interrupteurs qui en dépendent resteront désactivés',
settingsCompanionOnlyWillSkip: '{{companion}} interrupteurs resteront désactivés - les identifiants dont chacun a besoin ne peuvent pas être restaurés depuis une sauvegarde',
spoolsUsageCount: 'plus {{count}} enregistrements de consommation',
spoolsUsageCount: 'dont {{count}} enregistrements de consommation',
archivesMetadataOnly: 'Métadonnées uniquement - les fichiers 3MF et les miniatures ne sont pas dans une sauvegarde Git',
kprofilesPrinterCount: 'sur {{count}} imprimantes',
},
+1 -1
View File
@@ -5000,7 +5000,7 @@ export default {
settingsCredentialsWillSkip: '{{count}} chiavi simili a credenziali verranno saltate',
settingsCompanionWillSkip: '{{count}} chiavi simili a credenziali verranno saltate e {{companion}} interruttori che dipendono da esse resteranno disattivati',
settingsCompanionOnlyWillSkip: '{{companion}} interruttori resteranno disattivati - le credenziali necessarie a ciascuno non possono essere ripristinate da un backup',
spoolsUsageCount: 'più {{count}} record di consumo',
spoolsUsageCount: 'inclusi {{count}} record di consumo',
archivesMetadataOnly: 'Solo metadati - i file 3MF e le miniature non sono in un backup Git',
kprofilesPrinterCount: 'su {{count}} stampanti',
},
+1 -1
View File
@@ -5000,7 +5000,7 @@ export default {
settingsCredentialsWillSkip: '{{count}} chaves parecidas com credenciais serão ignoradas',
settingsCompanionWillSkip: '{{count}} chaves parecidas com credenciais serão ignoradas, e {{companion}} chaves que dependem delas ficarão desligadas',
settingsCompanionOnlyWillSkip: '{{companion}} chaves ficarão desligadas - a credencial que cada uma precisa não pode ser restaurada de um backup',
spoolsUsageCount: 'mais {{count}} registros de consumo',
spoolsUsageCount: 'incluindo {{count}} registros de consumo',
archivesMetadataOnly: 'Somente metadados - arquivos 3MF e miniaturas não ficam em um backup Git',
kprofilesPrinterCount: 'em {{count}} impressoras',
},
+1 -1
View File
@@ -4767,7 +4767,7 @@ export default {
settingsCredentialsWillSkip: 'Ключей, похожих на учётные данные, будет пропущено: {{count}}',
settingsCompanionWillSkip: 'Ключей, похожих на учётные данные, будет пропущено: {{count}}, а зависящие от них переключатели ({{companion}}) останутся выключенными',
settingsCompanionOnlyWillSkip: 'Переключатели ({{companion}}) останутся выключенными - учётные данные, нужные каждому из них, нельзя восстановить из резервной копии',
spoolsUsageCount: 'плюс записей расхода: {{count}}',
spoolsUsageCount: 'включая записей расхода: {{count}}',
archivesMetadataOnly: 'Только метаданные - файлы 3MF и миниатюры не входят в резервную копию Git',
kprofilesPrinterCount: 'по {{count}} принтерам',
},
+1 -1
View File
@@ -4989,7 +4989,7 @@ export default {
settingsCredentialsWillSkip: 'Kimlik bilgisi benzeri {{count}} anahtar atlanacak',
settingsCompanionWillSkip: 'Kimlik bilgisi benzeri {{count}} anahtar atlanacak ve bunlara bağlı {{companion}} anahtar kapalı bırakılacak',
settingsCompanionOnlyWillSkip: '{{companion}} anahtar kapalı bırakılacak - her birinin ihtiyaç duyduğu kimlik bilgisi bir yedekten geri yüklenemez',
spoolsUsageCount: 'ayrıca {{count}} kullanım kaydı',
spoolsUsageCount: '{{count}} kullanım kaydı dahil',
archivesMetadataOnly: 'Yalnızca üst veri - 3MF dosyaları ve küçük resimler Git yedeğinde yer almaz',
kprofilesPrinterCount: '{{count}} yazıcı genelinde',
},
+1 -1
View File
@@ -5054,7 +5054,7 @@ export default {
settingsCredentialsWillSkip: "Ключів, схожих на облікові дані, буде пропущено: {{count}}",
settingsCompanionWillSkip: "Ключів, схожих на облікові дані, буде пропущено: {{count}}, а залежні від них перемикачі ({{companion}}) залишаться вимкненими",
settingsCompanionOnlyWillSkip: 'Перемикачі ({{companion}}) залишаться вимкненими - облікові дані, потрібні кожному з них, не можна відновити з резервної копії',
spoolsUsageCount: "плюс записів використання: {{count}}",
spoolsUsageCount: "включно із записами використання: {{count}}",
archivesMetadataOnly: "Лише метадані - файли 3MF і мініатюри не входять до резервної копії Git",
kprofilesPrinterCount: "по {{count}} принтерах",
},
+1 -1
View File
@@ -5000,7 +5000,7 @@ export default {
settingsCredentialsWillSkip: '将跳过 {{count}} 个疑似凭据的键',
settingsCompanionWillSkip: '将跳过 {{count}} 个疑似凭据的键,依赖它们的 {{companion}} 个开关将保持关闭',
settingsCompanionOnlyWillSkip: '{{companion}} 个开关将保持关闭 - 每个开关所需的凭据无法从备份中恢复',
spoolsUsageCount: '另有 {{count}} 条使用记录',
spoolsUsageCount: '其中含 {{count}} 条使用记录',
archivesMetadataOnly: '仅元数据 - 3MF 文件和缩略图不在 Git 备份中',
kprofilesPrinterCount: '涉及 {{count}} 台打印机',
},
+1 -1
View File
@@ -5000,7 +5000,7 @@ export default {
settingsCredentialsWillSkip: '將略過 {{count}} 個疑似憑證的鍵',
settingsCompanionWillSkip: '將略過 {{count}} 個疑似憑證的鍵,依賴它們的 {{companion}} 個開關會維持關閉',
settingsCompanionOnlyWillSkip: '{{companion}} 個開關會維持關閉 - 每個開關所需的憑證無法從備份還原',
spoolsUsageCount: '另有 {{count}} 筆使用紀錄',
spoolsUsageCount: '其中含 {{count}} 筆使用紀錄',
archivesMetadataOnly: '僅中繼資料 - 3MF 檔案與縮圖不在 Git 備份中',
kprofilesPrinterCount: '涵蓋 {{count}} 台印表機',
},