Commit 217e570c authored by Qingyu Zhao's avatar Qingyu Zhao

Create API to update upcoming reconciliations

Internal API used by CustomersDot only. It accepts an array of
upcoming reconciliations. Each upcoming reconciliation contains:
`namespace_id`, `next_reconciliation_date`, `display_alert_from`.

If one namespace_id exists in upcoming_reconciliations table, the
existing record is updated. Othewise it creates a new record.

If all records are processed sucessfully, it returns 200. Othewise,
it returns 400 error with the namespace_ids failed to process.

Changelog: added
EE: true
parent 91ba88fe
......@@ -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
- CustomersDot
......@@ -2,11 +2,17 @@
module GitlabSubscriptions
class UpcomingReconciliation < ApplicationRecord
include BulkInsertSafe
belongs_to :namespace, inverse_of: :upcoming_reconciliation, optional: true
# Validate presence of namespace_id if this is running on a GitLab instance
# 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)
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
mount ::API::Ci::Minutes
mount ::API::Internal::AppSec::Dast::SiteValidations
mount ::API::Internal::UpcomingReconciliations
end
end
end
......
......@@ -11,7 +11,8 @@ RSpec.describe GitlabSubscriptions::UpcomingReconciliation do
# This is needed for the validate_uniqueness_of expectation.
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
create(:upcoming_reconciliation, :self_managed)
......@@ -28,10 +29,28 @@ RSpec.describe GitlabSubscriptions::UpcomingReconciliation do
end
it { is_expected.to validate_presence_of(:namespace) }
it { is_expected.not_to validate_uniqueness_of(:namespace) }
end
context 'when namespaces are not paid (ex: self managed instance)' do
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
......
# 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