#include "card_service.h"
#include "data_sqlite.h"
#include "multipart_reader.h"
#include "series_service.h"
#include "startup.h"
#include <chrono>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <memory>
#include <optional>
#include <string>
#include <system_error>
#include <Magick++.h>
#include <gtest/gtest.h>
namespace
{
class TemporaryCardRoot
{
public:
/// Allocate a unique temporary root for a card-service test.
TemporaryCardRoot()
: path_(
std::filesystem::path(testing::TempDir()) /
("card_service_" + std::to_string(
std::chrono::steady_clock::now()
.time_since_epoch().count())))
{
std::filesystem::create_directories(path_ / ".staging/upload");
}
/// Remove all temporary database and asset files.
~TemporaryCardRoot()
{
std::error_code error;
std::filesystem::remove_all(path_, error);
}
/// Return the temporary root.
const std::filesystem::path& path() const
{
return path_;
}
private:
std::filesystem::path path_;
};
void initializeImageMagick()
{
static const bool initialized = []
{
Magick::InitializeMagick(nullptr);
Magick::ResourceLimits::listLength(2);
Magick::ResourceLimits::width(2048);
Magick::ResourceLimits::height(2048);
return true;
}();
ASSERT_TRUE(initialized);
}
void writePng(const std::filesystem::path& path)
{
Magick::Image image(Magick::Geometry(350, 490), Magick::Color("navy"));
image.write("PNG:" + path.string());
}
void writeJpeg(const std::filesystem::path& path)
{
Magick::Image image(Magick::Geometry(350, 490), Magick::Color("navy"));
image.write("JPEG:" + path.string());
}
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>());
}
mw::E<std::unique_ptr<DataSourceInterface>> prepareTestDataSource(
const std::filesystem::path& database)
{
auto data_source = prepareDataSource(database);
if(!data_source)
{
return std::unexpected(std::move(data_source.error()));
}
auto transaction = (*data_source)->beginTransaction();
if(!transaction)
{
return std::unexpected(std::move(transaction.error()));
}
auto updated = (*transaction)->updateUsername(
1, "Administrator", "administrator");
if(!updated)
{
return std::unexpected(std::move(updated.error()));
}
auto committed = (*transaction)->commit();
if(!committed)
{
return std::unexpected(std::move(committed.error()));
}
return std::move(*data_source);
}
} // namespace
/// Verify card creation publishes normalized assets with its committed row.
TEST(CardServiceTest, CreatesLooseCard)
{
initializeImageMagick();
TemporaryCardRoot temporary;
const std::filesystem::path database = temporary.path() / "cards.sqlite3";
auto data_source = prepareTestDataSource(database);
ASSERT_TRUE(data_source);
const std::filesystem::path staging =
temporary.path() / ".staging/upload";
const std::filesystem::path front = staging / "upload_front";
writePng(front);
NonSecretRandom random(1);
CardService service(
**data_source,
random,
ImageProcessor(75, 256),
AssetStore(temporary.path()));
auto public_id = service.createLooseCard(1, {
"Moon card",
std::optional<std::string>("Short"),
std::nullopt,
4,
staging,
front,
std::nullopt,
std::nullopt,
});
ASSERT_TRUE(public_id) << public_id.error().msg();
auto cards = (*data_source)->getCards(
GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(cards);
ASSERT_EQ(cards->size(), 1);
EXPECT_EQ(cards->front().name, "Moon card");
EXPECT_EQ(cards->front().front_extension, "avif");
EXPECT_EQ(cards->front().thumbnail_extension, "avif");
auto stored_card = (*data_source)->getCard(
cards->front().identity, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(stored_card);
ASSERT_TRUE(*stored_card);
EXPECT_EQ((**stored_card).id, cards->front().id);
auto memberships = (*data_source)->getCardSeries(cards->front().id);
ASSERT_TRUE(memberships);
EXPECT_TRUE(memberships->empty());
const std::filesystem::path published =
temporary.path() / "published" / *public_id;
EXPECT_TRUE(
std::filesystem::is_regular_file(published / "front-art.avif"));
EXPECT_TRUE(std::filesystem::is_regular_file(published / "thumb.avif"));
EXPECT_FALSE(std::filesystem::exists(staging));
}
/// Verify invalid image bytes leave both persistence domains unchanged.
TEST(CardServiceTest, RejectsInvalidImage)
{
initializeImageMagick();
TemporaryCardRoot temporary;
const std::filesystem::path database = temporary.path() / "cards.sqlite3";
auto data_source = prepareTestDataSource(database);
ASSERT_TRUE(data_source);
const std::filesystem::path staging =
temporary.path() / ".staging/upload";
const std::filesystem::path front = staging / "upload_front";
{
std::ofstream output(front, std::ios::binary);
output << "not an image";
}
NonSecretRandom random(1);
CardService service(
**data_source,
random,
ImageProcessor(75, 256),
AssetStore(temporary.path()));
auto public_id = service.createLooseCard(1, {
"Bad card",
std::nullopt,
std::nullopt,
0,
staging,
front,
std::nullopt,
std::nullopt,
});
EXPECT_FALSE(public_id);
auto cards = (*data_source)->getCards(
GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(cards);
EXPECT_TRUE(cards->empty());
EXPECT_FALSE(std::filesystem::exists(temporary.path() / "published"));
}
/// Verify an opaque JPEG is accepted as a foil-control texture.
TEST(CardServiceTest, CreatesOpaqueJpegFoilCard)
{
initializeImageMagick();
TemporaryCardRoot temporary;
const std::filesystem::path database = temporary.path() / "cards.sqlite3";
auto data_source = prepareTestDataSource(database);
ASSERT_TRUE(data_source);
const std::filesystem::path staging =
temporary.path() / ".staging/upload";
const std::filesystem::path front = staging / "upload_front";
const std::filesystem::path foil = staging / "upload_foil";
const std::filesystem::path thumbnail = staging / "upload_thumbnail";
writeJpeg(front);
writeJpeg(foil);
writePng(thumbnail);
NonSecretRandom random(1);
CardService service(
**data_source,
random,
ImageProcessor(75, 256),
AssetStore(temporary.path()));
auto public_id = service.createLooseCard(1, {
"Opaque foil",
std::nullopt,
std::nullopt,
0,
staging,
front,
foil,
thumbnail,
});
ASSERT_TRUE(public_id) << public_id.error().msg();
auto cards = (*data_source)->getCards(
GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(cards);
ASSERT_EQ(cards->size(), 1);
EXPECT_EQ(cards->front().foil_extension, "jpg");
const std::filesystem::path published =
temporary.path() / "published" / *public_id;
EXPECT_TRUE(std::filesystem::is_regular_file(published / "foil.jpg"));
}
/// Verify dynamic-game creation allocates a sequence and memberships.
TEST(CardServiceTest, CreatesDynamicGameCard)
{
initializeImageMagick();
TemporaryCardRoot temporary;
const std::filesystem::path database = temporary.path() / "cards.sqlite3";
auto data_source = prepareTestDataSource(database);
ASSERT_TRUE(data_source);
auto series_transaction = (*data_source)->beginTransaction();
ASSERT_TRUE(series_transaction);
ASSERT_TRUE((*series_transaction)->insertGame(
{"test", "Test Game", "", GameVisibility::PUBLIC, 1}));
auto hp_id = (*series_transaction)->insertGameField(
{0, "test", "hp", "HP", GameFieldType::INTEGER, 0, {}}, {});
auto attack_id = (*series_transaction)->insertGameField(
{0, "test", "attack", "Attack", GameFieldType::INTEGER, 1, {}},
{});
ASSERT_TRUE(hp_id);
ASSERT_TRUE(attack_id);
auto series_id = (*series_transaction)->insertSeries(
{0, "test", "Core", "Core cards"});
ASSERT_TRUE(series_id);
ASSERT_TRUE((*series_transaction)->commit());
const std::filesystem::path staging =
temporary.path() / ".staging/upload";
const std::filesystem::path front = staging / "upload_front";
writePng(front);
NonSecretRandom random(1);
CardService service(
**data_source,
random,
ImageProcessor(75, 256),
AssetStore(temporary.path()));
auto public_id = service.createGameCard(
1,
{
"Game card",
std::nullopt,
std::nullopt,
2,
staging,
front,
std::nullopt,
std::nullopt,
},
"test",
1,
{{"hp", "50"}, {"attack", "10"}},
{*series_id});
ASSERT_TRUE(public_id) << public_id.error().msg();
EXPECT_EQ(*public_id, "test-1");
auto stored = (*data_source)->getCard(
{"test", 1}, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(stored);
ASSERT_TRUE(*stored);
auto memberships = (*data_source)->getCardSeries((**stored).id);
ASSERT_TRUE(memberships);
EXPECT_EQ(*memberships, std::vector<std::int64_t>{*series_id});
auto values = (*data_source)->getCardFieldValues(
(**stored).id, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(values);
ASSERT_TRUE(*values);
EXPECT_EQ(std::get<std::int64_t>((**values).values.front().value), 50);
const std::filesystem::path edit_staging =
temporary.path() / ".staging/edit";
std::filesystem::create_directory(edit_staging);
auto updated = service.updateGameCard(
1,
{
**stored,
1,
"Updated game card",
std::nullopt,
std::nullopt,
3,
edit_staging,
FrontAssetAction::KEEP,
FoilAssetAction::KEEP,
std::nullopt,
std::nullopt,
std::nullopt,
},
1,
{{"hp", "80"}, {"attack", "40"}},
{});
ASSERT_TRUE(updated) << updated.error().msg();
stored = (*data_source)->getCard(
{"test", 1}, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(stored);
ASSERT_TRUE(*stored);
EXPECT_EQ((**stored).name, "Updated game card");
values = (*data_source)->getCardFieldValues(
(**stored).id, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(values);
ASSERT_TRUE(*values);
EXPECT_EQ(std::get<std::int64_t>((**values).values.front().value), 80);
memberships = (*data_source)->getCardSeries((**stored).id);
ASSERT_TRUE(memberships);
EXPECT_TRUE(memberships->empty());
}
/// Verify generic metadata failures roll back every card side effect.
TEST(CardServiceTest, RollsBackDynamicMetadataFailures)
{
initializeImageMagick();
TemporaryCardRoot temporary;
const std::filesystem::path database = temporary.path() / "cards.sqlite3";
auto data_source = prepareTestDataSource(database);
ASSERT_TRUE(data_source);
auto setup = (*data_source)->beginTransaction();
ASSERT_TRUE(setup);
ASSERT_TRUE((*setup)->insertGame(
{"test", "Test Game", "", GameVisibility::PUBLIC, 1}));
auto hp_id = (*setup)->insertGameField(
{0, "test", "hp", "HP", GameFieldType::INTEGER, 0, {}}, {});
ASSERT_TRUE(hp_id);
auto series_id = (*setup)->insertSeries(
{0, "test", "Core", "Core cards"});
ASSERT_TRUE(series_id);
ASSERT_TRUE((*setup)->commit());
auto injection = mw::SQLite::connectFile(database.string());
ASSERT_TRUE(injection);
ASSERT_TRUE((*injection)->execute(
"CREATE TRIGGER fail_card_values BEFORE INSERT "
"ON card_field_values BEGIN "
"SELECT RAISE(ABORT, 'forced metadata failure'); END;"));
const std::filesystem::path staging =
temporary.path() / ".staging/upload";
const std::filesystem::path front = staging / "upload_front";
writePng(front);
NonSecretRandom random(1);
CardService service(
**data_source,
random,
ImageProcessor(75, 256),
AssetStore(temporary.path()));
auto failed_create = service.createGameCard(
1,
{
"Failed card",
std::nullopt,
std::nullopt,
2,
staging,
front,
std::nullopt,
std::nullopt,
},
"test",
1,
{{"hp", "50"}},
{*series_id});
ASSERT_FALSE(failed_create);
auto cards = (*data_source)->getCards(
GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(cards);
EXPECT_TRUE(cards->empty());
EXPECT_FALSE(std::filesystem::exists(
temporary.path() / "published/test-1"));
EXPECT_TRUE(std::filesystem::is_regular_file(staging / "front-art.avif"));
ASSERT_TRUE((*injection)->execute("DROP TRIGGER fail_card_values;"));
writePng(front);
auto public_id = service.createGameCard(
1,
{
"Committed card",
std::nullopt,
std::nullopt,
2,
staging,
front,
std::nullopt,
std::nullopt,
},
"test",
1,
{{"hp", "50"}},
{*series_id});
ASSERT_TRUE(public_id) << public_id.error().msg();
EXPECT_EQ(*public_id, "test-1");
auto stored = (*data_source)->getCard(
{"test", 1}, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(stored);
ASSERT_TRUE(*stored);
const std::filesystem::path published =
temporary.path() / "published/test-1";
const std::string original_art = readFile(published / "front-art.avif");
ASSERT_TRUE((*injection)->execute(
"CREATE TRIGGER fail_card_values BEFORE INSERT "
"ON card_field_values BEGIN "
"SELECT RAISE(ABORT, 'forced metadata failure'); END;"));
const std::filesystem::path edit_staging =
temporary.path() / ".staging/edit";
std::filesystem::create_directory(edit_staging);
const std::filesystem::path replacement =
edit_staging / "upload_front";
writeJpeg(replacement);
auto failed_update = service.updateGameCard(
1,
{
**stored,
1,
"Failed update",
std::nullopt,
std::nullopt,
3,
edit_staging,
FrontAssetAction::REPLACE,
FoilAssetAction::KEEP,
replacement,
std::nullopt,
std::nullopt,
},
1,
{{"hp", "80"}},
{});
ASSERT_FALSE(failed_update);
stored = (*data_source)->getCard(
{"test", 1}, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(stored);
ASSERT_TRUE(*stored);
EXPECT_EQ((**stored).name, "Committed card");
EXPECT_EQ((**stored).revision, 1);
auto values = (*data_source)->getCardFieldValues(
(**stored).id, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(values);
ASSERT_TRUE(*values);
ASSERT_EQ((**values).values.size(), 1);
EXPECT_EQ(std::get<std::int64_t>((**values).values.front().value), 50);
auto memberships = (*data_source)->getCardSeries((**stored).id);
ASSERT_TRUE(memberships);
EXPECT_EQ(*memberships, std::vector<std::int64_t>{*series_id});
EXPECT_EQ(readFile(published / "front-art.avif"), original_art);
EXPECT_FALSE(std::filesystem::exists(published / "front-art.jpg"));
EXPECT_TRUE(std::filesystem::is_regular_file(
edit_staging / "front-art.jpg"));
}
/// Verify metadata and artwork edits increment revisions and replace assets.
TEST(CardServiceTest, UpdatesLooseCard)
{
initializeImageMagick();
TemporaryCardRoot temporary;
const std::filesystem::path database = temporary.path() / "cards.sqlite3";
auto data_source = prepareTestDataSource(database);
ASSERT_TRUE(data_source);
const std::filesystem::path create_staging =
temporary.path() / ".staging/upload";
const std::filesystem::path original_front =
create_staging / "upload_front";
writePng(original_front);
NonSecretRandom random(1);
CardService service(
**data_source,
random,
ImageProcessor(75, 256),
AssetStore(temporary.path()));
auto public_id = service.createLooseCard(1, {
"Original",
std::nullopt,
std::nullopt,
0,
create_staging,
original_front,
std::nullopt,
std::nullopt,
});
ASSERT_TRUE(public_id);
auto cards = (*data_source)->getCards(
GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(cards);
ASSERT_EQ(cards->size(), 1);
const Card original_card = cards->front();
const std::filesystem::path metadata_staging =
temporary.path() / ".staging/metadata";
std::filesystem::create_directory(metadata_staging);
auto metadata_update = service.updateLooseCard(1, {
original_card,
1,
"Edited metadata",
std::optional<std::string>("Summary"),
std::nullopt,
3,
metadata_staging,
FrontAssetAction::KEEP,
FoilAssetAction::KEEP,
std::nullopt,
std::nullopt,
std::nullopt,
});
ASSERT_TRUE(metadata_update) << metadata_update.error().msg();
EXPECT_EQ(*metadata_update, *public_id);
cards = (*data_source)->getCards(
GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(cards);
ASSERT_EQ(cards->size(), 1);
EXPECT_EQ(cards->front().name, "Edited metadata");
EXPECT_EQ(cards->front().revision, 2);
const std::filesystem::path published =
temporary.path() / "published" / *public_id;
EXPECT_TRUE(
std::filesystem::is_regular_file(published / "front-art.avif"));
const std::filesystem::path stale_staging =
temporary.path() / ".staging/stale";
std::filesystem::create_directory(stale_staging);
auto stale_update = service.updateLooseCard(1, {
original_card,
1,
"Stale edit",
std::nullopt,
std::nullopt,
0,
stale_staging,
FrontAssetAction::KEEP,
FoilAssetAction::KEEP,
std::nullopt,
std::nullopt,
std::nullopt,
});
ASSERT_FALSE(stale_update);
const mw::HTTPError* stale_error =
stale_update.error().as<mw::HTTPError>();
ASSERT_NE(stale_error, nullptr);
EXPECT_EQ(stale_error->code, 409);
const std::filesystem::path artwork_staging =
temporary.path() / ".staging/artwork";
std::filesystem::create_directory(artwork_staging);
const std::filesystem::path replacement_front =
artwork_staging / "upload_front";
writeJpeg(replacement_front);
auto artwork_update = service.updateLooseCard(1, {
cards->front(),
2,
"Edited artwork",
std::nullopt,
std::nullopt,
1,
artwork_staging,
FrontAssetAction::REPLACE,
FoilAssetAction::KEEP,
replacement_front,
std::nullopt,
std::nullopt,
});
ASSERT_TRUE(artwork_update) << artwork_update.error().msg();
cards = (*data_source)->getCards(
GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(cards);
ASSERT_EQ(cards->size(), 1);
EXPECT_EQ(cards->front().front_extension, "jpg");
EXPECT_EQ(cards->front().thumbnail_extension, "avif");
EXPECT_EQ(cards->front().revision, 3);
EXPECT_TRUE(
std::filesystem::is_regular_file(published / "front-art.jpg"));
EXPECT_FALSE(
std::filesystem::exists(published / "front-art.avif"));
EXPECT_TRUE(std::filesystem::is_regular_file(published / "thumb.avif"));
auto deleted = service.deleteCard(1, cards->front());
ASSERT_TRUE(deleted) << deleted.error().msg();
cards = (*data_source)->getCards(
GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(cards);
EXPECT_TRUE(cards->empty());
EXPECT_FALSE(std::filesystem::exists(published));
}
/// Verify startup reconciliation restores edits/deletes and removes orphans.
TEST(AssetStoreTest, ReconcilesInterruptedTransitions)
{
initializeImageMagick();
TemporaryCardRoot temporary;
const std::filesystem::path database = temporary.path() / "cards.sqlite3";
auto data_source = prepareTestDataSource(database);
ASSERT_TRUE(data_source);
const std::filesystem::path staging =
temporary.path() / ".staging/upload";
const std::filesystem::path front = staging / "upload_front";
writePng(front);
NonSecretRandom random(1);
AssetStore assets(temporary.path());
CardService service(
**data_source,
random,
ImageProcessor(75, 256),
assets);
auto public_id = service.createLooseCard(1, {
"Recovery card",
std::nullopt,
std::nullopt,
0,
staging,
front,
std::nullopt,
std::nullopt,
});
ASSERT_TRUE(public_id);
const std::filesystem::path published =
temporary.path() / "published" / *public_id;
const std::filesystem::path replacement =
temporary.path() / ".staging/replacement";
std::filesystem::create_directory(replacement);
writeJpeg(replacement / "front-art.jpg");
writeJpeg(replacement / "thumb.jpg");
auto replaced = assets.replace(replacement, *public_id, 1);
ASSERT_TRUE(replaced);
ASSERT_TRUE(assets.reconcile(**data_source));
EXPECT_TRUE(
std::filesystem::is_regular_file(published / "front-art.avif"));
EXPECT_FALSE(
std::filesystem::is_regular_file(published / "front-art.jpg"));
auto trashed = assets.trash(*public_id, 1, "abc123");
ASSERT_TRUE(trashed);
EXPECT_FALSE(std::filesystem::exists(published));
ASSERT_TRUE(assets.reconcile(**data_source));
EXPECT_TRUE(std::filesystem::is_directory(published));
const std::filesystem::path orphan =
temporary.path() / "published/1";
std::filesystem::create_directory(orphan);
ASSERT_TRUE(assets.reconcile(**data_source));
EXPECT_FALSE(std::filesystem::exists(orphan));
}
/// Verify multipart binaries use server paths and empty file controls vanish.
TEST(MultipartReaderTest, StreamsExpectedFields)
{
initializeImageMagick();
TemporaryCardRoot temporary;
const std::filesystem::path source = temporary.path() / "source.png";
writePng(source);
const std::string image_bytes = readFile(source);
const httplib::ContentReader content_reader(
[](httplib::ContentReceiver)
{
return false;
},
[&](httplib::FormDataHeader header,
httplib::ContentReceiver receiver)
{
httplib::FormData name;
name.name = "name";
if(!header(name) || !receiver("Streamed card", 13))
{
return false;
}
httplib::FormData front;
front.name = "front";
front.filename = "../../browser-name.png";
if(!header(front) ||
!receiver(image_bytes.data(), image_bytes.size()))
{
return false;
}
httplib::FormData empty_foil;
empty_foil.name = "foil";
return header(empty_foil);
});
MultipartReader reader(temporary.path());
auto upload = reader.read(content_reader);
ASSERT_TRUE(upload);
EXPECT_EQ(upload->fields.at("name"), "Streamed card");
ASSERT_TRUE(upload->front);
EXPECT_EQ(upload->front->filename(), "upload_front");
EXPECT_EQ(readFile(*upload->front), image_bytes);
EXPECT_FALSE(upload->foil);
}
/// Verify multipart custom fields reject duplicates and excessive text.
TEST(MultipartReaderTest, RejectsDuplicateAndOversizedGameFields)
{
initializeImageMagick();
TemporaryCardRoot temporary;
const httplib::ContentReader duplicate_reader(
[](httplib::ContentReceiver)
{
return false;
},
[](httplib::FormDataHeader header,
httplib::ContentReceiver receiver)
{
httplib::FormData field;
field.name = "game.note";
return header(field) && receiver("first", 5) &&
header(field) && receiver("second", 6);
});
MultipartReader duplicate_parser(temporary.path());
auto duplicate = duplicate_parser.read(duplicate_reader);
ASSERT_FALSE(duplicate);
const auto* duplicate_error = duplicate.error().as<mw::HTTPError>();
ASSERT_NE(duplicate_error, nullptr);
EXPECT_EQ(duplicate_error->code, 400);
const std::string chunk(1024 * 1024, 'x');
const httplib::ContentReader oversized_reader(
[](httplib::ContentReceiver)
{
return false;
},
[&chunk](httplib::FormDataHeader header,
httplib::ContentReceiver receiver)
{
for(int index = 0; index < 9; ++index)
{
httplib::FormData field;
field.name = "game.value" + std::to_string(index);
if(!header(field) ||
!receiver(chunk.data(), chunk.size()))
{
return false;
}
}
return true;
});
MultipartReader oversized_parser(temporary.path());
auto oversized = oversized_parser.read(oversized_reader);
ASSERT_FALSE(oversized);
const auto* oversized_error = oversized.error().as<mw::HTTPError>();
ASSERT_NE(oversized_error, nullptr);
EXPECT_EQ(oversized_error->code, 413);
}
/// Verify creator authorship and rarity rules survive direct service calls.
TEST(CardServiceTest, EnforcesCreatorOwnershipAndRarity)
{
initializeImageMagick();
TemporaryCardRoot temporary;
const std::filesystem::path database = temporary.path() / "cards.sqlite3";
auto data_source = prepareTestDataSource(database);
ASSERT_TRUE(data_source);
auto transaction = (*data_source)->beginTransaction();
ASSERT_TRUE(transaction);
User creator = {
0,
"creator@example.com",
"creator@example.com",
std::nullopt,
UserRole::CREATOR,
1,
0,
0};
auto creator_id = (*transaction)->insertUser(creator);
ASSERT_TRUE(creator_id);
ASSERT_TRUE((*transaction)->updateUsername(
*creator_id, "Creator", "creator"));
ASSERT_TRUE((*transaction)->commit());
const std::filesystem::path staging =
temporary.path() / ".staging/creator";
const std::filesystem::path front = staging / "upload_front";
std::filesystem::create_directories(staging);
writePng(front);
NonSecretRandom random(5);
CardService service(
**data_source,
random,
ImageProcessor(75, 256),
AssetStore(temporary.path()));
CreateLooseCardInput input = {
"Creator card",
std::nullopt,
std::nullopt,
9,
staging,
front,
std::nullopt,
std::nullopt,
false};
auto public_id = service.createLooseCard(*creator_id, std::move(input));
ASSERT_TRUE(public_id) << public_id.error().msg();
auto cards = (*data_source)->getCards(
GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(cards);
ASSERT_EQ(cards->size(), 1);
EXPECT_EQ(cards->front().creator_user_id, *creator_id);
EXPECT_EQ(cards->front().rarity, 0);
EXPECT_FALSE(service.deleteCard(*creator_id, cards->front()));
const std::filesystem::path tampered_staging =
temporary.path() / ".staging/tampered";
const std::filesystem::path tampered_front =
tampered_staging / "upload_front";
std::filesystem::create_directories(tampered_staging);
writePng(tampered_front);
EXPECT_FALSE(service.createLooseCard(*creator_id, {
"Tampered card",
std::nullopt,
std::nullopt,
1,
tampered_staging,
tampered_front,
std::nullopt,
std::nullopt,
true,
}));
SeriesService series_service(**data_source);
auto forbidden_series = series_service.create(
*creator_id, "gh", "Creator series", "Not allowed");
ASSERT_FALSE(forbidden_series);
const auto* http_error = forbidden_series.error().as<mw::HTTPError>();
ASSERT_NE(http_error, nullptr);
EXPECT_EQ(http_error->code, 403);
}
TEST(CardServiceTest, RejectsCreatorMutationsForInternalGames)
{
initializeImageMagick();
TemporaryCardRoot temporary;
const std::filesystem::path database = temporary.path() / "cards.sqlite3";
auto data_source = prepareTestDataSource(database);
ASSERT_TRUE(data_source);
auto transaction = (*data_source)->beginTransaction();
ASSERT_TRUE(transaction);
User creator = {
0,
"creator@example.com",
"creator@example.com",
std::nullopt,
UserRole::CREATOR,
1,
0,
0};
auto creator_id = (*transaction)->insertUser(creator);
ASSERT_TRUE(creator_id);
ASSERT_TRUE((*transaction)->updateUsername(
*creator_id, "Creator", "creator"));
ASSERT_TRUE((*transaction)->insertGame(
{"internal", "Internal", "", GameVisibility::INTERNAL, 1}));
ASSERT_TRUE((*transaction)->insertGame(
{"public", "Public", "", GameVisibility::PUBLIC, 1}));
ASSERT_TRUE((*transaction)->commit());
NonSecretRandom random(7);
CardService service(
**data_source,
random,
ImageProcessor(75, 256),
AssetStore(temporary.path()));
const std::filesystem::path rejected_staging =
temporary.path() / ".staging/rejected";
std::filesystem::create_directories(rejected_staging);
const std::filesystem::path rejected_front =
rejected_staging / "upload_front";
writePng(rejected_front);
auto rejected = service.createGameCard(
*creator_id,
{"Rejected", std::nullopt, std::nullopt, 0, rejected_staging,
rejected_front, std::nullopt, std::nullopt, false},
"internal",
1,
{},
{});
ASSERT_FALSE(rejected);
const auto* create_error = rejected.error().as<mw::HTTPError>();
ASSERT_NE(create_error, nullptr);
EXPECT_EQ(create_error->code, 422);
EXPECT_EQ(create_error->msg, "Unknown game");
const std::filesystem::path public_staging =
temporary.path() / ".staging/public";
std::filesystem::create_directories(public_staging);
const std::filesystem::path public_front =
public_staging / "upload_front";
writePng(public_front);
auto created = service.createGameCard(
*creator_id,
{"Creator card", std::nullopt, std::nullopt, 0, public_staging,
public_front, std::nullopt, std::nullopt, false},
"public",
1,
{},
{});
ASSERT_TRUE(created) << created.error().msg();
auto stored = (*data_source)->getCard(
{"public", 1}, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(stored);
ASSERT_TRUE(*stored);
transaction = (*data_source)->beginTransaction();
ASSERT_TRUE(transaction);
auto hidden = (*transaction)->updateGame(
"public", "Public", "", GameVisibility::INTERNAL, 1);
ASSERT_TRUE(hidden);
ASSERT_TRUE(*hidden);
ASSERT_TRUE((*transaction)->commit());
const std::filesystem::path edit_staging =
temporary.path() / ".staging/edit";
std::filesystem::create_directories(edit_staging);
auto updated = service.updateGameCard(
*creator_id,
{**stored, 1, "Hidden edit", std::nullopt, std::nullopt, 0,
edit_staging, FrontAssetAction::KEEP, FoilAssetAction::KEEP,
std::nullopt, std::nullopt, std::nullopt, false},
2,
{},
{});
ASSERT_FALSE(updated);
const auto* update_error = updated.error().as<mw::HTTPError>();
ASSERT_NE(update_error, nullptr);
EXPECT_EQ(update_error->code, 404);
stored = (*data_source)->getCard(
{"public", 1}, GameContentScope::INCLUDE_INTERNAL);
ASSERT_TRUE(stored);
ASSERT_TRUE(*stored);
EXPECT_EQ((**stored).name, "Creator card");
}