// Catch2 migration of test_downloader_basic.cpp with expanded test coverage // Epic: yams-3s4 | Migration: downloader tests #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace fs = std::filesystem; using json = nlohmann::json; using namespace yams::downloader; namespace { // Helper to generate a unique temporary directory for tests fs::path make_temp_dir(const std::string& prefix = "yams-dl-test-") { auto base = fs::temp_directory_path(); std::random_device rd; std::mt19937_64 gen(rd()); std::uniform_int_distribution dist; fs::path dir; for (int i = 0; i < 5; ++i) { dir = base / (prefix + std::to_string(dist(gen))); if (!fs::exists(dir)) { fs::create_directories(dir); break; } } return dir; } // HTTP metadata parser helper struct ParsedHttpMeta { std::optional etag; std::optional lastModified; }; static inline std::string trim(std::string_view sv) { size_t b = 0, e = sv.size(); while (b < e && std::isspace(static_cast(sv[b]))) ++b; while (e > b && std::isspace(static_cast(sv[e - 1]))) --e; return std::string(sv.substr(b, e - b)); } ParsedHttpMeta parse_http_headers_for_meta(const std::vector& headers) { ParsedHttpMeta out; for (const auto& raw : headers) { auto pos = raw.find(':'); if (pos == std::string::npos) continue; std::string key = trim(std::string_view(raw.data(), pos)); std::string val = trim(std::string_view(raw.data() + pos + 1, raw.size() - pos - 1)); std::transform(key.begin(), key.end(), key.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); if (key == "etag" && !out.etag.has_value()) { if (val.size() >= 2 && ((val.front() == '"' && val.back() == '"') || (val.front() == '\'' && val.back() == '\''))) { out.etag = val.substr(1, val.size() - 2); } else { out.etag = val; } } else if (key == "last-modified" && !out.lastModified.has_value()) { out.lastModified = val; } } return out; } // Persistent JSON-based ResumeStore (test implementation) class PersistentResumeStore final : public yams::downloader::IResumeStore { public: explicit PersistentResumeStore(fs::path json_path) : path_(std::move(json_path)) { (void)loadFile(); } yams::downloader::Expected> load(std::string_view url) override { auto j = loadFile(); if (!j.ok()) return j.error(); auto& root = j.value(); const auto it = root.find(std::string(url)); if (it == root.end()) return std::optional{std::nullopt}; State st; const auto& entry = it.value(); if (entry.contains("etag") && entry["etag"].is_string()) { st.etag = entry["etag"].get(); } if (entry.contains("last_modified") && entry["last_modified"].is_string()) { st.lastModified = entry["last_modified"].get(); } if (entry.contains("total_bytes") && entry["total_bytes"].is_number_unsigned()) { st.totalBytes = entry["total_bytes"].get(); } if (entry.contains("completed_ranges") && entry["completed_ranges"].is_array()) { for (const auto& r : entry["completed_ranges"]) { if (r.is_array() && r.size() == 2 && r[0].is_number_unsigned() && r[1].is_number_unsigned()) { st.completedRanges.emplace_back(r[0].get(), r[1].get()); } } } return std::optional{st}; } yams::downloader::Expected save(std::string_view url, const State& state) override { auto j = loadFile(); if (!j.ok()) return j.error(); auto& root = j.value(); json entry; if (state.etag) entry["etag"] = *state.etag; if (state.lastModified) entry["last_modified"] = *state.lastModified; entry["total_bytes"] = state.totalBytes; entry["completed_ranges"] = json::array(); for (const auto& [off, len] : state.completedRanges) { entry["completed_ranges"].push_back(json::array({off, len})); } root[std::string(url)] = entry; return writeFile(root); } void remove(std::string_view url) noexcept override { auto j = loadFile(); if (!j.ok()) return; auto& root = j.value(); root.erase(std::string(url)); (void)writeFile(root); } private: yams::downloader::Expected loadFile() const { if (!fs::exists(path_)) { return json::object(); } std::ifstream in(path_); if (!in) { return yams::downloader::Error{yams::downloader::ErrorCode::IoError, "Failed to open resume JSON for read"}; } try { json root; in >> root; if (!root.is_object()) root = json::object(); return root; } catch (const std::exception&) { return json::object(); } } yams::downloader::Expected writeFile(const json& root) const { std::ofstream out(path_, std::ios::trunc); if (!out) { return yams::downloader::Error{yams::downloader::ErrorCode::IoError, "Failed to open resume JSON for write"}; } out << root.dump(2); return {}; } fs::path path_; }; // Tracking resume store for test verification class TrackingResumeStore final : public IResumeStore { public: std::optional initialState; std::vector savedStates; std::vector savedUrls; std::vector loadUrls; std::optional removedUrl; Expected> load(std::string_view url) override { loadUrls.emplace_back(url); return initialState; } Expected save(std::string_view url, const State& state) override { savedUrls.emplace_back(url); savedStates.push_back(state); initialState = state; return {}; } void remove(std::string_view url) noexcept override { removedUrl = std::string(url); initialState.reset(); } }; // Test HTTP adapter class TestHttpAdapter final : public IHttpAdapter { public: bool resumeSupported{true}; std::optional contentLength{}; std::optional etag{}; std::optional lastModified{}; std::vector requestedOffsets; std::vector requestedSizes; std::vector payload; bool checkCancelBeforeSink{false}; bool cancelObserved{false}; bool lastProbeFollowRedirects{true}; int probeCallCount{0}; int fetchCallCount{0}; Expected probe(std::string_view, const std::vector
&, bool& resumeSupportedOut, std::optional& contentLengthOut, std::optional& etagOut, std::optional& lastModifiedOut, std::optional& contentTypeOut, std::optional& suggestedFilenameOut, const TlsConfig&, const std::optional&, bool followRedirects, std::chrono::milliseconds) override { probeCallCount++; lastProbeFollowRedirects = followRedirects; resumeSupportedOut = resumeSupported; contentLengthOut = contentLength; etagOut = etag; lastModifiedOut = lastModified; contentTypeOut.reset(); suggestedFilenameOut.reset(); return {}; } Expected fetchRange(std::string_view, const std::vector
&, std::uint64_t offset, std::uint64_t size, const TlsConfig&, const std::optional&, std::chrono::milliseconds, const std::function(std::span)>& sink, const ShouldCancel& shouldCancel, const ProgressCallback& onProgress) override { fetchCallCount++; requestedOffsets.push_back(offset); requestedSizes.push_back(size); if (onProgress) { ProgressEvent ev; ev.downloadedBytes = 0; ev.stage = ProgressStage::Downloading; onProgress(ev); } if (checkCancelBeforeSink && shouldCancel && shouldCancel()) { cancelObserved = true; return Error{ErrorCode::OperationCancelled, "Operation cancelled"}; } if (!payload.empty()) { auto res = sink(std::span(payload.data(), payload.size())); if (!res.ok()) return res; } return {}; } }; // Fake disk writer for testing class FakeDiskWriter final : public IDiskWriter { public: explicit FakeDiskWriter(StorageConfig storage) : storage_(std::move(storage)) {} void setInitialData(const std::vector& data) { initialData_ = data; } const std::vector& stagedData() const { return data_; } const fs::path& lastFinalPath() const { return lastFinalPath_; } const std::string& lastSessionId() const { return lastSessionId_; } Expected createStagingFile(const StorageConfig&, std::string_view sessionId, std::string_view tempExtension) override { lastSessionId_ = std::string(sessionId); currentPath_ = stagingPath(sessionId, tempExtension); data_.clear(); writeToFile(); return currentPath_; } Expected createOrOpenStagingFile(const StorageConfig&, std::string_view sessionId, std::string_view tempExtension, std::optional expectedSize, std::uint64_t& currentSize) override { lastSessionId_ = std::string(sessionId); currentPath_ = stagingPath(sessionId, tempExtension); data_ = initialData_; currentSize = data_.size(); writeToFile(); if (expectedSize && currentSize == 0) { std::error_code ec; std::filesystem::resize_file(currentPath_, static_cast(*expectedSize), ec); if (!ec) { data_.resize(static_cast(*expectedSize)); writeToFile(); } } return currentPath_; } Expected writeAt(const fs::path&, std::uint64_t offset, std::span data) override { const auto end = offset + static_cast(data.size()); if (end > data_.size()) { data_.resize(static_cast(end)); } std::memcpy(data_.data() + offset, data.data(), data.size()); writeToFile(); return {}; } Expected sync(const fs::path&, const StorageConfig&) override { return {}; } Expected finalizeToCas(const fs::path&, std::string_view hashHex, const StorageConfig&) override { lastFinalPath_ = casPathForSha256(storage_.objectsDir, hashHex); std::error_code ec; std::filesystem::create_directories(lastFinalPath_.parent_path(), ec); std::ofstream out(lastFinalPath_, std::ios::binary | std::ios::trunc); if (!out) { return Error{ErrorCode::IoError, "Failed to write CAS object: " + lastFinalPath_.string()}; } if (!data_.empty()) { out.write(reinterpret_cast(data_.data()), static_cast(data_.size())); } return lastFinalPath_; } void cleanup(const fs::path& stagingFile) noexcept override { std::error_code ec; std::filesystem::remove(stagingFile, ec); } private: fs::path stagingPath(std::string_view sessionId, std::string_view ext) const { fs::path dir = storage_.stagingDir / "downloader"; std::filesystem::create_directories(dir); std::string fn(sessionId); if (!ext.empty()) { if (ext.front() == '.') fn.append(ext); else { fn.push_back('.'); fn.append(ext); } } else { fn.append(".part"); } return dir / fn; } void writeToFile() { if (currentPath_.empty()) return; std::error_code ec; std::filesystem::create_directories(currentPath_.parent_path(), ec); std::ofstream out(currentPath_, std::ios::binary | std::ios::trunc); if (!out) return; if (!data_.empty()) { out.write(reinterpret_cast(data_.data()), static_cast(data_.size())); } } StorageConfig storage_; std::vector initialData_{}; std::vector data_{}; fs::path currentPath_{}; fs::path lastFinalPath_{}; std::string lastSessionId_{}; }; std::vector to_bytes(std::string_view s) { std::vector out(s.size()); std::memcpy(out.data(), s.data(), s.size()); return out; } constexpr const char kExportMetaSuffix[] = ".yams.meta.json"; fs::path meta_path_for(const fs::path& exportPath) { return fs::path(exportPath.string() + kExportMetaSuffix); } } // namespace // ============================================================================= // HTTP Header Parsing Tests // ============================================================================= TEST_CASE("HTTP Header Parsing: Basic extraction", "[downloader][http][headers]") { SECTION("Extracts ETag and Last-Modified") { std::vector headers = {"Date: Tue, 19 Aug 2025 10:11:12 GMT", "Server: test-http", "ETag: \"abc123-xyz\"", "Last-Modified: Tue, 19 Aug 2025 09:00:00 GMT", "Accept-Ranges: bytes", "Content-Length: 1048576"}; auto parsed = parse_http_headers_for_meta(headers); REQUIRE(parsed.etag.has_value()); REQUIRE(parsed.lastModified.has_value()); CHECK(*parsed.etag == "abc123-xyz"); CHECK(*parsed.lastModified == "Tue, 19 Aug 2025 09:00:00 GMT"); } SECTION("Handles missing values") { std::vector headers = {"date: Tue, 19 Aug 2025 10:11:12 GMT", "server: test-http", "content-length: 512"}; auto parsed = parse_http_headers_for_meta(headers); CHECK_FALSE(parsed.etag.has_value()); CHECK_FALSE(parsed.lastModified.has_value()); } SECTION("Case insensitive header names") { std::vector headers = {"eTaG: 'W/\"weak-etag-value\"'", "LAST-MODIFIED: Wed, 20 Aug 2025 00:00:00 GMT "}; auto parsed = parse_http_headers_for_meta(headers); REQUIRE(parsed.etag.has_value()); REQUIRE(parsed.lastModified.has_value()); CHECK(*parsed.etag == "W/\"weak-etag-value\""); CHECK(*parsed.lastModified == "Wed, 20 Aug 2025 00:00:00 GMT"); } SECTION("First occurrence wins") { std::vector headers = {"ETag: first-etag", "ETag: second-etag"}; auto parsed = parse_http_headers_for_meta(headers); REQUIRE(parsed.etag.has_value()); CHECK(*parsed.etag == "first-etag"); } SECTION("Strips quotes from ETag") { std::vector headers = {"ETag: \"quoted-etag\""}; auto parsed = parse_http_headers_for_meta(headers); REQUIRE(parsed.etag.has_value()); CHECK(*parsed.etag == "quoted-etag"); } SECTION("Handles unquoted ETag") { std::vector headers = {"ETag: unquoted-etag"}; auto parsed = parse_http_headers_for_meta(headers); REQUIRE(parsed.etag.has_value()); CHECK(*parsed.etag == "unquoted-etag"); } } // ============================================================================= // Persistent Resume Store Tests // ============================================================================= TEST_CASE("PersistentResumeStore: Round-trip", "[downloader][resume]") { auto dir = make_temp_dir(); auto jsonPath = dir / "resume.json"; SECTION("Save and load single URL") { PersistentResumeStore store(jsonPath); yams::downloader::IResumeStore::State state; state.etag = std::string("abc123etag"); state.lastModified = std::string("Tue, 19 Aug 2025 09:00:00 GMT"); state.totalBytes = 1024 * 1024 * 5; state.completedRanges = {{0, 256 * 1024}, {256 * 1024, 512 * 1024}}; auto saveRes = store.save("https://example.com/file.bin", state); REQUIRE(saveRes.ok()); PersistentResumeStore store2(jsonPath); auto loadRes = store2.load("https://example.com/file.bin"); REQUIRE(loadRes.ok()); REQUIRE(loadRes.value().has_value()); const auto& got = *loadRes.value(); REQUIRE(got.etag.has_value()); REQUIRE(got.lastModified.has_value()); CHECK(*got.etag == *state.etag); CHECK(*got.lastModified == *state.lastModified); CHECK(got.totalBytes == state.totalBytes); REQUIRE(got.completedRanges.size() == state.completedRanges.size()); } SECTION("Remove entry") { PersistentResumeStore store(jsonPath); yams::downloader::IResumeStore::State state; state.etag = std::string("to-be-removed"); state.totalBytes = 42; REQUIRE(store.save("http://example.com/a", state).ok()); REQUIRE(store.save("http://example.com/b", state).ok()); store.remove("http://example.com/a"); PersistentResumeStore store2(jsonPath); auto la = store2.load("http://example.com/a"); auto lb = store2.load("http://example.com/b"); REQUIRE(la.ok()); REQUIRE(lb.ok()); CHECK_FALSE(la.value().has_value()); REQUIRE(lb.value().has_value()); CHECK(lb.value()->etag.has_value()); CHECK(*lb.value()->etag == "to-be-removed"); } SECTION("Multiple URLs") { PersistentResumeStore store(jsonPath); for (int i = 0; i < 5; ++i) { yams::downloader::IResumeStore::State state; state.etag = "etag-" + std::to_string(i); state.totalBytes = 1000L * (i + 1); REQUIRE(store.save("http://example.com/file" + std::to_string(i), state).ok()); } PersistentResumeStore store2(jsonPath); for (int i = 0; i < 5; ++i) { auto result = store2.load("http://example.com/file" + std::to_string(i)); REQUIRE(result.ok()); REQUIRE(result.value().has_value()); CHECK(*result.value()->etag == "etag-" + std::to_string(i)); } } SECTION("Load non-existent URL") { PersistentResumeStore store(jsonPath); auto result = store.load("http://nonexistent.com/file"); REQUIRE(result.ok()); CHECK_FALSE(result.value().has_value()); } // Cleanup std::error_code ec; fs::remove_all(dir, ec); } // ============================================================================= // CAS Helpers Tests // ============================================================================= TEST_CASE("CAS Helpers: Path and ID formatting", "[downloader][cas]") { fs::path objectsDir = "/tmp/yams-objects"; const std::string digest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; SECTION("CAS path includes shard directories") { auto path = yams::downloader::casPathForSha256(objectsDir, digest); CHECK(path.generic_string().find("sha256/01/23/0123456789abcdef") != std::string::npos); } SECTION("SHA256 ID format") { auto id = yams::downloader::makeSha256Id(digest); CHECK(id == "sha256:" + digest); } SECTION("Empty digest") { auto id = yams::downloader::makeSha256Id(""); CHECK(id == "sha256:"); } } // ============================================================================= // Download Manager Resume Tests // ============================================================================= TEST_CASE("DownloadManager: Resume functionality", "[downloader][resume]") { auto tempDir = make_temp_dir(); StorageConfig storage{tempDir / "objects", tempDir / "staging"}; DownloaderConfig cfg; cfg.defaultChunkSizeBytes = 4; SECTION("Persists and cleans ranges") { auto http = std::make_unique(); auto* httpPtr = http.get(); httpPtr->resumeSupported = true; httpPtr->contentLength = 10; httpPtr->etag = "etag-123"; httpPtr->payload = to_bytes("WORLD"); auto disk = std::make_unique(storage); auto* diskPtr = disk.get(); diskPtr->setInitialData(to_bytes("HELLO")); auto resume = std::make_unique(); auto* resumePtr = resume.get(); IResumeStore::State initial; initial.totalBytes = 10; initial.completedRanges.emplace_back(0, 5); initial.etag = "etag-123"; resumePtr->initialState = initial; auto dm = makeDownloadManagerWithDependencies( storage, cfg, std::move(http), std::move(disk), nullptr, std::move(resume), nullptr); DownloadRequest req; req.url = "https://example.com/file.bin"; req.resume = true; req.chunkSizeBytes = 5; req.checksum = Checksum{HashAlgo::Sha256, "0b21b7db59cd154904fac6336fa7d2be1bab38d632794f281549584068cdcb74"}; auto result = dm->download(req); REQUIRE(result.ok()); auto final = result.value(); REQUIRE(httpPtr->requestedOffsets.size() == 1); CHECK(httpPtr->requestedOffsets.front() == 5); REQUIRE_FALSE(resumePtr->savedStates.empty()); const auto& lastState = resumePtr->savedStates.back(); REQUIRE(lastState.completedRanges.size() == 1); CHECK(lastState.completedRanges.front().first == 0); CHECK(lastState.completedRanges.front().second == 10); REQUIRE(resumePtr->removedUrl.has_value()); CHECK(*resumePtr->removedUrl == req.url); REQUIRE_FALSE(diskPtr->lastFinalPath().empty()); std::ifstream finalFile(diskPtr->lastFinalPath(), std::ios::binary); REQUIRE(finalFile.good()); std::string contents((std::istreambuf_iterator(finalFile)), std::istreambuf_iterator()); CHECK(contents == "HELLOWORLD"); CHECK(final.hash == "sha256:0b21b7db59cd154904fac6336fa7d2be1bab38d632794f281549584068cdcb74"); CHECK(final.sizeBytes == 10); } SECTION("Uses a stable resume session id for the same URL") { auto makeResumingManager = [&](FakeDiskWriter*& diskPtr) { auto http = std::make_unique(); auto* httpPtr = http.get(); httpPtr->resumeSupported = true; httpPtr->contentLength = 10; httpPtr->etag = "etag-123"; httpPtr->payload = to_bytes("WORLD"); auto disk = std::make_unique(storage); diskPtr = disk.get(); diskPtr->setInitialData(to_bytes("HELLO")); auto resume = std::make_unique(); IResumeStore::State initial; initial.totalBytes = 10; initial.completedRanges.emplace_back(0, 5); initial.etag = "etag-123"; resume->initialState = initial; return makeDownloadManagerWithDependencies(storage, cfg, std::move(http), std::move(disk), nullptr, std::move(resume), nullptr); }; FakeDiskWriter* firstDisk = nullptr; auto firstManager = makeResumingManager(firstDisk); DownloadRequest req; req.url = "https://example.com/file.bin"; req.resume = true; auto firstResult = firstManager->download(req); REQUIRE(firstResult.ok()); REQUIRE_FALSE(firstDisk->lastSessionId().empty()); FakeDiskWriter* secondDisk = nullptr; auto secondManager = makeResumingManager(secondDisk); auto secondResult = secondManager->download(req); REQUIRE(secondResult.ok()); REQUIRE_FALSE(secondDisk->lastSessionId().empty()); CHECK(secondDisk->lastSessionId() == firstDisk->lastSessionId()); } // Cleanup std::error_code ec; fs::remove_all(tempDir, ec); } TEST_CASE("DownloadManager: probe forwards follow_redirects policy", "[downloader][redirect]") { auto tempDir = make_temp_dir(); StorageConfig storage{tempDir / "objects", tempDir / "staging"}; DownloaderConfig cfg; auto http = std::make_unique(); auto* httpPtr = http.get(); httpPtr->resumeSupported = false; httpPtr->contentLength = 5; httpPtr->payload = to_bytes("ABCDE"); auto disk = std::make_unique(storage); auto dm = makeDownloadManagerWithDependencies(storage, cfg, std::move(http), std::move(disk), nullptr, nullptr, nullptr); DownloadRequest req; req.url = "https://example.com/no-redirects.bin"; req.followRedirects = false; auto result = dm->download(req); REQUIRE(result.ok()); CHECK_FALSE(httpPtr->lastProbeFollowRedirects); std::error_code ec; fs::remove_all(tempDir, ec); } TEST_CASE("DownloadManager: verifies configured checksum algorithms", "[downloader][checksum][algorithms]") { const auto sha256 = std::string{"f0393febe8baaa55e32f7be2a7cc180bf34e52137d99e056c817a9c07b8f239a"}; const auto sha512 = std::string{"9989a8fcbc29044b5883a0a36c146fe7415b1439e995b4d806ea0af7da9ca4390eb92a604b3ecf" "a3d75f9911c768fbe2aecc59eff1e48dcaeca1957bdde01dfb"}; const auto md5 = std::string{"2ecdde3959051d913f61b14579ea136d"}; auto runCase = [&](HashAlgo algo, const std::string& expectedHex) { auto tempDir = make_temp_dir(); StorageConfig storage{tempDir / "objects", tempDir / "staging"}; DownloaderConfig cfg; auto http = std::make_unique(); http->resumeSupported = false; http->contentLength = 5; http->payload = to_bytes("ABCDE"); auto disk = std::make_unique(storage); auto dm = makeDownloadManagerWithDependencies(storage, cfg, std::move(http), std::move(disk), nullptr, nullptr, nullptr); DownloadRequest req; req.url = "https://example.com/checksum.bin"; req.checksum = Checksum{algo, expectedHex}; auto result = dm->download(req); REQUIRE(result.ok()); CHECK(result.value().success); std::error_code ec; fs::remove_all(tempDir, ec); }; SECTION("sha256") { runCase(HashAlgo::Sha256, sha256); } SECTION("sha512") { runCase(HashAlgo::Sha512, sha512); } SECTION("md5") { runCase(HashAlgo::Md5, md5); } } TEST_CASE("DownloadManager: Export with ETag matching", "[downloader][export]") { auto tempDir = make_temp_dir(); StorageConfig storage{tempDir / "objects", tempDir / "staging"}; DownloaderConfig cfg; SECTION("Skips copy when ETag matches") { auto http = std::make_unique(); auto* httpPtr = http.get(); httpPtr->resumeSupported = false; httpPtr->contentLength = 5; httpPtr->etag = "tag-777"; httpPtr->payload = to_bytes("ABCDE"); auto disk = std::make_unique(storage); auto* diskPtr = disk.get(); auto resume = std::make_unique(); auto* resumePtr = resume.get(); auto dm = makeDownloadManagerWithDependencies( storage, cfg, std::move(http), std::move(disk), nullptr, std::move(resume), nullptr); fs::path exportDir = tempDir / "exports"; fs::create_directories(exportDir); fs::path exportPath = exportDir / "file.bin"; { std::ofstream out(exportPath, std::ios::binary | std::ios::trunc); out << "OLD"; } auto metaPath = meta_path_for(exportPath); { json meta; meta["etag"] = "tag-777"; meta["hash"] = "placeholder"; std::ofstream out(metaPath, std::ios::binary | std::ios::trunc); out << meta.dump(2); } DownloadRequest req; req.url = "https://example.com/export.bin"; req.exportPath = exportPath; req.overwrite = OverwritePolicy::IfDifferentEtag; auto result = dm->download(req); REQUIRE(result.ok()); auto final = result.value(); std::string exported; { std::ifstream in(exportPath, std::ios::binary); exported.assign((std::istreambuf_iterator(in)), std::istreambuf_iterator()); } CHECK(exported == "OLD"); json metaUpdated; { std::ifstream in(metaPath); REQUIRE(in.good()); in >> metaUpdated; } REQUIRE(metaUpdated.contains("hash")); REQUIRE(metaUpdated.contains("etag")); CHECK(metaUpdated["etag"].get() == "tag-777"); REQUIRE_FALSE(diskPtr->lastFinalPath().empty()); std::ifstream casFile(diskPtr->lastFinalPath(), std::ios::binary); std::string casData((std::istreambuf_iterator(casFile)), std::istreambuf_iterator()); CHECK(casData == "ABCDE"); CHECK(resumePtr->savedStates.empty()); CHECK(httpPtr->requestedOffsets.size() == 1); } // Cleanup std::error_code ec; fs::remove_all(tempDir, ec); } TEST_CASE("DownloadManager: Cooperative cancellation stops fetch", "[downloader][cancel][catch2]") { auto tempDir = make_temp_dir(); StorageConfig storage{tempDir / "objects", tempDir / "staging"}; DownloaderConfig cfg; auto http = std::make_unique(); auto* httpPtr = http.get(); httpPtr->resumeSupported = false; httpPtr->contentLength = 5; httpPtr->payload = to_bytes("ABCDE"); httpPtr->checkCancelBeforeSink = true; auto disk = std::make_unique(storage); auto dm = makeDownloadManagerWithDependencies(storage, cfg, std::move(http), std::move(disk), nullptr, nullptr, nullptr); DownloadRequest req; req.url = "https://example.com/cancel.bin"; bool canceled = true; auto result = dm->download(req, {}, [&]() { return canceled; }); REQUIRE_FALSE(result.ok()); CHECK(result.error().code == ErrorCode::OperationCancelled); CHECK(httpPtr->cancelObserved); std::error_code ec; fs::remove_all(tempDir, ec); } // ============================================================================= // Test HTTP Adapter Tests // ============================================================================= TEST_CASE("TestHttpAdapter: Behavior verification", "[downloader][adapter]") { SECTION("Tracks probe calls") { TestHttpAdapter adapter; adapter.contentLength = 1000; bool resumeSupported; std::optional contentLength; std::optional etag, lastModified, contentType, filename; adapter.probe("http://test.com", {}, resumeSupported, contentLength, etag, lastModified, contentType, filename, TlsConfig{}, std::nullopt, true, std::chrono::milliseconds{1000}); CHECK(adapter.probeCallCount == 1); CHECK(resumeSupported == true); CHECK(contentLength.has_value()); CHECK(*contentLength == 1000); } SECTION("Tracks fetch calls") { TestHttpAdapter adapter; adapter.payload = to_bytes("test data"); std::vector received; auto sink = [&received](std::span data) -> Expected { received.insert(received.end(), data.begin(), data.end()); return {}; }; adapter.fetchRange("http://test.com", {}, 0, 9, TlsConfig{}, std::nullopt, std::chrono::milliseconds{1000}, sink, nullptr, nullptr); CHECK(adapter.fetchCallCount == 1); CHECK(adapter.requestedOffsets.size() == 1); CHECK(adapter.requestedOffsets[0] == 0); CHECK(adapter.requestedSizes[0] == 9); CHECK(received.size() == 9); } } // ============================================================================= // Tracking Resume Store Tests // ============================================================================= TEST_CASE("TrackingResumeStore: Tracking verification", "[downloader][resume][tracking]") { TrackingResumeStore store; SECTION("Tracks load calls") { store.load("http://url1.com"); store.load("http://url2.com"); store.load("http://url1.com"); CHECK(store.loadUrls.size() == 3); CHECK(store.loadUrls[0] == "http://url1.com"); CHECK(store.loadUrls[1] == "http://url2.com"); } SECTION("Tracks save calls") { IResumeStore::State state; state.totalBytes = 100; store.save("http://url1.com", state); state.totalBytes = 200; store.save("http://url2.com", state); CHECK(store.savedUrls.size() == 2); CHECK(store.savedStates.size() == 2); CHECK(store.savedStates[0].totalBytes == 100); CHECK(store.savedStates[1].totalBytes == 200); } SECTION("Tracks remove calls") { store.remove("http://removed.com"); REQUIRE(store.removedUrl.has_value()); CHECK(*store.removedUrl == "http://removed.com"); } SECTION("Initial state returned on load") { IResumeStore::State initial; initial.totalBytes = 500; initial.etag = "initial-etag"; store.initialState = initial; auto result = store.load("http://any.com"); REQUIRE(result.ok()); REQUIRE(result.value().has_value()); CHECK(result.value()->totalBytes == 500); CHECK(*result.value()->etag == "initial-etag"); } } // ============================================================================= // FakeDiskWriter Tests // ============================================================================= TEST_CASE("FakeDiskWriter: Operations", "[downloader][disk]") { auto tempDir = make_temp_dir(); StorageConfig storage{tempDir / "objects", tempDir / "staging"}; FakeDiskWriter writer(storage); SECTION("Creates staging file") { auto result = writer.createStagingFile(storage, "session-123", ".part"); REQUIRE(result.ok()); CHECK(fs::exists(result.value())); } SECTION("Writes at offset") { auto staging = writer.createStagingFile(storage, "session-456", ".part"); REQUIRE(staging.ok()); auto data = to_bytes("hello"); auto writeResult = writer.writeAt(staging.value(), 0, data); REQUIRE(writeResult.ok()); auto data2 = to_bytes("world"); writeResult = writer.writeAt(staging.value(), 5, data2); REQUIRE(writeResult.ok()); CHECK(writer.stagedData().size() == 10); } SECTION("Handles initial data") { writer.setInitialData(to_bytes("PREFIX")); std::uint64_t currentSize; auto result = writer.createOrOpenStagingFile(storage, "session-789", ".part", 100, currentSize); REQUIRE(result.ok()); CHECK(currentSize == 6); } // Cleanup std::error_code ec; fs::remove_all(tempDir, ec); } // ============================================================================= // Edge Cases and Error Handling // ============================================================================= TEST_CASE("Downloader: Edge cases", "[downloader][edge]") { SECTION("Empty URL handling") { TrackingResumeStore store; auto result = store.load(""); CHECK(result.ok()); } SECTION("Very long URL") { std::string longUrl = "http://example.com/" + std::string(1000, 'x'); TrackingResumeStore store; auto result = store.load(longUrl); CHECK(result.ok()); CHECK(store.loadUrls[0] == longUrl); } SECTION("Unicode in URL") { std::string unicodeUrl = "http://example.com/文件/файл.bin"; TrackingResumeStore store; IResumeStore::State state; state.totalBytes = 100; auto saveResult = store.save(unicodeUrl, state); CHECK(saveResult.ok()); CHECK(store.savedUrls[0] == unicodeUrl); } } TEST_CASE("HTTP header parsing edge cases", "[downloader][http][edge]") { SECTION("Empty headers list") { std::vector headers; auto parsed = parse_http_headers_for_meta(headers); CHECK_FALSE(parsed.etag.has_value()); CHECK_FALSE(parsed.lastModified.has_value()); } SECTION("Malformed headers (no colon)") { std::vector headers = {"InvalidHeaderNoColon", "ETag: valid-etag"}; auto parsed = parse_http_headers_for_meta(headers); REQUIRE(parsed.etag.has_value()); CHECK(*parsed.etag == "valid-etag"); } SECTION("Empty header value") { std::vector headers = {"ETag: ", "Last-Modified: "}; auto parsed = parse_http_headers_for_meta(headers); // Empty values should still be captured REQUIRE(parsed.etag.has_value()); CHECK(parsed.etag->empty()); } SECTION("Whitespace only value") { std::vector headers = {"ETag: "}; auto parsed = parse_http_headers_for_meta(headers); REQUIRE(parsed.etag.has_value()); CHECK(parsed.etag->empty()); } }