From ec388804df474eb7444f0204876b50c3dc867d17 Mon Sep 17 00:00:00 2001 From: Corey Alexander Date: Mon, 29 Apr 2019 18:11:53 -0400 Subject: [PATCH] Embed Twitch Live Streaming (#2591) * Get a job created that can create a webhook subscription for a twitch user login * Remove ngork url * Refactor add store the access token in the cache * Get a controller stood up to recieve the webhooks. Now they just need to be processed * Get User columns added and got webhook controller bones working * Update the webhook job to use the User * Add a way for the User to input their Twitch User Name. Plus a linter fix * Delay webhook registration when profile is updated * Don't add _ in username * Use String columns * Quick fix and add some more requests specs * Specs for the webhook job * Get a show page Twitch Live Streams. Just a straight embed of the Twitch Everything Embed UI. Works surprisingly well responsively, and works on all screen sizes * Fix Gemfile.lock from merge issues * Add support for expired tokens and add spec * Add secrets to webhook registration and clean up spec to remove token logic * Verify webhook secret and spec it * Add rake task to enqueue webhook registration for all Users. This can be used from Heroku Scheduler * Update the lease seconds to be for 5 days * Actually lets do 7 so we can refresh twice a week and try to make sure that we can always miss one * Hijack the existing Twitch logo instead of making a duplicate one * Remove comment and replace with log line * Remove some white space * Log to Airbrake when webhook errors occur * Move to passing in an id instead of User object * Extract logic from Job to Service object * Capitilize in the view * Move out of models and into services * Remove letover stub * Remove one usage of Faraday * Use HTTParty for all the HTTP here --- Envfile | 5 + app/assets/stylesheets/scaffolds.scss | 4 +- .../twitch_live_streams_controller.rb | 12 ++ .../twitch_stream_updates_controller.rb | 44 ++++++ app/controllers/users_controller.rb | 1 + .../twitch_webhook_registration_job.rb | 12 ++ app/models/user.rb | 15 ++- app/policies/user_policy.rb | 1 + .../streams/twitch_access_token/get.rb | 35 +++++ .../streams/twitch_webhook/register.rb | 49 +++++++ .../twitch_live_streams/no_twitch.html.erb | 3 + app/views/twitch_live_streams/show.html.erb | 21 +++ app/views/users/_profile.html.erb | 5 + app/views/users/show.html.erb | 15 ++- config/routes.rb | 5 +- config/sample_application.yml | 4 + ...190420000607_add_twitch_columns_to_user.rb | 8 ++ db/schema.rb | 4 + lib/tasks/twitch.rake | 8 ++ .../twitch_webhook_registration_job_spec.rb | 33 +++++ spec/rails_helper.rb | 2 + spec/requests/twitch_stream_updates_spec.rb | 127 ++++++++++++++++++ .../streams/twitch_access_token/get_spec.rb | 50 +++++++ .../streams/twitch_webhook/register_spec.rb | 40 ++++++ 24 files changed, 498 insertions(+), 5 deletions(-) create mode 100644 app/controllers/twitch_live_streams_controller.rb create mode 100644 app/controllers/twitch_stream_updates_controller.rb create mode 100644 app/jobs/streams/twitch_webhook_registration_job.rb create mode 100644 app/services/streams/twitch_access_token/get.rb create mode 100644 app/services/streams/twitch_webhook/register.rb create mode 100644 app/views/twitch_live_streams/no_twitch.html.erb create mode 100644 app/views/twitch_live_streams/show.html.erb create mode 100644 db/migrate/20190420000607_add_twitch_columns_to_user.rb create mode 100644 lib/tasks/twitch.rake create mode 100644 spec/jobs/streams/twitch_webhook_registration_job_spec.rb create mode 100644 spec/requests/twitch_stream_updates_spec.rb create mode 100644 spec/services/streams/twitch_access_token/get_spec.rb create mode 100644 spec/services/streams/twitch_webhook/register_spec.rb diff --git a/Envfile b/Envfile index cac827daa3917..fb2897eb26ad9 100644 --- a/Envfile +++ b/Envfile @@ -144,6 +144,11 @@ variable :TWILIO_ACCOUNT_SID, :String, default: "Optional" variable :TWILIO_VIDEO_API_KEY, :String, default: "Optional" variable :TWILIO_VIDEO_API_SECRET, :String, default: "Optional" +# For Twitch live stream integration +variable :TWITCH_CLIENT_ID, :String, default: "Optional" +variable :TWITCH_CLIENT_SECRET, :String, default: "Optional" +variable :TWITCH_WEBHOOK_SECRET, :String, default: "Optional" + # Trending tags on home page variable :TRENDING_TAGS, :String, default: "git,beginners" diff --git a/app/assets/stylesheets/scaffolds.scss b/app/assets/stylesheets/scaffolds.scss index 8c0f6aa06144f..f746b6cebc2fd 100644 --- a/app/assets/stylesheets/scaffolds.scss +++ b/app/assets/stylesheets/scaffolds.scss @@ -18,7 +18,7 @@ body { &:active { outline: 0; } -} +} .ptr--ptr{ @@ -38,7 +38,7 @@ body { overflow:hidden; min-height:88vh; visibility: visible; - &.stories-index,&.notifications-index,&.stories-search,&.podcast_episodes-index,&.reading_list_items-index,.tags-index{ + &.stories-index,&.notifications-index,&.stories-search,&.podcast_episodes-index,&.reading_list_items-index,.tags-index,&.twitch_live_streams-show{ margin-top:68px; } } diff --git a/app/controllers/twitch_live_streams_controller.rb b/app/controllers/twitch_live_streams_controller.rb new file mode 100644 index 0000000000000..789267f0b50c3 --- /dev/null +++ b/app/controllers/twitch_live_streams_controller.rb @@ -0,0 +1,12 @@ +class TwitchLiveStreamsController < ApplicationController + before_action :set_cache_control_headers + + def show + @user = User.find_by!(username: params[:username].tr("@", "").downcase) + if @user.twitch_username.present? + render :show + else + render :no_twitch + end + end +end diff --git a/app/controllers/twitch_stream_updates_controller.rb b/app/controllers/twitch_stream_updates_controller.rb new file mode 100644 index 0000000000000..93206983e617a --- /dev/null +++ b/app/controllers/twitch_stream_updates_controller.rb @@ -0,0 +1,44 @@ +class TwitchStreamUpdatesController < ApplicationController + skip_before_action :verify_authenticity_token + + def show + if params["hub.mode"] == "denied" + airbrake_logger.error("Twitch Webhook was denied: #{params.permit('hub.mode', 'hub.reason', 'hub.topic').to_json}") + head :no_content + else + render plain: params["hub.challenge"] + end + end + + def create + head :no_content + + unless secret_verified? + airbrake_logger.warn("Twitch Webhook Recieved for which the webhook could not be verified") + return + end + + user = User.find(params[:user_id]) + + if params[:data].first.present? + user.update!(currently_streaming_on: :twitch) + else + user.update!(currently_streaming_on: nil) + end + end + + private + + def airbrake_logger + Airbrake::AirbrakeLogger.new(Rails.logger) + end + + def secret_verified? + twitch_sha = request.headers["x-hub-signature"] + digest = Digest::SHA256.new + digest << ApplicationConfig["TWITCH_WEBHOOK_SECRET"] + digest << request.raw_post + + twitch_sha == "sha256=#{digest.hexdigest}" + end +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 3e64ffdcdbbb6..770a9ea3b6c9e 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -24,6 +24,7 @@ def update # raise permitted_attributes(@user).to_s if @user.update(permitted_attributes(@user)) RssReader.new.delay.fetch_user(@user) if @user.feed_url.present? + Streams::TwitchWebhookRegistrationJob.perform_later(@user.id) if @user.twitch_username.present? notice = "Your profile was successfully updated." if @user.export_requested? notice += " The export will be emailed to you shortly." diff --git a/app/jobs/streams/twitch_webhook_registration_job.rb b/app/jobs/streams/twitch_webhook_registration_job.rb new file mode 100644 index 0000000000000..7a312d6433efc --- /dev/null +++ b/app/jobs/streams/twitch_webhook_registration_job.rb @@ -0,0 +1,12 @@ +module Streams + class TwitchWebhookRegistrationJob < ApplicationJob + queue_as :twitch_webhook_registration + + def perform(user_id, service = TwitchWebhook::Register) + user = User.find_by(id: user_id) + return if user.blank? || user.twitch_username.blank? + + service.call(user) + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 18985d72664ee..f567d27b97149 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -130,6 +130,7 @@ class User < ApplicationRecord validates :mentee_description, :mentor_description, length: { maximum: 1000 } validates :inbox_type, inclusion: { in: %w[open private] } + validates :currently_streaming_on, inclusion: { in: %w[twitch] }, allow_nil: true validate :conditionally_validate_summary validate :validate_mastodon_url validate :validate_feed_url, if: :feed_url_changed? @@ -145,7 +146,7 @@ class User < ApplicationRecord before_update :mentorship_status_update before_validation :set_username # make sure usernames are not empty, to be able to use the database unique index - before_validation :verify_twitter_username, :verify_github_username, :verify_email + before_validation :verify_twitter_username, :verify_github_username, :verify_email, :verify_twitch_username before_validation :set_config_input before_validation :downcase_email before_validation :check_for_username_change @@ -400,6 +401,14 @@ def tag_moderator? roles.where(name: "tag_moderator").any? end + def currently_streaming? + currently_streaming_on.present? + end + + def currently_streaming_on_twitch? + currently_streaming_on == "twitch" + end + private def send_welcome_notification @@ -418,6 +427,10 @@ def verify_email self.email = nil if email == "" end + def verify_twitch_username + self.twitch_username = nil if twitch_username == "" + end + def set_username set_temp_username if username.blank? self.username = username&.downcase diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index dbcae75f82023..1e780f9e5bfa1 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -117,6 +117,7 @@ def permitted_attributes summary text_color_hex twitch_url + twitch_username username website_url export_requested] diff --git a/app/services/streams/twitch_access_token/get.rb b/app/services/streams/twitch_access_token/get.rb new file mode 100644 index 0000000000000..ecf648dc37c62 --- /dev/null +++ b/app/services/streams/twitch_access_token/get.rb @@ -0,0 +1,35 @@ +module Streams + module TwitchAccessToken + class Get + ACCESS_TOKEN_AND_EXPIRATION_CACHE_KEY = :twitch_access_token_with_expiration + def self.call + new.call + end + + def call + token, exp = Rails.cache.fetch(ACCESS_TOKEN_AND_EXPIRATION_CACHE_KEY) + + if token.nil? || Time.zone.now >= exp + token, exp = get_new_token + Rails.cache.write(ACCESS_TOKEN_AND_EXPIRATION_CACHE_KEY, [token, exp]) + end + + token + end + + private + + def get_new_token + resp = HTTParty.post( + "https://id.twitch.tv/oauth2/token", + body: { + client_id: ApplicationConfig["TWITCH_CLIENT_ID"], + client_secret: ApplicationConfig["TWITCH_CLIENT_SECRET"], + grant_type: :client_credentials + }, + ) + [resp["access_token"], resp["expires_in"].seconds.from_now] + end + end + end +end diff --git a/app/services/streams/twitch_webhook/register.rb b/app/services/streams/twitch_webhook/register.rb new file mode 100644 index 0000000000000..8a5bdd6faa08a --- /dev/null +++ b/app/services/streams/twitch_webhook/register.rb @@ -0,0 +1,49 @@ +module Streams + module TwitchWebhook + class Register + WEBHOOK_LEASE_SECONDS = 7.days.to_i + + def initialize(user, access_token_service = TwitchAccessToken::Get) + @user = user + @access_token_service = access_token_service + end + + def self.call(*args) + new(*args).call + end + + def call + user_resp = HTTParty.get("https://api.twitch.tv/helix/users", query: { login: user.twitch_username }, headers: authentication_request_headers) + twitch_user_id = user_resp["data"].first["id"] + + HTTParty.post( + "https://api.twitch.tv/helix/webhooks/hub", + body: webhook_request_body(twitch_user_id), + headers: authentication_request_headers, + ) + end + + private + + attr_reader :user, :access_token_service + + def webhook_request_body(twitch_user_id) + { + "hub.callback" => twitch_stream_updates_url_for_user(user), + "hub.mode" => "subscribe", + "hub.lease_seconds" => WEBHOOK_LEASE_SECONDS, + "hub.topic" => "https://api.twitch.tv/helix/streams?user_id=#{twitch_user_id}", + "hub.secret" => ApplicationConfig["TWITCH_WEBHOOK_SECRET"] + }.to_json + end + + def authentication_request_headers + { "Authorization" => "Bearer #{access_token_service.call}" } + end + + def twitch_stream_updates_url_for_user(user) + Rails.application.routes.url_helpers.user_twitch_stream_updates_url(user_id: user.id, host: ApplicationConfig["APP_DOMAIN"]) + end + end + end +end diff --git a/app/views/twitch_live_streams/no_twitch.html.erb b/app/views/twitch_live_streams/no_twitch.html.erb new file mode 100644 index 0000000000000..98f0d4d36d867 --- /dev/null +++ b/app/views/twitch_live_streams/no_twitch.html.erb @@ -0,0 +1,3 @@ +
+ This user does not use Twitch +
diff --git a/app/views/twitch_live_streams/show.html.erb b/app/views/twitch_live_streams/show.html.erb new file mode 100644 index 0000000000000..c4415a9f581f2 --- /dev/null +++ b/app/views/twitch_live_streams/show.html.erb @@ -0,0 +1,21 @@ +
+
+ + + diff --git a/app/views/users/_profile.html.erb b/app/views/users/_profile.html.erb index fbb416fb41368..29a8e7a85d411 100644 --- a/app/views/users/_profile.html.erb +++ b/app/views/users/_profile.html.erb @@ -150,6 +150,11 @@ <%= f.label :twitch_url, "Twitch URL" %> <%= f.url_field :twitch_url %> +

Streaming

+
+ <%= f.label :twitch_username, "Twitch User Name" %> + <%= f.text_field :twitch_username %> +
<%= f.hidden_field :tab, value: @tab %> diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 2f5bb907cf32d..64185471aa10e 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -105,7 +105,11 @@ <%= inline_svg("gitlab.svg", class: "icon-img") %> <% end %> - <% if @user.twitch_url? %> + <% if @user.twitch_username? %> + <%= link_to twitch_live_stream_path(username: @user.username) do %> + <%= inline_svg("twitch-logo.svg", class: "icon-img", id: "icon-twitch") %> + <% end %> + <% elsif @user.twitch_url? %> <%= inline_svg("twitch-logo.svg", class: "icon-img") %> @@ -139,6 +143,15 @@
<% end %> +<% if @user.currently_streaming_on_twitch? %> + +<% end %> +
" diff --git a/config/routes.rb b/config/routes.rb index c8050502bc469..a190967b641aa 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -108,7 +108,10 @@ resources :article_mutes, only: %i[update] resources :comments, only: %i[create update destroy] resources :comment_mutes, only: %i[update] - resources :users, only: [:update] + resources :users, only: [:update] do + resource :twitch_stream_updates, only: %i[show create] + end + resources :twitch_live_streams, only: :show, param: :username resources :reactions, only: %i[index create] resources :feedback_messages, only: %i[index create] get "/reports/:slug", to: "feedback_messages#show" diff --git a/config/sample_application.yml b/config/sample_application.yml index a829dae5109fd..a4528745796a9 100644 --- a/config/sample_application.yml +++ b/config/sample_application.yml @@ -15,3 +15,7 @@ PUSHER_APP_ID: PUSHER_KEY: PUSHER_SECRET: PUSHER_CLUSTER: + +TWITCH_CLIENT_ID: +TWITCH_CLIENT_SECRET: +TWITCH_WEBHOOK_SECRET: diff --git a/db/migrate/20190420000607_add_twitch_columns_to_user.rb b/db/migrate/20190420000607_add_twitch_columns_to_user.rb new file mode 100644 index 0000000000000..a85303a768fad --- /dev/null +++ b/db/migrate/20190420000607_add_twitch_columns_to_user.rb @@ -0,0 +1,8 @@ +class AddTwitchColumnsToUser < ActiveRecord::Migration[5.2] + def change + change_table :users do |t| + t.string :twitch_username, index: true, unique: true + t.string :currently_streaming_on + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 0efe783d9893c..2f418a3e4867e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # This file is auto-generated from the current state of the database. Instead # of editing this file, please use the migrations feature of Active Record to # incrementally modify your database, and then regenerate this schema definition. @@ -809,6 +811,7 @@ t.inet "current_sign_in_ip" t.string "currently_hacking_on" t.string "currently_learning" + t.string "currently_streaming_on" t.boolean "display_sponsors", default: true t.string "dribbble_url" t.string "editor_version", default: "v1" @@ -920,6 +923,7 @@ t.string "text_only_name" t.string "top_languages" t.string "twitch_url" + t.string "twitch_username" t.datetime "twitter_created_at" t.integer "twitter_followers_count" t.integer "twitter_following_count" diff --git a/lib/tasks/twitch.rake b/lib/tasks/twitch.rake new file mode 100644 index 0000000000000..5b4e903ef8fa3 --- /dev/null +++ b/lib/tasks/twitch.rake @@ -0,0 +1,8 @@ +namespace :twitch do + desc "Register for Webhooks for all User's Registered with Twitch" + task wehbook_register_all: :environment do + User.where.not(twitch_username: nil).find_each do |user| + Streams::TwitchWebhookRegistrationJob.perform_later(user.id) + end + end +end diff --git a/spec/jobs/streams/twitch_webhook_registration_job_spec.rb b/spec/jobs/streams/twitch_webhook_registration_job_spec.rb new file mode 100644 index 0000000000000..59d11d6657073 --- /dev/null +++ b/spec/jobs/streams/twitch_webhook_registration_job_spec.rb @@ -0,0 +1,33 @@ +require "rails_helper" + +RSpec.describe Streams::TwitchWebhookRegistrationJob, type: :job do + let(:user) { create(:user, twitch_username: "test-username") } + + let(:service) { double } + + before do + allow(service).to receive(:call) + end + + context "when the user does NOT have a twitch username present" do + let(:user) { create(:user) } + + it "noops" do + described_class.perform_now(user.id, service) + + expect(service).not_to have_received(:call) + end + end + + it "noops when the id passed does not belong to a user" do + described_class.perform_now(987_654_321, service) + + expect(service).not_to have_received(:call) + end + + it "registers for webhooks" do + described_class.perform_now(user.id, service) + + expect(service).to have_received(:call).with(user) + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index d4f00a55c5ac4..bad0f576e889a 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -57,6 +57,8 @@ ) end +RSpec::Matchers.define_negated_matcher :not_change, :change + RSpec.configure do |config| config.use_transactional_fixtures = true config.fixture_path = "#{::Rails.root}/spec/fixtures" diff --git a/spec/requests/twitch_stream_updates_spec.rb b/spec/requests/twitch_stream_updates_spec.rb new file mode 100644 index 0000000000000..cc0f752eaa6d2 --- /dev/null +++ b/spec/requests/twitch_stream_updates_spec.rb @@ -0,0 +1,127 @@ +require "rails_helper" + +RSpec.describe "TwitchStramUpdates", type: :request do + let(:user) { create(:user, twitch_username: "my-twtich-username", currently_streaming_on: currently_streaming_on) } + let(:currently_streaming_on) { nil } + + describe "GET /users/:user_id/twitch_stream_updates" do + context "when the subscription was successfull" do + let(:challenge) { "FAKE_CHALLENGE" } + let(:twitch_webhook_subscription_params) do + { + "hub.mode" => "subscribe", + "hub.topic" => "SOME_TOPIC_URL", + "hub.lease_seconds" => "864000", + "hub.challenge" => challenge + } + end + + it "returns the challenge" do + get "/users/#{user.id}/twitch_stream_updates", params: twitch_webhook_subscription_params + + expect(response.body).to eq challenge + end + end + + context "when the subscription is denied" do + let(:twitch_webhook_subscription_params) do + { + "hub.mode" => "denied", + "hub.topic" => "SOME_TOPIC_URL", + "hub.reason" => "unauthorized" + } + end + + it "returns a 204 and logs" do + get "/users/#{user.id}/twitch_stream_updates", params: twitch_webhook_subscription_params + + expect(response.status).to eq 204 + end + end + end + + describe "POST /users/:user_id/twitch_stream_updates" do + context "when the user was not streaming and starts streaming" do + let(:currently_streaming_on) { nil } + + let(:twitch_webhook_params) do + { + data: [{ + id: "0123456789", + user_id: "5678", + user_name: "wjdtkdqhs", + game_id: "21779", + community_ids: [], + type: "live", + title: "Best Stream Ever", + viewer_count: 417, + started_at: "2017-12-01T10:09:45Z", + language: "en", + thumbnail_url: "https://link/to/thumbnail.jpg" + }] + } + end + let(:twitch_webhook_secret_sha) do + digest = Digest::SHA256.new + digest << ApplicationConfig["TWITCH_WEBHOOK_SECRET"] + digest << twitch_webhook_params.to_json + "sha256=#{digest.hexdigest}" + end + + it "updates the Users twitch streaming status" do + expect { post "/users/#{user.id}/twitch_stream_updates", params: twitch_webhook_params.to_json, headers: { "Content-Type" => "application/json", "X-Hub-Signature" => twitch_webhook_secret_sha } }. + to change { user.reload.currently_streaming? }.from(false).to(true). + and change { user.reload.currently_streaming_on_twitch? }.from(false).to(true) + end + end + + context "when the webhook secret was NOT verified" do + let(:twitch_webhook_params) do + { + data: [{ + id: "0123456789", + user_id: "5678", + user_name: "wjdtkdqhs", + game_id: "21779", + community_ids: [], + type: "live", + title: "Best Stream Ever", + viewer_count: 417, + started_at: "2017-12-01T10:09:45Z", + language: "en", + thumbnail_url: "https://link/to/thumbnail.jpg" + }] + } + end + let(:twitch_webhook_secret_sha) { "sha256=BAD_HASH" } + + it "noops" do + expect { post "/users/#{user.id}/twitch_stream_updates", params: twitch_webhook_params.to_json, headers: { "Content-Type" => "application/json", "X-Hub-Signature" => twitch_webhook_secret_sha } }. + to not_change { user.reload.currently_streaming? }.from(false). + and not_change { user.reload.currently_streaming_on_twitch? }.from(false) + end + end + end + + context "when the user was streaming and stops" do + let(:currently_streaming_on) { :twitch } + + let(:twitch_webhook_params) do + { + data: [] + } + end + let(:twitch_webhook_secret_sha) do + digest = Digest::SHA256.new + digest << ApplicationConfig["TWITCH_WEBHOOK_SECRET"] + digest << twitch_webhook_params.to_json + "sha256=#{digest.hexdigest}" + end + + it "updates the Users twitch streaming status" do + expect { post "/users/#{user.id}/twitch_stream_updates", params: twitch_webhook_params.to_json, headers: { "Content-Type" => "application/json", "X-Hub-Signature" => twitch_webhook_secret_sha } }. + to change { user.reload.currently_streaming? }.from(true).to(false). + and change { user.reload.currently_streaming_on_twitch? }.from(true).to(false) + end + end +end diff --git a/spec/services/streams/twitch_access_token/get_spec.rb b/spec/services/streams/twitch_access_token/get_spec.rb new file mode 100644 index 0000000000000..0d1f016a39753 --- /dev/null +++ b/spec/services/streams/twitch_access_token/get_spec.rb @@ -0,0 +1,50 @@ +require "rails_helper" + +RSpec.describe Streams::TwitchAccessToken::Get, type: :service do + describe ".access_token" do + let(:expected_twitch_token_body) do + { + client_id: "FAKE_TWITCH_CLIENT_ID", + client_secret: "FAKE_TWITCH_CLIENT_SECRET", + grant_type: "client_credentials" + } + end + let!(:twitch_token_stubbed_route) do + stub_request(:post, "https://id.twitch.tv/oauth2/token"). + with(body: expected_twitch_token_body). + and_return(body: { access_token: "FAKE_BRAND_NEW_TWITCH_TOKEN", expires_in: 5_184_000 }.to_json, headers: { "Content-Type" => "application/json" }) + end + + before do + allow(ApplicationConfig).to receive(:[]).and_call_original + allow(ApplicationConfig).to receive(:[]).with("TWITCH_CLIENT_ID").and_return("FAKE_TWITCH_CLIENT_ID") + allow(ApplicationConfig).to receive(:[]).with("TWITCH_CLIENT_SECRET").and_return("FAKE_TWITCH_CLIENT_SECRET") + allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache.lookup_store(:memory_store)) + end + + context "when there is an unexpired token in the cache" do + it "returns the cached token" do + Rails.cache.write(described_class::ACCESS_TOKEN_AND_EXPIRATION_CACHE_KEY, ["FAKE_UNEXPIRED_TWITCH_TOKEN", 15.days.from_now]) + + expect(described_class.call).to eq "FAKE_UNEXPIRED_TWITCH_TOKEN" + expect(twitch_token_stubbed_route).not_to have_been_requested + end + end + + context "when there is an expired token in the cache" do + it "requests a new token and caches it" do + Rails.cache.write(described_class::ACCESS_TOKEN_AND_EXPIRATION_CACHE_KEY, ["FAKE_EXPIRED_TWITCH_TOKEN", 15.days.ago]) + + expect(described_class.call).to eq "FAKE_BRAND_NEW_TWITCH_TOKEN" + expect(twitch_token_stubbed_route).to have_been_requested + end + end + + context "when the token is not in the cache" do + it "requests a new token and caches it" do + expect(described_class.call).to eq "FAKE_BRAND_NEW_TWITCH_TOKEN" + expect(twitch_token_stubbed_route).to have_been_requested + end + end + end +end diff --git a/spec/services/streams/twitch_webhook/register_spec.rb b/spec/services/streams/twitch_webhook/register_spec.rb new file mode 100644 index 0000000000000..e3b39dbbfafa3 --- /dev/null +++ b/spec/services/streams/twitch_webhook/register_spec.rb @@ -0,0 +1,40 @@ +require "rails_helper" + +RSpec.describe Streams::TwitchWebhook::Register, type: :service do + describe "::call" do + let(:twitch_access_token_get) { instance_double(Streams::TwitchAccessToken::Get, call: "FAKE_TWITCH_TOKEN") } + let(:user) { create(:user, twitch_username: "test-username") } + + let(:expected_headers) do + { "Authorization" => "Bearer FAKE_TWITCH_TOKEN" } + end + let(:expected_twitch_webhook_params) do + { + "hub.callback" => "http://#{ApplicationConfig['APP_DOMAIN']}/users/#{user.id}/twitch_stream_updates", + "hub.mode" => "subscribe", + "hub.lease_seconds" => 604_800, + "hub.topic" => "https://api.twitch.tv/helix/streams?user_id=654321", + "hub.secret" => ApplicationConfig["TWITCH_WEBHOOK_SECRET"] + }.to_json + end + let!(:twitch_webhook_registration_stubbed_route) do + stub_request(:post, "https://api.twitch.tv/helix/webhooks/hub"). + with(body: expected_twitch_webhook_params, headers: expected_headers). + and_return(status: 204) + end + + let(:expected_twitch_user_params) { { login: "test-username" } } + let!(:twitch_user_stubbed_route) do + stub_request(:get, "https://api.twitch.tv/helix/users"). + with(query: expected_twitch_user_params, headers: expected_headers). + and_return(body: { data: [{ id: 654_321 }] }.to_json, headers: { "Content-Type" => "application/json" }) + end + + it "registers for webhooks" do + described_class.call(user, twitch_access_token_get) + + expect(twitch_webhook_registration_stubbed_route).to have_been_requested + expect(twitch_user_stubbed_route).to have_been_requested + end + end +end