Commit 7c814ff1 authored by Mikołaj Wawrzyniak's avatar Mikołaj Wawrzyniak

Merge branch '215187-create-api-to-update-upcoming-reconciliations' into 'master'

Create API to update upcoming reconciliations

See merge request gitlab-org/gitlab!63047
parents 6bc3aace 217e570c
...@@ -694,6 +694,45 @@ Example response: ...@@ -694,6 +694,45 @@ Example response:
} }
``` ```
## Upcoming reconciliations
The `upcoming_reconciliations` endpoint is used by [CustomersDot](https://gitlab.com/gitlab-org/customers-gitlab-com) (`customers.gitlab.com`)
to update upcoming reconciliations for namespaces.
### Updating `upcoming_reconciliations`
Use a PUT command to update `upcoming_reconciliations`.
```plaintext
PUT /internal/upcoming_reconciliations
```
| Attribute | Type | Required | Description |
|:-------------------|:-----------|:---------|:------------|
| `upcoming_reconciliations` | array | yes | Array of upcoming reconciliations |
Each array element contains:
| Attribute | Type | Required | Description |
|:-------------------|:-----------|:---------|:------------|
| `namespace_id` | integer | yes | ID of the namespace to be reconciled |
| `next_reconciliation_date` | date | yes | Date when next reconciliation will happen |
| `display_alert_from` | date | yes | Start date to display alert of upcoming reconciliation |
Example request:
```shell
curl --request PUT --header "PRIVATE-TOKEN: <admin_access_token>" --header "Content-Type: application/json" \
--data '{"upcoming_reconciliations": [{"namespace_id": 127, "next_reconciliation_date": "13 Jun 2021", "display_alert_from": "06 Jun 2021"}, {"namespace_id": 129, "next_reconciliation_date": "12 Jun 2021", "display_alert_from": "05 Jun 2021"}]}' \
"https://gitlab.com/api/v4/internal/upcoming_reconciliations"
```
Example response:
```plaintext
200
```
### Known consumers ### Known consumers
- CustomersDot - CustomersDot
...@@ -2,11 +2,17 @@ ...@@ -2,11 +2,17 @@
module GitlabSubscriptions module GitlabSubscriptions
class UpcomingReconciliation < ApplicationRecord class UpcomingReconciliation < ApplicationRecord
include BulkInsertSafe
belongs_to :namespace, inverse_of: :upcoming_reconciliation, optional: true belongs_to :namespace, inverse_of: :upcoming_reconciliation, optional: true
# Validate presence of namespace_id if this is running on a GitLab instance # Validate presence of namespace_id if this is running on a GitLab instance
# that has paid namespaces. # that has paid namespaces.
validates :namespace, uniqueness: true, presence: { if: proc { ::Gitlab::CurrentSettings.should_check_namespace_plan? } } validates :namespace, uniqueness: { unless: proc { ::Gitlab::CurrentSettings.should_check_namespace_plan? } },
presence: { if: proc { ::Gitlab::CurrentSettings.should_check_namespace_plan? } }
validates :next_reconciliation_date, :display_alert_from, presence: true
scope :by_namespace_ids, ->(namespace_ids) { where(namespace_id: namespace_ids) }
def self.next(namespace_id = nil) def self.next(namespace_id = nil)
if ::Gitlab::CurrentSettings.should_check_namespace_plan? if ::Gitlab::CurrentSettings.should_check_namespace_plan?
......
# frozen_string_literal: true
module UpcomingReconciliations
class UpdateService
def initialize(upcoming_reconciliations)
@upcoming_reconciliations = upcoming_reconciliations
@errors = []
end
def execute
bulk_upsert
result
end
private
attr_reader :upcoming_reconciliations, :errors
def bulk_upsert
GitlabSubscriptions::UpcomingReconciliation.bulk_upsert!(parse_upsert_records, unique_by: 'namespace_id')
rescue StandardError => e
errors << { 'bulk_upsert' => e.message }
Gitlab::AppLogger.error("Upcoming reconciliations bulk_upsert error: #{e.message}")
end
def parse_upsert_records
upcoming_reconciliations.map do |attributes|
parse_reconciliation(attributes)
end.compact
end
def parse_reconciliation(attributes)
attributes[:created_at] = attributes[:updated_at] = Time.zone.now
reconciliation = GitlabSubscriptions::UpcomingReconciliation.new(attributes)
if reconciliation.valid?
reconciliation
else
errors << { reconciliation.namespace_id => reconciliation.errors.full_messages }
nil
end
end
def result
errors.empty? ? ServiceResponse.success : ServiceResponse.error(message: errors.to_json)
end
end
end
# frozen_string_literal: true
module API
module Internal
class UpcomingReconciliations < ::API::Base
before do
forbidden!('This API is gitlab.com only!') unless ::Gitlab::CurrentSettings.should_check_namespace_plan?
authenticated_as_admin!
end
feature_category :purchase
namespace :internal do
resource :upcoming_reconciliations do
desc 'Update upcoming reconciliations'
params do
requires :upcoming_reconciliations, type: Array[JSON], desc: 'An array of upcoming reconciliations' do
requires :namespace_id, type: Integer, allow_blank: false
requires :next_reconciliation_date, type: Date
requires :display_alert_from, type: Date
end
end
put '/' do
service = ::UpcomingReconciliations::UpdateService.new(params['upcoming_reconciliations'])
response = service.execute
if response.success?
status 200
else
render_api_error!({ error: response.errors.first }, 400)
end
end
end
end
end
end
end
...@@ -54,6 +54,7 @@ module EE ...@@ -54,6 +54,7 @@ module EE
mount ::API::Ci::Minutes mount ::API::Ci::Minutes
mount ::API::Internal::AppSec::Dast::SiteValidations mount ::API::Internal::AppSec::Dast::SiteValidations
mount ::API::Internal::UpcomingReconciliations
end end
end end
end end
......
...@@ -11,7 +11,8 @@ RSpec.describe GitlabSubscriptions::UpcomingReconciliation do ...@@ -11,7 +11,8 @@ RSpec.describe GitlabSubscriptions::UpcomingReconciliation do
# This is needed for the validate_uniqueness_of expectation. # This is needed for the validate_uniqueness_of expectation.
let_it_be(:upcoming_reconciliation) { create(:upcoming_reconciliation, :saas) } let_it_be(:upcoming_reconciliation) { create(:upcoming_reconciliation, :saas) }
it { is_expected.to validate_uniqueness_of(:namespace) } it { is_expected.to validate_presence_of(:next_reconciliation_date) }
it { is_expected.to validate_presence_of(:display_alert_from) }
it 'does not allow multiple rows with namespace_id nil' do it 'does not allow multiple rows with namespace_id nil' do
create(:upcoming_reconciliation, :self_managed) create(:upcoming_reconciliation, :self_managed)
...@@ -28,10 +29,28 @@ RSpec.describe GitlabSubscriptions::UpcomingReconciliation do ...@@ -28,10 +29,28 @@ RSpec.describe GitlabSubscriptions::UpcomingReconciliation do
end end
it { is_expected.to validate_presence_of(:namespace) } it { is_expected.to validate_presence_of(:namespace) }
it { is_expected.not_to validate_uniqueness_of(:namespace) }
end end
context 'when namespaces are not paid (ex: self managed instance)' do context 'when namespaces are not paid (ex: self managed instance)' do
it { is_expected.not_to validate_presence_of(:namespace) } it { is_expected.not_to validate_presence_of(:namespace) }
it { is_expected.to validate_uniqueness_of(:namespace) }
end
end
describe 'scopes' do
let_it_be(:namespace1) { create(:namespace) }
let_it_be(:namespace2) { create(:namespace) }
let_it_be(:namespace3) { create(:namespace) }
let_it_be(:upcoming_reconciliation1) { create(:upcoming_reconciliation, :saas, namespace: namespace1) }
let_it_be(:upcoming_reconciliation2) { create(:upcoming_reconciliation, :saas, namespace: namespace2) }
let_it_be(:upcoming_reconciliation3) { create(:upcoming_reconciliation, :saas, namespace: namespace3) }
describe '.by_namespace_ids' do
it 'returns only upcoming reconciliations for given namespaces' do
expect(described_class.by_namespace_ids([namespace1.id, namespace3.id]))
.to contain_exactly(upcoming_reconciliation1, upcoming_reconciliation3)
end
end end
end end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe API::Internal::UpcomingReconciliations, :api do
describe 'PUT /internal/upcoming_reconciliations' do
before do
stub_application_setting(check_namespace_plan: true)
end
context 'when unauthenticated' do
it 'returns authentication error' do
put api('/internal/upcoming_reconciliations')
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
context 'when authenticated as user' do
let_it_be(:user) { create(:user) }
it 'returns authentication error' do
put api('/internal/upcoming_reconciliations', user)
expect(response).to have_gitlab_http_status(:forbidden)
end
end
context 'when authenticated as admin' do
let_it_be(:admin) { create(:admin) }
let_it_be(:namespace) { create(:namespace) }
let(:namespace_id) { namespace.id }
let(:upcoming_reconciliations) do
[{
namespace_id: namespace_id,
next_reconciliation_date: Date.today + 5.days,
display_alert_from: Date.today - 2.days
}]
end
subject(:put_upcoming_reconciliations) do
put api('/internal/upcoming_reconciliations', admin), params: { upcoming_reconciliations: upcoming_reconciliations }
end
it 'returns success' do
put_upcoming_reconciliations
expect(response).to have_gitlab_http_status(:ok)
end
context 'when namespace_id is empty' do
let(:namespace_id) { nil }
it 'returns error', :aggregate_failures do
put_upcoming_reconciliations
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response.dig('error')).to eq('upcoming_reconciliations[namespace_id] is empty')
end
end
context 'when update service failed' do
let(:error_message) { 'update_service_error' }
before do
allow_next_instance_of(::UpcomingReconciliations::UpdateService) do |service|
allow(service).to receive(:execute).and_return(ServiceResponse.error(message: error_message))
end
end
it 'returns error', :aggregate_failures do
put_upcoming_reconciliations
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response.dig('message', 'error')).to eq(error_message)
end
end
end
context 'when not gitlab.com', :aggregate_failures do
it 'returns 403 error' do
stub_application_setting(check_namespace_plan: false)
put api('/internal/upcoming_reconciliations')
expect(response).to have_gitlab_http_status(:forbidden)
expect(json_response.dig('message')).to eq('403 Forbidden - This API is gitlab.com only!')
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe UpcomingReconciliations::UpdateService do
let_it_be(:existing_upcoming_reconciliation) { create(:upcoming_reconciliation, :saas) }
let_it_be(:namespace) { create(:namespace) }
let(:record_to_create) do
{
namespace_id: namespace.id,
next_reconciliation_date: Date.today + 4.days,
display_alert_from: Date.today - 3.days
}
end
let(:record_to_update) do
{
namespace_id: existing_upcoming_reconciliation.namespace_id,
next_reconciliation_date: Date.today + 4.days,
display_alert_from: Date.today - 3.days
}
end
let(:record_invalid) do
{
namespace_id: namespace.id,
next_reconciliation_date: "invalid_date",
display_alert_from: Date.today - 3.days
}
end
before do
stub_application_setting(check_namespace_plan: true)
end
describe '#execute' do
subject(:service) { described_class.new(upcoming_reconciliations) }
shared_examples 'returns success' do
it do
result = service.execute
expect(result.status).to eq(:success)
end
end
shared_examples 'returns error' do
it 'returns error with correct error message' do
result = service.execute
errors = Gitlab::Json.parse(result.message)
expect(result.status).to eq(:error)
expect(errors).to include({ namespace_id.to_s => error })
end
end
shared_examples 'creates new upcoming reconciliation' do
it 'increases upcoming_reconciliations count' do
expect { service.execute }
.to change { GitlabSubscriptions::UpcomingReconciliation.count }.by(1)
end
it 'created upcoming reconciliation matches given hash' do
service.execute
expect_equal(GitlabSubscriptions::UpcomingReconciliation.last, record_to_create)
end
end
shared_examples 'does not increase upcoming_reconciliations count' do
it do
expect { service.execute }
.not_to change { GitlabSubscriptions::UpcomingReconciliation.count }
end
end
shared_examples 'updates existing upcoming reconciliation' do
it 'updated upcoming_reconciliation matches given hash' do
service.execute
expect_equal(
GitlabSubscriptions::UpcomingReconciliation.find_by_namespace_id(record_to_update[:namespace_id]),
record_to_update)
end
end
context 'when upcoming_reconciliation does not exist for given namespace' do
let(:upcoming_reconciliations) { [record_to_create] }
it_behaves_like 'creates new upcoming reconciliation'
it_behaves_like 'returns success'
end
context 'when upcoming_reconciliation exists for given namespace' do
let(:upcoming_reconciliations) { [record_to_update] }
context 'for gitlab.com' do
it_behaves_like 'updates existing upcoming reconciliation'
it_behaves_like 'does not increase upcoming_reconciliations count'
it_behaves_like 'returns success'
end
context 'for self managed' do
let(:record_to_update) do
{
namespace_id: nil,
next_reconciliation_date: Date.today + 4.days,
display_alert_from: Date.today - 3.days
}
end
before do
stub_application_setting(check_namespace_plan: false)
create(:upcoming_reconciliation, :self_managed)
end
it_behaves_like 'does not increase upcoming_reconciliations count'
it_behaves_like 'returns error' do
let(:namespace_id) { nil }
let(:error) { ['Namespace has already been taken'] }
end
end
end
context 'when invalid attributes' do
let(:upcoming_reconciliations) { [record_invalid] }
it_behaves_like 'returns error' do
let(:namespace_id) { record_invalid[:namespace_id] }
let(:error) { ["Next reconciliation date can't be blank"] }
end
end
context 'partial success' do
let(:upcoming_reconciliations) { [record_to_create, record_to_update, record_invalid] }
it_behaves_like 'creates new upcoming reconciliation'
it_behaves_like 'updates existing upcoming reconciliation'
it_behaves_like 'returns error' do
let(:namespace_id) { record_invalid[:namespace_id] }
let(:error) { ["Next reconciliation date can't be blank"] }
end
end
context 'when bulk upsert failed' do
let(:upcoming_reconciliations) { [record_to_create] }
let(:bulk_error) { 'bulk_upsert_error' }
before do
expect(GitlabSubscriptions::UpcomingReconciliation)
.to receive(:bulk_upsert!).and_raise(StandardError, bulk_error)
end
it 'logs bulk upsert error' do
expect(Gitlab::AppLogger).to receive(:error).with("Upcoming reconciliations bulk_upsert error: #{bulk_error}")
service.execute
end
it_behaves_like 'returns error' do
let(:error) { bulk_error }
let(:namespace_id) { 'bulk_upsert' }
end
end
def expect_equal(upcoming_reconciliation, hash)
aggregate_failures do
expect(upcoming_reconciliation.namespace_id).to eq(hash[:namespace_id])
expect(upcoming_reconciliation.next_reconciliation_date).to eq(hash[:next_reconciliation_date])
expect(upcoming_reconciliation.display_alert_from).to eq(hash[:display_alert_from])
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment