diff --git a/.gitignore b/.gitignore index 094f8ba..18891f6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ cmake* +!CMakePresets.json build \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index a68d3bf..bd28a17 100644 --- a/.gitmodules +++ b/.gitmodules @@ -3,7 +3,7 @@ url = https://github.com/semihc/qdecimal.git [submodule "3rdparty/QxOrm"] path = 3rdparty/QxOrm - url = https://github.com/PepaRokos/QxOrm.git + url = https://github.com/QxOrm/QxOrm.git [submodule "3rdparty/LimeReport"] path = 3rdparty/LimeReport url = https://github.com/PepaRokos/LimeReport.git diff --git a/3rdparty/QxOrm b/3rdparty/QxOrm index 975c3b5..44fd7f1 160000 --- a/3rdparty/QxOrm +++ b/3rdparty/QxOrm @@ -1 +1 @@ -Subproject commit 975c3b5ad3e02e3a257f28ff81071da0a17a1e64 +Subproject commit 44fd7f16172a8894f2bfb3d4d11efaa4c3d9e24e diff --git a/CMakeLists.txt b/CMakeLists.txt index d306a36..3391162 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,13 @@ cmake_minimum_required(VERSION 3.24) project(prodejna-root) +include(CTest) +option(ENABLE_ZINT "Enable LimeReport barcode support" ON) + +find_package(Qt6 6.11.2 REQUIRED COMPONENTS Core Core5Compat Test Gui Widgets PrintSupport Sql Qml Network Xml Svg) + +set(CMAKE_POLICY_VERSION_MINIMUM 3.10) + add_subdirectory(3rdparty/qdecimal) add_subdirectory(3rdparty/QxOrm) add_subdirectory(3rdparty/LimeReport) @@ -15,3 +22,7 @@ add_subdirectory(shop) add_subdirectory(commodity) add_subdirectory(camp) +if(BUILD_TESTING) + add_subdirectory(tests) +endif() + diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..bac2065 --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,34 @@ +{ + "version": 5, + "cmakeMinimumRequired": { + "major": 3, + "minor": 24, + "patch": 0 + }, + "configurePresets": [ + { + "name": "qt6-debug", + "displayName": "Qt 6.11.2+ Debug", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/presets/${presetName}", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug" + } + } + ], + "buildPresets": [ + { + "name": "qt6-debug", + "configurePreset": "qt6-debug" + } + ], + "testPresets": [ + { + "name": "qt6-debug", + "configurePreset": "qt6-debug", + "output": { + "outputOnFailure": true + } + } + ] +} \ No newline at end of file diff --git a/tests/camp_tests.cpp b/tests/camp_tests.cpp new file mode 100644 index 0000000..990372e --- /dev/null +++ b/tests/camp_tests.cpp @@ -0,0 +1,103 @@ +#include +#include + +#include "data/campdata.h" +#include "data/serviceitem.h" +#include "data/addressitem.h" +#include "data/personprice.h" + +class CampTests : public QObject +{ + Q_OBJECT + +private slots: + void campDataDefaultsAreZeroed() + { + CampData camp; + + QCOMPARE(camp.totalPrice().toDouble(), 0.0); + QCOMPARE(camp.sale().toDouble(), 0.0); + QCOMPARE(camp.totalSale().toDouble(), 0.0); + QCOMPARE(camp.fullPrice().toDouble(), 0.0); + QVERIFY(!camp.fixedSale()); + QVERIFY(!camp.onVoucher()); + } + + void campPricesRoundTripWithoutLoss() + { + CampData camp; + + camp.setTotalPrice(QDecDouble(19.9)); + camp.setSale(QDecDouble(4.9)); + camp.setFullPrice(QDecDouble(24.8)); + + QVERIFY(qAbs(camp.totalPrice().toDouble() - 19.9) < 0.0001); + QVERIFY(qAbs(camp.sale().toDouble() - 4.9) < 0.0001); + QVERIFY(qAbs(camp.fullPrice().toDouble() - 24.8) < 0.0001); + } + + void campCollectsPeopleAndServices() + { + CampData camp; + auto person = QSharedPointer::create(); + auto service = QSharedPointer::create(); + + camp.addPerson(person); + camp.addServiceItem(service); + QCOMPARE(camp.people().size(), 1); + QCOMPARE(camp.services().size(), 1); + + camp.removePerson(person); + camp.removeServiceItem(service); + QVERIFY(camp.people().isEmpty()); + QVERIFY(camp.services().isEmpty()); + } + + void serviceItemPricesRoundTrip() + { + ServiceItem item; + + item.setPrice(QDecDouble(33.3)); + item.setTotalPrice(QDecDouble(66.6)); + item.setSale(QDecDouble(3.3)); + + QVERIFY(qAbs(item.price().toDouble() - 33.3) < 0.0001); + QVERIFY(qAbs(item.totalPrice().toDouble() - 66.6) < 0.0001); + QVERIFY(qAbs(item.sale().toDouble() - 3.3) < 0.0001); + } + + void addressItemOwnerFlagAndPrice() + { + AddressItem person; + + person.setOwner(true); + person.setPrice(QDecDouble(12.5)); + + QVERIFY(person.owner()); + QVERIFY(qAbs(person.price().toDouble() - 12.5) < 0.0001); + } + + void personPriceAgeRangeAndPrice() + { + PersonPrice price; + + price.setFromAge(3); + price.setToAge(12); + price.setPrice(QDecDouble(7.5)); + price.setActive(true); + + QCOMPARE(price.fromAge(), 3); + QCOMPARE(price.toAge(), 12); + QVERIFY(qAbs(price.price().toDouble() - 7.5) < 0.0001); + QVERIFY(price.active()); + } +}; + +int main(int argc, char *argv[]) +{ + QCoreApplication app(argc, argv); + CampTests tests; + return QTest::qExec(&tests, argc, argv); +} + +#include "camp_tests.moc" diff --git a/tests/commodity_tests.cpp b/tests/commodity_tests.cpp new file mode 100644 index 0000000..3a6f745 --- /dev/null +++ b/tests/commodity_tests.cpp @@ -0,0 +1,82 @@ +#include +#include + +#include "data/commoditydata.h" + +class CommodityTests : public QObject +{ + Q_OBJECT + +private slots: + void defaultsAreZero() + { + CommodityData commodity; + + QCOMPARE(commodity.price().toDouble(), 0.0); + QCOMPARE(commodity.count(), 0); + QCOMPARE(commodity.vat(), Enums::NONE); + QVERIFY(!commodity.favorite()); + } + + void priceRoundTripUsesFixedPointStorage() + { + CommodityData commodity; + + commodity.setPrice(QDecDouble(19.9)); + + QVERIFY(qAbs(commodity.price().toDouble() - 19.9) < 0.0001); + QCOMPARE(commodity.price(), commodity.unitPrice()); + } + + void priceRoundTripHandlesWholeAmounts() + { + CommodityData commodity; + + commodity.setPrice(QDecDouble(125)); + + QCOMPARE(commodity.price().toDouble(), 125.0); + } + + void vatMirrorsShopItemInterface() + { + CommodityData commodity; + + commodity.setVat(Enums::HIGH); + + QCOMPARE(commodity.vat(), Enums::HIGH); + QCOMPARE(commodity.vatType(), Enums::HIGH); + } + + void pluginIdMatchesEmbeddedMetadata() + { + CommodityData commodity; + + QCOMPARE(commodity.pluginId(), QStringLiteral("COMMODITY")); + } + + void basicPropertiesRoundTrip() + { + CommodityData commodity; + + commodity.setName(QStringLiteral("Položka")); + commodity.setShortName(QStringLiteral("Pol.")); + commodity.setCode(QStringLiteral("A-01")); + commodity.setCount(7); + commodity.setFavorite(true); + + QCOMPARE(commodity.name(), QStringLiteral("Položka")); + QCOMPARE(commodity.shortName(), QStringLiteral("Pol.")); + QCOMPARE(commodity.code(), QStringLiteral("A-01")); + QCOMPARE(commodity.count(), 7); + QVERIFY(commodity.favorite()); + } +}; + +int main(int argc, char *argv[]) +{ + QCoreApplication app(argc, argv); + CommodityTests tests; + return QTest::qExec(&tests, argc, argv); +} + +#include "commodity_tests.moc" diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp new file mode 100644 index 0000000..62ec45f --- /dev/null +++ b/tests/core_tests.cpp @@ -0,0 +1,157 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include "context.h" +#include "data/permission.h" +#include "data/role.h" +#include "data/user.h" +#include "imetadataplugin.h" +#include "permissionevaluator.h" + +class CoreTests : public QObject +{ + Q_OBJECT + +private slots: + void init() + { + Context::instance().setCurrentUser({}); + } + + void cleanup() + { + Context::instance().setCurrentUser({}); + } + + void metadataRejectsMissingObject() + { + QVERIFY(!IMetaDataPlugin::loadBaseMetaData({})); + QVERIFY(!IMetaDataPlugin::loadBaseMetaData(QJsonObject{{"MetaData", QStringLiteral("invalid")}})); + } + + void metadataUsesDefaultLocaleAndExtractsSchema() + { + const QJsonObject metadata{ + {"id", QStringLiteral("EXAMPLE")}, + {"schemaVersion", 2}, + {"name", QJsonObject{{"default", QStringLiteral("Example plugin")}}}, + {"description", QJsonObject{{"default", QStringLiteral("Example description")}}}, + {"sql", QJsonArray{QStringLiteral("CREATE TABLE example (id INTEGER)")}}, + {"dependencies", QJsonArray{QStringLiteral("CORE")}} + }; + + const MetaDataPtr result = IMetaDataPlugin::loadBaseMetaData(QJsonObject{{"MetaData", metadata}}); + + QVERIFY(result); + QCOMPARE(result->getId(), QStringLiteral("EXAMPLE")); + QCOMPARE(result->getName(), QStringLiteral("Example plugin")); + QCOMPARE(result->getDescription(), QStringLiteral("Example description")); + QCOMPARE(result->getSchemaVersion(), 2); + QCOMPARE(result->getSchemas(), QStringList{QStringLiteral("CREATE TABLE example (id INTEGER)")}); + QCOMPARE(result->getDependsOn(), QStringList{QStringLiteral("CORE")}); + } + + void metadataPrefersSystemLocale() + { + const QString locale = QLocale::system().name(); + const QJsonObject metadata{ + {"id", QStringLiteral("LOCALIZED")}, + {"name", QJsonObject{{"default", QStringLiteral("Fallback")}, {locale, QStringLiteral("Localized")}}}, + {"description", QJsonObject{{"default", QStringLiteral("Fallback description")}}} + }; + + const MetaDataPtr result = IMetaDataPlugin::loadBaseMetaData(QJsonObject{{"MetaData", metadata}}); + + QVERIFY(result); + QCOMPARE(result->getName(), QStringLiteral("Localized")); + QCOMPARE(result->getDescription(), QStringLiteral("Fallback description")); + } + + void permissionDeniedWithoutUser() + { + PermissionEvaluator evaluator; + QSignalSpy denied(&evaluator, &PermissionEvaluator::permissionDenied); + + QVERIFY(!evaluator.hasPermission(QStringLiteral("SHOP"), QStringLiteral("READ"))); + QCOMPARE(denied.count(), 0); + } + + void administratorBypassesPermissionLookup() + { + auto user = QSharedPointer::create(); + user->setIsAdmin(true); + Context::instance().setCurrentUser(user); + + PermissionEvaluator evaluator; + QSignalSpy denied(&evaluator, &PermissionEvaluator::permissionDenied); + + QVERIFY(evaluator.hasPermission(QStringLiteral("SHOP"), QStringLiteral("DELETE"))); + QCOMPARE(denied.count(), 0); + } + + void matchingRolePermissionIsGranted() + { + auto permission = QSharedPointer::create(); + permission->setPluginId(QStringLiteral("SHOP")); + permission->setPermissionName(QStringLiteral("READ")); + + auto role = QSharedPointer::create(); + role->addPermission(permission); + + auto user = QSharedPointer::create(); + user->setIsAdmin(false); + user->addRole(role); + Context::instance().setCurrentUser(user); + + PermissionEvaluator evaluator; + QSignalSpy denied(&evaluator, &PermissionEvaluator::permissionDenied); + + QVERIFY(evaluator.hasPermission(QStringLiteral("SHOP"), QStringLiteral("READ"))); + QCOMPARE(denied.count(), 0); + } + + void mismatchedPermissionIsDeniedAndEmitsSignal() + { + auto permission = QSharedPointer::create(); + permission->setPluginId(QStringLiteral("SHOP")); + permission->setPermissionName(QStringLiteral("READ")); + + auto role = QSharedPointer::create(); + role->addPermission(permission); + + auto user = QSharedPointer::create(); + user->setIsAdmin(false); + user->addRole(role); + Context::instance().setCurrentUser(user); + + PermissionEvaluator evaluator; + QSignalSpy denied(&evaluator, &PermissionEvaluator::permissionDenied); + + QVERIFY(!evaluator.hasPermission(QStringLiteral("SHOP"), QStringLiteral("DELETE"))); + QCOMPARE(denied.count(), 1); + QCOMPARE(denied.takeFirst().at(0).toString(), QStringLiteral("DELETE")); + } +}; + +int main(int argc, char *argv[]) +{ + QCoreApplication app(argc, argv); + QTemporaryDir settingsDirectory; + if (!settingsDirectory.isValid()) { + return 1; + } + + QSettings::setDefaultFormat(QSettings::IniFormat); + QSettings::setPath(QSettings::IniFormat, QSettings::UserScope, settingsDirectory.path()); + + CoreTests tests; + return QTest::qExec(&tests, argc, argv); +} + +#include "core_tests.moc" \ No newline at end of file diff --git a/tests/persistence_tests.cpp b/tests/persistence_tests.cpp new file mode 100644 index 0000000..b69b0bf --- /dev/null +++ b/tests/persistence_tests.cpp @@ -0,0 +1,81 @@ +#include +#include +#include +#include +#include + +#include + +#include "context.h" +#include "service.h" +#include "../countryregister/data/countrydata.h" + +class PersistenceTests : public QObject +{ + Q_OBJECT + +private slots: + void crudCycleOnTemporaryDatabase() + { + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString dbPath = dir.filePath(QStringLiteral("countries.db")); + + qx::QxSqlDatabase::getSingleton()->setDriverName(QStringLiteral("QSQLITE")); + qx::QxSqlDatabase::getSingleton()->setDatabaseName(dbPath); + + const QSqlError createError = qx::dao::create_table(); + QVERIFY2(!createError.isValid(), qPrintable(createError.text())); + QVERIFY(QFileInfo::exists(dbPath)); + + Service service; + + auto country = QSharedPointer::create(); + country->setCode2(QStringLiteral("CZ")); + country->setCode3(QStringLiteral("CZE")); + country->setCzechName(QStringLiteral("Česko")); + country->setEnglishName(QStringLiteral("Czechia")); + + QSignalSpy errorSpy(&service, &Service::dbError); + service.save(country); + QCOMPARE(errorSpy.count(), 0); + QVERIFY(country->id() > 0); + + const QList> all = service.all(); + QCOMPARE(all.size(), 1); + QCOMPARE(all.first()->code2(), QStringLiteral("CZ")); + QCOMPARE(all.first()->czechName(), QStringLiteral("Česko")); + + auto loaded = service.loadById(country->id()); + QVERIFY(!loaded.isNull()); + QCOMPARE(loaded->code3(), QStringLiteral("CZE")); + + loaded->setCzechName(QStringLiteral("Česká republika")); + service.update(loaded); + QCOMPARE(errorSpy.count(), 0); + + auto reloaded = service.loadById(country->id()); + QCOMPARE(reloaded->czechName(), QStringLiteral("Česká republika")); + + service.erase(reloaded); + QCOMPARE(errorSpy.count(), 0); + QVERIFY(service.all().isEmpty()); + } +}; + +int main(int argc, char *argv[]) +{ + QCoreApplication app(argc, argv); + QTemporaryDir settingsDirectory; + if (!settingsDirectory.isValid()) { + return 1; + } + + QSettings::setDefaultFormat(QSettings::IniFormat); + QSettings::setPath(QSettings::IniFormat, QSettings::UserScope, settingsDirectory.path()); + + PersistenceTests tests; + return QTest::qExec(&tests, argc, argv); +} + +#include "persistence_tests.moc" diff --git a/tests/plugin_metadata_tests.cpp b/tests/plugin_metadata_tests.cpp new file mode 100644 index 0000000..47377e2 --- /dev/null +++ b/tests/plugin_metadata_tests.cpp @@ -0,0 +1,73 @@ +#include +#include +#include +#include +#include +#include + +#include "imetadataplugin.h" +#include "iplugin.h" + +struct PluginExpectation +{ + QString path; + QString id; +}; + +class PluginMetadataTests : public QObject +{ + Q_OBJECT + +public: + explicit PluginMetadataTests(QList plugins) + : m_plugins(std::move(plugins)) + { + } + +private slots: + void embeddedMetadataMatchesPluginContract() + { + for (const PluginExpectation &plugin : m_plugins) { + QVERIFY2(QFileInfo::exists(plugin.path), qPrintable(plugin.path)); + + QPluginLoader loader(plugin.path); + const QJsonObject metadata = loader.metaData(); + QVERIFY2(!metadata.isEmpty(), qPrintable(loader.errorString())); + QCOMPARE(metadata.value(QStringLiteral("IID")).toString(), QString::fromLatin1(PluginInterface_iid)); + + const QJsonObject data = metadata.value(QStringLiteral("MetaData")).toObject(); + QVERIFY2(!data.isEmpty(), qPrintable(plugin.path)); + QCOMPARE(data.value(QStringLiteral("id")).toString(), plugin.id); + QVERIFY(data.value(QStringLiteral("schemaVersion")).toInt() > 0); + QVERIFY(data.value(QStringLiteral("sql")).isArray()); + QVERIFY(data.value(QStringLiteral("dependencies")).isArray()); + + const MetaDataPtr parsed = IMetaDataPlugin::loadBaseMetaData(metadata); + QVERIFY(parsed); + QCOMPARE(parsed->getId(), plugin.id); + QCOMPARE(parsed->getSchemaVersion(), data.value(QStringLiteral("schemaVersion")).toInt()); + QCOMPARE(parsed->getSchemas().size(), data.value(QStringLiteral("sql")).toArray().size()); + } + } + +private: + QList m_plugins; +}; + +int main(int argc, char *argv[]) +{ + QCoreApplication app(argc, argv); + if (argc < 3 || (argc - 1) % 2 != 0) { + return 2; + } + + QList plugins; + for (int index = 1; index < argc; index += 2) { + plugins.append({QString::fromLocal8Bit(argv[index]), QString::fromLocal8Bit(argv[index + 1])}); + } + + PluginMetadataTests tests(std::move(plugins)); + return QTest::qExec(&tests, 1, argv); +} + +#include "plugin_metadata_tests.moc" \ No newline at end of file diff --git a/tests/services_tests.cpp b/tests/services_tests.cpp new file mode 100644 index 0000000..2967bac --- /dev/null +++ b/tests/services_tests.cpp @@ -0,0 +1,67 @@ +#include +#include + +#include "data/accservice.h" + +class ServicesTests : public QObject +{ + Q_OBJECT + +private slots: + void defaultsMatchExpectedInitialState() + { + AccService service; + + QCOMPARE(service.price().toDouble(), 0.0); + QVERIFY(service.active()); + QVERIFY(!service.salePossible()); + QCOMPARE(service.serviceType(), AccService::OTHER); + QCOMPARE(service.vatType(), Enums::NONE); + } + + void priceRoundTripUsesFixedPointStorage() + { + AccService service; + + service.setPrice(QDecDouble(19.9)); + + QVERIFY(qAbs(service.price().toDouble() - 19.9) < 0.0001); + } + + void priceRoundTripHandlesWholeAmounts() + { + AccService service; + + service.setPrice(QDecDouble(250)); + + QCOMPARE(service.price().toDouble(), 250.0); + } + + void basicPropertiesRoundTrip() + { + AccService service; + + service.setAccServiceName(QStringLiteral("Ubytování")); + service.setAccServiceCode(QStringLiteral("UB-1")); + service.setActive(false); + service.setSalePossible(true); + service.setServiceType(AccService::TENT); + service.setVatType(Enums::FIRST_LOWER); + + QCOMPARE(service.accServiceName(), QStringLiteral("Ubytování")); + QCOMPARE(service.accServiceCode(), QStringLiteral("UB-1")); + QVERIFY(!service.active()); + QVERIFY(service.salePossible()); + QCOMPARE(service.serviceType(), AccService::TENT); + QCOMPARE(service.vatType(), Enums::FIRST_LOWER); + } +}; + +int main(int argc, char *argv[]) +{ + QCoreApplication app(argc, argv); + ServicesTests tests; + return QTest::qExec(&tests, argc, argv); +} + +#include "services_tests.moc" diff --git a/tests/shop_tests.cpp b/tests/shop_tests.cpp new file mode 100644 index 0000000..b5ee344 --- /dev/null +++ b/tests/shop_tests.cpp @@ -0,0 +1,57 @@ +#include +#include + +#include "data/voucher.h" + +class ShopTests : public QObject +{ + Q_OBJECT + +private slots: + void defaultsMatchNewVoucherState() + { + Voucher voucher; + + QCOMPARE(voucher.totalPrice().toDouble(), 0.0); + QCOMPARE(voucher.status(), Voucher::NEW); + QCOMPARE(voucher.eetStatus(), Voucher::EET_FOR_SEND); + } + + void totalPriceRoundTripUsesFixedPointStorage() + { + Voucher voucher; + + voucher.setTotalPrice(QDecDouble(19.9)); + + QVERIFY(qAbs(voucher.totalPrice().toDouble() - 19.9) < 0.0001); + } + + void vatAmountIsDifferenceBetweenTotalAndBase() + { + Voucher voucher; + + voucher.setTotalPriceVatHigh(QDecDouble(121)); + voucher.setPriceVatHigh(QDecDouble(100)); + + QVERIFY(qAbs(voucher.vatAmountHigh().toDouble() - 21.0) < 0.0001); + } + + void voucherSumAccumulatesPricesWithoutTruncation() + { + VoucherSum sum(0, 0); + + sum.addPrice(QDecDouble(19.9)); + sum.addPrice(QDecDouble(0.1)); + + QVERIFY(qAbs(sum.totalPrice().toDouble() - 20.0) < 0.0001); + } +}; + +int main(int argc, char *argv[]) +{ + QCoreApplication app(argc, argv); + ShopTests tests; + return QTest::qExec(&tests, argc, argv); +} + +#include "shop_tests.moc"