mirror of
https://github.com/we-promise/sure.git
synced 2026-05-09 21:54:58 +00:00
feat(api): allow creating categories via API (#1676)
* feat(api): allow creating categories via API Adds POST /api/v1/categories so external integrations (e.g. bulk classification scripts that import already-categorized data from another system) can create categories without going through the web UI. Mirrors the existing tags create endpoint: requires the read_write scope, accepts name/color/icon/parent_id, auto-suggests an icon when omitted, and rejects parent_ids from other families. Also adds Minitest behavioural coverage, an rswag docs spec, a CategoryCreateRequest schema, and regenerates docs/api/openapi.yaml. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(api): address review feedback on POST /api/v1/categories - Re-raise ActionController::ParameterMissing in #create so the BaseController rescue_from handles it as a 400 instead of the generic 500 from the broad rescue inside the action. - Add a 403 'insufficient scope' response block to the rswag POST example so the generated OpenAPI documents read-only key rejection. - Switch the new create-action Minitest cases to API key auth via X-Api-Key + api_headers (using the existing api_keys fixtures), matching the project's API endpoint consistency rule. - Add Minitest coverage for two more 4xx paths: rejecting third-level nesting (parent_id pointing at a depth-2 subcategory) and rejecting requests without the category payload (400). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(test): migrate categories API index/show tests to X-Api-Key The pre-existing index and show tests in this file authenticated via Doorkeeper bearer tokens. Per the project's API endpoint consistency rule (CLAUDE.md, .cursor/rules/api-endpoint-consistency.mdc) Minitest controller tests under test/controllers/api/v1/ must use ApiKey + X-Api-Key auth. Drops the Doorkeeper application/access-token setup and routes every request through the existing api_keys fixtures and the api_headers helper, matching the create-action tests already in this file (and the pattern used in sync/users/family_settings tests). No behavioural change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(api): address second-round review on POST /api/v1/categories - Add a 400 response block to the POST rswag example so the generated OpenAPI documents the missing-category-payload contract that BaseController#handle_bad_request already returns. Regenerate docs/api/openapi.yaml. - Replace fixture-backed read_write_api_key / read_only_api_key helpers with explicit ApiKey.create! calls (matching the pattern in sync_controller_test, users_controller_test, and family_settings_controller_test). Setup now destroys active keys for the test user so the one-active-key-per-source validation does not collide with fixtures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(api): tighten 422 create-category cases - Pass color and icon explicitly in the duplicate-name and third-level-nesting tests so each case is self-documenting about which validation it isolates (the model's color presence check is satisfied by the column default today, but reviewers — human and bot — flagged the implicit reliance). - Assert the JSON error envelope (error key + present message) on every 422 path so the response shape stays consistent and a regression in the rendered error body is caught uniformly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(api): tighten POST /api/v1/categories per review - Drop the no-op `rescue ActionController::ParameterMissing; raise` and the broad `rescue => e` from the create action. The BaseController already has rescue_from ActionController::ParameterMissing → 400, and unexpected exceptions are best left to Rails' default 500 handling (which logs identically). Keeps the action focused on its happy path and the two real error branches. - Stop accepting `lucide_icon` as a request key. The OpenAPI schema documents only `icon`; the dual permit was undocumented and pointless. `icon` is now the single canonical request key, mapped to `lucide_icon` on the model in category_params. - Migrate the Minitest helpers to the project's documented API key pattern: ApiKey.generate_secure_key + api_key.plain_key in the X-Api-Key header (matching the rswag spec in this PR and the rule in .cursor/rules/api-endpoint-consistency.mdc), instead of hand-built display_key strings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Botched conflict merge --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Juan José Mata <jjmata@jjmata.com>
This commit is contained in:
@@ -8,17 +8,10 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest
|
||||
@other_family_user = users(:family_member)
|
||||
@other_family_user.update!(family: families(:empty))
|
||||
|
||||
@oauth_app = Doorkeeper::Application.create!(
|
||||
name: "Test API App",
|
||||
redirect_uri: "https://example.com/callback",
|
||||
scopes: "read read_write"
|
||||
)
|
||||
|
||||
@access_token = Doorkeeper::AccessToken.create!(
|
||||
application: @oauth_app,
|
||||
resource_owner_id: @user.id,
|
||||
scopes: "read"
|
||||
)
|
||||
# Fixtures pre-create active keys for family_admin; clear them so we can
|
||||
# create scoped keys per-test without tripping the one-active-key-per-source
|
||||
# validation.
|
||||
@user.api_keys.active.destroy_all
|
||||
|
||||
@category = categories(:food_and_drink)
|
||||
@subcategory = categories(:subcategory)
|
||||
@@ -35,9 +28,7 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "should return user's family categories successfully" do
|
||||
get "/api/v1/categories", params: {}, headers: {
|
||||
"Authorization" => "Bearer #{@access_token.token}"
|
||||
}
|
||||
get "/api/v1/categories", params: {}, headers: api_headers(read_only_api_key)
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
@@ -53,15 +44,15 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "should not return other family's categories" do
|
||||
access_token = Doorkeeper::AccessToken.create!(
|
||||
application: @oauth_app,
|
||||
resource_owner_id: @other_family_user.id,
|
||||
scopes: "read"
|
||||
other_family_api_key = ApiKey.create!(
|
||||
user: @other_family_user,
|
||||
name: "Other Family Read Key",
|
||||
key: ApiKey.generate_secure_key,
|
||||
scopes: %w[read],
|
||||
source: "web"
|
||||
)
|
||||
|
||||
get "/api/v1/categories", params: {}, headers: {
|
||||
"Authorization" => "Bearer #{access_token.token}"
|
||||
}
|
||||
get "/api/v1/categories", params: {}, headers: api_headers(other_family_api_key)
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
@@ -72,9 +63,7 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "should return proper category data structure" do
|
||||
get "/api/v1/categories", params: {}, headers: {
|
||||
"Authorization" => "Bearer #{@access_token.token}"
|
||||
}
|
||||
get "/api/v1/categories", params: {}, headers: api_headers(read_only_api_key)
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
@@ -96,9 +85,7 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "should include parent information for subcategories" do
|
||||
get "/api/v1/categories", params: {}, headers: {
|
||||
"Authorization" => "Bearer #{@access_token.token}"
|
||||
}
|
||||
get "/api/v1/categories", params: {}, headers: api_headers(read_only_api_key)
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
@@ -112,9 +99,7 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "should handle pagination parameters" do
|
||||
get "/api/v1/categories", params: { page: 1, per_page: 2 }, headers: {
|
||||
"Authorization" => "Bearer #{@access_token.token}"
|
||||
}
|
||||
get "/api/v1/categories", params: { page: 1, per_page: 2 }, headers: api_headers(read_only_api_key)
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
@@ -125,9 +110,7 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "should filter for roots only" do
|
||||
get "/api/v1/categories", params: { roots_only: true }, headers: {
|
||||
"Authorization" => "Bearer #{@access_token.token}"
|
||||
}
|
||||
get "/api/v1/categories", params: { roots_only: true }, headers: api_headers(read_only_api_key)
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
@@ -138,9 +121,7 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "should sort categories alphabetically" do
|
||||
get "/api/v1/categories", params: {}, headers: {
|
||||
"Authorization" => "Bearer #{@access_token.token}"
|
||||
}
|
||||
get "/api/v1/categories", params: {}, headers: api_headers(read_only_api_key)
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
@@ -152,9 +133,7 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest
|
||||
# Show action tests
|
||||
|
||||
test "should return a single category" do
|
||||
get "/api/v1/categories/#{@category.id}", params: {}, headers: {
|
||||
"Authorization" => "Bearer #{@access_token.token}"
|
||||
}
|
||||
get "/api/v1/categories/#{@category.id}", params: {}, headers: api_headers(read_only_api_key)
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
@@ -166,9 +145,7 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "should return 404 for non-existent category" do
|
||||
get "/api/v1/categories/00000000-0000-0000-0000-000000000000", params: {}, headers: {
|
||||
"Authorization" => "Bearer #{@access_token.token}"
|
||||
}
|
||||
get "/api/v1/categories/00000000-0000-0000-0000-000000000000", params: {}, headers: api_headers(read_only_api_key)
|
||||
|
||||
assert_response :not_found
|
||||
response_body = JSON.parse(response.body)
|
||||
@@ -182,10 +159,156 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest
|
||||
classification_unused: "expense"
|
||||
)
|
||||
|
||||
get "/api/v1/categories/#{other_family_category.id}", params: {}, headers: {
|
||||
"Authorization" => "Bearer #{@access_token.token}"
|
||||
}
|
||||
get "/api/v1/categories/#{other_family_category.id}", params: {}, headers: api_headers(read_only_api_key)
|
||||
|
||||
assert_response :not_found
|
||||
end
|
||||
|
||||
# Create action tests
|
||||
|
||||
test "create requires authentication" do
|
||||
post "/api/v1/categories", params: { category: { name: "Anything" } }
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
test "create rejects api key without read_write scope" do
|
||||
post "/api/v1/categories",
|
||||
params: { category: { name: "Coffee Runs", color: "#22c55e", icon: "coffee" } },
|
||||
headers: api_headers(read_only_api_key)
|
||||
|
||||
assert_response :forbidden
|
||||
end
|
||||
|
||||
test "create returns 201 with full attributes" do
|
||||
post "/api/v1/categories",
|
||||
params: { category: { name: "Coffee Runs", color: "#22c55e", icon: "coffee" } },
|
||||
headers: api_headers(read_write_api_key)
|
||||
|
||||
assert_response :created
|
||||
body = JSON.parse(response.body)
|
||||
assert body["id"].present?
|
||||
assert_equal "Coffee Runs", body["name"]
|
||||
assert_equal "#22c55e", body["color"]
|
||||
assert_equal "coffee", body["icon"]
|
||||
assert_nil body["parent"]
|
||||
assert_equal 0, body["subcategories_count"]
|
||||
|
||||
persisted = @user.family.categories.find(body["id"])
|
||||
assert_equal "coffee", persisted.lucide_icon
|
||||
end
|
||||
|
||||
test "create auto-suggests icon when omitted" do
|
||||
post "/api/v1/categories",
|
||||
params: { category: { name: "Groceries Imported", color: "#407706" } },
|
||||
headers: api_headers(read_write_api_key)
|
||||
|
||||
assert_response :created
|
||||
body = JSON.parse(response.body)
|
||||
assert body["icon"].present?
|
||||
assert_not_equal "", body["icon"]
|
||||
end
|
||||
|
||||
test "create attaches parent when provided" do
|
||||
post "/api/v1/categories",
|
||||
params: { category: { name: "Imported Subcategory", color: "#22c55e", icon: "shapes", parent_id: @category.id } },
|
||||
headers: api_headers(read_write_api_key)
|
||||
|
||||
assert_response :created
|
||||
body = JSON.parse(response.body)
|
||||
assert_equal @category.id, body.dig("parent", "id")
|
||||
assert_equal @category.name, body.dig("parent", "name")
|
||||
end
|
||||
|
||||
test "create returns 422 on duplicate name within family" do
|
||||
post "/api/v1/categories",
|
||||
params: { category: { name: @category.name, color: "#22c55e", icon: "shapes" } },
|
||||
headers: api_headers(read_write_api_key)
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
body = JSON.parse(response.body)
|
||||
assert_equal "unprocessable_entity", body["error"]
|
||||
assert body["message"].present?
|
||||
end
|
||||
|
||||
test "create returns 422 on invalid color" do
|
||||
post "/api/v1/categories",
|
||||
params: { category: { name: "Bad Color", color: "not-a-hex" } },
|
||||
headers: api_headers(read_write_api_key)
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
body = JSON.parse(response.body)
|
||||
assert_equal "unprocessable_entity", body["error"]
|
||||
assert body["message"].present?
|
||||
end
|
||||
|
||||
test "create returns 422 when parent_id belongs to another family" do
|
||||
other_family_category = families(:empty).categories.create!(
|
||||
name: "External Parent",
|
||||
color: "#FF0000",
|
||||
classification_unused: "expense"
|
||||
)
|
||||
|
||||
post "/api/v1/categories",
|
||||
params: { category: { name: "Should Fail", color: "#22c55e", icon: "shapes", parent_id: other_family_category.id } },
|
||||
headers: api_headers(read_write_api_key)
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
body = JSON.parse(response.body)
|
||||
assert_equal "unprocessable_entity", body["error"]
|
||||
assert body["message"].present?
|
||||
end
|
||||
|
||||
test "create returns 422 when nesting exceeds two levels" do
|
||||
child = @user.family.categories.create!(
|
||||
name: "Existing Child",
|
||||
color: "#22c55e",
|
||||
lucide_icon: "shapes",
|
||||
parent: @category
|
||||
)
|
||||
|
||||
post "/api/v1/categories",
|
||||
params: { category: { name: "Grandchild", color: "#22c55e", icon: "shapes", parent_id: child.id } },
|
||||
headers: api_headers(read_write_api_key)
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
body = JSON.parse(response.body)
|
||||
assert_equal "unprocessable_entity", body["error"]
|
||||
assert body["message"].present?
|
||||
end
|
||||
|
||||
test "create returns 400 when category payload is missing" do
|
||||
post "/api/v1/categories",
|
||||
params: {},
|
||||
headers: api_headers(read_write_api_key)
|
||||
|
||||
assert_response :bad_request
|
||||
body = JSON.parse(response.body)
|
||||
assert_equal "bad_request", body["error"]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def read_write_api_key
|
||||
@read_write_api_key ||= ApiKey.create!(
|
||||
user: @user,
|
||||
name: "Test RW Key",
|
||||
key: ApiKey.generate_secure_key,
|
||||
scopes: %w[read_write],
|
||||
source: "web"
|
||||
)
|
||||
end
|
||||
|
||||
def read_only_api_key
|
||||
@read_only_api_key ||= ApiKey.create!(
|
||||
user: @user,
|
||||
name: "Test RO Key",
|
||||
key: ApiKey.generate_secure_key,
|
||||
scopes: %w[read],
|
||||
source: "mobile"
|
||||
)
|
||||
end
|
||||
|
||||
def api_headers(api_key)
|
||||
{ "X-Api-Key" => api_key.plain_key }
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user