#include "doctest/doctest.h" #include "fgc/FileLogSink.h" #include #include #include #include #include #include using namespace fgc; namespace fs = std::filesystem; namespace { // A scratch directory per test case, removed on the way out even if a REQUIRE // aborts the body. struct TempDir { fs::path path; explicit TempDir(const char* tag) { path = fs::temp_directory_path() / ("fgc-log-test-" + std::string(tag) + "-" + std::to_string(::getpid())); fs::remove_all(path); fs::create_directories(path); } ~TempDir() { std::error_code ec; fs::remove_all(path, ec); } }; FileLogPolicy policyIn(const TempDir& d, long long max_bytes, int keep) { FileLogPolicy p; p.dir = d.path.string(); p.max_bytes = max_bytes; p.keep_files = keep; return p; } std::string read(const std::string& path) { std::ifstream in(path, std::ios::binary); return {std::istreambuf_iterator(in), std::istreambuf_iterator()}; } // Every _*.log in the directory, oldest first. std::vector filesOldestFirst(const TempDir& d) { std::vector out; for (const auto& e : fs::directory_iterator(d.path)) if (e.path().extension() == ".log") out.push_back(e.path().filename().string()); std::sort(out.begin(), out.end()); return out; } } // namespace TEST_CASE("a line lands in the file, dated, under a self-describing header") { TempDir d("basic"); FileLogSink s; REQUIRE(s.open(policyIn(d, 0, 0), "=== header ===")); s.write(LogLevel::Info, LogCat::None, "12:00:00.000 [INFO ] hello"); s.close(); const std::string text = read((d.path / filesOldestFirst(d).front()).string()); CHECK(text.find("=== header ===") != std::string::npos); CHECK(text.find("[INFO ] hello") != std::string::npos); // Logger's own stamp carries no date, so the sink prepends one - otherwise a // file spanning midnight is ambiguous. Check the shape, not just a dash. const auto at = text.find("12:00:00.000"); REQUIRE(at != std::string::npos); REQUIRE(at >= 11); const std::string date = text.substr(at - 11, 11); CHECK(date.size() == 11); CHECK(date[4] == '-'); CHECK(date[7] == '-'); CHECK(date[10] == ' '); } TEST_CASE("rotation fires at the boundary, and never splits a line") { TempDir d("rotate"); FileLogSink s; // Header empty so the budget is entirely lines. REQUIRE(s.open(policyIn(d, 200, 0), "")); // Each written record is "YYYY-MM-DD " (11) + line + "\n". const std::string line(50, 'x'); for (int i = 0; i < 10; ++i) s.write(LogLevel::Info, LogCat::None, line); s.close(); const auto files = filesOldestFirst(d); REQUIRE(files.size() > 1); for (const auto& f : files) { const std::string text = read((d.path / f).string()); CHECK(text.size() <= 200); // A record is only ever whole: the file ends on a newline and contains // no truncated 50-char run. CHECK(text.back() == '\n'); } } TEST_CASE("segment names sort chronologically") { // The bug this guards: with an unpadded suffix the FIRST segment is // ".log" and later ones ".1.log". Lexicographically 'l' > '1', // so the oldest file sorted as the newest and pruning deleted the wrong end. TempDir d("sort"); FileLogSink s; REQUIRE(s.open(policyIn(d, 120, 0), "")); for (int i = 0; i < 6; ++i) s.write(LogLevel::Info, LogCat::None, std::string(40, 'a' + i)); s.close(); auto files = filesOldestFirst(d); REQUIRE(files.size() >= 3); // Written in order a.., b.., c..; sorted by name, the contents must follow. const std::string first = read((d.path / files.front()).string()); const std::string second = read((d.path / files[1]).string()); CHECK(first.find(std::string(40, 'a')) != std::string::npos); CHECK(second.find(std::string(40, 'a')) == std::string::npos); // And the sink's own newest-first view must be the exact reverse. auto newest = FileLogSink::existingFiles(policyIn(d, 120, 0)); REQUIRE(newest.size() == files.size()); CHECK(fs::path(newest.front()).filename().string() == files.back()); } TEST_CASE("retention keeps the newest N and deletes the oldest") { TempDir d("prune"); FileLogSink s; REQUIRE(s.open(policyIn(d, 120, 3), "")); for (int i = 0; i < 20; ++i) s.write(LogLevel::Info, LogCat::None, std::string(40, 'q')); s.close(); auto files = filesOldestFirst(d); CHECK(files.size() == 3); // The survivors must be the LAST three segments, not the first three. CHECK(files.back().find(".log") != std::string::npos); auto newest = FileLogSink::existingFiles(policyIn(d, 120, 3)); CHECK(newest.size() == 3); } TEST_CASE("a max_bytes smaller than the header does not rotate forever") { // Without the "at least one line in this file" guard, the header alone // exceeds the cap, so every write rotates and the whole retention set is // consumed in a loop - unbounded file churn from one misconfigured key. TempDir d("tinycap"); FileLogSink s; REQUIRE(s.open(policyIn(d, 10, 0), "=== a header much longer than ten bytes ===")); for (int i = 0; i < 5; ++i) s.write(LogLevel::Info, LogCat::None, "some line"); s.close(); // Five writes, each in its own file because the cap is tiny - but exactly // five, not hundreds. CHECK(filesOldestFirst(d).size() <= 6); } TEST_CASE("a single line longer than max_bytes still lands, whole, and terminates") { TempDir d("oversize"); FileLogSink s; REQUIRE(s.open(policyIn(d, 50, 0), "")); const std::string huge(500, 'z'); s.write(LogLevel::Info, LogCat::None, "first"); s.write(LogLevel::Info, LogCat::None, huge); s.close(); bool found = false; for (const auto& f : filesOldestFirst(d)) if (read((d.path / f).string()).find(huge) != std::string::npos) found = true; CHECK(found); } TEST_CASE("the header is re-emitted after each rotation") { TempDir d("hdr"); FileLogSink s; REQUIRE(s.open(policyIn(d, 160, 0), "=== HDR ===")); for (int i = 0; i < 8; ++i) s.write(LogLevel::Info, LogCat::None, std::string(40, 'h')); s.close(); const auto files = filesOldestFirst(d); REQUIRE(files.size() > 1); for (const auto& f : files) CHECK(read((d.path / f).string()).rfind("=== HDR ===", 0) == 0); } TEST_CASE("an unopenable path fails cleanly and writes become no-ops") { // The sink runs inside Logger's lock and must never throw or log; a failure // has to be latched and reported by polling instead. TempDir d("fail"); const fs::path blocker = d.path / "blocked"; { std::ofstream(blocker) << "not a directory"; } FileLogPolicy p = policyIn(d, 0, 0); p.dir = (blocker / "sub").string(); // parent is a regular file FileLogSink s; CHECK_FALSE(s.open(p, "hdr")); CHECK(s.failed()); CHECK_FALSE(s.lastError().empty()); CHECK_NOTHROW(s.write(LogLevel::Error, LogCat::None, "after failure")); CHECK_FALSE(s.isOpen()); } TEST_CASE("the prefix cannot be confused with the self-test reports") { // Application::latestPriorTestReport() scans this same directory for // test_*.log and takes the lexicographically greatest. Our files must not // appear in that scan. TempDir d("prefix"); FileLogSink s; REQUIRE(s.open(policyIn(d, 0, 0), "")); s.write(LogLevel::Info, LogCat::None, "x"); s.close(); for (const auto& f : filesOldestFirst(d)) { CHECK(f.rfind("fgc_", 0) == 0); CHECK(f.rfind("test_", 0) != 0); } }