216 lines
7.6 KiB
C++
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);
|
|
}
|
|
}
|