// Persistence tests for CarrierStore + LoadPortStore. // // Mirrors the pattern from test_data_model.cpp:spool-persistence: enable // a journal directory, mutate state, "restart" by constructing a fresh // store pointed at the same directory, and assert replay reproduces the // in-memory state. Plus negative paths: partial-write files are // dropped, remove deletes the file, persistence-disabled is a no-op. #include #include #include #include #include #include "secsgem/gem/store/carriers.hpp" using namespace secsgem::gem; namespace fs = std::filesystem; namespace { fs::path scratch_dir(const char* tag) { std::random_device rd; auto p = fs::temp_directory_path() / (std::string("secsgem-carrier-") + tag + "-" + std::to_string(rd())); fs::remove_all(p); fs::create_directories(p); return p; } std::size_t count_with_ext(const fs::path& dir, const std::string& ext) { std::size_t n = 0; std::error_code ec; for (auto& e : fs::directory_iterator(dir, ec)) { if (e.is_regular_file() && e.path().extension() == ext) ++n; } return n; } } // namespace TEST_CASE("CarrierStore persistence: write + restart + replay") { auto dir = scratch_dir("write-replay"); { CarrierStore s; s.enable_persistence(dir); REQUIRE(s.create("CAR-1", /*port=*/2, /*capacity=*/4) == CarrierStore::CreateResult::Created); REQUIRE(s.create("CAR-2", /*port=*/3, /*capacity=*/4) == CarrierStore::CreateResult::Created); // Drive each FSM axis through a transition so we can prove every // byte makes it through the journal. REQUIRE(s.fire_id_event("CAR-1", CarrierIDEvent::ProceedWithCarrier)); REQUIRE(s.fire_slot_map_event("CAR-1", SlotMapEvent::Read)); REQUIRE(s.fire_access_event("CAR-1", CarrierAccessEvent::BeginAccess)); s.set_slot_state("CAR-1", 0, 1); // slot 1 occupied s.set_slot_state("CAR-1", 1, 0); // slot 2 empty CHECK(count_with_ext(dir, ".car") == 2); } // Restart: fresh store, same dir. { CarrierStore s; s.enable_persistence(dir); REQUIRE(s.has("CAR-1")); REQUIRE(s.has("CAR-2")); const auto* c1 = s.get("CAR-1"); REQUIRE(c1); CHECK(c1->port_id == 2); CHECK(c1->slots.size() == 4); CHECK(c1->slots[0].state == 1); CHECK(c1->slots[1].state == 0); CHECK(c1->fsm->id_status() == CarrierIDStatus::Confirmed); CHECK(c1->fsm->slot_map_status() == SlotMapStatus::Read); CHECK(c1->fsm->access_status() == CarrierAccessStatus::InAccess); const auto* c2 = s.get("CAR-2"); REQUIRE(c2); CHECK(c2->port_id == 3); CHECK(c2->fsm->id_status() == CarrierIDStatus::NotConfirmed); // Continued mutation after replay still writes through. REQUIRE(s.fire_id_event("CAR-2", CarrierIDEvent::Bind)); } // Second restart shows the post-replay mutation is durable. { CarrierStore s; s.enable_persistence(dir); REQUIRE(s.has("CAR-2")); CHECK(s.get("CAR-2")->fsm->id_status() == CarrierIDStatus::Confirmed); } fs::remove_all(dir); } TEST_CASE("CarrierStore persistence: remove deletes the journal file") { auto dir = scratch_dir("remove"); CarrierStore s; s.enable_persistence(dir); REQUIRE(s.create("CAR-A") == CarrierStore::CreateResult::Created); REQUIRE(s.create("CAR-B") == CarrierStore::CreateResult::Created); CHECK(count_with_ext(dir, ".car") == 2); REQUIRE(s.remove("CAR-A")); CHECK(count_with_ext(dir, ".car") == 1); // Restart and confirm CAR-A is gone, CAR-B remains. CarrierStore s2; s2.enable_persistence(dir); CHECK_FALSE(s2.has("CAR-A")); CHECK(s2.has("CAR-B")); fs::remove_all(dir); } TEST_CASE("CarrierStore persistence: malformed file is dropped, not poisonous") { auto dir = scratch_dir("malformed"); { CarrierStore s; s.enable_persistence(dir); REQUIRE(s.create("CAR-OK") == CarrierStore::CreateResult::Created); } // Inject a corrupt record (wrong magic byte at offset 0). { std::ofstream f(dir / "9999999999.car", std::ios::binary); const char garbage[] = {0x00, 0x01, 0x02, 0x03}; f.write(garbage, sizeof(garbage)); } // And a truncated record (magic+version only). { std::ofstream f(dir / "9999999998.car", std::ios::binary); const char hdr[] = {static_cast(0xC4), 0x01}; f.write(hdr, sizeof(hdr)); } CHECK(count_with_ext(dir, ".car") == 3); CarrierStore s; s.enable_persistence(dir); CHECK(s.has("CAR-OK")); CHECK(s.size() == 1); // The two malformed files were removed during replay. CHECK(count_with_ext(dir, ".car") == 1); fs::remove_all(dir); } TEST_CASE("CarrierStore: persistence disabled is a no-op") { auto dir = scratch_dir("disabled"); CarrierStore s; // Note: enable_persistence NOT called. REQUIRE(s.create("CAR-X") == CarrierStore::CreateResult::Created); REQUIRE(s.fire_id_event("CAR-X", CarrierIDEvent::ProceedWithCarrier)); CHECK_FALSE(s.persistence_enabled()); // No journal file should exist anywhere — but to be safe also check // that pointing a fresh store at `dir` doesn't see CAR-X (since we // never wrote it). CarrierStore s2; s2.enable_persistence(dir); CHECK_FALSE(s2.has("CAR-X")); CHECK(s2.size() == 0); fs::remove_all(dir); } TEST_CASE("LoadPortStore persistence: write + restart + replay") { auto dir = scratch_dir("lp-write-replay"); { LoadPortStore lp; lp.enable_persistence(dir); REQUIRE(lp.create(1)); REQUIRE(lp.create(2)); REQUIRE(lp.associate(1, "CAR-1")); REQUIRE(lp.fire_transfer_event(1, LoadPortTransferEvent::StartLoading)); REQUIRE(lp.fire_reservation_event(2, LoadPortReservationEvent::Reserve)); CHECK(count_with_ext(dir, ".lp") == 2); } { LoadPortStore lp; lp.enable_persistence(dir); REQUIRE(lp.has(1)); REQUIRE(lp.has(2)); const auto* p1 = lp.get(1); CHECK(p1->carrierid == "CAR-1"); CHECK(p1->fsm->transfer_state() == LoadPortTransferState::Loading); CHECK(p1->fsm->association_state() == LoadPortAssociationStatus::Associated); const auto* p2 = lp.get(2); CHECK(p2->fsm->reservation_state() == LoadPortReservationStatus::Reserved); } fs::remove_all(dir); } TEST_CASE("LoadPortStore persistence: malformed file dropped") { auto dir = scratch_dir("lp-malformed"); { std::ofstream f(dir / "001.lp", std::ios::binary); const char junk[] = {0x00, 0x01, 0x02}; f.write(junk, sizeof(junk)); } LoadPortStore lp; lp.enable_persistence(dir); CHECK(lp.size() == 0); CHECK(count_with_ext(dir, ".lp") == 0); fs::remove_all(dir); }