#include "app.h"
#include <cstdint>
#include <filesystem>
#include <memory>
#include <optional>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
#include <gtest/gtest.h>
#include "data_fake.h"
#include "public_id.h"
namespace
{
Config makeConfig(const std::string& base_url)
{
auto parsed_url = mw::URL::fromStr(base_url);
if(!parsed_url)
{
throw std::invalid_argument("Test base URL is invalid");
}
return {
std::move(*parsed_url),
mw::IPSocketInfo{"127.0.0.1", 8080},
std::filesystem::path(CARD_COLLECTION_SOURCE_DIR) / "static",
std::filesystem::path("cards.sqlite3"),
std::filesystem::path("cards"),
75,
256,
};
}
std::unique_ptr<DataSourceFake> emptyDataSource()
{
return std::make_unique<DataSourceFake>();
}
std::unique_ptr<NonSecretRandom> deterministicRandom()
{
return std::make_unique<NonSecretRandom>(1);
}
Card makeCard(std::int64_t id, std::uint64_t number, std::string name)
{
return {
id,
{"pkm", number},
std::move(name),
std::nullopt,
std::nullopt,
0,
"avif",
std::nullopt,
"avif",
1,
};
}
class AuthenticatedDataSource : public DataSourceFake
{
public:
using DataSourceFake::DataSourceFake;
/// Return one deterministic onboarded administrator session.
mw::E<std::optional<SessionContext>> getSession(
[[maybe_unused]] const TokenHash& token_hash,
[[maybe_unused]] std::int64_t now) const override
{
return SessionContext{
1,
{1, "admin@example.com", "admin@example.com", "Admin",
UserRole::ADMINISTRATOR, 1, 0, 0},
std::string(64, 'c'),
9999999999};
}
/// Treat configured cards as owned for focused rendering tests.
mw::E<bool> userOwnsCard(
[[maybe_unused]] std::int64_t user_id,
[[maybe_unused]] std::int64_t card_id) const override
{
return true;
}
/// Return configured cards as the focused test pool.
mw::E<std::vector<Card>> getPoolCards() const override
{
return getCards(GameContentScope::INCLUDE_INTERNAL);
}
};
App::Request authenticatedRequest()
{
App::Request request;
request.set_header(
"Cookie", "card_collection_session=" + std::string(64, 'a'));
return request;
}
} // namespace
/// Verify named routes respect a nested application base path.
TEST(AppTest, BuildsNamedUrls)
{
const App app(
makeConfig("https://example.test/collection/"),
emptyDataSource(),
deterministicRandom());
EXPECT_EQ(
app.urlFor("card-index"),
"https://example.test/collection/admin/cards");
EXPECT_EQ(
app.urlFor("card-new"),
"https://example.test/collection/cards/new");
EXPECT_EQ(
app.urlFor("card", {"PKM/2"}),
"https://example.test/collection/cards/PKM%2f2");
EXPECT_EQ(
app.urlFor("series-edit", {"42"}),
"https://example.test/collection/admin/series/42/edit");
EXPECT_EQ(
app.urlFor("admin-games"),
"https://example.test/collection/admin/games");
EXPECT_EQ(
app.urlFor("game-field-edit", {"a/b", "42"}),
"https://example.test/collection/admin/games/a%2fb/fields/42/edit");
}
/// Verify static relative paths and ordered query values are encoded.
TEST(AppTest, BuildsStaticAndQueryUrls)
{
const App app(
makeConfig("https://example.test/collection/"),
emptyDataSource(),
deterministicRandom());
EXPECT_EQ(
app.urlFor(
"static",
{"foil/model/card.obj"},
{{"v", "4"}, {"name", "a b"}}),
"https://example.test/collection/static/foil/model/card.obj"
"?v=4&name=a%20b");
}
/// Verify route programming errors are rejected.
TEST(AppTest, RejectsInvalidRoutes)
{
const App app(
makeConfig("https://example.test/"),
emptyDataSource(),
deterministicRandom());
EXPECT_THROW(app.urlFor("missing"), std::invalid_argument);
EXPECT_THROW(app.urlFor("card"), std::invalid_argument);
EXPECT_THROW(app.urlFor("card-index", {"extra"}), std::invalid_argument);
EXPECT_THROW(app.urlFor("static", {"../secret"}), std::invalid_argument);
}
/// Verify the index renders escaped fake records in natural ID order.
TEST(AppTest, RendersCardIndex)
{
auto data_source = std::make_unique<AuthenticatedDataSource>(
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),
deterministicRandom());
App::Request request = authenticatedRequest();
App::Response response;
app.handleCardIndex(request, response);
EXPECT_EQ(response.status, 200);
EXPECT_EQ(
response.get_header_value("Content-Type"),
"text/html; charset=utf-8");
EXPECT_EQ(response.body.find("<script>Second card</script>"),
std::string::npos);
EXPECT_NE(response.body.find("<script>Second card</script>"),
std::string::npos);
const std::size_t second_position = response.body.find("PKM-2");
const std::size_t tenth_position = response.body.find("PKM-10");
ASSERT_NE(second_position, std::string::npos);
ASSERT_NE(tenth_position, std::string::npos);
EXPECT_LT(second_position, tenth_position);
}
/// Verify the create-card page renders its multipart form and preview.
TEST(AppTest, RendersCardCreationForm)
{
App app(
makeConfig("https://example.test/collection/"),
std::make_unique<AuthenticatedDataSource>(),
deterministicRandom());
App::Request request = authenticatedRequest();
App::Response response;
app.handleCardNew(request, response);
EXPECT_EQ(response.status, 200);
EXPECT_NE(response.body.find("id=\"CardForm\""), std::string::npos);
EXPECT_NE(response.body.find("method=\"post\""), std::string::npos);
EXPECT_NE(
response.body.find(
"action=\"https://example.test/collection/cards\""),
std::string::npos);
EXPECT_NE(response.body.find("enctype=\"multipart/form-data\""),
std::string::npos);
EXPECT_NE(response.body.find("name=\"front\""), std::string::npos);
EXPECT_NE(response.body.find("name=\"foil\""), std::string::npos);
EXPECT_NE(response.body.find("\"mode\": \"create\""),
std::string::npos);
}
/// Verify the edit page reuses the card form with current values and actions.
TEST(AppTest, RendersCardEditForm)
{
Card card = makeCard(2, 35, "Moon card");
card.identity.game_short_name = std::nullopt;
card.short_description = "A quiet night.";
card.rarity = 4;
auto data_source = std::make_unique<AuthenticatedDataSource>(
std::vector<Card>{card});
App app(
makeConfig("https://example.test/collection/"),
std::move(data_source),
deterministicRandom());
auto public_id = formatPublicId(card.identity);
ASSERT_TRUE(public_id);
App::Request request = authenticatedRequest();
request.path_params.emplace("id", *public_id);
App::Response response;
app.handleCardEdit(request, response);
EXPECT_EQ(response.status, 200);
EXPECT_NE(response.body.find("Edit card"), std::string::npos);
EXPECT_NE(response.body.find("value=\"Moon card\""),
std::string::npos);
EXPECT_NE(response.body.find("name=\"revision\""),
std::string::npos);
EXPECT_NE(response.body.find("name=\"front_action\""),
std::string::npos);
EXPECT_NE(response.body.find("name=\"foil_action\""),
std::string::npos);
EXPECT_NE(response.body.find("\"mode\": \"edit\""),
std::string::npos);
}
/// Verify a card page renders fake metadata and read-only preview data.
TEST(AppTest, RendersCardView)
{
Card card = makeCard(2, 2, "<script>Moon card</script>");
card.identity.game_short_name = "test";
card.short_description = "A quiet <night>.";
card.long_description = "First line\nSecond line";
card.rarity = 4;
auto data_source = std::make_unique<AuthenticatedDataSource>(
std::vector<Card>{card},
std::vector<Series>{
{7, "test", "Night Signals", "A series description"},
},
std::unordered_map<std::int64_t, std::vector<std::int64_t>>{
{2, {7}},
},
std::vector<GameDefinitionSnapshot>{
{{"test", "Test Game", "", GameVisibility::PUBLIC, 1}, {}}},
std::unordered_map<std::int64_t, std::vector<GameFieldValue>>{});
App app(
makeConfig("https://example.test/collection/"),
std::move(data_source),
deterministicRandom());
App::Request request = authenticatedRequest();
request.path_params.emplace("id", "test-2");
App::Response response;
app.handleCardView(request, response);
EXPECT_EQ(response.status, 200);
EXPECT_NE(response.body.find("id=\"GLCanvas\""), std::string::npos);
EXPECT_NE(response.body.find("TEST-2"), std::string::npos);
EXPECT_NE(response.body.find("Night Signals"), std::string::npos);
EXPECT_NE(response.body.find("Some card assets are missing"),
std::string::npos);
EXPECT_NE(response.body.find("\"foil_url\":"), std::string::npos);
EXPECT_NE(response.body.find("<script>Moon card</script>"),
std::string::npos);
EXPECT_EQ(response.body.find("<script>Moon card</script>"),
std::string::npos);
}
/// Verify missing and malformed public IDs return a not-found response.
TEST(AppTest, RejectsUnknownCardView)
{
App app(
makeConfig("https://example.test/"),
std::make_unique<AuthenticatedDataSource>(),
deterministicRandom());
App::Request request = authenticatedRequest();
request.path_params.emplace("id", "PKM-2");
App::Response response;
app.handleCardView(request, response);
EXPECT_EQ(response.status, 404);
}
/// Verify canonical game and loose public IDs parse without ambiguity.
TEST(PublicIdTest, ParsesCanonicalIds)
{
const auto game_card = parsePublicId("pkm-42");
ASSERT_TRUE(game_card);
EXPECT_EQ(game_card->game_short_name, "pkm");
EXPECT_EQ(game_card->card_number, 42);
const auto loose_card = parsePublicId("z1");
ASSERT_TRUE(loose_card);
EXPECT_EQ(loose_card->game_short_name, std::nullopt);
EXPECT_EQ(loose_card->card_number, 1261);
EXPECT_FALSE(parsePublicId("PKM-42"));
EXPECT_FALSE(parsePublicId("pkm-042"));
EXPECT_FALSE(parsePublicId("pkm-0"));
EXPECT_FALSE(parsePublicId("z-1-extra"));
}