From e8d91b1b2452a4c2b52e9b733c99e154a27a1b75 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Sat, 16 Aug 2025 14:17:09 -0400 Subject: [PATCH 01/14] Initial move --- .../api/v1/statuses/contexts_controller.rb | 66 +++++++++++++++++++ app/controllers/api/v1/statuses_controller.rb | 53 +-------------- config/routes/api.rb | 5 +- .../requests/api/v1/statuses/contexts_spec.rb | 51 ++++++++++++++ spec/requests/api/v1/statuses_spec.rb | 31 --------- 5 files changed, 120 insertions(+), 86 deletions(-) create mode 100644 app/controllers/api/v1/statuses/contexts_controller.rb create mode 100644 spec/requests/api/v1/statuses/contexts_spec.rb diff --git a/app/controllers/api/v1/statuses/contexts_controller.rb b/app/controllers/api/v1/statuses/contexts_controller.rb new file mode 100644 index 00000000000..1ea1c5ca55c --- /dev/null +++ b/app/controllers/api/v1/statuses/contexts_controller.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::ContextsController < Api::BaseController + include Authorization + include AsyncRefreshesConcern + + before_action -> { authorize_if_got_token! :read, :'read:statuses' } + before_action :set_status + + # This API was originally unlimited and pagination cannot be introduced + # without breaking backwards-compatibility. Use a relatively high number to + # cover most conversations as "unlimited", while enforcing a resource cap + CONTEXT_LIMIT = 4_096 + + # Avoid expensive computation and limit results for logged-out users + ANCESTORS_LIMIT = 40 + DESCENDANTS_LIMIT = 60 + DESCENDANTS_DEPTH_LIMIT = 20 + + def show + cache_if_unauthenticated! + + ancestors_limit = CONTEXT_LIMIT + descendants_limit = CONTEXT_LIMIT + descendants_depth_limit = nil + + if current_account.nil? + ancestors_limit = ANCESTORS_LIMIT + descendants_limit = DESCENDANTS_LIMIT + descendants_depth_limit = DESCENDANTS_DEPTH_LIMIT + end + + ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(ancestors_limit, current_account) + descendants_results = @status.descendants(descendants_limit, current_account, descendants_depth_limit) + loaded_ancestors = preload_collection(ancestors_results, Status) + loaded_descendants = preload_collection(descendants_results, Status) + + @context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants) + statuses = [@status] + @context.ancestors + @context.descendants + + refresh_key = "context:#{@status.id}:refresh" + async_refresh = AsyncRefresh.new(refresh_key) + + if async_refresh.running? + add_async_refresh_header(async_refresh) + elsif !current_account.nil? && @status.should_fetch_replies? + add_async_refresh_header(AsyncRefresh.create(refresh_key)) + + WorkerBatch.new.within do |batch| + batch.connect(refresh_key, threshold: 1.0) + ActivityPub::FetchAllRepliesWorker.perform_async(@status.id, { 'batch_id' => batch.id }) + end + end + + render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id) + end + + private + + def set_status + @status = Status.find(params[:status_id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + not_found + end +end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 93dbd8f9d1c..1eb653a94c4 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -7,9 +7,9 @@ class Api::V1::StatusesController < Api::BaseController before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy] before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy] - before_action :require_user!, except: [:index, :show, :context] + before_action :require_user!, except: [:index, :show] before_action :set_statuses, only: [:index] - before_action :set_status, only: [:show, :context] + before_action :set_status, only: [:show] before_action :set_thread, only: [:create] before_action :set_quoted_status, only: [:create] before_action :check_statuses_limit, only: [:index] @@ -17,17 +17,6 @@ class Api::V1::StatusesController < Api::BaseController override_rate_limit_headers :create, family: :statuses override_rate_limit_headers :update, family: :statuses - # This API was originally unlimited, pagination cannot be introduced without - # breaking backwards-compatibility. Arbitrarily high number to cover most - # conversations as quasi-unlimited, it would be too much work to render more - # than this anyway - CONTEXT_LIMIT = 4_096 - - # This remains expensive and we don't want to show everything to logged-out users - ANCESTORS_LIMIT = 40 - DESCENDANTS_LIMIT = 60 - DESCENDANTS_DEPTH_LIMIT = 20 - def index @statuses = preload_collection(@statuses, Status) render json: @statuses, each_serializer: REST::StatusSerializer @@ -39,44 +28,6 @@ class Api::V1::StatusesController < Api::BaseController render json: @status, serializer: REST::StatusSerializer end - def context - cache_if_unauthenticated! - - ancestors_limit = CONTEXT_LIMIT - descendants_limit = CONTEXT_LIMIT - descendants_depth_limit = nil - - if current_account.nil? - ancestors_limit = ANCESTORS_LIMIT - descendants_limit = DESCENDANTS_LIMIT - descendants_depth_limit = DESCENDANTS_DEPTH_LIMIT - end - - ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(ancestors_limit, current_account) - descendants_results = @status.descendants(descendants_limit, current_account, descendants_depth_limit) - loaded_ancestors = preload_collection(ancestors_results, Status) - loaded_descendants = preload_collection(descendants_results, Status) - - @context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants) - statuses = [@status] + @context.ancestors + @context.descendants - - refresh_key = "context:#{@status.id}:refresh" - async_refresh = AsyncRefresh.new(refresh_key) - - if async_refresh.running? - add_async_refresh_header(async_refresh) - elsif !current_account.nil? && @status.should_fetch_replies? - add_async_refresh_header(AsyncRefresh.create(refresh_key)) - - WorkerBatch.new.within do |batch| - batch.connect(refresh_key, threshold: 1.0) - ActivityPub::FetchAllRepliesWorker.perform_async(@status.id, { 'batch_id' => batch.id }) - end - end - - render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id) - end - def create @status = PostStatusService.new.call( current_user.account, diff --git a/config/routes/api.rb b/config/routes/api.rb index 34b2e255da6..4bdab862b36 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -16,6 +16,7 @@ namespace :api, format: false do resources :reblogged_by, controller: :reblogged_by_accounts, only: :index resources :favourited_by, controller: :favourited_by_accounts, only: :index resource :reblog, only: :create + resource :context, only: :show post :unreblog, to: 'reblogs#destroy' resources :quotes, only: :index do @@ -43,10 +44,6 @@ namespace :api, format: false do post :translate, to: 'translations#create' end - - member do - get :context - end end namespace :timelines do diff --git a/spec/requests/api/v1/statuses/contexts_spec.rb b/spec/requests/api/v1/statuses/contexts_spec.rb new file mode 100644 index 00000000000..8188d5b4cbb --- /dev/null +++ b/spec/requests/api/v1/statuses/contexts_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'API V1 Statuses Contexts' do + context 'with an oauth token' do + let(:user) { Fabricate(:user) } + let(:client_app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, application: client_app, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/statuses/:status_id/context' do + let(:scopes) { 'read:statuses' } + let(:status) { Fabricate(:status, account: user.account) } + + before do + Fabricate(:status, account: user.account, thread: status) + end + + it 'returns http success' do + get "/api/v1/statuses/#{status.id}/context", headers: headers + + expect(response) + .to have_http_status(200) + expect(response.content_type) + .to start_with('application/json') + end + end + end + + context 'without an oauth token' do + context 'with a public status' do + let(:status) { Fabricate(:status, visibility: :public) } + + describe 'GET /api/v1/statuses/:status_id/context' do + before do + Fabricate(:status, thread: status) + end + + it 'returns http success' do + get "/api/v1/statuses/#{status.id}/context" + + expect(response) + .to have_http_status(200) + expect(response.content_type) + .to start_with('application/json') + end + end + end + end +end diff --git a/spec/requests/api/v1/statuses_spec.rb b/spec/requests/api/v1/statuses_spec.rb index ba18623302e..5158ec84d34 100644 --- a/spec/requests/api/v1/statuses_spec.rb +++ b/spec/requests/api/v1/statuses_spec.rb @@ -119,23 +119,6 @@ RSpec.describe '/api/v1/statuses' do end end - describe 'GET /api/v1/statuses/:id/context' do - let(:scopes) { 'read:statuses' } - let(:status) { Fabricate(:status, account: user.account) } - - before do - Fabricate(:status, account: user.account, thread: status) - end - - it 'returns http success' do - get "/api/v1/statuses/#{status.id}/context", headers: headers - - expect(response).to have_http_status(200) - expect(response.content_type) - .to start_with('application/json') - end - end - describe 'POST /api/v1/statuses' do subject do post '/api/v1/statuses', headers: headers, params: params @@ -406,20 +389,6 @@ RSpec.describe '/api/v1/statuses' do .to start_with('application/json') end end - - describe 'GET /api/v1/statuses/:id/context' do - before do - Fabricate(:status, thread: status) - end - - it 'returns http success' do - get "/api/v1/statuses/#{status.id}/context" - - expect(response).to have_http_status(200) - expect(response.content_type) - .to start_with('application/json') - end - end end end end From 914f6b411aad140e95c9e80b058a9b332f332693 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Sat, 16 Aug 2025 14:19:55 -0400 Subject: [PATCH 02/14] Extract limit methods --- .../api/v1/statuses/contexts_controller.rb | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/app/controllers/api/v1/statuses/contexts_controller.rb b/app/controllers/api/v1/statuses/contexts_controller.rb index 1ea1c5ca55c..a4983d3d923 100644 --- a/app/controllers/api/v1/statuses/contexts_controller.rb +++ b/app/controllers/api/v1/statuses/contexts_controller.rb @@ -20,16 +20,6 @@ class Api::V1::Statuses::ContextsController < Api::BaseController def show cache_if_unauthenticated! - ancestors_limit = CONTEXT_LIMIT - descendants_limit = CONTEXT_LIMIT - descendants_depth_limit = nil - - if current_account.nil? - ancestors_limit = ANCESTORS_LIMIT - descendants_limit = DESCENDANTS_LIMIT - descendants_depth_limit = DESCENDANTS_DEPTH_LIMIT - end - ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(ancestors_limit, current_account) descendants_results = @status.descendants(descendants_limit, current_account, descendants_depth_limit) loaded_ancestors = preload_collection(ancestors_results, Status) @@ -57,6 +47,18 @@ class Api::V1::Statuses::ContextsController < Api::BaseController private + def ancestors_limit + current_account.present? ? CONTEXT_LIMIT : ANCESTORS_LIMIT + end + + def descendants_limit + current_account.present? ? CONTEXT_LIMIT : DESCENDANTS_LIMIT + end + + def descendants_depth_limit + current_account.present? ? nil : DESCENDANTS_DEPTH_LIMIT + end + def set_status @status = Status.find(params[:status_id]) authorize @status, :show? From acf034d323ffcb2a74f81fb1f477c5d1e620a9f0 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Sat, 16 Aug 2025 14:20:54 -0400 Subject: [PATCH 03/14] Extract results methods --- app/controllers/api/v1/statuses/contexts_controller.rb | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/v1/statuses/contexts_controller.rb b/app/controllers/api/v1/statuses/contexts_controller.rb index a4983d3d923..a742472c75e 100644 --- a/app/controllers/api/v1/statuses/contexts_controller.rb +++ b/app/controllers/api/v1/statuses/contexts_controller.rb @@ -20,8 +20,6 @@ class Api::V1::Statuses::ContextsController < Api::BaseController def show cache_if_unauthenticated! - ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(ancestors_limit, current_account) - descendants_results = @status.descendants(descendants_limit, current_account, descendants_depth_limit) loaded_ancestors = preload_collection(ancestors_results, Status) loaded_descendants = preload_collection(descendants_results, Status) @@ -47,6 +45,14 @@ class Api::V1::Statuses::ContextsController < Api::BaseController private + def ancestors_results + @status.in_reply_to_id.nil? ? [] : @status.ancestors(ancestors_limit, current_account) + end + + def descendants_results + @status.descendants(descendants_limit, current_account, descendants_depth_limit) + end + def ancestors_limit current_account.present? ? CONTEXT_LIMIT : ANCESTORS_LIMIT end From 6029d5f9ca75b9c80d50f0595ec4c039399734b1 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Sat, 16 Aug 2025 14:21:56 -0400 Subject: [PATCH 04/14] Extract loaded methods --- .../api/v1/statuses/contexts_controller.rb | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/app/controllers/api/v1/statuses/contexts_controller.rb b/app/controllers/api/v1/statuses/contexts_controller.rb index a742472c75e..bd60f0faffc 100644 --- a/app/controllers/api/v1/statuses/contexts_controller.rb +++ b/app/controllers/api/v1/statuses/contexts_controller.rb @@ -20,9 +20,6 @@ class Api::V1::Statuses::ContextsController < Api::BaseController def show cache_if_unauthenticated! - loaded_ancestors = preload_collection(ancestors_results, Status) - loaded_descendants = preload_collection(descendants_results, Status) - @context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants) statuses = [@status] + @context.ancestors + @context.descendants @@ -45,6 +42,14 @@ class Api::V1::Statuses::ContextsController < Api::BaseController private + def loaded_ancestors + preload_collection(ancestors_results, Status) + end + + def loaded_descendants + preload_collection(descendants_results, Status) + end + def ancestors_results @status.in_reply_to_id.nil? ? [] : @status.ancestors(ancestors_limit, current_account) end From b2f17f559d99dc10b8f9ba612e8c41d359bdbc17 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Sat, 16 Aug 2025 14:28:12 -0400 Subject: [PATCH 05/14] Extract statuses methods --- app/controllers/api/v1/statuses/contexts_controller.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/controllers/api/v1/statuses/contexts_controller.rb b/app/controllers/api/v1/statuses/contexts_controller.rb index bd60f0faffc..81fa95b00ac 100644 --- a/app/controllers/api/v1/statuses/contexts_controller.rb +++ b/app/controllers/api/v1/statuses/contexts_controller.rb @@ -21,7 +21,6 @@ class Api::V1::Statuses::ContextsController < Api::BaseController cache_if_unauthenticated! @context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants) - statuses = [@status] + @context.ancestors + @context.descendants refresh_key = "context:#{@status.id}:refresh" async_refresh = AsyncRefresh.new(refresh_key) @@ -42,6 +41,10 @@ class Api::V1::Statuses::ContextsController < Api::BaseController private + def statuses + [@status] + @context.ancestors + @context.descendants + end + def loaded_ancestors preload_collection(ancestors_results, Status) end From 1c4cbd098235f7608bb4a02efd1560a5aaeb6b32 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Sat, 16 Aug 2025 14:36:05 -0400 Subject: [PATCH 06/14] Extract async refresh methods --- .../api/v1/statuses/contexts_controller.rb | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/app/controllers/api/v1/statuses/contexts_controller.rb b/app/controllers/api/v1/statuses/contexts_controller.rb index 81fa95b00ac..39451710ce9 100644 --- a/app/controllers/api/v1/statuses/contexts_controller.rb +++ b/app/controllers/api/v1/statuses/contexts_controller.rb @@ -22,12 +22,19 @@ class Api::V1::Statuses::ContextsController < Api::BaseController @context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants) - refresh_key = "context:#{@status.id}:refresh" + process_async_refresh! + + render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id) + end + + private + + def process_async_refresh! async_refresh = AsyncRefresh.new(refresh_key) if async_refresh.running? add_async_refresh_header(async_refresh) - elsif !current_account.nil? && @status.should_fetch_replies? + elsif current_account.present? && @status.should_fetch_replies? add_async_refresh_header(AsyncRefresh.create(refresh_key)) WorkerBatch.new.within do |batch| @@ -35,11 +42,11 @@ class Api::V1::Statuses::ContextsController < Api::BaseController ActivityPub::FetchAllRepliesWorker.perform_async(@status.id, { 'batch_id' => batch.id }) end end - - render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id) end - private + def refresh_key + "context:#{@status.id}:refresh" + end def statuses [@status] + @context.ancestors + @context.descendants From 368767a702bfc661ea9dcd88db5beeda07e568ca Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Sat, 16 Aug 2025 14:38:02 -0400 Subject: [PATCH 07/14] Extract fetch all replies batch methods --- .../api/v1/statuses/contexts_controller.rb | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/controllers/api/v1/statuses/contexts_controller.rb b/app/controllers/api/v1/statuses/contexts_controller.rb index 39451710ce9..ca48717b391 100644 --- a/app/controllers/api/v1/statuses/contexts_controller.rb +++ b/app/controllers/api/v1/statuses/contexts_controller.rb @@ -36,11 +36,14 @@ class Api::V1::Statuses::ContextsController < Api::BaseController add_async_refresh_header(async_refresh) elsif current_account.present? && @status.should_fetch_replies? add_async_refresh_header(AsyncRefresh.create(refresh_key)) + queue_fetch_replies_worker_batch + end + end - WorkerBatch.new.within do |batch| - batch.connect(refresh_key, threshold: 1.0) - ActivityPub::FetchAllRepliesWorker.perform_async(@status.id, { 'batch_id' => batch.id }) - end + def queue_fetch_replies_worker_batch + WorkerBatch.new.within do |batch| + batch.connect(refresh_key, threshold: 1.0) + ActivityPub::FetchAllRepliesWorker.perform_async(@status.id, { 'batch_id' => batch.id }) end end From aea92b51972133d586985186eaff8b2a358420f0 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Sat, 16 Aug 2025 14:39:18 -0400 Subject: [PATCH 08/14] Remove async refresh concern from original --- app/controllers/api/v1/statuses_controller.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 1eb653a94c4..87d271313b3 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -2,7 +2,6 @@ class Api::V1::StatusesController < Api::BaseController include Authorization - include AsyncRefreshesConcern include Api::InteractionPoliciesConcern before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy] From f0cce32c353a9461a9781d28cd6ae872e88de8be Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Sat, 16 Aug 2025 14:44:47 -0400 Subject: [PATCH 09/14] Coverage for too many IDs --- spec/requests/api/v1/statuses_spec.rb | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/spec/requests/api/v1/statuses_spec.rb b/spec/requests/api/v1/statuses_spec.rb index 5158ec84d34..0fc300d69fd 100644 --- a/spec/requests/api/v1/statuses_spec.rb +++ b/spec/requests/api/v1/statuses_spec.rb @@ -25,6 +25,19 @@ RSpec.describe '/api/v1/statuses' do hash_including(id: other_status.id.to_s) ) end + + context 'with too many IDs' do + before { stub_const 'Api::BaseController::DEFAULT_STATUSES_LIMIT', 2 } + + it 'returns error response' do + get '/api/v1/statuses', headers: headers, params: { id: [123, 456, 789] } + + expect(response) + .to have_http_status(422) + expect(response.content_type) + .to start_with('application/json') + end + end end describe 'GET /api/v1/statuses/:id' do From 6edefcc25f5169301d953c3108ef5fccf825f454 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Sat, 16 Aug 2025 14:52:51 -0400 Subject: [PATCH 10/14] Add coverage for when requested status is itself a reply --- .../requests/api/v1/statuses/contexts_spec.rb | 71 +++++++++++++------ 1 file changed, 49 insertions(+), 22 deletions(-) diff --git a/spec/requests/api/v1/statuses/contexts_spec.rb b/spec/requests/api/v1/statuses/contexts_spec.rb index 8188d5b4cbb..41bee8ce6b9 100644 --- a/spec/requests/api/v1/statuses/contexts_spec.rb +++ b/spec/requests/api/v1/statuses/contexts_spec.rb @@ -3,39 +3,66 @@ require 'rails_helper' RSpec.describe 'API V1 Statuses Contexts' do - context 'with an oauth token' do - let(:user) { Fabricate(:user) } - let(:client_app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } - let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, application: client_app, scopes: scopes) } - let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + describe 'GET /api/v1/statuses/:status_id/context' do + context 'with an oauth token' do + let(:user) { Fabricate(:user) } + let(:client_app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, application: client_app, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } - describe 'GET /api/v1/statuses/:status_id/context' do let(:scopes) { 'read:statuses' } - let(:status) { Fabricate(:status, account: user.account) } - before do - Fabricate(:status, account: user.account, thread: status) + context 'with a public status' do + let(:status) { Fabricate(:status, account: user.account) } + + before { Fabricate(:status, account: user.account, thread: status) } + + it 'returns http success' do + get "/api/v1/statuses/#{status.id}/context", headers: headers + + expect(response) + .to have_http_status(200) + expect(response.content_type) + .to start_with('application/json') + end end - it 'returns http success' do - get "/api/v1/statuses/#{status.id}/context", headers: headers + context 'with a public status that is a reply' do + let(:status) { Fabricate(:status, account: user.account, thread: Fabricate(:status)) } - expect(response) - .to have_http_status(200) - expect(response.content_type) - .to start_with('application/json') + before { Fabricate(:status, account: user.account, thread: status) } + + it 'returns http success' do + get "/api/v1/statuses/#{status.id}/context", headers: headers + + expect(response) + .to have_http_status(200) + expect(response.content_type) + .to start_with('application/json') + end end end - end - context 'without an oauth token' do - context 'with a public status' do - let(:status) { Fabricate(:status, visibility: :public) } + context 'without an oauth token' do + context 'with a public status' do + let(:status) { Fabricate(:status, visibility: :public) } - describe 'GET /api/v1/statuses/:status_id/context' do - before do - Fabricate(:status, thread: status) + before { Fabricate(:status, thread: status) } + + it 'returns http success' do + get "/api/v1/statuses/#{status.id}/context" + + expect(response) + .to have_http_status(200) + expect(response.content_type) + .to start_with('application/json') end + end + + context 'with a public status that is a reply' do + let(:status) { Fabricate(:status, visibility: :public, thread: Fabricate(:status)) } + + before { Fabricate(:status, thread: status) } it 'returns http success' do get "/api/v1/statuses/#{status.id}/context" From 7cb621d9ea9afd0f02c7252339d5ab2746010f76 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Sat, 16 Aug 2025 14:55:13 -0400 Subject: [PATCH 11/14] Barebones response body asssertions --- spec/requests/api/v1/statuses/contexts_spec.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/spec/requests/api/v1/statuses/contexts_spec.rb b/spec/requests/api/v1/statuses/contexts_spec.rb index 41bee8ce6b9..465a3a05ecb 100644 --- a/spec/requests/api/v1/statuses/contexts_spec.rb +++ b/spec/requests/api/v1/statuses/contexts_spec.rb @@ -24,6 +24,8 @@ RSpec.describe 'API V1 Statuses Contexts' do .to have_http_status(200) expect(response.content_type) .to start_with('application/json') + expect(response.parsed_body) + .to include(ancestors: be_an(Array)).and include(descendants: be_an(Array)) end end @@ -39,6 +41,8 @@ RSpec.describe 'API V1 Statuses Contexts' do .to have_http_status(200) expect(response.content_type) .to start_with('application/json') + expect(response.parsed_body) + .to include(ancestors: be_an(Array)).and include(descendants: be_an(Array)) end end end @@ -56,6 +60,8 @@ RSpec.describe 'API V1 Statuses Contexts' do .to have_http_status(200) expect(response.content_type) .to start_with('application/json') + expect(response.parsed_body) + .to include(ancestors: be_an(Array)).and include(descendants: be_an(Array)) end end @@ -71,6 +77,8 @@ RSpec.describe 'API V1 Statuses Contexts' do .to have_http_status(200) expect(response.content_type) .to start_with('application/json') + expect(response.parsed_body) + .to include(ancestors: be_an(Array)).and include(descendants: be_an(Array)) end end end From e2b1d4143949c6a51779b88b3f247ca7f7b13c22 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Sat, 16 Aug 2025 14:57:30 -0400 Subject: [PATCH 12/14] More detail --- spec/requests/api/v1/statuses/contexts_spec.rb | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/spec/requests/api/v1/statuses/contexts_spec.rb b/spec/requests/api/v1/statuses/contexts_spec.rb index 465a3a05ecb..eb4227a7cdb 100644 --- a/spec/requests/api/v1/statuses/contexts_spec.rb +++ b/spec/requests/api/v1/statuses/contexts_spec.rb @@ -25,7 +25,8 @@ RSpec.describe 'API V1 Statuses Contexts' do expect(response.content_type) .to start_with('application/json') expect(response.parsed_body) - .to include(ancestors: be_an(Array)).and include(descendants: be_an(Array)) + .to include(ancestors: be_an(Array).and(be_empty)) + .and include(descendants: be_an(Array).and(be_present)) end end @@ -42,7 +43,8 @@ RSpec.describe 'API V1 Statuses Contexts' do expect(response.content_type) .to start_with('application/json') expect(response.parsed_body) - .to include(ancestors: be_an(Array)).and include(descendants: be_an(Array)) + .to include(ancestors: be_an(Array).and(be_present)) + .and include(descendants: be_an(Array).and(be_present)) end end end @@ -61,7 +63,8 @@ RSpec.describe 'API V1 Statuses Contexts' do expect(response.content_type) .to start_with('application/json') expect(response.parsed_body) - .to include(ancestors: be_an(Array)).and include(descendants: be_an(Array)) + .to include(ancestors: be_an(Array).and(be_empty)) + .and include(descendants: be_an(Array).and(be_present)) end end @@ -78,7 +81,8 @@ RSpec.describe 'API V1 Statuses Contexts' do expect(response.content_type) .to start_with('application/json') expect(response.parsed_body) - .to include(ancestors: be_an(Array)).and include(descendants: be_an(Array)) + .to include(ancestors: be_an(Array).and(be_present)) + .and include(descendants: be_an(Array).and(be_present)) end end end From 7f8ab93b9d5280ead5c88971835b3af62f19c5a6 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Sat, 16 Aug 2025 14:59:06 -0400 Subject: [PATCH 13/14] Extract relationships methods --- app/controllers/api/v1/statuses/contexts_controller.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/controllers/api/v1/statuses/contexts_controller.rb b/app/controllers/api/v1/statuses/contexts_controller.rb index ca48717b391..6911e7fd0b6 100644 --- a/app/controllers/api/v1/statuses/contexts_controller.rb +++ b/app/controllers/api/v1/statuses/contexts_controller.rb @@ -24,11 +24,15 @@ class Api::V1::Statuses::ContextsController < Api::BaseController process_async_refresh! - render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id) + render json: @context, serializer: REST::ContextSerializer, relationships: end private + def relationships + StatusRelationshipsPresenter.new(statuses, current_user&.account_id) + end + def process_async_refresh! async_refresh = AsyncRefresh.new(refresh_key) From 09567bef95c8044c3474c63c5ce050ddfee0f549 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Sat, 16 Aug 2025 15:07:39 -0400 Subject: [PATCH 14/14] Fix confusing formatting --- app/controllers/api/v1/statuses/contexts_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/v1/statuses/contexts_controller.rb b/app/controllers/api/v1/statuses/contexts_controller.rb index 6911e7fd0b6..6369438ad89 100644 --- a/app/controllers/api/v1/statuses/contexts_controller.rb +++ b/app/controllers/api/v1/statuses/contexts_controller.rb @@ -13,9 +13,9 @@ class Api::V1::Statuses::ContextsController < Api::BaseController CONTEXT_LIMIT = 4_096 # Avoid expensive computation and limit results for logged-out users - ANCESTORS_LIMIT = 40 - DESCENDANTS_LIMIT = 60 + ANCESTORS_LIMIT = 40 DESCENDANTS_DEPTH_LIMIT = 20 + DESCENDANTS_LIMIT = 60 def show cache_if_unauthenticated!