diff --git a/app/controllers/api/v1/merchants_controller.rb b/app/controllers/api/v1/merchants_controller.rb index da6aa0ed3..ba1ddc72e 100644 --- a/app/controllers/api/v1/merchants_controller.rb +++ b/app/controllers/api/v1/merchants_controller.rb @@ -2,28 +2,14 @@ module Api module V1 - # API v1 endpoint for merchants - # Provides read-only access to family and provider merchants - # - # @example List all merchants - # GET /api/v1/merchants - # - # @example Get a specific merchant - # GET /api/v1/merchants/:id - # class MerchantsController < BaseController - before_action -> { authorize_scope!(:read) } + before_action -> { authorize_scope!(:read) }, only: %i[index show] + before_action -> { authorize_scope!(:read_write) }, only: %i[create update destroy] + before_action :set_merchant, only: %i[show update destroy] - # List all merchants available to the family - # - # Returns both family-owned merchants and provider merchants - # that are assigned to the family's transactions. - # - # @return [Array] JSON array of merchant objects def index family = current_resource_owner.family - # Single query with OR conditions - more efficient than Ruby deduplication family_merchant_ids = family.merchants.select(:id) provider_merchant_ids = family.transactions.select(:merchant_id) @@ -34,50 +20,59 @@ module Api .alphabetically render json: @merchants.map { |m| merchant_json(m) } - rescue StandardError => e - Rails.logger.error("API Merchants Error: #{e.message}") - render json: { error: "Failed to fetch merchants" }, status: :internal_server_error end - # Get a specific merchant by ID - # - # Returns a merchant if it belongs to the family or is assigned - # to any of the family's transactions. - # - # @param id [String] The merchant ID - # @return [Hash] JSON merchant object or error def show + render json: merchant_json(@merchant) + end + + def create family = current_resource_owner.family + @merchant = family.merchants.new(merchant_params) - @merchant = family.merchants.find_by(id: params[:id]) || - Merchant.joins(transactions: :entry) - .where(entries: { account_id: family.accounts.select(:id) }) - .distinct - .find_by(id: params[:id]) + if @merchant.save + render json: merchant_json(@merchant), status: :created + else + render json: { error: @merchant.errors.full_messages.join(", ") }, status: :unprocessable_entity + end + end - if @merchant + def update + if @merchant.update(merchant_params) render json: merchant_json(@merchant) else - render json: { error: "Merchant not found" }, status: :not_found + render json: { error: @merchant.errors.full_messages.join(", ") }, status: :unprocessable_entity end - rescue StandardError => e - Rails.logger.error("API Merchant Show Error: #{e.message}") - render json: { error: "Failed to fetch merchant" }, status: :internal_server_error + end + + def destroy + @merchant.destroy! + head :no_content end private - # Serialize a merchant to JSON format - # - # @param merchant [Merchant] The merchant to serialize - # @return [Hash] JSON-serializable hash + def set_merchant + family = current_resource_owner.family + @merchant = family.merchants.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render json: { error: "Merchant not found" }, status: :not_found + end + + def merchant_params + params.require(:merchant).permit(:name, :color, :website_url) + end + def merchant_json(merchant) { id: merchant.id, name: merchant.name, type: merchant.type, - created_at: merchant.created_at, - updated_at: merchant.updated_at + color: merchant.color, + logo_url: merchant.logo_url, + website_url: merchant.website_url, + created_at: merchant.created_at.iso8601, + updated_at: merchant.updated_at.iso8601 } end end diff --git a/app/models/family_merchant.rb b/app/models/family_merchant.rb index 3baec76bb..3dfeaf345 100644 --- a/app/models/family_merchant.rb +++ b/app/models/family_merchant.rb @@ -11,7 +11,7 @@ class FamilyMerchant < Merchant private def set_default_color - self.color = COLORS.sample + self.color ||= COLORS.sample end def should_generate_logo? diff --git a/config/routes.rb b/config/routes.rb index eb1055ba6..014aefd4f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -376,7 +376,7 @@ Rails.application.routes.draw do # Production API endpoints resources :accounts, only: [ :index, :show ] resources :categories, only: [ :index, :show ] - resources :merchants, only: %i[index show] + resources :merchants, only: %i[index show create update destroy] resources :tags, only: %i[index show create update destroy] resources :transactions, only: [ :index, :show, :create, :update, :destroy ] diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9417f1e97..62a89274f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -357,6 +357,44 @@ components: format: uuid name: type: string + MerchantDetail: + type: object + required: + - id + - name + - type + - created_at + - updated_at + properties: + id: + type: string + format: uuid + name: + type: string + type: + type: string + enum: + - FamilyMerchant + - ProviderMerchant + color: + type: string + nullable: true + logo_url: + type: string + nullable: true + website_url: + type: string + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + MerchantCollection: + type: array + items: + "$ref": "#/components/schemas/MerchantDetail" Tag: type: object required: @@ -798,10 +836,10 @@ components: format: date qty: type: string - description: Quantity as string (JSON number or string from API) + description: Quantity of shares held price: type: string - description: Price as string (JSON number or string from API) + description: Formatted price per share amount: type: string currency: @@ -875,6 +913,302 @@ paths: application/json: schema: "$ref": "#/components/schemas/AccountCollection" + "/api/v1/auth/signup": + post: + summary: Sign up a new user + tags: + - Auth + parameters: [] + responses: + '201': + description: user created + content: + application/json: + schema: + type: object + properties: + access_token: + type: string + refresh_token: + type: string + token_type: + type: string + expires_in: + type: integer + created_at: + type: integer + user: + type: object + properties: + id: + type: string + format: uuid + email: + type: string + first_name: + type: string + last_name: + type: string + '422': + description: validation error + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: invite code required or invalid + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + user: + type: object + properties: + email: + type: string + format: email + description: User email address + password: + type: string + description: Password (min 8 chars, mixed case, number, special + char) + first_name: + type: string + last_name: + type: string + required: + - email + - password + device: + type: object + properties: + device_id: + type: string + description: Unique device identifier + device_name: + type: string + description: Human-readable device name + device_type: + type: string + description: Device type (e.g. ios, android) + os_version: + type: string + app_version: + type: string + required: + - device_id + - device_name + - device_type + - os_version + - app_version + invite_code: + type: string + nullable: true + description: Invite code (required when invites are enforced) + required: + - user + - device + required: true + "/api/v1/auth/login": + post: + summary: Log in with email and password + tags: + - Auth + parameters: [] + responses: + '200': + description: login successful + content: + application/json: + schema: + type: object + properties: + access_token: + type: string + refresh_token: + type: string + token_type: + type: string + expires_in: + type: integer + created_at: + type: integer + user: + type: object + properties: + id: + type: string + format: uuid + email: + type: string + first_name: + type: string + last_name: + type: string + '401': + description: invalid credentials or MFA required + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + email: + type: string + format: email + password: + type: string + otp_code: + type: string + nullable: true + description: TOTP code if MFA is enabled + device: + type: object + properties: + device_id: + type: string + device_name: + type: string + device_type: + type: string + os_version: + type: string + app_version: + type: string + required: + - device_id + - device_name + - device_type + - os_version + - app_version + required: + - email + - password + - device + required: true + "/api/v1/auth/sso_exchange": + post: + summary: Exchange mobile SSO authorization code for tokens + tags: + - Auth + description: Exchanges a one-time authorization code (received via deep link + after mobile SSO) for OAuth tokens. The code is single-use and expires after + 5 minutes. + parameters: [] + responses: + '200': + description: tokens issued + content: + application/json: + schema: + type: object + properties: + access_token: + type: string + refresh_token: + type: string + token_type: + type: string + expires_in: + type: integer + created_at: + type: integer + user: + type: object + properties: + id: + type: string + format: uuid + email: + type: string + first_name: + type: string + last_name: + type: string + '401': + description: invalid or expired code + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + code: + type: string + description: One-time authorization code from mobile SSO callback + required: + - code + required: true + "/api/v1/auth/refresh": + post: + summary: Refresh an access token + tags: + - Auth + parameters: [] + responses: + '200': + description: token refreshed + content: + application/json: + schema: + type: object + properties: + access_token: + type: string + refresh_token: + type: string + token_type: + type: string + expires_in: + type: integer + created_at: + type: integer + '401': + description: invalid refresh token + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '400': + description: missing refresh token + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + refresh_token: + type: string + description: The refresh token from a previous login or refresh + device: + type: object + properties: + device_id: + type: string + required: + - device_id + required: + - refresh_token + - device + required: true "/api/v1/categories": get: summary: List categories @@ -1247,8 +1581,8 @@ paths: parameters: - name: id in: path - description: Holding ID required: true + description: Holding ID schema: type: string get: @@ -1449,6 +1783,138 @@ paths: application/json: schema: "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/merchants": + get: + summary: List merchants + tags: + - Merchants + security: + - apiKeyAuth: [] + responses: + '200': + description: merchants listed + content: + application/json: + schema: + "$ref": "#/components/schemas/MerchantCollection" + post: + summary: Create merchant + tags: + - Merchants + security: + - apiKeyAuth: [] + parameters: [] + responses: + '201': + description: merchant created with auto-assigned color + content: + application/json: + schema: + "$ref": "#/components/schemas/MerchantDetail" + '422': + description: validation error - duplicate name + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + merchant: + type: object + properties: + name: + type: string + description: Merchant name (required) + color: + type: string + description: Hex color code (optional, auto-assigned if not + provided) + website_url: + type: string + description: Website URL (optional) + required: + - name + required: + - merchant + required: true + "/api/v1/merchants/{id}": + parameters: + - name: id + in: path + required: true + description: Merchant ID + schema: + type: string + get: + summary: Retrieve a merchant + tags: + - Merchants + security: + - apiKeyAuth: [] + responses: + '200': + description: merchant retrieved + content: + application/json: + schema: + "$ref": "#/components/schemas/MerchantDetail" + '404': + description: merchant not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + patch: + summary: Update a merchant + tags: + - Merchants + security: + - apiKeyAuth: [] + parameters: [] + responses: + '200': + description: merchant updated + content: + application/json: + schema: + "$ref": "#/components/schemas/MerchantDetail" + '404': + description: merchant not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + merchant: + type: object + properties: + name: + type: string + color: + type: string + website_url: + type: string + required: true + delete: + summary: Delete a merchant + tags: + - Merchants + security: + - apiKeyAuth: [] + responses: + '204': + description: merchant deleted + '404': + description: merchant not found "/api/v1/tags": get: summary: List tags @@ -1732,8 +2198,8 @@ paths: parameters: - name: id in: path - description: Trade ID required: true + description: Trade ID schema: type: string get: @@ -1767,6 +2233,7 @@ paths: - Trades security: - apiKeyAuth: [] + parameters: [] responses: '200': description: trade updated @@ -1780,12 +2247,6 @@ paths: application/json: schema: "$ref": "#/components/schemas/ErrorResponse" - '422': - description: validation error - content: - application/json: - schema: - "$ref": "#/components/schemas/ErrorResponse" requestBody: content: application/json: @@ -1794,34 +2255,30 @@ paths: properties: trade: type: object - description: Flat params; controller builds internal structure. When qty/price are updated, type or nature controls sign; if omitted, existing trade direction is preserved. properties: date: type: string format: date - name: - type: string - amount: + qty: + type: number + price: type: number - currency: - type: string - notes: - type: string - nature: - type: string - enum: - - inflow - - outflow type: type: string enum: - buy - sell - description: Determines sign when qty/price are updated. - qty: - type: number - price: - type: number + nature: + type: string + enum: + - inflow + - outflow + name: + type: string + notes: + type: string + currency: + type: string investment_activity_label: type: string category_id: @@ -2136,6 +2593,8 @@ paths: items: type: string format: uuid + description: Array of tag IDs to assign. Omit to preserve existing + tags; use [] to clear all tags. required: true delete: summary: Delete a transaction diff --git a/spec/requests/api/v1/merchants_spec.rb b/spec/requests/api/v1/merchants_spec.rb new file mode 100644 index 000000000..d7f912d6b --- /dev/null +++ b/spec/requests/api/v1/merchants_spec.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'API V1 Merchants', 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: '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, + scopes: %w[read_write], + source: 'web' + ) + end + + let(:'X-Api-Key') { api_key.plain_key } + + let!(:amazon_merchant) do + family.merchants.create!(name: 'Amazon', color: '#ff9900', website_url: 'https://amazon.com') + end + + let!(:netflix_merchant) do + family.merchants.create!(name: 'Netflix', color: '#e50914', website_url: 'https://netflix.com') + end + + let!(:starbucks_merchant) do + family.merchants.create!(name: 'Starbucks', color: '#00704a') + end + + path '/api/v1/merchants' do + get 'List merchants' do + tags 'Merchants' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + response '200', 'merchants listed' do + schema '$ref' => '#/components/schemas/MerchantCollection' + + run_test! + end + end + + post 'Create merchant' do + tags 'Merchants' + security [ { apiKeyAuth: [] } ] + consumes 'application/json' + produces 'application/json' + parameter name: :body, in: :body, required: true, schema: { + type: :object, + properties: { + merchant: { + type: :object, + properties: { + name: { type: :string, description: 'Merchant name (required)' }, + color: { type: :string, description: 'Hex color code (optional, auto-assigned if not provided)' }, + website_url: { type: :string, description: 'Website URL (optional)' } + }, + required: %w[name] + } + }, + required: %w[merchant] + } + + response '201', 'merchant created' do + schema '$ref' => '#/components/schemas/MerchantDetail' + + let(:body) do + { + merchant: { + name: 'Walmart', + color: '#0071ce', + website_url: 'https://walmart.com' + } + } + end + + run_test! + end + + response '201', 'merchant created with auto-assigned color' do + schema '$ref' => '#/components/schemas/MerchantDetail' + + let(:body) do + { + merchant: { + name: 'Target' + } + } + end + + run_test! + end + + response '422', 'validation error - duplicate name' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) do + { + merchant: { + name: 'Amazon' + } + } + end + + run_test! + end + end + end + + path '/api/v1/merchants/{id}' do + parameter name: :id, in: :path, type: :string, required: true, description: 'Merchant ID' + + get 'Retrieve a merchant' do + tags 'Merchants' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + let(:id) { amazon_merchant.id } + + response '200', 'merchant retrieved' do + schema '$ref' => '#/components/schemas/MerchantDetail' + + run_test! + end + + response '404', 'merchant not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + end + + patch 'Update a merchant' do + tags 'Merchants' + security [ { apiKeyAuth: [] } ] + consumes 'application/json' + produces 'application/json' + + let(:id) { amazon_merchant.id } + + parameter name: :body, in: :body, required: true, schema: { + type: :object, + properties: { + merchant: { + type: :object, + properties: { + name: { type: :string }, + color: { type: :string }, + website_url: { type: :string } + } + } + } + } + + let(:body) do + { + merchant: { + name: 'Amazon Updated', + color: '#232f3e' + } + } + end + + response '200', 'merchant updated' do + schema '$ref' => '#/components/schemas/MerchantDetail' + + run_test! + end + + response '404', 'merchant not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + end + + delete 'Delete a merchant' do + tags 'Merchants' + security [ { apiKeyAuth: [] } ] + + let(:id) { starbucks_merchant.id } + + response '204', 'merchant deleted' do + run_test! + end + + response '404', 'merchant not found' do + let(:id) { SecureRandom.uuid } + + run_test! + end + end + end +end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index 5874fcb90..f81712bfe 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -254,6 +254,24 @@ RSpec.configure do |config| name: { type: :string } } }, + MerchantDetail: { + type: :object, + required: %w[id name type created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + name: { type: :string }, + type: { type: :string, enum: %w[FamilyMerchant ProviderMerchant] }, + color: { type: :string, nullable: true }, + logo_url: { type: :string, nullable: true }, + website_url: { type: :string, nullable: true }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, + MerchantCollection: { + type: :array, + items: { '$ref' => '#/components/schemas/MerchantDetail' } + }, Tag: { type: :object, required: %w[id name color], diff --git a/test/controllers/api/v1/merchants_controller_test.rb b/test/controllers/api/v1/merchants_controller_test.rb index 99068836c..e9f701691 100644 --- a/test/controllers/api/v1/merchants_controller_test.rb +++ b/test/controllers/api/v1/merchants_controller_test.rb @@ -5,39 +5,45 @@ require "test_helper" class Api::V1::MerchantsControllerTest < ActionDispatch::IntegrationTest setup do @user = users(:family_admin) + @family = @user.family @other_family_user = users(:empty) - # Verify cross-family isolation setup is correct assert_not_equal @user.family_id, @other_family_user.family_id, "Test setup error: @other_family_user must belong to a different family" - @oauth_app = Doorkeeper::Application.create!( - name: "Test App", - redirect_uri: "https://example.com/callback", - scopes: "read" + # Destroy existing active API keys to avoid validation errors + @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)}" ) - @access_token = Doorkeeper::AccessToken.create!( - application: @oauth_app, - resource_owner_id: @user.id, - scopes: "read" + @read_only_api_key = ApiKey.create!( + user: @user, + name: "Test Read-Only Key", + scopes: [ "read" ], + display_key: "test_ro_#{SecureRandom.hex(8)}", + source: "mobile" ) - @merchant = @user.family.merchants.first || @user.family.merchants.create!( - name: "Test Merchant" - ) + Redis.new.del("api_rate_limit:#{@api_key.id}") + Redis.new.del("api_rate_limit:#{@read_only_api_key.id}") + + @merchant = @family.merchants.first || @family.merchants.create!(name: "Test Merchant") end - # Index action tests + # ── INDEX ───────────────────────────────────────────────────────── + test "index requires authentication" do get api_v1_merchants_url - assert_response :unauthorized end - test "index returns user's family merchants successfully" do - get api_v1_merchants_url, headers: auth_headers - + test "index returns merchants with API key" do + get api_v1_merchants_url, headers: api_headers(@api_key) assert_response :success merchants = JSON.parse(response.body) @@ -47,17 +53,23 @@ class Api::V1::MerchantsControllerTest < ActionDispatch::IntegrationTest merchant = merchants.first assert merchant.key?("id") assert merchant.key?("name") + assert merchant.key?("type") + assert merchant.key?("color") assert merchant.key?("created_at") assert merchant.key?("updated_at") end - test "index does not return merchants from other families" do - # Create a merchant in another family - other_merchant = @other_family_user.family.merchants.create!(name: "Other Merchant") - - get api_v1_merchants_url, headers: auth_headers - + test "index returns merchants with read-only API key" do + get api_v1_merchants_url, headers: api_headers(@read_only_api_key) assert_response :success + end + + test "index does not return merchants from other families" do + other_merchant = @other_family_user.family.merchants.create!(name: "Other Family Merchant") + + get api_v1_merchants_url, headers: api_headers(@api_key) + assert_response :success + merchants = JSON.parse(response.body) merchant_ids = merchants.map { |m| m["id"] } @@ -65,40 +77,186 @@ class Api::V1::MerchantsControllerTest < ActionDispatch::IntegrationTest assert_not_includes merchant_ids, other_merchant.id end - # Show action tests + # ── SHOW ────────────────────────────────────────────────────────── + test "show requires authentication" do get api_v1_merchant_url(@merchant) - assert_response :unauthorized end - test "show returns merchant successfully" do - get api_v1_merchant_url(@merchant), headers: auth_headers - + test "show returns merchant with API key" do + get api_v1_merchant_url(@merchant), headers: api_headers(@api_key) assert_response :success merchant = JSON.parse(response.body) assert_equal @merchant.id, merchant["id"] assert_equal @merchant.name, merchant["name"] + assert_equal @merchant.type, merchant["type"] end test "show returns 404 for non-existent merchant" do - get api_v1_merchant_url(id: SecureRandom.uuid), headers: auth_headers - + get api_v1_merchant_url(id: SecureRandom.uuid), headers: api_headers(@api_key) assert_response :not_found end test "show returns 404 for merchant from another family" do other_merchant = @other_family_user.family.merchants.create!(name: "Other Merchant") - get api_v1_merchant_url(other_merchant), headers: auth_headers + get api_v1_merchant_url(other_merchant), headers: api_headers(@api_key) + assert_response :not_found + end + + # ── CREATE ──────────────────────────────────────────────────────── + + test "create requires authentication" do + post api_v1_merchants_url, params: { merchant: { name: "New Merchant" } } + assert_response :unauthorized + end + + test "create requires read_write scope" do + post api_v1_merchants_url, + params: { merchant: { name: "New Merchant", color: "#4da568" } }, + headers: api_headers(@read_only_api_key) + assert_response :forbidden + end + + test "create merchant successfully" do + merchant_name = "New Merchant #{SecureRandom.hex(4)}" + + assert_difference -> { @family.merchants.count }, 1 do + post api_v1_merchants_url, + params: { merchant: { name: merchant_name, color: "#4da568" } }, + headers: api_headers(@api_key) + end + + assert_response :created + + merchant = JSON.parse(response.body) + assert_equal merchant_name, merchant["name"] + assert_equal "#4da568", merchant["color"] + assert_equal "FamilyMerchant", merchant["type"] + end + + test "create merchant with auto-assigned color" do + merchant_name = "Auto Color Merchant #{SecureRandom.hex(4)}" + + post api_v1_merchants_url, + params: { merchant: { name: merchant_name } }, + headers: api_headers(@api_key) + + assert_response :created + + merchant = JSON.parse(response.body) + assert_equal merchant_name, merchant["name"] + assert merchant["color"].present? + end + + test "create fails with duplicate name in same family" do + post api_v1_merchants_url, + params: { merchant: { name: @merchant.name } }, + headers: api_headers(@api_key) + + assert_response :unprocessable_entity + end + + # ── UPDATE ──────────────────────────────────────────────────────── + + test "update requires authentication" do + patch api_v1_merchant_url(@merchant), params: { merchant: { name: "Updated" } } + assert_response :unauthorized + end + + test "update requires read_write scope" do + patch api_v1_merchant_url(@merchant), + params: { merchant: { name: "Updated" } }, + headers: api_headers(@read_only_api_key) + assert_response :forbidden + end + + test "update merchant successfully" do + new_name = "Updated Merchant #{SecureRandom.hex(4)}" + + patch api_v1_merchant_url(@merchant), + params: { merchant: { name: new_name, color: "#db5a54" } }, + headers: api_headers(@api_key) + + assert_response :success + + merchant = JSON.parse(response.body) + assert_equal new_name, merchant["name"] + assert_equal "#db5a54", merchant["color"] + end + + test "update merchant partially" do + original_name = @merchant.name + + patch api_v1_merchant_url(@merchant), + params: { merchant: { color: "#eb5429" } }, + headers: api_headers(@api_key) + + assert_response :success + + merchant = JSON.parse(response.body) + assert_equal original_name, merchant["name"] + assert_equal "#eb5429", merchant["color"] + end + + test "update returns 404 for non-existent merchant" do + patch api_v1_merchant_url(id: SecureRandom.uuid), + params: { merchant: { name: "Not Found" } }, + headers: api_headers(@api_key) + assert_response :not_found + end + + test "update returns 404 for merchant from another family" do + other_merchant = @other_family_user.family.merchants.create!(name: "Other Merchant") + + patch api_v1_merchant_url(other_merchant), + params: { merchant: { name: "Hacker Update" } }, + headers: api_headers(@api_key) + assert_response :not_found + end + + # ── DESTROY ─────────────────────────────────────────────────────── + + test "destroy requires authentication" do + delete api_v1_merchant_url(@merchant) + assert_response :unauthorized + end + + test "destroy requires read_write scope" do + delete api_v1_merchant_url(@merchant), headers: api_headers(@read_only_api_key) + assert_response :forbidden + end + + test "destroy merchant successfully" do + merchant_to_delete = @family.merchants.create!(name: "Delete Me #{SecureRandom.hex(4)}", color: "#c44fe9") + + assert_difference -> { @family.merchants.count }, -1 do + delete api_v1_merchant_url(merchant_to_delete), headers: api_headers(@api_key) + end + + assert_response :no_content + end + + test "destroy returns 404 for non-existent merchant" do + delete api_v1_merchant_url(id: SecureRandom.uuid), headers: api_headers(@api_key) + assert_response :not_found + end + + test "destroy returns 404 for merchant from another family" do + other_merchant = @other_family_user.family.merchants.create!(name: "Other Merchant") + + assert_no_difference -> { @other_family_user.family.merchants.count } do + delete api_v1_merchant_url(other_merchant), headers: api_headers(@api_key) + end assert_response :not_found end private - def auth_headers - { "Authorization" => "Bearer #{@access_token.token}" } + def api_headers(api_key) + { "X-Api-Key" => api_key.display_key } end end