Changes
diff --git a/src/app.cpp b/src/app.cpp
index 746ffa0..8dd10ff 100644
--- a/src/app.cpp
+++ b/src/app.cpp
@@ -668,6 +668,25 @@ mw::E<std::int64_t> positiveParameter(
return *value;
}
+mw::E<GameVisibility> visibilityParameter(const App::Request& request)
+{
+ auto value = scalarParameter(request, "visibility");
+ if(!value)
+ {
+ return std::unexpected(std::move(value.error()));
+ }
+ if(*value == "PUBLIC")
+ {
+ return GameVisibility::PUBLIC;
+ }
+ if(*value == "INTERNAL")
+ {
+ return GameVisibility::INTERNAL;
+ }
+ return std::unexpected(mw::httpError(
+ 400, "The form field 'visibility' is invalid"));
+}
+
std::optional<std::string> pathParameter(
const App::Request& request, const std::string& name)
{
@@ -787,9 +806,9 @@ inja::json gameFormJson(
}
mw::E<std::vector<GameDefinitionSnapshot>> loadGameDefinitions(
- DataSourceInterface& data_source)
+ DataSourceInterface& data_source, GameContentScope scope)
{
- auto games = data_source.getGames();
+ auto games = data_source.getGames(scope);
if(!games)
{
return std::unexpected(std::move(games.error()));
@@ -798,7 +817,8 @@ mw::E<std::vector<GameDefinitionSnapshot>> loadGameDefinitions(
definitions.reserve(games->size());
for(const Game& game : *games)
{
- auto definition = data_source.getGameDefinition(game.short_name);
+ auto definition = data_source.getGameDefinition(
+ game.short_name, scope);
if(!definition)
{
return std::unexpected(std::move(definition.error()));
@@ -1072,8 +1092,11 @@ void App::respondCardFieldError(
const RequestIdentity& identity,
const Card* card)
{
- auto definitions = loadGameDefinitions(*data_source_);
- auto all_series = data_source_->getSeries();
+ AuthorizationService authorization;
+ const GameContentScope scope = authorization.gameContentScope(
+ identity.session.user);
+ auto definitions = loadGameDefinitions(*data_source_, scope);
+ auto all_series = data_source_->getSeries(scope);
if(!definitions || !all_series)
{
respondInternalError(response);
@@ -1220,12 +1243,14 @@ void App::respondGameDefinitionError(
const std::string& short_name,
const std::string& display_name,
const std::string& description,
+ GameVisibility visibility,
bool editing)
{
std::optional<GameDefinitionSnapshot> definition;
if(editing)
{
- auto loaded = data_source_->getGameDefinition(short_name);
+ auto loaded = data_source_->getGameDefinition(
+ short_name, GameContentScope::INCLUDE_INTERNAL);
if(!loaded)
{
respondInternalError(response);
@@ -1308,6 +1333,8 @@ void App::respondGameDefinitionError(
? "Edit " + definition->game.display_name
: "Create game"},
{"mode", editing ? "edit" : "create"},
+ {"visibility_internal", visibility == GameVisibility::INTERNAL},
+ {"visibility_public", visibility == GameVisibility::PUBLIC},
{"short_name", renderableGameText(short_name)},
{"submit_label", editing ? "Save game" : "Create game"},
{"title", editing
@@ -1331,7 +1358,8 @@ void App::respondGameFieldDefinitionError(
const std::string& type,
const std::vector<std::string>& choices)
{
- auto loaded = data_source_->getGameDefinition(short_name);
+ auto loaded = data_source_->getGameDefinition(
+ short_name, GameContentScope::INCLUDE_INTERNAL);
if(!loaded)
{
respondInternalError(response);
@@ -1733,7 +1761,10 @@ void App::handleCollection(const Request& request, Response& response)
return;
}
auto user = collection_service_->refresh(identity->session.user.id);
- auto collection = data_source_->getCollection(identity->session.user.id);
+ AuthorizationService authorization;
+ auto collection = data_source_->getCollection(
+ identity->session.user.id,
+ authorization.gameContentScope(identity->session.user));
auto pool = data_source_->getPoolCards();
if(!user || !collection || !pool)
{
@@ -1865,7 +1896,8 @@ void App::handleAdminGames(const Request& request, Response& response)
return;
}
- auto games = data_source_->getGames();
+ auto games = data_source_->getGames(
+ GameContentScope::INCLUDE_INTERNAL);
if(!games)
{
respondInternalError(response);
@@ -1875,7 +1907,8 @@ void App::handleAdminGames(const Request& request, Response& response)
std::vector<HtmlSubstitution> html_substitutions;
for(const Game& game : *games)
{
- auto definition = data_source_->getGameDefinition(game.short_name);
+ auto definition = data_source_->getGameDefinition(
+ game.short_name, GameContentScope::INCLUDE_INTERNAL);
if(!definition || !*definition)
{
respondInternalError(response);
@@ -1915,6 +1948,8 @@ void App::handleAdminGames(const Request& request, Response& response)
{"fields", std::move(fields)},
{"has_description", !current_game.description.empty()},
{"initial", current_game.short_name.substr(0, 1)},
+ {"internal",
+ current_game.visibility == GameVisibility::INTERNAL},
{"short_name", current_game.short_name},
});
}
@@ -1968,6 +2003,8 @@ void App::handleGameNew(const Request& request, Response& response)
{"error_message", ""},
{"has_errors", false},
{"mode", "create"},
+ {"visibility_internal", false},
+ {"visibility_public", true},
{"short_name", ""},
{"submit_label", "Create game"},
{"title", "Create game ยท Card Collection"},
@@ -1990,16 +2027,18 @@ void App::handleGameCreate(const Request& request, Response& response)
auto short_name = scalarParameter(request, "short_name");
auto display_name = scalarParameter(request, "display_name");
auto description = scalarParameter(request, "description", false);
- if(!short_name || !display_name || !description)
+ auto visibility = visibilityParameter(request);
+ if(!short_name || !display_name || !description || !visibility)
{
const mw::Error& error = !short_name ? short_name.error()
- : !display_name ? display_name.error() : description.error();
+ : !display_name ? display_name.error()
+ : !description ? description.error() : visibility.error();
respondOperationError(response, error, "Invalid game form");
return;
}
auto created = game_service_->createGame(
identity->session.user.id, *short_name,
- *display_name, *description);
+ *display_name, *description, *visibility);
if(!created)
{
const auto* validation =
@@ -2013,6 +2052,7 @@ void App::handleGameCreate(const Request& request, Response& response)
*short_name,
*display_name,
*description,
+ *visibility,
false);
return;
}
@@ -2047,7 +2087,8 @@ void App::handleGameEdit(const Request& request, Response& response)
respondNotFound(response);
return;
}
- auto definition = data_source_->getGameDefinition(*short_name);
+ auto definition = data_source_->getGameDefinition(
+ *short_name, GameContentScope::INCLUDE_INTERNAL);
if(!definition)
{
respondInternalError(response);
@@ -2113,6 +2154,10 @@ void App::handleGameEdit(const Request& request, Response& response)
{"error_message", ""},
{"has_errors", false},
{"mode", "edit"},
+ {"visibility_internal",
+ snapshot.game.visibility == GameVisibility::INTERNAL},
+ {"visibility_public",
+ snapshot.game.visibility == GameVisibility::PUBLIC},
{"short_name", snapshot.game.short_name},
{"submit_label", "Save game"},
{"title", "Edit " + snapshot.game.display_name +
@@ -2137,7 +2182,9 @@ void App::handleGameUpdate(const Request& request, Response& response)
auto revision = positiveParameter(request, "game_revision");
auto display_name = scalarParameter(request, "display_name");
auto description = scalarParameter(request, "description", false);
- if(!short_name || !revision || !display_name || !description)
+ auto visibility = visibilityParameter(request);
+ if(!short_name || !revision || !display_name || !description ||
+ !visibility)
{
if(!short_name)
{
@@ -2147,14 +2194,15 @@ void App::handleGameUpdate(const Request& request, Response& response)
{
const mw::Error& error = !revision ? revision.error()
: !display_name ? display_name.error()
- : description.error();
+ : !description ? description.error()
+ : visibility.error();
respondOperationError(response, error, "Invalid game form");
}
return;
}
auto updated = game_service_->updateGame(
identity->session.user.id, *short_name, *revision,
- *display_name, *description);
+ *display_name, *description, *visibility);
if(!updated)
{
const auto* validation =
@@ -2168,6 +2216,7 @@ void App::handleGameUpdate(const Request& request, Response& response)
*short_name,
*display_name,
*description,
+ *visibility,
true);
return;
}
@@ -2199,7 +2248,8 @@ void App::handleGameDeleteConfirm(
}
auto short_name = pathParameter(request, "short");
auto definition = short_name
- ? data_source_->getGameDefinition(*short_name)
+ ? data_source_->getGameDefinition(
+ *short_name, GameContentScope::INCLUDE_INTERNAL)
: mw::E<std::optional<GameDefinitionSnapshot>>(
std::optional<GameDefinitionSnapshot>{});
if(!definition || !*definition)
@@ -2284,7 +2334,8 @@ void App::handleGameFieldNew(const Request& request, Response& response)
}
auto short_name = pathParameter(request, "short");
auto definition = short_name
- ? data_source_->getGameDefinition(*short_name)
+ ? data_source_->getGameDefinition(
+ *short_name, GameContentScope::INCLUDE_INTERNAL)
: mw::E<std::optional<GameDefinitionSnapshot>>(
std::optional<GameDefinitionSnapshot>{});
if(!definition || !*definition)
@@ -2426,7 +2477,8 @@ void App::handleGameFieldEdit(
respondNotFound(response);
return;
}
- auto definition = data_source_->getGameDefinition(*short_name);
+ auto definition = data_source_->getGameDefinition(
+ *short_name, GameContentScope::INCLUDE_INTERNAL);
if(!definition || !*definition)
{
if(!definition)
@@ -2571,7 +2623,8 @@ void App::handleGameFieldDeleteConfirm(
respondNotFound(response);
return;
}
- auto definition = data_source_->getGameDefinition(*short_name);
+ auto definition = data_source_->getGameDefinition(
+ *short_name, GameContentScope::INCLUDE_INTERNAL);
if(!definition || !*definition)
{
if(!definition)
@@ -2754,7 +2807,9 @@ void App::handleCardNew(
return;
}
inja::json games = inja::json::array();
- auto definitions = loadGameDefinitions(*data_source_);
+ const GameContentScope scope = authorization.gameContentScope(
+ identity->session.user);
+ auto definitions = loadGameDefinitions(*data_source_, scope);
if(!definitions)
{
spdlog::error(
@@ -2768,7 +2823,7 @@ void App::handleCardNew(
games.push_back(gameFormJson(definition, {}));
}
inja::json series = inja::json::array();
- auto series_result = data_source_->getSeries();
+ auto series_result = data_source_->getSeries(scope);
if(!series_result)
{
spdlog::error(
@@ -2867,7 +2922,10 @@ void App::handleCardEdit(
respondNotFound(response);
return;
}
- auto card_result = data_source_->getCard(*identity);
+ AuthorizationService authorization;
+ const GameContentScope scope = authorization.gameContentScope(
+ actor->session.user);
+ auto card_result = data_source_->getCard(*identity, scope);
if(!card_result)
{
spdlog::error(
@@ -2883,7 +2941,6 @@ void App::handleCardEdit(
return;
}
const Card& card = **card_result;
- AuthorizationService authorization;
if(!authorization.canEditCard(actor->session.user, card))
{
respondNotFound(response);
@@ -2915,7 +2972,7 @@ void App::handleCardEdit(
std::optional<GameDefinitionSnapshot> selected_game;
if(card.identity.game_short_name)
{
- auto values = data_source_->getCardFieldValues(card.id);
+ auto values = data_source_->getCardFieldValues(card.id, scope);
if(!values)
{
spdlog::error(
@@ -2935,7 +2992,7 @@ void App::handleCardEdit(
}
inja::json games = inja::json::array();
- auto definitions = loadGameDefinitions(*data_source_);
+ auto definitions = loadGameDefinitions(*data_source_, scope);
if(!definitions)
{
respondInternalError(response);
@@ -2952,7 +3009,7 @@ void App::handleCardEdit(
selected ? current_game_values : std::vector<GameFieldValue>{}));
}
- auto all_series = data_source_->getSeries();
+ auto all_series = data_source_->getSeries(scope);
auto memberships = data_source_->getCardSeries(card.id);
if(!all_series || !memberships)
{
@@ -3233,7 +3290,10 @@ void App::handleCardUpdate(
respondNotFound(response);
return;
}
- auto card_result = data_source_->getCard(*identity);
+ AuthorizationService authorization;
+ const GameContentScope scope = authorization.gameContentScope(
+ actor->session.user);
+ auto card_result = data_source_->getCard(*identity, scope);
if(!card_result)
{
spdlog::error(
@@ -3248,7 +3308,6 @@ void App::handleCardUpdate(
respondNotFound(response);
return;
}
- AuthorizationService authorization;
if(!authorization.canEditCard(actor->session.user, **card_result))
{
respondNotFound(response);
@@ -3489,7 +3548,10 @@ void App::handleCardView(
respondNotFound(response);
return;
}
- auto card_result = data_source_->getCard(*identity);
+ AuthorizationService authorization;
+ const GameContentScope scope = authorization.gameContentScope(
+ actor->session.user);
+ auto card_result = data_source_->getCard(*identity, scope);
if(!card_result)
{
spdlog::error(
@@ -3506,7 +3568,6 @@ void App::handleCardView(
}
const Card& card = **card_result;
auto owns = data_source_->userOwnsCard(actor->session.user.id, card.id);
- AuthorizationService authorization;
if(!owns)
{
respondInternalError(response);
@@ -3580,7 +3641,7 @@ void App::handleCardView(
std::string game_name = "Loose card";
if(card.identity.game_short_name)
{
- auto fields = data_source_->getCardFieldValues(card.id);
+ auto fields = data_source_->getCardFieldValues(card.id, scope);
if(!fields)
{
spdlog::error(
@@ -3628,7 +3689,7 @@ void App::handleCardView(
inja::json series = inja::json::array();
for(std::int64_t series_id : *membership_result)
{
- auto series_result = data_source_->getSeries(series_id);
+ auto series_result = data_source_->getSeries(series_id, scope);
if(!series_result)
{
spdlog::error(
@@ -3774,7 +3835,8 @@ void App::handleCardDeleteConfirm(
respondNotFound(response);
return;
}
- auto card = data_source_->getCard(*identity);
+ auto card = data_source_->getCard(
+ *identity, GameContentScope::INCLUDE_INTERNAL);
if(!card || !*card)
{
if(!card)
@@ -3833,7 +3895,8 @@ void App::handleCardDelete(
respondNotFound(response);
return;
}
- auto card = data_source_->getCard(*identity);
+ auto card = data_source_->getCard(
+ *identity, GameContentScope::INCLUDE_INTERNAL);
if(!card || !*card)
{
if(!card)
@@ -3902,8 +3965,9 @@ void App::handleCardIndex(
return;
}
auto cards_result = creator_route
- ? data_source_->getCardsByCreator(actor->session.user.id)
- : data_source_->getCards();
+ ? data_source_->getCardsByCreator(
+ actor->session.user.id, GameContentScope::PUBLIC_ONLY)
+ : data_source_->getCards(GameContentScope::INCLUDE_INTERNAL);
if(!cards_result)
{
spdlog::error(
@@ -3935,6 +3999,22 @@ void App::handleCardIndex(
request.get_param_value("direction") == "desc";
sortIndexCards(cards, descending);
+ std::unordered_map<std::string, GameVisibility> game_visibility;
+ if(!creator_route)
+ {
+ auto games = data_source_->getGames(
+ GameContentScope::INCLUDE_INTERNAL);
+ if(!games)
+ {
+ respondInternalError(response);
+ return;
+ }
+ for(const Game& game : *games)
+ {
+ game_visibility.emplace(game.short_name, game.visibility);
+ }
+ }
+
inja::json template_cards = inja::json::array();
for(const IndexCard& index_card : cards)
{
@@ -3970,8 +4050,21 @@ void App::handleCardIndex(
thumbnail_url = urlFor("static", {"card_placeholder.svg"});
}
+ bool internal = false;
+ if(!creator_route && card.identity.game_short_name)
+ {
+ const auto visibility = game_visibility.find(
+ *card.identity.game_short_name);
+ if(visibility == game_visibility.end())
+ {
+ respondInternalError(response);
+ return;
+ }
+ internal = visibility->second == GameVisibility::INTERNAL;
+ }
template_cards.push_back({
{"display_id", uppercaseAscii(index_card.public_id)},
+ {"internal", internal},
{"name", card.name},
{"thumbnail_url", std::move(thumbnail_url)},
{"url", urlFor("card", {index_card.public_id})},
@@ -4026,7 +4119,8 @@ void App::handleSeriesIndex(
response.status = 403;
return;
}
- auto series_result = data_source_->getSeries();
+ auto series_result = data_source_->getSeries(
+ GameContentScope::INCLUDE_INTERNAL);
if(!series_result)
{
spdlog::error(
@@ -4037,7 +4131,8 @@ void App::handleSeriesIndex(
}
inja::json series = inja::json::array();
std::vector<HtmlSubstitution> html_substitutions;
- auto games_result = data_source_->getGames();
+ auto games_result = data_source_->getGames(
+ GameContentScope::INCLUDE_INTERNAL);
if(!games_result)
{
respondInternalError(response);
@@ -4074,6 +4169,8 @@ void App::handleSeriesIndex(
{"game", game == games_result->end()
? uppercaseAscii(item.game_short_name)
: game->display_name},
+ {"internal", game != games_result->end() &&
+ game->visibility == GameVisibility::INTERNAL},
{"name", item.name},
});
}
@@ -4121,7 +4218,8 @@ void App::handleSeriesNew(
return;
}
inja::json games = inja::json::array();
- auto games_result = data_source_->getGames();
+ auto games_result = data_source_->getGames(
+ GameContentScope::INCLUDE_INTERNAL);
if(!games_result)
{
respondInternalError(response);
@@ -4131,6 +4229,7 @@ void App::handleSeriesNew(
{
games.push_back({
{"display_name", game.display_name},
+ {"internal", game.visibility == GameVisibility::INTERNAL},
{"short_name", game.short_name},
});
}
@@ -4217,7 +4316,8 @@ void App::handleSeriesEdit(
respondNotFound(response);
return;
}
- auto series_result = data_source_->getSeries(*id);
+ auto series_result = data_source_->getSeries(
+ *id, GameContentScope::INCLUDE_INTERNAL);
if(!series_result)
{
spdlog::error(
@@ -4233,7 +4333,8 @@ void App::handleSeriesEdit(
return;
}
const Series& series = **series_result;
- auto game = data_source_->getGameDefinition(series.game_short_name);
+ auto game = data_source_->getGameDefinition(
+ series.game_short_name, GameContentScope::INCLUDE_INTERNAL);
if(!game || !*game)
{
respondInternalError(response);
@@ -4326,7 +4427,8 @@ void App::handleSeriesDeleteConfirm(
respondNotFound(response);
return;
}
- auto series_result = data_source_->getSeries(*id);
+ auto series_result = data_source_->getSeries(
+ *id, GameContentScope::INCLUDE_INTERNAL);
if(!series_result || !*series_result)
{
if(!series_result)
diff --git a/src/app.h b/src/app.h
index 3cfe9a3..e660667 100644
--- a/src/app.h
+++ b/src/app.h
@@ -263,6 +263,7 @@ private:
const std::string& short_name,
const std::string& display_name,
const std::string& description,
+ GameVisibility visibility,
bool editing);
/// Re-render a field editor after definition validation fails.
diff --git a/src/asset_store.cpp b/src/asset_store.cpp
index bec17f1..2edfb22 100644
--- a/src/asset_store.cpp
+++ b/src/asset_store.cpp
@@ -327,7 +327,8 @@ mw::E<void> AssetStore::reconcile(DataSourceInterface& data_source) const
continue;
}
auto identity = parsePublicId(recovery->public_id);
- auto card = data_source.getCard(*identity);
+ auto card = data_source.getCard(
+ *identity, GameContentScope::INCLUDE_INTERNAL);
if(!card)
{
return std::unexpected(std::move(card.error()));
@@ -393,7 +394,8 @@ mw::E<void> AssetStore::reconcile(DataSourceInterface& data_source) const
continue;
}
auto identity = parsePublicId(recovery->public_id);
- auto card = data_source.getCard(*identity);
+ auto card = data_source.getCard(
+ *identity, GameContentScope::INCLUDE_INTERNAL);
if(!card)
{
return std::unexpected(std::move(card.error()));
@@ -431,7 +433,8 @@ mw::E<void> AssetStore::reconcile(DataSourceInterface& data_source) const
spdlog::warn("Leaving non-card published entry {}", public_id);
continue;
}
- auto card = data_source.getCard(*identity);
+ auto card = data_source.getCard(
+ *identity, GameContentScope::INCLUDE_INTERNAL);
if(!card)
{
return std::unexpected(std::move(card.error()));
diff --git a/src/authorization.cpp b/src/authorization.cpp
index 9a61c30..a4847d6 100644
--- a/src/authorization.cpp
+++ b/src/authorization.cpp
@@ -36,3 +36,18 @@ bool AuthorizationService::canAdminister(const User& actor) const
{
return actor.role == UserRole::ADMINISTRATOR;
}
+
+GameContentScope AuthorizationService::gameContentScope(
+ const User& actor) const
+{
+ return actor.role == UserRole::ADMINISTRATOR
+ ? GameContentScope::INCLUDE_INTERNAL
+ : GameContentScope::PUBLIC_ONLY;
+}
+
+bool AuthorizationService::canUseGame(
+ const User& actor, const Game& game) const
+{
+ return game.visibility == GameVisibility::PUBLIC ||
+ actor.role == UserRole::ADMINISTRATOR;
+}
diff --git a/src/authorization.h b/src/authorization.h
index 1129971..8d173f0 100644
--- a/src/authorization.h
+++ b/src/authorization.h
@@ -1,6 +1,7 @@
#pragma once
#include "card.h"
+#include "game.h"
#include "user.h"
/// Side-effect-free application permission policy.
@@ -25,4 +26,10 @@ public:
/// Return whether an actor may manage series and users.
bool canAdminister(const User& actor) const;
+
+ /// Return the game-content scope available to an actor.
+ GameContentScope gameContentScope(const User& actor) const;
+
+ /// Return whether an actor may use a game in a card mutation.
+ bool canUseGame(const User& actor, const Game& game) const;
};
diff --git a/src/card_service.cpp b/src/card_service.cpp
index 89ac64c..09053c0 100644
--- a/src/card_service.cpp
+++ b/src/card_service.cpp
@@ -192,6 +192,10 @@ mw::E<std::string> CardService::createCard(
{
return std::unexpected(mw::httpError(422, "Unknown game"));
}
+ if(!authorization_.canUseGame(**actor, (**definition).game))
+ {
+ return std::unexpected(mw::httpError(422, "Unknown game"));
+ }
if((**definition).game.revision != *expected_game_revision)
{
return std::unexpected(mw::httpError(
@@ -328,7 +332,9 @@ mw::E<std::string> CardService::updateCard(
SubmittedGameFields submitted_fields,
const std::vector<std::int64_t>& series_ids)
{
- auto stored = data_source_.getCard(input.current_card.identity);
+ auto stored = data_source_.getCard(
+ input.current_card.identity,
+ GameContentScope::INCLUDE_INTERNAL);
if(!stored)
{
return std::unexpected(std::move(stored.error()));
@@ -562,6 +568,10 @@ mw::E<std::string> CardService::updateCard(
return std::unexpected(mw::runtimeError(
"Card references a missing game"));
}
+ if(!authorization_.canUseGame(**actor, (**definition).game))
+ {
+ return std::unexpected(mw::httpError(404, "Card not found"));
+ }
if((**definition).game.revision != *expected_game_revision)
{
return std::unexpected(mw::httpError(
diff --git a/src/data.cpp b/src/data.cpp
index 1c61c05..aed13fc 100644
--- a/src/data.cpp
+++ b/src/data.cpp
@@ -70,6 +70,7 @@ mw::E<bool> DataSourceTransactionInterface::updateGame(
[[maybe_unused]] const std::string& short_name,
[[maybe_unused]] const std::string& display_name,
[[maybe_unused]] const std::string& description,
+ [[maybe_unused]] GameVisibility visibility,
[[maybe_unused]] std::int64_t expected_revision)
{
return std::unexpected(mw::runtimeError(
@@ -296,7 +297,8 @@ mw::E<std::int64_t> DataSourceTransactionInterface::incrementHolding(
}
mw::E<std::vector<Card>> DataSourceInterface::getCardsByCreator(
- [[maybe_unused]] std::int64_t creator_user_id) const
+ [[maybe_unused]] std::int64_t creator_user_id,
+ [[maybe_unused]] GameContentScope scope) const
{
return std::unexpected(mw::runtimeError("User data is unavailable"));
}
@@ -342,7 +344,8 @@ DataSourceInterface::getAuthenticationChallenge(
}
mw::E<std::vector<CollectionEntry>> DataSourceInterface::getCollection(
- [[maybe_unused]] std::int64_t user_id) const
+ [[maybe_unused]] std::int64_t user_id,
+ [[maybe_unused]] GameContentScope scope) const
{
return std::unexpected(mw::runtimeError(
"Collection data is unavailable"));
@@ -372,21 +375,24 @@ mw::E<void> DataSourceInterface::cleanupAuthentication(
"Authentication data is unavailable"));
}
-mw::E<std::vector<Game>> DataSourceInterface::getGames() const
+mw::E<std::vector<Game>> DataSourceInterface::getGames(
+ [[maybe_unused]] GameContentScope scope) const
{
return std::unexpected(mw::runtimeError("Game data is unavailable"));
}
mw::E<std::optional<GameDefinitionSnapshot>>
DataSourceInterface::getGameDefinition(
- [[maybe_unused]] const std::string& short_name) const
+ [[maybe_unused]] const std::string& short_name,
+ [[maybe_unused]] GameContentScope scope) const
{
return std::unexpected(mw::runtimeError("Game data is unavailable"));
}
mw::E<std::optional<CardGameFields>>
DataSourceInterface::getCardFieldValues(
- [[maybe_unused]] std::int64_t card_id) const
+ [[maybe_unused]] std::int64_t card_id,
+ [[maybe_unused]] GameContentScope scope) const
{
return std::unexpected(mw::runtimeError("Game data is unavailable"));
}
diff --git a/src/data.h b/src/data.h
index 23c35ff..9362564 100644
--- a/src/data.h
+++ b/src/data.h
@@ -108,6 +108,7 @@ public:
const std::string& short_name,
const std::string& display_name,
const std::string& description,
+ GameVisibility visibility,
std::int64_t expected_revision);
/// Delete an unused game, its fields, choices, and sequence.
@@ -288,29 +289,38 @@ public:
beginTransaction() = 0;
/// Return all cards for the unpaginated index.
- virtual mw::E<std::vector<Card>> getCards() const = 0;
+ virtual mw::E<std::vector<Card>> getCards(
+ GameContentScope scope) const = 0;
/// Return all cards authored by one user.
virtual mw::E<std::vector<Card>> getCardsByCreator(
- std::int64_t creator_user_id) const;
+ std::int64_t creator_user_id,
+ GameContentScope scope) const;
/// Return every current positive-rarity card in card-ID order.
virtual mw::E<std::vector<Card>> getPoolCards() const;
/// Return a card by its parsed identity.
virtual mw::E<std::optional<Card>>
- getCard(const CardIdentity& identity) const = 0;
+ getCard(
+ const CardIdentity& identity,
+ GameContentScope scope) const = 0;
/// Return games ordered by display name and then short name.
- virtual mw::E<std::vector<Game>> getGames() const;
+ virtual mw::E<std::vector<Game>> getGames(
+ GameContentScope scope) const;
/// Return a complete, consistently read game definition.
virtual mw::E<std::optional<GameDefinitionSnapshot>>
- getGameDefinition(const std::string& short_name) const;
+ getGameDefinition(
+ const std::string& short_name,
+ GameContentScope scope) const;
/// Return a card's definition and ordered custom values together.
virtual mw::E<std::optional<CardGameFields>>
- getCardFieldValues(std::int64_t card_id) const;
+ getCardFieldValues(
+ std::int64_t card_id,
+ GameContentScope scope) const;
/// Return a user by internal identity.
virtual mw::E<std::optional<User>> getUser(
@@ -334,7 +344,8 @@ public:
/// Return a user's distinct collection entries.
virtual mw::E<std::vector<CollectionEntry>> getCollection(
- std::int64_t user_id) const;
+ std::int64_t user_id,
+ GameContentScope scope) const;
/// Return whether a user currently owns a card.
virtual mw::E<bool> userOwnsCard(
@@ -351,11 +362,14 @@ public:
virtual mw::E<void> cleanupAuthentication(std::int64_t now);
/// Return all series, ordered by game and name.
- virtual mw::E<std::vector<Series>> getSeries() const = 0;
+ virtual mw::E<std::vector<Series>> getSeries(
+ GameContentScope scope) const = 0;
/// Return one series by internal ID.
virtual mw::E<std::optional<Series>>
- getSeries(std::int64_t series_id) const = 0;
+ getSeries(
+ std::int64_t series_id,
+ GameContentScope scope) const = 0;
/// Return the series memberships for one card.
virtual mw::E<std::vector<std::int64_t>>
diff --git a/src/data_fake.cpp b/src/data_fake.cpp
index caa43ac..dbd7e97 100644
--- a/src/data_fake.cpp
+++ b/src/data_fake.cpp
@@ -51,13 +51,59 @@ DataSourceFake::beginTransaction()
return std::unexpected(readOnlyError("begin a transaction"));
}
-mw::E<std::vector<Card>> DataSourceFake::getCards() const
+bool DataSourceFake::gameVisible(
+ const std::string& short_name, GameContentScope scope) const
{
- return cards_;
+ if(scope == GameContentScope::INCLUDE_INTERNAL)
+ {
+ return true;
+ }
+ const auto definition = std::ranges::find_if(
+ game_definitions_,
+ [&short_name](const GameDefinitionSnapshot& candidate)
+ {
+ return candidate.game.short_name == short_name;
+ });
+ return definition != game_definitions_.end() &&
+ definition->game.visibility == GameVisibility::PUBLIC;
+}
+
+bool DataSourceFake::cardVisible(
+ const Card& card, GameContentScope scope) const
+{
+ return !card.identity.game_short_name ||
+ gameVisible(*card.identity.game_short_name, scope);
+}
+
+mw::E<std::vector<Card>> DataSourceFake::getCards(
+ GameContentScope scope) const
+{
+ std::vector<Card> cards;
+ std::ranges::copy_if(
+ cards_, std::back_inserter(cards),
+ [this, scope](const Card& card)
+ {
+ return cardVisible(card, scope);
+ });
+ return cards;
+}
+
+mw::E<std::vector<Card>> DataSourceFake::getCardsByCreator(
+ std::int64_t creator_user_id, GameContentScope scope) const
+{
+ std::vector<Card> cards;
+ std::ranges::copy_if(
+ cards_, std::back_inserter(cards),
+ [this, creator_user_id, scope](const Card& card)
+ {
+ return card.creator_user_id == creator_user_id &&
+ cardVisible(card, scope);
+ });
+ return cards;
}
mw::E<std::optional<Card>> DataSourceFake::getCard(
- const CardIdentity& identity) const
+ const CardIdentity& identity, GameContentScope scope) const
{
const auto card = std::ranges::find_if(
cards_,
@@ -65,20 +111,24 @@ mw::E<std::optional<Card>> DataSourceFake::getCard(
{
return identitiesMatch(candidate.identity, identity);
});
- if(card == cards_.end())
+ if(card == cards_.end() || !cardVisible(*card, scope))
{
return std::nullopt;
}
return *card;
}
-mw::E<std::vector<Game>> DataSourceFake::getGames() const
+mw::E<std::vector<Game>> DataSourceFake::getGames(
+ GameContentScope scope) const
{
std::vector<Game> games;
games.reserve(game_definitions_.size());
for(const GameDefinitionSnapshot& definition : game_definitions_)
{
- games.push_back(definition.game);
+ if(gameVisible(definition.game.short_name, scope))
+ {
+ games.push_back(definition.game);
+ }
}
std::ranges::sort(
games,
@@ -94,7 +144,8 @@ mw::E<std::vector<Game>> DataSourceFake::getGames() const
}
mw::E<std::optional<GameDefinitionSnapshot>>
-DataSourceFake::getGameDefinition(const std::string& short_name) const
+DataSourceFake::getGameDefinition(
+ const std::string& short_name, GameContentScope scope) const
{
const auto definition = std::ranges::find_if(
game_definitions_,
@@ -102,7 +153,8 @@ DataSourceFake::getGameDefinition(const std::string& short_name) const
{
return candidate.game.short_name == short_name;
});
- if(definition == game_definitions_.end())
+ if(definition == game_definitions_.end() ||
+ !gameVisible(short_name, scope))
{
return std::nullopt;
}
@@ -110,16 +162,18 @@ DataSourceFake::getGameDefinition(const std::string& short_name) const
}
mw::E<std::optional<CardGameFields>> DataSourceFake::getCardFieldValues(
- std::int64_t card_id) const
+ std::int64_t card_id, GameContentScope scope) const
{
const auto card = std::ranges::find_if(
cards_,
[card_id](const Card& candidate) { return candidate.id == card_id; });
- if(card == cards_.end() || !card->identity.game_short_name)
+ if(card == cards_.end() || !card->identity.game_short_name ||
+ !cardVisible(*card, scope))
{
return std::nullopt;
}
- auto definition = getGameDefinition(*card->identity.game_short_name);
+ auto definition = getGameDefinition(
+ *card->identity.game_short_name, scope);
if(!definition || !*definition)
{
return std::unexpected(mw::runtimeError(
@@ -133,13 +187,21 @@ mw::E<std::optional<CardGameFields>> DataSourceFake::getCardFieldValues(
: values->second};
}
-mw::E<std::vector<Series>> DataSourceFake::getSeries() const
+mw::E<std::vector<Series>> DataSourceFake::getSeries(
+ GameContentScope scope) const
{
- return series_;
+ std::vector<Series> series;
+ std::ranges::copy_if(
+ series_, std::back_inserter(series),
+ [this, scope](const Series& item)
+ {
+ return gameVisible(item.game_short_name, scope);
+ });
+ return series;
}
mw::E<std::optional<Series>> DataSourceFake::getSeries(
- std::int64_t series_id) const
+ std::int64_t series_id, GameContentScope scope) const
{
const auto series = std::ranges::find_if(
series_,
@@ -147,7 +209,8 @@ mw::E<std::optional<Series>> DataSourceFake::getSeries(
{
return candidate.id == series_id;
});
- if(series == series_.end())
+ if(series == series_.end() ||
+ !gameVisible(series->game_short_name, scope))
{
return std::nullopt;
}
diff --git a/src/data_fake.h b/src/data_fake.h
index 70893e7..f63a52f 100644
--- a/src/data_fake.h
+++ b/src/data_fake.h
@@ -34,29 +34,43 @@ public:
beginTransaction() override;
/// Return all configured cards in insertion order.
- mw::E<std::vector<Card>> getCards() const override;
+ mw::E<std::vector<Card>> getCards(
+ GameContentScope scope) const override;
+
+ /// Return configured cards authored by one user.
+ mw::E<std::vector<Card>> getCardsByCreator(
+ std::int64_t creator_user_id,
+ GameContentScope scope) const override;
/// Return the configured card matching an identity.
mw::E<std::optional<Card>>
- getCard(const CardIdentity& identity) const override;
+ getCard(
+ const CardIdentity& identity,
+ GameContentScope scope) const override;
/// Return configured games ordered by display name and short name.
- mw::E<std::vector<Game>> getGames() const override;
+ mw::E<std::vector<Game>> getGames(
+ GameContentScope scope) const override;
/// Return one configured complete game definition.
mw::E<std::optional<GameDefinitionSnapshot>> getGameDefinition(
- const std::string& short_name) const override;
+ const std::string& short_name,
+ GameContentScope scope) const override;
/// Return a configured card's game definition and values.
mw::E<std::optional<CardGameFields>> getCardFieldValues(
- std::int64_t card_id) const override;
+ std::int64_t card_id,
+ GameContentScope scope) const override;
/// Return all configured series in insertion order.
- mw::E<std::vector<Series>> getSeries() const override;
+ mw::E<std::vector<Series>> getSeries(
+ GameContentScope scope) const override;
/// Return the configured series with an internal ID.
mw::E<std::optional<Series>>
- getSeries(std::int64_t series_id) const override;
+ getSeries(
+ std::int64_t series_id,
+ GameContentScope scope) const override;
/// Return configured series memberships for a card.
mw::E<std::vector<std::int64_t>>
@@ -67,6 +81,12 @@ protected:
mw::E<void> setSchemaVersion(std::int64_t version) override;
private:
+ bool gameVisible(
+ const std::string& short_name,
+ GameContentScope scope) const;
+
+ bool cardVisible(const Card& card, GameContentScope scope) const;
+
std::vector<Card> cards_;
std::vector<Series> series_;
std::unordered_map<std::int64_t, std::vector<std::int64_t>> card_series_;
diff --git a/src/data_sqlite.cpp b/src/data_sqlite.cpp
index 2989a5a..e065ec9 100644
--- a/src/data_sqlite.cpp
+++ b/src/data_sqlite.cpp
@@ -19,6 +19,32 @@
namespace
{
+std::int64_t gameVisibilityInteger(GameVisibility visibility)
+{
+ return static_cast<std::int64_t>(visibility);
+}
+
+mw::E<GameVisibility> gameVisibilityFromInteger(std::int64_t value)
+{
+ switch(value)
+ {
+ case 0:
+ return GameVisibility::PUBLIC;
+ case 1:
+ return GameVisibility::INTERNAL;
+ default:
+ return std::unexpected(mw::runtimeError(
+ "Database contains an invalid game visibility"));
+ }
+}
+
+std::string visibilityPredicate(GameContentScope scope)
+{
+ return scope == GameContentScope::PUBLIC_ONLY
+ ? " AND (card.game_short_name IS NULL OR game.visibility = 0)"
+ : "";
+}
+
mw::E<void> rollbackWithError(
mw::SQLite& connection,
mw::Error error)
@@ -115,6 +141,8 @@ const std::vector<std::string_view> SCHEMA_VERSION_1_STATEMENTS = {
short_name TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
+ visibility INTEGER NOT NULL DEFAULT 0
+ CHECK(visibility IN (0, 1)),
revision INTEGER NOT NULL DEFAULT 1 CHECK(revision >= 1),
CHECK(length(short_name) > 0),
CHECK(short_name NOT GLOB '*[^a-z0-9]*')
@@ -488,10 +516,11 @@ mw::E<std::vector<Card>> cardsFromRows(std::vector<CardRow> rows)
mw::E<std::optional<GameDefinitionSnapshot>> loadGameDefinition(
mw::SQLite& connection,
- const std::string& short_name)
+ const std::string& short_name,
+ std::optional<GameContentScope> scope = std::nullopt)
{
auto game_statement = connection.statementFromStr(
- "SELECT short_name, display_name, description, revision "
+ "SELECT short_name, display_name, description, visibility, revision "
"FROM games WHERE short_name = ?;");
if(!game_statement)
{
@@ -503,7 +532,7 @@ mw::E<std::optional<GameDefinitionSnapshot>> loadGameDefinition(
return std::unexpected(std::move(game_bind.error()));
}
auto game_rows = connection.eval<
- std::string, std::string, std::string, std::int64_t>(
+ std::string, std::string, std::string, std::int64_t, std::int64_t>(
std::move(*game_statement));
if(!game_rows)
{
@@ -514,16 +543,22 @@ mw::E<std::optional<GameDefinitionSnapshot>> loadGameDefinition(
return std::optional<GameDefinitionSnapshot>{};
}
- auto& [stored_short_name, display_name, description, revision] =
- game_rows->front();
- if(revision < 1)
+ auto& [stored_short_name, display_name, description, visibility_value,
+ revision] = game_rows->front();
+ auto visibility = gameVisibilityFromInteger(visibility_value);
+ if(!visibility || revision < 1)
{
return std::unexpected(mw::runtimeError(
- "Database contains an invalid game revision"));
+ "Database contains invalid game metadata"));
+ }
+ if(scope == GameContentScope::PUBLIC_ONLY &&
+ *visibility == GameVisibility::INTERNAL)
+ {
+ return std::optional<GameDefinitionSnapshot>{};
}
GameDefinitionSnapshot snapshot{
{std::move(stored_short_name), std::move(display_name),
- std::move(description), revision},
+ std::move(description), *visibility, revision},
{}};
auto field_statement = connection.statementFromStr(
@@ -725,14 +760,14 @@ public:
{
auto statement = connection_.statementFromStr(
"INSERT INTO games(short_name, display_name, description, "
- "revision) VALUES (?, ?, ?, ?);");
+ "visibility, revision) VALUES (?, ?, ?, ?, ?);");
if(!statement)
{
return std::unexpected(std::move(statement.error()));
}
auto bind = statement->bind(
game.short_name, game.display_name, game.description,
- game.revision);
+ gameVisibilityInteger(game.visibility), game.revision);
if(!bind)
{
return std::unexpected(std::move(bind.error()));
@@ -750,18 +785,20 @@ public:
const std::string& short_name,
const std::string& display_name,
const std::string& description,
+ GameVisibility visibility,
std::int64_t expected_revision) override
{
auto statement = connection_.statementFromStr(
"UPDATE games SET display_name = ?, description = ?, "
- "revision = revision + 1 "
+ "visibility = ?, revision = revision + 1 "
"WHERE short_name = ? AND revision = ?;");
if(!statement)
{
return std::unexpected(std::move(statement.error()));
}
auto bind = statement->bind(
- display_name, description, short_name, expected_revision);
+ display_name, description, gameVisibilityInteger(visibility),
+ short_name, expected_revision);
if(!bind)
{
return std::unexpected(std::move(bind.error()));
@@ -1137,7 +1174,10 @@ public:
mw::E<std::vector<Card>> getPoolCardsForUpdate() override
{
return readCards(
- "WHERE rarity > 0 ORDER BY id");
+ "LEFT JOIN games AS game "
+ "ON game.short_name = card.game_short_name "
+ "WHERE card.rarity > 0 AND (card.game_short_name IS NULL "
+ "OR game.visibility = 0) ORDER BY card.id");
}
/// Insert a newly confirmed account and return its internal ID.
@@ -2106,10 +2146,12 @@ private:
std::optional<std::string>,
std::string,
std::int64_t>(
- "SELECT id, creator_user_id, game_short_name, card_number, "
- "name, short_description, long_description, rarity, "
- "front_extension, foil_extension, thumbnail_extension, "
- "revision FROM cards " + suffix + ";");
+ "SELECT card.id, card.creator_user_id, "
+ "card.game_short_name, card.card_number, card.name, "
+ "card.short_description, card.long_description, "
+ "card.rarity, card.front_extension, card.foil_extension, "
+ "card.thumbnail_extension, card.revision FROM cards AS card " +
+ suffix + ";");
if(!rows)
{
return std::unexpected(std::move(rows.error()));
@@ -2233,14 +2275,16 @@ mw::E<std::int64_t> DataSourceSQLite::getSchemaVersion() const
}
if(*version == 1)
{
- auto users = connection_->evalToValue<int>(
+ auto current_shape = connection_->evalToValue<int>(
"SELECT EXISTS(SELECT 1 FROM sqlite_schema "
- "WHERE type = 'table' AND name = 'users');");
- if(!users)
+ "WHERE type = 'table' AND name = 'users') AND "
+ "EXISTS(SELECT 1 FROM pragma_table_info('games') "
+ "WHERE name = 'visibility');");
+ if(!current_shape)
{
- return std::unexpected(std::move(users.error()));
+ return std::unexpected(std::move(current_shape.error()));
}
- if(*users == 0)
+ if(*current_shape == 0)
{
return std::unexpected(mw::runtimeError(
"Database uses the obsolete prototype schema version 1; "
@@ -2349,9 +2393,22 @@ DataSourceSQLite::beginTransaction()
new DataSourceSQLiteTransaction(*connection_, std::move(lock)));
}
-mw::E<std::vector<Card>> DataSourceSQLite::getCards() const
+mw::E<std::vector<Card>> DataSourceSQLite::getCards(
+ GameContentScope scope) const
{
std::lock_guard lock(mutex_);
+ const std::string query =
+ "SELECT card.id, card.creator_user_id, card.game_short_name, "
+ "card.card_number, card.name, card.short_description, "
+ "card.long_description, card.rarity, card.front_extension, "
+ "card.foil_extension, card.thumbnail_extension, card.revision "
+ "FROM cards AS card" +
+ (scope == GameContentScope::PUBLIC_ONLY
+ ? std::string(" LEFT JOIN games AS game ON game.short_name = "
+ "card.game_short_name")
+ : std::string()) +
+ " WHERE 1 = 1" +
+ visibilityPredicate(scope) + " ORDER BY card.id;";
auto rows = connection_->eval<
std::int64_t,
std::int64_t,
@@ -2365,72 +2422,31 @@ mw::E<std::vector<Card>> DataSourceSQLite::getCards() const
std::optional<std::string>,
std::string,
std::int64_t>(
- "SELECT id, creator_user_id, game_short_name, card_number, name, "
- "short_description, long_description, rarity, "
- "front_extension, foil_extension, thumbnail_extension, "
- "revision FROM cards ORDER BY id;");
+ query);
if(!rows)
{
return std::unexpected(std::move(rows.error()));
}
- std::vector<Card> cards;
- cards.reserve(rows->size());
- for(auto& row : *rows)
- {
- auto& [
- id,
- creator_user_id,
- game_short_name,
- card_number,
- name,
- short_description,
- long_description,
- rarity,
- front_extension,
- foil_extension,
- thumbnail_extension,
- revision] = row;
- const bool invalid_loose_number =
- !game_short_name &&
- card_number > std::numeric_limits<std::uint32_t>::max();
- const bool invalid_game_number =
- game_short_name &&
- (game_short_name->empty() || card_number == 0);
- if(card_number < 0 || invalid_loose_number || invalid_game_number ||
- rarity < 0 || revision < 1)
- {
- return std::unexpected(mw::runtimeError(
- "Database contains an invalid card record"));
- }
-
- cards.push_back({
- id,
- {std::move(game_short_name),
- static_cast<std::uint64_t>(card_number)},
- std::move(name),
- std::move(short_description),
- std::move(long_description),
- rarity,
- std::move(front_extension),
- std::move(foil_extension),
- std::move(thumbnail_extension),
- revision,
- creator_user_id,
- });
- }
- return cards;
+ return cardsFromRows(std::move(*rows));
}
mw::E<std::vector<Card>> DataSourceSQLite::getCardsByCreator(
- std::int64_t creator_user_id) const
+ std::int64_t creator_user_id, GameContentScope scope) const
{
std::lock_guard lock(mutex_);
auto statement = connection_->statementFromStr(
- "SELECT id, creator_user_id, game_short_name, card_number, name, "
- "short_description, long_description, rarity, front_extension, "
- "foil_extension, thumbnail_extension, revision FROM cards "
- "WHERE creator_user_id = ? ORDER BY id;");
+ "SELECT card.id, card.creator_user_id, card.game_short_name, "
+ "card.card_number, card.name, card.short_description, "
+ "card.long_description, card.rarity, card.front_extension, "
+ "card.foil_extension, card.thumbnail_extension, card.revision "
+ "FROM cards AS card" +
+ (scope == GameContentScope::PUBLIC_ONLY
+ ? std::string(" LEFT JOIN games AS game ON game.short_name = "
+ "card.game_short_name")
+ : std::string()) +
+ " WHERE card.creator_user_id = ?" + visibilityPredicate(scope) +
+ " ORDER BY card.id;");
if(!statement)
{
return std::unexpected(std::move(statement.error()));
@@ -2461,10 +2477,14 @@ mw::E<std::vector<Card>> DataSourceSQLite::getPoolCards() const
std::int64_t, std::string, std::optional<std::string>,
std::optional<std::string>, std::int64_t, std::string,
std::optional<std::string>, std::string, std::int64_t>(
- "SELECT id, creator_user_id, game_short_name, card_number, "
- "name, short_description, long_description, rarity, "
- "front_extension, foil_extension, thumbnail_extension, "
- "revision FROM cards WHERE rarity > 0 ORDER BY id;");
+ "SELECT card.id, card.creator_user_id, card.game_short_name, "
+ "card.card_number, card.name, card.short_description, "
+ "card.long_description, card.rarity, card.front_extension, "
+ "card.foil_extension, card.thumbnail_extension, card.revision "
+ "FROM cards AS card LEFT JOIN games AS game "
+ "ON game.short_name = card.game_short_name "
+ "WHERE card.rarity > 0 AND (card.game_short_name IS NULL "
+ "OR game.visibility = 0) ORDER BY card.id;");
if(!rows)
{
return std::unexpected(std::move(rows.error()));
@@ -2473,16 +2493,23 @@ mw::E<std::vector<Card>> DataSourceSQLite::getPoolCards() const
}
mw::E<std::optional<Card>> DataSourceSQLite::getCard(
- const CardIdentity& identity) const
+ const CardIdentity& identity, GameContentScope scope) const
{
std::lock_guard lock(mutex_);
auto statement = connection_->statementFromStr(
- "SELECT id, creator_user_id, game_short_name, card_number, name, "
- "short_description, long_description, rarity, "
- "front_extension, foil_extension, thumbnail_extension, "
- "revision FROM cards WHERE "
- "((? IS NULL AND game_short_name IS NULL) OR "
- "game_short_name = ?) AND card_number = ?;");
+ "SELECT card.id, card.creator_user_id, card.game_short_name, "
+ "card.card_number, card.name, card.short_description, "
+ "card.long_description, card.rarity, card.front_extension, "
+ "card.foil_extension, card.thumbnail_extension, card.revision "
+ "FROM cards AS card" +
+ (scope == GameContentScope::PUBLIC_ONLY
+ ? std::string(" LEFT JOIN games AS game ON game.short_name = "
+ "card.game_short_name")
+ : std::string()) +
+ " WHERE "
+ "((? IS NULL AND card.game_short_name IS NULL) OR "
+ "card.game_short_name = ?) AND card.card_number = ?" +
+ visibilityPredicate(scope) + ";");
if(!statement)
{
return std::unexpected(std::move(statement.error()));
@@ -2547,47 +2574,59 @@ mw::E<std::optional<Card>> DataSourceSQLite::getCard(
return std::optional<Card>(std::move(card));
}
-mw::E<std::vector<Game>> DataSourceSQLite::getGames() const
+mw::E<std::vector<Game>> DataSourceSQLite::getGames(
+ GameContentScope scope) const
{
std::lock_guard lock(mutex_);
+ const std::string query =
+ "SELECT short_name, display_name, description, visibility, revision "
+ "FROM games" +
+ (scope == GameContentScope::PUBLIC_ONLY
+ ? std::string(" WHERE visibility = 0") : std::string()) +
+ " ORDER BY display_name, short_name;";
auto rows = connection_->eval<
- std::string, std::string, std::string, std::int64_t>(
- "SELECT short_name, display_name, description, revision "
- "FROM games ORDER BY display_name, short_name;");
+ std::string, std::string, std::string, std::int64_t, std::int64_t>(
+ query);
if(!rows)
{
return std::unexpected(std::move(rows.error()));
}
std::vector<Game> games;
games.reserve(rows->size());
- for(auto& [short_name, display_name, description, revision] : *rows)
+ for(auto& [short_name, display_name, description, visibility_value,
+ revision] : *rows)
{
- if(revision < 1)
+ auto visibility = gameVisibilityFromInteger(visibility_value);
+ if(!visibility || revision < 1)
{
return std::unexpected(mw::runtimeError(
"Database contains an invalid game revision"));
}
games.push_back({
std::move(short_name), std::move(display_name),
- std::move(description), revision});
+ std::move(description), *visibility, revision});
}
return games;
}
mw::E<std::optional<GameDefinitionSnapshot>>
DataSourceSQLite::getGameDefinition(
- const std::string& short_name) const
+ const std::string& short_name, GameContentScope scope) const
{
std::lock_guard lock(mutex_);
- return loadGameDefinition(*connection_, short_name);
+ return loadGameDefinition(*connection_, short_name, scope);
}
mw::E<std::optional<CardGameFields>>
-DataSourceSQLite::getCardFieldValues(std::int64_t card_id) const
+DataSourceSQLite::getCardFieldValues(
+ std::int64_t card_id, GameContentScope scope) const
{
std::lock_guard lock(mutex_);
auto card_statement = connection_->statementFromStr(
- "SELECT game_short_name FROM cards WHERE id = ?;");
+ "SELECT card.game_short_name FROM cards AS card "
+ "LEFT JOIN games AS game "
+ "ON game.short_name = card.game_short_name "
+ "WHERE card.id = ?" + visibilityPredicate(scope) + ";");
if(!card_statement)
{
return std::unexpected(std::move(card_statement.error()));
@@ -2608,7 +2647,7 @@ DataSourceSQLite::getCardFieldValues(std::int64_t card_id) const
return std::optional<CardGameFields>{};
}
const std::string& short_name = *std::get<0>(card_rows->front());
- auto definition = loadGameDefinition(*connection_, short_name);
+ auto definition = loadGameDefinition(*connection_, short_name, scope);
if(!definition)
{
return std::unexpected(std::move(definition.error()));
@@ -2874,7 +2913,7 @@ DataSourceSQLite::getAuthenticationChallenge(
}
mw::E<std::vector<CollectionEntry>> DataSourceSQLite::getCollection(
- std::int64_t user_id) const
+ std::int64_t user_id, GameContentScope scope) const
{
std::lock_guard lock(mutex_);
auto statement = connection_->statementFromStr(
@@ -2882,8 +2921,14 @@ mw::E<std::vector<CollectionEntry>> DataSourceSQLite::getCollection(
"c.name, c.short_description, c.long_description, c.rarity, "
"c.front_extension, c.foil_extension, c.thumbnail_extension, "
"c.revision, h.quantity FROM card_holdings h "
- "JOIN cards c ON c.id = h.card_id WHERE h.user_id = ? "
- "ORDER BY c.id;");
+ "JOIN cards c ON c.id = h.card_id "
+ "LEFT JOIN games AS game ON game.short_name = c.game_short_name "
+ "WHERE h.user_id = ?" +
+ (scope == GameContentScope::PUBLIC_ONLY
+ ? std::string(
+ " AND (c.game_short_name IS NULL OR game.visibility = 0)")
+ : std::string()) +
+ " ORDER BY c.id;");
if(!statement)
{
return std::unexpected(std::move(statement.error()));
@@ -3156,16 +3201,23 @@ mw::E<void> DataSourceSQLite::cleanupAuthentication(std::int64_t now)
return connection_->execute(std::move(*quota));
}
-mw::E<std::vector<Series>> DataSourceSQLite::getSeries() const
+mw::E<std::vector<Series>> DataSourceSQLite::getSeries(
+ GameContentScope scope) const
{
std::lock_guard lock(mutex_);
+ const std::string query =
+ "SELECT series.id, series.game_short_name, series.name, "
+ "series.description FROM series JOIN games AS game "
+ "ON game.short_name = series.game_short_name" +
+ (scope == GameContentScope::PUBLIC_ONLY
+ ? std::string(" WHERE game.visibility = 0") : std::string()) +
+ " ORDER BY series.game_short_name, series.name, series.id;";
auto rows = connection_->eval<
std::int64_t,
std::string,
std::string,
std::string>(
- "SELECT id, game_short_name, name, description "
- "FROM series ORDER BY game_short_name, name, id;");
+ query);
if(!rows)
{
return std::unexpected(std::move(rows.error()));
@@ -3185,12 +3237,17 @@ mw::E<std::vector<Series>> DataSourceSQLite::getSeries() const
}
mw::E<std::optional<Series>> DataSourceSQLite::getSeries(
- std::int64_t series_id) const
+ std::int64_t series_id, GameContentScope scope) const
{
std::lock_guard lock(mutex_);
auto statement = connection_->statementFromStr(
- "SELECT id, game_short_name, name, description "
- "FROM series WHERE id = ?;");
+ "SELECT series.id, series.game_short_name, series.name, "
+ "series.description FROM series JOIN games AS game "
+ "ON game.short_name = series.game_short_name "
+ "WHERE series.id = ?" +
+ (scope == GameContentScope::PUBLIC_ONLY
+ ? std::string(" AND game.visibility = 0") : std::string()) +
+ ";");
if(!statement)
{
return std::unexpected(std::move(statement.error()));
diff --git a/src/data_sqlite.h b/src/data_sqlite.h
index ba56641..1e79fa1 100644
--- a/src/data_sqlite.h
+++ b/src/data_sqlite.h
@@ -39,29 +39,38 @@ public:
beginTransaction() override;
/// Return all cards for the unpaginated index.
- mw::E<std::vector<Card>> getCards() const override;
+ mw::E<std::vector<Card>> getCards(
+ GameContentScope scope) const override;
/// Return all cards authored by one user.
mw::E<std::vector<Card>> getCardsByCreator(
- std::int64_t creator_user_id) const override;
+ std::int64_t creator_user_id,
+ GameContentScope scope) const override;
/// Return every positive-rarity card in card-ID order.
mw::E<std::vector<Card>> getPoolCards() const override;
/// Return a card by its parsed identity.
mw::E<std::optional<Card>>
- getCard(const CardIdentity& identity) const override;
+ getCard(
+ const CardIdentity& identity,
+ GameContentScope scope) const override;
/// Return all database-defined games ordered by display name.
- mw::E<std::vector<Game>> getGames() const override;
+ mw::E<std::vector<Game>> getGames(
+ GameContentScope scope) const override;
/// Return a complete, consistently read game definition.
mw::E<std::optional<GameDefinitionSnapshot>>
- getGameDefinition(const std::string& short_name) const override;
+ getGameDefinition(
+ const std::string& short_name,
+ GameContentScope scope) const override;
/// Return a card's definition and ordered custom values together.
mw::E<std::optional<CardGameFields>>
- getCardFieldValues(std::int64_t card_id) const override;
+ getCardFieldValues(
+ std::int64_t card_id,
+ GameContentScope scope) const override;
/// Return a user by internal identity.
mw::E<std::optional<User>> getUser(
@@ -85,7 +94,8 @@ public:
/// Return a user's distinct collection entries.
mw::E<std::vector<CollectionEntry>> getCollection(
- std::int64_t user_id) const override;
+ std::int64_t user_id,
+ GameContentScope scope) const override;
/// Return whether a user currently owns a card.
mw::E<bool> userOwnsCard(
@@ -102,11 +112,14 @@ public:
mw::E<void> cleanupAuthentication(std::int64_t now) override;
/// Return all series, ordered by game and name.
- mw::E<std::vector<Series>> getSeries() const override;
+ mw::E<std::vector<Series>> getSeries(
+ GameContentScope scope) const override;
/// Return one series by internal ID.
mw::E<std::optional<Series>>
- getSeries(std::int64_t series_id) const override;
+ getSeries(
+ std::int64_t series_id,
+ GameContentScope scope) const override;
/// Return the series memberships for one card.
mw::E<std::vector<std::int64_t>>
diff --git a/src/game.h b/src/game.h
index 4ef45d6..3b8ad35 100644
--- a/src/game.h
+++ b/src/game.h
@@ -14,6 +14,20 @@ enum class GameFieldType
CHOICE
};
+/// Application visibility assigned to a database-defined game.
+enum class GameVisibility
+{
+ PUBLIC = 0,
+ INTERNAL = 1
+};
+
+/// Visibility boundary for data derived from database-defined games.
+enum class GameContentScope
+{
+ PUBLIC_ONLY,
+ INCLUDE_INTERNAL
+};
+
/// One database-defined game.
struct Game
{
@@ -26,6 +40,9 @@ struct Game
/// Markdown game description.
std::string description;
+ /// Application visibility for this game and its derived content.
+ GameVisibility visibility;
+
/// Aggregate optimistic-concurrency revision.
std::int64_t revision;
};
diff --git a/src/game_service.cpp b/src/game_service.cpp
index ef9fa44..22ccbf9 100644
--- a/src/game_service.cpp
+++ b/src/game_service.cpp
@@ -186,7 +186,8 @@ mw::E<std::string> GameService::createGame(
std::int64_t actor_user_id,
std::string short_name,
std::string display_name,
- std::string description)
+ std::string description,
+ GameVisibility visibility)
{
if(!validShortName(short_name))
{
@@ -222,7 +223,8 @@ mw::E<std::string> GameService::createGame(
409, "A game with this short name already exists"));
}
auto inserted = (*transaction)->insertGame(
- {short_name, std::move(display_name), std::move(description), 1});
+ {short_name, std::move(display_name), std::move(description),
+ visibility, 1});
if(!inserted)
{
return std::unexpected(std::move(inserted.error()));
@@ -240,7 +242,8 @@ mw::E<void> GameService::updateGame(
const std::string& short_name,
std::int64_t expected_revision,
std::string display_name,
- std::string description)
+ std::string description,
+ GameVisibility visibility)
{
auto valid = validateDisplayText(
display_name, description, markdown_renderer_);
@@ -266,7 +269,8 @@ mw::E<void> GameService::updateGame(
return std::unexpected(std::move(definition.error()));
}
auto updated = (*transaction)->updateGame(
- short_name, display_name, description, expected_revision);
+ short_name, display_name, description, visibility,
+ expected_revision);
if(!updated)
{
return std::unexpected(std::move(updated.error()));
diff --git a/src/game_service.h b/src/game_service.h
index 955afe3..11e9e7d 100644
--- a/src/game_service.h
+++ b/src/game_service.h
@@ -32,7 +32,8 @@ public:
std::int64_t actor_user_id,
std::string short_name,
std::string display_name,
- std::string description);
+ std::string description,
+ GameVisibility visibility);
/// Update a game's mutable presentation metadata.
mw::E<void> updateGame(
@@ -40,7 +41,8 @@ public:
const std::string& short_name,
std::int64_t expected_revision,
std::string display_name,
- std::string description);
+ std::string description,
+ GameVisibility visibility);
/// Delete a game only when it has never issued a card number.
mw::E<void> removeGame(
diff --git a/src/series_service.cpp b/src/series_service.cpp
index a85b201..db3fda5 100644
--- a/src/series_service.cpp
+++ b/src/series_service.cpp
@@ -14,7 +14,8 @@ mw::E<void> rejectDuplicate(
DataSourceInterface& data_source,
const Series& candidate)
{
- auto series = data_source.getSeries();
+ auto series = data_source.getSeries(
+ GameContentScope::INCLUDE_INTERNAL);
if(!series)
{
return std::unexpected(std::move(series.error()));
@@ -130,7 +131,8 @@ mw::E<void> SeriesService::update(
std::string name,
std::string description)
{
- auto current = data_source_.getSeries(series_id);
+ auto current = data_source_.getSeries(
+ series_id, GameContentScope::INCLUDE_INTERNAL);
if(!current)
{
return std::unexpected(std::move(current.error()));
@@ -183,7 +185,8 @@ mw::E<void> SeriesService::remove(
std::int64_t actor_user_id,
std::int64_t series_id)
{
- auto current = data_source_.getSeries(series_id);
+ auto current = data_source_.getSeries(
+ series_id, GameContentScope::INCLUDE_INTERNAL);
if(!current)
{
return std::unexpected(std::move(current.error()));
diff --git a/static/css/styles.css b/static/css/styles.css
index aabdc6d..d7cf7c4 100644
--- a/static/css/styles.css
+++ b/static/css/styles.css
@@ -1931,6 +1931,94 @@ h1 {
text-transform: uppercase;
}
+.internal-badge {
+ display: inline-flex;
+ align-items: center;
+ min-height: 1.75rem;
+ padding: 0.25rem 0.7rem;
+ border: 1px solid rgb(219 39 119 / 18%);
+ border-radius: 999px;
+ background: rgb(245 158 11 / 15%);
+ box-shadow:
+ 4px 4px 9px rgb(160 150 180 / 16%),
+ -3px -3px 7px rgb(255 255 255 / 82%),
+ inset 2px 2px 4px rgb(255 255 255 / 40%),
+ inset -2px -2px 4px rgb(219 39 119 / 8%);
+ color: #9d174d;
+ font-size: 0.7rem;
+ font-weight: 900;
+ letter-spacing: 0.06em;
+ line-height: 1;
+ text-transform: uppercase;
+}
+
+.visibility-options {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 1rem;
+ margin: 1.5rem 0;
+ padding: 0;
+ border: 0;
+}
+
+.visibility-options legend {
+ grid-column: 1 / -1;
+ margin-bottom: 0.2rem;
+ font-family: Nunito, ui-rounded, sans-serif;
+ font-size: 0.9rem;
+ font-weight: 900;
+}
+
+.visibility-option {
+ display: flex;
+ align-items: center;
+ min-height: 4.5rem;
+ gap: 0.8rem;
+ padding: 0.9rem 1rem;
+ border-radius: 1.25rem;
+ background: #efebf5;
+ box-shadow: var(--clay-pressed-shadow);
+ cursor: pointer;
+}
+
+.visibility-option:has(input:checked) {
+ background: rgb(255 255 255 / 84%);
+ outline: 0.2rem solid rgb(124 58 237 / 18%);
+}
+
+.visibility-option:has(input:focus-visible) {
+ outline: 0.25rem solid rgb(124 58 237 / 30%);
+}
+
+.visibility-option input {
+ width: 1.25rem;
+ height: 1.25rem;
+ margin: 0;
+ accent-color: var(--violet);
+}
+
+.visibility-option span {
+ display: grid;
+ gap: 0.2rem;
+}
+
+.visibility-option strong {
+ font-family: Nunito, ui-rounded, sans-serif;
+ font-weight: 900;
+}
+
+.visibility-option small {
+ color: var(--muted);
+ font-weight: 600;
+ line-height: 1.35;
+}
+
+@media(max-width: 42rem) {
+ .visibility-options {
+ grid-template-columns: 1fr;
+ }
+}
+
.game-editor {
width: 100%;
}
diff --git a/templates/card_index.html b/templates/card_index.html
index c92a17b..6ef97dc 100644
--- a/templates/card_index.html
+++ b/templates/card_index.html
@@ -45,6 +45,9 @@
loading="lazy">
</a>
<div class="card-details">
+ {% if card.internal %}
+ <span class="internal-badge">Internal</span>
+ {% endif %}
<a class="card-id" href="{{ card.url }}">
{{ card.display_id }}
</a>
diff --git a/templates/game_admin.html b/templates/game_admin.html
index 2dee589..5b33d76 100644
--- a/templates/game_admin.html
+++ b/templates/game_admin.html
@@ -38,6 +38,9 @@
<div>
<p class="eyebrow">{{ game.short_name }}</p>
<h2>{{ game.display_name }}</h2>
+ {% if game.internal %}
+ <span class="internal-badge">Internal</span>
+ {% endif %}
</div>
</header>
{% if game.has_description %}
diff --git a/templates/game_form.html b/templates/game_form.html
index 666a101..ac63bcd 100644
--- a/templates/game_form.html
+++ b/templates/game_form.html
@@ -7,6 +7,9 @@
<div>
<p class="eyebrow">Game administration</p>
<h1 id="GameFormHeading">{{ heading }}</h1>
+ {% if visibility_internal %}
+ <span class="internal-badge">Internal</span>
+ {% endif %}
</div>
{% if mode == "edit" %}
<div class="management-actions">
@@ -79,6 +82,28 @@
{% endif %}
</label>
<p class="form-hint">Descriptions support Markdown.</p>
+ <fieldset class="visibility-options">
+ <legend>Visibility</legend>
+ <label class="visibility-option">
+ <input name="visibility" type="radio" value="PUBLIC"
+ {% if visibility_public %}checked{% endif %} required>
+ <span>
+ <strong>Public</strong>
+ <small>Available through normal card workflows.</small>
+ </span>
+ </label>
+ <label class="visibility-option">
+ <input name="visibility" type="radio" value="INTERNAL"
+ {% if visibility_internal %}checked{% endif %}>
+ <span>
+ <strong>Internal</strong>
+ <small>
+ Only the administrator can see this game and its
+ cards.
+ </small>
+ </span>
+ </label>
+ </fieldset>
<button class="form-submit" type="submit">{{ submit_label }}</button>
</form>
diff --git a/templates/series_form.html b/templates/series_form.html
index 20f6c53..c1917ab 100644
--- a/templates/series_form.html
+++ b/templates/series_form.html
@@ -16,6 +16,7 @@
{% for item in games %}
<option value="{{ item.short_name }}">
{{ item.display_name }}
+ {% if item.internal %} (Internal){% endif %}
</option>
{% endfor %}
</select>
diff --git a/templates/series_index.html b/templates/series_index.html
index 7c57c0e..c0bcc28 100644
--- a/templates/series_index.html
+++ b/templates/series_index.html
@@ -29,6 +29,9 @@
<li>
<div>
<p class="eyebrow">{{ item.game }}</p>
+ {% if item.internal %}
+ <span class="internal-badge">Internal</span>
+ {% endif %}
<h2>{{ item.name }}</h2>
{% if item.has_description %}
<div class="description-copy">{{ item.description }}</div>
diff --git a/tests/app_integration_test.cpp b/tests/app_integration_test.cpp
index f546e42..c0f80e3 100644
--- a/tests/app_integration_test.cpp
+++ b/tests/app_integration_test.cpp
@@ -139,7 +139,8 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
auto transaction = (*data_source)->beginTransaction();
ASSERT_TRUE(transaction);
ASSERT_TRUE((*transaction)->insertGame(
- {"test", "Test Game", "Dynamic extension test game.", 1}));
+ {"test", "Test Game", "Dynamic extension test game.",
+ GameVisibility::PUBLIC, 1}));
auto hp_id = (*transaction)->insertGameField(
{0, "test", "hp", "HP", GameFieldType::INTEGER, 0, {}}, {});
auto attack_id = (*transaction)->insertGameField(
@@ -357,6 +358,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
{"short_name", "dynamic"},
{"display_name", "Dynamic Game"},
{"description", "A **dynamic** game."},
+ {"visibility", "PUBLIC"},
};
const httplib::Params missing_csrf_game_fields = {
{"short_name", "forbidden"},
@@ -375,6 +377,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
{"short_name", "Invalid-Game"},
{"display_name", "Invalid Game"},
{"description", ""},
+ {"visibility", "PUBLIC"},
};
auto invalid_game = client.Post(
"/collection/admin/games",
@@ -390,6 +393,31 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
EXPECT_NE(invalid_game->body.find("Invalid-Game"),
std::string::npos);
+ const httplib::Params invalid_visibility_fields = {
+ {"csrf_token", csrf_token},
+ {"short_name", "private"},
+ {"display_name", "Private"},
+ {"description", ""},
+ {"visibility", "PRIVATE"},
+ };
+ auto invalid_visibility = client.Post(
+ "/collection/admin/games",
+ session_headers,
+ invalid_visibility_fields);
+ ASSERT_NE(invalid_visibility, nullptr);
+ EXPECT_EQ(invalid_visibility->status, 400);
+
+ httplib::Params duplicate_visibility_fields = invalid_visibility_fields;
+ duplicate_visibility_fields.erase("visibility");
+ duplicate_visibility_fields.emplace("visibility", "PUBLIC");
+ duplicate_visibility_fields.emplace("visibility", "INTERNAL");
+ auto duplicate_visibility = client.Post(
+ "/collection/admin/games",
+ session_headers,
+ duplicate_visibility_fields);
+ ASSERT_NE(duplicate_visibility, nullptr);
+ EXPECT_EQ(duplicate_visibility->status, 400);
+
auto dynamic_game = client.Post(
"/collection/admin/games",
session_headers,
@@ -480,6 +508,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
{"game_revision", "5"},
{"display_name", "Dynamic Renamed"},
{"description", "A **rendered** description."},
+ {"visibility", "PUBLIC"},
};
auto game_updated = client.Post(
"/collection/admin/games/dynamic",
@@ -506,6 +535,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
{"game_revision", "4"},
{"display_name", "Stale name"},
{"description", ""},
+ {"visibility", "PUBLIC"},
};
auto stale_game = client.Post(
"/collection/admin/games/dynamic",
@@ -621,6 +651,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
{"game_revision", "6"},
{"display_name", "Dynamic Final"},
{"description", "Final description."},
+ {"visibility", "PUBLIC"},
};
auto second_game_update = client.Post(
"/collection/admin/games/dynamic",
@@ -725,6 +756,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
{"short_name", "empty"},
{"display_name", "Empty Game"},
{"description", ""},
+ {"visibility", "PUBLIC"},
};
auto empty_game = client.Post(
"/collection/admin/games", session_headers, empty_game_fields);
@@ -758,6 +790,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
{"short_name", "gh"},
{"display_name", "Going Home"},
{"description", ""},
+ {"visibility", "PUBLIC"},
};
auto going_home = client.Post(
"/collection/admin/games", session_headers, going_home_fields);
@@ -807,15 +840,6 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
EXPECT_NE(edited_page->body.find("Going Home"), std::string::npos);
EXPECT_NE(edited_page->body.find("Edited upload"), std::string::npos);
- auto deleted = client.Post(
- created_path + "/delete",
- session_headers,
- csrf_fields);
- ASSERT_NE(deleted, nullptr);
- EXPECT_EQ(deleted->status, 303) << deleted->body;
- EXPECT_FALSE(std::filesystem::exists(
- config.card_storage_root / "published" / public_id));
-
httplib::Params create_series_fields = {
{"csrf_token", csrf_token},
{"game", "gh"},
@@ -926,6 +950,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
{"short_name", "playergame"},
{"display_name", "Player Game"},
{"description", ""},
+ {"visibility", "PUBLIC"},
};
auto player_game_create = client.Post(
"/collection/admin/games",
@@ -934,6 +959,37 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
ASSERT_NE(player_game_create, nullptr);
EXPECT_EQ(player_game_create->status, 403);
+ const httplib::Params internal_game_fields = {
+ {"csrf_token", csrf_token},
+ {"game_revision", "1"},
+ {"display_name", "Going Home"},
+ {"description", ""},
+ {"visibility", "INTERNAL"},
+ };
+ auto internal_game = client.Post(
+ "/collection/admin/games/gh",
+ session_headers,
+ internal_game_fields);
+ ASSERT_NE(internal_game, nullptr);
+ ASSERT_EQ(internal_game->status, 303) << internal_game->body;
+ auto concealed_card = client.Get(created_path, player_headers);
+ ASSERT_NE(concealed_card, nullptr);
+ EXPECT_EQ(concealed_card->status, 404);
+ EXPECT_EQ(
+ concealed_card->get_header_value("Cache-Control"),
+ "private, no-store");
+ auto administrator_cards = client.Get(
+ "/collection/admin/cards", session_headers);
+ ASSERT_NE(administrator_cards, nullptr);
+ ASSERT_EQ(administrator_cards->status, 200);
+ EXPECT_NE(administrator_cards->body.find("Internal"),
+ std::string::npos);
+ auto administrator_card = client.Get(created_path, session_headers);
+ ASSERT_NE(administrator_card, nullptr);
+ EXPECT_EQ(administrator_card->status, 200);
+ EXPECT_NE(administrator_card->body.find(
+ "Not currently in the pull pool"), std::string::npos);
+
auto promoted = client.Post(
"/collection/admin/users/2/promote",
session_headers,
@@ -950,6 +1006,19 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
ASSERT_NE(creator_new, nullptr);
ASSERT_EQ(creator_new->status, 200) << creator_new->body;
EXPECT_EQ(creator_new->body.find("name=\"rarity\""), std::string::npos);
+ EXPECT_EQ(creator_new->body.find("Going Home"), std::string::npos);
+ const httplib::UploadFormDataItems forged_internal_fields = {
+ {"csrf_token", player_csrf, "", ""},
+ {"game", "gh", "", ""},
+ {"game_revision", "2", "", ""},
+ {"name", "Hidden creator card", "", ""},
+ {"source_mode", "files", "", ""},
+ {"front", upload_bytes, "card.png", "image/png"},
+ };
+ auto forged_internal = client.Post(
+ "/collection/cards", player_headers, forged_internal_fields);
+ ASSERT_NE(forged_internal, nullptr);
+ EXPECT_EQ(forged_internal->status, 422) << forged_internal->body;
const httplib::UploadFormDataItems creator_card_fields = {
{"csrf_token", player_csrf, "", ""},
{"game", "", "", ""},
@@ -994,6 +1063,14 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
ASSERT_NE(owned_card, nullptr);
EXPECT_EQ(owned_card->status, 200) << owned_card->body;
EXPECT_NE(owned_card->body.find("100%"), std::string::npos);
+ auto deleted = client.Post(
+ created_path + "/delete",
+ session_headers,
+ csrf_fields);
+ ASSERT_NE(deleted, nullptr);
+ EXPECT_EQ(deleted->status, 303) << deleted->body;
+ EXPECT_FALSE(std::filesystem::exists(
+ config.card_storage_root / "published" / public_id));
const httplib::Params player_logout_fields = {
{"csrf_token", player_csrf}};
auto logged_out = client.Post(
diff --git a/tests/app_test.cpp b/tests/app_test.cpp
index c18328f..ddd7d20 100644
--- a/tests/app_test.cpp
+++ b/tests/app_test.cpp
@@ -92,7 +92,7 @@ public:
/// Return configured cards as the focused test pool.
mw::E<std::vector<Card>> getPoolCards() const override
{
- return getCards();
+ return getCards(GameContentScope::INCLUDE_INTERNAL);
}
};
@@ -172,7 +172,11 @@ TEST(AppTest, RendersCardIndex)
std::vector<Card>{
makeCard(10, 10, "Tenth card"),
makeCard(2, 2, "<script>Second card</script>"),
- });
+ },
+ std::vector<Series>{},
+ std::unordered_map<std::int64_t, std::vector<std::int64_t>>{},
+ std::vector<GameDefinitionSnapshot>{
+ {{"pkm", "Test Game", "", GameVisibility::PUBLIC, 1}, {}}});
App app(
makeConfig("https://example.test/collection/"),
std::move(data_source),
@@ -277,7 +281,7 @@ TEST(AppTest, RendersCardView)
{2, {7}},
},
std::vector<GameDefinitionSnapshot>{
- {{"test", "Test Game", "", 1}, {}}},
+ {{"test", "Test Game", "", GameVisibility::PUBLIC, 1}, {}}},
std::unordered_map<std::int64_t, std::vector<GameFieldValue>>{});
App app(
makeConfig("https://example.test/collection/"),
diff --git a/tests/card_service_test.cpp b/tests/card_service_test.cpp
index c7ae6f7..0150822 100644
--- a/tests/card_service_test.cpp
+++ b/tests/card_service_test.cpp
@@ -144,13 +144,15 @@ TEST(CardServiceTest, CreatesLooseCard)
});
ASSERT_TRUE(public_id) << public_id.error().msg();
- auto cards = (*data_source)->getCards();
+ auto cards = (*data_source)->getCards(
+ GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(cards);
ASSERT_EQ(cards->size(), 1);
EXPECT_EQ(cards->front().name, "Moon card");
EXPECT_EQ(cards->front().front_extension, "avif");
EXPECT_EQ(cards->front().thumbnail_extension, "avif");
- auto stored_card = (*data_source)->getCard(cards->front().identity);
+ auto stored_card = (*data_source)->getCard(
+ cards->front().identity, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(stored_card);
ASSERT_TRUE(*stored_card);
EXPECT_EQ((**stored_card).id, cards->front().id);
@@ -199,7 +201,8 @@ TEST(CardServiceTest, RejectsInvalidImage)
});
EXPECT_FALSE(public_id);
- auto cards = (*data_source)->getCards();
+ auto cards = (*data_source)->getCards(
+ GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(cards);
EXPECT_TRUE(cards->empty());
EXPECT_FALSE(std::filesystem::exists(temporary.path() / "published"));
@@ -241,7 +244,8 @@ TEST(CardServiceTest, CreatesOpaqueJpegFoilCard)
});
ASSERT_TRUE(public_id) << public_id.error().msg();
- auto cards = (*data_source)->getCards();
+ auto cards = (*data_source)->getCards(
+ GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(cards);
ASSERT_EQ(cards->size(), 1);
EXPECT_EQ(cards->front().foil_extension, "jpg");
@@ -262,7 +266,7 @@ TEST(CardServiceTest, CreatesDynamicGameCard)
auto series_transaction = (*data_source)->beginTransaction();
ASSERT_TRUE(series_transaction);
ASSERT_TRUE((*series_transaction)->insertGame(
- {"test", "Test Game", "", 1}));
+ {"test", "Test Game", "", GameVisibility::PUBLIC, 1}));
auto hp_id = (*series_transaction)->insertGameField(
{0, "test", "hp", "HP", GameFieldType::INTEGER, 0, {}}, {});
auto attack_id = (*series_transaction)->insertGameField(
@@ -303,13 +307,15 @@ TEST(CardServiceTest, CreatesDynamicGameCard)
ASSERT_TRUE(public_id) << public_id.error().msg();
EXPECT_EQ(*public_id, "test-1");
- auto stored = (*data_source)->getCard({"test", 1});
+ auto stored = (*data_source)->getCard(
+ {"test", 1}, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(stored);
ASSERT_TRUE(*stored);
auto memberships = (*data_source)->getCardSeries((**stored).id);
ASSERT_TRUE(memberships);
EXPECT_EQ(*memberships, std::vector<std::int64_t>{*series_id});
- auto values = (*data_source)->getCardFieldValues((**stored).id);
+ auto values = (*data_source)->getCardFieldValues(
+ (**stored).id, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(values);
ASSERT_TRUE(*values);
EXPECT_EQ(std::get<std::int64_t>((**values).values.front().value), 50);
@@ -336,11 +342,13 @@ TEST(CardServiceTest, CreatesDynamicGameCard)
{{"hp", "80"}, {"attack", "40"}},
{});
ASSERT_TRUE(updated) << updated.error().msg();
- stored = (*data_source)->getCard({"test", 1});
+ stored = (*data_source)->getCard(
+ {"test", 1}, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(stored);
ASSERT_TRUE(*stored);
EXPECT_EQ((**stored).name, "Updated game card");
- values = (*data_source)->getCardFieldValues((**stored).id);
+ values = (*data_source)->getCardFieldValues(
+ (**stored).id, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(values);
ASSERT_TRUE(*values);
EXPECT_EQ(std::get<std::int64_t>((**values).values.front().value), 80);
@@ -360,7 +368,8 @@ TEST(CardServiceTest, RollsBackDynamicMetadataFailures)
auto setup = (*data_source)->beginTransaction();
ASSERT_TRUE(setup);
- ASSERT_TRUE((*setup)->insertGame({"test", "Test Game", "", 1}));
+ ASSERT_TRUE((*setup)->insertGame(
+ {"test", "Test Game", "", GameVisibility::PUBLIC, 1}));
auto hp_id = (*setup)->insertGameField(
{0, "test", "hp", "HP", GameFieldType::INTEGER, 0, {}}, {});
ASSERT_TRUE(hp_id);
@@ -403,7 +412,8 @@ TEST(CardServiceTest, RollsBackDynamicMetadataFailures)
{{"hp", "50"}},
{*series_id});
ASSERT_FALSE(failed_create);
- auto cards = (*data_source)->getCards();
+ auto cards = (*data_source)->getCards(
+ GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(cards);
EXPECT_TRUE(cards->empty());
EXPECT_FALSE(std::filesystem::exists(
@@ -430,7 +440,8 @@ TEST(CardServiceTest, RollsBackDynamicMetadataFailures)
{*series_id});
ASSERT_TRUE(public_id) << public_id.error().msg();
EXPECT_EQ(*public_id, "test-1");
- auto stored = (*data_source)->getCard({"test", 1});
+ auto stored = (*data_source)->getCard(
+ {"test", 1}, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(stored);
ASSERT_TRUE(*stored);
const std::filesystem::path published =
@@ -467,12 +478,14 @@ TEST(CardServiceTest, RollsBackDynamicMetadataFailures)
{{"hp", "80"}},
{});
ASSERT_FALSE(failed_update);
- stored = (*data_source)->getCard({"test", 1});
+ stored = (*data_source)->getCard(
+ {"test", 1}, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(stored);
ASSERT_TRUE(*stored);
EXPECT_EQ((**stored).name, "Committed card");
EXPECT_EQ((**stored).revision, 1);
- auto values = (*data_source)->getCardFieldValues((**stored).id);
+ auto values = (*data_source)->getCardFieldValues(
+ (**stored).id, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(values);
ASSERT_TRUE(*values);
ASSERT_EQ((**values).values.size(), 1);
@@ -517,7 +530,8 @@ TEST(CardServiceTest, UpdatesLooseCard)
std::nullopt,
});
ASSERT_TRUE(public_id);
- auto cards = (*data_source)->getCards();
+ auto cards = (*data_source)->getCards(
+ GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(cards);
ASSERT_EQ(cards->size(), 1);
const Card original_card = cards->front();
@@ -541,7 +555,8 @@ TEST(CardServiceTest, UpdatesLooseCard)
});
ASSERT_TRUE(metadata_update) << metadata_update.error().msg();
EXPECT_EQ(*metadata_update, *public_id);
- cards = (*data_source)->getCards();
+ cards = (*data_source)->getCards(
+ GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(cards);
ASSERT_EQ(cards->size(), 1);
EXPECT_EQ(cards->front().name, "Edited metadata");
@@ -595,7 +610,8 @@ TEST(CardServiceTest, UpdatesLooseCard)
std::nullopt,
});
ASSERT_TRUE(artwork_update) << artwork_update.error().msg();
- cards = (*data_source)->getCards();
+ cards = (*data_source)->getCards(
+ GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(cards);
ASSERT_EQ(cards->size(), 1);
EXPECT_EQ(cards->front().front_extension, "jpg");
@@ -609,7 +625,8 @@ TEST(CardServiceTest, UpdatesLooseCard)
auto deleted = service.deleteCard(1, cards->front());
ASSERT_TRUE(deleted) << deleted.error().msg();
- cards = (*data_source)->getCards();
+ cards = (*data_source)->getCards(
+ GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(cards);
EXPECT_TRUE(cards->empty());
EXPECT_FALSE(std::filesystem::exists(published));
@@ -823,7 +840,8 @@ TEST(CardServiceTest, EnforcesCreatorOwnershipAndRarity)
false};
auto public_id = service.createLooseCard(*creator_id, std::move(input));
ASSERT_TRUE(public_id) << public_id.error().msg();
- auto cards = (*data_source)->getCards();
+ auto cards = (*data_source)->getCards(
+ GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(cards);
ASSERT_EQ(cards->size(), 1);
EXPECT_EQ(cards->front().creator_user_id, *creator_id);
@@ -855,3 +873,107 @@ TEST(CardServiceTest, EnforcesCreatorOwnershipAndRarity)
ASSERT_NE(http_error, nullptr);
EXPECT_EQ(http_error->code, 403);
}
+
+TEST(CardServiceTest, RejectsCreatorMutationsForInternalGames)
+{
+ initializeImageMagick();
+ TemporaryCardRoot temporary;
+ const std::filesystem::path database = temporary.path() / "cards.sqlite3";
+ auto data_source = prepareTestDataSource(database);
+ ASSERT_TRUE(data_source);
+ auto transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ User creator = {
+ 0,
+ "creator@example.com",
+ "creator@example.com",
+ std::nullopt,
+ UserRole::CREATOR,
+ 1,
+ 0,
+ 0};
+ auto creator_id = (*transaction)->insertUser(creator);
+ ASSERT_TRUE(creator_id);
+ ASSERT_TRUE((*transaction)->updateUsername(
+ *creator_id, "Creator", "creator"));
+ ASSERT_TRUE((*transaction)->insertGame(
+ {"internal", "Internal", "", GameVisibility::INTERNAL, 1}));
+ ASSERT_TRUE((*transaction)->insertGame(
+ {"public", "Public", "", GameVisibility::PUBLIC, 1}));
+ ASSERT_TRUE((*transaction)->commit());
+
+ NonSecretRandom random(7);
+ CardService service(
+ **data_source,
+ random,
+ ImageProcessor(75, 256),
+ AssetStore(temporary.path()));
+ const std::filesystem::path rejected_staging =
+ temporary.path() / ".staging/rejected";
+ std::filesystem::create_directories(rejected_staging);
+ const std::filesystem::path rejected_front =
+ rejected_staging / "upload_front";
+ writePng(rejected_front);
+ auto rejected = service.createGameCard(
+ *creator_id,
+ {"Rejected", std::nullopt, std::nullopt, 0, rejected_staging,
+ rejected_front, std::nullopt, std::nullopt, false},
+ "internal",
+ 1,
+ {},
+ {});
+ ASSERT_FALSE(rejected);
+ const auto* create_error = rejected.error().as<mw::HTTPError>();
+ ASSERT_NE(create_error, nullptr);
+ EXPECT_EQ(create_error->code, 422);
+ EXPECT_EQ(create_error->msg, "Unknown game");
+
+ const std::filesystem::path public_staging =
+ temporary.path() / ".staging/public";
+ std::filesystem::create_directories(public_staging);
+ const std::filesystem::path public_front =
+ public_staging / "upload_front";
+ writePng(public_front);
+ auto created = service.createGameCard(
+ *creator_id,
+ {"Creator card", std::nullopt, std::nullopt, 0, public_staging,
+ public_front, std::nullopt, std::nullopt, false},
+ "public",
+ 1,
+ {},
+ {});
+ ASSERT_TRUE(created) << created.error().msg();
+ auto stored = (*data_source)->getCard(
+ {"public", 1}, GameContentScope::INCLUDE_INTERNAL);
+ ASSERT_TRUE(stored);
+ ASSERT_TRUE(*stored);
+
+ transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ auto hidden = (*transaction)->updateGame(
+ "public", "Public", "", GameVisibility::INTERNAL, 1);
+ ASSERT_TRUE(hidden);
+ ASSERT_TRUE(*hidden);
+ ASSERT_TRUE((*transaction)->commit());
+
+ const std::filesystem::path edit_staging =
+ temporary.path() / ".staging/edit";
+ std::filesystem::create_directories(edit_staging);
+ auto updated = service.updateGameCard(
+ *creator_id,
+ {**stored, 1, "Hidden edit", std::nullopt, std::nullopt, 0,
+ edit_staging, FrontAssetAction::KEEP, FoilAssetAction::KEEP,
+ std::nullopt, std::nullopt, std::nullopt, false},
+ 2,
+ {},
+ {});
+ ASSERT_FALSE(updated);
+ const auto* update_error = updated.error().as<mw::HTTPError>();
+ ASSERT_NE(update_error, nullptr);
+ EXPECT_EQ(update_error->code, 404);
+ stored = (*data_source)->getCard(
+ {"public", 1}, GameContentScope::INCLUDE_INTERNAL);
+ ASSERT_TRUE(stored);
+ ASSERT_TRUE(*stored);
+ EXPECT_EQ((**stored).name, "Creator card");
+}
diff --git a/tests/data_fake_test.cpp b/tests/data_fake_test.cpp
index 2cb9233..8b080a7 100644
--- a/tests/data_fake_test.cpp
+++ b/tests/data_fake_test.cpp
@@ -42,16 +42,19 @@ TEST(DataSourceFakeTest, ReturnsCards)
makeCard(2, "test", 3, "Game card"),
});
- const auto cards = data_source.getCards();
+ const auto cards = data_source.getCards(
+ GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(cards);
ASSERT_EQ(cards->size(), 2U);
- const auto card = data_source.getCard({"test", 3});
+ const auto card = data_source.getCard(
+ {"test", 3}, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(card);
ASSERT_TRUE(*card);
EXPECT_EQ((*card)->name, "Game card");
- const auto missing = data_source.getCard({"test", 4});
+ const auto missing = data_source.getCard(
+ {"test", 4}, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(missing);
EXPECT_FALSE(*missing);
}
@@ -63,13 +66,14 @@ TEST(DataSourceFakeTest, ReturnsRelatedData)
{makeCard(2, "test", 3, "Game card")},
{{7, "test", "First series", "Description"}},
{{2, {7}}},
- {{{"test", "Test Game", "", 1}, {}}});
+ {{{"test", "Test Game", "", GameVisibility::PUBLIC, 1}, {}}});
const auto memberships = data_source.getCardSeries(2);
ASSERT_TRUE(memberships);
EXPECT_EQ(*memberships, std::vector<std::int64_t>({7}));
- const auto games = data_source.getGames();
+ const auto games = data_source.getGames(
+ GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(games);
ASSERT_EQ(games->size(), 1);
EXPECT_EQ(games->front().short_name, "test");
@@ -83,17 +87,56 @@ TEST(DataSourceFakeTest, SortsGamesDeterministically)
{},
{},
{
- {{"z", "Same", "", 1}, {}},
- {{"a", "Same", "", 1}, {}},
+ {{"z", "Same", "", GameVisibility::PUBLIC, 1}, {}},
+ {{"a", "Same", "", GameVisibility::PUBLIC, 1}, {}},
});
- const auto games = data_source.getGames();
+ const auto games = data_source.getGames(
+ GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(games);
ASSERT_EQ(games->size(), 2);
EXPECT_EQ((*games)[0].short_name, "a");
EXPECT_EQ((*games)[1].short_name, "z");
}
+TEST(DataSourceFakeTest, FiltersInternalGameContent)
+{
+ DataSourceFake data_source(
+ {
+ makeCard(1, std::nullopt, 1, "Loose"),
+ makeCard(2, "public", 1, "Public"),
+ makeCard(3, "internal", 1, "Internal"),
+ },
+ {
+ {1, "public", "Public series", ""},
+ {2, "internal", "Internal series", ""},
+ },
+ {},
+ {
+ {{"public", "Public", "", GameVisibility::PUBLIC, 1}, {}},
+ {{"internal", "Internal", "", GameVisibility::INTERNAL, 1},
+ {}},
+ });
+
+ auto cards = data_source.getCards(GameContentScope::PUBLIC_ONLY);
+ ASSERT_TRUE(cards);
+ ASSERT_EQ(cards->size(), 2);
+ EXPECT_EQ((*cards)[0].name, "Loose");
+ EXPECT_EQ((*cards)[1].name, "Public");
+ auto hidden = data_source.getCard(
+ {"internal", 1}, GameContentScope::PUBLIC_ONLY);
+ ASSERT_TRUE(hidden);
+ EXPECT_FALSE(*hidden);
+ auto games = data_source.getGames(GameContentScope::PUBLIC_ONLY);
+ ASSERT_TRUE(games);
+ ASSERT_EQ(games->size(), 1);
+ EXPECT_EQ(games->front().short_name, "public");
+ auto series = data_source.getSeries(GameContentScope::PUBLIC_ONLY);
+ ASSERT_TRUE(series);
+ ASSERT_EQ(series->size(), 1);
+ EXPECT_EQ(series->front().game_short_name, "public");
+}
+
/// Verify development data cannot accidentally be mutated.
TEST(DataSourceFakeTest, RejectsMutations)
{
diff --git a/tests/data_mock.h b/tests/data_mock.h
index 311ce88..1e19b6c 100644
--- a/tests/data_mock.h
+++ b/tests/data_mock.h
@@ -45,6 +45,7 @@ public:
(const std::string& short_name,
const std::string& display_name,
const std::string& description,
+ GameVisibility visibility,
std::int64_t expected_revision), (override));
/// Mock game deletion.
@@ -263,12 +264,13 @@ public:
MOCK_METHOD(
(mw::E<std::vector<Card>>),
getCards,
- (),
+ (GameContentScope scope),
(const, override));
/// Mock retrieval of one creator's authored cards.
MOCK_METHOD((mw::E<std::vector<Card>>), getCardsByCreator,
- (std::int64_t creator_user_id), (const, override));
+ (std::int64_t creator_user_id, GameContentScope scope),
+ (const, override));
/// Mock retrieval of the current positive-rarity pool.
MOCK_METHOD((mw::E<std::vector<Card>>), getPoolCards,
@@ -278,21 +280,23 @@ public:
MOCK_METHOD(
(mw::E<std::optional<Card>>),
getCard,
- (const CardIdentity& identity),
+ (const CardIdentity& identity, GameContentScope scope),
(const, override));
/// Mock retrieval of all database-defined games.
MOCK_METHOD((mw::E<std::vector<Game>>), getGames,
- (), (const, override));
+ (GameContentScope scope), (const, override));
/// Mock retrieval of one complete game definition.
MOCK_METHOD((mw::E<std::optional<GameDefinitionSnapshot>>),
- getGameDefinition, (const std::string& short_name),
+ getGameDefinition, (const std::string& short_name,
+ GameContentScope scope),
(const, override));
/// Mock retrieval of a card's definition and custom values.
MOCK_METHOD((mw::E<std::optional<CardGameFields>>),
- getCardFieldValues, (std::int64_t card_id),
+ getCardFieldValues, (std::int64_t card_id,
+ GameContentScope scope),
(const, override));
/// Mock retrieval of a user by internal identity.
@@ -320,7 +324,8 @@ public:
/// Mock retrieval of one user's distinct collection.
MOCK_METHOD((mw::E<std::vector<CollectionEntry>>), getCollection,
- (std::int64_t user_id), (const, override));
+ (std::int64_t user_id, GameContentScope scope),
+ (const, override));
/// Mock a persisted card-ownership query.
MOCK_METHOD((mw::E<bool>), userOwnsCard,
@@ -341,14 +346,14 @@ public:
MOCK_METHOD(
(mw::E<std::vector<Series>>),
getSeries,
- (),
+ (GameContentScope scope),
(const, override));
/// Mock retrieval of one series by internal ID.
MOCK_METHOD(
(mw::E<std::optional<Series>>),
getSeries,
- (std::int64_t series_id),
+ (std::int64_t series_id, GameContentScope scope),
(const, override));
/// Mock retrieval of a card's series memberships.
diff --git a/tests/data_sqlite_test.cpp b/tests/data_sqlite_test.cpp
index eca1044..6a4c6af 100644
--- a/tests/data_sqlite_test.cpp
+++ b/tests/data_sqlite_test.cpp
@@ -80,9 +80,12 @@ TEST(DataSourceSQLiteTest, RejectsReadsBeforeMigration)
std::unique_ptr<DataSourceSQLite> data_source =
std::move(*data_source_result);
- EXPECT_FALSE(data_source->getSeries());
- EXPECT_FALSE(data_source->getSeries(1));
- EXPECT_FALSE(data_source->getGames());
+ EXPECT_FALSE(data_source->getSeries(
+ GameContentScope::INCLUDE_INTERNAL));
+ EXPECT_FALSE(data_source->getSeries(
+ 1, GameContentScope::INCLUDE_INTERNAL));
+ EXPECT_FALSE(data_source->getGames(
+ GameContentScope::INCLUDE_INTERNAL));
}
/// Verify startup prepares a fresh database for its first card-index read.
@@ -95,7 +98,8 @@ TEST(DataSourceSQLiteTest, PreparesFreshDatabaseForStartup)
auto version = (*data_source)->getSchemaVersion();
ASSERT_TRUE(version);
EXPECT_EQ(*version, DB_SCHEMA_VERSION);
- auto cards = (*data_source)->getCards();
+ auto cards = (*data_source)->getCards(
+ GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(cards);
EXPECT_TRUE(cards->empty());
}
@@ -130,7 +134,8 @@ TEST(DataSourceSQLiteTest, ReturnsCards)
auto data_source = DataSourceSQLite::fromFile(database.path());
ASSERT_TRUE(data_source);
- auto cards = (*data_source)->getCards();
+ auto cards = (*data_source)->getCards(
+ GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(cards);
ASSERT_EQ(cards->size(), 2);
@@ -159,7 +164,8 @@ TEST(DataSourceSQLiteTest, ReturnsCards)
EXPECT_EQ((*cards)[1].thumbnail_extension, "avif");
EXPECT_EQ((*cards)[1].revision, 3);
- auto loose_card = (*data_source)->getCard({std::nullopt, 35});
+ auto loose_card = (*data_source)->getCard(
+ {std::nullopt, 35}, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(loose_card);
ASSERT_TRUE(*loose_card);
EXPECT_EQ((**loose_card).id, 1);
@@ -186,7 +192,8 @@ TEST(DataSourceSQLiteTest, RejectsInvalidCards)
auto data_source = DataSourceSQLite::fromFile(database.path());
ASSERT_TRUE(data_source);
- EXPECT_FALSE((*data_source)->getCards());
+ EXPECT_FALSE((*data_source)->getCards(
+ GameContentScope::INCLUDE_INTERNAL));
}
/// Verify a committed transaction persists a complete loose card.
@@ -210,7 +217,8 @@ TEST(DataSourceSQLiteTest, InsertsLooseCard)
EXPECT_TRUE(*exists_after);
ASSERT_TRUE((*transaction)->commit());
- auto cards = (*data_source)->getCards();
+ auto cards = (*data_source)->getCards(
+ GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(cards);
ASSERT_EQ(cards->size(), 1);
EXPECT_EQ(cards->front().id, *card_id);
@@ -244,7 +252,8 @@ TEST(DataSourceSQLiteTest, UpdatesLooseCard)
**card, {}, {}));
ASSERT_TRUE((*transaction)->commit());
- auto stored = (*data_source)->getCard({std::nullopt, 42});
+ auto stored = (*data_source)->getCard(
+ {std::nullopt, 42}, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(stored);
ASSERT_TRUE(*stored);
EXPECT_EQ((**stored).id, *card_id);
@@ -265,17 +274,19 @@ TEST(DataSourceSQLiteTest, MutatesSeries)
auto transaction = (*data_source)->beginTransaction();
ASSERT_TRUE(transaction);
ASSERT_TRUE((*transaction)->insertGame(
- {"gh", "Going Home", "", 1}));
+ {"gh", "Going Home", "", GameVisibility::PUBLIC, 1}));
auto series_id = (*transaction)->insertSeries(
{0, "gh", "First series", "Description"});
ASSERT_TRUE(series_id);
ASSERT_TRUE((*transaction)->commit());
- auto all_series = (*data_source)->getSeries();
+ auto all_series = (*data_source)->getSeries(
+ GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(all_series);
ASSERT_EQ(all_series->size(), 1);
EXPECT_EQ(all_series->front().id, *series_id);
- auto series = (*data_source)->getSeries(*series_id);
+ auto series = (*data_source)->getSeries(
+ *series_id, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(series);
ASSERT_TRUE(*series);
(**series).name = "Edited series";
@@ -284,7 +295,8 @@ TEST(DataSourceSQLiteTest, MutatesSeries)
ASSERT_TRUE(transaction);
ASSERT_TRUE((*transaction)->updateSeries(**series));
ASSERT_TRUE((*transaction)->commit());
- series = (*data_source)->getSeries(*series_id);
+ series = (*data_source)->getSeries(
+ *series_id, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(series);
ASSERT_TRUE(*series);
EXPECT_EQ((**series).name, "Edited series");
@@ -293,11 +305,119 @@ TEST(DataSourceSQLiteTest, MutatesSeries)
ASSERT_TRUE(transaction);
ASSERT_TRUE((*transaction)->deleteSeries(*series_id));
ASSERT_TRUE((*transaction)->commit());
- series = (*data_source)->getSeries(*series_id);
+ series = (*data_source)->getSeries(
+ *series_id, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(series);
EXPECT_FALSE(*series);
}
+TEST(DataSourceSQLiteTest, ScopesInternalGameContentAndPulls)
+{
+ TemporaryDatabase database;
+ auto data_source = prepareDataSource(database.path());
+ ASSERT_TRUE(data_source) << data_source.error().msg();
+
+ auto transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ ASSERT_TRUE((*transaction)->insertGame(
+ {"public", "Public", "", GameVisibility::PUBLIC, 1}));
+ ASSERT_TRUE((*transaction)->insertGame(
+ {"internal", "Internal", "", GameVisibility::INTERNAL, 1}));
+ auto public_series = (*transaction)->insertSeries(
+ {0, "public", "Public series", ""});
+ auto internal_series = (*transaction)->insertSeries(
+ {0, "internal", "Internal series", ""});
+ ASSERT_TRUE(public_series);
+ ASSERT_TRUE(internal_series);
+ auto public_number = (*transaction)->allocateGameNumber("public");
+ auto internal_number = (*transaction)->allocateGameNumber("internal");
+ ASSERT_TRUE(public_number);
+ ASSERT_TRUE(internal_number);
+ Card public_card = makeLooseCard(*public_number, "Public card");
+ public_card.identity.game_short_name = "public";
+ public_card.rarity = 1;
+ Card internal_card = makeLooseCard(*internal_number, "Internal card");
+ internal_card.identity.game_short_name = "internal";
+ internal_card.rarity = 1;
+ auto public_id = (*transaction)->insertCard(
+ public_card, {}, {*public_series});
+ auto internal_id = (*transaction)->insertCard(
+ internal_card, {}, {*internal_series});
+ ASSERT_TRUE(public_id);
+ ASSERT_TRUE(internal_id);
+ ASSERT_TRUE((*transaction)->incrementHolding(1, *public_id));
+ ASSERT_TRUE((*transaction)->incrementHolding(1, *internal_id));
+ ASSERT_TRUE((*transaction)->commit());
+
+ auto public_games = (*data_source)->getGames(
+ GameContentScope::PUBLIC_ONLY);
+ auto all_games = (*data_source)->getGames(
+ GameContentScope::INCLUDE_INTERNAL);
+ ASSERT_TRUE(public_games);
+ ASSERT_TRUE(all_games);
+ ASSERT_EQ(public_games->size(), 1);
+ ASSERT_EQ(all_games->size(), 2);
+ EXPECT_EQ(public_games->front().short_name, "public");
+ EXPECT_EQ((*all_games)[0].visibility, GameVisibility::INTERNAL);
+ EXPECT_EQ((*all_games)[1].visibility, GameVisibility::PUBLIC);
+
+ auto public_cards = (*data_source)->getCards(
+ GameContentScope::PUBLIC_ONLY);
+ auto all_cards = (*data_source)->getCards(
+ GameContentScope::INCLUDE_INTERNAL);
+ ASSERT_TRUE(public_cards);
+ ASSERT_TRUE(all_cards);
+ ASSERT_EQ(public_cards->size(), 1);
+ ASSERT_EQ(all_cards->size(), 2);
+ EXPECT_EQ(public_cards->front().name, "Public card");
+ auto hidden_card = (*data_source)->getCard(
+ {"internal", *internal_number}, GameContentScope::PUBLIC_ONLY);
+ ASSERT_TRUE(hidden_card);
+ EXPECT_FALSE(*hidden_card);
+ auto hidden_definition = (*data_source)->getGameDefinition(
+ "internal", GameContentScope::PUBLIC_ONLY);
+ ASSERT_TRUE(hidden_definition);
+ EXPECT_FALSE(*hidden_definition);
+ auto hidden_fields = (*data_source)->getCardFieldValues(
+ *internal_id, GameContentScope::PUBLIC_ONLY);
+ ASSERT_TRUE(hidden_fields);
+ EXPECT_FALSE(*hidden_fields);
+
+ auto public_series_list = (*data_source)->getSeries(
+ GameContentScope::PUBLIC_ONLY);
+ auto public_collection = (*data_source)->getCollection(
+ 1, GameContentScope::PUBLIC_ONLY);
+ auto pool = (*data_source)->getPoolCards();
+ ASSERT_TRUE(public_series_list);
+ ASSERT_TRUE(public_collection);
+ ASSERT_TRUE(pool);
+ EXPECT_EQ(public_series_list->size(), 1);
+ EXPECT_EQ(public_collection->size(), 1);
+ EXPECT_EQ(pool->size(), 1);
+
+ transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ auto locked_pool = (*transaction)->getPoolCardsForUpdate();
+ ASSERT_TRUE(locked_pool);
+ EXPECT_EQ(locked_pool->size(), 1);
+ auto visible = (*transaction)->updateGame(
+ "internal", "Internal", "", GameVisibility::PUBLIC, 1);
+ ASSERT_TRUE(visible);
+ ASSERT_TRUE(*visible);
+ ASSERT_TRUE((*transaction)->commit());
+
+ public_cards = (*data_source)->getCards(GameContentScope::PUBLIC_ONLY);
+ public_collection = (*data_source)->getCollection(
+ 1, GameContentScope::PUBLIC_ONLY);
+ pool = (*data_source)->getPoolCards();
+ ASSERT_TRUE(public_cards);
+ ASSERT_TRUE(public_collection);
+ ASSERT_TRUE(pool);
+ EXPECT_EQ(public_cards->size(), 2);
+ EXPECT_EQ(public_collection->size(), 2);
+ EXPECT_EQ(pool->size(), 2);
+}
+
/// Verify dynamic fields, numbering, values, and memberships persist.
TEST(DataSourceSQLiteTest, PersistsDynamicGameCards)
{
@@ -308,7 +428,8 @@ TEST(DataSourceSQLiteTest, PersistsDynamicGameCards)
auto transaction = (*data_source)->beginTransaction();
ASSERT_TRUE(transaction);
ASSERT_TRUE((*transaction)->insertGame(
- {"test", "Test Game", "Description", 1}));
+ {"test", "Test Game", "Description",
+ GameVisibility::PUBLIC, 1}));
auto hp_id = (*transaction)->insertGameField(
{0, "test", "hp", "HP", GameFieldType::INTEGER, 0, {}}, {});
auto note_id = (*transaction)->insertGameField(
@@ -336,10 +457,12 @@ TEST(DataSourceSQLiteTest, PersistsDynamicGameCards)
ASSERT_TRUE(card_id);
ASSERT_TRUE((*transaction)->commit());
- auto stored = (*data_source)->getCard({"test", 1});
+ auto stored = (*data_source)->getCard(
+ {"test", 1}, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(stored);
ASSERT_TRUE(*stored);
- auto values = (*data_source)->getCardFieldValues(*card_id);
+ auto values = (*data_source)->getCardFieldValues(
+ *card_id, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(values);
ASSERT_TRUE(*values);
ASSERT_EQ((**values).definition.fields.size(), 3);
@@ -357,7 +480,8 @@ TEST(DataSourceSQLiteTest, PersistsDynamicGameCards)
{{*hp_id, GameFieldType::INTEGER, std::int64_t{70}}},
{}));
ASSERT_TRUE((*transaction)->commit());
- values = (*data_source)->getCardFieldValues(*card_id);
+ values = (*data_source)->getCardFieldValues(
+ *card_id, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(values);
ASSERT_TRUE(*values);
ASSERT_EQ((**values).values.size(), 1);
@@ -370,7 +494,8 @@ TEST(DataSourceSQLiteTest, PersistsDynamicGameCards)
{});
ASSERT_TRUE(later_id);
ASSERT_TRUE((*transaction)->commit());
- values = (*data_source)->getCardFieldValues(*card_id);
+ values = (*data_source)->getCardFieldValues(
+ *card_id, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(values);
ASSERT_TRUE(*values);
EXPECT_EQ((**values).definition.fields.size(), 4);
@@ -390,7 +515,8 @@ TEST(DataSourceSQLiteTest, RollsBackLooseCard)
transaction->reset();
- auto cards = (*data_source)->getCards();
+ auto cards = (*data_source)->getCards(
+ GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(cards);
EXPECT_TRUE(cards->empty());
}
@@ -404,7 +530,7 @@ TEST(DataSourceSQLiteTest, AllocatesGameNumbers)
auto transaction = (*data_source)->beginTransaction();
ASSERT_TRUE(transaction);
ASSERT_TRUE((*transaction)->insertGame(
- {"gh", "Going Home", "", 1}));
+ {"gh", "Going Home", "", GameVisibility::PUBLIC, 1}));
auto first = (*transaction)->allocateGameNumber("gh");
auto second = (*transaction)->allocateGameNumber("gh");
ASSERT_TRUE(first);
@@ -595,7 +721,8 @@ TEST(DataSourceSQLiteTest, PersistsMvpAccountAndCollectionState)
EXPECT_EQ(*quantity, 2);
ASSERT_TRUE((*transaction)->commit());
- auto collection = (*data_source)->getCollection(*player_id);
+ auto collection = (*data_source)->getCollection(
+ *player_id, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(collection);
ASSERT_EQ(collection->size(), 1);
EXPECT_EQ(collection->front().quantity, 2);
@@ -603,7 +730,8 @@ TEST(DataSourceSQLiteTest, PersistsMvpAccountAndCollectionState)
ASSERT_TRUE(transaction);
ASSERT_TRUE((*transaction)->deleteCard(*card_id));
ASSERT_TRUE((*transaction)->commit());
- collection = (*data_source)->getCollection(*player_id);
+ collection = (*data_source)->getCollection(
+ *player_id, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(collection);
EXPECT_TRUE(collection->empty());
}
@@ -627,6 +755,47 @@ TEST(DataSourceSQLiteTest, RejectsObsoletePrototypeSchema)
std::string::npos);
}
+TEST(DataSourceSQLiteTest, RejectsVersionOneWithoutVisibility)
+{
+ TemporaryDatabase database;
+ auto connection = mw::SQLite::connectFile(database.path().string());
+ ASSERT_TRUE(connection);
+ ASSERT_TRUE((*connection)->execute(
+ "CREATE TABLE users(id INTEGER PRIMARY KEY);"));
+ ASSERT_TRUE((*connection)->execute(
+ "CREATE TABLE games(short_name TEXT PRIMARY KEY);"));
+ ASSERT_TRUE((*connection)->execute("PRAGMA user_version = 1;"));
+ connection->reset();
+
+ auto data_source = DataSourceSQLite::fromFile(database.path());
+ ASSERT_TRUE(data_source);
+ auto version = (*data_source)->getSchemaVersion();
+ ASSERT_FALSE(version);
+ EXPECT_NE(version.error().msg().find("delete and recreate"),
+ std::string::npos);
+
+ data_source->reset();
+ connection = mw::SQLite::connectFile(database.path().string());
+ ASSERT_TRUE(connection);
+ auto columns = (*connection)->evalToValue<std::int64_t>(
+ "SELECT COUNT(*) FROM pragma_table_info('games');");
+ ASSERT_TRUE(columns);
+ EXPECT_EQ(*columns, 1);
+}
+
+TEST(DataSourceSQLiteTest, RejectsInvalidGameVisibility)
+{
+ TemporaryDatabase database;
+ auto data_source = prepareDataSource(database.path());
+ ASSERT_TRUE(data_source) << data_source.error().msg();
+ auto transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+
+ EXPECT_FALSE((*transaction)->insertGame({
+ "invalid", "Invalid", "",
+ static_cast<GameVisibility>(2), 1}));
+}
+
/// Verify fresh initialization and reopening do not create any games.
TEST(DataSourceSQLiteTest, StartsAndReopensWithoutGames)
{
@@ -634,14 +803,16 @@ TEST(DataSourceSQLiteTest, StartsAndReopensWithoutGames)
auto data_source = prepareDataSource(database.path());
ASSERT_TRUE(data_source) << data_source.error().msg();
- auto games = (*data_source)->getGames();
+ auto games = (*data_source)->getGames(
+ GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(games);
EXPECT_TRUE(games->empty());
data_source->reset();
data_source = prepareDataSource(database.path());
ASSERT_TRUE(data_source) << data_source.error().msg();
- games = (*data_source)->getGames();
+ games = (*data_source)->getGames(
+ GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(games);
EXPECT_TRUE(games->empty());
}
@@ -714,7 +885,7 @@ TEST(DataSourceSQLiteTest, RejectsMissingGameSequence)
auto transaction = (*data_source)->beginTransaction();
ASSERT_TRUE(transaction);
ASSERT_TRUE((*transaction)->insertGame(
- {"test", "Test Game", "", 1}));
+ {"test", "Test Game", "", GameVisibility::PUBLIC, 1}));
ASSERT_TRUE((*transaction)->commit());
data_source->reset();
@@ -739,9 +910,9 @@ TEST(DataSourceSQLiteTest, EnforcesDynamicFieldInvariants)
auto transaction = (*data_source)->beginTransaction();
ASSERT_TRUE(transaction);
ASSERT_TRUE((*transaction)->insertGame(
- {"one", "One", "", 1}));
+ {"one", "One", "", GameVisibility::PUBLIC, 1}));
ASSERT_TRUE((*transaction)->insertGame(
- {"two", "Two", "", 1}));
+ {"two", "Two", "", GameVisibility::PUBLIC, 1}));
auto integer_id = (*transaction)->insertGameField(
{0, "one", "score", "Score", GameFieldType::INTEGER, 0, {}},
{});
@@ -802,7 +973,7 @@ TEST(DataSourceSQLiteTest, PreservesDynamicIdentitiesAndCascadesValues)
auto transaction = (*data_source)->beginTransaction();
ASSERT_TRUE(transaction);
ASSERT_TRUE((*transaction)->insertGame(
- {"fixed", "Fixed", "", 1}));
+ {"fixed", "Fixed", "", GameVisibility::PUBLIC, 1}));
auto field_id = (*transaction)->insertGameField(
{0, "fixed", "score", "Score", GameFieldType::INTEGER, 0, {}},
{});
diff --git a/tests/game_field_test.cpp b/tests/game_field_test.cpp
index 8b08b69..1dc6cc8 100644
--- a/tests/game_field_test.cpp
+++ b/tests/game_field_test.cpp
@@ -13,7 +13,7 @@ namespace
GameDefinitionSnapshot definition()
{
return {
- {"test", "Test", "", 1},
+ {"test", "Test", "", GameVisibility::PUBLIC, 1},
{
{1, "test", "score", "Score", GameFieldType::INTEGER, 0, {}},
{2, "test", "note", "Note", GameFieldType::STRING, 1, {}},
diff --git a/tests/game_service_test.cpp b/tests/game_service_test.cpp
index 189fb4a..8ae7618 100644
--- a/tests/game_service_test.cpp
+++ b/tests/game_service_test.cpp
@@ -115,7 +115,8 @@ TEST(GameServiceTest, MutatesDefinitionsAndRejectsStaleRevisions)
GameService service(*fixture->data_source);
auto created = service.createGame(
- fixture->administrator_id, "demo", " Demo Game ", "A **game**.");
+ fixture->administrator_id, "demo", " Demo Game ", "A **game**.",
+ GameVisibility::PUBLIC);
ASSERT_TRUE(created) << created.error().msg();
EXPECT_EQ(*created, "demo");
@@ -160,7 +161,8 @@ TEST(GameServiceTest, MutatesDefinitionsAndRejectsStaleRevisions)
EXPECT_EQ(httpError(duplicate_choices.error())->code, 409);
auto stale = service.updateGame(
- fixture->administrator_id, "demo", 3, "Stale", "");
+ fixture->administrator_id, "demo", 3, "Stale", "",
+ GameVisibility::PUBLIC);
ASSERT_FALSE(stale);
ASSERT_NE(httpError(stale.error()), nullptr);
EXPECT_EQ(httpError(stale.error())->code, 409);
@@ -182,13 +184,20 @@ TEST(GameServiceTest, MutatesDefinitionsAndRejectsStaleRevisions)
"demo",
6,
"Renamed Game",
- "New description"));
+ "New description",
+ GameVisibility::INTERNAL));
- auto definition = fixture->data_source->getGameDefinition("demo");
+ 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");
@@ -206,13 +215,15 @@ TEST(GameServiceTest, EnforcesAuthorizationAndDeletionRules)
GameService service(*fixture->data_source);
auto forbidden = service.createGame(
- fixture->player_id, "player", "Player Game", "");
+ 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", "");
+ fixture->administrator_id, "d\xc3\xa9mo", "Invalid", "",
+ GameVisibility::PUBLIC);
ASSERT_FALSE(invalid);
const auto* validation =
invalid.error().as<GameDefinitionValidationError>();
@@ -220,7 +231,8 @@ TEST(GameServiceTest, EnforcesAuthorizationAndDeletionRules)
EXPECT_EQ(validation->field_name, "short_name");
ASSERT_TRUE(service.createGame(
- fixture->administrator_id, "used", "Used Game", ""));
+ fixture->administrator_id, "used", "Used Game", "",
+ GameVisibility::PUBLIC));
auto choice_id = service.createField(
fixture->administrator_id,
"used",
@@ -292,10 +304,12 @@ TEST(GameServiceTest, EnforcesAuthorizationAndDeletionRules)
EXPECT_EQ(httpError(issued_game.error())->code, 409);
ASSERT_TRUE(service.createGame(
- fixture->administrator_id, "empty", "Empty Game", ""));
+ fixture->administrator_id, "empty", "Empty Game", "",
+ GameVisibility::PUBLIC));
ASSERT_TRUE(service.removeGame(
fixture->administrator_id, "empty", 1));
- auto removed = fixture->data_source->getGameDefinition("empty");
+ auto removed = fixture->data_source->getGameDefinition(
+ "empty", GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(removed);
EXPECT_FALSE(*removed);
}
diff --git a/tests/mvp_primitives_test.cpp b/tests/mvp_primitives_test.cpp
index 9e11fc0..2be5725 100644
--- a/tests/mvp_primitives_test.cpp
+++ b/tests/mvp_primitives_test.cpp
@@ -95,6 +95,31 @@ TEST(AuthorizationTest, EnforcesOwnershipAndRoleExceptions)
EXPECT_TRUE(authorization.canSetRarity(administrator));
}
+TEST(AuthorizationTest, AppliesInternalGameVisibility)
+{
+ AuthorizationService authorization;
+ const User player = user(1, UserRole::PLAYER);
+ const User creator = user(2, UserRole::CREATOR);
+ const User administrator = user(3, UserRole::ADMINISTRATOR);
+ const Game public_game = {
+ "public", "Public", "", GameVisibility::PUBLIC, 1};
+ const Game internal_game = {
+ "internal", "Internal", "", GameVisibility::INTERNAL, 1};
+
+ EXPECT_EQ(
+ authorization.gameContentScope(player),
+ GameContentScope::PUBLIC_ONLY);
+ EXPECT_EQ(
+ authorization.gameContentScope(creator),
+ GameContentScope::PUBLIC_ONLY);
+ EXPECT_EQ(
+ authorization.gameContentScope(administrator),
+ GameContentScope::INCLUDE_INTERNAL);
+ EXPECT_TRUE(authorization.canUseGame(creator, public_game));
+ EXPECT_FALSE(authorization.canUseGame(creator, internal_game));
+ EXPECT_TRUE(authorization.canUseGame(administrator, internal_game));
+}
+
TEST(CardPoolTest, CalculatesRequiredRarityRatio)
{
CardPoolService pool;