diff --git a/fastlane.gemspec b/fastlane.gemspec index ed7508bb438..0077b57f251 100644 --- a/fastlane.gemspec +++ b/fastlane.gemspec @@ -80,4 +80,5 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'rb-readline' # https://github.com/deivid-rodriguez/byebug/issues/289#issuecomment-251383465 spec.add_development_dependency 'rest-client', '~> 1.6.7' spec.add_development_dependency 'fakefs', '~> 0.8.1' + spec.add_development_dependency 'sinatra', '~> 1.4.8' end diff --git a/fastlane_core/lib/fastlane_core/build_watcher.rb b/fastlane_core/lib/fastlane_core/build_watcher.rb index 483b711662e..5985deaa37b 100644 --- a/fastlane_core/lib/fastlane_core/build_watcher.rb +++ b/fastlane_core/lib/fastlane_core/build_watcher.rb @@ -1,36 +1,54 @@ module FastlaneCore class BuildWatcher - # @return The build we waited for. This method will always return a build - def self.wait_for_build_processing_to_be_complete(app_id: nil, platform: nil) - # First, find the train and build version we want to watch for - processing_builds = Spaceship::TestFlight::Build.all_processing_builds(app_id: app_id, platform: platform) + class << self + # @return The build we waited for. This method will always return a build + def wait_for_build_processing_to_be_complete(app_id: nil, platform: nil) + # First, find the train and build version we want to watch for + watched_build = watching_build(app_id: app_id, platform: platform) + UI.crash!("Could not find a build for app: #{app_id} on platform: #{platform}") if watched_build.nil? - watching_build = processing_builds.sort_by(&:upload_date).last # either it's still processing - watching_build ||= Spaceship::TestFlight::Build.latest(app_id: app_id, platform: platform) # or we fallback to the most recent uplaod + loop do + matched_build = matching_build(watched_build: watched_build, app_id: app_id, platform: platform) - loop do - UI.message("Waiting for iTunes Connect to finish processing the new build (#{watching_build.train_version} - #{watching_build.build_version})") + report_status(build: matched_build) + if matched_build && matched_build.processed? + return matched_build + end + + sleep 10 + end + end + + private + + def watching_build(app_id: nil, platform: nil) + processing_builds = Spaceship::TestFlight::Build.all_processing_builds(app_id: app_id, platform: platform) + + watched_build = processing_builds.sort_by(&:upload_date).last + watched_build || Spaceship::TestFlight::Build.latest(app_id: app_id, platform: platform) + end + + def matching_build(watched_build: nil, app_id: nil, platform: nil) + matched_builds = Spaceship::TestFlight::Build.builds_for_train(app_id: app_id, platform: platform, train_version: watched_build.train_version) + matched_builds.find { |build| build.build_version == watched_build.build_version } + end + + def report_status(build: nil) # Due to iTunes Connect, builds disappear from the build list alltogether # after they finished processing. Before returning this build, we have to # wait for the build to appear in the build list again # As this method is very often used to wait for a build, and then do something # with it, we have to be sure that the build actually is ready - - matching_builds = Spaceship::TestFlight::Build.builds_for_train(app_id: app_id, platform: platform, train_version: watching_build.train_version) - matching_build = matching_builds.find { |build| build.build_version == watching_build.build_version } - - if matching_build.nil? + if build.nil? UI.message("Build doesn't show up in the build list any more, waiting for it to appear again") - elsif matching_build.active? - UI.success("Build #{matching_build.train_version} - #{matching_build.build_version} is already being tested") - return matching_build - elsif matching_build.ready_to_submit? || matching_build.export_compliance_missing? - UI.success("Successfully finished processing the build #{matching_build.train_version} - #{matching_build.build_version}") - return matching_build + elsif build.active? + UI.success("Build #{build.train_version} - #{build.build_version} is already being tested") + elsif build.ready_to_submit? || build.export_compliance_missing? + UI.success("Successfully finished processing the build #{build.train_version} - #{build.build_version}") + else + UI.message("Waiting for iTunes Connect to finish processing the new build (#{build.train_version} - #{build.build_version})") end - - sleep 10 end end end diff --git a/fastlane_core/spec/build_watcher_spec.rb b/fastlane_core/spec/build_watcher_spec.rb new file mode 100644 index 00000000000..4c178e0c759 --- /dev/null +++ b/fastlane_core/spec/build_watcher_spec.rb @@ -0,0 +1,154 @@ +require 'spec_helper' + +describe FastlaneCore::BuildWatcher do + context '.wait_for_build_processing_to_be_complete' do + let(:processing_build) do + double( + 'Processing Build', + processed?: false, + active?: false, + ready_to_submit?: false, + export_compliance_missing?: false, + train_version: '1.0', + build_version: '1', + upload_date: 1 + ) + end + let(:old_processing_build) do + double( + 'Old Processing Build', + processed?: false, + active?: false, + ready_to_submit?: false, + export_compliance_missing?: false, + train_version: '1.0', + build_version: '0', + upload_date: 0 + ) + end + let(:active_build) do + double( + 'Active Build', + processed?: true, + active?: true, + ready_to_submit?: false, + export_compliance_missing?: false, + train_version: '1.0', + build_version: '1', + upload_date: 1 + ) + end + let(:ready_build) do + double( + 'Ready Build', + processed?: true, + active?: false, + ready_to_submit?: true, + export_compliance_missing?: false, + train_version: '1.0', + build_version: '1', + upload_date: 1 + ) + end + let(:export_compliance_required_build) do + double( + 'Export Compliance Required Build', + processed?: true, + active?: false, + ready_to_submit?: false, + export_compliance_missing?: true, + train_version: '1.0', + build_version: '1', + upload_date: 1 + ) + end + + it 'returns an already-active build' do + expect(Spaceship::TestFlight::Build).to receive(:all_processing_builds).and_return([]) + expect(Spaceship::TestFlight::Build).to receive(:latest).and_return(active_build) + expect(Spaceship::TestFlight::Build).to receive(:builds_for_train).and_return([active_build]) + + expect(UI).to receive(:success).with("Build #{active_build.train_version} - #{active_build.build_version} is already being tested") + found_build = FastlaneCore::BuildWatcher.wait_for_build_processing_to_be_complete(app_id: 'some-app-id', platform: :ios) + + expect(found_build).to eq(active_build) + end + + it 'returns a ready to submit build' do + expect(Spaceship::TestFlight::Build).to receive(:all_processing_builds).and_return([]) + expect(Spaceship::TestFlight::Build).to receive(:latest).and_return(ready_build) + expect(Spaceship::TestFlight::Build).to receive(:builds_for_train).and_return([ready_build]) + + expect(UI).to receive(:success).with("Successfully finished processing the build #{ready_build.train_version} - #{ready_build.build_version}") + found_build = FastlaneCore::BuildWatcher.wait_for_build_processing_to_be_complete(app_id: 'some-app-id', platform: :ios) + + expect(found_build).to eq(ready_build) + end + + it 'returns a export-compliance-missing build' do + expect(Spaceship::TestFlight::Build).to receive(:all_processing_builds).and_return([]) + expect(Spaceship::TestFlight::Build).to receive(:latest).and_return(export_compliance_required_build) + expect(Spaceship::TestFlight::Build).to receive(:builds_for_train).and_return([export_compliance_required_build]) + + expect(UI).to receive(:success).with("Successfully finished processing the build #{export_compliance_required_build.train_version} - #{export_compliance_required_build.build_version}") + found_build = FastlaneCore::BuildWatcher.wait_for_build_processing_to_be_complete(app_id: 'some-app-id', platform: :ios) + + expect(found_build).to eq(export_compliance_required_build) + end + + it 'waits when a build is still processing' do + expect(Spaceship::TestFlight::Build).to receive(:all_processing_builds).and_return([processing_build]) + expect(Spaceship::TestFlight::Build).to receive(:builds_for_train).and_return([processing_build], [ready_build]) + expect(FastlaneCore::BuildWatcher).to receive(:sleep) + + expect(UI).to receive(:message).with("Waiting for iTunes Connect to finish processing the new build (#{ready_build.train_version} - #{ready_build.build_version})") + expect(UI).to receive(:success).with("Successfully finished processing the build #{ready_build.train_version} - #{ready_build.build_version}") + found_build = FastlaneCore::BuildWatcher.wait_for_build_processing_to_be_complete(app_id: 'some-app-id', platform: :ios) + + expect(found_build).to eq(ready_build) + end + + it 'waits when the build disappears' do + expect(Spaceship::TestFlight::Build).to receive(:all_processing_builds).and_return([processing_build]) + expect(Spaceship::TestFlight::Build).to receive(:builds_for_train).and_return([], [ready_build]) + expect(FastlaneCore::BuildWatcher).to receive(:sleep) + + expect(UI).to receive(:message).with("Build doesn't show up in the build list any more, waiting for it to appear again") + expect(UI).to receive(:success).with("Successfully finished processing the build #{ready_build.train_version} - #{ready_build.build_version}") + found_build = FastlaneCore::BuildWatcher.wait_for_build_processing_to_be_complete(app_id: 'some-app-id', platform: :ios) + + expect(found_build).to eq(ready_build) + end + + it 'watches the latest build when more than one build is processing' do + expect(Spaceship::TestFlight::Build).to receive(:all_processing_builds).and_return([processing_build, old_processing_build]) + # Mock `:builds_for_train` to return a build in the ready state because this will terminate the wait loop. + # Note that ready_build and processing_build have same build train and build number. + expect(Spaceship::TestFlight::Build).to receive(:builds_for_train).and_return([ready_build]) + + expect(UI).to receive(:success).with("Successfully finished processing the build #{ready_build.train_version} - #{ready_build.build_version}") + found_build = FastlaneCore::BuildWatcher.wait_for_build_processing_to_be_complete(app_id: 'some-app-id', platform: :ios) + + expect(found_build).to eq(ready_build) + end + + it 'watches the latest build when no builds are processing' do + expect(Spaceship::TestFlight::Build).to receive(:all_processing_builds).and_return([]) + expect(Spaceship::TestFlight::Build).to receive(:latest).and_return(ready_build) + expect(Spaceship::TestFlight::Build).to receive(:builds_for_train).and_return([ready_build]) + + expect(UI).to receive(:success).with("Successfully finished processing the build #{ready_build.train_version} - #{ready_build.build_version}") + found_build = FastlaneCore::BuildWatcher.wait_for_build_processing_to_be_complete(app_id: 'some-app-id', platform: :ios) + + expect(found_build).to eq(ready_build) + end + + it 'crashes if it cannot find a build to watch' do + expect(Spaceship::TestFlight::Build).to receive(:all_processing_builds).and_return([]) + expect(Spaceship::TestFlight::Build).to receive(:latest).and_return(nil) + + expect(UI).to receive(:crash!).with("Could not find a build for app: some-app-id on platform: ios").and_call_original + expect { FastlaneCore::BuildWatcher.wait_for_build_processing_to_be_complete(app_id: 'some-app-id', platform: :ios) }.to raise_error(FastlaneCore::Interface::FastlaneCrash) + end + end +end diff --git a/spaceship/docs/TestFlightTesting.md b/spaceship/docs/TestFlightTesting.md new file mode 100644 index 00000000000..4727c1f37c5 --- /dev/null +++ b/spaceship/docs/TestFlightTesting.md @@ -0,0 +1,119 @@ +Testing `Spaceship::TestFlight` +=================== +(But we would love for all of the _spaceship_ tests to be like this 😀) + +## Usage +To run the tests, in your terminal run: + +``` +bundle exec rspec spaceship/spec +``` + +## Overview + +Spaceship wraps various APIs using the following pattern: + +A simple `client` and various data models, usually subclassed from a `Base` model (e.g. Spaceship::TestFlight::Base) +The `client` is responsible for making HTTP requests for a given API or domain. It should be very simple and have no logic. +It is only responsible for creating the request and parsing the response. The best practice is for each method to have a single request and return the data from the response. + +The data models generally map to a REST resource or some logical grouping of data. Each data model has an instance of `client` which it can use to put or get data. It should encapsulate all interactions with the API, so other _fastlane_ tools interface with the data models, and not the `client` directly. + + +## Adding Tests +### Models + +Since the data models expect the client to return JSON data as a Ruby hash, we can reasonably mock the client response using RSpec doubles. We should *not* rely on HTTP fixtures because they are brittle, introduce global state, are not easily decomposable and are difficult to maintain. Instead, use the helper method `mock_client_response` to set up the expected data returned by the API. This design also leads the client to be as thin and logic-free as possible. + +Defining the response near the test site makes it easy to understand and maintain. Try not to include any more data than is necessary for the spec to pass. + +**Examples:** + +At the top of your data model spec, set the client to be a `mock_client`: + +```ruby +describe Spaceship::TestFlight::Tester do + let(:mock_client) { double('MockClient') } + before do + Spaceship::TestFlight::Base.client = mock_client + end +end +``` +Now, anytime we use a data model that is a subclass of `Spaceship::TestFlight::Base`, it has a `client` that is our mock. + +We then configure the response for a given client method using the `mock_client_response` method defined in `spaceship/spec/spec_helper.rb` which can be required by `require 'spec_helper`. This method is defined within an RSpec configuration block: + +```ruby +before do + mock_client_response(:get_tester, with: { tester_id: 1 }) do + { + id: 1, + name: 'Mr. Tester' + } + end +end +``` + +The first parameter is the name of the method we are mocking, and `with:` parameter specifies required parameters to that method. If you don't give it a `with:`, the mock will accept any parameters. The block is the return value of calling `client.get_tester`. + +Now we can test our data model method that uses the client: + +```ruby +it 'finds the test by id' do + tester = Spaceship::TestFlight::Tester.find(1) + expect(tester.name).to eq('Mr. Tester') +end +``` + +Collection methods: + +``` +context '.all' do + it 'contains all of the builds across all build trains' do + builds = Spaceship::TestFlight::Build.all(app_id: 10, platform: 'ios') + expect(builds.size).to eq(3) + expect(builds.sample).to be_instance_of(Spaceship::TestFlight::Build) + expect(builds.map(&:train_version).uniq).to eq(['1.0', '1.1']) + end +end +``` + +Instance methods: + +``` +context '#upload_date' do + it 'parses the string value' do + expect(build.upload_date).to eq(Time.utc(2017, 1, 1, 12)) + end +end +``` + +### Client +**Examples:** + +GET: + +``` +context '#get_build_trains' do + it 'executes the request' do + MockAPI::TestFlightServer.get('/testflight/v2/providers/fake-team-id/apps/some-app-id/platforms/ios/trains') {} + subject.get_build_trains(app_id: app_id, platform: platform) + expect(WebMock).to have_requested(:get, 'https://itunesconnect.apple.com/testflight/v2/providers/fake-team-id/apps/some-app-id/platforms/ios/trains') + end +end +``` + +PUT: + +``` +context '#add_group_to_build' do + it 'executes the request' do + MockAPI::TestFlightServer.put('/testflight/v2/providers/fake-team-id/apps/some-app-id/groups/fake-group-id/builds/fake-build-id') {} + subject.add_group_to_build(app_id: app_id, group_id: 'fake-group-id', build_id: 'fake-build-id') + expect(WebMock).to have_requested(:put, 'https://itunesconnect.apple.com/testflight/v2/providers/fake-team-id/apps/some-app-id/groups/fake-group-id/builds/fake-build-id') + end +end +``` + +#### How client tests work +The client adds routes to a [sinatra](http://www.sinatrarb.com/) server to receive and mock the responses that will be handled by `handle_response`. diff --git a/spaceship/lib/spaceship/test_flight/base.rb b/spaceship/lib/spaceship/test_flight/base.rb index b817a565888..4f2bc6a8622 100644 --- a/spaceship/lib/spaceship/test_flight/base.rb +++ b/spaceship/lib/spaceship/test_flight/base.rb @@ -4,6 +4,18 @@ def self.client @client ||= Client.client_with_authorization_from(Spaceship::Tunes.client) end + ## + # Have subclasses inherit the client from their superclass + # + # Essentially, we are making a class-inheritable-accessor as described here: + # https://apidock.com/rails/v4.2.7/Class/class_attribute + def self.inherited(subclass) + this_class = self + subclass.define_singleton_method(:client) do + this_class.client + end + end + def to_json raw_data.to_json end diff --git a/spaceship/lib/spaceship/test_flight/build.rb b/spaceship/lib/spaceship/test_flight/build.rb index 9931391f92e..efbc5a5cc79 100644 --- a/spaceship/lib/spaceship/test_flight/build.rb +++ b/spaceship/lib/spaceship/test_flight/build.rb @@ -72,12 +72,12 @@ class Build < Base export_compliance_missing: 'testflight.build.state.export.compliance.missing' } - # Find a Build by `build_id`. Returns `nil` if can't find it. + # Find a Build by `build_id`. # # @return (Spaceship::TestFlight::Build) def self.find(app_id: nil, build_id: nil) attrs = client.get_build(app_id: app_id, build_id: build_id) - self.new(attrs) if attrs + self.new(attrs) end def self.all(app_id: nil, platform: nil) @@ -105,7 +105,7 @@ def self.latest(app_id: nil, platform: nil) # # Note: this will overwrite any non-saved changes to the object # - # @return (Spaceceship::Base::DataHash) the raw_data of the build. + # @return (Spaceship::Base::DataHash) the raw_data of the build. def reload self.raw_data = self.class.find(app_id: app_id, build_id: id).raw_data end @@ -130,6 +130,10 @@ def export_compliance_missing? external_state == BUILD_STATES[:export_compliance_missing] end + def self.processed? + active? || ready_to_submit? || export_compliance_missing? + end + # Getting builds from BuildTrains only gets a partial Build object # We are then requesting the full build from iTC when we need to access # any of the variables below, because they are not inlcuded in the partial Build objects diff --git a/spaceship/lib/spaceship/test_flight/client.rb b/spaceship/lib/spaceship/test_flight/client.rb index 1fdf57179dd..ac104c07749 100644 --- a/spaceship/lib/spaceship/test_flight/client.rb +++ b/spaceship/lib/spaceship/test_flight/client.rb @@ -1,20 +1,34 @@ module Spaceship::TestFlight class Client < Spaceship::Client + ## + # Spaceship HTTP client for the testflight API. + # + # This client is solely responsible for the making HTTP requests and + # parsing their responses. Parameters should be either named parameters, or + # for large request data bodies, pass in anything that can resond to + # `to_json`. + # + # Each request method should validate the required parameters. A required parameter is one that would result in 400-range response if it is not supplied. + # Each request method should make only one request. For more high-level logic, put code in the data models. + def self.hostname 'https://itunesconnect.apple.com/testflight/v2/' end + ## + # @!group Build trains API + ## + # Returns an array of all available build trains (not the builds they include) - def get_build_trains(app_id: nil, platform: nil) + def get_build_trains(app_id: nil, platform: "ios") assert_required_params(__method__, binding) - platform ||= "ios" + response = request(:get, "providers/#{team_id}/apps/#{app_id}/platforms/#{platform}/trains") handle_response(response) end - def get_builds_for_train(app_id: nil, platform: nil, train_version: nil) + def get_builds_for_train(app_id: nil, platform: "ios", train_version: nil) assert_required_params(__method__, binding) - platform ||= "ios" response = request(:get, "providers/#{team_id}/apps/#{app_id}/platforms/#{platform}/trains/#{train_version}/builds") handle_response(response) @@ -34,8 +48,72 @@ def delete_tester_from_app(app_id: nil, tester_id: nil) handle_response(response) end + ## + # @!group Builds API + ## + + def get_build(app_id: nil, build_id: nil) + assert_required_params(__method__, binding) + + response = request(:get, "providers/#{team_id}/apps/#{app_id}/builds/#{build_id}") + handle_response(response) + end + + def put_build(app_id: nil, build_id: nil, build: nil) + assert_required_params(__method__, binding) + + response = request(:put) do |req| + req.url "providers/#{team_id}/apps/#{app_id}/builds/#{build_id}" + req.body = build.to_json + req.headers['Content-Type'] = 'application/json' + end + handle_response(response) + end + + def post_for_testflight_review(app_id: nil, build_id: nil, build: nil) + assert_required_params(__method__, binding) + + response = request(:post) do |req| + req.url "providers/#{team_id}/apps/#{app_id}/builds/#{build_id}/review" + req.body = build.to_json + req.headers['Content-Type'] = 'application/json' + end + handle_response(response) + end + + ## + # @!group Groups API + ## + + def get_groups(app_id: nil) + assert_required_params(__method__, binding) + + response = request(:get, "/testflight/v2/providers/#{team_id}/apps/#{app_id}/groups") + handle_response(response) + end + + def add_group_to_build(app_id: nil, group_id: nil, build_id: nil) + assert_required_params(__method__, binding) + + body = { + 'groupId' => group_id, + 'buildId' => build_id + } + response = request(:put) do |req| + req.url "providers/#{team_id}/apps/#{app_id}/groups/#{group_id}/builds/#{build_id}" + req.body = body.to_json + req.headers['Content-Type'] = 'application/json' + end + handle_response(response) + end + + ## + # @!group Testers API + ## + def post_tester(app_id: nil, tester: nil) assert_required_params(__method__, binding) + url = "providers/#{team_id}/apps/#{app_id}/testers" response = request(:post) do |req| req.url url @@ -51,6 +129,7 @@ def post_tester(app_id: nil, tester: nil) def put_tester_to_group(app_id: nil, tester_id: nil, group_id: nil) assert_required_params(__method__, binding) + # Then we can add the tester to the group that allows the app to test # This is easy enough, we already have all this data. We don't need any response from the previous request url = "providers/#{team_id}/apps/#{app_id}/groups/#{group_id}/testers/#{tester_id}" @@ -67,6 +146,7 @@ def put_tester_to_group(app_id: nil, tester_id: nil, group_id: nil) def delete_tester_from_group(group_id: nil, tester_id: nil, app_id: nil) assert_required_params(__method__, binding) + url = "providers/#{team_id}/apps/#{app_id}/groups/#{group_id}/testers/#{tester_id}" response = request(:delete) do |req| req.url url @@ -75,53 +155,25 @@ def delete_tester_from_group(group_id: nil, tester_id: nil, app_id: nil) handle_response(response) end - def get_build(app_id: nil, build_id: nil) - assert_required_params(__method__, binding) - response = request(:get, "providers/#{team_id}/apps/#{app_id}/builds/#{build_id}") - handle_response(response) - end + ## + # @!group TestInfo + ## - def put_build(app_id: nil, build_id: nil, build: nil) + def put_testinfo(app_id: nil, testinfo: nil) assert_required_params(__method__, binding) - response = request(:put) do |req| - req.url "providers/#{team_id}/apps/#{app_id}/builds/#{build_id}" - req.body = build.to_json - req.headers['Content-Type'] = 'application/json' - end - handle_response(response) - end - def post_for_testflight_review(app_id: nil, build_id: nil, build: nil) - assert_required_params(__method__, binding) - response = request(:post) do |req| - req.url "providers/#{team_id}/apps/#{app_id}/builds/#{build_id}/review" - req.body = build.to_json - req.headers['Content-Type'] = 'application/json' - end - handle_response(response) - end - - def get_groups(app_id: nil) - assert_required_params(__method__, binding) - response = request(:get, "/testflight/v2/providers/#{team_id}/apps/#{app_id}/groups") - handle_response(response) - end - - def add_group_to_build(app_id: nil, group_id: nil, build_id: nil) - body = { - 'groupId' => group_id, - 'buildId' => build_id - } response = request(:put) do |req| - req.url "providers/#{team_id}/apps/#{app_id}/groups/#{group_id}/builds/#{build_id}" - req.body = body.to_json + req.url "providers/#{team_id}/apps/#{app_id}/testInfo" + req.body = testinfo.to_json req.headers['Content-Type'] = 'application/json' end handle_response(response) end + protected + def handle_response(response) - if (200..300).cover?(response.status) && response.body.empty? + if (200...300).cover?(response.status) && (response.body.nil? || response.body.empty?) return end diff --git a/spaceship/lib/spaceship/test_flight/group.rb b/spaceship/lib/spaceship/test_flight/group.rb index 786ea0ad461..bcd1f4231cc 100644 --- a/spaceship/lib/spaceship/test_flight/group.rb +++ b/spaceship/lib/spaceship/test_flight/group.rb @@ -11,16 +11,13 @@ class Group < Base 'id' => :id, 'name' => :name, 'isInternalGroup' => :is_internal_group, + 'appAdamId' => :app_id, 'isDefaultExternalGroup' => :is_default_external_group }) def self.all(app_id: nil) groups = client.get_groups(app_id: app_id) - groups.map do |g| - current_element = self.new(g) - current_element.app_id = app_id - current_element - end + groups.map { |g| self.new(g) } end def self.find(app_id: nil, group_name: nil) diff --git a/spaceship/spec/mock_servers.rb b/spaceship/spec/mock_servers.rb new file mode 100644 index 00000000000..21b170a51ac --- /dev/null +++ b/spaceship/spec/mock_servers.rb @@ -0,0 +1,13 @@ +require_relative 'mock_servers/test_flight_server' + +RSpec.configure do |config| + config.include WebMock::API + + config.before do + stub_request(:any, %r(itunesconnect.apple.com/testflight/v2)).to_rack(MockAPI::TestFlightServer) + end + + config.after do + MockAPI::TestFlightServer.instance_variable_set(:@routes, {}) + end +end diff --git a/spaceship/spec/mock_servers/test_flight_server.rb b/spaceship/spec/mock_servers/test_flight_server.rb new file mode 100644 index 00000000000..acb0f482429 --- /dev/null +++ b/spaceship/spec/mock_servers/test_flight_server.rb @@ -0,0 +1,32 @@ +require 'sinatra/base' + +module MockAPI + class TestFlightServer < Sinatra::Base + # put errors in stdout instead of returning HTML + set :dump_errors, true + set :show_exceptions, false + + before do + content_type :json + end + + after do + if response.body.kind_of?(Hash) + response.body = JSON.dump(response.body) + end + end + + not_found do + content_type :html + status 404 + <<-HTML + + + #{request.request_method} : #{request.url} + HTTP ERROR: 404 + + + HTML + end + end +end diff --git a/spaceship/spec/spec_helper.rb b/spaceship/spec/spec_helper.rb index 1c7171af23e..a57451c3b1d 100644 --- a/spaceship/spec/spec_helper.rb +++ b/spaceship/spec/spec_helper.rb @@ -4,7 +4,6 @@ require_relative 'portal/portal_stubbing' require_relative 'tunes/tunes_stubbing' require_relative 'du/du_stubbing' - # Ensure that no ENV vars which interfere with testing are set # set_auth_vars = [ @@ -68,3 +67,15 @@ def before_each_spaceship def after_each_spaceship @cache_paths.each { |path| try_delete path } end + +RSpec.configure do |config| + def mock_client_response(method_name, with: anything) + mock_method = allow(mock_client).to receive(method_name) + mock_method = mock_method.with(with) + if block_given? + mock_method.and_return(JSON.parse(yield.to_json)) + else + mock_method + end + end +end diff --git a/spaceship/spec/test_flight/build_spec.rb b/spaceship/spec/test_flight/build_spec.rb new file mode 100644 index 00000000000..784bd75267b --- /dev/null +++ b/spaceship/spec/test_flight/build_spec.rb @@ -0,0 +1,225 @@ +require 'spec_helper' +require_relative '../mock_servers' + +describe Spaceship::TestFlight::Build do + let(:mock_client) { double('MockClient') } + + before do + # Use a simple client for all data models + Spaceship::TestFlight::Base.client = mock_client + end + + context '.find' do + it 'finds a build by a build_id' do + mock_client_response(:get_build) do + { + id: 456, + bundleId: 'com.foo.bar', + trainVersion: '1.0' + } + end + + build = Spaceship::TestFlight::Build.find(app_id: 123, build_id: 456) + expect(build).to be_instance_of(Spaceship::TestFlight::Build) + expect(build.id).to eq(456) + expect(build.bundle_id).to eq('com.foo.bar') + end + + it 'returns raises when the build cannot be found' do + mock_client_response(:get_build).and_raise(Spaceship::Client::UnexpectedResponse) + + expect do + Spaceship::TestFlight::Build.find(app_id: 123, build_id: 456) + end.to raise_error(Spaceship::Client::UnexpectedResponse) + end + end + + context 'collections' do + before do + mock_client_response(:get_build_trains) do + ['1.0', '1.1'] + end + + mock_client_response(:get_builds_for_train, with: hash_including(train_version: '1.0')) do + [ + { + id: 1, + appAdamId: 10, + trainVersion: '1.0', + uploadDate: '2017-01-01T12:00:00.000+0000', + externalState: 'testflight.build.state.export.compliance.missing' + } + ] + end + + mock_client_response(:get_builds_for_train, with: hash_including(train_version: '1.1')) do + [ + { + id: 2, + appAdamId: 10, + trainVersion: '1.1', + uploadDate: '2017-01-02T12:00:00.000+0000', + externalState: 'testflight.build.state.submit.ready' + }, + { + id: 3, + appAdamId: 10, + trainVersion: '1.1', + uploadDate: '2017-01-03T12:00:00.000+0000', + externalState: 'testflight.build.state.processing' + } + ] + end + end + + context '.all' do + it 'contains all of the builds across all build trains' do + builds = Spaceship::TestFlight::Build.all(app_id: 10, platform: 'ios') + expect(builds.size).to eq(3) + expect(builds.sample).to be_instance_of(Spaceship::TestFlight::Build) + expect(builds.map(&:train_version).uniq).to eq(['1.0', '1.1']) + end + end + + context '.builds_for_train' do + it 'returns the builds for a given train version' do + builds = Spaceship::TestFlight::Build.builds_for_train(app_id: 10, platform: 'ios', train_version: '1.0') + expect(builds.size).to eq(1) + expect(builds.map(&:train_version)).to eq(['1.0']) + end + end + + context '.all_processing_builds' do + it 'returns a collection of builds that are processing' do + builds = Spaceship::TestFlight::Build.all_processing_builds(app_id: 10, platform: 'ios') + expect(builds.size).to eq(1) + expect(builds.sample.id).to eq(3) + end + end + + context '.latest' do + it 'returns the latest build across all build trains' do + latest_build = Spaceship::TestFlight::Build.latest(app_id: 10, platform: 'ios') + expect(latest_build.upload_date).to eq(Time.utc(2017, 1, 3, 12)) + end + end + end + + context 'instances' do + let(:build) { Spaceship::TestFlight::Build.find(app_id: 'some-app-id', build_id: 'some-build-id') } + + before do + mock_client_response(:get_build) do + { + id: 1, + bundleId: 'some-bundle-id', + appAdamId: 'some-app-id', + uploadDate: '2017-01-01T12:00:00.000+0000', + betaReviewInfo: { + contactFirstName: 'Dev', + contactLastName: 'Toolio', + contactEmail: 'dev-toolio@fabric.io' + }, + exportCompliance: { + usesEncryption: true, + encryptionUpdated: false + }, + testInfo: [ + { + locale: 'en-US', + description: 'test info', + feedbackEmail: 'email@example.com', + whatsNew: 'this is new!' + } + ] + } + end + end + + it 'reloads a build' do + build = Spaceship::TestFlight::Build.new + build.id = 1 + build.app_id = 2 + expect do + build.reload + end.to change(build, :bundle_id).from(nil).to('some-bundle-id') + end + + context 'submission state' do + it 'is ready to submit' do + mock_client_response(:get_build) do + { + 'externalState' => 'testflight.build.state.submit.ready' + } + end + expect(build).to be_ready_to_submit + end + + it 'is ready to test' do + mock_client_response(:get_build) do + { + 'externalState' => 'testflight.build.state.testing.ready' + } + end + expect(build).to be_ready_to_test + end + + it 'is active' do + mock_client_response(:get_build) do + { + 'externalState' => 'testflight.build.state.testing.active' + } + end + expect(build).to be_active + end + + it 'is processing' do + mock_client_response(:get_build) do + { + 'externalState' => 'testflight.build.state.processing' + } + end + expect(build).to be_processing + end + + it 'is has missing export compliance' do + mock_client_response(:get_build) do + { + 'externalState' => 'testflight.build.state.export.compliance.missing' + } + end + expect(build).to be_export_compliance_missing + end + end + + context '#upload_date' do + it 'parses the string value' do + expect(build.upload_date).to eq(Time.utc(2017, 1, 1, 12)) + end + end + + context 'lazy loading attribute' do + let(:build) { Spaceship::TestFlight::Build.new('bundleId' => 1, 'appAdamId' => 1) } + it 'loads TestInfo' do + expect(build).to receive(:reload).once.and_call_original + expect(build.test_info).to be_instance_of(Spaceship::TestFlight::TestInfo) + end + it 'loads BetaReviewInfo' do + expect(build).to receive(:reload).once.and_call_original + expect(build.beta_review_info).to be_instance_of(Spaceship::TestFlight::BetaReviewInfo) + end + it 'loads ExportCompliance' do + expect(build).to receive(:reload).once.and_call_original + expect(build.export_compliance).to be_instance_of(Spaceship::TestFlight::ExportCompliance) + end + end + + context '#save!' do + it 'saves via the client' do + expect(build.client).to receive(:put_build).with(app_id: 'some-app-id', build_id: 1, build: instance_of(Spaceship::TestFlight::Build)) + build.test_info.whats_new = 'changes!' + build.save! + end + end + end +end diff --git a/spaceship/spec/test_flight/build_trains_spec.rb b/spaceship/spec/test_flight/build_trains_spec.rb new file mode 100644 index 00000000000..2289b130b55 --- /dev/null +++ b/spaceship/spec/test_flight/build_trains_spec.rb @@ -0,0 +1,50 @@ +require 'spec_helper' +require_relative '../mock_servers' + +describe Spaceship::TestFlight::BuildTrains do + let(:mock_client) { double('MockClient') } + before do + Spaceship::TestFlight::Base.client = mock_client + mock_client_response(:get_build_trains, with: { app_id: 'some-app-id', platform: 'ios' }) do + ['1.0', '1.1'] + end + mock_client_response(:get_builds_for_train, with: hash_including(train_version: '1.0')) do + [ + { + id: 1, + appAdamId: 10, + trainVersion: '1.0', + uploadDate: '2017-01-01T12:00:00.000+0000', + externalState: 'testflight.build.state.export.compliance.missing' + } + ] + end + mock_client_response(:get_builds_for_train, with: hash_including(train_version: '1.1')) do + [ + { + id: 2, + appAdamId: 10, + trainVersion: '1.1', + uploadDate: '2017-01-02T12:00:00.000+0000', + externalState: 'testflight.build.state.submit.ready' + }, + { + id: 3, + appAdamId: 10, + trainVersion: '1.1', + uploadDate: '2017-01-03T12:00:00.000+0000', + externalState: 'testflight.build.state.processing' + } + ] + end + end + + context '.all' do + it 'returns versions and builds' do + build_trains = Spaceship::TestFlight::BuildTrains.all(app_id: 'some-app-id', platform: 'ios') + expect(build_trains['1.0'].size).to eq(1) + expect(build_trains['1.1'].size).to eq(2) + expect(build_trains.values.flatten.size).to eq(3) + end + end +end diff --git a/spaceship/spec/test_flight/client_spec.rb b/spaceship/spec/test_flight/client_spec.rb new file mode 100644 index 00000000000..1dcab0917a9 --- /dev/null +++ b/spaceship/spec/test_flight/client_spec.rb @@ -0,0 +1,175 @@ +require 'spec_helper' +require_relative '../mock_servers' + +## +# subclass the client we want to test so we can make test-methods easier +class TestFlightTestClient < Spaceship::TestFlight::Client + def test_request(some_param: nil, another_param: nil) + assert_required_params(__method__, binding) + end + + def handle_response(response) + super(response) + end +end + +describe Spaceship::TestFlight::Client do + subject { TestFlightTestClient.new(current_team_id: 'fake-team-id') } + let(:app_id) { 'some-app-id' } + let(:platform) { 'ios' } + + context '#assert_required_params' do + it 'requires named parameters to be passed' do + expect do + subject.test_request(some_param: 1) + end.to raise_error(NameError) + end + end + + context '#handle_response' do + it 'handles successful responses with json' do + response = double('Response', status: 200) + response.stub(:body).and_return({ 'data' => 'value' }) + expect(subject.handle_response(response)).to eq('value') + end + + it 'handles successful responses with no data' do + response = double('Response', body: '', status: 201) + expect(subject.handle_response(response)).to eq(nil) + end + + it 'raises an exception on an API error' do + response = double('Response', status: 400) + response.stub(:body).and_return({ 'data' => nil, 'error' => 'Bad Request' }) + expect do + subject.handle_response(response) + end.to raise_error(Spaceship::Client::UnexpectedResponse) + end + + it 'raises an exception on a HTTP error' do + response = double('Response', body: 'Server Error', status: 500) + expect do + subject.handle_response(response) + end.to raise_error(Spaceship::Client::UnexpectedResponse) + end + end + + ## + # @!group Build Trains API + ## + + context '#get_build_trains' do + it 'executes the request' do + MockAPI::TestFlightServer.get('/testflight/v2/providers/fake-team-id/apps/some-app-id/platforms/ios/trains') {} + subject.get_build_trains(app_id: app_id, platform: platform) + expect(WebMock).to have_requested(:get, 'https://itunesconnect.apple.com/testflight/v2/providers/fake-team-id/apps/some-app-id/platforms/ios/trains') + end + end + + context '#get_builds_for_train' do + it 'executes the request' do + MockAPI::TestFlightServer.get('/testflight/v2/providers/fake-team-id/apps/some-app-id/platforms/ios/trains/1.0/builds') {} + subject.get_builds_for_train(app_id: app_id, platform: platform, train_version: '1.0') + expect(WebMock).to have_requested(:get, 'https://itunesconnect.apple.com/testflight/v2/providers/fake-team-id/apps/some-app-id/platforms/ios/trains/1.0/builds') + end + end + + ## + # @!group Builds API + ## + + context '#get_build' do + it 'executes the request' do + MockAPI::TestFlightServer.get('/testflight/v2/providers/fake-team-id/apps/some-app-id/builds/1') {} + subject.get_build(app_id: app_id, build_id: 1) + expect(WebMock).to have_requested(:get, 'https://itunesconnect.apple.com/testflight/v2/providers/fake-team-id/apps/some-app-id/builds/1') + end + end + + context '#put_build' do + let(:build) { double('Build', to_json: "") } + it 'executes the request' do + MockAPI::TestFlightServer.put('/testflight/v2/providers/fake-team-id/apps/some-app-id/builds/1') {} + subject.put_build(app_id: app_id, build_id: 1, build: build) + expect(WebMock).to have_requested(:put, 'https://itunesconnect.apple.com/testflight/v2/providers/fake-team-id/apps/some-app-id/builds/1') + end + end + + ## + # @!group Groups API + ## + + context '#get_groups' do + it 'executes the request' do + MockAPI::TestFlightServer.get('/testflight/v2/providers/fake-team-id/apps/some-app-id/groups') {} + subject.get_groups(app_id: app_id) + expect(WebMock).to have_requested(:get, 'https://itunesconnect.apple.com/testflight/v2/providers/fake-team-id/apps/some-app-id/groups') + end + end + + context '#add_group_to_build' do + it 'executes the request' do + MockAPI::TestFlightServer.put('/testflight/v2/providers/fake-team-id/apps/some-app-id/groups/fake-group-id/builds/fake-build-id') {} + subject.add_group_to_build(app_id: app_id, group_id: 'fake-group-id', build_id: 'fake-build-id') + expect(WebMock).to have_requested(:put, 'https://itunesconnect.apple.com/testflight/v2/providers/fake-team-id/apps/some-app-id/groups/fake-group-id/builds/fake-build-id') + end + end + + ## + # @!group Testers API + ## + + context '#testers_for_app' do + it 'executes the request' do + MockAPI::TestFlightServer.get('/testflight/v2/providers/fake-team-id/apps/some-app-id/testers') {} + subject.testers_for_app(app_id: app_id) + expect(WebMock).to have_requested(:get, 'https://itunesconnect.apple.com/testflight/v2/providers/fake-team-id/apps/some-app-id/testers') + end + end + + context '#delete_tester_from_app' do + it 'executes the request' do + MockAPI::TestFlightServer.delete('/testflight/v2/providers/fake-team-id/apps/some-app-id/testers/fake-tester-id') {} + subject.delete_tester_from_app(app_id: app_id, tester_id: 'fake-tester-id') + expect(WebMock).to have_requested(:delete, 'https://itunesconnect.apple.com/testflight/v2/providers/fake-team-id/apps/some-app-id/testers/fake-tester-id') + end + end + + context '#post_tester' do + let(:tester) { double('Tester', email: 'fake@email.com', first_name: 'Fake', last_name: 'Name') } + it 'executes the request' do + MockAPI::TestFlightServer.post('/testflight/v2/providers/fake-team-id/apps/some-app-id/testers') {} + subject.post_tester(app_id: app_id, tester: tester) + expect(WebMock).to have_requested(:post, 'https://itunesconnect.apple.com/testflight/v2/providers/fake-team-id/apps/some-app-id/testers') + end + end + + context '#put_tester_to_group' do + it 'executes the request' do + MockAPI::TestFlightServer.put('/testflight/v2/providers/fake-team-id/apps/some-app-id/groups/fake-group-id/testers/fake-tester-id') {} + subject.put_tester_to_group(app_id: app_id, tester_id: 'fake-tester-id', group_id: 'fake-group-id') + expect(WebMock).to have_requested(:put, 'https://itunesconnect.apple.com/testflight/v2/providers/fake-team-id/apps/some-app-id/groups/fake-group-id/testers/fake-tester-id') + end + end + + context '#delete_tester_from_group' do + it 'executes the request' do + MockAPI::TestFlightServer.delete('/testflight/v2/providers/fake-team-id/apps/some-app-id/groups/fake-group-id/testers/fake-tester-id') {} + subject.delete_tester_from_group(app_id: app_id, tester_id: 'fake-tester-id', group_id: 'fake-group-id') + expect(WebMock).to have_requested(:delete, 'https://itunesconnect.apple.com/testflight/v2/providers/fake-team-id/apps/some-app-id/groups/fake-group-id/testers/fake-tester-id') + end + end + + ## + # @!group TestInfo + ## + + context '#put_testinfo' do + let(:testinfo) { double('TestInfo', to_json: '') } + it 'executes the request' do + MockAPI::TestFlightServer.put('/testflight/v2/providers/fake-team-id/apps/some-app-id/testInfo') {} + subject.put_testinfo(app_id: app_id, testinfo: testinfo) + expect(WebMock).to have_requested(:put, 'https://itunesconnect.apple.com/testflight/v2/providers/fake-team-id/apps/some-app-id/testInfo') + end + end +end diff --git a/spaceship/spec/test_flight/group_spec.rb b/spaceship/spec/test_flight/group_spec.rb new file mode 100644 index 00000000000..31cce109839 --- /dev/null +++ b/spaceship/spec/test_flight/group_spec.rb @@ -0,0 +1,111 @@ +require 'spec_helper' + +describe Spaceship::TestFlight::Group do + let(:mock_client) { double('MockClient') } + + before do + Spaceship::TestFlight::Base.client = mock_client + end + + context 'attr_mapping' do + let(:group) do + Spaceship::TestFlight::Group.new({ + 'id' => 1, + 'name' => 'Group 1', + 'appAdamId' => 123, + 'isDefaultExternalGroup' => false + }) + end + + it 'has them' do + expect(group.id).to eq(1) + expect(group.name).to eq('Group 1') + expect(group.app_id).to eq(123) + expect(group.is_default_external_group).to eq(false) + end + end + + context 'collections' do + before do + mock_client_response(:get_groups, with: { app_id: 'app-id' }) do + [ + { + id: 1, + name: 'Group 1', + isDefaultExternalGroup: true + }, + { + id: 2, + name: 'Group 2', + isDefaultExternalGroup: false + } + ] + end + end + + context '.all' do + it 'returns all of the groups' do + groups = Spaceship::TestFlight::Group.all(app_id: 'app-id') + expect(groups.size).to eq(2) + expect(groups.first).to be_instance_of(Spaceship::TestFlight::Group) + end + end + + context '.find' do + it 'returns a Group by group_name' do + group = Spaceship::TestFlight::Group.find(app_id: 'app-id', group_name: 'Group 1') + expect(group).to be_instance_of(Spaceship::TestFlight::Group) + expect(group.name).to eq('Group 1') + end + + it 'returns nil if no group matches' do + group = Spaceship::TestFlight::Group.find(app_id: 'app-id', group_name: 'Group NaN') + expect(group).to be_nil + end + end + + context '.default_external_group' do + it 'returns the default external group' do + group = Spaceship::TestFlight::Group.default_external_group(app_id: 'app-id') + expect(group).to be_instance_of(Spaceship::TestFlight::Group) + expect(group.id).to eq(1) + end + end + + context '.filter_groups' do + it 'applies block and returns filtered groups' do + groups = Spaceship::TestFlight::Group.filter_groups(app_id: 'app-id') { |group| group.name == 'Group 1' } + expect(groups).to be_instance_of(Array) + expect(groups.size).to eq(1) + expect(groups.first.id).to eq(1) + end + end + end + + context 'instances' do + let(:group) { Spaceship::TestFlight::Group.new('appAdamId' => 1, 'id' => 2, 'isDefaultExternalGroup' => true) } + let(:tester) { double('Tester', tester_id: 'some-tester-id') } + + context '#add_tester!' do + it 'adds a tester via client' do + expect(mock_client).to receive(:post_tester).with(app_id: 1, tester: tester).and_return('id' => 'some-tester-id') + expect(mock_client).to receive(:put_tester_to_group).with(group_id: 2, tester_id: 'some-tester-id', app_id: 1) + group.add_tester!(tester) + end + end + + context '#remove_tester!' do + it 'removes a tester via client' do + expect(mock_client).to receive(:delete_tester_from_group).with(group_id: 2, tester_id: 'some-tester-id', app_id: 1) + group.remove_tester!(tester) + end + end + + context '#default_external_group?' do + it 'returns default_external_group' do + expect(group).to receive(:is_default_external_group).and_call_original + expect(group.default_external_group?).to be(true) + end + end + end +end diff --git a/spaceship/spec/test_flight/test_info_spec.rb b/spaceship/spec/test_flight/test_info_spec.rb new file mode 100644 index 00000000000..7071108abb9 --- /dev/null +++ b/spaceship/spec/test_flight/test_info_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +describe Spaceship::TestFlight::TestInfo do + let(:test_info) do + Spaceship::TestFlight::TestInfo.new([ + { + 'locale' => 'en-US', + 'description' => 'en-US description', + 'feedbackEmail' => 'enUS@email.com', + 'whatsNew' => 'US News' + }, + { + 'locale' => 'de-DE', + 'description' => 'de-DE description', + 'feedbackEmail' => 'deDE@email.com', + 'whatsNew' => 'German News' + }, + { + 'locale' => 'de-AT', + 'description' => 'de-AT description', + 'feedbackEmail' => 'deAT@email.com', + 'whatsNew' => 'Austrian News' + } + ]) + end + + it 'gets the value from the first locale' do + expect(test_info.feedback_email).to eq('enUS@email.com') + expect(test_info.description).to eq('en-US description') + expect(test_info.whats_new).to eq('US News') + end + + it 'sets values to all locales' do + test_info.whats_new = 'News!' + expect(test_info.raw_data.all? { |locale| locale['whatsNew'] == 'News!' }).to eq(true) + end +end diff --git a/spaceship/spec/test_flight/tester_spec.rb b/spaceship/spec/test_flight/tester_spec.rb new file mode 100644 index 00000000000..c4af7391450 --- /dev/null +++ b/spaceship/spec/test_flight/tester_spec.rb @@ -0,0 +1,72 @@ +require 'spec_helper' + +describe Spaceship::TestFlight::Tester do + let(:mock_client) { double('MockClient') } + + before do + Spaceship::TestFlight::Base.client = mock_client + end + + context 'attr_mapping' do + let(:tester) do + Spaceship::TestFlight::Tester.new({ + 'id' => 1, + 'email' => 'email@domain.com' + }) + end + + it 'has them' do + expect(tester.tester_id).to eq(1) + expect(tester.email).to eq('email@domain.com') + end + end + + context 'collections' do + before do + mock_client_response(:testers_for_app, with: { app_id: 'app-id' }) do + [ + { + id: 1, + email: "email_1@domain.com" + }, + { + id: 2, + email: 'email_2@domain.com' + } + ] + end + end + + context '.all' do + it 'returns all of the testers' do + groups = Spaceship::TestFlight::Tester.all(app_id: 'app-id') + expect(groups.size).to eq(2) + expect(groups.first).to be_instance_of(Spaceship::TestFlight::Tester) + end + end + + context '.find' do + it 'returns a Tester by email address' do + tester = Spaceship::TestFlight::Tester.find(app_id: 'app-id', email: 'email_1@domain.com') + expect(tester).to be_instance_of(Spaceship::TestFlight::Tester) + expect(tester.tester_id).to be(1) + end + + it 'returns nil if no Tester matches' do + tester = Spaceship::TestFlight::Tester.find(app_id: 'app-id', email: 'NaN@domain.com') + expect(tester).to be_nil + end + end + end + + context 'instances' do + let(:tester) { Spaceship::TestFlight::Tester.new('id' => 2, 'email' => 'email@domain.com') } + + context '.remove_from_app!' do + it 'removes a tester from the app' do + expect(mock_client).to receive(:delete_tester_from_app).with(app_id: 'app-id', tester_id: 2) + tester.remove_from_app!(app_id: 'app-id') + end + end + end +end