diff --git a/Gemfile b/Gemfile index 5a5fee4..f5744fb 100644 --- a/Gemfile +++ b/Gemfile @@ -61,6 +61,8 @@ group :test do gem "database_cleaner-sequel" # Cleans the database between tests + gem "committee", "~> 5.4" # Used to test the swagger documentation in the tests + gem "factory_bot" # makes it easier to create objects for tests gem "faker" # provides fake data for tests gem "mocha" # adds mocking capabilities diff --git a/Gemfile.lock b/Gemfile.lock index 85acca2..c2bf8b0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -50,6 +50,10 @@ GEM base64 (0.2.0) bcrypt (3.1.20) bigdecimal (3.1.8) + committee (5.5.0) + json_schema (~> 0.14, >= 0.14.3) + openapi_parser (~> 2.0) + rack (>= 1.5, < 3.2) concurrent-ruby (1.3.4) connection_pool (2.4.1) console (1.29.0) @@ -145,6 +149,7 @@ GEM rdoc (>= 4.0.0) reline (>= 0.4.2) json (2.8.2) + json_schema (0.21.0) jwt (2.9.3) base64 language_server-protocol (3.17.0.3) @@ -175,6 +180,7 @@ GEM octokit (7.2.0) faraday (>= 1, < 3) sawyer (~> 0.9) + openapi_parser (2.2.2) openssl (3.2.0) parallel (1.26.3) parser (3.3.6.0) @@ -297,6 +303,7 @@ DEPENDENCIES address_composer! async-http bcrypt + committee (~> 5.4) database_cleaner-sequel debug dotenv diff --git a/app/api/authenticated/conversations.rb b/app/api/authenticated/conversations.rb index f67b360..d74be52 100644 --- a/app/api/authenticated/conversations.rb +++ b/app/api/authenticated/conversations.rb @@ -56,11 +56,10 @@ def filter_conversation_for_current_profile!(conversation) present conversation, with: Entities::Conversation end + params do + requires :conversation_id, type: String, documentation: { format: :uuid }, regexp: Utils::UUID7_RE + end namespace ":conversation_id" do - params do - requires :conversation_id, type: String, regexp: Utils::UUID7_RE - end - desc "Returns a single conversation for the logged-in user", success: { model: Entities::Conversation, message: "A conversation" }, failure: Authenticated::FAILURES, diff --git a/app/api/base.rb b/app/api/base.rb index cb8e0f7..12c2b01 100644 --- a/app/api/base.rb +++ b/app/api/base.rb @@ -26,7 +26,7 @@ class Base < Grape::API error!({ error: "INTERNAL_SERVER_ERROR", with: Entities::Error }, 500) end - if Environment.development? + if Environment.development? || Environment.test? add_swagger_documentation \ mount_path: "/swagger_doc", doc_version: RetroMeet::Version.to_s, @@ -45,7 +45,9 @@ class Base < Grape::API authorizationUrl: "/login" } }, - security: { jwt_token: [] } + security: { jwt_token: [] }, + consumes: ["application/json"], + produces: ["application/json"] end route :any, "*path" do diff --git a/app/api/entities/conversation.rb b/app/api/entities/conversation.rb index e4107be..4ad2d7b 100644 --- a/app/api/entities/conversation.rb +++ b/app/api/entities/conversation.rb @@ -8,8 +8,8 @@ class Conversation < Grape::Entity expose :id, documentation: { type: String } expose :other_profile, using: API::Entities::OtherProfileInfo expose :created_at, format_with: :iso_timestamp, documentation: { type: DateTime } - expose :last_seen_at, format_with: :iso_timestamp, documentation: { type: [NilClass, DateTime] } - expose :new_messages, as: :new_messages_preview, documentation: { type: String } + expose :last_seen_at, format_with: :iso_timestamp, documentation: { type: [DateTime, NilClass] } + expose :new_messages, as: :new_messages_preview, documentation: { type: String }, expose_nil: false end end end diff --git a/app/api/entities/conversations.rb b/app/api/entities/conversations.rb index e09aabf..4e3a845 100644 --- a/app/api/entities/conversations.rb +++ b/app/api/entities/conversations.rb @@ -5,7 +5,7 @@ module Entities # Represents a collection of conversations class Conversations < Grape::Entity present_collection true - expose :items, as: "conversations", using: API::Entities::Conversation + expose :items, as: "conversations", using: API::Entities::Conversation, documentation: { is_array: true } end end end diff --git a/app/api/entities/messages.rb b/app/api/entities/messages.rb index 73459b6..4a18b0f 100644 --- a/app/api/entities/messages.rb +++ b/app/api/entities/messages.rb @@ -5,7 +5,7 @@ module Entities # Represents a collection of conversations class Messages < Grape::Entity present_collection true - expose :items, as: "messages", using: API::Entities::Message + expose :items, as: "messages", using: API::Entities::Message, documentation: { is_array: true } end end end diff --git a/app/api/entities/other_profile_info.rb b/app/api/entities/other_profile_info.rb index bf858ca..fec39ba 100644 --- a/app/api/entities/other_profile_info.rb +++ b/app/api/entities/other_profile_info.rb @@ -36,7 +36,7 @@ class OtherProfileInfo < Grape::Entity expose :religion, documentation: { type: String } expose :religion_importance, documentation: { type: String } expose :location_display_name, documentation: { type: Hash } - expose :location_distance, format_with: :distance_in_km, documentation: { type: Float } + expose :location_distance, format_with: :distance_in_km, documentation: { type: Float }, expose_nil: false expose :birth_date, format_with: :age_formatter, as: :age, documentation: { type: Integer } end end diff --git a/app/api/entities/other_profile_infos.rb b/app/api/entities/other_profile_infos.rb index 5c63d09..841e70d 100644 --- a/app/api/entities/other_profile_infos.rb +++ b/app/api/entities/other_profile_infos.rb @@ -5,7 +5,7 @@ module Entities # represents a collection of other users' profiles class OtherProfileInfos < Grape::Entity present_collection true - expose :items, as: "profiles", using: API::Entities::OtherProfileInfo + expose :items, as: "profiles", using: API::Entities::OtherProfileInfo, documentation: { is_array: true } end end end diff --git a/app/api/rodauth_middleware.rb b/app/api/rodauth_middleware.rb index 8d2868e..bb0065e 100644 --- a/app/api/rodauth_middleware.rb +++ b/app/api/rodauth_middleware.rb @@ -21,7 +21,7 @@ class RodauthMiddleware < Roda end route do |r| - unless Environment.development? && r.path == "/api/swagger_doc" + unless (Environment.development? || Environment.test?) && r.path == "/api/swagger_doc" r.rodauth rodauth.require_authentication rodauth.check_active_session diff --git a/test/app/api/authenticated/conversations_test.rb b/test/app/api/authenticated/conversations_test.rb index 56560da..0668b07 100644 --- a/test/app/api/authenticated/conversations_test.rb +++ b/test/app/api/authenticated/conversations_test.rb @@ -3,7 +3,7 @@ require_relative "../../../test_helper" describe API::Authenticated::Conversations do - include RackHelper + include SwaggerHelper::TestMethods before(:all) do @login = "foo@retromeet.social" @password = "bogus123" @@ -20,7 +20,7 @@ describe "get /conversations" do before do - @endpoint = "/api/conversations/" + @endpoint = "/api/conversations" @auth = login(login: @login, password: @password) end it "gets all conversations" do @@ -31,7 +31,6 @@ id: @conversation.id, created_at: @conversation.created_at.iso8601, last_seen_at: @conversation.profile1_last_seen_at.iso8601, - new_messages_preview: nil, other_profile: { id: other_profile.id, display_name: other_profile.display_name, @@ -52,7 +51,6 @@ religion: other_profile.religion, religion_importance: other_profile.religion_importance, location_display_name: other_profile.location.display_name.transform_keys(&:to_sym), - location_distance: nil, # TODO: I think this should display the distance to the logged in user age: 39 # TODO: calculate this so that this test don't breaks when the profile ages } } @@ -61,13 +59,14 @@ authorized_get @auth, format(@endpoint) assert_predicate last_response, :ok? + assert_schema_conform(200) assert_equal expected_response, JSON.parse(last_response.body, symbolize_names: true) end end describe "get /conversations/:id" do before do - @endpoint = "/api/conversations/%s/" + @endpoint = "/api/conversations/%s" @auth = login(login: @login, password: @password) end it "gets a 400 if the id is not valid" do @@ -81,6 +80,7 @@ authorized_get @auth, format(@endpoint, id: "boo!") assert_predicate last_response, :bad_request? + assert_response_schema_confirm(400) assert_equal expected_response, JSON.parse(last_response.body, symbolize_names: true) end it "gets the single conversation" do @@ -89,7 +89,6 @@ id: @conversation.id, created_at: @conversation.created_at.iso8601, last_seen_at: @conversation.profile1_last_seen_at.iso8601, - new_messages_preview: nil, other_profile: { id: other_profile.id, display_name: other_profile.display_name, @@ -110,13 +109,13 @@ religion: other_profile.religion, religion_importance: other_profile.religion_importance, location_display_name: other_profile.location.display_name.transform_keys(&:to_sym), - location_distance: nil, # TODO: I think this should display the distance to the logged in user age: 39 # TODO: calculate this so that this test don't breaks when the profile ages } } authorized_get @auth, format(@endpoint, id: @conversation.id) assert_predicate last_response, :ok? + assert_schema_conform(200) assert_equal expected_response, JSON.parse(last_response.body, symbolize_names: true) end it "gets the single conversation with an unseen message" do @@ -148,13 +147,13 @@ religion: other_profile.religion, religion_importance: other_profile.religion_importance, location_display_name: other_profile.location.display_name.transform_keys(&:to_sym), - location_distance: nil, # TODO: I think this should display the distance to the logged in user age: 39 # TODO: calculate this so that this test don't breaks when the profile ages } } authorized_get @auth, format(@endpoint, id: @conversation.id) assert_predicate last_response, :ok? + assert_schema_conform(200) assert_equal expected_response, JSON.parse(last_response.body, symbolize_names: true) end end @@ -168,6 +167,7 @@ authorized_put @auth, format(@endpoint, id: @conversation.id) assert_predicate last_response, :no_content? + assert_schema_conform(204) @conversation.reload assert_operator last_seen_at, :<, @conversation.profile1_last_seen_at @@ -191,6 +191,7 @@ authorized_get @auth, "#{format(@endpoint, id: @conversation.id)}?min_id=a&max_id=b" assert_predicate last_response, :bad_request? + assert_response_schema_confirm(400) assert_equal expected_response, JSON.parse(last_response.body, symbolize_names: true) end @@ -205,6 +206,7 @@ authorized_get @auth, format(@endpoint, id: @conversation.id) assert_predicate last_response, :ok? + assert_schema_conform(200) assert_equal expected_response, JSON.parse(last_response.body, symbolize_names: true) end end @@ -229,6 +231,7 @@ expected_response[:sent_at] = @message.sent_at.iso8601 assert_predicate last_response, :created? + assert_schema_conform(201) assert_equal expected_response, JSON.parse(last_response.body, symbolize_names: true) end end diff --git a/test/app/api/authenticated/listing_test.rb b/test/app/api/authenticated/listing_test.rb index bf112a6..bc5f164 100644 --- a/test/app/api/authenticated/listing_test.rb +++ b/test/app/api/authenticated/listing_test.rb @@ -3,7 +3,7 @@ require_relative "../../../test_helper" describe API::Authenticated::Profile do - include RackHelper + include SwaggerHelper::TestMethods before(:all) do # Create a few locations to create accounts around @schaerbeek = create(:location, latitude: 50.8676041, longitude: 4.3737121, language: "en", name: "Schaerbeek - Schaarbeek, Brussels-Capital, Belgium", country_code: "be", osm_id: 58_260) @@ -73,6 +73,7 @@ authorized_get @auth, @endpoint assert_predicate last_response, :ok? + assert_schema_conform(200) parsed_response = JSON.parse(last_response.body, symbolize_names: true) assert_equal 1, parsed_response[:profiles].size @@ -84,6 +85,7 @@ authorized_get @auth, @endpoint, { max_distance: 200 } assert_predicate last_response, :ok? + assert_schema_conform(200) parsed_response = JSON.parse(last_response.body, symbolize_names: true) assert_equal 3, parsed_response[:profiles].size @@ -95,6 +97,7 @@ authorized_get @auth, @endpoint, { max_distance: 400 } assert_predicate last_response, :ok? + assert_schema_conform(200) parsed_response = JSON.parse(last_response.body, symbolize_names: true) assert_equal 4, parsed_response[:profiles].size @@ -105,6 +108,7 @@ authorized_get @auth, @endpoint, { max_distance: 401 } assert_predicate last_response, :bad_request? + assert_response_schema_confirm(400) expected_response = { error: "VALIDATION_ERROR", diff --git a/test/app/api/authenticated/profile_test.rb b/test/app/api/authenticated/profile_test.rb index 119d5a9..898adde 100644 --- a/test/app/api/authenticated/profile_test.rb +++ b/test/app/api/authenticated/profile_test.rb @@ -3,7 +3,7 @@ require_relative "../../../test_helper" describe API::Authenticated::Profile do - include RackHelper + include SwaggerHelper::TestMethods before(:all) do @login = "foo@retromeet.social" @password = "bogus123" @@ -20,6 +20,7 @@ authorized_get @auth, "/api/profile/info" assert_predicate last_response, :ok? + assert_schema_conform(200) assert_equal expected_response, JSON.parse(last_response.body, symbolize_names: true) end end @@ -58,6 +59,7 @@ authorized_get @auth, @endpoint assert_predicate last_response, :ok? + assert_schema_conform(200) assert_equal expected_response, JSON.parse(last_response.body, symbolize_names: true) end end @@ -80,6 +82,7 @@ authorized_post @auth, @endpoint, body.to_json assert_predicate last_response, :unprocessable? + assert_schema_conform(422) end it "sends a location too generic and gets too many results back but it is correctly filtered by osm_id" do @@ -96,6 +99,7 @@ end assert_predicate last_response, :ok? + assert_schema_conform(200) end it "sends a location that has exactly one result and updates the location for the user" do @@ -112,6 +116,7 @@ end assert_predicate last_response, :ok? + assert_schema_conform(200) end end @@ -125,6 +130,7 @@ authorized_post @auth, @endpoint, {}.to_json assert_predicate last_response, :bad_request? + assert_schema_conform(400) end it "posts with the same information as the user account" do @@ -153,6 +159,7 @@ expected_response = body assert_predicate last_response, :ok? + assert_schema_conform(200) assert_equal expected_response, JSON.parse(last_response.body, symbolize_names: true) end it "sets all nullable fields to null" do @@ -179,13 +186,14 @@ expected_response = body assert_predicate last_response, :ok? + # assert_response_schema_confirm(200) # (renatolond, 2024-11-26) since oapi2 has no nullable possibility, this cannot be checked. see if https://github.com/interagent/committee/pull/400 can make this work assert_equal expected_response, JSON.parse(last_response.body, symbolize_names: true) end ### # This is a bit of meta programming to guarantee that the all the values the database supports are correctly declared in the endpoint documentation # We iterate through all the params that the endpoint supports and for each we get possible values in the database and update it - post_endpoint = API::Authenticated::Profile.routes.find { |v| v.request_method == "POST" && v.path == "/profile/complete(.:format)" } + post_endpoint = API::Authenticated::Profile.routes.find { |v| v.request_method == "POST" && v.path == "/api/profile/complete(.json)" } post_endpoint.params.each_key do |param| next if %w[text date].include? Profile.db_schema[param.to_sym][:db_type] @@ -208,6 +216,7 @@ expected_response = body assert_predicate last_response, :ok? + assert_schema_conform(200) assert_equal expected_response, JSON.parse(last_response.body, symbolize_names: true) end ) @@ -248,12 +257,12 @@ religion_importance: profile.religion_importance, display_name: profile.display_name, location_display_name: profile.location.display_name.transform_keys(&:to_sym), - location_distance: nil, # TODO: I think this should display the distance to the logged in user age: 39 # TODO: calculate this so that this test don't breaks when the profile ages } authorized_get @auth, format(@endpoint, id: @account.profile.id) assert_predicate last_response, :ok? + assert_schema_conform(200) assert_equal expected_response, JSON.parse(last_response.body, symbolize_names: true) end end diff --git a/test/app/api/authenticated/search_test.rb b/test/app/api/authenticated/search_test.rb index 8d1aba5..ce872f1 100644 --- a/test/app/api/authenticated/search_test.rb +++ b/test/app/api/authenticated/search_test.rb @@ -3,7 +3,7 @@ require_relative "../../../test_helper" describe API::Authenticated::Search do - include RackHelper + include SwaggerHelper::TestMethods before(:all) do @login = "foo@retromeet.social" @@ -35,6 +35,7 @@ authorized_post @auth, @endpoint, body.to_json assert_predicate last_response, :ok? + assert_schema_conform(200) assert_equal expected_response, JSON.parse(last_response.body, symbolize_names: true) end end diff --git a/test/swagger_helper.rb b/test/swagger_helper.rb new file mode 100644 index 0000000..77a2bad --- /dev/null +++ b/test/swagger_helper.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# This module encapsulates the Committee gem to be used in the tests. +# It will produce the Swagger documentation needed for the tests. +module SwaggerHelper + class RequestMaker + include RackHelper + + def make_request + get "/api/swagger_doc" + + JSON.parse(last_response.body) + end + end + + class << self + def prepare! + oapi_doc = RequestMaker.new.make_request + + # These types are generated by grape-swagger and should not error in the validation + JsonSchema.configure do |c| + c.register_format "int32", ->(data) { } + c.register_format "int64", ->(data) { } + c.register_format "float", ->(data) { } + end + + @committee_options = { schema: Committee::Drivers.load_from_data(oapi_doc) } + # @committee_options[:schema_coverage] = Committee::Test::SchemaCoverage.new(@committee_options[:schema]) # Committee only support oapiv3 for schema coverage, add when https://github.com/ruby-grape/grape-swagger/issues/603 is done + end + + attr_reader :committee_options + end + + module TestMethods + include RackHelper + include Committee::Test::Methods + + def request_object + last_request + end + + def response_data + [last_response.status, last_response.headers, last_response.body] + end + + def committee_options + SwaggerHelper.committee_options + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 557b515..ad893db 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -33,6 +33,10 @@ class Spec # @return [String] def webfixture_json_file(filename) = File.open("test/webfixtures/#{filename}.json") +require_relative "swagger_helper" + +SwaggerHelper.prepare! + # Adapted from https://github.com/rails/rails/blob/97169912f197eee6e76fafb091113bddf624aa67/activesupport/lib/active_support/testing/assertions.rb#L101 # Test numeric difference between the return value of an expression as a # result of what is evaluated in the yielded block. @@ -85,6 +89,7 @@ def assert_difference(expression, *args, &block) actual = exp.call error = "#{code.inspect} didn't change by #{diff}, but by #{actual - before_value}" error = "#{message}.\n#{error}" if message + assert_equal(before_value + diff, actual, error) end