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:
GermanDZ
2026-05-06 22:59:55 +02:00
committed by GitHub
parent dce2213a98
commit d1081547ec
6 changed files with 382 additions and 49 deletions

View File

@@ -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