#include #include "ccm/domain/MagicCard.hpp" #include "ccm/ports/ICollectionRepository.hpp" #include "ccm/ports/IImageStore.hpp" #include "ccm/services/CollectionService.hpp" #include #include #include using namespace ccm; namespace { class InMemoryRepo final : public ICollectionRepository { public: Map storage; bool failLoad{false}; bool failSave{false}; Result load(Game) override { if (failLoad) return Result::err("load failed"); return Result::ok(storage); } Result save(Game, const Map& m) override { if (failSave) return Result::err("save failed"); storage = m; return Result::ok(); } }; class StubImageStore final : public IImageStore { public: std::vector> removed; bool failRemove{false}; Result copyIn(Game, const std::filesystem::path&, const std::string& n) override { return Result::ok(n); } Result remove(Game g, const std::string& n) override { removed.emplace_back(g, n); if (failRemove) return Result::err("remove failed for " + n); return Result::ok(); } std::filesystem::path resolvePath(Game, const std::string& n) const override { return std::filesystem::path(n); } }; MagicCard makeCard(const std::string& name, std::vector imgs = {}) { MagicCard c; c.name = name; c.set = Set{"alp", "Alpha", "1993/08/05"}; c.images = std::move(imgs); return c; } } // namespace TEST_SUITE("CollectionService") { TEST_CASE("nextId uses highest existing id plus one") { InMemoryRepo repo; StubImageStore store; CollectionService svc{repo, store}; repo.storage.emplace(2, makeCard("A")); repo.storage.emplace(9, makeCard("B")); CHECK(CollectionService::nextId(repo.storage) == 10); } TEST_CASE("nextId on empty map is 0, then strictly increments") { InMemoryRepo repo; StubImageStore store; CollectionService svc{repo, store}; const auto id0 = svc.add(Game::Magic, makeCard("A")); REQUIRE(id0.isOk()); CHECK(id0.value() == 0); const auto id1 = svc.add(Game::Magic, makeCard("B")); REQUIRE(id1.isOk()); CHECK(id1.value() == 1); const auto listed = svc.list(Game::Magic); REQUIRE(listed.isOk()); CHECK(listed.value().size() == 2); } TEST_CASE("update modifies an existing card and is rejected for unknown ids") { InMemoryRepo repo; StubImageStore store; CollectionService svc{repo, store}; const auto id = svc.add(Game::Magic, makeCard("Initial")).value(); MagicCard updated = makeCard("Renamed"); updated.id = id; CHECK(svc.update(Game::Magic, updated).isOk()); const auto found = svc.findById(Game::Magic, id); REQUIRE(found.isOk()); REQUIRE(found.value().has_value()); CHECK(found.value()->name == "Renamed"); MagicCard ghost = makeCard("Ghost"); ghost.id = 999; CHECK(svc.update(Game::Magic, ghost).isErr()); } TEST_CASE("remove deletes images and the entry") { InMemoryRepo repo; StubImageStore store; CollectionService svc{repo, store}; const auto id = svc.add( Game::Magic, makeCard("With Images", {"a.png", "b.png"})).value(); REQUIRE(svc.remove(Game::Magic, id).isOk()); // Both images should have been requested for deletion. REQUIRE(store.removed.size() == 2); CHECK(store.removed[0].second == "a.png"); CHECK(store.removed[1].second == "b.png"); const auto listed = svc.list(Game::Magic); REQUIRE(listed.isOk()); CHECK(listed.value().empty()); } TEST_CASE("remove of unknown id returns an error") { InMemoryRepo repo; StubImageStore store; CollectionService svc{repo, store}; CHECK(svc.remove(Game::Magic, 12345).isErr()); } TEST_CASE("load errors are propagated by list and findById") { InMemoryRepo repo; repo.failLoad = true; StubImageStore store; CollectionService svc{repo, store}; const auto listed = svc.list(Game::Magic); REQUIRE(listed.isErr()); CHECK(listed.error() == "load failed"); const auto found = svc.findById(Game::Magic, 1); REQUIRE(found.isErr()); CHECK(found.error() == "load failed"); } TEST_CASE("save errors are propagated by add and update") { InMemoryRepo repo; repo.failSave = true; StubImageStore store; CollectionService svc{repo, store}; const auto addRes = svc.add(Game::Magic, makeCard("A")); REQUIRE(addRes.isErr()); CHECK(addRes.error() == "save failed"); repo.failSave = false; const auto id = svc.add(Game::Magic, makeCard("B")); REQUIRE(id.isOk()); repo.failSave = true; MagicCard updated = makeCard("Renamed"); updated.id = id.value(); const auto updateRes = svc.update(Game::Magic, updated); REQUIRE(updateRes.isErr()); CHECK(updateRes.error() == "save failed"); } TEST_CASE("add overwrites input card id with generated id") { InMemoryRepo repo; StubImageStore store; CollectionService svc{repo, store}; MagicCard card = makeCard("Has User Id"); card.id = 777; const auto out = svc.add(Game::Magic, card); REQUIRE(out.isOk()); CHECK(out.value() == 0); REQUIRE(repo.storage.count(0) == 1); CHECK(repo.storage.at(0).name == "Has User Id"); CHECK(repo.storage.count(777) == 0); } TEST_CASE("findById returns nullopt for missing id") { InMemoryRepo repo; StubImageStore store; CollectionService svc{repo, store}; const auto out = svc.findById(Game::Magic, 99); REQUIRE(out.isOk()); CHECK_FALSE(out.value().has_value()); } TEST_CASE("remove reports image cleanup issues but still removes card") { InMemoryRepo repo; StubImageStore store; store.failRemove = true; CollectionService svc{repo, store}; const auto id = svc.add( Game::Magic, makeCard("With Images", {"a.png", "b.png"})); REQUIRE(id.isOk()); const auto removed = svc.remove(Game::Magic, id.value()); REQUIRE(removed.isErr()); CHECK(removed.error().find("Card removed but image cleanup had issues:") != std::string::npos); CHECK(removed.error().find("remove failed for a.png") != std::string::npos); CHECK(removed.error().find("remove failed for b.png") != std::string::npos); const auto listed = svc.list(Game::Magic); REQUIRE(listed.isOk()); CHECK(listed.value().empty()); } TEST_CASE("remove propagates save failure after image cleanup") { InMemoryRepo repo; StubImageStore store; CollectionService svc{repo, store}; const auto id = svc.add( Game::Magic, makeCard("With Images", {"a.png", "b.png"})); REQUIRE(id.isOk()); repo.failSave = true; const auto removed = svc.remove(Game::Magic, id.value()); REQUIRE(removed.isErr()); CHECK(removed.error() == "save failed"); REQUIRE(store.removed.size() == 2); CHECK(store.removed[0].second == "a.png"); CHECK(store.removed[1].second == "b.png"); } TEST_CASE("saveAll replaces the collection map") { InMemoryRepo repo; StubImageStore store; CollectionService svc{repo, store}; REQUIRE(svc.add(Game::Magic, makeCard("A")).isOk()); REQUIRE(svc.add(Game::Magic, makeCard("B")).isOk()); MagicCard only = makeCard("Only"); only.id = 7; REQUIRE(svc.saveAll(Game::Magic, {only}).isOk()); auto listed = svc.list(Game::Magic); REQUIRE(listed.isOk()); REQUIRE(listed.value().size() == 1); CHECK(listed.value()[0].id == 7); CHECK(listed.value()[0].name == "Only"); } }