/** * Unit tests for v1.7.0 T1: layered config loader (conf.d drop-ins). * * Exercises loadLayeredConfig() directly — no server. * * Test cases: * 1. Missing base + empty conf.d returns {} * 2. Base-only loads correctly * 3. Drop-in overrides base (deep merge preserves siblings) * 4. Drop-ins merged in lexicographic order (last wins) * 5. Arrays replace per RFC 7396 (not append) * 6. Invalid JSON in drop-in throws with filename in message * 7. Non-.json files in conf.d are ignored * 8. Missing conf.d directory is not an error */ #include "../service/src/config/config_loader.hpp" #include #include #include #include #include #include namespace fs = std::filesystem; using smartbotic::database::loadLayeredConfig; namespace { fs::path makeTestDir(const std::string& name) { auto dir = fs::temp_directory_path() / ("conf-test-" + name + "-" + std::to_string(::getpid())); fs::remove_all(dir); fs::create_directories(dir); return dir; } void writeFile(const fs::path& p, const std::string& content) { std::ofstream f(p); f << content; } } // anonymous namespace void test_no_base_no_dropins_returns_empty() { auto dir = makeTestDir("empty"); auto base = dir / "config.json"; auto confDir = dir / "conf.d"; fs::create_directories(confDir); auto cfg = loadLayeredConfig(base, confDir); assert(cfg.is_object()); assert(cfg.empty()); fs::remove_all(dir); std::cout << "PASS: missing base + empty conf.d returns {}\n"; } void test_base_only() { auto dir = makeTestDir("base"); auto base = dir / "config.json"; auto confDir = dir / "conf.d"; fs::create_directories(confDir); writeFile(base, R"({"log_level":"info","storage":{"rpc_port":9004}})"); auto cfg = loadLayeredConfig(base, confDir); assert(cfg["log_level"] == "info"); assert(cfg["storage"]["rpc_port"] == 9004); fs::remove_all(dir); std::cout << "PASS: base-only config loads correctly\n"; } void test_dropin_overrides_base() { auto dir = makeTestDir("override"); auto base = dir / "config.json"; auto confDir = dir / "conf.d"; fs::create_directories(confDir); writeFile(base, R"({"log_level":"info","storage":{"rpc_port":9004,"node_id":"base"}})"); writeFile(confDir / "50-override.json", R"({"log_level":"debug","storage":{"node_id":"overridden"}})"); auto cfg = loadLayeredConfig(base, confDir); assert(cfg["log_level"] == "debug"); // overridden assert(cfg["storage"]["rpc_port"] == 9004); // preserved from base assert(cfg["storage"]["node_id"] == "overridden"); // overridden fs::remove_all(dir); std::cout << "PASS: drop-in overrides base (deep merge preserves siblings)\n"; } void test_lexicographic_order() { auto dir = makeTestDir("order"); auto base = dir / "config.json"; auto confDir = dir / "conf.d"; fs::create_directories(confDir); writeFile(base, R"({"log_level":"info"})"); writeFile(confDir / "50-middle.json", R"({"log_level":"debug"})"); writeFile(confDir / "10-first.json", R"({"log_level":"trace"})"); writeFile(confDir / "99-last.json", R"({"log_level":"warn"})"); auto cfg = loadLayeredConfig(base, confDir); // Order: base -> 10-first -> 50-middle -> 99-last. Last wins. assert(cfg["log_level"] == "warn"); fs::remove_all(dir); std::cout << "PASS: drop-ins merged in lexicographic order, last wins\n"; } void test_arrays_replace_not_append() { auto dir = makeTestDir("arrays"); auto base = dir / "config.json"; auto confDir = dir / "conf.d"; fs::create_directories(confDir); writeFile(base, R"({"sensitive_fields":["email","phone"]})"); writeFile(confDir / "50-override.json", R"({"sensitive_fields":["ssn"]})"); auto cfg = loadLayeredConfig(base, confDir); // RFC 7396: arrays replace, don't merge assert(cfg["sensitive_fields"].size() == 1); assert(cfg["sensitive_fields"][0] == "ssn"); fs::remove_all(dir); std::cout << "PASS: arrays replace (RFC 7396), don't append\n"; } void test_invalid_json_throws() { auto dir = makeTestDir("invalid"); auto base = dir / "config.json"; auto confDir = dir / "conf.d"; fs::create_directories(confDir); writeFile(base, R"({"log_level":"info"})"); writeFile(confDir / "50-bad.json", R"({this is not json)"); bool threw = false; try { (void)loadLayeredConfig(base, confDir); } catch (const std::exception& e) { threw = true; std::string msg = e.what(); // Error should mention the filename so the operator knows which file assert(msg.find("50-bad.json") != std::string::npos); } assert(threw); fs::remove_all(dir); std::cout << "PASS: invalid JSON in drop-in throws with filename\n"; } void test_non_json_files_ignored() { auto dir = makeTestDir("nonjson"); auto base = dir / "config.json"; auto confDir = dir / "conf.d"; fs::create_directories(confDir); writeFile(base, R"({"log_level":"info"})"); writeFile(confDir / "README.txt", "not a config file"); writeFile(confDir / "backup.json.bak", R"({"log_level":"trace"})"); writeFile(confDir / "50-real.json", R"({"log_level":"debug"})"); auto cfg = loadLayeredConfig(base, confDir); assert(cfg["log_level"] == "debug"); // only 50-real.json should apply fs::remove_all(dir); std::cout << "PASS: non-json files in conf.d ignored\n"; } void test_missing_confd_dir_is_ok() { auto dir = makeTestDir("nodir"); auto base = dir / "config.json"; auto confDir = dir / "conf.d"; // intentionally NOT created writeFile(base, R"({"log_level":"info"})"); auto cfg = loadLayeredConfig(base, confDir); assert(cfg["log_level"] == "info"); // no drop-ins, base wins fs::remove_all(dir); std::cout << "PASS: missing conf.d directory is not an error\n"; } int main() { test_no_base_no_dropins_returns_empty(); test_base_only(); test_dropin_overrides_base(); test_lexicographic_order(); test_arrays_replace_not_append(); test_invalid_json_throws(); test_non_json_files_ignored(); test_missing_confd_dir_is_ok(); std::cout << "\nAll config drop-in tests PASSED!\n"; return 0; }