BareGit
#include "app.h"

#include <chrono>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <memory>
#include <string>
#include <string_view>

#include <Magick++.h>
#include <gtest/gtest.h>
#include <httplib.h>

#include "non_secret_random.h"
#include "startup.h"

namespace
{

std::string readFile(const std::filesystem::path& path)
{
    std::ifstream input(path, std::ios::binary);
    return std::string(
        std::istreambuf_iterator<char>(input),
        std::istreambuf_iterator<char>());
}

std::string hiddenValue(const std::string& body, std::string_view name)
{
    std::string marker = "name=\"" + std::string(name) + "\" value=\"";
    std::size_t begin = body.find(marker);
    if(begin == std::string::npos)
    {
        marker = "name=" + std::string(name) + " value=\"";
        begin = body.find(marker);
    }
    if(begin == std::string::npos)
    {
        return {};
    }
    const std::size_t value_begin = begin + marker.size();
    const std::size_t end = body.find('"', value_begin);
    return end == std::string::npos
        ? std::string()
        : body.substr(value_begin, end - value_begin);
}

std::string responseCookie(
    const httplib::Response& response, std::string_view name)
{
    const std::string prefix = std::string(name) + '=';
    for(std::size_t index = 0;
        index < response.get_header_value_count("Set-Cookie");
        ++index)
    {
        const std::string value = response.get_header_value(
            "Set-Cookie", nullptr, index);
        if(value.starts_with(prefix))
        {
            return value.substr(0, value.find(';'));
        }
    }
    return {};
}

std::string urlRequestPath(const std::string& url)
{
    const std::size_t scheme = url.find("://");
    const std::size_t path = scheme == std::string::npos
        ? std::string::npos
        : url.find('/', scheme + 3);
    if(path == std::string::npos)
    {
        return {};
    }
    const std::size_t end = url.find_first_of("\r\n\t ", path);
    return url.substr(path, end - path);
}

class TemporaryServerRoot
{
public:
    /// Allocate private database and card-storage paths.
    TemporaryServerRoot()
            : path_(
                  std::filesystem::path(testing::TempDir()) /
                  ("card_server_" + std::to_string(
                      std::chrono::steady_clock::now()
                          .time_since_epoch().count())))
    {
        std::filesystem::create_directories(path_ / "cards/published");
    }

    /// Remove all temporary server state.
    ~TemporaryServerRoot()
    {
        std::error_code error;
        std::filesystem::remove_all(path_, error);
    }

    /// Return the temporary root path.
    const std::filesystem::path& path() const
    {
        return path_;
    }

private:
    std::filesystem::path path_;
};

} // namespace

/// Verify real HTTP routing, base paths, and independent static mounts.
TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
{
    Magick::InitializeMagick(nullptr);
    Magick::ResourceLimits::listLength(2);
    Magick::ResourceLimits::width(2048);
    Magick::ResourceLimits::height(2048);
    TemporaryServerRoot temporary;
    const int port = 39000 + static_cast<int>(
        std::chrono::steady_clock::now().time_since_epoch().count() % 1000);
    auto base_url = mw::URL::fromStr(
        "http://127.0.0.1:" + std::to_string(port) + "/collection/");
    ASSERT_TRUE(base_url);
    Config config = {
        std::move(*base_url),
        mw::IPSocketInfo{"127.0.0.1", port},
        std::filesystem::path(CARD_COLLECTION_SOURCE_DIR) / "static",
        temporary.path() / "cards.sqlite3",
        temporary.path() / "cards",
        75,
        256,
    };
    config.email.link_file = temporary.path() / "latest-link.txt";
    auto data_source = prepareDataSource(config.database_path);
    ASSERT_TRUE(data_source);
    auto transaction = (*data_source)->beginTransaction();
    ASSERT_TRUE(transaction);
    ASSERT_TRUE((*transaction)->insertGame(
        {"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(
        {0, "test", "attack", "Attack", GameFieldType::INTEGER, 1, {}},
        {});
    ASSERT_TRUE(hp_id);
    ASSERT_TRUE(attack_id);
    auto series_id = (*transaction)->insertSeries(
        {0, "test", "Core <Set>", "A **series**."});
    ASSERT_TRUE(series_id);
    auto number = (*transaction)->allocateGameNumber("test");
    ASSERT_TRUE(number);
    auto card_id = (*transaction)->insertCard(
        {
            0,
            {"test", *number},
            "Card <One>",
            std::nullopt,
            std::nullopt,
            0,
            "avif",
            std::nullopt,
            "avif",
            1,
            1,
        },
        {{*hp_id, GameFieldType::INTEGER, std::int64_t{20}},
         {*attack_id, GameFieldType::INTEGER, std::int64_t{5}}},
        {*series_id});
    ASSERT_TRUE(card_id);
    ASSERT_TRUE((*transaction)->commit());
    const std::filesystem::path card_directory =
        config.card_storage_root / "published/test-1";
    std::filesystem::create_directories(card_directory);
    std::ofstream(card_directory / "front-art.avif") << "front";
    std::ofstream(card_directory / "thumb.avif") << "thumbnail";
    const std::filesystem::path upload_path =
        temporary.path() / "upload.png";
    Magick::Image upload(
        Magick::Geometry(350, 490), Magick::Color("navy"));
    upload.write("PNG:" + upload_path.string());
    const std::string upload_bytes = readFile(upload_path);
    App app(
        config,
        std::move(*data_source),
        std::make_unique<NonSecretRandom>(1));
    ASSERT_TRUE(app.start());

    httplib::Client client("127.0.0.1", port);
    auto index = client.Get("/collection/");
    ASSERT_NE(index, nullptr);
    EXPECT_EQ(index->status, 200);
    EXPECT_NE(index->body.find("Card Collection"), std::string::npos);
    auto anonymous_card = client.Get("/collection/cards/test-1");
    ASSERT_NE(anonymous_card, nullptr);
    EXPECT_EQ(anonymous_card->status, 303);

    auto authentication = client.Get("/collection/authentication");
    ASSERT_NE(authentication, nullptr);
    ASSERT_EQ(authentication->status, 200);
    const std::string authentication_cookie = responseCookie(
        *authentication, "card_collection_auth_form");
    const std::string authentication_nonce = hiddenValue(
        authentication->body, "form_nonce");
    ASSERT_FALSE(authentication_cookie.empty());
    ASSERT_FALSE(authentication_nonce.empty());
    const httplib::Headers authentication_headers = {
        {"Cookie", authentication_cookie}};
    const httplib::Params email_fields = {
        {"email", "admin@example.com"},
        {"form_nonce", authentication_nonce}};
    auto email = client.Post(
        "/collection/authentication/email",
        authentication_headers,
        email_fields);
    ASSERT_NE(email, nullptr);
    ASSERT_EQ(email->status, 303) << email->body;
    const std::string confirmation_path = urlRequestPath(
        readFile(config.email.link_file));
    ASSERT_FALSE(confirmation_path.empty());
    auto limited_form = client.Get("/collection/authentication");
    ASSERT_NE(limited_form, nullptr);
    const httplib::Headers limited_headers = {{
        "Cookie",
        responseCookie(*limited_form, "card_collection_auth_form")}};
    const httplib::Params limited_fields = {
        {"email", "ADMIN@example.com"},
        {"form_nonce", hiddenValue(limited_form->body, "form_nonce")}};
    auto limited = client.Post(
        "/collection/authentication/email",
        limited_headers,
        limited_fields);
    ASSERT_NE(limited, nullptr);
    EXPECT_EQ(limited->status, 429);
    EXPECT_FALSE(limited->get_header_value("Retry-After").empty());
    auto confirmation = client.Get(confirmation_path);
    ASSERT_NE(confirmation, nullptr);
    ASSERT_EQ(confirmation->status, 200) << confirmation->body;
    EXPECT_EQ(
        confirmation->get_header_value("Referrer-Policy"), "no-referrer");
    const std::string confirmation_cookie = responseCookie(
        *confirmation, "card_collection_confirm_form");
    const std::string confirmation_nonce = hiddenValue(
        confirmation->body, "form_nonce");
    const httplib::Headers confirmation_headers = {
        {"Cookie", confirmation_cookie}};
    const httplib::Params confirmation_fields = {
        {"form_nonce", confirmation_nonce}};
    auto confirmed = client.Post(
        confirmation_path,
        confirmation_headers,
        confirmation_fields);
    ASSERT_NE(confirmed, nullptr);
    ASSERT_EQ(confirmed->status, 303) << confirmed->body;
    const std::string session_cookie = responseCookie(
        *confirmed, "card_collection_session");
    ASSERT_FALSE(session_cookie.empty());
    EXPECT_NE(
        confirmed->get_header_value("Set-Cookie").find("HttpOnly"),
        std::string::npos);

    const httplib::Headers session_headers = {{"Cookie", session_cookie}};
    auto onboarding = client.Get(
        "/collection/onboarding/username", session_headers);
    ASSERT_NE(onboarding, nullptr);
    ASSERT_EQ(onboarding->status, 200) << onboarding->body;
    const std::string csrf_token = hiddenValue(
        onboarding->body, "csrf_token");
    ASSERT_FALSE(csrf_token.empty());
    const httplib::Params onboarding_fields = {
        {"username", "Administrator"}, {"csrf_token", csrf_token}};
    auto onboarded = client.Post(
        "/collection/onboarding/username",
        session_headers,
        onboarding_fields);
    ASSERT_NE(onboarded, nullptr);
    ASSERT_EQ(onboarded->status, 303) << onboarded->body;

    auto administrator_collection = client.Get(
        "/collection/collection", session_headers);
    ASSERT_NE(administrator_collection, nullptr);
    ASSERT_EQ(administrator_collection->status, 200)
        << administrator_collection->body;
    EXPECT_NE(
        administrator_collection->body.find("/collection/admin/cards"),
        std::string::npos);
    EXPECT_NE(
        administrator_collection->body.find("/collection/admin/series"),
        std::string::npos);
    EXPECT_NE(
        administrator_collection->body.find("/collection/admin/games"),
        std::string::npos);
    EXPECT_NE(
        administrator_collection->body.find("/collection/admin/users"),
        std::string::npos);

    auto card = client.Get("/collection/cards/test-1", session_headers);
    ASSERT_NE(card, nullptr);
    EXPECT_EQ(card->status, 200) << card->body;
    EXPECT_NE(card->body.find("Card &lt;One&gt;"), std::string::npos);
    EXPECT_NE(card->body.find("Not currently"), std::string::npos);
    auto create = client.Get("/collection/cards/new", session_headers);
    ASSERT_NE(create, nullptr);
    EXPECT_EQ(create->status, 200) << create->body;
    EXPECT_NE(create->body.find("Test Game"), std::string::npos);
    EXPECT_EQ(create->body.find("Going Home"), std::string::npos);
    auto edit = client.Get(
        "/collection/cards/test-1/edit", session_headers);
    ASSERT_NE(edit, nullptr);
    EXPECT_EQ(edit->status, 200) << edit->body;
    EXPECT_NE(edit->body.find("value=\"20\""), std::string::npos);
    auto series = client.Get(
        "/collection/admin/series", session_headers);
    ASSERT_NE(series, nullptr);
    EXPECT_EQ(series->status, 200) << series->body;
    EXPECT_NE(series->body.find("Core &lt;Set&gt;"), std::string::npos);
    auto games_page = client.Get(
        "/collection/admin/games", session_headers);
    ASSERT_NE(games_page, nullptr);
    EXPECT_EQ(games_page->status, 200) << games_page->body;
    EXPECT_NE(games_page->body.find("Test Game"), std::string::npos);
    EXPECT_NE(games_page->body.find("Dynamic extension test game."),
              std::string::npos);
    EXPECT_NE(games_page->body.find("HP"), std::string::npos);
    EXPECT_EQ(games_page->body.find("Going Home"), std::string::npos);
    auto stylesheet = client.Get("/collection/static/css/styles.css");
    ASSERT_NE(stylesheet, nullptr);
    EXPECT_EQ(stylesheet->status, 200);
    auto card_asset = client.Get(
        "/collection/static-cards/test-1/front-art.avif");
    ASSERT_NE(card_asset, nullptr);
    EXPECT_EQ(card_asset->status, 200);
    EXPECT_EQ(card_asset->body, "front");
    auto traversal = client.Get(
        "/collection/static-cards/../cards.sqlite3");
    ASSERT_NE(traversal, nullptr);
    EXPECT_EQ(traversal->status, 404);

    const httplib::Params csrf_fields = {{"csrf_token", csrf_token}};
    const httplib::Params oversized_game_fields = {
        {"csrf_token", csrf_token},
        {"short_name", "oversized"},
        {"display_name", "Oversized"},
        {"description", std::string(1024 * 1024 + 1, 'x')},
    };
    auto oversized_game = client.Post(
        "/collection/admin/games",
        session_headers,
        oversized_game_fields);
    ASSERT_NE(oversized_game, nullptr);
    EXPECT_EQ(oversized_game->status, 413);

    const httplib::Params dynamic_game_fields = {
        {"csrf_token", csrf_token},
        {"short_name", "dynamic"},
        {"display_name", "Dynamic Game"},
        {"description", "A **dynamic** game."},
        {"visibility", "PUBLIC"},
    };
    const httplib::Params missing_csrf_game_fields = {
        {"short_name", "forbidden"},
        {"display_name", "Forbidden"},
        {"description", ""},
    };
    auto missing_csrf_game = client.Post(
        "/collection/admin/games",
        session_headers,
        missing_csrf_game_fields);
    ASSERT_NE(missing_csrf_game, nullptr);
    EXPECT_EQ(missing_csrf_game->status, 403);

    const httplib::Params invalid_game_fields = {
        {"csrf_token", csrf_token},
        {"short_name", "Invalid-Game"},
        {"display_name", "Invalid Game"},
        {"description", ""},
        {"visibility", "PUBLIC"},
    };
    auto invalid_game = client.Post(
        "/collection/admin/games",
        session_headers,
        invalid_game_fields);
    ASSERT_NE(invalid_game, nullptr);
    EXPECT_EQ(invalid_game->status, 422) << invalid_game->body;
    EXPECT_NE(invalid_game->body.find("Check the game details"),
              std::string::npos);
    EXPECT_NE(invalid_game->body.find(
        "aria-describedby=\"GameShortNameError\""),
        std::string::npos);
    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,
        dynamic_game_fields);
    ASSERT_NE(dynamic_game, nullptr);
    ASSERT_EQ(dynamic_game->status, 303) << dynamic_game->body;

    const httplib::Params invalid_field_fields = {
        {"csrf_token", csrf_token},
        {"game_revision", "1"},
        {"key", "Invalid-Key"},
        {"label", "Score"},
        {"type", "INTEGER"},
    };
    auto invalid_field = client.Post(
        "/collection/admin/games/dynamic/fields",
        session_headers,
        invalid_field_fields);
    ASSERT_NE(invalid_field, nullptr);
    EXPECT_EQ(invalid_field->status, 422) << invalid_field->body;
    EXPECT_NE(invalid_field->body.find("Check the field definition"),
              std::string::npos);
    EXPECT_NE(invalid_field->body.find(
        "aria-describedby=\"FieldKeyError\""), std::string::npos);
    EXPECT_NE(invalid_field->body.find("Invalid-Key"),
              std::string::npos);

    const httplib::Params integer_field_fields = {
        {"csrf_token", csrf_token},
        {"game_revision", "1"},
        {"key", "score"},
        {"label", "Score"},
        {"type", "INTEGER"},
    };
    auto integer_field = client.Post(
        "/collection/admin/games/dynamic/fields",
        session_headers,
        integer_field_fields);
    ASSERT_NE(integer_field, nullptr);
    ASSERT_EQ(integer_field->status, 303) << integer_field->body;

    const httplib::Params string_field_fields = {
        {"csrf_token", csrf_token},
        {"game_revision", "2"},
        {"key", "note"},
        {"label", "Note"},
        {"type", "STRING"},
    };
    auto string_field = client.Post(
        "/collection/admin/games/dynamic/fields",
        session_headers,
        string_field_fields);
    ASSERT_NE(string_field, nullptr);
    ASSERT_EQ(string_field->status, 303) << string_field->body;

    const httplib::Params choice_field_fields = {
        {"csrf_token", csrf_token},
        {"game_revision", "3"},
        {"key", "route"},
        {"label", "Route"},
        {"type", "CHOICE"},
        {"choice", "Forest & River"},
        {"choice", "Coast+Town"},
    };
    auto choice_field = client.Post(
        "/collection/admin/games/dynamic/fields",
        session_headers,
        choice_field_fields);
    ASSERT_NE(choice_field, nullptr);
    ASSERT_EQ(choice_field->status, 303) << choice_field->body;

    const httplib::Params field_order_fields = {
        {"csrf_token", csrf_token},
        {"game_revision", "4"},
        {"field_id", "5"},
        {"field_id", "4"},
        {"field_id", "3"},
    };
    auto fields_reordered = client.Post(
        "/collection/admin/games/dynamic/field-order",
        session_headers,
        field_order_fields);
    ASSERT_NE(fields_reordered, nullptr);
    ASSERT_EQ(fields_reordered->status, 303) << fields_reordered->body;

    const httplib::Params game_update_fields = {
        {"csrf_token", csrf_token},
        {"game_revision", "5"},
        {"display_name", "Dynamic Renamed"},
        {"description", "A **rendered** description."},
        {"visibility", "PUBLIC"},
    };
    auto game_updated = client.Post(
        "/collection/admin/games/dynamic",
        session_headers,
        game_update_fields);
    ASSERT_NE(game_updated, nullptr);
    ASSERT_EQ(game_updated->status, 303) << game_updated->body;
    auto dynamic_games_page = client.Get(
        "/collection/admin/games", session_headers);
    ASSERT_NE(dynamic_games_page, nullptr);
    ASSERT_EQ(dynamic_games_page->status, 200);
    EXPECT_NE(dynamic_games_page->body.find("Dynamic Renamed"),
              std::string::npos);
    EXPECT_NE(dynamic_games_page->body.find("<strong>rendered</strong>"),
              std::string::npos);
    auto foreign_field = client.Get(
        "/collection/admin/games/dynamic/fields/1/edit",
        session_headers);
    ASSERT_NE(foreign_field, nullptr);
    EXPECT_EQ(foreign_field->status, 404);

    const httplib::Params stale_game_fields = {
        {"csrf_token", csrf_token},
        {"game_revision", "4"},
        {"display_name", "Stale name"},
        {"description", ""},
        {"visibility", "PUBLIC"},
    };
    auto stale_game = client.Post(
        "/collection/admin/games/dynamic",
        session_headers,
        stale_game_fields);
    ASSERT_NE(stale_game, nullptr);
    EXPECT_EQ(stale_game->status, 409) << stale_game->body;
    EXPECT_NE(stale_game->body.find("Nothing was saved"),
              std::string::npos);
    EXPECT_NE(stale_game->body.find(
        "/collection/admin/games/dynamic/edit"), std::string::npos);

    const httplib::Params dynamic_series_fields = {
        {"csrf_token", csrf_token},
        {"game", "dynamic"},
        {"name", "Dynamic series"},
        {"description", "A dynamic series."},
    };
    auto dynamic_series = client.Post(
        "/collection/admin/series",
        session_headers,
        dynamic_series_fields);
    ASSERT_NE(dynamic_series, nullptr);
    ASSERT_EQ(dynamic_series->status, 303) << dynamic_series->body;

    const httplib::UploadFormDataItems dynamic_card_fields = {
        {"csrf_token", csrf_token, "", ""},
        {"game", "dynamic", "", ""},
        {"game_revision", "6", "", ""},
        {"name", "Dynamic card", "", ""},
        {"rarity", "7", "", ""},
        {"game.score", "9007199254740993", "", ""},
        {"game.note", "Line <one>\nLine two", "", ""},
        {"game.route", "Forest & River", "", ""},
        {"series_id", "2", "", ""},
        {"source_mode", "files", "", ""},
        {"front", upload_bytes, "card.png", "image/png"},
    };
    httplib::UploadFormDataItems invalid_dynamic_card_fields =
        dynamic_card_fields;
    for(httplib::UploadFormData& field : invalid_dynamic_card_fields)
    {
        if(field.name == "game.route")
        {
            field.content = "Unknown route";
        }
    }
    auto invalid_dynamic_card = client.Post(
        "/collection/cards",
        session_headers,
        invalid_dynamic_card_fields);
    ASSERT_NE(invalid_dynamic_card, nullptr);
    EXPECT_EQ(invalid_dynamic_card->status, 422)
        << invalid_dynamic_card->body;
    EXPECT_NE(invalid_dynamic_card->body.find("Check your card details"),
              std::string::npos);
    EXPECT_NE(invalid_dynamic_card->body.find(
        "aria-describedby=\"GameField-dynamic-route-error\""),
        std::string::npos);
    EXPECT_NE(invalid_dynamic_card->body.find("9007199254740993"),
              std::string::npos);
    EXPECT_NE(invalid_dynamic_card->body.find("must be selected again"),
              std::string::npos);

    auto dynamic_card = client.Post(
        "/collection/cards", session_headers, dynamic_card_fields);
    ASSERT_NE(dynamic_card, nullptr);
    ASSERT_EQ(dynamic_card->status, 303) << dynamic_card->body;
    const std::string dynamic_card_path = urlRequestPath(
        dynamic_card->get_header_value("Location"));
    EXPECT_EQ(dynamic_card_path, "/collection/cards/dynamic-1");
    auto dynamic_card_page = client.Get(
        dynamic_card_path, session_headers);
    ASSERT_NE(dynamic_card_page, nullptr);
    ASSERT_EQ(dynamic_card_page->status, 200) << dynamic_card_page->body;
    EXPECT_NE(dynamic_card_page->body.find("9007199254740993"),
              std::string::npos);
    EXPECT_NE(dynamic_card_page->body.find("Line &lt;one&gt;\nLine two"),
              std::string::npos);
    EXPECT_NE(dynamic_card_page->body.find("Forest &amp; River"),
              std::string::npos);

    const httplib::UploadFormDataItems dynamic_edit_fields = {
        {"csrf_token", csrf_token, "", ""},
        {"name", "Dynamic card edited", "", ""},
        {"rarity", "8", "", ""},
        {"revision", "1", "", ""},
        {"game_revision", "6", "", ""},
        {"game.score", "-7", "", ""},
        {"game.note", "Whitespace  kept", "", ""},
        {"game.route", "Coast+Town", "", ""},
        {"series_id", "2", "", ""},
        {"source_mode", "files", "", ""},
        {"front_action", "keep", "", ""},
        {"foil_action", "keep", "", ""},
    };
    auto dynamic_edited = client.Post(
        dynamic_card_path + "/edit",
        session_headers,
        dynamic_edit_fields);
    ASSERT_NE(dynamic_edited, nullptr);
    ASSERT_EQ(dynamic_edited->status, 303) << dynamic_edited->body;
    dynamic_card_page = client.Get(dynamic_card_path, session_headers);
    ASSERT_NE(dynamic_card_page, nullptr);
    ASSERT_EQ(dynamic_card_page->status, 200);
    EXPECT_NE(dynamic_card_page->body.find("Dynamic card edited"),
              std::string::npos);
    EXPECT_NE(dynamic_card_page->body.find("Coast+Town"),
              std::string::npos);

    const httplib::Params second_game_update_fields = {
        {"csrf_token", csrf_token},
        {"game_revision", "6"},
        {"display_name", "Dynamic Final"},
        {"description", "Final description."},
        {"visibility", "PUBLIC"},
    };
    auto second_game_update = client.Post(
        "/collection/admin/games/dynamic",
        session_headers,
        second_game_update_fields);
    ASSERT_NE(second_game_update, nullptr);
    ASSERT_EQ(second_game_update->status, 303)
        << second_game_update->body;
    httplib::UploadFormDataItems stale_dynamic_edit_fields =
        dynamic_edit_fields;
    for(httplib::UploadFormData& field : stale_dynamic_edit_fields)
    {
        if(field.name == "name")
        {
            field.content = "Stale card change";
        }
    }
    auto stale_card = client.Post(
        dynamic_card_path + "/edit",
        session_headers,
        stale_dynamic_edit_fields);
    ASSERT_NE(stale_card, nullptr);
    EXPECT_EQ(stale_card->status, 409) << stale_card->body;
    EXPECT_NE(stale_card->body.find("must be selected again"),
              std::string::npos);
    dynamic_card_page = client.Get(dynamic_card_path, session_headers);
    ASSERT_NE(dynamic_card_page, nullptr);
    EXPECT_EQ(dynamic_card_page->status, 200);
    EXPECT_EQ(dynamic_card_page->body.find("Stale card change"),
              std::string::npos);
    EXPECT_NE(dynamic_card_page->body.find("Dynamic card edited"),
              std::string::npos);

    const httplib::Params used_choice_fields = {
        {"csrf_token", csrf_token},
        {"game_revision", "7"},
        {"label", "Route"},
        {"choice", "Forest & River"},
    };
    auto used_choice = client.Post(
        "/collection/admin/games/dynamic/fields/5",
        session_headers,
        used_choice_fields);
    ASSERT_NE(used_choice, nullptr);
    EXPECT_EQ(used_choice->status, 409) << used_choice->body;
    EXPECT_NE(used_choice->body.find("1 card"), std::string::npos);

    auto dynamic_deleted = client.Post(
        dynamic_card_path + "/delete", session_headers, csrf_fields);
    ASSERT_NE(dynamic_deleted, nullptr);
    ASSERT_EQ(dynamic_deleted->status, 303) << dynamic_deleted->body;
    auto dynamic_series_deleted = client.Post(
        "/collection/admin/series/2/delete",
        session_headers,
        csrf_fields);
    ASSERT_NE(dynamic_series_deleted, nullptr);
    ASSERT_EQ(dynamic_series_deleted->status, 303)
        << dynamic_series_deleted->body;

    auto choice_after_card = client.Post(
        "/collection/admin/games/dynamic/fields/5",
        session_headers,
        used_choice_fields);
    ASSERT_NE(choice_after_card, nullptr);
    ASSERT_EQ(choice_after_card->status, 303) << choice_after_card->body;
    const httplib::Params delete_choice_field = {
        {"csrf_token", csrf_token}, {"game_revision", "8"}};
    const httplib::Params delete_string_field = {
        {"csrf_token", csrf_token}, {"game_revision", "9"}};
    const httplib::Params delete_integer_field = {
        {"csrf_token", csrf_token}, {"game_revision", "10"}};
    auto choice_deleted = client.Post(
        "/collection/admin/games/dynamic/fields/5/delete",
        session_headers,
        delete_choice_field);
    ASSERT_NE(choice_deleted, nullptr);
    ASSERT_EQ(choice_deleted->status, 303) << choice_deleted->body;
    auto string_deleted = client.Post(
        "/collection/admin/games/dynamic/fields/4/delete",
        session_headers,
        delete_string_field);
    ASSERT_NE(string_deleted, nullptr);
    ASSERT_EQ(string_deleted->status, 303) << string_deleted->body;
    auto integer_deleted = client.Post(
        "/collection/admin/games/dynamic/fields/3/delete",
        session_headers,
        delete_integer_field);
    ASSERT_NE(integer_deleted, nullptr);
    ASSERT_EQ(integer_deleted->status, 303) << integer_deleted->body;

    const httplib::Params delete_issued_game = {
        {"csrf_token", csrf_token}, {"game_revision", "11"}};
    auto issued_game = client.Post(
        "/collection/admin/games/dynamic/delete",
        session_headers,
        delete_issued_game);
    ASSERT_NE(issued_game, nullptr);
    EXPECT_EQ(issued_game->status, 409) << issued_game->body;

    const httplib::Params empty_game_fields = {
        {"csrf_token", csrf_token},
        {"short_name", "empty"},
        {"display_name", "Empty Game"},
        {"description", ""},
        {"visibility", "PUBLIC"},
    };
    auto empty_game = client.Post(
        "/collection/admin/games", session_headers, empty_game_fields);
    ASSERT_NE(empty_game, nullptr);
    ASSERT_EQ(empty_game->status, 303) << empty_game->body;
    const httplib::Params delete_empty_game = {
        {"csrf_token", csrf_token}, {"game_revision", "1"}};
    auto empty_deleted = client.Post(
        "/collection/admin/games/empty/delete",
        session_headers,
        delete_empty_game);
    ASSERT_NE(empty_deleted, nullptr);
    EXPECT_EQ(empty_deleted->status, 303) << empty_deleted->body;

    const httplib::UploadFormDataItems invalid_loose_fields = {
        {"csrf_token", csrf_token, "", ""},
        {"game", "", "", ""},
        {"game.route", "Forest", "", ""},
        {"name", "Tampered loose card", "", ""},
        {"rarity", "1", "", ""},
        {"source_mode", "files", "", ""},
        {"front", upload_bytes, "card.png", "image/png"},
    };
    auto invalid_loose = client.Post(
        "/collection/cards", session_headers, invalid_loose_fields);
    ASSERT_NE(invalid_loose, nullptr);
    EXPECT_EQ(invalid_loose->status, 422) << invalid_loose->body;

    const httplib::Params going_home_fields = {
        {"csrf_token", csrf_token},
        {"short_name", "gh"},
        {"display_name", "Going Home"},
        {"description", ""},
        {"visibility", "PUBLIC"},
    };
    auto going_home = client.Post(
        "/collection/admin/games", session_headers, going_home_fields);
    ASSERT_NE(going_home, nullptr);
    ASSERT_EQ(going_home->status, 303) << going_home->body;

    const httplib::UploadFormDataItems create_fields = {
        {"csrf_token", csrf_token, "", ""},
        {"game", "gh", "", ""},
        {"game_revision", "1", "", ""},
        {"name", "Uploaded card", "", ""},
        {"rarity", "3", "", ""},
        {"source_mode", "files", "", ""},
        {"front", upload_bytes, "card.png", "image/png"},
    };
    auto created = client.Post(
        "/collection/cards", session_headers, create_fields);
    ASSERT_NE(created, nullptr);
    ASSERT_EQ(created->status, 303) << created->body;
    const std::string location = created->get_header_value("Location");
    const std::size_t path_begin = location.find("/collection/cards/");
    ASSERT_NE(path_begin, std::string::npos);
    const std::string created_path = location.substr(path_begin);
    const std::string public_id = created_path.substr(
        std::string("/collection/cards/").size());
    EXPECT_EQ(public_id, "gh-1");
    EXPECT_TRUE(std::filesystem::is_directory(
        config.card_storage_root / "published" / public_id));

    const httplib::UploadFormDataItems edit_fields = {
        {"csrf_token", csrf_token, "", ""},
        {"name", "Edited upload", "", ""},
        {"rarity", "4", "", ""},
        {"revision", "1", "", ""},
        {"game_revision", "1", "", ""},
        {"source_mode", "files", "", ""},
        {"front_action", "keep", "", ""},
        {"foil_action", "keep", "", ""},
    };
    auto edited = client.Post(
        created_path + "/edit", session_headers, edit_fields);
    ASSERT_NE(edited, nullptr);
    EXPECT_EQ(edited->status, 303) << edited->body;
    auto edited_page = client.Get(created_path, session_headers);
    ASSERT_NE(edited_page, nullptr);
    EXPECT_EQ(edited_page->status, 200) << edited_page->body;
    EXPECT_NE(edited_page->body.find("Going Home"), std::string::npos);
    EXPECT_NE(edited_page->body.find("Edited upload"), std::string::npos);

    httplib::Params create_series_fields = {
        {"csrf_token", csrf_token},
        {"game", "gh"},
        {"name", "Uploaded series"},
        {"description", "Description"},
    };
    auto created_series = client.Post(
        "/collection/admin/series", session_headers, create_series_fields);
    ASSERT_NE(created_series, nullptr);
    EXPECT_EQ(created_series->status, 303) << created_series->body;
    httplib::Params edit_series_fields = {
        {"csrf_token", csrf_token},
        {"name", "Edited series"},
        {"description", "Changed"},
    };
    auto edited_series = client.Post(
        "/collection/admin/series/2", session_headers, edit_series_fields);
    ASSERT_NE(edited_series, nullptr);
    EXPECT_EQ(edited_series->status, 303) << edited_series->body;
    auto deleted_series = client.Post(
        "/collection/admin/series/2/delete",
        session_headers,
        csrf_fields);
    ASSERT_NE(deleted_series, nullptr);
    EXPECT_EQ(deleted_series->status, 303) << deleted_series->body;

    auto player_authentication = client.Get("/collection/authentication");
    ASSERT_NE(player_authentication, nullptr);
    const std::string player_auth_cookie = responseCookie(
        *player_authentication, "card_collection_auth_form");
    const std::string player_auth_nonce = hiddenValue(
        player_authentication->body, "form_nonce");
    const httplib::Headers player_auth_headers = {
        {"Cookie", player_auth_cookie}};
    const httplib::Params player_email_fields = {
        {"email", "player@example.com"},
        {"form_nonce", player_auth_nonce}};
    auto player_email = client.Post(
        "/collection/authentication/email",
        player_auth_headers,
        player_email_fields);
    ASSERT_NE(player_email, nullptr);
    ASSERT_EQ(player_email->status, 303) << player_email->body;
    const std::string player_confirmation_path = urlRequestPath(
        readFile(config.email.link_file));
    auto player_confirmation = client.Get(player_confirmation_path);
    ASSERT_NE(player_confirmation, nullptr);
    const std::string player_confirmation_cookie = responseCookie(
        *player_confirmation, "card_collection_confirm_form");
    const httplib::Headers player_confirmation_headers = {
        {"Cookie", player_confirmation_cookie}};
    const httplib::Params player_confirmation_fields = {
        {"form_nonce", hiddenValue(
            player_confirmation->body, "form_nonce")}};
    auto player_confirmed = client.Post(
        player_confirmation_path,
        player_confirmation_headers,
        player_confirmation_fields);
    ASSERT_NE(player_confirmed, nullptr);
    ASSERT_EQ(player_confirmed->status, 303) << player_confirmed->body;
    const std::string player_session_cookie = responseCookie(
        *player_confirmed, "card_collection_session");
    const httplib::Headers player_headers = {
        {"Cookie", player_session_cookie}};
    auto player_onboarding = client.Get(
        "/collection/onboarding/username", player_headers);
    ASSERT_NE(player_onboarding, nullptr);
    const std::string player_csrf = hiddenValue(
        player_onboarding->body, "csrf_token");
    const httplib::Params player_onboarding_fields = {
        {"username", "Player"}, {"csrf_token", player_csrf}};
    auto player_onboarded = client.Post(
        "/collection/onboarding/username",
        player_headers,
        player_onboarding_fields);
    ASSERT_NE(player_onboarded, nullptr);
    ASSERT_EQ(player_onboarded->status, 303) << player_onboarded->body;
    auto player_collection = client.Get(
        "/collection/collection", player_headers);
    ASSERT_NE(player_collection, nullptr);
    ASSERT_EQ(player_collection->status, 200) << player_collection->body;
    EXPECT_EQ(
        player_collection->body.find("/collection/admin/cards"),
        std::string::npos);
    EXPECT_EQ(
        player_collection->body.find("/collection/admin/series"),
        std::string::npos);
    EXPECT_EQ(
        player_collection->body.find("/collection/admin/games"),
        std::string::npos);
    EXPECT_EQ(
        player_collection->body.find("/collection/admin/users"),
        std::string::npos);
    auto player_card_new = client.Get(
        "/collection/cards/new", player_headers);
    ASSERT_NE(player_card_new, nullptr);
    EXPECT_EQ(player_card_new->status, 403);
    auto player_admin = client.Get(
        "/collection/admin/users", player_headers);
    ASSERT_NE(player_admin, nullptr);
    EXPECT_EQ(player_admin->status, 403);
    auto player_games = client.Get(
        "/collection/admin/games", player_headers);
    ASSERT_NE(player_games, nullptr);
    EXPECT_EQ(player_games->status, 403);
    const httplib::Params player_game_fields = {
        {"csrf_token", player_csrf},
        {"short_name", "playergame"},
        {"display_name", "Player Game"},
        {"description", ""},
        {"visibility", "PUBLIC"},
    };
    auto player_game_create = client.Post(
        "/collection/admin/games",
        player_headers,
        player_game_fields);
    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,
        csrf_fields);
    ASSERT_NE(promoted, nullptr);
    ASSERT_EQ(promoted->status, 303) << promoted->body;
    auto creator_game_create = client.Post(
        "/collection/admin/games",
        player_headers,
        player_game_fields);
    ASSERT_NE(creator_game_create, nullptr);
    EXPECT_EQ(creator_game_create->status, 403);
    auto creator_new = client.Get("/collection/cards/new", player_headers);
    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", "", "", ""},
        {"name", "Creator draft", "", ""},
        {"source_mode", "files", "", ""},
        {"front", upload_bytes, "card.png", "image/png"},
    };
    auto creator_card = client.Post(
        "/collection/cards", player_headers, creator_card_fields);
    ASSERT_NE(creator_card, nullptr);
    ASSERT_EQ(creator_card->status, 303) << creator_card->body;
    const std::string creator_card_path = urlRequestPath(
        creator_card->get_header_value("Location"));
    auto creator_cards = client.Get(
        "/collection/creator/cards", player_headers);
    ASSERT_NE(creator_cards, nullptr);
    EXPECT_EQ(creator_cards->status, 200) << creator_cards->body;
    EXPECT_NE(creator_cards->body.find("Creator draft"), std::string::npos);
    const httplib::UploadFormDataItems pool_edit_fields = {
        {"csrf_token", csrf_token, "", ""},
        {"name", "Creator draft", "", ""},
        {"rarity", "2", "", ""},
        {"revision", "1", "", ""},
        {"source_mode", "files", "", ""},
        {"front_action", "keep", "", ""},
        {"foil_action", "keep", "", ""},
    };
    auto pooled = client.Post(
        creator_card_path + "/edit", session_headers, pool_edit_fields);
    ASSERT_NE(pooled, nullptr);
    ASSERT_EQ(pooled->status, 303) << pooled->body;
    const httplib::Params player_pull_fields = {
        {"csrf_token", player_csrf}};
    auto player_pull = client.Post(
        "/collection/collection/pull", player_headers, player_pull_fields);
    ASSERT_NE(player_pull, nullptr);
    ASSERT_EQ(player_pull->status, 303) << player_pull->body;
    EXPECT_EQ(
        urlRequestPath(player_pull->get_header_value("Location")),
        creator_card_path);
    auto owned_card = client.Get(creator_card_path, player_headers);
    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(
        "/collection/logout", player_headers, player_logout_fields);
    ASSERT_NE(logged_out, nullptr);
    EXPECT_EQ(logged_out->status, 303) << logged_out->body;
    auto after_logout = client.Get("/collection/collection", player_headers);
    ASSERT_NE(after_logout, nullptr);
    EXPECT_EQ(after_logout->status, 303);

    app.stop();
    app.wait();
}