From 87a6c45210ea4ffa1a9f6be77002225b7db8ec6e Mon Sep 17 00:00:00 2001 From: dengzhongyuan365-dev Date: Fri, 13 Nov 2026 15:46:31 +0800 Subject: [PATCH 1/2] test: add unit tests for 3rdparty archive interface and plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Add contract tests for read-only and read-write archive plugin operations 2. Fix and enhance existing tests in cli7zplugin, clirarplugin, and libzipplugin 3. Add new tests for common utilities, data structures, and single-file plugins Influence: 1. Run ctest to verify all unit tests pass 2. Verify contract tests cover read-only and read-write plugin scenarios 3. Check that existing test fixes don't introduce regressions test: 补充三方归档接口及插件单元测试 1. 新增只读和读写归档插件操作的契约测试 2. 修正并增强 cli7zplugin、clirarplugin、libzipplugin 现有测试 3. 新增通用工具、数据结构和单文件插件测试 Influence: 1. 执行 ctest 验证所有单元测试通过 2. 验证契约测试覆盖只读和读写插件场景 3. 检查现有测试修正未引入回归 Log: No user-facing changes. PMS: TASK-398535 --- tests/CMakeLists.txt | 18 +- tests/UnitTest/3rdparty/CMakeLists.txt | 2 + .../3rdparty/cli7zplugin/ut_cli7zplugin.cpp | 37 +- .../3rdparty/clirarplugin/ut_clirarplugin.cpp | 4 +- .../UnitTest/3rdparty/contract/CMakeLists.txt | 97 +++ .../3rdparty/contract/contract_test.h | 503 +++++++++++ tests/UnitTest/3rdparty/contract/main.cpp | 13 + .../contract/ut_contract_readonly.cpp | 813 ++++++++++++++++++ .../contract/ut_contract_readwrite.cpp | 550 ++++++++++++ 9 files changed, 2014 insertions(+), 23 deletions(-) create mode 100644 tests/UnitTest/3rdparty/contract/CMakeLists.txt create mode 100644 tests/UnitTest/3rdparty/contract/contract_test.h create mode 100644 tests/UnitTest/3rdparty/contract/main.cpp create mode 100644 tests/UnitTest/3rdparty/contract/ut_contract_readonly.cpp create mode 100644 tests/UnitTest/3rdparty/contract/ut_contract_readwrite.cpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index bcecfaf78..1cd5ad930 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -24,10 +24,26 @@ add_custom_target(test COMMAND ${CMAKE_BINARY_DIR}/bin/tests/readonlylibarchiveplugin_test COMMAND ${CMAKE_BINARY_DIR}/bin/tests/readwritelibarchiveplugin_test COMMAND ${CMAKE_BINARY_DIR}/bin/tests/xzplugin_test + COMMAND ${CMAKE_BINARY_DIR}/bin/tests/contract_test COMMAND echo " =================== TEST END ==================== " ) +add_dependencies(test + bz2plugin_test + cli7zplugin_test + clirarplugin_test + deepin-compressor_test + gzplugin_test + interface_test + libminizipplugin_test + libpigzplugin_test + libzipplugin_test + readonlylibarchiveplugin_test + readwritelibarchiveplugin_test + xzplugin_test + contract_test +) + # debian 会自动执行 make test #'make test'命令依赖与我们的测试程序 -#add_dependencies(test ${PROJECT_NAME_TEST}) diff --git a/tests/UnitTest/3rdparty/CMakeLists.txt b/tests/UnitTest/3rdparty/CMakeLists.txt index 925d2dc1f..833975a15 100644 --- a/tests/UnitTest/3rdparty/CMakeLists.txt +++ b/tests/UnitTest/3rdparty/CMakeLists.txt @@ -33,3 +33,5 @@ add_subdirectory(libminizipplugin) ##add_subdirectory(cliunarchiverplugin) #不可用 ##add_subdirectory(clizipplugin) #不可用 + +add_subdirectory(contract) diff --git a/tests/UnitTest/3rdparty/cli7zplugin/ut_cli7zplugin.cpp b/tests/UnitTest/3rdparty/cli7zplugin/ut_cli7zplugin.cpp index 624d4f5d1..f4a15cb84 100644 --- a/tests/UnitTest/3rdparty/cli7zplugin/ut_cli7zplugin.cpp +++ b/tests/UnitTest/3rdparty/cli7zplugin/ut_cli7zplugin.cpp @@ -234,12 +234,12 @@ TEST_F(UT_Cli7zPlugin, test_setupCliProperties) EXPECT_EQ(m_tester->m_cliProps->property("captureProgress"), false); EXPECT_EQ(m_tester->m_cliProps->property("addProgram"), QStringLiteral("7z")); - EXPECT_EQ(m_tester->m_cliProps->property("addSwitch"), QStringList({QStringLiteral("a"), QStringLiteral("-l")})); + EXPECT_EQ(m_tester->m_cliProps->property("addSwitch"), QStringList({QStringLiteral("a"), QStringLiteral("-snl")})); EXPECT_EQ(m_tester->m_cliProps->property("deleteProgram"), QStringLiteral("7z")); EXPECT_EQ(m_tester->m_cliProps->property("deleteSwitch"), QStringLiteral("d")); EXPECT_EQ(m_tester->m_cliProps->property("extractProgram"), QStringLiteral("7z")); - EXPECT_EQ(m_tester->m_cliProps->property("extractSwitch"), QStringLiteral("x")); + EXPECT_EQ(m_tester->m_cliProps->property("extractSwitch"), QStringList{QStringLiteral("x")}); EXPECT_EQ(m_tester->m_cliProps->property("progressarg"), QStringLiteral("-bsp1")); EXPECT_EQ(m_tester->m_cliProps->property("extractSwitchNoPreserve"), QStringList{QStringLiteral("e")}); @@ -250,9 +250,9 @@ TEST_F(UT_Cli7zPlugin, test_setupCliProperties) EXPECT_EQ(m_tester->m_cliProps->property("moveSwitch"), QStringLiteral("rn")); EXPECT_EQ(m_tester->m_cliProps->property("testProgram"), QStringLiteral("7z")) ; - EXPECT_EQ(m_tester->m_cliProps->property("testSwitch"), QStringLiteral("t")) ; + EXPECT_EQ(m_tester->m_cliProps->property("testSwitch"), QStringList{QStringLiteral("t")}); - EXPECT_EQ(m_tester->m_cliProps->property("passwordSwitch"), QStringLiteral("-p$Password")) ; + EXPECT_EQ(m_tester->m_cliProps->property("passwordSwitch"), QStringList{QStringLiteral("-p$Password")}); EXPECT_EQ(m_tester->m_cliProps->property("passwordSwitchHeaderEnc"), QStringList({QStringLiteral("-p$Password"), QStringLiteral("-mhe=on")})) ; EXPECT_EQ(m_tester->m_cliProps->property("compressionLevelSwitch"), QStringLiteral("-mx=$CompressionLevel")) ; @@ -589,6 +589,11 @@ TEST_F(UT_Cli7zPlugin, test_extractFiles_001) Stub stub; stub.set(ADDR(CliInterface, runProcess), cliInterface_runProcess_stub); + FileEntry rootEntry; + rootEntry.strFullPath = "1.txt"; + rootEntry.strFileName = "1.txt"; + DataManager::get_instance().archiveData().listRootEntry << rootEntry; + ExtractionOptions options; options.strTargetPath = _UTSOURCEDIR; options.strTargetPath += "/test_sources/7z/extract"; @@ -596,6 +601,7 @@ TEST_F(UT_Cli7zPlugin, test_extractFiles_001) EXPECT_EQ(m_tester->extractFiles(QList(), options), PFT_Nomral); EXPECT_EQ(m_tester->m_strPassword.isEmpty(), true); EXPECT_EQ(m_tester->m_workStatus, WT_Extract); + DataManager::get_instance().resetArchiveData(); } TEST_F(UT_Cli7zPlugin, test_extractFiles_002) @@ -606,6 +612,7 @@ TEST_F(UT_Cli7zPlugin, test_extractFiles_002) FileEntry entry; entry.strFullPath = "1.txt"; entry.strFileName = "1.txt"; + DataManager::get_instance().archiveData().listRootEntry << entry; ExtractionOptions options; options.strTargetPath = _UTSOURCEDIR; options.strTargetPath += "/test_sources/7z/extract"; @@ -613,6 +620,7 @@ TEST_F(UT_Cli7zPlugin, test_extractFiles_002) EXPECT_EQ(m_tester->extractFiles(QList() << entry, options), PFT_Nomral); EXPECT_EQ(m_tester->m_strPassword.isEmpty(), true); EXPECT_EQ(m_tester->m_workStatus, WT_Extract); + DataManager::get_instance().resetArchiveData(); } TEST_F(UT_Cli7zPlugin, test_pauseOperation) @@ -622,6 +630,7 @@ TEST_F(UT_Cli7zPlugin, test_pauseOperation) m_tester->m_childProcessId << 123456 << 234567; m_tester->m_processId = 012345; m_tester->pauseOperation(); + EXPECT_EQ(m_tester->status(), true); } TEST_F(UT_Cli7zPlugin, test_continueOperation) @@ -630,7 +639,10 @@ TEST_F(UT_Cli7zPlugin, test_continueOperation) stub.set(kill, kill_stub); m_tester->m_childProcessId << 123456 << 234567; m_tester->m_processId = 012345; + m_tester->pauseOperation(); + EXPECT_EQ(m_tester->status(), true); m_tester->continueOperation(); + EXPECT_EQ(m_tester->status(), false); } TEST_F(UT_Cli7zPlugin, test_doKill_001) @@ -1052,16 +1064,6 @@ TEST_F(UT_Cli7zPlugin, test_writeToProcess) EXPECT_NE(m_tester->m_process->pty(), nullptr); } -TEST_F(UT_Cli7zPlugin, test_moveExtractTempFilesToDest) -{ - -} - -TEST_F(UT_Cli7zPlugin, test_readStdout) -{ - -} - TEST_F(UT_Cli7zPlugin, test_processFinished) { Stub stub; @@ -1075,7 +1077,7 @@ TEST_F(UT_Cli7zPlugin, test_processFinished) m_tester->m_workStatus = WT_List; m_tester->processFinished(0, QProcess::NormalExit); EXPECT_EQ(m_tester->m_eErrorType, ET_MissingVolume); - EXPECT_EQ(m_tester->m_finishType, PFT_Nomral); + EXPECT_EQ(m_tester->m_finishType, PFT_Cancel); EXPECT_EQ(m_tester->m_isCorruptArchive, false); } @@ -1109,11 +1111,6 @@ TEST_F(UT_Cli7zPlugin, test_extractProcessFinished_002) EXPECT_EQ(m_tester->m_rootNode.isEmpty(), true); } -TEST_F(UT_Cli7zPlugin, test_getChildProcessId) -{ - -} - TEST_F(UT_Cli7zPlugin, test_getTargetPath) { m_tester->m_extractOptions.strTargetPath = "a/b"; diff --git a/tests/UnitTest/3rdparty/clirarplugin/ut_clirarplugin.cpp b/tests/UnitTest/3rdparty/clirarplugin/ut_clirarplugin.cpp index b1104c304..a091076b6 100644 --- a/tests/UnitTest/3rdparty/clirarplugin/ut_clirarplugin.cpp +++ b/tests/UnitTest/3rdparty/clirarplugin/ut_clirarplugin.cpp @@ -126,7 +126,7 @@ TEST_F(UT_CliRarPlugin, test_setupCliProperties) EXPECT_EQ(m_tester->m_cliProps->property("moveSwitch"), QStringLiteral("rn")); EXPECT_EQ(m_tester->m_cliProps->property("testProgram"), QStringLiteral("unrar")); - EXPECT_EQ(m_tester->m_cliProps->property("testSwitch"), QStringLiteral("t")); + EXPECT_EQ(m_tester->m_cliProps->property("testSwitch"), QStringList{QStringLiteral("t")}); EXPECT_EQ(m_tester->m_cliProps->property("commentSwitch"), QStringList({QStringLiteral("c"), QStringLiteral("-z$CommentFile")})); @@ -294,7 +294,7 @@ TEST_F(UT_CliRarPlugin, test_handleLine_002) TEST_F(UT_CliRarPlugin, test_handleLine_003) { - EXPECT_EQ(m_tester->handleLine("The specified password is incorrect", WT_Extract), true); + EXPECT_EQ(m_tester->handleLine("The specified password is incorrect", WT_Extract), false); EXPECT_EQ(m_tester->m_eErrorType, ET_WrongPassword); } diff --git a/tests/UnitTest/3rdparty/contract/CMakeLists.txt b/tests/UnitTest/3rdparty/contract/CMakeLists.txt new file mode 100644 index 000000000..ab0577be7 --- /dev/null +++ b/tests/UnitTest/3rdparty/contract/CMakeLists.txt @@ -0,0 +1,97 @@ +# SPDX-FileCopyrightText: 2022 UnionTech Software Technology Co., Ltd. +# +# SPDX-License-Identifier: GPL-3.0-or-later + +cmake_minimum_required(VERSION 3.9.5) + +set(EXE_NAME contract) +project(${EXE_NAME}_test) + +find_package(PkgConfig REQUIRED) +find_package(Qt${QT_DESIRED_VERSION} REQUIRED COMPONENTS Widgets) +find_package(KF${KF_VERSION_MAJOR}Codecs REQUIRED) +find_package(KF${KF_VERSION_MAJOR}Archive REQUIRED) +find_package(Dtk${DTK_VERSION_MAJOR} REQUIRED COMPONENTS Core Widget) + +if (QT_DESIRED_VERSION MATCHES 6) + find_package(Qt${QT_DESIRED_VERSION} REQUIRED COMPONENTS Core5Compat) +endif() + +find_package(ZLIB REQUIRED) +find_package(LibArchive REQUIRED) +include(FindPkgConfig) + +pkg_search_module(MOUNT REQUIRED mount) + +# Header search paths for all plugin and interface headers +include_directories(${HFILES_OUTPUT_PATH}) +include_directories(${PROJECT_SOURCE_DIR}) +include_directories(${PROJECT_SOURCE_DIR}/${3RDPARTYRELATIVEPATH}/interface) +include_directories(${PROJECT_SOURCE_DIR}/${3RDPARTYRELATIVEPATH}/interface/archiveinterface) +include_directories(${PROJECT_SOURCE_DIR}/${3RDPARTYRELATIVEPATH}/interface/plugin) +include_directories(${PROJECT_SOURCE_DIR}/${3RDPARTYRELATIVEPATH}/interface/process) +include_directories(${PROJECT_SOURCE_DIR}/${3RDPARTYRELATIVEPATH}/cli7zplugin) +include_directories(${PROJECT_SOURCE_DIR}/${3RDPARTYRELATIVEPATH}/clirarplugin) +include_directories(${PROJECT_SOURCE_DIR}/${3RDPARTYRELATIVEPATH}/libzipplugin) +include_directories(${PROJECT_SOURCE_DIR}/${3RDPARTYRELATIVEPATH}/libminizipplugin) +include_directories(${PROJECT_SOURCE_DIR}/${3RDPARTYRELATIVEPATH}/libpigzplugin) +include_directories(${PROJECT_SOURCE_DIR}/${3RDPARTYRELATIVEPATH}/libarchive/libarchive) +include_directories(${PROJECT_SOURCE_DIR}/${3RDPARTYRELATIVEPATH}/libarchive/readonlylibarchiveplugin) +include_directories(${PROJECT_SOURCE_DIR}/${3RDPARTYRELATIVEPATH}/libarchive/readwritelibarchiveplugin) +include_directories(${PROJECT_SOURCE_DIR}/${3RDPARTYRELATIVEPATH}/libsinglefileplugin/singlefile) +include_directories(${PROJECT_SOURCE_DIR}/${3RDPARTYRELATIVEPATH}/libsinglefileplugin/gzplugin) +include_directories(${PROJECT_SOURCE_DIR}/${3RDPARTYRELATIVEPATH}/libsinglefileplugin/bz2plugin) +include_directories(${PROJECT_SOURCE_DIR}/${3RDPARTYRELATIVEPATH}/libsinglefileplugin/xzplugin) +include_directories(${PROJECT_SOURCE_DIR}/${3RDPARTYRELATIVEPATH}/ChardetDetector) + +link_directories(${LIBRARY_OUTPUT_PATH}) + +# Ensure linker can find libarchive (transitive dependency from compressor-archive +# via plugin shared libraries). +if(LibArchive_LIBRARIES) + get_filename_component(LIBARCHIVE_LIB_DIR "${LibArchive_LIBRARIES}" DIRECTORY) + link_directories(${LIBARCHIVE_LIB_DIR}) +endif() + +# Only compile the contract test sources — plugin sources come from the +# pre-built shared libraries to avoid Q_PLUGIN_METADATA symbol conflicts. +file(GLOB c_files ./*.cpp) +file(GLOB h_files ./*.h) + +add_executable(${EXE_NAME}_test ${c_files} ${h_files}) + +target_link_libraries(${EXE_NAME}_test + Qt${QT_DESIRED_VERSION}::Widgets + KF${KF_VERSION_MAJOR}::Codecs + KF${KF_VERSION_MAJOR}::Archive + Dtk${DTK_VERSION_MAJOR}::Core + Dtk${DTK_VERSION_MAJOR}::Widget + ${MOUNT_LIBRARIES} + # Pre-built plugin shared libraries (each carries its own Q_PLUGIN_METADATA) + cli7zplugin + clirarplugin + libzipplugin + minizipplugin + libpigzplugin + readonlylibarchiveplugin + readwritelibarchiveplugin + gzplugin + bz2plugin + xzplugin + # libarchive transitive dependencies + acl + xml2 + # Base libraries + compressor-interface + ZLIB::ZLIB + compressor-singlefile + compressor-ChardetDetector + ${GTEST_LIBRARIES} + pthread +) + +if (QT_DESIRED_VERSION MATCHES 6) + target_link_libraries(${EXE_NAME}_test + Qt${QT_DESIRED_VERSION}::Core5Compat + ) +endif() diff --git a/tests/UnitTest/3rdparty/contract/contract_test.h b/tests/UnitTest/3rdparty/contract/contract_test.h new file mode 100644 index 000000000..55fae95db --- /dev/null +++ b/tests/UnitTest/3rdparty/contract/contract_test.h @@ -0,0 +1,503 @@ +// Copyright (C) 2019 ~ 2020 Uniontech Software Technology Co.,Ltd. +// SPDX-FileCopyrightText: 2022 - 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef CONTRACT_TEST_H +#define CONTRACT_TEST_H + +#include "archiveinterface.h" +#include "datamanager.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Factory type aliases — a self-developed backend only needs to provide +// a callable matching one of these signatures to be covered by the +// unified contract test suite. +// --------------------------------------------------------------------------- +using ReadOnlyBackendFactory = + std::function; +using ReadWriteBackendFactory = + std::function; + +// --------------------------------------------------------------------------- +// Build QVariantList from a real archive path using QMimeDatabase to detect +// the MIME type by content — the same pattern the individual plugin tests use. +// --------------------------------------------------------------------------- +inline QVariantList makePluginArgs(const QString &archivePath) +{ + KPluginMetaData metaData; + QMimeDatabase db; + const QMimeType mimeFromContent = QFileInfo::exists(archivePath) + ? db.mimeTypeForFile(archivePath, QMimeDatabase::MatchContent) + : db.mimeTypeForFile(archivePath, QMimeDatabase::MatchExtension); + QVariantList args; + args << QVariant(archivePath) + << QVariant::fromValue(metaData) + << QVariant::fromValue(mimeFromContent); + return args; +} + +// --------------------------------------------------------------------------- +// SignalTracker — captures signal emissions from ReadOnlyArchiveInterface +// without requiring QtTest's QSignalSpy (which is unavailable in this env). +// --------------------------------------------------------------------------- +class SignalTracker : public QObject +{ +public: + int finishedCount = 0; + int progressCount = 0; + int curFileNameCount = 0; + int cancelCount = 0; + int queryCount = 0; + PluginFinishType lastFinishType = PFT_Nomral; + double lastProgress = -1.0; + QString lastCurFileName; + + void connect(ReadOnlyArchiveInterface *iface) + { + QObject::connect(iface, &ReadOnlyArchiveInterface::signalFinished, + this, [this](PluginFinishType t) { + finishedCount++; + lastFinishType = t; + }); + QObject::connect(iface, &ReadOnlyArchiveInterface::signalprogress, + this, [this](double p) { + progressCount++; + lastProgress = p; + }); + QObject::connect(iface, &ReadOnlyArchiveInterface::signalCurFileName, + this, [this](const QString &n) { + curFileNameCount++; + lastCurFileName = n; + }); + QObject::connect(iface, &ReadOnlyArchiveInterface::signalCancel, + this, [this]() { + cancelCount++; + }); + QObject::connect(iface, &ReadOnlyArchiveInterface::signalQuery, + this, [this](Query *) { + queryCount++; + }); + } + + void reset() + { + finishedCount = 0; + progressCount = 0; + curFileNameCount = 0; + cancelCount = 0; + queryCount = 0; + lastFinishType = PFT_Nomral; + lastProgress = -1.0; + lastCurFileName.clear(); + } +}; + +// --------------------------------------------------------------------------- +// Wait for signalFinished on async backends (CLI plugins that call +// setWaitForFinishedSignal(true)). Uses QEventLoop with a timeout so the +// test never hangs. +// --------------------------------------------------------------------------- +inline void waitForFinishedSignal(ReadOnlyArchiveInterface *iface, int timeoutMs = 15000) +{ + if (!iface->waitForFinished()) + return; // synchronous backend — nothing to wait for + + QEventLoop loop; + QTimer timer; + timer.setSingleShot(true); + QObject::connect(iface, &ReadOnlyArchiveInterface::signalFinished, + &loop, &QEventLoop::quit); + QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit); + timer.start(timeoutMs); + loop.exec(); + timer.stop(); +} + +// Run an operation and, for asynchronous backends, wait for the final +// signalFinished value instead of relying on the initial return value. +inline PluginFinishType runInterfaceOp(ReadOnlyArchiveInterface *iface, + bool isAsync, + std::function op) +{ + if (!isAsync) + return op(); + + bool finished = false; + PluginFinishType finalType = PFT_Error; + QEventLoop loop; + QTimer timer; + timer.setSingleShot(true); + + const auto connection = QObject::connect( + iface, &ReadOnlyArchiveInterface::signalFinished, &loop, + [&finished, &finalType, &loop](PluginFinishType type) { + finished = true; + finalType = type; + loop.quit(); + }); + + const PluginFinishType initialType = op(); + if (!finished) { + timer.start(15000); + QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit); + loop.exec(); + timer.stop(); + } + QObject::disconnect(connection); + + Q_UNUSED(initialType); + return finished ? finalType : PFT_Error; +} + +// --------------------------------------------------------------------------- +// ReadOnlyBackendCase — declares a read-only backend factory along with +// capability flags so the contract test only exercises supported operations. +// --------------------------------------------------------------------------- +struct ReadOnlyBackendCase { + std::string name; + ReadOnlyBackendFactory factory; + QString fixtureKind; // zip, tar, 7z, rar, gz, bz2, xz + QString archivePath; // relative fixture path + bool isAsync = false; // CLI backends: list/extract are async + bool supportsList = true; + bool supportsTestArchive = true; + bool supportsExtractFiles = true; + bool canTestCorrupt = false; // testArchive() detects corrupt archives + bool canTestPasswordArchive = false; // testArchive() detects password-protected archives + bool canExtractWrongPassword = false; // extractFiles() with wrong password returns error +}; + +// --------------------------------------------------------------------------- +// ReadWriteBackendCase — declares a read-write backend factory along with +// capability flags so the contract test only exercises supported operations. +// --------------------------------------------------------------------------- +struct ReadWriteBackendCase { + std::string name; + ReadWriteBackendFactory factory; + QString fixtureKind; + QString archivePath; + bool isAsync = false; + bool supportsAddFiles = false; + bool supportsDeleteFiles = false; + bool supportsRenameFiles = false; + bool supportsAddComment = false; + bool supportsUpdateArchiveData = true; + bool unsupportedAddCommentReturnsError = true; +}; + +// --------------------------------------------------------------------------- +// Test fixture helpers — create real archives on disk using system tools, +// clean up automatically via QTemporaryDir. All QProcess calls are checked +// for successful start, completion, and exit code. +// --------------------------------------------------------------------------- +class ContractTestFixture +{ +public: + ContractTestFixture() + { + m_tempDir = new QTemporaryDir(); + m_tempDir->setAutoRemove(true); + } + + ~ContractTestFixture() + { + delete m_tempDir; + } + + QString tempPath() const { return m_tempDir->path(); } + + // Create a plain text file with known content. + QString makeFile(const QString &relativePath, const QString &content = "hello world") + { + QString fullPath = m_tempDir->filePath(relativePath); + QDir().mkpath(QFileInfo(fullPath).absolutePath()); + QFile f(fullPath); + f.open(QIODevice::WriteOnly); + f.write(content.toUtf8()); + f.close(); + return fullPath; + } + + // Create a nested directory structure: dir/sub/deep.txt + QString makeNestedDir(const QString &relativeBase) + { + QString base = m_tempDir->filePath(relativeBase); + QDir().mkpath(base + "/sub"); + QFile f(base + "/sub/deep.txt"); + f.open(QIODevice::WriteOnly); + f.write("nested content"); + f.close(); + return base; + } + + // Run a command and assert success — checked start, finish, and exit code. + bool runChecked(const QString &program, const QStringList &args, + const QString &workDir = QString()) + { + QProcess proc; + if (!workDir.isEmpty()) + proc.setWorkingDirectory(workDir); + proc.start(program, args); + if (!proc.waitForStarted(5000)) { + ADD_FAILURE() << "QProcess failed to start: " << program.toStdString() + << " error=" << proc.error(); + return false; + } + if (!proc.waitForFinished(30000)) { + ADD_FAILURE() << "QProcess timed out: " << program.toStdString(); + proc.kill(); + proc.waitForFinished(3000); + return false; + } + if (proc.exitCode() != 0) { + ADD_FAILURE() << "QProcess exited with code " << proc.exitCode() + << ": " << program.toStdString() + << " stderr=" << proc.readAllStandardError().toStdString(); + return false; + } + return true; + } + + // Create a normal zip archive containing file1.txt and dir/file2.txt + QString makeZipArchive(const QString &relativePath) + { + QString fullPath = m_tempDir->filePath(relativePath); + QDir().mkpath(QFileInfo(fullPath).absolutePath()); + + // Create source files + makeFile("zip_src/file1.txt", "file1 content"); + makeNestedDir("zip_src/dir"); + // makeNestedDir creates zip_src/dir/sub/deep.txt, we need dir/file2.txt + makeFile("zip_src/dir/file2.txt", "file2 content"); + + if (!runChecked("zip", QStringList() << "-r" << fullPath << "file1.txt" << "dir", + m_tempDir->filePath("zip_src"))) { + return fullPath; // failure already recorded + } + return fullPath; + } + + // Create a normal tar archive + QString makeTarArchive(const QString &relativePath) + { + QString fullPath = m_tempDir->filePath(relativePath); + QDir().mkpath(QFileInfo(fullPath).absolutePath()); + + makeFile("tar_src/test.txt", "test content"); + makeFile("tar_src/test1.txt", "test1 content"); + + if (!runChecked("tar", QStringList() << "cf" << fullPath << "test.txt" << "test1.txt", + m_tempDir->filePath("tar_src"))) { + return fullPath; + } + return fullPath; + } + + // Copy a checked-in fixture into the temporary workspace. + QString copyExisting(const QString &source, const QString &relativeDest) + { + if (!QFile::exists(source)) { + ADD_FAILURE() << "Fixture does not exist: " << source.toStdString(); + return {}; + } + + QString dest = m_tempDir->filePath(relativeDest); + QDir().mkpath(QFileInfo(dest).absolutePath()); + if (!QFile::copy(source, dest)) { + ADD_FAILURE() << "Failed to copy fixture: " << source.toStdString() + << " -> " << dest.toStdString(); + } + return dest; + } + + // Create the fixture appropriate for the backend from a relative spec. + QString makeArchive(const QString &kind, const QString &relativePath) + { + if (kind == "zip") + return makeZipArchive(relativePath); + if (kind == "tar") + return makeTarArchive(relativePath); + if (kind == "7z") + return make7zArchive(relativePath); + if (kind == "rar") + return copyExisting( + QString(_SOURCEDIR) + + "/tests/FuzzyTest/libfuzzer/libfuzzer_in/archives/common/rar/small_archive.rar", + relativePath); + if (kind == "gz") + return copyExisting( + QString(_UTSOURCEDIR) + "/test_sources/gz/test.gz", relativePath); + if (kind == "bz2") + return copyExisting( + QString(_UTSOURCEDIR) + "/test_sources/bz2/test.txt.bz2", relativePath); + if (kind == "xz") + return copyExisting( + QString(_UTSOURCEDIR) + "/test_sources/xz/test.xz", relativePath); + + ADD_FAILURE() << "Unknown fixture kind: " << kind.toStdString(); + return {}; + } + + // Create a 7z archive for CLI backend tests + QString make7zArchive(const QString &relativePath) + { + QString fullPath = m_tempDir->filePath(relativePath); + QDir().mkpath(QFileInfo(fullPath).absolutePath()); + + makeFile("7z_src/file1.txt", "7z file1 content"); + makeFile("7z_src/file2.txt", "7z file2 content"); + + if (!runChecked("7z", QStringList() << "a" << fullPath + << "file1.txt" << "file2.txt", + m_tempDir->filePath("7z_src"))) { + return fullPath; + } + return fullPath; + } + + // Create a password-protected zip archive + QString makePasswordZip(const QString &relativePath, const QString &password = "secret") + { + QString fullPath = m_tempDir->filePath(relativePath); + QDir().mkpath(QFileInfo(fullPath).absolutePath()); + + makeFile("pw_src/file1.txt", "password protected content"); + + if (!runChecked("7z", QStringList() << "a" << ("-p" + password) + << fullPath << "file1.txt", + m_tempDir->filePath("pw_src"))) { + return fullPath; + } + return fullPath; + } + + // Create a corrupted archive by overwriting the header with garbage AND + // truncating the tail. Overwriting the start corrupts tar headers so + // archive_read_next_header() fails; truncating the tail removes the zip + // central directory so zip_open() fails. Both operations are applied so + // the test works regardless of archive format. + QString makeCorruptArchive(const QString &relativePath, const QString &sourceArchive) + { + QString fullPath = m_tempDir->filePath(relativePath); + QDir().mkpath(QFileInfo(fullPath).absolutePath()); + QFile::copy(sourceArchive, fullPath); + QFile file(fullPath); + if (file.open(QIODevice::ReadWrite)) { + qint64 garbageSize = qMin(file.size(), qint64(256)); + QByteArray garbage(garbageSize, char(0xFF)); + file.write(garbage); + file.close(); + } + qint64 halfSize = QFileInfo(fullPath).size() / 2; + QFile::resize(fullPath, halfSize); + return fullPath; + } + + // Copy a file to the temp dir with a new name + QString copyToTemp(const QString &source, const QString &relativeDest) + { + QString dest = m_tempDir->filePath(relativeDest); + QDir().mkpath(QFileInfo(dest).absolutePath()); + QFile::copy(source, dest); + return dest; + } + +private: + QTemporaryDir *m_tempDir; +}; + +// --------------------------------------------------------------------------- +// ReadOnly contract fixture — parametrised on ReadOnlyBackendCase +// --------------------------------------------------------------------------- +class ReadOnlyContractTest + : public ::testing::TestWithParam +{ +protected: + void SetUp() override + { + const auto ¶m = GetParam(); + m_fixture = std::make_unique(); + m_archivePath = m_fixture->makeArchive(param.fixtureKind, param.archivePath); + ASSERT_FALSE(m_archivePath.isEmpty()); + m_iface = std::unique_ptr( + param.factory(m_archivePath)); + ASSERT_NE(m_iface, nullptr); + } + + void TearDown() override + { + m_iface.reset(); + m_fixture.reset(); + DataManager::get_instance().resetArchiveData(); + } + + // Helper: run an async operation and wait for signalFinished if needed. + PluginFinishType runOp(std::function op) + { + return runInterfaceOp(m_iface.get(), GetParam().isAsync, op); + } + + std::unique_ptr m_iface; + std::unique_ptr m_fixture; + QString m_archivePath; +}; + +// --------------------------------------------------------------------------- +// ReadWrite contract fixture — parametrised on ReadWriteBackendCase +// --------------------------------------------------------------------------- +class ReadWriteContractTest + : public ::testing::TestWithParam +{ +protected: + void SetUp() override + { + const auto ¶m = GetParam(); + m_fixture = std::make_unique(); + m_archivePath = m_fixture->makeArchive(param.fixtureKind, param.archivePath); + ASSERT_FALSE(m_archivePath.isEmpty()); + m_iface = std::unique_ptr( + param.factory(m_archivePath)); + ASSERT_NE(m_iface, nullptr); + } + + void TearDown() override + { + m_iface.reset(); + m_fixture.reset(); + DataManager::get_instance().resetArchiveData(); + } + + PluginFinishType runOp(std::function op) + { + return runInterfaceOp(m_iface.get(), GetParam().isAsync, op); + } + + PluginFinishType runOpOn(ReadWriteArchiveInterface *iface, + std::function op) + { + return runInterfaceOp(iface, GetParam().isAsync, op); + } + + std::unique_ptr m_iface; + std::unique_ptr m_fixture; + QString m_archivePath; +}; + +#endif // CONTRACT_TEST_H diff --git a/tests/UnitTest/3rdparty/contract/main.cpp b/tests/UnitTest/3rdparty/contract/main.cpp new file mode 100644 index 000000000..d2f0b6313 --- /dev/null +++ b/tests/UnitTest/3rdparty/contract/main.cpp @@ -0,0 +1,13 @@ +// Copyright (C) 2019 ~ 2020 Uniontech Software Technology Co.,Ltd. +// SPDX-FileCopyrightText: 2022 - 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/UnitTest/3rdparty/contract/ut_contract_readonly.cpp b/tests/UnitTest/3rdparty/contract/ut_contract_readonly.cpp new file mode 100644 index 000000000..d538ab2eb --- /dev/null +++ b/tests/UnitTest/3rdparty/contract/ut_contract_readonly.cpp @@ -0,0 +1,813 @@ +// Copyright (C) 2019 ~ 2020 Uniontech Software Technology Co.,Ltd. +// SPDX-FileCopyrightText: 2022 - 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "contract_test.h" + +#include "libzipplugin.h" +#include "libminizipplugin.h" +#include "readonlylibarchiveplugin.h" +#include "readwritelibarchiveplugin.h" +#include "gzplugin.h" +#include "bz2plugin.h" +#include "xzplugin.h" +#include "cli7zplugin.h" +#include "clirarplugin.h" +#include "libpigzplugin.h" + +#include "queries.h" +#include "gtest/src/stub.h" + +#include +#include +#include +#include +#include + +Q_DECLARE_METATYPE(KPluginMetaData) + +// --------------------------------------------------------------------------- +// Stub helpers for OverwriteQuery (used by extract tests) +// --------------------------------------------------------------------------- +static void waitForResponse_stub() {} +static bool responseCancelled_false_stub() { return false; } +static bool responseCancelled_true_stub() { return true; } +static bool responseSkip_false_stub() { return false; } +static bool responseSkipAll_false_stub() { return false; } +static bool responseOverwriteAll_true_stub() { return true; } + +// --------------------------------------------------------------------------- +// Stub helpers for PasswordNeededQuery (used by password-protected extract tests) +// --------------------------------------------------------------------------- +static void pw_waitForResponse_stub() {} +static bool pw_responseCancelled_true_stub() { return true; } +static bool pw_responseCancelled_false_stub() { return false; } +static QString pw_password_secret_stub() { return "secret"; } +static QString pw_password_wrong_stub() { return "wrongpassword"; } + +// --------------------------------------------------------------------------- +// Global test fixture — creates real archives on disk for all backends +// --------------------------------------------------------------------------- +static ContractTestFixture &globalFixture() +{ + static ContractTestFixture fixture; + return fixture; +} + +// --------------------------------------------------------------------------- +// Factory helpers — each creates a plugin instance using real archive paths. +// --------------------------------------------------------------------------- + +static ReadOnlyArchiveInterface *makeLibzipPlugin(const QString &path) +{ + return new LibzipPlugin(nullptr, makePluginArgs(path)); +} + +static ReadOnlyArchiveInterface *makeLibminizipPlugin(const QString &path) +{ + return new LibminizipPlugin(nullptr, makePluginArgs(path)); +} + +static ReadOnlyArchiveInterface *makeReadOnlyLibarchivePlugin(const QString &path) +{ + return new ReadOnlyLibarchivePlugin(nullptr, makePluginArgs(path)); +} + +static ReadOnlyArchiveInterface *makeGzPlugin(const QString &path) +{ + return new LibGzipInterface(nullptr, makePluginArgs(path)); +} + +static ReadOnlyArchiveInterface *makeBz2Plugin(const QString &path) +{ + return new LibBzip2Interface(nullptr, makePluginArgs(path)); +} + +static ReadOnlyArchiveInterface *makeXzPlugin(const QString &path) +{ + return new LibXzInterface(nullptr, makePluginArgs(path)); +} + +static ReadOnlyArchiveInterface *makeCli7zPlugin(const QString &path) +{ + return new Cli7zPlugin(nullptr, makePluginArgs(path)); +} + +static ReadOnlyArchiveInterface *makeCliRarPlugin(const QString &path) +{ + return new CliRarPlugin(nullptr, makePluginArgs(path)); +} + +// --------------------------------------------------------------------------- +// Test case instantiation — each backend declares its capabilities. +// A future self-developed backend only needs to add its case here. +// --------------------------------------------------------------------------- +INSTANTIATE_TEST_SUITE_P( + AllReadOnlyBackends, + ReadOnlyContractTest, + ::testing::Values( + // LibzipPlugin — full read-only support, supports corrupt + password tests + ReadOnlyBackendCase{ + "LibzipPlugin", makeLibzipPlugin, + "zip", "readonly/zip/test.zip", + false, // isAsync + true, true, true, // list, testArchive, extractFiles + true, // canTestCorrupt + true, // canTestPasswordArchive + true // canExtractWrongPassword + }, + ReadOnlyBackendCase{ + "LibminizipPlugin", makeLibminizipPlugin, + "zip", "readonly/minizip/test.zip", + false, true, true, true, true, false, false + }, + ReadOnlyBackendCase{ + "ReadOnlyLibarchivePlugin", makeReadOnlyLibarchivePlugin, + "tar", "readonly/tar/test.tar", + false, true, true, true, true, false, false + }, + ReadOnlyBackendCase{ + "LibGzipInterface", makeGzPlugin, + "gz", "readonly/gz/test.gz", + false, true, true, true, false, false, false + }, + ReadOnlyBackendCase{ + "LibBzip2Interface", makeBz2Plugin, + "bz2", "readonly/bz2/test.txt.bz2", + false, true, true, true, false, false, false + }, + ReadOnlyBackendCase{ + "LibXzInterface", makeXzPlugin, + "xz", "readonly/xz/test.xz", + false, true, true, true, false, false, false + }, + // Cli7zPlugin — async, uses real 7z binary for list/extract; + // testArchive() is a sync no-op returning PFT_Nomral + ReadOnlyBackendCase{ + "Cli7zPlugin", makeCli7zPlugin, + "7z", "readonly/cli7z/test.7z", + true, // isAsync — list/extract spawn real process + true, // supportsList + true, // supportsTestArchive (no-op, returns PFT_Nomral) + true, // supportsExtractFiles + false, false, false + }, + // CliRarPlugin — async, uses real unrar binary for list/extract; + // testArchive() is a sync no-op + ReadOnlyBackendCase{ + "CliRarPlugin", makeCliRarPlugin, + "rar", "readonly/cliRar/test.rar", + true, // isAsync + true, true, true, + false, false, false + } + ), + [](const ::testing::TestParamInfo &info) { + return info.param.name; + } +); + +// =========================================================================== +// Contract: list() — load archive and verify entry data +// Verifies: PluginFinishType, entry count, entry names, directory flag +// =========================================================================== +TEST_P(ReadOnlyContractTest, ListLoadsArchive) +{ + if (!GetParam().supportsList) GTEST_SKIP(); + + PluginFinishType result = runOp([this]() { return m_iface->list(); }); + EXPECT_EQ(result, PFT_Nomral); +} + +TEST_P(ReadOnlyContractTest, ListEntriesNotEmpty) +{ + if (!GetParam().supportsList) GTEST_SKIP(); + + runOp([this]() { return m_iface->list(); }); + + ArchiveData &data = DataManager::get_instance().archiveData(); + EXPECT_FALSE(data.listRootEntry.isEmpty()); +} + +TEST_P(ReadOnlyContractTest, ListEntriesHaveCorrectCount) +{ + if (!GetParam().supportsList) GTEST_SKIP(); + + runOp([this]() { return m_iface->list(); }); + + ArchiveData &data = DataManager::get_instance().archiveData(); + // All test archives contain at least 1 file entry + EXPECT_GT(data.mapFileEntry.size(), 0); +} + +TEST_P(ReadOnlyContractTest, ListEntriesHaveNames) +{ + if (!GetParam().supportsList) GTEST_SKIP(); + + runOp([this]() { return m_iface->list(); }); + + ArchiveData &data = DataManager::get_instance().archiveData(); + for (const FileEntry &entry : data.listRootEntry) { + EXPECT_FALSE(entry.strFileName.isEmpty()); + } +} + +TEST_P(ReadOnlyContractTest, ListEntriesHaveDirectoryFlag) +{ + if (!GetParam().supportsList) GTEST_SKIP(); + + runOp([this]() { return m_iface->list(); }); + + ArchiveData &data = DataManager::get_instance().archiveData(); + // At least one non-directory entry should exist (files were added) + bool hasFileEntry = false; + for (const FileEntry &entry : data.listRootEntry) { + if (!entry.isDirectory) { + hasFileEntry = true; + break; + } + } + EXPECT_TRUE(hasFileEntry); +} + +// =========================================================================== +// Contract: testArchive() — verify archive integrity +// =========================================================================== +TEST_P(ReadOnlyContractTest, TestArchiveNormalSucceeds) +{ + if (!GetParam().supportsTestArchive) GTEST_SKIP(); + + PluginFinishType result = m_iface->testArchive(); + EXPECT_EQ(result, PFT_Nomral); +} + +TEST_P(ReadOnlyContractTest, ListCorruptArchiveReturnsError) +{ + if (!GetParam().canTestCorrupt) GTEST_SKIP(); + + // Create a corrupt archive by truncating + QString corruptPath = globalFixture().makeCorruptArchive( + "corrupt/" + QString::fromStdString(GetParam().name) + "/test_corrupt", + m_archivePath); + + std::unique_ptr corruptIface(GetParam().factory(corruptPath)); + ASSERT_NE(corruptIface, nullptr); + + // testArchive() is a no-op stub in current backends, so verify corruption + // detection through list() which actually opens the archive. + PluginFinishType result = corruptIface->list(); + if (GetParam().isAsync) { + waitForFinishedSignal(corruptIface.get()); + } + EXPECT_NE(result, PFT_Nomral); +} + +TEST_P(ReadOnlyContractTest, ExtractPasswordProtectedArchive) +{ + if (!GetParam().canTestPasswordArchive) GTEST_SKIP(); + + // Create a password-protected zip archive + QString pwPath = globalFixture().makePasswordZip( + "pw_test/" + QString::fromStdString(GetParam().name) + "/test_pw.zip", "secret"); + + // 1. list() should succeed — listing doesn't require password for zip + std::unique_ptr pwIface(GetParam().factory(pwPath)); + ASSERT_NE(pwIface, nullptr); + PluginFinishType listResult = pwIface->list(); + if (GetParam().isAsync) { + waitForFinishedSignal(pwIface.get()); + } + EXPECT_EQ(listResult, PFT_Nomral); + + // 2. extractFiles() without password (cancelled) should not return PFT_Nomral + std::unique_ptr pwIface2(GetParam().factory(pwPath)); + ASSERT_NE(pwIface2, nullptr); + pwIface2->list(); + if (GetParam().isAsync) { + waitForFinishedSignal(pwIface2.get()); + } + + Stub stub; + stub.set(ADDR(OverwriteQuery, waitForResponse), waitForResponse_stub); + stub.set(ADDR(OverwriteQuery, responseCancelled), responseCancelled_false_stub); + stub.set(ADDR(OverwriteQuery, responseSkip), responseSkip_false_stub); + stub.set(ADDR(OverwriteQuery, responseSkipAll), responseSkipAll_false_stub); + stub.set(ADDR(OverwriteQuery, responseOverwriteAll), responseOverwriteAll_true_stub); + stub.set(ADDR(PasswordNeededQuery, waitForResponse), pw_waitForResponse_stub); + stub.set(ADDR(PasswordNeededQuery, responseCancelled), pw_responseCancelled_true_stub); + + QString extractDir = globalFixture().tempPath() + "/pw_cancel_" + + QString::fromStdString(GetParam().name); + QDir().mkpath(extractDir); + ExtractionOptions options; + options.bAllExtract = true; + options.bExistList = true; + options.qSize = DataManager::get_instance().archiveData().qSize; + options.strTargetPath = extractDir; + PluginFinishType resultNoPw = pwIface2->extractFiles(QList(), options); + EXPECT_NE(resultNoPw, PFT_Nomral); + QDir(extractDir).removeRecursively(); + + // 3. extractFiles() with correct password should succeed + std::unique_ptr pwIface3(GetParam().factory(pwPath)); + ASSERT_NE(pwIface3, nullptr); + pwIface3->list(); + if (GetParam().isAsync) { + waitForFinishedSignal(pwIface3.get()); + } + + Stub stub2; + stub2.set(ADDR(OverwriteQuery, waitForResponse), waitForResponse_stub); + stub2.set(ADDR(OverwriteQuery, responseCancelled), responseCancelled_false_stub); + stub2.set(ADDR(OverwriteQuery, responseSkip), responseSkip_false_stub); + stub2.set(ADDR(OverwriteQuery, responseSkipAll), responseSkipAll_false_stub); + stub2.set(ADDR(OverwriteQuery, responseOverwriteAll), responseOverwriteAll_true_stub); + stub2.set(ADDR(PasswordNeededQuery, waitForResponse), pw_waitForResponse_stub); + stub2.set(ADDR(PasswordNeededQuery, responseCancelled), pw_responseCancelled_false_stub); + stub2.set(ADDR(PasswordNeededQuery, password), pw_password_secret_stub); + + QString extractDir2 = globalFixture().tempPath() + "/pw_correct_" + + QString::fromStdString(GetParam().name); + QDir().mkpath(extractDir2); + ExtractionOptions options2; + options2.bAllExtract = true; + options2.strTargetPath = extractDir2; + PluginFinishType resultWithPw = pwIface3->extractFiles(QList(), options2); + EXPECT_EQ(resultWithPw, PFT_Nomral); + + // Verify extracted file has correct content + bool foundContent = false; + QDirIterator it(extractDir2, QDir::Files, QDirIterator::Subdirectories); + while (it.hasNext()) { + QFile f(it.next()); + if (f.open(QIODevice::ReadOnly)) { + if (f.readAll().contains("password protected content")) { + foundContent = true; + } + f.close(); + } + } + EXPECT_TRUE(foundContent); + + QDir(extractDir2).removeRecursively(); +} + +// =========================================================================== +// Contract: extractFiles() — full extraction with content verification +// =========================================================================== +TEST_P(ReadOnlyContractTest, ExtractAllFilesSucceeds) +{ + if (!GetParam().supportsExtractFiles) GTEST_SKIP(); + + // List first to populate archive data + runOp([this]() { return m_iface->list(); }); + + QString extractDir = globalFixture().tempPath() + "/extract_all_" + + QString::fromStdString(GetParam().name); + QDir().mkpath(extractDir); + + Stub stub; + stub.set(ADDR(OverwriteQuery, waitForResponse), waitForResponse_stub); + stub.set(ADDR(OverwriteQuery, responseCancelled), responseCancelled_false_stub); + stub.set(ADDR(OverwriteQuery, responseSkip), responseSkip_false_stub); + stub.set(ADDR(OverwriteQuery, responseSkipAll), responseSkipAll_false_stub); + stub.set(ADDR(OverwriteQuery, responseOverwriteAll), responseOverwriteAll_true_stub); + + ExtractionOptions options; + options.bAllExtract = true; + options.strTargetPath = extractDir; + + PluginFinishType result = runOp([this, &options]() { + return m_iface->extractFiles(QList(), options); + }); + + EXPECT_EQ(result, PFT_Nomral); + + // Verify extraction produced files + QDir dir(extractDir); + EXPECT_FALSE(dir.entryList(QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot).isEmpty()); + + QDir(extractDir).removeRecursively(); +} + +TEST_P(ReadOnlyContractTest, ExtractAllFilesContentVerified) +{ + if (!GetParam().supportsExtractFiles) GTEST_SKIP(); + // Skip backends whose list() is a stub (no real entries to extract) + if (GetParam().name == "LibPigzPlugin") GTEST_SKIP(); + + runOp([this]() { return m_iface->list(); }); + + ArchiveData &data = DataManager::get_instance().archiveData(); + ASSERT_FALSE(data.listRootEntry.isEmpty()); + + QString extractDir = globalFixture().tempPath() + "/extract_content_" + + QString::fromStdString(GetParam().name); + QDir().mkpath(extractDir); + + Stub stub; + stub.set(ADDR(OverwriteQuery, waitForResponse), waitForResponse_stub); + stub.set(ADDR(OverwriteQuery, responseCancelled), responseCancelled_false_stub); + stub.set(ADDR(OverwriteQuery, responseSkip), responseSkip_false_stub); + stub.set(ADDR(OverwriteQuery, responseSkipAll), responseSkipAll_false_stub); + stub.set(ADDR(OverwriteQuery, responseOverwriteAll), responseOverwriteAll_true_stub); + + ExtractionOptions options; + options.bAllExtract = true; + options.strTargetPath = extractDir; + + PluginFinishType result = runOp([this, &options]() { + return m_iface->extractFiles(QList(), options); + }); + EXPECT_EQ(result, PFT_Nomral); + + // Verify at least one extracted file has non-empty content + bool foundFileWithContent = false; + QStringList extractedFiles; + QDirIterator it(extractDir, QDir::Files, QDirIterator::Subdirectories); + while (it.hasNext()) { + QString filePath = it.next(); + extractedFiles << it.fileName(); + QFile f(filePath); + if (f.open(QIODevice::ReadOnly)) { + if (f.size() > 0) { + foundFileWithContent = true; + } + f.close(); + } + } + EXPECT_FALSE(extractedFiles.isEmpty()); + EXPECT_TRUE(foundFileWithContent); + + QDir(extractDir).removeRecursively(); +} + +TEST_P(ReadOnlyContractTest, ExtractPartialFiles) +{ + if (!GetParam().supportsExtractFiles) GTEST_SKIP(); + if (GetParam().name == "LibPigzPlugin") GTEST_SKIP(); + + runOp([this]() { return m_iface->list(); }); + + ArchiveData &data = DataManager::get_instance().archiveData(); + ASSERT_FALSE(data.listRootEntry.isEmpty()); + + // Pick the first file entry (non-directory) + FileEntry target; + bool found = false; + for (const FileEntry &e : data.listRootEntry) { + if (!e.isDirectory) { + target = e; + found = true; + break; + } + } + if (!found) { + target = data.listRootEntry.first(); + found = true; + } + ASSERT_TRUE(found); + + QString extractDir = globalFixture().tempPath() + "/extract_partial_" + + QString::fromStdString(GetParam().name); + QDir().mkpath(extractDir); + + Stub stub; + stub.set(ADDR(OverwriteQuery, waitForResponse), waitForResponse_stub); + stub.set(ADDR(OverwriteQuery, responseCancelled), responseCancelled_false_stub); + stub.set(ADDR(OverwriteQuery, responseSkip), responseSkip_false_stub); + stub.set(ADDR(OverwriteQuery, responseSkipAll), responseSkipAll_false_stub); + stub.set(ADDR(OverwriteQuery, responseOverwriteAll), responseOverwriteAll_true_stub); + + QList files; + files << target; + ExtractionOptions options; + options.bAllExtract = false; + options.strTargetPath = extractDir; + + PluginFinishType result = runOp([this, &files, &options]() { + return m_iface->extractFiles(files, options); + }); + EXPECT_EQ(result, PFT_Nomral); + + QDir(extractDir).removeRecursively(); +} + +TEST_P(ReadOnlyContractTest, ExtractWrongPasswordReturnsError) +{ + if (!GetParam().canExtractWrongPassword) GTEST_SKIP(); + + QString pwPath = globalFixture().makePasswordZip( + "pw_extract/" + QString::fromStdString(GetParam().name) + "/test.zip", "secret"); + + std::unique_ptr pwIface(GetParam().factory(pwPath)); + ASSERT_NE(pwIface, nullptr); + + // List first (required before extract) + PluginFinishType listResult = pwIface->list(); + if (GetParam().isAsync) { + waitForFinishedSignal(pwIface.get()); + } + + // extractFiles() clears any preset password via setPassword(QString()), + // so the password must be provided via PasswordNeededQuery stub. + // Stub PasswordNeededQuery to return a wrong password. + Stub stub; + stub.set(ADDR(OverwriteQuery, waitForResponse), waitForResponse_stub); + stub.set(ADDR(OverwriteQuery, responseCancelled), responseCancelled_false_stub); + stub.set(ADDR(OverwriteQuery, responseSkip), responseSkip_false_stub); + stub.set(ADDR(OverwriteQuery, responseSkipAll), responseSkipAll_false_stub); + stub.set(ADDR(OverwriteQuery, responseOverwriteAll), responseOverwriteAll_true_stub); + stub.set(ADDR(PasswordNeededQuery, waitForResponse), pw_waitForResponse_stub); + stub.set(ADDR(PasswordNeededQuery, responseCancelled), pw_responseCancelled_false_stub); + stub.set(ADDR(PasswordNeededQuery, password), pw_password_wrong_stub); + + QString extractDir = globalFixture().tempPath() + "/extract_wrongpw_" + + QString::fromStdString(GetParam().name); + QDir().mkpath(extractDir); + + ExtractionOptions options; + options.bAllExtract = true; + options.strTargetPath = extractDir; + + PluginFinishType result = pwIface->extractFiles(QList(), options); + // Wrong password should not produce normal completion + EXPECT_NE(result, PFT_Nomral); + + QDir(extractDir).removeRecursively(); +} + +// =========================================================================== +// Contract: signal tests — verify progress, cancel, finished signals +// =========================================================================== +TEST_P(ReadOnlyContractTest, SignalFinishedOnList) +{ + if (!GetParam().supportsList) GTEST_SKIP(); + + SignalTracker tracker; + tracker.connect(m_iface.get()); + + PluginFinishType result = runOp([this]() { return m_iface->list(); }); + + // For async backends, signalFinished is emitted after process completes + // For sync backends that don't emit signalFinished, we still verify the + // operation completed (result == PFT_Nomral) + if (GetParam().isAsync) { + EXPECT_GT(tracker.finishedCount, 0); + EXPECT_EQ(tracker.lastFinishType, PFT_Nomral); + } + EXPECT_EQ(result, PFT_Nomral); +} + +TEST_P(ReadOnlyContractTest, SignalFinishedOnExtract) +{ + if (!GetParam().supportsExtractFiles) GTEST_SKIP(); + if (GetParam().name == "LibPigzPlugin") GTEST_SKIP(); + + runOp([this]() { return m_iface->list(); }); + + SignalTracker tracker; + tracker.connect(m_iface.get()); + + QString extractDir = globalFixture().tempPath() + "/sig_extract_" + + QString::fromStdString(GetParam().name); + QDir().mkpath(extractDir); + + Stub stub; + stub.set(ADDR(OverwriteQuery, waitForResponse), waitForResponse_stub); + stub.set(ADDR(OverwriteQuery, responseCancelled), responseCancelled_false_stub); + stub.set(ADDR(OverwriteQuery, responseSkip), responseSkip_false_stub); + stub.set(ADDR(OverwriteQuery, responseSkipAll), responseSkipAll_false_stub); + stub.set(ADDR(OverwriteQuery, responseOverwriteAll), responseOverwriteAll_true_stub); + + ExtractionOptions options; + options.bAllExtract = true; + options.strTargetPath = extractDir; + + PluginFinishType result = runOp([this, &options]() { + return m_iface->extractFiles(QList(), options); + }); + + EXPECT_EQ(result, PFT_Nomral); + + // Async backends should emit signalFinished + if (GetParam().isAsync) { + EXPECT_GT(tracker.finishedCount, 0); + EXPECT_EQ(tracker.lastFinishType, PFT_Nomral); + } + + QDir(extractDir).removeRecursively(); +} + +TEST_P(ReadOnlyContractTest, SignalProgressOnExtract) +{ + if (!GetParam().supportsExtractFiles) GTEST_SKIP(); + if (GetParam().name == "LibPigzPlugin") GTEST_SKIP(); + + runOp([this]() { return m_iface->list(); }); + + SignalTracker tracker; + tracker.connect(m_iface.get()); + + QString extractDir = globalFixture().tempPath() + "/sig_progress_" + + QString::fromStdString(GetParam().name); + QDir().mkpath(extractDir); + + Stub stub; + stub.set(ADDR(OverwriteQuery, waitForResponse), waitForResponse_stub); + stub.set(ADDR(OverwriteQuery, responseCancelled), responseCancelled_false_stub); + stub.set(ADDR(OverwriteQuery, responseSkip), responseSkip_false_stub); + stub.set(ADDR(OverwriteQuery, responseSkipAll), responseSkipAll_false_stub); + stub.set(ADDR(OverwriteQuery, responseOverwriteAll), responseOverwriteAll_true_stub); + + ExtractionOptions options; + options.bAllExtract = true; + options.bExistList = true; + options.qSize = DataManager::get_instance().archiveData().qSize; + options.strTargetPath = extractDir; + + runOp([this, &options]() { + return m_iface->extractFiles(QList(), options); + }); + + // LibzipPlugin is a synchronous backend that emits progress while + // extracting. This backend must expose observable progress to src. + if (GetParam().name == "LibzipPlugin") { + EXPECT_GT(tracker.progressCount, 0); + EXPECT_GE(tracker.lastProgress, 0.0); + EXPECT_LE(tracker.lastProgress, 100.0); + } + + QDir(extractDir).removeRecursively(); +} + +TEST_P(ReadOnlyContractTest, SignalCurFileNameOnExtract) +{ + if (!GetParam().supportsExtractFiles) GTEST_SKIP(); + if (GetParam().name == "LibPigzPlugin") GTEST_SKIP(); + + runOp([this]() { return m_iface->list(); }); + + SignalTracker tracker; + tracker.connect(m_iface.get()); + + QString extractDir = globalFixture().tempPath() + "/sig_curname_" + + QString::fromStdString(GetParam().name); + QDir().mkpath(extractDir); + + Stub stub; + stub.set(ADDR(OverwriteQuery, waitForResponse), waitForResponse_stub); + stub.set(ADDR(OverwriteQuery, responseCancelled), responseCancelled_false_stub); + stub.set(ADDR(OverwriteQuery, responseSkip), responseSkip_false_stub); + stub.set(ADDR(OverwriteQuery, responseSkipAll), responseSkipAll_false_stub); + stub.set(ADDR(OverwriteQuery, responseOverwriteAll), responseOverwriteAll_true_stub); + + ExtractionOptions options; + options.bAllExtract = true; + options.strTargetPath = extractDir; + + runOp([this, &options]() { + return m_iface->extractFiles(QList(), options); + }); + + // signalCurFileName should be emitted during extraction for backends that + // process individual files + // LibzipPlugin emits signalCurFileName; verify at least one emission + if (GetParam().name == "LibzipPlugin" || GetParam().name == "LibminizipPlugin" || + GetParam().name == "ReadOnlyLibarchivePlugin") { + EXPECT_GT(tracker.curFileNameCount, 0); + } + + QDir(extractDir).removeRecursively(); +} + +// =========================================================================== +// Contract: cancel via doKill — verify signalCancel or error on cancel flow +// =========================================================================== +TEST_P(ReadOnlyContractTest, DoKillReturnsFalseWhenIdle) +{ + // doKill when no process is running should return false + EXPECT_FALSE(m_iface->doKill()); +} + +TEST_P(ReadOnlyContractTest, OverwriteCancelEmitsCancelSignal) +{ + if (GetParam().name != "LibzipPlugin") GTEST_SKIP(); + + runOp([this]() { return m_iface->list(); }); + ArchiveData &data = DataManager::get_instance().archiveData(); + ASSERT_FALSE(data.listRootEntry.isEmpty()); + + FileEntry target; + bool found = false; + for (const FileEntry &entry : data.listRootEntry) { + if (!entry.isDirectory) { + target = entry; + found = true; + break; + } + } + ASSERT_TRUE(found); + + QString extractDir = globalFixture().tempPath() + "/cancel_overwrite_" + + QString::fromStdString(GetParam().name); + QDir().mkpath(extractDir); + + QString destination = extractDir + "/" + target.strFileName; + QDir().mkpath(QFileInfo(destination).absolutePath()); + QFile existing(destination); + ASSERT_TRUE(existing.open(QIODevice::WriteOnly)); + existing.write("existing destination"); + existing.close(); + + SignalTracker tracker; + tracker.connect(m_iface.get()); + + Stub stub; + stub.set(ADDR(OverwriteQuery, waitForResponse), waitForResponse_stub); + stub.set(ADDR(OverwriteQuery, responseCancelled), responseCancelled_true_stub); + stub.set(ADDR(OverwriteQuery, responseSkip), responseSkip_false_stub); + stub.set(ADDR(OverwriteQuery, responseSkipAll), responseSkip_false_stub); + stub.set(ADDR(OverwriteQuery, responseOverwriteAll), responseOverwriteAll_true_stub); + + ExtractionOptions options; + options.bAllExtract = true; + options.strTargetPath = extractDir; + + PluginFinishType result = m_iface->extractFiles(QList(), options); + EXPECT_NE(result, PFT_Nomral); + EXPECT_GT(tracker.queryCount, 0); + EXPECT_GT(tracker.cancelCount, 0); + + QDir(extractDir).removeRecursively(); +} + +// =========================================================================== +// Contract: password operations +// =========================================================================== +TEST_P(ReadOnlyContractTest, PasswordRoundTrip) +{ + m_iface->setPassword("test123"); + EXPECT_EQ(m_iface->getPassword().toStdString(), "test123"); +} + +TEST_P(ReadOnlyContractTest, PasswordRoundTripEmpty) +{ + m_iface->setPassword(""); + EXPECT_EQ(m_iface->getPassword().toStdString(), ""); +} + +TEST_P(ReadOnlyContractTest, PasswordRoundTripUnicode) +{ + m_iface->setPassword(QString::fromUtf8("密码123")); + EXPECT_EQ(m_iface->getPassword(), QString::fromUtf8("密码123")); +} + +// =========================================================================== +// Contract: errorType defaults to ET_NoError +// =========================================================================== +TEST_P(ReadOnlyContractTest, DefaultErrorTypeIsNoError) +{ + EXPECT_EQ(m_iface->errorType(), ET_NoError); +} + +// =========================================================================== +// Contract: status / pause / continue operations +// =========================================================================== +TEST_P(ReadOnlyContractTest, DefaultStatusIsNotPaused) +{ + EXPECT_FALSE(m_iface->status()); +} + +TEST_P(ReadOnlyContractTest, PauseOperationSetsPauseFlag) +{ + if (GetParam().name == "LibPigzPlugin") GTEST_SKIP(); + m_iface->pauseOperation(); + EXPECT_TRUE(m_iface->status()); +} + +TEST_P(ReadOnlyContractTest, ContinueOperationClearsPauseFlag) +{ + if (GetParam().name == "LibPigzPlugin") GTEST_SKIP(); + m_iface->pauseOperation(); + EXPECT_TRUE(m_iface->status()); + m_iface->continueOperation(); + EXPECT_FALSE(m_iface->status()); +} + +// =========================================================================== +// Contract: isUserCancel defaults to false +// =========================================================================== +TEST_P(ReadOnlyContractTest, DefaultIsUserCancelIsFalse) +{ + EXPECT_FALSE(m_iface->isUserCancel()); +} + +// =========================================================================== +// Contract: waitForFinished is deterministic when idle +// =========================================================================== +TEST_P(ReadOnlyContractTest, WaitForFinishedIsStableWhenIdle) +{ + bool first = m_iface->waitForFinished(); + EXPECT_EQ(m_iface->waitForFinished(), first); +} diff --git a/tests/UnitTest/3rdparty/contract/ut_contract_readwrite.cpp b/tests/UnitTest/3rdparty/contract/ut_contract_readwrite.cpp new file mode 100644 index 000000000..9a6f58b4d --- /dev/null +++ b/tests/UnitTest/3rdparty/contract/ut_contract_readwrite.cpp @@ -0,0 +1,550 @@ +// Copyright (C) 2019 ~ 2020 Uniontech Software Technology Co.,Ltd. +// SPDX-FileCopyrightText: 2022 - 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "contract_test.h" + +#include "libzipplugin.h" +#include "readwritelibarchiveplugin.h" +#include "cli7zplugin.h" + +#include "queries.h" +#include "gtest/src/stub.h" + +#include +#include +#include +#include +#include + +Q_DECLARE_METATYPE(KPluginMetaData) + +// --------------------------------------------------------------------------- +// Stub helpers for OverwriteQuery +// --------------------------------------------------------------------------- +static void rw_waitForResponse_stub() {} +static bool rw_responseCancelled_false_stub() { return false; } +static bool rw_responseSkip_false_stub() { return false; } +static bool rw_responseSkipAll_false_stub() { return false; } +static bool rw_responseOverwriteAll_true_stub() { return true; } + +// --------------------------------------------------------------------------- +// Global test fixture +// --------------------------------------------------------------------------- +static ContractTestFixture &rw_globalFixture() +{ + static ContractTestFixture fixture; + return fixture; +} + +// --------------------------------------------------------------------------- +// Factory helpers +// --------------------------------------------------------------------------- +static ReadWriteArchiveInterface *makeRwLibzipPlugin(const QString &path) +{ + return new LibzipPlugin(nullptr, makePluginArgs(path)); +} + +static ReadWriteArchiveInterface *makeRwReadWriteLibarchivePlugin(const QString &path) +{ + return new ReadWriteLibarchivePlugin(nullptr, makePluginArgs(path)); +} + +static ReadWriteArchiveInterface *makeRwCli7zPlugin(const QString &path) +{ + return new Cli7zPlugin(nullptr, makePluginArgs(path)); +} + +static QString archiveExtension(const ReadWriteBackendCase ¶m) +{ + if (param.name == "ReadWriteLibarchivePlugin") + return ".tar"; + if (param.name == "Cli7zPlugin") + return ".7z"; + return ".zip"; +} + +// --------------------------------------------------------------------------- +// Test case instantiation — each backend declares its read-write capabilities. +// +// LibzipPlugin: supports addFiles, deleteFiles, renameFiles, addComment. +// ReadWriteLibarchivePlugin: addFiles/deleteFiles/renameFiles are implemented; +// addComment is commented out in header (returns PFT_Error via base class). +// --------------------------------------------------------------------------- +INSTANTIATE_TEST_SUITE_P( + AllReadWriteBackends, + ReadWriteContractTest, + ::testing::Values( + ReadWriteBackendCase{ + "LibzipPlugin", makeRwLibzipPlugin, + "zip", "readwrite/zip/test.zip", + false, // isAsync + true, // supportsAddFiles + true, // supportsDeleteFiles + true, // supportsRenameFiles + true, // supportsAddComment + true, // supportsUpdateArchiveData + true // unsupportedAddCommentReturnsError + }, + ReadWriteBackendCase{ + "ReadWriteLibarchivePlugin", makeRwReadWriteLibarchivePlugin, + "tar", "readwrite/tar/test.tar", + false, // isAsync + true, // supportsAddFiles — implemented in source + true, // supportsDeleteFiles — implemented in source + true, // supportsRenameFiles — implemented in source + false, // supportsAddComment — commented out in header + true, // supportsUpdateArchiveData + true // unsupportedAddCommentReturnsError + }, + ReadWriteBackendCase{ + "Cli7zPlugin", makeRwCli7zPlugin, + "7z", "readwrite/cli7z/test.7z", + true, // isAsync + true, // supportsAddFiles + true, // supportsDeleteFiles + true, // supportsRenameFiles + false, // supportsAddComment — CliInterface currently returns success without writing + true, // supportsUpdateArchiveData + false // unsupportedAddCommentReturnsError + } + ), + [](const ::testing::TestParamInfo &info) { + return info.param.name; + } +); + +// =========================================================================== +// Contract: getArchiveName returns exact path passed to constructor +// =========================================================================== +TEST_P(ReadWriteContractTest, ArchiveNameMatchesConstructor) +{ + EXPECT_EQ(m_iface->getArchiveName(), m_archivePath); +} + +// =========================================================================== +// Contract: addFiles — create a real archive, re-list, verify entries + content +// =========================================================================== +TEST_P(ReadWriteContractTest, AddFilesCreatesArchive) +{ + if (!GetParam().supportsAddFiles) GTEST_SKIP(); + + const auto ¶m = GetParam(); + + // Determine the correct extension for the backend + QString ext = archiveExtension(param); + QString archivePath = rw_globalFixture().tempPath() + "/rw_add_" + + QString::fromStdString(param.name) + ext; + + // Source file to add + QString srcDir = rw_globalFixture().tempPath() + "/rw_add_src_" + + QString::fromStdString(param.name); + QDir().mkpath(srcDir); + QString srcFile = srcDir + "/test_add.txt"; + { + QFile f(srcFile); + if (f.open(QIODevice::WriteOnly | QIODevice::Text)) { + QTextStream ts(&f); + ts << "add test content for verification"; + f.close(); + } + } + ASSERT_TRUE(QFileInfo::exists(srcFile)); + + // Create a new plugin instance pointing at the new archive path + std::unique_ptr iface(param.factory(archivePath)); + ASSERT_NE(iface, nullptr); + + QList files; + FileEntry entry; + entry.strFullPath = srcFile; + entry.strFileName = "test_add.txt"; + files << entry; + + CompressOptions options; + PluginFinishType result = runOpOn(iface.get(), [&iface, &files, &options]() { + return iface->addFiles(files, options); + }); + EXPECT_EQ(result, PFT_Nomral); + + // Verify the archive file was created + EXPECT_TRUE(QFileInfo::exists(archivePath)); + + // Re-list the created archive and verify entries + DataManager::get_instance().resetArchiveData(); + PluginFinishType listResult = runOpOn(iface.get(), [&iface]() { + return iface->list(); + }); + EXPECT_EQ(listResult, PFT_Nomral); + + ArchiveData &data = DataManager::get_instance().archiveData(); + EXPECT_GT(data.mapFileEntry.size(), 0); + + // Verify the added file name appears in the entry list + bool foundAddedFile = false; + for (const FileEntry &e : data.listRootEntry) { + if (e.strFileName == "test_add.txt" || e.strFileName.contains("test_add.txt")) { + foundAddedFile = true; + break; + } + } + EXPECT_TRUE(foundAddedFile); + + // Extract the added file and verify content + QString extractDir = rw_globalFixture().tempPath() + "/rw_add_extract_" + + QString::fromStdString(param.name); + QDir().mkpath(extractDir); + + Stub stub; + stub.set(ADDR(OverwriteQuery, waitForResponse), rw_waitForResponse_stub); + stub.set(ADDR(OverwriteQuery, responseCancelled), rw_responseCancelled_false_stub); + stub.set(ADDR(OverwriteQuery, responseSkip), rw_responseSkip_false_stub); + stub.set(ADDR(OverwriteQuery, responseSkipAll), rw_responseSkipAll_false_stub); + stub.set(ADDR(OverwriteQuery, responseOverwriteAll), rw_responseOverwriteAll_true_stub); + + ExtractionOptions extractOptions; + extractOptions.bAllExtract = true; + extractOptions.strTargetPath = extractDir; + + PluginFinishType extractResult = runOpOn(iface.get(), [&iface, &extractOptions]() { + return iface->extractFiles(QList(), extractOptions); + }); + EXPECT_EQ(extractResult, PFT_Nomral); + + // Verify extracted file content + bool contentVerified = false; + QDirIterator it(extractDir, QDir::Files, QDirIterator::Subdirectories); + while (it.hasNext()) { + QString filePath = it.next(); + if (it.fileName().contains("test_add")) { + QFile f(filePath); + if (f.open(QIODevice::ReadOnly | QIODevice::Text)) { + QString content = QTextStream(&f).readAll(); + if (content.contains("add test content for verification")) { + contentVerified = true; + } + f.close(); + } + } + } + EXPECT_TRUE(contentVerified); + + QDir(extractDir).removeRecursively(); +} + +// =========================================================================== +// Contract: deleteFiles — verify entry count decreases after deletion +// =========================================================================== +TEST_P(ReadWriteContractTest, DeleteFilesReducesEntryCount) +{ + if (!GetParam().supportsDeleteFiles) GTEST_SKIP(); + + const auto ¶m = GetParam(); + QString ext = archiveExtension(param); + QString workArchive = rw_globalFixture().tempPath() + "/rw_delete_" + + QString::fromStdString(param.name) + ext; + + // Copy the source archive + QFile::copy(m_archivePath, workArchive); + ASSERT_TRUE(QFileInfo::exists(workArchive)); + + std::unique_ptr iface(param.factory(workArchive)); + ASSERT_NE(iface, nullptr); + + // List first to populate archive data and get initial count + DataManager::get_instance().resetArchiveData(); + runOpOn(iface.get(), [&iface]() { return iface->list(); }); + ArchiveData &data = DataManager::get_instance().archiveData(); + int initialCount = data.mapFileEntry.size(); + ASSERT_GT(initialCount, 0); + + // Pick first file entry to delete + FileEntry entryToDelete; + bool found = false; + for (const FileEntry &e : data.listRootEntry) { + if (!e.isDirectory) { + entryToDelete = e; + found = true; + break; + } + } + ASSERT_TRUE(found); + + QList toDelete; + toDelete << entryToDelete; + + PluginFinishType result = runOpOn(iface.get(), [&iface, &toDelete]() { + return iface->deleteFiles(toDelete); + }); + EXPECT_EQ(result, PFT_Nomral); + + // Re-list and verify entry count decreased + DataManager::get_instance().resetArchiveData(); + runOpOn(iface.get(), [&iface]() { return iface->list(); }); + ArchiveData &dataAfter = DataManager::get_instance().archiveData(); + EXPECT_LT(dataAfter.mapFileEntry.size(), initialCount); +} + +// =========================================================================== +// Contract: renameFiles — verify new name exists, old name doesn't +// =========================================================================== +TEST_P(ReadWriteContractTest, RenameFilesChangesName) +{ + if (!GetParam().supportsRenameFiles) GTEST_SKIP(); + + const auto ¶m = GetParam(); + QString ext = archiveExtension(param); + QString workArchive = rw_globalFixture().tempPath() + "/rw_rename_" + + QString::fromStdString(param.name) + ext; + + QFile::copy(m_archivePath, workArchive); + ASSERT_TRUE(QFileInfo::exists(workArchive)); + + std::unique_ptr iface(param.factory(workArchive)); + ASSERT_NE(iface, nullptr); + + // List to populate archive data + DataManager::get_instance().resetArchiveData(); + runOpOn(iface.get(), [&iface]() { return iface->list(); }); + ArchiveData &data = DataManager::get_instance().archiveData(); + ASSERT_FALSE(data.listRootEntry.isEmpty()); + + // Pick first file entry and rename it + FileEntry entry; + bool found = false; + for (const FileEntry &e : data.listRootEntry) { + if (!e.isDirectory) { + entry = e; + found = true; + break; + } + } + ASSERT_TRUE(found); + + QString oldName = entry.strFileName; + entry.strAlias = "renamed_file.txt"; + + QList toRename; + toRename << entry; + + PluginFinishType result = runOpOn(iface.get(), [&iface, &toRename]() { + return iface->renameFiles(toRename); + }); + EXPECT_EQ(result, PFT_Nomral); + + // Re-list and verify: renamed entry should exist, old name should not + DataManager::get_instance().resetArchiveData(); + runOpOn(iface.get(), [&iface]() { return iface->list(); }); + ArchiveData &dataAfter = DataManager::get_instance().archiveData(); + + bool hasNewName = false; + bool hasOldName = false; + for (const FileEntry &e : dataAfter.mapFileEntry) { + if (e.strFileName == "renamed_file.txt") hasNewName = true; + if (e.strFileName == oldName) hasOldName = true; + } + EXPECT_TRUE(hasNewName); + EXPECT_FALSE(hasOldName); +} + +// =========================================================================== +// Contract: addComment — verify comment for supporting backends, +// verify error for non-supporting backends +// =========================================================================== +TEST_P(ReadWriteContractTest, AddCommentOnSupportingBackend) +{ + if (!GetParam().supportsAddComment) GTEST_SKIP(); + + const auto ¶m = GetParam(); + QString workArchive = rw_globalFixture().tempPath() + "/rw_comment_" + + QString::fromStdString(param.name) + ".zip"; + + QFile::copy(m_archivePath, workArchive); + ASSERT_TRUE(QFileInfo::exists(workArchive)); + + std::unique_ptr iface(param.factory(workArchive)); + ASSERT_NE(iface, nullptr); + + PluginFinishType result = runOpOn(iface.get(), [&iface]() { + return iface->addComment("contract test comment"); + }); + EXPECT_EQ(result, PFT_Nomral); + + // Verify the comment was written by re-listing + DataManager::get_instance().resetArchiveData(); + runOpOn(iface.get(), [&iface]() { return iface->list(); }); + ArchiveData &data = DataManager::get_instance().archiveData(); + EXPECT_EQ(data.strComment.toStdString(), "contract test comment"); +} + +TEST_P(ReadWriteContractTest, AddCommentOnNonSupportingBackendReturnsError) +{ + if (GetParam().supportsAddComment) GTEST_SKIP(); + + PluginFinishType result = m_iface->addComment("should fail"); + if (GetParam().unsupportedAddCommentReturnsError) { + EXPECT_EQ(result, PFT_Error); + } else { + // CliInterface currently reports success without writing a comment. + // Keep the current behavior explicit; a future backend must not drift. + EXPECT_EQ(result, PFT_Nomral); + } +} + +// =========================================================================== +// Contract: updateArchiveData — verify ArchiveData changes after update +// =========================================================================== +TEST_P(ReadWriteContractTest, UpdateArchiveDataModifiesData) +{ + if (!GetParam().supportsUpdateArchiveData) GTEST_SKIP(); + + const auto ¶m = GetParam(); + QString ext = archiveExtension(param); + QString workArchive = rw_globalFixture().tempPath() + "/rw_update_" + + QString::fromStdString(param.name) + ext; + + QFile::copy(m_archivePath, workArchive); + ASSERT_TRUE(QFileInfo::exists(workArchive)); + + std::unique_ptr iface(param.factory(workArchive)); + ASSERT_NE(iface, nullptr); + + // List first to populate archive data + DataManager::get_instance().resetArchiveData(); + runOpOn(iface.get(), [&iface]() { return iface->list(); }); + ArchiveData &data = DataManager::get_instance().archiveData(); + int initialCount = data.mapFileEntry.size(); + ASSERT_GT(initialCount, 0); + + // Pick first entry to "delete" via updateArchiveData + FileEntry entry; + if (!data.listRootEntry.isEmpty()) { + entry = data.listRootEntry.first(); + } + + // For backends that re-read from disk in updateArchiveData (e.g. LibzipPlugin), + // deleteFiles must modify the archive on disk first so the re-read reflects + // the change. For backends that modify in-memory data directly (e.g. + // ReadWriteLibarchivePlugin), deleteFiles is not needed. + if (GetParam().supportsDeleteFiles) { + QList toDelete; + toDelete << entry; + runOpOn(iface.get(), [&iface, &toDelete]() { + return iface->deleteFiles(toDelete); + }); + } + + UpdateOptions options; + options.eType = UpdateOptions::Delete; + options.listEntry.push_back(entry); + + // updateArchiveData() updates the in-memory archive model and does not + // start a CLI process, even for asynchronous CLI backends. + PluginFinishType result = iface->updateArchiveData(options); + EXPECT_EQ(result, PFT_Nomral); + + // Verify entry count decreased + ArchiveData &dataAfter = DataManager::get_instance().archiveData(); + EXPECT_LT(dataAfter.mapFileEntry.size(), initialCount); +} + +// =========================================================================== +// Contract: signal tests — verify finished signal on operations +// =========================================================================== +TEST_P(ReadWriteContractTest, SignalFinishedOnList) +{ + SignalTracker tracker; + tracker.connect(m_iface.get()); + + DataManager::get_instance().resetArchiveData(); + PluginFinishType result = runOp([this]() { return m_iface->list(); }); + + // Both LibzipPlugin and ReadWriteLibarchivePlugin are synchronous + // backends (isAsync is not set for read-write cases). Synchronous + // backends do not emit signalFinished after list() — they return the + // result directly. We verify the operation succeeded instead. + EXPECT_EQ(result, PFT_Nomral); +} + +TEST_P(ReadWriteContractTest, SignalFinishedOnAddFiles) +{ + if (!GetParam().supportsAddFiles) GTEST_SKIP(); + + const auto ¶m = GetParam(); + QString ext = archiveExtension(param); + QString archivePath = rw_globalFixture().tempPath() + "/rw_sig_add_" + + QString::fromStdString(param.name) + ext; + + QString srcDir = rw_globalFixture().tempPath() + "/rw_sig_src_" + + QString::fromStdString(param.name); + QDir().mkpath(srcDir); + QString srcFile = srcDir + "/sig_test.txt"; + { + QFile f(srcFile); + if (f.open(QIODevice::WriteOnly | QIODevice::Text)) { + QTextStream(&f) << "signal test content"; + f.close(); + } + } + + std::unique_ptr iface(param.factory(archivePath)); + ASSERT_NE(iface, nullptr); + + SignalTracker tracker; + tracker.connect(iface.get()); + + QList files; + FileEntry entry; + entry.strFullPath = srcFile; + entry.strFileName = "sig_test.txt"; + files << entry; + + CompressOptions options; + PluginFinishType result = runOpOn(iface.get(), [&iface, &files, &options]() { + return iface->addFiles(files, options); + }); + EXPECT_EQ(result, PFT_Nomral); + + // Async CLI backends and ReadWriteLibarchivePlugin emit signalFinished. + if (GetParam().isAsync || GetParam().name == "ReadWriteLibarchivePlugin") { + EXPECT_GT(tracker.finishedCount, 0); + EXPECT_EQ(tracker.lastFinishType, PFT_Nomral); + } +} + +// =========================================================================== +// Contract: inherited read-only defaults +// =========================================================================== +TEST_P(ReadWriteContractTest, DefaultErrorTypeIsNoError) +{ + EXPECT_EQ(m_iface->errorType(), ET_NoError); +} + +TEST_P(ReadWriteContractTest, DefaultStatusIsNotPaused) +{ + EXPECT_FALSE(m_iface->status()); +} + +TEST_P(ReadWriteContractTest, PauseContinueChangesStatus) +{ + m_iface->pauseOperation(); + EXPECT_TRUE(m_iface->status()); + m_iface->continueOperation(); + EXPECT_FALSE(m_iface->status()); +} + +TEST_P(ReadWriteContractTest, DefaultIsUserCancelIsFalse) +{ + EXPECT_FALSE(m_iface->isUserCancel()); +} + +TEST_P(ReadWriteContractTest, PasswordRoundTrip) +{ + m_iface->setPassword("rwtest123"); + EXPECT_EQ(m_iface->getPassword().toStdString(), "rwtest123"); +} + +TEST_P(ReadWriteContractTest, DoKillReturnsFalseWhenIdle) +{ + EXPECT_FALSE(m_iface->doKill()); +} From 6b98d2dcee0b6050115340c320de7a4fcb9bb885 Mon Sep 17 00:00:00 2001 From: dengzhongyuan Date: Fri, 13 Nov 2026 15:58:33 +0800 Subject: [PATCH 2/2] test: harden archive contract fixtures 1. Capture asynchronous corrupt-archive failures through the contract helper. 2. Validate fixture file operations and fail fast when setup commands fail. 3. Cover ReadWriteLibarchive read paths and partial extraction content. Influence: 1. Build and run contract_test. 2. Verify corrupt and password archive contracts. 3. Verify partial extraction and read-write backend regressions. Log: No user-facing changes. PMS: TASK-398535 --- .../3rdparty/contract/contract_test.h | 96 ++++++++++++++----- .../contract/ut_contract_readonly.cpp | 69 +++++++++++-- .../contract/ut_contract_readwrite.cpp | 2 +- 3 files changed, 134 insertions(+), 33 deletions(-) diff --git a/tests/UnitTest/3rdparty/contract/contract_test.h b/tests/UnitTest/3rdparty/contract/contract_test.h index 55fae95db..8ebacd935 100644 --- a/tests/UnitTest/3rdparty/contract/contract_test.h +++ b/tests/UnitTest/3rdparty/contract/contract_test.h @@ -227,10 +227,19 @@ class ContractTestFixture QString makeFile(const QString &relativePath, const QString &content = "hello world") { QString fullPath = m_tempDir->filePath(relativePath); - QDir().mkpath(QFileInfo(fullPath).absolutePath()); + if (!QDir().mkpath(QFileInfo(fullPath).absolutePath())) { + ADD_FAILURE() << "Failed to create directory for: " << fullPath.toStdString(); + return {}; + } QFile f(fullPath); - f.open(QIODevice::WriteOnly); - f.write(content.toUtf8()); + if (!f.open(QIODevice::WriteOnly)) { + ADD_FAILURE() << "Failed to open file for writing: " << fullPath.toStdString(); + return {}; + } + if (f.write(content.toUtf8()) != content.toUtf8().size()) { + ADD_FAILURE() << "Failed to write fixture file: " << fullPath.toStdString(); + return {}; + } f.close(); return fullPath; } @@ -239,10 +248,20 @@ class ContractTestFixture QString makeNestedDir(const QString &relativeBase) { QString base = m_tempDir->filePath(relativeBase); - QDir().mkpath(base + "/sub"); + if (!QDir().mkpath(base + "/sub")) { + ADD_FAILURE() << "Failed to create nested fixture directory: " + << base.toStdString(); + return {}; + } QFile f(base + "/sub/deep.txt"); - f.open(QIODevice::WriteOnly); - f.write("nested content"); + if (!f.open(QIODevice::WriteOnly)) { + ADD_FAILURE() << "Failed to open nested fixture file"; + return {}; + } + if (f.write("nested content") != 14) { + ADD_FAILURE() << "Failed to write nested fixture file"; + return {}; + } f.close(); return base; } @@ -282,14 +301,17 @@ class ContractTestFixture QDir().mkpath(QFileInfo(fullPath).absolutePath()); // Create source files - makeFile("zip_src/file1.txt", "file1 content"); - makeNestedDir("zip_src/dir"); + if (makeFile("zip_src/file1.txt", "file1 content").isEmpty()) + return {}; + if (makeNestedDir("zip_src/dir").isEmpty()) + return {}; // makeNestedDir creates zip_src/dir/sub/deep.txt, we need dir/file2.txt - makeFile("zip_src/dir/file2.txt", "file2 content"); + if (makeFile("zip_src/dir/file2.txt", "file2 content").isEmpty()) + return {}; if (!runChecked("zip", QStringList() << "-r" << fullPath << "file1.txt" << "dir", m_tempDir->filePath("zip_src"))) { - return fullPath; // failure already recorded + return {}; } return fullPath; } @@ -300,12 +322,14 @@ class ContractTestFixture QString fullPath = m_tempDir->filePath(relativePath); QDir().mkpath(QFileInfo(fullPath).absolutePath()); - makeFile("tar_src/test.txt", "test content"); - makeFile("tar_src/test1.txt", "test1 content"); + if (makeFile("tar_src/test.txt", "test content").isEmpty() + || makeFile("tar_src/test1.txt", "test1 content").isEmpty()) { + return {}; + } if (!runChecked("tar", QStringList() << "cf" << fullPath << "test.txt" << "test1.txt", m_tempDir->filePath("tar_src"))) { - return fullPath; + return {}; } return fullPath; } @@ -323,6 +347,7 @@ class ContractTestFixture if (!QFile::copy(source, dest)) { ADD_FAILURE() << "Failed to copy fixture: " << source.toStdString() << " -> " << dest.toStdString(); + return {}; } return dest; } @@ -361,13 +386,15 @@ class ContractTestFixture QString fullPath = m_tempDir->filePath(relativePath); QDir().mkpath(QFileInfo(fullPath).absolutePath()); - makeFile("7z_src/file1.txt", "7z file1 content"); - makeFile("7z_src/file2.txt", "7z file2 content"); + if (makeFile("7z_src/file1.txt", "7z file1 content").isEmpty() + || makeFile("7z_src/file2.txt", "7z file2 content").isEmpty()) { + return {}; + } if (!runChecked("7z", QStringList() << "a" << fullPath << "file1.txt" << "file2.txt", m_tempDir->filePath("7z_src"))) { - return fullPath; + return {}; } return fullPath; } @@ -378,12 +405,13 @@ class ContractTestFixture QString fullPath = m_tempDir->filePath(relativePath); QDir().mkpath(QFileInfo(fullPath).absolutePath()); - makeFile("pw_src/file1.txt", "password protected content"); + if (makeFile("pw_src/file1.txt", "password protected content").isEmpty()) + return {}; if (!runChecked("7z", QStringList() << "a" << ("-p" + password) << fullPath << "file1.txt", m_tempDir->filePath("pw_src"))) { - return fullPath; + return {}; } return fullPath; } @@ -397,16 +425,28 @@ class ContractTestFixture { QString fullPath = m_tempDir->filePath(relativePath); QDir().mkpath(QFileInfo(fullPath).absolutePath()); - QFile::copy(sourceArchive, fullPath); + if (!QFile::copy(sourceArchive, fullPath)) { + ADD_FAILURE() << "Failed to copy corrupt fixture source: " + << sourceArchive.toStdString(); + return {}; + } QFile file(fullPath); - if (file.open(QIODevice::ReadWrite)) { - qint64 garbageSize = qMin(file.size(), qint64(256)); - QByteArray garbage(garbageSize, char(0xFF)); - file.write(garbage); - file.close(); + if (!file.open(QIODevice::ReadWrite)) { + ADD_FAILURE() << "Failed to open corrupt fixture: " << fullPath.toStdString(); + return {}; + } + qint64 garbageSize = qMin(file.size(), qint64(256)); + QByteArray garbage(garbageSize, char(0xFF)); + if (file.write(garbage) != garbageSize) { + ADD_FAILURE() << "Failed to corrupt fixture: " << fullPath.toStdString(); + return {}; } + file.close(); qint64 halfSize = QFileInfo(fullPath).size() / 2; - QFile::resize(fullPath, halfSize); + if (!QFile::resize(fullPath, halfSize)) { + ADD_FAILURE() << "Failed to truncate corrupt fixture: " << fullPath.toStdString(); + return {}; + } return fullPath; } @@ -415,7 +455,11 @@ class ContractTestFixture { QString dest = m_tempDir->filePath(relativeDest); QDir().mkpath(QFileInfo(dest).absolutePath()); - QFile::copy(source, dest); + if (!QFile::copy(source, dest)) { + ADD_FAILURE() << "Failed to copy file: " << source.toStdString() + << " -> " << dest.toStdString(); + return {}; + } return dest; } diff --git a/tests/UnitTest/3rdparty/contract/ut_contract_readonly.cpp b/tests/UnitTest/3rdparty/contract/ut_contract_readonly.cpp index d538ab2eb..d0f7fd410 100644 --- a/tests/UnitTest/3rdparty/contract/ut_contract_readonly.cpp +++ b/tests/UnitTest/3rdparty/contract/ut_contract_readonly.cpp @@ -13,8 +13,6 @@ #include "xzplugin.h" #include "cli7zplugin.h" #include "clirarplugin.h" -#include "libpigzplugin.h" - #include "queries.h" #include "gtest/src/stub.h" @@ -73,6 +71,11 @@ static ReadOnlyArchiveInterface *makeReadOnlyLibarchivePlugin(const QString &pat return new ReadOnlyLibarchivePlugin(nullptr, makePluginArgs(path)); } +static ReadOnlyArchiveInterface *makeReadWriteLibarchiveReadPlugin(const QString &path) +{ + return new ReadWriteLibarchivePlugin(nullptr, makePluginArgs(path)); +} + static ReadOnlyArchiveInterface *makeGzPlugin(const QString &path) { return new LibGzipInterface(nullptr, makePluginArgs(path)); @@ -126,6 +129,11 @@ INSTANTIATE_TEST_SUITE_P( "tar", "readonly/tar/test.tar", false, true, true, true, true, false, false }, + ReadOnlyBackendCase{ + "ReadWriteLibarchivePlugin", makeReadWriteLibarchiveReadPlugin, + "tar", "readonly/rw-libarchive/test.tar", + false, true, true, true, false, false, false + }, ReadOnlyBackendCase{ "LibGzipInterface", makeGzPlugin, "gz", "readonly/gz/test.gz", @@ -255,10 +263,9 @@ TEST_P(ReadOnlyContractTest, ListCorruptArchiveReturnsError) // testArchive() is a no-op stub in current backends, so verify corruption // detection through list() which actually opens the archive. - PluginFinishType result = corruptIface->list(); - if (GetParam().isAsync) { - waitForFinishedSignal(corruptIface.get()); - } + PluginFinishType result = runInterfaceOp( + corruptIface.get(), GetParam().isAsync, + [&corruptIface]() { return corruptIface->list(); }); EXPECT_NE(result, PFT_Nomral); } @@ -467,6 +474,7 @@ TEST_P(ReadOnlyContractTest, ExtractPartialFiles) found = true; } ASSERT_TRUE(found); + ASSERT_FALSE(target.isDirectory); QString extractDir = globalFixture().tempPath() + "/extract_partial_" + QString::fromStdString(GetParam().name); @@ -490,6 +498,55 @@ TEST_P(ReadOnlyContractTest, ExtractPartialFiles) }); EXPECT_EQ(result, PFT_Nomral); + // The selected entry must actually be written with its archive content. + const QString targetName = target.strFileName.isEmpty() + ? QFileInfo(target.strFullPath).fileName() + : target.strFileName; + bool foundTarget = false; + QString actualContent; + QDirIterator it(extractDir, QDir::Files, QDirIterator::Subdirectories); + while (it.hasNext()) { + const QString filePath = it.next(); + if (it.fileName() != targetName) + continue; + + QFile extracted(filePath); + if (extracted.open(QIODevice::ReadOnly)) { + actualContent = QString::fromUtf8(extracted.readAll()); + foundTarget = true; + extracted.close(); + } + } + + EXPECT_TRUE(foundTarget); + if (GetParam().fixtureKind == "zip") { + if (targetName == "file1.txt") + EXPECT_EQ(actualContent, "file1 content"); + else if (targetName == "file2.txt" || targetName == "deep.txt") + EXPECT_FALSE(actualContent.isEmpty()); + else + EXPECT_FALSE(actualContent.isEmpty()); + } else if (GetParam().fixtureKind == "7z") { + if (targetName == "file1.txt") + EXPECT_EQ(actualContent, "7z file1 content"); + else if (targetName == "file2.txt") + EXPECT_EQ(actualContent, "7z file2 content"); + else + EXPECT_FALSE(actualContent.isEmpty()); + } else if (GetParam().fixtureKind == "tar") { + if (targetName == "test.txt") + EXPECT_EQ(actualContent, "test content"); + else if (targetName == "test1.txt") + EXPECT_EQ(actualContent, "test1 content"); + else + EXPECT_FALSE(actualContent.isEmpty()); + } else { + // Single-file fixtures are prebuilt binaries; their decoded file + // names/content are not stable across tools, but extraction must + // still produce a non-empty selected file. + EXPECT_FALSE(actualContent.isEmpty()); + } + QDir(extractDir).removeRecursively(); } diff --git a/tests/UnitTest/3rdparty/contract/ut_contract_readwrite.cpp b/tests/UnitTest/3rdparty/contract/ut_contract_readwrite.cpp index 9a6f58b4d..bf21a51b4 100644 --- a/tests/UnitTest/3rdparty/contract/ut_contract_readwrite.cpp +++ b/tests/UnitTest/3rdparty/contract/ut_contract_readwrite.cpp @@ -357,7 +357,7 @@ TEST_P(ReadWriteContractTest, AddCommentOnSupportingBackend) const auto ¶m = GetParam(); QString workArchive = rw_globalFixture().tempPath() + "/rw_comment_" + - QString::fromStdString(param.name) + ".zip"; + QString::fromStdString(param.name) + archiveExtension(param); QFile::copy(m_archivePath, workArchive); ASSERT_TRUE(QFileInfo::exists(workArchive));