// Unit tests for the inventory library. Run only when
// -DOVERSEER_BUILD_TEST=ON.
#include <array>
#include <cstdint>
#include <memory>
#include <span>
#include <string>
#include <vector>
#include <gtest/gtest.h>
#include <mw/database.hpp>
#include <mw/error.hpp>
#include "db/migrations.hpp"
#include "inventory/repo.hpp"
#include "support/seed.hpp"
namespace inv = overseer::inventory;
namespace
{
std::unique_ptr<mw::SQLite> makeMemDB()
{
auto db = mw::SQLite::connectMemory();
EXPECT_TRUE(db.has_value());
auto& d = **db;
EXPECT_TRUE(overseer::db::migrateDB0To1(d).has_value());
return std::move(*db);
}
std::string errMsg(const mw::Error& e)
{
return mw::errorMsg(e);
}
} // namespace
// ---- storage ---------------------------------------------------------
TEST(InventoryRepo, CreateAndGetStorage)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
inv::Storage s;
s.name = "Living Room";
auto id_r = repo.createStorage(s);
ASSERT_TRUE(id_r.has_value());
auto got_r = repo.getStorage(*id_r);
ASSERT_TRUE(got_r.has_value());
EXPECT_EQ(got_r->name, "Living Room");
EXPECT_FALSE(got_r->parent_id.has_value());
}
TEST(InventoryRepo, CreateStorageEmptyNameIsBadInput)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
auto r = repo.createStorage(inv::Storage{});
ASSERT_FALSE(r.has_value());
EXPECT_EQ(errMsg(r.error()), inv::ERR_BAD_INPUT);
}
TEST(InventoryRepo, GetUnknownStorageReturnsNotFound)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
auto r = repo.getStorage(9999);
ASSERT_FALSE(r.has_value());
EXPECT_EQ(errMsg(r.error()), inv::ERR_NOT_FOUND);
}
TEST(InventoryRepo, SiblingNameCollisionIsTaken)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
inv::Storage parent;
parent.name = "Apartment";
auto pid = *repo.createStorage(parent);
inv::Storage a, dup;
a.name = "Room"; a.parent_id = pid;
dup.name = "Room"; dup.parent_id = pid;
ASSERT_TRUE(repo.createStorage(a).has_value());
auto r = repo.createStorage(dup);
ASSERT_FALSE(r.has_value());
EXPECT_EQ(errMsg(r.error()), inv::ERR_NAME_TAKEN);
}
TEST(InventoryRepo, RootNameCollisionIsTaken)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
inv::Storage a, dup;
a.name = "Root"; dup.name = "Root";
ASSERT_TRUE(repo.createStorage(a).has_value());
auto r = repo.createStorage(dup);
ASSERT_FALSE(r.has_value());
EXPECT_EQ(errMsg(r.error()), inv::ERR_NAME_TAKEN);
}
TEST(InventoryRepo, ListChildrenRootsAndNested)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
auto seeded = overseer::test::seedFamily(repo);
auto roots = *repo.listChildren(std::nullopt);
ASSERT_EQ(roots.size(), 1U);
EXPECT_EQ(roots[0].id, seeded.apartment_id);
auto kids = *repo.listChildren(seeded.apartment_id);
ASSERT_EQ(kids.size(), 1U);
EXPECT_EQ(kids[0].id, seeded.room_id);
}
TEST(InventoryRepo, PathToBuildsRootFirst)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
auto seeded = overseer::test::seedFamily(repo);
auto path = *repo.pathTo(seeded.shelf_id);
ASSERT_EQ(path.size(), 3U);
EXPECT_EQ(path[0].id, seeded.apartment_id);
EXPECT_EQ(path[1].id, seeded.room_id);
EXPECT_EQ(path[2].id, seeded.shelf_id);
}
TEST(InventoryRepo, PathToUnknownIsNotFound)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
auto r = repo.pathTo(7777);
ASSERT_FALSE(r.has_value());
EXPECT_EQ(errMsg(r.error()), inv::ERR_NOT_FOUND);
}
TEST(InventoryRepo, UpdateStorageChangesName)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
inv::Storage s;
s.name = "Old";
auto id = *repo.createStorage(s);
s.id = id;
s.name = "New";
ASSERT_TRUE(repo.updateStorage(s).has_value());
EXPECT_EQ(repo.getStorage(id)->name, "New");
}
TEST(InventoryRepo, UpdateUnknownStorageIsNotFound)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
inv::Storage s;
s.id = 12345;
s.name = "Phantom";
auto r = repo.updateStorage(s);
ASSERT_FALSE(r.has_value());
EXPECT_EQ(errMsg(r.error()), inv::ERR_NOT_FOUND);
}
TEST(InventoryRepo, UpdateStorageEmptyNameIsBadInput)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
inv::Storage s;
s.name = "Old";
auto id = *repo.createStorage(s);
s.id = id;
s.name = "";
auto r = repo.updateStorage(s);
ASSERT_FALSE(r.has_value());
EXPECT_EQ(errMsg(r.error()), inv::ERR_BAD_INPUT);
}
TEST(InventoryRepo, DeleteEmptyStorage)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
inv::Storage s; s.name = "Empty";
auto id = *repo.createStorage(s);
ASSERT_TRUE(repo.deleteStorage(id).has_value());
EXPECT_FALSE(repo.getStorage(id).has_value());
}
TEST(InventoryRepo, DeleteStorageWithChildIsNotEmpty)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
inv::Storage parent; parent.name = "P";
auto pid = *repo.createStorage(parent);
inv::Storage child; child.name = "C"; child.parent_id = pid;
ASSERT_TRUE(repo.createStorage(child).has_value());
auto r = repo.deleteStorage(pid);
ASSERT_FALSE(r.has_value());
EXPECT_EQ(errMsg(r.error()), inv::ERR_NOT_EMPTY);
}
TEST(InventoryRepo, DeleteStorageWithStuffIsNotEmpty)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
inv::Storage s; s.name = "S";
auto sid = *repo.createStorage(s);
inv::Stuff t; t.name = "Thing"; t.storage_id = sid;
ASSERT_TRUE(repo.createStuff(t).has_value());
auto r = repo.deleteStorage(sid);
ASSERT_FALSE(r.has_value());
EXPECT_EQ(errMsg(r.error()), inv::ERR_NOT_EMPTY);
}
TEST(InventoryRepo, DeleteUnknownStorageIsNotFound)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
auto r = repo.deleteStorage(999);
ASSERT_FALSE(r.has_value());
EXPECT_EQ(errMsg(r.error()), inv::ERR_NOT_FOUND);
}
// ---- stuff -----------------------------------------------------------
TEST(InventoryRepo, CreateStuffRequiresStorage)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
inv::Stuff t; t.name = "Thing"; // no storage_id
auto r = repo.createStuff(t);
ASSERT_FALSE(r.has_value());
EXPECT_EQ(errMsg(r.error()), inv::ERR_BAD_INPUT);
}
TEST(InventoryRepo, CreateStuffRequiresName)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
inv::Storage s; s.name = "X";
auto sid = *repo.createStorage(s);
inv::Stuff t; t.storage_id = sid;
auto r = repo.createStuff(t);
ASSERT_FALSE(r.has_value());
EXPECT_EQ(errMsg(r.error()), inv::ERR_BAD_INPUT);
}
TEST(InventoryRepo, GetUnknownStuffIsNotFound)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
auto r = repo.getStuff(42);
ASSERT_FALSE(r.has_value());
EXPECT_EQ(errMsg(r.error()), inv::ERR_NOT_FOUND);
}
TEST(InventoryRepo, ListInStorage)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
auto seeded = overseer::test::seedFamily(repo);
// 20 stuffs cycle across 3 storages -> apartment gets 7, room 7,
// shelf 6.
auto in_apt = *repo.listInStorage(seeded.apartment_id);
auto in_room = *repo.listInStorage(seeded.room_id);
auto in_shelf = *repo.listInStorage(seeded.shelf_id);
EXPECT_EQ(in_apt.size() + in_room.size() + in_shelf.size(), 20U);
EXPECT_EQ(in_apt.size(), 7U);
EXPECT_EQ(in_room.size(), 7U);
EXPECT_EQ(in_shelf.size(), 6U);
}
TEST(InventoryRepo, UpdateStuffRenamesAndMovesAtomically)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
inv::Storage a, b; a.name = "A"; b.name = "B";
auto aid = *repo.createStorage(a);
auto bid = *repo.createStorage(b);
inv::Stuff t; t.name = "thing"; t.storage_id = aid;
auto tid = *repo.createStuff(t);
inv::Stuff up;
up.id = tid; up.name = "thing2"; up.storage_id = bid;
ASSERT_TRUE(repo.updateStuff(up).has_value());
auto got = *repo.getStuff(tid);
EXPECT_EQ(got.name, "thing2");
EXPECT_EQ(got.storage_id, bid);
// The move via updateStuff went through moveStuff, so history
// should have a single entry for the previous location.
auto moves = *repo.recentMoves(tid);
ASSERT_EQ(moves.size(), 1U);
EXPECT_EQ(moves.front().storage_id, aid);
}
TEST(InventoryRepo, UpdateStuffBadInput)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
inv::Stuff up; up.id = 1; up.name = ""; up.storage_id = 1;
auto r = repo.updateStuff(up);
ASSERT_FALSE(r.has_value());
EXPECT_EQ(errMsg(r.error()), inv::ERR_BAD_INPUT);
}
TEST(InventoryRepo, MoveStuffRecordsHistory)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
inv::Storage a, b; a.name = "A"; b.name = "B";
auto a_id = *repo.createStorage(a);
auto b_id = *repo.createStorage(b);
inv::Stuff t; t.name = "thing"; t.storage_id = a_id;
auto t_id = *repo.createStuff(t);
ASSERT_TRUE(repo.moveStuff(t_id, b_id).has_value());
auto moves = *repo.recentMoves(t_id);
ASSERT_EQ(moves.size(), 1U);
EXPECT_EQ(moves.front().storage_id, a_id);
EXPECT_EQ(repo.getStuff(t_id)->storage_id, b_id);
}
TEST(InventoryRepo, MoveStuffSameStorageIsNoop)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
inv::Storage s; s.name = "S";
auto sid = *repo.createStorage(s);
inv::Stuff t; t.name = "thing"; t.storage_id = sid;
auto tid = *repo.createStuff(t);
ASSERT_TRUE(repo.moveStuff(tid, sid).has_value());
EXPECT_EQ(repo.recentMoves(tid)->size(), 0U);
}
TEST(InventoryRepo, MoveTrimTriggerKeepsLastTen)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
std::vector<int64_t> storages;
for(int i = 0; i < 12; ++i)
{
inv::Storage s;
s.name = std::string("S") + std::to_string(i);
storages.push_back(*repo.createStorage(s));
}
inv::Stuff t; t.name = "thing"; t.storage_id = storages[0];
auto tid = *repo.createStuff(t);
// 11 moves: from S0->S1, S1->S2, ..., S10->S11. That records 11
// entries; the trigger should keep only the most recent 10.
for(int i = 1; i <= 11; ++i)
{
ASSERT_TRUE(repo.moveStuff(tid, storages[static_cast<size_t>(i)])
.has_value());
}
auto moves = *repo.recentMoves(tid);
EXPECT_EQ(moves.size(), 10U);
// The most-recent move recorded was the one out of S10. The oldest
// (S0) should have been trimmed.
EXPECT_EQ(moves.front().storage_id, storages[10]);
}
TEST(InventoryRepo, DeleteStuff)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
inv::Storage s; s.name = "S";
auto sid = *repo.createStorage(s);
inv::Stuff t; t.name = "thing"; t.storage_id = sid;
auto tid = *repo.createStuff(t);
ASSERT_TRUE(repo.deleteStuff(tid).has_value());
EXPECT_FALSE(repo.getStuff(tid).has_value());
}
TEST(InventoryRepo, DeleteUnknownStuffIsNotFound)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
auto r = repo.deleteStuff(99);
ASSERT_FALSE(r.has_value());
EXPECT_EQ(errMsg(r.error()), inv::ERR_NOT_FOUND);
}
// ---- search ----------------------------------------------------------
TEST(InventoryRepo, SearchStuffByName)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
auto seeded = overseer::test::seedFamily(repo);
auto hits = *repo.searchStuffs("Stuff");
EXPECT_FALSE(hits.empty());
// First seeded stuff is named "Stuff 00", which matches the prefix.
bool found = false;
for(const auto& s : hits)
{
if(s.id == seeded.stuff_ids.front()) { found = true; break; }
}
EXPECT_TRUE(found);
}
TEST(InventoryRepo, SearchStorageFindsApartment)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
auto seeded = overseer::test::seedFamily(repo);
auto hits = *repo.searchStorages("Apartment");
bool found = false;
for(const auto& s : hits)
{
if(s.id == seeded.apartment_id) { found = true; break; }
}
EXPECT_TRUE(found);
}
TEST(InventoryRepo, SearchEmptyQueryReturnsEmpty)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
EXPECT_TRUE(repo.searchStorages("")->empty());
EXPECT_TRUE(repo.searchStuffs("")->empty());
}
// ---- attachments -----------------------------------------------------
TEST(InventoryRepo, PutAndGetAttachmentRoundTrip)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
auto seeded = overseer::test::seedFamily(repo);
// seedFamily inserted 5 attachments; pull the first one.
auto a = repo.getAttachment(seeded.attachment_ids[0]);
ASSERT_TRUE(a.has_value());
EXPECT_EQ(a->mime, "image/avif");
EXPECT_FALSE(a->bytes.empty());
}
TEST(InventoryRepo, PutEmptyAttachmentIsBadInput)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
std::span<const unsigned char> empty;
auto r = repo.putAttachment(empty, "image/png");
ASSERT_FALSE(r.has_value());
EXPECT_EQ(errMsg(r.error()), inv::ERR_BAD_INPUT);
}
TEST(InventoryRepo, GetUnknownAttachmentIsNotFound)
{
auto db = makeMemDB();
inv::InventoryRepo repo(*db);
auto r = repo.getAttachment(123);
ASSERT_FALSE(r.has_value());
EXPECT_EQ(errMsg(r.error()), inv::ERR_NOT_FOUND);
}