#include "game_service.h"
#include <chrono>
#include <cstdint>
#include <filesystem>
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include <gtest/gtest.h>
#include "startup.h"
namespace
{
class TemporaryDatabase
{
public:
/// Allocate a unique database path for one test.
TemporaryDatabase()
: path_(
std::filesystem::path(testing::TempDir()) /
("game_service_" + std::to_string(
std::chrono::steady_clock::now()
.time_since_epoch().count()) + ".sqlite3"))
{}
/// Remove the database and SQLite sidecars.
~TemporaryDatabase()
{
std::error_code error;
std::filesystem::remove(path_, error);
std::filesystem::remove(path_.string() + "-shm", error);
std::filesystem::remove(path_.string() + "-wal", error);
}
/// Return the allocated path.
const std::filesystem::path& path() const
{
return path_;
}
private:
std::filesystem::path path_;
};
struct ServiceFixture
{
std::unique_ptr<DataSourceInterface> data_source;
std::int64_t administrator_id;
std::int64_t player_id;
};
mw::E<ServiceFixture> prepareFixture(const std::filesystem::path& path)
{
auto data_source = prepareDataSource(path);
if(!data_source)
{
return std::unexpected(std::move(data_source.error()));
}
auto transaction = (*data_source)->beginTransaction();
if(!transaction)
{
return std::unexpected(std::move(transaction.error()));
}
auto named = (*transaction)->updateUsername(
1, "Administrator", "administrator");
if(!named)
{
return std::unexpected(std::move(named.error()));
}
User player = {
0,
"player@example.com",
"player@example.com",
std::nullopt,
UserRole::PLAYER,
1,
0,
0};
auto player_id = (*transaction)->insertUser(player);
if(!player_id)
{
return std::unexpected(std::move(player_id.error()));
}
named = (*transaction)->updateUsername(
*player_id, "Player", "player");
if(!named)
{
return std::unexpected(std::move(named.error()));
}
auto committed = (*transaction)->commit();
if(!committed)
{
return std::unexpected(std::move(committed.error()));
}
return ServiceFixture{std::move(*data_source), 1, *player_id};
}
const mw::HTTPError* httpError(const mw::Error& error)
{
return error.as<mw::HTTPError>();
}
} // namespace
/// Verify complete game definitions mutate atomically and advance revisions.
TEST(GameServiceTest, MutatesDefinitionsAndRejectsStaleRevisions)
{
TemporaryDatabase database;
auto fixture = prepareFixture(database.path());
ASSERT_TRUE(fixture) << fixture.error().msg();
GameService service(*fixture->data_source);
auto created = service.createGame(
fixture->administrator_id, "demo", " Demo Game ", "A **game**.",
GameVisibility::PUBLIC);
ASSERT_TRUE(created) << created.error().msg();
EXPECT_EQ(*created, "demo");
auto integer_id = service.createField(
fixture->administrator_id,
"demo",
1,
"score",
" Score ",
GameFieldType::INTEGER,
{});
ASSERT_TRUE(integer_id) << integer_id.error().msg();
auto string_id = service.createField(
fixture->administrator_id,
"demo",
2,
"note",
"Note",
GameFieldType::STRING,
{});
ASSERT_TRUE(string_id) << string_id.error().msg();
auto choice_id = service.createField(
fixture->administrator_id,
"demo",
3,
"route",
"Route",
GameFieldType::CHOICE,
{"Forest", "Coast"});
ASSERT_TRUE(choice_id) << choice_id.error().msg();
auto duplicate_choices = service.createField(
fixture->administrator_id,
"demo",
4,
"duplicate",
"Duplicate",
GameFieldType::CHOICE,
{"Same", "Same"});
ASSERT_FALSE(duplicate_choices);
ASSERT_NE(httpError(duplicate_choices.error()), nullptr);
EXPECT_EQ(httpError(duplicate_choices.error())->code, 409);
auto stale = service.updateGame(
fixture->administrator_id, "demo", 3, "Stale", "",
GameVisibility::PUBLIC);
ASSERT_FALSE(stale);
ASSERT_NE(httpError(stale.error()), nullptr);
EXPECT_EQ(httpError(stale.error())->code, 409);
ASSERT_TRUE(service.reorderFields(
fixture->administrator_id,
"demo",
4,
{*choice_id, *string_id, *integer_id}));
ASSERT_TRUE(service.updateField(
fixture->administrator_id,
"demo",
*choice_id,
5,
"Path",
{"Coast", "Forest", "Town"}));
ASSERT_TRUE(service.updateGame(
fixture->administrator_id,
"demo",
6,
"Renamed Game",
"New description",
GameVisibility::INTERNAL));
auto definition = fixture->data_source->getGameDefinition(
"demo", GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(definition);
ASSERT_TRUE(*definition);
EXPECT_EQ((**definition).game.display_name, "Renamed Game");
EXPECT_EQ((**definition).game.visibility, GameVisibility::INTERNAL);
EXPECT_EQ((**definition).game.revision, 7);
auto public_definition = fixture->data_source->getGameDefinition(
"demo", GameContentScope::PUBLIC_ONLY);
ASSERT_TRUE(public_definition);
EXPECT_FALSE(*public_definition);
ASSERT_EQ((**definition).fields.size(), 3);
EXPECT_EQ((**definition).fields[0].id, *choice_id);
EXPECT_EQ((**definition).fields[0].label, "Path");
ASSERT_EQ((**definition).fields[0].choices.size(), 3);
EXPECT_EQ((**definition).fields[0].choices[0].value, "Coast");
EXPECT_EQ((**definition).fields[0].choices[2].value, "Town");
}
/// Verify definition authorization, identity validation, and usage conflicts.
TEST(GameServiceTest, EnforcesAuthorizationAndDeletionRules)
{
TemporaryDatabase database;
auto fixture = prepareFixture(database.path());
ASSERT_TRUE(fixture) << fixture.error().msg();
GameService service(*fixture->data_source);
auto forbidden = service.createGame(
fixture->player_id, "player", "Player Game", "",
GameVisibility::PUBLIC);
ASSERT_FALSE(forbidden);
ASSERT_NE(httpError(forbidden.error()), nullptr);
EXPECT_EQ(httpError(forbidden.error())->code, 403);
auto invalid = service.createGame(
fixture->administrator_id, "d\xc3\xa9mo", "Invalid", "",
GameVisibility::PUBLIC);
ASSERT_FALSE(invalid);
const auto* validation =
invalid.error().as<GameDefinitionValidationError>();
ASSERT_NE(validation, nullptr);
EXPECT_EQ(validation->field_name, "short_name");
ASSERT_TRUE(service.createGame(
fixture->administrator_id, "used", "Used Game", "",
GameVisibility::PUBLIC));
auto choice_id = service.createField(
fixture->administrator_id,
"used",
1,
"route",
"Route",
GameFieldType::CHOICE,
{"Forest", "Coast"});
ASSERT_TRUE(choice_id) << choice_id.error().msg();
auto transaction = fixture->data_source->beginTransaction();
ASSERT_TRUE(transaction);
auto number = (*transaction)->allocateGameNumber("used");
ASSERT_TRUE(number);
Card card = {
0,
{"used", *number},
"Used card",
std::nullopt,
std::nullopt,
0,
"avif",
std::nullopt,
"avif",
1,
fixture->administrator_id};
auto card_id = (*transaction)->insertCard(
card,
{{*choice_id, GameFieldType::CHOICE, std::string("Forest")}},
{});
ASSERT_TRUE(card_id) << card_id.error().msg();
ASSERT_TRUE((*transaction)->commit());
auto used_choice = service.updateField(
fixture->administrator_id,
"used",
*choice_id,
2,
"Route",
{"Coast"});
ASSERT_FALSE(used_choice);
ASSERT_NE(httpError(used_choice.error()), nullptr);
EXPECT_EQ(httpError(used_choice.error())->code, 409);
EXPECT_NE(httpError(used_choice.error())->msg.find("1 card"),
std::string::npos);
auto used_field = service.removeField(
fixture->administrator_id, "used", *choice_id, 2);
ASSERT_FALSE(used_field);
ASSERT_NE(httpError(used_field.error()), nullptr);
EXPECT_EQ(httpError(used_field.error())->code, 409);
auto used_game = service.removeGame(
fixture->administrator_id, "used", 2);
ASSERT_FALSE(used_game);
ASSERT_NE(httpError(used_game.error()), nullptr);
EXPECT_EQ(httpError(used_game.error())->code, 409);
transaction = fixture->data_source->beginTransaction();
ASSERT_TRUE(transaction);
ASSERT_TRUE((*transaction)->deleteCard(*card_id));
ASSERT_TRUE((*transaction)->commit());
ASSERT_TRUE(service.removeField(
fixture->administrator_id, "used", *choice_id, 2));
auto issued_game = service.removeGame(
fixture->administrator_id, "used", 3);
ASSERT_FALSE(issued_game);
ASSERT_NE(httpError(issued_game.error()), nullptr);
EXPECT_EQ(httpError(issued_game.error())->code, 409);
ASSERT_TRUE(service.createGame(
fixture->administrator_id, "empty", "Empty Game", "",
GameVisibility::PUBLIC));
ASSERT_TRUE(service.removeGame(
fixture->administrator_id, "empty", 1));
auto removed = fixture->data_source->getGameDefinition(
"empty", GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(removed);
EXPECT_FALSE(*removed);
}