From 9cb3b8e05c8202007140659b157735b78a35ab01 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Sun, 3 May 2026 15:33:35 -0600 Subject: [PATCH] feat(api): expose rule run history (#1646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): expose rule run history * fix(api): address rule run review * fix(api): complete rule run review * test(api): cover unauthenticated rule run show * test(api): align rule run api key helper * Small Sonnet nit-pick --------- Co-authored-by: Juan José Mata --- app/controllers/api/v1/base_controller.rb | 22 ++ .../api/v1/rule_runs_controller.rb | 82 ++++++ app/controllers/api/v1/rules_controller.rb | 25 +- .../api/v1/rule_runs/_rule_run.json.jbuilder | 27 ++ .../api/v1/rule_runs/index.json.jbuilder | 16 ++ app/views/api/v1/rule_runs/show.json.jbuilder | 5 + config/routes.rb | 1 + docs/api/openapi.yaml | 245 ++++++++++++++++++ spec/requests/api/v1/rule_runs_spec.rb | 177 +++++++++++++ spec/swagger_helper.rb | 59 +++++ .../api/v1/rule_runs_controller_test.rb | 207 +++++++++++++++ 11 files changed, 843 insertions(+), 23 deletions(-) create mode 100644 app/controllers/api/v1/rule_runs_controller.rb create mode 100644 app/views/api/v1/rule_runs/_rule_run.json.jbuilder create mode 100644 app/views/api/v1/rule_runs/index.json.jbuilder create mode 100644 app/views/api/v1/rule_runs/show.json.jbuilder create mode 100644 spec/requests/api/v1/rule_runs_spec.rb create mode 100644 test/controllers/api/v1/rule_runs_controller_test.rb diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb index dd2fc993a..dccaae77c 100644 --- a/app/controllers/api/v1/base_controller.rb +++ b/app/controllers/api/v1/base_controller.rb @@ -216,6 +216,28 @@ class Api::V1::BaseController < ApplicationController value.to_s.match?(UUID_PATTERN) end + def safe_page_param + page = params[:page].to_i + page > 0 ? page : 1 + end + + def safe_per_page_param + per_page = params[:per_page].to_i + case per_page + when 1..100 then per_page + when (101..) then 100 + else 25 + end + end + + def render_validation_error(message) + render_json({ + error: "validation_failed", + message: message, + errors: [ message ] + }, status: :unprocessable_entity) + end + # Error handlers def handle_not_found(exception) Rails.logger.warn "API Record Not Found: #{exception.message}" diff --git a/app/controllers/api/v1/rule_runs_controller.rb b/app/controllers/api/v1/rule_runs_controller.rb new file mode 100644 index 000000000..4aeae3a62 --- /dev/null +++ b/app/controllers/api/v1/rule_runs_controller.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +class Api::V1::RuleRunsController < Api::V1::BaseController + include Pagy::Backend + + STATUSES = %w[pending success failed].freeze + EXECUTION_TYPES = %w[manual scheduled].freeze + InvalidFilterError = Class.new(StandardError) + + before_action :ensure_read_scope + before_action :set_rule_run, only: :show + + def index + rule_runs_query = apply_filters(rule_runs_scope).recent + @per_page = safe_per_page_param + + @pagy, @rule_runs = pagy( + rule_runs_query, + page: safe_page_param, + limit: @per_page + ) + + render :index + rescue InvalidFilterError => e + render_validation_error(e.message) + end + + def show + render :show + end + + private + + def set_rule_run + raise ActiveRecord::RecordNotFound, "Rule run not found" unless valid_uuid?(params[:id]) + + @rule_run = rule_runs_scope.find(params[:id]) + end + + def ensure_read_scope + authorize_scope!(:read) + end + + def rule_runs_scope + RuleRun + .joins(:rule) + .where(rules: { family_id: Current.family.id }) + .includes(:rule) + end + + def apply_filters(query) + if params[:rule_id].present? + raise InvalidFilterError, "rule_id must be a valid UUID" unless valid_uuid?(params[:rule_id]) + + query = query.where(rule_id: params[:rule_id]) + end + + if params[:status].present? + raise InvalidFilterError, "status must be one of: #{STATUSES.join(', ')}" unless STATUSES.include?(params[:status]) + + query = query.where(status: params[:status]) + end + + if params[:execution_type].present? + unless EXECUTION_TYPES.include?(params[:execution_type]) + raise InvalidFilterError, "execution_type must be one of: #{EXECUTION_TYPES.join(', ')}" + end + + query = query.where(execution_type: params[:execution_type]) + end + + query = query.where("rule_runs.executed_at >= ?", parse_time_param(:start_executed_at)) if params[:start_executed_at].present? + query = query.where("rule_runs.executed_at <= ?", parse_time_param(:end_executed_at)) if params[:end_executed_at].present? + query + end + + def parse_time_param(key) + Time.iso8601(params[key].to_s) + rescue ArgumentError + raise InvalidFilterError, "#{key} must be an ISO 8601 timestamp" + end +end diff --git a/app/controllers/api/v1/rules_controller.rb b/app/controllers/api/v1/rules_controller.rb index 2b2d800a8..06b9f753a 100644 --- a/app/controllers/api/v1/rules_controller.rb +++ b/app/controllers/api/v1/rules_controller.rb @@ -55,29 +55,11 @@ class Api::V1::RulesController < Api::V1::BaseController authorize_scope!(:read) end - def safe_page_param - page = params[:page].to_i - page > 0 ? page : 1 - end - - def safe_per_page_param - per_page = params[:per_page].to_i - case per_page - when 1..100 - per_page - else - 25 - end - end - def parse_boolean_filter(value) normalized = value.to_s.downcase return BOOLEAN_FILTERS[normalized] if BOOLEAN_FILTERS.key?(normalized) - render json: { - error: "validation_failed", - message: "active must be one of: true, false, 1, 0" - }, status: :unprocessable_entity + render_validation_error("active must be one of: true, false, 1, 0") nil end @@ -86,9 +68,6 @@ class Api::V1::RulesController < Api::V1::BaseController end def render_invalid_resource_type_filter - render json: { - error: "validation_failed", - message: "resource_type must be one of: #{RESOURCE_TYPES.join(", ")}" - }, status: :unprocessable_entity + render_validation_error("resource_type must be one of: #{RESOURCE_TYPES.join(", ")}") end end diff --git a/app/views/api/v1/rule_runs/_rule_run.json.jbuilder b/app/views/api/v1/rule_runs/_rule_run.json.jbuilder new file mode 100644 index 000000000..e61677714 --- /dev/null +++ b/app/views/api/v1/rule_runs/_rule_run.json.jbuilder @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +json.id rule_run.id +json.rule_id rule_run.rule_id +json.rule_name rule_run.rule_name +json.execution_type rule_run.execution_type +json.status rule_run.status +json.transactions_queued rule_run.transactions_queued +json.transactions_processed rule_run.transactions_processed +json.transactions_modified rule_run.transactions_modified +json.pending_jobs_count rule_run.pending_jobs_count +json.executed_at rule_run.executed_at.iso8601 +json.error_message rule_run.error_message + +if rule_run.rule + json.rule do + json.id rule_run.rule.id + json.name rule_run.rule.name + json.resource_type rule_run.rule.resource_type + json.active rule_run.rule.active + end +else + json.rule nil +end + +json.created_at rule_run.created_at.iso8601 +json.updated_at rule_run.updated_at.iso8601 diff --git a/app/views/api/v1/rule_runs/index.json.jbuilder b/app/views/api/v1/rule_runs/index.json.jbuilder new file mode 100644 index 000000000..6b1e1802e --- /dev/null +++ b/app/views/api/v1/rule_runs/index.json.jbuilder @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +json.data do + json.array! @rule_runs do |rule_run| + json.partial! "rule_run", rule_run: rule_run + end +end + +json.meta do + json.current_page @pagy.page + json.next_page @pagy.next + json.prev_page @pagy.prev + json.total_pages @pagy.pages + json.total_count @pagy.count + json.per_page @per_page +end diff --git a/app/views/api/v1/rule_runs/show.json.jbuilder b/app/views/api/v1/rule_runs/show.json.jbuilder new file mode 100644 index 000000000..c6f555d4d --- /dev/null +++ b/app/views/api/v1/rule_runs/show.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.data do + json.partial! "rule_run", rule_run: @rule_run +end diff --git a/config/routes.rb b/config/routes.rb index 12481e711..ce780cc49 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -428,6 +428,7 @@ Rails.application.routes.draw do resources :categories, only: [ :index, :show ] resources :merchants, only: %i[index show] resources :rules, only: [ :index, :show ] + resources :rule_runs, only: [ :index, :show ] resources :tags, only: %i[index show create update destroy] resources :transactions, only: [ :index, :show, :create, :update, :destroy ] diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8e9a15b3d..5fbd6aac9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -740,6 +740,124 @@ components: type: integer per_page: type: integer + RuleRun: + type: object + required: + - id + - rule_id + - rule_name + - execution_type + - status + - transactions_queued + - transactions_processed + - transactions_modified + - pending_jobs_count + - executed_at + - rule + - created_at + - updated_at + properties: + id: + type: string + format: uuid + rule_id: + type: string + format: uuid + rule_name: + type: string + nullable: true + execution_type: + type: string + enum: + - manual + - scheduled + status: + type: string + enum: + - pending + - success + - failed + transactions_queued: + type: integer + minimum: 0 + transactions_processed: + type: integer + minimum: 0 + transactions_modified: + type: integer + minimum: 0 + pending_jobs_count: + type: integer + minimum: 0 + executed_at: + type: string + format: date-time + error_message: + type: string + nullable: true + rule: + type: object + nullable: true + required: + - id + - resource_type + - active + properties: + id: + type: string + format: uuid + name: + type: string + nullable: true + resource_type: + type: string + active: + type: boolean + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + RuleRunResponse: + type: object + required: + - data + properties: + data: + "$ref": "#/components/schemas/RuleRun" + RuleRunCollection: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + "$ref": "#/components/schemas/RuleRun" + meta: + type: object + required: + - current_page + - total_pages + - total_count + - per_page + properties: + current_page: + type: integer + next_page: + type: integer + nullable: true + prev_page: + type: integer + nullable: true + total_pages: + type: integer + total_count: + type: integer + per_page: + type: integer Transfer: type: object required: @@ -3340,6 +3458,133 @@ paths: application/json: schema: "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/rule_runs": + get: + summary: List rule runs + description: List rule run history for the authenticated user family. + tags: + - Rule Runs + security: + - apiKeyAuth: [] + parameters: + - name: page + in: query + required: false + description: 'Page number (default: 1)' + schema: + type: integer + - name: per_page + in: query + required: false + description: 'Items per page (default: 25, max: 100)' + schema: + type: integer + - name: rule_id + in: query + required: false + description: Filter by rule ID + schema: + type: string + format: uuid + - name: status + in: query + required: false + description: Filter by run status + schema: + type: string + enum: + - pending + - success + - failed + - name: execution_type + in: query + required: false + description: Filter by execution type + schema: + type: string + enum: + - manual + - scheduled + - name: start_executed_at + in: query + required: false + description: Filter runs executed at or after this timestamp + schema: + type: string + format: date-time + - name: end_executed_at + in: query + required: false + description: Filter runs executed at or before this timestamp + schema: + type: string + format: date-time + responses: + '200': + description: rule runs listed + content: + application/json: + schema: + "$ref": "#/components/schemas/RuleRunCollection" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: insufficient scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '422': + description: invalid filter + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/rule_runs/{id}": + parameters: + - name: id + in: path + required: true + description: Rule run ID + schema: + type: string + format: uuid + get: + summary: Retrieve a rule run + description: Retrieve one rule run from the authenticated user family. + tags: + - Rule Runs + security: + - apiKeyAuth: [] + responses: + '200': + description: rule run retrieved + content: + application/json: + schema: + "$ref": "#/components/schemas/RuleRunResponse" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: insufficient scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: rule run not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" "/api/v1/rules": get: summary: List rules diff --git a/spec/requests/api/v1/rule_runs_spec.rb b/spec/requests/api/v1/rule_runs_spec.rb new file mode 100644 index 000000000..d3a992190 --- /dev/null +++ b/spec/requests/api/v1/rule_runs_spec.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'API V1 Rule Runs', type: :request do + let(:family) do + Family.create!( + name: 'API Family', + currency: 'USD', + locale: 'en', + date_format: '%m-%d-%Y' + ) + end + + let(:user) do + family.users.create!( + email: 'api-user@example.com', + password: 'password123', + password_confirmation: 'password123' + ) + end + + let(:api_key) do + key = ApiKey.generate_secure_key + ApiKey.create!( + user: user, + name: 'API Docs Key', + key: key, + scopes: %w[read_write], + source: 'web' + ) + end + + let(:api_key_without_read_scope) do + key = ApiKey.generate_secure_key + # Valid persisted API keys can only be read/read_write; this intentionally + # bypasses validations to document the runtime insufficient-scope response. + ApiKey.new( + user: user, + name: 'No Read Docs Key', + key: key, + scopes: [], + display_key: key, + source: 'mobile' + ).tap { |api_key| api_key.save!(validate: false) } + end + + let(:'X-Api-Key') { api_key.plain_key } + + let!(:rule) do + family.rules.build( + name: 'Coffee cleanup', + resource_type: 'transaction', + active: true, + effective_date: Date.new(2024, 1, 1) + ).tap do |record| + record.conditions.build(condition_type: 'transaction_name', operator: 'like', value: 'coffee') + record.actions.build(action_type: 'set_transaction_name', value: 'Coffee') + record.save! + end + end + + let!(:rule_run) do + rule.rule_runs.create!( + rule_name: rule.name, + execution_type: 'manual', + status: 'success', + transactions_queued: 10, + transactions_processed: 10, + transactions_modified: 4, + pending_jobs_count: 0, + executed_at: Time.zone.parse('2024-01-15 12:00:00') + ) + end + + path '/api/v1/rule_runs' do + get 'List rule runs' do + description 'List rule run history for the authenticated user family.' + tags 'Rule Runs' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + parameter name: :page, in: :query, type: :integer, required: false, + description: 'Page number (default: 1)' + parameter name: :per_page, in: :query, type: :integer, required: false, + description: 'Items per page (default: 25, max: 100)' + parameter name: :rule_id, in: :query, required: false, + description: 'Filter by rule ID', + schema: { type: :string, format: :uuid } + parameter name: :status, in: :query, required: false, + description: 'Filter by run status', + schema: { type: :string, enum: %w[pending success failed] } + parameter name: :execution_type, in: :query, required: false, + description: 'Filter by execution type', + schema: { type: :string, enum: %w[manual scheduled] } + parameter name: :start_executed_at, in: :query, required: false, + description: 'Filter runs executed at or after this timestamp', + schema: { type: :string, format: :'date-time' } + parameter name: :end_executed_at, in: :query, required: false, + description: 'Filter runs executed at or before this timestamp', + schema: { type: :string, format: :'date-time' } + + response '200', 'rule runs listed' do + schema '$ref' => '#/components/schemas/RuleRunCollection' + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + run_test! + end + + response '403', 'insufficient scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { api_key_without_read_scope.plain_key } + + run_test! + end + + response '422', 'invalid filter' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:status) { 'unknown' } + + run_test! + end + end + end + + path '/api/v1/rule_runs/{id}' do + parameter name: :id, in: :path, required: true, description: 'Rule run ID', + schema: { type: :string, format: :uuid } + + get 'Retrieve a rule run' do + description 'Retrieve one rule run from the authenticated user family.' + tags 'Rule Runs' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + let(:id) { rule_run.id } + + response '200', 'rule run retrieved' do + schema '$ref' => '#/components/schemas/RuleRunResponse' + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + run_test! + end + + response '403', 'insufficient scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { api_key_without_read_scope.plain_key } + + run_test! + end + + response '404', 'rule run not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + end + end +end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index 8ea9b45c1..cf86c73e0 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -460,6 +460,65 @@ RSpec.configure do |config| } } }, + RuleRun: { + type: :object, + required: %w[id rule_id rule_name execution_type status transactions_queued transactions_processed transactions_modified pending_jobs_count executed_at rule created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + rule_id: { type: :string, format: :uuid }, + rule_name: { type: :string, nullable: true }, + execution_type: { type: :string, enum: %w[manual scheduled] }, + status: { type: :string, enum: %w[pending success failed] }, + transactions_queued: { type: :integer, minimum: 0 }, + transactions_processed: { type: :integer, minimum: 0 }, + transactions_modified: { type: :integer, minimum: 0 }, + pending_jobs_count: { type: :integer, minimum: 0 }, + executed_at: { type: :string, format: :'date-time' }, + error_message: { type: :string, nullable: true }, + rule: { + type: :object, + nullable: true, + required: %w[id resource_type active], + properties: { + id: { type: :string, format: :uuid }, + name: { type: :string, nullable: true }, + resource_type: { type: :string }, + active: { type: :boolean } + } + }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, + RuleRunResponse: { + type: :object, + required: %w[data], + properties: { + data: { '$ref' => '#/components/schemas/RuleRun' } + } + }, + RuleRunCollection: { + type: :object, + required: %w[data meta], + properties: { + data: { + type: :array, + items: { '$ref' => '#/components/schemas/RuleRun' } + }, + meta: { + type: :object, + required: %w[current_page total_pages total_count per_page], + properties: { + current_page: { type: :integer }, + next_page: { type: :integer, nullable: true }, + prev_page: { type: :integer, nullable: true }, + total_pages: { type: :integer }, + total_count: { type: :integer }, + per_page: { type: :integer } + } + } + } + }, Transfer: { type: :object, required: %w[id amount currency], diff --git a/test/controllers/api/v1/rule_runs_controller_test.rb b/test/controllers/api/v1/rule_runs_controller_test.rb new file mode 100644 index 000000000..5db2c8afc --- /dev/null +++ b/test/controllers/api/v1/rule_runs_controller_test.rb @@ -0,0 +1,207 @@ +# frozen_string_literal: true + +require "test_helper" + +class Api::V1::RuleRunsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + @family = @user.family + + @user.api_keys.active.destroy_all + @api_key = ApiKey.create!( + user: @user, + name: "Test Read Key", + scopes: [ "read" ], + source: "web", + display_key: "test_read_#{SecureRandom.hex(8)}" + ) + @redis = Redis.new + @redis.del("api_rate_limit:#{@api_key.id}") + + @rule = @family.rules.build( + name: "Coffee cleanup", + resource_type: "transaction", + active: true, + effective_date: Date.new(2024, 1, 1) + ) + @rule.conditions.build(condition_type: "transaction_name", operator: "like", value: "coffee") + @rule.actions.build(action_type: "set_transaction_name", value: "Coffee") + @rule.save! + + @rule_run = @rule.rule_runs.create!( + rule_name: @rule.name, + execution_type: "manual", + status: "success", + transactions_queued: 10, + transactions_processed: 10, + transactions_modified: 4, + pending_jobs_count: 0, + executed_at: Time.zone.parse("2024-01-15 12:00:00") + ) + @failed_rule_run = @rule.rule_runs.create!( + rule_name: @rule.name, + execution_type: "scheduled", + status: "failed", + transactions_queued: 5, + transactions_processed: 2, + transactions_modified: 0, + pending_jobs_count: 0, + executed_at: Time.zone.parse("2024-01-16 12:00:00"), + error_message: "Rule failed" + ) + end + + test "lists rule runs scoped to family rules" do + other_rule_run = create_other_family_rule_run + + get api_v1_rule_runs_url, headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + rule_run_ids = response_data["data"].map { |rule_run| rule_run["id"] } + + assert_includes rule_run_ids, @rule_run.id + assert_includes rule_run_ids, @failed_rule_run.id + assert_not_includes rule_run_ids, other_rule_run.id + expected_count = RuleRun.joins(:rule).where(rules: { family_id: @family.id }).count + assert_equal expected_count, response_data["meta"]["total_count"] + end + + test "shows a rule run" do + get api_v1_rule_run_url(@rule_run), headers: api_headers(@api_key) + + assert_response :success + rule_run = JSON.parse(response.body)["data"] + + assert_equal @rule_run.id, rule_run["id"] + assert_equal @rule.id, rule_run["rule_id"] + assert_equal "manual", rule_run["execution_type"] + assert_equal "success", rule_run["status"] + assert_equal 10, rule_run["transactions_queued"] + assert_equal 4, rule_run["transactions_modified"] + assert_equal @rule.id, rule_run.dig("rule", "id") + end + + test "does not show another family's rule run" do + other_rule_run = create_other_family_rule_run + + get api_v1_rule_run_url(other_rule_run), headers: api_headers(@api_key) + + assert_response :not_found + response_data = JSON.parse(response.body) + assert_equal "record_not_found", response_data["error"] + end + + test "returns not found for malformed rule run id" do + get api_v1_rule_run_url("not-a-uuid"), headers: api_headers(@api_key) + + assert_response :not_found + response_data = JSON.parse(response.body) + assert_equal "record_not_found", response_data["error"] + end + + test "filters rule runs" do + get api_v1_rule_runs_url, + params: { + rule_id: @rule.id, + status: "failed", + execution_type: "scheduled", + start_executed_at: "2024-01-16T00:00:00Z", + end_executed_at: "2024-01-17T00:00:00Z" + }, + headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + assert_equal [ @failed_rule_run.id ], response_data["data"].map { |rule_run| rule_run["id"] } + end + + test "rejects invalid filters" do + get api_v1_rule_runs_url, params: { status: "unknown" }, headers: api_headers(@api_key) + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert_equal "validation_failed", response_data["error"] + end + + test "clamps oversized per_page values to the documented maximum" do + get api_v1_rule_runs_url, params: { per_page: 500 }, headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + assert_equal 100, response_data["meta"]["per_page"] + end + + test "rejects malformed rule_id filter" do + get api_v1_rule_runs_url, params: { rule_id: "not-a-uuid" }, headers: api_headers(@api_key) + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert_equal "validation_failed", response_data["error"] + end + + test "rejects invalid timestamp filters" do + get api_v1_rule_runs_url, params: { start_executed_at: "not-a-date" }, headers: api_headers(@api_key) + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert_equal "validation_failed", response_data["error"] + end + + test "requires authentication" do + get api_v1_rule_runs_url + + assert_response :unauthorized + end + + test "show requires authentication" do + get api_v1_rule_run_url(@rule_run) + + assert_response :unauthorized + end + + test "requires read scope" do + api_key_without_read = ApiKey.new( + user: @user, + name: "No Read Key", + scopes: [], + source: "web", + display_key: "no_read_#{SecureRandom.hex(8)}" + ) + api_key_without_read.save!(validate: false) + + get api_v1_rule_runs_url, headers: api_headers(api_key_without_read) + + assert_response :forbidden + ensure + api_key_without_read&.destroy + end + + teardown do + @redis&.close + end + + private + + def api_headers(api_key) + { "X-Api-Key" => api_key.display_key } + end + + def create_other_family_rule_run + other_family = Family.create!(name: "Other Family", currency: "USD", locale: "en") + other_rule = other_family.rules.build(name: "Other", resource_type: "transaction", active: true) + other_rule.conditions.build(condition_type: "transaction_name", operator: "like", value: "other") + other_rule.actions.build(action_type: "set_transaction_name", value: "Other") + other_rule.save! + other_rule.rule_runs.create!( + rule_name: other_rule.name, + execution_type: "manual", + status: "success", + transactions_queued: 1, + transactions_processed: 1, + transactions_modified: 1, + pending_jobs_count: 0, + executed_at: Time.current + ) + end +end