diff --git a/lib/candy_check/play_store.rb b/lib/candy_check/play_store.rb index 860fe7c..7cdcbff 100644 --- a/lib/candy_check/play_store.rb +++ b/lib/candy_check/play_store.rb @@ -4,9 +4,12 @@ require "candy_check/play_store/product_purchases/product_purchase" require "candy_check/play_store/subscription_purchases/subscription_purchase" require "candy_check/play_store/product_purchases/product_verification" +require "candy_check/play_store/product_acknowledgements/acknowledgement" +require "candy_check/play_store/product_acknowledgements/response" require "candy_check/play_store/subscription_purchases/subscription_verification" require "candy_check/play_store/verification_failure" require "candy_check/play_store/verifier" +require "candy_check/play_store/acknowledger" module CandyCheck # Module to request and verify a AppStore receipt diff --git a/lib/candy_check/play_store/acknowledger.rb b/lib/candy_check/play_store/acknowledger.rb new file mode 100644 index 0000000..900aea9 --- /dev/null +++ b/lib/candy_check/play_store/acknowledger.rb @@ -0,0 +1,19 @@ +module CandyCheck + module PlayStore + class Acknowledger + def initialize(authorization:) + @authorization = authorization + end + + def acknowledge_product_purchase(package_name:, product_id:, token:) + acknowledger = CandyCheck::PlayStore::ProductAcknowledgements::Acknowledgement.new( + package_name: package_name, + product_id: product_id, + token: token, + authorization: @authorization, + ) + acknowledger.call! + end + end + end +end diff --git a/lib/candy_check/play_store/product_acknowledgements/acknowledgement.rb b/lib/candy_check/play_store/product_acknowledgements/acknowledgement.rb new file mode 100644 index 0000000..59f0fbf --- /dev/null +++ b/lib/candy_check/play_store/product_acknowledgements/acknowledgement.rb @@ -0,0 +1,45 @@ +module CandyCheck + module PlayStore + module ProductAcknowledgements + # Verifies a purchase token against the PlayStore API + + class Acknowledgement + # @return [String] the package_name which will be queried + attr_reader :package_name + # @return [String] the item id which will be queried + attr_reader :product_id + # @return [String] the token for authentication + attr_reader :token + + # Initializes a new call to the API + # @param package_name [String] + # @param product_id [String] + # @param token [String] + def initialize(package_name:, product_id:, token:, authorization:) + @package_name = package_name + @product_id = product_id + @token = token + @authorization = authorization + end + + def call! + acknowlege! + + CandyCheck::PlayStore::ProductAcknowledgements::Response.new( + result: @response[:result], error_data: @response[:error_data]) + end + + private + + def acknowlege! + service = CandyCheck::PlayStore::AndroidPublisherService.new + + service.authorization = @authorization + service.acknowledge_purchase_product(package_name, product_id, token) do |result, error_data| + @response = { result: result, error_data: error_data } + end + end + end + end + end +end diff --git a/lib/candy_check/play_store/product_acknowledgements/response.rb b/lib/candy_check/play_store/product_acknowledgements/response.rb new file mode 100644 index 0000000..040cd48 --- /dev/null +++ b/lib/candy_check/play_store/product_acknowledgements/response.rb @@ -0,0 +1,24 @@ +module CandyCheck + module PlayStore + module ProductAcknowledgements + class Response + def initialize(result:, error_data:) + @result = result + @error_data = error_data + end + + def acknowleged? + !!result + end + + def error + return unless error_data + + { status_code: error_data.status_code, body: error_data.body } + end + + attr_reader :result, :error_data + end + end + end +end diff --git a/spec/fixtures/vcr_cassettes/play_store/product_acknowledgements/acknowledged.yml b/spec/fixtures/vcr_cassettes/play_store/product_acknowledgements/acknowledged.yml new file mode 100644 index 0000000..6c7809f --- /dev/null +++ b/spec/fixtures/vcr_cassettes/play_store/product_acknowledgements/acknowledged.yml @@ -0,0 +1,105 @@ +--- +http_interactions: +- request: + method: post + uri: https://www.googleapis.com/oauth2/v4/token + body: + encoding: ASCII-8BIT + string: params + headers: + User-Agent: + - Faraday v1.0.1 + Content-Type: + - application/x-www-form-urlencoded + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Content-Type: + - application/json; charset=UTF-8 + Vary: + - Origin + - Referer + - X-Origin + Date: + - Mon, 22 Jun 2020 11:38:56 GMT + Server: + - scaffolding on HTTPServer2 + Cache-Control: + - private + X-Xss-Protection: + - '0' + X-Frame-Options: + - SAMEORIGIN + X-Content-Type-Options: + - nosniff + Alt-Svc: + - h3-28=":443"; ma=2592000,h3-27=":443"; ma=2592000,h3-25=":443"; ma=2592000,h3-T050=":443"; + ma=2592000,h3-Q050=":443"; ma=2592000,h3-Q049=":443"; ma=2592000,h3-Q048=":443"; + ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000,quic=":443"; + ma=2592000; v="46,43" + Transfer-Encoding: + - chunked + body: + encoding: ASCII-8BIT + string: '{"access_token":"access_token","expires_in":3599,"token_type":"Bearer"}' + recorded_at: Mon, 22 Jun 2020 11:38:56 GMT +- request: + method: post + uri: https://www.googleapis.com/androidpublisher/v3/applications/fake_package_name/purchases/products/fake_product_id/tokens/fake_token:acknowledge + body: + encoding: UTF-8 + string: '' + headers: + User-Agent: + - unknown/0.0.0 google-api-ruby-client/0.34.1 Mac OS X/10.14.6 (gzip) + Accept: + - "*/*" + Accept-Encoding: + - gzip,deflate + Date: + - Mon, 22 Jun 2020 11:38:56 GMT + X-Goog-Api-Client: + - gl-ruby/2.5.1 gdcl/0.34.1 + Authorization: + - Bearer some_token + Content-Type: + - application/x-www-form-urlencoded + response: + status: + code: 204 + message: No Content + headers: + Content-Type: + - application/json; charset=UTF-8 + Vary: + - Origin + - Referer + - X-Origin + Date: + - Mon, 22 Jun 2020 11:38:57 GMT + Server: + - ESF + Content-Length: + - '0' + X-Xss-Protection: + - '0' + X-Frame-Options: + - SAMEORIGIN + X-Content-Type-Options: + - nosniff + Alt-Svc: + - h3-28=":443"; ma=2592000,h3-27=":443"; ma=2592000,h3-25=":443"; ma=2592000,h3-T050=":443"; + ma=2592000,h3-Q050=":443"; ma=2592000,h3-Q049=":443"; ma=2592000,h3-Q048=":443"; + ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000,quic=":443"; + ma=2592000; v="46,43" + body: + encoding: UTF-8 + string: '' + recorded_at: Mon, 22 Jun 2020 11:38:57 GMT +recorded_with: VCR 6.0.0 diff --git a/spec/fixtures/vcr_cassettes/play_store/product_acknowledgements/already_acknowledged.yml b/spec/fixtures/vcr_cassettes/play_store/product_acknowledgements/already_acknowledged.yml new file mode 100644 index 0000000..b4a23ce --- /dev/null +++ b/spec/fixtures/vcr_cassettes/play_store/product_acknowledgements/already_acknowledged.yml @@ -0,0 +1,124 @@ +--- +http_interactions: +- request: + method: post + uri: https://www.googleapis.com/oauth2/v4/token + body: + encoding: ASCII-8BIT + string: params + headers: + User-Agent: + - Faraday v1.0.1 + Content-Type: + - application/x-www-form-urlencoded + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Content-Type: + - application/json; charset=UTF-8 + Vary: + - Origin + - Referer + - X-Origin + Date: + - Mon, 22 Jun 2020 13:11:45 GMT + Server: + - scaffolding on HTTPServer2 + Cache-Control: + - private + X-Xss-Protection: + - '0' + X-Frame-Options: + - SAMEORIGIN + X-Content-Type-Options: + - nosniff + Alt-Svc: + - h3-28=":443"; ma=2592000,h3-27=":443"; ma=2592000,h3-25=":443"; ma=2592000,h3-T050=":443"; + ma=2592000,h3-Q050=":443"; ma=2592000,h3-Q049=":443"; ma=2592000,h3-Q048=":443"; + ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000,quic=":443"; + ma=2592000; v="46,43" + Transfer-Encoding: + - chunked + body: + encoding: ASCII-8BIT + string: '{"access_token":"access_token","expires_in":3599,"token_type":"Bearer"}' + recorded_at: Mon, 22 Jun 2020 13:11:45 GMT +- request: + method: post + uri: https://www.googleapis.com/androidpublisher/v3/applications/fake_package_name/purchases/products/fake_product_id/tokens/fake_token:acknowledge + body: + encoding: UTF-8 + string: '' + headers: + User-Agent: + - unknown/0.0.0 google-api-ruby-client/0.34.1 Mac OS X/10.14.6 (gzip) + Accept: + - "*/*" + Accept-Encoding: + - gzip,deflate + Date: + - Mon, 22 Jun 2020 13:11:45 GMT + X-Goog-Api-Client: + - gl-ruby/2.5.1 gdcl/0.34.1 + Authorization: + - Bearer some_token + Content-Type: + - application/x-www-form-urlencoded + response: + status: + code: 400 + message: Bad Request + headers: + Content-Type: + - application/json; charset=UTF-8 + Vary: + - Origin + - Referer + - X-Origin + Content-Encoding: + - gzip + Date: + - Mon, 22 Jun 2020 13:11:46 GMT + Server: + - ESF + Cache-Control: + - private + X-Xss-Protection: + - '0' + X-Frame-Options: + - SAMEORIGIN + X-Content-Type-Options: + - nosniff + Alt-Svc: + - h3-28=":443"; ma=2592000,h3-27=":443"; ma=2592000,h3-25=":443"; ma=2592000,h3-T050=":443"; + ma=2592000,h3-Q050=":443"; ma=2592000,h3-Q049=":443"; ma=2592000,h3-Q048=":443"; + ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000,quic=":443"; + ma=2592000; v="46,43" + Transfer-Encoding: + - chunked + body: + encoding: UTF-8 + string: | + { + "error": { + "code": 400, + "message": "The purchase is not in a valid state to perform the desired operation.", + "errors": [ + { + "message": "The purchase is not in a valid state to perform the desired operation.", + "domain": "androidpublisher", + "reason": "invalidPurchaseState", + "location": "token", + "locationType": "parameter" + } + ] + } + } + recorded_at: Mon, 22 Jun 2020 13:11:46 GMT +recorded_with: VCR 6.0.0 diff --git a/spec/fixtures/vcr_cassettes/play_store/product_acknowledgements/refunded.yml b/spec/fixtures/vcr_cassettes/play_store/product_acknowledgements/refunded.yml new file mode 100644 index 0000000..3e5159f --- /dev/null +++ b/spec/fixtures/vcr_cassettes/play_store/product_acknowledgements/refunded.yml @@ -0,0 +1,122 @@ +--- +http_interactions: +- request: + method: post + uri: https://www.googleapis.com/oauth2/v4/token + body: + encoding: ASCII-8BIT + string: params + headers: + User-Agent: + - Faraday v1.0.1 + Content-Type: + - application/x-www-form-urlencoded + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Content-Type: + - application/json; charset=UTF-8 + Vary: + - Origin + - Referer + - X-Origin + Date: + - Mon, 22 Jun 2020 14:37:46 GMT + Server: + - scaffolding on HTTPServer2 + Cache-Control: + - private + X-Xss-Protection: + - '0' + X-Frame-Options: + - SAMEORIGIN + X-Content-Type-Options: + - nosniff + Alt-Svc: + - h3-28=":443"; ma=2592000,h3-27=":443"; ma=2592000,h3-25=":443"; ma=2592000,h3-T050=":443"; + ma=2592000,h3-Q050=":443"; ma=2592000,h3-Q049=":443"; ma=2592000,h3-Q048=":443"; + ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000,quic=":443"; + ma=2592000; v="46,43" + Transfer-Encoding: + - chunked + body: + encoding: ASCII-8BIT + string: '{"access_token":"access_token","expires_in":3599,"token_type":"Bearer"}' + recorded_at: Mon, 22 Jun 2020 14:37:46 GMT +- request: + method: post + uri: https://www.googleapis.com/androidpublisher/v3/applications/fake_package_name/purchases/products/fake_product_id/tokens/fake_token:acknowledge + body: + encoding: UTF-8 + string: '' + headers: + User-Agent: + - unknown/0.0.0 google-api-ruby-client/0.34.1 Mac OS X/10.14.6 (gzip) + Accept: + - "*/*" + Accept-Encoding: + - gzip,deflate + Date: + - Mon, 22 Jun 2020 14:37:46 GMT + X-Goog-Api-Client: + - gl-ruby/2.5.1 gdcl/0.34.1 + Authorization: + - Bearer some_token + Content-Type: + - application/x-www-form-urlencoded + response: + status: + code: 400 + message: Bad Request + headers: + Content-Type: + - application/json; charset=UTF-8 + Vary: + - Origin + - Referer + - X-Origin + Content-Encoding: + - gzip + Date: + - Mon, 22 Jun 2020 14:37:47 GMT + Server: + - ESF + Cache-Control: + - private + X-Xss-Protection: + - '0' + X-Frame-Options: + - SAMEORIGIN + X-Content-Type-Options: + - nosniff + Alt-Svc: + - h3-28=":443"; ma=2592000,h3-27=":443"; ma=2592000,h3-25=":443"; ma=2592000,h3-T050=":443"; + ma=2592000,h3-Q050=":443"; ma=2592000,h3-Q049=":443"; ma=2592000,h3-Q048=":443"; + ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000,quic=":443"; + ma=2592000; v="46,43" + Transfer-Encoding: + - chunked + body: + encoding: UTF-8 + string: | + { + "error": { + "code": 400, + "message": "The product purchase is not owned by the user.", + "errors": [ + { + "message": "The product purchase is not owned by the user.", + "domain": "androidpublisher", + "reason": "productNotOwnedByUser" + } + ] + } + } + recorded_at: Mon, 22 Jun 2020 14:37:47 GMT +recorded_with: VCR 6.0.0 diff --git a/spec/play_store/acknowledger_spec.rb b/spec/play_store/acknowledger_spec.rb new file mode 100644 index 0000000..e66ec57 --- /dev/null +++ b/spec/play_store/acknowledger_spec.rb @@ -0,0 +1,48 @@ +require "spec_helper" + +describe CandyCheck::PlayStore::Acknowledger do + let(:json_key_file) { File.expand_path("../fixtures/play_store/random_dummy_key.json", __dir__) } + subject { CandyCheck::PlayStore::Acknowledger.new(authorization: authorization) } + + let(:package_name) { "fake_package_name" } + let(:product_id) { "fake_product_id" } + let(:token) { "fake_token" } + + let(:authorization) { CandyCheck::PlayStore.authorization(json_key_file) } + + describe "#acknowledge_product_purchase" do + it "when acknowlegement succeeds" do + VCR.use_cassette("play_store/product_acknowledgements/acknowledged") do + result = subject.acknowledge_product_purchase(package_name: package_name, product_id: product_id, token: token) + + result.must_be_instance_of CandyCheck::PlayStore::ProductAcknowledgements::Response + result.acknowleged?.must_be_true + result.error.must_be_nil + end + end + it "when already acknowledged" do + error_body = "{\n \"error\": {\n \"code\": 400,\n \"message\": \"The purchase is not in a valid state to perform the desired operation.\",\n \"errors\": [\n {\n \"message\": \"The purchase is not in a valid state to perform the desired operation.\",\n \"domain\": \"androidpublisher\",\n \"reason\": \"invalidPurchaseState\",\n \"location\": \"token\",\n \"locationType\": \"parameter\"\n }\n ]\n }\n}\n" + + VCR.use_cassette("play_store/product_acknowledgements/already_acknowledged") do + result = subject.acknowledge_product_purchase(package_name: package_name, product_id: product_id, token: token) + + result.must_be_instance_of CandyCheck::PlayStore::ProductAcknowledgements::Response + result.acknowleged?.must_be_false + result.error[:body].must_equal(error_body) + result.error[:status_code].must_equal(400) + end + end + it "when it has been refunded" do + error_body = "{\n \"error\": {\n \"code\": 400,\n \"message\": \"The product purchase is not owned by the user.\",\n \"errors\": [\n {\n \"message\": \"The product purchase is not owned by the user.\",\n \"domain\": \"androidpublisher\",\n \"reason\": \"productNotOwnedByUser\"\n }\n ]\n }\n}\n" + + VCR.use_cassette("play_store/product_acknowledgements/refunded") do + result = subject.acknowledge_product_purchase(package_name: package_name, product_id: product_id, token: token) + + result.must_be_instance_of CandyCheck::PlayStore::ProductAcknowledgements::Response + result.acknowleged?.must_be_false + result.error[:body].must_equal(error_body) + result.error[:status_code].must_equal(400) + end + end + end +end diff --git a/spec/play_store/product_acknowledgements/acknowledgement_spec.rb b/spec/play_store/product_acknowledgements/acknowledgement_spec.rb new file mode 100644 index 0000000..868ad2e --- /dev/null +++ b/spec/play_store/product_acknowledgements/acknowledgement_spec.rb @@ -0,0 +1,54 @@ +require "spec_helper" + +describe CandyCheck::PlayStore::ProductAcknowledgements::Acknowledgement do + subject do + CandyCheck::PlayStore::ProductAcknowledgements::Acknowledgement.new( + package_name: package_name, + product_id: product_id, + token: token, + authorization: authorization, + ) + end + + let(:package_name) { "fake_package_name" } + let(:product_id) { "fake_product_id" } + let(:token) { "fake_token" } + let(:json_key_file) { File.expand_path("../../fixtures/play_store/random_dummy_key.json", __dir__) } + let(:authorization) { CandyCheck::PlayStore.authorization(json_key_file) } + + describe "#call!" do + it "when acknowlegement succeeds" do + VCR.use_cassette("play_store/product_acknowledgements/acknowledged") do + result = subject.call! + + result.must_be_instance_of CandyCheck::PlayStore::ProductAcknowledgements::Response + result.acknowleged?.must_be_true + result.error.must_be_nil + end + end + it "when already acknowledged" do + error_body = "{\n \"error\": {\n \"code\": 400,\n \"message\": \"The purchase is not in a valid state to perform the desired operation.\",\n \"errors\": [\n {\n \"message\": \"The purchase is not in a valid state to perform the desired operation.\",\n \"domain\": \"androidpublisher\",\n \"reason\": \"invalidPurchaseState\",\n \"location\": \"token\",\n \"locationType\": \"parameter\"\n }\n ]\n }\n}\n" + + VCR.use_cassette("play_store/product_acknowledgements/already_acknowledged") do + result = subject.call! + + result.must_be_instance_of CandyCheck::PlayStore::ProductAcknowledgements::Response + result.acknowleged?.must_be_false + result.error[:body].must_equal(error_body) + result.error[:status_code].must_equal(400) + end + end + it "when it has been refunded" do + error_body = "{\n \"error\": {\n \"code\": 400,\n \"message\": \"The product purchase is not owned by the user.\",\n \"errors\": [\n {\n \"message\": \"The product purchase is not owned by the user.\",\n \"domain\": \"androidpublisher\",\n \"reason\": \"productNotOwnedByUser\"\n }\n ]\n }\n}\n" + + VCR.use_cassette("play_store/product_acknowledgements/refunded") do + result = subject.call! + + result.must_be_instance_of CandyCheck::PlayStore::ProductAcknowledgements::Response + result.acknowleged?.must_be_false + result.error[:body].must_equal(error_body) + result.error[:status_code].must_equal(400) + end + end + end +end diff --git a/spec/play_store/product_acknowledgements/response_spec.rb b/spec/play_store/product_acknowledgements/response_spec.rb new file mode 100644 index 0000000..62e1649 --- /dev/null +++ b/spec/play_store/product_acknowledgements/response_spec.rb @@ -0,0 +1,66 @@ +require "spec_helper" + +describe CandyCheck::PlayStore::ProductAcknowledgements::Response do + subject do + CandyCheck::PlayStore::ProductAcknowledgements::Response.new(result: result, error_data: error_data) + end + + describe '#acknowleged?' do + context 'when result present' do + let(:result) { '' } + let(:error_data) { nil } + + it 'returns true' do + result = subject.acknowleged? + + result.must_be_true + end + end + + context 'when result is not present' do + let(:result) { nil } + let(:error_data) { nil } + + it 'returns false' do + result = subject.acknowleged? + + result.must_be_false + end + end + end + + describe '#error' do + context 'when error present' do + let(:result) { nil } + let(:error_data) do + Module.new do + def status_code + 400 + end + def body + 'A String describing the issue' + end + module_function :status_code, :body + end + end + + it 'returns the expected data' do + result = subject.error + + result[:status_code].must_equal(400) + result[:body].must_equal('A String describing the issue') + end + end + + context 'when error is not present' do + let(:result) { '' } + let(:error_data) { nil } + + it 'returns false' do + result = subject.error + + result.must_be_nil + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 786c31b..b909a16 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -25,6 +25,13 @@ def in_continuous_integration_environment? ENV["DEBUG"] && Google::APIClient.logger.level = Logger::DEBUG +class MiniTest::Spec + class << self + alias :context :describe + end +end + + module MiniTest module Assertions # The first parameter must be ```true```, not coercible to true.