mirror of
https://github.com/we-promise/sure.git
synced 2026-05-11 14:45:01 +00:00
feat(api): expose sync status (#1635)
* feat(api): expose sync status * fix(api): harden sync status review paths * fix(api): address sync status review * fix(api): tighten sync status review fixes * fix(api): address sync status review * test(api): avoid secret-like sync fixture key * test(api): reuse sync status fixture key * fix(api): align sync route helpers * fix(api): tighten sync status scoping * fix(api): make sync status schema nullable-compliant
This commit is contained in:
46
app/controllers/api/v1/syncs_controller.rb
Normal file
46
app/controllers/api/v1/syncs_controller.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::SyncsController < Api::V1::BaseController
|
||||
include Pagy::Backend
|
||||
|
||||
before_action :ensure_read_scope
|
||||
before_action :set_sync, only: [ :show ]
|
||||
|
||||
def index
|
||||
@per_page = safe_per_page_param
|
||||
@pagy, @syncs = pagy(
|
||||
family_syncs_query.preload(:syncable, :children).ordered,
|
||||
page: safe_page_param,
|
||||
limit: @per_page
|
||||
)
|
||||
|
||||
render :index
|
||||
end
|
||||
|
||||
def latest
|
||||
@sync = family_syncs_query.preload(:syncable, :children).ordered.first
|
||||
return render json: { data: nil } unless @sync
|
||||
|
||||
render :show
|
||||
end
|
||||
|
||||
def show
|
||||
render :show
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_sync
|
||||
raise ActiveRecord::RecordNotFound unless valid_uuid?(params[:id])
|
||||
|
||||
@sync = family_syncs_query.preload(:syncable, :children).find(params[:id])
|
||||
end
|
||||
|
||||
def ensure_read_scope
|
||||
authorize_scope!(:read)
|
||||
end
|
||||
|
||||
def family_syncs_query
|
||||
Sync.for_family(Current.family, resource_owner: Current.user)
|
||||
end
|
||||
end
|
||||
@@ -15,7 +15,7 @@ class Sync < ApplicationRecord
|
||||
belongs_to :parent, class_name: "Sync", optional: true
|
||||
has_many :children, class_name: "Sync", foreign_key: :parent_id, dependent: :destroy
|
||||
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
scope :ordered, -> { order(created_at: :desc, id: :desc) }
|
||||
scope :incomplete, -> { where("syncs.status IN (?)", %w[pending syncing]) }
|
||||
scope :visible, -> { incomplete.where("syncs.created_at > ?", VISIBLE_FOR.ago) }
|
||||
|
||||
@@ -57,6 +57,52 @@ class Sync < ApplicationRecord
|
||||
def clean
|
||||
incomplete.where("syncs.created_at < ?", STALE_AFTER.ago).find_each(&:mark_stale!)
|
||||
end
|
||||
|
||||
def for_family(family, resource_owner: nil)
|
||||
query = where(syncable_type: "Family", syncable_id: family.id)
|
||||
query = query.or(where(syncable_type: "Account", syncable_id: account_syncable_ids(family, resource_owner)))
|
||||
|
||||
family_syncable_associations.each do |association|
|
||||
query = query.or(
|
||||
where(syncable_type: association.klass.name, syncable_id: family.public_send(association.name).select(:id))
|
||||
)
|
||||
end
|
||||
|
||||
query
|
||||
end
|
||||
|
||||
private
|
||||
def account_syncable_ids(family, resource_owner)
|
||||
(resource_owner ? resource_owner.accessible_accounts : family.accounts)
|
||||
.where(family_id: family.id)
|
||||
.select(:id)
|
||||
end
|
||||
|
||||
def family_syncable_associations
|
||||
Family.reflect_on_all_associations(:has_many).select do |association|
|
||||
association.name.to_s.end_with?("_items") &&
|
||||
association.klass.included_modules.include?(Syncable)
|
||||
rescue NameError
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def in_progress?
|
||||
pending? || syncing?
|
||||
end
|
||||
|
||||
def terminal?
|
||||
completed? || failed? || stale?
|
||||
end
|
||||
|
||||
def api_error_payload
|
||||
return unless failed? || stale?
|
||||
return if stale? && error.blank?
|
||||
|
||||
{
|
||||
message: stale? ? "Sync became stale before completion" : "Sync failed"
|
||||
}
|
||||
end
|
||||
|
||||
def perform
|
||||
|
||||
24
app/views/api/v1/syncs/_sync.json.jbuilder
Normal file
24
app/views/api/v1/syncs/_sync.json.jbuilder
Normal file
@@ -0,0 +1,24 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
syncable = sync.syncable
|
||||
|
||||
json.id sync.id
|
||||
json.status sync.status
|
||||
json.in_progress sync.in_progress?
|
||||
json.terminal sync.terminal?
|
||||
json.syncable do
|
||||
json.type sync.syncable_type
|
||||
json.id sync.syncable_id
|
||||
json.name syncable&.try(:name)
|
||||
end
|
||||
json.parent_id sync.parent_id
|
||||
json.children_count sync.children.size
|
||||
json.window_start_date sync.window_start_date
|
||||
json.window_end_date sync.window_end_date
|
||||
json.pending_at sync.pending_at
|
||||
json.syncing_at sync.syncing_at
|
||||
json.completed_at sync.completed_at
|
||||
json.failed_at sync.failed_at
|
||||
json.error sync.api_error_payload
|
||||
json.created_at sync.created_at
|
||||
json.updated_at sync.updated_at
|
||||
12
app/views/api/v1/syncs/index.json.jbuilder
Normal file
12
app/views/api/v1/syncs/index.json.jbuilder
Normal file
@@ -0,0 +1,12 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.data do
|
||||
json.array! @syncs, partial: "api/v1/syncs/sync", as: :sync
|
||||
end
|
||||
|
||||
json.meta do
|
||||
json.page @pagy.page
|
||||
json.per_page @per_page
|
||||
json.total_count @pagy.count
|
||||
json.total_pages @pagy.pages
|
||||
end
|
||||
9
app/views/api/v1/syncs/show.json.jbuilder
Normal file
9
app/views/api/v1/syncs/show.json.jbuilder
Normal file
@@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.data do
|
||||
if @sync
|
||||
json.partial! "api/v1/syncs/sync", sync: @sync
|
||||
else
|
||||
json.nil!
|
||||
end
|
||||
end
|
||||
@@ -450,7 +450,10 @@ Rails.application.routes.draw do
|
||||
resource :usage, only: [ :show ], controller: :usage
|
||||
resource :balance_sheet, only: [ :show ], controller: :balance_sheet
|
||||
resource :family_settings, only: [ :show ], controller: :family_settings
|
||||
post :sync, to: "sync#create"
|
||||
post :sync, to: "sync#create", as: :sync_job
|
||||
resources :syncs, only: [ :index, :show ] do
|
||||
get :latest, on: :collection
|
||||
end
|
||||
|
||||
resources :chats, only: [ :index, :show, :create, :update, :destroy ] do
|
||||
resources :messages, only: [ :create ] do
|
||||
|
||||
@@ -1887,6 +1887,119 @@ components:
|
||||
per_page:
|
||||
type: integer
|
||||
minimum: 1
|
||||
SyncableSummary:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- id
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
name:
|
||||
type: string
|
||||
nullable: true
|
||||
SyncErrorSummary:
|
||||
type: object
|
||||
required:
|
||||
- message
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
SyncResource:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- status
|
||||
- in_progress
|
||||
- terminal
|
||||
- syncable
|
||||
- children_count
|
||||
- created_at
|
||||
- updated_at
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- pending
|
||||
- syncing
|
||||
- completed
|
||||
- failed
|
||||
- stale
|
||||
in_progress:
|
||||
type: boolean
|
||||
terminal:
|
||||
type: boolean
|
||||
syncable:
|
||||
"$ref": "#/components/schemas/SyncableSummary"
|
||||
parent_id:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
children_count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
window_start_date:
|
||||
type: string
|
||||
format: date
|
||||
nullable: true
|
||||
window_end_date:
|
||||
type: string
|
||||
format: date
|
||||
nullable: true
|
||||
pending_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
syncing_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
completed_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
failed_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
error:
|
||||
nullable: true
|
||||
allOf:
|
||||
- "$ref": "#/components/schemas/SyncErrorSummary"
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
SyncResponse:
|
||||
type: object
|
||||
required:
|
||||
- data
|
||||
properties:
|
||||
data:
|
||||
nullable: true
|
||||
allOf:
|
||||
- "$ref": "#/components/schemas/SyncResource"
|
||||
SyncCollection:
|
||||
type: object
|
||||
required:
|
||||
- data
|
||||
- meta
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
maxItems: 100
|
||||
items:
|
||||
"$ref": "#/components/schemas/SyncResource"
|
||||
meta:
|
||||
"$ref": "#/components/schemas/Pagination"
|
||||
Trade:
|
||||
type: object
|
||||
required:
|
||||
@@ -5004,6 +5117,115 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
"/api/v1/syncs":
|
||||
get:
|
||||
summary: Lists sync history
|
||||
description: List sanitized sync status history for the authenticated user's
|
||||
family, accounts, and provider connections.
|
||||
tags:
|
||||
- Syncs
|
||||
security:
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
required: false
|
||||
description: 'Page number (default: 1)'
|
||||
schema:
|
||||
type: integer
|
||||
- name: per_page
|
||||
in: query
|
||||
required: false
|
||||
description: 'Items per page (default: 25, max: 100)'
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: syncs listed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/SyncCollection"
|
||||
'401':
|
||||
description: unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
'403':
|
||||
description: forbidden
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
"/api/v1/syncs/latest":
|
||||
get:
|
||||
summary: Shows the latest sync
|
||||
description: 'Return the most recently created sanitized sync status for the
|
||||
authenticated user''s family, or data: null when no sync exists.'
|
||||
tags:
|
||||
- Syncs
|
||||
security:
|
||||
- apiKeyAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: latest sync shown
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/SyncResponse"
|
||||
'401':
|
||||
description: unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
'403':
|
||||
description: forbidden
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
"/api/v1/syncs/{id}":
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
format: uuid
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
get:
|
||||
summary: Shows a sync
|
||||
description: Return sanitized status metadata for a single family-scoped sync.
|
||||
tags:
|
||||
- Syncs
|
||||
security:
|
||||
- apiKeyAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: sync shown
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/SyncResponse"
|
||||
'401':
|
||||
description: unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
'403':
|
||||
description: forbidden
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
'404':
|
||||
description: not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
"/api/v1/tags":
|
||||
get:
|
||||
summary: List tags
|
||||
|
||||
141
spec/requests/api/v1/syncs_spec.rb
Normal file
141
spec/requests/api/v1/syncs_spec.rb
Normal file
@@ -0,0 +1,141 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "swagger_helper"
|
||||
|
||||
RSpec.describe "Api::V1::Syncs", type: :request do
|
||||
let(:family) do
|
||||
Family.create!(
|
||||
name: "API Family",
|
||||
currency: "USD",
|
||||
locale: "en",
|
||||
date_format: "%m-%d-%Y"
|
||||
)
|
||||
end
|
||||
|
||||
let(:user) do
|
||||
family.users.create!(
|
||||
email: "sync-api-user@example.com",
|
||||
password: "password123",
|
||||
password_confirmation: "password123"
|
||||
)
|
||||
end
|
||||
|
||||
let(:api_key) do
|
||||
key = ApiKey.generate_secure_key
|
||||
ApiKey.create!(
|
||||
user: user,
|
||||
name: "API Docs Key",
|
||||
key: key,
|
||||
display_key: key,
|
||||
scopes: %w[read_write],
|
||||
source: "web"
|
||||
)
|
||||
end
|
||||
let(:api_key_without_read_scope) do
|
||||
key = ApiKey.generate_secure_key
|
||||
ApiKey.new(
|
||||
user: user,
|
||||
name: "No Read Docs Key",
|
||||
key: key,
|
||||
display_key: key,
|
||||
scopes: %w[write],
|
||||
source: "web"
|
||||
).tap { |api_key| api_key.save!(validate: false) }
|
||||
end
|
||||
let(:'X-Api-Key') { api_key.plain_key }
|
||||
let(:sync) { Sync.create!(syncable: family, status: "completed", completed_at: 1.minute.ago) }
|
||||
let(:id) { sync.id }
|
||||
|
||||
path "/api/v1/syncs" do
|
||||
get "Lists sync history" do
|
||||
description "List sanitized sync status history for the authenticated user's family, accounts, and provider connections."
|
||||
tags "Syncs"
|
||||
security [ { apiKeyAuth: [] } ]
|
||||
produces "application/json"
|
||||
parameter name: :page, in: :query, type: :integer, required: false, description: "Page number (default: 1)"
|
||||
parameter name: :per_page, in: :query, type: :integer, required: false, description: "Items per page (default: 25, max: 100)"
|
||||
|
||||
response "200", "syncs listed" do
|
||||
schema "$ref" => "#/components/schemas/SyncCollection"
|
||||
before { sync }
|
||||
run_test!
|
||||
end
|
||||
|
||||
response "401", "unauthorized" do
|
||||
let(:'X-Api-Key') { nil }
|
||||
schema "$ref" => "#/components/schemas/ErrorResponse"
|
||||
run_test!
|
||||
end
|
||||
|
||||
response "403", "forbidden" do
|
||||
let(:'X-Api-Key') { api_key_without_read_scope.plain_key }
|
||||
|
||||
schema "$ref" => "#/components/schemas/ErrorResponse"
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
path "/api/v1/syncs/latest" do
|
||||
get "Shows the latest sync" do
|
||||
description "Return the most recently created sanitized sync status for the authenticated user's family, or data: null when no sync exists."
|
||||
tags "Syncs"
|
||||
security [ { apiKeyAuth: [] } ]
|
||||
produces "application/json"
|
||||
|
||||
response "200", "latest sync shown" do
|
||||
schema "$ref" => "#/components/schemas/SyncResponse"
|
||||
before { sync }
|
||||
run_test!
|
||||
end
|
||||
|
||||
response "401", "unauthorized" do
|
||||
let(:'X-Api-Key') { nil }
|
||||
schema "$ref" => "#/components/schemas/ErrorResponse"
|
||||
run_test!
|
||||
end
|
||||
|
||||
response "403", "forbidden" do
|
||||
let(:'X-Api-Key') { api_key_without_read_scope.plain_key }
|
||||
|
||||
schema "$ref" => "#/components/schemas/ErrorResponse"
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
path "/api/v1/syncs/{id}" do
|
||||
parameter name: :id, in: :path, type: :string, format: :uuid, required: true
|
||||
|
||||
get "Shows a sync" do
|
||||
description "Return sanitized status metadata for a single family-scoped sync."
|
||||
tags "Syncs"
|
||||
security [ { apiKeyAuth: [] } ]
|
||||
produces "application/json"
|
||||
|
||||
response "200", "sync shown" do
|
||||
schema "$ref" => "#/components/schemas/SyncResponse"
|
||||
run_test!
|
||||
end
|
||||
|
||||
response "401", "unauthorized" do
|
||||
let(:'X-Api-Key') { nil }
|
||||
schema "$ref" => "#/components/schemas/ErrorResponse"
|
||||
run_test!
|
||||
end
|
||||
|
||||
response "403", "forbidden" do
|
||||
let(:'X-Api-Key') { api_key_without_read_scope.plain_key }
|
||||
|
||||
schema "$ref" => "#/components/schemas/ErrorResponse"
|
||||
run_test!
|
||||
end
|
||||
|
||||
response "404", "not found" do
|
||||
let(:id) { SecureRandom.uuid }
|
||||
schema "$ref" => "#/components/schemas/ErrorResponse"
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1027,6 +1027,63 @@ RSpec.configure do |config|
|
||||
}
|
||||
}
|
||||
},
|
||||
SyncableSummary: {
|
||||
type: :object,
|
||||
required: %w[type id],
|
||||
properties: {
|
||||
type: { type: :string },
|
||||
id: { type: :string, format: :uuid },
|
||||
name: { type: :string, nullable: true }
|
||||
}
|
||||
},
|
||||
SyncErrorSummary: {
|
||||
type: :object,
|
||||
required: %w[message],
|
||||
properties: {
|
||||
message: { type: :string }
|
||||
}
|
||||
},
|
||||
SyncResource: {
|
||||
type: :object,
|
||||
required: %w[id status in_progress terminal syncable children_count created_at updated_at],
|
||||
properties: {
|
||||
id: { type: :string, format: :uuid },
|
||||
status: { type: :string, enum: %w[pending syncing completed failed stale] },
|
||||
in_progress: { type: :boolean },
|
||||
terminal: { type: :boolean },
|
||||
syncable: { '$ref' => '#/components/schemas/SyncableSummary' },
|
||||
parent_id: { type: :string, format: :uuid, nullable: true },
|
||||
children_count: { type: :integer, minimum: 0 },
|
||||
window_start_date: { type: :string, format: :date, nullable: true },
|
||||
window_end_date: { type: :string, format: :date, nullable: true },
|
||||
pending_at: { type: :string, format: :'date-time', nullable: true },
|
||||
syncing_at: { type: :string, format: :'date-time', nullable: true },
|
||||
completed_at: { type: :string, format: :'date-time', nullable: true },
|
||||
failed_at: { type: :string, format: :'date-time', nullable: true },
|
||||
error: { nullable: true, allOf: [ { '$ref' => '#/components/schemas/SyncErrorSummary' } ] },
|
||||
created_at: { type: :string, format: :'date-time' },
|
||||
updated_at: { type: :string, format: :'date-time' }
|
||||
}
|
||||
},
|
||||
SyncResponse: {
|
||||
type: :object,
|
||||
required: %w[data],
|
||||
properties: {
|
||||
data: { nullable: true, allOf: [ { '$ref' => '#/components/schemas/SyncResource' } ] }
|
||||
}
|
||||
},
|
||||
SyncCollection: {
|
||||
type: :object,
|
||||
required: %w[data meta],
|
||||
properties: {
|
||||
data: {
|
||||
type: :array,
|
||||
maxItems: 100,
|
||||
items: { '$ref' => '#/components/schemas/SyncResource' }
|
||||
},
|
||||
meta: { '$ref' => '#/components/schemas/Pagination' }
|
||||
}
|
||||
},
|
||||
Trade: {
|
||||
type: :object,
|
||||
required: %w[id date amount currency name qty price account created_at updated_at],
|
||||
|
||||
@@ -33,7 +33,7 @@ class Api::V1::SyncControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
test "should trigger sync with valid write API key" do
|
||||
assert_enqueued_with(job: SyncJob) do
|
||||
post api_v1_sync_url, headers: api_headers(@api_key)
|
||||
post api_v1_sync_job_url, headers: api_headers(@api_key)
|
||||
end
|
||||
|
||||
assert_response :accepted
|
||||
@@ -48,7 +48,7 @@ class Api::V1::SyncControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "should reject sync with read-only API key" do
|
||||
post api_v1_sync_url, headers: api_headers(@read_only_api_key)
|
||||
post api_v1_sync_job_url, headers: api_headers(@read_only_api_key)
|
||||
assert_response :forbidden
|
||||
|
||||
response_data = JSON.parse(response.body)
|
||||
@@ -56,7 +56,7 @@ class Api::V1::SyncControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "should reject sync without API key" do
|
||||
post api_v1_sync_url
|
||||
post api_v1_sync_job_url
|
||||
assert_response :unauthorized
|
||||
|
||||
response_data = JSON.parse(response.body)
|
||||
@@ -64,7 +64,7 @@ class Api::V1::SyncControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "should return proper sync details in response" do
|
||||
post api_v1_sync_url, headers: api_headers(@api_key)
|
||||
post api_v1_sync_job_url, headers: api_headers(@api_key)
|
||||
assert_response :accepted
|
||||
|
||||
response_data = JSON.parse(response.body)
|
||||
|
||||
211
test/controllers/api/v1/syncs_controller_test.rb
Normal file
211
test/controllers/api/v1/syncs_controller_test.rb
Normal file
@@ -0,0 +1,211 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class Api::V1::SyncsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@user = users(:family_admin)
|
||||
@family = @user.family
|
||||
@account = @family.accounts.first
|
||||
|
||||
Sync.for_family(@family).destroy_all
|
||||
|
||||
@user.api_keys.active.destroy_all
|
||||
|
||||
@api_key = ApiKey.create!(
|
||||
user: @user,
|
||||
name: "Test Read-Write Key",
|
||||
scopes: [ "read_write" ],
|
||||
display_key: "test_rw_#{SecureRandom.hex(8)}",
|
||||
source: "web"
|
||||
)
|
||||
|
||||
@read_only_api_key = ApiKey.create!(
|
||||
user: @user,
|
||||
name: "Test Read Key",
|
||||
scopes: [ "read" ],
|
||||
display_key: "test_ro_#{SecureRandom.hex(8)}",
|
||||
source: "mobile"
|
||||
)
|
||||
|
||||
redis = Redis.new
|
||||
redis.del("api_rate_limit:#{@api_key.id}")
|
||||
redis.del("api_rate_limit:#{@read_only_api_key.id}")
|
||||
redis.close
|
||||
end
|
||||
|
||||
test "lists family scoped syncs" do
|
||||
family_sync = Sync.create!(syncable: @family, status: "completed", completed_at: 1.hour.ago)
|
||||
account_sync = Sync.create!(syncable: @account, status: "syncing", syncing_at: Time.current)
|
||||
other_sync = Sync.create!(syncable: families(:empty), status: "completed", completed_at: 1.hour.ago)
|
||||
|
||||
get api_v1_syncs_url, headers: api_headers(@read_only_api_key)
|
||||
assert_response :success
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
sync_ids = json_response["data"].map { |sync| sync["id"] }
|
||||
|
||||
assert_includes sync_ids, family_sync.id
|
||||
assert_includes sync_ids, account_sync.id
|
||||
assert_not_includes sync_ids, other_sync.id
|
||||
assert_equal 2, json_response["meta"]["total_count"]
|
||||
end
|
||||
|
||||
test "does not list account syncs outside caller account access" do
|
||||
private_account = @family.accounts.create!(
|
||||
owner: @user,
|
||||
name: "Private Sync Account",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
accountable: Depository.new
|
||||
)
|
||||
inaccessible_sync = Sync.create!(syncable: private_account, status: "completed", completed_at: 1.hour.ago)
|
||||
|
||||
@read_only_api_key.update_column(:user_id, users(:family_member).id)
|
||||
|
||||
get api_v1_syncs_url, headers: api_headers(@read_only_api_key)
|
||||
assert_response :success
|
||||
|
||||
sync_ids = JSON.parse(response.body)["data"].map { |sync| sync["id"] }
|
||||
assert_not_includes sync_ids, inaccessible_sync.id
|
||||
end
|
||||
|
||||
test "shows a sync" do
|
||||
sync = Sync.create!(
|
||||
syncable: @family,
|
||||
status: "completed",
|
||||
completed_at: 1.hour.ago,
|
||||
window_start_date: Date.current - 7.days,
|
||||
window_end_date: Date.current
|
||||
)
|
||||
|
||||
get api_v1_sync_url(sync), headers: api_headers(@read_only_api_key)
|
||||
assert_response :success
|
||||
|
||||
data = JSON.parse(response.body)["data"]
|
||||
assert_equal sync.id, data["id"]
|
||||
assert_equal "completed", data["status"]
|
||||
assert_equal false, data["in_progress"]
|
||||
assert_equal true, data["terminal"]
|
||||
assert_equal "Family", data["syncable"]["type"]
|
||||
assert_equal @family.id, data["syncable"]["id"]
|
||||
assert_nil data["error"]
|
||||
end
|
||||
|
||||
test "returns latest sync" do
|
||||
Sync.create!(syncable: @family, status: "completed", created_at: 2.hours.ago, completed_at: 2.hours.ago)
|
||||
latest_sync = Sync.create!(syncable: @account, status: "pending", created_at: 1.minute.ago)
|
||||
|
||||
get latest_api_v1_syncs_url, headers: api_headers(@read_only_api_key)
|
||||
assert_response :success
|
||||
|
||||
assert_equal latest_sync.id, JSON.parse(response.body)["data"]["id"]
|
||||
end
|
||||
|
||||
test "latest returns null data when no sync exists" do
|
||||
get latest_api_v1_syncs_url, headers: api_headers(@read_only_api_key)
|
||||
assert_response :success
|
||||
|
||||
assert_nil JSON.parse(response.body)["data"]
|
||||
end
|
||||
|
||||
test "does not expose raw sync errors" do
|
||||
sync = Sync.create!(
|
||||
syncable: @family,
|
||||
status: "failed",
|
||||
failed_at: Time.current,
|
||||
error: "provider token secret leaked"
|
||||
)
|
||||
|
||||
get api_v1_sync_url(sync), headers: api_headers(@read_only_api_key)
|
||||
assert_response :success
|
||||
|
||||
data = JSON.parse(response.body)["data"]
|
||||
assert data["error"].present?
|
||||
assert_equal "Sync failed", data["error"]["message"]
|
||||
refute_includes response.body, "provider token secret leaked"
|
||||
end
|
||||
|
||||
test "reports failed sync errors as present without raw error text" do
|
||||
sync = Sync.create!(
|
||||
syncable: @family,
|
||||
status: "failed",
|
||||
failed_at: Time.current,
|
||||
error: nil
|
||||
)
|
||||
|
||||
get api_v1_sync_url(sync), headers: api_headers(@read_only_api_key)
|
||||
assert_response :success
|
||||
|
||||
assert JSON.parse(response.body).dig("data", "error").present?
|
||||
assert_equal "Sync failed", JSON.parse(response.body).dig("data", "error", "message")
|
||||
end
|
||||
|
||||
test "omits stale sync error payload when no error is present" do
|
||||
sync = Sync.create!(
|
||||
syncable: @family,
|
||||
status: "stale"
|
||||
)
|
||||
|
||||
get api_v1_sync_url(sync), headers: api_headers(@read_only_api_key)
|
||||
assert_response :success
|
||||
|
||||
assert_nil JSON.parse(response.body).dig("data", "error")
|
||||
end
|
||||
|
||||
test "returns not found for another family sync" do
|
||||
sync = Sync.create!(syncable: families(:empty), status: "completed")
|
||||
|
||||
get api_v1_sync_url(sync), headers: api_headers(@read_only_api_key)
|
||||
assert_response :not_found
|
||||
|
||||
assert_equal "record_not_found", JSON.parse(response.body)["error"]
|
||||
end
|
||||
|
||||
test "returns not found for malformed sync id" do
|
||||
get api_v1_sync_url("not-a-uuid"), headers: api_headers(@read_only_api_key)
|
||||
assert_response :not_found
|
||||
|
||||
assert_equal "record_not_found", JSON.parse(response.body)["error"]
|
||||
end
|
||||
|
||||
test "index requires authentication" do
|
||||
get api_v1_syncs_url
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
test "latest requires authentication" do
|
||||
get latest_api_v1_syncs_url
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
test "show requires authentication" do
|
||||
sync = Sync.create!(syncable: @family, status: "completed", completed_at: 1.hour.ago)
|
||||
|
||||
get api_v1_sync_url(sync)
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
test "index requires read scope" do
|
||||
api_key_without_read = ApiKey.new(
|
||||
user: @user,
|
||||
name: "No Read Key",
|
||||
scopes: [],
|
||||
source: "monitoring",
|
||||
display_key: "no_read_#{SecureRandom.hex(8)}"
|
||||
)
|
||||
api_key_without_read.save!(validate: false)
|
||||
|
||||
get api_v1_syncs_url, headers: api_headers(api_key_without_read)
|
||||
|
||||
assert_response :forbidden
|
||||
ensure
|
||||
api_key_without_read&.destroy
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def api_headers(api_key)
|
||||
{ "X-Api-Key" => api_key.plain_key }
|
||||
end
|
||||
end
|
||||
@@ -200,6 +200,46 @@ class SyncTest < ActiveSupport::TestCase
|
||||
assert_equal "stale", stale_syncing.reload.status
|
||||
end
|
||||
|
||||
test "ordered uses id as deterministic tie breaker" do
|
||||
timestamp = Time.current.change(usec: 0)
|
||||
older_id = SecureRandom.uuid
|
||||
newer_id = SecureRandom.uuid
|
||||
older_id, newer_id = [ older_id, newer_id ].sort
|
||||
|
||||
older_sync = Sync.create!(id: older_id, syncable: accounts(:depository), status: :completed, created_at: timestamp)
|
||||
newer_sync = Sync.create!(id: newer_id, syncable: accounts(:connected), status: :completed, created_at: timestamp)
|
||||
|
||||
ordered_ids = Sync.where(id: [ older_sync.id, newer_sync.id ]).ordered.pluck(:id)
|
||||
|
||||
assert_equal [ newer_sync.id, older_sync.id ], ordered_ids
|
||||
end
|
||||
|
||||
test "for_family includes syncable provider item associations from family reflections" do
|
||||
family = families(:dylan_family)
|
||||
syncable_item_associations = Family.reflect_on_all_associations(:has_many).select do |association|
|
||||
association.name.to_s.end_with?("_items") &&
|
||||
association.klass.included_modules.include?(Syncable)
|
||||
rescue NameError
|
||||
false
|
||||
end
|
||||
|
||||
syncs = syncable_item_associations.filter_map do |association|
|
||||
syncable = family.public_send(association.name).first
|
||||
next unless syncable
|
||||
|
||||
Sync.create!(syncable: syncable, status: :completed)
|
||||
end
|
||||
|
||||
assert syncs.any?, "Expected syncable provider item fixtures for this family"
|
||||
assert_equal syncs.map(&:id).sort, Sync.for_family(family).where(id: syncs.map(&:id)).pluck(:id).sort
|
||||
end
|
||||
|
||||
test "api error payload is present for failed syncs without raw error text" do
|
||||
sync = Sync.create!(syncable: accounts(:depository), status: :failed)
|
||||
|
||||
assert_equal({ message: "Sync failed" }, sync.api_error_payload)
|
||||
end
|
||||
|
||||
test "expand_window_if_needed widens start and end dates on a pending sync" do
|
||||
initial_start = 1.day.ago.to_date
|
||||
initial_end = 1.day.ago.to_date
|
||||
|
||||
Reference in New Issue
Block a user