fwt_software/tests/test_filelogsink.cpp

216 lines
7.6 KiB
C++

#include "doctest/doctest.h"
#include "fgc/FileLogSink.h"
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
#include <unistd.h>
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<char>(in), std::istreambuf_iterator<char>()};
}
// Every <prefix>_*.log in the directory, oldest first.
std::vector<std::string> filesOldestFirst(const TempDir& d) {
std::vector<std::string> 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
// "<base>.log" and later ones "<base>.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);
}
}