diff --git a/app/controllers/api/v1/sync_controller.rb b/app/controllers/api/v1/sync_controller.rb new file mode 100644 index 000000000..b5f89fb60 --- /dev/null +++ b/app/controllers/api/v1/sync_controller.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class Api::V1::SyncController < Api::V1::BaseController + # Ensure proper scope authorization for write access + before_action :ensure_write_scope, only: [ :create ] + + def create + family = current_resource_owner.family + + # Trigger family sync which will: + # 1. Apply all active rules + # 2. Sync all accounts + # 3. Auto-match transfers + sync = family.sync_later + + @sync = sync + render :create, status: :accepted + rescue => e + Rails.logger.error "SyncController#create error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + render json: { + error: "internal_server_error", + message: "Error: #{e.message}" + }, status: :internal_server_error + end + + private + + def ensure_write_scope + authorize_scope!(:write) + end +end diff --git a/app/views/api/v1/sync/create.json.jbuilder b/app/views/api/v1/sync/create.json.jbuilder new file mode 100644 index 000000000..df400adae --- /dev/null +++ b/app/views/api/v1/sync/create.json.jbuilder @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +json.id @sync.id +json.status @sync.status +json.syncable_type @sync.syncable_type +json.syncable_id @sync.syncable_id +json.syncing_at @sync.syncing_at +json.completed_at @sync.completed_at +json.window_start_date @sync.window_start_date +json.window_end_date @sync.window_end_date +json.message "Sync has been queued and will apply all active rules" diff --git a/config/routes.rb b/config/routes.rb index a8321482d..4a24e84a5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -269,6 +269,7 @@ Rails.application.routes.draw do resources :accounts, only: [ :index ] resources :transactions, only: [ :index, :show, :create, :update, :destroy ] resource :usage, only: [ :show ], controller: "usage" + resource :sync, only: [ :create ], controller: "sync" resources :chats, only: [ :index, :show, :create, :update, :destroy ] do resources :messages, only: [ :create ] do diff --git a/test/controllers/api/v1/sync_controller_test.rb b/test/controllers/api/v1/sync_controller_test.rb new file mode 100644 index 000000000..3a920a672 --- /dev/null +++ b/test/controllers/api/v1/sync_controller_test.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require "test_helper" + +class Api::V1::SyncControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + @family = @user.family + + # Destroy existing active API keys to avoid validation errors + @user.api_keys.active.destroy_all + + # Create fresh API keys + @api_key = ApiKey.create!( + user: @user, + name: "Test Read-Write Key", + scopes: [ "read_write" ], + display_key: "test_rw_#{SecureRandom.hex(8)}" + ) + + @read_only_api_key = ApiKey.create!( + user: @user, + name: "Test Read-Only Key", + scopes: [ "read" ], + display_key: "test_ro_#{SecureRandom.hex(8)}", + source: "mobile" + ) + + # Clear any existing rate limit data + Redis.new.del("api_rate_limit:#{@api_key.id}") + Redis.new.del("api_rate_limit:#{@read_only_api_key.id}") + end + + 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) + end + + assert_response :accepted + + response_data = JSON.parse(response.body) + assert response_data.key?("id") + assert response_data.key?("status") + assert_equal "Family", response_data["syncable_type"] + assert_equal @family.id, response_data["syncable_id"] + assert response_data.key?("message") + assert_includes response_data["message"], "rules" + end + + test "should reject sync with read-only API key" do + post api_v1_sync_url, headers: api_headers(@read_only_api_key) + assert_response :forbidden + + response_data = JSON.parse(response.body) + assert_equal "insufficient_scope", response_data["error"] + end + + test "should reject sync without API key" do + post api_v1_sync_url + assert_response :unauthorized + + response_data = JSON.parse(response.body) + assert response_data.key?("error") + end + + test "should return proper sync details in response" do + post api_v1_sync_url, headers: api_headers(@api_key) + assert_response :accepted + + response_data = JSON.parse(response.body) + + # Check all expected fields are present + assert response_data.key?("id") + assert response_data.key?("status") + assert response_data.key?("syncable_type") + assert response_data.key?("syncable_id") + assert response_data.key?("syncing_at") + assert response_data.key?("completed_at") + assert response_data.key?("window_start_date") + assert response_data.key?("window_end_date") + assert response_data.key?("message") + end + + private + + def api_headers(api_key) + { "X-Api-Key" => api_key.display_key } + end +end