Files
sure/test/controllers/api/v1/merchants_controller_test.rb
Claude 932bb12634 Add full CRUD support for merchants in the API
Expand the merchants API from read-only (index/show) to full CRUD with
create, update, and destroy actions. Uses API key auth with proper scope
authorization (read for index/show, read_write for create/update/destroy)
and family-based isolation.

- Add create/update/destroy actions to MerchantsController
- Update routes to include all CRUD actions
- Enrich merchant JSON response with color, logo_url, website_url fields
- Fix FamilyMerchant#set_default_color to only set when blank (was
  unconditionally overriding color on every validation)
- Rewrite tests to use API key auth pattern with read/read_write scopes
- Add rswag OpenAPI spec and regenerate docs
- Add MerchantDetail/MerchantCollection schemas to swagger_helper

https://claude.ai/code/session_01G39SUd6QEv5nUusPvjFhmh
2026-02-13 15:25:25 +00:00

263 lines
8.6 KiB
Ruby

# frozen_string_literal: true
require "test_helper"
class Api::V1::MerchantsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@family = @user.family
@other_family_user = users(:empty)
assert_not_equal @user.family_id, @other_family_user.family_id,
"Test setup error: @other_family_user must belong to a different family"
# 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)}"
)
@read_only_api_key = ApiKey.create!(
user: @user,
name: "Test Read-Only Key",
scopes: [ "read" ],
display_key: "test_ro_#{SecureRandom.hex(8)}",
source: "mobile"
)
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 ─────────────────────────────────────────────────────────
test "index requires authentication" do
get api_v1_merchants_url
assert_response :unauthorized
end
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)
assert_kind_of Array, merchants
assert_not_empty merchants
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 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"] }
assert_includes merchant_ids, @merchant.id
assert_not_includes merchant_ids, other_merchant.id
end
# ── SHOW ──────────────────────────────────────────────────────────
test "show requires authentication" do
get api_v1_merchant_url(@merchant)
assert_response :unauthorized
end
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: 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: 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 api_headers(api_key)
{ "X-Api-Key" => api_key.display_key }
end
end