mirror of
https://github.com/we-promise/sure.git
synced 2026-05-09 05:35:00 +00:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user