diff --git a/app/controllers/api/v2_alpha/notifications/accounts_controller.rb b/app/controllers/api/v2/notifications/accounts_controller.rb similarity index 77% rename from app/controllers/api/v2_alpha/notifications/accounts_controller.rb rename to app/controllers/api/v2/notifications/accounts_controller.rb index 9933b6337..771e96638 100644 --- a/app/controllers/api/v2_alpha/notifications/accounts_controller.rb +++ b/app/controllers/api/v2/notifications/accounts_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Api::V2Alpha::Notifications::AccountsController < Api::BaseController +class Api::V2::Notifications::AccountsController < Api::BaseController before_action -> { doorkeeper_authorize! :read, :'read:notifications' } before_action :require_user! before_action :set_notifications! @@ -33,11 +33,11 @@ class Api::V2Alpha::Notifications::AccountsController < Api::BaseController end def next_path - api_v2_alpha_notification_accounts_url pagination_params(max_id: pagination_max_id) if records_continue? + api_v2_notification_accounts_url pagination_params(max_id: pagination_max_id) if records_continue? end def prev_path - api_v2_alpha_notification_accounts_url pagination_params(min_id: pagination_since_id) unless @paginated_notifications.empty? + api_v2_notification_accounts_url pagination_params(min_id: pagination_since_id) unless @paginated_notifications.empty? end def pagination_collection diff --git a/app/controllers/api/v2_alpha/notifications_controller.rb b/app/controllers/api/v2/notifications_controller.rb similarity index 89% rename from app/controllers/api/v2_alpha/notifications_controller.rb rename to app/controllers/api/v2/notifications_controller.rb index e8aa0b9e4..c070c0e5e 100644 --- a/app/controllers/api/v2_alpha/notifications_controller.rb +++ b/app/controllers/api/v2/notifications_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Api::V2Alpha::NotificationsController < Api::BaseController +class Api::V2::NotificationsController < Api::BaseController before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, except: [:clear, :dismiss] before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: [:clear, :dismiss] before_action :require_user! @@ -21,7 +21,7 @@ class Api::V2Alpha::NotificationsController < Api::BaseController ActiveRecord::Associations::Preloader.new(records: @presenter.accounts, associations: [:account_stat, { user: :role }]).call end - MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#index rendering') do |span| + MastodonOTELTracer.in_span('Api::V2::NotificationsController#index rendering') do |span| statuses = @grouped_notifications.filter_map { |group| group.target_status&.id } span.add_attributes( @@ -64,7 +64,7 @@ class Api::V2Alpha::NotificationsController < Api::BaseController private def load_notifications - MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#load_notifications') do + MastodonOTELTracer.in_span('Api::V2::NotificationsController#load_notifications') do notifications = browserable_account_notifications.includes(from_account: [:account_stat, :user]).to_a_grouped_paginated_by_id( limit_param(DEFAULT_NOTIFICATIONS_LIMIT), params.slice(:max_id, :since_id, :min_id, :grouped_types).permit(:max_id, :since_id, :min_id, grouped_types: []) @@ -79,7 +79,7 @@ class Api::V2Alpha::NotificationsController < Api::BaseController def load_grouped_notifications return [] if @notifications.empty? - MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#load_grouped_notifications') do + MastodonOTELTracer.in_span('Api::V2::NotificationsController#load_grouped_notifications') do NotificationGroup.from_notifications(@notifications, pagination_range: (@notifications.last.id)..(@notifications.first.id), grouped_types: params[:grouped_types]) end end @@ -101,11 +101,11 @@ class Api::V2Alpha::NotificationsController < Api::BaseController end def next_path - api_v2_alpha_notifications_url pagination_params(max_id: pagination_max_id) unless @notifications.empty? + api_v2_notifications_url pagination_params(max_id: pagination_max_id) unless @notifications.empty? end def prev_path - api_v2_alpha_notifications_url pagination_params(min_id: pagination_since_id) unless @notifications.empty? + api_v2_notifications_url pagination_params(min_id: pagination_since_id) unless @notifications.empty? end def pagination_collection diff --git a/app/javascript/mastodon/api/notifications.ts b/app/javascript/mastodon/api/notifications.ts index e34dc85bb..24d526655 100644 --- a/app/javascript/mastodon/api/notifications.ts +++ b/app/javascript/mastodon/api/notifications.ts @@ -37,7 +37,7 @@ export const apiFetchNotificationGroups = async (params?: { }) => { const response = await api().request({ method: 'GET', - url: '/api/v2_alpha/notifications', + url: '/api/v2/notifications', params, }); diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb index cced83587..19361277a 100644 --- a/app/serializers/rest/instance_serializer.rb +++ b/app/serializers/rest/instance_serializer.rb @@ -108,9 +108,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer end def api_versions - { - mastodon: 1, - } + Mastodon::Version.api_versions end private diff --git a/config/routes/api.rb b/config/routes/api.rb index df975065b..b23779173 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -316,6 +316,21 @@ namespace :api, format: false do end end + concern :grouped_notifications do + resources :notifications, param: :group_key, only: [:index, :show] do + collection do + post :clear + get :unread_count + end + + member do + post :dismiss + end + + resources :accounts, only: [:index], module: :notifications + end + end + namespace :v2 do get '/search', to: 'search#index', as: :search @@ -341,21 +356,12 @@ namespace :api, format: false do namespace :notifications do resource :policy, only: [:show, :update] end + + concerns :grouped_notifications end - namespace :v2_alpha do - resources :notifications, param: :group_key, only: [:index, :show] do - collection do - post :clear - get :unread_count - end - - member do - post :dismiss - end - - resources :accounts, only: [:index], module: :notifications - end + namespace :v2_alpha, module: 'v2' do + concerns :grouped_notifications end namespace :web do diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index caafa96b3..00e616089 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -43,6 +43,12 @@ module Mastodon @gem_version ||= Gem::Version.new(to_s.split('+')[0]) end + def api_versions + { + mastodon: 2, + } + end + def repository ENV.fetch('GITHUB_REPOSITORY', 'mastodon/mastodon') end diff --git a/spec/requests/api/v2_alpha/notifications/accounts_spec.rb b/spec/requests/api/v2/notifications/accounts_spec.rb similarity index 84% rename from spec/requests/api/v2_alpha/notifications/accounts_spec.rb rename to spec/requests/api/v2/notifications/accounts_spec.rb index 3c5bcd899..102b009c0 100644 --- a/spec/requests/api/v2_alpha/notifications/accounts_spec.rb +++ b/spec/requests/api/v2/notifications/accounts_spec.rb @@ -8,9 +8,9 @@ RSpec.describe 'Accounts in grouped notifications' do let(:scopes) { 'read:notifications write:notifications' } let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } - describe 'GET /api/v2_alpha/notifications/:group_key/accounts', :inline_jobs do + describe 'GET /api/v2/notifications/:group_key/accounts', :inline_jobs do subject do - get "/api/v2_alpha/notifications/#{user.account.notifications.first.group_key}/accounts", headers: headers, params: params + get "/api/v2/notifications/#{user.account.notifications.first.group_key}/accounts", headers: headers, params: params end let(:params) { {} } @@ -71,8 +71,8 @@ RSpec.describe 'Accounts in grouped notifications' do expect(response) .to include_pagination_headers( - prev: api_v2_alpha_notification_accounts_url(limit: params[:limit], min_id: notifications.first.id), - next: api_v2_alpha_notification_accounts_url(limit: params[:limit], max_id: notifications.second.id) + prev: api_v2_notification_accounts_url(limit: params[:limit], min_id: notifications.first.id), + next: api_v2_notification_accounts_url(limit: params[:limit], max_id: notifications.second.id) ) end end diff --git a/spec/requests/api/v2/notifications_spec.rb b/spec/requests/api/v2/notifications_spec.rb new file mode 100644 index 000000000..edf333ecd --- /dev/null +++ b/spec/requests/api/v2/notifications_spec.rb @@ -0,0 +1,343 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Notifications' do + let(:user) { Fabricate(:user, account_attributes: { username: 'alice' }) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'read:notifications write:notifications' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v2/notifications/unread_count', :inline_jobs do + subject do + get '/api/v2/notifications/unread_count', headers: headers, params: params + end + + let(:params) { {} } + + before do + first_status = PostStatusService.new.call(user.account, text: 'Test') + ReblogService.new.call(Fabricate(:account), first_status) + PostStatusService.new.call(Fabricate(:account), text: 'Hello @alice') + FavouriteService.new.call(Fabricate(:account), first_status) + FavouriteService.new.call(Fabricate(:account), first_status) + FollowService.new.call(Fabricate(:account), user.account) + end + + it_behaves_like 'forbidden for wrong scope', 'write write:notifications' + + context 'with no options' do + it 'returns expected notifications count' do + subject + + expect(response).to have_http_status(200) + expect(response.parsed_body[:count]).to eq 4 + end + end + + context 'with grouped_types parameter' do + let(:params) { { grouped_types: %w(reblog) } } + + it 'returns expected notifications count' do + subject + + expect(response).to have_http_status(200) + expect(response.parsed_body[:count]).to eq 5 + end + end + + context 'with a read marker' do + before do + id = user.account.notifications.browserable.order(id: :desc).offset(2).first.id + user.markers.create!(timeline: 'notifications', last_read_id: id) + end + + it 'returns expected notifications count' do + subject + + expect(response).to have_http_status(200) + expect(response.parsed_body[:count]).to eq 2 + end + end + + context 'with exclude_types param' do + let(:params) { { exclude_types: %w(mention) } } + + it 'returns expected notifications count' do + subject + + expect(response).to have_http_status(200) + expect(response.parsed_body[:count]).to eq 3 + end + end + + context 'with a user-provided limit' do + let(:params) { { limit: 2 } } + + it 'returns a capped value' do + subject + + expect(response).to have_http_status(200) + expect(response.parsed_body[:count]).to eq 2 + end + end + + context 'when there are more notifications than the limit' do + before do + stub_const('Api::V2::NotificationsController::DEFAULT_NOTIFICATIONS_COUNT_LIMIT', 2) + end + + it 'returns a capped value' do + subject + + expect(response).to have_http_status(200) + expect(response.parsed_body[:count]).to eq Api::V2::NotificationsController::DEFAULT_NOTIFICATIONS_COUNT_LIMIT + end + end + end + + describe 'GET /api/v2/notifications', :inline_jobs do + subject do + get '/api/v2/notifications', headers: headers, params: params + end + + let(:bob) { Fabricate(:user) } + let(:tom) { Fabricate(:user) } + let(:params) { {} } + + before do + first_status = PostStatusService.new.call(user.account, text: 'Test') + ReblogService.new.call(bob.account, first_status) + PostStatusService.new.call(bob.account, text: 'Hello @alice') + FavouriteService.new.call(bob.account, first_status) + FavouriteService.new.call(tom.account, first_status) + FollowService.new.call(bob.account, user.account) + end + + it_behaves_like 'forbidden for wrong scope', 'write write:notifications' + + context 'when there are no notifications' do + before do + user.account.notifications.destroy_all + end + + it 'returns 0 notifications' do + subject + + expect(response).to have_http_status(200) + expect(response.parsed_body[:notification_groups]).to eq [] + end + end + + context 'with no options' do + it 'returns expected notification types', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_json_types).to include('reblog', 'mention', 'favourite', 'follow') + end + end + + context 'with grouped_types param' do + let(:params) { { grouped_types: %w(reblog) } } + + it 'returns everything, but does not group favourites' do + subject + + expect(response).to have_http_status(200) + expect(response.parsed_body[:notification_groups]).to contain_exactly( + a_hash_including( + type: 'reblog', + sample_account_ids: [bob.account_id.to_s] + ), + a_hash_including( + type: 'mention', + sample_account_ids: [bob.account_id.to_s] + ), + a_hash_including( + type: 'favourite', + sample_account_ids: [bob.account_id.to_s] + ), + a_hash_including( + type: 'favourite', + sample_account_ids: [tom.account_id.to_s] + ), + a_hash_including( + type: 'follow', + sample_account_ids: [bob.account_id.to_s] + ) + ) + end + end + + context 'with exclude_types param' do + let(:params) { { exclude_types: %w(mention) } } + + it 'returns everything but excluded type', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(response.parsed_body.size).to_not eq 0 + expect(body_json_types.uniq).to_not include 'mention' + end + end + + context 'with types param' do + let(:params) { { types: %w(mention) } } + + it 'returns only requested type', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_json_types.uniq).to eq ['mention'] + expect(response.parsed_body.dig(:notification_groups, 0, :page_min_id)).to_not be_nil + end + end + + context 'with limit param' do + let(:params) { { limit: 3 } } + let(:notifications) { user.account.notifications.reorder(id: :desc) } + + it 'returns the requested number of notifications paginated', :aggregate_failures do + subject + + expect(response.parsed_body[:notification_groups].size) + .to eq(params[:limit]) + + expect(response) + .to include_pagination_headers( + prev: api_v2_notifications_url(limit: params[:limit], min_id: notifications.first.id), + # TODO: one downside of the current approach is that we return the first ID matching the group, + # not the last that has been skipped, so pagination is very likely to give overlap + next: api_v2_notifications_url(limit: params[:limit], max_id: notifications[3].id) + ) + end + end + + context 'with since_id param' do + let(:params) { { since_id: notifications[2].id } } + let(:notifications) { user.account.notifications.reorder(id: :desc) } + + it 'returns the requested number of notifications paginated', :aggregate_failures do + subject + + expect(response.parsed_body[:notification_groups].size) + .to eq(2) + + expect(response) + .to include_pagination_headers( + prev: api_v2_notifications_url(limit: params[:limit], min_id: notifications.first.id), + # TODO: one downside of the current approach is that we return the first ID matching the group, + # not the last that has been skipped, so pagination is very likely to give overlap + next: api_v2_notifications_url(limit: params[:limit], max_id: notifications[1].id) + ) + end + end + + context 'when requesting stripped-down accounts' do + let(:params) { { expand_accounts: 'partial_avatars' } } + + let(:recent_account) { Fabricate(:account) } + + before do + FavouriteService.new.call(recent_account, user.account.statuses.first) + end + + it 'returns an account in "partial_accounts", with the expected keys', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(response.parsed_body[:partial_accounts].size).to be > 0 + expect(response.parsed_body[:partial_accounts][0].keys.map(&:to_sym)).to contain_exactly(:acct, :avatar, :avatar_static, :bot, :id, :locked, :url) + expect(response.parsed_body[:partial_accounts].pluck(:id)).to_not include(recent_account.id.to_s) + expect(response.parsed_body[:accounts].pluck(:id)).to include(recent_account.id.to_s) + end + end + + context 'when passing an invalid value for "expand_accounts"' do + let(:params) { { expand_accounts: 'unknown_foobar' } } + + it 'returns http bad request' do + subject + + expect(response).to have_http_status(400) + end + end + + def body_json_types + response.parsed_body[:notification_groups].pluck(:type) + end + end + + describe 'GET /api/v2/notifications/:id' do + subject do + get "/api/v2/notifications/#{notification.group_key}", headers: headers + end + + let(:notification) { Fabricate(:notification, account: user.account, group_key: 'foobar') } + + it_behaves_like 'forbidden for wrong scope', 'write write:notifications' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + context 'when notification belongs to someone else' do + let(:notification) { Fabricate(:notification, group_key: 'foobar') } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /api/v2/notifications/:id/dismiss' do + subject do + post "/api/v2/notifications/#{notification.group_key}/dismiss", headers: headers + end + + let!(:notification) { Fabricate(:notification, account: user.account, group_key: 'foobar') } + + it_behaves_like 'forbidden for wrong scope', 'read read:notifications' + + it 'destroys the notification' do + subject + + expect(response).to have_http_status(200) + expect { notification.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + context 'when notification belongs to someone else' do + let(:notification) { Fabricate(:notification) } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /api/v2/notifications/clear' do + subject do + post '/api/v2/notifications/clear', headers: headers + end + + before do + Fabricate(:notification, account: user.account) + end + + it_behaves_like 'forbidden for wrong scope', 'read read:notifications' + + it 'clears notifications for the account' do + subject + + expect(user.account.reload.notifications).to be_empty + expect(response).to have_http_status(200) + end + end +end diff --git a/spec/requests/api/v2_alpha/notifications_spec.rb b/spec/requests/api/v2_alpha/notifications_spec.rb index b7821de56..6d7df45b6 100644 --- a/spec/requests/api/v2_alpha/notifications_spec.rb +++ b/spec/requests/api/v2_alpha/notifications_spec.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +# TODO: remove this before 4.3.0-rc1 + require 'rails_helper' RSpec.describe 'Notifications' do @@ -84,14 +86,14 @@ RSpec.describe 'Notifications' do context 'when there are more notifications than the limit' do before do - stub_const('Api::V2Alpha::NotificationsController::DEFAULT_NOTIFICATIONS_COUNT_LIMIT', 2) + stub_const('Api::V2::NotificationsController::DEFAULT_NOTIFICATIONS_COUNT_LIMIT', 2) end it 'returns a capped value' do subject expect(response).to have_http_status(200) - expect(response.parsed_body[:count]).to eq Api::V2Alpha::NotificationsController::DEFAULT_NOTIFICATIONS_COUNT_LIMIT + expect(response.parsed_body[:count]).to eq Api::V2::NotificationsController::DEFAULT_NOTIFICATIONS_COUNT_LIMIT end end end @@ -206,10 +208,10 @@ RSpec.describe 'Notifications' do expect(response) .to include_pagination_headers( - prev: api_v2_alpha_notifications_url(limit: params[:limit], min_id: notifications.first.id), + prev: api_v2_notifications_url(limit: params[:limit], min_id: notifications.first.id), # TODO: one downside of the current approach is that we return the first ID matching the group, # not the last that has been skipped, so pagination is very likely to give overlap - next: api_v2_alpha_notifications_url(limit: params[:limit], max_id: notifications[3].id) + next: api_v2_notifications_url(limit: params[:limit], max_id: notifications[3].id) ) end end @@ -226,10 +228,10 @@ RSpec.describe 'Notifications' do expect(response) .to include_pagination_headers( - prev: api_v2_alpha_notifications_url(limit: params[:limit], min_id: notifications.first.id), + prev: api_v2_notifications_url(limit: params[:limit], min_id: notifications.first.id), # TODO: one downside of the current approach is that we return the first ID matching the group, # not the last that has been skipped, so pagination is very likely to give overlap - next: api_v2_alpha_notifications_url(limit: params[:limit], max_id: notifications[1].id) + next: api_v2_notifications_url(limit: params[:limit], max_id: notifications[1].id) ) end end