Changes
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 65c5a50..ae6da9a 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -16,11 +16,20 @@ include(cmake/dependencies.cmake)
add_executable(
card_collection
+ src/app.cpp
src/data.cpp
+ src/data_fake.cpp
src/main.cpp
+ src/public_id.cpp
+ src/url_builder.cpp
)
target_compile_features(card_collection PRIVATE cxx_std_23)
+target_compile_definitions(
+ card_collection
+ PRIVATE
+ CARD_COLLECTION_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}"
+)
set_target_properties(
card_collection
PROPERTIES
@@ -76,4 +85,57 @@ if(CARD_COLLECTION_BUILD_TESTS)
include(GoogleTest)
gtest_discover_tests(data_mock_test)
+
+ add_executable(
+ app_test
+ src/app.cpp
+ src/data_fake.cpp
+ src/public_id.cpp
+ src/url_builder.cpp
+ tests/app_test.cpp
+ )
+ target_compile_features(app_test PRIVATE cxx_std_23)
+ set_target_properties(app_test PROPERTIES CXX_EXTENSIONS OFF)
+ target_include_directories(
+ app_test
+ PRIVATE
+ ${libmw_SOURCE_DIR}/includes
+ src
+ )
+ target_link_libraries(
+ app_test
+ PRIVATE
+ GTest::gtest_main
+ mw::http-server
+ mw::url
+ pantor::inja
+ spdlog::spdlog
+ )
+ target_compile_definitions(
+ app_test
+ PRIVATE
+ CARD_COLLECTION_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}"
+ )
+ gtest_discover_tests(app_test)
+
+ add_executable(
+ data_fake_test
+ src/data_fake.cpp
+ tests/data_fake_test.cpp
+ )
+ target_compile_features(data_fake_test PRIVATE cxx_std_23)
+ set_target_properties(data_fake_test PROPERTIES CXX_EXTENSIONS OFF)
+ target_include_directories(
+ data_fake_test
+ PRIVATE
+ ${libmw_SOURCE_DIR}/includes
+ src
+ )
+ target_link_libraries(
+ data_fake_test
+ PRIVATE
+ GTest::gtest_main
+ mw::mw
+ )
+ gtest_discover_tests(data_fake_test)
endif()
diff --git a/src/app.cpp b/src/app.cpp
new file mode 100644
index 0000000..bb5cb91
--- /dev/null
+++ b/src/app.cpp
@@ -0,0 +1,391 @@
+#include "app.h"
+
+#include <algorithm>
+#include <cctype>
+#include <cstddef>
+#include <filesystem>
+#include <functional>
+#include <memory>
+#include <stdexcept>
+#include <string>
+#include <system_error>
+#include <unordered_map>
+#include <utility>
+#include <vector>
+
+#include <spdlog/spdlog.h>
+
+#include "public_id.h"
+
+namespace
+{
+
+enum class RouteKind
+{
+ DYNAMIC,
+ STATIC_MOUNT
+};
+
+struct RouteDefinition
+{
+ RouteKind kind;
+ std::vector<RouteSegment> segments;
+};
+
+struct IndexCard
+{
+ const Card* card;
+ std::string public_id;
+};
+
+RouteSegment literal(std::string value)
+{
+ return {RouteSegmentKind::LITERAL, std::move(value)};
+}
+
+RouteSegment placeholder(std::string value)
+{
+ return {RouteSegmentKind::PLACEHOLDER, std::move(value)};
+}
+
+const std::unordered_map<std::string, RouteDefinition> ROUTES = {
+ {"card-index", {RouteKind::DYNAMIC, {}}},
+ {"card-new", {RouteKind::DYNAMIC, {literal("cards"), literal("new")}}},
+ {"cards", {RouteKind::DYNAMIC, {literal("cards")}}},
+ {"card", {RouteKind::DYNAMIC,
+ {literal("cards"), placeholder("id")}}},
+ {"card-edit", {RouteKind::DYNAMIC,
+ {literal("cards"), placeholder("id"), literal("edit")}}},
+ {"card-delete",
+ {RouteKind::DYNAMIC,
+ {literal("cards"), placeholder("id"), literal("delete")}}},
+ {"series-index", {RouteKind::DYNAMIC, {literal("series")}}},
+ {"series-new",
+ {RouteKind::DYNAMIC, {literal("series"), literal("new")}}},
+ {"series", {RouteKind::DYNAMIC, {literal("series")}}},
+ {"series-edit",
+ {RouteKind::DYNAMIC,
+ {literal("series"), placeholder("integer"), literal("edit")}}},
+ {"series-item",
+ {RouteKind::DYNAMIC,
+ {literal("series"), placeholder("integer")}}},
+ {"series-delete",
+ {RouteKind::DYNAMIC,
+ {literal("series"), placeholder("integer"), literal("delete")}}},
+ {"card-asset", {RouteKind::STATIC_MOUNT, {literal("static-cards")}}},
+ {"static", {RouteKind::STATIC_MOUNT, {literal("static")}}},
+};
+
+const RouteDefinition& routeDefinition(const std::string& name)
+{
+ const auto route = ROUTES.find(name);
+ if(route == ROUTES.end())
+ {
+ throw std::invalid_argument("Unknown route name: " + name);
+ }
+ return route->second;
+}
+
+std::size_t argumentCount(const RouteDefinition& route)
+{
+ std::size_t count = 0;
+ for(const RouteSegment& segment : route.segments)
+ {
+ if(segment.kind == RouteSegmentKind::PLACEHOLDER)
+ {
+ ++count;
+ }
+ }
+ return count;
+}
+
+std::vector<RouteSegment> resolveRoute(
+ const RouteDefinition& route,
+ const std::vector<std::string>& arguments,
+ RouteSegmentKind argument_kind)
+{
+ if(arguments.size() != argumentCount(route))
+ {
+ throw std::invalid_argument("Incorrect route argument count");
+ }
+
+ std::vector<RouteSegment> result;
+ result.reserve(route.segments.size());
+ std::size_t argument_index = 0;
+ for(const RouteSegment& segment : route.segments)
+ {
+ if(segment.kind == RouteSegmentKind::PLACEHOLDER)
+ {
+ result.push_back(
+ {argument_kind, arguments[argument_index++]});
+ }
+ else
+ {
+ result.push_back(segment);
+ }
+ }
+ return result;
+}
+
+bool indexCardLess(const IndexCard& left, const IndexCard& right)
+{
+ if(naturalPublicIdLess(left.public_id, right.public_id))
+ {
+ return true;
+ }
+ if(naturalPublicIdLess(right.public_id, left.public_id))
+ {
+ return false;
+ }
+ return left.card->id < right.card->id;
+}
+
+void sortIndexCards(std::vector<IndexCard>& cards, bool descending)
+{
+ if(descending)
+ {
+ std::ranges::sort(
+ cards,
+ [](const IndexCard& left, const IndexCard& right)
+ {
+ return indexCardLess(right, left);
+ });
+ return;
+ }
+ std::ranges::sort(cards, indexCardLess);
+}
+
+std::string uppercaseAscii(std::string value)
+{
+ for(char& character : value)
+ {
+ character = static_cast<char>(
+ std::toupper(static_cast<unsigned char>(character)));
+ }
+ return value;
+}
+
+void respondInternalError(App::Response& response)
+{
+ response.status = 500;
+ response.set_content(
+ "<!doctype html><title>Internal server error</title>"
+ "<h1>Internal server error</h1>",
+ "text/html; charset=utf-8");
+}
+
+} // namespace
+
+App::App(
+ const Config& config,
+ std::unique_ptr<DataSourceInterface> data_source)
+ : mw::HTTPServer(config.listen_address),
+ config_(config),
+ data_source_(std::move(data_source)),
+ url_builder_(config.base_url),
+ templates_(config.static_root.parent_path() / "templates")
+{
+ if(!data_source_)
+ {
+ throw std::invalid_argument("App requires a data source");
+ }
+
+ templates_.set_html_autoescape(true);
+ templates_.add_callback(
+ "url_for",
+ [this](inja::Arguments& callback_arguments) -> inja::json
+ {
+ if(callback_arguments.empty() ||
+ !callback_arguments.front()->is_string())
+ {
+ throw std::invalid_argument(
+ "url_for requires a string route name");
+ }
+
+ std::vector<std::string> arguments;
+ arguments.reserve(callback_arguments.size() - 1);
+ for(std::size_t index = 1;
+ index < callback_arguments.size();
+ ++index)
+ {
+ if(!callback_arguments[index]->is_string())
+ {
+ throw std::invalid_argument(
+ "url_for arguments must be strings");
+ }
+ arguments.push_back(
+ callback_arguments[index]->get<std::string>());
+ }
+ return urlFor(
+ callback_arguments.front()->get<std::string>(),
+ arguments);
+ });
+ card_index_template_ = templates_.parse_template("card_index.html");
+}
+
+std::string App::urlFor(
+ const std::string& name,
+ const std::vector<std::string>& arguments,
+ const QueryParameters& query) const
+{
+ const RouteDefinition& route = routeDefinition(name);
+ if(route.kind == RouteKind::STATIC_MOUNT)
+ {
+ if(arguments.size() != 1)
+ {
+ throw std::invalid_argument(
+ "Incorrect static route argument count");
+ }
+ return url_builder_.absoluteFromRelativePath(
+ route.segments, arguments.front(), query);
+ }
+
+ return url_builder_.absolute(
+ resolveRoute(route, arguments, RouteSegmentKind::DYNAMIC), query);
+}
+
+void App::handleCardIndex(
+ const Request& request,
+ Response& response)
+{
+ auto cards_result = data_source_->getCards();
+ if(!cards_result)
+ {
+ spdlog::error(
+ "Failed to load the card index: {}",
+ cards_result.error().msg());
+ respondInternalError(response);
+ return;
+ }
+
+ std::vector<IndexCard> cards;
+ cards.reserve(cards_result->size());
+ for(const Card& card : *cards_result)
+ {
+ auto public_id = formatPublicId(card.identity);
+ if(!public_id)
+ {
+ spdlog::error(
+ "Failed to format card {} for the index: {}",
+ card.id,
+ public_id.error().msg());
+ respondInternalError(response);
+ return;
+ }
+ cards.push_back({&card, std::move(*public_id)});
+ }
+
+ const bool descending =
+ request.has_param("direction") &&
+ request.get_param_value("direction") == "desc";
+ sortIndexCards(cards, descending);
+
+ inja::json template_cards = inja::json::array();
+ for(const IndexCard& index_card : cards)
+ {
+ const Card& card = *index_card.card;
+ const std::string thumbnail_name =
+ "thumb." + card.thumbnail_extension;
+ const std::filesystem::path thumbnail_path =
+ config_.card_storage_root / "published" /
+ index_card.public_id / thumbnail_name;
+
+ std::string thumbnail_url;
+ std::error_code filesystem_error;
+ const bool thumbnail_exists = std::filesystem::is_regular_file(
+ thumbnail_path, filesystem_error);
+ if(filesystem_error &&
+ filesystem_error != std::errc::no_such_file_or_directory)
+ {
+ spdlog::warn(
+ "Failed to inspect the thumbnail for card {}: {}",
+ card.id,
+ filesystem_error.message());
+ }
+
+ if(thumbnail_exists)
+ {
+ thumbnail_url = urlFor(
+ "card-asset",
+ {index_card.public_id + "/" + thumbnail_name},
+ {{"v", std::to_string(card.revision)}});
+ }
+ else
+ {
+ thumbnail_url = urlFor("static", {"card_placeholder.svg"});
+ }
+
+ template_cards.push_back({
+ {"display_id", uppercaseAscii(index_card.public_id)},
+ {"name", card.name},
+ {"thumbnail_url", std::move(thumbnail_url)},
+ {"url", urlFor("card", {index_card.public_id})},
+ });
+ }
+
+ const inja::json template_data = {
+ {"ascending_url",
+ urlFor("card-index", {}, {{"sort", "id"}, {"direction", "asc"}})},
+ {"cards", std::move(template_cards)},
+ {"descending", descending},
+ {"descending_url",
+ urlFor("card-index", {}, {{"sort", "id"}, {"direction", "desc"}})},
+ {"title", "Card Collection"},
+ };
+
+ try
+ {
+ response.status = 200;
+ response.set_content(
+ templates_.render(card_index_template_, template_data),
+ "text/html; charset=utf-8");
+ }
+ catch(const std::exception& error)
+ {
+ spdlog::error("Failed to render the card index: {}", error.what());
+ respondInternalError(response);
+ }
+}
+
+void App::setup()
+{
+ const std::filesystem::path published_cards =
+ config_.card_storage_root / "published";
+ if(!server.set_mount_point(
+ getMountPath("static"), config_.static_root.string()))
+ {
+ spdlog::error("Failed to mount application static files");
+ }
+ if(!server.set_mount_point(
+ getMountPath("card-asset"), published_cards.string()))
+ {
+ spdlog::error("Failed to mount published card assets");
+ }
+
+ server.Get(
+ getPath("card-index"),
+ std::bind_front(&App::handleCardIndex, this));
+}
+
+std::string App::getPath(
+ const std::string& name,
+ const std::vector<std::string>& argument_names) const
+{
+ const RouteDefinition& route = routeDefinition(name);
+ if(route.kind != RouteKind::DYNAMIC)
+ {
+ throw std::invalid_argument("Static mount has no handler path");
+ }
+ return url_builder_.requestPath(resolveRoute(
+ route, argument_names, RouteSegmentKind::PLACEHOLDER));
+}
+
+std::string App::getMountPath(const std::string& name) const
+{
+ const RouteDefinition& route = routeDefinition(name);
+ if(route.kind != RouteKind::STATIC_MOUNT)
+ {
+ throw std::invalid_argument("Dynamic route has no mount path");
+ }
+ return url_builder_.requestPath(route.segments);
+}
diff --git a/src/app.h b/src/app.h
new file mode 100644
index 0000000..1df9ccb
--- /dev/null
+++ b/src/app.h
@@ -0,0 +1,58 @@
+#pragma once
+
+#include <memory>
+#include <string>
+#include <vector>
+
+#include <inja/inja.hpp>
+#include <mw/http_server.hpp>
+
+#include "config.h"
+#include "data.h"
+#include "url_builder.h"
+
+/// Card Collection HTTP application and named-route owner.
+class App : public mw::HTTPServer
+{
+public:
+ /// HTTP request type supplied by libmw.
+ using Request = mw::HTTPServer::Request;
+
+ /// HTTP response type supplied by libmw.
+ using Response = mw::HTTPServer::Response;
+
+ /// Disable construction without validated configuration.
+ App() = delete;
+
+ /// Construct the application from configuration and a data source.
+ App(
+ const Config& config,
+ std::unique_ptr<DataSourceInterface> data_source);
+
+ /// Return the absolute URL for a named application route.
+ std::string urlFor(
+ const std::string& name,
+ const std::vector<std::string>& arguments = {},
+ const QueryParameters& query = {}) const;
+
+ /// Render the card index.
+ void handleCardIndex(const Request& request, Response& response);
+
+private:
+ /// Register implemented handlers and static mounts.
+ void setup() override;
+
+ /// Return the server request path for a named dynamic route.
+ std::string getPath(
+ const std::string& name,
+ const std::vector<std::string>& argument_names = {}) const;
+
+ /// Return the request prefix for a named static mount.
+ std::string getMountPath(const std::string& name) const;
+
+ Config config_;
+ std::unique_ptr<DataSourceInterface> data_source_;
+ UrlBuilder url_builder_;
+ inja::Environment templates_;
+ inja::Template card_index_template_;
+};
diff --git a/src/config.h b/src/config.h
new file mode 100644
index 0000000..982d3ce
--- /dev/null
+++ b/src/config.h
@@ -0,0 +1,32 @@
+#pragma once
+
+#include <cstdint>
+#include <filesystem>
+
+#include <mw/http_server.hpp>
+#include <mw/url.hpp>
+
+/// Validated process configuration.
+struct Config
+{
+ /// Normalized absolute HTTP or HTTPS application base URL.
+ mw::URL base_url;
+
+ /// TCP or Unix-domain address on which the server listens.
+ mw::HTTPServer::ListenAddress listen_address;
+
+ /// Root containing application static files.
+ std::filesystem::path static_root;
+
+ /// SQLite database file path.
+ std::filesystem::path database_path;
+
+ /// Private root containing staged and published card assets.
+ std::filesystem::path card_storage_root;
+
+ /// ImageMagick quality used when converting PNG inputs to AVIF.
+ int avif_quality;
+
+ /// Long-side pixel count used for generated thumbnails.
+ std::uint32_t thumbnail_long_side;
+};
diff --git a/src/data_fake.cpp b/src/data_fake.cpp
new file mode 100644
index 0000000..f972541
--- /dev/null
+++ b/src/data_fake.cpp
@@ -0,0 +1,141 @@
+#include "data_fake.h"
+
+#include <algorithm>
+#include <set>
+#include <string_view>
+#include <utility>
+
+namespace
+{
+
+mw::Error readOnlyError(std::string_view operation)
+{
+ return mw::runtimeError(
+ "DataSourceFake is read-only and cannot " +
+ std::string(operation) + ".");
+}
+
+bool identitiesMatch(const CardIdentity& left, const CardIdentity& right)
+{
+ return left.game_short_name == right.game_short_name &&
+ left.card_number == right.card_number;
+}
+
+} // namespace
+
+DataSourceFake::DataSourceFake(
+ std::vector<Card> cards,
+ std::vector<Series> series,
+ std::unordered_map<std::int64_t, std::vector<std::int64_t>> card_series,
+ std::unordered_map<std::int64_t, std::vector<DisplayField>> display_fields)
+ : cards_(std::move(cards)),
+ series_(std::move(series)),
+ card_series_(std::move(card_series)),
+ display_fields_(std::move(display_fields))
+{}
+
+mw::E<std::int64_t> DataSourceFake::getSchemaVersion() const
+{
+ return DB_SCHEMA_VERSION;
+}
+
+mw::E<void> DataSourceFake::migrateSchema0To1(
+ [[maybe_unused]] const GameRegistry& games)
+{
+ return std::unexpected(readOnlyError("migrate the schema"));
+}
+
+mw::E<std::unique_ptr<DataSourceTransactionInterface>>
+DataSourceFake::beginTransaction()
+{
+ return std::unexpected(readOnlyError("begin a transaction"));
+}
+
+mw::E<std::vector<Card>> DataSourceFake::getCards() const
+{
+ return cards_;
+}
+
+mw::E<std::optional<Card>> DataSourceFake::getCard(
+ const CardIdentity& identity) const
+{
+ const auto card = std::ranges::find_if(
+ cards_,
+ [&identity](const Card& candidate)
+ {
+ return identitiesMatch(candidate.identity, identity);
+ });
+ if(card == cards_.end())
+ {
+ return std::nullopt;
+ }
+ return *card;
+}
+
+mw::E<std::vector<DisplayField>> DataSourceFake::getGameDisplayFields(
+ [[maybe_unused]] const GameDefinition& game,
+ std::int64_t card_id) const
+{
+ const auto fields = display_fields_.find(card_id);
+ if(fields == display_fields_.end())
+ {
+ return std::vector<DisplayField>{};
+ }
+ return fields->second;
+}
+
+mw::E<std::vector<Series>> DataSourceFake::getSeries() const
+{
+ return series_;
+}
+
+mw::E<std::optional<Series>> DataSourceFake::getSeries(
+ std::int64_t series_id) const
+{
+ const auto series = std::ranges::find_if(
+ series_,
+ [series_id](const Series& candidate)
+ {
+ return candidate.id == series_id;
+ });
+ if(series == series_.end())
+ {
+ return std::nullopt;
+ }
+ return *series;
+}
+
+mw::E<std::vector<std::int64_t>> DataSourceFake::getCardSeries(
+ std::int64_t card_id) const
+{
+ const auto memberships = card_series_.find(card_id);
+ if(memberships == card_series_.end())
+ {
+ return std::vector<std::int64_t>{};
+ }
+ return memberships->second;
+}
+
+mw::E<std::vector<std::string>>
+DataSourceFake::getPersistedGameNames() const
+{
+ std::set<std::string> names;
+ for(const Card& card : cards_)
+ {
+ if(card.identity.game_short_name)
+ {
+ names.insert(*card.identity.game_short_name);
+ }
+ }
+ for(const Series& series : series_)
+ {
+ names.insert(series.game_short_name);
+ }
+ return std::vector<std::string>(names.begin(), names.end());
+}
+
+mw::E<void> DataSourceFake::setSchemaVersion(
+ [[maybe_unused]] std::int64_t version)
+{
+ return std::unexpected(readOnlyError("set the schema version"));
+}
diff --git a/src/data_fake.h b/src/data_fake.h
new file mode 100644
index 0000000..a050f2f
--- /dev/null
+++ b/src/data_fake.h
@@ -0,0 +1,73 @@
+#pragma once
+
+#include <cstdint>
+#include <unordered_map>
+#include <vector>
+
+#include "data.h"
+
+/// Deterministic read-only data source for development and focused tests.
+class DataSourceFake : public DataSourceInterface
+{
+public:
+ /// Construct an empty fake data source at the latest schema version.
+ DataSourceFake() = default;
+
+ /// Construct a fake data source containing the supplied records.
+ explicit DataSourceFake(
+ std::vector<Card> cards,
+ std::vector<Series> series = {},
+ std::unordered_map<std::int64_t, std::vector<std::int64_t>>
+ card_series = {},
+ std::unordered_map<std::int64_t, std::vector<DisplayField>>
+ display_fields = {});
+
+ /// Return the latest schema version represented by the fake.
+ mw::E<std::int64_t> getSchemaVersion() const override;
+
+ /// Reject migration because the fake always represents the latest schema.
+ mw::E<void>
+ migrateSchema0To1(const GameRegistry& games) override;
+
+ /// Reject mutation transactions because the fake is read-only.
+ mw::E<std::unique_ptr<DataSourceTransactionInterface>>
+ beginTransaction() override;
+
+ /// Return all configured cards in insertion order.
+ mw::E<std::vector<Card>> getCards() const override;
+
+ /// Return the configured card matching an identity.
+ mw::E<std::optional<Card>>
+ getCard(const CardIdentity& identity) const override;
+
+ /// Return configured display fields for a card.
+ mw::E<std::vector<DisplayField>> getGameDisplayFields(
+ const GameDefinition& game,
+ std::int64_t card_id) const override;
+
+ /// Return all configured series in insertion order.
+ mw::E<std::vector<Series>> getSeries() const override;
+
+ /// Return the configured series with an internal ID.
+ mw::E<std::optional<Series>>
+ getSeries(std::int64_t series_id) const override;
+
+ /// Return configured series memberships for a card.
+ mw::E<std::vector<std::int64_t>>
+ getCardSeries(std::int64_t card_id) const override;
+
+ /// Return the sorted unique compiled game names used by configured rows.
+ mw::E<std::vector<std::string>>
+ getPersistedGameNames() const override;
+
+protected:
+ /// Reject schema mutation because the fake is read-only.
+ mw::E<void> setSchemaVersion(std::int64_t version) override;
+
+private:
+ std::vector<Card> cards_;
+ std::vector<Series> series_;
+ std::unordered_map<std::int64_t, std::vector<std::int64_t>> card_series_;
+ std::unordered_map<std::int64_t, std::vector<DisplayField>>
+ display_fields_;
+};
diff --git a/src/main.cpp b/src/main.cpp
index 888fc25..0fc1186 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,5 +1,137 @@
-/// Start the Card Collection server.
+#include <chrono>
+#include <csignal>
+#include <cstdint>
+#include <filesystem>
+#include <memory>
+#include <optional>
+#include <string>
+#include <thread>
+#include <utility>
+#include <vector>
+
+#include <spdlog/spdlog.h>
+
+#include "app.h"
+#include "data_fake.h"
+
+namespace
+{
+
+/// Development HTTP port used until configuration loading is implemented.
+inline constexpr int DEVELOPMENT_PORT = 8080;
+
+/// Signal-safe flag requesting shutdown from the main loop.
+volatile std::sig_atomic_t STOP_REQUESTED = 0;
+
+void handleSignal([[maybe_unused]] int signal)
+{
+ STOP_REQUESTED = 1;
+}
+
+Card makeCard(
+ std::int64_t id,
+ std::uint32_t number,
+ std::string name,
+ std::int64_t rarity)
+{
+ return {
+ id,
+ {std::nullopt, number},
+ std::move(name),
+ std::nullopt,
+ std::nullopt,
+ rarity,
+ "avif",
+ std::nullopt,
+ "avif",
+ 1,
+ };
+}
+
+std::vector<Card> sampleCards()
+{
+ return {
+ makeCard(1, 2, "Frost Garden", 1),
+ makeCard(2, 10, "Midnight Relay", 3),
+ makeCard(3, 42, "Glass Comet", 2),
+ makeCard(4, 128, "Signal Bloom", 0),
+ makeCard(5, 1024, "Quiet Orbit", 4),
+ makeCard(6, 65535, "Last Light", 5),
+ };
+}
+
+mw::E<Config> makeDevelopmentConfig()
+{
+ const std::string base_url_text =
+ "http://127.0.0.1:" + std::to_string(DEVELOPMENT_PORT) + "/";
+ auto base_url = mw::URL::fromStr(base_url_text);
+ if(!base_url)
+ {
+ return std::unexpected(std::move(base_url.error()));
+ }
+
+ const std::filesystem::path source_root = CARD_COLLECTION_SOURCE_DIR;
+ const std::filesystem::path card_storage_root = source_root / "var/cards";
+ std::error_code filesystem_error;
+ std::filesystem::create_directories(
+ card_storage_root / "published", filesystem_error);
+ if(filesystem_error)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Failed to create the development card directory: " +
+ filesystem_error.message()));
+ }
+
+ return Config{
+ std::move(*base_url),
+ mw::IPSocketInfo{"127.0.0.1", DEVELOPMENT_PORT},
+ source_root / "static",
+ source_root / "var/card_collection.sqlite3",
+ card_storage_root,
+ 75,
+ 256,
+ };
+}
+
+} // namespace
+
+/// Start the Card Collection development server with deterministic fake data.
int main()
{
+ auto config = makeDevelopmentConfig();
+ if(!config)
+ {
+ spdlog::error(
+ "Failed to configure the server: {}",
+ config.error().msg());
+ return 1;
+ }
+
+ App app(
+ *config,
+ std::make_unique<DataSourceFake>(sampleCards()));
+
+ std::signal(SIGINT, handleSignal);
+ std::signal(SIGTERM, handleSignal);
+
+ auto start_result = app.start();
+ if(!start_result)
+ {
+ spdlog::error(
+ "Failed to start the server: {}",
+ start_result.error().msg());
+ return 1;
+ }
+
+ spdlog::info(
+ "Development server listening at http://127.0.0.1:{}/",
+ DEVELOPMENT_PORT);
+ while(STOP_REQUESTED == 0)
+ {
+ std::this_thread::sleep_for(std::chrono::milliseconds(100));
+ }
+
+ app.stop();
+ app.wait();
return 0;
}
diff --git a/src/public_id.cpp b/src/public_id.cpp
new file mode 100644
index 0000000..eeaf9df
--- /dev/null
+++ b/src/public_id.cpp
@@ -0,0 +1,134 @@
+#include "public_id.h"
+
+#include <array>
+#include <charconv>
+#include <cctype>
+#include <cstdint>
+#include <limits>
+#include <string>
+#include <system_error>
+
+namespace
+{
+
+mw::E<std::string> formatNumber(std::uint64_t number, int base)
+{
+ std::array<char, std::numeric_limits<std::uint64_t>::digits + 1> buffer;
+ const auto result = std::to_chars(
+ buffer.data(),
+ buffer.data() + buffer.size(),
+ number,
+ base);
+ if(result.ec != std::errc{})
+ {
+ return std::unexpected(
+ mw::runtimeError("Failed to format a public card number"));
+ }
+ return std::string(buffer.data(), result.ptr);
+}
+
+bool isDigit(char character)
+{
+ return std::isdigit(static_cast<unsigned char>(character)) != 0;
+}
+
+std::size_t runEnd(std::string_view value, std::size_t begin, bool digit)
+{
+ std::size_t end = begin;
+ while(end < value.size() && isDigit(value[end]) == digit)
+ {
+ ++end;
+ }
+ return end;
+}
+
+int compareNumericRuns(std::string_view left, std::string_view right)
+{
+ const std::size_t left_nonzero = left.find_first_not_of('0');
+ const std::size_t right_nonzero = right.find_first_not_of('0');
+ const std::string_view left_value = left_nonzero == std::string_view::npos
+ ? left.substr(left.size() - 1)
+ : left.substr(left_nonzero);
+ const std::string_view right_value = right_nonzero == std::string_view::npos
+ ? right.substr(right.size() - 1)
+ : right.substr(right_nonzero);
+
+ if(left_value.size() != right_value.size())
+ {
+ return left_value.size() < right_value.size() ? -1 : 1;
+ }
+ const int value_comparison = left_value.compare(right_value);
+ if(value_comparison != 0)
+ {
+ return value_comparison;
+ }
+ if(left.size() != right.size())
+ {
+ return left.size() < right.size() ? -1 : 1;
+ }
+ return 0;
+}
+
+} // namespace
+
+mw::E<std::string> formatPublicId(const CardIdentity& identity)
+{
+ if(identity.game_short_name)
+ {
+ if(identity.game_short_name->empty() || identity.card_number == 0 ||
+ identity.card_number >
+ static_cast<std::uint64_t>(
+ std::numeric_limits<std::int64_t>::max()))
+ {
+ return std::unexpected(
+ mw::runtimeError("Invalid game card identity"));
+ }
+ auto number = formatNumber(identity.card_number, 10);
+ if(!number)
+ {
+ return std::unexpected(std::move(number.error()));
+ }
+ return *identity.game_short_name + "-" + *number;
+ }
+
+ if(identity.card_number > std::numeric_limits<std::uint32_t>::max())
+ {
+ return std::unexpected(mw::runtimeError("Invalid loose card identity"));
+ }
+ return formatNumber(identity.card_number, 36);
+}
+
+bool naturalPublicIdLess(std::string_view left, std::string_view right)
+{
+ std::size_t left_index = 0;
+ std::size_t right_index = 0;
+ while(left_index < left.size() && right_index < right.size())
+ {
+ const bool left_digit = isDigit(left[left_index]);
+ const bool right_digit = isDigit(right[right_index]);
+ const std::size_t left_end = runEnd(left, left_index, left_digit);
+ const std::size_t right_end = runEnd(right, right_index, right_digit);
+ const std::string_view left_run =
+ left.substr(left_index, left_end - left_index);
+ const std::string_view right_run =
+ right.substr(right_index, right_end - right_index);
+
+ int comparison;
+ if(left_digit && right_digit)
+ {
+ comparison = compareNumericRuns(left_run, right_run);
+ }
+ else
+ {
+ comparison = left_run.compare(right_run);
+ }
+ if(comparison != 0)
+ {
+ return comparison < 0;
+ }
+
+ left_index = left_end;
+ right_index = right_end;
+ }
+ return left.size() < right.size();
+}
diff --git a/src/public_id.h b/src/public_id.h
new file mode 100644
index 0000000..f10067e
--- /dev/null
+++ b/src/public_id.h
@@ -0,0 +1,14 @@
+#pragma once
+
+#include <string>
+#include <string_view>
+
+#include <mw/error.hpp>
+
+#include "card.h"
+
+/// Format the canonical lowercase public ID derived from a card identity.
+mw::E<std::string> formatPublicId(const CardIdentity& identity);
+
+/// Compare canonical public IDs using numeric runs as integers.
+bool naturalPublicIdLess(std::string_view left, std::string_view right);
diff --git a/src/url_builder.cpp b/src/url_builder.cpp
new file mode 100644
index 0000000..e7d7c7d
--- /dev/null
+++ b/src/url_builder.cpp
@@ -0,0 +1,182 @@
+#include "url_builder.h"
+
+#include <cctype>
+#include <stdexcept>
+#include <string_view>
+
+namespace
+{
+
+void validateOrdinarySegment(std::string_view value)
+{
+ if(value.empty() || value == "." || value == "..")
+ {
+ throw std::invalid_argument("Invalid empty or dot path segment");
+ }
+}
+
+void validateLiteral(std::string_view value)
+{
+ validateOrdinarySegment(value);
+ if(value.contains('/') || value.contains('\\'))
+ {
+ throw std::invalid_argument("Literal route segment contains a slash");
+ }
+}
+
+void validatePlaceholder(std::string_view value)
+{
+ if(value.empty() ||
+ !(std::islower(static_cast<unsigned char>(value.front())) ||
+ value.front() == '_'))
+ {
+ throw std::invalid_argument("Invalid route placeholder name");
+ }
+
+ for(char character : value.substr(1))
+ {
+ const auto byte = static_cast<unsigned char>(character);
+ if(!(std::islower(byte) || std::isdigit(byte) || character == '_'))
+ {
+ throw std::invalid_argument("Invalid route placeholder name");
+ }
+ }
+}
+
+std::string segmentText(const RouteSegment& segment, bool allow_placeholder)
+{
+ switch(segment.kind)
+ {
+ case RouteSegmentKind::LITERAL:
+ validateLiteral(segment.value);
+ return segment.value;
+ case RouteSegmentKind::DYNAMIC:
+ validateOrdinarySegment(segment.value);
+ return mw::URL::encode(segment.value);
+ case RouteSegmentKind::PLACEHOLDER:
+ if(!allow_placeholder)
+ {
+ throw std::invalid_argument(
+ "Route placeholder is invalid in an absolute URL");
+ }
+ validatePlaceholder(segment.value);
+ return ":" + segment.value;
+ }
+
+ throw std::invalid_argument("Unknown route segment kind");
+}
+
+std::string buildPath(
+ const mw::URL& base_url,
+ const std::vector<RouteSegment>& segments,
+ bool allow_placeholder)
+{
+ std::string path = base_url.path();
+ if(path.empty())
+ {
+ path = "/";
+ }
+
+ if(segments.empty())
+ {
+ return path;
+ }
+
+ if(path.back() != '/')
+ {
+ path += '/';
+ }
+
+ for(std::size_t index = 0; index < segments.size(); ++index)
+ {
+ if(index != 0)
+ {
+ path += '/';
+ }
+ path += segmentText(segments[index], allow_placeholder);
+ }
+
+ return path;
+}
+
+std::string buildQuery(const QueryParameters& query)
+{
+ std::string result;
+ for(std::size_t index = 0; index < query.size(); ++index)
+ {
+ if(index != 0)
+ {
+ result += '&';
+ }
+ result += mw::URL::encode(query[index].first);
+ result += '=';
+ result += mw::URL::encode(query[index].second);
+ }
+ return result;
+}
+
+std::vector<RouteSegment> appendRelativePath(
+ std::vector<RouteSegment> prefix,
+ const std::string& relative_path)
+{
+ if(relative_path.empty() || relative_path.front() == '/' ||
+ relative_path.front() == '\\')
+ {
+ throw std::invalid_argument("Invalid relative static path");
+ }
+
+ std::size_t begin = 0;
+ while(begin <= relative_path.size())
+ {
+ const std::size_t end = relative_path.find('/', begin);
+ const std::string component = relative_path.substr(begin, end - begin);
+ validateOrdinarySegment(component);
+ prefix.push_back({RouteSegmentKind::DYNAMIC, component});
+
+ if(end == std::string::npos)
+ {
+ break;
+ }
+ begin = end + 1;
+ }
+
+ return prefix;
+}
+
+} // namespace
+
+UrlBuilder::UrlBuilder(mw::URL base_url)
+ : base_url_(std::move(base_url))
+{}
+
+std::string UrlBuilder::absolute(
+ const std::vector<RouteSegment>& segments,
+ const QueryParameters& query) const
+{
+ mw::URL result = base_url_;
+ result.path(buildPath(base_url_, segments, false).c_str());
+
+ if(query.empty())
+ {
+ result.query(nullptr);
+ }
+ else
+ {
+ result.query(buildQuery(query).c_str());
+ }
+ return result.str();
+}
+
+std::string UrlBuilder::absoluteFromRelativePath(
+ const std::vector<RouteSegment>& mount_prefix,
+ const std::string& relative_path,
+ const QueryParameters& query) const
+{
+ return absolute(appendRelativePath(mount_prefix, relative_path), query);
+}
+
+std::string UrlBuilder::requestPath(
+ const std::vector<RouteSegment>& segments) const
+{
+ return buildPath(base_url_, segments, true);
+}
diff --git a/src/url_builder.h b/src/url_builder.h
new file mode 100644
index 0000000..a8d6af6
--- /dev/null
+++ b/src/url_builder.h
@@ -0,0 +1,60 @@
+#pragma once
+
+#include <string>
+#include <utility>
+#include <vector>
+
+#include <mw/url.hpp>
+
+/// Kind of one path segment supplied to UrlBuilder.
+enum class RouteSegmentKind
+{
+ /// Trusted application route text.
+ LITERAL,
+
+ /// One value encoded as a single path segment.
+ DYNAMIC,
+
+ /// Route-registration placeholder accepted only by requestPath().
+ PLACEHOLDER
+};
+
+/// One trusted literal, encoded value, or route-registration placeholder.
+struct RouteSegment
+{
+ /// Interpretation applied to value.
+ RouteSegmentKind kind;
+
+ /// Unencoded segment text or placeholder name.
+ std::string value;
+};
+
+/// Ordered query parameters encoded by UrlBuilder.
+using QueryParameters =
+ std::vector<std::pair<std::string, std::string>>;
+
+/// Build base-aware URLs from trusted literal and encoded dynamic segments.
+class UrlBuilder
+{
+public:
+ /// Construct a builder from a validated absolute HTTP or HTTPS base URL.
+ explicit UrlBuilder(mw::URL base_url);
+
+ /// Return an absolute URL for the supplied route segments.
+ std::string absolute(
+ const std::vector<RouteSegment>& segments,
+ const QueryParameters& query = {}) const;
+
+ /// Append one validated relative path beneath a static-mount prefix.
+ std::string absoluteFromRelativePath(
+ const std::vector<RouteSegment>& mount_prefix,
+ const std::string& relative_path,
+ const QueryParameters& query = {}) const;
+
+ /// Return only the base-prefixed server request path.
+ std::string requestPath(
+ const std::vector<RouteSegment>& segments) const;
+
+private:
+ mw::URL base_url_;
+};
diff --git a/static/card_placeholder.svg b/static/card_placeholder.svg
new file mode 100644
index 0000000..d049c8f
--- /dev/null
+++ b/static/card_placeholder.svg
@@ -0,0 +1,18 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 700">
+ <defs>
+ <linearGradient id="background" x1="0" y1="0" x2="1" y2="1">
+ <stop stop-color="#322a43"/>
+ <stop offset="1" stop-color="#17181d"/>
+ </linearGradient>
+ </defs>
+ <rect width="500" height="700" fill="url(#background)"/>
+ <rect x="42" y="42" width="416" height="616" rx="22"
+ fill="none" stroke="#ffffff" stroke-opacity=".13" stroke-width="3"/>
+ <circle cx="250" cy="320" r="72" fill="#ffffff" fill-opacity=".08"/>
+ <path d="M210 335l28-30 24 24 20-16 32 38H190z"
+ fill="#ffffff" fill-opacity=".28"/>
+ <text x="250" y="440" text-anchor="middle" fill="#ffffff"
+ fill-opacity=".52" font-family="system-ui,sans-serif" font-size="24">
+ CARD ART
+ </text>
+</svg>
diff --git a/static/css/styles.css b/static/css/styles.css
new file mode 100644
index 0000000..ef9c36e
--- /dev/null
+++ b/static/css/styles.css
@@ -0,0 +1,157 @@
+:root {
+ color-scheme: dark;
+ font-family: Inter, ui-sans-serif, system-ui, sans-serif;
+ background: #101114;
+ color: #f4f1ea;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ background:
+ radial-gradient(circle at top left, #2e263d 0, transparent 32rem),
+ #101114;
+}
+
+a {
+ color: inherit;
+}
+
+.site-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1.25rem clamp(1.25rem, 4vw, 4rem);
+ border-bottom: 1px solid #ffffff1f;
+}
+
+.site-title {
+ font-size: 1.1rem;
+ font-weight: 750;
+ text-decoration: none;
+}
+
+nav {
+ display: flex;
+ gap: 1.25rem;
+}
+
+nav a,
+.sort-controls a {
+ color: #cbc5d6;
+ text-underline-offset: 0.3rem;
+}
+
+main {
+ width: min(90rem, 100%);
+ margin: 0 auto;
+ padding: 3rem clamp(1.25rem, 4vw, 4rem) 5rem;
+}
+
+.page-heading {
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ gap: 2rem;
+ margin-bottom: 2rem;
+}
+
+.eyebrow {
+ margin: 0 0 0.4rem;
+ color: #b9a5db;
+ font-size: 0.75rem;
+ font-weight: 750;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
+
+h1 {
+ margin: 0;
+ font-size: clamp(2.25rem, 6vw, 4.5rem);
+ letter-spacing: -0.055em;
+}
+
+.sort-controls {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ font-size: 0.9rem;
+}
+
+.sort-controls span {
+ color: #77727f;
+}
+
+.sort-controls [aria-current="page"] {
+ color: #ffffff;
+ font-weight: 700;
+}
+
+.card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
+ gap: clamp(1rem, 2.5vw, 2rem);
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.card-tile {
+ overflow: hidden;
+ border: 1px solid #ffffff1a;
+ border-radius: 0.85rem;
+ background: #191a1f;
+ box-shadow: 0 1rem 2.5rem #00000035;
+}
+
+.card-image-link {
+ display: block;
+ aspect-ratio: 5 / 7;
+ background: #24212b;
+}
+
+.card-image-link img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.card-details {
+ padding: 1rem;
+}
+
+.card-id {
+ color: #b9a5db;
+ font-family: ui-monospace, monospace;
+ font-size: 0.8rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-decoration: none;
+}
+
+.card-details h2 {
+ margin: 0.35rem 0 0;
+ font-size: 1rem;
+}
+
+.empty-state {
+ min-height: 14rem;
+ display: grid;
+ place-items: center;
+ border: 1px dashed #ffffff26;
+ border-radius: 0.85rem;
+ color: #918b98;
+}
+
+@media(max-width: 42rem) {
+ .site-header,
+ .page-heading {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+}
diff --git a/templates/card_index.html b/templates/card_index.html
new file mode 100644
index 0000000..68c6192
--- /dev/null
+++ b/templates/card_index.html
@@ -0,0 +1,39 @@
+{% extends "layout.html" %}
+
+{% block content %}
+<div class="page-heading">
+ <div>
+ <p class="eyebrow">Your archive</p>
+ <h1>Cards</h1>
+ </div>
+ <div class="sort-controls" aria-label="Sort cards">
+ <span>Public ID</span>
+ <a href="{{ ascending_url }}"
+ {% if not descending %}aria-current="page"{% endif %}>Ascending</a>
+ <a href="{{ descending_url }}"
+ {% if descending %}aria-current="page"{% endif %}>Descending</a>
+ </div>
+</div>
+
+{% if length(cards) == 0 %}
+<p class="empty-state">No cards yet.</p>
+{% endif %}
+
+<ul class="card-grid">
+{% for card in cards %}
+ <li class="card-tile">
+ <a class="card-image-link" href="{{ card.url }}">
+ <img src="{{ card.thumbnail_url }}"
+ alt=""
+ loading="lazy">
+ </a>
+ <div class="card-details">
+ <a class="card-id" href="{{ card.url }}">
+ {{ card.display_id }}
+ </a>
+ <h2>{{ card.name }}</h2>
+ </div>
+ </li>
+{% endfor %}
+</ul>
+{% endblock %}
diff --git a/templates/layout.html b/templates/layout.html
new file mode 100644
index 0000000..84aa7de
--- /dev/null
+++ b/templates/layout.html
@@ -0,0 +1,24 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>{{ title }}</title>
+ <link rel="stylesheet" href="{{ url_for("static", "css/styles.css") }}">
+</head>
+<body>
+ <header class="site-header">
+ <a class="site-title" href="{{ url_for("card-index") }}">
+ Card Collection
+ </a>
+ <nav aria-label="Primary navigation">
+ <a href="{{ url_for("card-index") }}">Cards</a>
+ <a href="{{ url_for("card-new") }}">Create card</a>
+ <a href="{{ url_for("series-index") }}">Series</a>
+ </nav>
+ </header>
+ <main>
+ {% block content %}{% endblock %}
+ </main>
+</body>
+</html>
diff --git a/tests/app_test.cpp b/tests/app_test.cpp
new file mode 100644
index 0000000..c892ca2
--- /dev/null
+++ b/tests/app_test.cpp
@@ -0,0 +1,141 @@
+#include "app.h"
+
+#include <cstdint>
+#include <filesystem>
+#include <memory>
+#include <optional>
+#include <stdexcept>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include <gtest/gtest.h>
+
+#include "data_fake.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>();
+}
+
+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,
+ };
+}
+
+} // namespace
+
+/// Verify named routes respect a nested application base path.
+TEST(AppTest, BuildsNamedUrls)
+{
+ const App app(
+ makeConfig("https://example.test/collection/"),
+ emptyDataSource());
+
+ EXPECT_EQ(
+ app.urlFor("card-index"),
+ "https://example.test/collection/");
+ 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/series/42/edit");
+}
+
+/// Verify static relative paths and ordered query values are encoded.
+TEST(AppTest, BuildsStaticAndQueryUrls)
+{
+ const App app(
+ makeConfig("https://example.test/collection/"),
+ emptyDataSource());
+
+ 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());
+
+ 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<DataSourceFake>(
+ std::vector<Card>{
+ makeCard(10, 10, "Tenth card"),
+ makeCard(2, 2, "<script>Second card</script>"),
+ });
+ App app(
+ makeConfig("https://example.test/collection/"),
+ std::move(data_source));
+ App::Request request;
+ 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);
+}
diff --git a/tests/data_fake_test.cpp b/tests/data_fake_test.cpp
new file mode 100644
index 0000000..f5e6e6b
--- /dev/null
+++ b/tests/data_fake_test.cpp
@@ -0,0 +1,86 @@
+#include "data_fake.h"
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <unordered_map>
+#include <utility>
+#include <vector>
+
+#include <gtest/gtest.h>
+
+namespace
+{
+
+Card makeCard(
+ std::int64_t id,
+ std::optional<std::string> game_short_name,
+ std::uint64_t card_number,
+ std::string name)
+{
+ return {
+ id,
+ {std::move(game_short_name), card_number},
+ std::move(name),
+ std::nullopt,
+ std::nullopt,
+ 0,
+ "avif",
+ std::nullopt,
+ "avif",
+ 1,
+ };
+}
+
+} // namespace
+
+/// Verify configured card records can be read by identity.
+TEST(DataSourceFakeTest, ReturnsCards)
+{
+ DataSourceFake data_source({
+ makeCard(1, std::nullopt, 10, "Loose card"),
+ makeCard(2, "test", 3, "Game card"),
+ });
+
+ const auto cards = data_source.getCards();
+ ASSERT_TRUE(cards);
+ ASSERT_EQ(cards->size(), 2U);
+
+ const auto card = data_source.getCard({"test", 3});
+ ASSERT_TRUE(card);
+ ASSERT_TRUE(*card);
+ EXPECT_EQ((*card)->name, "Game card");
+
+ const auto missing = data_source.getCard({"test", 4});
+ ASSERT_TRUE(missing);
+ EXPECT_FALSE(*missing);
+}
+
+/// Verify series data, memberships, and game names are deterministic.
+TEST(DataSourceFakeTest, ReturnsRelatedData)
+{
+ DataSourceFake data_source(
+ {makeCard(2, "test", 3, "Game card")},
+ {{7, "test", "First series", "Description"}},
+ {{2, {7}}});
+
+ const auto memberships = data_source.getCardSeries(2);
+ ASSERT_TRUE(memberships);
+ EXPECT_EQ(*memberships, std::vector<std::int64_t>({7}));
+
+ const auto names = data_source.getPersistedGameNames();
+ ASSERT_TRUE(names);
+ EXPECT_EQ(*names, std::vector<std::string>({"test"}));
+}
+
+/// Verify development data cannot accidentally be mutated.
+TEST(DataSourceFakeTest, RejectsMutations)
+{
+ DataSourceFake data_source;
+
+ const auto transaction = data_source.beginTransaction();
+ ASSERT_FALSE(transaction);
+ EXPECT_EQ(
+ transaction.error().msg(),
+ "DataSourceFake is read-only and cannot begin a transaction.");
+}