diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..beeb3b2 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,52 @@ +name: Code Coverage + +permissions: + pull-requests: write + contents: write + +on: + push: + branches: + - master + pull_request: + +jobs: + coverage_report: + name: Generate coverage report + runs-on: ubuntu-latest + steps: + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup vcpkg + run: | + chmod +x ./tools/install-vcpkg.sh + ./tools/install-vcpkg.sh + + - name: Setup lcov + uses: hrishikesh-kadam/setup-lcov@v1 + + - name: Generate lcov input + run: | + chmod +x ./build.sh + chmod +x ./test/coverage.sh + ./test/coverage.sh + + - name: Report code coverage + if: ${{ github.event_name == 'pull_request' }} + uses: zgosalvez/github-actions-report-lcov@v3 + with: + coverage-files: build/filtered_coverage.info + minimum-coverage: 80 + artifact-name: code-coverage-report + github-token: ${{ secrets.GITHUB_TOKEN }} + update-comment: true + + - name: Upload code coverage report + if: ${{ github.ref_name == 'master' && github.event_name == 'push' }} + uses: JamesIves/github-pages-deploy-action@v4 + with: + folder: build/coverage_report + target-folder: coverage-report + branch: gh-pages diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 8e49960..0c63345 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -10,7 +10,7 @@ on: jobs: build-and-test: runs-on: macos-latest - + steps: - uses: actions/checkout@v4 @@ -18,7 +18,7 @@ jobs: run: | chmod +x ./tools/install-vcpkg.sh ./tools/install-vcpkg.sh - + - name: Build run: | chmod +x ./build.sh diff --git a/CMakeLists.txt b/CMakeLists.txt index a91fa28..5b527d5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,14 +48,7 @@ set(VCPKG_BUILD_TYPE ${CMAKE_BUILD_TYPE}) message("CMAKE_BUILD_TYPE: ${CMAKE_BUILD_TYPE}") message("VCPKG_BUILD_TYPE: ${VCPKG_BUILD_TYPE}") -find_path(UWEBSOCKETS_INCLUDE_DIRS "uwebsockets/App.h") -message("µWebsockets include dir: ${UWEBSOCKETS_INCLUDE_DIRS}") -if(WIN32) - find_library(LIBUSOCKETS_STATIC uSockets.lib) -else(WIN32) - find_library(LIBUSOCKETS_STATIC libuSockets.a) -endif(WIN32) -message(${LIBUSOCKETS_STATIC}) +find_package(unofficial-uwebsockets CONFIG REQUIRED) find_path(MDNS_INCLUDE_DIRS "mdns.h") message("mdns include dir: ${MDNS_INCLUDE_DIRS}") @@ -63,8 +56,6 @@ message("mdns include dir: ${MDNS_INCLUDE_DIRS}") find_package(mdns REQUIRED) find_package(nlohmann_json 3.11.2 REQUIRED) find_package(nlohmann_json_schema_validator REQUIRED) -find_package(libuv REQUIRED NO_MODULE) -find_package(ZLIB REQUIRED) if(WT_WITH_SSL) find_package(OpenSSL REQUIRED) @@ -83,6 +74,13 @@ if(WT_BUILD_TESTS) endif() add_library(webthing-cpp INTERFACE) + +target_link_libraries(webthing-cpp INTERFACE + nlohmann_json_schema_validator::validator + nlohmann_json::nlohmann_json + unofficial::uwebsockets::uwebsockets +) + target_include_directories(webthing-cpp INTERFACE $ $ diff --git a/build.bat b/build.bat index 1711200..3fc464c 100644 --- a/build.bat +++ b/build.bat @@ -31,7 +31,6 @@ if %errorlevel% equ 0 ( ) echo Project architecture: %build_arch% - echo %* | find /i "with_ssl" > nul if %errorlevel% equ 0 ( set "ssl_support=ON" @@ -43,7 +42,31 @@ if %errorlevel% equ 0 ( echo Project SSL support: %ssl_support% copy %vcpkg_file% vcpkg.json -cmake -B "%build_dir%" -S . -DWT_WITH_SSL=%ssl_support% -DCMAKE_BUILD_TYPE=%build_type% -DCMAKE_TOOLCHAIN_FILE="%toolchain_file%" -DVCPKG_TARGET_TRIPLET="%vcpkg_triplet%" -G "Visual Studio 17 2022" -A "%build_arch%" +echo %* | find /i "without_tests" > nul +if %errorlevel% equ 0 ( + set "build_tests=OFF" +) else ( + set "build_tests=ON" +) +echo Project build tests: %build_tests% + +echo %* | find /i "skip_tests" > nul +if %errorlevel% equ 0 ( + set "skip_tests=ON" +) else ( + set "skip_tests=OFF" +) +echo Project skip tests: %skip_tests% + +echo %* | find /i "without_examples" > nul +if %errorlevel% equ 0 ( + set "build_examples=OFF" +) else ( + set "build_examples=ON" +) +echo Project build examples: %build_examples% + +cmake -B "%build_dir%" -S . -DWT_BUILD_TESTS=%build_tests% -DWT_SKIP_TESTS=%skip_tests% -DWT_BUILD_EXAMPLES=%build_examples% -DWT_WITH_SSL=%ssl_support% -DCMAKE_BUILD_TYPE=%build_type% -DCMAKE_TOOLCHAIN_FILE="%toolchain_file%" -DVCPKG_TARGET_TRIPLET="%vcpkg_triplet%" -G "Visual Studio 17 2022" -A "%build_arch%" cmake --build "%build_dir%" --config "%build_type%" --parallel %NUMBER_OF_PROCESSORS% ctest --test-dir "%build_dir%\test\" \ No newline at end of file diff --git a/build.sh b/build.sh index 86919a1..5235f11 100644 --- a/build.sh +++ b/build.sh @@ -16,11 +16,14 @@ fi if [[ "${@#release}" = "$@" ]] then build_type="Debug" + code_coverage="ON" else build_type="Release" + code_coverage="OFF" rm -rf $build_dir fi echo "project build type: $build_type" +echo "project code coverage: $code_coverage" if [[ "${@#with_ssl}" = "$@" ]] then @@ -33,8 +36,31 @@ fi echo "project SSL support: $ssl_support" cp $vcpkg_file vcpkg.json +if [[ "${@#without_tests}" = "$@" ]] +then + build_tests="ON" +else + build_tests="OFF" +fi +echo "project build tests: $build_tests" + +if [[ "${@#skip_tests}" = "$@" ]] +then + skip_tests="OFF" +else + skip_tests="ON" +fi +echo "project skip tests: $build_tests" + +if [[ "${@#without_examples}" = "$@" ]] +then + build_examples="ON" +else + build_examples="OFF" +fi +echo "project build examples: $build_examples" -cmake -B build -S . -D"WT_WITH_SSL=$ssl_support" -D"CMAKE_BUILD_TYPE=$build_type" -D"CMAKE_TOOLCHAIN_FILE=$toolchain_file" -D"CMAKE_MAKE_PROGRAM:PATH=make" -D"CMAKE_CXX_COMPILER=g++" +cmake -B build -S . -D"WT_BUILD_TESTS=$build_tests" -D"WT_SKIP_TESTS=$skip_tests" -D"WT_BUILD_EXAMPLES=$build_examples" -D"WT_WITH_SSL=$ssl_support" -D"CMAKE_BUILD_TYPE=$build_type" -D"WT_ENABLE_COVERAGE=$code_coverage" -D"CMAKE_TOOLCHAIN_FILE=$toolchain_file" -D"CMAKE_MAKE_PROGRAM:PATH=make" -D"CMAKE_CXX_COMPILER=g++" cmake --build build --parallel $(nproc) ctest --test-dir build/test/ \ No newline at end of file diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 9826dbb..2b052b0 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -7,15 +7,11 @@ configure_file( ) set(LIBS_FOR_EXAMPLES - ${LIBUSOCKETS_STATIC} - nlohmann_json::nlohmann_json nlohmann_json_schema_validator::validator - ZLIB::ZLIB - $,libuv::uv_a,libuv::uv> + unofficial::uwebsockets::uwebsockets ) -set(INCLUDES_FOR_EXAMPLES ../include ${UWEBSOCKETS_INCLUDE_DIRS} ${MDNS_INCLUDE_DIRS}) - +set(INCLUDES_FOR_EXAMPLES ../include) function(create_example_binary cpp_file) cmake_path(GET cpp_file STEM target) @@ -26,11 +22,6 @@ function(create_example_binary cpp_file) target_include_directories("${target}" PRIVATE ${INCLUDES_FOR_EXAMPLES}) target_link_libraries("${target}" PRIVATE ${LIBS_FOR_EXAMPLES}) - - if(WT_WITH_SSL) - target_link_libraries("${target}" PRIVATE OpenSSL::SSL OpenSSL::Crypto) - endif(WT_WITH_SSL) - endfunction() create_example_binary(single-thing.cpp) diff --git a/include/bw/webthing/server.hpp b/include/bw/webthing/server.hpp index 7e847f0..f903202 100644 --- a/include/bw/webthing/server.hpp +++ b/include/bw/webthing/server.hpp @@ -91,6 +91,7 @@ struct MultipleThings : public ThingContainer{ class WebThingServer { +public: struct Builder { Builder(ThingContainer things) @@ -133,11 +134,6 @@ class WebThingServer return *this; } - Builder& limit_memory() - { - return *this; - } - WebThingServer build() { return WebThingServer(things_, port_, hostname_, base_path_, @@ -160,7 +156,6 @@ class WebThingServer }; -public: struct Response { Response(uWS::HttpRequest* req, uwsHttpResponse* res) @@ -434,9 +429,7 @@ class WebThingServer else if(v.is_string()) prop_setter(v.get()); else if(v.is_number_integer()) - prop_setter(v.get()); - else if(v.is_number_unsigned()) - prop_setter(v.get()); + prop_setter(v.get()); else if(v.is_number_float()) prop_setter(v.get()); else @@ -528,6 +521,16 @@ class WebThingServer return name; } + int get_port() const + { + return port; + } + + std::string get_base_path() const + { + return base_path; + } + uWebsocketsApp* get_web_server() const { return web_server.get(); @@ -820,11 +823,9 @@ class WebThingServer if(v.is_boolean()) prop_setter(v.get()); else if(v.is_string()) - prop_setter(v.get()); + prop_setter(v.get()); else if(v.is_number_integer()) prop_setter(v.get()); - else if(v.is_number_unsigned()) - prop_setter(v.get()); else if(v.is_number_float()) prop_setter(v.get()); else diff --git a/include/bw/webthing/thing.hpp b/include/bw/webthing/thing.hpp index 693cd0b..4a4c6cc 100644 --- a/include/bw/webthing/thing.hpp +++ b/include/bw/webthing/thing.hpp @@ -132,7 +132,7 @@ class Thing return pds; } - // Get the thing's actions a json array + // Get the thing's actions as json array // action_name -- Optional action name to get description for json get_action_descriptions(std::optional action_name = std::nullopt) const { @@ -146,7 +146,7 @@ class Thing return descriptions; } - // Get the thing's events as a json array. + // Get the thing's events as json array. // event_name -- Optional event name to get description for json get_event_descriptions(const std::optional& event_name = std::nullopt) const { diff --git a/include/bw/webthing/version.hpp b/include/bw/webthing/version.hpp index 9ce0c39..ef8c565 100644 --- a/include/bw/webthing/version.hpp +++ b/include/bw/webthing/version.hpp @@ -8,6 +8,6 @@ namespace bw::webthing { -constexpr const char version[] = "1.1.0"; +constexpr const char version[] = "1.2.0"; } // bw::webthing diff --git a/include/bw/webthing/webthing.hpp b/include/bw/webthing/webthing.hpp index ad948f6..8b46622 100644 --- a/include/bw/webthing/webthing.hpp +++ b/include/bw/webthing/webthing.hpp @@ -20,7 +20,7 @@ namespace bw::webthing { -std::shared_ptr make_thing(std::string id, std::string title, std::vector type, std::string description) +inline std::shared_ptr make_thing(std::string id, std::string title, std::vector type, std::string description) { if(id == "") id = "uuid:" + generate_uuid(); @@ -29,7 +29,7 @@ std::shared_ptr make_thing(std::string id, std::string title, std::vector return std::make_shared(id, title, type, description); } -std::shared_ptr make_thing(std::string id = "", std::string title = "", std::string type = "", std::string description = "") +inline std::shared_ptr make_thing(std::string id = "", std::string title = "", std::string type = "", std::string description = "") { std::vector types; if(type != "") @@ -72,43 +72,43 @@ template std::shared_ptr> link_property(std::shared_ptradd_available_event(name, metadata); } -void link_event(std::shared_ptr thing, std::string name, json metadata = json::object()) +inline void link_event(std::shared_ptr thing, std::string name, json metadata = json::object()) { link_event(thing.get(), name, metadata); } -std::shared_ptr emit_event(Thing* thing, std::string name, std::optional data = std::nullopt) +inline std::shared_ptr emit_event(Thing* thing, std::string name, std::optional data = std::nullopt) { auto event = std::make_shared(thing, name, data); thing->add_event(event); return event; } -std::shared_ptr emit_event(std::shared_ptr thing, std::string name, std::optional data = std::nullopt) +inline std::shared_ptr emit_event(std::shared_ptr thing, std::string name, std::optional data = std::nullopt) { return emit_event(thing.get(), name, data); } -std::shared_ptr emit_event(Thing* thing, Event&& event) +inline std::shared_ptr emit_event(Thing* thing, Event&& event) { auto event_ptr = std::make_shared(event); thing->add_event(event_ptr); return event_ptr; } -std::shared_ptr emit_event(std::shared_ptr thing, Event&& event) +inline std::shared_ptr emit_event(std::shared_ptr thing, Event&& event) { auto event_ptr = std::make_shared(event); thing->add_event(event_ptr); return event_ptr; } -void link_action(Thing* thing, std::string action_name, json metadata, +inline void link_action(Thing* thing, std::string action_name, json metadata, std::function perform_action = nullptr, std::function cancel_action = nullptr) { Thing::ActionSupplier action_supplier = [thing, action_name, perform_action, cancel_action](auto input){ @@ -120,19 +120,19 @@ void link_action(Thing* thing, std::string action_name, json metadata, thing->add_available_action(action_name, metadata, std::move(action_supplier)); } -void link_action(std::shared_ptr thing, std::string action_name, json metadata, +inline void link_action(std::shared_ptr thing, std::string action_name, json metadata, std::function perform_action = nullptr, std::function cancel_action = nullptr) { link_action(thing.get(), action_name, metadata, perform_action, cancel_action); } -void link_action(Thing* thing, std::string action_name, +inline void link_action(Thing* thing, std::string action_name, std::function perform_action = nullptr, std::function cancel_action = nullptr) { link_action(thing, action_name, json::object(), perform_action, cancel_action); } -void link_action(std::shared_ptr thing, std::string action_name, +inline void link_action(std::shared_ptr thing, std::string action_name, std::function perform_action = nullptr, std::function cancel_action = nullptr) { link_action(thing.get(), action_name, perform_action, cancel_action); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index b80c342..e73cc62 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -1,11 +1,14 @@ find_package(Catch2 3 REQUIRED) +find_package(cpr CONFIG REQUIRED) +find_package(ixwebsocket CONFIG REQUIRED) add_executable(tests "catch2/unit-tests/action_tests.cpp" "catch2/unit-tests/event_tests.cpp" "catch2/unit-tests/json_validator_tests.cpp" "catch2/unit-tests/property_tests.cpp" - "catch2/unit-tests/server_tests.cpp" + "catch2/unit-tests/server_http_tests.cpp" + "catch2/unit-tests/server_ws_tests.cpp" "catch2/unit-tests/storage_tests.cpp" "catch2/unit-tests/thing_tests.cpp" "catch2/unit-tests/utils_tests.cpp" @@ -17,20 +20,28 @@ add_executable(tests set_property(TARGET tests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") -set(INCLUDES_FOR_TESTS ../include ${UWEBSOCKETS_INCLUDE_DIRS} ${MDNS_INCLUDE_DIRS}) +set(INCLUDES_FOR_TESTS ../include) target_compile_definitions(tests PRIVATE CATCH_CONFIG_ENABLE_ALL_STRINGMAKERS) target_include_directories(tests PRIVATE ${INCLUDES_FOR_TESTS}) target_link_libraries(tests PRIVATE Catch2::Catch2WithMain) -target_link_libraries(tests PRIVATE nlohmann_json::nlohmann_json) +target_link_libraries(tests PRIVATE cpr::cpr) +target_link_libraries(tests PRIVATE ixwebsocket::ixwebsocket) target_link_libraries(tests PRIVATE nlohmann_json_schema_validator::validator) -target_link_libraries(tests PRIVATE ${LIBUSOCKETS_STATIC}) -target_link_libraries(tests PRIVATE ZLIB::ZLIB) -target_link_libraries(tests PRIVATE $,libuv::uv_a,libuv::uv>) +target_link_libraries(tests PRIVATE unofficial::uwebsockets::uwebsockets) -if(WT_WITH_SSL) - target_link_libraries(tests PRIVATE OpenSSL::SSL OpenSSL::Crypto) -endif(WT_WITH_SSL) +if(WT_ENABLE_COVERAGE) + if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(tests PRIVATE --coverage) + target_link_options(tests PRIVATE --coverage) + else() + message(FATAL_ERROR "Coverage only supported with GCC/Clang") + endif() +endif() -include(CTest) -include(Catch) -catch_discover_tests(tests) \ No newline at end of file +option(WT_SKIP_TESTS "skip tests execution" OFF) +message("WT_SKIP_TESTS: ${WT_SKIP_TESTS}") +if(NOT WT_SKIP_TESTS) + include(CTest) + include(Catch) + catch_discover_tests(tests) +endif() \ No newline at end of file diff --git a/test/catch2/unit-tests/action_tests.cpp b/test/catch2/unit-tests/action_tests.cpp index 9ffa465..48eb65a 100644 --- a/test/catch2/unit-tests/action_tests.cpp +++ b/test/catch2/unit-tests/action_tests.cpp @@ -53,28 +53,57 @@ TEST_CASE( "Webthing specifies action status message fromat", "[action][json]" ) SCENARIO( "actions have a stateful lifecycle", "[action]" ) { + FIXED_TIME_SCOPED("2025-02-17T02:34:56.000+00:00"); + + GIVEN("a custom action") { struct CustomAction : public Action { - CustomAction() : Action("abc123", { + CustomAction(void* thing_void_ptr) + : Action("abc123", { /*notify_thing*/[](json j){ - std::cout << "THING GOT " << j << std::endl; + logger::info("THING GOT " + j.dump()); }, /*perform_action*/[&]{ - std::cout << "CustomAction perform action with input:" << get_input().value_or(json()) << std::endl; - std::this_thread::sleep_for(std::chrono::milliseconds(200)); - work_done = true; + logger::info("CustomAction perform action with input:" + get_input().value_or(json()).dump()); + + auto start_time = std::chrono::steady_clock::now(); + auto max_duration = std::chrono::milliseconds(300); + + while (!cancel_work && std::chrono::steady_clock::now() - start_time < max_duration) + { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + work_done = !cancel_work; + }, + /*canel_action*/[&]{ + logger::info("CustomAction cancel action" + get_input().value_or(json()).dump()); + cancel_work = true; + }, + /*get_thing*/[&]{ + return thing_ptr; } }, "my-custom-action", json({"a", "b", "c"})) + , thing_ptr(thing_void_ptr) {} + void* thing_ptr = nullptr; + bool cancel_work = false; bool work_done = false; }; - - CustomAction action; + + struct TestThing + { + } thing; + CustomAction action(&thing); REQUIRE( action.get_id() == "abc123" ); + REQUIRE( action.get_name() == "my-custom-action" ); REQUIRE( action.get_status() == "created" ); + REQUIRE( action.get_href() == "/actions/my-custom-action/abc123" ); + REQUIRE( action.get_thing() == &thing ); + REQUIRE( action.get_time_requested() == "2025-02-17T02:34:56.000+00:00" ); + REQUIRE_FALSE( action.get_time_completed() ); WHEN("action is performed successfully") { @@ -88,10 +117,34 @@ SCENARIO( "actions have a stateful lifecycle", "[action]" ) if(t.joinable()) t.join(); - THEN("action will be considered completed") + THEN("action will be considered completed and work to be done") { REQUIRE( action.get_status() == "completed" ); REQUIRE( action.work_done ); + REQUIRE( action.get_time_completed() == "2025-02-17T02:34:56.000+00:00" ); + } + } + + WHEN("action was canceled") + { + std::thread t([&](){ + action.start(); + }); + + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + REQUIRE( action.get_status() == "pending" ); + + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + action.cancel(); + + if(t.joinable()) + t.join(); + + THEN("action will be considered completed but work to be unfinsihed") + { + REQUIRE( action.get_status() == "completed" ); + REQUIRE_FALSE( action.work_done ); + REQUIRE( action.get_time_completed() == "2025-02-17T02:34:56.000+00:00" ); } } } diff --git a/test/catch2/unit-tests/property_tests.cpp b/test/catch2/unit-tests/property_tests.cpp index 82e3df6..37e3a58 100644 --- a/test/catch2/unit-tests/property_tests.cpp +++ b/test/catch2/unit-tests/property_tests.cpp @@ -66,6 +66,19 @@ TEST_CASE( "Properties can be updated", "[property]" ) REQUIRE( v1->get() == "Бennö 森林" ); } +TEST_CASE( "Properties meta data must be encoded as json object", "[property]" ) +{ + auto val = create_value("a_string_value"); + auto prop = create_proptery("test-prop", val, json{{"title", "some-test-property"}}); + + REQUIRE( prop->get_metadata()["title"] == "some-test-property" ); + REQUIRE( *prop->get_value() == "a_string_value" ); + + REQUIRE_THROWS_MATCHES(create_proptery("test-prop", val, json{"some","data","as","array"}), + PropertyError, + Catch::Matchers::Message("Only json::object is allowed as meta data.")); +} + TEST_CASE( "Properties value type can't be changed", "[property]" ) { auto val = create_value("a_string_value"); diff --git a/test/catch2/unit-tests/server_http_tests.cpp b/test/catch2/unit-tests/server_http_tests.cpp new file mode 100644 index 0000000..7832196 --- /dev/null +++ b/test/catch2/unit-tests/server_http_tests.cpp @@ -0,0 +1,544 @@ +// Webthing-CPP +// SPDX-FileCopyrightText: 2023-present Benno Waldhauer +// SPDX-License-Identifier: MIT + +#include +#include +#include +#include + +using namespace bw::webthing; + +inline void test_running_server(WebThingServer::Builder& builder, std::function test_callback) +{ + logger::set_level(log_level::trace); + auto server = builder.build(); + REQUIRE(server.get_web_server() != nullptr); + + int port = server.get_port(); + std::string base_path = server.get_base_path(); + std::exception_ptr thread_exception = nullptr; + + auto t = std::thread([&](){ + + try + { + std::string base_url = "http://localhost:" + std::to_string(port) + base_path; + test_callback(&server, base_url); + } catch (...) { + thread_exception = std::current_exception(); + } + + server.stop(); + }); + + server.start(); + t.join(); + + if (thread_exception) { + std::rethrow_exception(thread_exception); + } +}; + +TEST_CASE( "It can host a single thing", "[server][http]" ) +{ + auto thing = make_thing("uri:test:1", "single-thing"); + link_property(thing, "brightness", 50, { + {"@type", "BrightnessProperty"}, + {"title", "Brightness"}, + {"type", "integer"}, + {"description", "The level of light from 0-100"}, + {"minimum", 0}, + {"maximum", 100}, + {"unit", "percent"}}); + + auto thing_container = SingleThing(thing.get()); + auto builder = WebThingServer::host(thing_container).port(57456); + + test_running_server(builder, [](WebThingServer* server, const std::string& base_url) + { + REQUIRE(server->get_name() == "single-thing"); + auto res = cpr::Get(cpr::Url{base_url}); + REQUIRE(res.status_code == 200); + auto td = json::parse(res.text); + REQUIRE(td["title"] == "single-thing"); + + res = cpr::Get(cpr::Url{base_url + "/properties"}); + REQUIRE(res.status_code == 200); + auto props = json::parse(res.text); + REQUIRE(props["brightness"] == 50); + + res = cpr::Put( + cpr::Url{base_url + "/properties/brightness"}, + cpr::Body{json{{"brightness", 42}}.dump()} + ); + REQUIRE(res.status_code == 200); + + res = cpr::Get(cpr::Url{base_url + "/properties/brightness"}); + REQUIRE(res.status_code == 200); + props = json::parse(res.text); + REQUIRE(props["brightness"] == 42); + + res = cpr::Put( + cpr::Url{base_url + "/properties/brightness"} /*no body*/ + ); + REQUIRE(res.status_code == 400); // bad request + + // property name missing in payload + res = cpr::Put( + cpr::Url{base_url + "/properties/brightness"}, + cpr::Body{json{123}.dump()} + ); + REQUIRE(res.status_code == 400); // bad request + + res = cpr::Get(cpr::Url{base_url + "/properties/not-existing-property"}); + REQUIRE(res.status_code == 404); // not found + + res = cpr::Put( + cpr::Url{base_url + "/properties/not-existing-property"}, + cpr::Body{json{{"not-existing-property", 123}}.dump()} + ); + REQUIRE(res.status_code == 404); // not found + }); +} + +TEST_CASE( "It can host multiple things", "[server][http]" ) +{ + auto thing_a = make_thing("uri:test:a", "thing-a"); + + link_property(thing_a, "boolean-prop", true, { + {"title", "Bool Property"}, + {"type", "boolean"} + }); + + link_property(thing_a, "double-prop", 42.13, { + {"title", "Double Property"}, + {"type", "number"} + }); + + link_property(thing_a, "string-prop", std::string("the-value"), { + {"title", "String Property"}, + {"type", "string"} + }); + + auto thing_b = make_thing("uri:test:b", "thing-b"); + + link_property(thing_b, "object-prop", json{{"key", "value"}}, { + {"title", "Object Property"}, + {"type", "object"} + }); + + link_property(thing_b, "array-prop", json{"some", "values", 42}, { + {"title", "Array Property"}, + {"type", "array"} + }); + + auto thing_container = MultipleThings({thing_a.get(), thing_b.get()}, "things-a-and-b"); + + auto builder = WebThingServer::host(thing_container).port(57123); + test_running_server(builder, [](WebThingServer* server, const std::string& base_url) + { + REQUIRE(server->get_name() == "things-a-and-b"); + auto res = cpr::Get(cpr::Url{base_url}); + REQUIRE(res.status_code == 200); + + res = cpr::Get(cpr::Url{base_url + "/0"}); + REQUIRE(res.status_code == 200); + auto ta = json::parse(res.text); + REQUIRE(ta["title"] == "thing-a"); + + res = cpr::Get(cpr::Url{base_url + "/1"}); + REQUIRE(res.status_code == 200); + auto tb = json::parse(res.text); + REQUIRE(tb["title"] == "thing-b"); + + res = cpr::Put( + cpr::Url{base_url + "/0/properties/boolean-prop"}, + cpr::Body{json{{"boolean-prop", false}}.dump()} + ); + REQUIRE(res.status_code == 200); + REQUIRE(json::parse(res.text)["boolean-prop"] == false); + + res = cpr::Put( + cpr::Url{base_url + "/0/properties/double-prop"}, + cpr::Body{json{{"double-prop", 24.0}}.dump()} + ); + REQUIRE(res.status_code == 200); + REQUIRE(json::parse(res.text)["double-prop"] == 24.0); + + res = cpr::Put( + cpr::Url{base_url + "/0/properties/string-prop"}, + cpr::Body{json{{"string-prop", "the-updated-value"}}.dump()} + ); + REQUIRE(res.status_code == 200); + REQUIRE(json::parse(res.text)["string-prop"] == "the-updated-value"); + + res = cpr::Put( + cpr::Url{base_url + "/1/properties/object-prop"}, + cpr::Body{json{{"object-prop", {{"key", "updated-value"}}}}.dump()} + ); + REQUIRE(res.status_code == 200); + REQUIRE(json::parse(res.text)["object-prop"] == json{{"key", "updated-value"}}); + + res = cpr::Put( + cpr::Url{base_url + "/1/properties/array-prop"}, + cpr::Body{json{{"array-prop", {"a", "b", "c", 42}}}.dump()} + ); + REQUIRE(res.status_code == 200); + REQUIRE(json::parse(res.text)["array-prop"] == json{"a", "b", "c", 42}); + + // not exsiting thing + res = cpr::Get(cpr::Url{base_url + "/42"}); + REQUIRE(res.status_code == 404); + + res = cpr::Get(cpr::Url{base_url + "/42/properties"}); + REQUIRE(res.status_code == 404); + + res = cpr::Get(cpr::Url{base_url + "/42/properties/test-property"}); + REQUIRE(res.status_code == 404); + + res = cpr::Put( + cpr::Url{base_url + "/42/properties/test-property"}, + cpr::Body{json{{"key", "value"}}.dump()} + ); + REQUIRE(res.status_code == 404); + }); +} + +TEST_CASE( "It handles invalid requests", "[server][http]" ) +{ + Thing thing("uri:test", "single-thing"); + auto thing_container = SingleThing(&thing); + + auto builder = WebThingServer::host(thing_container).port(57111); + test_running_server(builder, [](WebThingServer* server, const std::string& base_url) + { + auto res = cpr::Get(cpr::Url{base_url}); + REQUIRE(res.status_code == 200); + + res = cpr::Put(cpr::Url{base_url}); + REQUIRE(res.status_code == 405); // method not allowed + + res = cpr::Get(cpr::Url{base_url + "/some-not-existing-resource"}); + REQUIRE(res.status_code == 405); // method not allowed + + res = cpr::Get(cpr::Url{base_url + "/properties/not-existing-property"}); + REQUIRE(res.status_code == 404); // not + }); +} + +TEST_CASE( "It supports preflight requests", "[server][http]" ) +{ + Thing thing("uri:test", "single-thing"); + auto thing_container = SingleThing(&thing); + + auto builder = WebThingServer::host(thing_container).port(57222); + test_running_server(builder, [](WebThingServer* server, const std::string& base_url) + { + auto res = cpr::Options(cpr::Url{base_url}); + REQUIRE(res.status_code == 204); // no content + }); +} + +TEST_CASE( "It redirects urls with trailing slash to corresponding url without trailing slash", "[server][http]" ) +{ + Thing thing("uri:test", "single-thing"); + auto thing_container = SingleThing(&thing); + + auto builder = WebThingServer::host(thing_container).port(57222); + test_running_server(builder, [](WebThingServer* server, const std::string& base_url) + { + auto res = cpr::Get(cpr::Url{base_url + "/properties/"}, cpr::Redirect{false}); + REQUIRE(res.status_code == 301); // moved permanently + REQUIRE(res.header["Location"] == base_url + "/properties"); + }); +} + +TEST_CASE( "It supports custom host name", "[server][http]" ) +{ + Thing thing("uri:test", "single-thing"); + auto thing_container = SingleThing(&thing); + + // test with enabled host validation + auto builder_host_validation = WebThingServer::host(thing_container) + .hostname("custom-host") + .disable_host_validation(false) + .port(57333); + + test_running_server(builder_host_validation, [](WebThingServer* server, const std::string& base_url) + { + auto res = cpr::Get(cpr::Url{base_url}, cpr::Header{{"Host","custom-host"}}); + REQUIRE(res.status_code == 200); + + res = cpr::Get(cpr::Url{base_url}, cpr::Header{{"Host","unknown-host"}}); + REQUIRE(res.status_code == 403); // forbidden + }); + + // test with disabled host validation + auto builder_no_host_validation = WebThingServer::host(thing_container) + .hostname("custom-host") + .disable_host_validation(true) + .port(57334); + + test_running_server(builder_no_host_validation, [](WebThingServer* server, const std::string& base_url) + { + auto res = cpr::Get(cpr::Url{base_url}, cpr::Header{{"Host","custom-host"}}); + REQUIRE(res.status_code == 200); + + res = cpr::Get(cpr::Url{base_url}, cpr::Header{{"Host","unknown-host"}}); + REQUIRE(res.status_code == 200); + }); +} + +TEST_CASE( "It supports custom base path", "[server][http]" ) +{ + Thing thing("uri:test", "single-thing"); + auto thing_container = SingleThing(&thing); + + auto builder = WebThingServer::host(thing_container) + .base_path("/custom-base") + .port(57444); + + test_running_server(builder, [](WebThingServer* server, const std::string& base_url) + { + auto res = cpr::Get(cpr::Url{base_url}); + REQUIRE(res.status_code == 200); + REQUIRE_THAT(base_url, Catch::Matchers::EndsWith("/custom-base")); + REQUIRE_THAT(json::parse(res.text)["base"], Catch::Matchers::EndsWith("/custom-base")); + }); +} + +TEST_CASE( "It offers REST api for actions", "[server][http]" ) +{ + auto thing = make_thing("uri:test:1", "single-thing"); + + link_action(thing, "test-action", json{{"title", "Test Action"}}, []{ + logger::info("PERFORM TEST ACTION"); + }); + + link_action(thing, "throwing-test-action", json{ + {"title", "Throwing Test Action"}, + {"input", {{"type","number"}}} + }, []{ + logger::info("PERFORM THROWING TEST ACTION"); + throw std::runtime_error("ACTION FAILED BY INTEND"); + }); + + auto thing_container = MultipleThings({thing.get()}, "single-thing-in-multi-container"); + auto builder = WebThingServer::host(thing_container).port(57777); + + test_running_server(builder, [](WebThingServer* server, const std::string& base_url) + { + std::string thing_base_url = base_url + "/0"; + + REQUIRE(server->get_name() == "single-thing-in-multi-container"); + auto res = cpr::Get(cpr::Url{thing_base_url}); + REQUIRE(res.status_code == 200); + auto td = json::parse(res.text); + REQUIRE(td["actions"]["test-action"]["title"] == "Test Action"); + + res = cpr::Get(cpr::Url{thing_base_url + "/actions"}); + REQUIRE(res.status_code == 200); + REQUIRE(json::parse(res.text).empty()); + + res = cpr::Get(cpr::Url{thing_base_url + "/actions/test-action"}); + REQUIRE(res.status_code == 200); + REQUIRE(json::parse(res.text).empty()); + + res = cpr::Post( + cpr::Url{thing_base_url + "/actions"}, + cpr::Body{json{{"test-action", {{"input", 42}}}}.dump()} + ); + REQUIRE(res.status_code == 201); + auto a1 = json::parse(res.text); + REQUIRE(a1["test-action"].contains("href")); + + std::string a1_href = a1["test-action"]["href"]; + res = cpr::Get(cpr::Url{base_url + a1_href}); + REQUIRE(res.status_code == 200); + REQUIRE(json::parse(res.text)["test-action"]["input"] == 42); + + res = cpr::Get(cpr::Url{thing_base_url + "/actions"}); + REQUIRE(res.status_code == 200); + REQUIRE(json::parse(res.text).size() == 1); + + res = cpr::Post( + cpr::Url{thing_base_url + "/actions"}, + cpr::Body{json{{"test-action", {{"input", 123}}}}.dump()} + ); + REQUIRE(res.status_code == 201); + auto a2 = json::parse(res.text); + REQUIRE(a2["test-action"].contains("href")); + + res = cpr::Get(cpr::Url{thing_base_url + "/actions/test-action"}); + REQUIRE(res.status_code == 200); + REQUIRE(json::parse(res.text).size() == 2); + + res = cpr::Put(cpr::Url{base_url + a1_href}); + REQUIRE(res.status_code == 200); + + res = cpr::Delete(cpr::Url{base_url + a1_href}); + REQUIRE(res.status_code == 204); // no content + + // action already deleted + res = cpr::Get(cpr::Url{base_url + a1_href}); + REQUIRE(res.status_code == 404); // not found + + res = cpr::Get(cpr::Url{thing_base_url + "/actions"}); + REQUIRE(res.status_code == 200); + REQUIRE(json::parse(res.text).size() == 1); + REQUIRE(json::parse(res.text)[0]["test-action"]["input"] == 123); + + res = cpr::Delete(cpr::Url{base_url + a1_href}); + REQUIRE(res.status_code == 404); // not found + + res = cpr::Get(cpr::Url{thing_base_url + "/actions/not-existing-action/123-456"}); + REQUIRE(res.status_code == 404); // not found + + res = cpr::Delete(cpr::Url{thing_base_url + "/actions/not-existing-action/123-456"}); + REQUIRE(res.status_code == 404); // not found + + res = cpr::Post( + cpr::Url{thing_base_url + "/actions"} /*no body*/ + ); + REQUIRE(res.status_code == 400); // bad request + + res = cpr::Post( + cpr::Url{thing_base_url + "/actions/test-action"}, + cpr::Body{json{{"invalid-action-body", {{"foo", "bar"}}}}.dump()} + ); + REQUIRE(res.status_code == 400); // bad request + + res = cpr::Post( + cpr::Url{thing_base_url + "/actions/throwing-test-action"}, + cpr::Body{json{{"throwing-test-action", {{"input", "some-string-but-number-expected"}}}}.dump()} + ); + REQUIRE(res.status_code == 400); // bad request + + // thing not existing + res = cpr::Get(cpr::Url{base_url + "/42/actions/test-action"}); + REQUIRE(res.status_code == 404); // not found + + res = cpr::Get(cpr::Url{base_url + "/42/actions/test-action/123-456"}); + REQUIRE(res.status_code == 404); // not found + + res = cpr::Delete(cpr::Url{base_url + "/42/actions/test-action/123-456"}); + REQUIRE(res.status_code == 404); // not found + + res = cpr::Put(cpr::Url{base_url + "/42/actions/test-action/123-456"}); + REQUIRE(res.status_code == 404); // not found + + res = cpr::Post( + cpr::Url{base_url + "/42/actions/test-action"} /*no body*/ + ); + REQUIRE(res.status_code == 404); // not found + }); +} + +TEST_CASE( "It offers REST api for events", "[server][http]" ) +{ + auto thing = make_thing("uri:test:1", "single-thing"); + link_event(thing, "count-event", {{"title", "Count Event"}, {"type", "number"}}); + link_event(thing, "message-event", {{"title", "Message Event"}, {"type", "string"}}); + + auto thing_container = MultipleThings({thing.get()}, "single-thing-in-multi-container"); + auto builder = WebThingServer::host(thing_container).port(57888); + + test_running_server(builder, [&thing](WebThingServer* server, const std::string& base_url) + { + std::string thing_base_url = base_url + "/0"; + + REQUIRE(server->get_name() == "single-thing-in-multi-container"); + auto res = cpr::Get(cpr::Url{thing_base_url}); + REQUIRE(res.status_code == 200); + auto td = json::parse(res.text); + REQUIRE(td["events"]["count-event"]["title"] == "Count Event"); + REQUIRE(td["events"]["message-event"]["title"] == "Message Event"); + + res = cpr::Get(cpr::Url{thing_base_url + "/events"}); + REQUIRE(res.status_code == 200); + REQUIRE(json::parse(res.text).empty()); + + res = cpr::Get(cpr::Url{thing_base_url + "/events/count-event"}); + REQUIRE(res.status_code == 200); + REQUIRE(json::parse(res.text).empty()); + + emit_event(thing, "count-event", 1); + res = cpr::Get(cpr::Url{thing_base_url + "/events/count-event"}); + REQUIRE(res.status_code == 200); + REQUIRE(json::parse(res.text).size() == 1); + REQUIRE(json::parse(res.text)[0]["count-event"]["data"] == 1); + + emit_event(thing, "message-event", "msg-a"); + res = cpr::Get(cpr::Url{thing_base_url + "/events/message-event"}); + REQUIRE(res.status_code == 200); + REQUIRE(json::parse(res.text).size() == 1); + REQUIRE(json::parse(res.text)[0]["message-event"]["data"] == "msg-a"); + + emit_event(thing, "count-event", 2); + res = cpr::Get(cpr::Url{thing_base_url + "/events/count-event"}); + REQUIRE(res.status_code == 200); + REQUIRE(json::parse(res.text).size() == 2); + REQUIRE(json::parse(res.text)[1]["count-event"]["data"] == 2); + + res = cpr::Get(cpr::Url{thing_base_url + "/events"}); + REQUIRE(res.status_code == 200); + REQUIRE(json::parse(res.text).size() == 3); + REQUIRE(json::parse(res.text)[0]["count-event"]["data"] == 1); + REQUIRE(json::parse(res.text)[1]["message-event"]["data"] == "msg-a"); + REQUIRE(json::parse(res.text)[2]["count-event"]["data"] == 2); + + // thing not existing + res = cpr::Get(cpr::Url{base_url + "/42/events"}); + REQUIRE(res.status_code == 404); // not found + + res = cpr::Get(cpr::Url{base_url + "/42/events/test-event"}); + REQUIRE(res.status_code == 404); // not found + }); +} + +TEST_CASE( "It supports custom html ui page", "[server][http]" ) +{ + auto thing = make_thing("uri:test:1", "single-thing"); + REQUIRE_FALSE(thing->get_ui_href()); + + thing->set_ui_href("/gui.html"); + REQUIRE(thing->get_ui_href() == "/gui.html"); + + + auto thing_container = MultipleThings({thing.get()}, "single-thing-in-multi-container"); + auto builder = WebThingServer::host(thing_container).port(57999); + + test_running_server(builder, [&thing](WebThingServer* server, const std::string& base_url) + { + auto web = server->get_web_server(); + + // register additional html page + web->get("/gui.html", [&](auto res, auto req){ + WebThingServer::Response(req, res).html("

It works...

").end(); + }); + + std::string thing_base_url = base_url + "/0"; + + REQUIRE(server->get_name() == "single-thing-in-multi-container"); + auto res = cpr::Get(cpr::Url{thing_base_url}); + REQUIRE(res.status_code == 200); + auto td = json::parse(res.text); + auto links = td["links"]; + + auto found_gui_link_obj = std::find_if(links.begin(), links.end(), [](const json& link) { + return link.contains("rel") && link["rel"] == "alternate" && + link.contains("mediaType") && link["mediaType"] == "text/html"; + }); + + REQUIRE(found_gui_link_obj != links.end()); + + std::string ui_href = (*found_gui_link_obj)["href"]; + REQUIRE(ui_href == "/gui.html"); + + res = cpr::Get(cpr::Url{base_url + ui_href}); + REQUIRE(res.status_code == 200); + REQUIRE_THAT(res.header["Content-Type"], Catch::Matchers::StartsWith("text/html")); + REQUIRE(res.text == "

It works...

"); + }); +} diff --git a/test/catch2/unit-tests/server_tests.cpp b/test/catch2/unit-tests/server_tests.cpp deleted file mode 100644 index a00b9da..0000000 --- a/test/catch2/unit-tests/server_tests.cpp +++ /dev/null @@ -1,41 +0,0 @@ -// Webthing-CPP -// SPDX-FileCopyrightText: 2023-present Benno Waldhauer -// SPDX-License-Identifier: MIT - -#include -#include - -using namespace bw::webthing; - -TEST_CASE( "It can host a single thing" ) -{ - Thing thing("uri:test:1", "single-thing"); - auto thing_container = SingleThing(&thing); - - auto server = WebThingServer::host(thing_container).port(57456).build(); - REQUIRE(server.get_name() == "single-thing"); - - auto t = std::thread([&server]{ - std::this_thread::sleep_for(std::chrono::seconds(1)); - server.stop(); - }); - server.start(); - t.join(); -} - -TEST_CASE( "It can host a multiple things" ) -{ - Thing thing_a("uri:test:a", "thing-a"); - Thing thing_b("uri:test:b", "thing-b"); - auto thing_container = MultipleThings({&thing_a, &thing_b}, "things-a-and-b"); - - auto server = WebThingServer::host(thing_container).port(57123).build(); - REQUIRE(server.get_name() == "things-a-and-b"); - - auto t = std::thread([&server]{ - std::this_thread::sleep_for(std::chrono::seconds(1)); - server.stop(); - }); - server.start(); - t.join(); -} \ No newline at end of file diff --git a/test/catch2/unit-tests/server_ws_tests.cpp b/test/catch2/unit-tests/server_ws_tests.cpp new file mode 100644 index 0000000..39aa581 --- /dev/null +++ b/test/catch2/unit-tests/server_ws_tests.cpp @@ -0,0 +1,385 @@ +// Webthing-CPP +// SPDX-FileCopyrightText: 2023-present Benno Waldhauer +// SPDX-License-Identifier: MIT + +#include +#include +#include +#include + +using namespace bw::webthing; + +inline void test_running_server(WebThingServer::Builder& builder, std::function test_callback) +{ + logger::set_level(log_level::trace); + auto server = builder.build(); + REQUIRE(server.get_web_server() != nullptr); + + int port = server.get_port(); + std::string base_path = server.get_base_path(); + std::exception_ptr thread_exception = nullptr; + + auto t = std::thread([&](){ + + try + { + std::string base_url = "http://localhost:" + std::to_string(port) + base_path; + test_callback(&server, base_url); + } catch (...) { + thread_exception = std::current_exception(); + } + + server.stop(); + }); + + server.start(); + t.join(); + + if (thread_exception) { + std::rethrow_exception(thread_exception); + } +}; + +void connect_via_ws(const std::string& url, std::function*)> client_callback) +{ + std::vector messages_received; + + ix::WebSocket ws; + ws.setUrl(url); + ws.setOnMessageCallback([&](const ix::WebSocketMessagePtr& msg) + { + if (msg->type == ix::WebSocketMessageType::Message) + { + std::string message = msg->str; + logger::info("WS_CLIENT RECEIVED: " + message); + messages_received.push_back(json::parse(message)); + } + }); + + ws.start(); + + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + client_callback(&ws, &messages_received); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + + ws.stop(); +} + +TEST_CASE( "It can make a single thing via websocket available", "[server][ws]" ) +{ + auto thing = make_thing("uri:test:1", "single-thing"); + link_property(thing, "brightness", 50, { + {"@type", "BrightnessProperty"}, + {"title", "Brightness"}, + {"type", "integer"}, + {"description", "The level of light from 0-100"}, + {"minimum", 0}, + {"maximum", 100}, + {"unit", "percent"}}); + + auto thing_container = SingleThing(thing.get()); + auto builder = WebThingServer::host(thing_container).port(57111); + + test_running_server(builder, [](WebThingServer* server, const std::string& base_url) + { + REQUIRE(server->get_name() == "single-thing"); + auto res = cpr::Get(cpr::Url{base_url}); + REQUIRE(res.status_code == 200); + auto td = json::parse(res.text); + REQUIRE(td["title"] == "single-thing"); + + auto links = td["links"]; + auto found_ws_link_obj = std::find_if(links.begin(), links.end(), [](const json& link) { + return link.contains("rel") && link["rel"] == "alternate" && !link.contains("mediaType"); + }); + REQUIRE(found_ws_link_obj != links.end()); + std::string ws_url = (*found_ws_link_obj)["href"]; + + connect_via_ws(ws_url, [&](auto con, std::vector* received_messages_ptr) + { + std::vector& received_messages = *received_messages_ptr; + + res = cpr::Put( + cpr::Url{base_url + "/properties/brightness"}, + cpr::Body{json{{"brightness", 42}}.dump()} + ); + REQUIRE(res.status_code == 200); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + REQUIRE(received_messages.back()["messageType"] == "propertyStatus"); + REQUIRE(received_messages.back()["data"]["brightness"] == 42); + + con->sendText(json{{"messageType", "setProperty"}, {"data", {{"brightness", 24}}}}.dump()); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + REQUIRE(received_messages.back()["messageType"] == "propertyStatus"); + REQUIRE(received_messages.back()["data"]["brightness"] == 24); + + // send no json message + con->sendText("Some string not beeing json..."); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + REQUIRE(received_messages.back()["messageType"] == "error"); + REQUIRE(received_messages.back()["data"]["status"] == "400 Bad Request"); + REQUIRE(received_messages.back()["data"]["message"] == "Parsing request failed"); + + // send json message missing messageType + con->sendText(json{{"data", {{"brightness", 666}}}}.dump()); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + REQUIRE(received_messages.back()["messageType"] == "error"); + REQUIRE(received_messages.back()["data"]["status"] == "400 Bad Request"); + REQUIRE(received_messages.back()["data"]["message"] == "Invalid message"); + + // send json message missing data + con->sendText(json{{"messageType", "setProperty"}}.dump()); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + REQUIRE(received_messages.back()["messageType"] == "error"); + REQUIRE(received_messages.back()["data"]["status"] == "400 Bad Request"); + REQUIRE(received_messages.back()["data"]["message"] == "Invalid message"); + + // send json message with wrong data type + con->sendText(json{{"messageType", "setProperty"}, {"data", {{"brightness", "some-unexpected-string"}}}}.dump()); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + REQUIRE(received_messages.back()["messageType"] == "error"); + REQUIRE(received_messages.back()["data"]["status"] == "400 Bad Request"); + REQUIRE(received_messages.back()["data"]["message"] == "Property value type not matching"); + + // send json message with invalid messageType + con->sendText(json{{"messageType", "invalidCommand"}, {"data", {{"perform", "invalid-task"}}}}.dump()); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + REQUIRE(received_messages.back()["messageType"] == "error"); + REQUIRE(received_messages.back()["data"]["status"] == "400 Bad Request"); + REQUIRE(received_messages.back()["data"]["message"] == "Unknown messageType: invalidCommand"); + }); + + res = cpr::Get(cpr::Url{base_url + "/properties"}); + REQUIRE(res.status_code == 200); + REQUIRE(json::parse(res.text)["brightness"] == 24); + }); +} + +TEST_CASE( "It can make multiple things via websocket available", "[server][ws]" ) +{ + auto thing_a = make_thing("uri:test:a", "thing-a"); + + link_property(thing_a, "boolean-prop", true, { + {"title", "Bool Property"}, + {"type", "boolean"} + }); + + link_property(thing_a, "double-prop", 42.13, { + {"title", "Double Property"}, + {"type", "number"} + }); + + link_property(thing_a, "string-prop", std::string("the-value"), { + {"title", "String Property"}, + {"type", "string"} + }); + + auto thing_b = make_thing("uri:test:b", "thing-b"); + + link_property(thing_b, "object-prop", json{{"key", "value"}}, { + {"title", "Object Property"}, + {"type", "object"} + }); + + link_property(thing_b, "array-prop", json{"some", "values", 42}, { + {"title", "Array Property"}, + {"type", "array"} + }); + + auto thing_container = MultipleThings({thing_a.get(), thing_b.get()}, "things-a-and-b"); + + auto builder = WebThingServer::host(thing_container).port(57112); + test_running_server(builder, [](WebThingServer* server, const std::string& base_url) + { + REQUIRE(server->get_name() == "things-a-and-b"); + auto res = cpr::Get(cpr::Url{base_url}); + REQUIRE(res.status_code == 200); + auto td = json::parse(res.text); + + auto links_a = td[0]["links"]; + auto found_ws_link_a_obj = std::find_if(links_a.begin(), links_a.end(), [](const json& link) { + return link.contains("rel") && link["rel"] == "alternate" && !link.contains("mediaType"); + }); + REQUIRE(found_ws_link_a_obj != links_a.end()); + std::string ws_url_a = (*found_ws_link_a_obj)["href"]; + + + auto links_b = td[1]["links"]; + auto found_ws_link_b_obj = std::find_if(links_b.begin(), links_b.end(), [](const json& link) { + return link.contains("rel") && link["rel"] == "alternate" && !link.contains("mediaType"); + }); + REQUIRE(found_ws_link_b_obj != links_b.end()); + std::string ws_url_b = (*found_ws_link_b_obj)["href"]; + + + connect_via_ws(ws_url_a, [&](auto con, std::vector* received_messages_ptr) + { + std::vector& received_messages = *received_messages_ptr; + + con->sendText(json{{"messageType", "setProperty"}, {"data", {{"boolean-prop", false}}}}.dump()); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + REQUIRE(received_messages.back()["messageType"] == "propertyStatus"); + REQUIRE(received_messages.back()["data"]["boolean-prop"] == false); + + con->sendText(json{{"messageType", "setProperty"}, {"data", {{"double-prop", 24.0}}}}.dump()); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + REQUIRE(received_messages.back()["messageType"] == "propertyStatus"); + REQUIRE(received_messages.back()["data"]["double-prop"] == 24.0); + + con->sendText(json{{"messageType", "setProperty"}, {"data", {{"string-prop", "the-updated-value"}}}}.dump()); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + REQUIRE(received_messages.back()["messageType"] == "propertyStatus"); + REQUIRE(received_messages.back()["data"]["string-prop"] == "the-updated-value"); + }); + + res = cpr::Get(cpr::Url{base_url + "/0/properties"}); + REQUIRE(res.status_code == 200); + REQUIRE(json::parse(res.text)["boolean-prop"] == false); + REQUIRE(json::parse(res.text)["double-prop"] == 24.0); + REQUIRE(json::parse(res.text)["string-prop"] == "the-updated-value"); + + connect_via_ws(ws_url_b, [&](auto con, std::vector* received_messages_ptr) + { + std::vector& received_messages = *received_messages_ptr; + + con->sendText(json{{"messageType", "setProperty"}, {"data", {{"object-prop", {{"key", "updated-value"}}}}}}.dump()); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + REQUIRE(received_messages.back()["messageType"] == "propertyStatus"); + REQUIRE(received_messages.back()["data"]["object-prop"] == json{{"key", "updated-value"}}); + + con->sendText(json{{"messageType", "setProperty"}, {"data", {{"array-prop", {"a", "b", "c", 42}}}}}.dump()); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + REQUIRE(received_messages.back()["messageType"] == "propertyStatus"); + REQUIRE(received_messages.back()["data"]["array-prop"] == json{"a", "b", "c", 42}); + }); + + res = cpr::Get(cpr::Url{base_url + "/1/properties"}); + REQUIRE(res.status_code == 200); + REQUIRE(json::parse(res.text)["object-prop"] == json{{"key", "updated-value"}}); + REQUIRE(json::parse(res.text)["array-prop"] == json{"a", "b", "c", 42}); + }); +} + + +TEST_CASE( "It offers websocket api for events", "[server][ws]" ) +{ + auto thing = make_thing("uri:test:1", "single-thing"); + link_event(thing, "count-event", {{"title", "Count Event"}, {"type", "number"}}); + link_event(thing, "message-event", {{"title", "Message Event"}, {"type", "string"}}); + + auto thing_container = SingleThing(thing.get()); + auto builder = WebThingServer::host(thing_container).port(57113); + + test_running_server(builder, [&](WebThingServer* server, const std::string& base_url) + { + REQUIRE(server->get_name() == "single-thing"); + auto res = cpr::Get(cpr::Url{base_url}); + REQUIRE(res.status_code == 200); + auto td = json::parse(res.text); + REQUIRE(td["title"] == "single-thing"); + + res = cpr::Get(cpr::Url{base_url + "/events"}); + REQUIRE(res.status_code == 200); + REQUIRE(json::parse(res.text).empty()); + + auto links = td["links"]; + auto found_ws_link_obj = std::find_if(links.begin(), links.end(), [](const json& link) { + return link.contains("rel") && link["rel"] == "alternate" && !link.contains("mediaType"); + }); + REQUIRE(found_ws_link_obj != links.end()); + std::string ws_url = (*found_ws_link_obj)["href"]; + + connect_via_ws(ws_url, [&](auto con, std::vector* received_messages_ptr) + { + std::vector& received_messages = *received_messages_ptr; + + // event emitted without websocket subscribers + emit_event(thing, "count-event", 0); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + REQUIRE(received_messages.empty()); + + // subscribe to events + con->sendText(json{{"messageType", "addEventSubscription"}, {"data", {{"count-event", json::object()}}}}.dump()); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + + con->sendText(json{{"messageType", "addEventSubscription"}, {"data", {{"message-event", json::object()}}}}.dump()); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + + // events emitted with websocket subscribers + emit_event(thing, "count-event", 1); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + REQUIRE(received_messages.back()["messageType"] == "event"); + REQUIRE(received_messages.back()["data"]["count-event"]["data"] == 1); + + emit_event(thing, "message-event", "msg-a"); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + REQUIRE(received_messages.back()["messageType"] == "event"); + REQUIRE(received_messages.back()["data"]["message-event"]["data"] == "msg-a"); + + emit_event(thing, "count-event", 2); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + REQUIRE(received_messages.back()["messageType"] == "event"); + REQUIRE(received_messages.back()["data"]["count-event"]["data"] == 2); + }); + + res = cpr::Get(cpr::Url{base_url + "/events"}); + REQUIRE(res.status_code == 200); + REQUIRE(json::parse(res.text).size() == 4); + }); +} + + +TEST_CASE( "It offers websocket api for actions", "[server][ws]" ) +{ + auto thing = make_thing("uri:test:1", "single-thing"); + + link_action(thing, "test-action", json{{"title", "Test Action"}}, []{ + logger::info("PERFORM TEST ACTION"); + }); + + auto thing_container = SingleThing(thing.get()); + auto builder = WebThingServer::host(thing_container).port(57114); + + test_running_server(builder, [&](WebThingServer* server, const std::string& base_url) + { + REQUIRE(server->get_name() == "single-thing"); + auto res = cpr::Get(cpr::Url{base_url}); + REQUIRE(res.status_code == 200); + auto td = json::parse(res.text); + REQUIRE(td["title"] == "single-thing"); + + res = cpr::Get(cpr::Url{base_url + "/actions"}); + REQUIRE(res.status_code == 200); + REQUIRE(json::parse(res.text).empty()); + + auto links = td["links"]; + auto found_ws_link_obj = std::find_if(links.begin(), links.end(), [](const json& link) { + return link.contains("rel") && link["rel"] == "alternate" && !link.contains("mediaType"); + }); + REQUIRE(found_ws_link_obj != links.end()); + std::string ws_url = (*found_ws_link_obj)["href"]; + + connect_via_ws(ws_url, [&](auto con, std::vector* received_messages_ptr) + { + std::vector& received_messages = *received_messages_ptr; + + con->sendText(json{{"messageType", "requestAction"}, {"data", {{"test-action", {{"input", 42}}}}}}.dump()); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + REQUIRE(received_messages.size() == 3); + + REQUIRE(received_messages[0]["messageType"] == "actionStatus"); + REQUIRE(received_messages[0]["data"]["test-action"]["status"] == "created"); + + REQUIRE(received_messages[1]["messageType"] == "actionStatus"); + REQUIRE(received_messages[1]["data"]["test-action"]["status"] == "pending"); + + REQUIRE(received_messages[2]["messageType"] == "actionStatus"); + REQUIRE(received_messages[2]["data"]["test-action"]["status"] == "completed"); + }); + + res = cpr::Get(cpr::Url{base_url + "/actions"}); + REQUIRE(res.status_code == 200); + REQUIRE(json::parse(res.text).size() == 1); + }); +} diff --git a/test/catch2/unit-tests/thing_tests.cpp b/test/catch2/unit-tests/thing_tests.cpp index 2e530d5..98643f8 100644 --- a/test/catch2/unit-tests/thing_tests.cpp +++ b/test/catch2/unit-tests/thing_tests.cpp @@ -11,7 +11,6 @@ using namespace bw::webthing; TEST_CASE( "Webthing thing context is configurable", "[context][thing]" ) { - auto sut = std::make_shared("uri::test.id", "my-test-thing"); REQUIRE( sut->get_context() == WEBTHINGS_IO_CONTEXT ); // default @@ -19,6 +18,17 @@ TEST_CASE( "Webthing thing context is configurable", "[context][thing]" ) REQUIRE( sut->get_context() == "https://some.custom/context" ); } +TEST_CASE( "Webthing thing validates description of available events", "[event][thing]" ) +{ + auto types = std::vector{"test-type"}; + auto sut = std::make_shared("uri::test.id", "my-test-thing", types, "This is the description of my-test-thing"); + + REQUIRE_NOTHROW(sut->add_available_event("test-event-a", {{"description", "Event A"}, {"type","string"}})); + + REQUIRE_THROWS_MATCHES(sut->add_available_event("test-event-b", {"\"JUST AN JSON STRING BUT NO OBJECT\""}), + EventError, Catch::Matchers::Message("Event metadata must be encoded as json object.")); +} + TEST_CASE( "Webthing thing stores all published events", "[event][thing]" ) { auto types = std::vector{"test-type"}; @@ -47,6 +57,17 @@ TEST_CASE( "Webthing thing stores all published events", "[event][thing]" ) REQUIRE( sut->get_event_descriptions("test-event-missing").size() == 0 ); } +TEST_CASE( "Webthing thing validates description of available actions", "[action][thing]" ) +{ + auto types = std::vector{"test-type"}; + auto sut = std::make_shared("uri::test.id", "my-test-thing", types, "This is the description of my-test-thing"); + + REQUIRE_NOTHROW(sut->add_available_action("test-action-a", {{"title", "Action A"}}, nullptr)); + + REQUIRE_THROWS_MATCHES(sut->add_available_action("test-action-b", {"\"JUST AN JSON STRING BUT NO OBJECT\""}, nullptr), + ActionError, Catch::Matchers::Message("Action metadata must be encoded as json object.")); +} + TEST_CASE( "Webthing things performes actions", "[action][thing]" ) { struct TestThing : public Thing @@ -106,6 +127,12 @@ TEST_CASE( "Webthing things performes actions", "[action][thing]" ) test_thing->remove_action("my-custom-action", "abc-123"); REQUIRE_FALSE( test_thing->get_action("my-custom-action", "abc-123") ); + + // try to perform unavailable action + { + FIXED_UUID_SCOPED("ghi-789"); + REQUIRE_FALSE( test_thing->perform_action("some-unavailable-action", "my-input-string") ); + } } #ifdef WT_USE_JSON_SCHEMA_VALIDATION diff --git a/test/coverage.sh b/test/coverage.sh new file mode 100644 index 0000000..1d4da14 --- /dev/null +++ b/test/coverage.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +script_path=$(realpath "$0") +base_dir="$( dirname "$script_path" )/.." +build_dir="$base_dir/build" + +${base_dir}/build.sh clean debug without_examples + +echo "generate coverage.info:" +lcov --capture --directory . --output-file "${build_dir}/coverage.info" + +echo "generate filtered_coverage.info:" +lcov --extract "${build_dir}/coverage.info" '*/bw/webthing/*' --output-file "${build_dir}/filtered_coverage.info" + +echo "generate coverage_summary.json" +lcov --summary "${build_dir}/filtered_coverage.info" > "${build_dir}/coverage_summary.txt" +total_line_coverage=$(grep -E "lines" "${build_dir}/coverage_summary.txt" | awk '{print $2}') +echo "{\"total_line_coverage\": \"$total_line_coverage\"}" > "${build_dir}/coverage_summary.json" + +echo "generate coverage report:" + +html_prolog="${build_dir}/coverage_report_prolog.html" +rm -f $html_prolog + +echo '' >> $html_prolog +echo '' >> $html_prolog +echo '' >> $html_prolog +echo 'Webthing-CPP - @pagetitle@' >> $html_prolog +echo '' >> $html_prolog +echo '' >> $html_prolog +echo '' >> $html_prolog +echo '

' >> $html_prolog +echo 'Webthing-CPP: a modern CPP implementation of the WebThings API' >> $html_prolog +echo '


' >> $html_prolog + +rm -Rf "${build_dir}/coverage_report" +genhtml "${build_dir}/filtered_coverage.info" -o "${build_dir}/coverage_report" --html-prolog "${build_dir}/coverage_report_prolog.html" --rc genhtml_hi_limit=80 --rc genhtml_med_limit=60 + +echo "download coverage badge" +curl -o "${build_dir}/coverage_report/badge.svg" "https://img.shields.io/badge/coverage-${total_line_coverage}25-rgb%2850,201,85%29" diff --git a/vcpkg-no-ssl.json b/vcpkg-no-ssl.json index ad2763d..cd5e26d 100644 --- a/vcpkg-no-ssl.json +++ b/vcpkg-no-ssl.json @@ -1,13 +1,15 @@ { "name": "webthing-cpp", - "version-string": "1.1.0", - "builtin-baseline": "c82f74667287d3dc386bce81e44964370c91a289", + "version-string": "1.2.0", + "builtin-baseline": "d5ec528843d29e3a52d745a64b469f810b2cedbf", "dependencies": [ "catch2", + "cpr", "json-schema-validator", "mdns", "nlohmann-json", - "uwebsockets" + "uwebsockets", + "ixwebsocket" ], "overrides": [ { @@ -28,7 +30,7 @@ }, { "name": "uwebsockets", - "version": "20.67.0" + "version": "20.71.0" } ] } \ No newline at end of file diff --git a/vcpkg-with-ssl.json b/vcpkg-with-ssl.json index aa70a01..74d5cd3 100644 --- a/vcpkg-with-ssl.json +++ b/vcpkg-with-ssl.json @@ -1,20 +1,20 @@ { "name": "webthing-cpp", - "version-string": "1.1.0", - "builtin-baseline": "c82f74667287d3dc386bce81e44964370c91a289", + "version-string": "1.2.0", + "builtin-baseline": "d5ec528843d29e3a52d745a64b469f810b2cedbf", "dependencies": [ "catch2", + "cpr", "json-schema-validator", "mdns", "nlohmann-json", - "openssl", { - "name": "usockets", + "name": "uwebsockets", "features": [ "ssl" ] }, - "uwebsockets" + "ixwebsocket" ], "overrides": [ { @@ -35,7 +35,7 @@ }, { "name": "uwebsockets", - "version": "20.67.0" + "version": "20.71.0" } ] } \ No newline at end of file diff --git a/vcpkg.json b/vcpkg.json index ad2763d..cd5e26d 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -1,13 +1,15 @@ { "name": "webthing-cpp", - "version-string": "1.1.0", - "builtin-baseline": "c82f74667287d3dc386bce81e44964370c91a289", + "version-string": "1.2.0", + "builtin-baseline": "d5ec528843d29e3a52d745a64b469f810b2cedbf", "dependencies": [ "catch2", + "cpr", "json-schema-validator", "mdns", "nlohmann-json", - "uwebsockets" + "uwebsockets", + "ixwebsocket" ], "overrides": [ { @@ -28,7 +30,7 @@ }, { "name": "uwebsockets", - "version": "20.67.0" + "version": "20.71.0" } ] } \ No newline at end of file