mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-10-26 09:30:30 -05:00
* [Installer][Client] Back up user data before old uninstallers can delete it * [Installer][Client] Fix stale backup resurrection, batch session backups, extend coverage NSIS: clear stale %TEMP% backup and only arm restore when something was copied, directory-existence checks, back up loose .dec decks, drop downloaded pics but keep pics\CUSTOM. Helper: single session dir per run (ms timestamps), same-basename disambiguation, partial copies still report their location. * [Installer][Client] Fix review findings: throttle backups, NSIS old dir, portable messaging * [Installer][Client] Fix review findings: backup markers, NSIS relocate, size cap * [Installer][Client] Fix review findings: sounds, session coverage, NSIS wording Client: back up data/sounds in portable mode and mention it in the warning; reuse a recent session only when complete and covering the current sources (source manifest), estimate size only for fresh copies, normalize dedup via isInsideDir. Helper: source manifest per session with sessionCoversSources(), clear out-params on all failure paths. NSIS: document defensive portable branch, merge backup over surviving data/ to repair partial deletes, copy (not move) wording in relocate, note .txt exclusion in deck notice. * [Installer][Client] Fix review findings: stale backup, empty reuse, relocate, portable warning NSIS: wipe TEMP backup once per run to avoid resurrecting deleted files, check data dir existence in relocate so dir-only trees are moved. Client: never reuse a recent session for an empty source set, only fall back to a covering session on copy failure, warn fresh portable installs instead of staying silent. * [Installer][Client] Fix review findings: usable session reuse, loose decks, NSIS pics, symlinks * [Installer][Client] Fix portable backup exclusions in offender merge * [Client] Split at-risk data warning into focused helpers Ricky's nit on the 200+ line warnAboutDataPathsInProgramDir: extract collection (portable/installed), backup, message building and dialog into named helpers. No behavior change, all tr() strings untouched. * [Client] Read path settings inside isPortableBackupExcluded * [Client] Make stateless at-risk helpers static * [Client] Extract at-risk data warning out of MainWindow * [Installer][Client] Fix review findings: subdir backup, portable warning, backup API NSIS: - Back up unknown top-level install subdirs (e.g. <installdir>\decks), not just loose deck files, so the 3.1.1 -> new upgrade no longer loses data folders the old RMDir /r deletes before the client ever runs. Payload dirs (plugins/translations/sounds/themes) stay in place to avoid restoring stale DLLs; restore + relocate handle the new backup set and the one-time notice covers both decks and folders. - Restart the %TEMP% backup when the 32/64-bit passes point at different dirs instead of merging (which restored one dir's files into the other); document that only the last pass relocates and the %TEMP% copy is not a durable second copy. Client: - Drop the portable at-risk warning: portable installs never shipped an uninstaller and portable updates preserve data/, so every portable user got a spurious dialog plus daily host-machine copies. - AtRiskDataWarning is now a plain class with Q_DECLARE_TR_FUNCTIONS instead of QObject+Q_OBJECT; logging category renamed to at_risk_data_warning; MainWindow wrapper inlined into startupConfigCheck. - Lower the synchronous startup backup cap 500 -> 200 MB. Utility/tests: - Remove test-only backupAtRiskPath()/findRecentBackupSession() and the no-marker/no-manifest legacy paths; isBackupSessionComplete() now requires the complete marker; tests updated to the plural API. * [Client] Restore collectInstalledAtRiskPaths per reviewa23e02b5removed the definition along with the portable helpers, breaking the build (undefined reference at link time, and isInsideAnyDir unused under -Werror on Arch/Debian 13). Restores the202c0d65version (pathsInsideDir plus loose top-level deck files) as suggested. --------- Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
582 lines
24 KiB
C++
582 lines
24 KiB
C++
#include "gtest/gtest.h"
|
|
#include <QDir>
|
|
#include <QFile>
|
|
#include <QTemporaryDir>
|
|
#include <libcockatrice/utility/user_data_backup.h>
|
|
|
|
namespace
|
|
{
|
|
|
|
TEST(UserDataBackup, CopyDirectoryKeepsNestedStructure)
|
|
{
|
|
QTemporaryDir sourceDir;
|
|
QTemporaryDir destParent;
|
|
ASSERT_TRUE(sourceDir.isValid());
|
|
ASSERT_TRUE(destParent.isValid());
|
|
QDir source(sourceDir.path());
|
|
ASSERT_TRUE(source.mkpath("decks/sub"));
|
|
QFile deck(source.absoluteFilePath("decks/sub/mydeck.cod"));
|
|
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
|
|
deck.write("<cockatrice_deck version=\"1\"/>\n");
|
|
deck.close();
|
|
|
|
QString dest = QDir(destParent.path()).absoluteFilePath("copy");
|
|
EXPECT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest));
|
|
EXPECT_TRUE(QFile::exists(dest + "/decks/sub/mydeck.cod"));
|
|
}
|
|
|
|
TEST(UserDataBackup, CopyDirectoryKeepsHiddenFiles)
|
|
{
|
|
QTemporaryDir sourceDir;
|
|
QTemporaryDir destParent;
|
|
ASSERT_TRUE(sourceDir.isValid());
|
|
ASSERT_TRUE(destParent.isValid());
|
|
QFile hidden(QDir(sourceDir.path()).absoluteFilePath(".hidden.ini"));
|
|
ASSERT_TRUE(hidden.open(QIODevice::WriteOnly | QIODevice::Text));
|
|
hidden.write("[general]\n");
|
|
hidden.close();
|
|
|
|
QString dest = QDir(destParent.path()).absoluteFilePath("copy");
|
|
EXPECT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest));
|
|
EXPECT_TRUE(QFile::exists(dest + "/.hidden.ini"));
|
|
}
|
|
|
|
TEST(UserDataBackup, CopyDirectoryOverwritesExistingTarget)
|
|
{
|
|
QTemporaryDir sourceDir;
|
|
QTemporaryDir destParent;
|
|
ASSERT_TRUE(sourceDir.isValid());
|
|
ASSERT_TRUE(destParent.isValid());
|
|
const QString sourceFile = QDir(sourceDir.path()).absoluteFilePath("settings.ini");
|
|
QFile first(sourceFile);
|
|
ASSERT_TRUE(first.open(QIODevice::WriteOnly | QIODevice::Text));
|
|
first.write("version=1\n");
|
|
first.close();
|
|
|
|
QString dest = QDir(destParent.path()).absoluteFilePath("copy");
|
|
ASSERT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest));
|
|
|
|
QFile second(sourceFile);
|
|
ASSERT_TRUE(second.open(QIODevice::WriteOnly | QIODevice::Text));
|
|
second.write("version=2\n");
|
|
second.close();
|
|
|
|
EXPECT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest));
|
|
QFile result(dest + "/settings.ini");
|
|
ASSERT_TRUE(result.open(QIODevice::ReadOnly | QIODevice::Text));
|
|
EXPECT_EQ(result.readAll(), QByteArray("version=2\n"));
|
|
}
|
|
|
|
TEST(UserDataBackup, CopyEmptyDirectorySucceeds)
|
|
{
|
|
QTemporaryDir sourceDir;
|
|
QTemporaryDir destParent;
|
|
ASSERT_TRUE(sourceDir.isValid());
|
|
ASSERT_TRUE(destParent.isValid());
|
|
ASSERT_TRUE(QDir(sourceDir.path()).mkpath("empty"));
|
|
|
|
QString dest = QDir(destParent.path()).absoluteFilePath("copy");
|
|
EXPECT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest));
|
|
EXPECT_TRUE(QDir(dest + "/empty").exists());
|
|
}
|
|
TEST(UserDataBackup, CopyMissingDirectoryFails)
|
|
{
|
|
QTemporaryDir destParent;
|
|
ASSERT_TRUE(destParent.isValid());
|
|
EXPECT_FALSE(
|
|
UserDataBackup::copyDirectoryRecursively("/nonexistent-cockatrice-path-xyz", destParent.path() + "/copy"));
|
|
}
|
|
|
|
TEST(UserDataBackup, BackupDirectoryCreatesTimestampedCopy)
|
|
{
|
|
QTemporaryDir sourceDir;
|
|
QTemporaryDir backupRoot;
|
|
ASSERT_TRUE(sourceDir.isValid());
|
|
ASSERT_TRUE(backupRoot.isValid());
|
|
QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod"));
|
|
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
|
|
deck.write("<cockatrice_deck version=\"1\"/>\n");
|
|
deck.close();
|
|
|
|
QStringList locations;
|
|
QString sessionDir;
|
|
EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir));
|
|
ASSERT_EQ(locations.size(), 1);
|
|
EXPECT_TRUE(QFile::exists(locations.first() + "/mydeck.cod"));
|
|
EXPECT_TRUE(locations.first().startsWith(QDir(backupRoot.path()).absolutePath()));
|
|
}
|
|
|
|
TEST(UserDataBackup, BackupSingleFile)
|
|
{
|
|
QTemporaryDir sourceDir;
|
|
QTemporaryDir backupRoot;
|
|
ASSERT_TRUE(sourceDir.isValid());
|
|
ASSERT_TRUE(backupRoot.isValid());
|
|
QString cardDb = QDir(sourceDir.path()).absoluteFilePath("cards.xml");
|
|
QFile db(cardDb);
|
|
ASSERT_TRUE(db.open(QIODevice::WriteOnly | QIODevice::Text));
|
|
db.write("<cockatrice_card_database/>\n");
|
|
db.close();
|
|
|
|
QStringList locations;
|
|
EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({cardDb}, backupRoot.path(), &locations, nullptr));
|
|
ASSERT_EQ(locations.size(), 1);
|
|
EXPECT_TRUE(QFile::exists(locations.first()));
|
|
EXPECT_TRUE(locations.first().endsWith("cards.xml"));
|
|
}
|
|
|
|
TEST(UserDataBackup, BackupMissingSourceFails)
|
|
{
|
|
QTemporaryDir backupRoot;
|
|
ASSERT_TRUE(backupRoot.isValid());
|
|
QStringList locations;
|
|
QString sessionDir;
|
|
EXPECT_FALSE(UserDataBackup::backupAtRiskPaths({QDir(backupRoot.path()).absoluteFilePath("does-not-exist.cod")},
|
|
backupRoot.path(), &locations, &sessionDir));
|
|
EXPECT_TRUE(locations.isEmpty());
|
|
EXPECT_TRUE(sessionDir.isEmpty());
|
|
}
|
|
|
|
TEST(UserDataBackup, BackupPathsShareSingleSessionDir)
|
|
{
|
|
QTemporaryDir sourceDir;
|
|
QTemporaryDir backupRoot;
|
|
ASSERT_TRUE(sourceDir.isValid());
|
|
ASSERT_TRUE(backupRoot.isValid());
|
|
QDir source(sourceDir.path());
|
|
ASSERT_TRUE(source.mkpath("decks"));
|
|
ASSERT_TRUE(source.mkpath("settings"));
|
|
QFile deck(source.absoluteFilePath("decks/mydeck.cod"));
|
|
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
|
|
deck.write("<cockatrice_deck version=\"1\"/>\n");
|
|
deck.close();
|
|
QFile settings(source.absoluteFilePath("settings/settings.ini"));
|
|
ASSERT_TRUE(settings.open(QIODevice::WriteOnly | QIODevice::Text));
|
|
settings.write("[general]\n");
|
|
settings.close();
|
|
|
|
QStringList locations;
|
|
QString sessionDir;
|
|
EXPECT_TRUE(
|
|
UserDataBackup::backupAtRiskPaths({source.absoluteFilePath("decks"), source.absoluteFilePath("settings")},
|
|
backupRoot.path(), &locations, &sessionDir));
|
|
ASSERT_EQ(locations.size(), 2);
|
|
// Both backups live in the same timestamped session, even across a second boundary.
|
|
for (const QString &location : locations) {
|
|
EXPECT_TRUE(location.startsWith(QDir(sessionDir).absolutePath()));
|
|
}
|
|
EXPECT_TRUE(QFile::exists(QDir(sessionDir).absoluteFilePath("decks/mydeck.cod")));
|
|
EXPECT_TRUE(QFile::exists(QDir(sessionDir).absoluteFilePath("settings/settings.ini")));
|
|
}
|
|
|
|
TEST(UserDataBackup, BackupPathsWithSameNameDoNotCollide)
|
|
{
|
|
QTemporaryDir firstDir;
|
|
QTemporaryDir secondDir;
|
|
QTemporaryDir backupRoot;
|
|
ASSERT_TRUE(firstDir.isValid());
|
|
ASSERT_TRUE(secondDir.isValid());
|
|
ASSERT_TRUE(backupRoot.isValid());
|
|
for (const QString &dir : {firstDir.path(), secondDir.path()}) {
|
|
QFile deck(QDir(dir).absoluteFilePath("cards.xml"));
|
|
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
|
|
deck.write("<cockatrice_card_database/>\n");
|
|
deck.close();
|
|
}
|
|
|
|
QStringList locations;
|
|
EXPECT_TRUE(UserDataBackup::backupAtRiskPaths(
|
|
{QDir(firstDir.path()).absoluteFilePath("cards.xml"), QDir(secondDir.path()).absoluteFilePath("cards.xml")},
|
|
backupRoot.path(), &locations, nullptr));
|
|
ASSERT_EQ(locations.size(), 2);
|
|
EXPECT_NE(locations.at(0), locations.at(1));
|
|
EXPECT_TRUE(QFile::exists(locations.at(0)));
|
|
EXPECT_TRUE(QFile::exists(locations.at(1)));
|
|
}
|
|
|
|
TEST(UserDataBackup, RapidBackupsGetUniqueSessionDirs)
|
|
{
|
|
QTemporaryDir sourceDir;
|
|
QTemporaryDir backupRoot;
|
|
ASSERT_TRUE(sourceDir.isValid());
|
|
ASSERT_TRUE(backupRoot.isValid());
|
|
QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod"));
|
|
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
|
|
deck.write("<cockatrice_deck version=\"1\"/>\n");
|
|
deck.close();
|
|
|
|
QString firstSession;
|
|
QString secondSession;
|
|
QStringList locations;
|
|
EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &firstSession));
|
|
EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &secondSession));
|
|
EXPECT_NE(firstSession, secondSession);
|
|
}
|
|
|
|
TEST(UserDataBackup, DefaultBackupRootIsUsable)
|
|
{
|
|
QString root = UserDataBackup::defaultBackupRoot();
|
|
EXPECT_FALSE(root.isEmpty());
|
|
EXPECT_TRUE(QDir(root).isAbsolute());
|
|
}
|
|
|
|
TEST(UserDataBackup, DefaultBackupRootHasNoDotDotSegments)
|
|
{
|
|
QString root = UserDataBackup::defaultBackupRoot();
|
|
EXPECT_FALSE(root.contains(QStringLiteral("..")));
|
|
EXPECT_EQ(root, QDir::cleanPath(root));
|
|
}
|
|
|
|
TEST(UserDataBackup, ListSessionsFindsRecentAndPrunesOld)
|
|
{
|
|
QTemporaryDir sourceDir;
|
|
QTemporaryDir backupRoot;
|
|
ASSERT_TRUE(sourceDir.isValid());
|
|
ASSERT_TRUE(backupRoot.isValid());
|
|
QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod"));
|
|
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
|
|
deck.write("<cockatrice_deck version=\"1\"/>\n");
|
|
deck.close();
|
|
|
|
QStringList locations;
|
|
QString firstSession;
|
|
QString secondSession;
|
|
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &firstSession));
|
|
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &secondSession));
|
|
|
|
const QStringList sessions = UserDataBackup::listBackupSessions(backupRoot.path());
|
|
ASSERT_EQ(sessions.size(), 2);
|
|
// Newest first
|
|
EXPECT_EQ(sessions.first(), secondSession);
|
|
|
|
const QString recent = UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceDir.path()}, 24);
|
|
EXPECT_EQ(recent, secondSession);
|
|
|
|
const QStringList reusedLocations = UserDataBackup::listSessionBackupLocations(recent);
|
|
EXPECT_FALSE(reusedLocations.isEmpty());
|
|
for (const QString &location : reusedLocations) {
|
|
EXPECT_TRUE(location.startsWith(QDir(recent).absolutePath()));
|
|
EXPECT_TRUE(QFile::exists(location));
|
|
}
|
|
|
|
UserDataBackup::pruneOldBackupSessions(backupRoot.path(), 1);
|
|
const QStringList pruned = UserDataBackup::listBackupSessions(backupRoot.path());
|
|
ASSERT_EQ(pruned.size(), 1);
|
|
EXPECT_EQ(pruned.first(), secondSession);
|
|
}
|
|
|
|
TEST(UserDataBackup, FindRecentReturnsEmptyWhenNone)
|
|
{
|
|
QTemporaryDir backupRoot;
|
|
ASSERT_TRUE(backupRoot.isValid());
|
|
EXPECT_TRUE(UserDataBackup::listBackupSessions(backupRoot.path()).isEmpty());
|
|
EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24)
|
|
.isEmpty());
|
|
EXPECT_TRUE(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24)
|
|
.isEmpty());
|
|
EXPECT_TRUE(UserDataBackup::listSessionBackupLocations(backupRoot.path() + "/missing").isEmpty());
|
|
EXPECT_FALSE(UserDataBackup::isBackupSessionComplete(backupRoot.path() + "/missing"));
|
|
// Pruning an empty root must not fail
|
|
UserDataBackup::pruneOldBackupSessions(backupRoot.path(), 5);
|
|
}
|
|
|
|
TEST(UserDataBackup, AllMissingSourcesCreateNoSession)
|
|
{
|
|
QTemporaryDir backupRoot;
|
|
ASSERT_TRUE(backupRoot.isValid());
|
|
QStringList locations;
|
|
QString sessionDir;
|
|
EXPECT_FALSE(UserDataBackup::backupAtRiskPaths({backupRoot.path() + "/nope1", backupRoot.path() + "/nope2"},
|
|
backupRoot.path(), &locations, &sessionDir));
|
|
EXPECT_TRUE(locations.isEmpty());
|
|
EXPECT_TRUE(sessionDir.isEmpty());
|
|
// No empty session left behind to block a retry or evict good sessions
|
|
EXPECT_TRUE(UserDataBackup::listBackupSessions(backupRoot.path()).isEmpty());
|
|
}
|
|
|
|
TEST(UserDataBackup, EmptySessionsAreSkippedAndPruned)
|
|
{
|
|
QTemporaryDir backupRoot;
|
|
ASSERT_TRUE(backupRoot.isValid());
|
|
QString emptySession;
|
|
ASSERT_TRUE(UserDataBackup::createBackupSessionDir(backupRoot.path(), &emptySession));
|
|
// An empty session from a failed attempt must never block a retry
|
|
EXPECT_TRUE(UserDataBackup::listSessionBackupLocations(emptySession).isEmpty());
|
|
EXPECT_FALSE(UserDataBackup::isBackupSessionComplete(emptySession));
|
|
EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24)
|
|
.isEmpty());
|
|
EXPECT_TRUE(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24)
|
|
.isEmpty());
|
|
UserDataBackup::pruneOldBackupSessions(backupRoot.path(), 5);
|
|
EXPECT_FALSE(QDir(emptySession).exists());
|
|
}
|
|
|
|
TEST(UserDataBackup, SuccessfulBackupIsMarkedComplete)
|
|
{
|
|
QTemporaryDir sourceDir;
|
|
QTemporaryDir backupRoot;
|
|
ASSERT_TRUE(sourceDir.isValid());
|
|
ASSERT_TRUE(backupRoot.isValid());
|
|
QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod"));
|
|
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
|
|
deck.write("<cockatrice_deck version=\"1\"/>\n");
|
|
deck.close();
|
|
|
|
QStringList locations;
|
|
QString sessionDir;
|
|
EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir));
|
|
EXPECT_TRUE(UserDataBackup::isBackupSessionComplete(sessionDir));
|
|
// Marker files are bookkeeping and must not leak into the user-facing locations
|
|
const QStringList listed = UserDataBackup::listSessionBackupLocations(sessionDir);
|
|
EXPECT_FALSE(listed.isEmpty());
|
|
for (const QString &location : listed) {
|
|
EXPECT_FALSE(location.endsWith(QStringLiteral("backup.complete")));
|
|
EXPECT_FALSE(location.endsWith(QStringLiteral("backup.incomplete")));
|
|
}
|
|
}
|
|
|
|
TEST(UserDataBackup, IncompleteMarkerIsNotUsableButRemainsCovering)
|
|
{
|
|
QTemporaryDir sourceDir;
|
|
QTemporaryDir backupRoot;
|
|
ASSERT_TRUE(sourceDir.isValid());
|
|
ASSERT_TRUE(backupRoot.isValid());
|
|
QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod"));
|
|
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
|
|
deck.write("<cockatrice_deck version=\"1\"/>\n");
|
|
deck.close();
|
|
|
|
QStringList locations;
|
|
QString sessionDir;
|
|
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir));
|
|
ASSERT_TRUE(QFile::remove(QDir(sessionDir).absoluteFilePath("backup.complete")));
|
|
QFile marker(QDir(sessionDir).absoluteFilePath("backup.incomplete"));
|
|
ASSERT_TRUE(marker.open(QIODevice::WriteOnly | QIODevice::Text));
|
|
marker.write("incomplete\n");
|
|
marker.close();
|
|
|
|
EXPECT_FALSE(UserDataBackup::isBackupSessionComplete(sessionDir));
|
|
// Incomplete sessions are skipped by the usable search but still found by the
|
|
// covering search, so a failed copy falls back to the salvaged content.
|
|
EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceDir.path()}, 24).isEmpty());
|
|
EXPECT_EQ(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {sourceDir.path()}, 24), sessionDir);
|
|
}
|
|
|
|
TEST(UserDataBackup, SessionWithoutMarkerCountsAsIncomplete)
|
|
{
|
|
QTemporaryDir backupRoot;
|
|
ASSERT_TRUE(backupRoot.isValid());
|
|
QString sessionDir;
|
|
ASSERT_TRUE(UserDataBackup::createBackupSessionDir(backupRoot.path(), &sessionDir));
|
|
ASSERT_TRUE(QDir(sessionDir).mkpath("decks"));
|
|
QFile deck(QDir(sessionDir).absoluteFilePath("decks/mydeck.cod"));
|
|
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
|
|
deck.write("<cockatrice_deck version=\"1\"/>\n");
|
|
deck.close();
|
|
EXPECT_FALSE(UserDataBackup::isBackupSessionComplete(sessionDir));
|
|
// No source manifest either, so neither search reuses it.
|
|
EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24)
|
|
.isEmpty());
|
|
EXPECT_TRUE(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24)
|
|
.isEmpty());
|
|
}
|
|
|
|
TEST(UserDataBackup, FindRecentIgnoresExpiredSessions)
|
|
{
|
|
QTemporaryDir sourceDir;
|
|
QTemporaryDir backupRoot;
|
|
ASSERT_TRUE(sourceDir.isValid());
|
|
ASSERT_TRUE(backupRoot.isValid());
|
|
QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod"));
|
|
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
|
|
deck.write("<cockatrice_deck version=\"1\"/>\n");
|
|
deck.close();
|
|
|
|
QStringList locations;
|
|
QString sessionDir;
|
|
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir));
|
|
EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceDir.path()}, 0).isEmpty());
|
|
EXPECT_TRUE(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {sourceDir.path()}, 0).isEmpty());
|
|
}
|
|
|
|
TEST(UserDataBackup, EstimateBackupSize)
|
|
{
|
|
QTemporaryDir sourceDir;
|
|
ASSERT_TRUE(sourceDir.isValid());
|
|
const QString missing = QDir(sourceDir.path()).absoluteFilePath("missing");
|
|
EXPECT_EQ(UserDataBackup::estimateBackupSize({missing}), 0);
|
|
QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod"));
|
|
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
|
|
deck.write("123456");
|
|
deck.close();
|
|
EXPECT_EQ(UserDataBackup::estimateBackupSize({sourceDir.path(), missing}), 6);
|
|
// Early-out: once over the cap the exact total no longer matters
|
|
EXPECT_GE(UserDataBackup::estimateBackupSize({sourceDir.path()}, 1), 1);
|
|
}
|
|
|
|
TEST(UserDataBackup, SessionCoversSourcesRequiresManifestMatch)
|
|
{
|
|
QTemporaryDir sourceA;
|
|
QTemporaryDir sourceB;
|
|
QTemporaryDir backupRoot;
|
|
ASSERT_TRUE(sourceA.isValid());
|
|
ASSERT_TRUE(sourceB.isValid());
|
|
ASSERT_TRUE(backupRoot.isValid());
|
|
QFile fa(QDir(sourceA.path()).absoluteFilePath("a.cod"));
|
|
ASSERT_TRUE(fa.open(QIODevice::WriteOnly | QIODevice::Text));
|
|
fa.write("a");
|
|
fa.close();
|
|
|
|
QStringList locations;
|
|
QString sessionDir;
|
|
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceA.path()}, backupRoot.path(), &locations, &sessionDir));
|
|
EXPECT_TRUE(UserDataBackup::sessionCoversSources(sessionDir, {sourceA.path()}));
|
|
EXPECT_FALSE(UserDataBackup::sessionCoversSources(sessionDir, {sourceA.path(), sourceB.path()}));
|
|
EXPECT_FALSE(UserDataBackup::sessionCoversSources(sessionDir, {sourceB.path()}));
|
|
}
|
|
|
|
TEST(UserDataBackup, SessionWithoutManifestIsNotCovering)
|
|
{
|
|
QTemporaryDir backupRoot;
|
|
ASSERT_TRUE(backupRoot.isValid());
|
|
QString sessionDir;
|
|
ASSERT_TRUE(UserDataBackup::createBackupSessionDir(backupRoot.path(), &sessionDir));
|
|
ASSERT_TRUE(QDir(sessionDir).mkpath("decks"));
|
|
QFile deck(QDir(sessionDir).absoluteFilePath("decks/mydeck.cod"));
|
|
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
|
|
deck.write("<cockatrice_deck version=\"1\"/>\n");
|
|
deck.close();
|
|
EXPECT_FALSE(UserDataBackup::sessionCoversSources(sessionDir, {QDir(sessionDir).absoluteFilePath("decks")}));
|
|
}
|
|
|
|
TEST(UserDataBackup, ManifestIsHiddenFromSessionLocations)
|
|
{
|
|
QTemporaryDir sourceDir;
|
|
QTemporaryDir backupRoot;
|
|
ASSERT_TRUE(sourceDir.isValid());
|
|
ASSERT_TRUE(backupRoot.isValid());
|
|
QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod"));
|
|
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
|
|
deck.write("x");
|
|
deck.close();
|
|
|
|
QStringList locations;
|
|
QString sessionDir;
|
|
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir));
|
|
for (const QString &location : UserDataBackup::listSessionBackupLocations(sessionDir)) {
|
|
EXPECT_FALSE(location.endsWith(QStringLiteral("backup.sources")));
|
|
}
|
|
}
|
|
|
|
TEST(UserDataBackup, FindRecentUsableSkipsIncompleteNewest)
|
|
{
|
|
QTemporaryDir sourceDir;
|
|
QTemporaryDir backupRoot;
|
|
ASSERT_TRUE(sourceDir.isValid());
|
|
ASSERT_TRUE(backupRoot.isValid());
|
|
QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod"));
|
|
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
|
|
deck.write("<cockatrice_deck version=\"1\"/>\n");
|
|
deck.close();
|
|
|
|
QStringList locations;
|
|
QString oldSession;
|
|
QString newSession;
|
|
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &oldSession));
|
|
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &newSession));
|
|
ASSERT_NE(oldSession, newSession);
|
|
|
|
// Newest session becomes incomplete: usable search must fall back to the older complete one.
|
|
ASSERT_TRUE(QFile::remove(QDir(newSession).absoluteFilePath("backup.complete")));
|
|
QFile marker(QDir(newSession).absoluteFilePath("backup.incomplete"));
|
|
ASSERT_TRUE(marker.open(QIODevice::WriteOnly | QIODevice::Text));
|
|
marker.write("incomplete\n");
|
|
marker.close();
|
|
|
|
EXPECT_EQ(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceDir.path()}), oldSession);
|
|
// Covering search still finds the newest session even though it is incomplete.
|
|
EXPECT_EQ(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {sourceDir.path()}), newSession);
|
|
}
|
|
|
|
TEST(UserDataBackup, FindRecentUsableRequiresCoveringSet)
|
|
{
|
|
QTemporaryDir sourceA;
|
|
QTemporaryDir sourceB;
|
|
QTemporaryDir backupRoot;
|
|
ASSERT_TRUE(sourceA.isValid());
|
|
ASSERT_TRUE(sourceB.isValid());
|
|
ASSERT_TRUE(backupRoot.isValid());
|
|
QFile fa(QDir(sourceA.path()).absoluteFilePath("a.cod"));
|
|
ASSERT_TRUE(fa.open(QIODevice::WriteOnly | QIODevice::Text));
|
|
fa.write("a");
|
|
fa.close();
|
|
QFile fb(QDir(sourceB.path()).absoluteFilePath("b.cod"));
|
|
ASSERT_TRUE(fb.open(QIODevice::WriteOnly | QIODevice::Text));
|
|
fb.write("b");
|
|
fb.close();
|
|
|
|
QStringList locations;
|
|
QString oldSession;
|
|
QString newSession;
|
|
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceA.path()}, backupRoot.path(), &locations, &oldSession));
|
|
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceB.path()}, backupRoot.path(), &locations, &newSession));
|
|
|
|
// Newest covers B only, so a query for A must reuse the older session.
|
|
EXPECT_EQ(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceA.path()}), oldSession);
|
|
EXPECT_EQ(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceB.path()}), newSession);
|
|
EXPECT_TRUE(
|
|
UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceA.path(), sourceB.path()}).isEmpty());
|
|
EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {}).isEmpty());
|
|
}
|
|
|
|
TEST(UserDataBackup, CopyDirectoryDoesNotSilentlyEmptyDirSymlink)
|
|
{
|
|
QTemporaryDir sourceDir;
|
|
QTemporaryDir destParent;
|
|
ASSERT_TRUE(sourceDir.isValid());
|
|
ASSERT_TRUE(destParent.isValid());
|
|
QDir source(sourceDir.path());
|
|
ASSERT_TRUE(source.mkpath("real"));
|
|
QFile inner(source.absoluteFilePath("real/card.cod"));
|
|
ASSERT_TRUE(inner.open(QIODevice::WriteOnly | QIODevice::Text));
|
|
inner.write("x");
|
|
inner.close();
|
|
if (!QFile::link(source.absoluteFilePath("real"), source.absoluteFilePath("link"))) {
|
|
GTEST_SKIP() << "Symlinks not permitted in this environment";
|
|
}
|
|
|
|
QString dest = QDir(destParent.path()).absoluteFilePath("copy");
|
|
const bool ok = UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest);
|
|
const QFileInfo linkCopy(QDir(dest).absoluteFilePath("link"));
|
|
// Old behavior created an empty plain dir and reported success, silently dropping contents.
|
|
// New behavior copies the link best-effort; if that fails it must report failure.
|
|
if (ok) {
|
|
EXPECT_TRUE(linkCopy.exists() || linkCopy.isSymLink());
|
|
if (linkCopy.isDir() && !linkCopy.isSymLink()) {
|
|
EXPECT_FALSE(QDir(linkCopy.absoluteFilePath()).isEmpty());
|
|
}
|
|
} else {
|
|
EXPECT_TRUE(linkCopy.isSymLink() || !linkCopy.exists() || !QDir(linkCopy.absoluteFilePath()).isEmpty());
|
|
}
|
|
}
|
|
|
|
TEST(UserDataBackup, PluralBackupClearsOutParamsOnTotalFailure)
|
|
{
|
|
QTemporaryDir backupRoot;
|
|
ASSERT_TRUE(backupRoot.isValid());
|
|
QStringList locations = {QStringLiteral("stale")};
|
|
QString sessionDir = QStringLiteral("stale");
|
|
const QString missing = QDir(backupRoot.path()).absoluteFilePath("does-not-exist.cod");
|
|
EXPECT_FALSE(UserDataBackup::backupAtRiskPaths({missing}, backupRoot.path(), &locations, &sessionDir));
|
|
EXPECT_TRUE(locations.isEmpty());
|
|
EXPECT_TRUE(sessionDir.isEmpty());
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main(int argc, char **argv)
|
|
{
|
|
::testing::InitGoogleTest(&argc, argv);
|
|
return RUN_ALL_TESTS();
|
|
}
|