mirror of
https://github.com/we-promise/sure.git
synced 2026-05-08 05:04:59 +00:00
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
263 lines
8.6 KiB
Ruby
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
|